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>
1711 lines
108 KiB
JavaScript
1711 lines
108 KiB
JavaScript
// 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 tick’s 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 EDIT’s 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 (addCut’s 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 else’s 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 tick’s 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 timeline’s 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');
|