SCENEGOD/scenegod/web/tlui.js
type-two 45debd3c10 [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>
2026-07-18 17:57:18 +10:00

314 lines
14 KiB
JavaScript

// 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;