SCENEGOD/scenegod/web/timeline_test.mjs
type-two 29694afda5 [m10-B] auto-cut to the beat + align film shots to downbeats
autoCutPlan()/autoCut(): given a beat grid and 2+ cameras, place camera cuts
on grid downbeats at a chosen bar cadence, cycling cameras so the same angle
never lands twice running, with a phase offset so a from-the-playhead run
doesn't open on the camera already live. Cut times come straight out of
gridTimes('bar') so a placed t IS a downbeat rather than a rounded neighbour.
Applied inside group() = one undo; refusals throw rather than half-apply.
UI: a scissors popover with cadence, range, per-camera checkboxes and a live
count that is literally the plan (previewed count == applied count).
FILM panel gains 'align to beat': snaps every shot's in/out to the nearest
downbeat, never reorders, clamps inside the scene, returns collapsing shots
untouched with a skipped list, and always names which grid it used.

Verified on scenes/music-video: 4 cuts at exact downbeats 3.749s apart
(2 bars at 128.02 BPM), cam2->cam3->cam4->cam1, one undo restores exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 11:42:10 +10:00

685 lines
42 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 } });
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: 3, camera: 'cam1' }], 'shot lands a cut to the camera');
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.equal(tlDir.cameraCuts.length, 0, 'undo reverts the cut');
// 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 } = 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 is left alone — and disclosed, because it
// interleaves and can repeat a camera (the popover says so; we don't delete it)
tlAC.addCut(4.5, 'camB');
const pk = tlAC.autoCutPlan({ cadence: 2 });
assert.equal(pk.kept.length, 1, 'an off-grid cut inside the range is reported as kept');
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');
// 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
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');
console.log('OK — timeline_test.mjs: all assertions passed');