SCENEGOD/scenegod/web/timeline_test.mjs
type-two ba29d0c6d6 [laneB] M4-B model: audio mutators, morphs track eval, importRhubarb
- timeline.js: morphs track kind evaluated via stage.setMorph (lerp per viseme
  name), audio mutators addAudio/moveAudio/setAudioGain/removeAudio (undo-able),
  importRhubarb(id, json, t0) mapping mouthCues → morph keys (per-cue ramp),
  load() ensures scene.audio[], addKey keyed by (t,name) for morphs too.
- stagestub.js: setMorph (records) + visemeTargets seam.
- timeline_test.mjs: morph lerp, rhubarb keys, audio mutators + undo round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:13:35 +10:00

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