[laneB] M2: SYNC1 row/bar fixes, clipdrop, crossfade, params/cut lanes, box-select

- timeline.js: onLoad hook (rebuilds rows + refreshes scene bar after load()),
  scenegod:clipdrop handler (addClipDrop: block at playhead, out=clip duration,
  auto 0.25 fade where it abuts a neighbour), crossfade now fade-IN + fade-OUT
  (pre-roll pulls successor start earlier for continuous local time).
- tlui.js: reads tracks from the TIMELINE (authoritative) not the stage copy —
  fixes post-load addKey/clipdrop not appearing. Params lanes (per-key color),
  camera-cut lane add via dblclick, snap toggle, box-select + multi-drag +
  Delete, selection highlight, fade wedge on blocks, grow-to-content height.
- timeline_test.mjs: + onLoad, addClipDrop duration, crossfade weight sum=1.

Verified in browser (throwaway harness, not committed): SYNC #3a/#3b repro
fixed, both clip blocks render, clipdrop/add-key show immediately, cut-add and
box-select multi-drag work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 20:10:07 +10:00
parent 24b7b4d4b7
commit 5d9950211c
3 changed files with 193 additions and 54 deletions

View File

@ -81,6 +81,7 @@ export class Timeline {
this.time = 0; this.time = 0;
this.playing = false; this.playing = false;
this._tickCbs = []; this._tickCbs = [];
this._loadCbs = [];
this._raf = null; this._raf = null;
this._last = 0; this._last = 0;
this._qcache = new WeakMap(); // key obj -> cached quat (keeps keys pure for round-trip) this._qcache = new WeakMap(); // key obj -> cached quat (keeps keys pure for round-trip)
@ -93,6 +94,10 @@ export class Timeline {
const id = e.detail && e.detail.id; const id = e.detail && e.detail.id;
if (id) this.addKey(id, 'transform', { t: this.time, ...this.stage.entityTransform(id), ease: 'inout' }); if (id) this.addKey(id, 'transform', { t: this.time, ...this.stage.entityTransform(id), ease: 'inout' });
}); });
// Lane A dock drops a clip on a character → add a block at the playhead.
window.addEventListener('scenegod:clipdrop', (e) => {
if (e.detail && e.detail.id) this.addClipDrop(e.detail);
});
} }
} }
@ -113,10 +118,31 @@ export class Timeline {
this._clipActions.clear(); this._clipActions.clear();
this._activeCam = undefined; this._activeCam = undefined;
this.seek(0); this.seek(0);
for (const cb of this._loadCbs) cb(this.scene); // UI: rebuild rows + refresh scene bar (SYNC1 #3)
return this; return this;
} }
onLoad(cb) { this._loadCbs.push(cb); }
toJSON() { return structuredClone(this.scene); } toJSON() { return structuredClone(this.scene); }
// Lane A clip-drop: fetch the clip (for its duration), drop a block at the
// playhead, add a default crossfade where it abuts a neighbour, preload.
async addClipDrop({ id, path, clipIndex = 0 }) {
const clip = await this.stage.prepareClip(id, path, clipIndex);
const dur = (clip && clip.duration) || 1;
this.addClipBlock(id, { path, clipIndex, start: this.time, in: 0, out: dur, loop: 1, fade: 0 });
this._setAbutFades(id);
await this.preload();
this.seek(this.time); // re-evaluate + redraw
}
_setAbutFades(id) { // 0.25s fade on any block that abuts its successor
const blocks = (this._tracks(id).clips) || [];
for (let i = 0; i < blocks.length - 1; i++) {
const b = blocks[i], n = blocks[i + 1];
const end = b.start + ((b.out - b.in) || 0) * (b.loop || 1);
if (Math.abs(end - n.start) < 1e-3 && !b.fade) b.fade = 0.25;
}
}
_sortTracks(e) { _sortTracks(e) {
const tr = e.tracks; const tr = e.tracks;
if (!tr) return; if (!tr) return;
@ -226,18 +252,22 @@ export class Timeline {
const rec = this._clipActions.get(b); const rec = this._clipActions.get(b);
if (!rec) continue; // not preloaded yet → silent if (!rec) continue; // not preloaded yet → silent
const span = (b.out - b.in) || 0.0001; const span = (b.out - b.in) || 0.0001;
const loops = b.loop || 1; const blockEnd = b.start + span * (b.loop || 1);
const blockEnd = b.start + span * loops; // pre-roll: a predecessor's fade pulls THIS block's playback start earlier
// by pred.fade so it advances during the crossfade (continuous local time).
const pred = blocks[i - 1];
const preroll = (pred && pred.fade > 0) ? pred.fade : 0;
const inStart = b.start - preroll;
let w = 0, local = b.in; let w = 0, local = b.in;
if (t >= b.start && t < blockEnd) { if (t >= inStart && t < blockEnd) {
local = b.in + ((t - b.start) % span); local = b.in + ((t - inStart) % span); // continuous across the boundary
w = 1; w = 1;
// ponytail: fade-OUT ramp only for M1; cross-block fade-IN is M2 "crossfade polish". if (t < b.start) w = (t - inStart) / preroll; // fade-IN 0→1 over the pred's fade window
const succ = blocks[i + 1]; const succ = blocks[i + 1]; // fade-OUT 1→0 over own fade window
if (succ && b.fade > 0 && t >= blockEnd - b.fade) w = Math.max(0, (blockEnd - t) / b.fade); if (succ && b.fade > 0 && t >= blockEnd - b.fade) w = Math.min(w, (blockEnd - t) / b.fade);
} }
rec.action.time = local; rec.action.time = local;
rec.action.weight = w; rec.action.weight = Math.max(0, w);
rec.action.enabled = w > 0; rec.action.enabled = w > 0;
} }
mixer.update(0); // set-time style — exact when scrubbing mixer.update(0); // set-time style — exact when scrubbing

View File

@ -99,4 +99,42 @@ const tl3 = new Timeline(new StageStub());
tl3.load(withExtra); tl3.load(withExtra);
assert.deepEqual(tl3.toJSON().entities[0].laneAOnly, { gizmo: 'move', foo: [1, 2, 3] }, 'unknown fields must survive'); assert.deepEqual(tl3.toJSON().entities[0].laneAOnly, { gizmo: 'move', foo: [1, 2, 3] }, 'unknown fields must survive');
// ---- M2: onLoad fires ----
let loadFired = 0;
const tlL = new Timeline(new StageStub());
tlL.onLoad(() => loadFired++);
tlL.load(scene);
assert.equal(loadFired, 1, 'onLoad must fire once per load()');
// ---- M2: addClipDrop sets out = clip duration, drops block at playhead ----
const dropStage = new StageStub();
await dropStage.addEntity({ id: 'c1', kind: 'character', label: 'c', tracks: {} });
const tlD = new Timeline(dropStage);
tlD.load({ version: 1, fps: 30, duration: 10, entities: [{ id: 'c1', kind: 'character', tracks: {} }], cameraCuts: [] });
tlD.seek(2);
await tlD.addClipDrop({ id: 'c1', path: 'animations/run.fbx', clipIndex: 0 });
const blk = tlD.scene.entities[0].tracks.clips[0];
assert.ok(blk && near(blk.start, 2) && near(blk.out, 2.4), `clipdrop block start=2 out=2.4, got ${blk && blk.start}/${blk && blk.out}`);
// ---- M2: crossfade — two abutting blocks fade through ~0.5 at the boundary ----
const xStage = new StageStub();
await xStage.addEntity({ id: 'c2', kind: 'character', tracks: {} });
const tlX = new Timeline(xStage);
tlX.load({
version: 1, fps: 30, duration: 10, cameraCuts: [],
entities: [{ id: 'c2', kind: 'character', tracks: { clips: [
{ path: 'a.fbx', clipIndex: 0, start: 0, in: 0, out: 1.0, loop: 1, fade: 0.4 },
{ path: 'b.fbx', clipIndex: 0, start: 1.0, in: 0, out: 1.0, loop: 1, fade: 0 },
] } }],
});
await tlX.preload();
xStage.applied.length = 0;
tlX.step(24); // t=0.8 → mid of block-A's [0.6,1.0] fade
const lastIn = (arr, pred) => { for (let i = arr.length - 1; i >= 0; i--) if (pred(arr[i])) return arr[i]; return null; };
const wa = lastIn(xStage.applied, (a) => a.type === 'clip' && a.clip === 'a.fbx#0');
const wb = lastIn(xStage.applied, (a) => a.type === 'clip' && a.clip === 'b.fbx#0');
assert.ok(wa && near(wa.weight, 0.5, 1e-6), `A fade-out weight ~0.5, got ${wa && wa.weight}`);
assert.ok(wb && near(wb.weight, 0.5, 1e-6), `B fade-in weight ~0.5, got ${wb && wb.weight}`);
assert.ok(near(wa.weight + wb.weight, 1, 1e-6), 'crossfade weights sum to 1');
console.log('OK — timeline_test.mjs: all assertions passed'); console.log('OK — timeline_test.mjs: all assertions passed');

View File

@ -3,31 +3,31 @@
// //
// Layout: [scene bar] / [names column | ruler+canvas lanes]. One <canvas> for // Layout: [scene bar] / [names column | ruler+canvas lanes]. One <canvas> for
// all lanes (decision: simpler than canvas-per-lane; hit-testing via a rebuilt // 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. // hitbox list each draw). Fit-to-width time axis; zoom/pan is a later problem.
const ROW_H = 28; const ROW_H = 28;
const RULER_H = 22; const RULER_H = 22;
const KEY_R = 5; const KEY_R = 5;
// ponytail: styles injected here, not written into Lane A's web/style.css — // 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 // keeps Lane B inside its own files (orchestrator confirmed this is permanent).
// SYNC if John wants them centralized.
const CSS = ` 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{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{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{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.name{width:150px}
#timeline .tl-bar input.num{width:56px} #timeline .tl-bar input.num{width:56px}
#timeline .tl-bar label{display:flex;align-items:center;gap:3px;color:#8b949e;cursor:pointer}
#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{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 button:hover{background:#2b323c}
#timeline .tl-bar .spring{flex:1} #timeline .tl-bar .spring{flex:1}
#timeline .tl-bar .clock{color:#7aa2f7;min-width:64px;text-align:right} #timeline .tl-bar .clock{color:#7aa2f7;min-width:64px;text-align:right}
#timeline .tl-body{display:flex;height:200px} #timeline .tl-body{display:flex}
#timeline .tl-names{width:150px;flex:none;overflow:hidden;border-right:1px solid #2b323c;background:#12161c} #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{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.head{margin-top:${RULER_H}px}
#timeline .tl-names .row .k{color:#565f6b;margin-right:6px;font-size:10px} #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-names .row.sub{color:#8b949e;padding-left:20px;font-size:11px}
#timeline .tl-lanes{flex:1;position:relative;overflow:hidden} #timeline .tl-lanes{flex:1;position:relative;overflow:hidden}
#timeline canvas{display:block;width:100%;height:100%} #timeline canvas{display:block;width:100%;height:100%}
`; `;
@ -39,14 +39,22 @@ export class TimelineUI {
this.el = typeof mountEl === 'string' ? document.querySelector(mountEl) : mountEl; this.el = typeof mountEl === 'string' ? document.querySelector(mountEl) : mountEl;
this._hits = []; this._hits = [];
this._drag = null; this._drag = null;
this._selKeys = []; // box-selected keys: [{id, track, key}]
this._snapOn = true;
this._injectCSS(); this._injectCSS();
this._build(); this._build();
this.tl.onTick(() => this.draw()); this.tl.onTick(() => this.draw());
this.tl.onLoad(() => { this._syncRows(); this._refreshBar(); }); // SYNC1 #3a/#3b
if (this.stage.onChange) this.stage.onChange(() => this._syncRows()); if (this.stage.onChange) this.stage.onChange(() => this._syncRows());
this._syncRows(); this._syncRows();
this.draw(); this.draw();
} }
_refreshBar() {
if (this.$name) this.$name.value = this.tl.scene.name || '';
if (this.$dur) this.$dur.value = this.tl.duration;
}
_injectCSS() { _injectCSS() {
if (document.getElementById('laneB-tl-css')) return; if (document.getElementById('laneB-tl-css')) return;
const s = document.createElement('style'); const s = document.createElement('style');
@ -57,7 +65,6 @@ export class TimelineUI {
_build() { _build() {
this.el.innerHTML = ''; this.el.innerHTML = '';
// scene bar
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.className = 'tl-bar'; bar.className = 'tl-bar';
bar.innerHTML = ` bar.innerHTML = `
@ -65,6 +72,7 @@ export class TimelineUI {
<span class="clock">0.00s</span> <span class="clock">0.00s</span>
<input class="name" placeholder="scene name" value="${this.tl.scene.name || ''}"> <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>dur</span><input class="num dur" type="number" min="0" step="0.5" value="${this.tl.duration}">
<label><input type="checkbox" class="snap" checked> snap</label>
<span class="spring"></span> <span class="spring"></span>
<button data-act="save">Save</button> <button data-act="save">Save</button>
<button data-act="load">Load</button>`; <button data-act="load">Load</button>`;
@ -73,13 +81,14 @@ export class TimelineUI {
this.$clock = bar.querySelector('.clock'); this.$clock = bar.querySelector('.clock');
this.$name = bar.querySelector('.name'); this.$name = bar.querySelector('.name');
this.$dur = bar.querySelector('.dur'); this.$dur = bar.querySelector('.dur');
this.$snap = bar.querySelector('.snap');
this.$play.onclick = () => this._toggle(); this.$play.onclick = () => this._toggle();
bar.querySelector('[data-act="save"]').onclick = () => this.save(); bar.querySelector('[data-act="save"]').onclick = () => this.save();
bar.querySelector('[data-act="load"]').onclick = () => this.load(this.$name.value); 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.$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; }; this.$name.onchange = () => { this.tl.scene.name = this.$name.value; };
this.$snap.onchange = () => { this._snapOn = this.$snap.checked; };
// body
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'tl-body'; body.className = 'tl-body';
this.$names = document.createElement('div'); this.$names.className = 'tl-names'; this.$names = document.createElement('div'); this.$names.className = 'tl-names';
@ -90,7 +99,6 @@ export class TimelineUI {
this.el.appendChild(body); this.el.appendChild(body);
this.lanesEl = lanes; this.lanesEl = lanes;
// canvas events
this.canvas.addEventListener('mousedown', (e) => this._down(e)); this.canvas.addEventListener('mousedown', (e) => this._down(e));
window.addEventListener('mousemove', (e) => this._move(e)); window.addEventListener('mousemove', (e) => this._move(e));
window.addEventListener('mouseup', () => this._up()); window.addEventListener('mouseup', () => this._up());
@ -100,12 +108,18 @@ export class TimelineUI {
window.addEventListener('resize', () => this.draw()); window.addEventListener('resize', () => this.draw());
} }
// rows: [{type:'transform'|'clip'|'cameras', id?, label}] // rows: [{type:'transform'|'params'|'clip'|'cameras', id?, label}].
// Built from the timeline's entities (authoritative for tracks) merged with
// any stage entities not yet keyframed (dock-added but not in a loaded scene).
_rows() { _rows() {
const rows = []; const rows = [];
for (const e of this.stage.entities()) { const list = [...this.tl.scene.entities];
for (const se of this.stage.entities()) if (!list.find((e) => e.id === se.id)) list.push(se);
for (const e of list) {
rows.push({ type: 'transform', id: e.id, kind: e.kind, label: e.label || e.id }); 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' }); if (e.kind === 'character') rows.push({ type: 'clip', id: e.id, label: '↳ clips' });
const paramable = (e.tracks && e.tracks.params) || e.kind === 'camera' || e.kind === 'light';
if (paramable) rows.push({ type: 'params', id: e.id, label: '↳ params' });
} }
rows.push({ type: 'cameras', label: 'Cameras' }); rows.push({ type: 'cameras', label: 'Cameras' });
return rows; return rows;
@ -113,15 +127,21 @@ export class TimelineUI {
_syncRows() { _syncRows() {
this.rows = this._rows(); this.rows = this._rows();
// rebuild names column
this.$names.innerHTML = ''; this.$names.innerHTML = '';
this.rows.forEach((r, i) => { this.rows.forEach((r, i) => {
const d = document.createElement('div'); const d = document.createElement('div');
d.className = 'row' + (i === 0 ? ' head' : '') + (r.type === 'clip' ? ' clip' : ''); const sub = r.type === 'clip' || r.type === 'params';
d.className = 'row' + (i === 0 ? ' head' : '') + (sub ? ' sub' : '');
if (r.type === 'transform') d.innerHTML = `<span class="k">${(r.kind || '?')[0].toUpperCase()}</span>${r.label}`; if (r.type === 'transform') d.innerHTML = `<span class="k">${(r.kind || '?')[0].toUpperCase()}</span>${r.label}`;
else d.textContent = r.label; else d.textContent = r.label;
this.$names.appendChild(d); this.$names.appendChild(d);
}); });
// size lanes to content so no row (esp. Cameras) is clipped as rows grow.
// ponytail: grow-to-content; add a max-height+scroll only if a scene ever
// has so many entities the panel dominates the viewport.
const hpx = RULER_H + this.rows.length * ROW_H;
this.$names.style.height = hpx + 'px';
this.lanesEl.style.height = hpx + 'px';
this.draw(); this.draw();
} }
@ -149,12 +169,12 @@ export class TimelineUI {
// ruler // ruler
c.fillStyle = '#12161c'; c.fillRect(0, 0, w, RULER_H); c.fillStyle = '#12161c'; c.fillRect(0, 0, w, RULER_H);
c.strokeStyle = '#2b323c'; c.fillStyle = '#565f6b'; c.font = '10px ui-monospace'; c.fillStyle = '#565f6b'; c.font = '10px ui-monospace';
const dur = this.tl.duration || 1; const dur = this.tl.duration || 1;
const stepSec = dur <= 5 ? 0.5 : dur <= 20 ? 1 : 5; const stepSec = dur <= 5 ? 0.5 : dur <= 20 ? 1 : 5;
for (let t = 0; t <= dur + 1e-6; t += stepSec) { for (let t = 0; t <= dur + 1e-6; t += stepSec) {
const x = this._t2x(t); const x = this._t2x(t);
c.beginPath(); c.moveTo(x, 0); c.lineTo(x, h); c.strokeStyle = '#1b2027'; c.stroke(); c.strokeStyle = '#1b2027'; c.beginPath(); c.moveTo(x, 0); c.lineTo(x, h); c.stroke();
c.fillText(t.toFixed(stepSec < 1 ? 1 : 0) + 's', x + 2, 12); c.fillText(t.toFixed(stepSec < 1 ? 1 : 0) + 's', x + 2, 12);
} }
@ -165,10 +185,20 @@ export class TimelineUI {
c.fillRect(0, y, w, ROW_H); 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(); 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); if (r.type === 'transform') this._drawKeys(c, r, y);
else if (r.type === 'params') this._drawParams(c, r, y);
else if (r.type === 'clip') this._drawClips(c, r, y); else if (r.type === 'clip') this._drawClips(c, r, y);
else if (r.type === 'cameras') this._drawCuts(c, y); else if (r.type === 'cameras') this._drawCuts(c, y);
}); });
// box-select overlay
const d = this._drag;
if (d && d.kind === 'box' && d.moved) {
c.fillStyle = 'rgba(122,162,247,0.15)'; c.strokeStyle = '#7aa2f7';
const x = Math.min(d.x0, d.x), y = Math.min(d.y0, d.y);
c.fillRect(x, y, Math.abs(d.x - d.x0), Math.abs(d.y - d.y0));
c.strokeRect(x + 0.5, y + 0.5, Math.abs(d.x - d.x0), Math.abs(d.y - d.y0));
}
// playhead // playhead
const px = this._t2x(this.tl.time); const px = this._t2x(this.tl.time);
c.strokeStyle = '#f7768e'; c.lineWidth = 1.5; c.strokeStyle = '#f7768e'; c.lineWidth = 1.5;
@ -179,30 +209,48 @@ export class TimelineUI {
this.$play.textContent = this.tl.playing ? '❚❚' : '▶'; this.$play.textContent = this.tl.playing ? '❚❚' : '▶';
} }
_entity(id) { return this.stage.getEntity(id); } _entity(id) { return this.stage.getEntity(id); } // stage: live transform/existence
_keysOf(id) { const e = this._entity(id); return (e && e.tracks && e.tracks.transform) || []; } _te(id) { return this.tl.scene.entities.find((e) => e.id === id); } // timeline: authoritative tracks
_selected(k) { return this._selKeys.some((s) => s.key === k); }
_diamond(c, x, cy, r, fill, sel) {
c.fillStyle = fill;
c.beginPath(); c.moveTo(x, cy - r); c.lineTo(x + r, cy); c.lineTo(x, cy + r); c.lineTo(x - r, cy); c.closePath(); c.fill();
if (sel) { c.strokeStyle = '#fff'; c.lineWidth = 1.5; c.stroke(); c.lineWidth = 1; }
}
_drawKeys(c, r, y) { _drawKeys(c, r, y) {
const cy = y + ROW_H / 2; const cy = y + ROW_H / 2;
for (const k of this._keysOf(r.id)) { const keys = (this._te(r.id)?.tracks?.transform) || [];
for (const k of keys) {
const x = this._t2x(k.t); const x = this._t2x(k.t);
c.fillStyle = '#7aa2f7'; this._diamond(c, x, cy, KEY_R, '#7aa2f7', this._selected(k));
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 }); this._hits.push({ kind: 'key', id: r.id, track: 'transform', key: k, x, y: cy });
} }
} }
_paramColor(name) { let h = 0; for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) % 360; return `hsl(${h},55%,62%)`; }
_drawParams(c, r, y) {
const cy = y + ROW_H / 2;
const keys = (this._te(r.id)?.tracks?.params) || [];
for (const k of keys) {
const x = this._t2x(k.t);
this._diamond(c, x, cy, 4, this._paramColor(k.key), this._selected(k));
this._hits.push({ kind: 'key', id: r.id, track: 'params', key: k, x, y: cy });
}
}
_drawClips(c, r, y) { _drawClips(c, r, y) {
const e = this._entity(r.id); const blocks = (this._te(r.id)?.tracks?.clips) || [];
const blocks = (e && e.tracks && e.tracks.clips) || [];
for (const b of blocks) { for (const b of blocks) {
const span = (b.out - b.in) || 0.0001, end = b.start + span * (b.loop || 1); 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); const x0 = this._t2x(b.start), x1 = this._t2x(end), bw = Math.max(2, x1 - x0);
c.fillStyle = '#3d5a80'; c.fillRect(x0, y + 5, Math.max(2, x1 - x0), ROW_H - 10); c.fillStyle = '#3d5a80'; c.fillRect(x0, y + 5, bw, ROW_H - 10);
c.strokeStyle = '#98c1d9'; c.strokeRect(x0 + 0.5, y + 5.5, Math.max(2, x1 - x0) - 1, ROW_H - 11); c.strokeStyle = '#98c1d9'; c.strokeRect(x0 + 0.5, y + 5.5, bw - 1, ROW_H - 11);
if (b.fade > 0) { // fade-out wedge at the tail
const fx = this._t2x(end - b.fade);
c.fillStyle = 'rgba(231,175,104,0.35)'; c.fillRect(fx, y + 5, x1 - fx, ROW_H - 10);
}
c.fillStyle = '#e0e6ed'; c.font = '10px ui-monospace'; 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.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(); c.fillText((b.path || '').split('/').pop(), 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: 'block', id: r.id, block: b, x0, x1, y });
this._hits.push({ kind: 'edge', id: r.id, block: b, side: 'out', x: x1, y }); this._hits.push({ kind: 'edge', id: r.id, block: b, side: 'out', x: x1, y });
} }
@ -211,8 +259,7 @@ export class TimelineUI {
const cy = y + ROW_H / 2; const cy = y + ROW_H / 2;
for (const cut of this.tl.cameraCuts) { for (const cut of this.tl.cameraCuts) {
const x = this._t2x(cut.t); const x = this._t2x(cut.t);
c.fillStyle = '#e0af68'; c.fillStyle = '#e0af68'; c.fillRect(x - 1, y + 4, 2, ROW_H - 8);
c.fillRect(x - 1, y + 4, 2, ROW_H - 8);
c.beginPath(); c.arc(x, cy, 4, 0, Math.PI * 2); c.fill(); 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); 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 }); this._hits.push({ kind: 'cut', cut, x, y: cy });
@ -221,9 +268,8 @@ export class TimelineUI {
// ---- interaction ---- // ---- interaction ----
_local(e) { const r = this.canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; } _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; } _snap(t, e) { return (e && e.altKey) || !this._snapOn ? t : Math.round(t * this.tl.fps) / this.tl.fps; }
_hitAt(x, y) { _hitAt(x, y) {
// prefer key/edge handles (small), then blocks
for (const h of this._hits) { 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; } 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; } else if (h.kind === 'edge') { if (Math.abs(h.x - x) < 5 && y > h.y && y < h.y + ROW_H) return h; }
@ -231,44 +277,67 @@ export class TimelineUI {
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; 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; return null;
} }
_keysInRect(x0, y0, x1, y1) {
const ax = Math.min(x0, x1), bx = Math.max(x0, x1), ay = Math.min(y0, y1), by = Math.max(y0, y1);
const out = [];
for (const h of this._hits) if (h.kind === 'key' && h.x >= ax && h.x <= bx && h.y >= ay && h.y <= by) out.push({ id: h.id, track: h.track, key: h.key });
return out;
}
_down(e) { _down(e) {
const { x, y } = this._local(e); const { x, y } = this._local(e);
if (y < RULER_H) { this._drag = { kind: 'seek' }; this.tl.seek(this._x2t(x)); return; } if (y < RULER_H) { this._drag = { kind: 'seek' }; this.tl.seek(this._x2t(x)); return; }
const h = this._hitAt(x, y); const h = this._hitAt(x, y);
if (!h) { this._drag = { kind: 'seek' }; this.tl.seek(this._x2t(x)); return; } if (!h) { this._drag = { kind: 'box', x0: x, y0: y, x, y, moved: false }; return; } // empty → box/seek
if (h.kind === 'key') this._drag = { kind: 'key', h }; if (h.kind === 'key') {
else if (h.kind === 'edge') this._drag = { kind: 'trim', h }; if (this._selected(h.key) && this._selKeys.length > 1) {
this._drag = { kind: 'multikey', grabT: this._x2t(x), orig: this._selKeys.map((s) => ({ ...s, t0: s.key.t })) };
} else { this._selKeys = []; 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 === 'block') this._drag = { kind: 'block', h, grabT: this._x2t(x) - h.block.start };
else if (h.kind === 'cut') this._drag = { kind: 'cut', h }; else if (h.kind === 'cut') this._drag = { kind: 'cut', h };
} }
_move(e) { _move(e) {
if (!this._drag) return; if (!this._drag) return;
const { x } = this._local(e); const { x, y } = this._local(e);
const t = this._x2t(x), d = this._drag; const t = this._x2t(x), d = this._drag;
if (d.kind === 'seek') this.tl.seek(t); 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 === 'key') { this.tl.moveKey(d.h.id, d.h.track, d.h.key, this._snap(t, e)); this.draw(); }
else if (d.kind === 'multikey') { for (const s of d.orig) this.tl.moveKey(s.id, s.track, s.key, this._snap(s.t0 + (t - d.grabT), 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 === '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 === '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(); } 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(); }
else if (d.kind === 'box') { d.x = x; d.y = y; d.moved = Math.abs(x - d.x0) > 4 || Math.abs(y - d.y0) > 4; this.draw(); }
}
_up() {
const d = this._drag; this._drag = null;
if (!d) return;
if (d.kind === 'box') {
if (!d.moved) this.tl.seek(this._x2t(d.x0)); // click on empty = seek
else { this._selKeys = this._keysInRect(d.x0, d.y0, d.x, d.y); this.draw(); }
}
} }
_up() { this._drag = null; }
_dbl(e) { _dbl(e) {
const { x, y } = this._local(e); const { x, y } = this._local(e);
const i = this._rowAtY(y); if (i < 0) return; const i = this._rowAtY(y); if (i < 0) return;
const r = this.rows[i]; const r = this.rows[i];
if (r.type !== 'transform') return;
const t = this._snap(this._x2t(x), e); const t = this._snap(this._x2t(x), e);
const cur = this.stage.entityTransform(r.id); if (r.type === 'transform') {
this.tl.addKey(r.id, 'transform', { t, pos: cur.pos, rot: cur.rot, scale: cur.scale, ease: 'inout' }); const cur = this.stage.entityTransform(r.id);
this.draw(); this.tl.addKey(r.id, 'transform', { t, pos: cur.pos, rot: cur.rot, scale: cur.scale, ease: 'inout' });
this.draw();
} else if (r.type === 'cameras') {
const cams = this.stage.entities().filter((en) => en.kind === 'camera');
const cam = this.tl._activeCam || (cams[0] && cams[0].id);
if (cam) { this.tl.addCut(t, cam); this.tl.seek(this.tl.time); this.draw(); }
}
} }
_ctx(e) { _ctx(e) {
e.preventDefault(); e.preventDefault();
const { x, y } = this._local(e); const { x, y } = this._local(e);
const h = this._hitAt(x, y); const h = this._hitAt(x, y);
if (h && h.kind === 'key') { this.tl.deleteKey(h.id, 'transform', h.key); this.draw(); } if (h && h.kind === 'key') { this.tl.deleteKey(h.id, h.track, h.key); this._selKeys = this._selKeys.filter((s) => s.key !== 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(); } 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) { _key(e) {
@ -277,6 +346,11 @@ export class TimelineUI {
else if (e.key === 'Home') this.tl.seek(0); else if (e.key === 'Home') this.tl.seek(0);
else if (e.key === 'End') this.tl.seek(this.tl.duration); 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(); } else if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); this.tl.undo(); this.draw(); }
else if ((e.key === 'Delete' || e.key === 'Backspace') && this._selKeys.length) {
e.preventDefault();
for (const s of this._selKeys) this.tl.deleteKey(s.id, s.track, s.key);
this._selKeys = []; this.draw();
}
} }
_toggle() { this.tl.playing ? this.tl.pause() : this.tl.play(); this.draw(); } _toggle() { this.tl.playing ? this.tl.pause() : this.tl.play(); this.draw(); }
@ -291,7 +365,7 @@ export class TimelineUI {
}); });
if (!res.ok) throw new Error(res.status); if (!res.ok) throw new Error(res.status);
} catch { } catch {
localStorage.setItem('scenegod:scene:' + name, JSON.stringify(json)); // fallback until Lane C lands localStorage.setItem('scenegod:scene:' + name, JSON.stringify(json)); // fallback until Lane C reachable
} }
} }
async load(name) { async load(name) {
@ -301,11 +375,8 @@ export class TimelineUI {
if (!json) { const raw = localStorage.getItem('scenegod:scene:' + name); if (raw) json = JSON.parse(raw); } if (!json) { const raw = localStorage.getItem('scenegod:scene:' + name); if (raw) json = JSON.parse(raw); }
if (!json) return; if (!json) return;
if (this.stage.applyState) await this.stage.applyState(json); if (this.stage.applyState) await this.stage.applyState(json);
this.tl.load(json); this.tl.load(json); // fires onLoad → _syncRows + _refreshBar
await this.tl.preload(); await this.tl.preload();
this.$name.value = json.name || name;
this.$dur.value = this.tl.duration;
this._syncRows();
this.draw(); this.draw();
} }
} }