SCENEGOD/logs/laneB.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

1059 lines
75 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 log — append-only, newest at bottom
Format per session:
## <date> session N
DONE: …
DECISIONS: …
BLOCKED/REQUESTS: …
NEXT: …
## 2026-07-18 session 1
DONE: M1 complete. All files under `scenegod/web/`.
- `timeline.js` — model + clock, no DOM, no three import (self-contained
quat/euler/slerp math so `node` can run it). `load/toJSON` lossless
round-trip (unknown entity fields preserved via structuredClone + a
WeakMap for cached quats). `evaluate(t)`: transform (pos/scale lerp, rot
slerp→euler, easing incl. step-hold), params (num lerp, color sRGB lerp),
clip blocks (set-time exact scrubbing, fade-out ramp), camera cuts (fires
only on change). Clock: `play()` rAF deltas, `seek` clamp, `step(frame)`
pure/deterministic for Lane C render. Mutators addKey/moveKey/deleteKey/
addClipBlock/moveClipBlock/trimClipBlock/addCut + undo stack (`undo()`
wired; Ctrl+Z hooked in tlui). `capturekey` window listener present.
Clips need `await tl.preload()` before evaluate (prepareClip is async).
- `tlui.js` — canvas panel into `#timeline`. Ruler scrub, drag playhead,
space=play/pause, dblclick lane=add key from stage state, drag key (snap
to frame, Alt=free), right-click=delete, drag clip block/edge, Home/End,
Ctrl+Z. Scene bar name/dur + Save/Load (POST/GET `/scenes/{name}`,
localStorage fallback). Names column auto-syncs to `stage.entities()` via
`onChange`.
- `stagestub.js` — full §4.2 Stage as plain objects; records effects into
`stage.applied[]`. Dev-only, delete at SYNC 1.
- `timeline_test.mjs``node scenegod/web/timeline_test.mjs` → green
(midpoint interp, step-ease hold, clip local time @3 frames, camera flip,
lossless round-trip, unknown-field preservation).
- `tldev.html` — dev-only browser harness. Verified in browser: scrub moves
entities (interp live in log), play runs to duration + auto-stops, camera
cut flips camA→camB at t=3, keys/clips/cuts render. Delete at SYNC 1.
DECISIONS:
- Timeline imports NO three.js — inlined euler↔quat + slerp (~30 lines) so
the node self-check needs zero deps (there is no node_modules/three in
this stack). setTransform rot is euler (matches entityTransform format).
- One `<canvas>` for all lanes (not canvas-per-lane); hit-testing via a
hitbox list rebuilt each draw. Simpler, fast enough at this scale.
- CSS injected from tlui.js via a `<style id=laneB-tl-css>` rather than
creating `web/style.css` — that file is Lane A's and didn't exist yet.
Move into a `/* === LANE B === */` block at SYNC if John wants it
centralized. (See REQUEST below.)
- M1 crossfade = fade-OUT only; cross-block fade-IN is M2 "crossfade polish"
per the milestone list. Marked with a ponytail comment in timeline.js.
BLOCKED/REQUESTS:
- None blocking. Note for orchestrator/Lane A: my timeline styles live in
an injected `<style>` in tlui.js. If you'd rather they sit in style.css,
say so and I'll emit a `/* === LANE B === */` block for Lane A to paste
(I won't edit style.css directly — ownership).
- Lane A's files (index.html, stage.js, room3d.js, dock.js, style.css) are
present untracked in the shared working dir but uncommitted — I staged
ONLY my 5 files, left theirs alone.
NEXT: Hold at M1 boundary. At SYNC 1 the stub swaps for the real Stage
(one-line change in the page import) and tldev.html/stagestub.js get
deleted. M2 (locked): clip crossfade polish, params/cut lane UI, snapping
config, undo Ctrl+Z already stubbed, box-select multi-drag.
## 2026-07-18 session 2 (M2)
DONE: M2 per orchestrator update. SYNC findings fixed first, then polish.
- SYNC1 #3a/#3b: added `Timeline.onLoad(cb)`; `load()` fires it →
TimelineUI rebuilds rows + refreshes name/duration fields. Verified with
the exact repro (UI built against empty stage, then `load()` a 2-entity
scene → rows now appear, scene bar updates).
- SYNC1 #4 clipdrop: `scenegod:clipdrop {id,path,clipIndex}``addClipDrop`
drops a block at the playhead, `out = clip.duration` (from preload), auto
`fade:0.25` where it abuts a neighbour. Verified via dispatched event.
- Crossfade fade-IN (was fade-OUT only): a predecessor's fade pre-rolls the
successor's playback start earlier by `pred.fade` so local time is
continuous across the boundary; weights ramp 1→0 / 0→1 and sum to 1
(asserted in test).
- Params lanes (per-key hue), camera-cut lane add via dblclick on Cameras
row (uses active cam / first camera), snap toggle in scene bar (Alt still
= free), box-select + multi-drag + Delete of keys, selection highlight,
fade wedge drawn on clip blocks, panel grows to content height (was fixed
200px → clipped the Cameras row once params rows were added).
- timeline_test.mjs extended: onLoad fires once, addClipDrop out=duration,
crossfade weights sum to 1. All green.
DECISIONS / BUG:
- **tlui reads tracks from the TIMELINE, not the stage entity.** Found mid-
verify: `load()` structuredClones the scene, so timeline.scene.entities and
stage entities are SEPARATE objects. Drawing from stage meant clipdrop/
addKey (which mutate the timeline copy) never appeared. Repointed all draw
methods to `_te(id) = tl.scene.entities.find(id)`. Rows now built from the
timeline list merged with any stage-only (dock-added, not-yet-keyframed)
entities. Per PLAN the timeline owns tracks — this is the correct source.
- Grow-to-content height, no scroll yet (ponytail: add max-height+scroll only
if a scene ever has enough entities to dominate the viewport).
BLOCKED/REQUESTS:
- Gap for the orchestrator/Lane A to note: there's no bridge for a
dock-added stage entity to enter `timeline.scene.entities`, so a freshly
dropped character can't be keyframed until a scene load. tlui shows it as a
row (empty tracks) but mutators need the entity present. If Lane A's dock
should register new entities with the timeline, we need a
`timeline.addEntity(desc)` (or the dock calls `stage.captureState()`
`timeline.load`). Flagging for SYNC 2 — not building it uninvited.
- Verified with a throwaway harness (`_check.html`) served on :8099; deleted,
NOT committed (orchestrator removed the M1 tldev.html deliberately).
NEXT: Hold at M2. Remaining M2 polish if a session 3 is granted: undo already
wired to Ctrl+Z; could add snapping increment config + keyable-param add UI
(currently params keys come from Lane A capture/inspector). Await SYNC 2.
## 2026-07-18 session 3 (M2 polish)
DONE: All three orchestrator session-3 items.
1. Absorbed `_mirror` (orchestrator's seam) — reviewed; it upserts dock-added
stage entities into `scene.entities` with empty tracks and drops them when
`stage.getEntity(id)===null`. Fixed the STUB to make removal testable:
`removeEntity` now fires `onChange` WITH the removed entity object (was
firing `null`, so `_mirror(null)` early-returned and never removed).
Added timeline_test.mjs lifecycle case: dock-add → keyframe → remove,
asserting entity+empty-tracks appear then vanish.
2. `setDuration(x)` mutator: clamps the playhead into range, KEEPS keys past
the new end (lossless), notifies the UI (reuses onLoad → syncRows +
refreshBar). Wired the dur field to it — plain `scene.duration = x` skipped
the bar refresh. Test asserts clamp + key retention + notify.
3. Snap-increment select (frame / 0.1 / 0.25 / 1s) in the scene bar; `_snap`
rounds to the chosen step (Alt still = free). Keyable-param add: dblclick a
params lane captures the entity's current param values as keys (skips
`type`/boolean descriptors) — mirrors the transform-capture dblclick.
DECISIONS:
- params lane now shows only when the entity has ≥1 param key OR kind is
camera/light. `_mirror` seeds `tracks.params=[]` (truthy), which was
giving every mirrored character an empty params lane; tightened to
`tracks?.params?.length > 0 || camera|light`.
- setDuration reuses the onLoad callback list as its "refresh UI chrome" hook
rather than adding a second callback channel (lazy; onLoad handler already
does syncRows+refreshBar+draw).
BLOCKED/REQUESTS: none. Verified with a throwaway harness (`_check.html`,
served :8099, deleted, NOT committed). timeline_test.mjs green.
NEXT: M2 polish complete. Undo already wired (Ctrl+Z). Open for M4 timeline
work when unlocked (audio tracks on the timeline UI + Web Audio sync, per
PLAN §5 M4) or any SYNC 3 fixes. Holding until orchestrator flips M4.
## 2026-07-18 session 4 (M4-B: audio + visemes)
DONE: M4-B all three items.
1. Audio rows in tlui from scene.audio[]: one lane per clip below Cameras,
green block at the DECODED buffer width (fallback width until decoded),
gain <input> in the name column, drag = offset (moveAudio), right-click =
delete. 🎵 button reads /assets/tree audio category into a small floating
menu → addAudio at the playhead.
2. Web Audio draft playback synced to the master clock: lazy AudioContext,
decode+cache buffers (/assets/file), _audioStart schedules
AudioBufferSourceNodes at (start - time) with per-clip gain, _audioStop on
pause/natural-end, user seeks reschedule (_seek helper). step()/render
never touch audio — server muxes from scene.audio.
3. morphs track kind: _evalMorphs lerps per viseme name → stage.setMorph;
importRhubarb(id, json, t0) maps mouthCues → morph keys; tlui shows a
"↳ visemes" lane guarded by stage.visemeTargets(id).length > 0.
Model (timeline.js, stagestub.js, timeline_test.mjs) committed ba29d0c;
tests cover morph lerp, rhubarb keys, audio mutators+undo. All green.
VERIFIED (real server :8020, real assets): 🎵 picker listed + added
audio/test/tone.wav, buffer decoded (8s), green block drawn, Web Audio
scheduled/rescheduled/stopped (ctx running), viseme lane rendered
importRhubarb keys, setMorph fired while scrubbing. Contract match confirmed:
real stage.js ships setMorph(id,name,weight) + visemeTargets(id) (returns
Object.keys(visemes)) — my `.length>0` guard works against both.
DECISIONS:
- Audio duration is a client draw concern (decode+cache in tlui), NOT stored
in the {path,start,gain} schema — server already knows durations at mux.
- importRhubarb = simple per-cue ramp (shape 1 at cue start → 0 at end);
hold/crossfade tuning deferred (ponytail marker in timeline.js) until it
looks wrong on a real blendshape character.
- Audio blocks can extend past scene.duration (tone.wav is 8s in a 6s scene)
— left as-is; the ruler just clips visually. Trim is a later nicety.
BLOCKED/REQUESTS:
- **Ownership note for orchestrator**: my tlui.js M4-B changes (+146 lines)
were committed inside Lane A's `df4e06f [laneA] M4-A ...`, not a [laneB]
commit — Lane A's commit swept my then-uncommitted tlui.js working changes
along with their dock.js/stage.js. Code is intact + verified; only the
attribution is off. Heads-up that our git hygiene let a cross-lane file
ride in on another lane's commit; a `git add <own files>` (not `-A`) avoids
it. Nothing for me to fix.
NEXT: M4-B complete. Open items if a session 5 lands: audio block trim/clip to
duration, viseme import UI button (currently importRhubarb is API-only; Lane
C has a /rhubarb endpoint — could wire a "lip-sync from audio" button that
POSTs the clip and feeds the result to importRhubarb). Holding for SYNC/next
orchestrator update.
## 2026-07-20 session 5 (M5 director-mode B + backlog, M6 video-follow)
DONE: All three M5 items + the M6 item, headless-verified.
1. `scenegod:direct` listener (timeline.js ctor) → `applyDirect(detail)`:
shot → transform+fov keys on the camera at playhead + addCut; light →
param keys; mark → transform key (walkTo → current/prevPos key at
playhead + mark key at playhead+2s). New `group(fn)` collapses every
mutator push inside one op into ONE undo entry — one Ctrl+Z reverts a
whole preset. `importRhubarb` is now grouped too.
**Payload coordination**: presets.js landed mid-session (untracked, Lane
A in flight), so I read it — Lane A's REAL payloads differ from the
PLAN-stated contract: values are spread at TOP level (`{op:'shot', camId,
pos, rot, fov,…}` — no `transform:{}` wrapper), and light is
`{sun:{color,intensity}, ambient:{…}, bg, sunId, ambientId}` (per-entity
values, not a flat `params` map; bg keys onto the ambient entity;
applyDirect ALSO captures the sun-boom transform from the stage since the
payload lacks sunPos rot). applyDirect accepts BOTH shapes (contract +
live); tests cover both.
2. 🗣 button on every audio row (tlui) → `GET /rhubarb?path=`
`importRhubarb(charId, json, clip.start)`. Character picker (`_choose`
promise-menu) when >1 viseme-bearing character, guard toast when 0,
`_toast` shows the 503 install hint from FastAPI `detail`. Viseme lane
resyncs after import.
3. Backlog: (a) wheel fix — plain wheel is NOT captured (page scrolls);
Cmd/Ctrl+wheel (incl. trackpad pinch) zooms 164× around the cursor,
horizontal wheel pans when zoomed; all geometry through _t2x/_x2t so
keys/blocks/playhead/hitboxes just work. (b) audio trim: drag the block's
right edge → `trimAudio(clip, {in,out})` (source-relative, like clip
blocks; absent = untrimmed so the {path,start,gain} schema is unchanged
for untouched clips); draft Web Audio playback honors in/out; ✂ badge on
trimmed blocks; undo restores pristine (fields deleted, not undefined).
4. M6: `_evalVideo` in evaluate for backdrop/screen entities via new nullable
stage seam `entityVideo(id)`: `currentTime = max(0, t-videoStart) %
duration` ONLY when drift >50ms; play/pause follows the clock (pause()
now re-evaluates so videos freeze); play() promise .catch()ed, never
awaited — step() stays sync (render determinism is stage.syncVideos,
Lane A/C).
Files: timeline.js, tlui.js, stagestub.js (StubVideo records currentTime
sets; entityVideo null for image backdrops — honest-nullable),
timeline_test.mjs. `node scenegod/web/timeline_test.mjs` → all green
(shot-via-real-EventTarget-dispatch + one-undo revert, generic + Lane A
light shapes, walkTo 2 keys, Lane A mark shape, rhubarb group undo, trim
undo, video drift-gate/modulo/videoStart-clamp/play-pause, nullable video).
DECISIONS:
- group() splices the undo stack rather than a begin/end transaction API —
mutators stay dumb, nesting works, zero cost when fn pushes ≤1 entry.
- Sun transform key on light presets comes from stage.entityTransform at
event time (Lane A setTransforms before firing) — payload has sunPos but
no rot; reading the stage gets both without a payload change.
- Video seek target clamps negative (t < videoStart hold frame 0); wrap
uses plain % (t-videoStart 0 after clamp).
- StubVideo lives in a stage-side Map, NOT on the entity object, so
captureState/clone stays clean.
BLOCKED/REQUESTS:
- **Lane A**: walk-to-mark can't work from the current payload applyMark
setTransforms the subject BEFORE firing, so "current pos" at event time is
already the mark. When you add a walk mark, either include `prevPos` (+
`walkTo:true`) in the detail (applyDirect already honors both) or skip the
pre-apply for walk marks. Also: no walk mark exists in grammar.js MARKS yet.
- **Lane C**: audio trim adds optional `in`/`out` (source-relative seconds)
to scene.audio clips mux should honor them (ffmpeg -ss/-t per clip);
absent = whole file (schema unchanged for untrimmed clips). Client draft
playback already honors them.
- **Orchestrator**: PLAN §4.1 audio schema + §4.2 Stage API could gain the
two seams now in live use: audio {in?, out?} and `entityVideo(id) ->
HTMLVideoElement|null` (Lane A implements on real Stage for M6-A).
NEXT: SYNC browser-verify DIRECT panel button keys+cut land + single
undo; 🗣 on real vo.wav viseme keys on a blendshape character (and the
503 toast with rhubarb absent); Cmd+wheel zoom feel; video backdrop
scrub-follow once Lane A ships entityVideo. Holding at M6-B boundary.
## 2026-07-25 session 6 (M7-B: the way in — templates + shortcuts overlay)
DONE: both required items + one backlog item, all headless-verified.
1. **New from template** (scene bar, left of Save/Load `tlui.js:122`).
`_pickTemplate()` (tlui.js:585) `GET templates` floating `.tl-menu.tmpl`
(same pattern as the 🎵 picker) with title + description + thumb
(`assets/file?path=`, `img.onerror` text-only row). All URLs RELATIVE.
`newFromTemplate(name)` (tlui.js:616): warn-before-clobber via
`tl.hasContent()` `confirm()`, then `GET templates/{name}`
`tl.applyScene(json)`, `_decodeAll()`, `draw()`. Any failure (fetch, !ok,
apply) toasts through `_toast` (window.sgToast still points at it).
2. **`?` shortcuts overlay** (tlui.js:633 `_toggleHelp` / `_helpClose`,
CSS `.tl-help` at tlui.js:48). Opens with `?` or the `?` button
(tlui.js:121), closes on Escape / click-outside / ✕. Three columns
STAGE, TIMELINE, SCENE BAR written by READING the code, not from memory:
stage.js:118-122 (W/E/R gizmo, selection-gated), dock.js drops, and every
binding in my `_key/_down/_dbl/_ctx/_wheel`. Deliberately NOT listed:
right-click on a clip block (only keys/cuts/audio blocks delete), left-edge
trim on animation clip blocks (only the right edge is a hit target).
3. Backlog: **audio HEAD trim** dragging an audio block's LEFT edge now eats
into the source (`in` and `start` move together so the audible part stays
put) instead of being dead space. `timeline.js:trimAudioIn` (grouped: one
undo for the pair, clamps at the file head, at t=0, and 0.05s before `out`);
tlui `aedge` hits now carry `side` ('in' | 'out').
Model seams added to timeline.js: `applyScene(json)` (timeline.js:173)
stage.applyState load preload, the ONE apply path now shared by Load and
New-from-template; and `hasContent()` (timeline.js:185).
TEST: `node scenegod/web/timeline_test.mjs` green, +5 new blocks:
hasContent (empty / cuts-only / audio-only / dock-added entity), applyScene
happy path (entities on stage, tracks in the model, onLoad once, camera live,
clip preloaded and playing at t=1, `template` key stripped, caller's json not
mutated), a loop that applies EVERY shipped `templates/*.json` through
applyScene against the stub ("3 shipped templates applied clean"), and
head-trim (pair + single undo + both clamps). `node --check` clean on
timeline.js, tlui.js, stagestub.js, timeline_test.mjs.
DECISIONS:
- `tlui.load()` now routes through `tl.applyScene` too, so Load and templates
cannot drift apart (the ask was "apply it the same way Load does" made it
literally the same code rather than a copy).
- `applyScene` deletes the `template` key before loading. server.py:264 claims
that metadata "never survives a Timeline round-trip" it WOULD have, since
load() structuredClones unknown fields on purpose (lossless round-trip is a
tested feature). Stripping it in one place makes Lane C's comment true and
keeps saved scenes plain scene JSON.
- `hasContent()` counts ANY entity, cut or audio clip a dock-added,
not-yet-keyframed character is still work worth a confirm. A fresh page has
zero entities (Stage creates none in its ctor), so the first New never
nags. Used plain `confirm()` a modal I'd have to build is not more honest
than the browser's.
- `_key` now also ignores SELECT + contentEditable targets, so `?` (and Space)
can't be hijacked while the snap dropdown or any text field has focus.
- The `?` button is excluded from the overlay's click-outside handler,
otherwise mousedown closed it and the click reopened it.
BLOCKED/REQUESTS:
- **Lane C / orchestrator (nit)**: `GET /templates` returns thumbs that are
ASSET-relative (`backdrops/video/scenegod_plates/*.jpg`) I render them via
`assets/file?path=`. If a future template ever ships a thumb that lives
outside SCENEGOD_ASSETS, the picker will show a broken (auto-removed) image;
keep thumbs inside the assets root.
- **Lane C (still open from session 5)**: audio `in`/`out` at ffmpeg mux.
Head trim makes it much easier to hit a head-trimmed clip now renders from
the file start server-side while the draft playback starts at `in`.
NEXT: nothing required. Remaining polish if a session 7 lands: box-select
multi-drag refinements (marquee currently only selects keys clip/audio
blocks and cuts are not box-selectable), and a per-drag undo coalesce (a
mousemove drag pushes one undo entry per frame; Ctrl+Z walks it back a pixel
at a time). Holding at the M7-B boundary.
## 2026-07-26 session 7 (M9-B: snap to the beat + the FILM panel)
DONE: both features, headless-verified. No git run this session (orchestrator's
instruction) `timeline.js`, `tlui.js`, `timeline_test.mjs` and this log are
edited in place, uncommitted. `stagestub.js` needed no change this time.
**M9-B1 — SNAP TO THE BEAT.**
- Model (`timeline.js:578-673`): `setBeatGrid/clearBeatGrid` (`:593`),
`beatOffset()` (`:600`), `gridTimes(kind)` (`:616`), `snapToGrid(t, kind)`
(`:635`), plus the pure `beatGridFrom(res, offset)` export (`:678`) that maps
Lane C's `/beats` response into the stored shape.
- UI (`tlui.js`): `♪` button on every audio row next to `🗣` (`:~250`,
`_detectBeats` at `:775`) `GET beats?path=` cache per path
(`this._beatCache`) `setBeatGrid` snap flips to `beat` and the toast says
the BPM, the beat count and the confidence. **beat** + **bar** added to the
snap-increment select (`:156`); `_snap()` (`:477`) routes those two through
`tl.snapToGrid` and falls back to frames when no grid exists (plus a toast if
you pick beat/bar with nothing detected). Grid drawing: `_drawBeatGrid`
(`:365`) beats `rgba(146,180,232,0.13)`, downbeats `rgba(224,175,104,0.34)`,
over the lanes, suppressed below ~7px spacing (beats drop out first, then bars,
then nothing never a solid wash).
- Because `_snap` was already the ONE snapping funnel, keys, clip blocks, audio
blocks, both trim edges, camera cuts and the playhead all became musical for
free. That was the whole bet in the M2 design and it paid.
**M9-B2 — FILM panel** (`tlui.js:848-1130`): collapsible `.tl-film` section
mounted INSIDE `#timeline` (`_buildFilm` `:854`, toggled by `🎬` in the scene
bar `:158`). Sequence dropdown + New…/Rename/Delete, computed runtime, size
picker, Save, Render film. Shot rows (`_filmRow` `:960`): scene `<select>` from
`GET scenes` (with `/Ns` scene length shown), in/out numbers, transition
cut|dissolve (+ seconds, only where a next shot exists), `↑ ↓ ✕`. All server
talk goes through Lane C's `film.js` helpers (`listSequences/getSequence/
saveSequence/deleteSequence/renderSequence`) no hand-rolled URLs.
`_filmRender` (`:1100`): `hasContent()` + a confirm that says plainly that every
shot loads its own scene and the open scene will be REPLACED; then it SAVES
first (the server renders the saved shot list, not the editor), disables the
whole panel for the duration (`_filmBusySet` `:1045`, so no second render), shows
`shot 2/3 — sync2-sunset — frame 45/120` live, and ends with a link to
`films/{id}/out.mp4` + a note that the stage now holds the last shot's scene.
422s from `plan_sequence` are unwrapped by `_errText` and shown in red.
TEST: `node scenegod/web/timeline_test.mjs` green, +2 blocks (`:394`, `:482`).
Beat block runs against the REAL captured `/beats` payload for
`audio/test/house128.wav` (bpm 128.02, 35 beats, 8 downbeats, conf 0.865), and
asserts: no grid `snapToGrid` null + nothing to draw; nearest beat over 9 time
points and nearest downbeat over 6; the 0.24/0.25s midpoint flip; bars use Lane
C's downbeat COSET (first downbeat is beats[3], not beats[0]); extrapolation past
the last beat; never snapping below 0; grid follows `moveAudio` and survives
`trimAudioIn` unmoved (start and `in` move together); `gridTimes` covers a longer
scene and stays sorted; bar lines are a subset of beat lines; a 0.12-confidence
grid still snaps and a grid alone does NOT make `hasContent()` true; `toJSON`
`load` identical snapping; `clearBeatGrid` leaves plain scene JSON. Film block
asserts `filmRuntime` equals the server's arithmetic on the shipped
`demo-two-shot` (11.50s), plus the last-shot-dissolve and cut-transitionDur
edge cases. `node --check` clean on all four files.
SERVER-SIDE CHECKS (read-only against the running :8020, nothing restarted):
- `validate_scene` (server.py:235) inspects only version/entities/tracks/
cameraCuts verified by calling it directly with a `beatGrid` scene
(`violations: []`), so the new top-level key is legal, not merely tolerated.
- Full round-trip proven live: POST a scene carrying `beatGrid` 200, GET it
back grid intact byte-for-byte. Test scene file deleted again (scenes/ holds
the same four it did before).
DECISIONS:
- **Field shape ONE top-level `scene.beatGrid`, not per-audio-clip**:
`{path, bpm, confidence, meter, period, offset, beats[], downbeats[]}`.
A music video has one tempo, and "which grid am I snapping to" must have
exactly one answer; per-clip grids would need a picker for a question nobody
asks. `beats`/`downbeats` stay in FILE seconds exactly as Lane C returns them
(so the stored grid is comparable with a re-fetch) and `offset` records where
the file's t=0 sat. The EFFECTIVE offset is re-derived from the audio clip
with the same `path` whenever one is still on the timeline drag the audio
block and the grid comes with it; delete the clip and the stored `offset`
keeps it working.
- **The grid extrapolates at constant tempo outside the analysed range**, in
BOTH `gridTimes` (drawing) and `snapToGrid`, so the lines you see and the
snap you get agree everywhere. Without it, an 8s track under a 30s scene drags
every late key back onto the last real beat a trap, not a feature. Lane C's
grid is explicitly constant-tempo, so this invents nothing.
- **Low confidence warns, never refuses** (Lane C: >0.5 solid, <0.2 mush). The
UI toasts "LOW confidence (0.12) this grid may be mush" at <0.35 and snaps
anyway; the model has no opinion about confidence at all.
- The fetch + per-path cache live in **tlui**, like every other server call in
this lane (`/rhubarb`, `/templates`, `/assets/tree`); `timeline.js` got the
pure `beatGridFrom` instead, so the responsestorage contract is testable
headless without stubbing `fetch` for a three-line call.
- `hasContent()` deliberately ignores `beatGrid`: it is one button press to
rebuild, so it is not work worth a confirm.
- **film.js is imported lazily** (`_filmMod()`, first 🎬 press) rather than at
the top of tlui.js: the timeline must still mount if Lane C's client files are
missing or broken, and nobody who never opens FILM should pay for render.js.
- `filmRuntime(shots)` is exported from tlui.js as a pure function so the number
on screen can be pinned to the server's own formula in the node test tlui.js
touches no DOM at module scope, and the test's `import('./tlui.js')` now fails
loudly the day that changes.
- Rename is save-then-delete (a failed save leaves the original untouched); the
slug the server returns becomes the working name.
BLOCKED/REQUESTS:
- **Lane C / orchestrator (nice-to-have, not blocking)**: `/beats` on a DJ mix
assumes one tempo for the whole hour Lane C already has `?from=&to=`
windowing on its own backlog. When it lands I'd store one grid per window, or
at minimum re-detect from the playhead; the current shape can grow a
`range:[from,to]` field without breaking anything.
- **Lane C (still open, third session running)**: nothing new audio `in`/`out`
at the mux is CLOSED as of C session 7, thanks.
- **Orchestrator**: PLAN §4.1 could gain the two fields now in live use
`scene.beatGrid` (shape above) and the already-shipped audio `{in?, out?}`.
NEXT: nothing required. Backlog if a session 8 lands: (1) a "nudge to grid" action
that re-snaps EXISTING keys/cuts to the nearest beat (today only new drags snap);
(2) box-select still only grabs keys, not clip/audio blocks or cuts; (3) per-drag
undo coalesce (a mousemove drag still pushes one undo entry per frame);
(4) film-level `audio[]` has no UI a sequence's soundtrack must still be edited
by hand, and it is the obvious partner to beat-snapping.
## 2026-07-26 session 8 (M10-B: AUTO-CUT + align a film to the beat)
DONE: both, headless-verified. No git run (standing instruction) `timeline.js`,
`tlui.js`, `timeline_test.mjs` and this log edited in place. `stagestub.js`
needed no change again. The running :8020 already serves the edits (checked with
a read-only curl: `/web/timeline.js` carries `autoCutPlan`, nothing restarted).
**M10-B1 — AUTO-CUT** (`timeline.js:635-700`, UI `tlui.js:823-910`).
- `cameraIds()` (`:635`) cameras in scene order, merged with any stage camera
not yet mirrored (the same merge tlui's `_rows` uses).
- `autoCutPlan(opts)` (`:647`) PURE preview: `{ok, cuts, replaced, kept,
cameras, cadence, barPeriod, confidence, bpm, from, to}` or `{ok:false,error}`.
Cuts come straight out of `gridTimes('bar')` (so a placed t IS a grid downbeat,
not a rounded neighbour); `bars.filter((_,i)=>i%cadence===0)`; cameras cycle
`cams[(i+phase)%n]`.
- `autoCut(opts)` (`:687`) applies inside `group()` ONE undo; `throw`s the
plan's error rather than half-applying; `seek(time)` so the new cut goes live.
- `removeCut(cut)` (`:459`) new undo-able mutator (replacements had to be
undoable too; tlui's right-click delete still splices directly, unchanged).
- UI: `✂` in the scene bar popover with cadence (1/2/4/8 bars, default 2),
range (whole scene | from the playhead), a checkbox per camera, and a live
"will place N cuts · one every 3.75s" line that is literally `autoCutPlan()`
the previewed count and the applied count are the same call. Refusals
render in that line in red with Apply disabled. Escape / click-outside close.
The count re-runs on every tick, so scrubbing with "from the playhead"
selected keeps it honest.
TEST: `node scenegod/web/timeline_test.mjs` green, +2 blocks (`:498`, `:646`).
Auto-cut block asserts against the same real house128.wav payload: 8 downbeats
in a 16s scene cadence 1/2/4/8 gives 8/4/2/1 cuts; every placed `t` is
`===` a member of `gridTimes('bar')`; 2 cameras strictly alternate A/B/A/B;
3 cameras cycle A/B/C/A/B/C/A/B; no camera lands twice in a row; ONE undo
restores the exact prior `cameraCuts` (deepEqual against a pre-snapshot) and
the stack grows by exactly 1; re-running replaces its own cuts (4 cuts stay 4,
all `t` unique, same positions); a hand cut ON a downbeat is replaced, one
BETWEEN downbeats is kept untouched and reported; no-grid and one-camera both
throw with a clear message AND leave `cameraCuts` and the undo stack empty;
an empty range refuses; the plan follows the audio when the block is dragged.
Also run against the REAL `scenes/music-video.json` (4 cams, saved 128.02 BPM
grid): cadence 2 1.416:cam2 5.165:cam3 8.915:cam4 12.664:cam1, undo restores
the file's original two cuts exactly.
**M10-B2 — align a film's shots to the beat** (`timeline.js:772-793` pure,
`tlui.js:1213-1252` panel). `♪ align` button in the FILM head row.
- WHICH GRID: the open scene's if it has one, else the first shot scene (in
shot order) with a saved `beatGrid`, fetched via `scenes/{name}`. The status
line always names the source ("from the open scene \"music-video\"" /
"from shot scene \"x\"") "why did it snap there" must have a visible answer.
- `alignShotsToGrid(shots, grid, off, durations)` snaps each `in`/`out` to the
nearest downbeat, never reorders, and returns `{shots, skipped}`. A shot that
would collapse is returned BYTE-IDENTICAL and listed in `skipped`; the panel
prints "left alone: #3 (would collapse)". Runtime redraws via `_filmRows()`;
nothing is sent to the server until the user presses Save.
TEST: order preserved, both boundaries land on real grid lines, `out > in` for
every surviving shot, the collapsing shot is untouched + reported, the caller's
array is not mutated, transitions ride along, aligning twice is a no-op, and an
offset grid shifts the whole alignment. Verified against both shipped
sequences with the music-video grid: demo-two-shot 0-4.5|4.5-8|0-4 becomes
1.4161-5.1654|5.1654-7.04|1.4161-3.2907, 0 skipped.
DECISIONS:
- **Pure grid helpers extracted** (`beatOffsetOf`, `beatPeriodOf`, `gridTimesOf`,
`snapToGridWith`, `timeline.js:716-770`); `beatOffset/gridTimes/snapToGrid`
are now one-line delegates. B2 aligns shots of scenes that are NOT loaded
it has a grid and a shot list, no Timeline and it must get bit-identical
arithmetic to what the user saw while cutting. Behaviour unchanged: the whole
M9 beat block still passes untouched.
- **Replace ON a position, keep everything else.** An existing cut within half
a frame of a placed time is replaced (a duplicate 15ms away is a flash frame);
cuts elsewhere in the range are the user's edit and are NOT deleted. But they
interleave and one can repeat a camera, so the plan returns `kept` and the
popover says "1 of your own cuts left in place between them". Disclosure
beats either silently stuttering or silently deleting. Flip to "clear the
range" if you'd rather, it's a one-line change but I'd want John to ask.
- **Phase offset so the run doesn't open on the camera already live.** With
"from the playhead", the last cut before the range is checked; if it holds
`cams[0]` the cycle starts at `cams[1]`. Otherwise a beat-perfect run can
still open with the same angle twice, which is the one thing a cut must
never do. Verified live on music-video.json (cut at 0 = cam1 run opens cam2).
- **No cleverness, as instructed**: no random order, no energy analysis, no
variation. Every knob is on the popover and the output is ordinary
`cameraCuts` the user hand-edits after.
- **B2 clamps by walking back a whole bar and RE-SNAPPING** (`timeline.js:786`),
not by subtracting the bar period: Lane C rounds beats to 4dp and the period
to 6dp, so `b - bar` lands 48µs off a real downbeat. "Lands on a downbeat"
has to mean the real one, and the test asserts membership at 1e-6.
- `autoCut` THROWS on refusal instead of returning a flag: the caller already
has `autoCutPlan()` to check, so a thrown error can't be ignored into a
half-applied edit. The popover never lets you press Apply on a bad plan anyway.
- FILM align has no undo (the panel has none by design the server doc is the
state and Save is the gate); the status line says "Save to keep it".
BLOCKED/REQUESTS:
- **Orchestrator**: PLAN §4.1 still doesn't list `scene.beatGrid` or audio
`{in?, out?}`, both in live use since M9 (carried from last session).
- **Lane C (nice-to-have)**: `GET scenes` returns `{name, duration}` but not
whether a scene HAS a beatGrid. B2 has to fetch whole scene JSONs one at a
time to find one. A `hasGrid`/`bpm` field on the list would make the FILM
panel's grid hunt one request instead of N.
NEXT: nothing required. Backlog if a session 9 lands, in order: (1) "nudge to
grid" for EXISTING keys/cuts (today only new drags and auto-cut are musical);
(2) box-select still only grabs keys, not clip/audio blocks or cuts; (3)
per-drag undo coalesce (a mousemove drag still pushes one entry per frame);
(4) film-level `audio[]` has no UI the obvious partner to align.
## 2026-07-26 session 8b (SYNC13 follow-up: drop no-op kept cuts)
DONE: orchestrator's M10-B acceptance note actioned a kept cut that selects the
camera already live is now DROPPED, not preserved. `autoCutPlan` (`timeline.js:668-690`)
no longer filters `kept` positionally; it merges the surviving existing cuts with
the about-to-be-placed ones, sorts by t, walks the merged list tracking the
EFFECTIVE camera, and splits in-range user cuts into `kept` (changes the angle)
vs `dropped` (`camera === eff` cuts cam2cam2, i.e. nothing). `autoCut`
(`:704`) removes `dropped` alongside `replaced`, inside the same `group()`, so it
is still ONE undo. Popover line now reads "… · 2 of your cuts kept · 1 dropped
(no camera change)"; the apply toast says which and why.
Walking the MERGED list (not comparing to the previous array element) is the
point: the duplicate only exists once the auto cuts are in 4.5:cam2 is a
perfectly good cut in the file as saved, and only becomes a no-op because
auto-cut puts cam2 at 1.4161.
TEST: +3 cases (`timeline_test.mjs:566-598`): a cut that changes the angle is kept
(unchanged behaviour, still asserted), a cut duplicating the incoming auto camera
is dropped and ONE undo restores it with everything else, and a mixed pair
(2.0:camA redundant after the 1.4161 auto camA / 6.0:camA real after the 5.1654
auto camB) drops exactly one and keeps exactly one. Also asserts the final list
has no consecutive camera repeat AT ALL, which is the property that was missing.
Replayed the orchestrator's own case on `scenes/music-video.json`: file cuts
`0:cam1, 4.5:cam2` auto-cut cadence 2 `0:cam1 1.42:cam2 5.17:cam3 8.91:cam4
12.66:cam1`, the 4.5 no-op gone, no repeats, one undo byte-identical to the file.
`node scenegod/web/timeline_test.mjs` green; `node --check` clean on all four.
DECISIONS:
- The drop is limited to cuts INSIDE the placed range and only when the camera is
genuinely unchanged. A user cut before the first or after the last placed cut is
still never touched auto-cut has no business outside its own range.
- Noted for whoever picks up `♪ align`: the "name the grid source in the status
line" pattern is endorsed by the orchestrator; shot lists can in principle span
scenes with different grids, and saying which one was used is the honest answer
rather than silently picking. Keep that pattern for anything similar.
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.8526.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.
## 2026-07-28 session 10 (make the app usable: render button, save truth, cut model, outliner)
DONE: all five items. No git (standing instruction), no browser (instructed), :8020
untouched read-only curl confirms it already serves the edits (`/web/tlui.js`
carries `renderPlan`/`syncFromStage`). Files: `timeline.js`, `tlui.js`,
`stagestub.js`, `timeline_test.mjs`, this log.
**1 — ⏺ Render in the scene bar** (`tlui.js:214-220` bar markup, `render()` at
`tlui.js:1414`, pure `renderPlan(tl, sizeStr)` at `tlui.js:113`).
`finalRender()` had ZERO callers; the only render control was inside the collapsed
🎬 panel (needs Save New sequence + shot, renders SILENT, clobbers the open
scene). Now: size select (verbatim copy of the FILM head's three options) + `⏺ Render`
pause + remember `tl.time` lazy `import('./render.js')` (same shape as `_filmMod()`)
`finalRender(stage, tl, {...renderPlan(size), onProgress})`. Button reads
`rendering 41/600` while it runs, both controls disabled, `_rendering` refuses a second
click; success puts a persistent relative `<a target=_blank rel=noopener>` in `.rstat`
AND toasts; failure toasts `_errText(e)`; `finally` re-enables and `seek(saved)`.
Chrome hiding untouched Lane C moved it INTO `captureFrames` (`render.js:76`,
`stage.pause?.()`) mid-session, so it now happens for free on this path too.
`renderPlan` throws `too long: N frames (max 20000)` (server.py:439's cap) and falls
back to 1920×1080 on an unparseable size.
**2 — save the stage you actually built** (`Timeline.syncFromStage()` `timeline.js:175`,
called at the top of `tlui.save()` `tlui.js:1456`). `_mirror` copies transform+params
ONCE and early-returns forever (`timeline.js:145`), so a prop dragged into place, a key
light dialled to 0.8 and a backdrop's fitDist all reverted to their drop-time values on
save/reload; `Stage.captureState()` (stage.js:682), which exists for exactly this, had
zero callers. Merge takes ONLY `transform` and `params` (structuredClone), never adds,
never removes, never touches `tracks` the stub's captureState clones whole entities
INCLUDING tracks, so a naive merge would have overwritten live keys with a stale copy.
`params.fit === 'frustum'` backdrops keep their authored transform (that wrapper is the
lens's, stage.js:485) and take their params.
**3 — save/load stop lying** (`tlui.js:1450-1560`, pure `uploadEntities`/`saveMessage`
at `tlui.js:133`/`145`). A 422 from `validate_scene` used to be indistinguishable from
success (silent localStorage write), and since `load()` prefers the server copy the next
Load restored the last good version and ate everything since. Now the response is
awaited: `!res.ok` console.error + toast `NOT saved — <server's own errors>` and NO
localStorage; only a thrown fetch falls back, toasting `server unreachable — kept a
local copy in this browser only`; success toasts `saved "name" · N entities`. Upload
entities are collected and confirmed before the POST (PLAN.md:72-73, never built).
Load is now a `tl-menu` popover of `GET scenes` (name · duration · mtime), gated on
`hasContent()` confirm like the other two clobbering actions, with `applyScene`
wrapped in try/catch so one missing clip can't kill `_decodeAll()` and leave the audio
row dead.
**4 — cameras go live at a cut** (`timeline.js:156-163`, `addCut` `:527`, `moveCut` `:549`,
`_evalCameraCuts` `:471`, `tlui.js` `_drawCuts`/`_ctx`/`_move`).
(a) `_mirror` gives the FIRST camera in a cutless scene a cut at 0 + a toast dock
`_addRig` sets it active for the PiP and the very next `evaluate` used to snap the view
back to the orbit cam. Guarded by `tl._applying` (set around `stage.applyState` in
`applyScene`) so scene loads don't toast.
(b) `addCut` REPLACES any cut within half a frame (autoCutPlan's own rule), in one undo
entry three "+ angle"s at the playhead used to stack three overprinted cuts, two of
them dead.
(c) the Cameras lane prints the camera's LABEL (was the raw id, so it read `e3 e5 e7`
next to a names column saying `cam2 / cam3`).
(d) right-click delete routes through `tl.removeCut` and the drag through the new
`tl.moveCut` both were raw mutations, so Ctrl+Z after either reverted an unrelated
earlier keyframe.
(e) `_evalCameraCuts` compares against `stage.activeCamera()` (added to stagestub) so
nothing can desync the cached value permanently.
**5 — the names column is the outliner** (`tlui.js` `_syncRows`, `_stageChanged`,
`_markSel`, CSS block; pure `rowFlags(entity)` at `tlui.js:157`).
Every row with an entity gets `.pick`, a title, `onclick → stage.select(id)` (guarded by
`getEntity`) and a `.sel` left-border highlight driven by `stage.onSelect`. This is the
ONLY way to reach an ambient light: its wrapper holds a HemisphereLight with no
geometry, so `Stage._pick` can never hit it, and every DIRECT lighting preset creates
one (intensity/colour/bg/fog were devtools-only). Rows whose entity has 1 transform key
carry a `⏺` glyph, and `stage.onChange` (gizmo mouseUp, stage.js:116 never from
setTransform, so no feedback loop) toasts that the pose will revert on the next scrub.
No auto-keying.
TEST: `node scenegod/web/timeline_test.mjs` **OK — all assertions passed**;
`node --check` clean on timeline.js, tlui.js, stagestub.js, timeline_test.mjs; Lane A's
`presets_test.mjs` / `stage_test.mjs` / `room3d_test.mjs` all still ALL PASS.
New blocks: renderPlan (1280x720 1280/720, garbage 1080p, `1920×1080` glyph form,
20s@30 600, audio passed through incl. gain, empty scene renders, 700s@30 throws with
'max 20000' AND '21000 frames'); syncFromStage (gizmo pos `[-3,0,1]` and fov 28 land in
`toJSON()`, e1 keeps BOTH original keys and its clip block, the frustum plate takes
`fitDist: 20` but keeps its transform byte-identical, an entity unknown to the model is
skipped not adopted, synced scene still round-trips); uploadEntities/saveMessage (all
four message cases); the cut model (0.01s replace at 30fps length stays 1 / camera c2,
1.0s appends, undo restores the replaced cut, removeCut+moveCut undo, and a seek
re-asserting the cut camera after an out-of-band `setActiveCamera`); rowFlags.
Three EXISTING blocks were adapted, not deleted, because behaviour changed on purpose:
the DIRECT block now asserts the auto first cut (`timeline_test.mjs:237-244`) and that a
second camera adds none; the MOVE block removes that cut explicitly to test moves in
isolation; the 3-camera auto-cut case asserts + removes it. Each adaptation FAILED
first on the new code and was checked, not silenced.
DECISIONS:
- **`_evalCameraCuts` does NOT use `?? this._activeCam`** as the brief wrote it: a stage
sitting on the director cam reports `null`, and `null ?? staleId` would fall back to
the stale id reintroducing the exact desync the change fixes. It prefers the stage's
answer whenever the accessor exists, keeping `_activeCam` only as the fallback for a
Stage that predates it.
- **Known consequence of that**: adding a SECOND camera (dock sets it active for the
PiP) now loses the preview at the next scrub, because the edit still cuts to the
first. That is the intended "the edit wins" reading, and the M11 gesture
(double-click the Cameras row = cut to the camera you're looking through) is the
documented way to make it stick. Flagging it because it is a visible behaviour change.
- **`_applying` flag** rather than the brief's `stage.getEntity()` guard: during
`applyScene` the entity IS on the stage, so that guard can't tell a load from a live
add. The flag also silences the "is keyed" toast during a load. film.js's per-shot
`applyScene` gets it for free.
- **Toast wording**: `press ⏺ key in the inspector, or double-click its lane` the
brief's bare "⏺" now collides with the new `⏺ Render` button in the scene bar.
- **stagestub honesty (two fixes)**: `select()` now hands listeners the ENTITY, not the
id (real Stage: stage.js:458 an outliner reading `en.id` would have passed here and
broken live), and `activeCamera()` was added to match stage.js:587.
- `saveMessage` treats **absent `status`** as the network case that is the only state
where no response exists, so the local-copy claim can't be made by mistake.
- `renderPlan` returns `total` (unused by finalRender, which recomputes it) so the
refusal and the count on screen come from one place.
BLOCKED/REQUESTS:
- **@orchestrator browser verification owed for item 5.** I am instructed not to use
browser tools this session, so the click/highlight/toast behaviour of the outliner is
headless-verified only (`rowFlags` + code review). Please check live: click a names
row the stage selects it and the row grows a blue left border; add an ambient light
via a DIRECT lighting preset and confirm its row is now reachable; drag a KEYED entity
with W and confirm the toast; and that `⏺ Render` produces an mp4 with audio.
- **@orchestrator**: PLAN §4.1 still doesn't list `scene.beatGrid` or audio
`{in?, out?}` (carried from sessions 7-9).
- **@Lane C (nit)**: `GET scenes` gives `{name, mtime, duration}` the Load picker shows
all three. Nothing needed unless you want a scene's entity count in the list.
NEXT: nothing required. Backlog unchanged and still in order: (1) "nudge to grid" for
EXISTING keys/cuts; (2) a camera move is only editable as two keys dragging the pair
together; (3) box-select still only grabs keys, not clip/audio blocks or cuts; (4)
per-drag undo coalesce (a mousemove drag still pushes one entry per frame now true of
`moveCut` too); (5) film-level `audio[]` has no UI.
## 2026-07-28 session 11 (adversarial review of session 10 — 4 majors + 4 minors)
DONE: every blocker/major from the reviewer's verdict, plus all four minors. Each was
REPRODUCED headlessly first (scratch node scripts, not in the repo), fixed at the root,
and given a regression test that was checked RED against the old behaviour and green
after. No git, no browser, :8020 untouched (read-only curl confirms it already serves
the edits `/web/tlui.js` carries `renderRefusal`/`cutAction`).
**BLOCKER — `syncFromStage()` rewrote the AUTHORED rest pose of keyed entities**
(`timeline.js`, the `syncFromStage()` body). Repro: e1 keyed [0,0,0]@0 [5,0,0]@5, sun
intensity keyed 08; scrub to 5 + save rest becomes [5,0,0]/8; scrub to 2.5 + save
[2.5,0,0]/4. Root cause: `Stage.captureState()` returns `entityTransform(id)`, i.e. the
pose the TIMELINE just wrote through setTransform/setParam on the last evaluate so the
merge copied the timeline's own output back over the source of truth, and PLAN §4.1's
"rest pose (used when no keys)" plus save idempotence both died. FIX: the sync is now per
CHANNEL an entity with 1 transform key keeps its authored `transform`; a param NAME
that appears in `tracks.params` keeps its authored value while every other param on the
same entity still syncs. Unkeyed props/lights/plates are unchanged (that was the point of
session 10's item 2 and it still works).
**MAJOR — no way to cut to a second camera from the timeline** (`tlui.js` `_dbl`
new `_cutPick`/`_cutTo`, pure `cutAction()`). Repro: add cam1 (auto cut at 0), add cam2
(dock sets it active), `seek(1.0)` `stage.activeCamera()` is back to cam1, so the M11
gesture appended `{t:4, camera:'cam1'}` a literal no-op cut, the artefact the M10
amendment asked to be dropped. Root cause: the gesture read `stage.activeCamera()`, but
`_evalCameraCuts` *drives* that value at every seek, so it can only ever echo the edit.
FIX: the row ASKS. `_cutPick` opens a `tl-menu` of the scene's cameras (the one live at t
marked "(live here)"), one camera = no menu, none = a toast. `_cutTo` routes through pure
`cutAction(cuts, t, camId, fps)` `add` | `replace` (addCut's half-frame rule) | `remove`
(a cut here that changes nothing is deleted rather than re-stacked) | `noop` (says so).
`_evalCameraCuts` itself is unchanged "the edit wins" is still the model; there is now
an honest way to author the edit. The dock's preview is still transient by design.
**MAJOR — ⏺ Render and 🎬 Render film were not mutually exclusive** (`tlui.js`
`renderRefusal()`, `render()`, `_filmRender()`, `_filmBusySet()`, new `_busy()`/
`_barBusySet()`). Repro (fake `this`, no DOM): with `_filmBusy` true, `render()` reached
the plan; with `_rendering` true, `_filmRender()` reached the save+render. Two capture
loops on one `timeline.step()`, and whichever finished first called `stage.resume()` and
un-hid the gizmo for the rest of the other one's frames (SYNC7, again). FIX: one gate both
entry points consult, plus the whole scene bar goes dead during EITHER render (Save, New…,
Load, and the size select), `_toggle`/`_down`/`_dbl`/`_ctx`/`_key` refuse while busy
(`?` and Escape still work), and `_rendering` is claimed BEFORE the first await so two fast
clicks can't both pass.
**MAJOR — the cut lane's camera label was invisible** (`tlui.js`: exported `LANE_BG`,
`CUT_COLOR`, `LABEL_COLOR`, used by `draw()` and `_drawCuts`). It was painted in `#161a20`
the odd lane's own fill (1.00:1), `#13171d` on the even one (1.03:1). So session 10's
claim "the lane prints the camera's LABEL" was not observable. FIX: the label is the cut's
own amber `#e0af68`; the colours are exported constants so the fill and the text can't
drift apart again, and the test holds them to 3:1 WCAG contrast against BOTH lane fills.
**MINOR — moveCut had no half-frame rule and one undo entry per mousemove**
(`timeline.js` `settleCut()` + `collapseUndo()`, wired in `tlui.js` `_down`/`_up`).
Repro: 50 synthetic mousemoves left 50 undo entries and two cuts 4e-16s apart, one of
which `cutCameraAt` can never reach. FIX: `_down` marks the undo depth, `_up` settles the
dropped cut (absorbing anything inside half a frame, reported in a toast) and collapses
the whole drag to ONE entry which also closes backlog item (4) for keys, blocks and
audio drags. `group()` now delegates to `collapseUndo`, one definition of the collapse.
**MINOR — save() reported a filename the server did not use** (`tlui.js` `save()`).
Repro: POST `scenes/My%20Scene` server writes `my-scene.json`, answers `{"name":
"my-scene"}`, toast said `saved "My Scene"`. FIX: the stem from the response body is what
the toast reports, and it is written back into the name field + `scene.name`, so the bar
matches the Load picker and the next save writes the same file.
**MINOR — audio was validated only after every PNG had been uploaded** (`tlui.js`
pure `audioProblems()` + `_audioMissing()`, called by `render()`). FIX: everything
`resolve_audio` refuses without the filesystem (missing/non-string path, non-numeric
start/gain/in/out, negative start/in, out in) is refused client-side like the frame cap,
and a HEAD per distinct path against `assets/file?path=` catches a moved/deleted file
before frame 1. Fails OPEN on a network error the server keeps the last word.
**MINOR — stale file:line pointers in comments**: every cross-lane citation in my files
now names a SYMBOL instead of a line (`stage.js setTransform`, `stage.js select()`,
`server.py render_frame`, `server.py validate_scene`, PLAN §4.1). Lines drift when another
lane edits; symbols don't. Same rule applied to this log entry.
TEST: `node scenegod/web/timeline_test.mjs` **OK — all assertions passed** (exit 0);
`node --check` clean on timeline.js / tlui.js / stagestub.js / timeline_test.mjs; Lane A's
presets_test / stage_test / room3d_test and Lane C's scripts/test_render_client.mjs all
still pass. New/changed blocks: playhead-independent save (two saves at different times
byte-identical, keyed transform + keyed param preserved, unkeyed colour on the same light
still synced); the `syncFromStage` block was RE-POINTED at a new unkeyed `crate` prop
the old assertion "the gizmo pose is what gets saved" was made on e1, which HAS two keys,
i.e. the suite encoded the bug as intended behaviour; renderRefusal + both wiring
directions through the prototype + `_filmBusySet` taking the bar out; audioProblems (8
cases) + the two refusal wirings incl. the gate being released; cutAction (8 branches),
`_cutTo` end-to-end after a scrub, `_cutPick`'s one-camera and no-camera branches; the
contrast check; settleCut/collapseUndo + the `_up` drag it exists for.
DISCRIMINATION PROVEN each fix was temporarily reverted and the suite went red on the
intended assertion: rest-pose sync "a keyed entity keeps the AUTHORED rest pose";
keyed-param sync "two saves byte-identical"; render gate "⏺ Render never even plans
while a film render runs"; bar lockout "a film render disables the whole scene bar";
audio pre-flight "the render must not start"; `_cutTo` guard "asking for the camera
already live adds NO dead cut"; label colour "must read against lane fill #13171d (got
1.03:1)"; settleCut/collapseUndo "head trim is a single undo entry" (an EXISTING
assertion, via group()); `_up` wiring "mouseup settles the dropped cut"; save name
"the toast names the file the server actually wrote". All restored green afterwards.
DECISIONS:
- **Per-channel, not per-entity, sync.** Skipping a whole entity that has any keys would
have thrown away the unkeyed inspector edits on the same object (a keyed light's colour,
a keyed camera's non-animated params). The keyed CHANNEL is the thing the timeline owns.
- **A keyed param with no authored rest value is DELETED from `params`, not frozen at the
live lerp.** Freezing would invent a rest value the user never typed; deleting keeps the
file honest and the key still drives it at evaluate time.
- **Ask, don't guess, for the cut camera.** Any "smart" reading of intent here is a guess
about which camera the director means; the menu is one extra click and can't be wrong.
The `remove` branch exists because a cut that changes nothing is not an edit.
- **The bar dies during a render, the canvas gate is separate.** Disabling DOM controls
can't stop Space or a canvas drag, so `_busy()` guards the handlers as well.
- **The audio existence check fails OPEN.** A wobbling network must never block a render
that would have worked; the server still validates.
- **Colours are exported constants.** The bug was a literal duplicated between the fill and
the text; the test asserts a contrast ratio, not a hex, so a future palette change is
free but an invisible one is not.
BLOCKED/REQUESTS:
- **@orchestrator browser verification still owed** (same as session 10, plus this
round): the Cameras-row picker (double-click with 2+ cameras menu cut lands and the
label is now READABLE amber), Render and 🎬 Render film refusing each other with the
scene bar greyed out, and the outliner items from session 10. All headless here.
- **@orchestrator** logs/laneB.md session 10 claims "double-click the Cameras row is the
documented way to make [a second camera] stick". That was wrong after any scrub; this
session replaces it with the picker. Flagging it since the log is append-only.
- **@orchestrator** PLAN §4.1 still doesn't list `scene.beatGrid` or audio `{in?, out?}`
(carried from sessions 7-10).
NEXT: nothing required. Backlog: (1) "nudge to grid" for EXISTING keys/cuts; (2) a camera
move is only editable as two keys dragging the pair together; (3) box-select still only
grabs keys, not clip/audio blocks or cuts; (4) film-level `audio[]` has no UI; (5) the
`_cutPick` menu is the only untested-by-node path in the new work (needs DOM) worth a
look at SYNC.
## 2026-07-28 session 12 (round-2 adversarial review — 2 majors + 6 minors)
DONE: both remaining MAJORs and ALL SIX remaining minors. Every one was REPRODUCED
first the two majors against a real uvicorn I started on port **8097** (my own,
killed afterwards; :8020 never touched, read-only curl confirms it already serves
these edits) and headless node repros in scratch, none in the repo. No git, no
browser.
**MAJOR — the audio existence pre-flight could never fire** (`tlui.js`
`_audioMissing`). REPRO, live, against this repo's server.py: `HEAD
/assets/file?path=audio/test/vo.wav` **405, `allow: GET`**; `HEAD …nope.wav`
**405**; GET 404; the previous implementation run verbatim against that server
returned `[]` for a file that does not exist. ROOT CAUSE: FastAPI's `APIRoute`
registers ONLY the methods you decorate unlike starlette's own `Route` it does
not add HEAD to a GET route so `@app.get("/assets/file")` answers every HEAD
with 405 and `r.status === 404` was unreachable. The session-11 claim "a HEAD per
distinct path catches a moved/deleted file" was false in production. FIX: a real
GET with `Range: bytes=0-0` the method the route answers and the body is
cancelled instead of streamed, so proving a 40MB stem exists costs one byte
(measured: 206, content-length 1). Still fails OPEN. Same function against the
live server now returns `['audio/nope.wav']` and passes the file that is there.
**MAJOR — the render mutex still raced** (`tlui.js` `_filmRender`, `render`).
REPRO (scratchpad, both real prototypes on a fake `this`, `_filmSave` held open):
🎬 ran its gate, awaited the save, and during that round-trip `_rendering`/
`_filmBusy` were both false and the bar was live a click passed
`renderRefusal`, called `tl.pause()` and started capturing; the film then set its
flag and started a second capture on the same clock. ROOT CAUSE: the flag was
claimed two awaits late. FIX: `_filmBusySet(true)` is now claimed SYNCHRONOUSLY
before the first await and released in a `finally` that covers every exit
(including "the save failed", which used to `return` with the gate held in the
new shape); `_filmRender`'s gate now reads BOTH flags through `renderRefusal` and
says which render is in the way instead of returning silently; `render()`'s
release moved out of a hand-written early-return into the same `finally`, and its
two synchronous reads (`tl.time`, the button label) now happen BEFORE the claim so
a throw there cannot strand a permanently-"rendering" app.
**MINOR — the saved FILE still carried the un-slugged name** (`tlui.js` `save`).
REPRO live: POST `scenes/My%20Scene` `{"name":"my-scene"}` and `my-scene.json`
containing `"name": "My Scene"` (server.py `scene_save` stores the body verbatim),
so every Load put the mismatch back. FIX: when the server's stem differs from what
we sent, the stem goes into the model/field AND one corrective re-POST rewrites the
file. Idempotent (`slugify(slug) === slug`) so it converges in exactly one extra
request, and only when the name needed slugging. Verified live: bar, picker and
stored `name` all read `round-two-scene`.
**MINOR a keyed param with no authored rest value was DELETED, and an inspector
edit to one vanished in silence** (`timeline.js` `syncFromStage`, `_evalParams`).
FIX (reverses last session's DECISION, which was wrong): the rest value falls back
to the FIRST KEY's value that is exactly what `evaluate` holds for t before it,
so it is authored, playhead-independent and invents nothing, and a keyed camera no
longer saves with no `fov` in `params` at all. For the silence: `_evalParams` now
records what the timeline itself last wrote per `id::key` (`_drove`), so
syncFromStage can tell its own lerp (say nothing) from a human's inspector edit
(say that it will not stick, and where to key it). dock.js `_param` calls
syncFromStage directly, so this is the exact line where the edit dies.
**MINOR — `cutAction` could MANUFACTURE the dead cut it exists to prevent**
(`tlui.js` `cutAction`, `_cutTo`). REPRO: cuts [{0,cam1},{4,cam2}], cut at 0 to
cam2 'replace' [{0,cam2},{4,cam2}]; it only ever looked BEFORE t. FIX: after
every branch the effective camera from t onward is camId, so the next cut is dead
iff it names camId reported as `dead`, but only when it was a real angle change
BEFORE the edit (`wasEff`), so a pre-existing dead cut is left alone rather than
silently deleted. `_cutTo` drops it inside ONE `group()` with the cut itself and
names it in the toast. Same rule now covers add/replace/remove.
**MINOR — the camera picker was invisible to Escape and leaked its listener**
(`tlui.js` `_cutPick`, new `_pickClose`, `_key`). The menu was a local `const`;
`_cutMenuClose` closes `this._cutMenu`, the AUTO-CUT panel a different menu.
FIX: tracked on `this`, one close path that also removes the document mousedown
listener, Escape wired to it, and opening a second picker closes the first.
**MINOR — two claimed wirings were asserted nowhere** (`_undoMark` in `_down`, the
`_busy()` gates). FIX: the drag test now goes through the REAL `_down` instead of
hand-setting `_undoMark`, so deleting that line takes the "one undo entry per drag"
assertion down with it; `_down`/`_dbl`/`_ctx` are called with a `_local` that throws.
**MINOR — director/clip-drop events mutated the scene mid-capture**
(`timeline.js` new `_locked`, `tlui.js` `_barBusySet`). `_barBusySet` can only
disable controls inside `$bar`; Lane A's DIRECT panel and the dock dispatch window
events straight at timeline.js. FIX: `_barBusySet` the one choke point BOTH
render paths go through sets `tl.locked`, and `scenegod:direct`,
`scenegod:clipdrop` and `scenegod:capturekey` refuse (out loud) while it is set.
TEST: `node scenegod/web/timeline_test.mjs` **OK — all assertions passed** (exit 0);
`node --check` clean on timeline.js / tlui.js / stagestub.js / timeline_test.mjs;
Lane A's presets/room3d/stage tests and Lane C's test_render_client.mjs still pass.
DISCRIMINATION PROVEN, mechanically: `scratchpad/rr2/discriminate.py` copies web/ +
templates/ + scenes/ to a scratch tree, breaks ONE fix at a time (17 of them) and
runs the suite. Baseline GREEN all 17 RED on the intended assertion restored
GREEN. e.g. HEAD restored "a moved/deleted audio file is actually DETECTED";
claim moved back after the save "the film claims the gate BEFORE its first
await"; `dead` forced null "the 4s cut is now dead and is named"; `_undoMark`
line deleted "mousedown marks the undo depth"; `delete next[k]` restored "an
unauthored keyed param rests at its FIRST key".
DECISIONS:
- **A one-byte ranged GET, not "drop the pre-flight".** The check is worth keeping
(a moved VO otherwise costs a whole render), it just has to speak a method the
route answers. Asking for `bytes=0-0` and cancelling the body keeps it as cheap
as the HEAD was meant to be.
- **Claim synchronously, release in `finally`.** Every "reset the flag on this
early-return" line is a future leak; there is now exactly one release per render
path and no exit that skips it.
- **The first key is the rest value.** Last session I argued deletion "keeps the
file honest" it doesn't, it drops the only fallback the entity has if the keys
are later removed. The first key is authored data and does not move with the
playhead, which was the actual property worth protecting.
- **Only drop a dead cut MY edit killed.** Cleaning up cuts that were already
redundant would be deleting something the user never asked me to touch.
- **`locked` lives on the model.** DOM disabling stops at `$bar`; anything that can
mutate the scene from outside it has to be able to ask.
BLOCKED/REQUESTS:
- **@orchestrator browser verification still owed** (carried from sessions 10-11,
plus this round): the Cameras-row picker closing on Escape and dropping a dead
following cut; vs 🎬 refusing each other with the bar greyed; a DIRECT preset
clicked mid-render toasting instead of keying; a render refused up front when an
audio file has been moved.
- **@Lane C (nit, not a blocker)**: `@app.get("/assets/file")` 405s on HEAD. My
check no longer needs it, but a plain `@app.head` (or `methods=["GET","HEAD"]`)
would be the conventional shape for a static-file route if you ever want it.
- **@orchestrator** PLAN §4.1 still doesn't list `scene.beatGrid` or audio
`{in?, out?}` (carried from sessions 7-11).
NEXT: nothing required. Backlog: (1) "nudge to grid" for EXISTING keys/cuts; (2) a
camera move is only editable as two keys dragging the pair together; (3)
box-select still only grabs keys, not clip/audio blocks or cuts; (4) film-level
`audio[]` has no UI.