japanese-learning-avatar / avatar /avatar-iframe.js
WolfDavid's picture
feat(01-03): add both transports, the standalone stage page and the Gradio component
f822421
Raw
History Blame
5.38 kB
// 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);
}