diff --git a/lanes/B-timeline.md b/lanes/B-timeline.md index d58c66c..bedad6d 100644 --- a/lanes/B-timeline.md +++ b/lanes/B-timeline.md @@ -225,3 +225,21 @@ Real case: a hand cut at 4.50→cam2 survived immediately after an auto 1.42→c 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. + +### 2026-07-26 — M11-B ACCEPTED at SYNC 15. Camera moves land on the beat. +Verified live on music-video with snap=bar and the playhead parked off-grid at 4.2s: +keys at 3.2907 → 8.9146, BOTH exact gridTimes('bar') members (3 bars apart), fov keyed at +both ends, cameraCuts byte-identical to the file (no silent edit), mid-move pose strictly +between the two ends, one undo deep-equal. +Three calls I want to endorse: +1. Moving snap config onto the MODEL so a model-level op can land on the beat exactly like a + mouse drag — that's the right layering, and it's why "push in on the downbeat" works at all. +2. Keying fov even when it doesn't change. Subtle and correct: an unkeyed fov gets dragged by + its neighbours, so a dolly next to a zoom would have crept. +3. Double-click on the Cameras row now cuts to the camera you're LOOKING THROUGH. That's the + same class of trap Lane A found with 🎥 frame after "+ angle" — both of you caught the + second-order consequence of adding cameras, independently. +Ruling on your open question: MOVE_MAX_DUR 6s is a soft ceiling — extending up to half a bar +to land a bar-snapped move on a downbeat is correct and worth the overshoot, since the +scene-end clamp still applies. Lane A can add the cut checkbox whenever it next touches the +DIRECT bar; the model honouring `cut:true` already is the right shape. diff --git a/logs/laneB.md b/logs/laneB.md index 3671a24..953cfb8 100644 --- a/logs/laneB.md +++ b/logs/laneB.md @@ -565,3 +565,123 @@ DECISIONS: BLOCKED/REQUESTS: unchanged from session 8. NEXT: unchanged from session 8 (nudge-to-grid, box-select beyond keys, per-drag undo coalesce, film-level audio[] UI). + +## 2026-07-26 session 9 (M11-B: land Lane A's camera moves on the timeline) +DONE: `op:'move'` is handled — the END now exists as a key instead of only in the + event, so 🎬 move animates. Headless-verified; no git (standing instruction), no + browser, :8020 untouched (read-only curl confirms it already serves the edits: + `/web/timeline.js` carries `applyMove`/`snapMove`, `/web/tlui.js` the snap accessors). + Files: `timeline.js`, `tlui.js`, `timeline_test.mjs`, this log. `stagestub.js` + needed no change (the camera path only uses entityTransform/setTransform/setParam). + +**M11-B1 — the move handler** (`timeline.js:553-655`). + - `applyDirect` (`:505`) early-returns into `applyMove(d)` — the existing shot/ + light/mark branches are untouched, so nothing that worked yesterday moved. + - `applyMove(d)` (`:573`): one `group()` ⇒ ONE undo containing + `_clearMoveWindow` → transform key at `t0` (`from`) → transform key at + `t0+dur` (`to`) → `fov` param keys at BOTH times → optional cut. `ease` + (`inout`|`linear`, anything else → `inout`) goes on both keys of each pair; + the FIRST is the load-bearing one (a segment reads its ease off its left key). + `scale` comes from the live camera (cameras are 1) so a key is never partial. + - **fov is keyed even when it doesn't change** — required, not belt-and-braces: + it is the only thing a zoom moves, and an unkeyed fov gets dragged around by + whatever fov keys sit either side of the move. + - `_clearMoveWindow` (`:615`): transform keys and `fov` param keys inside + `[t0, t0+dur]` (endpoints included, eps 1e-6) are deleted first and returned, + so a second move over the same span REPLACES rather than interleaving into + jitter, and the count is reported. Non-fov params in the window (a `dof` key) + are left alone — a move owns position, aim and lens, nothing else. + - Toast (`:603`), e.g. `🎬 push in · 6.0s on cam2 · 2 transform + 2 fov keys · + replaced 4 · snapped to bar`. Refusals throw with a message and land in the + existing `_fail` → toast path (`no camera id` / `needs from/to camera states` / + `needs a duration > 0`); a half-applied move is impossible because the throw + happens before the group. + - Returns `{camId, t0, dur, ease, keys, replaced, snapped, live}` — the toast is + built from the same object the caller gets, so screen and report can't drift. + +**M11-B2 — moves on the beat** (`snapMove`, `timeline.js:639`; UI seam + `tlui.js:145-153`). Snap config now lives on the MODEL (`tl.snap = {on, step}`, + `timeline.js:94`); tlui's `_snapOn`/`_snapStep` became accessors over it, so + every existing read/write in the panel is unchanged and an op that arrives + WITHOUT a mouse can snap exactly like a drag. With a grid loaded and snap = + beat/bar, `t0` snaps like everything else; on **bar** the END snaps to a + downbeat too (walk back whole bars re-snapping each step if it would leave the + scene — same trick as `alignShotsToGrid`). Guards: never `dur <= 0`, never + `t0+dur > duration`, and if snapping can't satisfy both, the RAW Lane A numbers + are returned untouched. `snap = frame` (the default) leaves moves exactly where + Lane A put them. +TEST: `node scenegod/web/timeline_test.mjs` → green, +2 blocks (`:714`, `:830`). + Asserts: two transform keys at EXACTLY `t0` and `t0+dur` carrying `from`/`to` + (incl. rot), fov keyed at both times with the ease, a **zoom** producing two + transform keys whose pos AND rot are deepEqual while fov differs (and a mid-zoom + sample that doesn't drift a millimetre while fov reads 32.425 — strictly between + the ends), a mid-move pose strictly between the two ends for a dolly (5.0 at the + midpoint, and an early sample still up near the start for inout), ONE undo + restoring the prior tracks deepEqual + the stack depth, a second move over the + same window reporting `replaced: 4` and leaving 2+2 keys sorted by `t` (not 4+4 + interleaved), strays inside the window absorbed (6) while a key at t=12 and a + `dof` param key survive, `cut:true` placing exactly one cut that comes off with + the same undo, all three refusals throwing, and the snap block: bar → 1.4161 → + 7.04 (both real downbeats), beat → t0 on a beat with `dur` untouched, snap off = + off, plus the three guards near the end of the scene. + Real material `scenes/music-video.json` (4 cams, saved 128.02 BPM grid), read + from disk like the templates block: playhead 4.2 + bar snap → keys at 3.2907 and + 8.9146 — both `gridTimes('bar')` members, exactly 3 bars apart — the file's two + cameraCuts untouched, `live` reported as cam1, ONE undo deepEqual to the file. + Also ran END-TO-END against Lane A's REAL solver in a scratch script (not + committed — importing presets.js into my suite would make a Lane A break turn + Lane B red): solveMove push_in/zoom_in/truck_L/orbit_L/crane_up → the exact + event payload → keys → mid-pose. push in travels 0.496m with fov fixed; zoom in + travels 0.000m with fov 37.85→26.99 (mid 32.42); truck 0.540m; orbit 0.698m; + crane 0.663m. Lane A's `presets_test.mjs`, `stage_test.mjs`, `room3d_test.mjs` + all still ALL PASS; `node --check` clean on all four files I touched. +DECISIONS: + - **No cut, per the orchestrator's call — but the silence is disclosed.** Since + nothing cuts to the moved camera, a move on a camera the edit never selects + renders as nothing. `cutCameraAt(t)` (`timeline.js:404`, extracted from + `_evalCameraCuts` so there is one definition of "which camera is live") lets + the toast add "note: cam1 is live here — no cut selects this one". Disclosure, + not a decision — same instinct as auto-cut's `kept`/`dropped` line. + - **The cut IS available, as an explicit opt-in**: `cut: true` on the payload + adds `addCut(t0, camId)` inside the same undo. No default, no checkbox in my + UI because the MOVE segment is Lane A's — see BLOCKED/REQUESTS. + - **Double-clicking the Cameras row now cuts to the camera you are LOOKING + THROUGH** (`tlui.js:597`, `stage.activeCamera()` first, old cut-derived + fallback kept). Without it the obvious "I moved cam2, now cut to it" gesture + silently cut to the camera the OLD edit had live — the one mistake a cut must + not make. This is the hand-affordance that makes "no automatic cut" liveable. + - **`t0` = `d.t0 ?? this.time`.** Lane A offered "prefer your own playhead if + they ever disagree", but `index.html:62` wires `window.timeline`, so Lane A's + `_playhead()` IS my playhead; honouring the field also keeps an explicit + `/director` `t0` working. If the wiring ever breaks, moves would pile up at 0 + — that's the symptom to look for. + - **Bar snaps the END, beat does not.** A bar is where an edit lands; quantising + every move length to ~0.47s would be noise, not music. Both snap `t0`. + - **The playhead follows the snap** (`seek(t0)`): the viewport then shows the + move's real first frame (Lane A already put the camera on `from`), and "why did + it land there" has a visible answer instead of a silent 0.6s shift. + - **Endpoints are inside the replace window.** Pressing the same move twice must + land on its own keys, not beside them — `addKey`'s same-`t` replacement would + have covered the exact case, but not a key 3ms away from a re-snapped `t0`. + - ponytail (noted in code, `:569`): two keys ⇒ position LERPS, so an orbit + travels the chord, not the arc. Measured 3.8cm short of the 1.611m radius at + the midpoint of Lane A's ±25° default (invisible), 47cm on a 90° orbit + (visible corner-cutting). Intermediate keys only if wide orbits are asked for. +BLOCKED/REQUESTS: + - **@Lane A (small)**: a `cut` checkbox in the MOVE segment of the DIRECT bar → + put `cut: true` on the `move` payload. The model already honours it (one undo, + cut at `t0`). Default OFF, per the orchestrator's call. + - **@Lane A (nice-to-have)**: `moveDuration()` caps at MOVE_MAX_DUR 6s; a + bar-snapped end can extend a move up to half a bar past that (max ~+0.94s at + 128 BPM) so it lands on a downbeat. The hard clamp (never past + `scene.duration`) is re-applied here at the snapped start. Say if 6s is meant + to be a hard ceiling and I'll clamp the snap down a bar instead of up. + - **@orchestrator**: PLAN §4.1 still doesn't list `scene.beatGrid` or audio + `{in?, out?}` (carried from sessions 7-8). Nothing new needed for moves — they + are ordinary transform/params keys, exactly as Lane A predicted. +NEXT: nothing required. Backlog, in order: (1) "nudge to grid" for EXISTING + keys/cuts (moves and new drags are musical, old keys still aren't); (2) a move + is only editable as two keys — dragging the pair together (or a "move block" + overlay on the transform lane) is the obvious next UX step; (3) box-select still + only grabs keys, not clip/audio blocks or cuts; (4) per-drag undo coalesce; + (5) film-level `audio[]` has no UI. diff --git a/scenegod-8020.log b/scenegod-8020.log index 7b8baad..a0dcddb 100644 --- a/scenegod-8020.log +++ b/scenegod-8020.log @@ -2190,3 +2190,38 @@ INFO: 127.0.0.1:61935 - "GET /assets/file?path=characters%2Ftest%2Flady.glb INFO: 127.0.0.1:61935 - "GET /assets/file?path=animations%2Ftest%2Fcarrying.fbx HTTP/1.1" 200 OK INFO: 127.0.0.1:62391 - "GET /scenes/music-video HTTP/1.1" 200 OK INFO: 127.0.0.1:62391 - "GET /assets/file?path=characters%2Ftest%2Flady.glb HTTP/1.1" 200 OK +INFO: 127.0.0.1:49435 - "GET /web/timeline.js?cb=27512 HTTP/1.1" 200 OK +INFO: 127.0.0.1:49436 - "GET /web/tlui.js?cb=27512 HTTP/1.1" 200 OK +INFO: Shutting down +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. +INFO: Finished server process [20352] +INFO: Started server process [77004] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://127.0.0.1:8020 (Press CTRL+C to quit) +INFO: 127.0.0.1:61287 - "GET /?cb=m11b HTTP/1.1" 200 OK +INFO: 127.0.0.1:61287 - "GET /web/style.css HTTP/1.1" 200 OK +INFO: 127.0.0.1:61287 - "GET /web/stage.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:61288 - "GET /web/dock.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:61288 - "GET /web/room3d.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:61298 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:61287 - "GET /web/presets.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:61288 - "GET /assets/tree HTTP/1.1" 200 OK +INFO: 127.0.0.1:61288 - "GET /favicon.ico HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:61288 - "GET /web/grammar.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:61288 - "GET /director HTTP/1.1" 405 Method Not Allowed +INFO: 127.0.0.1:61287 - "GET /web/timeline.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:61287 - "GET /web/tlui.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:61331 - "GET /?cb=m11b HTTP/1.1" 200 OK +INFO: 127.0.0.1:61331 - "GET /assets/tree HTTP/1.1" 200 OK +INFO: 127.0.0.1:61332 - "GET /favicon.ico HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:61332 - "GET /director HTTP/1.1" 405 Method Not Allowed +INFO: 127.0.0.1:61363 - "GET /scenes/music-video HTTP/1.1" 200 OK +INFO: 127.0.0.1:61363 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 206 Partial Content +INFO: 127.0.0.1:61364 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 206 Partial Content +INFO: 127.0.0.1:61364 - "GET /assets/thumb?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 200 OK +INFO: 127.0.0.1:61364 - "GET /assets/file?path=characters%2Ftest%2Fman.fbx HTTP/1.1" 200 OK +INFO: 127.0.0.1:61364 - "GET /assets/file?path=characters%2Ftest%2Flady.glb HTTP/1.1" 200 OK +INFO: 127.0.0.1:61364 - "GET /assets/file?path=animations%2Ftest%2Fcarrying.fbx HTTP/1.1" 200 OK +INFO: 127.0.0.1:61364 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 206 Partial Content diff --git a/scenegod/web/timeline.js b/scenegod/web/timeline.js index 45ff47b..338b666 100644 --- a/scenegod/web/timeline.js +++ b/scenegod/web/timeline.js @@ -88,6 +88,10 @@ export class Timeline { this._clipActions = new Map(); // block obj -> {action, clip} this._activeCam = undefined; // last camera pushed (avoid re-spamming setActiveCamera) this.undoStack = []; + // Snap config lives on the MODEL (tlui mirrors its checkbox + select into it) + // so model-level ops — a camera move arriving on scenegod:direct — can land + // on the beat exactly like a mouse drag does. UI writes, model reads. + this.snap = { on: true, step: 'frame' }; // step: 'frame' | seconds | 'beat' | 'bar' if (typeof window !== 'undefined') { window.addEventListener('scenegod:capturekey', (e) => { @@ -122,6 +126,13 @@ export class Timeline { if (typeof window !== 'undefined' && typeof window.sgToast === 'function') window.sgToast(`${what}: ${msg}`); return null; } + // The positive counterpart to _fail: say what an op actually did. A timeline op + // that succeeds silently — especially one that REPLACES existing keys — is how + // users stop trusting the button. + _say(msg) { + if (typeof window !== 'undefined' && typeof window.sgToast === 'function') window.sgToast(msg); + return msg; + } _mirror(en) { if (!en || !en.id) return; @@ -387,10 +398,16 @@ export class Timeline { mixer.update(0); // set-time style — exact when scrubbing } - _evalCameraCuts(t) { - const cuts = this.cameraCuts; + // Which camera the EDIT is on at t: the last cut at or before it (null = the + // scene has no cut yet, i.e. the director cam). Public because a move needs to + // say when it is animating a camera nothing cuts to. + cutCameraAt(t) { let cam = null; - for (const c of cuts) { if (c.t <= t) cam = c.camera; else break; } + for (const c of this.cameraCuts) { if (c.t <= t) cam = c.camera; else break; } + return cam; + } + _evalCameraCuts(t) { + const cam = this.cutCameraAt(t); if (cam !== this._activeCam) { this._activeCam = cam; this.stage.setActiveCamera(cam); } } @@ -485,6 +502,7 @@ export class Timeline { // and the PLAN §5 contract shapes ({transform:{pos,rot}}, {params:{k:v}}, // walkTo). One op = ONE undo entry (group()). applyDirect(d) { + if (d.op === 'move') return this.applyMove(d); // M11-B: its own group + window + toast const t = this.time; const key = (id, k) => this.addKey(id, 'params', { t, key: k[0], value: k[1], ease: 'inout' }); this.group(() => { @@ -532,6 +550,108 @@ export class Timeline { this.seek(t); // re-evaluate: cut/keys take effect now } + // ---- M11-B: CAMERA MOVES (Lane A's 🎬 move — contract: logs/laneA.md s7) ---- + // `from`/`to` are complete camera states {pos, rot, fov}; Lane A has ALREADY + // put the live camera on `from`, so until this lands the END exists only in the + // event and nothing animates. Landed as ordinary keys, so a move is scrubbable, + // draggable and undoable like anything else on the timeline: + // • transform key at t0 (from) and at t0+dur (to) — `ease` on the FIRST of + // each pair (a segment reads its ease off its left key); + // • an `fov` param key at BOTH times, ALWAYS, even on a truck where it never + // changes: fov is the ONLY thing a zoom moves, and an unkeyed fov gets + // dragged around by whatever fov keys sit either side of the move. + // Existing transform/fov keys INSIDE [t0, t0+dur] are replaced (and counted) — + // a second move over the same span would otherwise interleave with the first + // and read as jitter. Same instinct as auto-cut replacing cuts on its beats. + // Everything inside ONE group() ⇒ ONE undo. + // NO CUT is placed: a move often continues the shot that is already running, so + // auto-cutting would silently change the edit. Opt in per-op with `cut: true`. + // ponytail: two keys, so position LERPS — an orbit travels the chord, not the + // arc (measured: 1.611m radius, 3.8cm short at the midpoint of Lane A's ±25° + // default — invisible; 47cm on a 90° orbit, which would visibly cut the + // corner). Intermediate keys only if someone actually asks for wide orbits. + applyMove(d) { + const camId = d.camId ?? (d.entityIds && d.entityIds[0]); + if (camId == null) throw new Error('move: no camera id in the payload'); + const from = d.from, to = d.to; + if (!from || !to || !from.pos || !to.pos) throw new Error('move: needs from/to camera states'); + const easeKind = d.ease === 'linear' ? 'linear' : 'inout'; // EASES = inout | linear + const { t0, dur, snapped } = this.snapMove(d.t0 == null ? this.time : +d.t0, +d.dur || 0); + if (!(dur > 0)) throw new Error('move: needs a duration > 0'); + const t1 = t0 + dur; + const cur = this.stage.entityTransform(camId) || {}; + const st = (s) => ({ pos: s.pos.slice(), rot: (s.rot || cur.rot || [0, 0, 0]).slice(), scale: cur.scale ?? 1 }); + const fov0 = from.fov ?? to.fov ?? this._paramNow(camId, 'fov'); + const fov1 = to.fov ?? fov0; + const replaced = this.group(() => { + const gone = this._clearMoveWindow(camId, t0, t1); + this.addKey(camId, 'transform', { t: t0, ...st(from), ease: easeKind }); + this.addKey(camId, 'transform', { t: t1, ...st(to), ease: easeKind }); + if (fov0 != null) { + this.addKey(camId, 'params', { t: t0, key: 'fov', value: fov0, ease: easeKind }); + this.addKey(camId, 'params', { t: t1, key: 'fov', value: fov1, ease: easeKind }); + } + if (d.cut) this.addCut(t0, camId); // opt-in only — never the default + return gone; + }); + this.seek(t0); // playhead + viewport on the move's first frame + const e = this.scene.entities.find((x) => x.id === camId); + // Disclosure, not a decision: since no cut is placed, a move on a camera the + // edit never cuts to renders as nothing. Say so instead of leaving the user + // to wonder why their push-in isn't in the mp4. + const live = this.cutCameraAt(t0); + this._say(`🎬 ${String(d.move || 'move').replace(/_/g, ' ')} · ${dur.toFixed(1)}s on ${(e && e.label) || camId}` + + ` · 2 transform + ${fov0 != null ? 2 : 0} fov keys` + + (replaced.length ? ` · replaced ${replaced.length}` : '') + + (snapped ? ` · snapped to ${snapped}` : '') + (d.cut ? ' · cut' : '') + + (this.cameraCuts.length && live !== camId ? ` · note: ${live || 'no camera'} is live here — no cut selects this one` : '')); + return { camId, t0, dur, ease: easeKind, keys: 2 + (fov0 != null ? 2 : 0), replaced: replaced.length, snapped, live }; + } + + // The keys a move takes ownership of: transform keys and `fov` param keys + // inside its window, endpoints included (a repeat of the same move must land + // on its own keys, not beside them). Returned so the caller can report the + // count — a silent replacement reads as a bug the second time you press it. + _clearMoveWindow(id, t0, t1, eps = 1e-6) { + const tr = this._tracks(id); + const inWin = (k) => k.t >= t0 - eps && k.t <= t1 + eps; + const gone = [ + ...(tr.transform || []).filter(inWin).map((k) => ['transform', k]), + ...(tr.params || []).filter((k) => k.key === 'fov' && inWin(k)).map((k) => ['params', k]), + ]; + for (const [track, k] of gone) this.deleteKey(id, track, k); + return gone.map(([, k]) => k); + } + + _paramNow(id, key) { // current value of a keyable param (scene, then stage) + const e = this.scene.entities.find((x) => x.id === id) + || (this.stage.getEntity ? this.stage.getEntity(id) : null); + return e && e.params ? e.params[key] : undefined; + } + + // Put a move on the music. When the UI's snap is set to beat/bar and a grid is + // loaded, `t0` snaps exactly like every other drag; on `bar` the END is snapped + // to a downbeat too, so a move starts AND stops on the music — the first thing + // that makes the beat grid apply to camera work. Guards: the end never leaves + // the scene (walk back whole bars, re-snapping each step, like + // alignShotsToGrid) and the duration is never <= 0 — if snapping can't satisfy + // both, the raw values are returned untouched. + snapMove(t0, dur) { + const raw = { t0, dur, snapped: null }; + const kind = this.snap && this.snap.on ? this.snap.step : null; + if ((kind !== 'beat' && kind !== 'bar') || !this.scene.beatGrid) return raw; + const a = this.snapToGrid(t0, kind); + if (a == null || a >= this.duration) return raw; + let d = Math.min(dur, this.duration - a); // Lane A's clamp, re-applied at the snapped start + if (kind === 'bar') { + const bar = this._beatPeriod('bar'); + let b = this.snapToGrid(a + d, 'bar'); + for (let g = 0; b != null && bar > 0 && b > this.duration + 1e-9 && g < 64; g++) b = this.snapToGrid(b - bar, 'bar'); + if (b != null && b > a + 1e-6 && b <= this.duration + 1e-9) d = b - a; + } + return d > 0 ? { t0: a, dur: d, snapped: kind } : raw; + } + // ---- audio track (M4-B). Clips are {path, start, gain}; duration is a // client-side draw concern (decode + cache in tlui). Server muxes from // scene.audio at render, so step()/evaluate never touch audio. ---- diff --git a/scenegod/web/timeline_test.mjs b/scenegod/web/timeline_test.mjs index 5e0051f..b21b8f7 100644 --- a/scenegod/web/timeline_test.mjs +++ b/scenegod/web/timeline_test.mjs @@ -711,4 +711,195 @@ assert.ok(onBar2(alOff.shots[0].in) && onBar2(alOff.shots[0].out), 'the grid off 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'); +// ---- M11-B: CAMERA MOVES — Lane A's 🎬 move keyed as a from/to pair ------- +// Payload contract: logs/laneA.md session 7. `from` is ALREADY on the live +// camera when the event fires; the END exists only in the event until it is +// keyed here, so "the move doesn't happen" is exactly what this block guards. +globalThis.window = new EventTarget(); +const mvStage = new StageStub(); +const tlMv = new Timeline(mvStage); // ctor: scenegod:direct listener + _mirror +await mvStage.addEntity({ id: 'cam2', kind: 'camera', label: 'cam2', params: { fov: 37.85 } }); +await mvStage.addEntity({ id: 'man', kind: 'character', label: 'man' }); +tlMv.setDuration(16); +tlMv.seek(2); +const camMv = tlMv.scene.entities.find((x) => x.id === 'cam2'); +const PUSH = { // presets.js:283, verbatim shape + op: 'move', camId: 'cam2', t0: 2, dur: 6, ease: 'inout', + from: { pos: [0, 1.6, 6], rot: [0, 0, 0], fov: 37.85 }, + to: { pos: [0, 1.6, 4], rot: [0, 0, 0], fov: 37.85 }, // a dolly: fov does NOT change + entityIds: ['cam2'], move: 'push_in', subjectId: 'man', shot: 'MS', angle: 'eye', focal: 35, +}; +assert.deepEqual(tlMv.snap, { on: true, step: 'frame' }, 'the model carries the snap config the UI writes'); + +let mvDepth = tlMv.undoStack.length; +const mvBefore = structuredClone(camMv.tracks); +window.dispatchEvent(new CustomEvent('scenegod:direct', { detail: PUSH })); // via the EVENT +const mvTr = camMv.tracks.transform, mvFov = () => camMv.tracks.params.filter((k) => k.key === 'fov'); +assert.equal(mvTr.length, 2, 'a move lands exactly two transform keys'); +assert.equal(mvTr[0].t, 2, 'first key at t0'); +assert.equal(mvTr[1].t, 8, 'second key at t0+dur'); +assert.deepEqual(mvTr[0].pos, PUSH.from.pos, 'the t0 key carries `from`'); +assert.deepEqual(mvTr[1].pos, PUSH.to.pos, 'the t0+dur key carries `to`'); +assert.deepEqual([mvTr[0].rot, mvTr[1].rot], [PUSH.from.rot, PUSH.to.rot], 'rotations ride along'); +assert.equal(mvTr[0].ease, 'inout', "the op's ease is on the FIRST key of the pair (the segment reads it there)"); +assert.equal(mvFov().length, 2, 'fov is keyed at BOTH ends'); +assert.deepEqual(mvFov().map((k) => k.t), [2, 8], 'and at exactly the move times'); +assert.deepEqual(mvFov().map((k) => k.value), [37.85, 37.85], + 'a dolly keys fov even though it never changes — an unkeyed fov gets dragged around by its neighbours'); +assert.equal(mvFov()[0].ease, 'inout', 'the fov pair carries the ease too'); +assert.equal(tlMv.cameraCuts.length, 0, 'a move places NO cut (it often continues the shot already running)'); +assert.equal(tlMv.undoStack.length, mvDepth + 1, 'the whole move is ONE undo entry'); +assert.ok(near(tlMv.time, 2), 'the playhead sits on the move\'s first frame'); + +// the payoff: the camera is somewhere between the two ends mid-move +mvStage.applied.length = 0; +tlMv.seek(5); // halfway through a 6s move +const midPose = lastIn(mvStage.applied, (a) => a.type === 'transform' && a.id === 'cam2'); +assert.ok(midPose && midPose.pos[2] < 6 && midPose.pos[2] > 4, `mid-move z must be strictly between 6 and 4, got ${midPose && midPose.pos[2]}`); +assert.ok(near(midPose.pos[2], 5, 1e-9), 'inout is symmetric: the midpoint is the midpoint'); +tlMv.seek(2.5); +const earlyPose = lastIn(mvStage.applied, (a) => a.type === 'transform' && a.id === 'cam2'); +assert.ok(earlyPose.pos[2] < 6 && earlyPose.pos[2] > 5, 'an early sample has barely left the start (ease-in)'); +tlMv.seek(2); + +tlMv.undo(); +assert.deepEqual(camMv.tracks, mvBefore, 'ONE undo restores the prior tracks deep-equal (keys AND fov)'); +assert.equal(tlMv.undoStack.length, mvDepth, 'undo stack back where it started'); + +// a ZOOM moves the lens, not the camera — the proof that from/to are kept whole +const ZOOM = { + op: 'move', camId: 'cam2', t0: 2, dur: 4, ease: 'linear', move: 'zoom_in', + from: { pos: [0, 1.6, 6], rot: [0, 0.2, 0], fov: 37.85 }, + to: { pos: [0, 1.6, 6], rot: [0, 0.2, 0], fov: 27.0 }, + entityIds: ['cam2'], +}; +const zRep = tlMv.applyMove(ZOOM); +assert.deepEqual(zRep, { camId: 'cam2', t0: 2, dur: 4, ease: 'linear', keys: 4, replaced: 0, snapped: null, live: null }, + 'applyMove reports what it did (the toast is built from this)'); +const zTr = camMv.tracks.transform; +assert.deepEqual(zTr[0].pos, zTr[1].pos, 'a zoom does not move the camera (pos identical at both ends)'); +assert.deepEqual(zTr[0].rot, zTr[1].rot, 'and does not turn it either'); +assert.deepEqual(mvFov().map((k) => k.value), [37.85, 27.0], 'only fov differs across a zoom'); +assert.equal(zTr[0].ease, 'linear', 'linear ease is honoured'); +mvStage.applied.length = 0; +tlMv.seek(4); // halfway through the zoom +const zPose = lastIn(mvStage.applied, (a) => a.type === 'transform' && a.id === 'cam2'); +const zFov = lastIn(mvStage.applied, (a) => a.type === 'param' && a.id === 'cam2' && a.key === 'fov'); +assert.deepEqual(zPose.pos, [0, 1.6, 6], 'the camera does not drift a millimetre during a zoom'); +assert.ok(zFov.value < 37.85 && zFov.value > 27, `mid-zoom fov must be strictly between the ends, got ${zFov.value}`); +assert.ok(near(zFov.value, 32.425, 1e-9), 'linear fov ramp: exactly halfway at halfway'); +tlMv.seek(2); + +// a second move over the same window REPLACES — interleaved keys read as jitter +const rep2 = tlMv.applyMove({ ...ZOOM, to: { pos: [0, 1.6, 6], rot: [0, 0.2, 0], fov: 20 } }); +assert.equal(rep2.replaced, 4, 'the previous move\'s 2 transform + 2 fov keys are displaced, and counted'); +assert.equal(camMv.tracks.transform.length, 2, 'still exactly two transform keys — not four interleaved'); +assert.equal(mvFov().length, 2, 'and two fov keys'); +assert.deepEqual(mvFov().map((k) => k.value), [37.85, 20], 'the NEW move wins'); +assert.ok(camMv.tracks.transform.every((k, i, a) => i === 0 || k.t >= a[i - 1].t), 'transform keys stay sorted by t'); +assert.ok(camMv.tracks.params.every((k, i, a) => i === 0 || k.t >= a[i - 1].t), 'param keys stay sorted by t'); +tlMv.undo(); +assert.deepEqual(camMv.tracks.params.map((k) => k.value), [37.85, 27.0], 'one undo puts the replaced move back exactly'); + +// keys the move does not own are left alone; strays inside the window are not +tlMv.addKey('cam2', 'transform', { t: 3, pos: [9, 9, 9], rot: [0, 0, 0], scale: 1, ease: 'linear' }); +tlMv.addKey('cam2', 'params', { t: 3.5, key: 'fov', value: 99, ease: 'linear' }); +tlMv.addKey('cam2', 'params', { t: 3.5, key: 'dof', value: 1.2, ease: 'linear' }); // not a move's business +tlMv.addKey('cam2', 'transform', { t: 12, pos: [1, 1, 1], rot: [0, 0, 0], scale: 1, ease: 'linear' }); +const rep3 = tlMv.applyMove(ZOOM); +assert.equal(rep3.replaced, 6, '4 of its own + the stray transform + the stray fov, all inside the window'); +assert.ok(camMv.tracks.transform.some((k) => k.t === 12), 'a key OUTSIDE the window survives untouched'); +assert.ok(!camMv.tracks.transform.some((k) => k.t === 3), 'the stray inside the window is gone'); +assert.ok(camMv.tracks.params.some((k) => k.key === 'dof' && k.t === 3.5), 'a non-fov param key inside the window is NOT the move\'s business'); +tlMv.undo(); +assert.ok(camMv.tracks.transform.some((k) => k.t === 3), 'and one undo brings the strays back'); + +// a cut is opt-in, never automatic +const cutRep = tlMv.applyMove({ ...ZOOM, cut: true }); +assert.deepEqual(tlMv.cameraCuts, [{ t: 2, camera: 'cam2' }], '`cut: true` places one at t0'); +tlMv.undo(); +assert.equal(tlMv.cameraCuts.length, 0, 'and it comes off with the same single undo'); +assert.equal(cutRep.keys, 4, 'the cut does not change the key count'); + +// refusals are loud (the listener funnels them into _fail → toast) +assert.throws(() => tlMv.applyMove({ op: 'move', from: ZOOM.from, to: ZOOM.to }), /no camera/, 'no camera id → throws'); +assert.throws(() => tlMv.applyMove({ op: 'move', camId: 'cam2', from: ZOOM.from }), /from\/to/, 'half a payload → throws'); +assert.throws(() => tlMv.applyMove({ ...ZOOM, dur: 0 }), /duration/, 'a zero-length move → throws'); +delete globalThis.window; + +// ---- M11-B: moves land on the beat (the grid finally reaches camera work) --- +const snStage = new StageStub(); +const tlSn = new Timeline(snStage); +await snStage.addEntity({ id: 'cam1', kind: 'camera', params: { fov: 40 } }); +tlSn.load({ version: 1, name: 'snapmove', fps: 30, duration: 16, cameraCuts: [], + audio: [{ path: 'audio/test/house128.wav', start: 0, gain: 1 }], + entities: [{ id: 'cam1', kind: 'camera', params: { fov: 40 }, + transform: { pos: [0, 1.6, 6], rot: [0, 0, 0], scale: 1 }, tracks: {} }] }); +tlSn.setBeatGrid(beatGridFrom(BEATS128, 0)); +const onBar128 = (t) => BEATS128.downbeats.some((b) => near(b, t, 1e-6)); +const MV = { op: 'move', camId: 'cam1', t0: 2.03, dur: 6, ease: 'inout', move: 'truck_L', + from: { pos: [0, 1.6, 6], rot: [0, 0, 0], fov: 40 }, to: { pos: [1.2, 1.6, 6], rot: [0, 0, 0], fov: 40 } }; + +assert.equal(tlSn.snapMove(2.03, 6).snapped, null, 'snap = frame (the default) leaves a move exactly where Lane A put it'); +assert.deepEqual(tlSn.snapMove(2.03, 6), { t0: 2.03, dur: 6, snapped: null }, 'and does not touch the numbers'); +tlSn.snap.step = 'bar'; +const sm = tlSn.snapMove(2.03, 6); +assert.ok(onBar128(sm.t0), `bar snap puts t0 on a downbeat, got ${sm.t0}`); +assert.ok(onBar128(sm.t0 + sm.dur), `and the END on a downbeat too, got ${sm.t0 + sm.dur}`); +assert.ok(near(sm.t0, 1.4161) && near(sm.t0 + sm.dur, 7.04), 'the nearest downbeats either side, not the next ones'); +tlSn.applyMove(MV); +const snTr = tlSn.scene.entities.find((x) => x.id === 'cam1').tracks.transform; +assert.ok(onBar128(snTr[0].t) && onBar128(snTr[1].t), 'the keys themselves land on the grid, not near it'); +assert.ok(near(tlSn.time, snTr[0].t), 'the playhead follows the snap (so "why did it land there" is visible)'); +tlSn.undo(); +tlSn.snap.step = 'beat'; +const sb = tlSn.snapMove(2.03, 6); +assert.ok(BEATS128.beats.some((b) => near(b, sb.t0, 1e-6)), 'beat snap puts t0 on a beat'); +assert.equal(sb.dur, 6, 'beat snap leaves the duration alone (only bar snaps the END — a bar is a musical edit point)'); +tlSn.snap.on = false; +assert.equal(tlSn.snapMove(2.03, 6).snapped, null, 'snap off = off, grid or no grid'); +tlSn.snap.on = true; + +// the guards: never past the scene end, never a zero/negative duration +tlSn.snap.step = 'bar'; +const late = tlSn.snapMove(14.6, 6); // Lane A already clamped; re-clamp at the snapped start +assert.ok(late.t0 + late.dur <= 16 + 1e-9, 'the snapped end never leaves the scene'); +assert.ok(late.dur > 0, 'and never collapses'); +const tiny = tlSn.snapMove(15.9, 0.1); +assert.ok(tiny.dur > 0 && tiny.t0 + tiny.dur <= 16 + 1e-9, 'a short move near the end survives snapping'); +assert.deepEqual(tlSn.snapMove(15.95, 0.05), { t0: 15.95, dur: 0.05, snapped: null }, + 'a start that would snap past the end of the scene is left alone entirely'); +tlSn.snap.step = 'frame'; + +// real material: scenes/music-video.json (4 cameras, saved 128.02 BPM grid) +const mvPath = new URL('../../scenes/music-video.json', import.meta.url); +let mvJson = null; +try { mvJson = JSON.parse((await import('node:fs')).readFileSync(mvPath, 'utf8')); } catch { /* not checked out */ } +if (mvJson) { + const rStage = new StageStub(), tlR = new Timeline(rStage); + await tlR.applyScene(mvJson); + tlR.snap = { on: true, step: 'bar' }; + const cutsBefore = structuredClone(tlR.cameraCuts); + const tracksBefore = structuredClone(tlR.scene.entities.find((x) => x.id === 'cam2').tracks); + tlR.seek(4.2); + const rr = tlR.applyMove({ op: 'move', camId: 'cam2', dur: 6, ease: 'inout', move: 'orbit_L', + from: { pos: [3, 1.5, 4], rot: [0, 0.6, 0], fov: 32 }, to: { pos: [4.6, 1.5, 1.9], rot: [0, 1.05, 0], fov: 32 } }); + const rTr = tlR.scene.entities.find((x) => x.id === 'cam2').tracks.transform; + const rBars = tlR.gridTimes('bar'); + assert.equal(rTr.length, 2, 'music-video: the move keys cam2'); + assert.ok(rBars.some((b) => near(b, rTr[0].t, 1e-6)) && rBars.some((b) => near(b, rTr[1].t, 1e-6)), + `music-video: both keys are real grid downbeats (${rTr[0].t} → ${rTr[1].t})`); + // playhead 4.2 → nearest downbeat 3.2907; 3.2907+6 → nearest downbeat 8.9146 (3 bars, 5.6239s) + assert.ok(near(rTr[0].t, 3.2907) && near(rTr[1].t, 8.9146), `music-video: 3.2907 → 8.9146, got ${rTr[0].t} → ${rTr[1].t}`); + assert.ok(near(rTr[1].t - rTr[0].t, 3 * (0.468662 * 4), 1e-4), 'the snapped move is a whole number of bars long'); + assert.equal(rr.replaced, 0, 'nothing to displace on a fresh camera'); + assert.deepEqual(tlR.cameraCuts, cutsBefore, 'music-video: the saved edit is untouched — no cut goes in behind your back'); + assert.equal(rr.live, 'cam1', 'and the report names the camera the EDIT is on at t0, so the toast can warn'); + assert.equal(tlR.cutCameraAt(5), 'cam2', 'cutCameraAt reads the edit exactly like _evalCameraCuts does'); + tlR.undo(); + assert.deepEqual(tlR.scene.entities.find((x) => x.id === 'cam2').tracks, tracksBefore, + 'music-video: ONE undo restores the file\'s tracks deep-equal'); + console.log(' (music-video.json: bar-snapped move on cam2, 3.2907 → 8.9146 = 3 bars)'); +} + console.log('OK — timeline_test.mjs: all assertions passed'); diff --git a/scenegod/web/tlui.js b/scenegod/web/tlui.js index 8e87211..84a2c0e 100644 --- a/scenegod/web/tlui.js +++ b/scenegod/web/tlui.js @@ -117,8 +117,8 @@ export class TimelineUI { this._hits = []; this._drag = null; this._selKeys = []; // box-selected keys: [{id, track, key}] - this._snapOn = true; - this._snapStep = 'frame'; // 'frame' | seconds string + this._snapOn = true; // both write through to tl.snap (accessors below) + this._snapStep = 'frame'; // 'frame' | seconds string | 'beat' | 'bar' this._zoom = 1; // 1 = fit-to-width; Cmd/Ctrl+wheel zooms this._panT = 0; // left edge of the view, in seconds this._audioBuf = new Map(); // path -> decoded AudioBuffer @@ -142,6 +142,16 @@ export class TimelineUI { this.draw(); } + // M11-B: the snap config is STORED ON THE MODEL (`tl.snap`) — the UI is still + // the only thing that writes it, but ops that arrive without a mouse (a camera + // move off `scenegod:direct`) have to snap the same way a drag does, and + // timeline.js can't reach into the panel. These accessors keep every existing + // `this._snapOn` / `this._snapStep` read+write working unchanged. + get _snapOn() { return this.tl.snap.on; } + set _snapOn(v) { this.tl.snap.on = !!v; } + get _snapStep() { return this.tl.snap.step; } + set _snapStep(v) { this.tl.snap.step = v; } + _refreshBar() { if (this.$name) this.$name.value = this.tl.scene.name || ''; if (this.$dur) this.$dur.value = this.tl.duration; @@ -584,7 +594,12 @@ export class TimelineUI { this.draw(); } else if (r.type === 'cameras') { const cams = this.stage.entities().filter((en) => en.kind === 'camera'); - const cam = this.tl._activeCam || (cams[0] && cams[0].id); + // M11-B: the camera you are LOOKING THROUGH wins. After a 🎬 move the stage + // is on the moved camera while the cut list still says the old one — and + // "add a cut here" then meant the wrong camera, which is the one mistake a + // cut must not make. Falls back to the cut-derived camera as before. + const cam = (this.stage.activeCamera && this.stage.activeCamera()) + || this.tl._activeCam || (cams[0] && cams[0].id); if (cam) { this.tl.addCut(t, cam); this.tl.seek(this.tl.time); this.draw(); } } } @@ -753,7 +768,7 @@ export class TimelineUI { ['click or drag the ruler', 'scrub'], ['double-click a transform lane', 'key the object where it stands now'], ['double-click a params lane', 'key its current params (fov, intensity, colour…)'], - ['double-click the Cameras row', 'cut to the active camera'], + ['double-click the Cameras row', 'cut to the camera you are looking through'], ['drag a key', 'move it — snaps to the increment, Alt = free'], ['drag empty lane space', 'box-select keys; drag one of them to move them all'], ['Delete / Backspace', 'delete the selected keys'], @@ -763,6 +778,7 @@ export class TimelineUI { ['two-finger sideways', 'pan when zoomed in (plain wheel scrolls the page)'], ['Cmd/Ctrl + Z', 'undo — one director preset = one undo'], ['♪ on an audio row', 'detect the beat grid, then snap = beat / bar'], + ['🎬 move (DIRECT bar)', 'keys the camera move on this timeline — start+end, one undo; snap = bar puts both on downbeats'], ]) + S('SCENE BAR', [ ['New…', 'start from a starter template (asks before clobbering)'],