SCENEGOD/scenegod/web/tlui.js
type-two 838edd0e1f [m10-B] auto-cut drops no-op kept cuts (orchestrator amendment)
A hand-placed cut is kept only if it genuinely changes the angle: the plan now
merges surviving cuts with the placed ones, walks the merged list tracking the
EFFECTIVE camera, and drops in-range user cuts whose camera equals it. Real
case: 4.50->cam2 became a no-op once auto-cut placed cam2 at 1.4161 - it
rendered as nothing but read as an edit in the saved file. Removed in the same
group() so it stays one undo; popover and toast report kept vs dropped; cuts
outside the placed range are still never touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 11:52:02 +10:00

1315 lines
69 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.
import { beatGridFrom, beatOffsetOf, alignShotsToGrid } from './timeline.js';
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}
/* M10-B1 auto-cut popover — a small form in the same menu shell */
.tl-menu.cut{padding:8px 10px 10px;min-width:230px;max-height:none}
.tl-menu.cut .hd{color:#e0af68;margin-bottom:6px}
.tl-menu.cut .fld{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:4px 0;color:#8b949e}
.tl-menu.cut label{display:flex;gap:4px;align-items:center;cursor:pointer;color:#c9d1d9}
.tl-menu.cut select{background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;border-radius:3px;font:inherit;padding:1px 4px}
.tl-menu.cut .cams{display:flex;flex-direction:column;gap:2px;max-height:120px;overflow:auto;margin:2px 0 6px 6px}
.tl-menu.cut .lbl{color:#565f6b}
.tl-menu.cut .cnt{color:#57c99a;margin:6px 0;white-space:normal;line-height:1.4}
.tl-menu.cut .cnt.err{color:#f7768e}
.tl-menu.cut .act{display:flex;gap:6px;justify-content:flex-end}
.tl-menu.cut .act button{background:#21262d;border:1px solid #2b323c;color:#c9d1d9;padding:3px 10px;border-radius:3px;cursor:pointer;font:inherit}
.tl-menu.cut .act button.go{border-color:#57c99a;color:#dff5ea}
.tl-menu.cut .act button:disabled{opacity:.45;cursor:default}
.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}
/* M9-B2 FILM panel — collapsible shot list, mounted inside #timeline */
#timeline .tl-film{border-top:1px solid #2b323c;background:#12161c;padding:6px 8px 8px}
#timeline .tl-film .frow{display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:3px 0}
#timeline .tl-film .frow.head{border-bottom:1px solid #1b2027;padding-bottom:6px;margin-bottom:4px}
#timeline .tl-film select,#timeline .tl-film input{background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;padding:2px 4px;border-radius:3px;font:11px ui-monospace}
#timeline .tl-film input.n{width:52px}
#timeline .tl-film button{background:#21262d;border:1px solid #2b323c;color:#c9d1d9;padding:2px 8px;border-radius:3px;cursor:pointer;font:11px ui-monospace}
#timeline .tl-film button:hover:not(:disabled){background:#2b323c}
#timeline .tl-film button:disabled{opacity:.45;cursor:default}
#timeline .tl-film button.go{border-color:#57c99a;color:#dff5ea}
#timeline .tl-film .spring{flex:1}
#timeline .tl-film .run{color:#7aa2f7}
#timeline .tl-film .fstat{color:#8b949e;max-width:46ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#timeline .tl-film .fstat.err{color:#f7768e;white-space:normal;max-width:none}
#timeline .tl-film .fstat a{color:#57c99a}
#timeline .tl-film .shot{color:#565f6b}
#timeline .tl-film .lbl{color:#565f6b}
`;
// Film runtime in seconds: shots minus the overlap every dissolve eats. Mirrors
// server.py plan_sequence (`sum(len) - sum(transitionDur of all but the last)`);
// exported pure so timeline_test.mjs can hold it to the server's arithmetic.
export function filmRuntime(shots) {
let total = 0;
(shots || []).forEach((s, i) => {
total += Math.max(0, (+s.out || 0) - (+s.in || 0));
if (i < shots.length - 1 && s.transition === 'dissolve') total -= Math.max(0, +s.transitionDur || 0);
});
return Math.max(0, total);
}
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._beatCache = new Map(); // path -> Lane C /beats response (analysis is expensive once, free after)
this._wasPlaying = false;
this._seq = null; // FILM panel: the sequence doc being edited
this._seqName = null;
this._filmBusy = false; // one film render at a time
this._injectCSS();
this._build();
this.tl.onTick(() => { // natural end-of-play stops audio (timeline auto-pauses)
this.draw();
if (this._cutRefresh) this._cutRefresh(); // "from the playhead" count stays honest while scrubbing
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 (beat/bar need a detected beat grid — ♪ on an audio row)">
<option value="frame">frame</option><option value="0.1">0.1s</option>
<option value="0.25">0.25s</option><option value="1">1s</option>
<option value="beat">beat</option><option value="bar">bar</option></select>
<button data-act="audio" title="add audio clip at playhead">🎵</button>
<button data-act="autocut" title="auto-cut: put camera cuts on the downbeats (needs a beat grid + 2 cameras)">✂</button>
<button data-act="film" title="FILM: shot list / sequences / render a multi-shot film">🎬</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.$cutBtn = bar.querySelector('[data-act="autocut"]');
this.$cutBtn.onclick = () => this._autoCutMenu();
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.$filmBtn = bar.querySelector('[data-act="film"]');
this.$filmBtn.onclick = () => this._toggleFilm();
this.$snap.onchange = () => { this._snapOn = this.$snap.checked; };
this.$snapStep.onchange = () => {
this._snapStep = this.$snapStep.value;
if ((this._snapStep === 'beat' || this._snapStep === 'bar') && !this.tl.scene.beatGrid)
this._toast('no beat grid yet — press ♪ on an audio row to detect one (snapping to frames until then)');
};
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._buildFilm(); // collapsed until 🎬 (mounts inside #timeline — Lane A's files stay untouched)
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 beat = document.createElement('button'); // M9-B1: beat grid from this audio
beat.className = 'lip'; beat.textContent = '♪';
beat.title = 'detect beats: build a beat grid from this clip → snap increment beat / bar';
beat.onclick = () => this._detectBeats(r.clip, beat);
d.appendChild(beat);
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);
});
this._drawBeatGrid(c, w, h);
// 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 ? '❚❚' : '▶';
}
// M9-B1: the beat grid over the lanes — faint on beats, brighter on downbeats.
// Drawn only while it's readable: under ~7px apart the lines are mush, so beats
// drop out first and then bars, rather than painting the panel solid.
_drawBeatGrid(c, w, h) {
const g = this.tl.scene.beatGrid;
if (!g) return;
const { pps } = this._dims();
const per = g.period || (g.bpm ? 60 / g.bpm : 0);
if (!(per > 0)) return;
const barGap = per * (g.meter || 4) * pps;
if (barGap < 7) return; // even bars would be mush → draw nothing
const line = (times, style) => {
c.strokeStyle = style; c.beginPath();
for (const t of times) {
const x = Math.round(this._t2x(t)) + 0.5;
if (x < 0 || x > w) continue;
c.moveTo(x, RULER_H); c.lineTo(x, h);
}
c.stroke();
};
if (per * pps >= 7) line(this.tl.gridTimes('beat'), 'rgba(146,180,232,0.13)');
line(this.tl.gridTimes('bar'), 'rgba(224,175,104,0.34)');
}
_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 }; }
// Everything that snaps goes through here — keys, clip/audio blocks, trims,
// cuts, the playhead — so beat/bar makes ALL of them land on the music.
_snap(t, e) {
if ((e && e.altKey) || !this._snapOn) return t;
if (this._snapStep === 'beat' || this._snapStep === 'bar') {
const s = this.tl.snapToGrid(t, this._snapStep);
if (s != null) return s; // no grid → fall through to frames
}
const step = (this._snapStep === 'frame' || isNaN(+this._snapStep))
? 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(); this._cutMenuClose(); }
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'],
['♪ on an audio row', 'detect the beat grid, then snap = beat / bar'],
]) +
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 — frame/0.1/0.25/1s, or beat/bar once ♪ found a grid'],
['🎵 / ♪ / 🗣', 'add audio at the playhead · beat-detect it · lip-sync it onto a character'],
['✂', 'auto-cut: camera cuts on the downbeats, cameras cycling — one undo'],
['🎬', 'FILM: shot list — sequences of scene windows, cut/dissolve, render to one mp4'],
['🎬 → ♪ align', 'snap every shot\'s in/out to the nearest downbeat (then Save)'],
]) + '</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;
}
// ---- M9-B1: SNAP TO THE BEAT (♪ on an audio row) ----
// GET beats?path= (Lane C) → grid stored on the scene → every existing snap
// (keys, blocks, trims, cuts, playhead) becomes musical. Cached per path: the
// first analysis of a 5-min track is ~0.8s on the server, cache hits are ~3ms,
// and re-pressing ♪ after moving the clip must not re-analyse.
async _detectBeats(clip, btnEl) {
const path = clip.path;
let res = this._beatCache.get(path);
if (!res) {
if (btnEl) { btnEl.disabled = true; btnEl.textContent = '…'; }
try {
const r = await fetch('beats?path=' + encodeURIComponent(path));
if (!r.ok) {
let msg = 'beat detection failed: ' + r.status;
try { const j = await r.json(); if (j.detail) msg = j.detail; } catch { /* non-json body */ }
this._toast(msg); return; // 503 carries "ffmpeg not found on server"
}
res = await r.json();
this._beatCache.set(path, res);
} catch { this._toast('server unreachable — beat detection needs Lane C running'); return; }
finally { if (btnEl) { btnEl.disabled = false; btnEl.textContent = '♪'; } }
}
if (!(res.beats || []).length) { this._toast(`no beats found in ${path.split('/').pop()}${res.note ? ' — ' + res.note : ''}`); return; }
this.tl.setBeatGrid(beatGridFrom(res, clip.start - (clip.in || 0)));
this._snapOn = true; this.$snap.checked = true; // pressing ♪ means "snap me to this"
this._snapStep = 'beat'; this.$snapStep.value = 'beat';
const conf = res.confidence ?? 0;
this._toast(conf < 0.35
? `${(+res.bpm).toFixed(2)} BPM but LOW confidence (${conf.toFixed(2)}) — this grid may be mush; check it against the audio before you cut to it. Snap = beat.`
: `${(+res.bpm).toFixed(2)} BPM · ${res.beats.length} beats · confidence ${conf.toFixed(2)} · snap = beat`);
this.draw();
}
// ---- M10-B1: AUTO-CUT (✂ in the scene bar) ----
// The popover is a preview, not a wizard: pick cadence / cameras / range and
// the line above Apply says exactly how many cuts you are about to get. Every
// refusal (no grid, <2 cameras, empty range) is the model's own message, shown
// in place of the count with Apply disabled — never a button that does nothing.
_autoCutMenu() {
if (this._cutMenu) { this._cutMenuClose(); return; }
const cams = this.tl.cameraIds();
const menu = document.createElement('div');
menu.className = 'tl-menu cut';
menu.innerHTML = `
<div class="hd">✂ auto-cut to the beat</div>
<label class="fld">cadence
<select class="cad">
<option value="1">every bar</option>
<option value="2" selected>every 2 bars</option>
<option value="4">every 4 bars</option>
<option value="8">every 8 bars</option>
</select></label>
<div class="fld">range
<label><input type="radio" name="sgcutrange" class="rall" checked> whole scene</label>
<label><input type="radio" name="sgcutrange" class="rph"> from the playhead</label>
</div>
<div class="fld">cameras <span class="lbl">(cycled in this order)</span></div>
<div class="cams"></div>
<div class="cnt"></div>
<div class="act"><button class="go">Apply</button><button class="no">Cancel</button></div>`;
const camHost = menu.querySelector('.cams');
for (const id of cams) {
const en = this.stage.getEntity(id) || this._te(id) || {};
const l = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = true; cb.value = id;
cb.onchange = () => refresh();
l.appendChild(cb); l.append(' ' + (en.label || id));
camHost.appendChild(l);
}
if (!cams.length) camHost.innerHTML = '<span class="lbl">no cameras in this scene</span>';
const opts = () => ({
cadence: +menu.querySelector('.cad').value,
cameras: [...menu.querySelectorAll('.cams input:checked')].map((c) => c.value),
from: menu.querySelector('.rph').checked ? this.tl.time : 0,
});
const go = menu.querySelector('.go');
const refresh = () => {
const plan = this.tl.autoCutPlan(opts());
const cnt = menu.querySelector('.cnt');
go.disabled = !plan.ok;
if (!plan.ok) { cnt.className = 'cnt err'; cnt.textContent = plan.error; return; }
const every = (plan.barPeriod * plan.cadence);
cnt.className = 'cnt';
cnt.textContent = `will place ${plan.cuts.length} cut${plan.cuts.length === 1 ? '' : 's'}`
+ (every > 0 ? ` · one every ${every.toFixed(2)}s` : '')
+ (plan.replaced.length ? ` · ${plan.replaced.length} existing replaced` : '')
+ (plan.kept.length ? ` · ${plan.kept.length} of your cut${plan.kept.length === 1 ? '' : 's'} kept` : '')
+ (plan.dropped.length ? ` · ${plan.dropped.length} dropped (no camera change)` : '')
+ (plan.confidence < 0.35 ? ` · LOW grid confidence (${plan.confidence.toFixed(2)})` : '');
};
menu.querySelector('.cad').onchange = refresh;
menu.querySelector('.rall').onchange = refresh;
menu.querySelector('.rph').onchange = refresh;
menu.querySelector('.no').onclick = () => this._cutMenuClose();
go.onclick = () => {
let plan;
try { plan = this.tl.autoCut(opts()); }
catch (e) { this._toast('auto-cut: ' + ((e && e.message) || e)); return; }
this._cutMenuClose();
this.draw();
this._toast(`${plan.cuts.length} cuts on the downbeats · every ${plan.cadence} bar${plan.cadence === 1 ? '' : 's'}`
+ ` · ${plan.cameras.length} cameras cycling`
+ (plan.dropped.length ? ` · dropped ${plan.dropped.length} no-op cut${plan.dropped.length === 1 ? '' : 's'} (cut to the camera already live)` : '')
+ (plan.confidence < 0.35 ? ` · LOW grid confidence (${plan.confidence.toFixed(2)}) — check it against the music` : '')
+ ' · Cmd/Ctrl+Z undoes the lot');
};
document.body.appendChild(menu);
const rect = this.$cutBtn.getBoundingClientRect();
menu.style.top = (rect.bottom + 2) + 'px';
menu.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - menu.offsetWidth - 6)) + 'px';
this._cutMenu = menu;
this._cutRefresh = refresh;
this._cutAway = (ev) => { if (!menu.contains(ev.target) && ev.target !== this.$cutBtn) this._cutMenuClose(); };
setTimeout(() => document.addEventListener('mousedown', this._cutAway), 0);
refresh();
}
_cutMenuClose() {
if (!this._cutMenu) return;
document.removeEventListener('mousedown', this._cutAway);
this._cutMenu.remove(); this._cutMenu = null; this._cutRefresh = 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);
}
// ---- M9-B2: FILM panel — the shot list a film is actually made of --------
// A scene is one take; a SEQUENCE is the film (Lane C, M8-C). Everything here
// goes through web/film.js's helpers so the URLs are never hand-rolled. film.js
// is imported LAZILY (first 🎬 press): the timeline panel must still mount if
// Lane C's client files are missing, and nobody who never opens FILM pays for
// render.js. Mounted inside #timeline — index.html is Lane A's file.
_buildFilm() {
const el = document.createElement('div');
el.className = 'tl-film';
el.hidden = true;
el.innerHTML = `
<div class="frow head">
<span class="lbl">FILM</span>
<select class="seq" title="sequence"></select>
<button data-f="new" title="create a sequence">New…</button>
<button data-f="ren" title="rename this sequence">Rename</button>
<button data-f="del" title="delete this sequence">Delete</button>
<span class="lbl">runtime</span><span class="run">—</span>
<span class="spring"></span>
<select class="size" title="render size">
<option value="1920x1080">1920×1080</option>
<option value="1280x720">1280×720</option>
<option value="854x480">854×480</option></select>
<button data-f="beat" title="align to beat: snap every shot's in/out to the nearest downbeat">♪ align</button>
<button data-f="save" title="save the shot list to the server">Save</button>
<button class="go" data-f="render" title="render every shot and concatenate">Render film</button>
</div>
<div class="shots"></div>
<div class="frow"><button data-f="add">+ shot</button><span class="fstat"></span></div>`;
this.el.appendChild(el);
this.$film = el;
this.$seq = el.querySelector('.seq');
this.$size = el.querySelector('.size');
this.$render = el.querySelector('[data-f="render"]');
this.$seq.onchange = () => this._filmSelect(this.$seq.value);
const on = (f, fn) => { el.querySelector(`[data-f="${f}"]`).onclick = fn; };
on('new', () => this._filmNew());
on('ren', () => this._filmRename());
on('del', () => this._filmDelete());
on('beat', () => this._filmAlign());
on('save', () => this._filmSave());
on('add', () => this._filmAddShot());
on('render', () => this._filmRender());
this._filmRows();
}
_toggleFilm() {
this.$film.hidden = !this.$film.hidden;
if (!this.$film.hidden && !this._seqLoaded) this._filmRefresh();
}
_filmMod() { // lazy, once
if (!this._filmModP) this._filmModP = import('./film.js');
return this._filmModP;
}
// postJSON throws `url: 422 {"errors":[…]}` — dig the real message back out.
_errText(e) {
const m = (e && e.message) || String(e);
const i = m.indexOf('{');
if (i >= 0) {
try {
const j = JSON.parse(m.slice(i));
if (Array.isArray(j.errors)) return j.errors.join(' · ');
if (j.detail) return j.detail;
} catch { /* not json after all */ }
}
return m;
}
_filmStatus(msg, err) {
const st = this.$film.querySelector('.fstat');
st.className = 'fstat' + (err ? ' err' : '');
st.textContent = msg || '';
return st;
}
_sceneDur(name) { const s = (this._scenes || []).find((x) => x.name === name); return (s && s.duration) || 0; }
async _filmRefresh(select) {
let seqs = [], scenes = [];
try {
const F = await this._filmMod();
seqs = await F.listSequences();
const r = await fetch('scenes');
scenes = r.ok ? await r.json() : [];
} catch (e) { this._filmStatus('sequences unavailable — ' + this._errText(e), true); return; }
this._scenes = scenes;
this._seqLoaded = true;
this.$seq.innerHTML = '';
for (const s of seqs) {
const o = document.createElement('option');
o.value = s.name;
o.textContent = `${s.title || s.name} · ${s.shots} shot${s.shots === 1 ? '' : 's'}${s.ok ? '' : ' ⚠'}`;
this.$seq.appendChild(o);
}
const want = (select && seqs.some((s) => s.name === select)) ? select : (seqs[0] && seqs[0].name);
if (want) { this.$seq.value = want; await this._filmSelect(want); }
else { this._seq = null; this._seqName = null; this._filmRows(); this._filmStatus('no sequences yet — New… starts one'); }
}
async _filmSelect(name) {
try {
const F = await this._filmMod();
this._seq = await F.getSequence(name);
this._seq.shots = this._seq.shots || [];
this._seqName = name;
this._filmRows();
this._filmStatus('');
} catch (e) { this._filmStatus(`load "${name}" — ` + this._errText(e), true); }
}
_filmRows() {
const host = this.$film.querySelector('.shots');
host.innerHTML = '';
const shots = (this._seq && this._seq.shots) || [];
shots.forEach((sh, i) => host.appendChild(this._filmRow(sh, i, shots.length)));
this.$film.querySelector('.run').textContent = shots.length ? filmRuntime(shots).toFixed(2) + 's' : '—';
if (!this._filmBusy) this._filmEnable();
}
_filmRow(sh, i, n) {
const row = document.createElement('div'); row.className = 'frow';
const tag = document.createElement('span'); tag.className = 'shot'; tag.textContent = '#' + (i + 1);
row.appendChild(tag);
const sc = document.createElement('select'); sc.title = 'scene';
const names = (this._scenes || []).map((s) => s.name);
if (sh.scene && !names.includes(sh.scene)) names.push(sh.scene); // keep an unknown scene visible
for (const nm of names) {
const o = document.createElement('option'); o.value = nm;
o.textContent = nm + (this._sceneDur(nm) ? '' : ' (missing)');
sc.appendChild(o);
}
sc.value = sh.scene || '';
sc.onchange = () => { // new scene → reset the window to the whole take
sh.scene = sc.value; sh.in = 0; sh.out = this._sceneDur(sh.scene) || sh.out || 1;
this._filmRows();
};
row.appendChild(sc);
const num = (label, get, set, extra = {}) => {
const l = document.createElement('span'); l.className = 'lbl'; l.textContent = label;
const inp = document.createElement('input');
inp.type = 'number'; inp.className = 'n'; inp.step = '0.1'; inp.min = '0';
Object.assign(inp, extra);
inp.value = get();
inp.onchange = () => { set(Math.max(0, +inp.value || 0)); this._filmRows(); };
row.appendChild(l); row.appendChild(inp);
return inp;
};
num('in', () => sh.in ?? 0, (v) => { sh.in = v; });
num('out', () => sh.out ?? this._sceneDur(sh.scene), (v) => { sh.out = v; });
const dur = this._sceneDur(sh.scene);
if (dur) { const d = document.createElement('span'); d.className = 'lbl'; d.textContent = `/ ${dur}s`; row.appendChild(d); }
const tr = document.createElement('select'); tr.title = 'transition into the NEXT shot';
for (const v of ['cut', 'dissolve']) { const o = document.createElement('option'); o.value = v; o.textContent = v; tr.appendChild(o); }
tr.value = sh.transition || 'cut';
tr.disabled = i === n - 1; // nothing follows the last shot
tr.onchange = () => { sh.transition = tr.value; if (sh.transition === 'dissolve' && !sh.transitionDur) sh.transitionDur = 0.5; this._filmRows(); };
row.appendChild(tr);
if (sh.transition === 'dissolve' && i < n - 1) {
num('s', () => sh.transitionDur ?? 0.5, (v) => { sh.transitionDur = v; }, { step: '0.1', title: 'dissolve seconds' });
}
const btn = (label, title, fn, disabled) => {
const b = document.createElement('button'); b.textContent = label; b.title = title;
b.disabled = !!disabled; b.onclick = fn; row.appendChild(b);
};
btn('↑', 'move earlier', () => this._filmMove(i, -1), i === 0);
btn('↓', 'move later', () => this._filmMove(i, 1), i === n - 1);
btn('✕', 'remove this shot', () => this._filmRemove(i));
return row;
}
_filmMove(i, d) {
const s = this._seq.shots, j = i + d;
if (j < 0 || j >= s.length) return;
[s[i], s[j]] = [s[j], s[i]];
this._filmRows();
}
_filmRemove(i) { this._seq.shots.splice(i, 1); this._filmRows(); }
_filmAddShot() {
const prev = this._seq.shots[this._seq.shots.length - 1];
const scene = (prev && prev.scene) || ((this._scenes || [])[0] || {}).name;
if (!scene) { this._filmStatus('no saved scenes to cut together — save a scene first', true); return; }
this._seq.shots.push({ scene, in: 0, out: this._sceneDur(scene) || 5, transition: 'cut', transitionDur: 0.5 });
this._filmRows();
}
_filmEnable() {
for (const nEl of this.$film.querySelectorAll('button,select,input')) nEl.disabled = false;
const has = !!this._seq;
for (const f of ['ren', 'del', 'save', 'add', 'render', 'beat']) {
const b = this.$film.querySelector(`[data-f="${f}"]`);
if (b) b.disabled = !has;
}
const shots = (this._seq && this._seq.shots) || [];
// re-apply the per-row disables the rebuild just cleared
this.$film.querySelectorAll('.shots .frow').forEach((row, i) => {
const bs = row.querySelectorAll('button');
if (bs[0]) bs[0].disabled = i === 0;
if (bs[1]) bs[1].disabled = i === shots.length - 1;
const sels = row.querySelectorAll('select');
if (sels[1]) sels[1].disabled = i === shots.length - 1;
});
}
_filmBusySet(b) {
this._filmBusy = b;
for (const nEl of this.$film.querySelectorAll('button,select,input')) nEl.disabled = b;
this.$render.textContent = b ? 'rendering…' : 'Render film';
if (!b) this._filmEnable();
}
async _filmSave() {
if (!this._seq) return null;
try {
const F = await this._filmMod();
const r = await F.saveSequence(this._seqName, this._seq);
this._seqName = r.name || this._seqName;
this._filmStatus(`saved · ${r.shots} shots · ${(+r.duration).toFixed(2)}s`
+ ((r.warnings || []).length ? ' · ' + r.warnings.join(' · ') : ''));
return r;
} catch (e) { this._filmStatus('save failed — ' + this._errText(e), true); return null; }
}
async _filmNew() {
const name = (prompt('new sequence name', 'my-film') || '').trim();
if (!name) return;
const first = (this._scenes || [])[0];
if (!first) { this._filmStatus('no saved scenes to cut together — save a scene first', true); return; }
this._seq = { version: 1, name, title: name, audio: [],
shots: [{ scene: first.name, in: 0, out: first.duration || 5, transition: 'cut', transitionDur: 0.5 }] };
this._seqName = name;
this._filmRows();
const r = await this._filmSave(); // a sequence only exists once the server has it
if (r) await this._filmRefresh(r.name);
}
async _filmRename() {
if (!this._seq) return;
const old = this._seqName;
const name = (prompt('rename sequence', old) || '').trim();
if (!name || name === old) return;
this._seq.name = name;
if (!this._seq.title || this._seq.title === old) this._seq.title = name;
this._seqName = name;
const r = await this._filmSave(); // save-then-delete: a failed save leaves the original
if (!r) { this._seqName = old; return; }
try { const F = await this._filmMod(); await F.deleteSequence(old); } catch { /* old copy lingers, harmless */ }
await this._filmRefresh(r.name);
}
async _filmDelete() {
if (!this._seq) return;
if (!confirm(`Delete the sequence "${this._seqName}"? The scenes it cuts together are NOT touched.`)) return;
try { const F = await this._filmMod(); await F.deleteSequence(this._seqName); }
catch (e) { this._filmStatus('delete failed — ' + this._errText(e), true); return; }
this._seq = null; this._seqName = null;
await this._filmRefresh();
}
// ---- M10-B2: align the shot list to the beat (♪ align in the FILM panel) --
// WHICH GRID: the open scene's, if it has one — that is the grid the user has
// been cutting against and can see. Otherwise the first shot scene (in shot
// order) that carries a saved grid. Either way the status line names it, so
// "why did it snap there" always has a visible answer.
async _filmGrid() {
if (this.tl.scene.beatGrid) {
return { grid: this.tl.scene.beatGrid, off: this.tl.beatOffset(), src: `the open scene "${this.tl.scene.name || 'untitled'}"` };
}
const seen = new Set();
for (const sh of (this._seq.shots || [])) {
if (!sh.scene || seen.has(sh.scene)) continue;
seen.add(sh.scene);
try {
const r = await fetch('scenes/' + encodeURIComponent(sh.scene));
if (!r.ok) continue;
const json = await r.json();
if (json && json.beatGrid) return { grid: json.beatGrid, off: beatOffsetOf(json), src: `shot scene "${sh.scene}"` };
} catch { /* offline / missing scene → keep looking */ }
}
return null;
}
async _filmAlign() {
if (!this._seq || this._filmBusy) return;
const shots = this._seq.shots || [];
if (!shots.length) { this._filmStatus('nothing to align — add a shot', true); return; }
const g = await this._filmGrid();
if (!g) {
this._filmStatus('no beat grid anywhere — load a scene and press ♪ on an audio row (or save a scene that already has a grid)', true);
return;
}
const durations = shots.map((sh) => this._sceneDur(sh.scene));
const { shots: aligned, skipped } = alignShotsToGrid(shots, g.grid, g.off, durations);
const moved = aligned.filter((s, i) => s.in !== shots[i].in || s.out !== shots[i].out).length;
this._seq.shots = aligned;
this._filmRows(); // runtime + every field redraws
const conf = g.grid.confidence ?? 0;
this._filmStatus(`aligned ${moved}/${shots.length} shot${shots.length === 1 ? '' : 's'} to the bar grid from ${g.src}`
+ (g.grid.bpm ? ` (${(+g.grid.bpm).toFixed(2)} BPM)` : '')
+ (conf < 0.35 ? ` · LOW confidence ${conf.toFixed(2)} — check it against the music` : '')
+ (skipped.length ? ` · left alone: ${skipped.map((s) => '#' + (s.index + 1) + ' (' + s.why + ')').join(', ')}` : '')
+ ' · Save to keep it', skipped.length > 0);
}
// renderSequence loads EVERY shot's scene onto the stage in turn (via
// tl.applyScene), so it clobbers whatever is open — same warn-before-clobber
// contract as New-from-template, said plainly. One render at a time.
async _filmRender() {
if (!this._seq || this._filmBusy) return;
if (!(this._seq.shots || []).length) { this._filmStatus('nothing to render — add a shot', true); return; }
if (this.tl.hasContent() && !confirm(
'Render this film?\n\nEach shot loads its own saved scene onto the stage in turn, so the scene you have open right now will be REPLACED and any unsaved work is lost. Save it first if you want to keep it.')) return;
const saved = await this._filmSave(); // the server renders the SAVED shot list, not this editor
if (!saved) return;
const [w, h] = (this.$size.value || '1920x1080').split('x').map(Number);
this._filmBusySet(true);
this._filmStatus('starting…');
try {
const F = await this._filmMod();
const url = await F.renderSequence(this.stage, this.tl, this._seqName, {
width: w, height: h,
onShot: (i, n, shot) => this._filmStatus(`shot ${i + 1}/${n}${shot.scene} ${shot.in}${shot.out}s`),
onProgress: (p) => this._filmStatus(`shot ${p.shot + 1}/${p.of}${p.scene} — frame ${p.frame}/${p.frames}`),
onWarning: (m) => this._toast('film: ' + m),
});
const st = this._filmStatus('done · ');
const a = document.createElement('a');
a.href = url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = url;
st.appendChild(a);
st.append(" · the stage now holds the last shot's scene");
this._toast('🎬 film rendered — ' + url);
} catch (e) { this._filmStatus('render failed — ' + this._errText(e), true); }
finally {
this._filmBusySet(false);
this._syncRows(); this._refreshBar(); this.draw(); // the loaded scene changed under us
}
}
// ---- 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;