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>
17 KiB
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
~125–290 (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
bakeRetargetcloses over the globalchar. New signaturebakeRetarget(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.jsgets 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=1on 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 aTHREE.Groupwrapper — ALL transforms (gizmo + timeline) act on the wrapper, never the loaded root. For characters:captureRestonce at load, store on the entity; createAnimationMixeron the wrapper.backdrop:params.mode—plane: 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.SRGBColorSpaceon 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 firstkeylight dims defaults.
- Selection: raycast on click →
select(id), outline or bbox highlight,TransformControlsgizmo (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 withsource.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 hardcodedmock=1tree + local file-drop — drag any glb/fbx/image onto the page adds it assource.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 callsprepareClip+ 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=1retarget 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:
- /assets/tree item shape confirmed (from Lane C's server):
{name, path, formats: ["fbx", ...], thumb: relpath|null}— rigroom-style, nofilesarray. Drop your bare-string fallback./web/*absolute imports are confirmed correct as served. - 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, dispatchwindow 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. - Priority 2 — make sure
onChangefires for every entity added viaapplyState(SYNC finding: tlui's names column stayed empty after a programmatic scene load — fix on both sides; B is doing theirs). - Then per plan: multiple cameras + PiP overlay (
renderActiveCameraneeds 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 inassets/{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):
- 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).
- 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). - Keep
renderActiveCamera/pause/resumestable — 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.
- 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 hassource:{type:'none'}(PLAN §4.1) — emit it for camera/light inaddEntitydefaults +captureState, treat 'none' as always-rebuild in applyState. Keep tolerating legacy 'upload' cams (saved scenes exist). - 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.
- M4-A — the viseme lane (last A item in PLAN §5): on character load,
detect morph-target dictionaries (
mesh.morphTargetDictionary); exposestage.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. Addstage.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:
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).web/presets.js—frameSubject(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.- 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:directCustomEvent 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. - NL box (small text input in the panel) POSTs to
/director(Lane C) and dispatches the returned ops through the samescenegod:directevents. 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:
- 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 carryuserData.chrome. - The timeline dragged fitted plates out of frame —
evaluate()re-applies each entity's rest transform every tick, undoing the fit.setTransformnow re-fits instead forfit:'frustum'backdrops (the lens owns them; keyframe a plate only with fit:'free'). - Plates letterboxed in every render — fit used the LIVE viewport aspect while output
renders at its own (1280×720 etc).
_renderre-fits when the aspect it's about to draw at differs. Pure algebra, no re-sampling. root.position.y = -height/2put the plate's TOP EDGE on the lens axis, so everything above centre-frame was void; centring instead put the road at eye level. Nowparams.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): exposefitAnchor; the hard plate/floor horizon seam could use a soft gradient; and lady's hips-travelratio0 (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.