SCENEGOD/scenegod/web/timeline_test.mjs
type-two 915f653525 [m9-B] snap-to-beat + FILM shot-list panel
Beat grid consumed from Lane C's /beats: one top-level scene.beatGrid (beats
kept in FILE seconds, effective offset re-derived from the audio clip so
dragging the audio drags the grid and head-trim leaves it put). beat and bar
join the snap selector, so keys, clip and audio blocks, both trim edges, cuts
and the playhead all became musical for free. Grid drawn faint on beats and
brighter on downbeats, suppressed below ~7px spacing. Low confidence still
snaps but says so. Tempo extrapolates past the analysed range.

FILM panel mounts inside #timeline: sequence CRUD, editable shot rows (scene
dropdown, in/out, cut|dissolve + seconds, reorder, delete), live runtime, size
picker, and Render film with per-shot/per-frame progress - gated behind
hasContent() + a confirm that says plainly every shot replaces the open scene,
and locked while running so it cannot be started twice. film.js imported
lazily so its absence cannot stop the timeline mounting.

Verified live: grid 128.02 BPM/35 beats off house128.wav, snapped positions
8-10ms from the true 0.468735s grid across the whole file, bar snap hits a
downbeat, grid survives toJSON->load; FILM lists both sequences with the
server's 11.50s runtime and 3 editable rows.

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

499 lines
30 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 } 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');
console.log('OK — timeline_test.mjs: all assertions passed');