SCENEGOD/logs/laneB.md
type-two 16eb96a60e [m7-B] New-from-template picker + ? shortcuts overlay (M7 complete)
Lane B: New... in the scene bar fetches the templates API and applies through
a new single apply path (applyScene = stage.applyState + load + preload, now
shared with Load), confirm-before-clobber via hasContent(), thumbs, toasts on
failure. ? overlay documents 24 real bindings read out of the code across
STAGE/TIMELINE/SCENE BAR, ignores ? while typing. Audio head-trim (left edge
eats into the source, one grouped undo). applyScene strips the template key so
saved scenes stay plain scene JSON.

Orchestrator SYNC-9 fix: help overlay dt column was auto-width + nowrap, so
long terms overflowed and the three columns overlapped visually.

Verified live: picker lists 3 (2 thumbs), no confirm when empty / confirms when
dirty, loads 7 entities + 2 cuts with bar refresh, no template key in toJSON,
overlay opens on ? and Escape closes, no hijack while typing. 5 suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 22:47:29 +10:00

21 KiB
Raw Blame History

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.mjsnode 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 in the name column, drag = offset (moveAudio), right-click = delete. 🎵 button reads /assets/tree audio category into a small floating menu → addAudio at the playhead.
  2. Web Audio draft playback synced to the master clock: lazy AudioContext, decode+cache buffers (/assets/file), _audioStart schedules AudioBufferSourceNodes at (start - time) with per-clip gain, _audioStop on pause/natural-end, user seeks reschedule (_seek helper). step()/render never touch audio — server muxes from scene.audio.
  3. morphs track kind: _evalMorphs lerps per viseme name → stage.setMorph; importRhubarb(id, json, t0) maps mouthCues → morph keys; tlui shows a "↳ visemes" lane guarded by stage.visemeTargets(id).length > 0. Model (timeline.js, stagestub.js, timeline_test.mjs) committed ba29d0c; tests cover morph lerp, rhubarb keys, audio mutators+undo. All green. VERIFIED (real server :8020, real assets): 🎵 picker listed + added audio/test/tone.wav, buffer decoded (8s), green block drawn, Web Audio scheduled/rescheduled/stopped (ctx running), viseme lane rendered importRhubarb keys, setMorph fired while scrubbing. Contract match confirmed: real stage.js ships setMorph(id,name,weight) + visemeTargets(id) (returns Object.keys(visemes)) — my .length>0 guard works against both. DECISIONS:
  • Audio duration is a client draw concern (decode+cache in tlui), NOT stored in the {path,start,gain} schema — server already knows durations at mux.
  • importRhubarb = simple per-cue ramp (shape 1 at cue start → 0 at end); hold/crossfade tuning deferred (ponytail marker in timeline.js) until it looks wrong on a real blendshape character.
  • Audio blocks can extend past scene.duration (tone.wav is 8s in a 6s scene) — left as-is; the ruler just clips visually. Trim is a later nicety. BLOCKED/REQUESTS:
  • Ownership note for orchestrator: my tlui.js M4-B changes (+146 lines) were committed inside Lane A's df4e06f [laneA] M4-A ..., not a [laneB] commit — Lane A's commit swept my then-uncommitted tlui.js working changes along with their dock.js/stage.js. Code is intact + verified; only the attribution is off. Heads-up that our git hygiene let a cross-lane file ride in on another lane's commit; a git add <own files> (not -A) avoids it. Nothing for me to fix. NEXT: M4-B complete. Open items if a session 5 lands: audio block trim/clip to duration, viseme import UI button (currently importRhubarb is API-only; Lane C has a /rhubarb endpoint — could wire a "lip-sync from audio" button that POSTs the clip and feeds the result to importRhubarb). Holding for SYNC/next orchestrator update.

2026-07-20 session 5 (M5 director-mode B + backlog, M6 video-follow)

DONE: All three M5 items + the M6 item, headless-verified.

  1. scenegod:direct listener (timeline.js ctor) → applyDirect(detail): shot → transform+fov keys on the camera at playhead + addCut; light → param keys; mark → transform key (walkTo → current/prevPos key at playhead + mark key at playhead+2s). New group(fn) collapses every mutator push inside one op into ONE undo entry — one Ctrl+Z reverts a whole preset. importRhubarb is now grouped too. Payload coordination: presets.js landed mid-session (untracked, Lane A in flight), so I read it — Lane A's REAL payloads differ from the PLAN-stated contract: values are spread at TOP level ({op:'shot', camId, pos, rot, fov,…} — no transform:{} wrapper), and light is {sun:{color,intensity}, ambient:{…}, bg, sunId, ambientId} (per-entity values, not a flat params map; bg keys onto the ambient entity; applyDirect ALSO captures the sun-boom transform from the stage since the payload lacks sunPos rot). applyDirect accepts BOTH shapes (contract + live); tests cover both.
  2. 🗣 button on every audio row (tlui) → GET /rhubarb?path=importRhubarb(charId, json, clip.start). Character picker (_choose promise-menu) when >1 viseme-bearing character, guard toast when 0, _toast shows the 503 install hint from FastAPI detail. Viseme lane resyncs after import.
  3. Backlog: (a) wheel fix — plain wheel is NOT captured (page scrolls); Cmd/Ctrl+wheel (incl. trackpad pinch) zooms 164× around the cursor, horizontal wheel pans when zoomed; all geometry through _t2x/_x2t so keys/blocks/playhead/hitboxes just work. (b) audio trim: drag the block's right edge → trimAudio(clip, {in,out}) (source-relative, like clip blocks; absent = untrimmed so the {path,start,gain} schema is unchanged for untouched clips); draft Web Audio playback honors in/out; ✂ badge on trimmed blocks; undo restores pristine (fields deleted, not undefined).
  4. M6: _evalVideo in evaluate for backdrop/screen entities via new nullable stage seam entityVideo(id): currentTime = max(0, t-videoStart) % duration ONLY when drift >50ms; play/pause follows the clock (pause() now re-evaluates so videos freeze); play() promise .catch()ed, never awaited — step() stays sync (render determinism is stage.syncVideos, Lane A/C). Files: timeline.js, tlui.js, stagestub.js (StubVideo records currentTime sets; entityVideo null for image backdrops — honest-nullable), timeline_test.mjs. node scenegod/web/timeline_test.mjs → all green (shot-via-real-EventTarget-dispatch + one-undo revert, generic + Lane A light shapes, walkTo 2 keys, Lane A mark shape, rhubarb group undo, trim undo, video drift-gate/modulo/videoStart-clamp/play-pause, nullable video). DECISIONS:
  • group() splices the undo stack rather than a begin/end transaction API — mutators stay dumb, nesting works, zero cost when fn pushes ≤1 entry.
  • Sun transform key on light presets comes from stage.entityTransform at event time (Lane A setTransforms before firing) — payload has sunPos but no rot; reading the stage gets both without a payload change.
  • Video seek target clamps negative (t < videoStart → hold frame 0); wrap uses plain % (t-videoStart ≥ 0 after clamp).
  • StubVideo lives in a stage-side Map, NOT on the entity object, so captureState/clone stays clean. BLOCKED/REQUESTS:
  • Lane A: walk-to-mark can't work from the current payload — applyMark setTransforms the subject BEFORE firing, so "current pos" at event time is already the mark. When you add a walk mark, either include prevPos (+ walkTo:true) in the detail (applyDirect already honors both) or skip the pre-apply for walk marks. Also: no walk mark exists in grammar.js MARKS yet.
  • Lane C: audio trim adds optional in/out (source-relative seconds) to scene.audio clips — mux should honor them (ffmpeg -ss/-t per clip); absent = whole file (schema unchanged for untrimmed clips). Client draft playback already honors them.
  • Orchestrator: PLAN §4.1 audio schema + §4.2 Stage API could gain the two seams now in live use: audio {in?, out?} and entityVideo(id) -> HTMLVideoElement|null (Lane A implements on real Stage for M6-A). NEXT: SYNC — browser-verify DIRECT panel button → keys+cut land + single undo; 🗣 on real vo.wav → viseme keys on a blendshape character (and the 503 toast with rhubarb absent); Cmd+wheel zoom feel; video backdrop scrub-follow once Lane A ships entityVideo. Holding at M6-B boundary.

2026-07-25 session 6 (M7-B: the way in — templates + shortcuts overlay)

DONE: both required items + one backlog item, all headless-verified.

  1. New from template (scene bar, left of Save/Load — tlui.js:122). _pickTemplate() (tlui.js:585) — GET templates → floating .tl-menu.tmpl (same pattern as the 🎵 picker) with title + description + thumb (assets/file?path=, img.onerror → text-only row). All URLs RELATIVE. newFromTemplate(name) (tlui.js:616): warn-before-clobber via tl.hasContent()confirm(), then GET templates/{name}tl.applyScene(json), _decodeAll(), draw(). Any failure (fetch, !ok, apply) toasts through _toast (window.sgToast still points at it).
  2. ? shortcuts overlay (tlui.js:633 _toggleHelp / _helpClose, CSS .tl-help at tlui.js:48). Opens with ? or the ? button (tlui.js:121), closes on Escape / click-outside / ✕. Three columns — STAGE, TIMELINE, SCENE BAR — written by READING the code, not from memory: stage.js:118-122 (W/E/R gizmo, selection-gated), dock.js drops, and every binding in my _key/_down/_dbl/_ctx/_wheel. Deliberately NOT listed: right-click on a clip block (only keys/cuts/audio blocks delete), left-edge trim on animation clip blocks (only the right edge is a hit target).
  3. Backlog: audio HEAD trim — dragging an audio block's LEFT edge now eats into the source (in and start move together so the audible part stays put) instead of being dead space. timeline.js:trimAudioIn (grouped: one undo for the pair, clamps at the file head, at t=0, and 0.05s before out); tlui aedge hits now carry side ('in' | 'out'). Model seams added to timeline.js: applyScene(json) (timeline.js:173) — stage.applyState → load → preload, the ONE apply path now shared by Load and New-from-template; and hasContent() (timeline.js:185). TEST: node scenegod/web/timeline_test.mjs → green, +5 new blocks: hasContent (empty / cuts-only / audio-only / dock-added entity), applyScene happy path (entities on stage, tracks in the model, onLoad once, camera live, clip preloaded and playing at t=1, template key stripped, caller's json not mutated), a loop that applies EVERY shipped templates/*.json through applyScene against the stub ("3 shipped templates applied clean"), and head-trim (pair + single undo + both clamps). node --check clean on timeline.js, tlui.js, stagestub.js, timeline_test.mjs. DECISIONS:
  • tlui.load() now routes through tl.applyScene too, so Load and templates cannot drift apart (the ask was "apply it the same way Load does" — made it literally the same code rather than a copy).
  • applyScene deletes the template key before loading. server.py:264 claims that metadata "never survives a Timeline round-trip" — it WOULD have, since load() structuredClones unknown fields on purpose (lossless round-trip is a tested feature). Stripping it in one place makes Lane C's comment true and keeps saved scenes plain scene JSON.
  • hasContent() counts ANY entity, cut or audio clip — a dock-added, not-yet-keyframed character is still work worth a confirm. A fresh page has zero entities (Stage creates none in its ctor), so the first New… never nags. Used plain confirm() — a modal I'd have to build is not more honest than the browser's.
  • _key now also ignores SELECT + contentEditable targets, so ? (and Space) can't be hijacked while the snap dropdown or any text field has focus.
  • The ? button is excluded from the overlay's click-outside handler, otherwise mousedown closed it and the click reopened it. BLOCKED/REQUESTS:
  • Lane C / orchestrator (nit): GET /templates returns thumbs that are ASSET-relative (backdrops/video/scenegod_plates/*.jpg) — I render them via assets/file?path=. If a future template ever ships a thumb that lives outside SCENEGOD_ASSETS, the picker will show a broken (auto-removed) image; keep thumbs inside the assets root.
  • Lane C (still open from session 5): audio in/out at ffmpeg mux. Head trim makes it much easier to hit — a head-trimmed clip now renders from the file start server-side while the draft playback starts at in. NEXT: nothing required. Remaining polish if a session 7 lands: box-select multi-drag refinements (marquee currently only selects keys — clip/audio blocks and cuts are not box-selectable), and a per-drag undo coalesce (a mousemove drag pushes one undo entry per frame; Ctrl+Z walks it back a pixel at a time). Holding at the M7-B boundary.