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>
479 lines
27 KiB
JavaScript
479 lines
27 KiB
JavaScript
// 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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){ // 0–1 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);
|
||
}
|
||
}
|