SCENEGOD/scenegod/web/tlui.js
type-two 16eb96a60e [m7-B] New-from-template picker + ? shortcuts overlay (M7 complete)
Lane B: New... in the scene bar fetches the templates API and applies through
a new single apply path (applyScene = stage.applyState + load + preload, now
shared with Load), confirm-before-clobber via hasContent(), thumbs, toasts on
failure. ? overlay documents 24 real bindings read out of the code across
STAGE/TIMELINE/SCENE BAR, ignores ? while typing. Audio head-trim (left edge
eats into the source, one grouped undo). applyScene strips the template key so
saved scenes stay plain scene JSON.

Orchestrator SYNC-9 fix: help overlay dt column was auto-width + nowrap, so
long terms overflowed and the three columns overlapped visually.

Verified live: picker lists 3 (2 thumbs), no confirm when empty / confirms when
dirty, loads 7 entities + 2 cuts with bar refresh, no template key in toJSON,
overlay opens on ? and Escape closes, no hijack while typing. 5 suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 22:47:29 +10:00

759 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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). Time axis fits to width at zoom 1; Cmd/Ctrl+wheel
// zooms around the cursor, plain wheel is left to the page (scroll), and
// trackpad horizontal wheel pans when zoomed in.
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 select{background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;padding:2px 4px;border-radius:3px;font:inherit}
#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-names .row input.gain{width:38px;margin-left:auto;background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;font:10px ui-monospace;border-radius:3px;padding:1px 2px}
#timeline .tl-names .row button.lip{margin-left:4px;background:#21262d;border:1px solid #2b323c;border-radius:3px;color:#c9d1d9;cursor:pointer;font-size:11px;padding:0 4px}
#timeline .tl-names .row button.lip:hover{background:#2b323c}
#timeline .tl-lanes{flex:1;position:relative;overflow:hidden}
#timeline canvas{display:block;width:100%;height:100%}
.tl-menu{position:fixed;z-index:9999;background:#12161c;border:1px solid #2b323c;border-radius:4px;max-height:240px;overflow:auto;font:12px ui-monospace;color:#c9d1d9;min-width:160px}
.tl-menu .item{padding:4px 10px;cursor:pointer;white-space:nowrap}
.tl-menu .item:hover{background:#2b323c}
.tl-menu .empty{padding:6px 10px;color:#565f6b}
.tl-menu.tmpl{max-width:420px}
.tl-menu.tmpl .item{display:flex;gap:9px;align-items:flex-start;padding:8px 10px;white-space:normal;border-bottom:1px solid #1b2027}
.tl-menu.tmpl .item img{width:68px;height:42px;flex:none;object-fit:cover;border-radius:3px;background:#0d1117}
.tl-menu.tmpl .t{color:#e0e6ed}
.tl-menu.tmpl .d{color:#8b949e;font-size:11px;line-height:1.35;margin-top:2px}
.tl-help{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:10001;background:#12161c;border:1px solid #2b323c;border-radius:6px;box-shadow:0 10px 44px #000b;padding:14px 18px 16px;color:#c9d1d9;font:12px/1.5 ui-monospace,Menlo,monospace;max-width:min(880px,94vw);max-height:84vh;overflow:auto}
.tl-help h3{margin:0 0 2px;font-size:13px;color:#7aa2f7}
.tl-help .hint{color:#565f6b;margin-bottom:10px}
.tl-help .cols{display:flex;gap:28px;flex-wrap:wrap;align-items:flex-start}
.tl-help section{min-width:250px;flex:1}
.tl-help h4{margin:8px 0 4px;font-size:11px;color:#e0af68;letter-spacing:.07em}
/* SYNC9: dt was auto-width + nowrap, so long terms ("drag an animation onto a
character") blew past the grid cell and the three columns overlapped. Cap the
term column and let it wrap. */
.tl-help dl{display:grid;grid-template-columns:minmax(0,11em) minmax(0,1fr);gap:3px 12px;margin:0}
.tl-help dt{color:#57c99a;white-space:normal;overflow-wrap:anywhere}
.tl-help dd{margin:0;color:#8b949e}
.tl-help .close{position:absolute;top:6px;right:10px;background:none;border:0;color:#565f6b;font:14px ui-monospace;cursor:pointer}
.tl-help .close:hover{color:#c9d1d9}
.tl-toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:10000;background:#12161c;border:1px solid #e0af68;color:#e0e6ed;padding:6px 14px;border-radius:4px;font:12px ui-monospace;max-width:70vw}
`;
export class TimelineUI {
constructor(timeline, mountEl) {
this.tl = timeline;
this.stage = timeline.stage;
this.el = typeof mountEl === 'string' ? document.querySelector(mountEl) : mountEl;
// REVIEW-FIX: shared failure surface — timeline._fail (and any other module)
// toasts through here instead of dying silently in the console.
window.sgToast = (m) => this._toast(m);
this._hits = [];
this._drag = null;
this._selKeys = []; // box-selected keys: [{id, track, key}]
this._snapOn = true;
this._snapStep = 'frame'; // 'frame' | seconds string
this._zoom = 1; // 1 = fit-to-width; Cmd/Ctrl+wheel zooms
this._panT = 0; // left edge of the view, in seconds
this._audioBuf = new Map(); // path -> decoded AudioBuffer
this._audioSrcs = []; // live BufferSourceNodes during playback
this._wasPlaying = false;
this._injectCSS();
this._build();
this.tl.onTick(() => { // natural end-of-play stops audio (timeline auto-pauses)
this.draw();
if (this._wasPlaying && !this.tl.playing) this._audioStop();
this._wasPlaying = this.tl.playing;
});
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 = `
<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}">
<label><input type="checkbox" class="snap" checked> snap</label>
<select class="snapstep" title="snap increment">
<option value="frame">frame</option><option value="0.1">0.1s</option>
<option value="0.25">0.25s</option><option value="1">1s</option></select>
<button data-act="audio" title="add audio clip at playhead">🎵</button>
<span class="spring"></span>
<button data-act="help" title="keyboard shortcuts (?)">?</button>
<button data-act="tmpl" title="start a new scene from a starter template">New…</button>
<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.$snap = bar.querySelector('.snap');
this.$snapStep = bar.querySelector('.snapstep');
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.$audioBtn = bar.querySelector('[data-act="audio"]');
this.$audioBtn.onclick = () => this._pickAudio();
this.$tmplBtn = bar.querySelector('[data-act="tmpl"]');
this.$tmplBtn.onclick = () => this._pickTemplate();
this.$helpBtn = bar.querySelector('[data-act="help"]');
this.$helpBtn.onclick = () => this._toggleHelp();
this.$dur.onchange = () => this.tl.setDuration(this.$dur.value); // clamps + refreshes bar
this.$name.onchange = () => { this.tl.scene.name = this.$name.value; };
this.$snap.onchange = () => { this._snapOn = this.$snap.checked; };
this.$snapStep.onchange = () => { this._snapStep = this.$snapStep.value; };
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));
this.canvas.addEventListener('wheel', (e) => this._wheel(e), { passive: false });
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?.params?.length > 0) || e.kind === 'camera' || e.kind === 'light';
if (paramable) rows.push({ type: 'params', id: e.id, label: '↳ params' });
// viseme lane only when the character actually has morph targets (M4-B)
const hasMorphs = (e.tracks?.morphs?.length > 0) ||
(e.kind === 'character' && this.stage.visemeTargets && (this.stage.visemeTargets(e.id) || []).length > 0);
if (hasMorphs) rows.push({ type: 'morphs', id: e.id, label: '↳ visemes' });
}
rows.push({ type: 'cameras', label: 'Cameras' });
for (const clip of (this.tl.scene.audio || [])) {
rows.push({ type: 'audio', clip, label: '🎵 ' + (clip.path || '').split('/').pop() });
}
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' || r.type === 'morphs' || r.type === 'audio';
d.className = 'row' + (i === 0 ? ' head' : '') + (sub ? ' sub' : '');
if (r.type === 'transform') d.innerHTML = `<span class="k">${(r.kind || '?')[0].toUpperCase()}</span>${r.label}`;
else if (r.type === 'audio') {
d.append(r.label);
const g = document.createElement('input');
g.type = 'number'; g.className = 'gain'; g.step = '0.1'; g.min = '0'; g.title = 'gain';
g.value = r.clip.gain ?? 1;
g.onchange = () => this.tl.setAudioGain(r.clip, Math.max(0, +g.value || 0));
d.appendChild(g);
const lip = document.createElement('button'); // M5: lip-sync from this audio
lip.className = 'lip'; lip.textContent = '🗣';
lip.title = 'lip-sync: rhubarb this audio → viseme keys on a character';
lip.onclick = () => this._lipSync(r.clip, lip);
d.appendChild(lip);
} 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)) * this._zoom };
}
_x2t(x) { const { pps } = this._dims(); return Math.max(0, Math.min(this.tl.duration, this._panT + x / pps)); }
_t2x(t) { const { pps } = this._dims(); return (t - this._panT) * pps; }
// UX (session-5 nit): plain wheel is NOT captured — the page scrolls as users
// expect. Cmd/Ctrl+wheel (incl. trackpad pinch → ctrlKey) zooms around the
// cursor; horizontal wheel pans when zoomed in.
_wheel(e) {
const zoomG = e.ctrlKey || e.metaKey;
const panG = this._zoom > 1 && Math.abs(e.deltaX) > Math.abs(e.deltaY);
if (!zoomG && !panG) return;
e.preventDefault();
const { x } = this._local(e);
const { w, pps } = this._dims();
if (zoomG) {
const tAt = this._panT + x / pps; // time under the cursor
this._zoom = Math.max(1, Math.min(64, this._zoom * Math.pow(1.0015, -e.deltaY)));
this._panT = tAt - x / ((w / (this.tl.duration || 1)) * this._zoom); // keep it under the cursor
} else {
this._panT += e.deltaX / pps;
}
const view = (this.tl.duration || 1) / this._zoom; // visible seconds
this._panT = Math.max(0, Math.min(this.tl.duration - view, this._panT));
this.draw();
}
_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 === 'morphs') this._drawMorphs(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 === 'audio') this._drawAudio(c, r, 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 });
}
}
_drawMorphs(c, r, y) {
const cy = y + ROW_H / 2;
const keys = (this._te(r.id)?.tracks?.morphs) || [];
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: 'morphs', key: k, x, y: cy });
}
}
_drawAudio(c, r, y) {
const clip = r.clip;
const srcDur = this._audioDur(clip.path);
const inT = clip.in || 0; // trim within the source (like clip blocks)
const outT = clip.out ?? srcDur;
const len = outT != null ? Math.max(0.05, outT - inT) : null;
const x0 = this._t2x(clip.start);
const x1 = len != null ? this._t2x(clip.start + len) : x0 + 60; // fallback width until decoded
const bw = Math.max(3, x1 - x0);
c.fillStyle = '#2e7d5b'; c.fillRect(x0, y + 5, bw, ROW_H - 10);
c.strokeStyle = '#57c99a'; c.strokeRect(x0 + 0.5, y + 5.5, bw - 1, ROW_H - 11);
c.fillStyle = '#dff5ea'; c.font = '10px ui-monospace';
c.save(); c.beginPath(); c.rect(x0, y, bw, ROW_H); c.clip();
const trimmed = clip.in != null || clip.out != null;
c.fillText((trimmed ? '✂ ' : '') + (clip.path || '').split('/').pop() + (srcDur == null ? ' …' : ''), x0 + 4, y + ROW_H / 2 + 3); c.restore();
this._hits.push({ kind: 'audioblock', clip, x0, x1, y });
if (len != null) { // drag an edge = trim (head / tail)
this._hits.push({ kind: 'aedge', clip, side: 'out', x: x1, y });
this._hits.push({ kind: 'aedge', clip, side: 'in', x: x0, y });
}
}
// ---- interaction ----
_local(e) { const r = this.canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
_snap(t, e) {
if ((e && e.altKey) || !this._snapOn) return t;
const step = this._snapStep === 'frame' ? 1 / this.tl.fps : (+this._snapStep || 1 / this.tl.fps);
return Math.round(t / step) * step;
}
_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' || h.kind === 'aedge') { 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' || h.kind === 'audioblock') && x >= h.x0 && x <= h.x1 && y > h.y && y < h.y + ROW_H) return h;
return null;
}
_seek(t) { this.tl.seek(t); if (this.tl.playing) this._audioStart(); } // user seek: reschedule audio
_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._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 === 'aedge') this._drag = { kind: 'atrim', h };
else if (h.kind === 'block') this._drag = { kind: 'block', h, grabT: this._x2t(x) - h.block.start };
else if (h.kind === 'audioblock') this._drag = { kind: 'audio', h, grabT: this._x2t(x) - h.clip.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._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 === 'audio') { this.tl.moveAudio(d.h.clip, 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 === 'atrim') {
const cl = d.h.clip, srcDur = this._audioDur(cl.path);
if (d.h.side === 'in') this.tl.trimAudioIn(cl, this._snap(t, e), srcDur); // head trim: in + start together
else {
let out = (cl.in || 0) + (t - cl.start);
if (srcDur != null) out = Math.min(out, srcDur);
this.tl.trimAudio(cl, { out });
}
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._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 === 'params') {
// capture every keyable param at its current value (mirrors transform capture)
const params = (this._entity(r.id)?.params) || {};
for (const [key, value] of Object.entries(params)) {
if (key === 'type' || typeof value === 'boolean') continue; // non-keyable descriptors
this.tl.addKey(r.id, 'params', { t, key, value, 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(); }
else if (h && h.kind === 'audioblock') { this.tl.removeAudio(h.clip); this._syncRows(); }
}
_key(e) {
if (e.target && (/INPUT|TEXTAREA|SELECT/.test(e.target.tagName) || e.target.isContentEditable)) return;
if (e.key === '?') { e.preventDefault(); this._toggleHelp(); } // never hijacked while typing
else if (e.key === 'Escape') this._helpClose();
else if (e.code === 'Space') { e.preventDefault(); this._toggle(); }
else if (e.key === 'Home') this._seek(0);
else if (e.key === 'End') this._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() {
if (this.tl.playing) { this.tl.pause(); this._audioStop(); }
else { this.tl.play(); this._audioStart(); }
this._wasPlaying = this.tl.playing;
this.draw();
}
// ---- Web Audio (draft playback synced to the master clock). step()/render
// NEVER touch this — the server muxes from scene.audio deterministically. ----
_ensureCtx() {
if (!this._audioCtx) { const C = window.AudioContext || window.webkitAudioContext; if (C) this._audioCtx = new C(); }
return this._audioCtx;
}
_audioDur(path) { const b = this._audioBuf.get(path); return b ? b.duration : null; }
async _decode(path) {
if (this._audioBuf.has(path)) return this._audioBuf.get(path);
const ctx = this._ensureCtx(); if (!ctx) return null;
try {
const res = await fetch('assets/file?path=' + encodeURIComponent(path));
if (!res.ok) throw new Error(res.status);
const buf = await ctx.decodeAudioData(await res.arrayBuffer());
this._audioBuf.set(path, buf);
this.draw(); // width now known
return buf;
} catch { return null; } // keep the block at fallback width
}
_decodeAll() { for (const clip of (this.tl.scene.audio || [])) this._decode(clip.path); }
_audioStart() {
const ctx = this._ensureCtx(); if (!ctx) return;
if (ctx.state === 'suspended') ctx.resume();
this._audioStop();
const now = ctx.currentTime, t = this.tl.time;
for (const clip of (this.tl.scene.audio || [])) {
const buf = this._audioBuf.get(clip.path); if (!buf) continue;
const inT = Math.min(clip.in || 0, buf.duration); // trim within source
const len = Math.min(clip.out ?? buf.duration, buf.duration) - inT;
const offset = Math.max(0, t - clip.start); // seconds into the (trimmed) block at playhead
if (len <= 0 || offset >= len) continue; // already finished
const src = ctx.createBufferSource(); src.buffer = buf;
const g = ctx.createGain(); g.gain.value = clip.gain ?? 1;
src.connect(g).connect(ctx.destination);
const when = now + Math.max(0, clip.start - t);
src.start(when, inT + offset, len - offset);
this._audioSrcs.push(src);
}
}
_audioStop() { for (const s of this._audioSrcs) { try { s.stop(); } catch { /* already stopped */ } } this._audioSrcs = []; }
async _pickAudio() {
let list = [];
try { const r = await fetch('assets/tree'); if (r.ok) list = (await r.json()).audio || []; } catch { /* offline */ }
const menu = document.createElement('div'); menu.className = 'tl-menu';
if (!list.length) menu.innerHTML = '<div class="empty">no audio in /assets/tree</div>';
for (const it of list) {
const path = it.path || it;
const item = document.createElement('div'); item.className = 'item';
item.textContent = (it.name || path).split('/').pop();
item.onclick = async () => {
this.tl.addAudio({ path, start: this.tl.time, gain: 1 });
menu.remove();
await this._decode(path);
this._syncRows();
};
menu.appendChild(item);
}
document.body.appendChild(menu);
const rect = this.$audioBtn.getBoundingClientRect();
menu.style.left = rect.left + 'px'; menu.style.top = (rect.bottom + 2) + 'px';
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
setTimeout(() => document.addEventListener('mousedown', close), 0);
}
// ---- M7-B: new from template (Lane C's read-only /templates) ----
// All URLs stay RELATIVE — the app is served under /scenegod/ in prod.
async _pickTemplate() {
let list = [];
try { const r = await fetch('templates'); if (r.ok) list = await r.json(); } catch { /* offline */ }
const menu = document.createElement('div'); menu.className = 'tl-menu tmpl';
if (!Array.isArray(list) || !list.length) menu.innerHTML = '<div class="empty">no starter templates on the server</div>';
for (const t of (list || [])) {
const item = document.createElement('div'); item.className = 'item';
if (t.thumb) {
const img = document.createElement('img');
img.src = 'assets/file?path=' + encodeURIComponent(t.thumb);
img.onerror = () => img.remove(); // thumb missing → text-only row
item.appendChild(img);
}
const txt = document.createElement('div');
const ti = document.createElement('div'); ti.className = 't'; ti.textContent = t.title || t.name;
txt.appendChild(ti);
if (t.description) { const d = document.createElement('div'); d.className = 'd'; d.textContent = t.description; txt.appendChild(d); }
item.appendChild(txt);
item.onclick = () => { menu.remove(); this.newFromTemplate(t.name); };
menu.appendChild(item);
}
document.body.appendChild(menu);
const rect = this.$tmplBtn.getBoundingClientRect();
menu.style.top = (rect.bottom + 2) + 'px';
menu.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - menu.offsetWidth - 6)) + 'px';
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
setTimeout(() => document.addEventListener('mousedown', close), 0);
}
// Applies exactly like load(): stage.applyState → tl.load → preload (all three
// inside tl.applyScene, which also strips the presentation-only `template` key).
async newFromTemplate(name) {
if (this.tl.hasContent() && !confirm('Replace the current scene with this template? Unsaved work is lost.')) return;
let json;
try {
const res = await fetch('templates/' + encodeURIComponent(name));
if (!res.ok) throw new Error(res.status);
json = await res.json();
} catch (e) { this._toast('template "' + name + '" failed to load: ' + ((e && e.message) || e)); return; }
try { await this.tl.applyScene(json); }
catch (e) { this._toast('template failed to apply: ' + ((e && e.message) || e)); return; }
this._decodeAll();
this.draw();
}
// ---- M7-B: ? shortcuts overlay. Documents what the code ACTUALLY binds
// (stage.js gizmo keys + dock drops + everything in _key/_down/_dbl/_ctx/
// _wheel below). Toggle with ?, close with Escape or a click outside. ----
_toggleHelp() {
if (this._help) { this._helpClose(); return; }
const S = (h, rows) => `<section><h4>${h}</h4><dl>` +
rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('') + '</dl></section>';
const el = document.createElement('div'); el.className = 'tl-help';
el.innerHTML = `<button class="close" title="close">✕</button>
<h3>SCENEGOD shortcuts</h3><div class="hint">? toggles this panel · Escape closes it</div><div class="cols">` +
S('STAGE', [
['drag / right-drag / wheel', 'orbit · pan · dolly the director cam'],
['click', 'select an object'],
['W / E / R', 'gizmo: move · rotate · scale (on the selection)'],
['drag a dock card in', 'place a character, prop or backdrop'],
['drag an animation onto a character', 'retarget + drop a clip block at the playhead'],
['drop files on the page', 'local glb/fbx/image/video (upload — not saved)'],
]) +
S('TIMELINE', [
['Space', 'play / pause'],
['Home / End', 'jump to start / end'],
['click or drag the ruler', 'scrub'],
['double-click a transform lane', 'key the object where it stands now'],
['double-click a params lane', 'key its current params (fov, intensity, colour…)'],
['double-click the Cameras row', 'cut to the active camera'],
['drag a key', 'move it — snaps to the increment, Alt = free'],
['drag empty lane space', 'box-select keys; drag one of them to move them all'],
['Delete / Backspace', 'delete the selected keys'],
['right-click', 'delete a key, a cut, or an audio block'],
['drag a clip / audio block', 'move it; drag an edge to trim (audio trims either end)'],
['Cmd/Ctrl + wheel', 'zoom the time axis 164× around the cursor'],
['two-finger sideways', 'pan when zoomed in (plain wheel scrolls the page)'],
['Cmd/Ctrl + Z', 'undo — one director preset = one undo'],
]) +
S('SCENE BAR', [
['New…', 'start from a starter template (asks before clobbering)'],
['Save / Load', 'scenes by name (falls back to localStorage offline)'],
['dur / snap', 'scene length · snap increment for drags'],
['🎵 / 🗣', 'add audio at the playhead · lip-sync that clip onto a character'],
]) + '</div>';
el.querySelector('.close').onclick = () => this._helpClose();
document.body.appendChild(el);
this._help = el;
// the ? button closes via _toggleHelp — don't let click-outside close first,
// or the button would just reopen the panel on every click.
this._helpAway = (ev) => { if (!el.contains(ev.target) && ev.target !== this.$helpBtn) this._helpClose(); };
setTimeout(() => document.addEventListener('mousedown', this._helpAway), 0);
}
_helpClose() {
if (!this._help) return;
document.removeEventListener('mousedown', this._helpAway);
this._help.remove(); this._help = null;
}
// ---- M5: lip-sync from audio (🗣 on an audio row) ----
// GET /rhubarb?path=… → importRhubarb onto a viseme-bearing character.
// >1 candidate → picker menu; 503 → toast the install hint from `detail`.
async _lipSync(clip, anchorEl) {
const vt = (id) => (this.stage.visemeTargets && this.stage.visemeTargets(id)) || [];
const chars = this.stage.entities().filter((en) => en.kind === 'character' && vt(en.id).length > 0);
if (!chars.length) { this._toast('no character with viseme morph targets in the scene'); return; }
const id = chars.length === 1 ? chars[0].id
: await this._choose(chars.map((c) => ({ label: c.label || c.id, value: c.id })), anchorEl);
if (!id) return;
let res;
try { res = await fetch('rhubarb?path=' + encodeURIComponent(clip.path)); }
catch { this._toast('server unreachable — /rhubarb needs Lane C running'); return; }
if (!res.ok) {
let msg = 'rhubarb failed: ' + res.status;
try { const j = await res.json(); if (j.detail) msg = j.detail; } catch { /* non-json body */ }
this._toast(msg); return; // 503 carries the rhubarb install hint
}
const json = await res.json();
this.tl.importRhubarb(id, json, clip.start || 0); // grouped: one undo reverts the import
this._toast(`🗣 ${(json.mouthCues || []).length} cues → viseme keys on ${id}`);
this.tl.seek(this.tl.time);
this._syncRows(); // viseme lane may be new
}
_choose(items, anchorEl) { // small promise-menu (resolves value | null)
return new Promise((resolve) => {
const menu = document.createElement('div'); menu.className = 'tl-menu';
for (const it of items) {
const el = document.createElement('div'); el.className = 'item'; el.textContent = it.label;
el.onclick = () => { menu.remove(); resolve(it.value); };
menu.appendChild(el);
}
document.body.appendChild(menu);
const rect = anchorEl.getBoundingClientRect();
menu.style.left = rect.left + 'px'; menu.style.top = (rect.bottom + 2) + 'px';
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); resolve(null); } };
setTimeout(() => document.addEventListener('mousedown', close), 0);
});
}
_toast(msg) {
const d = document.createElement('div'); d.className = 'tl-toast'; d.textContent = msg;
document.body.appendChild(d);
setTimeout(() => d.remove(), 4000);
}
// ---- 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;
await this.tl.applyScene(json); // stage.applyState → tl.load (onLoad → rows+bar) → preload
this._decodeAll(); // decode audio for lane widths + playback
this.draw();
}
}
export default TimelineUI;