[m11-B] camera moves become keyframes, snapped to the beat

applyDirect handles op:'move': one group() = transform keys at t0 and t0+dur
from the op's from/to, fov param keys at BOTH times (the only thing a zoom
moves, and an unkeyed fov gets dragged by its neighbours), ease on the leading
key of each pair. Repeating a move over the same span REPLACES the window
rather than interleaving, and reports how many keys it displaced. No automatic
cut - a move usually continues a shot - but the toast discloses when no cut
selects the moved camera, and cut:true is honoured if asked.

Snap config moved onto the model so a model-level op lands on the beat exactly
like a mouse drag: with a grid and snap=bar, t0 AND t0+dur land on downbeats.
Double-clicking the Cameras row now cuts to the camera you are looking through.

Verified live on music-video: playhead off-grid at 4.2s -> keys at 3.2907 and
8.9146, both exact bar lines, fov keyed at both, cuts unchanged, mid-move pose
interpolating, one undo deep-equal. 5 suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-28 20:19:55 +10:00
parent f20a8d5d4e
commit 5baebb410a
6 changed files with 507 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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');

View File

@ -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)'],