From d329759961ad7d4218feb30d59c20f2e6222f2ac Mon Sep 17 00:00:00 2001 From: luciano Date: Fri, 31 Jul 2026 11:31:26 +0200 Subject: [PATCH] Load FBX characters directly: in-app conversion to GLB The model button now accepts .fbx too: a new model:convert IPC runs Blender with scripts/fbx2modelglb.py (mesh + skin kept, animations dropped, embedded diffuse recovered even when the FBX references missing external textures) into userData/models3d, then the resulting GLB loads as usual. anim/model conversion share one convertFbx helper. Co-Authored-By: Claude Fable 5 --- main.js | 15 ++++++----- package.json | 5 ++-- preload.js | 3 ++- scripts/fbx2modelglb.py | 60 +++++++++++++++++++++++++++++++++++++++++ src/control.html | 4 +-- src/control.js | 25 +++++++++++------ 6 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 scripts/fbx2modelglb.py diff --git a/main.js b/main.js index a59accc..cfb5916 100644 --- a/main.js +++ b/main.js @@ -117,24 +117,27 @@ function blenderPath() { for (const c of cands) if (fs.existsSync(c)) return c; return null; } -ipcMain.handle('anim:convert', async (_e, fbxPath) => { +function convertFbx(fbxPath, scriptName, outSub) { 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'); + if (!blender) return Promise.resolve({ ok: false, + error: 'Blender non trovato: serve per convertire gli FBX (oppure carica un .glb)' }); + const outDir = path.join(app.getPath('userData'), outSub); 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') + const script = path.join(__dirname, 'scripts', scriptName) .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) => { + { maxBuffer: 1 << 24, timeout: 300000 }, (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 }); }); }); -}); +} +ipcMain.handle('anim:convert', (_e, p) => convertFbx(p, 'fbx2animglb.py', 'anims')); +ipcMain.handle('model:convert', (_e, p) => convertFbx(p, 'fbx2modelglb.py', 'models3d')); // Camera permission is requested lazily (only when an interactive preset is // activated), not at startup: no camera prompt for users who never use it. diff --git a/package.json b/package.json index ecaed1c..b1c2ae7 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,11 @@ "src/**/*", "svg/**/*", "models/**/*", - "scripts/fbx2animglb.py" + "scripts/fbx2animglb.py", + "scripts/fbx2modelglb.py" ], "asarUnpack": [ - "scripts/fbx2animglb.py" + "scripts/*.py" ] }, "devDependencies": { diff --git a/preload.js b/preload.js index 488e372..9c14c05 100644 --- a/preload.js +++ b/preload.js @@ -38,7 +38,8 @@ contextBridge.exposeInMainWorld('djv', { peaks: (path, buckets) => ipcRenderer.invoke('audio:peaks', path, buckets), // Ask macOS for camera permission (interactive family) right before use. camAccess: () => ipcRenderer.invoke('cam:ensure'), - // Convert a Mixamo FBX animation to a library GLB clip (via Blender). + // Convert Mixamo FBX files via Blender: animation clips / character models. convertAnim: (p) => ipcRenderer.invoke('anim:convert', p), + convertModel: (p) => ipcRenderer.invoke('model:convert', p), readFile: (path) => ipcRenderer.invoke('file:read', path) }); diff --git a/scripts/fbx2modelglb.py b/scripts/fbx2modelglb.py new file mode 100644 index 0000000..889ab1f --- /dev/null +++ b/scripts/fbx2modelglb.py @@ -0,0 +1,60 @@ +# Convert a character FBX (e.g. a Mixamo T-pose download) into a GLB model +# for DJ Visualizer: mesh + skeleton/skin kept, animations dropped (the dance +# library provides the moves), embedded diffuse texture recovered even when +# the FBX references missing external files. +# blender -b -P fbx2modelglb.py -- input.fbx output.glb +import bpy, sys, os +argv = sys.argv[sys.argv.index('--')+1:] +SRC, OUT = argv[0], argv[1] +TMP = OUT + '.diffuse.png' +bpy.ops.wm.read_factory_settings(use_empty=True) +bpy.ops.import_scene.fbx(filepath=SRC) +arm = None; meshes = [] +for o in bpy.data.objects: + if o.type == 'ARMATURE' and arm is None: + arm = o + if o.type == 'MESH': + meshes.append(o) +if not meshes: + raise SystemExit('nessuna mesh nel file') +# drop any animation, keep the rest pose +for o in bpy.data.objects: + o.animation_data_clear() +for a in list(bpy.data.actions): + bpy.data.actions.remove(a) +if arm: + for pb in arm.pose.bones: + pb.location = (0, 0, 0) + pb.rotation_quaternion = (1, 0, 0, 0) + pb.scale = (1, 1, 1) +# recover the diffuse: prefer an embedded image that actually has data +valid = [i for i in bpy.data.images if i.has_data and i.size[0] > 0] +diff = next((i for i in valid if 'Diffuse' in i.name), valid[0] if valid else None) +if diff: + try: + diff.filepath_raw = TMP + diff.file_format = 'PNG' + diff.save() + diff = bpy.data.images.load(TMP) + diff.pack() + mat = bpy.data.materials.new('skin') + mat.use_nodes = True + bsdf = next(n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED') + tex = mat.node_tree.nodes.new('ShaderNodeTexImage') + tex.image = diff + mat.node_tree.links.new(bsdf.inputs['Base Color'], tex.outputs['Color']) + for m in meshes: + m.data.materials.clear() + m.data.materials.append(mat) + except Exception as e: + print('texture non recuperata:', e) +try: + bpy.ops.export_scene.gltf(filepath=OUT, export_format='GLB', export_animations=False, + export_skins=True, export_rest_position_armature=True) +except TypeError: + bpy.ops.export_scene.gltf(filepath=OUT, export_format='GLB', export_animations=False, export_skins=True) +try: + os.remove(TMP) +except OSError: + pass +print('EXPORT_OK') diff --git a/src/control.html b/src/control.html index 78ca2e8..e801cd7 100644 --- a/src/control.html +++ b/src/control.html @@ -163,8 +163,8 @@
- - + +
Il modello ruota e balla a tempo di musica nella famiglia “Modello 3D”; con la camera lo comandi in “Interattivo Avatar”.
diff --git a/src/control.js b/src/control.js index 2d9baed..a2567d4 100644 --- a/src/control.js +++ b/src/control.js @@ -287,17 +287,26 @@ pre3dSel.addEventListener('change', apply3d); $('#glb-effect-go').addEventListener('click', apply3d); $('#btn-glb-load').addEventListener('click', () => $('#glb-input').click()); -$('#glb-input').addEventListener('change', (e) => { +$('#glb-input').addEventListener('change', async (e) => { const f = e.target.files[0]; if (!f) return; - const p = djv.pathForFile(f); - if (p) { - glbPath = p; - localStorage.setItem('glb3d', p); - glbSend(); - glbLabel(f.name); - } + let p = djv.pathForFile(f); e.target.value = ''; + if (!p) return; + // FBX characters (e.g. a Mixamo T-pose) are converted to GLB by Blender + if (/\.fbx$/i.test(p)) { + glbLabel('⏳ converto ' + f.name + '… (qualche secondo)'); + const res = await djv.convertModel(p); + if (!res || !res.ok) { + $('#glb-name').textContent = '⚠️ ' + ((res && res.error) || 'conversione fallita'); + return; + } + p = res.path; + } + glbPath = p; + localStorage.setItem('glb3d', p); + glbSend(); + glbLabel(f.name.replace(/\.fbx$/i, '.glb')); }); // --- Effect sequence (playlist of effects with auto-cycle) ---