[m9-B] snap-to-beat + FILM shot-list panel

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>
This commit is contained in:
type-two 2026-07-26 00:32:39 +10:00
parent e05a1d2946
commit 915f653525
6 changed files with 770 additions and 7 deletions

View File

@ -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 810 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.

View File

@ -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 `<select>` from
`GET scenes` (with `/Ns` scene length shown), in/out numbers, transition
cut|dissolve (+ seconds, only where a next shot exists), `↑ ↓ ✕`. All server
talk goes through Lane C's `film.js` helpers (`listSequences/getSequence/
saveSequence/deleteSequence/renderSequence`) — no hand-rolled URLs.
`_filmRender` (`:1100`): `hasContent()` + a confirm that says plainly that every
shot loads its own scene and the open scene will be REPLACED; then it SAVES
first (the server renders the saved shot list, not the editor), disables the
whole panel for the duration (`_filmBusySet` `:1045`, so no second render), shows
`shot 2/3 — sync2-sunset — frame 45/120` live, and ends with a link to
`films/{id}/out.mp4` + a note that the stage now holds the last shot's scene.
422s from `plan_sequence` are unwrapped by `_errText` and shown in red.
TEST: `node scenegod/web/timeline_test.mjs` → green, +2 blocks (`:394`, `:482`).
Beat block runs against the REAL captured `/beats` payload for
`audio/test/house128.wav` (bpm 128.02, 35 beats, 8 downbeats, conf 0.865), and
asserts: no grid → `snapToGrid` null + nothing to draw; nearest beat over 9 time
points and nearest downbeat over 6; the 0.24/0.25s midpoint flip; bars use Lane
C's downbeat COSET (first downbeat is beats[3], not beats[0]); extrapolation past
the last beat; never snapping below 0; grid follows `moveAudio` and survives
`trimAudioIn` unmoved (start and `in` move together); `gridTimes` covers a longer
scene and stays sorted; bar lines are a subset of beat lines; a 0.12-confidence
grid still snaps and a grid alone does NOT make `hasContent()` true; `toJSON`
`load` → identical snapping; `clearBeatGrid` leaves plain scene JSON. Film block
asserts `filmRuntime` equals the server's arithmetic on the shipped
`demo-two-shot` (11.50s), plus the last-shot-dissolve and cut-transitionDur
edge cases. `node --check` clean on all four files.
SERVER-SIDE CHECKS (read-only against the running :8020, nothing restarted):
- `validate_scene` (server.py:235) inspects only version/entities/tracks/
cameraCuts — verified by calling it directly with a `beatGrid` scene
(`violations: []`), so the new top-level key is legal, not merely tolerated.
- Full round-trip proven live: POST a scene carrying `beatGrid` → 200, GET it
back → grid intact byte-for-byte. Test scene file deleted again (scenes/ holds
the same four it did before).
DECISIONS:
- **Field shape — ONE top-level `scene.beatGrid`, not per-audio-clip**:
`{path, bpm, confidence, meter, period, offset, beats[], downbeats[]}`.
A music video has one tempo, and "which grid am I snapping to" must have
exactly one answer; per-clip grids would need a picker for a question nobody
asks. `beats`/`downbeats` stay in FILE seconds exactly as Lane C returns them
(so the stored grid is comparable with a re-fetch) and `offset` records where
the file's t=0 sat. The EFFECTIVE offset is re-derived from the audio clip
with the same `path` whenever one is still on the timeline — drag the audio
block and the grid comes with it; delete the clip and the stored `offset`
keeps it working.
- **The grid extrapolates at constant tempo outside the analysed range**, in
BOTH `gridTimes` (drawing) and `snapToGrid`, so the lines you see and the
snap you get agree everywhere. Without it, an 8s track under a 30s scene drags
every late key back onto the last real beat — a trap, not a feature. Lane C's
grid is explicitly constant-tempo, so this invents nothing.
- **Low confidence warns, never refuses** (Lane C: >0.5 solid, <0.2 mush). The
UI toasts "LOW confidence (0.12) — this grid may be mush" at <0.35 and snaps
anyway; the model has no opinion about confidence at all.
- The fetch + per-path cache live in **tlui**, like every other server call in
this lane (`/rhubarb`, `/templates`, `/assets/tree`); `timeline.js` got the
pure `beatGridFrom` instead, so the response→storage contract is testable
headless without stubbing `fetch` for a three-line call.
- `hasContent()` deliberately ignores `beatGrid`: it is one button press to
rebuild, so it is not work worth a confirm.
- **film.js is imported lazily** (`_filmMod()`, first 🎬 press) rather than at
the top of tlui.js: the timeline must still mount if Lane C's client files are
missing or broken, and nobody who never opens FILM should pay for render.js.
- `filmRuntime(shots)` is exported from tlui.js as a pure function so the number
on screen can be pinned to the server's own formula in the node test — tlui.js
touches no DOM at module scope, and the test's `import('./tlui.js')` now fails
loudly the day that changes.
- Rename is save-then-delete (a failed save leaves the original untouched); the
slug the server returns becomes the working name.
BLOCKED/REQUESTS:
- **Lane C / orchestrator (nice-to-have, not blocking)**: `/beats` on a DJ mix
assumes one tempo for the whole hour — Lane C already has `?from=&to=`
windowing on its own backlog. When it lands I'd store one grid per window, or
at minimum re-detect from the playhead; the current shape can grow a
`range:[from,to]` field without breaking anything.
- **Lane C (still open, third session running)**: nothing new — audio `in`/`out`
at the mux is CLOSED as of C session 7, thanks.
- **Orchestrator**: PLAN §4.1 could gain the two fields now in live use —
`scene.beatGrid` (shape above) and the already-shipped audio `{in?, out?}`.
NEXT: nothing required. Backlog if a session 8 lands: (1) a "nudge to grid" action
that re-snaps EXISTING keys/cuts to the nearest beat (today only new drags snap);
(2) box-select still only grabs keys, not clip/audio blocks or cuts; (3) per-drag
undo coalesce (a mousemove drag still pushes one undo entry per frame);
(4) film-level `audio[]` has no UI — a sequence's soundtrack must still be edited
by hand, and it is the obvious partner to beat-snapping.

View File

@ -1609,3 +1609,35 @@ INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8020 (Press CTRL+C to quit)
INFO: 127.0.0.1:62801 - "GET /beats?path=audio/test/beat120.wav HTTP/1.1" 200 OK
INFO: 127.0.0.1:62802 - "GET /beats?path=audio/test/house128.wav HTTP/1.1" 200 OK
INFO: 127.0.0.1:63436 - "GET /beats?path=audio/test/house128.wav HTTP/1.1" 200 OK
INFO: 127.0.0.1:63442 - "GET /scenes HTTP/1.1" 200 OK
INFO: 127.0.0.1:63443 - "GET /sequences HTTP/1.1" 200 OK
INFO: 127.0.0.1:63444 - "GET /sequences/demo-two-shot HTTP/1.1" 200 OK
INFO: 127.0.0.1:65139 - "GET /beats?path=audio/test/house128.wav HTTP/1.1" 200 OK
INFO: 127.0.0.1:49381 - "POST /scenes/laneb-beatgrid-check HTTP/1.1" 200 OK
INFO: 127.0.0.1:49382 - "GET /scenes/laneb-beatgrid-check HTTP/1.1" 200 OK
INFO: 127.0.0.1:49747 - "HEAD /web/tlui.js HTTP/1.1" 405 Method Not Allowed
INFO: 127.0.0.1:49765 - "GET /web/tlui.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:49766 - "GET /web/tlui.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:49976 - "GET /?cb=m9b HTTP/1.1" 200 OK
INFO: 127.0.0.1:49976 - "GET /web/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:49976 - "GET /web/stage.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:49977 - "GET /web/dock.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:49977 - "GET /web/room3d.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:49978 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:49976 - "GET /web/presets.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:49977 - "GET /assets/tree HTTP/1.1" 200 OK
INFO: 127.0.0.1:49977 - "GET /web/grammar.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:49977 - "GET /director HTTP/1.1" 405 Method Not Allowed
INFO: 127.0.0.1:49976 - "GET /web/timeline.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:49976 - "GET /web/tlui.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:50062 - "GET /templates/two-hander-golden-hour HTTP/1.1" 200 OK
INFO: 127.0.0.1:50062 - "GET /assets/thumb?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:50062 - "GET /assets/file?path=characters%2Ftest%2Flady.glb HTTP/1.1" 200 OK
INFO: 127.0.0.1:50062 - "GET /beats?path=audio%2Ftest%2Fhouse128.wav HTTP/1.1" 200 OK
INFO: 127.0.0.1:50170 - "GET /web/film.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:50170 - "GET /web/render.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:50170 - "GET /sequences HTTP/1.1" 200 OK
INFO: 127.0.0.1:50170 - "GET /scenes HTTP/1.1" 200 OK
INFO: 127.0.0.1:50170 - "GET /sequences/demo-two-shot HTTP/1.1" 200 OK
INFO: 127.0.0.1:50170 - "GET /sequences/demo-two-shot HTTP/1.1" 200 OK

View File

@ -575,6 +575,85 @@ export class Timeline {
this.group(() => { this.trimAudio(clip, { in: inT + d }); this.moveAudio(clip, clip.start + d); });
}
// ---- M9-B1: BEAT GRID (Lane C's `GET beats?path=`) -------------------------
// Stored ON THE SCENE (`scene.beatGrid`) so it survives save/load — the grid is
// the musical spine of an edit, not a UI preference. It is a single top-level
// object, NOT per-audio-clip: one track sets the tempo of a music video, and
// "which grid is the timeline snapping to" must have exactly one answer.
// Legal by inspection: server.py:235 `validate_scene` only looks at version /
// entities / cameraCuts, so an extra top-level key passes, and load()'s
// structuredClone carries it through the lossless round-trip for free.
//
// {path, bpm, confidence, meter, period, offset, beats[], downbeats[]}
//
// `beats`/`downbeats` stay in FILE seconds exactly as Lane C returns them;
// `offset` is the timeline second the file's t=0 sits at. The effective offset
// is re-derived from the audio clip with the same path whenever one is still
// on the timeline, so dragging the audio block drags the grid with it.
setBeatGrid(grid) {
if (!grid) { delete this.scene.beatGrid; return null; }
this.scene.beatGrid = grid;
return grid;
}
clearBeatGrid() { delete this.scene.beatGrid; }
beatOffset() {
const g = this.scene.beatGrid;
if (!g) return 0;
const clip = (this.scene.audio || []).find((c) => c.path === g.path);
return clip ? clip.start - (clip.in || 0) : (g.offset || 0);
}
_beatPeriod(kind) {
const g = this.scene.beatGrid;
if (!g) return 0;
const per = g.period || (g.bpm ? 60 / g.bpm : 0);
return kind === 'bar' ? per * (g.meter || 4) : per;
}
// Grid positions in TIMELINE seconds, extended at the constant tempo across the
// whole scene so the drawn lines and snapToGrid() agree everywhere — not only
// where the audio file happens to have samples.
gridTimes(kind = 'beat') {
const g = this.scene.beatGrid;
if (!g) return [];
const src = kind === 'bar' ? (g.downbeats || []) : (g.beats || []);
if (!src.length) return [];
const off = this.beatOffset();
const per = this._beatPeriod(kind);
const inFile = src.map((b) => b + off);
if (!(per > 0.05)) return inFile.filter((t) => t >= 0); // nonsense tempo → no extrapolation
const before = [];
for (let x = inFile[0] - per; x >= 0; x -= per) before.push(x);
const after = [];
for (let x = inFile[inFile.length - 1] + per; x <= this.duration; x += per) after.push(x);
return [...before.reverse(), ...inFile.filter((t) => t >= 0), ...after];
}
// Nearest grid line to t, in timeline seconds. null = no grid (caller falls
// back to its fixed increment). Outside the analysed range the constant tempo
// is extrapolated, same as gridTimes.
snapToGrid(t, kind = 'beat') {
const g = this.scene.beatGrid;
if (!g) return null;
const src = kind === 'bar' ? g.downbeats : g.beats;
if (!src || !src.length) return null;
const off = this.beatOffset();
const per = this._beatPeriod(kind);
const x = t - off;
const last = src[src.length - 1];
let r;
if (per > 0 && x <= src[0]) r = src[0] + Math.round((x - src[0]) / per) * per;
else if (per > 0 && x >= last) r = last + Math.round((x - last) / per) * per;
else {
let lo = 0, hi = src.length - 1;
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (src[m] <= x) lo = m; else hi = m; }
r = (x - src[lo] <= src[hi] - x) ? src[lo] : src[hi];
}
r += off;
if (r < 0 && per > 0) r += per; // never snap off the front of the timeline
return Math.max(0, r);
}
// Rhubarb lip-sync JSON → morph keys. { mouthCues: [{start, end, value}] }.
// ponytail: per-cue ramp (shape 1 at cue start → 0 at cue end); hold/crossfade
// tuning deferred until it looks wrong on a real blendshape character.
@ -591,4 +670,22 @@ export class Timeline {
}
}
// Lane C's /beats response → the scene-stored grid (M9-B1). Pure, so the shape
// contract is testable headless against a real captured payload; the fetch + the
// per-path cache live in tlui, like every other server call in this lane.
// `offset` = the timeline second where the file's t=0 sits (audio clip
// `start - (in||0)`, per Lane C's log: "beat + audioClip.start - (audioClip.in||0)").
export function beatGridFrom(res, offset = 0) {
return {
path: res.path,
bpm: res.bpm,
confidence: res.confidence ?? 0,
meter: res.meter || 4,
period: res.period || (res.bpm ? 60 / res.bpm : 0),
offset,
beats: res.beats || [],
downbeats: res.downbeats || [],
};
}
export default Timeline;

View File

@ -2,7 +2,7 @@
// node scenegod/web/timeline_test.mjs
import assert from 'node:assert/strict';
import { StageStub } from './stagestub.js';
import { Timeline } from './timeline.js';
import { Timeline, beatGridFrom } from './timeline.js';
const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
@ -391,4 +391,108 @@ for (const f of shipped) {
}
console.log(` (${shipped.length} shipped templates applied clean)`);
// ---- M9-B1: BEAT GRID — snap to the beat --------------------------------
// The literal below is the REAL response of Lane C's `GET beats?path=
// audio/test/house128.wav` (true tempo 128.004; detector 128.02, worst drift
// 10ms over the file). Testing against the shipped payload means the stored
// shape can't drift from the endpoint without this failing.
const BEATS128 = {
path: 'audio/test/house128.wav', bpm: 128.02, confidence: 0.865, meter: 4,
duration: 16.0, analyzed: 16.0, period: 0.468662, acPeak: 0.881, onBeatContrast: 0.85,
beats: [0.0101, 0.4787, 0.9474, 1.4161, 1.8847, 2.3534, 2.822, 3.2907, 3.7594, 4.228,
4.6967, 5.1654, 5.634, 6.1027, 6.5713, 7.04, 7.5087, 7.9773, 8.446, 8.9146, 9.3833,
9.852, 10.3206, 10.7893, 11.258, 11.7266, 12.1953, 12.6639, 13.1326, 13.6013, 14.0699,
14.5386, 15.0073, 15.4759, 15.9446],
downbeats: [1.4161, 3.2907, 5.1654, 7.04, 8.9146, 10.7893, 12.6639, 14.5386],
};
const nearestOf = (arr, t) => arr.reduce((b, x) => (Math.abs(x - t) < Math.abs(b - t) ? x : b));
const beatStage = new StageStub();
const tlBeat = new Timeline(beatStage);
tlBeat.load({ version: 1, name: 'beat', fps: 30, duration: 16, entities: [], cameraCuts: [],
audio: [{ path: 'audio/test/house128.wav', start: 0, gain: 1 }] });
assert.equal(tlBeat.snapToGrid(1.0, 'beat'), null, 'no grid → snapToGrid is null (the UI falls back to frames)');
assert.deepEqual(tlBeat.gridTimes('beat'), [], 'no grid → nothing to draw');
const aud = tlBeat.scene.audio[0];
tlBeat.setBeatGrid(beatGridFrom(BEATS128, aud.start - (aud.in || 0)));
const bg = tlBeat.scene.beatGrid;
assert.equal(bg.bpm, 128.02, 'grid carries the bpm');
assert.equal(bg.meter, 4, 'grid carries the meter (4/4 is an assumption Lane C reports, not a detection)');
assert.equal(bg.beats.length, 35, 'grid carries every beat in file seconds');
// the payoff: nearest beat / nearest downbeat, in range
for (const t of [0.0, 0.2, 0.25, 0.3, 1.39, 4.44, 7.9, 12.0, 15.8]) { // whole file is in beat range
const b = tlBeat.snapToGrid(t, 'beat');
assert.ok(near(b, nearestOf(BEATS128.beats, t), 1e-9), `snap(${t},'beat') → ${b}, want ${nearestOf(BEATS128.beats, t)}`);
}
for (const t of [1.5, 2.2, 4.44, 7.9, 12.0, 14.4]) { // inside the downbeat range
const bar = tlBeat.snapToGrid(t, 'bar');
assert.ok(near(bar, nearestOf(BEATS128.downbeats, t), 1e-9), `snap(${t},'bar') → ${bar}, want ${nearestOf(BEATS128.downbeats, t)}`);
}
assert.ok(near(tlBeat.snapToGrid(0.24, 'beat'), 0.0101, 1e-9), 'just under the midpoint snaps back');
assert.ok(near(tlBeat.snapToGrid(0.25, 'beat'), 0.4787, 1e-9), 'just over the midpoint snaps forward');
// every 4th beat IS the downbeat coset Lane C picked (index 3, not index 0)
assert.ok(near(tlBeat.snapToGrid(1.5, 'bar'), BEATS128.beats[3], 1e-9), 'bars use Lane C\'s downbeat coset, not "every 4th from zero"');
// past the analysed range the constant tempo extrapolates (an 8s track under a
// 30s scene must not drag every late key back onto the last beat)
assert.ok(near(tlBeat.snapToGrid(16.3, 'beat'), 15.9446 + BEATS128.period, 1e-9), 'extrapolates past the last beat');
assert.ok(tlBeat.snapToGrid(0, 'bar') >= 0, 'never snaps off the front of the timeline');
// grid moves with its audio block (offset is re-derived from the clip)
tlBeat.moveAudio(aud, 2.0);
assert.ok(near(tlBeat.beatOffset(), 2.0), 'offset follows the audio clip start');
assert.ok(near(tlBeat.snapToGrid(2.5, 'beat'), nearestOf(BEATS128.beats, 0.5) + 2, 1e-9), 'the whole grid moved with the block');
tlBeat.undo();
assert.ok(near(tlBeat.beatOffset(), 0), 'undo puts the clip (and the grid) back');
// head-trimming moves `in` and `start` together, so the grid must NOT shift
tlBeat.trimAudioIn(aud, 1.0, 16);
assert.ok(near(tlBeat.beatOffset(), 0), 'head trim keeps the grid where it was (start and in move together)');
tlBeat.undo();
// drawing positions: cover the scene, bars are a subset of beat positions
const gt = tlBeat.gridTimes('beat'), gbar = tlBeat.gridTimes('bar');
assert.equal(gt.length, 35, 'a 16s grid in a 16s scene needs no extension');
assert.equal(gbar.length, 8, 'bar lines = the downbeats');
assert.ok(gbar.every((t) => gt.some((b) => near(b, t, 1e-9))), 'every bar line sits on a beat line');
tlBeat.setDuration(30);
assert.ok(tlBeat.gridTimes('beat').length > 35, 'the grid extends at tempo across a longer scene (draw == snap)');
assert.ok(tlBeat.gridTimes('beat').every((t, i, a) => i === 0 || t > a[i - 1]), 'extended grid stays sorted');
tlBeat.setDuration(16);
// low confidence is a WARNING, not a refusal — the grid still works
const tlLow = new Timeline(new StageStub());
tlLow.load({ version: 1, fps: 30, duration: 16, entities: [], cameraCuts: [], audio: [] });
tlLow.setBeatGrid(beatGridFrom({ ...BEATS128, confidence: 0.12 }, 0));
assert.equal(tlLow.scene.beatGrid.confidence, 0.12, 'confidence is stored so the UI can say so');
assert.ok(near(tlLow.snapToGrid(0.3, 'beat'), 0.4787, 1e-9), 'a low-confidence grid still snaps (UI toasts the warning)');
assert.equal(tlLow.hasContent(), false, 'a beat grid alone is not "work worth a confirm" — it is one click to redo');
// survives save/load
const withGrid = tlBeat.toJSON();
assert.ok(withGrid.beatGrid && withGrid.beatGrid.beats.length === 35, 'beatGrid rides toJSON (it saves with the scene)');
const tlRT = new Timeline(new StageStub());
tlRT.load(withGrid);
assert.deepEqual(tlRT.toJSON(), withGrid, 'beatGrid survives a lossless round-trip');
assert.ok(near(tlRT.snapToGrid(3.0, 'beat'), tlBeat.snapToGrid(3.0, 'beat'), 1e-12), 'a reloaded scene snaps identically');
tlRT.clearBeatGrid();
assert.ok(!('beatGrid' in tlRT.toJSON()) && tlRT.snapToGrid(3, 'beat') === null, 'clearBeatGrid leaves plain scene JSON');
// ---- M9-B2: FILM shot list — runtime must agree with the server ----------
// tlui.js has no top-level DOM, so the pure bits are importable here (and this
// import fails loudly the day that stops being true).
const { filmRuntime } = await import('./tlui.js');
const demoShots = [ // sequences/demo-two-shot.json
{ scene: 'sync2-sunset', in: 0.0, out: 4.5, transition: 'dissolve', transitionDur: 0.5 },
{ scene: 'sync2-sunset', in: 4.5, out: 8.0, transition: 'cut' },
{ scene: 'sync1-demo', in: 0.0, out: 4.0, transition: 'cut' },
];
assert.ok(near(filmRuntime(demoShots), 11.5, 1e-9), `demo-two-shot must read 11.5s (server agrees), got ${filmRuntime(demoShots)}`);
assert.equal(filmRuntime([]), 0, 'an empty shot list is 0s');
assert.ok(near(filmRuntime([{ in: 0, out: 2, transition: 'dissolve', transitionDur: 0.5 }]), 2, 1e-9),
'a dissolve on the LAST shot has nothing to dissolve into — server ignores it, so do we');
assert.ok(near(filmRuntime([{ in: 1, out: 3, transition: 'cut', transitionDur: 0.5 }, { in: 0, out: 2 }]), 4, 1e-9),
'cut transitions never shorten the film, whatever transitionDur says');
console.log('OK — timeline_test.mjs: all assertions passed');

View File

@ -6,6 +6,7 @@
// 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;
@ -60,8 +61,37 @@ const CSS = `
.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;
@ -79,7 +109,11 @@ export class TimelineUI {
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)
@ -116,10 +150,12 @@ export class TimelineUI {
<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">
<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></select>
<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>
@ -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 164× around the cursor'],
['two-finger sideways', 'pan when zoomed in (plain wheel scrolls the page)'],
['Cmd/Ctrl + Z', 'undo — one director preset = one undo'],
['♪ on an audio row', 'detect the beat grid, then snap = beat / bar'],
]) +
S('SCENE BAR', [
['New…', 'start from a starter template (asks before clobbering)'],
['Save / Load', 'scenes by name (falls back to localStorage offline)'],
['dur / snap', 'scene length · snap increment 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'],
]) + '</div>';
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 = `
<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();