Files
lucianoandClaude Fable 5 14e5b48176 scripts/merge-mixamo.py: fuse a Mixamo character + N dance FBX into one GLB
Recovers the embedded diffuse when the FBX references missing external
textures, discards animation-file objects keeping only their actions,
retargets bone-name prefixes (mixamorig vs mixamorig1) so clips recorded
for another character land on the base rig, and exports all clips as NLA
tracks for the dance director.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:37:02 +02:00

62 lines
3.1 KiB
Python

# Merge Mixamo FBX files (1 character + N animations, same rig family) into a
# single GLB with all the clips, ready for DJ Visualizer's dance director.
#
# blender -b -P scripts/merge-mixamo.py -- <base.fbx> <anim1.fbx> ... <out.glb>
#
# - the base FBX provides the character (mesh+skin); its embedded diffuse
# texture is recovered when the FBX references missing external files
# - animation FBX files only contribute their action (objects are discarded)
# - bone-name prefixes are retargeted (mixamorig / mixamorig1 / ...) so clips
# recorded for another character land on this rig's bones
import bpy, os, re, sys
argv = sys.argv[sys.argv.index('--')+1:]
BASE, DANCES, OUT = argv[0], argv[1:-1], argv[-1]
TMP = os.path.join(os.path.dirname(OUT) or '.', '_diffuse_tmp.png')
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=BASE)
base_arm = None; meshes = []
for o in bpy.data.objects:
if o.type == 'ARMATURE': base_arm = o
if o.type == 'MESH': meshes.append(o)
print('BASE bones:', len(base_arm.data.bones),
'meshes:', [(m.name, len(m.data.vertices)) for m in meshes])
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)
for a in list(bpy.data.actions): bpy.data.actions.remove(a)
if diff:
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)
for src in DANCES:
before = set(bpy.data.objects)
bpy.ops.import_scene.fbx(filepath=src)
new = [o for o in bpy.data.objects if o not in before]
arm = next((o for o in new if o.type == 'ARMATURE'), None)
name = os.path.splitext(os.path.basename(src))[0].replace('_with_skin', '')
a = arm.animation_data.action if arm and arm.animation_data else None
if a:
a.name = name; a.use_fake_user = True
print('CLIP:', name)
for o in new: bpy.data.objects.remove(o, do_unlink=True)
base_prefix = base_arm.data.bones[0].name.split(':')[0]
for act in bpy.data.actions:
for fc in act.fcurves:
if 'mixamorig' in fc.data_path and not fc.data_path.startswith('pose.bones["%s:' % base_prefix):
fc.data_path = re.sub(r'mixamorig\d*:', base_prefix + ':', fc.data_path)
ad = base_arm.animation_data or base_arm.animation_data_create()
ad.action = None
for a in bpy.data.actions:
tr = ad.nla_tracks.new(); tr.name = a.name
tr.strips.new(a.name, int(a.frame_range[0]), a)
try:
bpy.ops.export_scene.gltf(filepath=OUT, export_format='GLB', export_animations=True,
export_skins=True, export_animation_mode='NLA_TRACKS')
except TypeError:
bpy.ops.export_scene.gltf(filepath=OUT, export_format='GLB', export_animations=True, export_skins=True)
print('TOT_CLIPS:', len(bpy.data.actions)); print('EXPORT_OK')