SCENEGOD/scenegod/web/stage.js
type-two 22c8a9a324 M12: look, render button, save integrity — 3 workflow rounds, 22 agents
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>
2026-07-28 22:41:17 +10:00

989 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.054 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.23.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 = '<i class="fgb t"></i><i class="fgb b"></i><i class="fgb l"></i><i class="fgb r"></i>'
+ '<i class="fggate"><i class="fgv" style="left:33.333%"></i><i class="fgv" style="left:66.667%"></i>'
+ '<i class="fgh" style="top:33.333%"></i><i class="fgh" style="top:66.667%"></i>'
+ '<i class="fgsafe"></i></i>';
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 <canvas>
const pw = Math.max(180, Math.min(340, w*0.24));
this._pip.style.width = Math.round(pw)+'px'; this._pip.style.height = Math.round(pw*9/16)+'px';
this._layoutGuide();
this.fitBackdrops(); // frame aspect changed → auto-fit plates no longer fill it
}
// ---- ⬚ frame guide (DOM overlay; see the constructor) ----
_layoutGuide(){
const fg = this._fg; if(!fg) return;
const b = guideBars(this.view.clientWidth, this.view.clientHeight, this._frameAspect);
const px = n => Math.round(n) + 'px';
const [t, bo, l, r, gate] = fg.children;
t.style.height = px(b.top); bo.style.height = px(b.bottom);
l.style.width = px(b.left); r.style.width = px(b.right);
gate.style.top = px(b.top); gate.style.bottom = px(b.bottom);
// THE GATE IS THE ENCODED FRAME, even when that frame is wider than the window: a negative
// inset runs it off both sides (.frameguide clips it) so its thirds and action-safe box —
// percentages of the gate — mark the real frame instead of the visible crop. Clamped to the
// viewport they were ~6% of the frame width out on a tall window, and off by more the taller
// it got, which is precisely when a director needs them.
gate.style.left = px(b.side); gate.style.right = px(b.side);
// overscan: the delivery frame is WIDER than this viewport, so its sides are off-screen and
// no bar can show that. Say it (the dashed edges + note live on the guide, not on the gate,
// because the gate's own edges are now off-screen).
fg.classList.toggle('over', b.overscan > 1.001);
}
setFrameAspect(a){ if(a > 0){ this._frameAspect = a; this._layoutGuide(); } return this._frameAspect; }
setFrameGuide(on){ if(this._fg) this._fg.style.display = on ? '' : 'none'; return !!on; }
frameGuide(){ return !!this._fg && this._fg.style.display !== 'none'; }
frameAspect(){ return this._frameAspect; }
// ---- shadows ----
// Shared config for every shadow-casting directional light (the default key + scene keys).
// normalBias kills the acne a 2048 map still shows on a 1.7m character at a grazing sun.
_shadowRig(dir){
dir.castShadow = true;
dir.shadow.mapSize.set(2048, 2048);
dir.shadow.camera.far = 60;
dir.shadow.normalBias = 0.02; dir.shadow.bias = -0.0005;
}
// Fit every shadow camera's ortho box to what is actually on stage. three's default ±5 units
// sliced shadows off dead straight the moment two characters stood more than ~10m apart, and
// left near/far unrelated to where the boom is. Recomputed at most once per frame (_shadowDirty).
_fitShadows(){
const boxes = [];
for(const en of this._entities.values()){
if(en.kind !== 'character' && en.kind !== 'prop' && en.kind !== 'screen') continue;
const b = this.entityBBox(en.id); if(b) boxes.push(b);
}
const h = shadowHalfExtent(boxes);
const p = new THREE.Vector3();
const fit = dir => {
if(!dir || !dir.shadow) return;
const c = dir.shadow.camera;
c.left = -h; c.right = h; c.top = h; c.bottom = -h;
c.near = 0.5;
dir.updateWorldMatrix(true, false); dir.getWorldPosition(p);
c.far = p.length() + 2 * h; // boom distance + the depth of the box behind the stage
c.updateProjectionMatrix();
};
fit(this._defKey);
for(const en of this._entities.values())
if(en.light && en.light.isDirectionalLight) fit(en.light);
}
// ---- callbacks ----
onSelect(cb){ this._selCbs.push(cb); }
onChange(cb){ this._changeCbs.push(cb); }
_fireChange(en){ for(const cb of this._changeCbs) cb(en); }
// ---- entity lifecycle ----
entities(){ return [...this._entities.values()]; }
getEntity(id){ return this._entities.get(id); }
async addEntity(desc){
const id = desc.id || ('e' + (++this._idc));
if(this._idc <= (parseInt(String(id).slice(1)) || 0)) this._idc = parseInt(String(id).slice(1)) || this._idc;
const wrapper = new THREE.Group();
wrapper.userData.entityId = id;
const noAsset = desc.kind === 'camera' || desc.kind === 'light'; // rig entities have no file
const en = { id, kind: desc.kind, label: desc.label || desc.kind,
source: desc.source || (noAsset ? {type:'none'} : null), params: {...(desc.params||{})},
wrapper, root:null, mixer:null, rest:null, cam:null, light:null, morphs:null, visemes:null,
video:null, ground:null };
if(desc.kind === 'character' || desc.kind === 'prop'){
const { root } = await this._loadAsset(desc);
this._normalize(root, desc.kind === 'character');
root.traverse(o=>{ if(o.isMesh){ o.castShadow = true; o.receiveShadow = true; } if(o.isSkinnedMesh) o.frustumCulled=false; });
wrapper.add(root); en.root = root;
if(desc.kind === 'character'){ en.rest = captureRest(root); en.mixer = new THREE.AnimationMixer(root);
const v = detectVisemes(root); en.morphs = v.morphs; en.visemes = v.visemes; }
} else if(desc.kind === 'backdrop'){
en.root = await this._buildBackdrop(en); wrapper.add(en.root);
} else if(desc.kind === 'screen'){
en.root = await this._buildScreen(en); wrapper.add(en.root);
} else if(desc.kind === 'camera'){
const fov = en.params.fov || 45;
en.cam = new THREE.PerspectiveCamera(fov, 16/9, 0.05, 3000);
wrapper.add(en.cam);
const proxy = new THREE.Mesh(new THREE.ConeGeometry(0.18, 0.35, 4).rotateX(-Math.PI/2),
new THREE.MeshBasicMaterial({color:0x43c0cf, wireframe:true}));
proxy.position.z = 0.18; proxy.userData.chrome = true; // SYNC7: editor-only, never in a render
wrapper.add(proxy); en.proxy = proxy;
} else if(desc.kind === 'light'){
this._buildLight(en);
} else throw new Error('unknown kind: ' + desc.kind);
this.scene.add(wrapper);
this._entities.set(id, en);
if(desc.transform) this.setTransform(id, desc.transform);
if(en.kind === 'backdrop'){ this._fitBackdrop(en); this._sampleGround(en); } // M7: world tie-in
// _buildLight ran BEFORE the entity was registered, so its _refreshDefaults couldn't see it —
// without this the built-in hemi stayed at 2.2 and washed every lighting preset out.
if(en.kind === 'light'){ this._refreshDefaults(); this._applyExposure(); this._applyWorld(); }
this._shadowDirty = true;
this._fireChange(en); // tlui/dock build a row per entity off onChange (incl. applyState loads)
return en;
}
removeEntity(id){
const en = this._entities.get(id); if(!en) return;
if(this._selected === id) this.select(null);
// A dead active-camera id kept the PiP drawing a duplicate of the director view and made
// renderActiveCamera() silently shoot the DIRECTOR cam into the mp4. Fall back to the orbit
// cam, which is what `null` has always meant.
if(this._activeCamId === id) this.setActiveCamera(null);
this._dropVideo(en);
if(en.root) disposeRoot(en.root);
if(en.light) en.light.parent && en.light.parent.remove(en.light);
if(en.target) this.scene.remove(en.target); // the light's aim point lives on the scene
this.scene.remove(en.wrapper);
this._entities.delete(id);
for(const k of [...this._baked.keys()]) if(k.startsWith(id+'|')) this._baked.delete(k);
// exposure is recomputed from the ambients that are STILL on stage (sceneExposure) — deleting
// one of two ambients used to reset the whole frame to 1.0 and never restore the survivor's.
if(en.kind === 'light'){ this._refreshDefaults(); this._applyExposure(); }
if(en.kind === 'backdrop' || en.kind === 'light') this._applyWorld(); // floor/grid/fog follow the plates
this._shadowDirty = true;
this._fireChange(en); // trigger a row resync so the deleted entity drops out
}
async _loadAsset(desc){
let buf, name;
if(desc.source && desc.source.type === 'upload'){
if(!desc._buf) throw new Error('upload entity has no buffer');
buf = desc._buf; name = desc.source.path;
} else {
const path = desc.source.path;
const res = await fetch(ASSET_URL(path));
if(!res.ok) throw new Error('asset fetch ' + res.status + ': ' + path);
buf = await res.arrayBuffer(); name = path;
}
return parseAny(buf, name);
}
_normalize(root, isChar){
root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(root);
if(box.isEmpty() || !isFinite(box.min.x)) return;
if(isChar){
const size = box.getSize(new THREE.Vector3());
const s = 1.7 / (Math.max(size.x, size.y, size.z) || 1);
root.scale.setScalar(s); root.updateMatrixWorld(true);
}
const b = new THREE.Box3().setFromObject(root);
root.position.x -= (b.min.x + b.max.x)/2;
root.position.z -= (b.min.z + b.max.z)/2;
root.position.y -= b.min.y; // feet on the floor
}
// Backdrop plate — image OR video (M6). params.mode: plane|corner|dome|video ('video' = a
// plane; the other modes also accept .mp4 sources). Video plates show their poster jpg (the
// server's same-stem sidecar) until first play/seek, then swap to the live VideoTexture.
async _buildBackdrop(en){
const p = en.params;
const src = p.image || (en.source && en.source.path) || '';
const upload = en.source && en.source.type === 'upload';
const url = upload ? en.source._url : ASSET_URL(src);
const isVideo = p.mode === 'video' || /\.(mp4|webm|mov)$/i.test(src);
let tex, aspect, vidTex = null, poster = null;
if(isVideo){
const { video, texture } = await this._makeVideo(url, src);
en.video = video; en.videoTex = vidTex = texture;
aspect = (video.videoWidth / video.videoHeight) || 16/9;
poster = upload ? null : await this._posterTex(src);
tex = poster || vidTex;
} else {
tex = await new THREE.TextureLoader().loadAsync(url);
tex.colorSpace = THREE.SRGBColorSpace;
aspect = (tex.image.width / tex.image.height) || 1;
}
const w = p.width || 10, h = w / aspect;
const grp = new THREE.Group(), mats = [];
if(p.mode === 'dome'){
const dm = this._plateMat(tex, {side:THREE.BackSide}); mats.push(dm);
grp.add(new THREE.Mesh(new THREE.SphereGeometry(40, 40, 24), dm));
} else {
const mat = this._plateMat(tex); mats.push(mat);
const wall = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
wall.position.y = h/2; grp.add(wall);
if(p.mode === 'corner'){ // cheap "L": duplicate lower third laid flat as ground
// The ground is the SAME PHOTOGRAPH as the wall, so it gets the SAME material. It used to
// be a MeshStandardMaterial "because it catches the shadow", which rendered one image at
// two levels across the fold — lit + tone-mapped below, unlit + untonemapped above, a hard
// seam at the L junction (under `noir`, ambient 0.15, the ground copy went near-black
// while the wall stayed at full brightness). The shadow now lands on a separate
// ShadowMaterial catcher laid 2mm over it: invisible except where something shades it.
const gh = h/3, gm = this._plateMat(tex); mats.push(gm);
const g = new THREE.Mesh(new THREE.PlaneGeometry(w, gh), gm);
g.rotation.x = -Math.PI/2; g.position.z = gh/2; grp.add(g);
// fog:false for the same reason the plate has it: _applyWorld installs a FogExp2 from the
// plate's own ground colour, and three's default (fog:true) would have tinted and faded
// the SHADOW with distance while the photograph it lands on stayed fog-free — one image,
// two atmospheres, which is the seam this catcher was introduced to remove.
const catcher = new THREE.Mesh(new THREE.PlaneGeometry(w, gh),
new THREE.ShadowMaterial({opacity:0.45, fog:false}));
catcher.rotation.x = -Math.PI/2; catcher.position.set(0, 0.002, gh/2);
catcher.receiveShadow = true; catcher.userData.shadowCatcher = true; grp.add(catcher);
wall.position.z = -gh/2;
}
}
this._plateLevel(mats, p.plateExposure);
if(vidTex && poster) this._swapOnPlay(en.video, vidTex, mats);
if(en.video && this._mixerAuto) en.video.play().catch(()=>{}); // ambient preview until a timeline owns the clock
return grp;
}
// In-set display (M6 `screen` kind): a video plane with an emissive boost so TVs/club
// screens/jumbotrons read "on" under any lighting preset. params {video, width, emissive}.
// ponytail: CRT curve skipped — flat plane until a real TV asset asks for it.
async _buildScreen(en){
const p = en.params;
const src = p.video || (en.source && en.source.path) || '';
const upload = en.source && en.source.type === 'upload';
const { video, texture } = await this._makeVideo(upload ? en.source._url : ASSET_URL(src), src);
en.video = video; en.videoTex = texture;
const aspect = (video.videoWidth / video.videoHeight) || 16/9;
const poster = upload ? null : await this._posterTex(src);
const tex = poster || texture;
const w = p.width || 3, h = w / aspect;
const mat = new THREE.MeshStandardMaterial({ map:tex, emissive:0xffffff, emissiveMap:tex,
emissiveIntensity: p.emissive ?? 0.6, roughness:0.4, metalness:0, toneMapped:false });
if(poster) this._swapOnPlay(video, texture, [mat]);
if(this._mixerAuto) video.play().catch(()=>{});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
mesh.position.y = h/2;
const grp = new THREE.Group(); grp.add(mesh);
return grp;
}
// <video> element + VideoTexture, resolved once metadata (dimensions/duration) is in
_makeVideo(url, srcPath){
return new Promise((resolve, reject) => {
const v = document.createElement('video');
v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'auto'; v.crossOrigin = 'anonymous';
v.src = url;
const texture = new THREE.VideoTexture(v);
texture.colorSpace = THREE.SRGBColorSpace;
v.addEventListener('loadedmetadata', () => resolve({ video: v, texture }), { once:true });
v.addEventListener('error', () => reject(new Error('video load failed: ' + srcPath)), { once:true });
});
}
_posterTex(src){ // same-stem jpg via the server's sidecar route; null when absent
return new THREE.TextureLoader().loadAsync('assets/thumb?path=' + encodeURIComponent(src))
.then(t => { t.colorSpace = THREE.SRGBColorSpace; return t; })
.catch(() => null);
}
_swapOnPlay(video, texture, mats){ // poster stays up until the video actually shows a frame
const swap = () => {
video.removeEventListener('playing', swap); video.removeEventListener('seeked', swap);
for(const m of mats){ m.map = texture; if(m.emissiveMap) m.emissiveMap = texture; m.needsUpdate = true; }
};
video.addEventListener('playing', swap); video.addEventListener('seeked', swap);
}
_dropVideo(en){
if(!en.video) return;
en.video.pause(); en.video.removeAttribute('src'); en.video.load(); en.video = null;
}
// ---- world-integrated backdrops (M7) ---------------------------------------------------------
// A plate used to read as a billboard in the void: a gap between its bottom edge and the grid, a
// floor that belongs to a different scene, no air between camera and horizon. Three cheap fixes,
// all recomputed on load / param change / camera change — NEVER per frame.
// params.fit:'frustum' size+place the plate so it fills the active camera's frustum (+bleed)
// params.groundTint tint the stage floor to the plate's bottom rows (default on)
// params `fog` on the ambient light keyable FogExp2 density, coloured from the plate
_activeAspect(){ const c = this._activeCam();
return c.aspect || (this.view.clientWidth / this.view.clientHeight) || 16/9; }
fitBackdrops(){ for(const en of this._entities.values())
if(en.kind === 'backdrop') this._fitBackdrop(en); }
// Scale/park the plate on the active camera's axis so it covers the frame at params.fitDist.
// ponytail: fit OWNS the backdrop's wrapper transform — keyframe a plate only with fit:'free'.
_fitBackdrop(en){
const p = en.params, root = en.root;
if(!root || p.fit !== 'frustum') return;
if(p.mode === 'dome' || p.mode === 'corner') return; // dome already wraps; corner needs its floor flat
const mesh = root.children[0], g = mesh && mesh.geometry && mesh.geometry.parameters;
if(!g || !g.width) return;
const cam = this._activeCam(); cam.updateMatrixWorld(true);
const d = p.fitDist || 14; // far enough to read as background
const { s } = fitScale(cam.fov, this._activeAspect(), d, g.width, g.height);
root.scale.setScalar(s);
// SYNC7: which row of the plate sits at eye level. 1.0 parked the plate's TOP EDGE on the
// camera axis (void above centre-frame); 0.5 put the image's middle — road tarmac, for a
// street plate — at eye height. params.fitAnchor is the fraction of image height at the
// lens axis: 0.75 lands a typical plate's horizon near eye level and still fills upward.
const anchor = p.fitAnchor == null ? 0.75 : p.fitAnchor;
root.position.set(0, (0.5 - anchor) * g.height * s, 0);
const camPos = cam.getWorldPosition(new THREE.Vector3());
const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(cam.getWorldQuaternion(new THREE.Quaternion()));
en.wrapper.position.copy(camPos).addScaledVector(fwd, d);
en.wrapper.lookAt(camPos); // plane normal is +Z
en.wrapper.scale.setScalar(1);
}
_unfitBackdrop(en){ if(en.root){ en.root.scale.setScalar(1); en.root.position.set(0,0,0); } }
// ONE material for every surface that shows the photograph — wall, corner ground, dome.
// fog:false — the plate IS the distance; atmosphere is for what stands in front of it.
// toneMapped:false + unlit — a photograph/Flow frame is DISPLAY-REFERRED, already lit and
// graded; re-lighting it meant `noir` (ambient 0.15) blacked the photograph out, and running
// ACES over it a second time muddies its blacks. Level is params.plateExposure (_plateLevel),
// never the key light. Anything that shows this image and does NOT come through here will
// read at a different level than the rest of the plate.
_plateMat(tex, extra){
return new THREE.MeshBasicMaterial({map:tex, fog:false, toneMapped:false, ...extra});
}
// plate level: an unlit plate has no key to expose it, so params.plateExposure (default 1) rides
// the material colour — same knob for every plate surface (never the shadow catcher, which has
// no map and whose `color` is the shadow's own tint).
_plateLevel(mats, v){
const n = +v, s = Number.isFinite(n) ? Math.max(0, Math.min(4, n)) : 1;
for(const m of (mats || [])) if(m && m.color) m.color.setScalar(s);
}
// average colour of the plate's bottom rows — the ground the character should be standing on.
// Works for images and for video (poster or current frame); same-origin, so the canvas is clean.
_plateGround(en){
const mesh = en.root && en.root.children[0], tex = mesh && mesh.material && mesh.material.map;
const img = tex && tex.image;
if(!img || !(img.width || img.videoWidth)) return null;
if(img.videoWidth && img.readyState < 2) return null; // no decoded frame yet → sample black
try {
const c = document.createElement('canvas'); c.width = c.height = 24;
const g = c.getContext('2d', { willReadFrequently: true });
g.drawImage(img, 0, 0, 24, 24);
const d = g.getImageData(0, 17, 24, 7).data; // bottom ~quarter
let r = 0, gr = 0, b = 0; const n = d.length / 4;
for(let i = 0; i < d.length; i += 4){ r += d[i]; gr += d[i+1]; b += d[i+2]; }
return new THREE.Color().setRGB(r/n/255, gr/n/255, b/n/255, THREE.SRGBColorSpace);
} catch(e){ return null; } // tainted canvas / no frame yet
}
_sampleGround(en){
const col = this._plateGround(en);
if(col) en.ground = col;
else if(en.video) // no decoded frame yet — take the first one that lands
en.video.addEventListener('loadeddata', () => this._sampleGround(en), { once:true });
this._applyWorld(); // grid/fog follow the plate even when sampling came back empty
}
// floor tint + grid fade + fog, derived from whatever plates are on stage
_applyWorld(){
const plates = this.entities().filter(e => e.kind === 'backdrop');
const tint = plates.filter(e => e.ground && (e.params.groundTint ?? true)).pop();
const col = tint ? tint.ground : null;
this._floor.material.color.copy(col ? col.clone().multiplyScalar(0.85) : this._floorBase);
this._grid.material.opacity = plates.length ? 0.12 : 1; // fade the studio grid out under a plate
const amb = this.entities().find(e => e.kind === 'light' && e.params.type === 'ambient');
const dens = amb && amb.params.fog != null ? amb.params.fog : (col ? 0.02 : 0); // subtle default
const had = !!this.scene.fog;
if(col && dens > 0){ // Lane B can key `fog` per tick — mutate, don't reallocate
if(had){ this.scene.fog.color.copy(col); this.scene.fog.density = dens; }
else this.scene.fog = new THREE.FogExp2(col.getHex(), dens);
} else this.scene.fog = null;
// toggling fog on/off changes the shader program — materials must recompile (once, not per frame)
if(had !== !!this.scene.fog) this.scene.traverse(o => { const m = o.material;
if(m) (Array.isArray(m) ? m : [m]).forEach(x => x.needsUpdate = true); });
}
_buildLight(en){
const p = en.params;
if(p.type === 'ambient'){
en.light = new THREE.HemisphereLight(new THREE.Color(p.color||'#ffffff'), 0x444450, p.intensity ?? 1.0);
en.wrapper.add(en.light);
if(p.bg) this.scene.background = new THREE.Color(p.bg); // saved lighting preset restores its backdrop color
// exposure rides the ambient entity exactly like bg (keyable, persists in the scene JSON),
// but building a light does NOT set the frame's level — _applyExposure() does, from the
// ambients that are on stage, once addEntity has registered this one. Writing it here meant
// a dock "+ ambient" (which carries no exposure) stomped a preset's 1.6 down to 1.0.
} else {
const dir = en.light = new THREE.DirectionalLight(new THREE.Color(p.color||'#ffffff'), p.intensity ?? 1.5);
this._shadowRig(dir);
dir.castShadow = p.castShadow ?? true;
dir.position.set(0,0,0);
// The aim point is nailed to the STAGE ORIGIN and lives on the SCENE, not on the light's
// wrapper. As a child at z=1 it travelled with the light, so gizmo-translating a key light
// 15m along X carried the whole light→target axis with it: the ortho shadow box (which
// shadowHalfExtent measures from the origin) went with it and the character silently lost
// his shadow. A sun's aim IS its position — elevation/azimuth on a boom, exactly what
// applyLight solves.
const tgt = en.target = new THREE.Object3D();
this.scene.add(tgt); dir.target = tgt;
this._aimLight(en);
en.wrapper.add(dir);
const proxy = new THREE.Mesh(new THREE.SphereGeometry(0.15, 12, 8),
new THREE.MeshBasicMaterial({color:0xffe08a}));
proxy.userData.chrome = true; // editor-only, like the camera cone — never in a render
en.wrapper.add(proxy);
}
this._refreshDefaults();
this._shadowDirty = true;
}
// Point a key light's target. It sits at the stage origin — that is what makes a light's ANGLE
// its position and keeps the ortho shadow box (measured from the origin) over the stage. The one
// position that has no direction is the origin itself: gizmo-dragging a sun onto (0,0,0) made
// light.position === target.position, a zero-length direction three resolves to NaN (no shadow,
// undefined shading). A light standing ON the mark shines straight down.
_aimLight(en){
if(!en || !en.target) return;
const p = en.wrapper.position;
en.target.position.set(0, Math.hypot(p.x, p.y, p.z) < 1e-3 ? -1 : 0, 0);
}
// the ACES level, recomputed from the ambients on stage (see sceneExposure)
_applyExposure(){ this.renderer.toneMappingExposure = sceneExposure(this.entities()); }
// dim built-in lights once scene lights exist
_refreshDefaults(){
const hasKey = this.entities().some(e => e.kind==='light' && e.params.type!=='ambient');
const hasAmb = this.entities().some(e => e.kind==='light' && e.params.type==='ambient');
this._defKey.visible = !hasKey;
this._defHemi.intensity = hasAmb ? 0 : 2.2;
}
// ---- selection + gizmo ----
select(id){
this._selected = id;
const en = id == null ? null : this._entities.get(id);
// A frustum-fitted plate is placed BY THE LENS: dragging its gizmo visibly moved it until the
// next refit yanked it back with no explanation. No gizmo — selection/inspector still work.
if(!en || (en.kind === 'backdrop' && en.params && en.params.fit === 'frustum')) this._tc.detach();
else {
this._tc.attach(en.wrapper);
// …and never hand a light a rotate/scale gizmo left over from the last selection (see the
// `w`/`e`/`r` handler: only its position means anything).
if(en.kind === 'light') this._tc.setMode?.('translate');
}
for(const cb of this._selCbs) cb(id ? this._entities.get(id) : null);
}
_pick(e){
if(this._tc.dragging) return;
const rect = this.renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(
((e.clientX - rect.left)/rect.width)*2 - 1,
-((e.clientY - rect.top)/rect.height)*2 + 1);
const rc = new THREE.Raycaster(); rc.setFromCamera(ndc, this._dirCam);
const wraps = this.entities().map(en => en.wrapper);
const hits = rc.intersectObjects(wraps, true);
for(const h of hits){
let o = h.object; while(o && o.userData.entityId == null) o = o.parent;
if(o){ this.select(o.userData.entityId); return; }
}
this.select(null);
}
// ---- transforms / params (Timeline drives these every tick) ----
entityTransform(id){
const w = this._entities.get(id)?.wrapper; if(!w) return null;
return { pos:[w.position.x, w.position.y, w.position.z],
rot:[w.rotation.x, w.rotation.y, w.rotation.z],
scale: w.scale.x };
}
setTransform(id, {pos, rot, scale}){
const en = this._entities.get(id); const w = en?.wrapper; if(!w) return;
// SYNC7: a frustum-fitted plate is placed BY THE LENS, not by the user. The timeline
// re-applies every entity's rest transform each tick, which dragged the plate back to
// its authored pos and left a black bar at frame top. Camera owns it; ignore the rest.
if(en.kind === 'backdrop' && en.params && en.params.fit === 'frustum'){ this._fitBackdrop(en); return; }
// ponytail: only a light that ACTUALLY MOVED refits (the box's far plane is the boom
// distance). The timeline re-applies every entity's transform each tick, so flagging
// characters — or flagging a keyed sun that lands on the same pos every frame — would run a
// Box3.setFromObject sweep over skinned meshes every frame; add/remove already covers the
// cases that change the box's size.
const moved = en.kind === 'light' && pos && !samePos(pos, [w.position.x, w.position.y, w.position.z]);
if(pos) w.position.set(pos[0], pos[1], pos[2]);
if(rot) w.rotation.set(rot[0], rot[1], rot[2]);
if(scale != null) w.scale.setScalar(scale);
if(moved){ this._aimLight(en); this._shadowDirty = true; }
}
setParam(id, key, value){
const en = this._entities.get(id); if(!en) return;
en.params[key] = value;
if(en.kind === 'light' && shadowAffectingParam(key)) this._shadowDirty = true;
if(en.cam && key === 'fov'){ en.cam.fov = value; en.cam.updateProjectionMatrix();
if(en.id === this._activeCamId) this.fitBackdrops(); } // the frame changed shape
if(en.light){
if(key === 'intensity') en.light.intensity = value;
if(key === 'color') en.light.color.set(value);
if(key === 'castShadow' && en.light.isDirectionalLight) en.light.castShadow = !!value;
if(key === 'bg') this.scene.background = new THREE.Color(value); // bg rides the ambient entity (keyable, persists)
if(key === 'fog') this._applyWorld(); // keyable atmosphere, same pattern as bg
// overall level for the whole frame. Goes through the SAME rule as add/remove (the last
// ambient that states an exposure owns it) so there is one answer, and it is clamped so a
// keyed lerp through 0 can't black the film out.
if(key === 'exposure') this._applyExposure();
}
if(en.kind === 'backdrop'){
if(key === 'fit'){ value === 'frustum' ? this._fitBackdrop(en) : this._unfitBackdrop(en);
if(this._selected === en.id) this.select(en.id); } // frustum ⇄ free swaps the gizmo
if(key === 'fitDist' || key === 'fitAnchor') this._fitBackdrop(en);
if(key === 'groundTint') this._applyWorld();
if(key === 'plateExposure' && en.root){
// map-bearing materials only: the corner shadow catcher is a ShadowMaterial whose
// `color` is the shadow tint — setScalar(1) on it would erase the shadow.
const mats = []; en.root.traverse(o => { if(o.isMesh && o.material && o.material.map) mats.push(o.material); });
this._plateLevel(mats, value);
}
}
const rebuild = () => this._rebuild(en).catch(e => console.warn('media rebuild failed:', en.id, e));
if(en.kind === 'backdrop' && (key === 'mode' || key === 'image' || key === 'width'))
rebuild(); // geometry depends on these — rebuild the mesh
if(en.kind === 'screen'){
if(key === 'emissive' && en.root)
en.root.traverse(o => { if(o.isMesh) o.material.emissiveIntensity = value; });
if(key === 'video' || key === 'width') rebuild();
}
// videoStart is data for the timeline/syncVideos — nothing to rebuild
}
async _rebuild(en){ // backdrop/screen media rebuild (mode/image/video/width changed)
const old = en.root;
this._dropVideo(en);
en.root = en.kind === 'screen' ? await this._buildScreen(en) : await this._buildBackdrop(en);
en.wrapper.add(en.root);
if(old){ en.wrapper.remove(old); disposeRoot(old); }
if(en.kind === 'backdrop'){ this._fitBackdrop(en); en.ground = null; this._sampleGround(en); }
}
// ---- clips (bake is expensive — cache per path parse and per (entity,path,index) bake) ----
async prepareClip(id, path, clipIndex=0){
const en = this._entities.get(id);
if(!en || en.kind !== 'character') throw new Error('prepareClip: not a character: ' + id);
const bkey = `${id}|${path}|${clipIndex}`;
if(this._baked.has(bkey)) return this._baked.get(bkey);
let src = this._clipSrc.get(path);
if(!src){
const res = await fetch(ASSET_URL(path));
if(!res.ok) throw new Error('clip fetch ' + res.status + ': ' + path);
const { root, anims } = await parseAny(await res.arrayBuffer(), path);
src = { root, anims, rest: captureRest(root) };
this._clipSrc.set(path, src);
}
const clip = src.anims[clipIndex];
if(!clip) throw new Error('no clip index ' + clipIndex + ' in ' + path);
const baked = bakeRetarget(clip, src.root, src.rest, en.root, en.rest);
this._baked.set(bkey, baked);
return baked;
}
// same bake, from in-memory bytes (dropped clip file) instead of a server path
async prepareClipUpload(id, buf, name, clipIndex=0){
const en = this._entities.get(id);
if(!en || en.kind !== 'character') throw new Error('prepareClipUpload: not a character: ' + id);
const { root, anims } = await parseAny(buf, name);
const clip = anims[clipIndex];
if(!clip) throw new Error('no animation tracks in ' + name);
return bakeRetarget(clip, root, captureRest(root), en.root, en.rest);
}
entityMixer(id){ return this._entities.get(id)?.mixer || null; }
// ---- visemes / morph targets (lip sync) ----
visemeTargets(id){ return Object.keys(this._entities.get(id)?.visemes || {}); }
// drive a canonical viseme (A/E/I/O/U/MBP/FV/L) or a raw morph name; sets every mapped mesh morph
setMorph(id, name, weight){
const en = this._entities.get(id); if(!en || !en.morphs) return;
const raws = (en.visemes && en.visemes[name]) || (en.morphs.has(name.toLowerCase()) ? [name.toLowerCase()] : []);
for(const r of raws) for(const {mesh, idx} of en.morphs.get(r))
if(mesh.morphTargetInfluences) mesh.morphTargetInfluences[idx] = weight;
}
// M1 temp helper: play a prepared clip on a character (Timeline replaces this in M2).
playClip(id, clip, {loop=true} = {}){
const en = this._entities.get(id); if(!en || !en.mixer) return;
en.mixer.stopAllAction();
const a = en.mixer.clipAction(clip);
a.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
a.clampWhenFinished = !loop; a.reset().play();
}
setMixerAuto(on){ this._mixerAuto = on; } // Timeline calls setMixerAuto(false) at integration
// ---- cameras ----
setActiveCamera(id){
this._activeCamId = id;
this._pip.style.display = (id && this._entities.get(id)?.cam) ? 'block' : 'none';
this.fitBackdrops(); // auto-fit plates belong to whichever camera is shooting
}
activeCamera(){ return this._activeCamId || null; } // entity id, or null = director orbit cam
_activeCam(){ const en = this._activeCamId && this._entities.get(this._activeCamId);
return en && en.cam ? en.cam : this._dirCam; }
// render `cam` to the main WebGL canvas at the target's aspect; if `canvas` given, blit there and
// restore the cam's aspect (so using a cam for the inset never distorts it in the main view).
// SYNC7: gizmo + helpers were being BAKED INTO THE FINAL MP4 (a green translate
// arrow across every frame). Anything that's editor chrome hides while paused —
// pause() is exactly "the caller is rendering output now".
_chrome(visible){
const set = o => { if(o) o.visible = visible; };
set(this._tc); set(this._grid);
this.scene.traverse(o => { if(o.userData.chrome || o.isTransformControls || o.type === 'GridHelper'
|| o.isCameraHelper || o.isDirectionalLightHelper || o.isBox3Helper || o.isSkeletonHelper) o.visible = visible; });
for(const en of this._entities.values()) if(en.helper) en.helper.visible = visible;
}
_render(cam, canvas){
// Consume the shadow refit HERE rather than in the rAF loop: the final render drives _render
// directly (renderActiveCamera, while _paused), so a fit that only fired on a rAF callback
// made which frames saw the new ortho box depend on browser timing instead of on
// timeline.step(). Still ≤1 refit per drawn frame — the PiP pass clears the flag for the main.
if(this._shadowDirty){ this._shadowDirty = false; this._fitShadows(); }
const w = canvas ? canvas.width : this.renderer.domElement.width;
const h = canvas ? canvas.height : this.renderer.domElement.height;
const prev = cam.aspect;
if(cam.isPerspectiveCamera){ cam.aspect = w/h; cam.updateProjectionMatrix(); }
// SYNC7: plates are fitted to the LIVE viewport aspect, but output renders at their own
// (960x540 etc). A narrower output frame is taller than what the plate was sized for →
// black bars top and bottom of every rendered film. Refit to the aspect we're about to
// draw at. Pure algebra (no texture sampling), so per-frame is fine.
if(cam.isPerspectiveCamera && Math.abs((prev || 0) - cam.aspect) > 1e-4) this.fitBackdrops();
if(this._paused) this._chrome(false);
this.renderer.render(this.scene, cam);
if(this._paused) this._chrome(true);
if(canvas){ canvas.getContext('2d').drawImage(this.renderer.domElement, 0, 0, canvas.width, canvas.height);
if(cam.isPerspectiveCamera && cam.aspect !== prev){ cam.aspect = prev; cam.updateProjectionMatrix(); } }
}
// PiP: pass the small overlay canvas. Final render (M3): pass null after pause() — the active cam
// lands on the main WebGL canvas at its current size for readPixels/toBlob. Hard contract, keep it.
renderActiveCamera(canvas){ this._render(this._activeCam(), canvas); }
pause(){ this._paused = true; }
resume(){ this._paused = false; this.clock.getDelta(); } // swallow the paused gap so mixers don't jump
// ---- director-mode helpers (M5 presets.js solvers are three-free — plain arrays) ----
entityBBox(id){
const en = this._entities.get(id); if(!en) return null;
en.wrapper.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(en.wrapper);
return (box.isEmpty() || !isFinite(box.min.x)) ? null : { min: box.min.toArray(), max: box.max.toArray() };
}
cameraWorldPos(){ // active camera if set, else the director orbit cam
const p = new THREE.Vector3(); this._activeCam().getWorldPosition(p); return p.toArray();
}
viewAspect(){ return this._activeAspect(); } // M11: a truck is framed in the REAL frame, not 16:9
// "stamp a camera on the view you are looking at" — the orbit cam's own state, so a new camera
// lands where you already composed instead of at a hardcoded [0,1.6,4] you then guess back.
directorCamState(){
const c = this._dirCam, p = c.position, r = c.rotation; // .rotation is already an XYZ euler
return { pos:[p.x, p.y, p.z], rot:[r.x, r.y, r.z], fov: c.fov };
}
// ---- video plates (M6): master-clock sync + deterministic render seeks ----
videos(){ return this.entities().filter(e => e.video)
.map(e => ({ id: e.id, video: e.video, start: e.params.videoStart || 0 })); }
entityVideo(id){ return this._entities.get(id)?.video || null; } // SYNC6: Lane B's per-entity seam
setVideoPlaying(on){ for(const { video } of this.videos()) on ? video.play().catch(()=>{}) : video.pause(); }
// Seek every stage video to timeline time t and resolve once all fire 'seeked' —
// render.js awaits this per frame so plates are frame-exact, never mid-decode. Hard contract.
async syncVideos(t){
const jobs = [];
// SYNC6: r160 VideoTexture auto-updates via requestVideoFrameCallback, which never
// fires in a hidden/background tab — force the GPU upload or renders show black plates.
for(const en of this._entities.values()) if(en.videoTex) en.videoTex.needsUpdate = true;
for(const { video, start } of this.videos()){
if(video.readyState < 1 || !isFinite(video.duration) || video.duration <= 0) continue;
// videoTimeAt CLAMPS (matching Timeline._evalVideo) — it used to WRAP here, so a plate with
// videoStart:2 on a 10s clip showed frame 0 while scrubbing and the 8.0s frame in the mp4.
const vt = videoTimeAt(t, start, video.duration);
if(!video.paused) video.pause();
if(Math.abs(video.currentTime - vt) < 0.004) continue; // already on this frame
jobs.push(new Promise(res => {
const done = () => { video.removeEventListener('seeked', done); res(); };
video.addEventListener('seeked', done);
setTimeout(done, 1500); // ponytail: never hang a render on a stuck decoder
video.currentTime = vt;
}));
}
await Promise.all(jobs);
}
// ---- pointer→world helpers (drag-from-dock placement) ----
screenToFloor(clientX, clientY){
const rect = this.renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(((clientX-rect.left)/rect.width)*2-1, -((clientY-rect.top)/rect.height)*2+1);
const rc = new THREE.Raycaster(); rc.setFromCamera(ndc, this._dirCam);
const hit = new THREE.Vector3();
return rc.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0,1,0), 0), hit) ? [hit.x, 0, hit.z] : [0,0,0];
}
entityAt(clientX, clientY, kind){
const rect = this.renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(((clientX-rect.left)/rect.width)*2-1, -((clientY-rect.top)/rect.height)*2+1);
const rc = new THREE.Raycaster(); rc.setFromCamera(ndc, this._dirCam);
const hits = rc.intersectObjects(this.entities().map(e=>e.wrapper), true);
for(const h of hits){ let o=h.object; while(o && o.userData.entityId==null) o=o.parent;
if(o){ const en=this._entities.get(o.userData.entityId); if(en && (!kind || en.kind===kind)) return en.id; } }
return null;
}
// ---- scene JSON round-trip (entities only; Timeline owns tracks/duration/cuts) ----
captureState(){
return this.entities().map(en => ({
id: en.id, kind: en.kind, label: en.label,
source: en.source, params: {...en.params},
transform: this.entityTransform(en.id),
upload: en.source && en.source.type === 'upload' || undefined,
}));
}
async applyState(sceneJson){
for(const en of this.entities()) this.removeEntity(en.id);
for(const d of (sceneJson.entities || [])){
// skip only what needs asset BYTES we don't have: an 'upload' mesh, backdrop or screen.
// camera/light use source:{type:'none'} (legacy scenes: 'upload') and always rebuild.
const needsBytes = d.kind === 'character' || d.kind === 'prop' || d.kind === 'backdrop' || d.kind === 'screen';
if(d.source && d.source.type === 'upload' && needsBytes){
console.warn('skipping upload entity (no bytes):', d.id); continue; }
try { await this.addEntity(d); } catch(err){ console.error('addEntity failed', d.id, err); }
}
this.fitBackdrops(); // cameras usually load after their plates
}
}