SCENEGOD/scenegod/web/stagestub.js
type-two ba29d0c6d6 [laneB] M4-B model: audio mutators, morphs track eval, importRhubarb
- timeline.js: morphs track kind evaluated via stage.setMorph (lerp per viseme
  name), audio mutators addAudio/moveAudio/setAudioGain/removeAudio (undo-able),
  importRhubarb(id, json, t0) mapping mouthCues → morph keys (per-cue ramp),
  load() ensures scene.audio[], addKey keyed by (t,name) for morphs too.
- stagestub.js: setMorph (records) + visemeTargets seam.
- timeline_test.mjs: morph lerp, rhubarb keys, audio mutators + undo round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:13:35 +10:00

112 lines
4.0 KiB
JavaScript

// 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; }
}
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._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);
this._fireChange(e);
return e;
}
removeEntity(id) {
const e = this._entities.get(id);
this._entities.delete(id); this._mixers.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) || []; }
captureState() { return this.entities().map(clone); }
async applyState(scene) {
this._entities.clear();
for (const e of (scene.entities || [])) await this.addEntity(e);
}
}
export default StageStub;