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:
1 parent
83fc655219
commit
97376b22c9
10 files changed
+340
-20
No files matched your search
@@ -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
@@ -27,7 +27,11 @@
|
|||||||
"preload.js",
|
"preload.js",
|
||||||
"src/**/*",
|
"src/**/*",
|
||||||
"svg/**/*",
|
"svg/**/*",
|
||||||
"models/**/*"
|
"models/**/*",
|
||||||
|
"scripts/fbx2animglb.py"
|
||||||
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"scripts/fbx2animglb.py"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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)
|
||||||
});
|
});
|
||||||
@@ -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')
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user