diff --git a/lanes/B-timeline.md b/lanes/B-timeline.md index b75cdf9..d58c66c 100644 --- a/lanes/B-timeline.md +++ b/lanes/B-timeline.md @@ -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. diff --git a/logs/laneB.md b/logs/laneB.md index 0d0e5d1..e90aa1c 100644 --- a/logs/laneB.md +++ b/logs/laneB.md @@ -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. diff --git a/scenegod-8020.log b/scenegod-8020.log index 20dcf8f..b918d4d 100644 --- a/scenegod-8020.log +++ b/scenegod-8020.log @@ -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 diff --git a/scenegod/web/timeline.js b/scenegod/web/timeline.js index 05f3ad8..995825e 100644 --- a/scenegod/web/timeline.js +++ b/scenegod/web/timeline.js @@ -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. diff --git a/scenegod/web/timeline_test.mjs b/scenegod/web/timeline_test.mjs index 42dc771..446a84e 100644 --- a/scenegod/web/timeline_test.mjs +++ b/scenegod/web/timeline_test.mjs @@ -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'); diff --git a/scenegod/web/tlui.js b/scenegod/web/tlui.js index f7666a7..5e2f67d 100644 --- a/scenegod/web/tlui.js +++ b/scenegod/web/tlui.js @@ -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 { + @@ -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)'], ]) + ''; 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 = ` +
✂ auto-cut to the beat
+ +
range + + +
+
cameras (cycled in this order)
+
+
+
`; + 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 = 'no cameras in this scene'; + + 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 { + @@ -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.