[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:
parent
915f653525
commit
29694afda5
@ -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.
|
||||
|
||||
101
logs/laneB.md
101
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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user