Compare commits
4 Commits
a15612eed0
...
df4e06f047
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df4e06f047 | ||
|
|
ba29d0c6d6 | ||
|
|
355ad2fe33 | ||
|
|
fb59601cc3 |
@ -147,3 +147,41 @@ BLOCKED/REQUESTS: none.
|
||||
|
||||
NEXT: hold at M3-polish boundary. Available for SYNC-3 findings (finalRender drives
|
||||
`pause`/`resume`/`renderActiveCamera` — kept stable per the hard contract).
|
||||
|
||||
## 2026-07-18 session 4 (SYNC4 fix + M4-A visemes)
|
||||
DONE:
|
||||
- **applyState/source clean fix.** Absorbed the orchestrator's SYNC4 hotfix and did the schema-correct
|
||||
version: `addEntity` defaults camera/light `source` to `{type:'none'}` (PLAN §4.1); `captureState`
|
||||
emits it; `applyState` skips only entities that need bytes it lacks (`type:'upload'` AND
|
||||
kind∈{character,prop,backdrop}), so `'none'` always rebuilds and legacy `'upload'` cams still
|
||||
rebuild. Verified live: save→load of {character, camera, light} → srcTypes
|
||||
`[character:assets, camera:none, light:none]`, all 3 come back (rig no longer lost).
|
||||
- **M4-A viseme lane** (my deliverable = detection + setMorph hook + badge; Rhubarb→keys is Lane B):
|
||||
- `detectVisemes(root)` (stage.js): scans every skinned mesh's `morphTargetDictionary`, loosely
|
||||
maps names → canonical visemes A/E/I/O/U/MBP/FV/L (covers CC `V_Open/V_Explosive/V_Dental_Lip/
|
||||
V_Tongue_*`, ARKit `jawOpen/mouthPucker/mouthFunnel/mouthClose`, plain vowels). Built once at
|
||||
character load; stored on the entity as `{morphs, visemes}` (head/teeth/tongue meshes sharing a
|
||||
morph name are grouped so one call drives them all).
|
||||
- `stage.visemeTargets(id)` → canonical names present; `stage.setMorph(id, name, weight)` drives a
|
||||
canonical viseme OR a raw morph name across all mapped meshes.
|
||||
- inspector 🗣 badge (dock.js): lists detected visemes, greyed `🗣 no visemes` when the rig has none.
|
||||
- `visemeSelfCheck()` exported: synthetic dict of CC+ARKit+blink names → asserts A/U/MBP/FV/L
|
||||
detected, E/I/O NOT (no blink/brow false positives), and setMorph('A') drives the mapped indices
|
||||
only. Verified in-browser: `ok=true, found=[A,FV,L,MBP,U]`.
|
||||
|
||||
VERIFICATION (:8020, zero console errors): viseme self-check ok; man/lady show greyed 🗣 badge
|
||||
(neither rig carries mouth morphs — lady.glb is a CC body export with 0 morph targets, confirmed by
|
||||
reading the GLB); save/load round-trip keeps cameras+lights.
|
||||
|
||||
DECISIONS:
|
||||
- Viseme matcher is intentionally loose (orchestrator asked "match loosely") and detection-only; the
|
||||
authoritative Rhubarb-letter→canonical mapping + keyframing is Lane B's. `setMorph` also accepts
|
||||
raw morph names so B can drive anything the matcher missed.
|
||||
|
||||
BLOCKED/REQUESTS:
|
||||
- @orchestrator: no viseme-bearing test character in `assets/` (man.fbx and lady.glb both have zero
|
||||
mouth morphs), so the LIT badge + a visible mouth move are proven only by the synthetic self-check,
|
||||
not on a real asset. If you can drop a CC/ARKit head with visemes into `assets/characters/test/`,
|
||||
I'll confirm live and Lane B can validate Rhubarb end-to-end.
|
||||
|
||||
NEXT: hold at M4-A boundary. Available for a live viseme asset check and any SYNC findings.
|
||||
|
||||
@ -98,3 +98,16 @@ stage running live; deferred.
|
||||
NEXT: Lane C M1-M4 all shipped. Available for polish / integration support.
|
||||
Blender binary here is /Applications/Blender.app/Contents/MacOS/Blender (not
|
||||
on PATH as `blender` in this session, contrary to orchestrator note).
|
||||
|
||||
## 2026-07-18 session 3b
|
||||
DONE: optional item from orchestrator — `/rhubarb?path=` endpoint. Commit fb59601.
|
||||
- `GET /rhubarb?path=` (path-guarded) -> `rhubarb -f json <wav>` -> returns
|
||||
`{metadata, mouthCues:[{start,end,value}]}` for Lane A's viseme lane.
|
||||
503 with install hint when the binary is absent (ffmpeg-style gate).
|
||||
- test_server.py: +503-when-absent assert (9 groups green).
|
||||
DECISIONS: synchronous subprocess (VO clips are short); rhubarb NOT installed
|
||||
on ultra, so only the 503 path is exercised here — happy path (JSON parse) is
|
||||
untested until `rhubarb` is on PATH. Flagged same as MediaRecorder backlog.
|
||||
BLOCKED/REQUESTS: none. To fully verify: install rhubarb-lip-sync on ultra,
|
||||
then GET /rhubarb?path=audio/<some>.wav.
|
||||
NEXT: nothing outstanding — Lane C M1-M4 + rhubarb shipped. Standing by.
|
||||
|
||||
@ -45,6 +45,7 @@ MEDIA_TYPES = {
|
||||
SCENES.mkdir(parents=True, exist_ok=True)
|
||||
RENDERS.mkdir(parents=True, exist_ok=True)
|
||||
HAVE_FFMPEG = shutil.which("ffmpeg") is not None
|
||||
HAVE_RHUBARB = shutil.which("rhubarb") is not None
|
||||
|
||||
# MODELBEAST proxy (M4) — off unless SCENEGOD_MB=1. Token read from disk at
|
||||
# request time, never logged. Contract mirrors MESHGOD meshgod/ops.py.
|
||||
@ -347,6 +348,23 @@ def render_out(rid: str):
|
||||
return FileResponse(out, media_type="video/mp4")
|
||||
|
||||
|
||||
# ---- viseme lip-sync (M4) — feeds Lane A's viseme lane ----
|
||||
@app.get("/rhubarb")
|
||||
def rhubarb(path: str):
|
||||
"""Rhubarb Lip Sync on an asset audio file -> {metadata, mouthCues:[...]}.
|
||||
503 if rhubarb absent (same pattern as ffmpeg). ponytail: synchronous —
|
||||
VO clips are short; make it a render-style session if that ever bites."""
|
||||
if not HAVE_RHUBARB:
|
||||
raise HTTPException(503, "rhubarb not installed (see github.com/DanielSWolf/rhubarb-lip-sync)")
|
||||
f = safe_under(ASSETS, path)
|
||||
if not f.is_file():
|
||||
raise HTTPException(404, "not found")
|
||||
p = subprocess.run(["rhubarb", "-f", "json", str(f)], capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
raise HTTPException(500, f"rhubarb failed: {p.stderr[-500:]}")
|
||||
return json.loads(p.stdout)
|
||||
|
||||
|
||||
# ---- MODELBEAST proxy (M4, gated) ----
|
||||
def _mb_post(path: str, data: bytes, content_type: str) -> dict:
|
||||
req = urllib.request.Request(MB_HOST + path, data=data, method="POST",
|
||||
|
||||
@ -163,6 +163,14 @@ export class Dock {
|
||||
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';
|
||||
|
||||
@ -8,6 +8,46 @@ import { parseAny, captureRest, disposeRoot, stats, bakeRetarget } from './room3
|
||||
|
||||
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 = {};
|
||||
for(const [canon, pats] of Object.entries(VISEMES)){
|
||||
const hits = [...morphs.keys()].filter(k => pats.some(p => p.test(k)));
|
||||
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};
|
||||
const mesh = { isMesh:true, morphTargetDictionary:dict, morphTargetInfluences:new Array(10).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('E') && !has('I') && !has('O'); // blink/brow must NOT register as visemes
|
||||
// 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 };
|
||||
}
|
||||
|
||||
export class Stage {
|
||||
constructor(viewEl){
|
||||
this.view = viewEl;
|
||||
@ -100,16 +140,18 @@ export class Stage {
|
||||
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 || null, params: {...(desc.params||{})},
|
||||
wrapper, root:null, mixer:null, rest:null, cam:null, light:null };
|
||||
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 };
|
||||
|
||||
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); }
|
||||
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 === 'camera'){
|
||||
@ -312,6 +354,16 @@ export class Stage {
|
||||
}
|
||||
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;
|
||||
@ -377,9 +429,10 @@ export class Stage {
|
||||
async applyState(sceneJson){
|
||||
for(const en of this.entities()) this.removeEntity(en.id);
|
||||
for(const d of (sceneJson.entities || [])){
|
||||
// SYNC4: cameras/lights carry source:'upload' but need no bytes — never skip them,
|
||||
// or scene loads silently lose the whole directing rig (cams + sun).
|
||||
if(d.source && d.source.type === 'upload' && d.kind !== 'camera' && d.kind !== 'light'){
|
||||
// skip only what needs asset BYTES we don't have: an 'upload' mesh or backdrop image.
|
||||
// camera/light use source:{type:'none'} (legacy scenes: 'upload') and always rebuild.
|
||||
const needsBytes = d.kind === 'character' || d.kind === 'prop' || d.kind === 'backdrop';
|
||||
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); }
|
||||
}
|
||||
|
||||
@ -97,6 +97,10 @@ export class StageStub {
|
||||
setActiveCamera(id) { this._active = id; this.applied.push({ type: 'camera', id }); console.debug('stub.setActiveCamera', id); }
|
||||
renderActiveCamera(_canvas) { /* noop in stub */ }
|
||||
|
||||
// M4-B viseme seam (real Stage: Lane A). Entities may carry a `visemes` array.
|
||||
setMorph(id, name, weight) { this.applied.push({ type: 'morph', id, name, weight }); console.debug('stub.setMorph', id, name, weight); }
|
||||
visemeTargets(id) { const e = this._entities.get(id); return (e && e.visemes) || []; }
|
||||
|
||||
captureState() { return this.entities().map(clone); }
|
||||
async applyState(scene) {
|
||||
this._entities.clear();
|
||||
|
||||
@ -59,6 +59,9 @@ header .tag{font-family:var(--mono);font-size:10px;color:var(--teal);letter-spac
|
||||
button.wide{width:100%;margin:8px 0 0;background:var(--panel);border:1px solid var(--line2);color:var(--ink);
|
||||
border-radius:7px;padding:8px;font-family:var(--mono);font-size:11px;cursor:pointer}
|
||||
button.wide:hover{border-color:var(--teal)}
|
||||
.viseme{font-family:var(--mono);font-size:10.5px;color:var(--teal);background:var(--bg2);
|
||||
border:1px solid var(--line2);border-radius:6px;padding:6px 8px;margin:8px 0 0;word-break:break-word}
|
||||
.viseme.off{color:var(--mut);opacity:.6}
|
||||
|
||||
/* drop + toast */
|
||||
body.hot::after{content:'drop to load';position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
|
||||
|
||||
@ -134,6 +134,7 @@ export class Timeline {
|
||||
if (this.scene.duration == null) this.scene.duration = 10;
|
||||
this.scene.entities = this.scene.entities || [];
|
||||
this.scene.cameraCuts = this.scene.cameraCuts || [];
|
||||
this.scene.audio = this.scene.audio || [];
|
||||
for (const e of this.scene.entities) this._sortTracks(e);
|
||||
this.scene.cameraCuts.sort((a, b) => a.t - b.t);
|
||||
this._clipActions.clear();
|
||||
@ -181,6 +182,7 @@ export class Timeline {
|
||||
if (!tr) return;
|
||||
if (tr.transform) tr.transform.sort((a, b) => a.t - b.t);
|
||||
if (tr.params) tr.params.sort((a, b) => a.t - b.t);
|
||||
if (tr.morphs) tr.morphs.sort((a, b) => a.t - b.t);
|
||||
if (tr.clips) tr.clips.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
_quat(key) {
|
||||
@ -235,11 +237,24 @@ export class Timeline {
|
||||
for (const e of this.entities) {
|
||||
if (e.kind === 'camera' || e.tracks) this._evalTransform(e, t);
|
||||
this._evalParams(e, t);
|
||||
if (e.tracks && e.tracks.morphs) this._evalMorphs(e, t);
|
||||
if (e.tracks && e.tracks.clips) this._evalClips(e, t);
|
||||
}
|
||||
this._evalCameraCuts(t);
|
||||
}
|
||||
|
||||
_evalMorphs(e, t) { // viseme/blendshape weights → stage.setMorph (M4-B)
|
||||
const keys = e.tracks && e.tracks.morphs;
|
||||
if (!keys || !keys.length || !this.stage.setMorph) return;
|
||||
const byKey = new Map();
|
||||
for (const k of keys) { if (!byKey.has(k.key)) byKey.set(k.key, []); byKey.get(k.key).push(k); }
|
||||
for (const [name, ks] of byKey) {
|
||||
const [k0, k1, u] = bracket(ks, t);
|
||||
const w = k0 === k1 ? k0.value : lerp(k0.value, k1.value, ease(u, k0.ease));
|
||||
this.stage.setMorph(e.id, name, w);
|
||||
}
|
||||
}
|
||||
|
||||
_evalTransform(e, t) {
|
||||
const keys = e.tracks && e.tracks.transform;
|
||||
if (!keys || keys.length === 0) return; // 0 keys → leave rest pose alone
|
||||
@ -323,8 +338,10 @@ export class Timeline {
|
||||
addKey(id, track, key) {
|
||||
const tr = this._tracks(id);
|
||||
const arr = (tr[track] = tr[track] || []);
|
||||
// for transform/param a "key" replaces any existing at same t (+key name)
|
||||
const same = (k) => k.t === key.t && (track !== 'params' || k.key === key.key);
|
||||
// a key replaces any existing at the same t; named tracks (params/morphs)
|
||||
// are keyed by (t, key name) since many names share the lane.
|
||||
const named = track === 'params' || track === 'morphs';
|
||||
const same = (k) => k.t === key.t && (!named || k.key === key.key);
|
||||
const prevIdx = arr.findIndex(same);
|
||||
const prev = prevIdx >= 0 ? arr[prevIdx] : null;
|
||||
if (prevIdx >= 0) arr.splice(prevIdx, 1);
|
||||
@ -374,6 +391,43 @@ export class Timeline {
|
||||
return cut;
|
||||
}
|
||||
undo() { const op = this.undoStack.pop(); if (op) op.undo(); this.evaluate(this.time); }
|
||||
|
||||
// ---- audio track (M4-B). Clips are {path, start, gain}; duration is a
|
||||
// client-side draw concern (decode + cache in tlui). Server muxes from
|
||||
// scene.audio at render, so step()/evaluate never touch audio. ----
|
||||
addAudio(clip) {
|
||||
this.scene.audio = this.scene.audio || [];
|
||||
this.scene.audio.push(clip);
|
||||
this.undoStack.push({ undo: () => { const i = this.scene.audio.indexOf(clip); if (i >= 0) this.scene.audio.splice(i, 1); } });
|
||||
return clip;
|
||||
}
|
||||
moveAudio(clip, newStart) {
|
||||
const old = clip.start; clip.start = Math.max(0, newStart);
|
||||
this.undoStack.push({ undo: () => { clip.start = old; } });
|
||||
}
|
||||
setAudioGain(clip, gain) {
|
||||
const old = clip.gain; clip.gain = gain;
|
||||
this.undoStack.push({ undo: () => { clip.gain = old; } });
|
||||
}
|
||||
removeAudio(clip) {
|
||||
const i = (this.scene.audio || []).indexOf(clip);
|
||||
if (i < 0) return;
|
||||
this.scene.audio.splice(i, 1);
|
||||
this.undoStack.push({ undo: () => { this.scene.audio.splice(i, 0, clip); } });
|
||||
}
|
||||
|
||||
// Rhubarb lip-sync JSON → morph keys. { mouthCues: [{start, end, value}] }.
|
||||
// ponytail: per-cue ramp (shape 1 at cue start → 0 at cue end); hold/crossfade
|
||||
// tuning deferred until it looks wrong on a real blendshape character.
|
||||
importRhubarb(id, rhubarbJson, t0 = 0) {
|
||||
const cues = (rhubarbJson && rhubarbJson.mouthCues) || [];
|
||||
for (const c of cues) {
|
||||
if (!c.value) continue;
|
||||
this.addKey(id, 'morphs', { t: t0 + c.start, key: c.value, value: 1, ease: 'linear' });
|
||||
this.addKey(id, 'morphs', { t: t0 + c.end, key: c.value, value: 0, ease: 'linear' });
|
||||
}
|
||||
return (this._tracks(id).morphs || []).length;
|
||||
}
|
||||
}
|
||||
|
||||
export default Timeline;
|
||||
|
||||
@ -163,4 +163,35 @@ assert.equal(tlDur.time, 5, 'setDuration clamps the playhead into range');
|
||||
assert.equal(tlDur.scene.entities[0].tracks.transform.length, 1, 'keys past the new end are kept (lossless)');
|
||||
assert.ok(durNotified >= 1, 'setDuration notifies the UI via onLoad');
|
||||
|
||||
// ---- M4-B: morphs track lerps → stage.setMorph ----
|
||||
const morphStage = new StageStub();
|
||||
await morphStage.addEntity({ id: 'face', kind: 'character', visemes: ['A', 'B', 'X'] });
|
||||
const tlMo = new Timeline(morphStage);
|
||||
tlMo.load({ version: 1, fps: 30, duration: 4, cameraCuts: [], entities: [
|
||||
{ id: 'face', kind: 'character', tracks: { morphs: [
|
||||
{ t: 0, key: 'A', value: 0, ease: 'linear' }, { t: 2, key: 'A', value: 1, ease: 'linear' },
|
||||
] } },
|
||||
] });
|
||||
morphStage.applied.length = 0;
|
||||
tlMo.step(30); // t=1 → A at 0.5
|
||||
const mp = lastIn(morphStage.applied, (a) => a.type === 'morph' && a.name === 'A');
|
||||
assert.ok(mp && near(mp.weight, 0.5, 1e-6), `morph A weight ~0.5, got ${mp && mp.weight}`);
|
||||
|
||||
// ---- M4-B: importRhubarb → morph keys (1 at cue start, 0 at cue end) ----
|
||||
const rj = { mouthCues: [{ start: 0.0, end: 0.3, value: 'A' }, { start: 0.3, end: 0.6, value: 'B' }] };
|
||||
tlMo.importRhubarb('face', rj, 1.0); // offset by 1s
|
||||
const mk = tlMo.scene.entities[0].tracks.morphs.filter((k) => k.key === 'A' || k.key === 'B').map((k) => k.key + '@' + k.t + '=' + k.value);
|
||||
assert.ok(mk.includes('B@1.3=1') && mk.includes('B@1.6=0'), `rhubarb B keys wrong: ${mk.join(',')}`);
|
||||
|
||||
// ---- M4-B: audio mutators + undo ----
|
||||
const tlA = new Timeline(new StageStub());
|
||||
tlA.load({ version: 1, fps: 30, duration: 10, entities: [], cameraCuts: [] });
|
||||
const aclip = tlA.addAudio({ path: 'audio/vo1.wav', start: 2, gain: 1 });
|
||||
assert.equal(tlA.scene.audio.length, 1, 'addAudio appends');
|
||||
tlA.moveAudio(aclip, 3.5); assert.equal(aclip.start, 3.5, 'moveAudio sets start');
|
||||
tlA.setAudioGain(aclip, 0.4); assert.equal(aclip.gain, 0.4, 'setAudioGain sets gain');
|
||||
assert.deepEqual(tlA.toJSON().audio, [{ path: 'audio/vo1.wav', start: 3.5, gain: 0.4 }], 'audio survives round-trip');
|
||||
tlA.removeAudio(aclip); assert.equal(tlA.scene.audio.length, 0, 'removeAudio drops it');
|
||||
tlA.undo(); assert.equal(tlA.scene.audio.length, 1, 'undo restores removed audio');
|
||||
|
||||
console.log('OK — timeline_test.mjs: all assertions passed');
|
||||
|
||||
@ -29,8 +29,13 @@ const CSS = `
|
||||
#timeline .tl-names .row.head{margin-top:${RULER_H}px}
|
||||
#timeline .tl-names .row .k{color:#565f6b;margin-right:6px;font-size:10px}
|
||||
#timeline .tl-names .row.sub{color:#8b949e;padding-left:20px;font-size:11px}
|
||||
#timeline .tl-names .row input.gain{width:38px;margin-left:auto;background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;font:10px ui-monospace;border-radius:3px;padding:1px 2px}
|
||||
#timeline .tl-lanes{flex:1;position:relative;overflow:hidden}
|
||||
#timeline canvas{display:block;width:100%;height:100%}
|
||||
.tl-menu{position:fixed;z-index:9999;background:#12161c;border:1px solid #2b323c;border-radius:4px;max-height:240px;overflow:auto;font:12px ui-monospace;color:#c9d1d9;min-width:160px}
|
||||
.tl-menu .item{padding:4px 10px;cursor:pointer;white-space:nowrap}
|
||||
.tl-menu .item:hover{background:#2b323c}
|
||||
.tl-menu .empty{padding:6px 10px;color:#565f6b}
|
||||
`;
|
||||
|
||||
export class TimelineUI {
|
||||
@ -43,9 +48,16 @@ export class TimelineUI {
|
||||
this._selKeys = []; // box-selected keys: [{id, track, key}]
|
||||
this._snapOn = true;
|
||||
this._snapStep = 'frame'; // 'frame' | seconds string
|
||||
this._audioBuf = new Map(); // path -> decoded AudioBuffer
|
||||
this._audioSrcs = []; // live BufferSourceNodes during playback
|
||||
this._wasPlaying = false;
|
||||
this._injectCSS();
|
||||
this._build();
|
||||
this.tl.onTick(() => this.draw());
|
||||
this.tl.onTick(() => { // natural end-of-play stops audio (timeline auto-pauses)
|
||||
this.draw();
|
||||
if (this._wasPlaying && !this.tl.playing) this._audioStop();
|
||||
this._wasPlaying = this.tl.playing;
|
||||
});
|
||||
this.tl.onLoad(() => { this._syncRows(); this._refreshBar(); }); // SYNC1 #3a/#3b
|
||||
if (this.stage.onChange) this.stage.onChange(() => this._syncRows());
|
||||
this._syncRows();
|
||||
@ -78,6 +90,7 @@ export class TimelineUI {
|
||||
<select class="snapstep" title="snap increment">
|
||||
<option value="frame">frame</option><option value="0.1">0.1s</option>
|
||||
<option value="0.25">0.25s</option><option value="1">1s</option></select>
|
||||
<button data-act="audio" title="add audio clip at playhead">🎵</button>
|
||||
<span class="spring"></span>
|
||||
<button data-act="save">Save</button>
|
||||
<button data-act="load">Load</button>`;
|
||||
@ -91,6 +104,8 @@ export class TimelineUI {
|
||||
this.$play.onclick = () => this._toggle();
|
||||
bar.querySelector('[data-act="save"]').onclick = () => this.save();
|
||||
bar.querySelector('[data-act="load"]').onclick = () => this.load(this.$name.value);
|
||||
this.$audioBtn = bar.querySelector('[data-act="audio"]');
|
||||
this.$audioBtn.onclick = () => this._pickAudio();
|
||||
this.$dur.onchange = () => this.tl.setDuration(this.$dur.value); // clamps + refreshes bar
|
||||
this.$name.onchange = () => { this.tl.scene.name = this.$name.value; };
|
||||
this.$snap.onchange = () => { this._snapOn = this.$snap.checked; };
|
||||
@ -127,8 +142,15 @@ export class TimelineUI {
|
||||
if (e.kind === 'character') rows.push({ type: 'clip', id: e.id, label: '↳ clips' });
|
||||
const paramable = (e.tracks?.params?.length > 0) || e.kind === 'camera' || e.kind === 'light';
|
||||
if (paramable) rows.push({ type: 'params', id: e.id, label: '↳ params' });
|
||||
// viseme lane only when the character actually has morph targets (M4-B)
|
||||
const hasMorphs = (e.tracks?.morphs?.length > 0) ||
|
||||
(e.kind === 'character' && this.stage.visemeTargets && (this.stage.visemeTargets(e.id) || []).length > 0);
|
||||
if (hasMorphs) rows.push({ type: 'morphs', id: e.id, label: '↳ visemes' });
|
||||
}
|
||||
rows.push({ type: 'cameras', label: 'Cameras' });
|
||||
for (const clip of (this.tl.scene.audio || [])) {
|
||||
rows.push({ type: 'audio', clip, label: '🎵 ' + (clip.path || '').split('/').pop() });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@ -137,10 +159,17 @@ export class TimelineUI {
|
||||
this.$names.innerHTML = '';
|
||||
this.rows.forEach((r, i) => {
|
||||
const d = document.createElement('div');
|
||||
const sub = r.type === 'clip' || r.type === 'params';
|
||||
const sub = r.type === 'clip' || r.type === 'params' || r.type === 'morphs' || r.type === 'audio';
|
||||
d.className = 'row' + (i === 0 ? ' head' : '') + (sub ? ' sub' : '');
|
||||
if (r.type === 'transform') d.innerHTML = `<span class="k">${(r.kind || '?')[0].toUpperCase()}</span>${r.label}`;
|
||||
else d.textContent = r.label;
|
||||
else if (r.type === 'audio') {
|
||||
d.append(r.label);
|
||||
const g = document.createElement('input');
|
||||
g.type = 'number'; g.className = 'gain'; g.step = '0.1'; g.min = '0'; g.title = 'gain';
|
||||
g.value = r.clip.gain ?? 1;
|
||||
g.onchange = () => this.tl.setAudioGain(r.clip, Math.max(0, +g.value || 0));
|
||||
d.appendChild(g);
|
||||
} else d.textContent = r.label;
|
||||
this.$names.appendChild(d);
|
||||
});
|
||||
// size lanes to content so no row (esp. Cameras) is clipped as rows grow.
|
||||
@ -193,8 +222,10 @@ export class TimelineUI {
|
||||
c.strokeStyle = '#1b2027'; c.beginPath(); c.moveTo(0, y + ROW_H); c.lineTo(w, y + ROW_H); c.stroke();
|
||||
if (r.type === 'transform') this._drawKeys(c, r, y);
|
||||
else if (r.type === 'params') this._drawParams(c, r, y);
|
||||
else if (r.type === 'morphs') this._drawMorphs(c, r, y);
|
||||
else if (r.type === 'clip') this._drawClips(c, r, y);
|
||||
else if (r.type === 'cameras') this._drawCuts(c, y);
|
||||
else if (r.type === 'audio') this._drawAudio(c, r, y);
|
||||
});
|
||||
|
||||
// box-select overlay
|
||||
@ -272,6 +303,28 @@ export class TimelineUI {
|
||||
this._hits.push({ kind: 'cut', cut, x, y: cy });
|
||||
}
|
||||
}
|
||||
_drawMorphs(c, r, y) {
|
||||
const cy = y + ROW_H / 2;
|
||||
const keys = (this._te(r.id)?.tracks?.morphs) || [];
|
||||
for (const k of keys) {
|
||||
const x = this._t2x(k.t);
|
||||
this._diamond(c, x, cy, 4, this._paramColor(k.key), this._selected(k));
|
||||
this._hits.push({ kind: 'key', id: r.id, track: 'morphs', key: k, x, y: cy });
|
||||
}
|
||||
}
|
||||
_drawAudio(c, r, y) {
|
||||
const clip = r.clip;
|
||||
const dur = this._audioDur(clip.path);
|
||||
const x0 = this._t2x(clip.start);
|
||||
const x1 = dur != null ? this._t2x(clip.start + dur) : x0 + 60; // fallback width until decoded
|
||||
const bw = Math.max(3, x1 - x0);
|
||||
c.fillStyle = '#2e7d5b'; c.fillRect(x0, y + 5, bw, ROW_H - 10);
|
||||
c.strokeStyle = '#57c99a'; c.strokeRect(x0 + 0.5, y + 5.5, bw - 1, ROW_H - 11);
|
||||
c.fillStyle = '#dff5ea'; c.font = '10px ui-monospace';
|
||||
c.save(); c.beginPath(); c.rect(x0, y, bw, ROW_H); c.clip();
|
||||
c.fillText((clip.path || '').split('/').pop() + (dur == null ? ' …' : ''), x0 + 4, y + ROW_H / 2 + 3); c.restore();
|
||||
this._hits.push({ kind: 'audioblock', clip, x0, x1, y });
|
||||
}
|
||||
|
||||
// ---- interaction ----
|
||||
_local(e) { const r = this.canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
|
||||
@ -285,9 +338,10 @@ export class TimelineUI {
|
||||
if (h.kind === 'key' || h.kind === 'cut') { if (Math.abs(h.x - x) < 7 && Math.abs(h.y - y) < 9) return h; }
|
||||
else if (h.kind === 'edge') { if (Math.abs(h.x - x) < 5 && y > h.y && y < h.y + ROW_H) return h; }
|
||||
}
|
||||
for (const h of this._hits) if (h.kind === 'block' && x >= h.x0 && x <= h.x1 && y > h.y && y < h.y + ROW_H) return h;
|
||||
for (const h of this._hits) if ((h.kind === 'block' || h.kind === 'audioblock') && x >= h.x0 && x <= h.x1 && y > h.y && y < h.y + ROW_H) return h;
|
||||
return null;
|
||||
}
|
||||
_seek(t) { this.tl.seek(t); if (this.tl.playing) this._audioStart(); } // user seek: reschedule audio
|
||||
_keysInRect(x0, y0, x1, y1) {
|
||||
const ax = Math.min(x0, x1), bx = Math.max(x0, x1), ay = Math.min(y0, y1), by = Math.max(y0, y1);
|
||||
const out = [];
|
||||
@ -297,7 +351,7 @@ export class TimelineUI {
|
||||
|
||||
_down(e) {
|
||||
const { x, y } = this._local(e);
|
||||
if (y < RULER_H) { this._drag = { kind: 'seek' }; this.tl.seek(this._x2t(x)); return; }
|
||||
if (y < RULER_H) { this._drag = { kind: 'seek' }; this._seek(this._x2t(x)); return; }
|
||||
const h = this._hitAt(x, y);
|
||||
if (!h) { this._drag = { kind: 'box', x0: x, y0: y, x, y, moved: false }; return; } // empty → box/seek
|
||||
if (h.kind === 'key') {
|
||||
@ -306,17 +360,19 @@ export class TimelineUI {
|
||||
} else { this._selKeys = []; this._drag = { kind: 'key', h }; }
|
||||
} else if (h.kind === 'edge') this._drag = { kind: 'trim', h };
|
||||
else if (h.kind === 'block') this._drag = { kind: 'block', h, grabT: this._x2t(x) - h.block.start };
|
||||
else if (h.kind === 'audioblock') this._drag = { kind: 'audio', h, grabT: this._x2t(x) - h.clip.start };
|
||||
else if (h.kind === 'cut') this._drag = { kind: 'cut', h };
|
||||
}
|
||||
_move(e) {
|
||||
if (!this._drag) return;
|
||||
const { x, y } = this._local(e);
|
||||
const t = this._x2t(x), d = this._drag;
|
||||
if (d.kind === 'seek') this.tl.seek(t);
|
||||
if (d.kind === 'seek') this._seek(t);
|
||||
else if (d.kind === 'key') { this.tl.moveKey(d.h.id, d.h.track, d.h.key, this._snap(t, e)); this.draw(); }
|
||||
else if (d.kind === 'multikey') { for (const s of d.orig) this.tl.moveKey(s.id, s.track, s.key, this._snap(s.t0 + (t - d.grabT), e)); this.draw(); }
|
||||
else if (d.kind === 'cut') { d.h.cut.t = this._snap(t, e); this.tl.cameraCuts.sort((a, b) => a.t - b.t); this.tl._activeCam = undefined; this.draw(); }
|
||||
else if (d.kind === 'block') { this.tl.moveClipBlock(d.h.id, d.h.block, Math.max(0, this._snap(t - d.grabT, e))); this.draw(); }
|
||||
else if (d.kind === 'audio') { this.tl.moveAudio(d.h.clip, this._snap(t - d.grabT, e)); this.draw(); }
|
||||
else if (d.kind === 'trim') { const b = d.h.block; this.tl.trimClipBlock(d.h.id, b, { out: Math.max(b.in + 0.05, b.in + (t - b.start)) }); this.draw(); }
|
||||
else if (d.kind === 'box') { d.x = x; d.y = y; d.moved = Math.abs(x - d.x0) > 4 || Math.abs(y - d.y0) > 4; this.draw(); }
|
||||
}
|
||||
@ -324,7 +380,7 @@ export class TimelineUI {
|
||||
const d = this._drag; this._drag = null;
|
||||
if (!d) return;
|
||||
if (d.kind === 'box') {
|
||||
if (!d.moved) this.tl.seek(this._x2t(d.x0)); // click on empty = seek
|
||||
if (!d.moved) this._seek(this._x2t(d.x0)); // click on empty = seek
|
||||
else { this._selKeys = this._keysInRect(d.x0, d.y0, d.x, d.y); this.draw(); }
|
||||
}
|
||||
}
|
||||
@ -358,12 +414,13 @@ export class TimelineUI {
|
||||
const h = this._hitAt(x, y);
|
||||
if (h && h.kind === 'key') { this.tl.deleteKey(h.id, h.track, h.key); this._selKeys = this._selKeys.filter((s) => s.key !== h.key); this.draw(); }
|
||||
else if (h && h.kind === 'cut') { const i = this.tl.cameraCuts.indexOf(h.cut); if (i >= 0) this.tl.cameraCuts.splice(i, 1); this.tl._activeCam = undefined; this.draw(); }
|
||||
else if (h && h.kind === 'audioblock') { this.tl.removeAudio(h.clip); this._syncRows(); }
|
||||
}
|
||||
_key(e) {
|
||||
if (e.target && /INPUT|TEXTAREA/.test(e.target.tagName)) return;
|
||||
if (e.code === 'Space') { e.preventDefault(); this._toggle(); }
|
||||
else if (e.key === 'Home') this.tl.seek(0);
|
||||
else if (e.key === 'End') this.tl.seek(this.tl.duration);
|
||||
else if (e.key === 'Home') this._seek(0);
|
||||
else if (e.key === 'End') this._seek(this.tl.duration);
|
||||
else if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); this.tl.undo(); this.draw(); }
|
||||
else if ((e.key === 'Delete' || e.key === 'Backspace') && this._selKeys.length) {
|
||||
e.preventDefault();
|
||||
@ -371,7 +428,75 @@ export class TimelineUI {
|
||||
this._selKeys = []; this.draw();
|
||||
}
|
||||
}
|
||||
_toggle() { this.tl.playing ? this.tl.pause() : this.tl.play(); this.draw(); }
|
||||
_toggle() {
|
||||
if (this.tl.playing) { this.tl.pause(); this._audioStop(); }
|
||||
else { this.tl.play(); this._audioStart(); }
|
||||
this._wasPlaying = this.tl.playing;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
// ---- Web Audio (draft playback synced to the master clock). step()/render
|
||||
// NEVER touch this — the server muxes from scene.audio deterministically. ----
|
||||
_ensureCtx() {
|
||||
if (!this._audioCtx) { const C = window.AudioContext || window.webkitAudioContext; if (C) this._audioCtx = new C(); }
|
||||
return this._audioCtx;
|
||||
}
|
||||
_audioDur(path) { const b = this._audioBuf.get(path); return b ? b.duration : null; }
|
||||
async _decode(path) {
|
||||
if (this._audioBuf.has(path)) return this._audioBuf.get(path);
|
||||
const ctx = this._ensureCtx(); if (!ctx) return null;
|
||||
try {
|
||||
const res = await fetch('/assets/file?path=' + encodeURIComponent(path));
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const buf = await ctx.decodeAudioData(await res.arrayBuffer());
|
||||
this._audioBuf.set(path, buf);
|
||||
this.draw(); // width now known
|
||||
return buf;
|
||||
} catch { return null; } // keep the block at fallback width
|
||||
}
|
||||
_decodeAll() { for (const clip of (this.tl.scene.audio || [])) this._decode(clip.path); }
|
||||
_audioStart() {
|
||||
const ctx = this._ensureCtx(); if (!ctx) return;
|
||||
if (ctx.state === 'suspended') ctx.resume();
|
||||
this._audioStop();
|
||||
const now = ctx.currentTime, t = this.tl.time;
|
||||
for (const clip of (this.tl.scene.audio || [])) {
|
||||
const buf = this._audioBuf.get(clip.path); if (!buf) continue;
|
||||
const offset = t - clip.start; // seconds into the clip at current playhead
|
||||
if (offset >= buf.duration) continue; // already finished
|
||||
const src = ctx.createBufferSource(); src.buffer = buf;
|
||||
const g = ctx.createGain(); g.gain.value = clip.gain ?? 1;
|
||||
src.connect(g).connect(ctx.destination);
|
||||
const when = now + Math.max(0, clip.start - t);
|
||||
src.start(when, Math.max(0, offset));
|
||||
this._audioSrcs.push(src);
|
||||
}
|
||||
}
|
||||
_audioStop() { for (const s of this._audioSrcs) { try { s.stop(); } catch { /* already stopped */ } } this._audioSrcs = []; }
|
||||
|
||||
async _pickAudio() {
|
||||
let list = [];
|
||||
try { const r = await fetch('/assets/tree'); if (r.ok) list = (await r.json()).audio || []; } catch { /* offline */ }
|
||||
const menu = document.createElement('div'); menu.className = 'tl-menu';
|
||||
if (!list.length) menu.innerHTML = '<div class="empty">no audio in /assets/tree</div>';
|
||||
for (const it of list) {
|
||||
const path = it.path || it;
|
||||
const item = document.createElement('div'); item.className = 'item';
|
||||
item.textContent = (it.name || path).split('/').pop();
|
||||
item.onclick = async () => {
|
||||
this.tl.addAudio({ path, start: this.tl.time, gain: 1 });
|
||||
menu.remove();
|
||||
await this._decode(path);
|
||||
this._syncRows();
|
||||
};
|
||||
menu.appendChild(item);
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
const rect = this.$audioBtn.getBoundingClientRect();
|
||||
menu.style.left = rect.left + 'px'; menu.style.top = (rect.bottom + 2) + 'px';
|
||||
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
|
||||
setTimeout(() => document.addEventListener('mousedown', close), 0);
|
||||
}
|
||||
|
||||
// ---- persistence (Lane C endpoints; localStorage fallback) ----
|
||||
async save() {
|
||||
@ -396,6 +521,7 @@ export class TimelineUI {
|
||||
if (this.stage.applyState) await this.stage.applyState(json);
|
||||
this.tl.load(json); // fires onLoad → _syncRows + _refreshBar
|
||||
await this.tl.preload();
|
||||
this._decodeAll(); // decode audio for lane widths + playback
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,6 +176,11 @@ def main():
|
||||
else:
|
||||
print("(render test skipped — ffmpeg/ffprobe not found)")
|
||||
|
||||
# --- rhubarb: 503 when the binary is absent (happy path needs rhubarb) ---
|
||||
if not shutil.which("rhubarb"):
|
||||
assert req("GET", "/rhubarb?path=audio/x.wav")[0] == 503
|
||||
n += 1
|
||||
|
||||
# --- MODELBEAST proxy: gating + two-step contract (mock MB, no farm jobs) ---
|
||||
assert req("POST", "/mb/submit", json.dumps({"operator": "tts"}))[0] == 503 # gated off
|
||||
n += 1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user