// stage.js — the 3D stage: renderer, IBL scene, entities on wrappers, gizmo, retarget cache.
// Timeline (Lane B) talks to the stage ONLY through this API (PLAN §4.2).
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { parseAny, captureRest, disposeRoot, stats, bakeRetarget } from './room3d.js';
const ASSET_URL = p => 'assets/file?path=' + encodeURIComponent(p);
// Loose viseme matcher: map a character's morph-target names (CC "V_Open", ARKit "jawOpen", plain
// "AA"…) to a canonical set the timeline drives. Detection only — Rhubarb→keyframes is Lane B's.
const VISEMES = {
A: [/\bah\b/, /(^|_)aa?($|_)/, /jaw[_ ]?open/, /(^|[_-])open($|[_-])/, /mouth_?open/],
E: [/\bee\b/, /(^|_)e[hr]?($|_)/, /wide/, /smile/],
I: [/(^|_)i[hy]?($|_)/, /affric/],
O: [/\boh\b/, /(^|_)o($|_)/, /tight[_-]?o/, /round/],
U: [/\boo\b/, /(^|_)u[w]?($|_)/, /pucker/, /funnel/, /w[_-]?oo/, /(^|_)tight($|_)/],
MBP: [/mbp/, /b[_-]?m[_-]?p/, /explos/, /mouth_?close/, /press/, /lip[_-]?open/, /(^|_)pp($|_)/],
FV: [/(^|_)f[_-]?v($|_)/, /dental[_-]?lip/, /(^|_)ff($|_)/],
L: [/t[_-]?l[_-]?d[_-]?n/, /tongue/, /(^|_)l($|_)/, /(^|_)dd($|_)/, /(^|_)nn($|_)/],
};
function detectVisemes(root){
const morphs = new Map(); // rawName(lower) -> [{mesh, idx}] (head, teeth, tongue often share a name)
root.traverse(o => { if(o.isMesh && o.morphTargetDictionary)
for(const [name, idx] of Object.entries(o.morphTargetDictionary)){
const k = name.toLowerCase(); (morphs.get(k) || morphs.set(k, []).get(k)).push({mesh:o, idx}); } });
const visemes = {};
// SYNC5: ARKit side suffixes (_L/_R) made /(^|_)l($|_)/ swallow eyeblink_l & co —
// never match non-mouth regions, and match against the side-stripped name.
const NONMOUTH = /eye|blink|brow|squint|look|nose|cheek/;
for(const [canon, pats] of Object.entries(VISEMES)){
const hits = [...morphs.keys()].filter(k => !NONMOUTH.test(k)
&& pats.some(p => p.test(k.replace(/(.)[_-][lr]$/, '$1'))));
if(hits.length) visemes[canon] = hits;
}
return { morphs, visemes };
}
// runnable check for the matcher (assert-based). Call visemeSelfCheck() from the console.
export function visemeSelfCheck(){
const dict = {'V_Open':0,'V_Explosive':1,'jawOpen':2,'mouthPucker':3,'mouthFunnel':4,
'mouthClose':5,'V_Dental_Lip':6,'V_Tongue_up':7,'EyeBlink_L':8,'browDown_L':9,'mouthSmile_L':10};
const mesh = { isMesh:true, morphTargetDictionary:dict, morphTargetInfluences:new Array(11).fill(0) };
const { morphs, visemes } = detectVisemes({ traverse(cb){ cb(mesh); } });
const has = v => v in visemes;
const detectOk = ['A','U','MBP','FV','L'].every(has) // open/jaw, pucker/funnel, explos/close, dental, tongue
&& !has('I') && !has('O')
// SYNC5 regression guard: side-suffixed ARKit names must land on the RIGHT viseme —
// L is tongue only (no blink/brow/smile bleed), E gets the side-stripped smile.
&& visemes.L.length === 1 && visemes.L[0] === 'v_tongue_up'
&& has('E') && visemes.E.includes('mouthsmile_l');
// canonical 'A' drives every mesh morph mapped to it (jawOpen idx2 + V_Open idx0)
for(const r of visemes.A) for(const {idx} of morphs.get(r)) mesh.morphTargetInfluences[idx] = 0.7;
const driveOk = mesh.morphTargetInfluences[0] === 0.7 && mesh.morphTargetInfluences[2] === 0.7
&& mesh.morphTargetInfluences[8] === 0; // blink untouched
return { ok: detectOk && driveOk, found: Object.keys(visemes).sort().join(','), visemes };
}
// M7 backdrop auto-fit, kept pure so node can check the algebra (stage_test.mjs): the frustum is
// `fh` tall at `dist`, and the plate scales to COVER it (either axis, whichever is tighter) + bleed.
export const BLEED = 1.06;
export function fitScale(fovDeg, aspect, dist, planeW, planeH){
const fh = 2 * Math.tan(fovDeg * Math.PI / 360) * dist, fw = fh * aspect;
return { fh, fw, s: Math.max(fw / planeW, fh / planeH) * BLEED };
}
// ACES exposure lives on the RENDERER, so a keyed lerp through 0 would black the whole film —
// clamp it. 0.05–4 is ±~4.3 stops around 1.0, more than any preset asks for.
export const EXPOSURE_RANGE = [0.05, 4];
export const clampExposure = v =>
Math.min(EXPOSURE_RANGE[1], Math.max(EXPOSURE_RANGE[0], Number.isFinite(+v) ? +v : 1));
// The frame's ACES level belongs to the scene's ambient light (same deal as `bg`), so it is a
// property of WHAT IS ON STAGE — never of whichever light happened to be built or deleted last.
// It used to be written straight from _buildLight/removeEntity: pressing `night` (1.6) and then
// `+ ambient` in the dock dropped the whole image to 1.0 with no visible cause (the new entity
// carries no `exposure`), and deleting any ambient reset the frame even when another one was
// still holding 1.6. Recompute from the entity list instead: the last ambient that actually
// states an exposure wins, and 1.0 only when nothing states one.
export function sceneExposure(entities){
let v = null;
for(const en of (entities || []))
if(en && en.kind === 'light' && en.params && en.params.type === 'ambient' && en.params.exposure != null)
v = en.params.exposure;
return clampExposure(v == null ? 1 : v);
}
// Which light edit actually MOVES the shadow box. Colour/intensity/bg/fog/exposure change how the
// frame LOOKS, not where the box goes — and Timeline._evalParams re-applies every keyed param on
// every tick, so treating any light key as dirty ran a Box3.setFromObject sweep over every
// character on every frame: exactly the per-frame cost the `ponytail:` note in setTransform
// claimed to be avoiding.
export function shadowAffectingParam(key){ return key === 'castShadow' || key === 'type'; }
// …and a transform is only dirty when it actually MOVED. The timeline re-applies the same pose
// every tick, so a single keyed sun would otherwise refit the box 30×/second for nothing.
export function samePos(a, b, eps = 1e-6){
if(!a || !b) return false;
for(let i = 0; i < 3; i++) if(!(Math.abs(a[i] - b[i]) < eps)) return false;
return true;
}
// Shadow-box fit. A directional light's shadow camera is an ORTHO box centred on the light→target
// axis, and every key light here aims at the stage ORIGIN — enforced, not assumed: _buildLight
// parents each light's target to the SCENE at (0,0,0), never to the light's own wrapper, so
// gizmo-translating a light can't walk the box off the stage. The half-extent is therefore
// measured from the origin, not from the entities' own centre (a lone character standing 8m
// off-axis needs a box that reaches him, and half his own diagonal does not). Union the bboxes
// WITH the origin, take half the diagonal, add margin for the ground the shadows land on.
// three's default is ±5.
export function shadowHalfExtent(bboxes, margin = 6){
const mn = [0, 0, 0], mx = [0, 0, 0]; // seeded with the origin = the axis the box sits on
let seen = false;
for(const b of (bboxes || [])){
if(!b || !b.min || !b.max) continue;
seen = true;
for(let i = 0; i < 3; i++){
if(Number.isFinite(b.min[i])) mn[i] = Math.min(mn[i], b.min[i]);
if(Number.isFinite(b.max[i])) mx[i] = Math.max(mx[i], b.max[i]);
}
}
if(!seen) return 4; // bare floor — nothing to cast, nothing to fit
return Math.max(4, Math.hypot(mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2]) / 2 + margin);
}
// One clamp shared by syncVideos (the CAPTURED frame) and Timeline._evalVideo (the viewport), so
// what you scrub is what you render. Wrapping instead of clamping made a plate with videoStart:2
// show frame 0 live and the 8.0s frame in the mp4.
export function videoTimeAt(t, start, dur){
return (Number.isFinite(dur) && dur > 0) ? Math.max(0, t - (start || 0)) % dur : 0;
}
// Frame guide bar thicknesses (px) that mask a viewport down to the delivery aspect.
// A camera's fov in three is VERTICAL, and rendering at another size only rewrites cam.aspect
// (_render) — so the encoded frame ALWAYS shows exactly the vertical extent the viewport shows,
// and differs only in WIDTH:
// viewport wider than delivery → the render is narrower ⇒ mask left/right (pillarbox).
// viewport narrower → the render is WIDER ⇒ there is nothing to mask; the viewport
// is showing a crop of the frame, and `overscan` says by how
// much (delivery ÷ viewport aspect) so the UI can disclose it.
// Top/bottom bars are therefore ALWAYS zero. Letterboxing a tall viewport (what this used to do)
// masked content that ends up in the mp4 and implied side content that does not exist.
// `side` is the SIGNED viewport-px inset of the encoded frame's side edges — the same number as
// left/right when the viewport is wider, NEGATIVE when it is narrower (the frame runs off-screen).
// It is what positions the gate rectangle, and the gate is what the thirds and the action-safe box
// are drawn as percentages OF: with the gate clamped to the visible area, a tall viewport drew the
// left third ~6% of the frame width away from where it actually falls, which is the one thing a
// director composes with. Pure geometry so node can assert it; the guide itself is DOM, never
// WebGL (see _layoutGuide).
export function guideBars(viewW, viewH, aspect){
const z = { top:0, bottom:0, left:0, right:0, overscan:1, side:0 };
if(!(viewW > 0 && viewH > 0 && aspect > 0)) return z; // hidden panel → no divide by zero
const va = viewW / viewH;
const side = (viewW - viewH * aspect) / 2; // frame is always viewH tall (vertical fov)
if(va > aspect) return { ...z, left:side, right:side, side };
return { ...z, overscan: aspect / va, side };
}
export class Stage {
constructor(viewEl){
this.view = viewEl;
this._entities = new Map();
this._selCbs = []; this._changeCbs = [];
this._selected = null;
this._activeCamId = null;
this._mixerAuto = true; // Stage advances mixers each frame until Timeline takes over
this._idc = 0;
this._clipSrc = new Map(); // path -> {root, rest, anims} (parse+rest cached)
this._baked = new Map(); // id|path|index -> AnimationClip
const r = this.renderer = new THREE.WebGLRenderer({antialias:true, preserveDrawingBuffer:true});
r.setPixelRatio(devicePixelRatio);
r.shadowMap.enabled = true;
r.shadowMap.type = THREE.PCFSoftShadowMap; // free: the hard-edged default read as aliasing
// grammar.js runs physical sun intensities of 1.2–3.4; with NoToneMapping every lit face
// clipped to 255,255,255 and a warm key went neutral white. ACES + a per-preset exposure
// (the ambient entity's `exposure` param) is the single biggest look win in the app.
r.toneMapping = THREE.ACESFilmicToneMapping; r.toneMappingExposure = 1.0;
this.view.appendChild(r.domElement);
this._paused = false; // M3 final render freezes the director loop, then drives renderActiveCamera
// PiP overlay — the active camera's view (bottom-right). CSS sizes it (~24% w); the buffer
// stays small. Click to swap which view is main vs inset.
const pip = this._pip = document.createElement('canvas');
pip.width = 320; pip.height = 180; pip.className = 'pip'; pip.style.display = 'none';
pip.title = 'click to swap with the main view';
this._pipSwap = false;
pip.addEventListener('click', () => { this._pipSwap = !this._pipSwap; });
this.view.appendChild(pip);
// ⬚ frame guide — DOM ONLY. The viewport is whatever shape the CSS grid gives it and _render
// rewrites cam.aspect every draw, so headroom / lead room / a plate's fitAnchor horizon were
// judged in a rectangle that is never the encoded one. Bars mask it down to the delivery
// aspect. Being DOM (z BELOW the PiP, pointer-events:none) it can never reach a captured frame.
const fg = this._fg = document.createElement('div'); fg.className = 'frameguide';
fg.innerHTML = ''
+ ''
+ ''
+ '';
this._frameAspect = 16/9;
this.view.appendChild(fg);
const scene = this.scene = new THREE.Scene();
const cam = this._dirCam = new THREE.PerspectiveCamera(45, 1, 0.01, 3000);
cam.position.set(0, 1.6, 5);
const ctr = this.controls = new OrbitControls(cam, r.domElement);
ctr.enableDamping = true; ctr.target.set(0, 1.0, 0);
const pmrem = new THREE.PMREMGenerator(r);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; pmrem.dispose();
// default lights — replaced/dimmed once the scene carries light entities
this._defHemi = new THREE.HemisphereLight(0xffffff, 0x555a66, 2.2); scene.add(this._defHemi);
// the default key CASTS: without this nothing threw a shadow until the user added a light
// entity, so characters hovered on an untouched stage despite shadowMap.enabled.
this._defKey = new THREE.DirectionalLight(0xffffff, 1.8); this._defKey.position.set(3, 5, 2);
this._shadowRig(this._defKey); scene.add(this._defKey);
this._shadowDirty = true; // consumed once per DRAWN frame in _render — never fit per light-key
const floor = this._floor = new THREE.Mesh(new THREE.CircleGeometry(30, 64).rotateX(-Math.PI/2),
new THREE.MeshStandardMaterial({color:0x141a20, roughness:.97}));
floor.receiveShadow = true; scene.add(floor);
this._floorBase = new THREE.Color(0x141a20);
const grid = this._grid = new THREE.GridHelper(60, 60, 0x2a323b, 0x1c232b);
grid.material.transparent = true; scene.add(grid);
// gizmo
const tc = this._tc = new TransformControls(cam, r.domElement);
tc.addEventListener('dragging-changed', e => { ctr.enabled = !e.value; });
tc.addEventListener('mouseUp', () => { if(this._selected) this._fireChange(this.getEntity(this._selected)); });
scene.add(tc);
addEventListener('keydown', e => {
if(!this._selected) return;
// A light has exactly ONE degree of freedom that does anything: position. Its target is
// nailed to the stage origin (_buildLight/_aimLight), so `e`/`r` used to swing a gizmo,
// fire onChange and key a rotation that changed no pixel. Refuse the mode instead.
const posOnly = this.getEntity(this._selected)?.kind === 'light';
if(e.key==='w') tc.setMode('translate');
else if(e.key==='e' && !posOnly) tc.setMode('rotate');
else if(e.key==='r' && !posOnly) tc.setMode('scale');
});
r.domElement.addEventListener('click', e => this._pick(e));
this.clock = new THREE.Clock();
this._resize(); addEventListener('resize', () => this._resize());
const loop = () => { requestAnimationFrame(loop);
const dt = this.clock.getDelta();
if(this._paused) return; // M3: caller drives rendering deterministically (_render refits)
ctr.update();
if(this._mixerAuto) for(const en of this._entities.values()) en.mixer && en.mixer.update(dt);
// inset first (it scribbles onto the main canvas), then the main view last so it wins
if(this._activeCamId && pip.style.display !== 'none')
this._render(this._pipSwap ? this._dirCam : this._activeCam(), pip);
this._render((this._pipSwap && this._activeCamId) ? this._activeCam() : this._dirCam, null);
};
loop();
}
_resize(){ const w=this.view.clientWidth, h=this.view.clientHeight;
this.renderer.setSize(w, h, false); this._dirCam.aspect = w/h; this._dirCam.updateProjectionMatrix();
// size the inset here — CSS aspect-ratio/% is unreliable on a