// tlui.js — SCENEGOD timeline panel (Lane B). ALL DOM/canvas lives here; // timeline.js stays headless. Mounts into #timeline, drives a Timeline. // // Layout: [scene bar] / [names column | ruler+canvas lanes]. One for // all lanes (decision: simpler than canvas-per-lane; hit-testing via a rebuilt // hitbox list each draw). 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; // Canvas palette. EXPORTED because the cut lane's camera label was drawn in // `#161a20` — the odd lane's own background, contrast 1.00:1 — so the feature // ("the lane prints the camera's LABEL") was not observable on screen at all. // The colours now come from one place and timeline_test.mjs holds them to a // real contrast ratio against BOTH lane fills. export const LANE_BG = ['#13171d', '#161a20']; // [even row, odd row] export const CUT_COLOR = '#e0af68'; // cut bar + dot + its label export const LABEL_COLOR = '#e0e6ed'; // text on a clip/audio block // 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;border-left:2px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} /* the names column is the OUTLINER: rows with an entity select it on the stage */ #timeline .tl-names .row.pick{cursor:pointer} #timeline .tl-names .row.pick:hover{background:#1a2029} #timeline .tl-names .row.sel{border-left-color:#7aa2f7;color:#e0e6ed;background:#1a2029} #timeline .tl-names .row .kf{margin-left:auto;color:#7aa2f7;font-size:9px;padding-left:6px} #timeline .tl-bar .rstat{color:#57c99a;max-width:34ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} #timeline .tl-bar .rstat a{color:#57c99a} #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); } // ---- pure seams (exported so timeline_test.mjs can pin the behaviour) ------- // server.py's `render_frame` refuses a frame index outside [0, 20000] — a render // that would overrun it must be refused HERE, before 20000 PNGs are uploaded. const RENDER_MAX_FRAMES = 20000; // What "⏺ Render" is about to do: the finalRender() opts for THIS scene at the // picked size. Throws rather than starting a render the server will reject. // Unparseable size → 1080p (the select can only produce good values; a hand- // edited DOM shouldn't render a 0×0 film). export function renderPlan(tl, sizeStr) { const m = /^\s*(\d+)\s*[x×]\s*(\d+)\s*$/.exec(String(sizeStr == null ? '' : sizeStr)); const width = m ? +m[1] : 1920, height = m ? +m[2] : 1080; const fps = tl.fps; const total = Math.max(1, Math.round(tl.duration * fps)); if (total > RENDER_MAX_FRAMES) throw new Error(`too long: ${total} frames (max ${RENDER_MAX_FRAMES})`); return { width, height, fps, name: tl.scene.name || 'untitled', audio: tl.scene.audio || [], total }; } // One capture loop at a time. ⏺ Render and 🎬 Render film BOTH drive // timeline.step() + stage.renderActiveCamera() and both bracket the capture with // stage.pause()/stage.resume() — run them together and they interleave frames // into two different render sessions, film.js swaps the scene under the ⏺ // render's feet, and whichever finishes first calls resume(), un-hiding the // gizmo/grid/camera cones for the REST of the other one's frames (the SYNC7 bug, // again). Returns a refusal string, or null when it's safe to start. export function renderRefusal({ rendering, filmBusy } = {}) { if (rendering) return 'a ⏺ scene render is already running — wait for it to finish'; if (filmBusy) return 'a 🎬 film render is running — wait for it to finish'; return null; } // Everything server.py's `resolve_audio` refuses that can be seen WITHOUT the // filesystem. It runs at POST /render/{id}/end, i.e. after every PNG of the // render has been uploaded — minutes of work to be told `audio: in (4) must be // less than out (1)`. Same reasoning as the frame cap: refuse here. (Existence // needs the filesystem, so it is asked for separately — see _audioMissing.) export function audioProblems(audio) { const num = (v) => typeof v === 'number' && Number.isFinite(v); const out = []; (audio || []).forEach((a, i) => { const at = `audio[${i}]`; if (!a || typeof a !== 'object' || typeof a.path !== 'string' || !a.path) { out.push(`${at}: needs a string path`); return; } const start = a.start == null ? 0 : a.start, gain = a.gain == null ? 1 : a.gain; const tIn = a.in == null ? 0 : a.in; if (!num(start) || !num(gain) || !num(tIn) || (a.out != null && !num(a.out))) { out.push(`${at} (${a.path}): start/gain/in/out must be numbers`); return; } if (start < 0 || tIn < 0) out.push(`${at} (${a.path}): start/in must be >= 0 (start=${start}, in=${tIn})`); else if (a.out != null && a.out <= tIn) out.push(`${at} (${a.path}): in (${tIn}) must be less than out (${a.out})`); }); return out; } // What double-clicking the Cameras row should DO at t for the chosen camera, // given the cut list. `at` = the cut this one would replace (addCut's half-frame // window). Cutting to the camera that is ALREADY live renders as nothing but // reads as an edit in the file — the exact artefact the M10 auto-cut amendment // asked to be dropped — so it is refused, and a redundant cut already sitting // there is removed instead. Pure so timeline_test.mjs can pin every branch. // // REVIEW-FIX: it only ever looked BACKWARDS, so the edit could manufacture the // very artefact it exists to prevent. cuts [{0,cam1},{4,cam2}], cut at 0 to cam2: // `before` is null (nothing precedes 0) → 'replace' → [{0,cam2},{4,cam2}] and the // 4s cut now changes nothing. After ANY of the four branches the effective camera // from t onward is camId, so the cut immediately after t is dead iff it names // camId — but only report it when it was a real angle change BEFORE this edit // (`wasEff`), otherwise we would be deleting somebody else's pre-existing mess. export function cutAction(cuts, t, camId, fps = 30) { const eps = 0.5 / (fps || 30); const sorted = [...(cuts || [])].sort((a, b) => a.t - b.t); const at = sorted.find((c) => Math.abs(c.t - t) <= eps) || null; let before = null; for (const c of sorted) { if (c === at) continue; if (c.t <= t) before = c.camera; else break; } const next = sorted.find((c) => c !== at && c.t > t) || null; const wasEff = at ? at.camera : before; // what was live going into `next` before this edit const dead = (next && next.camera === camId && wasEff !== camId) ? next : null; if (before === camId) return { action: at ? 'remove' : 'noop', at, before, dead }; return { action: at ? 'replace' : 'add', at, before, dead }; } // Entities whose bytes only exist in this browser session (a file dropped on the // page). They are written into the scene file but SKIPPED on load (stage.js // `applyState` skips source.type === 'upload' when the kind needs bytes), so a // scene built around a dropped character silently loses it — and everything // keyed on it. Labels, for the warning. export function uploadEntities(json) { return ((json && json.entities) || []) .filter((e) => e && e.source && e.source.type === 'upload') .map((e) => e.label || e.id); } // The one place that decides what a save TELLS you. A 422 from validate_scene // used to look exactly like a successful save (silent localStorage fallback), // and since load() prefers the server copy, the next Load quietly restored the // last good version — with everything since it gone. export function saveMessage({ ok, status, errors, name, count } = {}) { if (ok) return `saved "${name}" · ${count} entit${count === 1 ? 'y' : 'ies'}`; if (status == null) return 'server unreachable — kept a local copy in this browser only'; const why = (errors && errors.length) ? errors.join(' · ') : `server said ${status}`; return `NOT saved — ${why}`; } // What a names-column row says about its entity. `keyed` = the timeline re-applies // a pose to it on every seek, so a gizmo drag will be silently reverted. export function rowFlags(entity) { const keys = (entity && entity.tracks && entity.tracks.transform) || []; return { keyed: keys.length > 0, selectable: !!(entity && entity.id) }; } 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; // both write through to tl.snap (accessors below) this._snapStep = 'frame'; // 'frame' | seconds string | 'beat' | 'bar' 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._rendering = false; // …and never at the same time as a ⏺ scene render this._undoMark = null; // undo depth at mousedown → a drag collapses to one entry this._pickMenu = null; // Cameras-row camera picker (Escape/away close it — _pickClose) this._pickAway = null; 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((en) => this._stageChanged(en)); // the names column IS the outliner (there is no other list of what is in the // scene) — keep its highlight in step with the stage's selection. if (this.stage.onSelect) this.stage.onSelect((en) => { this._selId = (en && en.id) || null; this._markSel(); }); this._syncRows(); this.draw(); } // M11-B: the snap config is STORED ON THE MODEL (`tl.snap`) — the UI is still // the only thing that writes it, but ops that arrive without a mouse (a camera // move off `scenegod:direct`) have to snap the same way a drag does, and // timeline.js can't reach into the panel. These accessors keep every existing // `this._snapOn` / `this._snapStep` read+write working unchanged. get _snapOn() { return this.tl.snap.on; } set _snapOn(v) { this.tl.snap.on = !!v; } get _snapStep() { return this.tl.snap.step; } set _snapStep(v) { this.tl.snap.step = v; } _refreshBar() { if (this.$name) this.$name.value = this.tl.scene.name || ''; if (this.$dur) this.$dur.value = this.tl.duration; } _injectCSS() { if (document.getElementById('laneB-tl-css')) return; const s = document.createElement('style'); s.id = 'laneB-tl-css'; s.textContent = CSS; document.head.appendChild(s); } _build() { this.el.innerHTML = ''; const bar = document.createElement('div'); bar.className = 'tl-bar'; bar.innerHTML = ` 0.00s dur `; this.el.appendChild(bar); this.$bar = bar; // whole bar goes dead during a capture (_barBusySet) 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._pickScene(); this.$rsize = bar.querySelector('.rsize'); this.$rstat = bar.querySelector('.rstat'); this.$renderBtn = bar.querySelector('[data-act="render"]'); this.$renderBtn.onclick = () => this.render(); 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; } // stage.onChange fires on add, on remove, and on a gizmo mouseUp (stage.js's // TransformControls 'mouseUp' → _fireChange — NOT from setTransform, so // nothing here can feed back into the timeline). _stageChanged(en) { this._syncRows(); if (!en || !en.id || this.tl._applying) return; // A keyed entity's pose is re-applied on EVERY seek, so the drag you just did // looks right until the next scrub and then silently reverts. Don't auto-key // (too opinionated) — say so. if (!rowFlags(this._te(en.id)).keyed) return; // fresh adds have no keys → silent this._toast(`${en.label || en.id} is keyed — press ⏺ key in the inspector, or double-click its lane, to keep this pose`); } _markSel() { if (!this.rows) return; [...this.$names.children].forEach((d, i) => { const r = this.rows[i]; d.classList.toggle('sel', !!(this._selId && r && r.id === this._selId)); }); } _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 = `${(r.kind || '?')[0].toUpperCase()}${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; // OUTLINER: an ambient light has no clickable geometry on the stage (its // wrapper holds a HemisphereLight, which the raycast can never hit), so its // intensity / colour / bg / fog fields were reachable only from devtools. // Every row that has an entity selects it — sub-rows select their parent. if (r.id) { const te = this._te(r.id); const f = rowFlags(te); d.classList.add('pick'); d.title = `${r.label} — ${(r.kind || (te && te.kind) || 'entity')} ${r.id}` + (f.keyed ? ` · ${te.tracks.transform.length} transform key(s)` : '') + ' · click to select'; d.onclick = () => { if (this.stage.getEntity(r.id)) this.stage.select(r.id); }; // may have been removed if (f.keyed && r.type === 'transform') { const kf = document.createElement('span'); kf.className = 'kf'; kf.textContent = '⏺'; kf.title = 'keyed: the timeline drives this transform — moving it by hand reverts on the next scrub'; d.appendChild(kf); } } this.$names.appendChild(d); }); this._markSel(); // 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 = LANE_BG[i % 2]; 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 = LABEL_COLOR; 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 = CUT_COLOR; c.fillRect(x - 1, y + 4, 2, ROW_H - 8); c.beginPath(); c.arc(x, cy, 4, 0, Math.PI * 2); c.fill(); // the LABEL, not the raw id — the lane read "e3 e5 e7" while the names // column right beside it said "cam2 / cam3". REVIEW-FIX: it was painted in // the lane's own background hex, i.e. invisible; it is the cut's own amber. const en = this.stage.getEntity(cut.camera) || this._te(cut.camera); c.fillStyle = CUT_COLOR; c.font = '9px ui-monospace'; c.fillText((en && en.label) || 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) { if (this._busy()) return; // a render is stepping this clock — don't fight it const { x, y } = this._local(e); this._undoMark = this.tl.undoStack.length; // a drag = ONE undo entry (collapsed in _up) 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') { this.tl.moveCut(d.h.cut, this._snap(t, e)); this.draw(); } // undo-able like every other drag 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(); } } // A dropped cut absorbs anything it landed on top of (addCut's half-frame // rule), then the whole drag — every mousemove — collapses to ONE undo entry. if (d.kind === 'cut') { const eaten = this.tl.settleCut(d.h.cut); if (eaten) this._toast(`merged ${eaten} cut${eaten === 1 ? '' : 's'} you dropped this one on`); } if (this._undoMark != null) { this.tl.collapseUndo(this._undoMark); this._undoMark = null; } if (d.kind === 'cut') { this.tl.seek(this.tl.time); this.draw(); } // re-assert the live camera } _dbl(e) { if (this._busy()) return; 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') { this._cutPick(t, e); } } // Double-click the Cameras row = cut to a camera at t. It used to cut to // `stage.activeCamera()` — "the camera you are looking through" — but the // TIMELINE drives that: `_evalCameraCuts` puts the stage back on the camera the // EDIT has live at every seek, so one scrub after adding cam2 the gesture could // only ever write a no-op cut on top of cam1, and with 2+ cameras and no beat // grid (✂ auto-cut refuses without one) there was no way at all to cut to the // second camera from the timeline. So: ASK. One camera → no menu, just cut. _cutPick(t, ev) { const cams = this.stage.entities().filter((en) => en.kind === 'camera'); if (!cams.length) { this._toast('no cameras in this scene — add one from the dock (+ camera)'); return; } if (cams.length === 1) { this._cutTo(t, cams[0].id); return; } const live = this.tl.cutCameraAt(t); const menu = document.createElement('div'); menu.className = 'tl-menu'; const hd = document.createElement('div'); hd.className = 'empty'; hd.textContent = `cut at ${t.toFixed(2)}s to…`; menu.appendChild(hd); for (const cm of cams) { const item = document.createElement('div'); item.className = 'item'; item.textContent = (cm.label || cm.id) + (cm.id === live ? ' (live here)' : ''); // REVIEW-FIX: `menu.remove()` alone left the document mousedown listener // registered (holding a detached node until the next click anywhere), and // the menu was invisible to Escape because nothing on `this` pointed at it. // One close path, tracked on `this`, like every other overlay in this file. item.onclick = () => { this._pickClose(); this._cutTo(t, cm.id); }; menu.appendChild(item); } this._pickClose(); // never two pickers at once document.body.appendChild(menu); menu.style.top = ((ev && ev.clientY != null ? ev.clientY : 100) + 4) + 'px'; menu.style.left = Math.max(4, Math.min((ev && ev.clientX != null ? ev.clientX : 100), window.innerWidth - menu.offsetWidth - 6)) + 'px'; const away = (evt) => { if (!menu.contains(evt.target)) this._pickClose(); }; this._pickMenu = menu; this._pickAway = away; setTimeout(() => { if (this._pickMenu === menu) document.addEventListener('mousedown', away); }, 0); } _pickClose() { if (!this._pickMenu) return; if (this._pickAway) document.removeEventListener('mousedown', this._pickAway); this._pickMenu.remove(); this._pickMenu = null; this._pickAway = null; } _cutTo(t, camId) { const label = (this.stage.getEntity(camId) || this._te(camId) || {}).label || camId; const { action, at, dead } = cutAction(this.tl.cameraCuts, t, camId, this.tl.fps); if (action === 'noop') { this._toast(`${label} is already live at ${t.toFixed(2)}s — no cut added`); return; } // ONE undo entry for the whole gesture: the cut, plus the cut this edit just // made redundant. Two entries would leave a half-undone edit (the dead cut // back, the new one still there) one Ctrl+Z in. const deadT = dead ? dead.t : null; this.tl.group(() => { if (action === 'remove') this.tl.removeCut(at); // the cut sitting here changed nothing else this.tl.addCut(t, camId); // 'replace' is addCut's half-frame rule if (dead) this.tl.removeCut(dead); }); const also = deadT != null ? ` · dropped the now-dead cut at ${deadT.toFixed(2)}s (${label} either side of it)` : ''; if (action === 'remove') this._toast(`removed the cut at ${at.t.toFixed(2)}s — ${label} was already live${also}`); else this._toast(`cut at ${t.toFixed(2)}s → ${label}` + (action === 'replace' ? ' (replaced the cut here)' : '') + also); this.tl.seek(this.tl.time); this.draw(); } _ctx(e) { e.preventDefault(); if (this._busy()) return; 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') { this.tl.removeCut(h.cut); this.tl.seek(this.tl.time); this.draw(); } // undo-able (was a raw splice) 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(); this._pickClose(); } // ✂ panel AND the camera picker else if (this._busy()) return; // a render owns the clock: no play, no seek, no edits 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._busy()) return; // ▶ during a capture would race the frame stepper 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 = '
no audio in /assets/tree
'; 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 = '
no starter templates on the server
'; 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) => `

${h}

` + rows.map(([k, v]) => `
${k}
${v}
`).join('') + '
'; const el = document.createElement('div'); el.className = 'tl-help'; el.innerHTML = `

SCENEGOD shortcuts

? toggles this panel · Escape closes it
` + 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'], ['click a name-column row', 'select that entity on the stage (the only way to reach an ambient light)'], ['⏺ beside a name', 'the timeline drives that transform — a gizmo move reverts on the next scrub'], ['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 a camera here — pick which one (one camera = straight in)'], ['drag a key', 'move it — snaps to the increment, Alt = free (a whole drag = one undo)'], ['drag a cut', 'move it; dropped within a frame of another cut it absorbs it'], ['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 1–64× 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'], ['🎬 move (DIRECT bar)', 'keys the camera move on this timeline — start+end, one undo; snap = bar puts both on downbeats'], ]) + S('SCENE BAR', [ ['New…', 'start from a starter template (asks before clobbering)'], ['Save / Load', 'Save says plainly if the server refused it · Load lists what is on the server (asks before clobbering)'], ['⏺ Render', 'render THIS scene to mp4 at the picked size, with its audio'], ['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)'], ]) + '
'; 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 = `
✂ auto-cut to the beat
range
cameras (cycled in this order)
`; 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 = 'no cameras in this scene'; 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 = `
FILM runtime
`; 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._barBusySet(b); // the scene bar drives the SAME timeline — Save/Load/▶ off too 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) return; // REVIEW-FIX: the busy flag used to be claimed only AFTER `await // this._filmSave()` — i.e. after a network round-trip during which // `_rendering`/`_filmBusy` were both still false and the scene bar was still // live, so a ⏺ Render click walked straight through `renderRefusal` and both // capture loops ended up stepping the same clock (and whichever finished // first called resume() + re-armed the bar under the other one). The gate is // now claimed SYNCHRONOUSLY, before the first await, and released in a // finally that covers every exit — including "the save failed". const no = renderRefusal({ rendering: this._rendering, filmBusy: this._filmBusy }); if (no) { this._filmStatus(no, true); 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 [w, h] = (this.$size.value || '1920x1080').split('x').map(Number); this._filmBusySet(true); // claimed BEFORE the first await try { const saved = await this._filmSave(); // the server renders the SAVED shot list, not this editor if (!saved) return; // _filmSave already said why; the finally hands the gate back this._filmStatus('starting…'); 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 } } // ---- ⏺ Render: the scene you are looking at, to mp4, with its audio ------- // render.js's finalRender() had ZERO callers — the only render control in the // app was "Render film" inside the collapsed 🎬 panel, which needs a saved // scene, a sequence and a shot before it will do anything, renders SILENT by // design, and replaces the open scene when it's done. This is the one-button // version: this scene, this size, its audio[], nothing clobbered. // render.js is imported LAZILY, exactly like film.js — the panel must still // mount if Lane C's client files are missing. renderPlan(sizeStr) { return renderPlan(this.tl, sizeStr); } // A capture loop OWNS the timeline: it steps the clock, swaps cameras and hides // the chrome. Anything the user does to the scene bar or the lanes while it runs // either corrupts the film or is lost. `_busy` is the one gate. _busy() { return !!(this._rendering || this._filmBusy); } // The one choke point BOTH render paths go through, so it is where the model // learns a capture is running. Disabling DOM controls can only reach `$bar`; // Lane A's DIRECT panel and the dock live outside it and mutate the scene by // dispatching window events straight at timeline.js (`scenegod:direct`, // `scenegod:clipdrop`, `scenegod:capturekey`) — see Timeline._locked. _barBusySet(b) { if (this.tl) this.tl.locked = b; if (!this.$bar) return; for (const el of this.$bar.querySelectorAll('button,select,input')) el.disabled = b; } async render() { const no = renderRefusal({ rendering: this._rendering, filmBusy: this._filmBusy }); if (no) { this._toast(no); return; } // never two capture loops on one timeline let plan; try { plan = this.renderPlan(this.$rsize.value); } catch (e) { this._toast('render: ' + this._errText(e)); return; } // audio is validated by the server at POST /render/{id}/end — i.e. after // every frame has been uploaded. Refuse the whole render up front instead. const bad = audioProblems(plan.audio); if (bad.length) { this._toast('render: ' + bad.join(' · ')); return; } // claim the timeline BEFORE the first await — two fast clicks would otherwise // both get through the gate while the existence check is in flight — and // release it in a FINALLY that covers every exit from here on, so no early // return can leave the app permanently "rendering" with a dead scene bar. const saved = this.tl.time; // read before the claim (both sync — nothing can interleave) const label = this.$renderBtn.textContent; // …so a throw here can't strand the gate this._rendering = true; this._barBusySet(true); // Save / New… / Load / ▶ all go dead too try { const missing = await this._audioMissing(plan.audio); if (missing.length) { this._toast(`render: audio not found under assets — ${missing.join(', ')}`); return; } this.tl.pause(); this._audioStop(); // wall-clock playback vs frame-stepping this.$rstat.textContent = ''; const R = await import('./render.js'); const url = await R.finalRender(this.stage, this.tl, { ...plan, onProgress: (n, total) => { this.$renderBtn.textContent = `rendering ${n}/${total}`; }, }); const a = document.createElement('a'); // relative — never /render/… a.href = url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = url; this.$rstat.textContent = ''; this.$rstat.appendChild(a); this._toast('⏺ rendered — ' + url); } catch (e) { this._toast('render failed — ' + this._errText(e)); } finally { this._rendering = false; this._barBusySet(false); this.$renderBtn.textContent = label; this.tl.seek(saved); // put the playhead back where it was this.draw(); } } // Existence half of the audio pre-flight: one ONE-BYTE ranged GET per distinct // path against the same `assets/file` route the server resolves against // (relative URL — prod serves under /scenegod/). // REVIEW-FIX: this used to send HEAD, which made the whole check INERT. // FastAPI's APIRoute registers only the methods you decorate — unlike // starlette's own Route it does NOT add HEAD to a GET route — so // `@app.get("/assets/file")` answers every HEAD with 405, and `status === 404` // could never be true no matter how many audio files had been moved. Measured // against a real uvicorn on this repo: HEAD → 405 `allow: GET`; GET → 404; // GET with `Range: bytes=0-0` → 206, content-length 1. So: a real GET (the // method the route answers) that asks for one byte, and the body is cancelled // rather than streamed — a 40MB stem must not be downloaded to prove it exists. // Fails OPEN: a network wobble must not block a render, it just means the // server gets the last word as before. async _audioMissing(audio) { const paths = [...new Set(((audio || []).map((a) => a && a.path).filter(Boolean)))]; const out = []; for (const p of paths) { try { const r = await fetch('assets/file?path=' + encodeURIComponent(p), { headers: { Range: 'bytes=0-0' } }); if (r.body && r.body.cancel) r.body.cancel().catch(() => {}); // don't download the file to check it exists if (r.status === 404) out.push(p); } catch { /* offline → let the server decide */ } } return out; } // ---- persistence (Lane C endpoints; localStorage fallback) ---- // A save that fails must SAY SO. It used to catch every non-ok response into // localStorage with no toast and no console line, so a 422 from validate_scene // was indistinguishable from success — and because load() prefers the server // copy, the next Load silently restored the last good version and ate // everything since. Only a thrown fetch (genuinely offline) falls back now. async save() { const name = (this.$name.value || 'untitled').trim(); this.tl.scene.name = name; this.tl.syncFromStage(); // gizmo moves + inspector edits → the model const json = this.tl.toJSON(); const ups = uploadEntities(json); // PLAN §4.1 `source.type:"upload"`: warn about dropped files if (ups.length && !confirm(`${ups.length} dropped file${ups.length === 1 ? '' : 's'} (${ups.join(', ')}) ` + 'can only live in this browser session and will not be in the saved scene. Save anyway?')) return; let res; try { res = await fetch(`scenes/${encodeURIComponent(name)}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(json), }); } catch { localStorage.setItem('scenegod:scene:' + name, JSON.stringify(json)); // real network failure this._toast(saveMessage({ ok: false, name })); return; } if (!res.ok) { const body = await res.text().catch(() => ''); const why = body.trim() ? [this._errText(body)] : null; // unwraps {errors:[…]} / {detail} const msg = saveMessage({ ok: false, status: res.status, errors: why, name }); console.error('[scenegod:save]', res.status, body); this._toast(msg); return; } // The server SLUGIFIES: `My Scene` is written as my-scene.json and the Load // picker lists `my-scene`. It hands the real stem back in the response body // and this used to throw it away, so the toast named a file that does not // exist. Take the server's answer and put it in the field + the scene, so the // next save writes the same file and the bar matches the picker. const stem = await res.json().then((b) => (b && typeof b.name === 'string' && b.name) || name).catch(() => name); if (stem !== name) { this.tl.scene.name = stem; if (this.$name) this.$name.value = stem; // REVIEW-FIX: the server stores the BODY verbatim (`scene_save` writes what // you POST), so my-scene.json still carried `"name": "My Scene"` and the // next Load put the un-slugged name straight back into the bar while the // Load picker went on listing `my-scene` — the same disagreement, restored // on every load. Write the server's own stem back and re-POST once. The // stem is idempotent (slugify(slug) === slug), so this converges in one // extra request and only when the name actually needed slugging. Best // effort: the scene IS saved either way, the toast already names the file. json.name = stem; try { await fetch(`scenes/${encodeURIComponent(stem)}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(json), }); } catch { /* the first write landed; the stored name stays stale until the next save */ } } this._toast(saveMessage({ ok: true, name: stem, count: (json.entities || []).length })); } // Load used to slugify nothing, list nothing, and do literally nothing on a // wrong name — while a RIGHT name wiped the stage and every unsaved key with no // warning, unlike the two other clobbering actions in the same bar. Now: a // picker of what the server actually has, and the same confirm as New…. async _pickScene() { let list = []; try { const r = await fetch('scenes'); if (r.ok) list = await r.json(); } catch { /* offline */ } const menu = document.createElement('div'); menu.className = 'tl-menu'; if (!Array.isArray(list) || !list.length) menu.innerHTML = '
no saved scenes on the server
'; for (const s of (list || [])) { const item = document.createElement('div'); item.className = 'item'; const when = s.mtime ? new Date(s.mtime * 1000).toLocaleString() : ''; item.textContent = `${s.name}${s.duration ? ' · ' + (+s.duration).toFixed(1) + 's' : ''}${when ? ' · ' + when : ''}`; item.onclick = () => { menu.remove(); this.load(s.name); }; menu.appendChild(item); } document.body.appendChild(menu); const rect = this.el.querySelector('[data-act="load"]').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); } 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) { this._toast(`no scene called "${name}" on the server or in this browser`); return; } if (this.tl.hasContent() && !confirm('Replace the current scene? Unsaved work is lost.')) return; try { await this.tl.applyScene(json); } // stage.applyState → tl.load (onLoad → rows+bar) → preload catch (e) { // one missing clip used to reject out of load() uncaught, which also killed // _decodeAll() — the audio row went dead and every later clip did nothing. this._toast(`load "${name}" failed — ` + this._errText(e)); } this._decodeAll(); // decode audio for lane widths + playback this._syncRows(); this._refreshBar(); this.draw(); } } export default TimelineUI;