diff --git a/lanes/B-timeline.md b/lanes/B-timeline.md index 9d698ec..b75cdf9 100644 --- a/lanes/B-timeline.md +++ b/lanes/B-timeline.md @@ -190,3 +190,22 @@ column (tlui.js CSS). Your call to strip the `template` key inside `applyScene` lean on round-trip behaviour was the right one; PLAN §4.1 stays honest. Carried forward (yours or Lane C's): audio `in`/`out` still ignored at ffmpeg mux — draft playback honours the trim, the render doesn't. Worth closing before anyone cuts to music. + +### 2026-07-26 — M9-B ACCEPTED at SYNC 12. Beat snapping + FILM panel both verified live. +Grid: 128.02 BPM / 35 beats / 8 downbeats / conf 0.865 off house128.wav, snap select +auto-flips to beat, and snapped positions land 8–10 ms from the TRUE grid (0.468735s +period) at t=0.3 through t=12.4 — i.e. all residual is Lane C's detector, none is yours. +Bar snap hits a downbeat. beatGrid survives toJSON→load. FILM panel lists both sequences, +demo-two-shot shows 3 editable shot rows and the server's own 11.50s arithmetic. +Two calls I want to endorse, because both were judgement rather than instruction-following: +1. Storing ONE top-level `scene.beatGrid` rather than per-clip — "which grid am I snapping + to" needs exactly one answer. +2. Keeping beats in FILE seconds and re-deriving the offset from the audio clip, so + dragging the audio drags the grid and a head-trim leaves it put. That's the behaviour a + musician expects and it falls out of the data shape instead of special-casing. +And you CHECKED validate_scene before inventing a field (and round-tripped it live) rather +than guessing — exactly right; that's the difference between a schema change and a bug. +NEXT: nothing queued. Open ideas if you want them: `?from=&to=` windowing on /beats for DJ +mixes (logged for Lane C), and auto-cut — given a grid and a shot list, distribute cuts +onto downbeats automatically. That last one is small now and would be the whole music-video +workflow in one button. diff --git a/logs/laneB.md b/logs/laneB.md index 54091f3..0d0e5d1 100644 --- a/logs/laneB.md +++ b/logs/laneB.md @@ -316,3 +316,115 @@ NEXT: nothing required. Remaining polish if a session 7 lands: box-select blocks and cuts are not box-selectable), and a per-drag undo coalesce (a mousemove drag pushes one undo entry per frame; Ctrl+Z walks it back a pixel at a time). Holding at the M7-B boundary. + +## 2026-07-26 session 7 (M9-B: snap to the beat + the FILM panel) +DONE: both features, headless-verified. No git run this session (orchestrator's + instruction) — `timeline.js`, `tlui.js`, `timeline_test.mjs` and this log are + edited in place, uncommitted. `stagestub.js` needed no change this time. + +**M9-B1 — SNAP TO THE BEAT.** + - Model (`timeline.js:578-673`): `setBeatGrid/clearBeatGrid` (`:593`), + `beatOffset()` (`:600`), `gridTimes(kind)` (`:616`), `snapToGrid(t, kind)` + (`:635`), plus the pure `beatGridFrom(res, offset)` export (`:678`) that maps + Lane C's `/beats` response into the stored shape. + - UI (`tlui.js`): `♪` button on every audio row next to `🗣` (`:~250`, + `_detectBeats` at `:775`) — `GET beats?path=` → cache per path + (`this._beatCache`) → `setBeatGrid` → snap flips to `beat` and the toast says + the BPM, the beat count and the confidence. **beat** + **bar** added to the + snap-increment select (`:156`); `_snap()` (`:477`) routes those two through + `tl.snapToGrid` and falls back to frames when no grid exists (plus a toast if + you pick beat/bar with nothing detected). Grid drawing: `_drawBeatGrid` + (`:365`) — beats `rgba(146,180,232,0.13)`, downbeats `rgba(224,175,104,0.34)`, + over the lanes, suppressed below ~7px spacing (beats drop out first, then bars, + then nothing — never a solid wash). + - Because `_snap` was already the ONE snapping funnel, keys, clip blocks, audio + blocks, both trim edges, camera cuts and the playhead all became musical for + free. That was the whole bet in the M2 design and it paid. + +**M9-B2 — FILM panel** (`tlui.js:848-1130`): collapsible `.tl-film` section + mounted INSIDE `#timeline` (`_buildFilm` `:854`, toggled by `🎬` in the scene + bar `:158`). Sequence dropdown + New…/Rename/Delete, computed runtime, size + picker, Save, Render film. Shot rows (`_filmRow` `:960`): scene ` dur - - + + + @@ -143,8 +179,14 @@ export class TimelineUI { 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; }; + 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'; @@ -155,6 +197,7 @@ export class TimelineUI { 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 }); @@ -205,6 +248,11 @@ export class TimelineUI { 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'; @@ -290,6 +338,8 @@ export class TimelineUI { 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) { @@ -309,6 +359,30 @@ export class TimelineUI { 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); } @@ -398,9 +472,16 @@ export class TimelineUI { // ---- 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; - const step = this._snapStep === 'frame' ? 1 / this.tl.fps : (+this._snapStep || 1 / this.tl.fps); + 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) { @@ -663,12 +744,14 @@ export class TimelineUI { ['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 for drags'], - ['🎵 / 🗣', 'add audio at the playhead · lip-sync that clip onto a character'], + ['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'], ]) + ''; el.querySelector('.close').onclick = () => this._helpClose(); document.body.appendChild(el); @@ -684,6 +767,39 @@ export class TimelineUI { 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`. @@ -729,6 +845,289 @@ export class TimelineUI { setTimeout(() => d.remove(), 4000); } + // ---- M9-B2: FILM panel — the shot list a film is actually made of -------- + // A scene is one take; a SEQUENCE is the film (Lane C, M8-C). Everything here + // goes through web/film.js's helpers so the URLs are never hand-rolled. film.js + // is imported LAZILY (first 🎬 press): the timeline panel must still mount if + // Lane C's client files are missing, and nobody who never opens FILM pays for + // render.js. Mounted inside #timeline — index.html is Lane A's file. + _buildFilm() { + const el = document.createElement('div'); + el.className = 'tl-film'; + el.hidden = true; + el.innerHTML = ` +
+ FILM + + + + + runtime + + + + +
+
+
`; + this.el.appendChild(el); + this.$film = el; + this.$seq = el.querySelector('.seq'); + this.$size = el.querySelector('.size'); + this.$render = el.querySelector('[data-f="render"]'); + this.$seq.onchange = () => this._filmSelect(this.$seq.value); + const on = (f, fn) => { el.querySelector(`[data-f="${f}"]`).onclick = fn; }; + on('new', () => this._filmNew()); + on('ren', () => this._filmRename()); + on('del', () => this._filmDelete()); + on('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();