SCENEGOD/lanes/A-stage.md
type-two f20a8d5d4e [m11-A] camera moves, + angle, fitAnchor in the inspector
MOVES grammar + solveMove(): push in/pull out (one rung along the shot ladder,
along the aim ray), truck L/R (lateral, parallel, no re-aim), crane up/down
(vertical then re-aim, floor-clamped), orbit L/R (constant distance, +/-25deg),
zoom in/out (focal only - pos and rot returned identical). Amounts are
frame-relative so a move reads the same on a CU as a WS. Emits a new 'move' op
on scenegod:direct with explicit from/to states, pre-clamped duration and ease;
the START is applied to the live camera immediately.

'+ angle' creates a NEW camera from the current shot settings instead of
reframing the active one - the friction hit while building a 4-camera scene by
hand. 🎥 frame now prefers the live camera so it adjusts what you just made
rather than silently editing camera 1. fitAnchor exposed in the backdrop
inspector (it was inert from the UI).

Verified live: push_in 1.975->1.588m fov fixed; zoom_in travel 0.000 with
pos/rot identical, fov 37.85->26.99; orbit_L constant distance; truck_R
parallel. 4 suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:58:10 +10:00

17 KiB
Raw Blame History

Lane A — Stage: viewport, assets on stage, retarget port

You own: scenegod/web/index.html, style.css, room3d.js, stage.js, dock.js. Milestone: M1 (do not start M2 items until the orchestrator flips this line).

A1. index.html skeleton (first commit)

  • Layout: full-viewport CSS grid — left dock #dock (280px), center #view, bottom #timeline (260px, empty div — Lane B mounts into it), right #inspector (260px). Dark UI, system font; steal the visual language of MESHGOD's rigroom (read /Users/johnking/Documents/MESHGOD/meshgod/web/rigroom.html).
  • Importmap: copy rigroom's exactly (same three.js version + addons paths) so behavior matches the code you're porting.
  • Script entry: <script type="module" src="/web/main-stage.js">? No — keep entry inline in index.html: import {Stage} from './stage.js' etc., create the Stage, Dock, and (when it exists) new Timeline(stage) guarded by a dynamic import that tolerates timeline.js not existing yet: try { const {Timeline} = await import('./timeline.js'); ... } catch {}. That keeps lanes decoupled — your page must never be broken by B's absence.
  • Style: style.css; leave a /* === LANE B === */ marker at the bottom.

A2. room3d.js — port from rigroom.html (the crown jewels)

Source: /Users/johnking/Documents/MESHGOD/meshgod/web/rigroom.html lines ~125290 (loaders, canon/boneMap, captureRest, bakeRetarget, stats, disposeRoot). Port rules:

  • Make it a real ES module with the exports listed in PLAN §4.2. No DOM, no globals, no toast() — throw Errors, callers handle UI.
  • Parameterize the target: rigroom's bakeRetarget closes over the global char. New signature bakeRetarget(clip, srcRoot, srcRest, tgtRoot, tgtRest) -> AnimationClip. Keep the algorithm byte-for-byte otherwise: 30fps sampling, world-space rotation delta from source rest applied to target rest, parent-first world quat propagation, hips world-position track scaled by target/source hips height ratio, quaternion sign continuity (dot < 0 → negate), and the duplicate-FBX-hierarchy dedupe (bind only first bone per canon key).
  • Keep canon() exactly as-is (mixamorig\d*[:_]? strip) — it encodes real Mixamo quirks.
  • Self-check: room3d.js gets a tiny exported _selftest() that builds two 3-bone synthetic skeletons with different rests, bakes a 1s clip across, and asserts end-pose world quats match within 1e-3. Wire it to ?selftest=1 on the page; log the result.

A3. stage.js — the Stage class (PLAN §4.2 API)

  • Renderer setup: copy rigroom's (antialias, PMREM + RoomEnvironment IBL — metals gotcha), hemisphere + key directional light as default lights (replaced by scene light entities when present), circle floor + grid, OrbitControls director camera.
  • addEntity(desc):
    • character/prop: fetch /assets/file?path=, parseAny, normalize scale like rigroom (fit ~1.7m for characters; props keep native size but expose scale), ground to y=0, attach to a THREE.Group wrapper — ALL transforms (gizmo + timeline) act on the wrapper, never the loaded root. For characters: captureRest once at load, store on the entity; create AnimationMixer on the wrapper.
    • backdrop: params.modeplane: textured PlaneGeometry, height from image aspect × params.width (default 10m); corner: plane + floor plane sharing the image bottom third (cheap "L" street corner); dome: 40m sphere, side: BackSide, texture on inside. SRGBColorSpace on all image textures.
    • camera: PerspectiveCamera in a wrapper + a small frustum helper mesh so it's visible/selectable in the director view. params.fov.
    • light: key → DirectionalLight + helper; ambient → replaces the default hemisphere intensity. Adding the first key light dims defaults.
  • Selection: raycast on click → select(id), outline or bbox highlight, TransformControls gizmo (W/E/R = move/rotate/scale) on the wrapper; gizmo drag end → onChange(entity).
  • prepareClip(id, path, clipIndex): fetch+parse the clip source, cache {srcRoot, srcRest, anims} per path (Map), bake per (entity, path, index) and cache the baked clip. Baking is the expensive step — never re-bake on seek.
  • renderActiveCamera(canvas): render the active camera to a small PiP canvas overlay (M2) and, for Lane C's final render, to the main canvas at exact size. Keep director-orbit rendering the default main view.
  • captureState()/applyState(): round-trip with scene JSON entities. Assets with source.type:"upload" (drag-and-dropped local files) live only in memory — mark them in the dock and warn on save.

A4. dock.js

  • Tabs: Characters / Animations / Props / Backdrops from GET /assets/tree (Lane C; until it exists use a hardcoded mock=1 tree + local file-drop — drag any glb/fbx/image onto the page adds it as source.type:"upload").
  • Click or drag-onto-viewport → stage.addEntity. Animations are NOT added to the stage; dragging one onto a character in the viewport (raycast under drop point) or onto its inspector calls prepareClip + M1 temp "play once" button. In M2, B takes over clip placement (dock drag → timeline track).
  • Inspector panel: selected entity's label, kind, transform numbers (editable), params (fov slider for cameras, color+intensity for lights, backdrop mode), delete button, "⏺ key" button that emits window.dispatchEvent(new CustomEvent('scenegod:capturekey',{detail:{id}})) — Lane B listens; you don't call timeline code directly.

M1 acceptance (log it with evidence)

  • ?selftest=1 retarget check passes.
  • Load lady.glb (put test files in assets/ yourself — grab from MESHGOD library banks), drop street.jpg as plane backdrop, gizmo-move the lady, drop walk.fbx on her → she walks in place. Screenshot in log dir welcome (logs/shots/, yours to create).
  • Page loads clean with timeline.js absent AND with server absent (mock=1).

ORCHESTRATOR UPDATES

2026-07-18 — M1 ACCEPTED, SYNC 1 done. M2 is UNLOCKED.

M1 verified end-to-end in-browser by the orchestrator (character + backdrop + retargeted clip + timeline scrub, all through the real Stage). Answers + M2 order:

  1. /assets/tree item shape confirmed (from Lane C's server): {name, path, formats: ["fbx", ...], thumb: relpath|null} — rigroom-style, no files array. Drop your bare-string fallback. /web/* absolute imports are confirmed correct as served.
  2. Priority 1 — eventize clip drops. stage.setMixerAuto(false) is now active whenever the timeline mounts, so your drop-clip-to-preview path is dead in the integrated page. Replace it: on clip-drop onto a character, dispatch window CustomEvent 'scenegod:clipdrop' with {detail: {id, path, clipIndex}} (Lane B adds the block at the playhead). Keep bake-and-preview ONLY when no timeline is present.
  3. Priority 2 — make sure onChange fires for every entity added via applyState (SYNC finding: tlui's names column stayed empty after a programmatic scene load — fix on both sides; B is doing theirs).
  4. Then per plan: multiple cameras + PiP overlay (renderActiveCamera needs to work as the M3 final-render target — treat that contract as hard), backdrop rebuild-on-param-change, drag-from-dock onto viewport. Test assets now live in assets/{characters,animations,backdrops}/test/ (man.fbx, hiphop.fbx, carrying.fbx, street.jpg — gitignored, on ultra).

2026-07-18 — M2 ACCEPTED, SYNC 2 PASSED. M3-polish unlocked.

Your entity-mirror seam report was exactly right and well-diagnosed. Resolution: Lane B's Timeline now subscribes to stage.onChange and mirrors adds/removes into its model (orchestrator implemented, timeline.js _mirror) — your onChange-on-every-add fix is what made that possible; no dedicated entityadd/remove events needed. Verified live: 6 dock-added entities all keyframable, full sunset demo saved as scene sync2-sunset. M3-polish list (order):

  1. PiP is enormous — it covered ~70% of the viewport in the SYNC 2 screenshot. Cap it (~24% width, bottom-right corner, click to swap director/active-cam views or a size toggle).
  2. lady.glb (non-mixamo rig, assets/characters/test/lady.glb) renders flat white — her textures didn't come through the GLB load path. Check whether it's the asset or a loader/material issue (compare in rigroom).
  3. Keep renderActiveCamera/pause/resume stable — the finalRender client drives them now (SYNC 3).

2026-07-18 — session 3 ACCEPTED (PiP + spec-gloss both verified live).

One SYNC 4 regression found & hotfixed by orchestrator, then M4-A unlocked.

  1. applyState dropped cameras/lights on scene load: they carry source:{type:'upload'} and applyState skips all upload entities, so a loaded scene silently lost its whole directing rig (verified: 3 of 6 entities landed; PiP dead). Hotfix in your stage.js (kind !== camera/light never skipped) — absorb it, then do the clean fix: schema now has source:{type:'none'} (PLAN §4.1) — emit it for camera/light in addEntity defaults + captureState, treat 'none' as always-rebuild in applyState. Keep tolerating legacy 'upload' cams (saved scenes exist).
  2. Your spec-gloss shim + PiP cap verified: lady 26/26 textured on stage and in-render; PiP 178×99 capped 16:9, shows on cut. Nice diagnosis on the CSS aspect-ratio trap and the CC-export extension.
  3. M4-A — the viseme lane (last A item in PLAN §5): on character load, detect morph-target dictionaries (mesh.morphTargetDictionary); expose stage.visemeTargets(id) → canonical viseme names found (A/E/I/O/U/MBP… match loosely, e.g. CC/ARKit names). Inspector shows a 🗣 badge when present, greyed otherwise. Add stage.setMorph(id, name, weight) for the timeline to drive. Rhubarb-JSON → keyframes lands on Lane B's side; your deliverable is detection + the setMorph hook + the badge.

2026-07-18 — session 4 ACCEPTED with one matcher hotfix. v1 COMPLETE — no open A work.

applyState source:'none' fix verified live (rig survives save/load). Viseme lane accepted, but the loose matcher had a real bug your self-check dict happened not to trip in its assertions: L: /(^|_)l($|_)/ matched every ARKit left-side morph — your own selftest output showed L: [v_tongue_up, eyeblink_l, browdown_l], so setMorph('L') would blink the eye on a real ARKit head. Orchestrator hotfix in detectVisemes (absorb it): NONMOUTH blocklist (eye/blink/brow/squint/look/nose/cheek) + patterns match against the side-stripped name (eyeblink_l→eyeblink, mouthsmile_l→mouthsmile→E). Self-check hardened: dict grew mouthSmile_L; asserts L === [v_tongue_up] and E includes the stripped smile. Lesson worth keeping: assert group CONTENTS, not just which groups exist. Still owed by John, not you: a viseme-bearing test character for a live badge check. Standing by.

2026-07-19 — M5 DIRECTOR MODE unlocked (spec in PLAN §5 M5). Your part:

  1. web/grammar.js — pure data, you own it, everyone reads it: SHOTS (ECU .12H@.93H, CU .25H@.87H, MCU .45H@.75H, MS .65H@.65H, MWS .85H@.55H, WS 1.2H@.5H, EWS 2.5H@.5H — vertical span fraction of subject height @ frame-center height), ANGLES (eye/low/high/dutch 8°roll/OTS-L/OTS-R), FOCALS [18,24,35,50,85] (fovV = 2·atan(12/f) in degrees), LIGHTS (day, golden_hour, night, noir, horror, neon, overcast — each {sunColor, sunIntensity, sunPos-ish elevation/azimuth, ambient, bg}), MARKS (center, two_shot_L/R, face_camera, face_other).
  2. web/presets.jsframeSubject(stage, camId, subjectId, {shot, angle, focal}): bbox of subject → camera pos/rot/fov so the shot's span fills the frame; OTS places the cam over the OTHER selected entity's shoulder. applyLight(stage, presetName) → setParam on sun/ambient + background. applyMark(stage, id, mark) → transform (marks are stage-relative). Pure functions returning the values AND applying them — Lane B needs the values to write keys.
  3. DIRECT panel UI (top toolbar or dock tab): with an entity selected — shot-size buttons, angle chips, focal select, "🎥 frame it" (creates/moves a camera + fires one scenegod:direct CustomEvent with the op payload, detail below); light preset buttons; mark buttons. Event contract (B consumes): scenegod:direct {detail: {op:'shot'|'light'|'mark', ...values, entityIds}} — YOU apply nothing to the timeline; B keys it.
  4. NL box (small text input in the panel) POSTs to /director (Lane C) and dispatches the returned ops through the same scenegod:direct events. Hidden entirely when /director 503s. Acceptance: select man → CU low 35mm → camera frames his face from below in one click; golden_hour relights the scene in one click; every action lands as keys via B (scrub proves it) and is undo-able.

2026-07-19 — M6 FLOW VIDEO PLATES queued behind M5 (full spec PLAN §5 M6).

Do M5 first; M6 is unlocked the moment your M5 acceptance is logged. Test media will be arriving in assets/backdrops/video/ via scripts/flow_intake.py.

2026-07-20 — M5+M6 ACCEPTED at SYNC 6 (all lanes ran as orchestrator subagents).

Verified live: DIRECT bar CU/low/frame → camera+keys+cut, ONE undo reverts; golden_hour keys; video plate follows the clock frame-exact; finalRender shows the Flow plate behind an animating character. Orchestrator seam fixes to absorb: (1) server: VIDEO_EXTS in tree/media-types, video beats poster as primary; (2) stage.entityVideo(id) added (Lane B seam); (3) syncVideos forces videoTex.needsUpdate — r160 VideoTexture rides requestVideoFrameCallback which never fires in hidden tabs → black plates in renders. Backlog carried: honor audio in/out at mux (C), walk-to-mark prevPos pre-apply (A), live viseme asset still owed by John.

2026-07-25 — M7-A ACCEPTED at SYNC 7. Outstanding work; four orchestrator fixes to absorb.

Verified live: ?selftest=1 PASS+alias ok; lady.glb (CC_Base rig) retargets — 53 tracks where she used to throw; man.fbx unchanged (66); both clips land; fog FogExp2 #4b3320 from the plate; floor tinted #452f1d; old sync2-sunset (no fit) loads unchanged. The _refreshDefaults catch was real — presets read far stronger now.

Four things only a rendered frame could show, fixed by the orchestrator in stage.js — absorb, don't revert:

  1. Editor chrome was baked into the mp4 — a green translate gizmo across every frame. _chrome(false) now hides gizmo/grid/helpers/camera proxies whenever _paused (which is exactly "the caller is rendering output"); camera proxies carry userData.chrome.
  2. The timeline dragged fitted plates out of frameevaluate() re-applies each entity's rest transform every tick, undoing the fit. setTransform now re-fits instead for fit:'frustum' backdrops (the lens owns them; keyframe a plate only with fit:'free').
  3. Plates letterboxed in every render — fit used the LIVE viewport aspect while output renders at its own (1280×720 etc). _render re-fits when the aspect it's about to draw at differs. Pure algebra, no re-sampling.
  4. root.position.y = -height/2 put the plate's TOP EDGE on the lens axis, so everything above centre-frame was void; centring instead put the road at eye level. Now params.fitAnchor (default 0.75) = which fraction of the image sits at eye level. Expose it in the inspector next to fitDist. NEXT for Lane A (not urgent): expose fitAnchor; the hard plate/floor horizon seam could use a soft gradient; and lady's hips-travel ratio 0 (Z-up CC export) still means CC clips play in place — fine for machinima (keyframe the walk) but worth a look if a walk cycle ever needs root motion.

2026-07-26 — M11-A ACCEPTED at SYNC 14. Move geometry verified live on music-video.

Fired all four families through the real DIRECT bar and measured the emitted payloads against the subject: push_in travels 0.496m and closes 1.975→1.588m with fov FIXED; zoom_in has pos AND rot deep-equal with travel 0.000 and fov 37.85→26.99; orbit_L travels 0.698m at constant distance (1.975→1.973); truck_R keeps rot identical and shifts distance only 3.6%. That is exactly how those four read on screen, and the dolly-vs-zoom pair is now honest in the data, not just in a comment — the right call, and the assertion that pins it is the most valuable one in presets_test. Frame-relative amounts (multiples of span·H/2) so a move reads the same on a CU as a WS was also the right instinct; it's what makes these presets rather than magic numbers. Preferring the LIVE camera in 🎥 frame after "+ angle" is correct and you were right to flag it — without it the new-angle button was a trap that silently edited camera 1. NEXT for Lane A (unqueued ideas): the plate/floor horizon is still a hard seam and could take a soft gradient; and contact shadows under characters would ground them further.