SCENEGOD/scenegod/web/dock.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

479 lines
27 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.

// dock.js — asset dock (left) + inspector (right). Talks to Stage's public API only.
// M1: click a card to add; click an animation to bake onto the selected character; drop local
// files onto the view for offline/upload work. M2: Lane B takes over clip placement.
import { stats } from './room3d.js';
const TABS = ['characters', 'props', 'backdrops', 'animations'];
const esc = s => String(s).replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); // ship-check: XSS
// What a 🗑 delete would destroy, and whether that is worth a confirm dialog.
// stage.removeEntity → Timeline._mirror splices the entity out of scene.entities, taking its
// transform/param/morph keys and clip blocks with it, and nothing pushes an undo entry — Cmd+Z
// cannot bring it back. So count the work first and only ask when there IS work; a freshly added,
// unkeyed entity stays one click, which is the common case.
// CAMERA CUTS COUNT AS WORK: "never confirm for a camera" let one unconfirmed click leave a cut
// pointing at a dead id, and the server rejects that scene with 422 on every save afterwards.
// Returns the cut OBJECTS too, so the caller can take them with the camera.
export function deleteWarning(tl, en){
const e = tl && tl.scene && (tl.scene.entities || []).find(x => x.id === en.id);
const keys = (e?.tracks?.transform?.length||0) + (e?.tracks?.params?.length||0)
+ (e?.tracks?.morphs?.length||0);
const clips = e?.tracks?.clips?.length||0;
const cutObjs = (tl && tl.scene && (tl.scene.cameraCuts || []) || []).filter(c => c.camera === en.id);
// no timeline mounted → no count to show: ask for meshes, never for a camera/light
const ask = tl ? (keys + clips + cutObjs.length > 0)
: (en.kind === 'character' || en.kind === 'prop');
// Only what is actually there, singularised: "0 keyframes, 0 clip blocks, 1 camera cut go with
// it" both listed nothing and disagreed with itself. And say the part that matters most —
// nothing about this delete is undoable (see dropCuts).
const n = (c, one, many) => `${c} ${c === 1 ? one : many}`;
const bits = [];
if(keys) bits.push(n(keys, 'keyframe', 'keyframes'));
if(clips) bits.push(n(clips, 'clip block', 'clip blocks'));
if(cutObjs.length) bits.push(n(cutObjs.length, 'camera cut', 'camera cuts'));
const plural = bits.length > 1 || keys > 1 || clips > 1 || cutObjs.length > 1;
const msg = bits.length
? `Delete "${en.label}"? ${bits.join(' and ')} ${plural ? 'go' : 'goes'} with it. This cannot be undone.`
: `Delete "${en.label}"? This cannot be undone.`;
return { keys, clips, cuts: cutObjs.length, cutObjs, ask, msg };
}
// Take a deleted camera's cameraCuts with it — WITHOUT leaving an undo entry behind.
// A delete spans two subsystems and only one of them has an undo: Stage.removeEntity pushes
// nothing, and Timeline._mirror splices the entity + its tracks out just as silently. Removing the
// cuts inside `tl.group()` (what this used to do) therefore made the delete HALF-undoable: one
// Ctrl+Z put the cut back without the camera it points at — a dangling cut, i.e. exactly the
// `cameraCut references non-camera entity` 422 the cleanup exists to prevent, one keystroke away
// and with no second undo to fix it. Symmetry is the rule: the entity cannot come back, so neither
// may its cuts. `undoStack` is Lane B's cross-lane surface for exactly this kind of bookkeeping
// (tlui marks and collapses it around a drag); removeCut is still the only thing that touches the
// model, so `_activeCam` invalidation etc. stay Lane B's.
export function dropCuts(tl, cuts){
if(!tl || typeof tl.removeCut !== 'function' || !cuts || !cuts.length) return 0;
const mark = Array.isArray(tl.undoStack) ? tl.undoStack.length : -1;
for(const c of cuts) tl.removeCut(c);
if(mark >= 0) tl.undoStack.length = mark; // the cuts left with the camera; they don't come back
return cuts.length;
}
// Is this param name driven by a key? Then the inspector field is a request, not a setting:
// Timeline.syncFromStage resets the model to the authored value on the same tick and the next
// evaluate() writes the key's value over the stage. Say so instead of letting the number bounce.
export function keyedParam(tl, id, key){
const e = tl && tl.scene && (tl.scene.entities || []).find(x => x.id === id);
return !!(e && (e.tracks?.params || []).some(p => p.key === key));
}
export class Dock {
constructor({ stage, dockEl, inspectorEl, viewEl }){
this.stage = stage; this.dock = dockEl; this.insp = inspectorEl; this.view = viewEl;
this.tree = { characters:[], props:[], backdrops:[], animations:[] };
this.tab = 'characters';
stage.onSelect(en => this.renderInspector(en));
stage.onChange(en => { if(en && en.id === this._selId) this.renderInspector(en); });
this._selId = null;
this._buildTabs();
this._viewDrop();
this._dropZone();
this.load();
}
async load(){
const empty = { characters:[], props:[], backdrops:[], animations:[] };
this._offline = false; this._loadErr = null;
try {
const r = await fetch('assets/tree');
if(r.ok) this.tree = { ...empty, ...(await r.json()) };
else {
// a REACHED server that answered 500 is not "offline" — SCENEGOD_ASSETS pointing at a
// missing dir says so in the body, and collapsing that into "server offline" sent every
// prod volume mis-mount to the wrong place to look.
this._loadErr = r.status + ': ' + (await r.text().catch(() => '')).slice(0, 120);
this.tree = { ...empty };
}
} catch(e){
this._offline = true; // fetch threw = nothing answered
this.tree = { ...empty };
}
this.renderBrowser();
}
_buildTabs(){
const add = document.createElement('div'); add.className = 'addbar';
for(const [txt, what, tip] of [['+ camera (from view)','camera','stamps a camera at the orbit view you are looking at now'],
['+ key light','keylight',''], ['+ ambient','ambient','']]){
const b = document.createElement('button'); b.textContent = txt; if(tip) b.title = tip;
b.onclick = () => this._addRig(what).catch(e => this._toast('failed: ' + String(e).slice(0,80)));
add.appendChild(b);
}
this.dock.appendChild(add);
const bar = document.createElement('div'); bar.className = 'tabs';
this._tabBtns = {};
for(const t of TABS){
const b = this._tabBtns[t] = document.createElement('button');
b.innerHTML = t.toUpperCase() + '<i>0</i>'; // TABS is a module constant — nothing to escape
b.className = t === this.tab ? 'on' : '';
b.onclick = () => { this.tab = t; [...bar.children].forEach(c => c.classList.toggle('on', c === b)); this.renderBrowser(); };
bar.appendChild(b);
}
this.dock.appendChild(bar);
this.list = document.createElement('div'); this.list.className = 'cards';
this.dock.appendChild(this.list);
}
// drag a dock card onto the viewport → place at the drop point (chars/props) or bake onto the
// character under the cursor (animations). Library items only; local files use the window drop.
_viewDrop(){
this.view.addEventListener('dragover', e => {
if([...e.dataTransfer.types].includes('application/scenegod')) e.preventDefault(); });
this.view.addEventListener('drop', async e => {
const raw = e.dataTransfer.getData('application/scenegod');
if(!raw) return; // a local file drop — let the window handler take it
e.preventDefault(); e.stopPropagation();
const { tab, path, name, video } = JSON.parse(raw);
try {
if(tab === 'animations'){
const id = this.stage.entityAt(e.clientX, e.clientY, 'character');
if(!id) return this._toast('drop the clip onto a character');
return this._applyClip(id, path, 0);
}
const kind = tab === 'characters' ? 'character' : tab === 'props' ? 'prop' : 'backdrop';
const desc = { kind, label: name, source:{ type:'assets', path: video || path } };
if(kind === 'backdrop') desc.params = video
? { mode:'video', image:video, width:12, fit:'frustum' }
: { mode:'plane', image:path, width:10, fit:'frustum' };
else desc.transform = { pos: this.stage.screenToFloor(e.clientX, e.clientY), rot:[0,0,0], scale:1 };
const en = await this.stage.addEntity(desc);
this.stage.select(en.id);
} catch(err){ this._toast('drop failed: ' + String(err).slice(0,90)); }
});
}
// /assets/tree item shape (confirmed by Lane C): {name, path, formats:[...], thumb: relpath|null}
_path(it){ return it.path; }
_name(it){ return it.name || (it.path || '').split('/').pop(); }
// M6 video plates: same-stem grouping guarantees <stem>.<videoExt> when formats carries one
_videoPath(it){
const vf = (it.formats || []).find(f => ['mp4','webm','mov'].includes(f));
return vf ? (it.path || '').replace(/\.\w+$/, '.' + vf) : null;
}
renderBrowser(){
this.list.innerHTML = '';
for(const t of TABS){ const b = this._tabBtns && this._tabBtns[t];
if(b) b.innerHTML = t.toUpperCase() + '<i>' + ((this.tree[t] || []).length) + '</i>'; }
const items = this.tree[this.tab] || [];
if(!items.length){
this.list.innerHTML = this._offline
? '<div class="empty">server offline — drag a .glb / .fbx / image onto the view</div>'
: this._loadErr
? `<div class="empty"><b>assets/tree failed</b><br>${esc(this._loadErr)}</div>`
// "drop banks into assets/…" was an instruction you cannot follow from a browser
: `<div class="empty">no ${esc(this.tab)} on the server yet — drag a .glb / .fbx / image `
+ 'from your desktop onto the stage</div>';
return;
}
const icon = { characters:'🕺', props:'🪑', backdrops:'🖼', animations:'🏃' }[this.tab];
for(const it of items){
const c = document.createElement('div'); c.className = 'card'; c.draggable = true;
const th = it.thumb
? `<img class="th" loading="lazy" src="assets/file?path=${encodeURIComponent(it.thumb)}">`
: `<div class="th">${icon}</div>`;
const vp = this.tab === 'backdrops' ? this._videoPath(it) : null;
c.innerHTML = `${th}<div class="nm">${vp ? '🎞 ' : ''}${esc(this._name(it))}</div>`;
c.onclick = () => this._addFromLibrary(it);
c.ondragstart = ev => ev.dataTransfer.setData('application/scenegod',
JSON.stringify({ tab: this.tab, path: this._path(it), name: this._name(it), video: vp }));
if(vp){ // second affordance on video plates: add as an in-set screen (TV/club/jumbotron)
const tv = document.createElement('button'); tv.className = 'tv'; tv.textContent = '📺';
tv.title = 'add as in-set screen';
tv.onclick = ev => { ev.stopPropagation(); this._addScreen(vp, this._name(it)); };
c.appendChild(tv);
}
this.list.appendChild(c);
}
}
async _addFromLibrary(it){
const path = this._path(it);
try {
if(this.tab === 'animations'){
const en = this.stage.getEntity(this._selId);
if(!en || en.kind !== 'character') return this._toast('select a character first');
return this._applyClip(en.id, path, 0);
}
const kind = this.tab === 'characters' ? 'character' : this.tab === 'props' ? 'prop' : 'backdrop';
const desc = { kind, label: this._name(it), source: { type:'assets', path } };
if(kind === 'backdrop'){
const vp = this._videoPath(it); // video plate → video plane; else image plane
// M7: new plates auto-fit the camera frustum (saved scenes without `fit` keep their sizing)
desc.params = vp ? { mode:'video', image:vp, width:12, fit:'frustum' }
: { mode:'plane', image:path, width:10, fit:'frustum' };
if(vp) desc.source = { type:'assets', path: vp };
}
this._toast('loading ' + this._name(it) + '…');
const en = await this.stage.addEntity(desc);
this.stage.select(en.id);
this._toast('');
} catch(e){ this._toast('failed: ' + String(e).slice(0, 100)); }
}
// clips go to the timeline once it's mounted; standalone preview only when it isn't
async _applyClip(id, path, clipIndex){
if(window.timeline){
dispatchEvent(new CustomEvent('scenegod:clipdrop', { detail:{ id, path, clipIndex } }));
this._toast('+ ' + path.split('/').pop() + ' → timeline');
return;
}
this._toast('baking clip…');
const clip = await this.stage.prepareClip(id, path, clipIndex);
this.stage.playClip(id, clip);
this._toast('▶ ' + path.split('/').pop());
}
// M6: video plate → `screen` entity (emissive in-set display, PLAN §4.1 params)
async _addScreen(path, name){
try {
this._toast('loading ' + name + '…');
const en = await this.stage.addEntity({ kind:'screen', label:name,
source:{ type:'assets', path }, params:{ video:path, width:3, emissive:0.6 } });
this.stage.select(en.id); this._toast('');
} catch(e){ this._toast('failed: ' + String(e).slice(0, 100)); }
}
// cameras and lights have no asset file — spawn them from the toolbar
async _addRig(what){
// a camera spawns ON THE VIEW you composed — the old hardcoded [0,1.6,4] meant orbiting to a
// framing and then guessing it back with the gizmo.
const cs = what === 'camera' && this.stage.directorCamState ? this.stage.directorCamState() : null;
const D = {
camera: { kind:'camera', label:'camera', params:{ fov: cs ? cs.fov : 45 },
transform: cs ? { pos:cs.pos, rot:cs.rot, scale:1 } : { pos:[0,1.6,4], rot:[0,0,0], scale:1 } },
keylight: { kind:'light', label:'key light', params:{ type:'key', color:'#ffffff', intensity:2.2, castShadow:true }, transform:{ pos:[3,5,2], rot:[-0.9,0.5,0], scale:1 } },
ambient: { kind:'light', label:'ambient', params:{ type:'ambient', color:'#ffffff', intensity:1.0 } },
}[what];
const en = await this.stage.addEntity(D);
this.stage.select(en.id);
if(en.kind === 'camera') this.stage.setActiveCamera(en.id); // preview through the new cam
}
// ---- inspector ----
// EVERY inspector param edit goes through here. The value is live on the stage immediately, but
// the timeline keeps its OWN snapshot of each entity's params (taken once by _mirror, at drop
// time) and evaluates from that — so typing 2 into `vid start` moved the RENDERED frame
// (stage.syncVideos reads the stage) while the scrubbed viewport stayed on 0
// (Timeline._evalVideo reads the snapshot): the same param meaning two different things.
// syncFromStage is Lane B's own "write the stage back into the model" pass; running it on the
// edit instead of only at save time is what actually makes "what you scrub is what you render"
// true, rather than just sharing the arithmetic.
_param(id, key, value){
this.stage.setParam(id, key, value);
const tl = typeof window !== 'undefined' && window.timeline;
if(tl && tl.syncFromStage) tl.syncFromStage();
// …and if the channel is KEYED, that sync just threw the typed number away (and the next
// evaluate will overwrite the stage too). Silently. Say it: a light preset makes `exposure`
// a keyed param, and "type it into the ambient inspector" was the documented way to fix a
// level — it is now "⏺ key", and nothing said so.
// Said through tlui's toast channel (window.sgToast) when it exists, because that is where
// "<entity> is keyed — press ⏺ key…" already goes for a gizmo drag: one channel, one banner,
// and a duplicate from the timeline's own side simply overwrites it rather than stacking.
if(keyedParam(tl, id, key)){
const m = `${key} is keyed — the key at the playhead wins. Press ⏺ key to change it there.`;
(typeof window !== 'undefined' && window.sgToast) ? window.sgToast(m) : this._toast(m);
}
}
renderInspector(en){
this._selId = en ? en.id : null;
const el = this.insp; el.innerHTML = '';
if(!en){ el.innerHTML = '<div class="empty">nothing selected</div>'; return; }
const t = this.stage.entityTransform(en.id);
const h = document.createElement('div'); h.className = 'ihd';
h.innerHTML = `<b>${esc(en.label)}</b><span class="kind">${esc(en.kind)}${
en.source && en.source.type === 'upload' ? ' · <span class="warn">upload</span>' : ''}</span>`;
el.appendChild(h);
// setTransform hard-returns for a frustum-fitted plate (the lens owns it), so nine number
// fields that look like everyone else's were silently discarding everything typed into them.
if(en.kind === 'backdrop' && en.params.fit === 'frustum'){
const n = document.createElement('div'); n.className = 'inote';
n.textContent = 'placed by the camera — switch fit to "free" to position it by hand';
el.appendChild(n);
} else if(en.kind === 'light'){
// A key light aims at the stage centre (stage.js _buildLight: the target is nailed to the
// origin so the ortho shadow box, measured from the origin, stays over the stage). Its
// ANGLE therefore IS its position — elevation/azimuth on a boom, exactly what the light
// presets solve. `rot`/`scale` fields would have been three number rows that change
// nothing, so they are a sentence instead. An ambient (hemisphere) has no transform at all.
const n = document.createElement('div'); n.className = 'inote';
if(en.params.type === 'ambient'){
n.textContent = 'ambient light — it comes from everywhere: no position, no angle';
} else {
el.appendChild(this._vec3('pos', t.pos, v => this.stage.setTransform(en.id, { pos:v })));
n.textContent = 'aims at the stage centre — MOVE it to change the light angle (rotating does nothing)';
}
el.appendChild(n);
} else {
el.appendChild(this._vec3('pos', t.pos, v => this.stage.setTransform(en.id, { pos:v })));
el.appendChild(this._vec3('rot', t.rot, v => this.stage.setTransform(en.id, { rot:v })));
el.appendChild(this._num('scale', t.scale, v => this.stage.setTransform(en.id, { scale:v })));
}
if(en.kind === 'character'){
const vis = this.stage.visemeTargets ? this.stage.visemeTargets(en.id) : [];
const badge = document.createElement('div');
badge.className = 'viseme' + (vis.length ? '' : ' off');
badge.textContent = vis.length ? `🗣 visemes: ${vis.join(' ')}` : '🗣 no visemes';
badge.title = vis.length ? 'lip-sync targets detected' : 'no mouth morph targets on this rig';
el.appendChild(badge);
}
if(en.kind === 'camera'){
el.appendChild(this._num('fov', en.params.fov ?? 45, v => this._param(en.id, 'fov', v)));
const act = document.createElement('button'); act.textContent = '◉ set active camera'; act.className = 'wide';
act.onclick = () => this.stage.setActiveCamera(en.id);
el.appendChild(act);
}
if(en.kind === 'light'){
el.appendChild(this._num('intensity', en.params.intensity ?? 1.5, v => this._param(en.id, 'intensity', v)));
el.appendChild(this._color('color', en.params.color || '#ffffff', v => this._param(en.id, 'color', v)));
if(en.params.type === 'ambient'){ // M7: keyable atmosphere (0 = off), coloured from the plate
el.appendChild(this._num('fog', en.params.fog ?? 0.02, v => this._param(en.id, 'fog', v)));
// ACES level for the WHOLE frame — rides the ambient entity like bg/fog (keyable, persists)
el.appendChild(this._num('exposure', en.params.exposure ?? 1,
v => this._param(en.id, 'exposure', v)));
}
}
if(en.kind === 'backdrop'){
el.appendChild(this._sel('mode', ['plane','corner','dome','video'], en.params.mode || 'plane',
v => this._param(en.id, 'mode', v))); // stage rebuilds the mesh on change
el.appendChild(this._sel('fit', ['frustum','free'], en.params.fit || 'free',
v => { this._param(en.id, 'fit', v); this.renderInspector(en); }));
if(en.params.fit === 'frustum'){
el.appendChild(this._num('fit dist', en.params.fitDist ?? 14, v => this._param(en.id, 'fitDist', v)));
// which fraction of the plate sits on the lens axis: 1 = top edge at eye level (void above),
// 0.5 = dead centre, 0.75 = a typical plate's horizon at eye level (SYNC7 default)
el.appendChild(this._range('anchor', en.params.fitAnchor ?? 0.75,
v => this._param(en.id, 'fitAnchor', v)));
const re = document.createElement('button'); re.textContent = '⤢ refit to camera'; re.className = 'wide';
re.onclick = () => this._param(en.id, 'fit', 'frustum'); // after moving the camera
el.appendChild(re);
}
// plates are UNLIT (they are display-referred photographs, not surfaces) — this is their
// only level control, 1 = the image as shot.
el.appendChild(this._num('plate exp', en.params.plateExposure ?? 1,
v => this._param(en.id, 'plateExposure', Math.max(0, Math.min(2, v)))));
el.appendChild(this._chk('ground tint', en.params.groundTint ?? true,
v => this._param(en.id, 'groundTint', v)));
if(en.video) el.appendChild(this._num('vid start', en.params.videoStart ?? 0,
v => this._param(en.id, 'videoStart', v)));
}
if(en.kind === 'screen'){
el.appendChild(this._num('width', en.params.width ?? 3, v => this._param(en.id, 'width', v)));
el.appendChild(this._num('emissive', en.params.emissive ?? 0.6, v => this._param(en.id, 'emissive', v)));
el.appendChild(this._num('vid start', en.params.videoStart ?? 0,
v => this._param(en.id, 'videoStart', v)));
}
const row = document.createElement('div'); row.className = 'ibtns';
const key = document.createElement('button'); key.textContent = '⏺ key'; key.title = 'capture keyframe';
key.onclick = () => dispatchEvent(new CustomEvent('scenegod:capturekey', { detail:{ id: en.id } }));
const del = document.createElement('button'); del.textContent = '🗑 delete'; del.className = 'danger';
del.onclick = () => {
const tl = window.timeline;
const w = deleteWarning(tl, en);
if(w.ask && !confirm(w.msg)) return;
this.stage.removeEntity(en.id);
// Timeline._mirror drops the entity and its tracks, but a cameraCut pointing at it survives
// and the scene then fails server validation ("cameraCut references non-camera entity") —
// the whole scene becomes unsaveable until the user hunts the cut down by hand. The cuts
// leave with the camera, and leave the same way it did: not undoably (see dropCuts).
dropCuts(tl, w.cutObjs);
this.renderInspector(null);
};
row.append(key, del); el.appendChild(row);
}
_field(label, inputEl){ const w = document.createElement('label'); w.className = 'fld';
w.innerHTML = `<span>${esc(label)}</span>`; w.appendChild(inputEl); return w; }
_num(label, val, cb){ const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=val;
i.oninput = () => cb(parseFloat(i.value) || 0); return this._field(label, i); }
_color(label, val, cb){ const i = document.createElement('input'); i.type='color'; i.value=val;
i.oninput = () => cb(i.value); return this._field(label, i); }
_chk(label, val, cb){ const i = document.createElement('input'); i.type='checkbox'; i.checked=!!val;
i.onchange = () => cb(i.checked); return this._field(label, i); }
_range(label, val, cb, min=0, max=1, step=0.05){ // 01 slider + live readout
const i = document.createElement('input'); i.type='range'; i.min=min; i.max=max; i.step=step; i.value=val;
const w = this._field(label, i), n = document.createElement('b'); n.className='rv';
n.textContent = (+val).toFixed(2);
i.oninput = () => { n.textContent = (+i.value).toFixed(2); cb(parseFloat(i.value)); };
w.appendChild(n); return w; }
_sel(label, opts, val, cb){ const s = document.createElement('select');
for(const o of opts){ const op = document.createElement('option'); op.value=op.textContent=o; if(o===val) op.selected=true; s.appendChild(op); }
s.onchange = () => cb(s.value); return this._field(label, s); }
_vec3(label, arr, cb){ const w = document.createElement('div'); w.className = 'fld vec';
w.innerHTML = `<span>${esc(label)}</span>`;
const cur = [...arr];
['x','y','z'].forEach((_, k) => { const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=+arr[k].toFixed(3);
i.oninput = () => { cur[k] = parseFloat(i.value) || 0; cb([...cur]); }; w.appendChild(i); });
return w; }
// ---- local file drop (upload / offline path) ----
_dropZone(){
addEventListener('dragover', e => { e.preventDefault(); document.body.classList.add('hot'); });
addEventListener('dragleave', e => { if(e.target === document.body) document.body.classList.remove('hot'); });
addEventListener('drop', async e => {
e.preventDefault(); document.body.classList.remove('hot');
for(const f of e.dataTransfer.files){
const nm = f.name.toLowerCase();
try {
if(/\.(png|jpe?g|webp)$/.test(nm)){
const url = URL.createObjectURL(f);
const en = await this.stage.addEntity({ kind:'backdrop', label:f.name,
source:{ type:'upload', path:f.name, _url:url }, params:{ mode:'plane', fit:'frustum' } });
this.stage.select(en.id);
} else if(/\.(mp4|webm|mov)$/.test(nm)){ // M6: dropped video → video plate (upload, warn on save)
const url = URL.createObjectURL(f);
const en = await this.stage.addEntity({ kind:'backdrop', label:f.name,
source:{ type:'upload', path:f.name, _url:url }, params:{ mode:'video', width:12, fit:'frustum' } });
this.stage.select(en.id);
} else if(/\.(glb|gltf|fbx|obj|bvh)$/.test(nm)){
const buf = await f.arrayBuffer();
// clip drop onto selected character → bake; else load as a character
const sel = this.stage.getEntity(this._selId);
const probe = await this._probe(buf, f.name);
if(sel && sel.kind === 'character' && probe.anims && !probe.meshes){
// uploaded clip has no server path → the timeline can't place a block for it
if(window.timeline){ this._toast('add clips from the dock, not local files, once the timeline is up'); }
else {
this._toast('baking ' + f.name + '…');
const clip = await this.stage.prepareClipUpload(sel.id, buf, f.name, 0);
this.stage.playClip(sel.id, clip); this._toast('▶ ' + f.name);
}
} else {
const en = await this.stage.addEntity({ kind:'character', label:f.name,
source:{ type:'upload', path:f.name }, _buf:buf });
this.stage.select(en.id); this._toast('');
}
}
} catch(err){ this._toast('drop failed: ' + String(err).slice(0, 100)); }
}
});
}
// cheap probe: is this file animation-only (clip) or a mesh (character)?
async _probe(buf, name){
const { parseAny } = await import('./room3d.js');
const { root, anims } = await parseAny(buf.slice(0), name);
const st = stats(root);
return { anims: anims.length > 0, meshes: st.meshes };
}
_toast(m){ let t = document.getElementById('toast');
if(!t){ t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); }
t.textContent = m; t.style.display = m ? 'block' : 'none';
clearTimeout(this._th); if(m) this._th = setTimeout(() => t.style.display = 'none', 3500);
}
}