#!/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()