diff --git a/scenegod/web/stagestub.js b/scenegod/web/stagestub.js new file mode 100644 index 0000000..78af37d --- /dev/null +++ b/scenegod/web/stagestub.js @@ -0,0 +1,103 @@ +// stagestub.js — dev-only fake Stage implementing the PLAN §4.2 API with +// plain objects, so Lane B's timeline can be built + tested headless before +// Lane A's real Stage lands. DELETED at SYNC 1 (one-line swap in the page). +// ponytail: no three.js, no DOM — runs under plain node for timeline_test.mjs. + +const clone = (x) => (x == null ? x : JSON.parse(JSON.stringify(x))); + +class StubAction { + constructor(clip) { + this._clip = clip; + this.time = 0; + this.weight = 0; + this.enabled = false; + this.paused = false; + } + play() { this._playing = true; return this; } + reset() { this.time = 0; return this; } +} + +class StubMixer { + constructor(id, stage) { this.id = id; this.stage = stage; this._actions = new Map(); } + clipAction(clip) { + if (!this._actions.has(clip)) this._actions.set(clip, new StubAction(clip)); + return this._actions.get(clip); + } + update(_dt) { + // record every audibly-active action so tests can assert clip local time + for (const a of this._actions.values()) { + if (a.enabled && a.weight > 0) { + this.stage.applied.push({ type: 'clip', id: this.id, clip: a._clip.name, time: a.time, weight: a.weight }); + } + } + } +} + +export class StageStub { + constructor(viewportEl) { + this.viewportEl = viewportEl || null; + this._entities = new Map(); + this._mixers = new Map(); + this._clipCache = new Map(); + this._changeCbs = []; + this._selectCbs = []; + this._active = null; + this._selected = null; + this.applied = []; // timeline effects land here for assertions + } + + async addEntity(desc) { + const e = clone(desc); + if (!e.transform) e.transform = { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }; + if (!e.params) e.params = {}; + this._entities.set(e.id, e); + this._fireChange(e); + return e; + } + removeEntity(id) { this._entities.delete(id); this._mixers.delete(id); this._fireChange(null); } + getEntity(id) { return this._entities.get(id) || null; } + entities() { return [...this._entities.values()]; } + + select(id) { this._selected = id; this._selectCbs.forEach((cb) => cb(id)); } + onSelect(cb) { this._selectCbs.push(cb); } + onChange(cb) { this._changeCbs.push(cb); } + _fireChange(e) { this._changeCbs.forEach((cb) => cb(e)); } + + entityTransform(id) { + const e = this._entities.get(id); + return e ? clone(e.transform) : { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }; + } + setTransform(id, t) { + const e = this._entities.get(id); + if (e) e.transform = clone(t); + this.applied.push({ type: 'transform', id, ...clone(t) }); + console.debug('stub.setTransform', id, t); + } + setParam(id, key, value) { + const e = this._entities.get(id); + if (e) { e.params = e.params || {}; e.params[key] = value; } + this.applied.push({ type: 'param', id, key, value }); + console.debug('stub.setParam', id, key, value); + } + + async prepareClip(id, path, clipIndex) { + const k = `${path}#${clipIndex}`; // CACHED per §4.2 + if (!this._clipCache.has(k)) this._clipCache.set(k, { name: k, duration: 2.4 }); + return this._clipCache.get(k); + } + entityMixer(id) { + if (!this._mixers.has(id)) this._mixers.set(id, new StubMixer(id, this)); + return this._mixers.get(id); + } + + setActiveCamera(id) { this._active = id; this.applied.push({ type: 'camera', id }); console.debug('stub.setActiveCamera', id); } + renderActiveCamera(_canvas) { /* noop in stub */ } + + captureState() { return this.entities().map(clone); } + async applyState(scene) { + this._entities.clear(); + for (const e of (scene.entities || [])) await this.addEntity(e); + } +} + +export default StageStub; diff --git a/scenegod/web/timeline.js b/scenegod/web/timeline.js new file mode 100644 index 0000000..9a505bf --- /dev/null +++ b/scenegod/web/timeline.js @@ -0,0 +1,314 @@ +// 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; + const mixer = this.stage.entityMixer(e.id); + 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; diff --git a/scenegod/web/timeline_test.mjs b/scenegod/web/timeline_test.mjs new file mode 100644 index 0000000..061f19b --- /dev/null +++ b/scenegod/web/timeline_test.mjs @@ -0,0 +1,102 @@ +// 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'); + +console.log('OK — timeline_test.mjs: all assertions passed'); diff --git a/scenegod/web/tldev.html b/scenegod/web/tldev.html new file mode 100644 index 0000000..4f44291 --- /dev/null +++ b/scenegod/web/tldev.html @@ -0,0 +1,59 @@ + + + +