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>
983 lines
76 KiB
Markdown
983 lines
76 KiB
Markdown
# Lane A log — append-only, newest at bottom
|
||
|
||
Format per session:
|
||
|
||
## <date> session N
|
||
DONE: …
|
||
DECISIONS: …
|
||
BLOCKED/REQUESTS: …
|
||
NEXT: …
|
||
|
||
(no sessions yet)
|
||
|
||
## 2026-07-18 session 1
|
||
DONE: M1 shipped and verified end-to-end in a browser.
|
||
- `web/room3d.js` — ported the rigroom retarget as a pure ES module (no DOM/globals).
|
||
Exports parseAny/canon/boneMap/captureRest/bakeRetarget/disposeRoot/stats + `_selftest`.
|
||
bakeRetarget is now `(clip, srcRoot, srcRest, tgtRoot, tgtRest, opts?)` — target is a
|
||
parameter instead of the closed-over `char`. Algorithm kept byte-for-byte (30fps world-delta
|
||
bake, hips-height ratio, quat sign continuity, dup-hierarchy dedupe). Added `.bvh` to parseAny.
|
||
- `web/stage.js` — `class Stage` per PLAN §4.2. IBL (RoomEnvironment+PMREM), grid+circle floor,
|
||
OrbitControls director cam, TransformControls gizmo (W/E/R), raycast select, shadow-casting.
|
||
addEntity handles character/prop/backdrop(plane|corner|dome)/camera/light; wrapper-Group holds
|
||
all transforms, loaded root normalized+grounded underneath. prepareClip caches parse-per-path and
|
||
bake-per-(entity,path,index). captureState/applyState round-trip entities.
|
||
- `web/dock.js` — asset dock (Characters/Props/Backdrops/Animations tabs from `/assets/tree`,
|
||
offline→file-drop) + inspector (transform/params editors, ⏺key dispatches `scenegod:capturekey`,
|
||
delete). Local drop: image→backdrop, mesh→character, clip-onto-selected-character→bake+play.
|
||
- `web/index.html` + `web/style.css` — CSS-grid layout (dock/view/inspector/timeline), rigroom
|
||
importmap (three 0.160.0), `?selftest=1` badge. Timeline loaded via guarded dynamic import so the
|
||
page never breaks when Lane B's file is absent.
|
||
|
||
VERIFICATION (browser, in-app):
|
||
- `?selftest=1`: PASS — two 3-bone skeletons, different rests+namespaces, baked 1s clip,
|
||
world-delta match maxErr=6.4e-12, srcMoved=0.224rad (guard against a silently-frozen source).
|
||
- Real end-to-end (Female_mixamo.fbx char + "Running.fbx" clip + a jpg backdrop, served from a
|
||
throwaway scratchpad harness): char loaded (65 bones, 18.6k tris, normalized), gizmo-moved,
|
||
clip retargeted (66 tracks) and animating live. Screenshot: `logs/shots/m1-lady-running.png`.
|
||
- Page boots with zero console errors with BOTH timeline.js and the server absent.
|
||
|
||
DECISIONS:
|
||
- selftest uses underscore namespaces (`mixamorig1_`/`mixamorig4_`), not colon — a colon in a bone
|
||
name breaks three's PropertyBinding so the source clip wouldn't bind (silent pass). canon() still
|
||
collapses both to the same key, which is the thing under test.
|
||
- Added `Stage.prepareClipUpload(id, buf, name, idx)` (bake from in-memory bytes) so the offline
|
||
file-drop path can retarget without a server. `Stage.setMixerAuto(false)` lets Lane B take over
|
||
mixer stepping at SYNC 1 (default true so dropped clips preview standalone now).
|
||
- backdrop param edits (mode/image/width) don't rebuild the mesh yet — marked M2. fov/light
|
||
color/intensity are live.
|
||
|
||
BLOCKED/REQUESTS:
|
||
- @Lane C: please confirm the item shape inside `/assets/tree` arrays. dock.js tolerates both a
|
||
bare path string and `{path,name,files:[{path,ext}]}` (rigroom-style) for now — tell me the real
|
||
one and I'll drop the fallback. Also confirm the page is served such that absolute `/web/*.js`
|
||
imports resolve (I used `/web/...` to be URL-independent).
|
||
|
||
NEXT (M2, do not start until orchestrator flips the milestone line):
|
||
- multiple cameras + PiP overlay via renderActiveCamera, light entity param plumbing polish,
|
||
backdrop rebuild-on-param-change, drag-from-dock onto viewport (currently click-to-add).
|
||
|
||
## 2026-07-18 session 2 (M2)
|
||
DONE: M2 Lane-A scope shipped and verified in the *integrated* app (Lane C server :8020 +
|
||
Lane B timeline/tlui mounted). Worked the orchestrator's priority order:
|
||
- **P0 /assets/tree shape** — dropped the bare-string fallback; dock.js now reads the confirmed
|
||
`{name, path, formats, thumb}` and renders `thumb` (→ `/assets/file?path=`) when present.
|
||
- **P1 eventize clip drops** — `dock._applyClip(id,path,idx)`: when `window.timeline` exists it
|
||
dispatches `scenegod:clipdrop {detail:{id,path,clipIndex}}` (Lane B places the block); only
|
||
bakes+previews standalone when no timeline. Uploaded/local clip files can't hand off a server
|
||
path, so with a timeline up they toast a "use the dock" note instead of a dead preview.
|
||
- **P2 onChange on every entity** — `Stage.addEntity`/`removeEntity` now fire `_fireChange(en)`,
|
||
so tlui's `onChange→_syncRows` builds a row per entity, incl. programmatic `applyState` loads.
|
||
Fixed the empty-names bug on both add and remove. Also fixed a latent bug: the gizmo `mouseUp`
|
||
fired `_fireChange` with the *id* not the entity, so the inspector never refreshed on drag.
|
||
- **P4 cameras/lights/PiP/backdrop/drag**:
|
||
- `Stage` PiP: overlay `<canvas class="pip">` in `#view`, rendered each frame from the active
|
||
cam (render active→main canvas, blit to pip, then render director last so the main view is
|
||
correct). `setActiveCamera` shows/hides it. Verified 16,495 lit pixels (real content).
|
||
- `renderActiveCamera(canvas|null)` hardened as the M3 final-render target; added
|
||
`pause()/resume()` so Lane C can freeze the director loop and drive frames deterministically.
|
||
- dock "add rig" toolbar: **+ camera / + key light / + ambient** (these have no asset file);
|
||
inspector gains **◉ set active camera** for cameras; light `castShadow` param plumbed.
|
||
- `setParam` rebuilds the backdrop mesh on mode/image/width change (`_rebuildBackdrop`).
|
||
- drag-from-dock onto the viewport: cards are `draggable`; `#view` drop places chars/props at
|
||
the cursor (`screenToFloor`) and bakes clips onto the character under the cursor (`entityAt`).
|
||
|
||
VERIFICATION (in-app, :8020, zero console errors throughout):
|
||
- Added man + key light + ambient + camera via toolbar → all 4 appear as tlui rows immediately
|
||
(P2 proven). Man lit by the scene lights. Screenshot: `logs/shots/m2-cameras-lights-dome.png`.
|
||
- Clicked ANIMATIONS→hiphop on the selected man → `scenegod:clipdrop` fired with
|
||
`{id:'e1', path:'animations/test/hiphop.fbx', clipIndex:0}` (P1 proven). Lane B DOES consume it
|
||
(timeline.js:98) but the block doesn't land — see the integration gap below.
|
||
- capturekey button → `scenegod:capturekey {id:'e1'}`. backdrop plane→dome rebuild swapped the
|
||
root mesh with no error.
|
||
|
||
DECISIONS:
|
||
- PiP renders the active cam into the *main* WebGL canvas then blits to the 2D pip canvas (one
|
||
shared context); director view is rendered last each frame so it always wins the main canvas.
|
||
Same primitive is the M3 render path: `pause()` the loop, `renderActiveCamera(null)`, read pixels.
|
||
- Local/upload clip files are a no-op-with-note when a timeline is mounted (no server path to place
|
||
a block). ponytail: revisit only if John wants drag-in clip files to become timeline blocks.
|
||
|
||
BLOCKED/REQUESTS — INTEGRATION GAP (orchestrator, please route):
|
||
- Live dock/stage-added entities never reach the timeline's model. `Timeline.scene.entities`
|
||
starts `[]` (timeline.js:80) and is only filled by `timeline.load(sceneJson)`. Both
|
||
`scenegod:clipdrop`→`addClipDrop` and `scenegod:capturekey`→`addKey` call `_tracks(id)`, which
|
||
throws `no entity <id>` for anything added through the dock. Verified live at :8020:
|
||
`manOnStage=true, inTimelineModel=false, addClipDropErr="no entity e1"`. So on today's build,
|
||
keyframing/clip-dropping a dock-added entity is dead — it only works after a scene `load()`.
|
||
- This is a cross-lane seam, not a Lane-A-file fix (timeline.js is Lane B's). The natural hook is
|
||
already in place on my side: **`stage.onChange(en)` now fires on every add AND remove**
|
||
(my P2 fix). Suggested resolution (Lane B, ~4 lines): in the Timeline ctor,
|
||
`stage.onChange(en => this._mirror(en))` where `_mirror` upserts/removes a `scene.entities`
|
||
entry from `{id,kind,label,source,params, transform: stage.entityTransform(id), tracks:{}}`.
|
||
I can instead dispatch a dedicated `scenegod:entityadd/entityremove` event from the dock if the
|
||
orchestrator prefers an explicit contract over reusing onChange — say the word and I'll add it.
|
||
|
||
NEXT: once the entity-mirror seam is decided, the "lady + man + street + 2 cameras + sunset" SYNC-2
|
||
demo should play. Holding at the M2 boundary until the orchestrator flips the milestone line.
|
||
|
||
## 2026-07-18 session 3 (M3-polish)
|
||
DONE: both M3-polish items the orchestrator flagged, verified live at :8020.
|
||
- **PiP cap + swap.** Was fixed 256×144 and (with the CSS `aspect-ratio`/`%` combo) blew up to
|
||
340×413 portrait on a `<canvas>` — CSS aspect-ratio is unreliable on replaced elements. Now sized
|
||
in `_resize()`: `clamp(180, view.width*0.24, 340)` wide × 16:9, bottom-right. Click the inset to
|
||
swap main↔active-cam (`_pipSwap`). Refactored `renderActiveCamera`→ private `_render(cam,canvas)`
|
||
that restores the cam's aspect after a pip blit, so swapping never distorts either camera
|
||
(verified: dirCam.aspect stays 1.79 while it's shown in the inset). `renderActiveCamera(canvas)`
|
||
is now a one-line wrapper over `_render(this._activeCam(), canvas)` — M3 contract unchanged;
|
||
`pause()/resume()` untouched. Verified 180×101 @1.78 ratio, swap flips the views cleanly.
|
||
- **lady.glb flat-white diagnosed + fixed.** It's the ASSET: a Reallusion/Character-Creator export
|
||
with `extensionsRequired:["KHR_materials_pbrSpecularGlossiness"]` — diffuse lives in that
|
||
extension, which three r160 dropped, so all 26 materials fell back to white MeshStandard. (rigroom
|
||
would show the same — no port regression.) Since SCENEGOD must eat any dropped character, added a
|
||
minimal `SpecGlossPlugin` registered on GLTFLoader in room3d.js: spec-gloss → MeshStandard
|
||
(diffuse→map/color, roughness≈1−glossiness, metalness 0). Only touches materials carrying the
|
||
extension; metallic-rough GLBs and FBX untouched. Verified: lady loads with withMap=26/26 and
|
||
renders fully textured (skin/clothes/face). Shot: `logs/shots/m3-pip-swap.png`.
|
||
|
||
DECISIONS:
|
||
- Runtime spec-gloss shim over an offline gltf-transform convert: keeps CC characters "just work"
|
||
in the drop-on-stage flow and in the render pipeline, no per-asset conversion step. Approximation
|
||
(ignores the specular map) is fine for machinima lighting; upgrade path = full spec→metal convert
|
||
if a hero asset needs it.
|
||
- PiP size lives in JS, not CSS — `<canvas>` + `aspect-ratio` is the trap that produced the giant
|
||
inset. CSS keeps only position/border.
|
||
|
||
BLOCKED/REQUESTS: none.
|
||
|
||
NEXT: hold at M3-polish boundary. Available for SYNC-3 findings (finalRender drives
|
||
`pause`/`resume`/`renderActiveCamera` — kept stable per the hard contract).
|
||
|
||
## 2026-07-18 session 4 (SYNC4 fix + M4-A visemes)
|
||
DONE:
|
||
- **applyState/source clean fix.** Absorbed the orchestrator's SYNC4 hotfix and did the schema-correct
|
||
version: `addEntity` defaults camera/light `source` to `{type:'none'}` (PLAN §4.1); `captureState`
|
||
emits it; `applyState` skips only entities that need bytes it lacks (`type:'upload'` AND
|
||
kind∈{character,prop,backdrop}), so `'none'` always rebuilds and legacy `'upload'` cams still
|
||
rebuild. Verified live: save→load of {character, camera, light} → srcTypes
|
||
`[character:assets, camera:none, light:none]`, all 3 come back (rig no longer lost).
|
||
- **M4-A viseme lane** (my deliverable = detection + setMorph hook + badge; Rhubarb→keys is Lane B):
|
||
- `detectVisemes(root)` (stage.js): scans every skinned mesh's `morphTargetDictionary`, loosely
|
||
maps names → canonical visemes A/E/I/O/U/MBP/FV/L (covers CC `V_Open/V_Explosive/V_Dental_Lip/
|
||
V_Tongue_*`, ARKit `jawOpen/mouthPucker/mouthFunnel/mouthClose`, plain vowels). Built once at
|
||
character load; stored on the entity as `{morphs, visemes}` (head/teeth/tongue meshes sharing a
|
||
morph name are grouped so one call drives them all).
|
||
- `stage.visemeTargets(id)` → canonical names present; `stage.setMorph(id, name, weight)` drives a
|
||
canonical viseme OR a raw morph name across all mapped meshes.
|
||
- inspector 🗣 badge (dock.js): lists detected visemes, greyed `🗣 no visemes` when the rig has none.
|
||
- `visemeSelfCheck()` exported: synthetic dict of CC+ARKit+blink names → asserts A/U/MBP/FV/L
|
||
detected, E/I/O NOT (no blink/brow false positives), and setMorph('A') drives the mapped indices
|
||
only. Verified in-browser: `ok=true, found=[A,FV,L,MBP,U]`.
|
||
|
||
VERIFICATION (:8020, zero console errors): viseme self-check ok; man/lady show greyed 🗣 badge
|
||
(neither rig carries mouth morphs — lady.glb is a CC body export with 0 morph targets, confirmed by
|
||
reading the GLB); save/load round-trip keeps cameras+lights.
|
||
|
||
DECISIONS:
|
||
- Viseme matcher is intentionally loose (orchestrator asked "match loosely") and detection-only; the
|
||
authoritative Rhubarb-letter→canonical mapping + keyframing is Lane B's. `setMorph` also accepts
|
||
raw morph names so B can drive anything the matcher missed.
|
||
|
||
BLOCKED/REQUESTS:
|
||
- @orchestrator: no viseme-bearing test character in `assets/` (man.fbx and lady.glb both have zero
|
||
mouth morphs), so the LIT badge + a visible mouth move are proven only by the synthetic self-check,
|
||
not on a real asset. If you can drop a CC/ARKit head with visemes into `assets/characters/test/`,
|
||
I'll confirm live and Lane B can validate Rhubarb end-to-end.
|
||
|
||
NEXT: hold at M4-A boundary. Available for a live viseme asset check and any SYNC findings.
|
||
|
||
## 2026-07-20 session 5 (M5 DIRECTOR MODE + M6 FLOW VIDEO PLATES)
|
||
DONE — both milestones, headless-verified (no browser/servers touched per orchestrator order):
|
||
|
||
M5:
|
||
- `web/grammar.js` (NEW, pure data, zero imports): SHOTS (exact spans/centers from the lane
|
||
spec), ANGLES (eye/low −18°/high +22°/dutch 8° roll/ots_L/ots_R), FOCALS [18,24,35,50,85],
|
||
LIGHTS (day/golden_hour/night/noir/horror-underlight/neon/overcast — {sun{color,intensity,
|
||
elev,azim}, ambient{color,intensity}, bg}), MARKS (center/two_shot_L/R/face_camera/face_other).
|
||
- `web/presets.js` (NEW, NO three.js import — node-runnable): fovForFocal (presets.js:11),
|
||
lookAtEuler w/ dutch roll + three-matching XYZ euler extraction (:14), solveShot bbox→
|
||
pos/rot/fov/dist deterministic solver incl. OTS over-the-shoulder placement (:37),
|
||
frameSubject (:62, bbox via new stage.entityBBox), applyLight (:75 — ensures/reuses one
|
||
sun+ambient pair, writes grammar values verbatim, aims a 20m sun boom via lookAtEuler,
|
||
background rides the AMBIENT entity as param `bg` so it's keyable AND survives save/load),
|
||
applyMark (:99, pos marks keep y; face marks yaw toward active cam / nearest character),
|
||
applyOps (:139) — ONE op runner shared by panel buttons and /director NL, fires
|
||
`scenegod:direct {op, ...values, entityIds}` per action (B keys them; I never touch the
|
||
timeline), mountDirectPanel (:174) — shot/angle/lens segments + 🎥 frame, light buttons,
|
||
mark buttons, NL box.
|
||
- NL box: POST /director {text, selected, entities:[{id,kind,label}]} → expects {ops:[...]},
|
||
applied via applyOps. Hidden by default; probe `GET /director` shows it on 405/200 (FastAPI
|
||
405 = POST route exists); hides itself again when a POST returns 503.
|
||
- index.html:14 `#direct` bar + guarded dynamic import (:49) — page survives presets.js absent.
|
||
style.css:85 DIRECT styles (before the LANE B marker).
|
||
- stage.js additions for M5: entityBBox (stage.js:490, plain arrays — keeps presets three-free),
|
||
cameraWorldPos (:497), light param `bg` → scene.background live (:393) + restored at
|
||
_buildLight (:335) so a loaded scene gets its sky back.
|
||
|
||
M6:
|
||
- `_buildBackdrop` rework (stage.js:225): params.mode gains "video" (renders as plane), and
|
||
plane/corner/dome accept .mp4/.webm/.mov sources; THREE.VideoTexture muted+loop+playsInline,
|
||
SRGB; poster jpg (server /assets/thumb same-stem sidecar) shown until first play/seek then
|
||
swapped in via _swapOnPlay (:309). Standalone page (no timeline → _mixerAuto) auto-plays as
|
||
ambient preview; with a timeline mounted videos stay paused at poster for B's clock.
|
||
- NEW kind `screen` (addEntity :165, _buildScreen :272): video plane, params {video,width,
|
||
emissive}, MeshStandard + emissiveMap boost (default 0.6) so club screens read "on" under
|
||
noir/night. ponytail: CRT curve skipped until a real TV asset asks.
|
||
- `stage.syncVideos(t)` (:506): seeks every video to ((t−videoStart) mod duration), pauses,
|
||
awaits 'seeked' per video (1.5s timeout guard so a stuck decoder can't hang a render), skips
|
||
sub-4ms deltas. render.js already awaits it per frame (Lane C landed the hook). Plus
|
||
videos() (:501) + setVideoPlaying(on) (:503) for Lane B's clock-follow, and params.videoStart
|
||
plumbed (inspector fields, dock.js:214/220).
|
||
- dock.js: _videoPath (:86, derives <stem>.<videoext> from formats), 🎞 badge + 📺
|
||
add-as-screen button on video backdrop cards (:106), click/drag adds mode:'video' plates
|
||
(:132 and _viewDrop), local .mp4 drop → upload video plate, screen/backdrop inspector fields
|
||
(width/emissive/vid start; mode select gains 'video').
|
||
- Lifecycle: removeEntity/_rebuild release the <video> (_dropVideo :316); applyState treats
|
||
upload screens as needs-bytes (skip+warn) like backdrops.
|
||
|
||
VERIFICATION (headless):
|
||
- `web/presets_test.mjs` (NEW): fov-from-focal exact for 18/35/85mm (+35mm≈37.849°); CU dist <
|
||
MS dist on a 1.7m subject + exact span-formula match + CU aims at .87H; dutch sets rot z =
|
||
8° with zero stray pitch/yaw; low angle puts camera below aim pitched up;
|
||
applyLight('golden_hour') returns grammar values verbatim (deepEqual) + applies
|
||
intensity/bg to a stub stage + reuses the sun/ambient pair on a second preset.
|
||
→ `node scenegod/web/presets_test.mjs` = ALL PASS.
|
||
- Scratchpad ops smoke (not committed): applyOps shot-by-LABEL auto-creates 'shotcam', sets
|
||
85mm fov 16.07°, activates cam, low-angle below aim; ots_R auto-resolves the other
|
||
character; two_shot_L pos; face_camera/face_other yaw; unresolvable subject reports error
|
||
without throwing. ALL PASS.
|
||
- ESM syntax-check (node --check via scratchpad .mjs copies): stage/dock/presets/grammar/room3d
|
||
all clean. Retarget code and every existing Stage API untouched.
|
||
|
||
DECISIONS:
|
||
- presets.js takes bboxes as plain arrays (new stage.entityBBox) instead of importing three —
|
||
the whole solver runs under node, same trick as Lane B's timeline.
|
||
- ONE op path: panel buttons call applyOps exactly like /director responses do, so button and
|
||
LLM behavior can never drift; every applied op fires one scenegod:direct event.
|
||
- Background color is a param (`bg`) on the ambient light entity rather than new scene-level
|
||
schema — keyable through B's existing params track, persists via captureState, zero schema
|
||
change. Orchestrator: if you'd rather have scene-level bg in PLAN §4.1, say so and I'll move it.
|
||
- Poster-until-play = swap material maps on first 'playing' OR 'seeked' — so a final render
|
||
that never plays (only seeks) still gets real video frames, and an idle stage shows the jpg.
|
||
- OTS shoulder offset is a fixed 0.35m slide + 0.1m rise along the partner line — deterministic,
|
||
no LLM; eyeball-tune at SYNC if the shoulder isn't reading.
|
||
|
||
BLOCKED/REQUESTS:
|
||
- **@Lane C (blocking M6 dock visibility): mp4 is invisible to the server.** server.py
|
||
ASSET_EXTS/CATEGORIES: backdrops primary = IMG_EXTS only and mp4 ∉ ASSET_EXTS, so
|
||
assets/backdrops/video/scenegod_plates/suburb_strip_goldenhour.mp4 never appears in
|
||
/assets/tree (its poster jpg shows up as a plain image backdrop instead). Please add
|
||
VIDEO_EXTS = {"mp4","webm","mov"} to ASSET_EXTS and to the backdrops primary set. My dock
|
||
already handles the resulting shape (finds the video ext in `formats`, derives
|
||
<stem>.<ext>). Also add MEDIA_TYPES entries ("mp4":"video/mp4","webm":"video/webm",
|
||
"mov":"video/quicktime") — /assets/file currently serves octet-stream and <video> can be
|
||
picky about that (and note Range support matters for long plates).
|
||
Until then, video plates ARE testable via console: `stage.addEntity({kind:'backdrop',
|
||
label:'plate', source:{type:'assets', path:'backdrops/video/scenegod_plates/
|
||
suburb_strip_goldenhour.mp4'}, params:{mode:'video', image:'backdrops/video/
|
||
scenegod_plates/suburb_strip_goldenhour.mp4', width:12}})` (file streams fine, just untyped).
|
||
- @Lane C: /director endpoint per PLAN M5 — my probe expects GET→405 (or 200) when it exists,
|
||
and I hide the box on POST 503. Request contract above; response {ops:[{op:'shot'|'light'|
|
||
'mark', ...}]} — shapes documented at presets.js:136.
|
||
- @Lane B: your M5/M6 hooks are ready on my side — `scenegod:direct` events carry full values
|
||
for keying; for video clock-follow use stage.videos() [{id,video,start}] +
|
||
stage.setVideoPlaying(on) + drift-check video.currentTime yourself per PLAN (>50ms), or just
|
||
await stage.syncVideos(t) on hard seeks.
|
||
|
||
NEXT / orchestrator SYNC checklist (browser):
|
||
1. Select man → CU low 35mm → 🎥 frame: camera frames his face from below in one click; PiP
|
||
shows it (frame auto-activates the shot cam).
|
||
2. golden_hour button: sun+ambient recolor + orange sky in one click; scrub proves B keyed it
|
||
(needs B's scenegod:direct consumer — not landed yet as of this session).
|
||
3. Suburb plate: console addEntity above → poster shows, then video motion (auto-play only
|
||
when timeline absent; with timeline mounted it waits for B's clock). 📺 dock button appears
|
||
once Lane C ships the tree change.
|
||
4. finalRender a 2s scene with the plate — frames must show advancing video (syncVideos is
|
||
awaited by render.js already).
|
||
5. NL box stays hidden until /director exists (probe is GET → expect 405 to reveal).
|
||
|
||
## 2026-07-25 session 6 (M7-A1 non-mixamo rigs + M7-A2 world-integrated backdrops)
|
||
DONE — both M7 items, headless-verified (no browser, no servers touched, per orchestrator order):
|
||
|
||
**M7-A1 — bone aliasing.** `canon()` used to strip a mixamorig prefix and nothing else, so every
|
||
non-mixamo skeleton paired 0 bones and died at "missing hips" (lady.glb has never been animatable).
|
||
- `room3d.js:70` legacy strip is now `rawCanon`; `room3d.js:136` `canon = raw ∈ CANON ? raw : alias(n) || raw`.
|
||
The CANON short-circuit is what makes mixamo→mixamo **bit-identical** — a name that already canons
|
||
to a mixamo bone never touches the alias tables (test asserts all 65 man.fbx bones × 5 namespace
|
||
spellings against an inlined copy of the old one-liner).
|
||
- Tables (room3d.js:77–125): `CANON` (the 54 mixamo names, generated), `EXACT` side-less bones,
|
||
`STEMS` sided stems + generated finger stems, `SIDES` side decompositions. `alias()` walks 4
|
||
progressively-normalised candidates — raw → prefix-stripped → index-stripped → both — and takes the
|
||
first hit, so **`CC_Base_Spine01` (2nd spine bone) beats the generic `spine_01` (1st) reading**.
|
||
Structure handled: namespaces (`mixamorig4:`, `Armature|`), rig prefixes (`CC_Base_`, `DEF-`/`ORG-`/
|
||
`MCH-`, `Bip01 `), CC's per-export numeric tail (`_02`, `_0100`), and side spellings
|
||
`L_`/`_l`/`.L`/`Left`. A decomposition only counts when the leftover stem is known, so `lowerarm`
|
||
is never read as left-"owerarm" and `shoulder` is never read as "shoulde"+right.
|
||
- Covered: Character Creator (bone names read out of the REAL lady.glb JSON chunk, not guessed),
|
||
Unreal/HumanIK, Blender Rigify, Unity-humanoid/VRM/Rokoko long names, plain lowercase, 3ds Max Biped.
|
||
Twist / share / toe-detail / facial / breast / eye bones deliberately stay UNMAPPED — the pair map
|
||
already tolerates missing pairs, and a guessed mapping is worse than an unbaked bone.
|
||
- Guardrail: pairing moved out of `bakeRetarget` into three-free `pairBones()` (room3d.js:146). Under
|
||
`min(8, boneCount)` matches it throws e.g.
|
||
`retarget: only 1/54 bones matched (target looks like 'CC_Base', source looks like 'mixamo'; first
|
||
unmatched bone 'CC_Base_BoneRoot_01'); unsupported skeleton`. `rigStyle()` (room3d.js:127) does the
|
||
detection. The missing-hips error (room3d.js:176) now names both styles too. `min(8, …)` keeps
|
||
`_selftest`'s 3-bone synthetic skeletons legal.
|
||
- **Result on the real asset: lady.glb pairs 52/102 bones** — core 6 + both arms/legs + all 30 finger
|
||
bones, `CC_Base_Hip` wins `hips` and `CC_Base_Pelvis` folds onto the same key and is left unbaked
|
||
(dedupe does the right thing). Reverse direction (CC as the clip SOURCE onto a mixamo body) is 52/52.
|
||
|
||
**M7-A2 — world-integrated backdrops.** A plate read as a billboard in the void: gap under it, alien
|
||
floor, no air. Three fixes, all recomputed on load/param/camera change, never per frame (stage.js:340–415).
|
||
- `params.fit:'frustum'` — `_fitBackdrop` scales `en.root` to COVER the active camera's frustum at
|
||
`params.fitDist` (default 14m) with 6% bleed, drops the plate's centre onto the wrapper origin, parks
|
||
the wrapper on the camera axis and `lookAt`s the camera. Because the plate now extends BELOW y=0 the
|
||
floor cuts it at a clean horizon — the gap is gone. `fitScale()` (stage.js:62) is a pure export so
|
||
node can assert the algebra. Recompute triggers: `addEntity`, `_rebuild`, `setParam(fov)` on the
|
||
ACTIVE cam, `setActiveCamera`, `_resize`, end of `applyState`, and the inspector's `⤢ refit`.
|
||
Absent `fit` = old behaviour (saved scenes unchanged); dock-added plates default to `'frustum'`.
|
||
- `params.groundTint` (default on) — `_plateGround` draws the plate's map (image, poster, or video
|
||
frame) into a 24×24 2D canvas and averages the bottom 7 rows, once per load/param change; the stage
|
||
floor takes that colour ×0.85 and the grid helper fades to 0.12 opacity while a plate is present.
|
||
- Atmosphere — `FogExp2` coloured from the same sample, density from a **keyable `fog` param on the
|
||
ambient light entity** (same trick as `bg`: Lane B's params track drives it with zero schema change).
|
||
Default 0.02, off with no plate. Plate/dome materials get `fog:false` so atmosphere sits between the
|
||
camera and the plate instead of washing it out. Fog object is mutated, not reallocated, when keyed.
|
||
- dock.js: inspector gains `fit` select, `fit dist`, `⤢ refit to camera`, `ground tint` checkbox
|
||
(new `_chk` helper, dock.js:250) and `fog` on ambient lights; new `_chk` styling in style.css:52.
|
||
|
||
VERIFICATION (headless only):
|
||
- `node scenegod/web/room3d_test.mjs` → `ALL PASS (26 CC bones paired against mixamo)`. NEW file; it
|
||
loads the REAL room3d.js with the three imports stripped and imported as a data: URL, so the shipped
|
||
tables are what's asserted (no drift-prone copy). Covers: 65 mixamo bones × 5 namespaces identical to
|
||
the legacy canon; 20 CC name→canonical assertions; 8 CC twist/share/facial bones must stay unmapped;
|
||
36 Unreal/Rigify/Unity/Biped assertions; side-less limbs must stay unmapped; CC↔mixamo pairing = 26
|
||
on the sample; mixamo→mixamo pairing 65/65 unchanged; the guardrail throws with style + unmatched
|
||
root in the message; 3-bone `_selftest` skeletons still legal; `rigStyle` on 5 rigs.
|
||
- `node scenegod/web/stage_test.mjs` → `ALL PASS (fit s=2.186 → plate 21.9×12.3m over a 20.6×11.6m
|
||
frame at 14m)`. NEW file, same import trick on stage.js: frustum size, cover-on-both-axes, bleed
|
||
factor, portrait/letterbox plates scaling on the tight axis, distance-invariance of framing, longer
|
||
lens → smaller plate.
|
||
- `node scenegod/web/presets_test.mjs` (M5) and `timeline_test.mjs` (Lane B) still pass unchanged.
|
||
- `node --check` clean on room3d.js / stage.js / dock.js / presets.js / grammar.js and on index.html's
|
||
inline module.
|
||
|
||
DECISIONS:
|
||
- Aliasing lives in `canon()` behind a CANON short-circuit rather than a new module — Lane A owns 5
|
||
files and the baker is the only consumer. `pairBones`/`rigStyle`/`fitScale` are exported THREE-free
|
||
so node can test the parts that actually encode judgement.
|
||
- Ambiguity resolved by qualification, not by guessing: CC's 3-bone torso (Waist/Spine01/Spine02) maps
|
||
to mixamo Spine/Spine1/Spine2 via `ccbase*` keys; UE's `spine_01/02/03` maps to the same three via
|
||
generic keys. Chains longer than mixamo's 3 (UE `spine_04/05`, rigify `spine.003+`) stay unmapped.
|
||
- **Known limit, marked `ponytail:` at room3d.js:190**: lady.glb hangs its hip height off LOCAL Z
|
||
(Z-up CC export), so the hips-travel `ratio` lands 0 and clips play IN PLACE. That is the safe read —
|
||
the source's travel delta is in the SOURCE's axes and scaling it into a Z-up hip would fly the
|
||
character. Rotations are unaffected (they transfer through world space), so she dances correctly.
|
||
Proper fix = rotate the travel delta through both rests' parent world quats; deliberately not done,
|
||
since it would perturb the verified mixamo→mixamo path for a case nobody has asked for yet.
|
||
- Auto-fit OWNS the backdrop's wrapper transform (marked `ponytail:` at stage.js:355) — keyframe a
|
||
plate only with `fit:'free'`. Fit is NOT recomputed on camera *movement* (only fov/cut/resize):
|
||
following a dolly would glue the plate to the lens and kill parallax.
|
||
- **Absorbed bug fix you should know about (visual delta):** `_buildLight` ran `_refreshDefaults()`
|
||
BEFORE the entity was registered in `_entities`, so adding a scene light never dimmed the built-in
|
||
hemi (2.2) / key — every M5 lighting preset was being washed out by studio defaults. Now refreshed
|
||
after registration (stage.js:197). Expect noir/night/golden_hour to read much stronger than in the
|
||
SYNC-6 screenshots; RoomEnvironment IBL still keeps nothing pitch black.
|
||
|
||
BLOCKED/REQUESTS:
|
||
- @orchestrator (PLAN §4.1 amendment, no code impact): backdrop `params` gains `fit:"frustum"|"free"`,
|
||
`fitDist` (m, default 14) and `groundTint` (bool, default true); `light` params gain `fog` (FogExp2
|
||
density, keyable, lives on the ambient entity next to `bg`). All round-trip through
|
||
captureState/applyState today.
|
||
- @Lane B (free win, not blocking): `fog` behaves exactly like `bg` — a params-track key on the ambient
|
||
light entity animates the atmosphere. A "haze up over 2s" key is now a one-liner for the DIRECT panel.
|
||
- Still owed by John: a viseme-bearing test character (carried from session 4).
|
||
|
||
NEXT / orchestrator SYNC checklist (browser, :8020):
|
||
1. `?selftest=1` → header badge must read `selftest: PASS (maxErr=…, alias ok)` — the alias check is
|
||
new (index.html:33), it canons CC + Unreal + namespaced-mixamo names in-page.
|
||
2. **Lady dances (the headline):** CHARACTERS → lady → ANIMATIONS → hiphop.fbx (drag onto her, or
|
||
select her and click the card) → block lands on the timeline → press play. Before today this threw
|
||
`retarget: missing hips (mixamorig skeleton?)`. Console proof if you want it:
|
||
`stage.prepareClip('e1','animations/test/hiphop.fbx',0).then(c=>console.log(c.tracks.length))` →
|
||
expect **53** tracks (52 bone quats + 1 hips position). She dances in place by design (see the
|
||
ponytail note above); man.fbx is unchanged and still travels.
|
||
3. **Plate fills frame + ground + fog:** BACKDROPS → the golden-hour video plate (or street.jpg) →
|
||
it should land already filling the view with no gap under it, the floor should go warm/orange, the
|
||
grid should nearly vanish. Inspector shows `fit: frustum`, `fit dist 14`, `ground tint ✓`. Move the
|
||
director cam and hit `⤢ refit to camera` to re-fill. Add an ambient light → its inspector `fog`
|
||
field (0.02 default) — crank to 0.08 to see the haze eat the far floor, 0 to kill it.
|
||
4. Cut to a scene camera (◉ set active camera) — the plate refits to THAT lens; change its fov and it
|
||
refits again. PiP should show the same filled frame.
|
||
5. Regression watch: existing saved scenes (`sync2-sunset`) have no `fit` param → plates must load at
|
||
their old size/place. Lighting will look stronger than before (see the `_refreshDefaults` note).
|
||
|
||
## 2026-07-26 session 7 (M11-A CAMERA MOVEMENT + "+ angle" + fitAnchor)
|
||
DONE — all three items, headless-verified (no browser, no git, :8020 untouched per orders):
|
||
|
||
**M11-A1 — move presets.** Every shot the app makes was locked off; now the solver places a camera
|
||
AND moves it. `grammar.js:60` MOVES (10, pure data) + `SHOT_ORDER` (:74) + `EASES` (:77) +
|
||
`MOVE_MAX_DUR` 6s (:78) + `DOLLY_CLAMP` (:79). `presets.js:87` `solveMove()` returns the same shape
|
||
`solveShot` does, twice: `{move, from:{pos,rot,fov}, to:{pos,rot,fov}, aim, dist, shot, angle, focal,
|
||
endShot, endFocal}`. START is byte-for-byte what 🎥 frame would solve for the panel's
|
||
{shot,angle,focal}; END is that state displaced. Per kind:
|
||
- **dolly** (push_in/pull_out): distance of one rung along SHOT_ORDER (MS→MCU 1.61m→1.12m on a 1.7m
|
||
subject at 35mm), travelling ALONG the start→aim ray — `rot` and `fov` untouched. At ECU/EWS the
|
||
rung runs out and it clamps by DOLLY_CLAMP (÷1.4 / ×1.4) so the button always does something.
|
||
- **truck** (L/R): lateral along level camera-right, `rot` COPIED (a truck stays parallel — it is not
|
||
a pan and not an orbit). Amount = 0.55 × the half-frame WIDTH, so the subject slides to ~the
|
||
outer third and stays in frame on any lens, and distance-to-subject shifts <6%.
|
||
- **crane** (up/down): vertical only, then re-aimed (`lookAtEuler`) so the subject stays framed;
|
||
amount = 1.2 × the half-frame HEIGHT. Floor-clamped at y=0.05, and the clamp re-aims too.
|
||
- **orbit** (L/R): rotates the horizontal offset about the aim, y held → distance constant to 1e-9,
|
||
sweep exactly ±25° (`degrees` overridable).
|
||
- **zoom** (in/out): one rung along FOCALS (35→50 / 35→24; past the ends ×1.6) — `pos` and `rot`
|
||
returned as identical arrays. A zoom is not a dolly and the code makes that structural, not a
|
||
comment.
|
||
Amounts are FRAME-RELATIVE (multiples of span·H/2) rather than metres, so a move reads the same on a
|
||
CU and a WS, an 18mm and an 85mm — asserted, not assumed. `moveCamera()` (`presets.js:143`) is the
|
||
applier: solves, then puts the camera on the START state immediately (viewport shows frame 1 of the
|
||
move) and returns the pair. `moveDuration()` (`:79`) = scene remaining from the playhead, min 0.5s,
|
||
capped at 6s; no timeline / past the end → the cap. UI: a MOVE segment in the DIRECT bar
|
||
(`presets.js:339`) — move select · dur box (blank = auto) · ease select · **🎬 move**.
|
||
|
||
**M11-A2 — "+ angle"** (`presets.js:331`, next to 🎥 frame). New op flag `newCam:true` on the
|
||
EXISTING `shot` op, so panel and /director still share one code path: `_camFor()` (`:235`) makes a
|
||
fresh camera labelled by `_camLabel()` (`:225` → cam2, cam3, … skipping taken labels), solves the
|
||
current shot/angle/lens onto it, activates it, touches nothing else. Emits an ordinary `shot` event,
|
||
so Lane B keys + cuts it exactly as today — coverage now takes one click instead of a console call.
|
||
|
||
**M11-A3 — fitAnchor** exposed (`dock.js:228`): 0–1 slider + live readout (new `_range` helper,
|
||
`dock.js:262`; styles `style.css:53`), shown next to `fit dist` when `fit:'frustum'`.
|
||
`stage.js:507` now refits on `fitAnchor` too (it was inert from the inspector before).
|
||
|
||
VERIFICATION (headless):
|
||
- `node scenegod/web/presets_test.mjs` → **ALL PASS (10 moves; push in 1.61m → 1.12m, orbit ±25°,
|
||
zoom 35→50mm at a fixed 1.61m)**. New blocks assert the geometry, not the plumbing: an
|
||
independent `aimsAt()` built from three's own `Euler('XYZ')` third column
|
||
(`[sin y, −sin x·cos y, cos x·cos y]`) — NOT from `lookAtEuler`'s internals — plus a
|
||
`assert.throws` proving that checker fails a camera turned 17° off its subject. Then: push-in ends
|
||
closer / pull-out farther with both states still aimed; the push-in end distance EQUALS the MCU
|
||
solve; travel is parallel to the view axis; ECU push-in still pushes; truck distance within 10%,
|
||
displacement ⟂ to the view axis, level, rot identical, inside the half-frame width, L/R exact
|
||
mirrors, same metres at 85mm as at 35mm, farther on a WS; crane changes only y, re-aims, pitch
|
||
crosses from ~0 to negative (up) / positive (down); orbit distance equal to 1e-9, sweep exactly
|
||
±25°, a 90° orbit lands square to the side, orbit-left agrees with truck-left; zoom `pos`/`rot`
|
||
deepEqual and distance unchanged while fov moves — with the explicit push-in-vs-zoom-in pair
|
||
("position, not lens" / "lens, not position"). Plus the ladders, `moveDuration` in 5 states, and
|
||
an applyOps block (stub stage + shimmed `dispatchEvent`/`window.timeline`) asserting the exact
|
||
`move` event keys, t0 = playhead 2s, dur = 6 (8s remaining capped), explicit dur/ease honoured,
|
||
unknown ease → `inout`, the START state actually landing on the camera, no camera spam, and the
|
||
"+ angle" sequence cam2/cam3 with the other cameras' transforms byte-identical.
|
||
- `node scenegod/web/stage_test.mjs` and `room3d_test.mjs` unchanged → ALL PASS. Lane B's
|
||
`timeline_test.mjs` also still green (read-only run; their `applyDirect` ignores an unknown op, so
|
||
today's build no-ops on `move` instead of throwing).
|
||
- `node --check` clean: grammar.js, presets.js, stage.js, dock.js, presets_test.mjs.
|
||
|
||
**THE `move` OP CONTRACT (Lane B, next session) — emitted once per 🎬 move:**
|
||
```js
|
||
CustomEvent('scenegod:direct', { detail: {
|
||
op: 'move', camId, t0, dur, ease, // ease ∈ 'inout' | 'linear'
|
||
from: { pos:[x,y,z], rot:[x,y,z], fov }, // ALREADY applied to the live camera by Lane A
|
||
to: { pos:[x,y,z], rot:[x,y,z], fov },
|
||
entityIds: [camId],
|
||
// informational, safe to ignore: move, subjectId, shot, angle, focal
|
||
}})
|
||
```
|
||
Expected handling: inside one `group()` → `addKey(camId,'transform',{t:t0, ...from, ease})` +
|
||
`addKey(camId,'transform',{t:t0+dur, ...to, ease})`, and the same pair on `params` `fov` (fov is the
|
||
ONLY thing that changes on a zoom — key it always, it costs one key and keeps every move uniform).
|
||
A cut is your call: `addCut(t0, camId)` matches what `shot` does and is what I'd expect, but the
|
||
payload doesn't presume it. `t0` is my read of your playhead (`window.timeline.time`, 0 without a
|
||
timeline) — prefer your own if they ever disagree. `dur` is already clamped to the scene remainder,
|
||
so `t0+dur` never lands past `scene.duration`.
|
||
|
||
DECISIONS:
|
||
- **Push-in is a pure axis dolly** (rot + fov fixed), not "solve the tighter shot and interpolate".
|
||
Re-solving would also raise the aim (MS centre .65H → MCU .75H) — a dolly plus a covert tilt. The
|
||
rung is used for the DISTANCE only. It keeps the dolly/zoom pair a controlled experiment: same
|
||
intent, one variable each. A director who wants MCU composition picks MCU.
|
||
- **Frame-relative amounts, not metres.** Truck/crane amounts multiply span·H/2 (and ×aspect for the
|
||
truck), which is the framed half-height by construction. A fixed 1.5m truck is fine on a WS and
|
||
throws the subject out of frame on an 85mm CU; this can't.
|
||
- **The truck does not re-aim.** The lane brief says both "stays parallel" and "keeps the subject
|
||
framed"; those only co-exist if the lateral is bounded by the frame, so it is (0.55 of half-width)
|
||
and the test asserts the subject is still inside. Re-aiming would make it a tracking pan, which is
|
||
a different move and should be a different button if John wants one.
|
||
- **🎥 frame now prefers the LIVE camera** (`_camFor`, `presets.js:235`), falling back to
|
||
`shotcam`→first→create, i.e. the old order whenever no scene camera is active. Without this, "+
|
||
angle" was a trap: build cam2, adjust the size, and the edit silently lands on camera 1. Behaviour
|
||
is unchanged in every scene that existed before (🎥 frame activates the camera it frames).
|
||
- Both new Stage getters are one-liners and read-only: `activeCamera()` (`stage.js:587`) and
|
||
`viewAspect()` (`:634`, so the truck is bounded by the REAL frame, not an assumed 16:9). No
|
||
existing contract touched — Stage API, `syncVideos`/`entityVideo`, the shot/light/mark payloads,
|
||
scene-JSON round-trip and relative URLs are all as they were.
|
||
- `newCam` rides the existing `shot` op instead of a new `angle` op: one code path for buttons and
|
||
/director, and Lane B needs no change to key coverage cameras.
|
||
- Coverage cameras are numbered (cam2/cam3), not named `wide`/`close`: the label outlives the shot
|
||
size that produced it, and a lying label is worse than a boring one.
|
||
|
||
BLOCKED/REQUESTS:
|
||
- **@Lane B (the one thing that makes this real)**: implement `applyDirect` for `op:'move'` per the
|
||
contract above. Until then the camera snaps to the move's START and the END exists only in the
|
||
event — nothing animates.
|
||
- @orchestrator: no PLAN §4.1 schema change needed — moves land as ordinary transform/params keys.
|
||
Worth adding MOVES/EASES to the M5 grammar bullet in §5 when you next touch it.
|
||
- Still owed by John: a viseme-bearing test character (carried from session 4).
|
||
|
||
NEXT / orchestrator SYNC checklist (browser, :8020):
|
||
1. Select man → shot MS, lens 35mm → **🎬 move** with `push in`, dur blank, ease inout. Camera jumps
|
||
to the MS start and the PiP shows it. Console proof of the payload:
|
||
`addEventListener('scenegod:direct', e => console.log(e.detail), {once:true})` before pressing —
|
||
expect `op:'move'`, `t0` = the playhead, `dur` = min(6, scene remaining), `from`/`to` with `to`
|
||
closer. Once Lane B lands its handler, play should push in over the duration.
|
||
2. `zoom in` on the same subject: `from.pos` and `to.pos` must be IDENTICAL in that console line and
|
||
only `fov` differs (35→50mm ⇒ 37.85°→27.0°). That is the dolly-vs-zoom distinction on screen.
|
||
3. `orbit left` 25° then `crane up`: the subject stays centred through both ends (apply the END by
|
||
hand if B isn't ready: `stage.setTransform(camId,{pos:d.to.pos,rot:d.to.rot})`).
|
||
4. **+ angle**: with man selected, MS → 🎥 frame (cam becomes `shotcam`), then CU low 85mm →
|
||
**+ angle** → a NEW `cam2` appears in the dock/timeline rows, goes live, and `shotcam` has not
|
||
moved. Press 🎥 frame again → it now adjusts cam2 (the live one), creating nothing.
|
||
`scenes/music-video.json` (4 cams) is the scene to sanity-check that nothing else shifts.
|
||
5. Load a plate with `fit: frustum` → inspector shows **anchor** under `fit dist`; drag it 0.75→0.4
|
||
and the plate slides down live (horizon drops); 1.0 puts its top edge on the lens axis.
|
||
|
||
## 2026-07-28 session 12 — LOOK PASS: tone mapping, shadows, frame guide, dead controls
|
||
|
||
DONE (5 items, best-first, all shipped):
|
||
|
||
**A1 — ACES tone mapping + unlit backdrop plates + per-preset exposure.**
|
||
`stage.js:126-130` sets `toneMapping = ACESFilmicToneMapping`, `toneMappingExposure = 1.0`.
|
||
grammar.js ran physical sun intensities of 1.2–3.4 into `NoToneMapping`, so every lit face
|
||
clipped to 255,255,255 and a warm key went neutral white.
|
||
Plates stopped being re-lit: `_buildBackdrop` (`stage.js:400-420`) builds the wall with
|
||
`MeshBasicMaterial({map, fog:false, toneMapped:false})`, the dome material gained `toneMapped:false`
|
||
(`:404`), and so did the `screen` material (`:441`). The corner-mode ground copy is now built as its
|
||
own `MeshStandardMaterial` (`:414`) rather than `mat.clone()` — it still receives the character's
|
||
shadow, which is the only reason it stays lit. `toneMapped:false` is load-bearing on all of them: a
|
||
photo/Flow frame is display-referred and ACES over an already-graded image muddies its blacks.
|
||
Plate level is `params.plateExposure` (default 1) via `_plateLevel()` (`stage.js:487`), wired in
|
||
`setParam` next to `fitDist` (`:640`) and exposed as a 0–2 number field in the dock's backdrop block
|
||
(`dock.js:249`).
|
||
`exposure` added to every LIGHTS entry (`grammar.js:38-52`: day 1.0, golden_hour 1.1, night 1.6,
|
||
noir 1.2, horror 1.4, neon 1.3, overcast 1.0). `applyLight` writes it exactly like `bg`
|
||
(`presets.js:174`) and returns it (`:178`), so it rides the ambient entity → keyable, persists in
|
||
scene JSON. `Stage.setParam` handles `exposure` on a light entity (`stage.js:632`) through
|
||
`clampExposure` (exported, `stage.js:68`, clamps 0.05–4 so a keyed lerp through 0 can't black the
|
||
frame); `_buildLight`'s ambient branch applies `p.exposure ?? 1` on build (`:567`), and
|
||
`removeEntity` puts the renderer back to 1.0 when the ambient goes (`:249`) so loading a scene with
|
||
no ambient doesn't inherit the last one's level. Inspector also got an `exposure` field on the
|
||
ambient light next to `fog` (`dock.js:229`).
|
||
|
||
**A2 — a stage that actually casts shadows, with a box that fits the shot.**
|
||
`this._defKey` now casts (`stage.js:164-166`): until you added a light entity NOTHING cast a shadow
|
||
despite `shadowMap.enabled`, so characters hovered on an untouched stage. `shadowMap.type =
|
||
PCFSoftShadowMap` (`:125`). New `_shadowRig(dir)` (`stage.js:216`) is the one config for the default
|
||
key AND scene keys: `castShadow`, `mapSize 2048²`, `normalBias 0.02`, `bias -0.0005` — applied in
|
||
`_buildLight` (`:569`).
|
||
`shadowHalfExtent(bboxes, margin=6)` exported (`stage.js:73`) + private `_fitShadows()` (`:227`)
|
||
which sets `camera.left/right/top/bottom = ±h`, `near 0.5`, `far = boomDistance + 2h` and
|
||
`updateProjectionMatrix()` on `_defKey` and every directional light entity. Cost control per spec:
|
||
`_shadowDirty` set in `addEntity` (`:317`), `removeEntity` (`:252`), `setTransform` of a light
|
||
(`:626`), `setParam` of a light (`:630`), `_buildLight` (`:580`); consumed once per frame at the top
|
||
of the rAF loop (`:198`), BEFORE the `_paused` early-return so a paused render still gets a fit.
|
||
Key-light proxy sphere got `userData.chrome = true` (`stage.js:576`) — without it yellow spheres
|
||
would render into the mp4 the moment Lane C calls `pause()`.
|
||
|
||
**A3 — compose in the real frame.**
|
||
(a) `Stage.directorCamState()` (`stage.js:824`) returns `{pos, rot, fov}` off `_dirCam`;
|
||
`dock._addRig` uses it for a new camera's transform + fov (`dock.js:190-194`) and the button is now
|
||
`+ camera (from view)` with a tooltip (`dock.js:40`). `setActiveCamera(en.id)` unchanged.
|
||
(b) `⬚ frame guide`: a DOM overlay built in the Stage constructor next to the PiP
|
||
(`stage.js:143-153`) — four black bars + rule-of-thirds + 90% action-safe hairlines at 25% opacity,
|
||
`pointer-events:none`, `z-index:3` (PiP is 5, so the PiP stays clickable). `setFrameAspect(a)` /
|
||
`setFrameGuide(on)` / `frameGuide()` / `frameAspect()` at `stage.js:207-213`; `_layoutGuide()`
|
||
(`:196`) recomputes from the pure `guideBars()` and is called from `_resize()` (`:194`). Styles
|
||
`style.css:83-93`. Toggle is a `⬚ frame` button in presets.js's `lens` segment, default ON
|
||
(`presets.js:340-346`), guarded on `stage.setFrameGuide` existing so a stub Stage can't break the
|
||
panel. It is pure DOM and NOT tied to `_chrome()`, so it can never reach a captured frame.
|
||
|
||
**A4 — a fitted backdrop stops offering controls that do nothing.**
|
||
(1) `renderInspector` replaces the nine pos/rot/scale fields with one muted line
|
||
(`dock.js:209-217`, `.inote` style `style.css:71`) when `kind==='backdrop' && params.fit==='frustum'`
|
||
— `setTransform` hard-returns for exactly those, so those fields were silently eating input. The
|
||
`fit` select sits right below and already re-renders on change.
|
||
(2) `Stage.select()` (`stage.js:594-598`) detaches instead of attaching the gizmo for a
|
||
frustum-fitted plate; selection/inspector/onSelect all still fire. `setParam('fit', …)` re-runs
|
||
`select()` on the selected entity (`stage.js:637`) so switching frustum⇄free swaps the gizmo
|
||
immediately rather than on the next click.
|
||
(3) `videoTimeAt(t, start, dur)` exported (`stage.js:87`) and used by `syncVideos` (`stage.js:797`).
|
||
It used to WRAP (`((t-start)%d+d)%d`) while `Timeline._evalVideo` clamps — so a plate with
|
||
`videoStart:2` on a 10s clip showed frame 0 in the viewport at t=0 and the 8.0s frame in the mp4.
|
||
Same clamp both sides now: what you scrub is what you render.
|
||
|
||
**A5 — destructive delete asks first; the dock stops calling every failure "server offline".**
|
||
(1) `dock.js:288-303`: counts `transform+params+morphs` keys and `clips` blocks off
|
||
`window.timeline.scene.entities`, and only `confirm()`s when `keys+clips > 0`. A freshly added,
|
||
unkeyed entity is still one click. With no timeline mounted there is no count, so it confirms for
|
||
characters/props and never for cameras/lights, per spec.
|
||
(2) `load()` (`dock.js:26-43`) now separates `!r.ok` (keeps `this._loadErr = status + ': ' + body`)
|
||
from a thrown fetch (`this._offline`). `renderBrowser` (`:112-122`) shows the server's own message
|
||
(escaped with `esc()` — ship-check) for the first, "server offline" only for the second, and the
|
||
populated-empty state is now `no <tab> on the server yet — drag a .glb / .fbx / image from your
|
||
desktop onto the stage` instead of an instruction you cannot follow from a browser. Tab buttons
|
||
carry a live item count on a second line (`dock.js:51-54, 108-110`; `style.css:35-38`) so an empty
|
||
category is visible before you click it — a count appended inline overflowed the 65px tab.
|
||
|
||
VERIFICATION (headless, exact output):
|
||
```
|
||
$ node scenegod/web/presets_test.mjs
|
||
presets_test: ALL PASS (10 moves; push in 1.61m → 1.12m, orbit ±25°, zoom 35→50mm at a fixed 1.61m)
|
||
$ node scenegod/web/stage_test.mjs
|
||
stage_test: ALL PASS (fit s=2.186 → plate 21.9×12.3m over a 20.6×11.6m frame at 14m; shadow box
|
||
±6.95m solo / ±10.39m for a two-hander; 1600×600 gate bars 266.7px)
|
||
$ node scenegod/web/room3d_test.mjs
|
||
room3d_test: ALL PASS (26 CC bones paired against mixamo)
|
||
```
|
||
`node --check` clean on stage.js, dock.js, presets.js, grammar.js, stage_test.mjs, presets_test.mjs.
|
||
Lane B's `timeline_test.mjs` re-run read-only: `OK — timeline_test.mjs: all assertions passed`.
|
||
New assertions (all fail on the old code):
|
||
- presets_test: every LIGHTS key has a finite `exposure` in [0.5,2]; night/horror exposed ABOVE day
|
||
(the whole point); `applyLight(stub,'noir')` records `setParam(ambientId,'exposure',1.2)` on the
|
||
existing stub and the returned object carries it.
|
||
- stage_test `shadowHalfExtent`: empty → 4; one 1.7×0.6m character → exactly half-diagonal + margin
|
||
(6.95) and already > three's default ±5; a `covers()` helper asserting EVERY bbox corner sits
|
||
inside ±h for a two-hander 8m apart (10.39) and for a lone character 8m off-axis (10.25 > 8.3);
|
||
margin monotonicity; the 4m floor minimum.
|
||
- stage_test `guideBars`: 1600×600@16:9 → left/right (1600−1066.67)/2 and zero top/bottom;
|
||
800×800 → top/bottom (800−450)/2 and zero left/right; 1920×1080 → all zeros; three zero-size
|
||
viewports → zeros not NaN; plus a `gate()` check that the remaining rectangle is EXACTLY 16:9 at
|
||
four viewport shapes (that's the assertion with teeth — bar thicknesses alone could be self-consistent
|
||
and still wrong).
|
||
- stage_test `videoTimeAt`: 0/2/10→0, 3/2/10→1, 13/2/10→1, dur 0 and dur NaN → 0, plus an explicit
|
||
`notEqual` against what the old wrap returned (8.0) at t=0.
|
||
- stage_test `clampExposure`: 0→0.05, −3→0.05, 99→4, undefined→1.0 (old scenes unchanged), '1.6'→1.6.
|
||
|
||
DECISIONS:
|
||
- **The 10.7m shadow-length rationale in the A2 brief is wrong, and I did not write that assertion.**
|
||
A directional light's shadow lies EXACTLY behind its caster in light space — the shadowed ground
|
||
points are the caster's own projection along the light direction — so a low sun does not widen the
|
||
ortho box laterally at all, it only pushes DEPTH (near/far). Checked numerically at golden_hour's
|
||
9° elevation: the character's top (0,1.7,0) and the shadow tip (10.73,0,0) both project to light-Y
|
||
1.678. Asserting "≥ 10.7 so the shadow is not guillotined" would have been a test that passes for
|
||
a reason that isn't true. The real lateral defects are (a) entities spread wider than ±5, and (b)
|
||
the box being centred on the light→target axis, i.e. on the STAGE ORIGIN, not on the entities.
|
||
So `shadowHalfExtent` unions the bboxes **with the origin** before taking the half-diagonal — a
|
||
one-word change to the briefed formula that is a genuine fix: a lone character standing 8m
|
||
off-axis gets 6.95 (clipped!) under the centre-relative version and 10.25 (covered) under this one,
|
||
and that pair is asserted. `far` is where the low sun actually needed help: `boomDistance + 2h`.
|
||
- **Only a LIGHT move sets `_shadowDirty` in `setTransform`** (`stage.js:626`). The brief also
|
||
listed characters/props implicitly via add/remove, which I kept — but the timeline re-applies
|
||
EVERY entity's rest transform each tick, so flagging characters there would run a
|
||
`Box3.setFromObject` sweep over skinned meshes every single frame. Marked `ponytail:` in situ.
|
||
- **The corner-mode ground is a fresh MeshStandardMaterial, not `mat.clone()`.** Once the wall
|
||
became MeshBasic, cloning it would have silently made the shadow-catching ground unlit too.
|
||
- `plateExposure` clamps 0–4 in `_plateLevel` and a non-finite value falls back to 1 (a NaN from a
|
||
half-typed inspector field would otherwise paint the plate black).
|
||
- `select()` on a fitted plate DETACHES rather than hiding the gizmo, so `_tc.dragging` can never be
|
||
true for it — the pick path and `mouseUp`→`onChange` are untouched.
|
||
- Frame guide is deliberately NOT wired to `_chrome()`: `_chrome` is a WebGL-visibility switch and
|
||
the guide is DOM, so there is nothing to hide. It also can't be captured by `readPixels`/`toBlob`.
|
||
- Contracts preserved: Stage API (PLAN §4.2) is additive only — `directorCamState`, `setFrameAspect`,
|
||
`setFrameGuide`, `frameGuide`, `frameAspect`, plus the pure exports `shadowHalfExtent`,
|
||
`guideBars`, `videoTimeAt`, `clampExposure`. `scenegod:direct` (shot/light/mark/move),
|
||
`scenegod:clipdrop`, `scenegod:capturekey`, `syncVideos`/`entityVideo`, scene-JSON round-trip and
|
||
relative URLs all unchanged. `exposure` / `plateExposure` are ordinary `params` entries so they
|
||
round-trip through `captureState`/`applyState` and are keyable with no schema change.
|
||
|
||
BLOCKED/REQUESTS:
|
||
- **@Lane C (small, optional):** the frame guide defaults to 16:9. If `render.js` knows the delivery
|
||
size, one call — `stage.setFrameAspect(width / height)` before a render setup — makes the guide
|
||
match what will actually be encoded. Purely cosmetic; nothing breaks without it.
|
||
- **@Lane B (FYI, no action needed):** `Timeline._evalVideo`'s clamp is now the shared definition —
|
||
`stage.videoTimeAt(t, start, dur)` is exported if you'd rather import it than keep the inline copy.
|
||
- @orchestrator: `exposure` (ambient light param) and `plateExposure` (backdrop param) are new
|
||
keyable scalars worth a line in PLAN §4.1's params-by-kind note when you next touch it.
|
||
- Still owed by John: a viseme-bearing test character (carried from session 4).
|
||
|
||
NEXT / browser checks the orchestrator must do at :8020 (nothing here is DOM-testable headlessly):
|
||
1. **Look pass.** Load `scenes/sync2-sunset` (or any old scene). Every preset button in turn: `noir`
|
||
must now show the PLATE (it used to black out at ambient 0.15) while the character stays hard-lit;
|
||
`night` and `horror` should read as lit-but-dark rather than as holes; `day` should no longer clip
|
||
faces to flat white. Ambient inspector now has `exposure` — drag it and the whole frame should
|
||
ride, live.
|
||
2. **Shadows.** Fresh page, drop ONE character, no lights: he should now have a shadow (he never did).
|
||
Then two characters ~8m apart → both shadows present and neither sliced off in a straight line.
|
||
`golden_hour` → long soft shadow, no acne bands on the body.
|
||
3. **Frame guide.** Black bars + thirds + safe box on load; `⬚ frame` in the DIRECT bar toggles them;
|
||
drag the timeline panel taller/shorter (or resize the window) and the bars must re-fit. The PiP
|
||
must still be clickable through/over the guide. Then render an mp4 and confirm the bars are NOT
|
||
in it (they're DOM, but confirm once).
|
||
4. **+ camera (from view).** Orbit to a framing you like → `+ camera (from view)` → the PiP shows
|
||
the SAME framing (it used to spawn at [0,1.6,4] looking down −Z).
|
||
5. **Fitted plate.** Select a `fit:frustum` backdrop → the inspector shows the muted "placed by the
|
||
camera…" line instead of pos/rot/scale, and there is NO gizmo. Switch `fit` to `free` → nine
|
||
fields and the gizmo come back immediately. New `plate exp` field: 0.5 should visibly darken it.
|
||
6. **videoStart.** Video plate, `vid start = 2`, scrub to t=0 → the plate's FIRST frame (not 8.0s),
|
||
and the rendered mp4 must match frame-for-frame at t=0.
|
||
7. **Delete + dock errors** (item A5 is browser-only by design, no DOM harness):
|
||
(a) add a character, delete → no dialog; (b) key it twice, delete → dialog naming "2 keyframes";
|
||
(c) stop the server, reload → "server offline"; (d) run a scratch server on ANOTHER port with
|
||
`SCENEGOD_ASSETS=/nope` → the dock shows the server's own "asset root missing: … (set
|
||
SCENEGOD_ASSETS)" text, not "offline". Tab buttons should read CHARACTERS/2 style counts.
|
||
|
||
## 2026-07-28 session 8 — adversarial-review fixes (6 majors + 4 minors)
|
||
An independent reviewer audited session 7 and confirmed six majors and five minors. Every one was
|
||
REPRODUCED first with a scratch harness (`scratchpad/laneA5/repro.mjs`) that loads the SHIPPED
|
||
files — stage.js/dock.js with their three/room3d imports stripped and a stubbed `globalThis.THREE`
|
||
so real Stage/Dock methods can be called via `Object.prototype.call`, plus Lane B's REAL
|
||
`Timeline` for anything that crosses the seam. That harness is now the pattern the new assertions
|
||
use, so these fixes are testable headlessly instead of "browser-only by design".
|
||
|
||
DONE (defect → root cause → fix; every fix has an assertion that goes red when reverted):
|
||
|
||
1. **`exposure` was advertised as keyable and nothing keyed it.** Reproduced: `day` at t=0,
|
||
`night` at t=5, scrub back → the ambient's param keys were `color/intensity/bg` ×2 and exposure
|
||
stayed 1.6 over the whole day stretch (viewport AND mp4, then saved). Root cause: Lane B's
|
||
`applyDirect` light branch keys sun colour/intensity/transform + ambient colour/intensity/bg,
|
||
and the exposure was only ever pushed to the renderer. Fix in `presets.js applyOps` (`:271`):
|
||
after the normal light payload, fire a SECOND op in the handler's documented GENERIC shape
|
||
(`{op:'light', entityIds:[ambientId], params:{exposure}}` — the `else` branch of
|
||
`if (d.sunId != null && d.sun)`), so it lands as an ordinary param key at the playhead with no
|
||
Lane B change. Verified end-to-end against the real Timeline: keys `exposure@0` + `exposure@5`,
|
||
scrub to 0 → 1.0, scrub to 5 → 1.6. Cost: two undo entries per preset press instead of one —
|
||
REQUEST below asks Lane B to fold it into the light branch, after which this stays correct.
|
||
2. **`+ ambient` / deleting an ambient reset the whole frame to 1.0.** Reproduced: 1.6 → 1.0 on
|
||
add, still 1.0 after deleting the new one. Root cause: two places wrote a RENDERER GLOBAL from
|
||
whichever light was built/removed last. Fix: one rule, `sceneExposure(entities)` (`stage.js:80`,
|
||
exported+pure) = the last ambient that actually STATES an exposure, clamped; `_applyExposure()`
|
||
(`:656`) is the only writer and runs from `addEntity`, `removeEntity` and `setParam('exposure')`.
|
||
`_buildLight` no longer touches the renderer at all.
|
||
3. **The ⬚ frame guide was geometrically wrong on a narrow viewport.** Reproduced: 900×700 @16:9
|
||
→ 96.875px bars top and bottom, while the vertical half-angle of the render and of the viewport
|
||
are IDENTICAL (0.4142 at 45°) — the mask hid content that is in the mp4. Root cause: the guide
|
||
assumed the render loses height; three's fov is VERTICAL and `_render` only rewrites
|
||
`cam.aspect`, so the render can only gain/lose WIDTH. Fix: `guideBars` (`:143`) returns
|
||
left/right bars only — top/bottom are now always 0 — plus `overscan` (delivery ÷ viewport
|
||
aspect) which `_layoutGuide` (`:267`) turns into a `.over` class; `style.css` marks the gate
|
||
edges dashed with "the render is wider than this viewport — its sides are off-screen".
|
||
4. **corner plates showed one photo at two levels across the fold.** Reproduced: wall
|
||
`MeshBasicMaterial toneMapped=false`, ground `MeshStandardMaterial toneMapped=undefined`
|
||
(→ three's default true) — lit + tone-mapped below the fold, neither above it. Root cause: the
|
||
ground was a different material MODEL because it was doing double duty as the shadow catcher.
|
||
Fix: `_plateMat()` (`:566`) is now the ONE factory for every surface showing the plate (wall,
|
||
corner ground, dome), and the shadow lands on a dedicated `THREE.ShadowMaterial` catcher
|
||
(`:455`) floated 2mm over the ground copy. `setParam('plateExposure')` now only collects
|
||
map-bearing materials — `setScalar(1)` on a ShadowMaterial would have erased the shadow.
|
||
5. **Deleting an unkeyed camera stranded its cut → every later save 422s.** Reproduced: the old
|
||
`ask = keys+clips > 0` rule with a `cameraCuts:[{t:0,camera:'e1'}]` model → no confirm, one
|
||
dangling cut; and `removeEntity` left `_activeCamId` on the dead id. Fix: `deleteWarning(tl, en)`
|
||
exported from `dock.js:19` counts cuts as work, names them in the dialog and hands the cut
|
||
OBJECTS back; the delete handler (`dock.js:326-335`) drops them through the public
|
||
`timeline.removeCut` inside one `group()`. `stage.removeEntity` (`:369`) falls back to the
|
||
director cam when the active camera is the one being deleted.
|
||
6. **`videoStart` still meant two things.** Reproduced: inspector edit → model 0 / stage 2 →
|
||
viewport showed the 3.0s frame and the render the 1.0s frame at t=3. Root cause: the inspector
|
||
wrote only to the stage while `Timeline._evalVideo` reads the timeline's own params snapshot
|
||
(taken once by `_mirror`). Fix: every inspector param edit goes through `Dock._param`
|
||
(`dock.js:237`) = `stage.setParam` + Lane B's own `syncFromStage()`. Now 1.0 vs 1.0.
|
||
|
||
MINORS fixed:
|
||
- **Per-frame shadow sweep.** Any light param key set `_shadowDirty`, and `_evalParams` re-applies
|
||
every keyed param every tick → `Box3.setFromObject` over every character, every frame. Now
|
||
`shadowAffectingParam(key)` (`:93`, castShadow/type only) and `samePos()` (`:96`) gate it, so a
|
||
keyed sun re-applying the same pose is free. The refit was also moved OUT of the rAF loop into
|
||
`_render` (`:842`) — the render path drives `_render` directly while paused, so the fit is now
|
||
deterministic per drawn frame instead of dependent on rAF timing.
|
||
- **shadowHalfExtent's premise is now enforced, not assumed.** The light's target was a CHILD of
|
||
its wrapper at z=−1, so gizmo-moving a light carried the light→target axis (and therefore the
|
||
ortho box) off the stage. `_buildLight` (`:643`) parents the target to the SCENE at the origin:
|
||
a sun's aim IS its position, which is exactly the boom `applyLight` solves.
|
||
- **`setFrameAspect` was dead code.** Now wired: `aspectFromSize()` (`presets.js:15`) parses
|
||
tlui's render-size selector value and `mountDirectPanel` (`:368`) reads `select.rsize` (delegated
|
||
`change` listener + a deferred first read, because the timeline mounts after the DIRECT panel).
|
||
- **Implicit global `view`.** All three constructor appends use `this.view`; a static assertion in
|
||
stage_test keeps it that way.
|
||
|
||
DECISIONS:
|
||
- The exposure key rides the GENERIC op shape rather than me calling timeline code directly: the
|
||
lane contract is "you apply nothing to the timeline, B keys it", and the generic branch is a
|
||
documented part of the same handler. The one cost (2 undo entries) is disclosed, not hidden.
|
||
- Camera-cut cleanup DOES call `timeline.removeCut` from dock — deleting an entity has no event
|
||
contract, and the alternative was leaving a scene that cannot be saved until Lane B ships. It is
|
||
idempotent: if `_mirror` starts dropping cuts (REQUEST below), `w.cutObjs` is simply empty.
|
||
- `Dock._param` calls the whole `syncFromStage()` rather than poking Lane B's model per key. It
|
||
also copies transforms, which is exactly what `save()` already does, and rest transforms are
|
||
only read for entities with no transform keys.
|
||
- The corner ground could have gone the other way (make the wall lit too) — that would re-open the
|
||
bug where `noir` (ambient 0.15) blacks a photograph out. Display-referred plate + separate
|
||
catcher keeps both properties.
|
||
- Reverting each fix individually was checked: 13 of 14 reverts turn the new assertions red. The
|
||
one that does not is re-adding the old `_buildLight` exposure write while `_applyExposure()` is
|
||
still in `addEntity` — the stomp is immediately corrected in the same synchronous call, so the
|
||
defect genuinely does not occur in that configuration. That is why the write was REMOVED from
|
||
`_buildLight` rather than guarded: one writer, one rule.
|
||
|
||
BLOCKED/REQUESTS:
|
||
- **@Lane B (real request this time, not an FYI):** please key `exposure` inside `applyDirect`'s
|
||
light branch — `if (d.exposure != null) key(d.ambientId, ['exposure', d.exposure]);` right after
|
||
the `bg` line. The main light payload already carries `exposure` at top level. That makes one
|
||
preset press one undo entry again; my second generic-shape event can then be deleted (it lands on
|
||
the same (t, 'exposure') key, so both together are harmless in the meantime).
|
||
- **@Lane B (optional):** `Timeline._mirror` is the natural home for "entity gone from stage → drop
|
||
the cameraCuts that point at it"; dock does it defensively today.
|
||
- **@Lane C (optional, unchanged):** `render.js` could call `stage.setFrameAspect(w/h)` before a
|
||
render; the guide now follows tlui's `select.rsize` on its own, so nothing is broken without it.
|
||
- Still owed by John: a viseme-bearing test character (carried from session 4).
|
||
|
||
VERIFICATION (exact output):
|
||
```
|
||
$ node scenegod/web/presets_test.mjs
|
||
presets_test: ALL PASS (10 moves; push in 1.61m → 1.12m, orbit ±25°, zoom 35→50mm at a fixed 1.61m)
|
||
$ node scenegod/web/stage_test.mjs
|
||
stage_test: ALL PASS (fit s=2.186 → plate 21.9×12.3m over a 20.6×11.6m frame at 14m; shadow box
|
||
±6.95m solo / ±10.39m for a two-hander; 1600×600 gate bars 266.7px)
|
||
$ node scenegod/web/room3d_test.mjs
|
||
room3d_test: ALL PASS (26 CC bones paired against mixamo)
|
||
$ node scenegod/web/timeline_test.mjs # Lane B's, read-only
|
||
OK — timeline_test.mjs: all assertions passed
|
||
```
|
||
`node --check` clean on stage.js, dock.js, presets.js, grammar.js, room3d.js and all three .mjs.
|
||
|
||
NEXT / browser checks the orchestrator must do at :8020:
|
||
1. **Guide.** Drag the window narrow (taller than 16:9): NO black bars top/bottom any more, and the
|
||
gate edges go dashed with the "render is wider" note. Widen: pillarbox bars return. Change
|
||
tlui's render size — with a non-16:9 option the gate must change shape (all three are 16:9
|
||
today, so this is only visible if Lane C adds one).
|
||
2. **Exposure.** `day` at t=0, `night` at t=5, scrub: the frame's level must ride with the presets.
|
||
Then `+ ambient` — the image must NOT jump; delete it — still no jump.
|
||
3. **Corner plate.** mode=corner under `noir`: the ground third and the wall must read as ONE
|
||
photograph, and a character standing on it must still cast a visible shadow onto it.
|
||
4. **Camera delete.** `+ camera (from view)` → gizmo-move it (no key) → 🗑: the dialog must mention
|
||
"1 camera cut", and after confirming, Save must succeed (this is the 422 that was reproducible).
|
||
The PiP must close instead of showing a duplicate of the director view.
|
||
5. **vid start.** Video plate, `vid start = 2`, scrub to t=3 → the 1.0s frame, and the rendered mp4
|
||
must match at t=3.
|
||
6. **Key light aim.** Add a key light, translate it far along X: the character keeps his shadow
|
||
(the light still aims at the stage), and rotating a key light now does nothing — expected.
|
||
|
||
## 2026-07-28 session 9 — round-2 review: 2 majors (both UNDO regressions of my own session-8 fixes) + 6 minors
|
||
Every defect was REPRODUCED first against the shipped files with the reviewer's own harness
|
||
(`scratchpad/laneA6/harness.mjs`, copied from `scratchpad/rev/`) — real stage.js/dock.js/presets.js
|
||
with three/room3d imports stripped, plus Lane B's REAL `timeline.js`. Then every fix was broken
|
||
again in a scratch COPY (`scratchpad/laneA6/discriminate.sh`, 10 mutations) to prove the new
|
||
assertions discriminate: 10/10 go RED on the mutation and GREEN on restore.
|
||
|
||
### MAJOR 1 — one Ctrl+Z after deleting a camera resurrected the dangling cut
|
||
REPRO (`laneA6/r5b_cut.mjs`): `+ camera (from view)` → `_mirror` auto-cuts at 0 → gizmo-move → 🗑 →
|
||
`after delete: cuts=[] dangling=0`, then ONE `tl.undo()` → `cuts=[{"t":0,"camera":"e1"}] dangling=1`,
|
||
and the real validator (`.venv/bin/python -c "from scenegod.server import validate_scene"`) said
|
||
`["cameraCut references non-camera entity 'e1'"]`.
|
||
ROOT CAUSE: a delete spans two subsystems and only one of them has an undo. `Stage.removeEntity`
|
||
pushes nothing; `Timeline._mirror` splices the entity + its tracks out just as silently; but my
|
||
session-8 cleanup removed the cuts through `tl.group(() => tl.removeCut(c))`, which DOES push one.
|
||
Half-undoable = a state that is neither before nor after.
|
||
FIX (`dock.js`, exported `dropCuts(tl, cuts)`): still `tl.removeCut` for the model change (so Lane B
|
||
keeps owning `_activeCam` invalidation), then truncate `tl.undoStack` back to the mark taken before
|
||
the loop — the same field tlui marks/collapses around a drag. The cuts leave the way the camera did.
|
||
The dialog now says so, and lists only what exists: `Delete "camera"? 1 camera cut goes with it.
|
||
This cannot be undone.` (was `0 keyframes, 0 clip blocks, 1 camera cut go with it.`).
|
||
PROOF: post-undo scene through the real validator → `validate_scene(...) -> []`, six undos deep.
|
||
TESTS (stage_test): "no undo entry survives", "undoing everything on the stack can never resurrect a
|
||
cut whose camera is gone", plural/singular + "cannot be undone" wording, `dropCuts(null|{})` no-ops.
|
||
|
||
### MAJOR 2 — one Ctrl+Z after a light preset stripped ONLY the exposure key
|
||
REPRO (`laneA6/r1b_undo.mjs`): `day`@0 + `night`@5 → undoStack 4 (2/press); one undo left
|
||
`color@5,intensity@5,bg@5` with no `exposure@5` — night's colours running at day's ACES level.
|
||
ROOT CAUSE: one `scenegod:direct` event gets one `applyDirect` → one `group()` → one undo entry, and
|
||
a preset has to go out as TWO events.
|
||
WHY THE BRIEF'S FIX ("fold exposure into the single payload, delete the second event") IS NOT
|
||
AVAILABLE YET, measured not assumed: `grep -c exposure timeline.js tlui.js` → **0 and 0**. The light
|
||
branch keys sun colour/intensity/transform + ambient colour/intensity/bg and has no `exposure` line,
|
||
and the two branches are `if (d.sunId != null && d.sun) … else {generic}` — one event, one branch, so
|
||
a single event can carry EITHER the sun transform key OR a third ambient param, never both. The
|
||
payload already carries `exposure` at top level; nothing reads it. Counterfactual, run end-to-end
|
||
against the real Timeline (`laneA6/cf.mjs`):
|
||
```
|
||
SHIPPED (2 events, joined) | undo entries/press: 1 | exposure keys: 2 | renderer at t=0 after scrub back: 1
|
||
2nd event DELETED | undo entries/press: 1 | exposure keys: 0 | renderer at t=0 after scrub back: 1.6
|
||
```
|
||
i.e. deleting the event alone re-opens round-1 major #1 (the day stretch renders at night's 1.6).
|
||
FIX (`presets.js` `_undoMark`/`_undoJoin` around the light branch): `collapseUndo` is Lane B's own
|
||
documented tool for "edits that span EVENTS rather than a function call" — it is how tlui turns 50
|
||
mousemoves into one entry. Mark the stack before the first event, collapse after the last. One press
|
||
= one entry; one Ctrl+Z takes the whole look (sun keys included) back.
|
||
TESTS (presets_test, real Timeline): `undoStack.length` grows by exactly 1 per press; the press keys
|
||
`bg,color,exposure,intensity` on the ambient AND `color,intensity` on the sun at the playhead; ONE
|
||
undo removes ALL of them (including the half that arrived on the other event) and restores the stack
|
||
depth; the earlier presses still agree with each other.
|
||
|
||
### MINORS
|
||
1. **Thirds/action-safe were drawn at VIEWPORT fractions in the overscan branch.** `guideBars` now
|
||
also returns `side` — the SIGNED inset of the encoded frame's edges — and `_layoutGuide` places
|
||
the gate at it, so on a 900×700 window at 16:9 the gate hangs off both sides at −172px and its
|
||
33.333% mark lands at x=242.6 (the real left third) instead of 300. `.frameguide{overflow:hidden}`
|
||
clips it; the dashed "⟷ the render is wider…" disclosure moved from `.fggate` to `.frameguide`
|
||
because the gate's own edges are now off-screen. Asserted twice: the pure geometry AND
|
||
`_layoutGuide` driving a fake DOM (`-172px` / `267px`).
|
||
2. **`setTimeout(readSize, 0)` lost the race** with two awaited dynamic imports. Deleted:
|
||
`mountDirectPanel` returns `syncFrameAspect`, and index.html calls it right after `new TimelineUI`
|
||
— the first moment `select.rsize` exists. Asserted: no timer in the code, and the call site is
|
||
AFTER `new TimelineUI(` in index.html.
|
||
3. **Light rotation was a silent no-op** (target nailed to the origin). Made honest rather than
|
||
re-broken: the inspector shows `pos` + "aims at the stage centre — MOVE it to change the light
|
||
angle (rotating does nothing)" (ambient: "no position, no angle"), `e`/`r` refuse to switch the
|
||
gizmo on a light, and selecting one resets it to translate. Plus the degenerate case the review
|
||
named: a light dragged ONTO (0,0,0) had a zero-length light→target vector — `_aimLight` aims it
|
||
straight down instead.
|
||
4. **Shadow catcher kept three's default `fog:true`** over a fog-free plate. `fog:false`, asserted.
|
||
5. **The ambient `exposure` field is inert once keyed** — now it says so. `keyedParam(tl,id,key)`
|
||
(exported from dock.js) + a message through **tlui's** toast channel (`window.sgToast`), the same
|
||
banner that already carries "… is keyed — press ⏺ key…" for a gizmo drag, so if Lane B lands the
|
||
same message inside `syncFromStage` (their timeline_test is currently red on exactly that) the two
|
||
writes overwrite instead of stacking two banners.
|
||
6. **Stale comment** at `_shadowDirty = true` → "consumed once per DRAWN frame in `_render`".
|
||
The "1 camera cut go" grammar nit went with MAJOR 1's dialog rewrite.
|
||
|
||
DECISIONS:
|
||
- Delete stays NOT undoable, in both halves. Restoring an entity would mean re-loading its asset and
|
||
re-inserting Lane B's tracks; the honest reading is a one-way door, and the dialog now states it.
|
||
Truncating `undoStack` (rather than never calling `removeCut`) keeps the model change inside Lane
|
||
B's method, so `_activeCam` invalidation stays theirs.
|
||
- I did NOT delete the second exposure event: measured, it is still the only thing keying `exposure`
|
||
(counterfactual above). The undo cost it used to carry is gone, which was the actual defect. The
|
||
comment marks the exact one-line deletion for the day Lane B keys `d.exposure`, after which
|
||
`_undoJoin` self-neutralises (`collapseUndo` is a no-op for a single entry).
|
||
- The gate rectangle IS the encoded frame, even off-screen. Every interior mark is a percentage of
|
||
it, so this is one rule instead of a second set of overscan-aware coordinates for thirds and safe.
|
||
|
||
BLOCKED/REQUESTS:
|
||
- **@Lane B (unchanged, now measured):** `if (d.exposure != null) key(d.ambientId, ['exposure',
|
||
d.exposure]);` after the `bg` line in `applyDirect`'s light branch. `grep -c exposure timeline.js`
|
||
= 0 today. When it lands, delete the second `_fire` + `_undoMark`/`_undoJoin` in presets.js.
|
||
- **@Lane B (FYI):** external harnesses that cherry-pick `Stage.prototype` methods need
|
||
`_aimLight` added alongside `_buildLight` now (mine and stage_test's are updated).
|
||
- Still owed by John: a viseme-bearing test character (carried from session 4).
|
||
|
||
VERIFICATION (exact output):
|
||
```
|
||
$ node scenegod/web/presets_test.mjs
|
||
presets_test: ALL PASS (10 moves; push in 1.61m → 1.12m, orbit ±25°, zoom 35→50mm at a fixed 1.61m)
|
||
$ node scenegod/web/stage_test.mjs
|
||
stage_test: ALL PASS (fit s=2.186 → plate 21.9×12.3m over a 20.6×11.6m frame at 14m; shadow box
|
||
±6.95m solo / ±10.39m for a two-hander; 1600×600 gate bars 266.7px)
|
||
$ node scenegod/web/room3d_test.mjs
|
||
room3d_test: ALL PASS (26 CC bones paired against mixamo)
|
||
```
|
||
`node --check` clean on stage.js, dock.js, presets.js, grammar.js, room3d.js and all three .mjs.
|
||
Lane B's `timeline_test.mjs` is RED at the moment on THEIR own new assertion ("an edit to a KEYED
|
||
param is no longer discarded in silence" — timeline.js has no such message yet); nothing in it
|
||
touches Lane A files.
|
||
|
||
BROWSER CHECKS for the orchestrator at :8020:
|
||
1. **Camera delete + undo.** `+ camera (from view)` → 🗑 → dialog reads "1 camera cut goes with it.
|
||
This cannot be undone." → Ctrl+Z: NOTHING comes back, and Save still succeeds.
|
||
2. **Light preset undo.** `day` at 0, `night` at 5, ONE Ctrl+Z at the playhead: night's colours AND
|
||
its level go together (the frame must not sit at day's exposure with night's colours).
|
||
3. **Guide on a tall window.** Drag the window narrow: dashed edges + the "⟷ the render is wider…"
|
||
note, and the thirds must sit INSIDE the visible area at ~27%/73%, not at 33%/67%.
|
||
4. **Light inspector.** Select a key light: pos + the "aims at the stage centre" line, no rot/scale
|
||
fields, and `e` leaves the gizmo in translate.
|
||
5. **Keyed exposure.** After any preset, type into the ambient's `exposure`: the tlui toast must say
|
||
"exposure is keyed — … Press ⏺ key…" (and only one banner, if Lane B lands theirs too).
|