MOVES grammar + solveMove(): push in/pull out (one rung along the shot ladder,
along the aim ray), truck L/R (lateral, parallel, no re-aim), crane up/down
(vertical then re-aim, floor-clamped), orbit L/R (constant distance, +/-25deg),
zoom in/out (focal only - pos and rot returned identical). Amounts are
frame-relative so a move reads the same on a CU as a WS. Emits a new 'move' op
on scenegod:direct with explicit from/to states, pre-clamped duration and ease;
the START is applied to the live camera immediately.
'+ angle' creates a NEW camera from the current shot settings instead of
reframing the active one - the friction hit while building a 4-camera scene by
hand. 🎥 frame now prefers the live camera so it adjusts what you just made
rather than silently editing camera 1. fitAnchor exposed in the backdrop
inspector (it was inert from the UI).
Verified live: push_in 1.975->1.588m fov fixed; zoom_in travel 0.000 with
pos/rot identical, fov 37.85->26.99; orbit_L constant distance; truck_R
parallel. 4 suites green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
334 lines
18 KiB
JavaScript
334 lines
18 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
|
||
|
||
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(){
|
||
try {
|
||
const r = await fetch('assets/tree');
|
||
if(!r.ok) throw new Error(r.status);
|
||
this.tree = { characters:[], props:[], backdrops:[], animations:[], ...(await r.json()) };
|
||
} catch(e){
|
||
this._offline = true;
|
||
this.tree = { characters:[], props:[], backdrops:[], animations:[] };
|
||
}
|
||
this.renderBrowser();
|
||
}
|
||
|
||
_buildTabs(){
|
||
const add = document.createElement('div'); add.className = 'addbar';
|
||
for(const [txt, what] of [['+ camera','camera'], ['+ key light','keylight'], ['+ ambient','ambient']]){
|
||
const b = document.createElement('button'); b.textContent = txt;
|
||
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';
|
||
for(const t of TABS){
|
||
const b = document.createElement('button'); b.textContent = t.toUpperCase();
|
||
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 = '';
|
||
const items = this.tree[this.tab] || [];
|
||
if(!items.length){
|
||
this.list.innerHTML = `<div class="empty">${this._offline
|
||
? 'server offline — drag a .glb / .fbx / image onto the view'
|
||
: 'nothing here — drop banks into assets/' + this.tab + '/'}</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){
|
||
const D = {
|
||
camera: { kind:'camera', label:'camera', params:{ fov:45 }, transform:{ 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 ----
|
||
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);
|
||
|
||
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.stage.setParam(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.stage.setParam(en.id, 'intensity', v)));
|
||
el.appendChild(this._color('color', en.params.color || '#ffffff', v => this.stage.setParam(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.stage.setParam(en.id, 'fog', v)));
|
||
}
|
||
if(en.kind === 'backdrop'){
|
||
el.appendChild(this._sel('mode', ['plane','corner','dome','video'], en.params.mode || 'plane',
|
||
v => this.stage.setParam(en.id, 'mode', v))); // stage rebuilds the mesh on change
|
||
el.appendChild(this._sel('fit', ['frustum','free'], en.params.fit || 'free',
|
||
v => { this.stage.setParam(en.id, 'fit', v); this.renderInspector(en); }));
|
||
if(en.params.fit === 'frustum'){
|
||
el.appendChild(this._num('fit dist', en.params.fitDist ?? 14, v => this.stage.setParam(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.stage.setParam(en.id, 'fitAnchor', v)));
|
||
const re = document.createElement('button'); re.textContent = '⤢ refit to camera'; re.className = 'wide';
|
||
re.onclick = () => this.stage.setParam(en.id, 'fit', 'frustum'); // after moving the camera
|
||
el.appendChild(re);
|
||
}
|
||
el.appendChild(this._chk('ground tint', en.params.groundTint ?? true,
|
||
v => this.stage.setParam(en.id, 'groundTint', v)));
|
||
if(en.video) el.appendChild(this._num('vid start', en.params.videoStart ?? 0,
|
||
v => this.stage.setParam(en.id, 'videoStart', v)));
|
||
}
|
||
if(en.kind === 'screen'){
|
||
el.appendChild(this._num('width', en.params.width ?? 3, v => this.stage.setParam(en.id, 'width', v)));
|
||
el.appendChild(this._num('emissive', en.params.emissive ?? 0.6, v => this.stage.setParam(en.id, 'emissive', v)));
|
||
el.appendChild(this._num('vid start', en.params.videoStart ?? 0,
|
||
v => this.stage.setParam(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 = () => { this.stage.removeEntity(en.id); 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);
|
||
}
|
||
}
|