FEATURES (from a 5-lens audit: UX friction, feature gaps, bug hunt, visual quality, discoverability): - ACES filmic tone mapping + per-preset exposure. grammar.js runs physical sun intensities of 1.2-3.4; with NoToneMapping every lit face clipped to white and a warm key rendered neutral. Single biggest look win in the app. - Real shadows: PCFSoft, a shadow box fitted to the scene's bounds, and plates that no longer double as the shadow catcher (dedicated ShadowMaterial). - A Render button in the scene bar. finalRender had ZERO callers - the app's deliverable was console-only. - Save actually saves the stage you built (gizmo moves, inspector edits), and Save/Load stop lying about what happened. - 2x supersample + lanczos downscale; correct bt709 tagging and faststart. - Frame guide, camera-from-view, dock counts, delete confirmations. DEFECTS FOUND BY ADVERSARIAL REVIEW AND FIXED (all reproduced first): - BLOCKER: save wrote the playhead pose over the authored rest transform of keyed entities, so every save produced a different file. - renders were converted with the bt601 matrix while tagged bt709. - deleting an unkeyed camera stranded its cut -> scene 422s forever. - corner plates showed one photo at two exposures across the fold. - exposure was advertised as keyable but nothing keyed it, so a second lighting preset permanently poisoned the first. - the audio pre-flight could never fire (FastAPI does not route HEAD -> 405). - the two render buttons were not mutually exclusive. - frame guide letterboxed a narrow viewport that the render does not crop. ORCHESTRATOR (round 3): dangling camera cuts are pruned in Timeline.toJSON and skipped by cutCameraAt, rather than trying to keep every undo history clean - deleting a camera spans Stage (no undo) and Timeline (undo), so any older entry can resurrect a cut for a dead camera. Verified against the real validator. Also replaced a VACUOUS test that drained a stack whose only entry was a seeded no-op; timeline_test.mjs now replays both real histories and discriminates. Verified: 4 JS suites + 23 server groups + render client green; blocker repro now preserves the authored rest at every playhead position; save round-trips 200 with zero dangling cuts; zero console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
137 lines
5.6 KiB
JavaScript
137 lines
5.6 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; }
|
|
}
|
|
|
|
// 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()]; }
|
|
|
|
// honest-stub: the real Stage hands the ENTITY (or null) to onSelect listeners,
|
|
// not the id (stage.js `select()`). The stub used to pass the id, which would
|
|
// have let an outliner that reads `en.id` pass here and break live.
|
|
select(id) { this._selected = id; this._selectCbs.forEach((cb) => cb(id ? this.getEntity(id) : null)); }
|
|
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); }
|
|
activeCamera() { return this._active || null; } // real Stage: stage.js `activeCamera()` (entity id, null = orbit cam)
|
|
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;
|