SCENEGOD/logs/laneB.md
type-two 20af1b20c2 [m5+m6] DIRECTOR MODE + FLOW VIDEO PLATES — all lanes + sync6 seam fixes
Lane A: grammar.js (shot/angle/focal/light/mark data), presets.js (bbox shot
solver, light/mark appliers, DIRECT bar UI, NL box), stage video plates
(VideoTexture plane/corner/dome, screen kind, syncVideos), dock video cards.
Lane B: scenegod:direct → grouped-undo keys/cuts, 🗣 lip-sync from audio via
/rhubarb, Cmd+wheel zoom / wheel scroll, audio trim, video clock-follow via
nullable entityVideo seam. Lane C: POST /director (LLM proxy, server-side op
validation vs vocab+scene, mock-tested), render.js awaits stage.syncVideos.
Sync6 (orchestrator): VIDEO_EXTS in server tree/media types (video primary,
img poster as thumb), stage.entityVideo accessor, syncVideos forces
videoTex.needsUpdate (rVFC never fires in hidden tabs → black plates).

Verified: 9 server groups + timeline + presets suites green; browser SYNC:
one-click MCU framing lands camera+fov keys+cut (single undo), golden_hour
relight keys, plate scrub-follow frame-exact, finalRender mp4 shows the Flow
golden-hour street plate behind an animated character.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:51:28 +10:00

252 lines
16 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.