[m10-B] auto-cut to the beat + align film shots to downbeats

autoCutPlan()/autoCut(): given a beat grid and 2+ cameras, place camera cuts
on grid downbeats at a chosen bar cadence, cycling cameras so the same angle
never lands twice running, with a phase offset so a from-the-playhead run
doesn't open on the camera already live. Cut times come straight out of
gridTimes('bar') so a placed t IS a downbeat rather than a rounded neighbour.
Applied inside group() = one undo; refusals throw rather than half-apply.
UI: a scissors popover with cadence, range, per-camera checkboxes and a live
count that is literally the plan (previewed count == applied count).
FILM panel gains 'align to beat': snaps every shot's in/out to the nearest
downbeat, never reorders, clamps inside the scene, returns collapsing shots
untouched with a skipped list, and always names which grid it used.

Verified on scenes/music-video: 4 cuts at exact downbeats 3.749s apart
(2 bars at 128.02 BPM), cam2->cam3->cam4->cam1, one undo restores exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 11:42:10 +10:00
parent 915f653525
commit 29694afda5
6 changed files with 757 additions and 47 deletions

View File

@ -209,3 +209,19 @@ NEXT: nothing queued. Open ideas if you want them: `?from=&to=` windowing on /be
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.
### 2026-07-26 — M10-B ACCEPTED at SYNC 13. Auto-cut verified on real material.
On `scenes/music-video` (4 cameras, saved 128.02 BPM grid): 4 cuts at 1.4161 / 5.1654 /
8.9146 / 12.6639 — each an exact `gridTimes('bar')` member (asserted by ===, not proximity),
3.749s apart = 2 bars, cycling cam2→cam3→cam4→cam1 with no consecutive repeat, and ONE undo
restored the prior array byte-for-byte. Refusal paths (no grid / one camera) leave both
cameraCuts and the undo stack untouched.
The phase-offset decision — never open the cycle on the camera already live — is the single
best call in this session; without it "from the playhead" would land the same angle twice,
which is the one thing a cut must never do.
One amendment requested (not a blocker, sent separately): keep user cuts that genuinely
change the angle, but DROP a kept cut whose camera equals the effective camera before it.
Real case: a hand cut at 4.50→cam2 survived immediately after an auto 1.42→cam2, i.e. a
literal no-op cut that renders as nothing but reads as an edit in the file. Report dropped
ones in the popover alongside `kept`.
NEXT: nothing queued for Lane B.

View File

@ -428,3 +428,104 @@ NEXT: nothing required. Backlog if a session 8 lands: (1) a "nudge to grid" acti
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.
## 2026-07-26 session 8 (M10-B: AUTO-CUT + align a film to the beat)
DONE: both, headless-verified. No git run (standing instruction) — `timeline.js`,
`tlui.js`, `timeline_test.mjs` and this log edited in place. `stagestub.js`
needed no change again. The running :8020 already serves the edits (checked with
a read-only curl: `/web/timeline.js` carries `autoCutPlan`, nothing restarted).
**M10-B1 — AUTO-CUT** (`timeline.js:635-700`, UI `tlui.js:823-910`).
- `cameraIds()` (`:635`) — cameras in scene order, merged with any stage camera
not yet mirrored (the same merge tlui's `_rows` uses).
- `autoCutPlan(opts)` (`:647`) — PURE preview: `{ok, cuts, replaced, kept,
cameras, cadence, barPeriod, confidence, bpm, from, to}` or `{ok:false,error}`.
Cuts come straight out of `gridTimes('bar')` (so a placed t IS a grid downbeat,
not a rounded neighbour); `bars.filter((_,i)=>i%cadence===0)`; cameras cycle
`cams[(i+phase)%n]`.
- `autoCut(opts)` (`:687`) — applies inside `group()` → ONE undo; `throw`s the
plan's error rather than half-applying; `seek(time)` so the new cut goes live.
- `removeCut(cut)` (`:459`) — new undo-able mutator (replacements had to be
undoable too; tlui's right-click delete still splices directly, unchanged).
- UI: `✂` in the scene bar → popover with cadence (1/2/4/8 bars, default 2),
range (whole scene | from the playhead), a checkbox per camera, and a live
"will place N cuts · one every 3.75s" line that is literally `autoCutPlan()`
— the previewed count and the applied count are the same call. Refusals
render in that line in red with Apply disabled. Escape / click-outside close.
The count re-runs on every tick, so scrubbing with "from the playhead"
selected keeps it honest.
TEST: `node scenegod/web/timeline_test.mjs` → green, +2 blocks (`:498`, `:646`).
Auto-cut block asserts against the same real house128.wav payload: 8 downbeats
in a 16s scene → cadence 1/2/4/8 gives 8/4/2/1 cuts; every placed `t` is
`===` a member of `gridTimes('bar')`; 2 cameras strictly alternate A/B/A/B;
3 cameras cycle A/B/C/A/B/C/A/B; no camera lands twice in a row; ONE undo
restores the exact prior `cameraCuts` (deepEqual against a pre-snapshot) and
the stack grows by exactly 1; re-running replaces its own cuts (4 cuts stay 4,
all `t` unique, same positions); a hand cut ON a downbeat is replaced, one
BETWEEN downbeats is kept untouched and reported; no-grid and one-camera both
throw with a clear message AND leave `cameraCuts` and the undo stack empty;
an empty range refuses; the plan follows the audio when the block is dragged.
Also run against the REAL `scenes/music-video.json` (4 cams, saved 128.02 BPM
grid): cadence 2 → 1.416:cam2 5.165:cam3 8.915:cam4 12.664:cam1, undo restores
the file's original two cuts exactly.
**M10-B2 — align a film's shots to the beat** (`timeline.js:772-793` pure,
`tlui.js:1213-1252` panel). `♪ align` button in the FILM head row.
- WHICH GRID: the open scene's if it has one, else the first shot scene (in
shot order) with a saved `beatGrid`, fetched via `scenes/{name}`. The status
line always names the source ("from the open scene \"music-video\"" /
"from shot scene \"x\"") — "why did it snap there" must have a visible answer.
- `alignShotsToGrid(shots, grid, off, durations)` snaps each `in`/`out` to the
nearest downbeat, never reorders, and returns `{shots, skipped}`. A shot that
would collapse is returned BYTE-IDENTICAL and listed in `skipped`; the panel
prints "left alone: #3 (would collapse)". Runtime redraws via `_filmRows()`;
nothing is sent to the server until the user presses Save.
TEST: order preserved, both boundaries land on real grid lines, `out > in` for
every surviving shot, the collapsing shot is untouched + reported, the caller's
array is not mutated, transitions ride along, aligning twice is a no-op, and an
offset grid shifts the whole alignment. Verified against both shipped
sequences with the music-video grid: demo-two-shot 0-4.5|4.5-8|0-4 becomes
1.4161-5.1654|5.1654-7.04|1.4161-3.2907, 0 skipped.
DECISIONS:
- **Pure grid helpers extracted** (`beatOffsetOf`, `beatPeriodOf`, `gridTimesOf`,
`snapToGridWith`, `timeline.js:716-770`); `beatOffset/gridTimes/snapToGrid`
are now one-line delegates. B2 aligns shots of scenes that are NOT loaded —
it has a grid and a shot list, no Timeline — and it must get bit-identical
arithmetic to what the user saw while cutting. Behaviour unchanged: the whole
M9 beat block still passes untouched.
- **Replace ON a position, keep everything else.** An existing cut within half
a frame of a placed time is replaced (a duplicate 15ms away is a flash frame);
cuts elsewhere in the range are the user's edit and are NOT deleted. But they
interleave and one can repeat a camera, so the plan returns `kept` and the
popover says "1 of your own cuts left in place between them". Disclosure
beats either silently stuttering or silently deleting. Flip to "clear the
range" if you'd rather, it's a one-line change — but I'd want John to ask.
- **Phase offset so the run doesn't open on the camera already live.** With
"from the playhead", the last cut before the range is checked; if it holds
`cams[0]` the cycle starts at `cams[1]`. Otherwise a beat-perfect run can
still open with the same angle twice, which is the one thing a cut must
never do. Verified live on music-video.json (cut at 0 = cam1 → run opens cam2).
- **No cleverness, as instructed**: no random order, no energy analysis, no
variation. Every knob is on the popover and the output is ordinary
`cameraCuts` the user hand-edits after.
- **B2 clamps by walking back a whole bar and RE-SNAPPING** (`timeline.js:786`),
not by subtracting the bar period: Lane C rounds beats to 4dp and the period
to 6dp, so `b - bar` lands 48µs off a real downbeat. "Lands on a downbeat"
has to mean the real one, and the test asserts membership at 1e-6.
- `autoCut` THROWS on refusal instead of returning a flag: the caller already
has `autoCutPlan()` to check, so a thrown error can't be ignored into a
half-applied edit. The popover never lets you press Apply on a bad plan anyway.
- FILM align has no undo (the panel has none by design — the server doc is the
state and Save is the gate); the status line says "Save to keep it".
BLOCKED/REQUESTS:
- **Orchestrator**: PLAN §4.1 still doesn't list `scene.beatGrid` or audio
`{in?, out?}`, both in live use since M9 (carried from last session).
- **Lane C (nice-to-have)**: `GET scenes` returns `{name, duration}` but not
whether a scene HAS a beatGrid. B2 has to fetch whole scene JSONs one at a
time to find one. A `hasGrid`/`bpm` field on the list would make the FILM
panel's grid hunt one request instead of N.
NEXT: nothing required. Backlog if a session 9 lands, in order: (1) "nudge to
grid" for EXISTING keys/cuts (today only new drags and auto-cut are musical);
(2) box-select still only grabs keys, not clip/audio blocks or cuts; (3)
per-drag undo coalesce (a mousemove drag still pushes one entry per frame);
(4) film-level `audio[]` has no UI — the obvious partner to ♪ align.

View File

@ -1641,3 +1641,135 @@ 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
INFO: 127.0.0.1:57046 - "GET / HTTP/1.1" 200 OK
INFO: 127.0.0.1:57046 - "GET /web/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:57046 - "GET /web/stage.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:57047 - "GET /web/dock.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:57047 - "GET /web/room3d.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:57046 - "GET /web/presets.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:57050 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:57047 - "GET /assets/tree HTTP/1.1" 200 OK
INFO: 127.0.0.1:57047 - "GET /web/grammar.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:57047 - "GET /director HTTP/1.1" 405 Method Not Allowed
INFO: 127.0.0.1:57050 - "GET /web/timeline.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:57050 - "GET /web/tlui.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:57174 - "GET /templates/two-hander-golden-hour HTTP/1.1" 200 OK
INFO: 127.0.0.1:57174 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 206 Partial Content
INFO: 127.0.0.1:57175 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 206 Partial Content
INFO: 127.0.0.1:57175 - "GET /assets/thumb?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57176 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 206 Partial Content
INFO: 127.0.0.1:57175 - "GET /assets/file?path=characters%2Ftest%2Flady.glb HTTP/1.1" 200 OK
INFO: 127.0.0.1:57175 - "GET /beats?path=audio%2Ftest%2Fhouse128.wav HTTP/1.1" 200 OK
INFO: 127.0.0.1:57175 - "POST /scenes/music-video HTTP/1.1" 200 OK
INFO: 127.0.0.1:57430 - "GET /templates/two-hander-golden-hour HTTP/1.1" 200 OK
INFO: 127.0.0.1:57430 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 206 Partial Content
INFO: 127.0.0.1:57431 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 206 Partial Content
INFO: 127.0.0.1:57431 - "GET /assets/file?path=characters%2Ftest%2Flady.glb HTTP/1.1" 200 OK
INFO: 127.0.0.1:57432 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 206 Partial Content
INFO: 127.0.0.1:57431 - "POST /scenes/music-video HTTP/1.1" 200 OK
INFO: 127.0.0.1:58812 - "GET /web/tlui.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:58813 - "GET /web/timeline.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:58814 - "GET /web/tlui.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:59429 - "GET /?cb=m10 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59429 - "GET /web/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:59429 - "GET /web/stage.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:59430 - "GET /web/dock.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:59430 - "GET /web/room3d.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:59430 - "GET /assets/tree HTTP/1.1" 200 OK
INFO: 127.0.0.1:59429 - "GET /web/presets.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:59430 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:59429 - "GET /web/grammar.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:59429 - "GET /director HTTP/1.1" 405 Method Not Allowed
INFO: 127.0.0.1:59430 - "GET /web/timeline.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:59430 - "GET /web/tlui.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:59431 - "GET /scenes/music-video HTTP/1.1" 200 OK
INFO: 127.0.0.1:59431 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 206 Partial Content
INFO: 127.0.0.1:59538 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 206 Partial Content
INFO: 127.0.0.1:59538 - "GET /assets/file?path=characters%2Ftest%2Flady.glb HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "GET /web/render.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/begin HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/1 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/5 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/6 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/7 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/8 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/9 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/10 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/11 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/13 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/14 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/15 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/16 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/17 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/18 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/19 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/21 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/22 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/23 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/25 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/26 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/27 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/28 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/29 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/30 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/31 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/32 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/33 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/34 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/35 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/36 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/37 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/38 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/39 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/40 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/41 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/42 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/43 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/44 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/45 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/46 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/47 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/48 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/49 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/50 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/51 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/52 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/53 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/54 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/55 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/56 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/57 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/58 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/59 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/60 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/61 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/62 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/63 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/64 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/65 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/66 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/67 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/68 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/69 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/70 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/71 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/72 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/73 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/74 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/75 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/76 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/77 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/78 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/79 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/80 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/82 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/83 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/84 HTTP/1.1" 200 OK

View File

@ -456,6 +456,13 @@ export class Timeline {
this.undoStack.push({ undo: () => { const i = this.scene.cameraCuts.indexOf(cut); if (i >= 0) this.scene.cameraCuts.splice(i, 1); } });
return cut;
}
removeCut(cut) {
const i = this.scene.cameraCuts.indexOf(cut);
if (i < 0) return;
this.scene.cameraCuts.splice(i, 1);
this._activeCam = undefined;
this.undoStack.push({ undo: () => { this.scene.cameraCuts.splice(i, 0, cut); this._activeCam = undefined; } });
}
undo() { const op = this.undoStack.pop(); if (op) op.undo(); this.evaluate(this.time); }
// Run fn (which may push many undo entries) and collapse everything it
@ -597,61 +604,95 @@ export class Timeline {
}
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;
}
// All four delegate to the pure module helpers at the bottom of this file —
// the FILM panel aligns ANOTHER scene's shot list to ITS grid, with no
// Timeline instance in hand, and must get identical arithmetic.
beatOffset() { return beatOffsetOf(this.scene); }
_beatPeriod(kind) { return beatPeriodOf(this.scene.beatGrid, kind); }
// 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];
return gridTimesOf(this.scene.beatGrid, kind, this.beatOffset(), this.duration);
}
// 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];
return snapToGridWith(this.scene.beatGrid, t, kind, this.beatOffset());
}
// ---- M10-B1: AUTO-CUT — a beat grid + 2 cameras → cuts on the downbeats ----
// Deliberately dumb and predictable: every Nth downbeat in range gets a cut,
// cameras cycle in SCENE order (2 cameras = strict A/B, which is what a DJ
// edit looks like), and the result is ordinary cameraCuts the user can then
// hand-edit. No randomness, no energy analysis, no "smart" variation.
// Cameras in scene order, plus any live-on-stage camera the model hasn't
// mirrored yet (same merge tlui uses to build its rows).
cameraIds() {
const ids = this.scene.entities.filter((e) => e.kind === 'camera').map((e) => e.id);
if (this.stage && this.stage.entities) {
for (const se of this.stage.entities()) if (se.kind === 'camera' && !ids.includes(se.id)) ids.push(se.id);
}
r += off;
if (r < 0 && per > 0) r += per; // never snap off the front of the timeline
return Math.max(0, r);
return ids;
}
// Pure: what autoCut WOULD do. The popover shows `cuts.length` before the user
// commits, and Apply runs the same plan — so the count on screen is the count
// you get. `{ok:false, error}` for every refusal (no grid / <2 cameras / empty
// range); never a silent no-op.
autoCutPlan(opts = {}) {
const g = this.scene.beatGrid;
if (!g) return { ok: false, error: 'no beat grid — press ♪ on an audio row to detect one first' };
const cadence = Math.max(1, Math.round(+opts.cadence || 2));
const all = this.cameraIds();
const cams = opts.cameras && opts.cameras.length ? all.filter((id) => opts.cameras.includes(id)) : all;
if (cams.length < 2) {
return { ok: false, error: `auto-cut needs at least 2 cameras — ${cams.length === 1 ? 'only 1 is' : 'none are'} selected in this scene` };
}
const from = Math.max(0, opts.from == null ? 0 : +opts.from);
const to = Math.min(this.duration, opts.to == null ? this.duration : +opts.to);
const bars = this.gridTimes('bar').filter((t) => t >= from - 1e-9 && t <= to + 1e-9);
if (!bars.length) return { ok: false, error: `no downbeats between ${from.toFixed(2)}s and ${to.toFixed(2)}s` };
const times = bars.filter((_, i) => i % cadence === 0);
// Don't open with the camera that is already live going into the range —
// otherwise "from the playhead" can land the same camera twice in a row.
const eps = 0.5 / (this.fps || 30);
const before = this.cameraCuts.filter((c) => c.t < times[0] - eps);
const prior = before.length ? before[before.length - 1] : null;
const phase = prior && prior.camera === cams[0] ? 1 : 0;
const cuts = times.map((t, i) => ({ t, camera: cams[(i + phase) % cams.length] }));
// An existing cut ON a placed position is REPLACED (half a frame either way,
// so a hand-placed cut near the downbeat is absorbed rather than duplicated
// into a flash frame). Cuts elsewhere in the range are left alone.
const replaced = this.cameraCuts.filter((c) => times.some((t) => Math.abs(c.t - t) <= eps));
// Hand-placed cuts elsewhere in the range are NOT touched (deleting someone's
// edit is over-reach) — but they interleave, and one of them can repeat a
// camera. Report them so the popover can say so instead of the user
// wondering why a beat-perfect edit has a stutter in it.
const kept = this.cameraCuts.filter((c) => c.t >= times[0] && c.t <= times[times.length - 1]
&& !replaced.includes(c) && !times.some((t) => Math.abs(c.t - t) <= eps));
return {
ok: true, cuts, replaced, kept, cameras: cams, cadence,
barPeriod: this._beatPeriod('bar'), confidence: g.confidence ?? 0, bpm: g.bpm,
from, to,
};
}
// Apply the plan as ONE undo entry. Refuses loudly (throws) rather than
// half-applying — the caller already has autoCutPlan() to check first.
autoCut(opts = {}) {
const plan = this.autoCutPlan(opts);
if (!plan.ok) throw new Error(plan.error);
this.group(() => {
for (const c of plan.replaced) this.removeCut(c);
for (const c of plan.cuts) this.addCut(c.t, c.camera);
});
this.seek(this.time); // the new cut under the playhead goes live
return plan;
}
// Rhubarb lip-sync JSON → morph keys. { mouthCues: [{start, end, value}] }.
@ -670,6 +711,85 @@ export class Timeline {
}
}
// ---- pure beat-grid helpers ------------------------------------------------
// The Timeline methods above are thin wrappers over these. They exist as free
// functions because the FILM panel (M10-B2) aligns the shots of scenes that are
// NOT loaded — it holds a grid and a shot list, not a Timeline — and it must get
// bit-identical arithmetic to what the user saw while cutting.
// Where the grid's file t=0 sits on that scene's timeline: the audio clip that
// carries the grid wins (drag the audio, drag the grid), else the stored offset.
export function beatOffsetOf(scene) {
const g = scene && scene.beatGrid;
if (!g) return 0;
const clip = ((scene && scene.audio) || []).find((c) => c.path === g.path);
return clip ? clip.start - (clip.in || 0) : (g.offset || 0);
}
export function beatPeriodOf(grid, kind = 'beat') {
if (!grid) return 0;
const per = grid.period || (grid.bpm ? 60 / grid.bpm : 0);
return kind === 'bar' ? per * (grid.meter || 4) : per;
}
export function gridTimesOf(grid, kind = 'beat', off = 0, span = 0) {
if (!grid) return [];
const src = kind === 'bar' ? (grid.downbeats || []) : (grid.beats || []);
if (!src.length) return [];
const per = beatPeriodOf(grid, 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 <= span; x += per) after.push(x);
return [...before.reverse(), ...inFile.filter((t) => t >= 0), ...after];
}
export function snapToGridWith(grid, t, kind = 'beat', off = 0) {
if (!grid) return null;
const src = kind === 'bar' ? grid.downbeats : grid.beats;
if (!src || !src.length) return null;
const per = beatPeriodOf(grid, 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);
}
// M10-B2: snap a FILM shot list's in/out to downbeats so the shot boundaries
// land musically. Order is never touched. A shot that would collapse (in >= out
// after snapping, e.g. a shot shorter than a bar) is left EXACTLY as it was and
// reported — silently mangling someone's edit is worse than not helping.
// `durations[i]` (a scene's length, 0/absent = unknown) keeps `out` inside its
// scene: an out past the end walks back whole bars until it fits.
export function alignShotsToGrid(shots, grid, off = 0, durations = []) {
const bar = beatPeriodOf(grid, 'bar');
const r6 = (x) => Math.round(x * 1e6) / 1e6; // kill float noise in the panel's number fields
const out = [];
const skipped = [];
(shots || []).forEach((sh, i) => {
const a0 = +sh.in || 0, b0 = +sh.out || 0;
let a = snapToGridWith(grid, a0, 'bar', off);
let b = snapToGridWith(grid, b0, 'bar', off);
const lim = +durations[i] || 0;
if (a == null || b == null) { out.push({ ...sh }); skipped.push({ index: i, scene: sh.scene, why: 'no downbeats' }); return; }
// Walk back a bar at a time and RE-SNAP each step: `b - bar` is a bar-period
// away from a grid line, not on one (Lane C rounds beats to 4dp, the period
// to 6dp), and "lands on a downbeat" has to mean the real one.
for (let guard = 0; lim && bar > 0 && b > lim + 1e-9 && guard < 64; guard++) b = snapToGridWith(grid, b - bar, 'bar', off);
if (!(b > a + 1e-9)) { out.push({ ...sh }); skipped.push({ index: i, scene: sh.scene, why: 'would collapse' }); return; }
out.push({ ...sh, in: r6(a), out: r6(b) });
});
return { shots: out, skipped };
}
// 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.

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, beatGridFrom } from './timeline.js';
import { Timeline, beatGridFrom, alignShotsToGrid, gridTimesOf } from './timeline.js';
const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
@ -495,4 +495,190 @@ assert.ok(near(filmRuntime([{ in: 0, out: 2, transition: 'dissolve', transitionD
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');
// ---- M10-B1: AUTO-CUT — cuts on the downbeats, cameras cycling -----------
// Same real house128.wav payload as above: 8 downbeats in a 16s scene, so the
// expected counts are arithmetic anyone can check by hand (8 bars: cadence 1→8
// cuts, 2→4, 4→2, 8→1).
const acStage = new StageStub();
const acScene = {
version: 1, name: 'autocut', fps: 30, duration: 16, cameraCuts: [],
audio: [{ path: 'audio/test/house128.wav', start: 0, gain: 1 }],
entities: [
{ id: 'camA', kind: 'camera', params: { fov: 40 }, tracks: {} },
{ id: 'camB', kind: 'camera', params: { fov: 40 }, tracks: {} },
{ id: 'lady', kind: 'character', tracks: {} },
],
};
await acStage.applyState(acScene);
const tlAC = new Timeline(acStage);
tlAC.load(acScene);
tlAC.setBeatGrid(beatGridFrom(BEATS128, 0));
const bars = tlAC.gridTimes('bar');
assert.equal(bars.length, 8, 'the 16s test scene has 8 downbeats');
assert.deepEqual(tlAC.cameraIds(), ['camA', 'camB'], 'cameras come back in scene order');
// no grid → refuse loudly, change nothing
const tlNoGrid = new Timeline(new StageStub());
tlNoGrid.load({ ...structuredClone(acScene) });
assert.throws(() => tlNoGrid.autoCut({ cadence: 2 }), /no beat grid/, 'no grid → refuses with a clear error');
assert.equal(tlNoGrid.cameraCuts.length, 0, 'a refused auto-cut applies NOTHING');
assert.equal(tlNoGrid.undoStack.length, 0, 'a refused auto-cut pushes no undo entry');
assert.equal(tlNoGrid.autoCutPlan({}).ok, false, 'plan() reports the refusal instead of throwing');
// fewer than 2 cameras → refuse
const tlOne = new Timeline(new StageStub());
tlOne.load({ version: 1, fps: 30, duration: 16, cameraCuts: [], audio: [],
entities: [{ id: 'camA', kind: 'camera', tracks: {} }] });
tlOne.setBeatGrid(beatGridFrom(BEATS128, 0));
assert.throws(() => tlOne.autoCut({ cadence: 2 }), /at least 2 cameras/, 'one camera → refuses with a clear error');
assert.equal(tlOne.cameraCuts.length, 0, 'the one-camera refusal applies nothing');
assert.equal(tlAC.autoCutPlan({ cameras: ['camA'] }).ok, false, 'deselecting down to one camera refuses too');
// the main event: cadence 2 over the whole scene
const before2 = structuredClone(tlAC.cameraCuts);
const undo0 = tlAC.undoStack.length;
const plan = tlAC.autoCut({ cadence: 2 });
assert.equal(plan.cuts.length, 4, 'every 2nd of 8 downbeats = 4 cuts');
assert.equal(tlAC.cameraCuts.length, 4, 'and 4 cuts actually landed');
for (const c of tlAC.cameraCuts) {
assert.ok(bars.some((b) => b === c.t), `cut at ${c.t} must BE a grid downbeat, not merely near one`);
}
assert.deepEqual(tlAC.cameraCuts.map((c) => c.t), [bars[0], bars[2], bars[4], bars[6]], 'cadence 2 takes every other bar');
assert.deepEqual(tlAC.cameraCuts.map((c) => c.camera), ['camA', 'camB', 'camA', 'camB'], '2 cameras = strict A/B alternation');
assert.ok(tlAC.cameraCuts.every((c, i, a) => i === 0 || c.camera !== a[i - 1].camera), 'no camera lands twice in a row');
assert.equal(acStage._active, null, 'before the first downbeat no cut has happened yet — auto-cut invents nothing at t=0');
tlAC.seek(bars[0]); assert.equal(acStage._active, 'camA', 'the first placed cut is live the moment the playhead reaches it');
tlAC.seek(bars[2]); assert.equal(acStage._active, 'camB', 'and the second flips cameras');
tlAC.seek(0);
assert.equal(tlAC.undoStack.length, undo0 + 1, '4 cuts = ONE undo entry');
tlAC.undo();
assert.deepEqual(tlAC.cameraCuts, before2, 'one undo restores the exact prior cameraCuts array');
// cadence maths, and the count the popover shows is the count you get
for (const [cad, n] of [[1, 8], [2, 4], [4, 2], [8, 1]]) {
const p = tlAC.autoCutPlan({ cadence: cad });
assert.equal(p.cuts.length, n, `cadence ${cad} bars → ${n} cuts`);
const applied = tlAC.autoCut({ cadence: cad });
assert.equal(tlAC.cameraCuts.length, applied.cuts.length, 'the previewed count is the applied count');
assert.equal(applied.cuts.length, n, `cadence ${cad} applies ${n} cuts`);
tlAC.undo();
assert.equal(tlAC.cameraCuts.length, 0, 'each cadence run undoes cleanly');
}
// idempotent-ish: re-running replaces its own cuts instead of stacking them
tlAC.autoCut({ cadence: 2 });
const firstPass = structuredClone(tlAC.cameraCuts);
const p2 = tlAC.autoCut({ cadence: 2 });
assert.equal(p2.replaced.length, 4, 'the second pass sees its own 4 cuts as replacements');
assert.equal(tlAC.cameraCuts.length, 4, 're-running does NOT accumulate duplicates');
assert.equal(new Set(tlAC.cameraCuts.map((c) => c.t)).size, 4, 'no two cuts share a t');
assert.deepEqual(tlAC.cameraCuts.map((c) => c.t), firstPass.map((c) => c.t), 'same input → same positions');
tlAC.undo();
assert.deepEqual(tlAC.cameraCuts.map((c) => c.t), firstPass.map((c) => c.t), 'one undo takes the whole second pass back');
tlAC.undo();
assert.equal(tlAC.cameraCuts.length, 0, 'and the first pass with it');
// a hand-placed cut ON a downbeat is replaced, not duplicated
tlAC.addCut(bars[2], 'camB');
tlAC.autoCut({ cadence: 2 });
assert.equal(tlAC.cameraCuts.filter((c) => Math.abs(c.t - bars[2]) < 1e-9).length, 1, 'an existing cut at a placed position is replaced');
tlAC.undo(); tlAC.undo();
assert.equal(tlAC.cameraCuts.length, 0, 'undo unwinds the replacement too');
// a hand-placed cut BETWEEN downbeats is left alone — and disclosed, because it
// interleaves and can repeat a camera (the popover says so; we don't delete it)
tlAC.addCut(4.5, 'camB');
const pk = tlAC.autoCutPlan({ cadence: 2 });
assert.equal(pk.kept.length, 1, 'an off-grid cut inside the range is reported as kept');
assert.equal(pk.replaced.length, 0, 'and is NOT counted as a replacement');
tlAC.autoCut({ cadence: 2 });
assert.equal(tlAC.cameraCuts.length, 5, 'the user\'s own cut survives auto-cut (4 placed + 1 kept)');
assert.ok(tlAC.cameraCuts.some((c) => c.t === 4.5 && c.camera === 'camB'), 'exactly as they left it');
tlAC.undo(); tlAC.undo();
assert.equal(tlAC.cameraCuts.length, 0, 'and both come back off');
// range = from the playhead, and the camera already live doesn't repeat across it
tlAC.seek(6);
tlAC.addCut(0, 'camA'); // camA is live going into the range
const pr = tlAC.autoCut({ cadence: 2, from: tlAC.time });
const inRange = bars.filter((b) => b >= 6);
assert.equal(inRange.length, 5, '5 downbeats sit at/after t=6');
assert.equal(pr.cuts.length, 3, 'every 2nd of those 5 = 3 cuts');
assert.ok(tlAC.cameraCuts.slice(1).every((c) => c.t >= 6), 'nothing is placed before the playhead');
assert.equal(tlAC.cameraCuts[0].t, 0, 'the cut before the range is left alone');
assert.equal(tlAC.cameraCuts[1].camera, 'camB', 'the run opens on the OTHER camera (camA was already live)');
assert.ok(tlAC.cameraCuts.every((c, i, a) => i === 0 || c.camera !== a[i - 1].camera), 'no repeat across the range boundary either');
tlAC.undo();
assert.deepEqual(tlAC.cameraCuts, [{ t: 0, camera: 'camA' }], 'one undo, the manual cut survives untouched');
tlAC.undo(); tlAC.seek(0);
// 3 cameras cycle in scene order; a deselected camera is skipped
await acStage.addEntity({ id: 'camC', kind: 'camera', params: {} }); // _mirror → scene.entities
assert.deepEqual(tlAC.cameraIds(), ['camA', 'camB', 'camC'], 'a dock-added camera joins the cycle');
tlAC.autoCut({ cadence: 1 });
assert.deepEqual(tlAC.cameraCuts.map((c) => c.camera), ['camA', 'camB', 'camC', 'camA', 'camB', 'camC', 'camA', 'camB'],
'3 cameras cycle in scene order, never repeating');
tlAC.undo();
tlAC.autoCut({ cadence: 2, cameras: ['camA', 'camC'] });
assert.deepEqual(tlAC.cameraCuts.map((c) => c.camera), ['camA', 'camC', 'camA', 'camC'], 'only the ticked cameras are used');
tlAC.undo();
// low confidence works exactly the same (the UI says so; the model has no opinion)
tlAC.setBeatGrid(beatGridFrom({ ...BEATS128, confidence: 0.11 }, 0));
const lowPlan = tlAC.autoCut({ cadence: 2 });
assert.equal(lowPlan.cuts.length, 4, 'a low-confidence grid still auto-cuts');
assert.ok(lowPlan.confidence < 0.35, 'the plan carries the confidence so the UI can warn');
tlAC.undo();
tlAC.setBeatGrid(beatGridFrom(BEATS128, 0));
// an empty range refuses instead of doing nothing quietly
assert.equal(tlAC.autoCutPlan({ from: 15.5 }).ok, false, 'a range with no downbeats in it refuses');
assert.match(tlAC.autoCutPlan({ from: 15.5 }).error, /no downbeats/, 'and says why');
// the grid follows its audio, so auto-cut does too
tlAC.moveAudio(tlAC.scene.audio[0], 0.5);
const movedBars = tlAC.gridTimes('bar');
assert.ok(movedBars.some((t) => near(t, 1.4161 + 0.5, 1e-9)), 'the grid moves with its audio block');
assert.deepEqual(tlAC.autoCutPlan({ cadence: 2 }).cuts.map((c) => c.t), movedBars.filter((_, i) => i % 2 === 0),
'auto-cut follows the moved grid — including the bar the shift extrapolates into view at the front');
tlAC.undo();
assert.deepEqual(tlAC.gridTimes('bar'), bars, 'undoing the drag puts the grid back');
// ---- M10-B2: align a film's shots to the beat ----------------------------
const barTimes = gridTimesOf(BEATS128, 'bar', 0, 40);
const onBar = (t) => barTimes.some((b) => near(b, t, 1e-6));
const rawShots = [
{ scene: 'shot-street', in: 0, out: 4.5, transition: 'dissolve', transitionDur: 0.5 },
{ scene: 'shot-store', in: 4.5, out: 8.0, transition: 'cut' },
{ scene: 'shot-street', in: 3.0, out: 3.4, transition: 'cut' }, // shorter than a bar → would collapse
];
const al = alignShotsToGrid(rawShots, BEATS128, 0, [8, 8, 8]);
assert.equal(al.shots.length, 3, 'every shot comes back — aligning never drops one');
assert.deepEqual(al.shots.map((s) => s.scene), rawShots.map((s) => s.scene), 'shot ORDER is never touched');
assert.ok(onBar(al.shots[0].in) && onBar(al.shots[0].out), 'shot 1 boundaries land on downbeats');
assert.ok(onBar(al.shots[1].in) && onBar(al.shots[1].out), 'shot 2 boundaries land on downbeats');
assert.ok(al.shots.every((s) => s.out > s.in), 'no in >= out survives');
assert.ok(near(al.shots[0].in, 1.4161) && near(al.shots[0].out, 5.1654), `shot 1 → 1.4161..5.1654, got ${al.shots[0].in}..${al.shots[0].out}`);
assert.ok(near(al.shots[1].in, 5.1654) && near(al.shots[1].out, 7.04), `shot 2 clamps inside its 8s scene, got ${al.shots[1].in}..${al.shots[1].out}`);
assert.ok(onBar(al.shots[1].out), 'the clamped end is a REAL downbeat, not a bar-period subtraction');
assert.equal(al.skipped.length, 1, 'the collapsing shot is reported');
assert.equal(al.skipped[0].index, 2, 'and named by its index');
assert.deepEqual({ in: al.shots[2].in, out: al.shots[2].out }, { in: 3.0, out: 3.4 }, 'a skipped shot is left EXACTLY as it was');
assert.deepEqual(rawShots[0], { scene: 'shot-street', in: 0, out: 4.5, transition: 'dissolve', transitionDur: 0.5 },
'alignShotsToGrid does not mutate the caller\'s shots');
assert.equal(al.shots[0].transition, 'dissolve', 'transitions ride along untouched');
assert.equal(al.shots[0].transitionDur, 0.5, 'and so do their durations');
// runtime is the server's arithmetic on the NEW numbers (the panel updates)
assert.ok(near(filmRuntime(al.shots), (5.1654 - 1.4161) + (7.04 - 5.1654) - 0.5 + 0.4, 1e-6),
'aligned runtime = the same formula on the snapped windows');
// idempotent: aligning twice changes nothing
const al2 = alignShotsToGrid(al.shots, BEATS128, 0, [8, 8, 8]);
assert.deepEqual(al2.shots, al.shots, 'aligning an already-aligned list is a no-op');
// the grid offset applies (a shot list aligned against a scene whose audio starts late)
const barTimes2 = gridTimesOf(BEATS128, 'bar', 2, 40);
const alOff = alignShotsToGrid([{ scene: 's', in: 0, out: 6 }], BEATS128, 2, [16]);
const onBar2 = (t) => barTimes2.some((b) => near(b, t, 1e-6));
assert.ok(onBar2(alOff.shots[0].in) && onBar2(alOff.shots[0].out), 'the grid offset shifts the whole alignment');
assert.ok(alOff.shots[0].out > alOff.shots[0].in && alOff.shots[0].in >= 0, 'an offset grid still yields a valid window');
assert.equal(alOff.skipped.length, 0, 'and nothing collapses');
console.log('OK — timeline_test.mjs: all assertions passed');

View File

@ -6,7 +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';
import { beatGridFrom, beatOffsetOf, alignShotsToGrid } from './timeline.js';
const ROW_H = 28;
const RULER_H = 22;
@ -46,6 +46,20 @@ const CSS = `
.tl-menu.tmpl .item img{width:68px;height:42px;flex:none;object-fit:cover;border-radius:3px;background:#0d1117}
.tl-menu.tmpl .t{color:#e0e6ed}
.tl-menu.tmpl .d{color:#8b949e;font-size:11px;line-height:1.35;margin-top:2px}
/* M10-B1 auto-cut popover — a small form in the same menu shell */
.tl-menu.cut{padding:8px 10px 10px;min-width:230px;max-height:none}
.tl-menu.cut .hd{color:#e0af68;margin-bottom:6px}
.tl-menu.cut .fld{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:4px 0;color:#8b949e}
.tl-menu.cut label{display:flex;gap:4px;align-items:center;cursor:pointer;color:#c9d1d9}
.tl-menu.cut select{background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;border-radius:3px;font:inherit;padding:1px 4px}
.tl-menu.cut .cams{display:flex;flex-direction:column;gap:2px;max-height:120px;overflow:auto;margin:2px 0 6px 6px}
.tl-menu.cut .lbl{color:#565f6b}
.tl-menu.cut .cnt{color:#57c99a;margin:6px 0;white-space:normal;line-height:1.4}
.tl-menu.cut .cnt.err{color:#f7768e}
.tl-menu.cut .act{display:flex;gap:6px;justify-content:flex-end}
.tl-menu.cut .act button{background:#21262d;border:1px solid #2b323c;color:#c9d1d9;padding:3px 10px;border-radius:3px;cursor:pointer;font:inherit}
.tl-menu.cut .act button.go{border-color:#57c99a;color:#dff5ea}
.tl-menu.cut .act button:disabled{opacity:.45;cursor:default}
.tl-help{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:10001;background:#12161c;border:1px solid #2b323c;border-radius:6px;box-shadow:0 10px 44px #000b;padding:14px 18px 16px;color:#c9d1d9;font:12px/1.5 ui-monospace,Menlo,monospace;max-width:min(880px,94vw);max-height:84vh;overflow:auto}
.tl-help h3{margin:0 0 2px;font-size:13px;color:#7aa2f7}
.tl-help .hint{color:#565f6b;margin-bottom:10px}
@ -118,6 +132,7 @@ export class TimelineUI {
this._build();
this.tl.onTick(() => { // natural end-of-play stops audio (timeline auto-pauses)
this.draw();
if (this._cutRefresh) this._cutRefresh(); // "from the playhead" count stays honest while scrubbing
if (this._wasPlaying && !this.tl.playing) this._audioStop();
this._wasPlaying = this.tl.playing;
});
@ -155,6 +170,7 @@ export class TimelineUI {
<option value="0.25">0.25s</option><option value="1">1s</option>
<option value="beat">beat</option><option value="bar">bar</option></select>
<button data-act="audio" title="add audio clip at playhead">🎵</button>
<button data-act="autocut" title="auto-cut: put camera cuts on the downbeats (needs a beat grid + 2 cameras)"></button>
<button data-act="film" title="FILM: shot list / sequences / render a multi-shot film">🎬</button>
<span class="spring"></span>
<button data-act="help" title="keyboard shortcuts (?)">?</button>
@ -173,6 +189,8 @@ export class TimelineUI {
bar.querySelector('[data-act="load"]').onclick = () => this.load(this.$name.value);
this.$audioBtn = bar.querySelector('[data-act="audio"]');
this.$audioBtn.onclick = () => this._pickAudio();
this.$cutBtn = bar.querySelector('[data-act="autocut"]');
this.$cutBtn.onclick = () => this._autoCutMenu();
this.$tmplBtn = bar.querySelector('[data-act="tmpl"]');
this.$tmplBtn.onclick = () => this._pickTemplate();
this.$helpBtn = bar.querySelector('[data-act="help"]');
@ -581,7 +599,7 @@ export class TimelineUI {
_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.key === 'Escape') { this._helpClose(); this._cutMenuClose(); }
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);
@ -751,7 +769,9 @@ export class TimelineUI {
['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'],
['✂', 'auto-cut: camera cuts on the downbeats, cameras cycling — one undo'],
['🎬', 'FILM: shot list — sequences of scene windows, cut/dissolve, render to one mp4'],
['🎬 → ♪ align', 'snap every shot\'s in/out to the nearest downbeat (then Save)'],
]) + '</div>';
el.querySelector('.close').onclick = () => this._helpClose();
document.body.appendChild(el);
@ -800,6 +820,95 @@ export class TimelineUI {
this.draw();
}
// ---- M10-B1: AUTO-CUT (✂ in the scene bar) ----
// The popover is a preview, not a wizard: pick cadence / cameras / range and
// the line above Apply says exactly how many cuts you are about to get. Every
// refusal (no grid, <2 cameras, empty range) is the model's own message, shown
// in place of the count with Apply disabled — never a button that does nothing.
_autoCutMenu() {
if (this._cutMenu) { this._cutMenuClose(); return; }
const cams = this.tl.cameraIds();
const menu = document.createElement('div');
menu.className = 'tl-menu cut';
menu.innerHTML = `
<div class="hd"> auto-cut to the beat</div>
<label class="fld">cadence
<select class="cad">
<option value="1">every bar</option>
<option value="2" selected>every 2 bars</option>
<option value="4">every 4 bars</option>
<option value="8">every 8 bars</option>
</select></label>
<div class="fld">range
<label><input type="radio" name="sgcutrange" class="rall" checked> whole scene</label>
<label><input type="radio" name="sgcutrange" class="rph"> from the playhead</label>
</div>
<div class="fld">cameras <span class="lbl">(cycled in this order)</span></div>
<div class="cams"></div>
<div class="cnt"></div>
<div class="act"><button class="go">Apply</button><button class="no">Cancel</button></div>`;
const camHost = menu.querySelector('.cams');
for (const id of cams) {
const en = this.stage.getEntity(id) || this._te(id) || {};
const l = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = true; cb.value = id;
cb.onchange = () => refresh();
l.appendChild(cb); l.append(' ' + (en.label || id));
camHost.appendChild(l);
}
if (!cams.length) camHost.innerHTML = '<span class="lbl">no cameras in this scene</span>';
const opts = () => ({
cadence: +menu.querySelector('.cad').value,
cameras: [...menu.querySelectorAll('.cams input:checked')].map((c) => c.value),
from: menu.querySelector('.rph').checked ? this.tl.time : 0,
});
const go = menu.querySelector('.go');
const refresh = () => {
const plan = this.tl.autoCutPlan(opts());
const cnt = menu.querySelector('.cnt');
go.disabled = !plan.ok;
if (!plan.ok) { cnt.className = 'cnt err'; cnt.textContent = plan.error; return; }
const every = (plan.barPeriod * plan.cadence);
cnt.className = 'cnt';
cnt.textContent = `will place ${plan.cuts.length} cut${plan.cuts.length === 1 ? '' : 's'}`
+ (every > 0 ? ` · one every ${every.toFixed(2)}s` : '')
+ (plan.replaced.length ? ` · ${plan.replaced.length} existing replaced` : '')
+ (plan.kept.length ? ` · ${plan.kept.length} of your own cut${plan.kept.length === 1 ? '' : 's'} left in place between them` : '')
+ (plan.confidence < 0.35 ? ` · LOW grid confidence (${plan.confidence.toFixed(2)})` : '');
};
menu.querySelector('.cad').onchange = refresh;
menu.querySelector('.rall').onchange = refresh;
menu.querySelector('.rph').onchange = refresh;
menu.querySelector('.no').onclick = () => this._cutMenuClose();
go.onclick = () => {
let plan;
try { plan = this.tl.autoCut(opts()); }
catch (e) { this._toast('auto-cut: ' + ((e && e.message) || e)); return; }
this._cutMenuClose();
this.draw();
this._toast(`${plan.cuts.length} cuts on the downbeats · every ${plan.cadence} bar${plan.cadence === 1 ? '' : 's'}`
+ ` · ${plan.cameras.length} cameras cycling`
+ (plan.confidence < 0.35 ? ` · LOW grid confidence (${plan.confidence.toFixed(2)}) — check it against the music` : '')
+ ' · Cmd/Ctrl+Z undoes the lot');
};
document.body.appendChild(menu);
const rect = this.$cutBtn.getBoundingClientRect();
menu.style.top = (rect.bottom + 2) + 'px';
menu.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - menu.offsetWidth - 6)) + 'px';
this._cutMenu = menu;
this._cutRefresh = refresh;
this._cutAway = (ev) => { if (!menu.contains(ev.target) && ev.target !== this.$cutBtn) this._cutMenuClose(); };
setTimeout(() => document.addEventListener('mousedown', this._cutAway), 0);
refresh();
}
_cutMenuClose() {
if (!this._cutMenu) return;
document.removeEventListener('mousedown', this._cutAway);
this._cutMenu.remove(); this._cutMenu = null; this._cutRefresh = null;
}
// ---- M5: lip-sync from audio (🗣 on an audio row) ----
// GET /rhubarb?path=… → importRhubarb onto a viseme-bearing character.
// >1 candidate → picker menu; 503 → toast the install hint from `detail`.
@ -868,6 +977,7 @@ export class TimelineUI {
<option value="1920x1080">1920×1080</option>
<option value="1280x720">1280×720</option>
<option value="854x480">854×480</option></select>
<button data-f="beat" title="align to beat: snap every shot's in/out to the nearest downbeat"> align</button>
<button data-f="save" title="save the shot list to the server">Save</button>
<button class="go" data-f="render" title="render every shot and concatenate">Render film</button>
</div>
@ -883,6 +993,7 @@ export class TimelineUI {
on('new', () => this._filmNew());
on('ren', () => this._filmRename());
on('del', () => this._filmDelete());
on('beat', () => this._filmAlign());
on('save', () => this._filmSave());
on('add', () => this._filmAddShot());
on('render', () => this._filmRender());
@ -1028,7 +1139,7 @@ export class TimelineUI {
_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']) {
for (const f of ['ren', 'del', 'save', 'add', 'render', 'beat']) {
const b = this.$film.querySelector(`[data-f="${f}"]`);
if (b) b.disabled = !has;
}
@ -1094,6 +1205,50 @@ export class TimelineUI {
await this._filmRefresh();
}
// ---- M10-B2: align the shot list to the beat (♪ align in the FILM panel) --
// WHICH GRID: the open scene's, if it has one — that is the grid the user has
// been cutting against and can see. Otherwise the first shot scene (in shot
// order) that carries a saved grid. Either way the status line names it, so
// "why did it snap there" always has a visible answer.
async _filmGrid() {
if (this.tl.scene.beatGrid) {
return { grid: this.tl.scene.beatGrid, off: this.tl.beatOffset(), src: `the open scene "${this.tl.scene.name || 'untitled'}"` };
}
const seen = new Set();
for (const sh of (this._seq.shots || [])) {
if (!sh.scene || seen.has(sh.scene)) continue;
seen.add(sh.scene);
try {
const r = await fetch('scenes/' + encodeURIComponent(sh.scene));
if (!r.ok) continue;
const json = await r.json();
if (json && json.beatGrid) return { grid: json.beatGrid, off: beatOffsetOf(json), src: `shot scene "${sh.scene}"` };
} catch { /* offline / missing scene → keep looking */ }
}
return null;
}
async _filmAlign() {
if (!this._seq || this._filmBusy) return;
const shots = this._seq.shots || [];
if (!shots.length) { this._filmStatus('nothing to align — add a shot', true); return; }
const g = await this._filmGrid();
if (!g) {
this._filmStatus('no beat grid anywhere — load a scene and press ♪ on an audio row (or save a scene that already has a grid)', true);
return;
}
const durations = shots.map((sh) => this._sceneDur(sh.scene));
const { shots: aligned, skipped } = alignShotsToGrid(shots, g.grid, g.off, durations);
const moved = aligned.filter((s, i) => s.in !== shots[i].in || s.out !== shots[i].out).length;
this._seq.shots = aligned;
this._filmRows(); // runtime + every field redraws
const conf = g.grid.confidence ?? 0;
this._filmStatus(`aligned ${moved}/${shots.length} shot${shots.length === 1 ? '' : 's'} to the bar grid from ${g.src}`
+ (g.grid.bpm ? ` (${(+g.grid.bpm).toFixed(2)} BPM)` : '')
+ (conf < 0.35 ? ` · LOW confidence ${conf.toFixed(2)} — check it against the music` : '')
+ (skipped.length ? ` · left alone: ${skipped.map((s) => '#' + (s.index + 1) + ' (' + s.why + ')').join(', ')}` : '')
+ ' · Save to keep it', skipped.length > 0);
}
// renderSequence loads EVERY shot's scene onto the stage in turn (via
// tl.applyScene), so it clobbers whatever is open — same warn-before-clobber
// contract as New-from-template, said plainly. One render at a time.