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