Dance library: bundled clips bind to any rigged model + repertoire + manual BPM

- models/dance_library.glb: skeleton-only GLB with the 13 Mixamo dances,
  loaded at startup and bound BY BONE NAME to whatever rigged GLB the
  user loads — new models dance immediately, no baking needed.
- Repertoire UI (Media tab): checkbox chips of the available clips, the
  director only picks the ticked ones (none = all); persisted.
- 'Aggiungi animazioni': extra .glb clips load directly; .fbx are
  converted on the fly by Blender via a new anim:convert IPC and the
  bundled scripts/fbx2animglb.py (asarUnpacked for packaged builds).
- Manual BPM with TAP tempo: 0 = follow the detected beat, otherwise a
  pure metronome drives the dance clock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-31 11:15:10 +02:00
1 parent 83fc655219
commit 97376b22c9
10 files changed
+340 -20

No files matched your search

+28
View File
@@ -108,6 +108,34 @@ async function ensureMicAccess() {
} catch (e) { /* getUserMedia will prompt again if needed */ } } catch (e) { /* getUserMedia will prompt again if needed */ }
} }
// Convert a Mixamo FBX animation into a skeleton-only GLB clip via Blender
// (when installed): lets the user add moves to the dance library from the
// panel without any manual conversion step.
function blenderPath() {
const cands = ['/Applications/Blender.app/Contents/MacOS/Blender',
'/opt/homebrew/bin/blender', '/usr/local/bin/blender'];
for (const c of cands) if (fs.existsSync(c)) return c;
return null;
}
ipcMain.handle('anim:convert', async (_e, fbxPath) => {
const blender = blenderPath();
if (!blender) return { ok: false, error: 'Blender non trovato: serve per convertire gli FBX (oppure carica un .glb)' };
const outDir = path.join(app.getPath('userData'), 'anims');
fs.mkdirSync(outDir, { recursive: true });
const out = path.join(outDir, path.basename(fbxPath).replace(/\.fbx$/i, '') + '.glb');
// packaged app: the .py must live outside the asar for Blender to read it
const script = path.join(__dirname, 'scripts', 'fbx2animglb.py')
.replace('app.asar' + path.sep, 'app.asar.unpacked' + path.sep);
return new Promise((resolve) => {
execFile(blender, ['-b', '-P', script, '--', fbxPath, out],
{ maxBuffer: 1 << 24, timeout: 180000 }, (err, _so, se) => {
if (err || !fs.existsSync(out))
resolve({ ok: false, error: 'conversione fallita: ' + String(se || (err && err.message) || '').slice(-300) });
else resolve({ ok: true, path: out });
});
});
});
// Camera permission is requested lazily (only when an interactive preset is // Camera permission is requested lazily (only when an interactive preset is
// activated), not at startup: no camera prompt for users who never use it. // activated), not at startup: no camera prompt for users who never use it.
ipcMain.handle('cam:ensure', async () => { ipcMain.handle('cam:ensure', async () => {
Binary file not shown.
+5 -1
View File
@@ -27,7 +27,11 @@
"preload.js", "preload.js",
"src/**/*", "src/**/*",
"svg/**/*", "svg/**/*",
"models/**/*" "models/**/*",
"scripts/fbx2animglb.py"
],
"asarUnpack": [
"scripts/fbx2animglb.py"
] ]
}, },
"devDependencies": { "devDependencies": {
+2
View File
@@ -38,5 +38,7 @@ contextBridge.exposeInMainWorld('djv', {
peaks: (path, buckets) => ipcRenderer.invoke('audio:peaks', path, buckets), peaks: (path, buckets) => ipcRenderer.invoke('audio:peaks', path, buckets),
// Ask macOS for camera permission (interactive family) right before use. // Ask macOS for camera permission (interactive family) right before use.
camAccess: () => ipcRenderer.invoke('cam:ensure'), camAccess: () => ipcRenderer.invoke('cam:ensure'),
// Convert a Mixamo FBX animation to a library GLB clip (via Blender).
convertAnim: (p) => ipcRenderer.invoke('anim:convert', p),
readFile: (path) => ipcRenderer.invoke('file:read', path) readFile: (path) => ipcRenderer.invoke('file:read', path)
}); });
+31
View File
@@ -0,0 +1,31 @@
# Convert a Mixamo animation FBX into a skeleton-only GLB clip for the
# DJ Visualizer dance library (bones only, no mesh — tiny files).
# blender -b -P fbx2animglb.py -- input.fbx output.glb
import bpy, sys, os
argv = sys.argv[sys.argv.index('--')+1:]
SRC, OUT = argv[0], argv[1]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=SRC)
arm = None
for o in list(bpy.data.objects):
if o.type == 'ARMATURE' and arm is None:
arm = o
elif o.type == 'MESH':
bpy.data.objects.remove(o, do_unlink=True)
name = os.path.splitext(os.path.basename(SRC))[0].replace('_with_skin', '')
act = arm.animation_data.action if arm and arm.animation_data else None
if not act:
raise SystemExit('nessuna animazione nel file')
act.name = name
act.use_fake_user = True
ad = arm.animation_data
ad.action = None
tr = ad.nla_tracks.new()
tr.name = name
tr.strips.new(name, int(act.frame_range[0]), act)
try:
bpy.ops.export_scene.gltf(filepath=OUT, export_format='GLB',
export_animations=True, export_animation_mode='NLA_TRACKS')
except TypeError:
bpy.ops.export_scene.gltf(filepath=OUT, export_format='GLB', export_animations=True)
print('EXPORT_OK')
+13
View File
@@ -141,6 +141,19 @@
<option value="wave">Fluido Onda</option> <option value="wave">Fluido Onda</option>
<!-- tutte le altre famiglie vengono aggiunte da control.js --> <!-- tutte le altre famiglie vengono aggiunte da control.js -->
</select> </select>
<label>Repertorio del ballerino</label>
<div id="glb-anims" class="av-fams"></div>
<button id="btn-anim-load" class="wide">💃 Aggiungi animazioni (.fbx / .glb)…</button>
<input type="file" id="anim-input" accept=".fbx,.glb" multiple hidden />
<label>BPM ballerino <span id="glb-bpm-val">auto</span></label>
<div class="row led-row">
<input type="number" class="led-num" id="glb-bpm" min="0" max="220" step="1" value="0"
title="0 = automatico dal beat rilevato" />
<button id="glb-tap">👏 TAP</button>
</div>
<div class="hint-mini">Spunta le mosse che il regista può usare (nessuna spuntata = tutte).
Le animazioni extra arrivano da Mixamo: .glb diretti, .fbx convertiti al volo (serve Blender).
BPM: 0 = segue il beat; oppure scrivilo o battilo col TAP.</div>
</section> </section>
<section class="span2" id="effect-seq-section"> <section class="span2" id="effect-seq-section">
+83
View File
@@ -147,9 +147,40 @@ $('#svg-input').addEventListener('change', (e) => {
// The path persists and is re-sent when the output window (re)starts. // The path persists and is re-sent when the output window (re)starts.
let glbPath = localStorage.getItem('glb3d') || ''; let glbPath = localStorage.getItem('glb3d') || '';
let glbBg = localStorage.getItem('glbbg') || 'gradient'; let glbBg = localStorage.getItem('glbbg') || 'gradient';
let glbClipSel = [];
try { glbClipSel = JSON.parse(localStorage.getItem('glbclips') || '[]'); } catch (e) {}
let glbAnimFiles = [];
try { glbAnimFiles = JSON.parse(localStorage.getItem('glbanimfiles') || '[]'); } catch (e) {}
let glbBpm = parseInt(localStorage.getItem('glbbpm') || '0', 10) || 0;
function glbSend() { function glbSend() {
if (glbPath) send({ type: 'glb', path: glbPath }); if (glbPath) send({ type: 'glb', path: glbPath });
send({ type: 'modelBg', mode: glbBg }); send({ type: 'modelBg', mode: glbBg });
glbAnimFiles.forEach(p => send({ type: 'animAdd', path: p }));
send({ type: 'glbAnims', names: glbClipSel });
send({ type: 'modelBpm', bpm: glbBpm });
}
function renderGlbClips(names) {
const box = $('#glb-anims');
box.innerHTML = '';
if (!names || !names.length) {
box.innerHTML = '<span class="hint-mini">Carica un modello riggato per vedere le mosse.</span>';
return;
}
names.forEach((n) => {
const l = document.createElement('label');
l.className = 'av-fam' + (glbClipSel.includes(n) ? ' on' : '');
const c = document.createElement('input');
c.type = 'checkbox'; c.checked = glbClipSel.includes(n);
c.addEventListener('change', () => {
glbClipSel = c.checked ? glbClipSel.concat(n) : glbClipSel.filter(x => x !== n);
l.classList.toggle('on', c.checked);
localStorage.setItem('glbclips', JSON.stringify(glbClipSel));
send({ type: 'glbAnims', names: glbClipSel });
});
l.appendChild(c);
l.appendChild(document.createTextNode(' ' + n));
box.appendChild(l);
});
} }
function glbLabel(name) { function glbLabel(name) {
$('#glb-name').textContent = name $('#glb-name').textContent = name
@@ -173,6 +204,57 @@ $('#glb-bg').addEventListener('change', (e) => {
localStorage.setItem('glbbg', glbBg); localStorage.setItem('glbbg', glbBg);
send({ type: 'modelBg', mode: glbBg }); send({ type: 'modelBg', mode: glbBg });
}); });
renderGlbClips([]);
$('#btn-anim-load').addEventListener('click', () => $('#anim-input').click());
$('#anim-input').addEventListener('change', async (e) => {
for (const f of [...e.target.files]) {
const p = djv.pathForFile(f);
if (!p) continue;
if (/\.glb$/i.test(p)) {
if (!glbAnimFiles.includes(p)) glbAnimFiles.push(p);
send({ type: 'animAdd', path: p });
} else {
glbLabel('⏳ converto ' + f.name + '…');
const res = await djv.convertAnim(p);
if (res && res.ok) {
if (!glbAnimFiles.includes(res.path)) glbAnimFiles.push(res.path);
send({ type: 'animAdd', path: res.path });
glbLabel(glbPath ? glbPath.split('/').pop() : '');
} else {
$('#glb-name').textContent = '⚠️ ' + ((res && res.error) || 'conversione fallita');
}
}
}
localStorage.setItem('glbanimfiles', JSON.stringify(glbAnimFiles));
e.target.value = '';
});
function glbBpmLabel() { $('#glb-bpm-val').textContent = glbBpm > 0 ? glbBpm + ' BPM' : 'auto'; }
$('#glb-bpm').value = glbBpm;
glbBpmLabel();
$('#glb-bpm').addEventListener('change', (e) => {
glbBpm = Math.max(0, Math.min(220, parseInt(e.target.value || '0', 10) || 0));
e.target.value = glbBpm;
localStorage.setItem('glbbpm', String(glbBpm));
glbBpmLabel();
send({ type: 'modelBpm', bpm: glbBpm });
});
let tapTimes = [];
$('#glb-tap').addEventListener('click', () => {
const now = performance.now();
if (tapTimes.length && now - tapTimes[tapTimes.length-1] > 2000) tapTimes = [];
tapTimes.push(now);
if (tapTimes.length >= 4) {
const iv = [];
for (let i = Math.max(1, tapTimes.length-8); i < tapTimes.length; i++)
iv.push(tapTimes[i] - tapTimes[i-1]);
const avg = iv.reduce((a, b) => a + b, 0) / iv.length;
glbBpm = Math.max(40, Math.min(220, Math.round(60000/avg)));
$('#glb-bpm').value = glbBpm;
localStorage.setItem('glbbpm', String(glbBpm));
glbBpmLabel();
send({ type: 'modelBpm', bpm: glbBpm });
}
});
$('#btn-glb-load').addEventListener('click', () => $('#glb-input').click()); $('#btn-glb-load').addEventListener('click', () => $('#glb-input').click());
$('#glb-input').addEventListener('change', (e) => { $('#glb-input').addEventListener('change', (e) => {
const f = e.target.files[0]; const f = e.target.files[0];
@@ -1535,6 +1617,7 @@ djv.onReport((m) => {
$('#scene-name').textContent = m.name + (m.bpm ? ' · ' + m.bpm + ' BPM' : ''); $('#scene-name').textContent = m.name + (m.bpm ? ' · ' + m.bpm + ' BPM' : '');
} }
break; break;
case 'glbClips': renderGlbClips(m.names); break;
case 'devices': { case 'devices': {
// The output reports its devices once at startup: good moment to push // The output reports its devices once at startup: good moment to push
// config the (possibly recreated) output window doesn't have yet. // config the (possibly recreated) output window doesn't have yet.
+109 -9
View File
@@ -187,7 +187,7 @@ function readAccessor(json, bin, idx) {
return { data: out, acc }; return { data: out, acc };
} }
function parseGLB(buf) { function splitGLB(buf) {
const dv = new DataView(buf); const dv = new DataView(buf);
if (dv.getUint32(0, true) !== 0x46546C67) throw new Error('non è un file GLB'); if (dv.getUint32(0, true) !== 0x46546C67) throw new Error('non è un file GLB');
let off = 12, json = null, bin = null; let off = 12, json = null, bin = null;
@@ -199,6 +199,36 @@ function parseGLB(buf) {
off += 8 + len + (len % 4 ? 4 - len % 4 : 0); off += 8 + len + (len % 4 ? 4 - len % 4 : 0);
} }
if (!json || !bin) throw new Error('GLB incompleto'); if (!json || !bin) throw new Error('GLB incompleto');
return { json, bin };
}
const PACE_RE = /thriller|break|flair|freeze|spin|moonwalk/i;
// Parse a skeleton-only GLB as an animation LIBRARY: clips whose channels are
// keyed by bone base-name, bindable to any mixamorig-style rig.
function parseAnimLib(buf) {
const { json, bin } = splitGLB(buf);
if (!json.animations || !json.animations.length) throw new Error('nessuna animazione nel GLB');
const nodeBase = json.nodes.map(n => (n.name || '').split(':').pop().split('.').pop());
return json.animations.map(a => {
const channels = a.channels
.filter(ch => ch.target.node != null &&
(ch.target.path === 'rotation' || ch.target.path === 'translation'))
.map(ch => {
const s = a.samplers[ch.sampler];
return { bone: nodeBase[ch.target.node], path: ch.target.path,
times: readAccessor(json, bin, s.input).data,
vals: readAccessor(json, bin, s.output).data };
});
let dur = 0;
channels.forEach(c => { const e = c.times[c.times.length-1]; if (e > dur) dur = e; });
const name = a.name || 'clip';
return { name, channels, dur: Math.max(0.1, dur), pace: PACE_RE.test(name) ? 0.5 : 1 };
});
}
function parseGLB(buf) {
const { json, bin } = splitGLB(buf);
// node table (hierarchy kept for skinning) + world transforms // node table (hierarchy kept for skinning) + world transforms
const nodesInfo = json.nodes.map((n, i) => ({ const nodesInfo = json.nodes.map((n, i) => ({
@@ -348,8 +378,7 @@ function parseGLB(buf) {
channels.forEach(c => { const e = c.times[c.times.length-1]; if (e > dur) dur = e; }); channels.forEach(c => { const e = c.times[c.times.length-1]; if (e > dur) dur = e; });
const name = a.name || 'clip'; const name = a.name || 'clip';
// expressive/half-time choreographies: one count every TWO beats // expressive/half-time choreographies: one count every TWO beats
const pace = /thriller|break|flair|freeze|spin|moonwalk/i.test(name) ? 0.5 : 1; return { name, channels, dur: Math.max(0.1, dur), pace: PACE_RE.test(name) ? 0.5 : 1 };
return { name, channels, dur: Math.max(0.1, dur), pace };
}); });
} }
return { prims, min, max, skel: hasSkin ? skel : null, anims }; return { prims, min, max, skel: hasSkin ? skel : null, anims };
@@ -442,9 +471,51 @@ class ModelSim {
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
this.meshes = null; this.meshes = null;
this.loadError = ''; this.loadError = '';
this.animLib = null; // bone-name clips (bundled dance library)
this.ownAnims = []; // clips carried by the loaded GLB itself
this.libAnims = []; // library clips bound to the current skeleton
this.clipFilter = null; // Set of allowed clip names (empty/null = all)
this.manualBpm = 0; // 0 = follow the detected beat
this._upload(torusKnot()); this._upload(torusKnot());
} }
// Bundled animation library (skeleton-only GLB): bound by bone base-name to
// whatever rigged model is currently loaded.
setAnimLibrary(buf) {
try { this.animLib = parseAnimLib(buf); } catch (e) { this.animLib = null; return; }
this._bindLibrary();
}
_bindLibrary() {
this.libAnims = [];
if (this.skel && this.animLib) {
const idxByBase = {};
this.skel.nodes.forEach((n, i) => {
idxByBase[n.name.split(':').pop().split('.').pop()] = i;
});
for (const clip of this.animLib) {
const channels = [];
for (const ch of clip.channels) {
const ni = idxByBase[ch.bone];
if (ni === undefined) continue;
channels.push({ node: ni, path: ch.path, times: ch.times, vals: ch.vals });
}
if (channels.length > 4)
this.libAnims.push({ name: clip.name, channels, dur: clip.dur, pace: clip.pace });
}
}
this._mergeAnims();
}
_mergeAnims() {
if (!this.skel) { this.anims = null; return; }
const own = this.ownAnims || [];
const lib = this.libAnims.filter(l => !own.some(o => o.name === l.name));
const all = own.concat(lib);
this.anims = all.length ? all : null;
this._dir = null;
}
_freeMeshes() { _freeMeshes() {
const gl = this.gl; const gl = this.gl;
(this.meshes || []).forEach(m => { (this.meshes || []).forEach(m => {
@@ -459,7 +530,7 @@ class ModelSim {
const gl = this.gl; const gl = this.gl;
this._freeMeshes(); this._freeMeshes();
this.skel = model.skel || null; this.skel = model.skel || null;
this.anims = model.anims || null; this.ownAnims = model.anims || [];
this.restJoints = null; this.restJoints = null;
this._db = null; this._db = null;
if (this.skel) { if (this.skel) {
@@ -531,10 +602,23 @@ class ModelSim {
} }
return m; return m;
}); });
this._bindLibrary();
} }
get hasSkin() { return !!this.skel; } get hasSkin() { return !!this.skel; }
// Append extra clips (a user-loaded GLB with animations) to the library.
addAnimLibrary(buf) {
const extra = parseAnimLib(buf); // throws on invalid input
if (!this.animLib) this.animLib = [];
for (const c of extra) {
const i = this.animLib.findIndex(x => x.name === c.name);
if (i >= 0) this.animLib[i] = c; else this.animLib.push(c);
}
this._bindLibrary();
return extra.map(c => c.name);
}
// Joint matrices (world * inverseBind) for the whole skeleton. With // Joint matrices (world * inverseBind) for the whole skeleton. With
// `targets` (base bone name -> world-space direction) an extra local // `targets` (base bone name -> world-space direction) an extra local
// rotation is solved per bone so that its chain child points along the // rotation is solved per bone so that its chain child points along the
@@ -583,6 +667,15 @@ class ModelSim {
// Musical beat clock: counts beats (with a free-running metronome between // Musical beat clock: counts beats (with a free-running metronome between
// and without detected beats) and exposes a smoothed phase inside the beat. // and without detected beats) and exposes a smoothed phase inside the beat.
_beatClock(t, beat) { _beatClock(t, beat) {
// manual BPM: a pure metronome, immune to the beat detector
if (this.manualBpm > 0) {
const per = 60/this.manualBpm;
const db = this._db || (this._db = { n: 0, last: t, period: per, prevBeat: 0 });
db.period = per;
if (t - db.last > 10) db.last = t;
while (t - db.last >= per) { db.last += per; db.n++; }
return { n: db.n, p: Math.max(0, (t - db.last)/per), period: per };
}
const db = this._db || (this._db = { n: 0, last: -1, period: 0.5, prevBeat: 0 }); const db = this._db || (this._db = { n: 0, last: -1, period: 0.5, prevBeat: 0 });
if (beat > 0.6 && db.prevBeat <= 0.6) { if (beat > 0.6 && db.prevBeat <= 0.6) {
// refractory window: energetic tracks fire on kick AND snare/hats, // refractory window: energetic tracks fire on kick AND snare/hats,
@@ -615,11 +708,18 @@ class ModelSim {
pn0: 0, pp0: 0, lastSwitch: t, sb: 0, psb: 0, lastT: t }); pn0: 0, pp0: 0, lastSwitch: t, sb: 0, psb: 0, lastT: t });
const dt = Math.min(0.1, Math.max(0.001, t - d.lastT)); const dt = Math.min(0.1, Math.max(0.001, t - d.lastT));
d.lastT = t; d.lastT = t;
if (this.anims.length > 1 && // repertoire: only the clips ticked in the panel (none ticked = all)
((bc.n - d.n0) >= 16 || t - d.lastSwitch > 14)) { const pool = [];
d.prev = d.clip; d.pn0 = d.n0; d.pp0 = d.p0; d.psb = d.sb; this.anims.forEach((a, i) => {
let next = Math.floor(Math.random()*this.anims.length); if (!this.clipFilter || !this.clipFilter.size || this.clipFilter.has(a.name)) pool.push(i);
if (next === d.clip) next = (next + 1) % this.anims.length; });
if (!pool.length) this.anims.forEach((_, i) => pool.push(i));
const outOfPool = pool.indexOf(d.clip) < 0;
if ((pool.length > 1 || outOfPool) &&
(outOfPool || (bc.n - d.n0) >= 16 || t - d.lastSwitch > 14)) {
d.prev = outOfPool ? -1 : d.clip; d.pn0 = d.n0; d.pp0 = d.p0; d.psb = d.sb;
let next = pool[Math.floor(Math.random()*pool.length)];
if (next === d.clip && pool.length > 1) next = pool[(pool.indexOf(next) + 1) % pool.length];
d.clip = next; d.n0 = bc.n; d.p0 = bc.p; d.sb = 0; d.lastSwitch = t; d.clip = next; d.n0 = bc.n; d.p0 = bc.p; d.sb = 0; d.lastSwitch = t;
} }
const SPB = 0.5*speed; // clip-seconds per music beat const SPB = 0.5*speed; // clip-seconds per music beat
+26
View File
@@ -321,6 +321,18 @@ djv.onControl(async (m) => {
break; break;
case 'svg': loadCustomTexture(m.dataUrl); break; case 'svg': loadCustomTexture(m.dataUrl); break;
case 'modelBg': viz.modelBg = m.mode || 'gradient'; break; case 'modelBg': viz.modelBg = m.mode || 'gradient'; break;
case 'glbAnims': viz.setClipFilter(m.names || []); break;
case 'modelBpm': viz.setManualBpm(m.bpm || 0); break;
case 'animAdd':
// extra animation GLB for the dance library
try {
const b = await djv.readFile(m.path);
if (!b) throw new Error('file non leggibile');
const names = viz.addAnimLibrary(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
reportClips();
if (names.length) djv.report({ type: 'animAdded', names });
} catch (e) { djv.report({ type: 'error', message: 'Animazione: ' + e.message }); }
break;
case 'glb': case 'glb':
try { try {
const bytes = await djv.readFile(m.path); const bytes = await djv.readFile(m.path);
@@ -329,6 +341,7 @@ djv.onControl(async (m) => {
const ok = viz.setModelData(buf); const ok = viz.setModelData(buf);
if (ok === false) throw new Error((viz.model3d && viz.model3d.loadError) || 'GLB non valido'); if (ok === false) throw new Error((viz.model3d && viz.model3d.loadError) || 'GLB non valido');
djv.report({ type: 'glbLoaded', name: m.path.split('/').pop() }); djv.report({ type: 'glbLoaded', name: m.path.split('/').pop() });
reportClips();
hideHint(); hideHint();
} catch (e) { djv.report({ type: 'error', message: 'GLB: ' + e.message }); } } catch (e) { djv.report({ type: 'error', message: 'GLB: ' + e.message }); }
break; break;
@@ -615,5 +628,18 @@ function frame() {
} }
requestAnimationFrame(frame); requestAnimationFrame(frame);
// Tell the panel which dance clips the current model can play.
function reportClips() {
const names = viz.model3d && viz.model3d.anims ? viz.model3d.anims.map(a => a.name) : [];
djv.report({ type: 'glbClips', names });
}
// Bundled dance library (skeleton-only GLB with the Mixamo clips): binds to
// any rigged model the user loads.
fetch('djvres://models/dance_library.glb')
.then(r => r.arrayBuffer())
.then(b => { viz.setAnimLibrary(b); reportClips(); })
.catch(() => { /* library optional: models with own clips still dance */ });
// Populate the device list once at startup so the control dropdown fills in. // Populate the device list once at startup so the control dropdown fills in.
reportDevices(); reportDevices();
+43 -10
View File
@@ -86,12 +86,48 @@ class Visualizer {
this.effect = Object.assign({}, this.effect, effect); this.effect = Object.assign({}, this.effect, effect);
} }
// Load a GLB model for the 'Modello 3D' family. Parsed lazily on the next // The shared 3D engine, fed with any pending model, the bundled animation
// render so the engine is only created when the family is active. // library and the persisted repertoire/BPM settings.
_ensureModel3d() {
if (!this.model3d) {
this.model3d = new window.ModelSim(this.gl);
if (this._libBuf) this.model3d.setAnimLibrary(this._libBuf);
if (this._extraLibs) this._extraLibs.forEach(b => {
try { this.model3d.addAnimLibrary(b); } catch (e) { /* già segnalato */ }
});
if (this._clipFilter) this.model3d.clipFilter = this._clipFilter;
if (this._manualBpm) this.model3d.manualBpm = this._manualBpm;
}
if (this._pendingGlb) { this.model3d.setModel(this._pendingGlb); this._pendingGlb = null; }
return this.model3d;
}
// Load a GLB model for the 'Modello 3D' family (parsed immediately so the
// panel can list its clips).
setModelData(buf) { setModelData(buf) {
if (this.model3d) return this.model3d.setModel(buf); if (!window.ModelSim) { this._pendingGlb = buf; return true; }
this._pendingGlb = buf; return this._ensureModel3d().setModel(buf);
return true; }
setAnimLibrary(buf) {
this._libBuf = buf;
if (this.model3d) this.model3d.setAnimLibrary(buf);
}
addAnimLibrary(buf) {
(this._extraLibs = this._extraLibs || []).push(buf);
if (!window.ModelSim) return [];
return this._ensureModel3d().addAnimLibrary(buf);
}
setClipFilter(names) {
this._clipFilter = new Set(names || []);
if (this.model3d) this.model3d.clipFilter = this._clipFilter;
}
setManualBpm(bpm) {
this._manualBpm = bpm || 0;
if (this.model3d) this.model3d.manualBpm = this._manualBpm;
} }
// Upload a custom source (HTMLImageElement / HTMLCanvasElement) for the // Upload a custom source (HTMLImageElement / HTMLCanvasElement) for the
@@ -112,8 +148,7 @@ class Visualizer {
// Release the camera as soon as a non-interactive preset takes over. // Release the camera as soon as a non-interactive preset takes over.
if (this.inter && !e.isInteractive) this.inter.suspend(); if (this.inter && !e.isInteractive) this.inter.suspend();
if (e.isModel3d && window.ModelSim) { if (e.isModel3d && window.ModelSim) {
if (!this.model3d) this.model3d = new window.ModelSim(gl); this._ensureModel3d();
if (this._pendingGlb) { this.model3d.setModel(this._pendingGlb); this._pendingGlb = null; }
// optional backdrop behind the model: a fluid mode ('ink'/'fire'/...) // optional backdrop behind the model: a fluid mode ('ink'/'fire'/...)
// or any shader family ('fam:<index>'), tinted with the active palette // or any shader family ('fam:<index>'), tinted with the active palette
const bg = this.modelBg || 'gradient'; const bg = this.modelBg || 'gradient';
@@ -142,9 +177,7 @@ class Visualizer {
this.inter.customSource = this.customSource || null; this.inter.customSource = this.customSource || null;
// The model-puppet and avatar modes drive the shared 3D renderer. // The model-puppet and avatar modes drive the shared 3D renderer.
if ((e.interactiveMode === 'model' || e.interactiveMode === 'avatar') && window.ModelSim) { if ((e.interactiveMode === 'model' || e.interactiveMode === 'avatar') && window.ModelSim) {
if (!this.model3d) this.model3d = new window.ModelSim(gl); this.inter.modelSim = this._ensureModel3d();
if (this._pendingGlb) { this.model3d.setModel(this._pendingGlb); this._pendingGlb = null; }
this.inter.modelSim = this.model3d;
} }
this.inter.render(timeSec, audio, e, this.canvas); this.inter.render(timeSec, audio, e, this.canvas);
return; return;