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>
1059 lines
75 KiB
Markdown
1059 lines
75 KiB
Markdown
# 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 1–64× 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 response→storage 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 cam2→cam2, 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.85→26.99 (mid 32.42); truck 0.540m; orbit 0.698m;
|
||
crane 0.663m. Lane A's `presets_test.mjs`, `stage_test.mjs`, `room3d_test.mjs`
|
||
all still ALL PASS; `node --check` clean on all four files I touched.
|
||
DECISIONS:
|
||
- **No cut, per the orchestrator's call — but the silence is disclosed.** Since
|
||
nothing cuts to the moved camera, a move on a camera the edit never selects
|
||
renders as nothing. `cutCameraAt(t)` (`timeline.js:404`, extracted from
|
||
`_evalCameraCuts` so there is one definition of "which camera is live") lets
|
||
the toast add "note: cam1 is live here — no cut selects this one". Disclosure,
|
||
not a decision — same instinct as auto-cut's `kept`/`dropped` line.
|
||
- **The cut IS available, as an explicit opt-in**: `cut: true` on the payload
|
||
adds `addCut(t0, camId)` inside the same undo. No default, no checkbox in my
|
||
UI because the MOVE segment is Lane A's — see BLOCKED/REQUESTS.
|
||
- **Double-clicking the Cameras row now cuts to the camera you are LOOKING
|
||
THROUGH** (`tlui.js:597`, `stage.activeCamera()` first, old cut-derived
|
||
fallback kept). Without it the obvious "I moved cam2, now cut to it" gesture
|
||
silently cut to the camera the OLD edit had live — the one mistake a cut must
|
||
not make. This is the hand-affordance that makes "no automatic cut" liveable.
|
||
- **`t0` = `d.t0 ?? this.time`.** Lane A offered "prefer your own playhead if
|
||
they ever disagree", but `index.html:62` wires `window.timeline`, so Lane A's
|
||
`_playhead()` IS my playhead; honouring the field also keeps an explicit
|
||
`/director` `t0` working. If the wiring ever breaks, moves would pile up at 0
|
||
— that's the symptom to look for.
|
||
- **Bar snaps the END, beat does not.** A bar is where an edit lands; quantising
|
||
every move length to ~0.47s would be noise, not music. Both snap `t0`.
|
||
- **The playhead follows the snap** (`seek(t0)`): the viewport then shows the
|
||
move's real first frame (Lane A already put the camera on `from`), and "why did
|
||
it land there" has a visible answer instead of a silent 0.6s shift.
|
||
- **Endpoints are inside the replace window.** Pressing the same move twice must
|
||
land on its own keys, not beside them — `addKey`'s same-`t` replacement would
|
||
have covered the exact case, but not a key 3ms away from a re-snapped `t0`.
|
||
- ponytail (noted in code, `:569`): two keys ⇒ position LERPS, so an orbit
|
||
travels the chord, not the arc. Measured 3.8cm short of the 1.611m radius at
|
||
the midpoint of Lane A's ±25° default (invisible), 47cm on a 90° orbit
|
||
(visible corner-cutting). Intermediate keys only if wide orbits are asked for.
|
||
BLOCKED/REQUESTS:
|
||
- **@Lane A (small)**: a `cut` checkbox in the MOVE segment of the DIRECT bar →
|
||
put `cut: true` on the `move` payload. The model already honours it (one undo,
|
||
cut at `t0`). Default OFF, per the orchestrator's call.
|
||
- **@Lane A (nice-to-have)**: `moveDuration()` caps at MOVE_MAX_DUR 6s; a
|
||
bar-snapped end can extend a move up to half a bar past that (max ~+0.94s at
|
||
128 BPM) so it lands on a downbeat. The hard clamp (never past
|
||
`scene.duration`) is re-applied here at the snapped start. Say if 6s is meant
|
||
to be a hard ceiling and I'll clamp the snap down a bar instead of up.
|
||
- **@orchestrator**: PLAN §4.1 still doesn't list `scene.beatGrid` or audio
|
||
`{in?, out?}` (carried from sessions 7-8). Nothing new needed for moves — they
|
||
are ordinary transform/params keys, exactly as Lane A predicted.
|
||
NEXT: nothing required. Backlog, in order: (1) "nudge to grid" for EXISTING
|
||
keys/cuts (moves and new drags are musical, old keys still aren't); (2) a move
|
||
is only editable as two keys — dragging the pair together (or a "move block"
|
||
overlay on the transform lane) is the obvious next UX step; (3) box-select still
|
||
only grabs keys, not clip/audio blocks or cuts; (4) per-drag undo coalesce;
|
||
(5) film-level `audio[]` has no UI.
|
||
|
||
## 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 0→8; 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.
|