// 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); }