FEATURES (from a 5-lens audit: UX friction, feature gaps, bug hunt, visual quality, discoverability): - ACES filmic tone mapping + per-preset exposure. grammar.js runs physical sun intensities of 1.2-3.4; with NoToneMapping every lit face clipped to white and a warm key rendered neutral. Single biggest look win in the app. - Real shadows: PCFSoft, a shadow box fitted to the scene's bounds, and plates that no longer double as the shadow catcher (dedicated ShadowMaterial). - A Render button in the scene bar. finalRender had ZERO callers - the app's deliverable was console-only. - Save actually saves the stage you built (gizmo moves, inspector edits), and Save/Load stop lying about what happened. - 2x supersample + lanczos downscale; correct bt709 tagging and faststart. - Frame guide, camera-from-view, dock counts, delete confirmations. DEFECTS FOUND BY ADVERSARIAL REVIEW AND FIXED (all reproduced first): - BLOCKER: save wrote the playhead pose over the authored rest transform of keyed entities, so every save produced a different file. - renders were converted with the bt601 matrix while tagged bt709. - deleting an unkeyed camera stranded its cut -> scene 422s forever. - corner plates showed one photo at two exposures across the fold. - exposure was advertised as keyable but nothing keyed it, so a second lighting preset permanently poisoned the first. - the audio pre-flight could never fire (FastAPI does not route HEAD -> 405). - the two render buttons were not mutually exclusive. - frame guide letterboxed a narrow viewport that the render does not crop. ORCHESTRATOR (round 3): dangling camera cuts are pruned in Timeline.toJSON and skipped by cutCameraAt, rather than trying to keep every undo history clean - deleting a camera spans Stage (no undo) and Timeline (undo), so any older entry can resurrect a cut for a dead camera. Verified against the real validator. Also replaced a VACUOUS test that drained a stack whose only entry was a seeded no-op; timeline_test.mjs now replays both real histories and discriminates. Verified: 4 JS suites + 23 server groups + render client green; blocker repro now preserves the authored rest at every playhead position; save round-trips 200 with zero dangling cuts; zero console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1772 lines
97 KiB
JavaScript
1772 lines
97 KiB
JavaScript
// tlui.js — SCENEGOD timeline panel (Lane B). ALL DOM/canvas lives here;
|
||
// timeline.js stays headless. Mounts into #timeline, drives a Timeline.
|
||
//
|
||
// Layout: [scene bar] / [names column | ruler+canvas lanes]. One <canvas> for
|
||
// all lanes (decision: simpler than canvas-per-lane; hit-testing via a rebuilt
|
||
// hitbox list each draw). 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 = `
|
||
<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>
|
||
<select class="rsize" title="render size">
|
||
<option value="1920x1080">1920×1080</option>
|
||
<option value="1280x720">1280×720</option>
|
||
<option value="854x480">854×480</option></select>
|
||
<button data-act="render" title="render THIS scene to mp4 with its audio">⏺ Render</button>
|
||
<span class="rstat"></span>
|
||
<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.$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 = `<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;
|
||
// 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 = '<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'],
|
||
['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)'],
|
||
]) + '</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._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 = '<div class="empty">no saved scenes on the server</div>';
|
||
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;
|