// stagestub.js — dev-only fake Stage implementing the PLAN §4.2 API with // plain objects, so Lane B's timeline can be built + tested headless before // Lane A's real Stage lands. DELETED at SYNC 1 (one-line swap in the page). // ponytail: no three.js, no DOM — runs under plain node for timeline_test.mjs. const clone = (x) => (x == null ? x : JSON.parse(JSON.stringify(x))); class StubAction { constructor(clip) { this._clip = clip; this.time = 0; this.weight = 0; this.enabled = false; this.paused = false; } play() { this._playing = true; return this; } reset() { this.time = 0; return this; } } // M6 seam: fake HTMLVideoElement for video backdrops/screens. Records every // currentTime assignment so tests can assert "seek only when drift >50ms". class StubVideo { constructor(duration) { this.duration = duration; this.paused = true; this.seeks = []; this._t = 0; } get currentTime() { return this._t; } set currentTime(v) { this._t = v; this.seeks.push(v); } play() { this.paused = false; return Promise.resolve(); } // real video.play() → Promise pause() { this.paused = true; } } class StubMixer { constructor(id, stage) { this.id = id; this.stage = stage; this._actions = new Map(); } clipAction(clip) { if (!this._actions.has(clip)) this._actions.set(clip, new StubAction(clip)); return this._actions.get(clip); } update(_dt) { // record every audibly-active action so tests can assert clip local time for (const a of this._actions.values()) { if (a.enabled && a.weight > 0) { this.stage.applied.push({ type: 'clip', id: this.id, clip: a._clip.name, time: a.time, weight: a.weight }); } } } } export class StageStub { constructor(viewportEl) { this.viewportEl = viewportEl || null; this._entities = new Map(); this._mixers = new Map(); this._videos = new Map(); this._clipCache = new Map(); this._changeCbs = []; this._selectCbs = []; this._active = null; this._selected = null; this.applied = []; // timeline effects land here for assertions } async addEntity(desc) { const e = clone(desc); if (!e.transform) e.transform = { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }; if (!e.params) e.params = {}; this._entities.set(e.id, e); // M6: video backdrops/screens get a StubVideo (kept OUT of the entity // object so captureState stays clean). videoDuration is a stub-only knob. if (e.kind === 'screen' || (e.kind === 'backdrop' && ((e.params && e.params.mode === 'video') || /\.mp4$/i.test((e.params && e.params.image) || '')))) { this._videos.set(e.id, new StubVideo((e.params && e.params.videoDuration) || 5)); } this._fireChange(e); return e; } removeEntity(id) { const e = this._entities.get(id); this._entities.delete(id); this._mixers.delete(id); this._videos.delete(id); this._fireChange(e || { id }); // fire WITH the removed entity; getEntity(id) now null (real Stage contract) } getEntity(id) { return this._entities.get(id) || null; } entities() { return [...this._entities.values()]; } select(id) { this._selected = id; this._selectCbs.forEach((cb) => cb(id)); } onSelect(cb) { this._selectCbs.push(cb); } onChange(cb) { this._changeCbs.push(cb); } _fireChange(e) { this._changeCbs.forEach((cb) => cb(e)); } entityTransform(id) { const e = this._entities.get(id); return e ? clone(e.transform) : { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }; } setTransform(id, t) { const e = this._entities.get(id); if (e) e.transform = clone(t); this.applied.push({ type: 'transform', id, ...clone(t) }); console.debug('stub.setTransform', id, t); } setParam(id, key, value) { const e = this._entities.get(id); if (e) { e.params = e.params || {}; e.params[key] = value; } this.applied.push({ type: 'param', id, key, value }); console.debug('stub.setParam', id, key, value); } async prepareClip(id, path, clipIndex) { const k = `${path}#${clipIndex}`; // CACHED per §4.2 if (!this._clipCache.has(k)) this._clipCache.set(k, { name: k, duration: 2.4 }); return this._clipCache.get(k); } entityMixer(id) { if (!this._mixers.has(id)) this._mixers.set(id, new StubMixer(id, this)); return this._mixers.get(id); } setActiveCamera(id) { this._active = id; this.applied.push({ type: 'camera', id }); console.debug('stub.setActiveCamera', id); } renderActiveCamera(_canvas) { /* noop in stub */ } // M4-B viseme seam (real Stage: Lane A). Entities may carry a `visemes` array. setMorph(id, name, weight) { this.applied.push({ type: 'morph', id, name, weight }); console.debug('stub.setMorph', id, name, weight); } visemeTargets(id) { const e = this._entities.get(id); return (e && e.visemes) || []; } // M6 seam (real Stage: Lane A): the HTMLVideoElement behind a video // backdrop/screen, or null (image backdrop, other kinds, not loaded yet). entityVideo(id) { return this._videos.get(id) || null; } captureState() { return this.entities().map(clone); } async applyState(scene) { this._entities.clear(); this._mixers.clear(); this._videos.clear(); for (const e of (scene.entities || [])) await this.addEntity(e); } } export default StageStub;