Beat grid consumed from Lane C's /beats: one top-level scene.beatGrid (beats kept in FILE seconds, effective offset re-derived from the audio clip so dragging the audio drags the grid and head-trim leaves it put). beat and bar join the snap selector, so keys, clip and audio blocks, both trim edges, cuts and the playhead all became musical for free. Grid drawn faint on beats and brighter on downbeats, suppressed below ~7px spacing. Low confidence still snaps but says so. Tempo extrapolates past the analysed range. FILM panel mounts inside #timeline: sequence CRUD, editable shot rows (scene dropdown, in/out, cut|dissolve + seconds, reorder, delete), live runtime, size picker, and Render film with per-shot/per-frame progress - gated behind hasContent() + a confirm that says plainly every shot replaces the open scene, and locked while running so it cannot be started twice. film.js imported lazily so its absence cannot stop the timeline mounting. Verified live: grid 128.02 BPM/35 beats off house128.wav, snapped positions 8-10ms from the true 0.468735s grid across the whole file, bar snap hits a downbeat, grid survives toJSON->load; FILM lists both sequences with the server's 11.50s runtime and 3 editable rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1158 lines
60 KiB
JavaScript
1158 lines
60 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 } from './timeline.js';
|
||
|
||
const ROW_H = 28;
|
||
const RULER_H = 22;
|
||
const KEY_R = 5;
|
||
|
||
// ponytail: styles injected here, not written into Lane A's web/style.css —
|
||
// keeps Lane B inside its own files (orchestrator confirmed this is permanent).
|
||
const CSS = `
|
||
#timeline{display:flex;flex-direction:column;background:#161a20;color:#c9d1d9;font:12px/1.4 ui-monospace,Menlo,monospace;border-top:1px solid #2b323c;user-select:none}
|
||
#timeline .tl-bar{display:flex;gap:8px;align-items:center;padding:6px 8px;border-bottom:1px solid #2b323c}
|
||
#timeline .tl-bar input{background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;padding:2px 6px;border-radius:3px;font:inherit}
|
||
#timeline .tl-bar input.name{width:150px}
|
||
#timeline .tl-bar input.num{width:56px}
|
||
#timeline .tl-bar select{background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;padding:2px 4px;border-radius:3px;font:inherit}
|
||
#timeline .tl-bar label{display:flex;align-items:center;gap:3px;color:#8b949e;cursor:pointer}
|
||
#timeline .tl-bar button{background:#21262d;border:1px solid #2b323c;color:#c9d1d9;padding:3px 10px;border-radius:3px;cursor:pointer;font:inherit}
|
||
#timeline .tl-bar button:hover{background:#2b323c}
|
||
#timeline .tl-bar .spring{flex:1}
|
||
#timeline .tl-bar .clock{color:#7aa2f7;min-width:64px;text-align:right}
|
||
#timeline .tl-body{display:flex}
|
||
#timeline .tl-names{width:150px;flex:none;overflow:hidden;border-right:1px solid #2b323c;background:#12161c}
|
||
#timeline .tl-names .row{height:${ROW_H}px;display:flex;align-items:center;padding:0 8px;box-sizing:border-box;border-bottom:1px solid #1b2027;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#timeline .tl-names .row.head{margin-top:${RULER_H}px}
|
||
#timeline .tl-names .row .k{color:#565f6b;margin-right:6px;font-size:10px}
|
||
#timeline .tl-names .row.sub{color:#8b949e;padding-left:20px;font-size:11px}
|
||
#timeline .tl-names .row input.gain{width:38px;margin-left:auto;background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;font:10px ui-monospace;border-radius:3px;padding:1px 2px}
|
||
#timeline .tl-names .row button.lip{margin-left:4px;background:#21262d;border:1px solid #2b323c;border-radius:3px;color:#c9d1d9;cursor:pointer;font-size:11px;padding:0 4px}
|
||
#timeline .tl-names .row button.lip:hover{background:#2b323c}
|
||
#timeline .tl-lanes{flex:1;position:relative;overflow:hidden}
|
||
#timeline canvas{display:block;width:100%;height:100%}
|
||
.tl-menu{position:fixed;z-index:9999;background:#12161c;border:1px solid #2b323c;border-radius:4px;max-height:240px;overflow:auto;font:12px ui-monospace;color:#c9d1d9;min-width:160px}
|
||
.tl-menu .item{padding:4px 10px;cursor:pointer;white-space:nowrap}
|
||
.tl-menu .item:hover{background:#2b323c}
|
||
.tl-menu .empty{padding:6px 10px;color:#565f6b}
|
||
.tl-menu.tmpl{max-width:420px}
|
||
.tl-menu.tmpl .item{display:flex;gap:9px;align-items:flex-start;padding:8px 10px;white-space:normal;border-bottom:1px solid #1b2027}
|
||
.tl-menu.tmpl .item img{width:68px;height:42px;flex:none;object-fit:cover;border-radius:3px;background:#0d1117}
|
||
.tl-menu.tmpl .t{color:#e0e6ed}
|
||
.tl-menu.tmpl .d{color:#8b949e;font-size:11px;line-height:1.35;margin-top:2px}
|
||
.tl-help{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:10001;background:#12161c;border:1px solid #2b323c;border-radius:6px;box-shadow:0 10px 44px #000b;padding:14px 18px 16px;color:#c9d1d9;font:12px/1.5 ui-monospace,Menlo,monospace;max-width:min(880px,94vw);max-height:84vh;overflow:auto}
|
||
.tl-help h3{margin:0 0 2px;font-size:13px;color:#7aa2f7}
|
||
.tl-help .hint{color:#565f6b;margin-bottom:10px}
|
||
.tl-help .cols{display:flex;gap:28px;flex-wrap:wrap;align-items:flex-start}
|
||
.tl-help section{min-width:250px;flex:1}
|
||
.tl-help h4{margin:8px 0 4px;font-size:11px;color:#e0af68;letter-spacing:.07em}
|
||
/* SYNC9: dt was auto-width + nowrap, so long terms ("drag an animation onto a
|
||
character") blew past the grid cell and the three columns overlapped. Cap the
|
||
term column and let it wrap. */
|
||
.tl-help dl{display:grid;grid-template-columns:minmax(0,11em) minmax(0,1fr);gap:3px 12px;margin:0}
|
||
.tl-help dt{color:#57c99a;white-space:normal;overflow-wrap:anywhere}
|
||
.tl-help dd{margin:0;color:#8b949e}
|
||
.tl-help .close{position:absolute;top:6px;right:10px;background:none;border:0;color:#565f6b;font:14px ui-monospace;cursor:pointer}
|
||
.tl-help .close:hover{color:#c9d1d9}
|
||
.tl-toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:10000;background:#12161c;border:1px solid #e0af68;color:#e0e6ed;padding:6px 14px;border-radius:4px;font:12px ui-monospace;max-width:70vw}
|
||
/* M9-B2 FILM panel — collapsible shot list, mounted inside #timeline */
|
||
#timeline .tl-film{border-top:1px solid #2b323c;background:#12161c;padding:6px 8px 8px}
|
||
#timeline .tl-film .frow{display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:3px 0}
|
||
#timeline .tl-film .frow.head{border-bottom:1px solid #1b2027;padding-bottom:6px;margin-bottom:4px}
|
||
#timeline .tl-film select,#timeline .tl-film input{background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;padding:2px 4px;border-radius:3px;font:11px ui-monospace}
|
||
#timeline .tl-film input.n{width:52px}
|
||
#timeline .tl-film button{background:#21262d;border:1px solid #2b323c;color:#c9d1d9;padding:2px 8px;border-radius:3px;cursor:pointer;font:11px ui-monospace}
|
||
#timeline .tl-film button:hover:not(:disabled){background:#2b323c}
|
||
#timeline .tl-film button:disabled{opacity:.45;cursor:default}
|
||
#timeline .tl-film button.go{border-color:#57c99a;color:#dff5ea}
|
||
#timeline .tl-film .spring{flex:1}
|
||
#timeline .tl-film .run{color:#7aa2f7}
|
||
#timeline .tl-film .fstat{color:#8b949e;max-width:46ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
#timeline .tl-film .fstat.err{color:#f7768e;white-space:normal;max-width:none}
|
||
#timeline .tl-film .fstat a{color:#57c99a}
|
||
#timeline .tl-film .shot{color:#565f6b}
|
||
#timeline .tl-film .lbl{color:#565f6b}
|
||
`;
|
||
|
||
// Film runtime in seconds: shots minus the overlap every dissolve eats. Mirrors
|
||
// server.py plan_sequence (`sum(len) - sum(transitionDur of all but the last)`);
|
||
// exported pure so timeline_test.mjs can hold it to the server's arithmetic.
|
||
export function filmRuntime(shots) {
|
||
let total = 0;
|
||
(shots || []).forEach((s, i) => {
|
||
total += Math.max(0, (+s.out || 0) - (+s.in || 0));
|
||
if (i < shots.length - 1 && s.transition === 'dissolve') total -= Math.max(0, +s.transitionDur || 0);
|
||
});
|
||
return Math.max(0, total);
|
||
}
|
||
|
||
export class TimelineUI {
|
||
constructor(timeline, mountEl) {
|
||
this.tl = timeline;
|
||
this.stage = timeline.stage;
|
||
this.el = typeof mountEl === 'string' ? document.querySelector(mountEl) : mountEl;
|
||
// REVIEW-FIX: shared failure surface — timeline._fail (and any other module)
|
||
// toasts through here instead of dying silently in the console.
|
||
window.sgToast = (m) => this._toast(m);
|
||
this._hits = [];
|
||
this._drag = null;
|
||
this._selKeys = []; // box-selected keys: [{id, track, key}]
|
||
this._snapOn = true;
|
||
this._snapStep = 'frame'; // 'frame' | seconds string
|
||
this._zoom = 1; // 1 = fit-to-width; Cmd/Ctrl+wheel zooms
|
||
this._panT = 0; // left edge of the view, in seconds
|
||
this._audioBuf = new Map(); // path -> decoded AudioBuffer
|
||
this._audioSrcs = []; // live BufferSourceNodes during playback
|
||
this._beatCache = new Map(); // path -> Lane C /beats response (analysis is expensive once, free after)
|
||
this._wasPlaying = false;
|
||
this._seq = null; // FILM panel: the sequence doc being edited
|
||
this._seqName = null;
|
||
this._filmBusy = false; // one film render at a time
|
||
this._injectCSS();
|
||
this._build();
|
||
this.tl.onTick(() => { // natural end-of-play stops audio (timeline auto-pauses)
|
||
this.draw();
|
||
if (this._wasPlaying && !this.tl.playing) this._audioStop();
|
||
this._wasPlaying = this.tl.playing;
|
||
});
|
||
this.tl.onLoad(() => { this._syncRows(); this._refreshBar(); }); // SYNC1 #3a/#3b
|
||
if (this.stage.onChange) this.stage.onChange(() => this._syncRows());
|
||
this._syncRows();
|
||
this.draw();
|
||
}
|
||
|
||
_refreshBar() {
|
||
if (this.$name) this.$name.value = this.tl.scene.name || '';
|
||
if (this.$dur) this.$dur.value = this.tl.duration;
|
||
}
|
||
|
||
_injectCSS() {
|
||
if (document.getElementById('laneB-tl-css')) return;
|
||
const s = document.createElement('style');
|
||
s.id = 'laneB-tl-css';
|
||
s.textContent = CSS;
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
_build() {
|
||
this.el.innerHTML = '';
|
||
const bar = document.createElement('div');
|
||
bar.className = 'tl-bar';
|
||
bar.innerHTML = `
|
||
<button data-act="play">▶</button>
|
||
<span class="clock">0.00s</span>
|
||
<input class="name" placeholder="scene name" value="${this.tl.scene.name || ''}">
|
||
<span>dur</span><input class="num dur" type="number" min="0" step="0.5" value="${this.tl.duration}">
|
||
<label><input type="checkbox" class="snap" checked> snap</label>
|
||
<select class="snapstep" title="snap increment (beat/bar need a detected beat grid — ♪ on an audio row)">
|
||
<option value="frame">frame</option><option value="0.1">0.1s</option>
|
||
<option value="0.25">0.25s</option><option value="1">1s</option>
|
||
<option value="beat">beat</option><option value="bar">bar</option></select>
|
||
<button data-act="audio" title="add audio clip at playhead">🎵</button>
|
||
<button data-act="film" title="FILM: shot list / sequences / render a multi-shot film">🎬</button>
|
||
<span class="spring"></span>
|
||
<button data-act="help" title="keyboard shortcuts (?)">?</button>
|
||
<button data-act="tmpl" title="start a new scene from a starter template">New…</button>
|
||
<button data-act="save">Save</button>
|
||
<button data-act="load">Load</button>`;
|
||
this.el.appendChild(bar);
|
||
this.$play = bar.querySelector('[data-act="play"]');
|
||
this.$clock = bar.querySelector('.clock');
|
||
this.$name = bar.querySelector('.name');
|
||
this.$dur = bar.querySelector('.dur');
|
||
this.$snap = bar.querySelector('.snap');
|
||
this.$snapStep = bar.querySelector('.snapstep');
|
||
this.$play.onclick = () => this._toggle();
|
||
bar.querySelector('[data-act="save"]').onclick = () => this.save();
|
||
bar.querySelector('[data-act="load"]').onclick = () => this.load(this.$name.value);
|
||
this.$audioBtn = bar.querySelector('[data-act="audio"]');
|
||
this.$audioBtn.onclick = () => this._pickAudio();
|
||
this.$tmplBtn = bar.querySelector('[data-act="tmpl"]');
|
||
this.$tmplBtn.onclick = () => this._pickTemplate();
|
||
this.$helpBtn = bar.querySelector('[data-act="help"]');
|
||
this.$helpBtn.onclick = () => this._toggleHelp();
|
||
this.$dur.onchange = () => this.tl.setDuration(this.$dur.value); // clamps + refreshes bar
|
||
this.$name.onchange = () => { this.tl.scene.name = this.$name.value; };
|
||
this.$filmBtn = bar.querySelector('[data-act="film"]');
|
||
this.$filmBtn.onclick = () => this._toggleFilm();
|
||
this.$snap.onchange = () => { this._snapOn = this.$snap.checked; };
|
||
this.$snapStep.onchange = () => {
|
||
this._snapStep = this.$snapStep.value;
|
||
if ((this._snapStep === 'beat' || this._snapStep === 'bar') && !this.tl.scene.beatGrid)
|
||
this._toast('no beat grid yet — press ♪ on an audio row to detect one (snapping to frames until then)');
|
||
};
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'tl-body';
|
||
this.$names = document.createElement('div'); this.$names.className = 'tl-names';
|
||
const lanes = document.createElement('div'); lanes.className = 'tl-lanes';
|
||
this.canvas = document.createElement('canvas');
|
||
lanes.appendChild(this.canvas);
|
||
body.appendChild(this.$names); body.appendChild(lanes);
|
||
this.el.appendChild(body);
|
||
this.lanesEl = lanes;
|
||
this._buildFilm(); // collapsed until 🎬 (mounts inside #timeline — Lane A's files stay untouched)
|
||
|
||
this.canvas.addEventListener('mousedown', (e) => this._down(e));
|
||
this.canvas.addEventListener('wheel', (e) => this._wheel(e), { passive: false });
|
||
window.addEventListener('mousemove', (e) => this._move(e));
|
||
window.addEventListener('mouseup', () => this._up());
|
||
this.canvas.addEventListener('dblclick', (e) => this._dbl(e));
|
||
this.canvas.addEventListener('contextmenu', (e) => this._ctx(e));
|
||
window.addEventListener('keydown', (e) => this._key(e));
|
||
window.addEventListener('resize', () => this.draw());
|
||
}
|
||
|
||
// rows: [{type:'transform'|'params'|'clip'|'cameras', id?, label}].
|
||
// Built from the timeline's entities (authoritative for tracks) merged with
|
||
// any stage entities not yet keyframed (dock-added but not in a loaded scene).
|
||
_rows() {
|
||
const rows = [];
|
||
const list = [...this.tl.scene.entities];
|
||
for (const se of this.stage.entities()) if (!list.find((e) => e.id === se.id)) list.push(se);
|
||
for (const e of list) {
|
||
rows.push({ type: 'transform', id: e.id, kind: e.kind, label: e.label || e.id });
|
||
if (e.kind === 'character') rows.push({ type: 'clip', id: e.id, label: '↳ clips' });
|
||
const paramable = (e.tracks?.params?.length > 0) || e.kind === 'camera' || e.kind === 'light';
|
||
if (paramable) rows.push({ type: 'params', id: e.id, label: '↳ params' });
|
||
// viseme lane only when the character actually has morph targets (M4-B)
|
||
const hasMorphs = (e.tracks?.morphs?.length > 0) ||
|
||
(e.kind === 'character' && this.stage.visemeTargets && (this.stage.visemeTargets(e.id) || []).length > 0);
|
||
if (hasMorphs) rows.push({ type: 'morphs', id: e.id, label: '↳ visemes' });
|
||
}
|
||
rows.push({ type: 'cameras', label: 'Cameras' });
|
||
for (const clip of (this.tl.scene.audio || [])) {
|
||
rows.push({ type: 'audio', clip, label: '🎵 ' + (clip.path || '').split('/').pop() });
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
_syncRows() {
|
||
this.rows = this._rows();
|
||
this.$names.innerHTML = '';
|
||
this.rows.forEach((r, i) => {
|
||
const d = document.createElement('div');
|
||
const sub = r.type === 'clip' || r.type === 'params' || r.type === 'morphs' || r.type === 'audio';
|
||
d.className = 'row' + (i === 0 ? ' head' : '') + (sub ? ' sub' : '');
|
||
if (r.type === 'transform') d.innerHTML = `<span class="k">${(r.kind || '?')[0].toUpperCase()}</span>${r.label}`;
|
||
else if (r.type === 'audio') {
|
||
d.append(r.label);
|
||
const g = document.createElement('input');
|
||
g.type = 'number'; g.className = 'gain'; g.step = '0.1'; g.min = '0'; g.title = 'gain';
|
||
g.value = r.clip.gain ?? 1;
|
||
g.onchange = () => this.tl.setAudioGain(r.clip, Math.max(0, +g.value || 0));
|
||
d.appendChild(g);
|
||
const beat = document.createElement('button'); // M9-B1: beat grid from this audio
|
||
beat.className = 'lip'; beat.textContent = '♪';
|
||
beat.title = 'detect beats: build a beat grid from this clip → snap increment beat / bar';
|
||
beat.onclick = () => this._detectBeats(r.clip, beat);
|
||
d.appendChild(beat);
|
||
const lip = document.createElement('button'); // M5: lip-sync from this audio
|
||
lip.className = 'lip'; lip.textContent = '🗣';
|
||
lip.title = 'lip-sync: rhubarb this audio → viseme keys on a character';
|
||
lip.onclick = () => this._lipSync(r.clip, lip);
|
||
d.appendChild(lip);
|
||
} else d.textContent = r.label;
|
||
this.$names.appendChild(d);
|
||
});
|
||
// size lanes to content so no row (esp. Cameras) is clipped as rows grow.
|
||
// ponytail: grow-to-content; add a max-height+scroll only if a scene ever
|
||
// has so many entities the panel dominates the viewport.
|
||
const hpx = RULER_H + this.rows.length * ROW_H;
|
||
this.$names.style.height = hpx + 'px';
|
||
this.lanesEl.style.height = hpx + 'px';
|
||
this.draw();
|
||
}
|
||
|
||
// ---- geometry ----
|
||
_dims() {
|
||
const w = this.canvas.clientWidth, h = this.canvas.clientHeight;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
if (this.canvas.width !== w * dpr || this.canvas.height !== h * dpr) {
|
||
this.canvas.width = w * dpr; this.canvas.height = h * dpr;
|
||
}
|
||
return { w, h, dpr, pps: (w / (this.tl.duration || 1)) * this._zoom };
|
||
}
|
||
_x2t(x) { const { pps } = this._dims(); return Math.max(0, Math.min(this.tl.duration, this._panT + x / pps)); }
|
||
_t2x(t) { const { pps } = this._dims(); return (t - this._panT) * pps; }
|
||
// UX (session-5 nit): plain wheel is NOT captured — the page scrolls as users
|
||
// expect. Cmd/Ctrl+wheel (incl. trackpad pinch → ctrlKey) zooms around the
|
||
// cursor; horizontal wheel pans when zoomed in.
|
||
_wheel(e) {
|
||
const zoomG = e.ctrlKey || e.metaKey;
|
||
const panG = this._zoom > 1 && Math.abs(e.deltaX) > Math.abs(e.deltaY);
|
||
if (!zoomG && !panG) return;
|
||
e.preventDefault();
|
||
const { x } = this._local(e);
|
||
const { w, pps } = this._dims();
|
||
if (zoomG) {
|
||
const tAt = this._panT + x / pps; // time under the cursor
|
||
this._zoom = Math.max(1, Math.min(64, this._zoom * Math.pow(1.0015, -e.deltaY)));
|
||
this._panT = tAt - x / ((w / (this.tl.duration || 1)) * this._zoom); // keep it under the cursor
|
||
} else {
|
||
this._panT += e.deltaX / pps;
|
||
}
|
||
const view = (this.tl.duration || 1) / this._zoom; // visible seconds
|
||
this._panT = Math.max(0, Math.min(this.tl.duration - view, this._panT));
|
||
this.draw();
|
||
}
|
||
_rowAtY(y) { const i = Math.floor((y - RULER_H) / ROW_H); return (i >= 0 && i < this.rows.length) ? i : -1; }
|
||
|
||
// ---- draw ----
|
||
draw() {
|
||
if (!this.rows) this.rows = this._rows();
|
||
const { w, h, dpr } = this._dims();
|
||
const c = this.canvas.getContext('2d');
|
||
c.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
c.clearRect(0, 0, w, h);
|
||
this._hits = [];
|
||
|
||
// ruler
|
||
c.fillStyle = '#12161c'; c.fillRect(0, 0, w, RULER_H);
|
||
c.fillStyle = '#565f6b'; c.font = '10px ui-monospace';
|
||
const dur = this.tl.duration || 1;
|
||
const stepSec = dur <= 5 ? 0.5 : dur <= 20 ? 1 : 5;
|
||
for (let t = 0; t <= dur + 1e-6; t += stepSec) {
|
||
const x = this._t2x(t);
|
||
c.strokeStyle = '#1b2027'; c.beginPath(); c.moveTo(x, 0); c.lineTo(x, h); c.stroke();
|
||
c.fillText(t.toFixed(stepSec < 1 ? 1 : 0) + 's', x + 2, 12);
|
||
}
|
||
|
||
// lanes
|
||
this.rows.forEach((r, i) => {
|
||
const y = RULER_H + i * ROW_H;
|
||
c.fillStyle = i % 2 ? '#161a20' : '#13171d';
|
||
c.fillRect(0, y, w, ROW_H);
|
||
c.strokeStyle = '#1b2027'; c.beginPath(); c.moveTo(0, y + ROW_H); c.lineTo(w, y + ROW_H); c.stroke();
|
||
if (r.type === 'transform') this._drawKeys(c, r, y);
|
||
else if (r.type === 'params') this._drawParams(c, r, y);
|
||
else if (r.type === 'morphs') this._drawMorphs(c, r, y);
|
||
else if (r.type === 'clip') this._drawClips(c, r, y);
|
||
else if (r.type === 'cameras') this._drawCuts(c, y);
|
||
else if (r.type === 'audio') this._drawAudio(c, r, y);
|
||
});
|
||
|
||
this._drawBeatGrid(c, w, h);
|
||
|
||
// box-select overlay
|
||
const d = this._drag;
|
||
if (d && d.kind === 'box' && d.moved) {
|
||
c.fillStyle = 'rgba(122,162,247,0.15)'; c.strokeStyle = '#7aa2f7';
|
||
const x = Math.min(d.x0, d.x), y = Math.min(d.y0, d.y);
|
||
c.fillRect(x, y, Math.abs(d.x - d.x0), Math.abs(d.y - d.y0));
|
||
c.strokeRect(x + 0.5, y + 0.5, Math.abs(d.x - d.x0), Math.abs(d.y - d.y0));
|
||
}
|
||
|
||
// playhead
|
||
const px = this._t2x(this.tl.time);
|
||
c.strokeStyle = '#f7768e'; c.lineWidth = 1.5;
|
||
c.beginPath(); c.moveTo(px, 0); c.lineTo(px, h); c.stroke(); c.lineWidth = 1;
|
||
c.fillStyle = '#f7768e'; c.beginPath(); c.moveTo(px - 4, 0); c.lineTo(px + 4, 0); c.lineTo(px, 6); c.fill();
|
||
|
||
this.$clock.textContent = this.tl.time.toFixed(2) + 's';
|
||
this.$play.textContent = this.tl.playing ? '❚❚' : '▶';
|
||
}
|
||
|
||
// M9-B1: the beat grid over the lanes — faint on beats, brighter on downbeats.
|
||
// Drawn only while it's readable: under ~7px apart the lines are mush, so beats
|
||
// drop out first and then bars, rather than painting the panel solid.
|
||
_drawBeatGrid(c, w, h) {
|
||
const g = this.tl.scene.beatGrid;
|
||
if (!g) return;
|
||
const { pps } = this._dims();
|
||
const per = g.period || (g.bpm ? 60 / g.bpm : 0);
|
||
if (!(per > 0)) return;
|
||
const barGap = per * (g.meter || 4) * pps;
|
||
if (barGap < 7) return; // even bars would be mush → draw nothing
|
||
const line = (times, style) => {
|
||
c.strokeStyle = style; c.beginPath();
|
||
for (const t of times) {
|
||
const x = Math.round(this._t2x(t)) + 0.5;
|
||
if (x < 0 || x > w) continue;
|
||
c.moveTo(x, RULER_H); c.lineTo(x, h);
|
||
}
|
||
c.stroke();
|
||
};
|
||
if (per * pps >= 7) line(this.tl.gridTimes('beat'), 'rgba(146,180,232,0.13)');
|
||
line(this.tl.gridTimes('bar'), 'rgba(224,175,104,0.34)');
|
||
}
|
||
|
||
_entity(id) { return this.stage.getEntity(id); } // stage: live transform/existence
|
||
_te(id) { return this.tl.scene.entities.find((e) => e.id === id); } // timeline: authoritative tracks
|
||
_selected(k) { return this._selKeys.some((s) => s.key === k); }
|
||
|
||
_diamond(c, x, cy, r, fill, sel) {
|
||
c.fillStyle = fill;
|
||
c.beginPath(); c.moveTo(x, cy - r); c.lineTo(x + r, cy); c.lineTo(x, cy + r); c.lineTo(x - r, cy); c.closePath(); c.fill();
|
||
if (sel) { c.strokeStyle = '#fff'; c.lineWidth = 1.5; c.stroke(); c.lineWidth = 1; }
|
||
}
|
||
_drawKeys(c, r, y) {
|
||
const cy = y + ROW_H / 2;
|
||
const keys = (this._te(r.id)?.tracks?.transform) || [];
|
||
for (const k of keys) {
|
||
const x = this._t2x(k.t);
|
||
this._diamond(c, x, cy, KEY_R, '#7aa2f7', this._selected(k));
|
||
this._hits.push({ kind: 'key', id: r.id, track: 'transform', key: k, x, y: cy });
|
||
}
|
||
}
|
||
_paramColor(name) { let h = 0; for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) % 360; return `hsl(${h},55%,62%)`; }
|
||
_drawParams(c, r, y) {
|
||
const cy = y + ROW_H / 2;
|
||
const keys = (this._te(r.id)?.tracks?.params) || [];
|
||
for (const k of keys) {
|
||
const x = this._t2x(k.t);
|
||
this._diamond(c, x, cy, 4, this._paramColor(k.key), this._selected(k));
|
||
this._hits.push({ kind: 'key', id: r.id, track: 'params', key: k, x, y: cy });
|
||
}
|
||
}
|
||
_drawClips(c, r, y) {
|
||
const blocks = (this._te(r.id)?.tracks?.clips) || [];
|
||
for (const b of blocks) {
|
||
const span = (b.out - b.in) || 0.0001, end = b.start + span * (b.loop || 1);
|
||
const x0 = this._t2x(b.start), x1 = this._t2x(end), bw = Math.max(2, x1 - x0);
|
||
c.fillStyle = '#3d5a80'; c.fillRect(x0, y + 5, bw, ROW_H - 10);
|
||
c.strokeStyle = '#98c1d9'; c.strokeRect(x0 + 0.5, y + 5.5, bw - 1, ROW_H - 11);
|
||
if (b.fade > 0) { // fade-out wedge at the tail
|
||
const fx = this._t2x(end - b.fade);
|
||
c.fillStyle = 'rgba(231,175,104,0.35)'; c.fillRect(fx, y + 5, x1 - fx, ROW_H - 10);
|
||
}
|
||
c.fillStyle = '#e0e6ed'; c.font = '10px ui-monospace';
|
||
c.save(); c.beginPath(); c.rect(x0, y, x1 - x0, ROW_H); c.clip();
|
||
c.fillText((b.path || '').split('/').pop(), x0 + 4, y + ROW_H / 2 + 3); c.restore();
|
||
this._hits.push({ kind: 'block', id: r.id, block: b, x0, x1, y });
|
||
this._hits.push({ kind: 'edge', id: r.id, block: b, side: 'out', x: x1, y });
|
||
}
|
||
}
|
||
_drawCuts(c, y) {
|
||
const cy = y + ROW_H / 2;
|
||
for (const cut of this.tl.cameraCuts) {
|
||
const x = this._t2x(cut.t);
|
||
c.fillStyle = '#e0af68'; c.fillRect(x - 1, y + 4, 2, ROW_H - 8);
|
||
c.beginPath(); c.arc(x, cy, 4, 0, Math.PI * 2); c.fill();
|
||
c.fillStyle = '#161a20'; c.font = '9px ui-monospace'; c.fillText(cut.camera || '?', x + 6, cy + 3);
|
||
this._hits.push({ kind: 'cut', cut, x, y: cy });
|
||
}
|
||
}
|
||
_drawMorphs(c, r, y) {
|
||
const cy = y + ROW_H / 2;
|
||
const keys = (this._te(r.id)?.tracks?.morphs) || [];
|
||
for (const k of keys) {
|
||
const x = this._t2x(k.t);
|
||
this._diamond(c, x, cy, 4, this._paramColor(k.key), this._selected(k));
|
||
this._hits.push({ kind: 'key', id: r.id, track: 'morphs', key: k, x, y: cy });
|
||
}
|
||
}
|
||
_drawAudio(c, r, y) {
|
||
const clip = r.clip;
|
||
const srcDur = this._audioDur(clip.path);
|
||
const inT = clip.in || 0; // trim within the source (like clip blocks)
|
||
const outT = clip.out ?? srcDur;
|
||
const len = outT != null ? Math.max(0.05, outT - inT) : null;
|
||
const x0 = this._t2x(clip.start);
|
||
const x1 = len != null ? this._t2x(clip.start + len) : x0 + 60; // fallback width until decoded
|
||
const bw = Math.max(3, x1 - x0);
|
||
c.fillStyle = '#2e7d5b'; c.fillRect(x0, y + 5, bw, ROW_H - 10);
|
||
c.strokeStyle = '#57c99a'; c.strokeRect(x0 + 0.5, y + 5.5, bw - 1, ROW_H - 11);
|
||
c.fillStyle = '#dff5ea'; c.font = '10px ui-monospace';
|
||
c.save(); c.beginPath(); c.rect(x0, y, bw, ROW_H); c.clip();
|
||
const trimmed = clip.in != null || clip.out != null;
|
||
c.fillText((trimmed ? '✂ ' : '') + (clip.path || '').split('/').pop() + (srcDur == null ? ' …' : ''), x0 + 4, y + ROW_H / 2 + 3); c.restore();
|
||
this._hits.push({ kind: 'audioblock', clip, x0, x1, y });
|
||
if (len != null) { // drag an edge = trim (head / tail)
|
||
this._hits.push({ kind: 'aedge', clip, side: 'out', x: x1, y });
|
||
this._hits.push({ kind: 'aedge', clip, side: 'in', x: x0, y });
|
||
}
|
||
}
|
||
|
||
// ---- interaction ----
|
||
_local(e) { const r = this.canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
|
||
// Everything that snaps goes through here — keys, clip/audio blocks, trims,
|
||
// cuts, the playhead — so beat/bar makes ALL of them land on the music.
|
||
_snap(t, e) {
|
||
if ((e && e.altKey) || !this._snapOn) return t;
|
||
if (this._snapStep === 'beat' || this._snapStep === 'bar') {
|
||
const s = this.tl.snapToGrid(t, this._snapStep);
|
||
if (s != null) return s; // no grid → fall through to frames
|
||
}
|
||
const step = (this._snapStep === 'frame' || isNaN(+this._snapStep))
|
||
? 1 / this.tl.fps : (+this._snapStep || 1 / this.tl.fps);
|
||
return Math.round(t / step) * step;
|
||
}
|
||
_hitAt(x, y) {
|
||
for (const h of this._hits) {
|
||
if (h.kind === 'key' || h.kind === 'cut') { if (Math.abs(h.x - x) < 7 && Math.abs(h.y - y) < 9) return h; }
|
||
else if (h.kind === 'edge' || h.kind === 'aedge') { if (Math.abs(h.x - x) < 5 && y > h.y && y < h.y + ROW_H) return h; }
|
||
}
|
||
for (const h of this._hits) if ((h.kind === 'block' || h.kind === 'audioblock') && x >= h.x0 && x <= h.x1 && y > h.y && y < h.y + ROW_H) return h;
|
||
return null;
|
||
}
|
||
_seek(t) { this.tl.seek(t); if (this.tl.playing) this._audioStart(); } // user seek: reschedule audio
|
||
_keysInRect(x0, y0, x1, y1) {
|
||
const ax = Math.min(x0, x1), bx = Math.max(x0, x1), ay = Math.min(y0, y1), by = Math.max(y0, y1);
|
||
const out = [];
|
||
for (const h of this._hits) if (h.kind === 'key' && h.x >= ax && h.x <= bx && h.y >= ay && h.y <= by) out.push({ id: h.id, track: h.track, key: h.key });
|
||
return out;
|
||
}
|
||
|
||
_down(e) {
|
||
const { x, y } = this._local(e);
|
||
if (y < RULER_H) { this._drag = { kind: 'seek' }; this._seek(this._x2t(x)); return; }
|
||
const h = this._hitAt(x, y);
|
||
if (!h) { this._drag = { kind: 'box', x0: x, y0: y, x, y, moved: false }; return; } // empty → box/seek
|
||
if (h.kind === 'key') {
|
||
if (this._selected(h.key) && this._selKeys.length > 1) {
|
||
this._drag = { kind: 'multikey', grabT: this._x2t(x), orig: this._selKeys.map((s) => ({ ...s, t0: s.key.t })) };
|
||
} else { this._selKeys = []; this._drag = { kind: 'key', h }; }
|
||
} else if (h.kind === 'edge') this._drag = { kind: 'trim', h };
|
||
else if (h.kind === 'aedge') this._drag = { kind: 'atrim', h };
|
||
else if (h.kind === 'block') this._drag = { kind: 'block', h, grabT: this._x2t(x) - h.block.start };
|
||
else if (h.kind === 'audioblock') this._drag = { kind: 'audio', h, grabT: this._x2t(x) - h.clip.start };
|
||
else if (h.kind === 'cut') this._drag = { kind: 'cut', h };
|
||
}
|
||
_move(e) {
|
||
if (!this._drag) return;
|
||
const { x, y } = this._local(e);
|
||
const t = this._x2t(x), d = this._drag;
|
||
if (d.kind === 'seek') this._seek(t);
|
||
else if (d.kind === 'key') { this.tl.moveKey(d.h.id, d.h.track, d.h.key, this._snap(t, e)); this.draw(); }
|
||
else if (d.kind === 'multikey') { for (const s of d.orig) this.tl.moveKey(s.id, s.track, s.key, this._snap(s.t0 + (t - d.grabT), e)); this.draw(); }
|
||
else if (d.kind === 'cut') { d.h.cut.t = this._snap(t, e); this.tl.cameraCuts.sort((a, b) => a.t - b.t); this.tl._activeCam = undefined; this.draw(); }
|
||
else if (d.kind === 'block') { this.tl.moveClipBlock(d.h.id, d.h.block, Math.max(0, this._snap(t - d.grabT, e))); this.draw(); }
|
||
else if (d.kind === 'audio') { this.tl.moveAudio(d.h.clip, this._snap(t - d.grabT, e)); this.draw(); }
|
||
else if (d.kind === 'trim') { const b = d.h.block; this.tl.trimClipBlock(d.h.id, b, { out: Math.max(b.in + 0.05, b.in + (t - b.start)) }); this.draw(); }
|
||
else if (d.kind === 'atrim') {
|
||
const cl = d.h.clip, srcDur = this._audioDur(cl.path);
|
||
if (d.h.side === 'in') this.tl.trimAudioIn(cl, this._snap(t, e), srcDur); // head trim: in + start together
|
||
else {
|
||
let out = (cl.in || 0) + (t - cl.start);
|
||
if (srcDur != null) out = Math.min(out, srcDur);
|
||
this.tl.trimAudio(cl, { out });
|
||
}
|
||
this.draw();
|
||
}
|
||
else if (d.kind === 'box') { d.x = x; d.y = y; d.moved = Math.abs(x - d.x0) > 4 || Math.abs(y - d.y0) > 4; this.draw(); }
|
||
}
|
||
_up() {
|
||
const d = this._drag; this._drag = null;
|
||
if (!d) return;
|
||
if (d.kind === 'box') {
|
||
if (!d.moved) this._seek(this._x2t(d.x0)); // click on empty = seek
|
||
else { this._selKeys = this._keysInRect(d.x0, d.y0, d.x, d.y); this.draw(); }
|
||
}
|
||
}
|
||
|
||
_dbl(e) {
|
||
const { x, y } = this._local(e);
|
||
const i = this._rowAtY(y); if (i < 0) return;
|
||
const r = this.rows[i];
|
||
const t = this._snap(this._x2t(x), e);
|
||
if (r.type === 'transform') {
|
||
const cur = this.stage.entityTransform(r.id);
|
||
this.tl.addKey(r.id, 'transform', { t, pos: cur.pos, rot: cur.rot, scale: cur.scale, ease: 'inout' });
|
||
this.draw();
|
||
} else if (r.type === 'params') {
|
||
// capture every keyable param at its current value (mirrors transform capture)
|
||
const params = (this._entity(r.id)?.params) || {};
|
||
for (const [key, value] of Object.entries(params)) {
|
||
if (key === 'type' || typeof value === 'boolean') continue; // non-keyable descriptors
|
||
this.tl.addKey(r.id, 'params', { t, key, value, ease: 'inout' });
|
||
}
|
||
this.draw();
|
||
} else if (r.type === 'cameras') {
|
||
const cams = this.stage.entities().filter((en) => en.kind === 'camera');
|
||
const cam = this.tl._activeCam || (cams[0] && cams[0].id);
|
||
if (cam) { this.tl.addCut(t, cam); this.tl.seek(this.tl.time); this.draw(); }
|
||
}
|
||
}
|
||
_ctx(e) {
|
||
e.preventDefault();
|
||
const { x, y } = this._local(e);
|
||
const h = this._hitAt(x, y);
|
||
if (h && h.kind === 'key') { this.tl.deleteKey(h.id, h.track, h.key); this._selKeys = this._selKeys.filter((s) => s.key !== h.key); this.draw(); }
|
||
else if (h && h.kind === 'cut') { const i = this.tl.cameraCuts.indexOf(h.cut); if (i >= 0) this.tl.cameraCuts.splice(i, 1); this.tl._activeCam = undefined; this.draw(); }
|
||
else if (h && h.kind === 'audioblock') { this.tl.removeAudio(h.clip); this._syncRows(); }
|
||
}
|
||
_key(e) {
|
||
if (e.target && (/INPUT|TEXTAREA|SELECT/.test(e.target.tagName) || e.target.isContentEditable)) return;
|
||
if (e.key === '?') { e.preventDefault(); this._toggleHelp(); } // never hijacked while typing
|
||
else if (e.key === 'Escape') this._helpClose();
|
||
else if (e.code === 'Space') { e.preventDefault(); this._toggle(); }
|
||
else if (e.key === 'Home') this._seek(0);
|
||
else if (e.key === 'End') this._seek(this.tl.duration);
|
||
else if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); this.tl.undo(); this.draw(); }
|
||
else if ((e.key === 'Delete' || e.key === 'Backspace') && this._selKeys.length) {
|
||
e.preventDefault();
|
||
for (const s of this._selKeys) this.tl.deleteKey(s.id, s.track, s.key);
|
||
this._selKeys = []; this.draw();
|
||
}
|
||
}
|
||
_toggle() {
|
||
if (this.tl.playing) { this.tl.pause(); this._audioStop(); }
|
||
else { this.tl.play(); this._audioStart(); }
|
||
this._wasPlaying = this.tl.playing;
|
||
this.draw();
|
||
}
|
||
|
||
// ---- Web Audio (draft playback synced to the master clock). step()/render
|
||
// NEVER touch this — the server muxes from scene.audio deterministically. ----
|
||
_ensureCtx() {
|
||
if (!this._audioCtx) { const C = window.AudioContext || window.webkitAudioContext; if (C) this._audioCtx = new C(); }
|
||
return this._audioCtx;
|
||
}
|
||
_audioDur(path) { const b = this._audioBuf.get(path); return b ? b.duration : null; }
|
||
async _decode(path) {
|
||
if (this._audioBuf.has(path)) return this._audioBuf.get(path);
|
||
const ctx = this._ensureCtx(); if (!ctx) return null;
|
||
try {
|
||
const res = await fetch('assets/file?path=' + encodeURIComponent(path));
|
||
if (!res.ok) throw new Error(res.status);
|
||
const buf = await ctx.decodeAudioData(await res.arrayBuffer());
|
||
this._audioBuf.set(path, buf);
|
||
this.draw(); // width now known
|
||
return buf;
|
||
} catch { return null; } // keep the block at fallback width
|
||
}
|
||
_decodeAll() { for (const clip of (this.tl.scene.audio || [])) this._decode(clip.path); }
|
||
_audioStart() {
|
||
const ctx = this._ensureCtx(); if (!ctx) return;
|
||
if (ctx.state === 'suspended') ctx.resume();
|
||
this._audioStop();
|
||
const now = ctx.currentTime, t = this.tl.time;
|
||
for (const clip of (this.tl.scene.audio || [])) {
|
||
const buf = this._audioBuf.get(clip.path); if (!buf) continue;
|
||
const inT = Math.min(clip.in || 0, buf.duration); // trim within source
|
||
const len = Math.min(clip.out ?? buf.duration, buf.duration) - inT;
|
||
const offset = Math.max(0, t - clip.start); // seconds into the (trimmed) block at playhead
|
||
if (len <= 0 || offset >= len) continue; // already finished
|
||
const src = ctx.createBufferSource(); src.buffer = buf;
|
||
const g = ctx.createGain(); g.gain.value = clip.gain ?? 1;
|
||
src.connect(g).connect(ctx.destination);
|
||
const when = now + Math.max(0, clip.start - t);
|
||
src.start(when, inT + offset, len - offset);
|
||
this._audioSrcs.push(src);
|
||
}
|
||
}
|
||
_audioStop() { for (const s of this._audioSrcs) { try { s.stop(); } catch { /* already stopped */ } } this._audioSrcs = []; }
|
||
|
||
async _pickAudio() {
|
||
let list = [];
|
||
try { const r = await fetch('assets/tree'); if (r.ok) list = (await r.json()).audio || []; } catch { /* offline */ }
|
||
const menu = document.createElement('div'); menu.className = 'tl-menu';
|
||
if (!list.length) menu.innerHTML = '<div class="empty">no audio in /assets/tree</div>';
|
||
for (const it of list) {
|
||
const path = it.path || it;
|
||
const item = document.createElement('div'); item.className = 'item';
|
||
item.textContent = (it.name || path).split('/').pop();
|
||
item.onclick = async () => {
|
||
this.tl.addAudio({ path, start: this.tl.time, gain: 1 });
|
||
menu.remove();
|
||
await this._decode(path);
|
||
this._syncRows();
|
||
};
|
||
menu.appendChild(item);
|
||
}
|
||
document.body.appendChild(menu);
|
||
const rect = this.$audioBtn.getBoundingClientRect();
|
||
menu.style.left = rect.left + 'px'; menu.style.top = (rect.bottom + 2) + 'px';
|
||
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
|
||
setTimeout(() => document.addEventListener('mousedown', close), 0);
|
||
}
|
||
|
||
// ---- M7-B: new from template (Lane C's read-only /templates) ----
|
||
// All URLs stay RELATIVE — the app is served under /scenegod/ in prod.
|
||
async _pickTemplate() {
|
||
let list = [];
|
||
try { const r = await fetch('templates'); if (r.ok) list = await r.json(); } catch { /* offline */ }
|
||
const menu = document.createElement('div'); menu.className = 'tl-menu tmpl';
|
||
if (!Array.isArray(list) || !list.length) menu.innerHTML = '<div class="empty">no starter templates on the server</div>';
|
||
for (const t of (list || [])) {
|
||
const item = document.createElement('div'); item.className = 'item';
|
||
if (t.thumb) {
|
||
const img = document.createElement('img');
|
||
img.src = 'assets/file?path=' + encodeURIComponent(t.thumb);
|
||
img.onerror = () => img.remove(); // thumb missing → text-only row
|
||
item.appendChild(img);
|
||
}
|
||
const txt = document.createElement('div');
|
||
const ti = document.createElement('div'); ti.className = 't'; ti.textContent = t.title || t.name;
|
||
txt.appendChild(ti);
|
||
if (t.description) { const d = document.createElement('div'); d.className = 'd'; d.textContent = t.description; txt.appendChild(d); }
|
||
item.appendChild(txt);
|
||
item.onclick = () => { menu.remove(); this.newFromTemplate(t.name); };
|
||
menu.appendChild(item);
|
||
}
|
||
document.body.appendChild(menu);
|
||
const rect = this.$tmplBtn.getBoundingClientRect();
|
||
menu.style.top = (rect.bottom + 2) + 'px';
|
||
menu.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - menu.offsetWidth - 6)) + 'px';
|
||
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
|
||
setTimeout(() => document.addEventListener('mousedown', close), 0);
|
||
}
|
||
|
||
// Applies exactly like load(): stage.applyState → tl.load → preload (all three
|
||
// inside tl.applyScene, which also strips the presentation-only `template` key).
|
||
async newFromTemplate(name) {
|
||
if (this.tl.hasContent() && !confirm('Replace the current scene with this template? Unsaved work is lost.')) return;
|
||
let json;
|
||
try {
|
||
const res = await fetch('templates/' + encodeURIComponent(name));
|
||
if (!res.ok) throw new Error(res.status);
|
||
json = await res.json();
|
||
} catch (e) { this._toast('template "' + name + '" failed to load: ' + ((e && e.message) || e)); return; }
|
||
try { await this.tl.applyScene(json); }
|
||
catch (e) { this._toast('template failed to apply: ' + ((e && e.message) || e)); return; }
|
||
this._decodeAll();
|
||
this.draw();
|
||
}
|
||
|
||
// ---- M7-B: ? shortcuts overlay. Documents what the code ACTUALLY binds
|
||
// (stage.js gizmo keys + dock drops + everything in _key/_down/_dbl/_ctx/
|
||
// _wheel below). Toggle with ?, close with Escape or a click outside. ----
|
||
_toggleHelp() {
|
||
if (this._help) { this._helpClose(); return; }
|
||
const S = (h, rows) => `<section><h4>${h}</h4><dl>` +
|
||
rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('') + '</dl></section>';
|
||
const el = document.createElement('div'); el.className = 'tl-help';
|
||
el.innerHTML = `<button class="close" title="close">✕</button>
|
||
<h3>SCENEGOD shortcuts</h3><div class="hint">? toggles this panel · Escape closes it</div><div class="cols">` +
|
||
S('STAGE', [
|
||
['drag / right-drag / wheel', 'orbit · pan · dolly the director cam'],
|
||
['click', 'select an object'],
|
||
['W / E / R', 'gizmo: move · rotate · scale (on the selection)'],
|
||
['drag a dock card in', 'place a character, prop or backdrop'],
|
||
['drag an animation onto a character', 'retarget + drop a clip block at the playhead'],
|
||
['drop files on the page', 'local glb/fbx/image/video (upload — not saved)'],
|
||
]) +
|
||
S('TIMELINE', [
|
||
['Space', 'play / pause'],
|
||
['Home / End', 'jump to start / end'],
|
||
['click or drag the ruler', 'scrub'],
|
||
['double-click a transform lane', 'key the object where it stands now'],
|
||
['double-click a params lane', 'key its current params (fov, intensity, colour…)'],
|
||
['double-click the Cameras row', 'cut to the active camera'],
|
||
['drag a key', 'move it — snaps to the increment, Alt = free'],
|
||
['drag empty lane space', 'box-select keys; drag one of them to move them all'],
|
||
['Delete / Backspace', 'delete the selected keys'],
|
||
['right-click', 'delete a key, a cut, or an audio block'],
|
||
['drag a clip / audio block', 'move it; drag an edge to trim (audio trims either end)'],
|
||
['Cmd/Ctrl + wheel', 'zoom the time axis 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'],
|
||
]) +
|
||
S('SCENE BAR', [
|
||
['New…', 'start from a starter template (asks before clobbering)'],
|
||
['Save / Load', 'scenes by name (falls back to localStorage offline)'],
|
||
['dur / snap', 'scene length · snap increment — frame/0.1/0.25/1s, or beat/bar once ♪ found a grid'],
|
||
['🎵 / ♪ / 🗣', 'add audio at the playhead · beat-detect it · lip-sync it onto a character'],
|
||
['🎬', 'FILM: shot list — sequences of scene windows, cut/dissolve, render to one mp4'],
|
||
]) + '</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();
|
||
}
|
||
|
||
// ---- 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="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('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']) {
|
||
const b = this.$film.querySelector(`[data-f="${f}"]`);
|
||
if (b) b.disabled = !has;
|
||
}
|
||
const shots = (this._seq && this._seq.shots) || [];
|
||
// re-apply the per-row disables the rebuild just cleared
|
||
this.$film.querySelectorAll('.shots .frow').forEach((row, i) => {
|
||
const bs = row.querySelectorAll('button');
|
||
if (bs[0]) bs[0].disabled = i === 0;
|
||
if (bs[1]) bs[1].disabled = i === shots.length - 1;
|
||
const sels = row.querySelectorAll('select');
|
||
if (sels[1]) sels[1].disabled = i === shots.length - 1;
|
||
});
|
||
}
|
||
_filmBusySet(b) {
|
||
this._filmBusy = b;
|
||
for (const nEl of this.$film.querySelectorAll('button,select,input')) nEl.disabled = b;
|
||
this.$render.textContent = b ? 'rendering…' : 'Render film';
|
||
if (!b) this._filmEnable();
|
||
}
|
||
|
||
async _filmSave() {
|
||
if (!this._seq) return null;
|
||
try {
|
||
const F = await this._filmMod();
|
||
const r = await F.saveSequence(this._seqName, this._seq);
|
||
this._seqName = r.name || this._seqName;
|
||
this._filmStatus(`saved · ${r.shots} shots · ${(+r.duration).toFixed(2)}s`
|
||
+ ((r.warnings || []).length ? ' · ' + r.warnings.join(' · ') : ''));
|
||
return r;
|
||
} catch (e) { this._filmStatus('save failed — ' + this._errText(e), true); return null; }
|
||
}
|
||
async _filmNew() {
|
||
const name = (prompt('new sequence name', 'my-film') || '').trim();
|
||
if (!name) return;
|
||
const first = (this._scenes || [])[0];
|
||
if (!first) { this._filmStatus('no saved scenes to cut together — save a scene first', true); return; }
|
||
this._seq = { version: 1, name, title: name, audio: [],
|
||
shots: [{ scene: first.name, in: 0, out: first.duration || 5, transition: 'cut', transitionDur: 0.5 }] };
|
||
this._seqName = name;
|
||
this._filmRows();
|
||
const r = await this._filmSave(); // a sequence only exists once the server has it
|
||
if (r) await this._filmRefresh(r.name);
|
||
}
|
||
async _filmRename() {
|
||
if (!this._seq) return;
|
||
const old = this._seqName;
|
||
const name = (prompt('rename sequence', old) || '').trim();
|
||
if (!name || name === old) return;
|
||
this._seq.name = name;
|
||
if (!this._seq.title || this._seq.title === old) this._seq.title = name;
|
||
this._seqName = name;
|
||
const r = await this._filmSave(); // save-then-delete: a failed save leaves the original
|
||
if (!r) { this._seqName = old; return; }
|
||
try { const F = await this._filmMod(); await F.deleteSequence(old); } catch { /* old copy lingers, harmless */ }
|
||
await this._filmRefresh(r.name);
|
||
}
|
||
async _filmDelete() {
|
||
if (!this._seq) return;
|
||
if (!confirm(`Delete the sequence "${this._seqName}"? The scenes it cuts together are NOT touched.`)) return;
|
||
try { const F = await this._filmMod(); await F.deleteSequence(this._seqName); }
|
||
catch (e) { this._filmStatus('delete failed — ' + this._errText(e), true); return; }
|
||
this._seq = null; this._seqName = null;
|
||
await this._filmRefresh();
|
||
}
|
||
|
||
// renderSequence loads EVERY shot's scene onto the stage in turn (via
|
||
// tl.applyScene), so it clobbers whatever is open — same warn-before-clobber
|
||
// contract as New-from-template, said plainly. One render at a time.
|
||
async _filmRender() {
|
||
if (!this._seq || this._filmBusy) return;
|
||
if (!(this._seq.shots || []).length) { this._filmStatus('nothing to render — add a shot', true); return; }
|
||
if (this.tl.hasContent() && !confirm(
|
||
'Render this film?\n\nEach shot loads its own saved scene onto the stage in turn, so the scene you have open right now will be REPLACED and any unsaved work is lost. Save it first if you want to keep it.')) return;
|
||
const saved = await this._filmSave(); // the server renders the SAVED shot list, not this editor
|
||
if (!saved) return;
|
||
const [w, h] = (this.$size.value || '1920x1080').split('x').map(Number);
|
||
this._filmBusySet(true);
|
||
this._filmStatus('starting…');
|
||
try {
|
||
const F = await this._filmMod();
|
||
const url = await F.renderSequence(this.stage, this.tl, this._seqName, {
|
||
width: w, height: h,
|
||
onShot: (i, n, shot) => this._filmStatus(`shot ${i + 1}/${n} — ${shot.scene} ${shot.in}–${shot.out}s`),
|
||
onProgress: (p) => this._filmStatus(`shot ${p.shot + 1}/${p.of} — ${p.scene} — frame ${p.frame}/${p.frames}`),
|
||
onWarning: (m) => this._toast('film: ' + m),
|
||
});
|
||
const st = this._filmStatus('done · ');
|
||
const a = document.createElement('a');
|
||
a.href = url; a.target = '_blank'; a.rel = 'noopener'; a.textContent = url;
|
||
st.appendChild(a);
|
||
st.append(" · the stage now holds the last shot's scene");
|
||
this._toast('🎬 film rendered — ' + url);
|
||
} catch (e) { this._filmStatus('render failed — ' + this._errText(e), true); }
|
||
finally {
|
||
this._filmBusySet(false);
|
||
this._syncRows(); this._refreshBar(); this.draw(); // the loaded scene changed under us
|
||
}
|
||
}
|
||
|
||
// ---- persistence (Lane C endpoints; localStorage fallback) ----
|
||
async save() {
|
||
const name = (this.$name.value || 'untitled').trim();
|
||
this.tl.scene.name = name;
|
||
const json = this.tl.toJSON();
|
||
try {
|
||
const res = await fetch(`scenes/${encodeURIComponent(name)}`, {
|
||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(json),
|
||
});
|
||
if (!res.ok) throw new Error(res.status);
|
||
} catch {
|
||
localStorage.setItem('scenegod:scene:' + name, JSON.stringify(json)); // fallback until Lane C reachable
|
||
}
|
||
}
|
||
async load(name) {
|
||
name = (name || '').trim(); if (!name) return;
|
||
let json = null;
|
||
try { const res = await fetch(`scenes/${encodeURIComponent(name)}`); if (res.ok) json = await res.json(); } catch { /* fall through */ }
|
||
if (!json) { const raw = localStorage.getItem('scenegod:scene:' + name); if (raw) json = JSON.parse(raw); }
|
||
if (!json) return;
|
||
await this.tl.applyScene(json); // stage.applyState → tl.load (onLoad → rows+bar) → preload
|
||
this._decodeAll(); // decode audio for lane widths + playback
|
||
this.draw();
|
||
}
|
||
}
|
||
|
||
export default TimelineUI;
|