SCENEGOD/lanes/B-timeline.md
type-two 22c8a9a324 M12: look, render button, save integrity — 3 workflow rounds, 22 agents
FEATURES (from a 5-lens audit: UX friction, feature gaps, bug hunt, visual
quality, discoverability):
- ACES filmic tone mapping + per-preset exposure. grammar.js runs physical sun
  intensities of 1.2-3.4; with NoToneMapping every lit face clipped to white and
  a warm key rendered neutral. Single biggest look win in the app.
- Real shadows: PCFSoft, a shadow box fitted to the scene's bounds, and plates
  that no longer double as the shadow catcher (dedicated ShadowMaterial).
- A Render button in the scene bar. finalRender had ZERO callers - the app's
  deliverable was console-only.
- Save actually saves the stage you built (gizmo moves, inspector edits), and
  Save/Load stop lying about what happened.
- 2x supersample + lanczos downscale; correct bt709 tagging and faststart.
- Frame guide, camera-from-view, dock counts, delete confirmations.

DEFECTS FOUND BY ADVERSARIAL REVIEW AND FIXED (all reproduced first):
- BLOCKER: save wrote the playhead pose over the authored rest transform of
  keyed entities, so every save produced a different file.
- renders were converted with the bt601 matrix while tagged bt709.
- deleting an unkeyed camera stranded its cut -> scene 422s forever.
- corner plates showed one photo at two exposures across the fold.
- exposure was advertised as keyable but nothing keyed it, so a second lighting
  preset permanently poisoned the first.
- the audio pre-flight could never fire (FastAPI does not route HEAD -> 405).
- the two render buttons were not mutually exclusive.
- frame guide letterboxed a narrow viewport that the render does not crop.

ORCHESTRATOR (round 3): dangling camera cuts are pruned in Timeline.toJSON and
skipped by cutCameraAt, rather than trying to keep every undo history clean -
deleting a camera spans Stage (no undo) and Timeline (undo), so any older entry
can resurrect a cut for a dead camera. Verified against the real validator.
Also replaced a VACUOUS test that drained a stack whose only entry was a seeded
no-op; timeline_test.mjs now replays both real histories and discriminates.

Verified: 4 JS suites + 23 server groups + render client green; blocker repro
now preserves the authored rest at every playhead position; save round-trips
200 with zero dangling cuts; zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:41:17 +10:00

262 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Lane B — Timeline: master clock, tracks, keyframes, evaluation
You own: `scenegod/web/timeline.js`, `tlui.js` (+ your marked block at the
bottom of `style.css`, + your dev-only `stagestub.js` until SYNC 1).
Milestone: **M1**. You develop against the Stage API in PLAN §4.2 — code to
the contract, not to Lane A's implementation.
## B1. stagestub.js (first commit, deleted at SYNC 1)
Implements the full Stage API from PLAN §4.2 with plain objects: entities
are `{id, kind, transform, params}`, `setTransform`/`setParam` record into a
`stub.applied` array and `console.debug`. `prepareClip` returns a fake
`{duration: 2.4}` clip. This lets every timeline feature be built and tested
headless before Lane A lands.
## B2. timeline.js — the model + clock (no DOM in this file)
- Owns the authoritative scene fields: `fps`, `duration`, `entities[].tracks`,
`cameraCuts`. `load(sceneJson)` / `toJSON()` round-trip **losslessly**
unknown fields on entities are preserved (Lane A owns them).
- Master clock: `play()` uses `requestAnimationFrame` deltas; `seek(t)`
clamps to `[0, duration]`; every advance calls `evaluate(t)` then
`onTick` listeners. `step(frame)` = `seek(frame/fps)` with NO raf — pure
and deterministic (Lane C's final render calls it in a loop).
- `evaluate(t)` per entity:
1. **Transform track**: binary-search the bracketing keys, interpolate —
pos/scale lerp, rot via Quaternion.slerp (convert stored eulers once at
load, cache quats on the key objects), easing (`linear|in|out|inout` =
cubic, `step` = hold) applied to the segment's normalized u. 0 keys →
rest transform; 1 key → hold. Result → `stage.setTransform(id, …)`.
2. **Params track**: same interpolation per `key` name (numbers lerp,
colors lerp in sRGB — fine for v1) → `stage.setParam`.
3. **Clip blocks** (characters): a block covers
`[start, start + (out-in)*loop]`. If t inside: local clip time =
`in + ((t-start) % (out-in))`; drive via the entity's mixer — one
`AnimationAction` per block, `action.time = localTime;
action.weight = w; mixer.update(0)` (set-time style, NOT free-running —
scrubbing must be exact). Crossfade: in the last `fade` seconds of a
block that has a successor, ramp weight 1→0 while successor ramps 0→1.
Outside any block: weight 0 (character holds rest / last transform).
4. **Camera cuts**: last cut with `cut.t <= t``stage.setActiveCamera`.
- Keyframe ops API (tlui + Lane A's event use these): `addKey(id, track, key)`,
`moveKey`, `deleteKey`, `addClipBlock(id, block)`, `moveClipBlock`,
`trimClipBlock`, `addCut(t, cameraId)` — every mutator keeps arrays sorted
by `t` and pushes an inverse op onto a simple undo stack (M2: wire to
Ctrl+Z; the stack itself costs nothing now).
- Listen for `scenegod:capturekey` (see Lane A dock): on it,
`addKey(id, 'transform', {t: this.time, ...stage.entityTransform(id), ease:'inout'})`.
## B3. tlui.js — the panel (mounts into `#timeline`)
- Left column: track names (one row per entity, auto-synced to
stage.entities via `onChange`/poll; + Cameras row + a per-character clip
sub-row). Right: time ruler + canvas-drawn track lanes (a `<canvas>` per
lane or one big one — your call, log the decision).
- Interactions (M1): click ruler = seek; drag playhead; space = play/pause;
double-click a transform lane = add key at that t from current stage state;
drag key = move (snap to frame, hold Alt = free); right-click key = delete;
drag a clip block horizontally; drag block edges = trim. Home/End = 0/duration.
- Scene bar: name field, duration field, Save/Load buttons →
`POST/GET /scenes/{name}` (Lane C; until then, localStorage fallback —
keep the fetch code, catch and fall back).
- Keep ALL DOM here; timeline.js must run headless under node for tests.
## B4. self-check — `web/timeline_test.mjs`
Runs under plain `node` (import timeline.js + stagestub): build a scene with
2 transform keys + 1 clip block + 2 camera cuts, `step()` through 90 frames,
assert: interpolated midpoint position, ease-step hold, clip local time at
3 sample frames, active camera flips at the cut, `toJSON()` deep-equals a
re-`load`ed copy. `node web/timeline_test.mjs` green = M1 code-done.
## M1 acceptance (log it)
- timeline_test.mjs passes.
- In the browser against the stub: scrub bar moves entities in the stub log,
keys draggable, play/pause works, save/load round-trips via localStorage.
- Do NOT integrate with the real Stage yourself — that's SYNC 1, the
orchestrator runs it (your code should make it a one-line swap).
## M2 preview (locked until orchestrator flips it)
Clip crossfades polish, params lanes UI, camera-cut lane UI, snapping
config, undo wiring, box-select + multi-drag of keys.
## ORCHESTRATOR UPDATES
### 2026-07-18 — M1 ACCEPTED, SYNC 1 done. **M2 is UNLOCKED.**
Your model survived integration with the real Stage; transform interp and
clip retarget playback verified frame-exact via `step()`. Notes:
1. **Fix applied by orchestrator** (in your file, keep it): `_evalClips` now
early-returns when the entity has no clip blocks or no mixer — the real
Stage returns `null` from `entityMixer` for backdrops/props/cameras; your
stub always returned an object, which hid the crash. Lesson for the stub:
mirror the contract's nullable returns.
2. **stagestub.js was NOT deleted**`timeline_test.mjs` imports it. It's
now a permanent test fixture, not dev scaffolding. tldev.html is gone.
3. **SYNC findings, fix first in M2:** (a) after `load()` + `applyState`,
your track-name rows never appeared (only "Cameras") — rebuild rows on
`load()` and on `stage.onChange`; (b) the scene-bar name/duration fields
don't refresh after `load()` — bind them. Verified repro: load a 2-entity
scene programmatically, rows stay empty.
4. **New event:** Lane A will dispatch `scenegod:clipdrop`
`{detail:{id, path, clipIndex}}` when a clip is dropped on a character —
add a clip block on that entity at the playhead (default out = clip
duration after preload, fade 0.25 when it abuts a neighbor).
5. rAF note: in the embedded preview the tab can be `document.hidden`
rAF fully suspended → `play()` appears frozen. Not a bug in your clock
(verified); don't "fix" it. `step()` is the deterministic path and works.
6. Injected `<style id=laneB-tl-css>` is fine permanently — keep it.
Then per plan: crossfade fade-IN polish, params + camera-cut lane UI,
snapping config, box-select multi-drag (undo already wired).
### 2026-07-18 — M2 ACCEPTED, SYNC 2 PASSED. **Session 3 (polish) granted.**
Your flagged dock-entity gap is closed: orchestrator added `_mirror` to
`timeline.js` (ctor subscribes `stage.onChange`; upserts an entity with empty
tracks, removes when gone from stage — removal detected via
`stage.getEntity(id) === null`). Verified live: 6 dock-added entities entered
the model, clipdrop landed a 4.47s block, sunset param lerp + camera cuts all
frame-exact. Session 3 items:
1. **Absorb `_mirror`** — review it, then add a timeline_test.mjs case
(stub add → mutate → remove; assert entity+tracks lifecycle). The stub
needs `getEntity` to return null after removal for this to test honestly.
2. Programmatic `scene.duration = x` doesn't refresh the scene bar (only
`load()` fires onLoad). Add a `setDuration(x)` mutator that clamps
existing keys' visibility and notifies the UI; wire the dur field to it.
3. Your listed polish: snapping increment config, keyable-param add UI.
### 2026-07-18 — session 3 ACCEPTED. **M4-B unlocked: audio on the timeline.**
Good catch fixing the stub's removeEntity-onChange payload — that's the
honest-stub lesson applied. All three items verified (setDuration bar
refresh + snap select seen live; tests green). M4-B scope (PLAN §5):
1. Audio rows in tlui from `scene.audio[]`: one lane per clip, draggable
offset (`start`), gain in the inspector-side of the row, add via a 🎵
button reading `/assets/tree` audio category; delete.
2. Web Audio playback synced to the master clock: on `play()` schedule
`AudioBufferSourceNode`s at `start - time` offsets; on `seek/pause` stop
+ reschedule. `step()` NEVER touches audio (deterministic render path —
the server muxes from scene.audio, already working end-to-end).
3. Viseme keyframes (pairs with Lane A's M4-A): a `morphs` track kind —
`{t, key: visemeName, value: weight}` evaluated via
`stage.setMorph(id, name, weight)` with lerp between keys; plus
`importRhubarb(id, rhubarbJson, t0)` mapping mouthShape cues → morph keys.
Guard: only offer when `stage.visemeTargets(id).length > 0`.
### 2026-07-18 — session 4 ACCEPTED. **v1 COMPLETE — no required B work.**
Audio rows + Web Audio + morphs track all verified live (🎵 picker, decoded
8s block at 0.5s offset, gain input, param/cut/sun lanes, zero console
errors). Your ownership note is acted on: CLAUDE.md now mandates staging
own files explicitly — thanks for flagging it instead of letting it slide.
Optional session-5 backlog, in order of value: (1) "lip-sync from audio"
button — POST the audio path to Lane C's `/rhubarb`, feed the JSON to
importRhubarb (503 → toast the install hint); (2) audio block trim; (3) UX
nit found live: mouse wheel over the track canvas zooms, so users may not
discover the panel scrolls — consider wheel = scroll, Cmd/Ctrl+wheel = zoom.
### 2026-07-19 — **M5 DIRECTOR MODE unlocked** (spec in PLAN §5 M5). Your part:
1. Listen for `scenegod:direct` (contract in lanes/A §M5.3): every preset op
becomes NORMAL keys/cuts at the playhead through your existing mutators —
`shot` → transform+fov keys on the camera + a cut to it; `light` → param
keys on sun/ambient (+bg); `mark` → transform key; `walk_to_mark` → key at
playhead-with-current-pos + key at playhead+2s at the mark. All undo-able
(one undo entry per op — group the batch, don't leave 5 half-undoable keys).
2. Wire the "lip-sync from audio" button now that `/rhubarb` is LIVE and
verified (`GET /rhubarb?path=audio/test/vo.wav` returns real mouthCues —
vo.wav is in assets): on an audio row, 🗣 button → fetch cues →
`importRhubarb(characterId, json, audioStart)`; character picker if >1
viseme-bearing char; toast the 503 install hint if absent.
3. Session-5 backlog if time remains: audio trim, wheel-scroll fix (above).
### 2026-07-19 — M6 FLOW VIDEO PLATES queued behind M5 (full spec PLAN §5 M6).
Do M5 first; M6 is unlocked the moment your M5 acceptance is logged. Test
media will be arriving in assets/backdrops/video/ via scripts/flow_intake.py.
### 2026-07-20 — M5+M6 ACCEPTED at SYNC 6 (all lanes ran as orchestrator subagents).
Verified live: DIRECT bar CU/low/frame → camera+keys+cut, ONE undo reverts;
golden_hour keys; video plate follows the clock frame-exact; finalRender shows
the Flow plate behind an animating character. Orchestrator seam fixes to absorb:
(1) server: VIDEO_EXTS in tree/media-types, video beats poster as primary;
(2) stage.entityVideo(id) added (Lane B seam); (3) syncVideos forces
videoTex.needsUpdate — r160 VideoTexture rides requestVideoFrameCallback which
never fires in hidden tabs → black plates in renders. Backlog carried: honor
audio in/out at mux (C), walk-to-mark prevPos pre-apply (A), live viseme asset
still owed by John.
### 2026-07-25 — M7-B ACCEPTED at SYNC 9. **M7 COMPLETE.**
Verified live: scene bar reads `? · New… · Save · Load`; picker lists all three templates
(2 with thumbs, golden-hour first); NO confirm on an empty scene, confirm DOES fire on a
dirty one; the pick loads 7 entities + 2 cuts and refreshes name/dur; `toJSON()` carries no
`template` key. Help overlay: opens on `?`, 24 real bindings across STAGE/TIMELINE/SCENE BAR,
Escape closes, and it correctly does NOT hijack `?` while the name field has focus.
One orchestrator fix absorbed: `.tl-help dt` was `auto`-width + `white-space:nowrap`, so long
terms overflowed and the three columns overlapped on screen — now a capped, wrapping term
column (tlui.js CSS). Your call to strip the `template` key inside `applyScene` rather than
lean on round-trip behaviour was the right one; PLAN §4.1 stays honest.
Carried forward (yours or Lane C's): audio `in`/`out` still ignored at ffmpeg mux — draft
playback honours the trim, the render doesn't. Worth closing before anyone cuts to music.
### 2026-07-26 — M9-B ACCEPTED at SYNC 12. Beat snapping + FILM panel both verified live.
Grid: 128.02 BPM / 35 beats / 8 downbeats / conf 0.865 off house128.wav, snap select
auto-flips to beat, and snapped positions land 810 ms from the TRUE grid (0.468735s
period) at t=0.3 through t=12.4 — i.e. all residual is Lane C's detector, none is yours.
Bar snap hits a downbeat. beatGrid survives toJSON→load. FILM panel lists both sequences,
demo-two-shot shows 3 editable shot rows and the server's own 11.50s arithmetic.
Two calls I want to endorse, because both were judgement rather than instruction-following:
1. Storing ONE top-level `scene.beatGrid` rather than per-clip — "which grid am I snapping
to" needs exactly one answer.
2. Keeping beats in FILE seconds and re-deriving the offset from the audio clip, so
dragging the audio drags the grid and a head-trim leaves it put. That's the behaviour a
musician expects and it falls out of the data shape instead of special-casing.
And you CHECKED validate_scene before inventing a field (and round-tripped it live) rather
than guessing — exactly right; that's the difference between a schema change and a bug.
NEXT: nothing queued. Open ideas if you want them: `?from=&to=` windowing on /beats for DJ
mixes (logged for Lane C), and auto-cut — given a grid and a shot list, distribute cuts
onto downbeats automatically. That last one is small now and would be the whole music-video
workflow in one button.
### 2026-07-26 — M10-B ACCEPTED at SYNC 13. Auto-cut verified on real material.
On `scenes/music-video` (4 cameras, saved 128.02 BPM grid): 4 cuts at 1.4161 / 5.1654 /
8.9146 / 12.6639 — each an exact `gridTimes('bar')` member (asserted by ===, not proximity),
3.749s apart = 2 bars, cycling cam2→cam3→cam4→cam1 with no consecutive repeat, and ONE undo
restored the prior array byte-for-byte. Refusal paths (no grid / one camera) leave both
cameraCuts and the undo stack untouched.
The phase-offset decision — never open the cycle on the camera already live — is the single
best call in this session; without it "from the playhead" would land the same angle twice,
which is the one thing a cut must never do.
One amendment requested (not a blocker, sent separately): keep user cuts that genuinely
change the angle, but DROP a kept cut whose camera equals the effective camera before it.
Real case: a hand cut at 4.50→cam2 survived immediately after an auto 1.42→cam2, i.e. a
literal no-op cut that renders as nothing but reads as an edit in the file. Report dropped
ones in the popover alongside `kept`.
NEXT: nothing queued for Lane B.
### 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.
### 2026-07-26 — ROUND 3 (workflow): orchestrator fix, dangling camera cuts.
Adversarial review found that "delete a camera, one Ctrl+Z, scene is unsaveable" was closed only
for the single history it was reproduced on. It is not winnable that way: deleting a camera spans
Stage (no undo) and Timeline (undo), so ANY older entry — a removeCut, or an addCut restoring the
`prev` it replaced — resurrects a cut for a camera that is gone. Two real histories were
demonstrated against the REAL server validator.
Orchestrator fix, in timeline.js: enforce the invariant at serialization instead of policing
history. `liveCameraIds()` / `danglingCuts()`; `toJSON()` prunes cuts naming a non-camera and says
so; `cutCameraAt()` skips them so playback holds the last LIVE camera instead of chasing a dead id.
History-independent, cheap, and it is the same rule the server applies.
Also: the assertion in stage_test.mjs that claimed to cover this was VACUOUS — it drained a
hand-built stack whose only entry was a seeded no-op, so it could not fail for the reason it named.
Replaced with a note; timeline_test.mjs now owns the honest version, replaying both real histories
and asserting nothing dangling survives toJSON. Proven to discriminate (reverting toJSON → RED).
LESSON worth keeping: when a test asserts a property, check the fixture can actually violate it.