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:
1 parent
a5c0a8b297
commit
d329759961
6 files changed
+91
-17
No files matched your search
@@ -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
@@ -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
@@ -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)
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+15
-6
@@ -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) {
|
||||
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);
|
||||
}
|
||||
e.target.value = '';
|
||||
glbLabel(f.name.replace(/\.fbx$/i, '.glb'));
|
||||
});
|
||||
|
||||
// --- Effect sequence (playlist of effects with auto-cycle) ---
|
||||
|
||||
Reference in new issue
Block a user