PROCITY/pipeline/clips_export.py
m3ultra 8ad446a48a Lane E R41 (§41.1+§41.2): 46 clips and 110 fittings — and every handover number was the wrong unit
MOTION (§41.1). The clip library goes 8 -> 46: ten idles, eight browse, eight sit/lean,
eight social, six locomotion, six venue, in SIX grouped GLBs (one fetch each), 3.35 MB —
LESS than the 4.29 MB the old eight cost, via lossless dedup + int16 rotations (worst
error 0.0034 deg). All six verified skeleton-only (tris 0, meshes 0, nodes 66): ZERO DRAW,
which is what makes this round affordable. No retarget was run and none was wanted — the
bank and the peds are the same mixamorig skeleton, so retargeting would add error AND bake
the ped mesh into the clip, ending zero-draw. MIRPAMO 'make smoke' green to prove the tool,
then deliberately unused. Three seeds in the brief were duds and got substituted: the whole
*_Degree_Turn set is RIFLE-AIMING, and two 'examine' clips are 262 KB static poses, not
motion. R16 flat-body trap re-checked on all 46 (spine tilt 24 samples/clip, 0 frames >75
deg); turn_in_place auto-demoted from loopable on a 36.7 deg seam.

RULING 1 — the Bandai hazard, closed non-destructively and better than specified. 3,077 CC
BY-NC clips sat unzoned in a neutrally-named path. Renamed PER FILE (3,077/3,077) not just
the directory, because mirpamo names output <rig>@<clip>.glb — the _NC-research marker now
PROPAGATES into any retargeted GLB automatically. Nothing deleted; ultra's red/bandai
re-verified as canonical. Ledger corrected: the manifest claimed CC-BY-NC-ND, the bundled
licence text says CC BY-NC, no ND.

PROPS (§41.2). 110 assets from three libraries, published, sha1-verified, 0 validator errors
both modes. THE FINDING: every handover number was a TRIANGLE count, and triangles were
never the binding constraint — DRAW CALLS were. As handed over this cargo cost 14,140 draws
against a 162-draw margin; it ships at 117. '3dstore passes as-is' was true on tris/metres/
Draco/textures and FALSE on draws — 30 of 46 files carried up to 16 materials on one mesh
(one per record sleeve), so a 1,020-tri tub cost 16 draws; baked to COLOR_0, 288 -> 40, zero
tri drift, A/B identical. dj-gear's own manifest claimed median 6,288 tris / 18 under budget;
measured 51,528 and 12, with draws to 2,145. Pub props were NOT metre-correct (every source
unit-normalised to max dim 1.00 m) and 40x decimation was impossible as specified (loungeChair
is 94% non-manifold).

Ruling 5 vs the draw budget was a real conflict (one mixer = 4.6x a whole room); resolved by
joining SCENERY selectively while every control node keeps its object/name/pivot — verified
by parsing the SHIPPED GLB, not the tool that wrote it: PASS 4 / STATIC 10 / N/A 17 / FAIL 0.
deck_1200_rigged = 5 draws with Platter_SPIN, Arm_YAW, Fader_PITCH, Btn_STARTSTOP intact.

The R40 four-surface transmission gate FIRED ON LIVE CARGO: 5 genuinely transmissive
materials (cartridge dust-covers, a mixer meter window) that would each have doubled every
opaque draw in the room. 9 assets rejected on eyeball after decimation rather than shipping
mush. Two existing-tooling bugs fixed: normalize.py's yaw/up were silent no-ops (the jukebox
exported 0.40 m instead of 0.97 m), and footprints now measure the shipped GLB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:10:12 +10:00

111 lines
4.8 KiB
Python

#!/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 <dir of the named FBX> --out <staging dir>
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()