[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:
parent
f20a8d5d4e
commit
5baebb410a
@ -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.
|
||||
|
||||
120
logs/laneB.md
120
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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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. ----
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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)'],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user