#!/usr/bin/env python3
"""PROCITY R41 §41.1 — stage 1 of 2: Mixamo anim-only FBX -> skeleton-only per-clip GLB.
Runs INSIDE Blender:
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python pipeline/clips_export.py -- \
--list pipeline/clips_r41.json --fbx
--out
Stage 2 is `pipeline/clips_pack.py` (pure python, no Blender): it merges the per-clip GLBs into
the <=6 grouped GLBs under web/models/clips/ and writes web/assets/motion_manifest.json.
WHY TWO STAGES. MIRPAMO's roadmap item "multi-clip single-GLB export" is not built yet, and the
documented Blender-5 slotted-action hazard (MIRPAMO/CLAUDE.md) bites exactly when you try to move
actions between armatures inside one .blend. Exporting one clip per GLB is the proven path — it is
how web/models/peds/sit.glb and look.glb were made (generator "Khronos glTF Blender I/O v5.1.20",
66 nodes, 0 meshes) — and the merge is then a deterministic glTF-level operation with no Blender
version risk at all.
NO RETARGET IS NEEDED and none is performed: the Mixamo bank and PROCITY's ped fleet are the SAME
mixamorig skeleton (rigs.js `_canon` folds mixamorig1/4/... -> mixamorig), which is the whole point
of the house "one canonical clip bank" law. MIRPAMO's retargeter is for foreign skeletons (CMU/BVH)
and for rigging raw meshes; pointing it at a mixamorig->mixamorig pair would only add error, and it
would emit the ped MESH into the clip file, breaking the zero-draw property that makes this round
affordable. `make smoke` is still run as the tool ground-truth check (see E-progress.md).
"""
import json
import os
import sys
import bpy
def argv_after_ddash():
return sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
def parse_args(a):
out = {}
i = 0
while i < len(a):
out[a[i].lstrip("-")] = a[i + 1]
i += 2
return out
def reset():
bpy.ops.wm.read_factory_settings(use_empty=True)
def import_clip(path):
bpy.ops.import_scene.fbx(filepath=path, automatic_bone_orientation=False,
ignore_leaf_bones=False)
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
if len(arms) != 1:
raise RuntimeError("expected exactly 1 armature, got %d in %s" % (len(arms), path))
return arms[0]
def export_glb(arm, out_path, anim_name):
# name the action == the clip id so the exported animation carries it
if arm.animation_data and arm.animation_data.action:
arm.animation_data.action.name = anim_name
for o in bpy.data.objects:
o.select_set(o is arm)
bpy.context.view_layer.objects.active = arm
kw = dict(filepath=out_path, export_format="GLB",
export_animations=True, export_animation_mode="ACTIVE_ACTIONS",
export_skins=True, export_yup=True,
export_materials="NONE", export_texcoords=False, export_normals=False,
export_apply=False, use_selection=True)
try:
bpy.ops.export_scene.gltf(**kw)
except TypeError: # older/newer exporter arg drift — retry minimal
bpy.ops.export_scene.gltf(filepath=out_path, export_format="GLB",
export_animations=True, use_selection=True)
def main():
a = parse_args(argv_after_ddash())
sel = json.load(open(a["list"]))
fbx_dir, out_dir = a["fbx"], a["out"]
os.makedirs(out_dir, exist_ok=True)
results, fails = [], []
for gname, g in sel["groups"].items():
for c in g["clips"]:
src = os.path.join(fbx_dir, c["file"])
dst = os.path.join(out_dir, "%s.glb" % c["id"])
try:
reset()
arm = import_clip(src)
nbones = len(arm.data.bones)
act = arm.animation_data.action if arm.animation_data else None
fs, fe = (act.frame_range if act else (0, 0))
export_glb(arm, dst, c["id"])
results.append(dict(id=c["id"], group=gname, file=c["file"], bones=nbones,
frame_start=float(fs), frame_end=float(fe),
fps=bpy.context.scene.render.fps,
bytes=os.path.getsize(dst)))
print("[ok ] %-22s %-64s bones=%d frames=%.0f..%.0f -> %d B"
% (c["id"], c["file"], nbones, fs, fe, os.path.getsize(dst)))
except Exception as e: # noqa: BLE001
fails.append(dict(id=c["id"], file=c["file"], err=repr(e)))
print("[FAIL] %-22s %s: %r" % (c["id"], c["file"], e))
with open(os.path.join(out_dir, "_export_results.json"), "w") as f:
json.dump(dict(ok=results, fail=fails), f, indent=1)
print("\n%d exported / %d failed" % (len(results), len(fails)))
main()