// 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 a later problem. 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 (orchestrator confirmed this is permanent). 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 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: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} #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.sub{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._selKeys = []; // box-selected keys: [{id, track, key}] this._snapOn = true; this._injectCSS(); this._build(); this.tl.onTick(() => this.draw()); this.tl.onLoad(() => { this._syncRows(); this._refreshBar(); }); // SYNC1 #3a/#3b if (this.stage.onChange) this.stage.onChange(() => this._syncRows()); this._syncRows(); this.draw(); } _refreshBar() { if (this.$name) this.$name.value = this.tl.scene.name || ''; if (this.$dur) this.$dur.value = this.tl.duration; } _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 = ''; 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.$snap = bar.querySelector('.snap'); 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; }; this.$snap.onchange = () => { this._snapOn = this.$snap.checked; }; 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; 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'|'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() { const rows = []; 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 }); 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' }); return rows; } _syncRows() { this.rows = this._rows(); this.$names.innerHTML = ''; this.rows.forEach((r, i) => { const d = document.createElement('div'); const sub = r.type === 'clip' || r.type === 'params'; d.className = 'row' + (i === 0 ? ' head' : '') + (sub ? ' sub' : ''); if (r.type === 'transform') d.innerHTML = `${(r.kind || '?')[0].toUpperCase()}${r.label}`; else d.textContent = r.label; 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(); } // ---- 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.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.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); } // 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 === 'params') this._drawParams(c, r, y); else if (r.type === 'clip') this._drawClips(c, r, 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 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); } // stage: live transform/existence _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) { const cy = y + ROW_H / 2; const keys = (this._te(r.id)?.tracks?.transform) || []; for (const k of keys) { const x = this._t2x(k.t); this._diamond(c, x, cy, KEY_R, '#7aa2f7', this._selected(k)); 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) { const blocks = (this._te(r.id)?.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), bw = Math.max(2, x1 - x0); c.fillStyle = '#3d5a80'; c.fillRect(x0, y + 5, bw, ROW_H - 10); 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.save(); c.beginPath(); c.rect(x0, y, x1 - x0, ROW_H); c.clip(); 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: '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) || !this._snapOn ? t : Math.round(t * this.tl.fps) / this.tl.fps; } _hitAt(x, y) { 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; } _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) { 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: 'box', x0: x, y0: y, x, y, moved: false }; return; } // empty → box/seek if (h.kind === 'key') { 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 === 'cut') this._drag = { kind: 'cut', h }; } _move(e) { if (!this._drag) return; const { x, y } = 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, 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 === '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 === '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(); } } } _dbl(e) { const { x, y } = this._local(e); const i = this._rowAtY(y); if (i < 0) return; const r = this.rows[i]; const t = this._snap(this._x2t(x), e); if (r.type === 'transform') { 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(); } 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) { e.preventDefault(); const { x, y } = this._local(e); const h = this._hitAt(x, y); 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(); } } _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(); } 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(); } // ---- 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 reachable } } 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); // fires onLoad → _syncRows + _refreshBar await this.tl.preload(); this.draw(); } } export default TimelineUI;