Spaces:
Running on Zero
Running on Zero
| // avatar/avatar-iframe.js | |
| // | |
| // THE IFRAME TRANSPORT: the renderer lives inside avatar/stage.html, reachable only | |
| // by postMessage. This is the documented escape hatch for the inline spike. | |
| // | |
| // It is deliberately the same shape as avatar.js: build a six-method stagePort, then | |
| // call the SAME createTurnLoop and the SAME installFacade. Every method below is | |
| // message plumbing. There is no turn behaviour in this file, and there must never be | |
| // - it belongs in avatar/turn-loop.js so both transports get it at once. | |
| // | |
| // Note for later waves: microphone capture and ASR run in the PARENT document under | |
| // both transports, so nothing about them crosses this boundary. Only rendering and | |
| // audio playback live inside the frame. | |
| import { installFacade } from './facade.js'; | |
| import { createTurnLoop } from './turn-loop.js'; | |
| const STAGE_PATH = '/gradio_api/file=avatar/stage.html'; | |
| // speak() resolves at speech-end, so a reply can legitimately take an utterance's | |
| // worth of time. This bound only exists so a dead frame fails loudly. | |
| const REPLY_TIMEOUT_MS = 60000; | |
| /** | |
| * A bus that exists before the real one does. Identical in purpose to the one in | |
| * avatar.js: the frame can report an error before installFacade() has built the event | |
| * bus, and those events must not be lost. Buffers until connect(), then replays. | |
| */ | |
| function deferredBus() { | |
| let sink = null; | |
| const queued = []; | |
| const emit = (name, data) => { | |
| if (sink) sink(name, data); | |
| else queued.push([name, data]); | |
| }; | |
| emit.connect = (real) => { | |
| sink = real; | |
| while (queued.length > 0) { | |
| const [name, data] = queued.shift(); | |
| sink(name, data); | |
| } | |
| }; | |
| return emit; | |
| } | |
| /** | |
| * @param {HTMLElement} element the host-provided mount point | |
| * @param {object} props at minimum { vrmUrl }; optional { stageUrl } | |
| * @param {Function} [trigger] host event sink | |
| * @param {object} [server] host bridge, handed to the turn loop as a getter | |
| */ | |
| export async function boot(element, props = {}, trigger = null, server = null) { | |
| if (window.Avatar && window.Avatar.__debug && window.Avatar.__debug.ready) { | |
| return window.Avatar; | |
| } | |
| const emit = deferredBus(); | |
| const frame = document.createElement('iframe'); | |
| frame.title = 'avatar stage'; | |
| frame.allow = 'microphone; autoplay'; | |
| const stageUrl = props.stageUrl || STAGE_PATH; | |
| frame.src = `${stageUrl}?vrm=${encodeURIComponent(props.vrmUrl || '')}`; | |
| element.appendChild(frame); | |
| const pending = new Map(); | |
| let seq = 0; | |
| let announceReady = null; | |
| const frameReady = new Promise((resolve) => { | |
| announceReady = resolve; | |
| }); | |
| window.addEventListener('message', (ev) => { | |
| if (ev.source !== frame.contentWindow) return; | |
| const msg = ev.data; | |
| if (!msg || typeof msg.type !== 'string' || !msg.type.startsWith('avatar:')) return; | |
| if (msg.type === 'avatar:frame-ready') { | |
| announceReady(); | |
| return; | |
| } | |
| if (msg.type === 'avatar:event') { | |
| emit(msg.event, msg.data); | |
| return; | |
| } | |
| if (msg.type !== 'avatar:reply') return; | |
| const slot = pending.get(msg.id); | |
| if (!slot) return; | |
| pending.delete(msg.id); | |
| clearTimeout(slot.timer); | |
| if (msg.ok) slot.resolve(msg.value); | |
| else slot.reject(new Error(msg.error || `stage frame failed: ${msg.id}`)); | |
| }); | |
| /** One request, one reply, correlated by id. This is the whole transport. */ | |
| async function call(type, extra) { | |
| await frameReady; | |
| seq += 1; | |
| const id = `m${seq}`; | |
| return new Promise((resolve, reject) => { | |
| const timer = setTimeout(() => { | |
| pending.delete(id); | |
| reject(new Error(`stage frame did not answer ${type} within ${REPLY_TIMEOUT_MS} ms`)); | |
| }, REPLY_TIMEOUT_MS); | |
| pending.set(id, { resolve, reject, timer }); | |
| frame.contentWindow.postMessage({ ...extra, type, id }, '*'); | |
| }); | |
| } | |
| // Byte-for-byte the same six names as the inline transport's stagePort. Only the | |
| // bodies differ, and every body is one round trip. | |
| const stagePort = { | |
| mount: (vrmUrl) => call('avatar:mount', { vrmUrl: vrmUrl || props.vrmUrl }), | |
| speak: (payload) => call('avatar:speak', { payload }), | |
| replayCached: () => call('avatar:replay', {}), | |
| setThinking: (value) => call('avatar:setThinking', { value }), | |
| setListening: (value) => call('avatar:setListening', { value }), | |
| getDebug: () => call('avatar:debug', {}), | |
| }; | |
| const turnLoop = createTurnLoop({ stagePort, emit, getServer: () => server }); | |
| const installed = installFacade({ | |
| transport: 'iframe', | |
| stagePort, | |
| turnLoop, | |
| onEmit: (name, data) => { | |
| if (typeof trigger === 'function') trigger(name, data); | |
| }, | |
| }); | |
| emit.connect(installed.emit); | |
| await installed.avatar.mount(props.vrmUrl); | |
| return installed.avatar; | |
| } | |
| /** | |
| * What the host's watch('value', ...) calls. Identical to the inline transport's: | |
| * routing a directive is not turn behaviour, it is one line of delegation. | |
| */ | |
| export function onDirective(value) { | |
| if (value === null || value === undefined || value === '') return null; | |
| let directive = value; | |
| if (typeof value === 'string') { | |
| try { | |
| directive = JSON.parse(value); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| if (!directive || typeof directive !== 'object') return null; | |
| if (!window.Avatar) throw new Error('onDirective called before boot()'); | |
| return window.Avatar.speak(directive); | |
| } | |