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