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

+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.
let glbPath = localStorage.getItem('glb3d') || '';
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() {
if (glbPath) send({ type: 'glb', path: glbPath });
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) {
$('#glb-name').textContent = name
@@ -173,6 +204,57 @@ $('#glb-bg').addEventListener('change', (e) => {
localStorage.setItem('glbbg', 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());
$('#glb-input').addEventListener('change', (e) => {
const f = e.target.files[0];
@@ -1535,6 +1617,7 @@ djv.onReport((m) => {
$('#scene-name').textContent = m.name + (m.bpm ? ' · ' + m.bpm + ' BPM' : '');
}
break;
case 'glbClips': renderGlbClips(m.names); break;
case 'devices': {
// The output reports its devices once at startup: good moment to push
// config the (possibly recreated) output window doesn't have yet.