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>
75 KiB
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 sonodecan run it).load/toJSONlossless 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,seekclamp,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).capturekeywindow listener present. Clips needawait 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 tostage.entities()viaonChange.stagestub.js— full §4.2 Stage as plain objects; records effects intostage.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 creatingweb/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, thenload()a 2-entity scene → rows now appear, scene bar updates). - SYNC1 #4 clipdrop:
scenegod:clipdrop {id,path,clipIndex}→addClipDropdrops a block at the playhead,out = clip.duration(from preload), autofade:0.25where 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.fadeso 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 atimeline.addEntity(desc)(or the dock callsstage.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.
- Absorbed
_mirror(orchestrator's seam) — reviewed; it upserts dock-added stage entities intoscene.entitieswith empty tracks and drops them whenstage.getEntity(id)===null. Fixed the STUB to make removal testable:removeEntitynow firesonChangeWITH the removed entity object (was firingnull, 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. 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 — plainscene.duration = xskipped the bar refresh. Test asserts clamp + key retention + notify.- Snap-increment select (frame / 0.1 / 0.25 / 1s) in the scene bar;
_snaprounds to the chosen step (Alt still = free). Keyable-param add: dblclick a params lane captures the entity's current param values as keys (skipstype/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.
_mirrorseedstracks.params=[](truthy), which was giving every mirrored character an empty params lane; tightened totracks?.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.
- 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 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.
- 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.
- 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>0guard 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; agit 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.
scenegod:directlistener (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). Newgroup(fn)collapses every mutator push inside one op into ONE undo entry — one Ctrl+Z reverts a whole preset.importRhubarbis 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,…}— notransform:{}wrapper), and light is{sun:{color,intensity}, ambient:{…}, bg, sunId, ambientId}(per-entity values, not a flatparamsmap; 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.- 🗣 button on every audio row (tlui) →
GET /rhubarb?path=→importRhubarb(charId, json, clip.start). Character picker (_choosepromise-menu) when >1 viseme-bearing character, guard toast when 0,_toastshows the 503 install hint from FastAPIdetail. Viseme lane resyncs after import. - 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). - M6:
_evalVideoin evaluate for backdrop/screen entities via new nullable stage seamentityVideo(id):currentTime = max(0, t-videoStart) % durationONLY 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.
- 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 viatl.hasContent()→confirm(), thenGET templates/{name}→tl.applyScene(json),_decodeAll(),draw(). Any failure (fetch, !ok, apply) toasts through_toast(window.sgToast still points at it). ?shortcuts overlay (tlui.js:633_toggleHelp/_helpClose, CSS.tl-helpat 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).- Backlog: audio HEAD trim — dragging an audio block's LEFT edge now eats
into the source (
inandstartmove 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 beforeout); tluiaedgehits now carryside('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; andhasContent()(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,templatekey stripped, caller's json not mutated), a loop that applies EVERY shippedtemplates/*.jsonthrough applyScene against the stub ("3 shipped templates applied clean"), and head-trim (pair + single undo + both clamps).node --checkclean on timeline.js, tlui.js, stagestub.js, timeline_test.mjs. DECISIONS:
tlui.load()now routes throughtl.applyScenetoo, 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).applyScenedeletes thetemplatekey 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 plainconfirm()— a modal I'd have to build is not more honest than the browser's._keynow 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 /templatesreturns thumbs that are ASSET-relative (backdrops/video/scenegod_plates/*.jpg) — I render them viaassets/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/outat 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 atin. 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 purebeatGridFrom(res, offset)export (:678) that maps Lane C's/beatsresponse into the stored shape. - UI (
tlui.js):♪button on every audio row next to🗣(:~250,_detectBeatsat:775) —GET beats?path=→ cache per path (this._beatCache) →setBeatGrid→ snap flips tobeatand 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 throughtl.snapToGridand falls back to frames when no grid exists (plus a toast if you pick beat/bar with nothing detected). Grid drawing:_drawBeatGrid(:365) — beatsrgba(146,180,232,0.13), downbeatsrgba(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
_snapwas 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 abeatGridscene (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/downbeatsstay in FILE seconds exactly as Lane C returns them (so the stored grid is comparable with a re-fetch) andoffsetrecords where the file's t=0 sat. The EFFECTIVE offset is re-derived from the audio clip with the samepathwhenever one is still on the timeline — drag the audio block and the grid comes with it; delete the clip and the storedoffsetkeeps it working. - The grid extrapolates at constant tempo outside the analysed range, in
BOTH
gridTimes(drawing) andsnapToGrid, 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.jsgot the purebeatGridFrominstead, so the response→storage contract is testable headless without stubbingfetchfor a three-line call. hasContent()deliberately ignoresbeatGrid: 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'simport('./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):
/beatson 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 arange:[from,to]field without breaking anything. - Lane C (still open, third session running): nothing new — audio
in/outat 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-levelaudio[]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_rowsuses).autoCutPlan(opts)(:647) — PURE preview:{ok, cuts, replaced, kept, cameras, cadence, barPeriod, confidence, bpm, from, to}or{ok:false,error}. Cuts come straight out ofgridTimes('bar')(so a placed t IS a grid downbeat, not a rounded neighbour);bars.filter((_,i)=>i%cadence===0); cameras cyclecams[(i+phase)%n].autoCut(opts)(:687) — applies insidegroup()→ ONE undo;throws 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 literallyautoCutPlan()— 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 placedtis===a member ofgridTimes('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 priorcameraCuts(deepEqual against a pre-snapshot) and the stack grows by exactly 1; re-running replaces its own cuts (4 cuts stay 4, alltunique, 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 leavecameraCutsand the undo stack empty; an empty range refuses; the plan follows the audio when the block is dragged. Also run against the REALscenes/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 viascenes/{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 eachin/outto the nearest downbeat, never reorders, and returns{shots, skipped}. A shot that would collapse is returned BYTE-IDENTICAL and listed inskipped; 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 > infor 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/snapToGridare 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
keptand 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 atcams[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
cameraCutsthe 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, sob - barlands 48µs off a real downbeat. "Lands on a downbeat" has to mean the real one, and the test asserts membership at 1e-6. autoCutTHROWS on refusal instead of returning a flag: the caller already hasautoCutPlan()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.beatGridor audio{in?, out?}, both in live use since M9 (carried from last session). - Lane C (nice-to-have):
GET scenesreturns{name, duration}but not whether a scene HAS a beatGrid. B2 has to fetch whole scene JSONs one at a time to find one. AhasGrid/bpmfield 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-levelaudio[]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 intoapplyMove(d)— the existing shot/ light/mark branches are untouched, so nothing that worked yesterday moved.applyMove(d)(:573): onegroup()⇒ ONE undo containing_clearMoveWindow→ transform key att0(from) → transform key att0+dur(to) →fovparam 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).scalecomes 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 andfovparam 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 (adofkey) 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_evalCameraCutsso 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'skept/droppedline. - The cut IS available, as an explicit opt-in:
cut: trueon the payload addsaddCut(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", butindex.html:62wireswindow.timeline, so Lane A's_playhead()IS my playhead; honouring the field also keeps an explicit/directort0working. 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 onfrom), 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-treplacement would have covered the exact case, but not a key 3ms away from a re-snappedt0. - 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
cutcheckbox in the MOVE segment of the DIRECT bar → putcut: trueon themovepayload. The model already honours it (one undo, cut att0). 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 pastscene.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.beatGridor 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-levelaudio[]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:
_evalCameraCutsdoes NOT use?? this._activeCamas the brief wrote it: a stage sitting on the director cam reportsnull, andnull ?? staleIdwould fall back to the stale id — reintroducing the exact desync the change fixes. It prefers the stage's answer whenever the accessor exists, keeping_activeCamonly 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.
_applyingflag rather than the brief'sstage.getEntity()guard: duringapplyScenethe 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-shotapplyScenegets it for free.- Toast wording:
press ⏺ key in the inspector, or double-click its lane— the brief's bare "⏺" now collides with the new⏺ Renderbutton in the scene bar. - stagestub honesty (two fixes):
select()now hands listeners the ENTITY, not the id (real Stage: stage.js:458 — an outliner readingen.idwould have passed here and broken live), andactiveCamera()was added to match stage.js:587. saveMessagetreats absentstatusas the network case — that is the only state where no response exists, so the local-copy claim can't be made by mistake.renderPlanreturnstotal(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⏺ Renderproduces an mp4 with audio. - @orchestrator: PLAN §4.1 still doesn't list
scene.beatGridor audio{in?, out?}(carried from sessions 7-9). - @Lane C (nit):
GET scenesgives{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 ofmoveCuttoo); (5) film-levelaudio[]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
removebranch 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.beatGridor 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-levelaudio[]has no UI; (5) the_cutPickmenu 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-0and 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.
lockedlives 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(ormethods=["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.beatGridor 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-levelaudio[]has no UI.