[laneB] M1: timeline model+clock, panel UI, stub, self-check
- timeline.js: headless clock/tracks/evaluate (transform slerp, param lerp, clip blocks with fade-out, camera cuts, undo stack). No three/DOM import. - tlui.js: canvas panel — ruler scrub, playhead, key add/drag/delete, clip blocks, camera cuts, save/load with localStorage fallback. Injects own CSS. - stagestub.js: dev Stage per PLAN §4.2 (deleted at SYNC 1). - timeline_test.mjs: node self-check, all assertions pass. - tldev.html: dev-only browser harness (deleted at SYNC 1). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f6292f2c84
commit
45debd3c10
103
scenegod/web/stagestub.js
Normal file
103
scenegod/web/stagestub.js
Normal file
@ -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;
|
||||
314
scenegod/web/timeline.js
Normal file
314
scenegod/web/timeline.js
Normal file
@ -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;
|
||||
102
scenegod/web/timeline_test.mjs
Normal file
102
scenegod/web/timeline_test.mjs
Normal file
@ -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');
|
||||
59
scenegod/web/tldev.html
Normal file
59
scenegod/web/tldev.html
Normal file
@ -0,0 +1,59 @@
|
||||
<!doctype html>
|
||||
<!-- tldev.html — Lane B DEV-ONLY harness to exercise the timeline UI against
|
||||
the stub before Lane A's index.html exists. Deleted at SYNC 1. -->
|
||||
<meta charset="utf-8">
|
||||
<title>SCENEGOD · timeline dev harness</title>
|
||||
<style>
|
||||
html,body{margin:0;height:100%;background:#0d1117;color:#c9d1d9;font:13px ui-monospace,monospace}
|
||||
#top{padding:8px 12px;border-bottom:1px solid #2b323c}
|
||||
#log{height:120px;overflow:auto;padding:6px 12px;color:#565f6b;font-size:11px;white-space:pre-wrap;border-bottom:1px solid #2b323c}
|
||||
#timeline{position:fixed;left:0;right:0;bottom:0}
|
||||
</style>
|
||||
<div id="top">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.</div>
|
||||
<div id="log"></div>
|
||||
<div id="timeline"></div>
|
||||
|
||||
<script type="module">
|
||||
import { StageStub } from './stagestub.js';
|
||||
import { Timeline } from './timeline.js';
|
||||
import { TimelineUI } from './tlui.js';
|
||||
|
||||
// mirror stub effects into the on-page log
|
||||
const logEl = document.getElementById('log');
|
||||
const stage = new StageStub();
|
||||
const _st = stage.setTransform.bind(stage), _sp = stage.setParam.bind(stage), _sc = stage.setActiveCamera.bind(stage);
|
||||
const line = (s) => { logEl.textContent = s + '\n' + logEl.textContent.slice(0, 4000); };
|
||||
stage.setTransform = (id, t) => { _st(id, t); line(`setTransform ${id} pos=[${t.pos.map(n=>n.toFixed(2))}]`); };
|
||||
stage.setParam = (id, k, v) => { _sp(id, k, v); line(`setParam ${id} ${k}=${v}`); };
|
||||
stage.setActiveCamera = (id) => { _sc(id); line(`activeCamera ${id}`); };
|
||||
|
||||
const scene = {
|
||||
version: 1, name: 'dev', fps: 30, duration: 6,
|
||||
entities: [
|
||||
{ id: 'lady', kind: 'character', label: 'lady',
|
||||
source: { type: 'assets', path: 'characters/lady.glb' },
|
||||
transform: { pos: [0,0,0], rot: [0,0,0], scale: 1 },
|
||||
tracks: { transform: [
|
||||
{ t: 0, pos: [-3,0,0], rot: [0,0,0], scale: 1, ease: 'linear' },
|
||||
{ t: 4, pos: [3,0,0], rot: [0,1.57,0], scale: 1, ease: 'inout' } ],
|
||||
clips: [ { path: 'animations/walk.fbx', clipIndex: 0, start: 0.5, in: 0, out: 2.4, loop: 1, fade: 0 } ] } },
|
||||
{ id: 'sun', kind: 'light', label: 'sun',
|
||||
params: { type: 'key', color: '#ffcc88', intensity: 1 },
|
||||
transform: { pos: [2,5,2], rot: [0,0,0], scale: 1 },
|
||||
tracks: { params: [ { t: 0, key: 'intensity', value: 0.2, ease: 'inout' }, { t: 5, key: 'intensity', value: 3, ease: 'inout' } ] } },
|
||||
{ id: 'camA', kind: 'camera', label: 'wide', params: { fov: 40 }, transform: { pos: [0,1.5,6], rot: [0,0,0], scale: 1 }, tracks: {} },
|
||||
{ id: 'camB', kind: 'camera', label: 'close', params: { fov: 60 }, transform: { pos: [2,1.6,2], rot: [0,0,0], scale: 1 }, tracks: {} },
|
||||
],
|
||||
cameraCuts: [ { t: 0, camera: 'camA' }, { t: 3, camera: 'camB' } ],
|
||||
audio: [],
|
||||
};
|
||||
|
||||
await stage.applyState(scene);
|
||||
const tl = new Timeline(stage);
|
||||
tl.load(scene);
|
||||
await tl.preload();
|
||||
const ui = new TimelineUI(tl, '#timeline');
|
||||
window._tl = tl; window._ui = ui; window._stage = stage; // console poking
|
||||
line('ready — scene loaded');
|
||||
</script>
|
||||
313
scenegod/web/tlui.js
Normal file
313
scenegod/web/tlui.js
Normal file
@ -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 <canvas> 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 = `
|
||||
<button data-act="play">▶</button>
|
||||
<span class="clock">0.00s</span>
|
||||
<input class="name" placeholder="scene name" value="${this.tl.scene.name || ''}">
|
||||
<span>dur</span><input class="num dur" type="number" min="0" step="0.5" value="${this.tl.duration}">
|
||||
<span class="spring"></span>
|
||||
<button data-act="save">Save</button>
|
||||
<button data-act="load">Load</button>`;
|
||||
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 = `<span class="k">${(r.kind || '?')[0].toUpperCase()}</span>${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;
|
||||
Loading…
Reference in New Issue
Block a user