// avatar/avatar.js // // THE INLINE TRANSPORT: the renderer lives in the same document as the host component. // // Its ONLY job is to build a six-method stagePort and hand it to the shared // installFacade. It implements no turn behaviour and it never assigns window.Avatar. // tests/test_transport_seam.py fails if either rule is broken here. import { getLastDecoded, playBuffer } from './audio-queue.js'; import { installFacade } from './facade.js'; import { makePlayer } from './lipsync.js'; import { createTurnLoop } from './turn-loop.js'; import { mountStage } from './vrm-stage.js'; /** * A bus that exists before the real one does. * * mountStage() needs an emit callback, but the facade's event bus is only created * once installFacade() runs - which is after the stage is mounted. Anything emitted * during mount (notably the 'error' raised when a VRM has no expressionManager, the * exact symptom of a double three.js instance) would otherwise be dropped on the * floor. This buffers until connect() and then replays in order. */ 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 } * @param {Function} [trigger] host event sink, so the backend also sees avatar events * @param {object} [server] host bridge, handed to the turn loop as a getter */ export async function boot(element, props = {}, trigger = null, server = null) { // Re-entry guard. key="vrm-stage" should stop the host re-running js_on_load at all, // but if it ever does, returning the live object without remounting is what holds // mountCount at 1 across 20 interactions (AVTR-01). if (window.Avatar && window.Avatar.__debug && window.Avatar.__debug.ready) { return window.Avatar; } const canvas = element.querySelector('#vrm-canvas'); if (!canvas) throw new Error('boot: no #vrm-canvas inside the mounted element'); const AudioCtor = window.AudioContext || window.webkitAudioContext; const audioCtx = new AudioCtor(); const emit = deferredBus(); let stage = null; let player = null; let mounting = null; let cached = null; // Memoised: the stage is mounted exactly once no matter how many callers ask. function ensureStage(vrmUrl) { if (!mounting) { mounting = (async () => { stage = await mountStage(canvas, vrmUrl || props.vrmUrl, emit); player = makePlayer(stage); stage.setOnTick((dt) => player.tick(dt)); return stage; })(); } return mounting; } await ensureStage(props.vrmUrl); // The stagePort is the ONLY thing that differs between transports. Here every // method is a direct call; in the iframe transport every method is a round trip. const stagePort = { mount: (vrmUrl) => ensureStage(vrmUrl), async speak(payload = {}) { await ensureStage(); cached = payload; return playBuffer(audioCtx, payload.audioUrl, emit, (when) => player.start(payload.timeline, audioCtx, when) ); }, async replayCached() { // The decoded buffer, not the URL: zero network requests, which is what // plan 01-09's test_replay asserts with a browser request listener. const buffer = getLastDecoded(); if (!buffer) throw new Error('nothing cached to re-play'); return playBuffer(audioCtx, buffer, emit, (when) => player.start(cached && cached.timeline, audioCtx, when) ); }, setThinking: (value) => (stage ? stage.setThinking(value) : undefined), setListening: (value) => (stage ? stage.setListening(value) : undefined), getDebug: async () => (stage ? stage.getDebug() : {}), }; const turnLoop = createTurnLoop({ stagePort, emit, getServer: () => server }); const installed = installFacade({ transport: 'inline', stagePort, turnLoop, // The one host-aware line in the file: mirror avatar events onto the host's own // event system so backend listeners see them too. 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. Tolerates null, '' and already-parsed * objects, because a component's initial value is empty and must not throw. */ 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); }