SCENEGOD/scenegod/web/timeline.js
type-two 1f8ad13ed6 [sync1] wire TimelineUI to real Stage, null-mixer guard, M2 unlock + orchestrator updates
- index.html: mount TimelineUI, setMixerAuto(false) when timeline present
- timeline.js: _evalClips guards no-blocks / null-mixer (real Stage returns
  null for non-characters; the stub never did)
- tldev.html removed; stagestub.js kept as the node-test fixture
- lane files: M1 accepted for all three, answers to open questions, M2
  priorities (A: clipdrop event, PiP; B: row/scene-bar refresh; C: straight
  into M3 render pipeline)
- SCENEGOD launch.json (informational; preview uses MESHGOD's entry)

SYNC 1 verified in-browser: character+backdrop+retargeted clip scene loads,
scrub interpolates frame-exact, scene save/load round-trips via server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:36:16 +10:00

317 lines
13 KiB
JavaScript

// timeline.js — SCENEGOD master clock, tracks, keyframe evaluation (Lane B).
// Talks to Stage ONLY through the PLAN §4.2 API. NO DOM, NO three.js import,
// so it runs headless under `node` for timeline_test.mjs. All the vector /
// quaternion math it needs is inlined below (a few lines each).
// ---- tiny math (self-contained; euler XYZ radians) --------------------------
const lerp = (a, b, u) => a + (b - a) * u;
const lerpArr = (a, b, u) => a.map((v, i) => lerp(v, b[i], u));
function eulerToQuat([x, y, z]) { // XYZ order, matches three
const c1 = Math.cos(x / 2), s1 = Math.sin(x / 2);
const c2 = Math.cos(y / 2), s2 = Math.sin(y / 2);
const c3 = Math.cos(z / 2), s3 = Math.sin(z / 2);
return [
s1 * c2 * c3 + c1 * s2 * s3,
c1 * s2 * c3 - s1 * c2 * s3,
c1 * c2 * s3 + s1 * s2 * c3,
c1 * c2 * c3 - s1 * s2 * s3,
];
}
function quatToEuler([x, y, z, w]) { // XYZ order
const m11 = 1 - 2 * (y * y + z * z), m12 = 2 * (x * y - z * w), m13 = 2 * (x * z + y * w);
const m22 = 1 - 2 * (x * x + z * z), m23 = 2 * (y * z - x * w);
const m33 = 1 - 2 * (x * x + y * y);
const ey = Math.asin(Math.max(-1, Math.min(1, m13)));
let ex, ez;
if (Math.abs(m13) < 0.9999999) { ex = Math.atan2(-m23, m33); ez = Math.atan2(-m12, m11); }
else { ex = Math.atan2(m22 !== undefined ? 2 * (y * z + x * w) : 0, m22); ez = 0; }
return [ex, ey, ez];
}
function slerp(a, b, u) { // quaternion slerp, sign-safe
let [ax, ay, az, aw] = a, [bx, by, bz, bw] = b;
let cos = ax * bx + ay * by + az * bz + aw * bw;
if (cos < 0) { bx = -bx; by = -by; bz = -bz; bw = -bw; cos = -cos; }
if (cos > 0.9995) { // nearly parallel → nlerp
const q = [lerp(ax, bx, u), lerp(ay, by, u), lerp(az, bz, u), lerp(aw, bw, u)];
const n = Math.hypot(...q) || 1;
return q.map((v) => v / n);
}
const t = Math.acos(cos), s = Math.sin(t);
const wa = Math.sin((1 - u) * t) / s, wb = Math.sin(u * t) / s;
return [ax * wa + bx * wb, ay * wa + by * wb, az * wa + bz * wb, aw * wa + bw * wb];
}
function ease(u, kind) {
switch (kind) {
case 'step': return 0; // hold start value across segment
case 'in': return u * u * u;
case 'out': return 1 - Math.pow(1 - u, 3);
case 'inout': return u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
case 'linear': default: return u;
}
}
// color helpers for param track (sRGB lerp is fine for v1 per B2)
const isColor = (v) => typeof v === 'string' && v[0] === '#';
function hexToRgb(h) { const n = parseInt(h.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
function rgbToHex([r, g, b]) { return '#' + [r, g, b].map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join(''); }
// find bracketing keys in a t-sorted array. ponytail: linear scan — key arrays
// are tiny (handfuls); swap for binary search only if a lane ever profiles hot.
function bracket(keys, t) {
if (keys.length === 0) return null;
if (t <= keys[0].t) return [keys[0], keys[0], 0];
const last = keys[keys.length - 1];
if (t >= last.t) return [last, last, 0];
for (let i = 0; i < keys.length - 1; i++) {
if (t >= keys[i].t && t < keys[i + 1].t) {
const span = keys[i + 1].t - keys[i].t;
return [keys[i], keys[i + 1], span > 0 ? (t - keys[i].t) / span : 0];
}
}
return [last, last, 0];
}
// ---- Timeline ---------------------------------------------------------------
export class Timeline {
constructor(stage) {
this.stage = stage;
this.scene = { version: 1, name: 'untitled', fps: 30, duration: 10, entities: [], cameraCuts: [], audio: [] };
this.time = 0;
this.playing = false;
this._tickCbs = [];
this._raf = null;
this._last = 0;
this._qcache = new WeakMap(); // key obj -> cached quat (keeps keys pure for round-trip)
this._clipActions = new Map(); // block obj -> {action, clip}
this._activeCam = undefined; // last camera pushed (avoid re-spamming setActiveCamera)
this.undoStack = [];
if (typeof window !== 'undefined') {
window.addEventListener('scenegod:capturekey', (e) => {
const id = e.detail && e.detail.id;
if (id) this.addKey(id, 'transform', { t: this.time, ...this.stage.entityTransform(id), ease: 'inout' });
});
}
}
get fps() { return this.scene.fps; }
get duration() { return this.scene.duration; }
get entities() { return this.scene.entities; }
get cameraCuts() { return this.scene.cameraCuts; }
// ---- load / save (lossless round-trip) ----
load(sceneJson) {
this.scene = structuredClone(sceneJson);
if (this.scene.fps == null) this.scene.fps = 30;
if (this.scene.duration == null) this.scene.duration = 10;
this.scene.entities = this.scene.entities || [];
this.scene.cameraCuts = this.scene.cameraCuts || [];
for (const e of this.scene.entities) this._sortTracks(e);
this.scene.cameraCuts.sort((a, b) => a.t - b.t);
this._clipActions.clear();
this._activeCam = undefined;
this.seek(0);
return this;
}
toJSON() { return structuredClone(this.scene); }
_sortTracks(e) {
const tr = e.tracks;
if (!tr) return;
if (tr.transform) tr.transform.sort((a, b) => a.t - b.t);
if (tr.params) tr.params.sort((a, b) => a.t - b.t);
if (tr.clips) tr.clips.sort((a, b) => a.start - b.start);
}
_quat(key) {
let q = this._qcache.get(key);
if (!q) { q = eulerToQuat(key.rot || [0, 0, 0]); this._qcache.set(key, q); }
return q;
}
// ---- clock ----
onTick(cb) { this._tickCbs.push(cb); }
play() {
if (this.playing || typeof requestAnimationFrame === 'undefined') return;
this.playing = true;
this._last = performance.now();
const loop = (now) => {
if (!this.playing) return;
const dt = (now - this._last) / 1000;
this._last = now;
let t = this.time + dt;
if (t >= this.duration) { t = this.duration; this.playing = false; }
this.seek(t);
if (this.playing) this._raf = requestAnimationFrame(loop);
};
this._raf = requestAnimationFrame(loop);
}
pause() { this.playing = false; if (this._raf) cancelAnimationFrame(this._raf); this._raf = null; }
seek(t) {
this.time = Math.max(0, Math.min(this.duration, t));
this.evaluate(this.time);
for (const cb of this._tickCbs) cb(this.time);
}
step(frame) { this.time = Math.max(0, Math.min(this.duration, frame / this.fps)); this.evaluate(this.time); return this.time; }
// ---- clip preload (async fetch/retarget once; evaluate stays sync) ----
async preload() {
for (const e of this.entities) {
const clips = e.tracks && e.tracks.clips;
if (!clips) continue;
const mixer = this.stage.entityMixer(e.id);
for (const b of clips) {
if (this._clipActions.has(b)) continue;
const clip = await this.stage.prepareClip(e.id, b.path, b.clipIndex || 0);
const action = mixer.clipAction(clip);
action.play(); action.paused = true; action.enabled = false; action.weight = 0;
this._clipActions.set(b, { action, clip });
}
}
}
// ---- evaluation ----
evaluate(t) {
for (const e of this.entities) {
if (e.kind === 'camera' || e.tracks) this._evalTransform(e, t);
this._evalParams(e, t);
if (e.tracks && e.tracks.clips) this._evalClips(e, t);
}
this._evalCameraCuts(t);
}
_evalTransform(e, t) {
const keys = e.tracks && e.tracks.transform;
if (!keys || keys.length === 0) return; // 0 keys → leave rest pose alone
const br = bracket(keys, t);
const [k0, k1, u] = br;
if (k0 === k1) { this.stage.setTransform(e.id, { pos: k0.pos, rot: k0.rot, scale: k0.scale }); return; }
const eu = ease(u, k0.ease);
const pos = lerpArr(k0.pos, k1.pos, eu);
const scale = typeof k0.scale === 'number'
? lerp(k0.scale, k1.scale, eu)
: lerpArr(k0.scale, k1.scale, eu);
const rot = k0.ease === 'step' ? k0.rot.slice() : quatToEuler(slerp(this._quat(k0), this._quat(k1), eu));
this.stage.setTransform(e.id, { pos, rot, scale });
}
_evalParams(e, t) {
const keys = e.tracks && e.tracks.params;
if (!keys || keys.length === 0) return;
const byKey = new Map();
for (const k of keys) { if (!byKey.has(k.key)) byKey.set(k.key, []); byKey.get(k.key).push(k); }
for (const [name, ks] of byKey) {
const br = bracket(ks, t);
const [k0, k1, u] = br;
let val;
if (k0 === k1) val = k0.value;
else {
const eu = ease(u, k0.ease);
val = isColor(k0.value)
? rgbToHex(lerpArr(hexToRgb(k0.value), hexToRgb(k1.value), eu))
: lerp(k0.value, k1.value, eu);
}
this.stage.setParam(e.id, name, val);
}
}
_evalClips(e, t) {
const blocks = e.tracks.clips;
if (!blocks || !blocks.length) return;
const mixer = this.stage.entityMixer(e.id);
if (!mixer) return; // SYNC1: real Stage returns null for non-characters (stub always had one)
for (let i = 0; i < blocks.length; i++) {
const b = blocks[i];
const rec = this._clipActions.get(b);
if (!rec) continue; // not preloaded yet → silent
const span = (b.out - b.in) || 0.0001;
const loops = b.loop || 1;
const blockEnd = b.start + span * loops;
let w = 0, local = b.in;
if (t >= b.start && t < blockEnd) {
local = b.in + ((t - b.start) % span);
w = 1;
// ponytail: fade-OUT ramp only for M1; cross-block fade-IN is M2 "crossfade polish".
const succ = blocks[i + 1];
if (succ && b.fade > 0 && t >= blockEnd - b.fade) w = Math.max(0, (blockEnd - t) / b.fade);
}
rec.action.time = local;
rec.action.weight = w;
rec.action.enabled = w > 0;
}
mixer.update(0); // set-time style — exact when scrubbing
}
_evalCameraCuts(t) {
const cuts = this.cameraCuts;
let cam = null;
for (const c of cuts) { if (c.t <= t) cam = c.camera; else break; }
if (cam !== this._activeCam) { this._activeCam = cam; this.stage.setActiveCamera(cam); }
}
// ---- keyframe / block mutators (keep sorted, push inverse for undo) ----
_tracks(id) {
const e = this.scene.entities.find((x) => x.id === id);
if (!e) throw new Error(`no entity ${id}`);
e.tracks = e.tracks || {};
return e.tracks;
}
addKey(id, track, key) {
const tr = this._tracks(id);
const arr = (tr[track] = tr[track] || []);
// for transform/param a "key" replaces any existing at same t (+key name)
const same = (k) => k.t === key.t && (track !== 'params' || k.key === key.key);
const prevIdx = arr.findIndex(same);
const prev = prevIdx >= 0 ? arr[prevIdx] : null;
if (prevIdx >= 0) arr.splice(prevIdx, 1);
arr.push(key);
arr.sort((a, b) => a.t - b.t);
this._qcache.delete(key);
this.undoStack.push({ undo: () => { const i = arr.indexOf(key); if (i >= 0) arr.splice(i, 1); if (prev) { arr.push(prev); arr.sort((a, b) => a.t - b.t); } } });
return key;
}
moveKey(id, track, key, newT) {
const oldT = key.t; key.t = newT;
this._tracks(id)[track].sort((a, b) => a.t - b.t);
this.undoStack.push({ undo: () => { key.t = oldT; this._tracks(id)[track].sort((a, b) => a.t - b.t); } });
}
deleteKey(id, track, key) {
const arr = this._tracks(id)[track] || [];
const i = arr.indexOf(key);
if (i < 0) return;
arr.splice(i, 1);
this.undoStack.push({ undo: () => { arr.push(key); arr.sort((a, b) => a.t - b.t); } });
}
addClipBlock(id, block) {
const tr = this._tracks(id);
const arr = (tr.clips = tr.clips || []);
arr.push(block); arr.sort((a, b) => a.start - b.start);
this._clipActions.delete(block);
this.undoStack.push({ undo: () => { const i = arr.indexOf(block); if (i >= 0) arr.splice(i, 1); } });
return block;
}
moveClipBlock(id, block, newStart) {
const old = block.start; block.start = newStart;
this._tracks(id).clips.sort((a, b) => a.start - b.start);
this.undoStack.push({ undo: () => { block.start = old; this._tracks(id).clips.sort((a, b) => a.start - b.start); } });
}
trimClipBlock(id, block, { in: inV, out: outV } = {}) {
const oldIn = block.in, oldOut = block.out;
if (inV != null) block.in = inV;
if (outV != null) block.out = outV;
this.undoStack.push({ undo: () => { block.in = oldIn; block.out = oldOut; } });
}
addCut(t, cameraId) {
const cut = { t, camera: cameraId };
this.scene.cameraCuts.push(cut);
this.scene.cameraCuts.sort((a, b) => a.t - b.t);
this._activeCam = undefined; // force re-eval of active cam
this.undoStack.push({ undo: () => { const i = this.scene.cameraCuts.indexOf(cut); if (i >= 0) this.scene.cameraCuts.splice(i, 1); } });
return cut;
}
undo() { const op = this.undoStack.pop(); if (op) op.undo(); this.evaluate(this.time); }
}
export default Timeline;