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 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-31 11:31:26 +02:00
1 parent a5c0a8b297
commit d329759961
6 files changed
+93 -19

No files matched your search

+9 -6
View File
@@ -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.
+3 -2
View File
@@ -28,10 +28,11 @@
"src/**/*",
"svg/**/*",
"models/**/*",
"scripts/fbx2animglb.py"
"scripts/fbx2animglb.py",
"scripts/fbx2modelglb.py"
],
"asarUnpack": [
"scripts/fbx2animglb.py"
"scripts/*.py"
]
},
"devDependencies": {
+2 -1
View File
@@ -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)
});
+60
View File
@@ -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')
+2 -2
View File
@@ -163,8 +163,8 @@
</section>
<section class="span2">
<label>Modello 3D (GLB)</label>
<button id="btn-glb-load" class="wide">🧊 Carica un modello .glb…</button>
<input type="file" id="glb-input" accept=".glb" hidden />
<button id="btn-glb-load" class="wide">🧊 Carica un modello (.glb / .fbx)…</button>
<input type="file" id="glb-input" accept=".glb,.fbx" hidden />
<div class="hint-mini" id="glb-name">Il modello ruota e balla a tempo di musica nella famiglia “Modello 3D”;
con la camera lo comandi in “Interattivo Avatar”.</div>
<label>Sfondo</label>
+17 -8
View File
@@ -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) ---