SCENEGOD/scenegod/web/timeline_test.mjs
type-two 22c8a9a324 M12: look, render button, save integrity — 3 workflow rounds, 22 agents
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>
2026-07-28 22:41:17 +10:00

1711 lines
108 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// timeline_test.mjs — Lane B M1 self-check. Runs under plain `node`, no deps.
// node scenegod/web/timeline_test.mjs
import assert from 'node:assert/strict';
import { StageStub } from './stagestub.js';
import { Timeline, beatGridFrom, alignShotsToGrid, gridTimesOf } from './timeline.js';
const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
const scene = {
version: 1, name: 'test', fps: 30, duration: 3,
entities: [
{
id: 'e1', kind: 'character', label: 'lady',
source: { type: 'assets', path: 'characters/lady.glb' },
params: {}, transform: { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 },
tracks: {
transform: [
{ t: 0, pos: [0, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'linear' },
{ t: 2, pos: [10, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'linear' },
],
clips: [
{ path: 'animations/walk.fbx', clipIndex: 0, start: 1.0, in: 0.0, out: 2.4, loop: 1, fade: 0 },
],
},
},
{
id: 'e2', kind: 'light', label: 'sun',
params: { type: 'key', color: '#000000', intensity: 0 },
transform: { pos: [0, 5, 0], rot: [0, 0, 0], scale: 1 },
tracks: {
params: [
{ t: 0, key: 'intensity', value: 0, ease: 'step' },
{ t: 2, key: 'intensity', value: 10, ease: 'step' },
],
},
},
{ id: 'cam1', kind: 'camera', params: { fov: 45 }, transform: { pos: [0, 1, 5], rot: [0, 0, 0], scale: 1 }, tracks: {} },
{ id: 'cam2', kind: 'camera', params: { fov: 60 }, transform: { pos: [5, 1, 5], rot: [0, 0, 0], scale: 1 }, tracks: {} },
],
cameraCuts: [{ t: 0, camera: 'cam1' }, { t: 1.5, camera: 'cam2' }],
audio: [],
};
const stage = new StageStub();
await stage.applyState(scene);
const tl = new Timeline(stage);
tl.load(scene);
await tl.preload();
const last = (pred) => { for (let i = stage.applied.length - 1; i >= 0; i--) if (pred(stage.applied[i])) return stage.applied[i]; return null; };
// step through 90 frames (3s @ 30fps)
for (let f = 0; f <= 90; f++) {
stage.applied.length = 0;
tl.step(f);
if (f === 30) { // t=1.0 → midpoint of a 2s linear move
const tr = last((a) => a.type === 'transform' && a.id === 'e1');
assert.ok(tr && near(tr.pos[0], 5, 1e-4), `midpoint x should be ~5, got ${tr && tr.pos[0]}`);
}
if (f === 15) { // t=0.5 → ease:step holds start (0)
const p = last((a) => a.type === 'param' && a.id === 'e2' && a.key === 'intensity');
assert.ok(p && near(p.value, 0), `step-ease should hold 0, got ${p && p.value}`);
}
if (f === 45) { // t=1.5 → clip active, local = 0.5 into walk
const c = last((a) => a.type === 'clip' && a.id === 'e1');
assert.ok(c && near(c.time, 0.5, 1e-6), `clip local time should be 0.5, got ${c && c.time}`);
const cam = last((a) => a.type === 'camera'); // camera flips to cam2 at t>=1.5
// cut fires only on change; check stage state instead
assert.equal(stage._active, 'cam2', `active cam should be cam2 at t=1.5, is ${stage._active}`);
}
if (f === 20) { // t=0.667 → before the 1.5s cut, still cam1
assert.equal(stage._active, 'cam1', `active cam should be cam1 before cut, is ${stage._active}`);
}
if (f === 0) { // clip not started at t=0
const c = last((a) => a.type === 'clip' && a.id === 'e1');
assert.equal(c, null, 'clip should be inactive at t=0');
}
}
// clip sample frames: at t=1.0 (start) local=0, at t=2.4 local≈1.4
stage.applied.length = 0; tl.step(30); // t=1.0
let c = last((a) => a.type === 'clip' && a.id === 'e1');
assert.ok(c && near(c.time, 0, 1e-6), `clip local at start should be 0, got ${c && c.time}`);
stage.applied.length = 0; tl.step(72); // t=2.4
c = last((a) => a.type === 'clip' && a.id === 'e1');
assert.ok(c && near(c.time, 1.4, 1e-6), `clip local at 2.4 should be 1.4, got ${c && c.time}`);
// toJSON deep-equals a re-loaded copy (lossless round-trip)
const out = tl.toJSON();
const tl2 = new Timeline(new StageStub());
tl2.load(out);
assert.deepEqual(tl2.toJSON(), out, 'round-trip must be lossless');
// unknown entity fields preserved (Lane A owns them)
const withExtra = structuredClone(scene);
withExtra.entities[0].laneAOnly = { gizmo: 'move', foo: [1, 2, 3] };
const tl3 = new Timeline(new StageStub());
tl3.load(withExtra);
assert.deepEqual(tl3.toJSON().entities[0].laneAOnly, { gizmo: 'move', foo: [1, 2, 3] }, 'unknown fields must survive');
// ---- M2: onLoad fires ----
let loadFired = 0;
const tlL = new Timeline(new StageStub());
tlL.onLoad(() => loadFired++);
tlL.load(scene);
assert.equal(loadFired, 1, 'onLoad must fire once per load()');
// ---- M2: addClipDrop sets out = clip duration, drops block at playhead ----
const dropStage = new StageStub();
await dropStage.addEntity({ id: 'c1', kind: 'character', label: 'c', tracks: {} });
const tlD = new Timeline(dropStage);
tlD.load({ version: 1, fps: 30, duration: 10, entities: [{ id: 'c1', kind: 'character', tracks: {} }], cameraCuts: [] });
tlD.seek(2);
await tlD.addClipDrop({ id: 'c1', path: 'animations/run.fbx', clipIndex: 0 });
const blk = tlD.scene.entities[0].tracks.clips[0];
assert.ok(blk && near(blk.start, 2) && near(blk.out, 2.4), `clipdrop block start=2 out=2.4, got ${blk && blk.start}/${blk && blk.out}`);
// ---- M2: crossfade — two abutting blocks fade through ~0.5 at the boundary ----
const xStage = new StageStub();
await xStage.addEntity({ id: 'c2', kind: 'character', tracks: {} });
const tlX = new Timeline(xStage);
tlX.load({
version: 1, fps: 30, duration: 10, cameraCuts: [],
entities: [{ id: 'c2', kind: 'character', tracks: { clips: [
{ path: 'a.fbx', clipIndex: 0, start: 0, in: 0, out: 1.0, loop: 1, fade: 0.4 },
{ path: 'b.fbx', clipIndex: 0, start: 1.0, in: 0, out: 1.0, loop: 1, fade: 0 },
] } }],
});
await tlX.preload();
xStage.applied.length = 0;
tlX.step(24); // t=0.8 → mid of block-A's [0.6,1.0] fade
const lastIn = (arr, pred) => { for (let i = arr.length - 1; i >= 0; i--) if (pred(arr[i])) return arr[i]; return null; };
const wa = lastIn(xStage.applied, (a) => a.type === 'clip' && a.clip === 'a.fbx#0');
const wb = lastIn(xStage.applied, (a) => a.type === 'clip' && a.clip === 'b.fbx#0');
assert.ok(wa && near(wa.weight, 0.5, 1e-6), `A fade-out weight ~0.5, got ${wa && wa.weight}`);
assert.ok(wb && near(wb.weight, 0.5, 1e-6), `B fade-in weight ~0.5, got ${wb && wb.weight}`);
assert.ok(near(wa.weight + wb.weight, 1, 1e-6), 'crossfade weights sum to 1');
// ---- SYNC2: _mirror entity lifecycle (stub add → mutate → remove) ----
const mStage = new StageStub();
const tlM = new Timeline(mStage); // ctor subscribes _mirror to stage.onChange
await mStage.addEntity({ id: 'p1', kind: 'prop', label: 'crate' }); // fires onChange → mirrored
let me = tlM.scene.entities.find((x) => x.id === 'p1');
assert.ok(me, 'dock-added stage entity must be mirrored into the timeline');
assert.deepEqual(me.tracks, { transform: [], params: [], clips: [] }, 'mirrored entity starts with empty tracks');
tlM.addKey('p1', 'transform', { t: 0, pos: [1, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'linear' });
assert.equal(tlM.scene.entities.find((x) => x.id === 'p1').tracks.transform.length, 1, 'can keyframe a mirrored entity');
mStage.removeEntity('p1'); // fires onChange w/ removed entity; getEntity → null
assert.ok(!tlM.scene.entities.find((x) => x.id === 'p1'), 'removing from stage drops the entity + its tracks');
// ---- SYNC2: setDuration clamps the playhead + keeps keys, fires onLoad ----
let durNotified = 0;
const tlDur = new Timeline(new StageStub());
tlDur.load({ version: 1, fps: 30, duration: 10, entities: [
{ id: 'e', kind: 'prop', tracks: { transform: [{ t: 8, pos: [0, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'linear' }] } },
], cameraCuts: [] });
tlDur.onLoad(() => durNotified++);
tlDur.seek(9);
tlDur.setDuration(5);
assert.equal(tlDur.duration, 5, 'setDuration updates duration');
assert.equal(tlDur.time, 5, 'setDuration clamps the playhead into range');
assert.equal(tlDur.scene.entities[0].tracks.transform.length, 1, 'keys past the new end are kept (lossless)');
assert.ok(durNotified >= 1, 'setDuration notifies the UI via onLoad');
// ---- M4-B: morphs track lerps → stage.setMorph ----
const morphStage = new StageStub();
await morphStage.addEntity({ id: 'face', kind: 'character', visemes: ['A', 'B', 'X'] });
const tlMo = new Timeline(morphStage);
tlMo.load({ version: 1, fps: 30, duration: 4, cameraCuts: [], entities: [
{ id: 'face', kind: 'character', tracks: { morphs: [
{ t: 0, key: 'A', value: 0, ease: 'linear' }, { t: 2, key: 'A', value: 1, ease: 'linear' },
] } },
] });
morphStage.applied.length = 0;
tlMo.step(30); // t=1 → A at 0.5
const mp = lastIn(morphStage.applied, (a) => a.type === 'morph' && a.name === 'A');
assert.ok(mp && near(mp.weight, 0.5, 1e-6), `morph A weight ~0.5, got ${mp && mp.weight}`);
// ---- M4-B: importRhubarb → morph keys (1 at cue start, 0 at cue end) ----
const rj = { mouthCues: [{ start: 0.0, end: 0.3, value: 'A' }, { start: 0.3, end: 0.6, value: 'B' }] };
tlMo.importRhubarb('face', rj, 1.0); // offset by 1s
const mk = tlMo.scene.entities[0].tracks.morphs.filter((k) => k.key === 'A' || k.key === 'B').map((k) => k.key + '@' + k.t + '=' + k.value);
assert.ok(mk.includes('B@1.3=1') && mk.includes('B@1.6=0'), `rhubarb B keys wrong: ${mk.join(',')}`);
// ---- M4-B: audio mutators + undo ----
const tlA = new Timeline(new StageStub());
tlA.load({ version: 1, fps: 30, duration: 10, entities: [], cameraCuts: [] });
const aclip = tlA.addAudio({ path: 'audio/vo1.wav', start: 2, gain: 1 });
assert.equal(tlA.scene.audio.length, 1, 'addAudio appends');
tlA.moveAudio(aclip, 3.5); assert.equal(aclip.start, 3.5, 'moveAudio sets start');
tlA.setAudioGain(aclip, 0.4); assert.equal(aclip.gain, 0.4, 'setAudioGain sets gain');
assert.deepEqual(tlA.toJSON().audio, [{ path: 'audio/vo1.wav', start: 3.5, gain: 0.4 }], 'audio survives round-trip');
tlA.removeAudio(aclip); assert.equal(tlA.scene.audio.length, 0, 'removeAudio drops it');
tlA.undo(); assert.equal(tlA.scene.audio.length, 1, 'undo restores removed audio');
// ---- M5 backlog: audio trim (in/out within source, like clip blocks) ----
const ac = tlA.scene.audio[0];
tlA.trimAudio(ac, { out: 2.5 }); assert.equal(ac.out, 2.5, 'trimAudio sets out');
tlA.trimAudio(ac, { in: 0.5 }); assert.equal(ac.in, 0.5, 'trimAudio sets in');
tlA.undo(); tlA.undo();
assert.ok(!('in' in ac) && !('out' in ac), 'undo removes trim fields entirely (schema stays clean)');
assert.deepEqual(tlA.toJSON().audio, [{ path: 'audio/vo1.wav', start: 3.5, gain: 0.4 }], 'untrimmed clip round-trips without in/out');
// ---- M7-B polish: audio HEAD trim — in and start move together, one undo ----
const hc = tlA.scene.audio[0]; // {path, start: 3.5, gain: 0.4}, untrimmed
const hUndo = tlA.undoStack.length;
tlA.trimAudioIn(hc, 4.2, 8); // drag the left edge 0.7s right, 8s source
assert.ok(near(hc.in, 0.7) && near(hc.start, 4.2), `head trim: in=0.7 start=4.2, got ${hc.in}/${hc.start}`);
assert.equal(tlA.undoStack.length, hUndo + 1, 'head trim is a single undo entry');
tlA.undo();
assert.ok(!('in' in hc) && near(hc.start, 3.5), 'undo restores the pristine (untrimmed) clip');
tlA.trimAudioIn(hc, -5, 8); // nothing before the file head → no-op (use the body to move)
assert.ok(near(hc.start, 3.5) && !hc.in, `head trim clamps at the source head, got start=${hc.start} in=${hc.in}`);
tlA.trimAudio(hc, { out: 2 }); // 3.5..(2s of source)
tlA.trimAudioIn(hc, 99, 8); // dragging past the tail leaves a sliver
assert.ok(hc.in <= 2 - 0.05 + 1e-9 && hc.in > 0, `head trim stops before out, got in=${hc.in}`);
// ---- M5: importRhubarb is grouped — ONE undo reverts the whole import ----
const preLen = tlMo.scene.entities[0].tracks.morphs.length;
const preUndo = tlMo.undoStack.length;
tlMo.importRhubarb('face', rj, 3.0);
assert.equal(tlMo.undoStack.length, preUndo + 1, 'import pushes one grouped undo entry');
assert.ok(tlMo.scene.entities[0].tracks.morphs.length > preLen, 'import added keys');
tlMo.undo();
assert.equal(tlMo.scene.entities[0].tracks.morphs.length, preLen, 'one undo reverts the whole import');
// ---- M5 DIRECTOR MODE: scenegod:direct ops → grouped keys/cuts at playhead ----
// Wire a real EventTarget as `window` so the ctor's listener path is what's
// tested (node ≥19 has CustomEvent). Removed again below so later Timelines
// stay headless-pure.
globalThis.window = new EventTarget();
const dStage = new StageStub();
const tlDir = new Timeline(dStage); // ctor: listener + _mirror wired
await dStage.addEntity({ id: 'cam1', kind: 'camera', params: { fov: 45 } });
// CAMERAS GO LIVE AT A CUT: the first camera added to a cutless scene gets one at
// 0, or the next evaluate() drops the view straight back to the orbit cam.
assert.deepEqual(tlDir.cameraCuts, [{ t: 0, camera: 'cam1' }], 'the first camera added goes live at t=0');
tlDir.seek(0);
assert.equal(dStage._active, 'cam1', 'and a seek does NOT snap the view back to the orbit cam');
assert.equal(dStage.activeCamera(), 'cam1', 'stagestub exposes activeCamera() like the real Stage');
// the edit wins over anything that changes the active camera behind our back
dStage.setActiveCamera(null);
tlDir.seek(0);
assert.equal(dStage._active, 'cam1', 'a seek re-asserts the cut-derived camera after an out-of-band change');
await dStage.addEntity({ id: 'cam9', kind: 'camera', params: { fov: 45 } });
assert.equal(tlDir.cameraCuts.length, 1, 'a SECOND camera adds no cut — the edit is the user\'s from here');
dStage.removeEntity('cam9');
await dStage.addEntity({ id: 'sun', kind: 'light', params: { type: 'key', color: '#ffffff', intensity: 1 } });
await dStage.addEntity({ id: 'lady', kind: 'character', label: 'lady' });
tlDir.seek(3);
// shot: transform+fov keys on the camera at playhead + a cut — via the EVENT,
// using Lane A's live payload shape (values spread at top level, presets.js:165)
let depth = tlDir.undoStack.length;
window.dispatchEvent(new CustomEvent('scenegod:direct', { detail: {
op: 'shot', camId: 'cam1', subjectId: 'lady', shot: 'MS', angle: 'eye', focal: 35,
pos: [0, 2, 6], rot: [0, 0.4, 0], fov: 35, dist: 4.2, aim: [0, 1.2, 0], entityIds: ['cam1', 'lady'],
} }));
const camE = tlDir.scene.entities.find((x) => x.id === 'cam1');
assert.equal(camE.tracks.transform.length, 1, 'shot lands one transform key');
assert.ok(near(camE.tracks.transform[0].t, 3) && near(camE.tracks.transform[0].pos[2], 6), 'shot key at playhead with the preset pos');
assert.ok(camE.tracks.params.some((k) => k.key === 'fov' && k.value === 35 && near(k.t, 3)), 'shot lands the fov param key');
assert.deepEqual(tlDir.cameraCuts, [{ t: 0, camera: 'cam1' }, { t: 3, camera: 'cam1' }],
'shot lands a cut to the camera (on top of the auto first cut)');
assert.equal(dStage._active, 'cam1', 'shot re-evaluates: camera is live immediately');
assert.equal(tlDir.undoStack.length, depth + 1, 'one shot op = one undo entry');
tlDir.undo();
assert.equal(camE.tracks.transform.length, 0, 'undo reverts the shot transform key');
assert.equal(camE.tracks.params.length, 0, 'undo reverts the fov key');
assert.deepEqual(tlDir.cameraCuts, [{ t: 0, camera: 'cam1' }], 'undo reverts the shot cut only');
// light: generic contract shape — entityId + params map at the playhead
depth = tlDir.undoStack.length;
tlDir.applyDirect({ op: 'light', entityId: 'sun', params: { intensity: 6.5, color: '#ffaa66' } });
const sunE = tlDir.scene.entities.find((x) => x.id === 'sun');
assert.equal(sunE.tracks.params.length, 2, 'light op lands one key per param');
assert.ok(sunE.tracks.params.every((k) => near(k.t, 3)), 'light keys at the playhead');
assert.equal(tlDir.undoStack.length, depth + 1, 'one light op = one undo entry');
tlDir.undo();
assert.equal(sunE.tracks.params.length, 0, 'undo reverts all light keys');
// light: Lane A applyLight shape — sun + ambient + bg (presets.js:145)
await dStage.addEntity({ id: 'amb', kind: 'light', params: { type: 'ambient', color: '#111111', intensity: 0.3 } });
depth = tlDir.undoStack.length;
tlDir.applyDirect({ op: 'light', preset: 'noir',
sun: { color: '#aabbcc', intensity: 2, elev: 55, azim: -60 }, ambient: { color: '#112233', intensity: 0.5 },
bg: '#000011', sunId: 'sun', ambientId: 'amb', sunPos: [1, 16, 2], entityIds: ['sun', 'amb'] });
const ambE = tlDir.scene.entities.find((x) => x.id === 'amb');
assert.equal(sunE.tracks.params.length, 2, 'preset lands sun color+intensity keys');
assert.equal(sunE.tracks.transform.length, 1, 'preset captures the sun boom transform');
assert.equal(ambE.tracks.params.length, 3, 'preset lands ambient color+intensity+bg keys');
assert.ok(ambE.tracks.params.some((k) => k.key === 'bg' && k.value === '#000011'), 'bg key rides the ambient entity');
assert.equal(tlDir.undoStack.length, depth + 1, 'whole preset = one undo entry');
tlDir.undo();
assert.equal(sunE.tracks.params.length + sunE.tracks.transform.length + ambE.tracks.params.length, 0,
'one undo reverts the entire light preset');
// mark + walkTo: current-pos key at playhead, mark key at playhead+2s
depth = tlDir.undoStack.length;
tlDir.applyDirect({ op: 'mark', entityId: 'lady', transform: { pos: [2, 0, 1], rot: [0, 1.57, 0] }, walkTo: true });
const ladyE = tlDir.scene.entities.find((x) => x.id === 'lady');
assert.equal(ladyE.tracks.transform.length, 2, 'walk-to-mark lands 2 keys');
assert.ok(near(ladyE.tracks.transform[0].t, 3) && near(ladyE.tracks.transform[0].pos[0], 0), 'first key = current pos at playhead');
assert.ok(near(ladyE.tracks.transform[1].t, 5) && near(ladyE.tracks.transform[1].pos[0], 2), 'second key = mark at playhead+2s');
tlDir.undo();
assert.equal(ladyE.tracks.transform.length, 0, 'one undo reverts both walk keys');
assert.equal(tlDir.undoStack.length, depth, 'walk-to-mark was a single undo entry');
// mark: Lane A applyMark shape — top-level pos, id field (presets.js:151)
tlDir.applyDirect({ op: 'mark', mark: 'two_shot_L', id: 'lady', pos: [-0.7, 0, 0], entityIds: ['lady'] });
assert.equal(ladyE.tracks.transform.length, 1, 'plain mark lands one key');
assert.ok(near(ladyE.tracks.transform[0].pos[0], -0.7), 'mark key carries the preset pos');
tlDir.undo();
delete globalThis.window;
// ---- M6 FLOW VIDEO PLATES: videos follow the clock, seek only on >50ms drift ----
const vStage = new StageStub();
const tlV = new Timeline(vStage);
const vScene = { version: 1, fps: 30, duration: 12, cameraCuts: [], entities: [
{ id: 'bg', kind: 'backdrop', params: { mode: 'video', image: 'backdrops/video/street.mp4', videoStart: 1, videoDuration: 4 }, tracks: {} },
{ id: 'still', kind: 'backdrop', params: { mode: 'plane', image: 'backdrops/street.jpg' }, tracks: {} },
] };
await vStage.applyState(vScene);
tlV.load(vScene);
assert.equal(vStage.entityVideo('still'), null, 'image backdrop has no video (nullable contract)');
const vid = vStage.entityVideo('bg');
assert.ok(vid, 'video backdrop exposes a video');
vid.seeks.length = 0;
tlV.step(90); // t=3 → want (3-1)%4 = 2
assert.deepEqual(vid.seeks, [2], 'drift >50ms → one seek to (t-videoStart)%duration');
tlV.step(90); // same t → drift 0
tlV.step(91); // t≈3.033 → drift ~33ms
assert.equal(vid.seeks.length, 1, 'no seek while drift <=50ms');
tlV.step(94); // t≈3.133 → drift ~133ms
assert.equal(vid.seeks.length, 2, 'drift >50ms → seeks again');
assert.ok(near(vid.currentTime, 94 / 30 - 1, 1e-6), 'resynced to the clock');
tlV.step(180); // t=6 → want (6-1)%4 = 1 (wraps)
assert.ok(near(vid.currentTime, 1, 1e-6), 'loops via modulo duration');
tlV.step(0); // t=0 < videoStart → clamp to frame 0
assert.ok(near(vid.currentTime, 0, 1e-6), 'before videoStart holds frame 0');
assert.equal(vid.paused, true, 'video paused while the clock is stopped');
tlV.playing = true; tlV.evaluate(tlV.time); // clock running → video plays
assert.equal(vid.paused, false, 'video plays with the clock');
tlV.pause(); // pause() syncs videos
assert.equal(vid.paused, true, 'pause() pauses the video');
// ---- M7-B: hasContent() — the confirm-before-clobber predicate ----
const tlH = new Timeline(new StageStub());
assert.equal(tlH.hasContent(), false, 'a fresh timeline is empty (no confirm on first template)');
tlH.load({ version: 1, fps: 30, duration: 5, entities: [], cameraCuts: [{ t: 0, camera: 'cam1' }], audio: [] });
assert.equal(tlH.hasContent(), true, 'a camera cut counts as content');
tlH.load({ version: 1, fps: 30, duration: 5, entities: [], cameraCuts: [], audio: [{ path: 'a.wav', start: 0, gain: 1 }] });
assert.equal(tlH.hasContent(), true, 'an audio clip counts as content');
tlH.load({ version: 1, fps: 30, duration: 5, entities: [], cameraCuts: [], audio: [] });
assert.equal(tlH.hasContent(), false, 'loading an empty scene clears content');
// a dock-added, not-yet-keyframed entity is still work worth confirming over
const hStage = new StageStub();
const tlH2 = new Timeline(hStage);
await hStage.addEntity({ id: 'p9', kind: 'prop', label: 'crate' }); // _mirror → scene.entities
assert.equal(tlH2.hasContent(), true, 'a dock-added entity counts as content');
// ---- M7-B: applyScene() — the shared Load / new-from-template path ----
const tStage = new StageStub();
const tlT = new Timeline(tStage);
let tLoads = 0; tlT.onLoad(() => tLoads++);
const tmpl = {
version: 1, name: 'two-hander', fps: 30, duration: 8,
template: { title: 'Two-hander', description: 'demo', thumb: 'backdrops/x.jpg', order: 1 },
entities: [
{ id: 'cam1', kind: 'camera', source: { type: 'none' }, params: { fov: 40 },
transform: { pos: [0, 1.6, 6], rot: [0, 0, 0], scale: 1 }, tracks: { transform: [], params: [], clips: [] } },
{ id: 'lady', kind: 'character', source: { type: 'assets', path: 'characters/lady.glb' },
transform: { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 },
tracks: { transform: [{ t: 0, pos: [0, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'inout' }],
clips: [{ path: 'animations/walk.fbx', clipIndex: 0, start: 0, in: 0, out: 2.4, loop: 1, fade: 0 }] } },
],
cameraCuts: [{ t: 0, camera: 'cam1' }], audio: [],
};
const applied = await tlT.applyScene(tmpl);
assert.equal(tStage.entities().length, 2, 'applyScene builds the entities on the stage');
assert.equal(tlT.scene.entities.length, 2, 'applyScene loads the tracks into the timeline');
assert.equal(tLoads, 1, 'applyScene fires onLoad once (UI rebuilds rows + scene bar)');
assert.equal(tlT.duration, 8, 'applyScene takes the template duration');
assert.equal(tStage._active, 'cam1', 'applyScene evaluates: the template camera is live');
assert.ok(!('template' in applied) && !('template' in tlT.toJSON()),
'presentation metadata is stripped — a saved scene stays plain scene JSON');
assert.ok('template' in tmpl, 'applyScene does not mutate the caller\'s json');
assert.equal(tlT._clipActions.size, 1, 'applyScene preloads clip blocks (playable immediately)');
tlT.step(30); // t=1 → the template clip is running
assert.ok(lastIn(tStage.applied, (a) => a.type === 'clip' && a.id === 'lady'), 'template clip plays after applyScene');
assert.equal(tlT.hasContent(), true, 'after a template the scene is non-empty (next New… confirms)');
// the templates Lane C actually ships must survive the same path
const tmplDir = new URL('../../templates/', import.meta.url);
let shipped = [];
try { shipped = (await import('node:fs')).readdirSync(tmplDir).filter((f) => f.endsWith('.json')); } catch { /* no templates dir */ }
for (const f of shipped) {
const json = JSON.parse((await import('node:fs')).readFileSync(new URL(f, tmplDir), 'utf8'));
const s = new StageStub(), t2 = new Timeline(s);
await t2.applyScene(json);
assert.equal(s.entities().length, (json.entities || []).length, `${f}: every entity reaches the stage`);
assert.ok(!('template' in t2.toJSON()), `${f}: template metadata stripped`);
assert.ok(t2.duration > 0, `${f}: has a duration`);
}
console.log(` (${shipped.length} shipped templates applied clean)`);
// ---- M9-B1: BEAT GRID — snap to the beat --------------------------------
// The literal below is the REAL response of Lane C's `GET beats?path=
// audio/test/house128.wav` (true tempo 128.004; detector 128.02, worst drift
// 10ms over the file). Testing against the shipped payload means the stored
// shape can't drift from the endpoint without this failing.
const BEATS128 = {
path: 'audio/test/house128.wav', bpm: 128.02, confidence: 0.865, meter: 4,
duration: 16.0, analyzed: 16.0, period: 0.468662, acPeak: 0.881, onBeatContrast: 0.85,
beats: [0.0101, 0.4787, 0.9474, 1.4161, 1.8847, 2.3534, 2.822, 3.2907, 3.7594, 4.228,
4.6967, 5.1654, 5.634, 6.1027, 6.5713, 7.04, 7.5087, 7.9773, 8.446, 8.9146, 9.3833,
9.852, 10.3206, 10.7893, 11.258, 11.7266, 12.1953, 12.6639, 13.1326, 13.6013, 14.0699,
14.5386, 15.0073, 15.4759, 15.9446],
downbeats: [1.4161, 3.2907, 5.1654, 7.04, 8.9146, 10.7893, 12.6639, 14.5386],
};
const nearestOf = (arr, t) => arr.reduce((b, x) => (Math.abs(x - t) < Math.abs(b - t) ? x : b));
const beatStage = new StageStub();
const tlBeat = new Timeline(beatStage);
tlBeat.load({ version: 1, name: 'beat', fps: 30, duration: 16, entities: [], cameraCuts: [],
audio: [{ path: 'audio/test/house128.wav', start: 0, gain: 1 }] });
assert.equal(tlBeat.snapToGrid(1.0, 'beat'), null, 'no grid → snapToGrid is null (the UI falls back to frames)');
assert.deepEqual(tlBeat.gridTimes('beat'), [], 'no grid → nothing to draw');
const aud = tlBeat.scene.audio[0];
tlBeat.setBeatGrid(beatGridFrom(BEATS128, aud.start - (aud.in || 0)));
const bg = tlBeat.scene.beatGrid;
assert.equal(bg.bpm, 128.02, 'grid carries the bpm');
assert.equal(bg.meter, 4, 'grid carries the meter (4/4 is an assumption Lane C reports, not a detection)');
assert.equal(bg.beats.length, 35, 'grid carries every beat in file seconds');
// the payoff: nearest beat / nearest downbeat, in range
for (const t of [0.0, 0.2, 0.25, 0.3, 1.39, 4.44, 7.9, 12.0, 15.8]) { // whole file is in beat range
const b = tlBeat.snapToGrid(t, 'beat');
assert.ok(near(b, nearestOf(BEATS128.beats, t), 1e-9), `snap(${t},'beat') → ${b}, want ${nearestOf(BEATS128.beats, t)}`);
}
for (const t of [1.5, 2.2, 4.44, 7.9, 12.0, 14.4]) { // inside the downbeat range
const bar = tlBeat.snapToGrid(t, 'bar');
assert.ok(near(bar, nearestOf(BEATS128.downbeats, t), 1e-9), `snap(${t},'bar') → ${bar}, want ${nearestOf(BEATS128.downbeats, t)}`);
}
assert.ok(near(tlBeat.snapToGrid(0.24, 'beat'), 0.0101, 1e-9), 'just under the midpoint snaps back');
assert.ok(near(tlBeat.snapToGrid(0.25, 'beat'), 0.4787, 1e-9), 'just over the midpoint snaps forward');
// every 4th beat IS the downbeat coset Lane C picked (index 3, not index 0)
assert.ok(near(tlBeat.snapToGrid(1.5, 'bar'), BEATS128.beats[3], 1e-9), 'bars use Lane C\'s downbeat coset, not "every 4th from zero"');
// past the analysed range the constant tempo extrapolates (an 8s track under a
// 30s scene must not drag every late key back onto the last beat)
assert.ok(near(tlBeat.snapToGrid(16.3, 'beat'), 15.9446 + BEATS128.period, 1e-9), 'extrapolates past the last beat');
assert.ok(tlBeat.snapToGrid(0, 'bar') >= 0, 'never snaps off the front of the timeline');
// grid moves with its audio block (offset is re-derived from the clip)
tlBeat.moveAudio(aud, 2.0);
assert.ok(near(tlBeat.beatOffset(), 2.0), 'offset follows the audio clip start');
assert.ok(near(tlBeat.snapToGrid(2.5, 'beat'), nearestOf(BEATS128.beats, 0.5) + 2, 1e-9), 'the whole grid moved with the block');
tlBeat.undo();
assert.ok(near(tlBeat.beatOffset(), 0), 'undo puts the clip (and the grid) back');
// head-trimming moves `in` and `start` together, so the grid must NOT shift
tlBeat.trimAudioIn(aud, 1.0, 16);
assert.ok(near(tlBeat.beatOffset(), 0), 'head trim keeps the grid where it was (start and in move together)');
tlBeat.undo();
// drawing positions: cover the scene, bars are a subset of beat positions
const gt = tlBeat.gridTimes('beat'), gbar = tlBeat.gridTimes('bar');
assert.equal(gt.length, 35, 'a 16s grid in a 16s scene needs no extension');
assert.equal(gbar.length, 8, 'bar lines = the downbeats');
assert.ok(gbar.every((t) => gt.some((b) => near(b, t, 1e-9))), 'every bar line sits on a beat line');
tlBeat.setDuration(30);
assert.ok(tlBeat.gridTimes('beat').length > 35, 'the grid extends at tempo across a longer scene (draw == snap)');
assert.ok(tlBeat.gridTimes('beat').every((t, i, a) => i === 0 || t > a[i - 1]), 'extended grid stays sorted');
tlBeat.setDuration(16);
// low confidence is a WARNING, not a refusal — the grid still works
const tlLow = new Timeline(new StageStub());
tlLow.load({ version: 1, fps: 30, duration: 16, entities: [], cameraCuts: [], audio: [] });
tlLow.setBeatGrid(beatGridFrom({ ...BEATS128, confidence: 0.12 }, 0));
assert.equal(tlLow.scene.beatGrid.confidence, 0.12, 'confidence is stored so the UI can say so');
assert.ok(near(tlLow.snapToGrid(0.3, 'beat'), 0.4787, 1e-9), 'a low-confidence grid still snaps (UI toasts the warning)');
assert.equal(tlLow.hasContent(), false, 'a beat grid alone is not "work worth a confirm" — it is one click to redo');
// survives save/load
const withGrid = tlBeat.toJSON();
assert.ok(withGrid.beatGrid && withGrid.beatGrid.beats.length === 35, 'beatGrid rides toJSON (it saves with the scene)');
const tlRT = new Timeline(new StageStub());
tlRT.load(withGrid);
assert.deepEqual(tlRT.toJSON(), withGrid, 'beatGrid survives a lossless round-trip');
assert.ok(near(tlRT.snapToGrid(3.0, 'beat'), tlBeat.snapToGrid(3.0, 'beat'), 1e-12), 'a reloaded scene snaps identically');
tlRT.clearBeatGrid();
assert.ok(!('beatGrid' in tlRT.toJSON()) && tlRT.snapToGrid(3, 'beat') === null, 'clearBeatGrid leaves plain scene JSON');
// ---- M9-B2: FILM shot list — runtime must agree with the server ----------
// tlui.js has no top-level DOM, so the pure bits are importable here (and this
// import fails loudly the day that stops being true).
const { filmRuntime, renderPlan, uploadEntities, saveMessage, rowFlags,
renderRefusal, audioProblems, cutAction, LANE_BG, CUT_COLOR, TimelineUI } = await import('./tlui.js');
const demoShots = [ // sequences/demo-two-shot.json
{ scene: 'sync2-sunset', in: 0.0, out: 4.5, transition: 'dissolve', transitionDur: 0.5 },
{ scene: 'sync2-sunset', in: 4.5, out: 8.0, transition: 'cut' },
{ scene: 'sync1-demo', in: 0.0, out: 4.0, transition: 'cut' },
];
assert.ok(near(filmRuntime(demoShots), 11.5, 1e-9), `demo-two-shot must read 11.5s (server agrees), got ${filmRuntime(demoShots)}`);
assert.equal(filmRuntime([]), 0, 'an empty shot list is 0s');
assert.ok(near(filmRuntime([{ in: 0, out: 2, transition: 'dissolve', transitionDur: 0.5 }]), 2, 1e-9),
'a dissolve on the LAST shot has nothing to dissolve into — server ignores it, so do we');
assert.ok(near(filmRuntime([{ in: 1, out: 3, transition: 'cut', transitionDur: 0.5 }, { in: 0, out: 2 }]), 4, 1e-9),
'cut transitions never shorten the film, whatever transitionDur says');
// ---- M10-B1: AUTO-CUT — cuts on the downbeats, cameras cycling -----------
// Same real house128.wav payload as above: 8 downbeats in a 16s scene, so the
// expected counts are arithmetic anyone can check by hand (8 bars: cadence 1→8
// cuts, 2→4, 4→2, 8→1).
const acStage = new StageStub();
const acScene = {
version: 1, name: 'autocut', fps: 30, duration: 16, cameraCuts: [],
audio: [{ path: 'audio/test/house128.wav', start: 0, gain: 1 }],
entities: [
{ id: 'camA', kind: 'camera', params: { fov: 40 }, tracks: {} },
{ id: 'camB', kind: 'camera', params: { fov: 40 }, tracks: {} },
{ id: 'lady', kind: 'character', tracks: {} },
],
};
await acStage.applyState(acScene);
const tlAC = new Timeline(acStage);
tlAC.load(acScene);
tlAC.setBeatGrid(beatGridFrom(BEATS128, 0));
const bars = tlAC.gridTimes('bar');
assert.equal(bars.length, 8, 'the 16s test scene has 8 downbeats');
assert.deepEqual(tlAC.cameraIds(), ['camA', 'camB'], 'cameras come back in scene order');
// no grid → refuse loudly, change nothing
const tlNoGrid = new Timeline(new StageStub());
tlNoGrid.load({ ...structuredClone(acScene) });
assert.throws(() => tlNoGrid.autoCut({ cadence: 2 }), /no beat grid/, 'no grid → refuses with a clear error');
assert.equal(tlNoGrid.cameraCuts.length, 0, 'a refused auto-cut applies NOTHING');
assert.equal(tlNoGrid.undoStack.length, 0, 'a refused auto-cut pushes no undo entry');
assert.equal(tlNoGrid.autoCutPlan({}).ok, false, 'plan() reports the refusal instead of throwing');
// fewer than 2 cameras → refuse
const tlOne = new Timeline(new StageStub());
tlOne.load({ version: 1, fps: 30, duration: 16, cameraCuts: [], audio: [],
entities: [{ id: 'camA', kind: 'camera', tracks: {} }] });
tlOne.setBeatGrid(beatGridFrom(BEATS128, 0));
assert.throws(() => tlOne.autoCut({ cadence: 2 }), /at least 2 cameras/, 'one camera → refuses with a clear error');
assert.equal(tlOne.cameraCuts.length, 0, 'the one-camera refusal applies nothing');
assert.equal(tlAC.autoCutPlan({ cameras: ['camA'] }).ok, false, 'deselecting down to one camera refuses too');
// the main event: cadence 2 over the whole scene
const before2 = structuredClone(tlAC.cameraCuts);
const undo0 = tlAC.undoStack.length;
const plan = tlAC.autoCut({ cadence: 2 });
assert.equal(plan.cuts.length, 4, 'every 2nd of 8 downbeats = 4 cuts');
assert.equal(tlAC.cameraCuts.length, 4, 'and 4 cuts actually landed');
for (const c of tlAC.cameraCuts) {
assert.ok(bars.some((b) => b === c.t), `cut at ${c.t} must BE a grid downbeat, not merely near one`);
}
assert.deepEqual(tlAC.cameraCuts.map((c) => c.t), [bars[0], bars[2], bars[4], bars[6]], 'cadence 2 takes every other bar');
assert.deepEqual(tlAC.cameraCuts.map((c) => c.camera), ['camA', 'camB', 'camA', 'camB'], '2 cameras = strict A/B alternation');
assert.ok(tlAC.cameraCuts.every((c, i, a) => i === 0 || c.camera !== a[i - 1].camera), 'no camera lands twice in a row');
assert.equal(acStage._active, null, 'before the first downbeat no cut has happened yet — auto-cut invents nothing at t=0');
tlAC.seek(bars[0]); assert.equal(acStage._active, 'camA', 'the first placed cut is live the moment the playhead reaches it');
tlAC.seek(bars[2]); assert.equal(acStage._active, 'camB', 'and the second flips cameras');
tlAC.seek(0);
assert.equal(tlAC.undoStack.length, undo0 + 1, '4 cuts = ONE undo entry');
tlAC.undo();
assert.deepEqual(tlAC.cameraCuts, before2, 'one undo restores the exact prior cameraCuts array');
// cadence maths, and the count the popover shows is the count you get
for (const [cad, n] of [[1, 8], [2, 4], [4, 2], [8, 1]]) {
const p = tlAC.autoCutPlan({ cadence: cad });
assert.equal(p.cuts.length, n, `cadence ${cad} bars → ${n} cuts`);
const applied = tlAC.autoCut({ cadence: cad });
assert.equal(tlAC.cameraCuts.length, applied.cuts.length, 'the previewed count is the applied count');
assert.equal(applied.cuts.length, n, `cadence ${cad} applies ${n} cuts`);
tlAC.undo();
assert.equal(tlAC.cameraCuts.length, 0, 'each cadence run undoes cleanly');
}
// idempotent-ish: re-running replaces its own cuts instead of stacking them
tlAC.autoCut({ cadence: 2 });
const firstPass = structuredClone(tlAC.cameraCuts);
const p2 = tlAC.autoCut({ cadence: 2 });
assert.equal(p2.replaced.length, 4, 'the second pass sees its own 4 cuts as replacements');
assert.equal(tlAC.cameraCuts.length, 4, 're-running does NOT accumulate duplicates');
assert.equal(new Set(tlAC.cameraCuts.map((c) => c.t)).size, 4, 'no two cuts share a t');
assert.deepEqual(tlAC.cameraCuts.map((c) => c.t), firstPass.map((c) => c.t), 'same input → same positions');
tlAC.undo();
assert.deepEqual(tlAC.cameraCuts.map((c) => c.t), firstPass.map((c) => c.t), 'one undo takes the whole second pass back');
tlAC.undo();
assert.equal(tlAC.cameraCuts.length, 0, 'and the first pass with it');
// a hand-placed cut ON a downbeat is replaced, not duplicated
tlAC.addCut(bars[2], 'camB');
tlAC.autoCut({ cadence: 2 });
assert.equal(tlAC.cameraCuts.filter((c) => Math.abs(c.t - bars[2]) < 1e-9).length, 1, 'an existing cut at a placed position is replaced');
tlAC.undo(); tlAC.undo();
assert.equal(tlAC.cameraCuts.length, 0, 'undo unwinds the replacement too');
// a hand-placed cut BETWEEN downbeats that GENUINELY changes the angle is left
// alone — and disclosed, because it interleaves with the beat cuts
tlAC.addCut(4.5, 'camB'); // cadence 2 puts camA at 1.4161 → this really cuts
const pk = tlAC.autoCutPlan({ cadence: 2 });
assert.equal(pk.kept.length, 1, 'an off-grid cut that changes camera is reported as kept');
assert.equal(pk.dropped.length, 0, 'and is not dropped');
assert.equal(pk.replaced.length, 0, 'and is NOT counted as a replacement');
tlAC.autoCut({ cadence: 2 });
assert.equal(tlAC.cameraCuts.length, 5, 'the user\'s own cut survives auto-cut (4 placed + 1 kept)');
assert.ok(tlAC.cameraCuts.some((c) => c.t === 4.5 && c.camera === 'camB'), 'exactly as they left it');
tlAC.undo(); tlAC.undo();
assert.equal(tlAC.cameraCuts.length, 0, 'and both come back off');
// SYNC13: a kept cut that selects the camera ALREADY LIVE is a no-op — it cuts
// camA→camA, nothing happens on screen, and it would ride into the saved scene
// looking like an edit. Drop it, and say so.
tlAC.addCut(4.5, 'camA'); // camA is live from the 1.4161 auto cut
const beforeDrop = structuredClone(tlAC.cameraCuts);
const pd = tlAC.autoCutPlan({ cadence: 2 });
assert.equal(pd.dropped.length, 1, 'a no-op cut is reported as dropped');
assert.equal(pd.kept.length, 0, 'and is not reported as kept');
assert.equal(pd.dropped[0].t, 4.5, 'the right cut is named');
tlAC.autoCut({ cadence: 2 });
assert.equal(tlAC.cameraCuts.length, 4, 'the no-op cut is gone — only the 4 beat cuts remain');
assert.ok(!tlAC.cameraCuts.some((c) => c.t === 4.5), 'specifically that one');
assert.ok(tlAC.cameraCuts.every((c, i, a) => i === 0 || c.camera !== a[i - 1].camera), 'the final list has no camera repeat at all');
tlAC.undo();
assert.deepEqual(tlAC.cameraCuts, beforeDrop, 'ONE undo restores the dropped cut along with everything else');
tlAC.undo();
assert.equal(tlAC.cameraCuts.length, 0, 'and the manual cut comes off with the next undo');
// the effective camera is judged AFTER the auto cuts merge in, not against the
// previous array element: this cut duplicates a camera that only exists once
// auto-cut has run, and is still caught
tlAC.addCut(2.0, 'camA'); // 1.4161 auto = camA → 2.0 changes nothing
tlAC.addCut(6.0, 'camA'); // 5.1654 auto = camB → 6.0 IS a real cut
const pe = tlAC.autoCutPlan({ cadence: 2 });
assert.deepEqual(pe.dropped.map((c) => c.t), [2.0], 'only the genuinely redundant one is dropped');
assert.deepEqual(pe.kept.map((c) => c.t), [6.0], 'the one that changes the angle is kept');
tlAC.undo(); tlAC.undo();
assert.equal(tlAC.cameraCuts.length, 0, 'test cuts cleaned up');
// range = from the playhead, and the camera already live doesn't repeat across it
tlAC.seek(6);
tlAC.addCut(0, 'camA'); // camA is live going into the range
const pr = tlAC.autoCut({ cadence: 2, from: tlAC.time });
const inRange = bars.filter((b) => b >= 6);
assert.equal(inRange.length, 5, '5 downbeats sit at/after t=6');
assert.equal(pr.cuts.length, 3, 'every 2nd of those 5 = 3 cuts');
assert.ok(tlAC.cameraCuts.slice(1).every((c) => c.t >= 6), 'nothing is placed before the playhead');
assert.equal(tlAC.cameraCuts[0].t, 0, 'the cut before the range is left alone');
assert.equal(tlAC.cameraCuts[1].camera, 'camB', 'the run opens on the OTHER camera (camA was already live)');
assert.ok(tlAC.cameraCuts.every((c, i, a) => i === 0 || c.camera !== a[i - 1].camera), 'no repeat across the range boundary either');
tlAC.undo();
assert.deepEqual(tlAC.cameraCuts, [{ t: 0, camera: 'camA' }], 'one undo, the manual cut survives untouched');
tlAC.undo(); tlAC.seek(0);
// 3 cameras cycle in scene order; a deselected camera is skipped
await acStage.addEntity({ id: 'camC', kind: 'camera', params: {} }); // _mirror → scene.entities
// the cut list happens to be empty at this instant, so _mirror ALSO puts camC
// live at 0 ("cameras go live at a cut"). Take that one back off so the cycle
// assertions below read only auto-cut's own output.
assert.deepEqual(tlAC.cameraCuts, [{ t: 0, camera: 'camC' }], 'a camera added to a cutless scene goes live at 0');
tlAC.removeCut(tlAC.cameraCuts[0]);
assert.deepEqual(tlAC.cameraIds(), ['camA', 'camB', 'camC'], 'a dock-added camera joins the cycle');
tlAC.autoCut({ cadence: 1 });
assert.deepEqual(tlAC.cameraCuts.map((c) => c.camera), ['camA', 'camB', 'camC', 'camA', 'camB', 'camC', 'camA', 'camB'],
'3 cameras cycle in scene order, never repeating');
tlAC.undo();
tlAC.autoCut({ cadence: 2, cameras: ['camA', 'camC'] });
assert.deepEqual(tlAC.cameraCuts.map((c) => c.camera), ['camA', 'camC', 'camA', 'camC'], 'only the ticked cameras are used');
tlAC.undo();
// low confidence works exactly the same (the UI says so; the model has no opinion)
tlAC.setBeatGrid(beatGridFrom({ ...BEATS128, confidence: 0.11 }, 0));
const lowPlan = tlAC.autoCut({ cadence: 2 });
assert.equal(lowPlan.cuts.length, 4, 'a low-confidence grid still auto-cuts');
assert.ok(lowPlan.confidence < 0.35, 'the plan carries the confidence so the UI can warn');
tlAC.undo();
tlAC.setBeatGrid(beatGridFrom(BEATS128, 0));
// an empty range refuses instead of doing nothing quietly
assert.equal(tlAC.autoCutPlan({ from: 15.5 }).ok, false, 'a range with no downbeats in it refuses');
assert.match(tlAC.autoCutPlan({ from: 15.5 }).error, /no downbeats/, 'and says why');
// the grid follows its audio, so auto-cut does too
tlAC.moveAudio(tlAC.scene.audio[0], 0.5);
const movedBars = tlAC.gridTimes('bar');
assert.ok(movedBars.some((t) => near(t, 1.4161 + 0.5, 1e-9)), 'the grid moves with its audio block');
assert.deepEqual(tlAC.autoCutPlan({ cadence: 2 }).cuts.map((c) => c.t), movedBars.filter((_, i) => i % 2 === 0),
'auto-cut follows the moved grid — including the bar the shift extrapolates into view at the front');
tlAC.undo();
assert.deepEqual(tlAC.gridTimes('bar'), bars, 'undoing the drag puts the grid back');
// ---- M10-B2: align a film's shots to the beat ----------------------------
const barTimes = gridTimesOf(BEATS128, 'bar', 0, 40);
const onBar = (t) => barTimes.some((b) => near(b, t, 1e-6));
const rawShots = [
{ scene: 'shot-street', in: 0, out: 4.5, transition: 'dissolve', transitionDur: 0.5 },
{ scene: 'shot-store', in: 4.5, out: 8.0, transition: 'cut' },
{ scene: 'shot-street', in: 3.0, out: 3.4, transition: 'cut' }, // shorter than a bar → would collapse
];
const al = alignShotsToGrid(rawShots, BEATS128, 0, [8, 8, 8]);
assert.equal(al.shots.length, 3, 'every shot comes back — aligning never drops one');
assert.deepEqual(al.shots.map((s) => s.scene), rawShots.map((s) => s.scene), 'shot ORDER is never touched');
assert.ok(onBar(al.shots[0].in) && onBar(al.shots[0].out), 'shot 1 boundaries land on downbeats');
assert.ok(onBar(al.shots[1].in) && onBar(al.shots[1].out), 'shot 2 boundaries land on downbeats');
assert.ok(al.shots.every((s) => s.out > s.in), 'no in >= out survives');
assert.ok(near(al.shots[0].in, 1.4161) && near(al.shots[0].out, 5.1654), `shot 1 → 1.4161..5.1654, got ${al.shots[0].in}..${al.shots[0].out}`);
assert.ok(near(al.shots[1].in, 5.1654) && near(al.shots[1].out, 7.04), `shot 2 clamps inside its 8s scene, got ${al.shots[1].in}..${al.shots[1].out}`);
assert.ok(onBar(al.shots[1].out), 'the clamped end is a REAL downbeat, not a bar-period subtraction');
assert.equal(al.skipped.length, 1, 'the collapsing shot is reported');
assert.equal(al.skipped[0].index, 2, 'and named by its index');
assert.deepEqual({ in: al.shots[2].in, out: al.shots[2].out }, { in: 3.0, out: 3.4 }, 'a skipped shot is left EXACTLY as it was');
assert.deepEqual(rawShots[0], { scene: 'shot-street', in: 0, out: 4.5, transition: 'dissolve', transitionDur: 0.5 },
'alignShotsToGrid does not mutate the caller\'s shots');
assert.equal(al.shots[0].transition, 'dissolve', 'transitions ride along untouched');
assert.equal(al.shots[0].transitionDur, 0.5, 'and so do their durations');
// runtime is the server's arithmetic on the NEW numbers (the panel updates)
assert.ok(near(filmRuntime(al.shots), (5.1654 - 1.4161) + (7.04 - 5.1654) - 0.5 + 0.4, 1e-6),
'aligned runtime = the same formula on the snapped windows');
// idempotent: aligning twice changes nothing
const al2 = alignShotsToGrid(al.shots, BEATS128, 0, [8, 8, 8]);
assert.deepEqual(al2.shots, al.shots, 'aligning an already-aligned list is a no-op');
// the grid offset applies (a shot list aligned against a scene whose audio starts late)
const barTimes2 = gridTimesOf(BEATS128, 'bar', 2, 40);
const alOff = alignShotsToGrid([{ scene: 's', in: 0, out: 6 }], BEATS128, 2, [16]);
const onBar2 = (t) => barTimes2.some((b) => near(b, t, 1e-6));
assert.ok(onBar2(alOff.shots[0].in) && onBar2(alOff.shots[0].out), 'the grid offset shifts the whole alignment');
assert.ok(alOff.shots[0].out > alOff.shots[0].in && alOff.shots[0].in >= 0, 'an offset grid still yields a valid window');
assert.equal(alOff.skipped.length, 0, 'and nothing collapses');
// ---- M11-B: CAMERA MOVES — Lane A's 🎬 move keyed as a from/to pair -------
// Payload contract: logs/laneA.md session 7. `from` is ALREADY on the live
// camera when the event fires; the END exists only in the event until it is
// keyed here, so "the move doesn't happen" is exactly what this block guards.
globalThis.window = new EventTarget();
const mvStage = new StageStub();
const tlMv = new Timeline(mvStage); // ctor: scenegod:direct listener + _mirror
await mvStage.addEntity({ id: 'cam2', kind: 'camera', label: 'cam2', params: { fov: 37.85 } });
// the first camera goes live at 0 (asserted in the DIRECT block above); this
// block is about MOVES, so take that cut back off and test them in isolation.
assert.deepEqual(tlMv.cameraCuts, [{ t: 0, camera: 'cam2' }], 'the added camera went live at 0');
tlMv.removeCut(tlMv.cameraCuts[0]);
await mvStage.addEntity({ id: 'man', kind: 'character', label: 'man' });
tlMv.setDuration(16);
tlMv.seek(2);
const camMv = tlMv.scene.entities.find((x) => x.id === 'cam2');
const PUSH = { // presets.js:283, verbatim shape
op: 'move', camId: 'cam2', t0: 2, dur: 6, ease: 'inout',
from: { pos: [0, 1.6, 6], rot: [0, 0, 0], fov: 37.85 },
to: { pos: [0, 1.6, 4], rot: [0, 0, 0], fov: 37.85 }, // a dolly: fov does NOT change
entityIds: ['cam2'], move: 'push_in', subjectId: 'man', shot: 'MS', angle: 'eye', focal: 35,
};
assert.deepEqual(tlMv.snap, { on: true, step: 'frame' }, 'the model carries the snap config the UI writes');
let mvDepth = tlMv.undoStack.length;
const mvBefore = structuredClone(camMv.tracks);
window.dispatchEvent(new CustomEvent('scenegod:direct', { detail: PUSH })); // via the EVENT
const mvTr = camMv.tracks.transform, mvFov = () => camMv.tracks.params.filter((k) => k.key === 'fov');
assert.equal(mvTr.length, 2, 'a move lands exactly two transform keys');
assert.equal(mvTr[0].t, 2, 'first key at t0');
assert.equal(mvTr[1].t, 8, 'second key at t0+dur');
assert.deepEqual(mvTr[0].pos, PUSH.from.pos, 'the t0 key carries `from`');
assert.deepEqual(mvTr[1].pos, PUSH.to.pos, 'the t0+dur key carries `to`');
assert.deepEqual([mvTr[0].rot, mvTr[1].rot], [PUSH.from.rot, PUSH.to.rot], 'rotations ride along');
assert.equal(mvTr[0].ease, 'inout', "the op's ease is on the FIRST key of the pair (the segment reads it there)");
assert.equal(mvFov().length, 2, 'fov is keyed at BOTH ends');
assert.deepEqual(mvFov().map((k) => k.t), [2, 8], 'and at exactly the move times');
assert.deepEqual(mvFov().map((k) => k.value), [37.85, 37.85],
'a dolly keys fov even though it never changes — an unkeyed fov gets dragged around by its neighbours');
assert.equal(mvFov()[0].ease, 'inout', 'the fov pair carries the ease too');
assert.equal(tlMv.cameraCuts.length, 0, 'a move places NO cut (it often continues the shot already running)');
assert.equal(tlMv.undoStack.length, mvDepth + 1, 'the whole move is ONE undo entry');
assert.ok(near(tlMv.time, 2), 'the playhead sits on the move\'s first frame');
// the payoff: the camera is somewhere between the two ends mid-move
mvStage.applied.length = 0;
tlMv.seek(5); // halfway through a 6s move
const midPose = lastIn(mvStage.applied, (a) => a.type === 'transform' && a.id === 'cam2');
assert.ok(midPose && midPose.pos[2] < 6 && midPose.pos[2] > 4, `mid-move z must be strictly between 6 and 4, got ${midPose && midPose.pos[2]}`);
assert.ok(near(midPose.pos[2], 5, 1e-9), 'inout is symmetric: the midpoint is the midpoint');
tlMv.seek(2.5);
const earlyPose = lastIn(mvStage.applied, (a) => a.type === 'transform' && a.id === 'cam2');
assert.ok(earlyPose.pos[2] < 6 && earlyPose.pos[2] > 5, 'an early sample has barely left the start (ease-in)');
tlMv.seek(2);
tlMv.undo();
assert.deepEqual(camMv.tracks, mvBefore, 'ONE undo restores the prior tracks deep-equal (keys AND fov)');
assert.equal(tlMv.undoStack.length, mvDepth, 'undo stack back where it started');
// a ZOOM moves the lens, not the camera — the proof that from/to are kept whole
const ZOOM = {
op: 'move', camId: 'cam2', t0: 2, dur: 4, ease: 'linear', move: 'zoom_in',
from: { pos: [0, 1.6, 6], rot: [0, 0.2, 0], fov: 37.85 },
to: { pos: [0, 1.6, 6], rot: [0, 0.2, 0], fov: 27.0 },
entityIds: ['cam2'],
};
const zRep = tlMv.applyMove(ZOOM);
assert.deepEqual(zRep, { camId: 'cam2', t0: 2, dur: 4, ease: 'linear', keys: 4, replaced: 0, snapped: null, live: null },
'applyMove reports what it did (the toast is built from this)');
const zTr = camMv.tracks.transform;
assert.deepEqual(zTr[0].pos, zTr[1].pos, 'a zoom does not move the camera (pos identical at both ends)');
assert.deepEqual(zTr[0].rot, zTr[1].rot, 'and does not turn it either');
assert.deepEqual(mvFov().map((k) => k.value), [37.85, 27.0], 'only fov differs across a zoom');
assert.equal(zTr[0].ease, 'linear', 'linear ease is honoured');
mvStage.applied.length = 0;
tlMv.seek(4); // halfway through the zoom
const zPose = lastIn(mvStage.applied, (a) => a.type === 'transform' && a.id === 'cam2');
const zFov = lastIn(mvStage.applied, (a) => a.type === 'param' && a.id === 'cam2' && a.key === 'fov');
assert.deepEqual(zPose.pos, [0, 1.6, 6], 'the camera does not drift a millimetre during a zoom');
assert.ok(zFov.value < 37.85 && zFov.value > 27, `mid-zoom fov must be strictly between the ends, got ${zFov.value}`);
assert.ok(near(zFov.value, 32.425, 1e-9), 'linear fov ramp: exactly halfway at halfway');
tlMv.seek(2);
// a second move over the same window REPLACES — interleaved keys read as jitter
const rep2 = tlMv.applyMove({ ...ZOOM, to: { pos: [0, 1.6, 6], rot: [0, 0.2, 0], fov: 20 } });
assert.equal(rep2.replaced, 4, 'the previous move\'s 2 transform + 2 fov keys are displaced, and counted');
assert.equal(camMv.tracks.transform.length, 2, 'still exactly two transform keys — not four interleaved');
assert.equal(mvFov().length, 2, 'and two fov keys');
assert.deepEqual(mvFov().map((k) => k.value), [37.85, 20], 'the NEW move wins');
assert.ok(camMv.tracks.transform.every((k, i, a) => i === 0 || k.t >= a[i - 1].t), 'transform keys stay sorted by t');
assert.ok(camMv.tracks.params.every((k, i, a) => i === 0 || k.t >= a[i - 1].t), 'param keys stay sorted by t');
tlMv.undo();
assert.deepEqual(camMv.tracks.params.map((k) => k.value), [37.85, 27.0], 'one undo puts the replaced move back exactly');
// keys the move does not own are left alone; strays inside the window are not
tlMv.addKey('cam2', 'transform', { t: 3, pos: [9, 9, 9], rot: [0, 0, 0], scale: 1, ease: 'linear' });
tlMv.addKey('cam2', 'params', { t: 3.5, key: 'fov', value: 99, ease: 'linear' });
tlMv.addKey('cam2', 'params', { t: 3.5, key: 'dof', value: 1.2, ease: 'linear' }); // not a move's business
tlMv.addKey('cam2', 'transform', { t: 12, pos: [1, 1, 1], rot: [0, 0, 0], scale: 1, ease: 'linear' });
const rep3 = tlMv.applyMove(ZOOM);
assert.equal(rep3.replaced, 6, '4 of its own + the stray transform + the stray fov, all inside the window');
assert.ok(camMv.tracks.transform.some((k) => k.t === 12), 'a key OUTSIDE the window survives untouched');
assert.ok(!camMv.tracks.transform.some((k) => k.t === 3), 'the stray inside the window is gone');
assert.ok(camMv.tracks.params.some((k) => k.key === 'dof' && k.t === 3.5), 'a non-fov param key inside the window is NOT the move\'s business');
tlMv.undo();
assert.ok(camMv.tracks.transform.some((k) => k.t === 3), 'and one undo brings the strays back');
// a cut is opt-in, never automatic
const cutRep = tlMv.applyMove({ ...ZOOM, cut: true });
assert.deepEqual(tlMv.cameraCuts, [{ t: 2, camera: 'cam2' }], '`cut: true` places one at t0');
tlMv.undo();
assert.equal(tlMv.cameraCuts.length, 0, 'and it comes off with the same single undo');
assert.equal(cutRep.keys, 4, 'the cut does not change the key count');
// refusals are loud (the listener funnels them into _fail → toast)
assert.throws(() => tlMv.applyMove({ op: 'move', from: ZOOM.from, to: ZOOM.to }), /no camera/, 'no camera id → throws');
assert.throws(() => tlMv.applyMove({ op: 'move', camId: 'cam2', from: ZOOM.from }), /from\/to/, 'half a payload → throws');
assert.throws(() => tlMv.applyMove({ ...ZOOM, dur: 0 }), /duration/, 'a zero-length move → throws');
delete globalThis.window;
// ---- M11-B: moves land on the beat (the grid finally reaches camera work) ---
const snStage = new StageStub();
const tlSn = new Timeline(snStage);
await snStage.addEntity({ id: 'cam1', kind: 'camera', params: { fov: 40 } });
tlSn.load({ version: 1, name: 'snapmove', fps: 30, duration: 16, cameraCuts: [],
audio: [{ path: 'audio/test/house128.wav', start: 0, gain: 1 }],
entities: [{ id: 'cam1', kind: 'camera', params: { fov: 40 },
transform: { pos: [0, 1.6, 6], rot: [0, 0, 0], scale: 1 }, tracks: {} }] });
tlSn.setBeatGrid(beatGridFrom(BEATS128, 0));
const onBar128 = (t) => BEATS128.downbeats.some((b) => near(b, t, 1e-6));
const MV = { op: 'move', camId: 'cam1', t0: 2.03, dur: 6, ease: 'inout', move: 'truck_L',
from: { pos: [0, 1.6, 6], rot: [0, 0, 0], fov: 40 }, to: { pos: [1.2, 1.6, 6], rot: [0, 0, 0], fov: 40 } };
assert.equal(tlSn.snapMove(2.03, 6).snapped, null, 'snap = frame (the default) leaves a move exactly where Lane A put it');
assert.deepEqual(tlSn.snapMove(2.03, 6), { t0: 2.03, dur: 6, snapped: null }, 'and does not touch the numbers');
tlSn.snap.step = 'bar';
const sm = tlSn.snapMove(2.03, 6);
assert.ok(onBar128(sm.t0), `bar snap puts t0 on a downbeat, got ${sm.t0}`);
assert.ok(onBar128(sm.t0 + sm.dur), `and the END on a downbeat too, got ${sm.t0 + sm.dur}`);
assert.ok(near(sm.t0, 1.4161) && near(sm.t0 + sm.dur, 7.04), 'the nearest downbeats either side, not the next ones');
tlSn.applyMove(MV);
const snTr = tlSn.scene.entities.find((x) => x.id === 'cam1').tracks.transform;
assert.ok(onBar128(snTr[0].t) && onBar128(snTr[1].t), 'the keys themselves land on the grid, not near it');
assert.ok(near(tlSn.time, snTr[0].t), 'the playhead follows the snap (so "why did it land there" is visible)');
tlSn.undo();
tlSn.snap.step = 'beat';
const sb = tlSn.snapMove(2.03, 6);
assert.ok(BEATS128.beats.some((b) => near(b, sb.t0, 1e-6)), 'beat snap puts t0 on a beat');
assert.equal(sb.dur, 6, 'beat snap leaves the duration alone (only bar snaps the END — a bar is a musical edit point)');
tlSn.snap.on = false;
assert.equal(tlSn.snapMove(2.03, 6).snapped, null, 'snap off = off, grid or no grid');
tlSn.snap.on = true;
// the guards: never past the scene end, never a zero/negative duration
tlSn.snap.step = 'bar';
const late = tlSn.snapMove(14.6, 6); // Lane A already clamped; re-clamp at the snapped start
assert.ok(late.t0 + late.dur <= 16 + 1e-9, 'the snapped end never leaves the scene');
assert.ok(late.dur > 0, 'and never collapses');
const tiny = tlSn.snapMove(15.9, 0.1);
assert.ok(tiny.dur > 0 && tiny.t0 + tiny.dur <= 16 + 1e-9, 'a short move near the end survives snapping');
assert.deepEqual(tlSn.snapMove(15.95, 0.05), { t0: 15.95, dur: 0.05, snapped: null },
'a start that would snap past the end of the scene is left alone entirely');
tlSn.snap.step = 'frame';
// real material: scenes/music-video.json (4 cameras, saved 128.02 BPM grid)
const mvPath = new URL('../../scenes/music-video.json', import.meta.url);
let mvJson = null;
try { mvJson = JSON.parse((await import('node:fs')).readFileSync(mvPath, 'utf8')); } catch { /* not checked out */ }
if (mvJson) {
const rStage = new StageStub(), tlR = new Timeline(rStage);
await tlR.applyScene(mvJson);
tlR.snap = { on: true, step: 'bar' };
const cutsBefore = structuredClone(tlR.cameraCuts);
const tracksBefore = structuredClone(tlR.scene.entities.find((x) => x.id === 'cam2').tracks);
tlR.seek(4.2);
const rr = tlR.applyMove({ op: 'move', camId: 'cam2', dur: 6, ease: 'inout', move: 'orbit_L',
from: { pos: [3, 1.5, 4], rot: [0, 0.6, 0], fov: 32 }, to: { pos: [4.6, 1.5, 1.9], rot: [0, 1.05, 0], fov: 32 } });
const rTr = tlR.scene.entities.find((x) => x.id === 'cam2').tracks.transform;
const rBars = tlR.gridTimes('bar');
assert.equal(rTr.length, 2, 'music-video: the move keys cam2');
assert.ok(rBars.some((b) => near(b, rTr[0].t, 1e-6)) && rBars.some((b) => near(b, rTr[1].t, 1e-6)),
`music-video: both keys are real grid downbeats (${rTr[0].t}${rTr[1].t})`);
// playhead 4.2 → nearest downbeat 3.2907; 3.2907+6 → nearest downbeat 8.9146 (3 bars, 5.6239s)
assert.ok(near(rTr[0].t, 3.2907) && near(rTr[1].t, 8.9146), `music-video: 3.2907 → 8.9146, got ${rTr[0].t}${rTr[1].t}`);
assert.ok(near(rTr[1].t - rTr[0].t, 3 * (0.468662 * 4), 1e-4), 'the snapped move is a whole number of bars long');
assert.equal(rr.replaced, 0, 'nothing to displace on a fresh camera');
assert.deepEqual(tlR.cameraCuts, cutsBefore, 'music-video: the saved edit is untouched — no cut goes in behind your back');
assert.equal(rr.live, 'cam1', 'and the report names the camera the EDIT is on at t0, so the toast can warn');
assert.equal(tlR.cutCameraAt(5), 'cam2', 'cutCameraAt reads the edit exactly like _evalCameraCuts does');
tlR.undo();
assert.deepEqual(tlR.scene.entities.find((x) => x.id === 'cam2').tracks, tracksBefore,
'music-video: ONE undo restores the file\'s tracks deep-equal');
console.log(' (music-video.json: bar-snapped move on cam2, 3.2907 → 8.9146 = 3 bars)');
}
// ---- ⏺ RENDER: the finalRender() plan for the scene you are looking at ------
// renderPlan is the whole click handler minus the await — pinning it here pins
// what the button does. (finalRender itself is Lane C's and needs a WebGL canvas.)
const rpStage = new StageStub();
const tlRP = new Timeline(rpStage);
tlRP.load({ version: 1, name: 'street', fps: 30, duration: 20, entities: [], cameraCuts: [],
audio: [{ path: 'audio/vo1.wav', start: 0.5, gain: 0.8 }] });
const rp = renderPlan(tlRP, '1280x720');
assert.equal(rp.width, 1280, 'the size select drives the width');
assert.equal(rp.height, 720, 'and the height');
assert.equal(rp.fps, 30, 'fps comes from the scene, never the picker');
assert.equal(rp.name, 'street', 'the render is named after the scene');
assert.equal(rp.total, 600, 'a 20s scene at 30fps is 600 frames');
assert.deepEqual(rp.audio, tlRP.scene.audio, 'scene audio[] is passed through to the mux');
assert.equal(rp.audio[0].gain, 0.8, 'gain and all');
assert.deepEqual(renderPlan(tlRP, 'not-a-size'), { ...rp, width: 1920, height: 1080 },
'an unparseable size falls back to 1080p rather than rendering 0x0');
assert.equal(renderPlan(tlRP, '1920×1080').width, 1920, 'the ×-glyph label form parses too');
const tlEmpty = new Timeline(new StageStub());
tlEmpty.load({ version: 1, fps: 30, duration: 2, entities: [], cameraCuts: [], audio: [] });
assert.equal(renderPlan(tlEmpty, '854x480').name, 'untitled', 'an unnamed scene still renders');
assert.equal(renderPlan(tlEmpty, '854x480').total, 60, 'and an EMPTY scene is a valid black plate');
const tlLong = new Timeline(new StageStub());
tlLong.load({ version: 1, fps: 30, duration: 700, entities: [], cameraCuts: [], audio: [] });
assert.throws(() => renderPlan(tlLong, '854x480'), /max 20000/,
'a render past the server frame cap is refused BEFORE 20000 PNGs are uploaded');
assert.throws(() => renderPlan(tlLong, '854x480'), /21000 frames/, 'and says how long it actually is');
// ---- SAVE the stage you actually built (syncFromStage) ----------------------
// _mirror snapshots an entity's transform+params ONCE, at drop time, and then
// early-returns forever. So a prop dragged into place with W, a key light dialled
// to 0.8 and a backdrop's fitDist all reverted to their drop-time values on the
// next save/reload. Only keyframed entities survived — and nobody keyframes set
// dressing. FAILS on the old code (no syncFromStage at all).
const syScene = structuredClone(scene);
syScene.entities.push({
id: 'plate', kind: 'backdrop', label: 'street',
source: { type: 'assets', path: 'backdrops/street.jpg' },
params: { mode: 'plane', fit: 'frustum', fitDist: 12 },
transform: { pos: [0, 3, -9], rot: [0, 0, 0], scale: 1 }, tracks: {},
});
syScene.entities.push({ // set dressing: no keys, ever
id: 'crate', kind: 'prop', label: 'crate',
source: { type: 'assets', path: 'props/crate.glb' },
params: {}, transform: { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, tracks: {},
});
const syStage = new StageStub();
const tlSy = new Timeline(syStage);
await tlSy.applyScene(syScene);
const plateBefore = structuredClone(tlSy.scene.entities.find((e) => e.id === 'plate').transform);
syStage.setTransform('crate', { pos: [-3, 0, 1], rot: [0, 0, 0], scale: 1 }); // gizmo drag
syStage.setParam('cam1', 'fov', 28); // inspector edit
syStage.setParam('plate', 'fitDist', 20);
syStage.setTransform('plate', { pos: [0, 99, -99], rot: [0, 0, 0], scale: 4 }); // the LENS did this
const synced = tlSy.syncFromStage();
assert.equal(synced, 6, 'every entity on the stage is merged home');
const outSy = tlSy.toJSON();
assert.deepEqual(outSy.entities.find((e) => e.id === 'crate').transform.pos, [-3, 0, 1],
'the gizmo pose of an UNKEYED entity is what gets saved');
assert.equal(outSy.entities.find((e) => e.id === 'cam1').params.fov, 28, 'and the inspector fov');
const e1Sy = outSy.entities.find((e) => e.id === 'e1');
assert.equal(e1Sy.tracks.transform.length, 2, 'the entity keeps BOTH its original keys');
assert.deepEqual(e1Sy.tracks.transform.map((k) => k.t), [0, 2], 'exactly where they were');
assert.deepEqual(e1Sy.tracks.clips[0].path, 'animations/walk.fbx', 'and its clip block');
const plateSy = outSy.entities.find((e) => e.id === 'plate');
assert.equal(plateSy.params.fitDist, 20, 'a frustum plate DOES take its params');
assert.deepEqual(plateSy.transform, plateBefore,
'but NOT its transform — that wrapper is owned by the lens (stage.js `setTransform`), not the user');
// an entity that is on the stage but unknown to the model is ignored here — it
// arrives via _mirror, and syncFromStage must never add or remove anything.
const strayAt = tlSy.scene.entities.findIndex((e) => e.id === 'plate');
const stray = tlSy.scene.entities.splice(strayAt, 1)[0];
assert.equal(tlSy.syncFromStage(), 5, 'the unknown stage entity is skipped, not adopted');
assert.equal(tlSy.scene.entities.length, 5, 'and nothing was added');
tlSy.scene.entities.splice(strayAt, 0, stray);
// keys still win at evaluate time, so writing the current pose home is lossless
tlSy.seek(1);
const roundTrip = new Timeline(new StageStub());
roundTrip.load(tlSy.toJSON());
assert.deepEqual(roundTrip.toJSON(), tlSy.toJSON(), 'a synced scene still round-trips losslessly');
// REVIEW-FIX (blocker): a save must NOT depend on where the playhead is.
// Stage.captureState() reports entityTransform(id) — the pose the TIMELINE just
// wrote via setTransform on the last evaluate — so syncing a KEYED channel copied
// the timeline's own output back over the authored rest pose (PLAN §4.1: "rest
// pose (used when no keys)"). Old behaviour: scrub to 2 → save → e1.transform.pos
// becomes [10,0,0]; scrub to 1 → save → [5,0,0]. Same scene, two saves, two files,
// and deleting the keys left the character somewhere nobody authored.
const pdStage = new StageStub();
const tlPD = new Timeline(pdStage);
await tlPD.applyScene(structuredClone(syScene));
const authoredPos = structuredClone(tlPD.scene.entities.find((e) => e.id === 'e1').transform.pos);
const authoredSun = tlPD.scene.entities.find((e) => e.id === 'e2').params.intensity;
tlPD.seek(2); tlPD.syncFromStage();
const atEnd = structuredClone(tlPD.toJSON());
tlPD.seek(1); tlPD.syncFromStage();
const atMid = tlPD.toJSON();
assert.deepEqual(atMid.entities.find((e) => e.id === 'e1').transform.pos, authoredPos,
'a keyed entity keeps the AUTHORED rest pose, whatever the playhead shows');
assert.equal(atMid.entities.find((e) => e.id === 'e2').params.intensity, authoredSun,
'and a keyed param keeps its authored rest value (the live value is this ticks lerp)');
assert.deepEqual(atMid, atEnd, 'two saves of the same scene at different playhead times are byte-identical');
// per CHANNEL, not per entity: the sun's intensity is keyed, its colour is not.
pdStage.setParam('e2', 'color', '#ff8800');
pdStage.setParam('e2', 'intensity', 999); // as if the inspector fought the key
tlPD.syncFromStage();
const sunPD = tlPD.toJSON().entities.find((e) => e.id === 'e2');
assert.equal(sunPD.params.color, '#ff8800', 'an UNKEYED param on a keyed entity still syncs');
assert.equal(sunPD.params.intensity, authoredSun, 'the keyed one does not');
// and the unkeyed neighbours are still synced on the very same pass
assert.deepEqual(tlPD.toJSON().entities.find((e) => e.id === 'crate').transform.pos, [0, 0, 0],
'an untouched unkeyed prop keeps its own pose');
// ---- SAVE/LOAD stop lying: the two pure seams -------------------------------
assert.deepEqual(uploadEntities({ entities: [
{ id: 'a', label: 'lady', source: { type: 'upload' } },
{ id: 'b', label: 'street', source: { type: 'upload' } },
{ id: 'c', label: 'cam', source: { type: 'none' } },
{ id: 'd', label: 'crate', source: { type: 'assets', path: 'props/crate.glb' } },
] }), ['lady', 'street'], 'both dropped files are found, by label');
assert.deepEqual(uploadEntities(scene), [], 'a clean scene warns about nothing');
assert.deepEqual(uploadEntities(null), [], 'and a missing scene does not throw');
assert.deepEqual(uploadEntities({ entities: [{ id: 'x', source: { type: 'upload' } }] }), ['x'],
'an unlabelled upload falls back to its id');
const failMsg = saveMessage({ ok: false, status: 422, errors: ['cameraCut references non-camera entity e2'] });
assert.ok(failMsg.includes('NOT saved'), 'a refused save says NOT saved');
assert.ok(failMsg.includes('cameraCut references non-camera entity e2'), 'and quotes the server verbatim');
assert.ok(saveMessage({ ok: false, status: 422, errors: ['a', 'b'] }).includes('a · b'), 'multiple errors are joined');
assert.ok(saveMessage({ ok: false, status: 500 }).includes('server said 500'), 'a bodyless failure still names the status');
const netMsg = saveMessage({ ok: false, name: 'x' }); // no status = the fetch itself threw
assert.ok(netMsg.includes('this browser only'), 'only a REAL network failure claims a local copy');
assert.ok(!netMsg.includes('NOT saved'), 'and it is not reported as a refusal');
const okMsg = saveMessage({ ok: true, name: 'street-scene', count: 7 });
assert.ok(okMsg.includes('saved') && okMsg.includes('street-scene') && okMsg.includes('7'),
'a good save names the scene and the entity count');
assert.ok(saveMessage({ ok: true, name: 'x', count: 1 }).includes('1 entity'), 'and counts in English');
// ---- the Cameras lane: replace, not stack; undo-able; live at a cut ---------
const cutStage = new StageStub();
const tlCut = new Timeline(cutStage);
tlCut.load({ version: 1, fps: 30, duration: 10, cameraCuts: [], audio: [],
entities: [{ id: 'c1', kind: 'camera', label: 'wide', tracks: {} },
{ id: 'c2', kind: 'camera', label: 'tight', tracks: {} }] });
tlCut.addCut(0, 'c1');
tlCut.addCut(0.01, 'c2'); // 0.3 frames later = the same instant
assert.equal(tlCut.cameraCuts.length, 1, 'a cut within half a frame REPLACES rather than stacking');
assert.equal(tlCut.cameraCuts[0].camera, 'c2', 'the replacement wins');
assert.equal(tlCut.cameraCuts[0].t, 0.01, 'at its own time');
tlCut.undo();
assert.deepEqual(tlCut.cameraCuts, [{ t: 0, camera: 'c1' }], 'ONE undo puts the replaced cut back');
tlCut.addCut(1.0, 'c2');
assert.equal(tlCut.cameraCuts.length, 2, 'a cut a whole second later appends normally');
const goneCut = tlCut.cameraCuts[1];
tlCut.removeCut(goneCut);
assert.equal(tlCut.cameraCuts.length, 1, 'removeCut removes');
tlCut.undo();
assert.deepEqual(tlCut.cameraCuts, [{ t: 0, camera: 'c1' }, { t: 1, camera: 'c2' }],
'and undo restores it in place (a raw splice used to revert an unrelated edit instead)');
tlCut.moveCut(tlCut.cameraCuts[1], 4);
assert.equal(tlCut.cameraCuts[1].t, 4, 'moveCut moves');
tlCut.undo();
assert.equal(tlCut.cameraCuts[1].t, 1, 'and one undo puts it back');
tlCut.seek(2);
assert.equal(cutStage._active, 'c2', 'the cut is live at t=2');
cutStage.setActiveCamera('c1'); // something changed it behind our back (dock ◉)
tlCut.seek(2.5);
assert.equal(cutStage._active, 'c2', 'a seek re-asserts the cut-derived camera — the cached value cannot desync');
// ---- the names column as an outliner: rowFlags ------------------------------
assert.deepEqual(rowFlags(tlCut.scene.entities[0]), { keyed: false, selectable: true },
'a camera with no tracks is selectable but not keyed');
assert.deepEqual(rowFlags({ id: 'e', tracks: { transform: [{ t: 0 }] } }), { keyed: true, selectable: true },
'one transform key = keyed (the timeline re-applies it on every seek)');
assert.deepEqual(rowFlags({ id: 'e', tracks: { params: [{ t: 0, key: 'fov' }] } }), { keyed: false, selectable: true },
'param keys do NOT drive the transform, so they are not a keyed pose');
assert.deepEqual(rowFlags({ id: 'e', tracks: {} }), { keyed: false, selectable: true }, 'empty tracks are not keyed');
assert.deepEqual(rowFlags(null), { keyed: false, selectable: false },
'the Cameras and audio rows have no entity and never select');
// ---- REVIEW-FIX: one capture loop at a time (⏺ Render vs 🎬 Render film) ----
// Both drive timeline.step() + stage.renderActiveCamera() and both bracket the
// capture with stage.pause()/resume(); run together they interleave frames into
// two render sessions and the first one to finish un-hides the gizmo for the
// REST of the other one's frames. The pure gate…
assert.equal(renderRefusal({}), null, 'idle: nothing refuses a render');
assert.match(renderRefusal({ rendering: true }), /⏺ scene render/, 'a second ⏺ click is refused');
assert.match(renderRefusal({ filmBusy: true }), /🎬 film render/, 'and ⏺ during a film render');
assert.match(renderRefusal({ rendering: true, filmBusy: true }), /⏺ scene render/, 'the nearer one wins the message');
// …and the WIRING, driven headlessly through the prototype with a fake `this`.
// (On the old code both of these reached the point of no return.)
let planned = false, gateToast = null;
await TimelineUI.prototype.render.call(Object.assign(Object.create(TimelineUI.prototype), {
_rendering: false, _filmBusy: true, // 🎬 film render in flight
$rsize: { value: '1280x720' },
_toast: (m) => { gateToast = m; },
_errText: (e) => String(e),
renderPlan() { planned = true; throw new Error('must not get here'); },
}));
assert.equal(planned, false, '⏺ Render never even plans while a film render runs');
assert.match(gateToast, /🎬 film render is running/, 'and says why');
let filmProceeded = false, gateStatus = null;
await TimelineUI.prototype._filmRender.call({
_seq: { shots: [{ scene: 's', in: 0, out: 5 }] }, _filmBusy: false,
_rendering: true, // ⏺ scene render in flight
tl: { hasContent: () => false },
_filmStatus: (m) => { gateStatus = m; return { appendChild() {}, append() {} }; },
async _filmSave() { filmProceeded = true; return null; },
});
assert.equal(filmProceeded, false, '🎬 Render film never saves or renders while ⏺ runs');
assert.match(gateStatus, /⏺ scene render is already running/, 'and says why');
// the scene bar drives the SAME timeline (Save / Load / New… / ▶), so a film
// render has to take it out too — _filmBusySet only disabled the film panel.
const barEls = [{ disabled: false }, { disabled: false }, { disabled: false }];
const busyUi = Object.assign(Object.create(TimelineUI.prototype), {
$bar: { querySelectorAll: () => barEls },
$film: { querySelectorAll: () => [] },
$render: {}, _filmEnable() {},
});
busyUi._filmBusySet(true);
assert.ok(barEls.every((el) => el.disabled), 'a film render disables the whole scene bar');
assert.equal(busyUi._busy(), true, 'and the canvas gate reads busy');
busyUi._filmBusySet(false);
assert.ok(barEls.every((el) => !el.disabled), 'and hands it back when the film is done');
assert.equal(busyUi._busy(), false, 'gate clear');
// ---- REVIEW-FIX: audio is refused BEFORE the frames, not after --------------
// server.py's resolve_audio runs at POST /render/{id}/end — every PNG is already
// uploaded by then. Everything it can refuse without touching the filesystem is
// refused client-side, exactly like the frame cap.
assert.deepEqual(audioProblems([]), [], 'no audio is not a problem');
assert.deepEqual(audioProblems([{ path: 'audio/vo1.wav', start: 0.5, gain: 0.8 }]), [], 'a good clip passes');
assert.deepEqual(audioProblems([{ path: 'a.wav', in: 0, out: 4 }]), [], 'so does a trimmed one');
assert.match(audioProblems([{ start: 0 }])[0], /needs a string path/, 'a pathless clip is caught');
assert.match(audioProblems([{ path: 'a.wav', start: -2 }])[0], /start\/in must be >= 0/, 'negative start');
assert.match(audioProblems([{ path: 'a.wav', in: 4, out: 1 }])[0], /must be less than out/, 'inverted trim');
assert.match(audioProblems([{ path: 'a.wav', gain: 'loud' }])[0], /must be numbers/, 'a non-numeric gain');
assert.equal(audioProblems([{ path: 'a.wav', in: 4, out: 1 }, { path: 'b.wav', start: -1 }]).length, 2,
'every bad clip is named, not just the first');
assert.match(audioProblems([{ path: 'a.wav', start: -1 }])[0], /audio\[0\] \(a\.wav\)/, 'and named by index + path');
// wiring: ⏺ Render refuses before it touches the clock (i.e. before frame 1)
let auToast = null;
await TimelineUI.prototype.render.call(Object.assign(Object.create(TimelineUI.prototype), {
_rendering: false, _filmBusy: false, $rsize: { value: '1280x720' },
_toast: (m) => { auToast = m; }, _errText: (e) => String(e),
renderPlan: () => ({ width: 1280, height: 720, fps: 30, name: 'x', total: 60,
audio: [{ path: 'audio/vo1.wav', in: 4, out: 1 }] }),
_audioMissing: async () => [], // existence needs the server; not this assertion
tl: { pause() { throw new Error('the render must not start'); } },
}));
assert.match(auToast, /must be less than out/, 'a broken audio trim is refused up front, not after 600 PNGs');
// a missing file is refused too — and the refusal hands the timeline back
let mvToast = null;
const mvBar = [{ disabled: false }];
const mvUi = Object.assign(Object.create(TimelineUI.prototype), {
_rendering: false, _filmBusy: false, $rsize: { value: '1280x720' },
$bar: { querySelectorAll: () => mvBar },
$renderBtn: { textContent: '⏺ Render' }, $rstat: { textContent: '' }, draw() {},
_toast: (m) => { mvToast = m; }, _errText: (e) => String(e),
renderPlan: () => ({ width: 1280, height: 720, fps: 30, name: 'x', total: 60, audio: [{ path: 'audio/gone.wav' }] }),
_audioMissing: async () => ['audio/gone.wav'],
tl: { time: 3, seek() {}, pause() { throw new Error('the render must not start'); } },
});
await mvUi.render();
assert.match(mvToast, /audio not found under assets — audio\/gone\.wav/, 'a moved/deleted audio file is named before frame 1');
assert.equal(mvUi._rendering, false, 'and the refusal releases the gate');
assert.equal(mvBar[0].disabled, false, 'and re-arms the scene bar');
// ---- REVIEW-FIX: cutting to a camera from the Cameras row -------------------
// The old gesture cut to stage.activeCamera(), but _evalCameraCuts puts the stage
// back on the EDIT's camera at every seek — so after any scrub it could only ever
// write a no-op cut on top of the camera already live, and with 2+ cameras and no
// beat grid there was NO way to cut to camera 2 from the timeline at all.
const cutsFx = [{ t: 0, camera: 'cam1' }, { t: 8, camera: 'cam2' }];
assert.equal(cutAction(cutsFx, 4, 'cam2', 30).action, 'add', 'a real angle change at 4s is a new cut');
assert.equal(cutAction(cutsFx, 4, 'cam1', 30).action, 'noop', 'cutting to the camera already live does nothing');
assert.equal(cutAction([], 4, 'cam1', 30).action, 'add', 'the first cut in a cutless scene always lands');
assert.equal(cutAction(cutsFx, 8.01, 'cam3', 30).action, 'replace', 'inside half a frame of a cut = replace');
assert.equal(cutAction(cutsFx, 8.01, 'cam3', 30).at, cutsFx[1], 'and it names the cut being replaced');
const redundant = [{ t: 0, camera: 'cam1' }, { t: 5, camera: 'cam2' }, { t: 9, camera: 'cam1' }];
assert.equal(cutAction(redundant, 9, 'cam2', 30).action, 'remove',
'asking for the camera live BEFORE a cut removes that cut instead of stacking a dead one');
assert.equal(cutAction(cutsFx, 9, 'cam2', 30).action, 'noop', 'the same ask with no cut there is simply refused');
assert.equal(cutAction([{ t: 6, camera: 'cam2' }], 3, 'cam1', 30).action, 'add',
'before the first cut the director cam is live, so any camera is a change');
// the whole point, end to end: two cameras, a scrub, and cam2 is still reachable
const pkStage = new StageStub();
const tlPk = new Timeline(pkStage);
tlPk.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
await pkStage.addEntity({ id: 'cam1', kind: 'camera', label: 'cam1', source: { type: 'none' }, params: { fov: 45 } });
pkStage.setActiveCamera('cam1');
await pkStage.addEntity({ id: 'cam2', kind: 'camera', label: 'cam2', source: { type: 'none' }, params: { fov: 45 } });
pkStage.setActiveCamera('cam2'); // dock lights the PiP on the new camera
tlPk.seek(1.0); // …and the edit takes it straight back
assert.equal(pkStage.activeCamera(), 'cam1', 'after a scrub the stage is on the EDITs camera (the old gesture read this)');
const pkToasts = [];
TimelineUI.prototype._cutTo.call({
stage: pkStage, tl: tlPk, _te: (id) => tlPk.scene.entities.find((e) => e.id === id),
_toast: (m) => pkToasts.push(m), draw() {},
}, 4, 'cam2');
assert.deepEqual(tlPk.cameraCuts, [{ t: 0, camera: 'cam1' }, { t: 4, camera: 'cam2' }],
'picking cam2 from the row cuts to cam2 — not to whatever the stage happens to show');
tlPk.seek(4.5);
assert.equal(pkStage.activeCamera(), 'cam2', 'and the cut is live');
TimelineUI.prototype._cutTo.call({
stage: pkStage, tl: tlPk, _te: (id) => tlPk.scene.entities.find((e) => e.id === id),
_toast: (m) => pkToasts.push(m), draw() {},
}, 6, 'cam2');
assert.equal(tlPk.cameraCuts.length, 2, 'asking for the camera already live adds NO dead cut');
assert.match(pkToasts[pkToasts.length - 1], /already live/, 'it says so instead of pretending');
// _cutPick's no-menu branches (one camera / none) need no DOM
const pk1 = new StageStub();
const tlPk1 = new Timeline(pk1);
tlPk1.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
await pk1.addEntity({ id: 'only', kind: 'camera', label: 'only', source: { type: 'none' }, params: {} });
const pk1Ui = Object.assign(Object.create(TimelineUI.prototype),
{ stage: pk1, tl: tlPk1, _te: () => null, _toast: (m) => pkToasts.push(m), draw() {} });
tlPk1.scene.cameraCuts.length = 0; // _mirror's automatic first cut, out of the way
TimelineUI.prototype._cutPick.call(pk1Ui, 3);
assert.deepEqual(tlPk1.cameraCuts, [{ t: 3, camera: 'only' }], 'one camera = no menu, just cut');
const pk0 = new StageStub();
const tlPk0 = new Timeline(pk0);
tlPk0.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
let pk0Toast = null;
TimelineUI.prototype._cutPick.call(Object.assign(Object.create(TimelineUI.prototype),
{ stage: pk0, tl: tlPk0, _toast: (m) => { pk0Toast = m; } }), 3);
assert.equal(tlPk0.cameraCuts.length, 0, 'no cameras = no cut invented');
assert.match(pk0Toast, /no cameras/, 'and it says so');
// ---- REVIEW-FIX: the cut lane's camera label must be visible ----------------
// It was drawn in '#161a20' — the odd lane's own background (contrast 1.00:1),
// #13171d on the even one. The feature was advertised in the log and could not
// be seen on screen. WCAG relative luminance, the same maths a browser devtool
// contrast checker uses.
const _lum = (hex) => {
const v = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2];
};
const contrast = (a, b) => { const [x, y] = [_lum(a), _lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
assert.equal(contrast('#161a20', '#161a20'), 1, 'sanity: the old label colour on the old lane fill was 1.00:1');
for (const bg of LANE_BG) {
assert.ok(contrast(CUT_COLOR, bg) >= 3,
`the cut label must read against lane fill ${bg} (got ${contrast(CUT_COLOR, bg).toFixed(2)}:1)`);
}
// ---- REVIEW-FIX: a dragged cut settles, and a drag is ONE undo entry --------
const dcStage = new StageStub();
const tlDC = new Timeline(dcStage);
tlDC.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
const cutA = tlDC.addCut(2, 'cam1');
const cutB = tlDC.addCut(6, 'cam2');
const dcMark = tlDC.undoStack.length;
for (let i = 0; i < 50; i++) tlDC.moveCut(cutB, 6 - (i * (4 / 49))); // one mousemove each
assert.equal(tlDC.undoStack.length - dcMark, 50, 'a drag still records every step while it is happening');
assert.equal(tlDC.settleCut(cutB), 1, 'dropping a cut on another absorbs it (addCuts half-frame rule)');
assert.equal(tlDC.cameraCuts.length, 1, 'so the lane never holds two cuts one frame apart');
assert.equal(tlDC.cameraCuts[0], cutB, 'the one you dragged is the one that survives');
tlDC.collapseUndo(dcMark);
assert.equal(tlDC.undoStack.length, dcMark + 1, 'and mouseup collapses the whole drag into ONE undo entry');
tlDC.undo();
assert.equal(cutB.t, 6, 'one Ctrl+Z reverts the DRAG, not one mouse-frame of it');
assert.deepEqual(tlDC.cameraCuts, [cutA, cutB], 'and the absorbed cut comes back with it');
assert.equal(tlDC.collapseUndo(tlDC.undoStack.length), 0, 'collapsing nothing costs nothing');
const oneMark = tlDC.undoStack.length;
tlDC.addCut(9, 'cam2');
tlDC.collapseUndo(oneMark);
assert.equal(tlDC.undoStack.length, oneMark + 1, 'a single edit is not wrapped twice');
tlDC.undo();
assert.equal(tlDC.cameraCuts.length, 2, 'and still undoes exactly once');
// …and the mouseup handler is what wires both (this is the drag the user does)
const upTl = new Timeline(new StageStub());
upTl.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
const upKeep = upTl.addCut(2, 'cam1');
const upDrag = upTl.addCut(6, 'cam2');
const upBase = upTl.undoStack.length;
const upUi = Object.assign(Object.create(TimelineUI.prototype), {
tl: upTl, _undoMark: upBase, _drag: { kind: 'cut', h: { cut: upDrag } },
_toast() {}, draw() {},
});
for (let i = 0; i < 40; i++) upTl.moveCut(upDrag, 6 - (i * (4 / 39)));
TimelineUI.prototype._up.call(upUi);
assert.equal(upTl.cameraCuts.length, 1, 'mouseup settles the dropped cut onto the one underneath');
assert.equal(upTl.undoStack.length, upBase + 1, 'and the whole drag is ONE undo entry (was ~40)');
upTl.undo();
assert.equal(upDrag.t, 6, 'one Ctrl+Z and the cut is back where the drag began');
assert.deepEqual(upTl.cameraCuts, [upKeep, upDrag], 'with the cut it swallowed restored too');
// ---- REVIEW-FIX: a save reports the name the SERVER used --------------------
// server.py slugifies: `My Scene` is written as my-scene.json and GET /scenes
// lists `my-scene`. The stem comes back in the response body and used to be
// thrown away, so the toast named a file that does not exist.
const svTl = new Timeline(new StageStub());
svTl.load({ version: 1, name: 'x', fps: 30, duration: 5, entities: [], cameraCuts: [], audio: [] });
const svField = { value: 'My Scene' };
let svToast = null;
const svPosts = [];
const realFetch = globalThis.fetch;
// server.py `scene_save` stores the BODY VERBATIM and only slugifies the URL, so
// this fake keeps both and we can look at what actually landed on disk.
globalThis.fetch = async (url, opts) => {
svPosts.push({ url, body: JSON.parse(opts.body) });
return { ok: true, status: 200, async json() { return { ok: true, name: 'my-scene' }; } };
};
await TimelineUI.prototype.save.call(Object.assign(Object.create(TimelineUI.prototype), {
$name: svField, tl: svTl, _toast: (m) => { svToast = m; }, _errText: (e) => String(e),
}));
globalThis.fetch = realFetch;
assert.equal(svPosts[0].url, 'scenes/My%20Scene', 'the raw name is still what gets POSTed');
assert.match(svToast, /saved "my-scene"/, 'but the toast names the file the server actually wrote');
assert.equal(svField.value, 'my-scene', 'and the name field is corrected to match the Load picker');
assert.equal(svTl.scene.name, 'my-scene', 'so the next save writes the same file');
// REVIEW-FIX: the toast was right but the FILE still said "My Scene" — the server
// writes the body as posted — so the next Load put the un-slugged name straight
// back in the bar while the picker went on listing `my-scene`. One corrective
// re-POST, keyed off the stem the server itself reported.
assert.equal(svPosts.length, 2, 'a slugged name is written back with one corrective POST');
assert.equal(svPosts[1].url, 'scenes/my-scene', 'to the stem the server reported');
assert.equal(svPosts[1].body.name, 'my-scene', 'and the stored scene now agrees with its own filename');
// …and a name that needed no slugging costs exactly one request
const svTl2 = new Timeline(new StageStub());
svTl2.load({ version: 1, name: 'x', fps: 30, duration: 5, entities: [], cameraCuts: [], audio: [] });
svPosts.length = 0;
globalThis.fetch = async (url, opts) => {
svPosts.push({ url, body: JSON.parse(opts.body) });
return { ok: true, status: 200, async json() { return { ok: true, name: 'already-slug' }; } };
};
await TimelineUI.prototype.save.call(Object.assign(Object.create(TimelineUI.prototype), {
$name: { value: 'already-slug' }, tl: svTl2, _toast() {}, _errText: (e) => String(e),
}));
globalThis.fetch = realFetch;
assert.equal(svPosts.length, 1, 'a name the server did not change is saved with a single POST');
// ---- REVIEW-FIX: the audio existence check must use a method the server ANSWERS
// It sent HEAD, which made the whole pre-flight inert: FastAPI's APIRoute
// registers only the methods you decorate (starlette's own Route adds HEAD to a
// GET route — APIRoute does not), so `@app.get("/assets/file")` answers every
// HEAD with 405 and `status === 404` could never be true. Measured against a real
// uvicorn on this repo: HEAD → 405 `allow: GET`; GET → 404; GET with
// `Range: bytes=0-0` → 206 + content-length 1. This fake answers the same way, so
// a check that talks to the server wrongly cannot pass here either.
const amSeen = [];
const fastapiish = async (url, opts = {}) => {
const method = (opts.method || 'GET').toUpperCase();
const p = decodeURIComponent(String(url).split('path=')[1] || '');
const range = (opts.headers || {}).Range || (opts.headers || {}).range || null;
amSeen.push({ method, path: p, range });
if (method !== 'GET') return { status: 405, body: null }; // FastAPI: allow: GET
if (p !== 'audio/here.wav') return { status: 404, body: null };
let cancelled = false;
return { status: range ? 206 : 200, body: { cancel: async () => { cancelled = true; return cancelled; } } };
};
const amFetch = globalThis.fetch;
globalThis.fetch = fastapiish;
const amMissing = await TimelineUI.prototype._audioMissing.call({},
[{ path: 'audio/here.wav' }, { path: 'audio/gone.wav' }, { path: 'audio/gone.wav' }, { path: '' }, null]);
globalThis.fetch = amFetch;
assert.deepEqual(amMissing, ['audio/gone.wav'], 'a moved/deleted audio file is actually DETECTED (a HEAD-based check never could)');
assert.ok(amSeen.length > 0, 'the check talks to the server at all');
assert.ok(amSeen.every((r) => r.method === 'GET'),
`every request uses a method the route answers — HEAD is 405 on every FastAPI GET route (saw ${amSeen.map((r) => r.method).join(',')})`);
assert.ok(amSeen.every((r) => r.range === 'bytes=0-0'), 'and asks for ONE byte, not the whole stem');
assert.equal(amSeen.length, 2, 'one request per DISTINCT path, blanks skipped');
// fails OPEN — a wobbling network must never block a render the server would take
globalThis.fetch = async () => { throw new Error('offline'); };
assert.deepEqual(await TimelineUI.prototype._audioMissing.call({}, [{ path: 'audio/x.wav' }]), [],
'a network failure is not a missing file — the server still gets the last word');
globalThis.fetch = amFetch;
// ---- REVIEW-FIX: ⏺ Render and 🎬 Render film really are mutually exclusive ----
// The film gate used to be claimed only AFTER `await this._filmSave()`, so during
// that round-trip both flags were false, the scene bar was live, and a ⏺ Render
// click walked straight through renderRefusal. Two capture loops then stepped the
// same clock and the first `finally` re-armed the bar under the other one.
{
const log = [];
let releaseSave;
const savePending = new Promise((r) => { releaseSave = r; });
const bar = [{ disabled: false }];
const ui = Object.assign(Object.create(TimelineUI.prototype), {
_rendering: false, _filmBusy: false,
_seq: { shots: [{ scene: 's1', in: 0, out: 5 }] }, _seqName: 'seq',
$size: { value: '1280x720' }, $rsize: { value: '1280x720' },
$bar: { querySelectorAll: () => bar }, $film: { querySelectorAll: () => [] },
$render: {}, $renderBtn: { textContent: '⏺ Render' }, $rstat: { textContent: '', appendChild() {} },
_filmEnable() {},
_filmStatus(m) { log.push('film-status: ' + m); return { appendChild() {}, append() {} }; },
_toast(m) { log.push('toast: ' + m); }, _errText: (e) => String(e),
_syncRows() {}, _refreshBar() {}, draw() {}, _audioStop() {},
async _filmSave() { await savePending; return { name: 'seq' }; },
async _filmMod() { return { async renderSequence() { log.push('FILM CAPTURE'); return 'films/x.mp4'; } }; },
renderPlan: () => ({ width: 1280, height: 720, fps: 30, name: 'x', total: 60, audio: [] }),
async _audioMissing() { return []; },
tl: { time: 0, locked: false, hasContent: () => false, pause() { log.push('SCENE CAPTURE'); }, seek() {} },
});
const oldConfirm = globalThis.confirm;
globalThis.confirm = () => true;
const filmP = ui._filmRender();
await new Promise((r) => setTimeout(r, 0)); // film is parked on its save POST
assert.equal(ui._filmBusy, true, 'the film claims the gate BEFORE its first await, not after');
assert.equal(bar[0].disabled, true, 'and the scene bar is dead for the whole thing, not just the capture');
assert.equal(ui.tl.locked, true, 'and the model knows (dock/DIRECT events are outside the bar)');
await ui.render(); // the ⏺ click that used to get through
assert.ok(!log.includes('SCENE CAPTURE'), '⏺ Render cannot start while a film render is in its pre-flight');
assert.match(log.find((l) => l.startsWith('toast:')) || '', /film render is running/, 'and it says why');
releaseSave();
await filmP;
globalThis.confirm = oldConfirm;
assert.ok(log.includes('FILM CAPTURE'), 'the film render itself still runs');
assert.equal(ui._filmBusy, false, 'and hands the gate back when it is done');
assert.equal(bar[0].disabled, false, 'bar re-armed');
assert.equal(ui.tl.locked, false, 'model unlocked');
}
// the same gate the other way round, and released by a FINALLY: a film save that
// fails must not leave the app permanently "rendering" with a dead scene bar.
{
const bar = [{ disabled: false }];
const ui = Object.assign(Object.create(TimelineUI.prototype), {
_rendering: false, _filmBusy: false, _seq: { shots: [{ scene: 's1' }] }, _seqName: 'seq',
$size: { value: '1280x720' }, $bar: { querySelectorAll: () => bar }, $film: { querySelectorAll: () => [] },
$render: {}, _filmEnable() {}, _filmStatus() { return { appendChild() {}, append() {} }; },
_toast() {}, _errText: (e) => String(e), _syncRows() {}, _refreshBar() {}, draw() {},
async _filmSave() { return null; }, // the save 422'd / the server is down
async _filmMod() { throw new Error('must not get this far'); },
tl: { locked: false, hasContent: () => false },
});
await ui._filmRender();
assert.equal(ui._filmBusy, false, 'a failed film save releases the gate (finally, not a hand-written reset)');
assert.equal(bar[0].disabled, false, 'and the scene bar comes back');
assert.equal(ui.tl.locked, false, 'and the model is unlocked again');
ui._rendering = true; // now a ⏺ render owns it
ui._seq = { shots: [{ scene: 's1' }] };
let why = null;
ui._filmStatus = (m) => { why = m; return { appendChild() {}, append() {} }; };
await ui._filmRender();
assert.match(why, /scene render is already running/, '🎬 Render film refuses while ⏺ Render owns the clock');
assert.equal(ui._filmBusy, false, 'and does not claim the gate on the way out');
}
// ---- REVIEW-FIX: a scene edit dispatched from OUTSIDE the scene bar ----------
// _barBusySet can only disable controls inside $bar; Lane A's DIRECT panel and the
// dock dispatch window events straight at timeline.js, so a shot preset clicked
// mid-capture keyed the playhead the frame stepper was walking.
{
const oldWindow = globalThis.window;
const said = [];
globalThis.window = {
innerWidth: 1200, _l: {},
addEventListener(t, f) { (this._l[t] = this._l[t] || []).push(f); },
removeEventListener(t, f) { this._l[t] = (this._l[t] || []).filter((x) => x !== f); },
dispatchEvent(e) { for (const f of (this._l[e.type] || []).slice()) f(e); return true; },
sgToast: (m) => said.push(m),
};
const lkStage = new StageStub();
const lkTl = new Timeline(lkStage); // constructed WITH a window → real listeners
lkTl.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
await lkStage.addEntity({ id: 'cam1', kind: 'camera', label: 'cam1', source: { type: 'none' }, params: { fov: 45 } });
await lkStage.addEntity({ id: 'sub', kind: 'character', label: 'sub', source: { type: 'none' }, params: {} });
lkTl.seek(2);
const cutsBefore = JSON.stringify(lkTl.cameraCuts);
const keysBefore = JSON.stringify(lkTl.scene.entities.map((e) => e.tracks));
said.length = 0; // drop _mirror's "cut at 0s → cam1"
lkTl.locked = true; // …a capture loop is running
globalThis.window.dispatchEvent({ type: 'scenegod:direct',
detail: { op: 'shot', camId: 'cam1', pos: [0, 1, 5], rot: [0, 0, 0], fov: 35, entityIds: ['cam1', 'sub'] } });
globalThis.window.dispatchEvent({ type: 'scenegod:capturekey', detail: { id: 'sub' } });
assert.equal(JSON.stringify(lkTl.cameraCuts), cutsBefore, 'a DIRECT preset cannot cut under a running render');
assert.equal(JSON.stringify(lkTl.scene.entities.map((e) => e.tracks)), keysBefore, 'and cannot key under one either');
assert.equal(said.length, 2, 'both refusals are spoken, not swallowed');
assert.match(said[0], /a render is capturing this timeline/, 'and they say why');
lkTl.locked = false; // render over → the same click works
globalThis.window.dispatchEvent({ type: 'scenegod:capturekey', detail: { id: 'sub' } });
assert.equal(lkTl.scene.entities.find((e) => e.id === 'sub').tracks.transform.length, 1,
'and the gate is only a gate — the op still works once the render is done');
globalThis.window = oldWindow;
}
// ---- REVIEW-FIX: cutAction must not MANUFACTURE the dead cut it prevents -----
// It only inspected the cut BEFORE t. cuts [{0,cam1},{4,cam2}], cut at 0 to cam2:
// `before` is null (nothing precedes 0) → 'replace' → [{0,cam2},{4,cam2}] and the
// 4s cut renders as nothing while reading as an edit — the exact artefact the M10
// amendment asked to be dropped.
{
const cs = [{ t: 0, camera: 'cam1' }, { t: 4, camera: 'cam2' }];
const r = cutAction(cs, 0, 'cam2', 30);
assert.equal(r.action, 'replace', 'replacing the 0s cut with cam2 is still the right action');
assert.equal(r.dead, cs[1], 'but the 4s cut is now dead and is named');
const add = cutAction([{ t: 0, camera: 'cam1' }, { t: 6, camera: 'cam3' }], 3, 'cam3', 30);
assert.equal(add.action, 'add', 'a new cut at 3s to cam3 is a real change');
assert.equal(add.dead && add.dead.t, 6, 'and it makes the 6s cut to the same camera redundant');
const rem = cutAction([{ t: 0, camera: 'cam1' }, { t: 4, camera: 'cam2' }, { t: 8, camera: 'cam1' }], 4, 'cam1', 30);
assert.equal(rem.action, 'remove', 'removing a cut back to the camera already live');
assert.equal(rem.dead && rem.dead.t, 8, 'also strands the next cut to that camera');
assert.equal(cutAction([{ t: 0, camera: 'cam1' }, { t: 4, camera: 'cam1' }], 2, 'cam1', 30).dead, null,
'a cut that was ALREADY dead before this edit is somebody elses mess — left alone');
assert.equal(cutAction([{ t: 0, camera: 'cam1' }, { t: 4, camera: 'cam2' }], 2, 'cam3', 30).dead, null,
'and an unrelated following cut is never touched');
// end to end, through the real gesture, as ONE undo entry
const dcStage2 = new StageStub();
const dcTl = new Timeline(dcStage2);
dcTl.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
await dcStage2.addEntity({ id: 'cam1', kind: 'camera', label: 'cam1', source: { type: 'none' }, params: {} });
await dcStage2.addEntity({ id: 'cam2', kind: 'camera', label: 'cam2', source: { type: 'none' }, params: {} });
dcTl.scene.cameraCuts.length = 0;
dcTl.addCut(0, 'cam1'); dcTl.addCut(4, 'cam2');
const dcBefore = JSON.parse(JSON.stringify(dcTl.cameraCuts));
const dcMark2 = dcTl.undoStack.length;
const dcToasts = [];
TimelineUI.prototype._cutTo.call({
stage: dcStage2, tl: dcTl, _te: (id) => dcTl.scene.entities.find((e) => e.id === id),
_toast: (m) => dcToasts.push(m), draw() {},
}, 0, 'cam2');
assert.deepEqual(dcTl.cameraCuts, [{ t: 0, camera: 'cam2' }],
'cutting to cam2 at 0 does NOT leave a 4s cut to the camera already live');
assert.match(dcToasts[dcToasts.length - 1], /dropped the now-dead cut at 4\.00s/, 'and it says what it dropped');
assert.equal(dcTl.undoStack.length, dcMark2 + 1, 'the whole gesture is ONE undo entry');
dcTl.undo();
assert.deepEqual(dcTl.cameraCuts, dcBefore, 'and one Ctrl+Z puts both cuts back');
}
// ---- REVIEW-FIX: the mousedown half of the one-entry-per-drag fix ------------
// The `_up` test used to hand-set `_undoMark` on the fake `this`, so deleting the
// `_undoMark = this.tl.undoStack.length` line in `_down` left the suite green
// while every drag went back to ~50 undo entries. Do the whole gesture instead.
{
const dgTl = new Timeline(new StageStub());
dgTl.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
const keep = dgTl.addCut(2, 'cam1');
const dragged = dgTl.addCut(6, 'cam2');
const base = dgTl.undoStack.length;
const ui = Object.assign(Object.create(TimelineUI.prototype), {
tl: dgTl, _drag: null, _undoMark: null, _selKeys: [],
_busy: () => false, _local: () => ({ x: 200, y: 120 }), _x2t: (x) => x / 20,
_hitAt: () => ({ kind: 'cut', cut: dragged }), _toast() {}, draw() {},
});
TimelineUI.prototype._down.call(ui, {});
assert.equal(ui._undoMark, base, 'mousedown marks the undo depth');
assert.equal(ui._drag.kind, 'cut', 'and starts the drag');
for (let i = 0; i < 40; i++) dgTl.moveCut(dragged, 6 - (i * (4 / 39)));
TimelineUI.prototype._up.call(ui);
assert.equal(dgTl.undoStack.length, base + 1, 'and the whole down→move→up drag is ONE undo entry');
dgTl.undo();
assert.equal(dragged.t, 6, 'one Ctrl+Z reverts the drag');
assert.deepEqual(dgTl.cameraCuts, [keep, dragged], 'with the absorbed cut restored');
// …and both handlers refuse outright while a render owns the clock
const boom = () => { throw new Error('the canvas must not be read while a render is capturing'); };
const bz = Object.assign(Object.create(TimelineUI.prototype),
{ _busy: () => true, _local: boom, _undoMark: null, _drag: null });
TimelineUI.prototype._down.call(bz, {});
assert.equal(bz._undoMark, null, '_down refuses while busy');
assert.equal(bz._drag, null, 'and starts no drag');
TimelineUI.prototype._dbl.call(bz, {}); // throws if the gate is gone
TimelineUI.prototype._ctx.call(Object.assign(Object.create(TimelineUI.prototype),
{ _busy: () => true, _local: boom }), { preventDefault() {} });
}
// ---- REVIEW-FIX: a keyed param keeps a rest value, and says when an edit dies -
// A keyed param with no authored rest value was DELETED from `params` on every
// save (a keyed camera saved with no fov at all), and an inspector edit to it —
// dock.js `_param` calls syncFromStage directly — vanished with no feedback.
{
const kpStage = new StageStub();
const kpTl = new Timeline(kpStage);
await kpTl.applyScene({ // onto the STAGE too — syncFromStage reads captureState()
version: 1, fps: 30, duration: 10, cameraCuts: [], audio: [],
entities: [{ id: 'cam1', kind: 'camera', label: 'cam1', source: { type: 'none' },
params: {}, transform: { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 },
tracks: { transform: [], params: [{ t: 0, key: 'fov', value: 80, ease: 'linear' },
{ t: 4, key: 'fov', value: 20, ease: 'linear' }], clips: [] } }],
});
kpTl.seek(2); // fov is mid-lerp on the stage right now
kpTl.syncFromStage();
assert.equal(kpTl.toJSON().entities[0].params.fov, 80,
'an unauthored keyed param rests at its FIRST key, not at this ticks lerp and not gone');
kpTl.seek(3.5); kpTl.syncFromStage();
assert.equal(kpTl.toJSON().entities[0].params.fov, 80, 'and the value does not move with the playhead');
// the silent-discard half: an inspector edit to a keyed param
const kpSaid = [];
const oldWin = globalThis.window;
globalThis.window = { sgToast: (m) => kpSaid.push(m), addEventListener() {} };
kpStage.setParam('cam1', 'fov', 12); // exactly what dock.js `_param` does…
kpTl.syncFromStage(); // …followed by exactly this
assert.equal(kpSaid.length, 1, 'an edit to a KEYED param is no longer discarded in silence');
assert.match(kpSaid[0], /fov is keyed/, 'and the message names the param');
assert.equal(kpTl.toJSON().entities[0].params.fov, 80, 'the authored rest value is still what gets saved');
kpSaid.length = 0;
kpTl.seek(1); kpTl.syncFromStage(); // the timelines OWN lerp is not an edit
assert.equal(kpSaid.length, 0, 'and the timeline driving its own keyed param says nothing');
globalThis.window = oldWin;
}
// ---- REVIEW-FIX: the Cameras-row camera picker closes properly --------------
// The menu was a local `const` invisible to `this`, so Escape closed nothing and
// picking a camera left the document mousedown listener (and a detached node)
// registered until the next click anywhere.
{
const oldDoc = globalThis.document, oldWin = globalThis.window;
const mkEl = () => ({
children: [], className: '', textContent: '', style: {}, offsetWidth: 120, parent: null,
appendChild(c) { this.children.push(c); c.parent = this; return c; },
remove() { if (this.parent) this.parent.children = this.parent.children.filter((x) => x !== this); this.gone = true; },
contains(n) { return n === this || this.children.some((c) => c.contains && c.contains(n)); },
});
const docL = {};
globalThis.document = {
createElement: mkEl, body: mkEl(),
addEventListener(t, f) { (docL[t] = docL[t] || []).push(f); },
removeEventListener(t, f) { docL[t] = (docL[t] || []).filter((x) => x !== f); },
};
globalThis.window = { innerWidth: 1200, addEventListener() {} };
const pkS = new StageStub();
const pkT = new Timeline(pkS);
pkT.load({ version: 1, fps: 30, duration: 20, entities: [], cameraCuts: [], audio: [] });
await pkS.addEntity({ id: 'cam1', kind: 'camera', label: 'cam1', source: { type: 'none' }, params: {} });
await pkS.addEntity({ id: 'cam2', kind: 'camera', label: 'cam2', source: { type: 'none' }, params: {} });
pkT.scene.cameraCuts.length = 0;
const ui = Object.assign(Object.create(TimelineUI.prototype), {
stage: pkS, tl: pkT, _pickMenu: null, _pickAway: null, _busy: () => false,
_te: (id) => pkT.scene.entities.find((e) => e.id === id),
_toast() {}, draw() {}, _helpClose() {}, _cutMenuClose() {},
});
TimelineUI.prototype._cutPick.call(ui, 3, { clientX: 10, clientY: 10 });
await new Promise((r) => setTimeout(r, 1));
assert.ok(ui._pickMenu, 'the picker is tracked on `this` (Escape can find it)');
assert.equal((docL.mousedown || []).length, 1, 'one away-click listener while it is open');
TimelineUI.prototype._key.call(ui, { key: 'Escape', target: null });
assert.equal(ui._pickMenu, null, 'Escape closes the picker, like every other overlay in this file');
assert.equal((docL.mousedown || []).length, 0, 'and takes its listener with it');
// and picking a camera cleans up too (this is the leak that survived every click)
TimelineUI.prototype._cutPick.call(ui, 3, { clientX: 10, clientY: 10 });
await new Promise((r) => setTimeout(r, 1));
const items = ui._pickMenu.children.filter((c) => c.className === 'item');
assert.equal(items.length, 2, 'both cameras are offered');
items[1].onclick();
assert.equal(ui._pickMenu, null, 'choosing a camera closes the menu');
assert.equal((docL.mousedown || []).length, 0, 'and unregisters the document listener');
assert.deepEqual(pkT.cameraCuts, [{ t: 3, camera: 'cam2' }], 'and the cut it was opened for lands');
globalThis.document = oldDoc; globalThis.window = oldWin;
}
// ---- round-3 orchestrator: a saved scene NEVER carries a dangling camera cut ----
// Two real histories found by adversarial review, both of which used to produce a
// scene the server 422s on. The point is that neither is reachable-by-luck: any
// undo entry older than the delete can put a cut back for a camera that is gone,
// so the invariant is enforced at serialization, not by policing history.
{
const st = new StageStub();
const tl = new Timeline(st);
const cam = (id) => ({ id, kind: 'camera', label: id, source: { type: 'none' },
params: { fov: 40 }, transform: { pos: [0, 1, 4], rot: [0, 0, 0], scale: 1 },
tracks: { transform: [], params: [], clips: [] } });
// (A) an older removeCut is undone after the camera is deleted
tl.load({ version: 1, name: 'a', fps: 30, duration: 10, entities: [cam('e1')],
cameraCuts: [{ t: 0, camera: 'e1' }, { t: 5, camera: 'e1' }], audio: [] });
tl.removeCut(tl.scene.cameraCuts[1]); // ordinary edit, undoable
tl.scene.entities = []; // camera deleted (Stage side has no undo)
tl.scene.cameraCuts = []; // dock's dropCuts cleared what it could see
tl.undo(); // ONE Ctrl+Z resurrects the t=5 cut
assert.ok(tl.scene.cameraCuts.some((c) => c.camera === 'e1'),
'precondition: undo really does put a cut back for the deleted camera');
assert.deepEqual(tl.toJSON().cameraCuts, [],
'A: a resurrected cut for a deleted camera never reaches the saved scene');
// (B) an addCut that REPLACED an earlier cut restores it on undo
const tl2 = new Timeline(new StageStub());
tl2.load({ version: 1, name: 'b', fps: 30, duration: 10, entities: [cam('e1'), cam('e2')],
cameraCuts: [{ t: 0, camera: 'e1' }], audio: [] });
tl2.addCut(0, 'e2'); // replaces e1's cut, remembers it as prev
tl2.scene.entities = tl2.scene.entities.filter((e) => e.id !== 'e1'); // delete e1
tl2.undo(); // restores prev → a cut naming the dead e1
assert.ok(tl2.scene.cameraCuts.some((c) => c.camera === 'e1'), 'precondition: prev came back');
assert.deepEqual(tl2.toJSON().cameraCuts.filter((c) => c.camera === 'e1'), [],
'B: a restored prev cut for a deleted camera never reaches the saved scene');
// and playback must not chase the dead id either
assert.equal(tl2.cutCameraAt(9), tl2.scene.cameraCuts.some((c) => c.camera === 'e2') ? 'e2' : null,
'cutCameraAt skips cuts whose camera is gone');
assert.deepEqual(tl2.danglingCuts().map((c) => c.camera), ['e1'], 'danglingCuts reports them');
}
console.log('OK — timeline_test.mjs: all assertions passed');