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 @@ + + + +SCENEGOD · timeline dev harness + +
Lane B dev harness — stub stage. Double-click a lane to add a key, + drag keys, ▶ to play, drag the ruler to scrub. setTransform/param calls log below.
+
+
+ + diff --git a/scenegod/web/tlui.js b/scenegod/web/tlui.js new file mode 100644 index 0000000..ffa6611 --- /dev/null +++ b/scenegod/web/tlui.js @@ -0,0 +1,313 @@ +// tlui.js — SCENEGOD timeline panel (Lane B). ALL DOM/canvas lives here; +// timeline.js stays headless. Mounts into #timeline, drives a Timeline. +// +// Layout: [scene bar] / [names column | ruler+canvas lanes]. One for +// all lanes (decision: simpler than canvas-per-lane; hit-testing via a rebuilt +// hitbox list each draw). Fit-to-width time axis; zoom/pan is M2. + +const ROW_H = 28; +const RULER_H = 22; +const KEY_R = 5; + +// ponytail: styles injected here, not written into Lane A's web/style.css — +// keeps Lane B inside its own files. Move into a /* === LANE B === */ block at +// SYNC if John wants them centralized. +const CSS = ` +#timeline{display:flex;flex-direction:column;background:#161a20;color:#c9d1d9;font:12px/1.4 ui-monospace,Menlo,monospace;border-top:1px solid #2b323c;user-select:none} +#timeline .tl-bar{display:flex;gap:8px;align-items:center;padding:6px 8px;border-bottom:1px solid #2b323c} +#timeline .tl-bar input{background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;padding:2px 6px;border-radius:3px;font:inherit} +#timeline .tl-bar input.name{width:150px} +#timeline .tl-bar input.num{width:56px} +#timeline .tl-bar button{background:#21262d;border:1px solid #2b323c;color:#c9d1d9;padding:3px 10px;border-radius:3px;cursor:pointer;font:inherit} +#timeline .tl-bar button:hover{background:#2b323c} +#timeline .tl-bar .spring{flex:1} +#timeline .tl-bar .clock{color:#7aa2f7;min-width:64px;text-align:right} +#timeline .tl-body{display:flex;height:200px} +#timeline .tl-names{width:150px;flex:none;overflow:hidden;border-right:1px solid #2b323c;background:#12161c} +#timeline .tl-names .row{height:${ROW_H}px;display:flex;align-items:center;padding:0 8px;box-sizing:border-box;border-bottom:1px solid #1b2027;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#timeline .tl-names .row.head{margin-top:${RULER_H}px} +#timeline .tl-names .row .k{color:#565f6b;margin-right:6px;font-size:10px} +#timeline .tl-names .row.clip{color:#8b949e;padding-left:20px;font-size:11px} +#timeline .tl-lanes{flex:1;position:relative;overflow:hidden} +#timeline canvas{display:block;width:100%;height:100%} +`; + +export class TimelineUI { + constructor(timeline, mountEl) { + this.tl = timeline; + this.stage = timeline.stage; + this.el = typeof mountEl === 'string' ? document.querySelector(mountEl) : mountEl; + this._hits = []; + this._drag = null; + this._injectCSS(); + this._build(); + this.tl.onTick(() => this.draw()); + if (this.stage.onChange) this.stage.onChange(() => this._syncRows()); + this._syncRows(); + this.draw(); + } + + _injectCSS() { + if (document.getElementById('laneB-tl-css')) return; + const s = document.createElement('style'); + s.id = 'laneB-tl-css'; + s.textContent = CSS; + document.head.appendChild(s); + } + + _build() { + this.el.innerHTML = ''; + // scene bar + const bar = document.createElement('div'); + bar.className = 'tl-bar'; + bar.innerHTML = ` + + 0.00s + + dur + + + `; + this.el.appendChild(bar); + this.$play = bar.querySelector('[data-act="play"]'); + this.$clock = bar.querySelector('.clock'); + this.$name = bar.querySelector('.name'); + this.$dur = bar.querySelector('.dur'); + this.$play.onclick = () => this._toggle(); + bar.querySelector('[data-act="save"]').onclick = () => this.save(); + bar.querySelector('[data-act="load"]').onclick = () => this.load(this.$name.value); + this.$dur.onchange = () => { this.tl.scene.duration = Math.max(0.1, +this.$dur.value || 1); this.draw(); }; + this.$name.onchange = () => { this.tl.scene.name = this.$name.value; }; + + // body + const body = document.createElement('div'); + body.className = 'tl-body'; + this.$names = document.createElement('div'); this.$names.className = 'tl-names'; + const lanes = document.createElement('div'); lanes.className = 'tl-lanes'; + this.canvas = document.createElement('canvas'); + lanes.appendChild(this.canvas); + body.appendChild(this.$names); body.appendChild(lanes); + this.el.appendChild(body); + this.lanesEl = lanes; + + // canvas events + this.canvas.addEventListener('mousedown', (e) => this._down(e)); + window.addEventListener('mousemove', (e) => this._move(e)); + window.addEventListener('mouseup', () => this._up()); + this.canvas.addEventListener('dblclick', (e) => this._dbl(e)); + this.canvas.addEventListener('contextmenu', (e) => this._ctx(e)); + window.addEventListener('keydown', (e) => this._key(e)); + window.addEventListener('resize', () => this.draw()); + } + + // rows: [{type:'transform'|'clip'|'cameras', id?, label}] + _rows() { + const rows = []; + for (const e of this.stage.entities()) { + rows.push({ type: 'transform', id: e.id, kind: e.kind, label: e.label || e.id }); + if (e.kind === 'character') rows.push({ type: 'clip', id: e.id, label: '↳ clips' }); + } + rows.push({ type: 'cameras', label: 'Cameras' }); + return rows; + } + + _syncRows() { + this.rows = this._rows(); + // rebuild names column + this.$names.innerHTML = ''; + this.rows.forEach((r, i) => { + const d = document.createElement('div'); + d.className = 'row' + (i === 0 ? ' head' : '') + (r.type === 'clip' ? ' clip' : ''); + if (r.type === 'transform') d.innerHTML = `${(r.kind || '?')[0].toUpperCase()}${r.label}`; + else d.textContent = r.label; + this.$names.appendChild(d); + }); + this.draw(); + } + + // ---- geometry ---- + _dims() { + const w = this.canvas.clientWidth, h = this.canvas.clientHeight; + const dpr = window.devicePixelRatio || 1; + if (this.canvas.width !== w * dpr || this.canvas.height !== h * dpr) { + this.canvas.width = w * dpr; this.canvas.height = h * dpr; + } + return { w, h, dpr, pps: w / (this.tl.duration || 1) }; + } + _x2t(x) { const { pps } = this._dims(); return Math.max(0, Math.min(this.tl.duration, x / pps)); } + _t2x(t) { const { pps } = this._dims(); return t * pps; } + _rowAtY(y) { const i = Math.floor((y - RULER_H) / ROW_H); return (i >= 0 && i < this.rows.length) ? i : -1; } + + // ---- draw ---- + draw() { + if (!this.rows) this.rows = this._rows(); + const { w, h, dpr } = this._dims(); + const c = this.canvas.getContext('2d'); + c.setTransform(dpr, 0, 0, dpr, 0, 0); + c.clearRect(0, 0, w, h); + this._hits = []; + + // ruler + c.fillStyle = '#12161c'; c.fillRect(0, 0, w, RULER_H); + c.strokeStyle = '#2b323c'; c.fillStyle = '#565f6b'; c.font = '10px ui-monospace'; + const dur = this.tl.duration || 1; + const stepSec = dur <= 5 ? 0.5 : dur <= 20 ? 1 : 5; + for (let t = 0; t <= dur + 1e-6; t += stepSec) { + const x = this._t2x(t); + c.beginPath(); c.moveTo(x, 0); c.lineTo(x, h); c.strokeStyle = '#1b2027'; c.stroke(); + c.fillText(t.toFixed(stepSec < 1 ? 1 : 0) + 's', x + 2, 12); + } + + // lanes + this.rows.forEach((r, i) => { + const y = RULER_H + i * ROW_H; + c.fillStyle = i % 2 ? '#161a20' : '#13171d'; + c.fillRect(0, y, w, ROW_H); + c.strokeStyle = '#1b2027'; c.beginPath(); c.moveTo(0, y + ROW_H); c.lineTo(w, y + ROW_H); c.stroke(); + if (r.type === 'transform') this._drawKeys(c, r, y); + else if (r.type === 'clip') this._drawClips(c, r, y); + else if (r.type === 'cameras') this._drawCuts(c, y); + }); + + // playhead + const px = this._t2x(this.tl.time); + c.strokeStyle = '#f7768e'; c.lineWidth = 1.5; + c.beginPath(); c.moveTo(px, 0); c.lineTo(px, h); c.stroke(); c.lineWidth = 1; + c.fillStyle = '#f7768e'; c.beginPath(); c.moveTo(px - 4, 0); c.lineTo(px + 4, 0); c.lineTo(px, 6); c.fill(); + + this.$clock.textContent = this.tl.time.toFixed(2) + 's'; + this.$play.textContent = this.tl.playing ? '❚❚' : '▶'; + } + + _entity(id) { return this.stage.getEntity(id); } + _keysOf(id) { const e = this._entity(id); return (e && e.tracks && e.tracks.transform) || []; } + + _drawKeys(c, r, y) { + const cy = y + ROW_H / 2; + for (const k of this._keysOf(r.id)) { + const x = this._t2x(k.t); + c.fillStyle = '#7aa2f7'; + c.beginPath(); c.moveTo(x, cy - KEY_R); c.lineTo(x + KEY_R, cy); c.lineTo(x, cy + KEY_R); c.lineTo(x - KEY_R, cy); c.fill(); + this._hits.push({ kind: 'key', id: r.id, track: 'transform', key: k, x, y: cy }); + } + } + _drawClips(c, r, y) { + const e = this._entity(r.id); + const blocks = (e && e.tracks && e.tracks.clips) || []; + for (const b of blocks) { + const span = (b.out - b.in) || 0.0001, end = b.start + span * (b.loop || 1); + const x0 = this._t2x(b.start), x1 = this._t2x(end); + c.fillStyle = '#3d5a80'; c.fillRect(x0, y + 5, Math.max(2, x1 - x0), ROW_H - 10); + c.strokeStyle = '#98c1d9'; c.strokeRect(x0 + 0.5, y + 5.5, Math.max(2, x1 - x0) - 1, ROW_H - 11); + c.fillStyle = '#e0e6ed'; c.font = '10px ui-monospace'; + const name = (b.path || '').split('/').pop(); + c.save(); c.beginPath(); c.rect(x0, y, x1 - x0, ROW_H); c.clip(); + c.fillText(name, x0 + 4, y + ROW_H / 2 + 3); c.restore(); + this._hits.push({ kind: 'block', id: r.id, block: b, x0, x1, y }); + this._hits.push({ kind: 'edge', id: r.id, block: b, side: 'out', x: x1, y }); + } + } + _drawCuts(c, y) { + const cy = y + ROW_H / 2; + for (const cut of this.tl.cameraCuts) { + const x = this._t2x(cut.t); + c.fillStyle = '#e0af68'; + c.fillRect(x - 1, y + 4, 2, ROW_H - 8); + c.beginPath(); c.arc(x, cy, 4, 0, Math.PI * 2); c.fill(); + c.fillStyle = '#161a20'; c.font = '9px ui-monospace'; c.fillText(cut.camera || '?', x + 6, cy + 3); + this._hits.push({ kind: 'cut', cut, x, y: cy }); + } + } + + // ---- interaction ---- + _local(e) { const r = this.canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; } + _snap(t, e) { return e && e.altKey ? t : Math.round(t * this.tl.fps) / this.tl.fps; } + _hitAt(x, y) { + // prefer key/edge handles (small), then blocks + for (const h of this._hits) { + if (h.kind === 'key' || h.kind === 'cut') { if (Math.abs(h.x - x) < 7 && Math.abs(h.y - y) < 9) return h; } + else if (h.kind === 'edge') { if (Math.abs(h.x - x) < 5 && y > h.y && y < h.y + ROW_H) return h; } + } + for (const h of this._hits) if (h.kind === 'block' && x >= h.x0 && x <= h.x1 && y > h.y && y < h.y + ROW_H) return h; + return null; + } + + _down(e) { + const { x, y } = this._local(e); + if (y < RULER_H) { this._drag = { kind: 'seek' }; this.tl.seek(this._x2t(x)); return; } + const h = this._hitAt(x, y); + if (!h) { this._drag = { kind: 'seek' }; this.tl.seek(this._x2t(x)); return; } + if (h.kind === 'key') this._drag = { kind: 'key', h }; + else if (h.kind === 'edge') this._drag = { kind: 'trim', h }; + else if (h.kind === 'block') this._drag = { kind: 'block', h, grabT: this._x2t(x) - h.block.start }; + else if (h.kind === 'cut') this._drag = { kind: 'cut', h }; + } + _move(e) { + if (!this._drag) return; + const { x } = this._local(e); + const t = this._x2t(x), d = this._drag; + if (d.kind === 'seek') this.tl.seek(t); + else if (d.kind === 'key') { this.tl.moveKey(d.h.id, 'transform', d.h.key, this._snap(t, e)); this.draw(); } + else if (d.kind === 'cut') { d.h.cut.t = this._snap(t, e); this.tl.cameraCuts.sort((a, b) => a.t - b.t); this.tl._activeCam = undefined; this.draw(); } + else if (d.kind === 'block') { this.tl.moveClipBlock(d.h.id, d.h.block, Math.max(0, this._snap(t - d.grabT, e))); this.draw(); } + else if (d.kind === 'trim') { const b = d.h.block; this.tl.trimClipBlock(d.h.id, b, { out: Math.max(b.in + 0.05, b.in + (t - b.start)) }); this.draw(); } + } + _up() { this._drag = null; } + + _dbl(e) { + const { x, y } = this._local(e); + const i = this._rowAtY(y); if (i < 0) return; + const r = this.rows[i]; + if (r.type !== 'transform') return; + const t = this._snap(this._x2t(x), e); + const cur = this.stage.entityTransform(r.id); + this.tl.addKey(r.id, 'transform', { t, pos: cur.pos, rot: cur.rot, scale: cur.scale, ease: 'inout' }); + this.draw(); + } + _ctx(e) { + e.preventDefault(); + const { x, y } = this._local(e); + const h = this._hitAt(x, y); + if (h && h.kind === 'key') { this.tl.deleteKey(h.id, 'transform', h.key); this.draw(); } + else if (h && h.kind === 'cut') { const i = this.tl.cameraCuts.indexOf(h.cut); if (i >= 0) this.tl.cameraCuts.splice(i, 1); this.tl._activeCam = undefined; this.draw(); } + } + _key(e) { + if (e.target && /INPUT|TEXTAREA/.test(e.target.tagName)) return; + if (e.code === 'Space') { e.preventDefault(); this._toggle(); } + else if (e.key === 'Home') this.tl.seek(0); + else if (e.key === 'End') this.tl.seek(this.tl.duration); + else if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); this.tl.undo(); this.draw(); } + } + _toggle() { this.tl.playing ? this.tl.pause() : this.tl.play(); this.draw(); } + + // ---- persistence (Lane C endpoints; localStorage fallback) ---- + async save() { + const name = (this.$name.value || 'untitled').trim(); + this.tl.scene.name = name; + const json = this.tl.toJSON(); + try { + const res = await fetch(`/scenes/${encodeURIComponent(name)}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(json), + }); + if (!res.ok) throw new Error(res.status); + } catch { + localStorage.setItem('scenegod:scene:' + name, JSON.stringify(json)); // fallback until Lane C lands + } + } + async load(name) { + name = (name || '').trim(); if (!name) return; + let json = null; + try { const res = await fetch(`/scenes/${encodeURIComponent(name)}`); if (res.ok) json = await res.json(); } catch { /* fall through */ } + if (!json) { const raw = localStorage.getItem('scenegod:scene:' + name); if (raw) json = JSON.parse(raw); } + if (!json) return; + if (this.stage.applyState) await this.stage.applyState(json); + this.tl.load(json); + await this.tl.preload(); + this.$name.value = json.name || name; + this.$dur.value = this.tl.duration; + this._syncRows(); + this.draw(); + } +} + +export default TimelineUI;