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

319 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Lane B log — append-only, newest at bottom
Format per session:
## <date> session N
DONE: …
DECISIONS: …
BLOCKED/REQUESTS: …
NEXT: …
## 2026-07-18 session 1
DONE: M1 complete. All files under `scenegod/web/`.
- `timeline.js` — model + clock, no DOM, no three import (self-contained
quat/euler/slerp math so `node` can run it). `load/toJSON` lossless
round-trip (unknown entity fields preserved via structuredClone + a
WeakMap for cached quats). `evaluate(t)`: transform (pos/scale lerp, rot
slerp→euler, easing incl. step-hold), params (num lerp, color sRGB lerp),
clip blocks (set-time exact scrubbing, fade-out ramp), camera cuts (fires
only on change). Clock: `play()` rAF deltas, `seek` clamp, `step(frame)`
pure/deterministic for Lane C render. Mutators addKey/moveKey/deleteKey/
addClipBlock/moveClipBlock/trimClipBlock/addCut + undo stack (`undo()`
wired; Ctrl+Z hooked in tlui). `capturekey` window listener present.
Clips need `await tl.preload()` before evaluate (prepareClip is async).
- `tlui.js` — canvas panel into `#timeline`. Ruler scrub, drag playhead,
space=play/pause, dblclick lane=add key from stage state, drag key (snap
to frame, Alt=free), right-click=delete, drag clip block/edge, Home/End,
Ctrl+Z. Scene bar name/dur + Save/Load (POST/GET `/scenes/{name}`,
localStorage fallback). Names column auto-syncs to `stage.entities()` via
`onChange`.
- `stagestub.js` — full §4.2 Stage as plain objects; records effects into
`stage.applied[]`. Dev-only, delete at SYNC 1.
- `timeline_test.mjs``node scenegod/web/timeline_test.mjs` → green
(midpoint interp, step-ease hold, clip local time @3 frames, camera flip,
lossless round-trip, unknown-field preservation).
- `tldev.html` — dev-only browser harness. Verified in browser: scrub moves
entities (interp live in log), play runs to duration + auto-stops, camera
cut flips camA→camB at t=3, keys/clips/cuts render. Delete at SYNC 1.
DECISIONS:
- Timeline imports NO three.js — inlined euler↔quat + slerp (~30 lines) so
the node self-check needs zero deps (there is no node_modules/three in
this stack). setTransform rot is euler (matches entityTransform format).
- One `<canvas>` for all lanes (not canvas-per-lane); hit-testing via a
hitbox list rebuilt each draw. Simpler, fast enough at this scale.
- CSS injected from tlui.js via a `<style id=laneB-tl-css>` rather than
creating `web/style.css` — that file is Lane A's and didn't exist yet.
Move into a `/* === LANE B === */` block at SYNC if John wants it
centralized. (See REQUEST below.)
- M1 crossfade = fade-OUT only; cross-block fade-IN is M2 "crossfade polish"
per the milestone list. Marked with a ponytail comment in timeline.js.
BLOCKED/REQUESTS:
- None blocking. Note for orchestrator/Lane A: my timeline styles live in
an injected `<style>` in tlui.js. If you'd rather they sit in style.css,
say so and I'll emit a `/* === LANE B === */` block for Lane A to paste
(I won't edit style.css directly — ownership).
- Lane A's files (index.html, stage.js, room3d.js, dock.js, style.css) are
present untracked in the shared working dir but uncommitted — I staged
ONLY my 5 files, left theirs alone.
NEXT: Hold at M1 boundary. At SYNC 1 the stub swaps for the real Stage
(one-line change in the page import) and tldev.html/stagestub.js get
deleted. M2 (locked): clip crossfade polish, params/cut lane UI, snapping
config, undo Ctrl+Z already stubbed, box-select multi-drag.
## 2026-07-18 session 2 (M2)
DONE: M2 per orchestrator update. SYNC findings fixed first, then polish.
- SYNC1 #3a/#3b: added `Timeline.onLoad(cb)`; `load()` fires it →
TimelineUI rebuilds rows + refreshes name/duration fields. Verified with
the exact repro (UI built against empty stage, then `load()` a 2-entity
scene → rows now appear, scene bar updates).
- SYNC1 #4 clipdrop: `scenegod:clipdrop {id,path,clipIndex}``addClipDrop`
drops a block at the playhead, `out = clip.duration` (from preload), auto
`fade:0.25` where it abuts a neighbour. Verified via dispatched event.
- Crossfade fade-IN (was fade-OUT only): a predecessor's fade pre-rolls the
successor's playback start earlier by `pred.fade` so local time is
continuous across the boundary; weights ramp 1→0 / 0→1 and sum to 1
(asserted in test).
- Params lanes (per-key hue), camera-cut lane add via dblclick on Cameras
row (uses active cam / first camera), snap toggle in scene bar (Alt still
= free), box-select + multi-drag + Delete of keys, selection highlight,
fade wedge drawn on clip blocks, panel grows to content height (was fixed
200px → clipped the Cameras row once params rows were added).
- timeline_test.mjs extended: onLoad fires once, addClipDrop out=duration,
crossfade weights sum to 1. All green.
DECISIONS / BUG:
- **tlui reads tracks from the TIMELINE, not the stage entity.** Found mid-
verify: `load()` structuredClones the scene, so timeline.scene.entities and
stage entities are SEPARATE objects. Drawing from stage meant clipdrop/
addKey (which mutate the timeline copy) never appeared. Repointed all draw
methods to `_te(id) = tl.scene.entities.find(id)`. Rows now built from the
timeline list merged with any stage-only (dock-added, not-yet-keyframed)
entities. Per PLAN the timeline owns tracks — this is the correct source.
- Grow-to-content height, no scroll yet (ponytail: add max-height+scroll only
if a scene ever has enough entities to dominate the viewport).
BLOCKED/REQUESTS:
- Gap for the orchestrator/Lane A to note: there's no bridge for a
dock-added stage entity to enter `timeline.scene.entities`, so a freshly
dropped character can't be keyframed until a scene load. tlui shows it as a
row (empty tracks) but mutators need the entity present. If Lane A's dock
should register new entities with the timeline, we need a
`timeline.addEntity(desc)` (or the dock calls `stage.captureState()`
`timeline.load`). Flagging for SYNC 2 — not building it uninvited.
- Verified with a throwaway harness (`_check.html`) served on :8099; deleted,
NOT committed (orchestrator removed the M1 tldev.html deliberately).
NEXT: Hold at M2. Remaining M2 polish if a session 3 is granted: undo already
wired to Ctrl+Z; could add snapping increment config + keyable-param add UI
(currently params keys come from Lane A capture/inspector). Await SYNC 2.
## 2026-07-18 session 3 (M2 polish)
DONE: All three orchestrator session-3 items.
1. Absorbed `_mirror` (orchestrator's seam) — reviewed; it upserts dock-added
stage entities into `scene.entities` with empty tracks and drops them when
`stage.getEntity(id)===null`. Fixed the STUB to make removal testable:
`removeEntity` now fires `onChange` WITH the removed entity object (was
firing `null`, so `_mirror(null)` early-returned and never removed).
Added timeline_test.mjs lifecycle case: dock-add → keyframe → remove,
asserting entity+empty-tracks appear then vanish.
2. `setDuration(x)` mutator: clamps the playhead into range, KEEPS keys past
the new end (lossless), notifies the UI (reuses onLoad → syncRows +
refreshBar). Wired the dur field to it — plain `scene.duration = x` skipped
the bar refresh. Test asserts clamp + key retention + notify.
3. Snap-increment select (frame / 0.1 / 0.25 / 1s) in the scene bar; `_snap`
rounds to the chosen step (Alt still = free). Keyable-param add: dblclick a
params lane captures the entity's current param values as keys (skips
`type`/boolean descriptors) — mirrors the transform-capture dblclick.
DECISIONS:
- params lane now shows only when the entity has ≥1 param key OR kind is
camera/light. `_mirror` seeds `tracks.params=[]` (truthy), which was
giving every mirrored character an empty params lane; tightened to
`tracks?.params?.length > 0 || camera|light`.
- setDuration reuses the onLoad callback list as its "refresh UI chrome" hook
rather than adding a second callback channel (lazy; onLoad handler already
does syncRows+refreshBar+draw).
BLOCKED/REQUESTS: none. Verified with a throwaway harness (`_check.html`,
served :8099, deleted, NOT committed). timeline_test.mjs green.
NEXT: M2 polish complete. Undo already wired (Ctrl+Z). Open for M4 timeline
work when unlocked (audio tracks on the timeline UI + Web Audio sync, per
PLAN §5 M4) or any SYNC 3 fixes. Holding until orchestrator flips M4.
## 2026-07-18 session 4 (M4-B: audio + visemes)
DONE: M4-B all three items.
1. Audio rows in tlui from scene.audio[]: one lane per clip below Cameras,
green block at the DECODED buffer width (fallback width until decoded),
gain <input> in the name column, drag = offset (moveAudio), right-click =
delete. 🎵 button reads /assets/tree audio category into a small floating
menu → addAudio at the playhead.
2. Web Audio draft playback synced to the master clock: lazy AudioContext,
decode+cache buffers (/assets/file), _audioStart schedules
AudioBufferSourceNodes at (start - time) with per-clip gain, _audioStop on
pause/natural-end, user seeks reschedule (_seek helper). step()/render
never touch audio — server muxes from scene.audio.
3. morphs track kind: _evalMorphs lerps per viseme name → stage.setMorph;
importRhubarb(id, json, t0) maps mouthCues → morph keys; tlui shows a
"↳ visemes" lane guarded by stage.visemeTargets(id).length > 0.
Model (timeline.js, stagestub.js, timeline_test.mjs) committed ba29d0c;
tests cover morph lerp, rhubarb keys, audio mutators+undo. All green.
VERIFIED (real server :8020, real assets): 🎵 picker listed + added
audio/test/tone.wav, buffer decoded (8s), green block drawn, Web Audio
scheduled/rescheduled/stopped (ctx running), viseme lane rendered
importRhubarb keys, setMorph fired while scrubbing. Contract match confirmed:
real stage.js ships setMorph(id,name,weight) + visemeTargets(id) (returns
Object.keys(visemes)) — my `.length>0` guard works against both.
DECISIONS:
- Audio duration is a client draw concern (decode+cache in tlui), NOT stored
in the {path,start,gain} schema — server already knows durations at mux.
- importRhubarb = simple per-cue ramp (shape 1 at cue start → 0 at end);
hold/crossfade tuning deferred (ponytail marker in timeline.js) until it
looks wrong on a real blendshape character.
- Audio blocks can extend past scene.duration (tone.wav is 8s in a 6s scene)
— left as-is; the ruler just clips visually. Trim is a later nicety.
BLOCKED/REQUESTS:
- **Ownership note for orchestrator**: my tlui.js M4-B changes (+146 lines)
were committed inside Lane A's `df4e06f [laneA] M4-A ...`, not a [laneB]
commit — Lane A's commit swept my then-uncommitted tlui.js working changes
along with their dock.js/stage.js. Code is intact + verified; only the
attribution is off. Heads-up that our git hygiene let a cross-lane file
ride in on another lane's commit; a `git add <own files>` (not `-A`) avoids
it. Nothing for me to fix.
NEXT: M4-B complete. Open items if a session 5 lands: audio block trim/clip to
duration, viseme import UI button (currently importRhubarb is API-only; Lane
C has a /rhubarb endpoint — could wire a "lip-sync from audio" button that
POSTs the clip and feeds the result to importRhubarb). Holding for SYNC/next
orchestrator update.
## 2026-07-20 session 5 (M5 director-mode B + backlog, M6 video-follow)
DONE: All three M5 items + the M6 item, headless-verified.
1. `scenegod:direct` listener (timeline.js ctor) → `applyDirect(detail)`:
shot → transform+fov keys on the camera at playhead + addCut; light →
param keys; mark → transform key (walkTo → current/prevPos key at
playhead + mark key at playhead+2s). New `group(fn)` collapses every
mutator push inside one op into ONE undo entry — one Ctrl+Z reverts a
whole preset. `importRhubarb` is now grouped too.
**Payload coordination**: presets.js landed mid-session (untracked, Lane
A in flight), so I read it — Lane A's REAL payloads differ from the
PLAN-stated contract: values are spread at TOP level (`{op:'shot', camId,
pos, rot, fov,…}` — no `transform:{}` wrapper), and light is
`{sun:{color,intensity}, ambient:{…}, bg, sunId, ambientId}` (per-entity
values, not a flat `params` map; bg keys onto the ambient entity;
applyDirect ALSO captures the sun-boom transform from the stage since the
payload lacks sunPos rot). applyDirect accepts BOTH shapes (contract +
live); tests cover both.
2. 🗣 button on every audio row (tlui) → `GET /rhubarb?path=`
`importRhubarb(charId, json, clip.start)`. Character picker (`_choose`
promise-menu) when >1 viseme-bearing character, guard toast when 0,
`_toast` shows the 503 install hint from FastAPI `detail`. Viseme lane
resyncs after import.
3. Backlog: (a) wheel fix — plain wheel is NOT captured (page scrolls);
Cmd/Ctrl+wheel (incl. trackpad pinch) zooms 164× around the cursor,
horizontal wheel pans when zoomed; all geometry through _t2x/_x2t so
keys/blocks/playhead/hitboxes just work. (b) audio trim: drag the block's
right edge → `trimAudio(clip, {in,out})` (source-relative, like clip
blocks; absent = untrimmed so the {path,start,gain} schema is unchanged
for untouched clips); draft Web Audio playback honors in/out; ✂ badge on
trimmed blocks; undo restores pristine (fields deleted, not undefined).
4. M6: `_evalVideo` in evaluate for backdrop/screen entities via new nullable
stage seam `entityVideo(id)`: `currentTime = max(0, t-videoStart) %
duration` ONLY when drift >50ms; play/pause follows the clock (pause()
now re-evaluates so videos freeze); play() promise .catch()ed, never
awaited — step() stays sync (render determinism is stage.syncVideos,
Lane A/C).
Files: timeline.js, tlui.js, stagestub.js (StubVideo records currentTime
sets; entityVideo null for image backdrops — honest-nullable),
timeline_test.mjs. `node scenegod/web/timeline_test.mjs` → all green
(shot-via-real-EventTarget-dispatch + one-undo revert, generic + Lane A
light shapes, walkTo 2 keys, Lane A mark shape, rhubarb group undo, trim
undo, video drift-gate/modulo/videoStart-clamp/play-pause, nullable video).
DECISIONS:
- group() splices the undo stack rather than a begin/end transaction API —
mutators stay dumb, nesting works, zero cost when fn pushes ≤1 entry.
- Sun transform key on light presets comes from stage.entityTransform at
event time (Lane A setTransforms before firing) — payload has sunPos but
no rot; reading the stage gets both without a payload change.
- Video seek target clamps negative (t < videoStart hold frame 0); wrap
uses plain % (t-videoStart 0 after clamp).
- StubVideo lives in a stage-side Map, NOT on the entity object, so
captureState/clone stays clean.
BLOCKED/REQUESTS:
- **Lane A**: walk-to-mark can't work from the current payload applyMark
setTransforms the subject BEFORE firing, so "current pos" at event time is
already the mark. When you add a walk mark, either include `prevPos` (+
`walkTo:true`) in the detail (applyDirect already honors both) or skip the
pre-apply for walk marks. Also: no walk mark exists in grammar.js MARKS yet.
- **Lane C**: audio trim adds optional `in`/`out` (source-relative seconds)
to scene.audio clips mux should honor them (ffmpeg -ss/-t per clip);
absent = whole file (schema unchanged for untrimmed clips). Client draft
playback already honors them.
- **Orchestrator**: PLAN §4.1 audio schema + §4.2 Stage API could gain the
two seams now in live use: audio {in?, out?} and `entityVideo(id) ->
HTMLVideoElement|null` (Lane A implements on real Stage for M6-A).
NEXT: SYNC browser-verify DIRECT panel button keys+cut land + single
undo; 🗣 on real vo.wav viseme keys on a blendshape character (and the
503 toast with rhubarb absent); Cmd+wheel zoom feel; video backdrop
scrub-follow once Lane A ships entityVideo. Holding at M6-B boundary.
## 2026-07-25 session 6 (M7-B: the way in — templates + shortcuts overlay)
DONE: both required items + one backlog item, all headless-verified.
1. **New from template** (scene bar, left of Save/Load `tlui.js:122`).
`_pickTemplate()` (tlui.js:585) `GET templates` floating `.tl-menu.tmpl`
(same pattern as the 🎵 picker) with title + description + thumb
(`assets/file?path=`, `img.onerror` text-only row). All URLs RELATIVE.
`newFromTemplate(name)` (tlui.js:616): warn-before-clobber via
`tl.hasContent()` `confirm()`, then `GET templates/{name}`
`tl.applyScene(json)`, `_decodeAll()`, `draw()`. Any failure (fetch, !ok,
apply) toasts through `_toast` (window.sgToast still points at it).
2. **`?` shortcuts overlay** (tlui.js:633 `_toggleHelp` / `_helpClose`,
CSS `.tl-help` at tlui.js:48). Opens with `?` or the `?` button
(tlui.js:121), closes on Escape / click-outside / ✕. Three columns
STAGE, TIMELINE, SCENE BAR written by READING the code, not from memory:
stage.js:118-122 (W/E/R gizmo, selection-gated), dock.js drops, and every
binding in my `_key/_down/_dbl/_ctx/_wheel`. Deliberately NOT listed:
right-click on a clip block (only keys/cuts/audio blocks delete), left-edge
trim on animation clip blocks (only the right edge is a hit target).
3. Backlog: **audio HEAD trim** dragging an audio block's LEFT edge now eats
into the source (`in` and `start` move together so the audible part stays
put) instead of being dead space. `timeline.js:trimAudioIn` (grouped: one
undo for the pair, clamps at the file head, at t=0, and 0.05s before `out`);
tlui `aedge` hits now carry `side` ('in' | 'out').
Model seams added to timeline.js: `applyScene(json)` (timeline.js:173)
stage.applyState load preload, the ONE apply path now shared by Load and
New-from-template; and `hasContent()` (timeline.js:185).
TEST: `node scenegod/web/timeline_test.mjs` green, +5 new blocks:
hasContent (empty / cuts-only / audio-only / dock-added entity), applyScene
happy path (entities on stage, tracks in the model, onLoad once, camera live,
clip preloaded and playing at t=1, `template` key stripped, caller's json not
mutated), a loop that applies EVERY shipped `templates/*.json` through
applyScene against the stub ("3 shipped templates applied clean"), and
head-trim (pair + single undo + both clamps). `node --check` clean on
timeline.js, tlui.js, stagestub.js, timeline_test.mjs.
DECISIONS:
- `tlui.load()` now routes through `tl.applyScene` too, so Load and templates
cannot drift apart (the ask was "apply it the same way Load does" made it
literally the same code rather than a copy).
- `applyScene` deletes the `template` key before loading. server.py:264 claims
that metadata "never survives a Timeline round-trip" it WOULD have, since
load() structuredClones unknown fields on purpose (lossless round-trip is a
tested feature). Stripping it in one place makes Lane C's comment true and
keeps saved scenes plain scene JSON.
- `hasContent()` counts ANY entity, cut or audio clip a dock-added,
not-yet-keyframed character is still work worth a confirm. A fresh page has
zero entities (Stage creates none in its ctor), so the first New never
nags. Used plain `confirm()` a modal I'd have to build is not more honest
than the browser's.
- `_key` now also ignores SELECT + contentEditable targets, so `?` (and Space)
can't be hijacked while the snap dropdown or any text field has focus.
- The `?` button is excluded from the overlay's click-outside handler,
otherwise mousedown closed it and the click reopened it.
BLOCKED/REQUESTS:
- **Lane C / orchestrator (nit)**: `GET /templates` returns thumbs that are
ASSET-relative (`backdrops/video/scenegod_plates/*.jpg`) I render them via
`assets/file?path=`. If a future template ever ships a thumb that lives
outside SCENEGOD_ASSETS, the picker will show a broken (auto-removed) image;
keep thumbs inside the assets root.
- **Lane C (still open from session 5)**: audio `in`/`out` at ffmpeg mux.
Head trim makes it much easier to hit a head-trimmed clip now renders from
the file start server-side while the draft playback starts at `in`.
NEXT: nothing required. Remaining polish if a session 7 lands: box-select
multi-drag refinements (marquee currently only selects keys clip/audio
blocks and cuts are not box-selectable), and a per-drag undo coalesce (a
mousemove drag pushes one undo entry per frame; Ctrl+Z walks it back a pixel
at a time). Holding at the M7-B boundary.