PROCITY/pipeline/fbx_to_clip.py
m3ultra b322c998db Lane E R16 (v3.1): the drummer's sit clip — sit.glb produced + published
Ledger #2 asset half (E produces, D wires). Fetched Adobe Mixamo Sitting_Idle.fbx (533KB) by scp
from the tailnet source box, converted on-device via Blender (new pipeline/fbx_to_clip.py: import ->
skinned GLB export -> pure-python mesh-strip) to web/models/peds/sit.glb (109KB, mesh-free,
structurally identical to walk/idle: 66 nodes, 65 mixamorig bones, dangling skin, 195 channels).
Verified: three.js loads + 195/195 tracks bind to peds + 0 NaN; Blender confirms seated (hips
0.955->0.299m, stature 1.276->0.758m) + eyes-on bone-dot render (docs/shots/laneE/sit_clip.png).
Published to depot via tailnet under John's R13 auth (_published.json 38->39; benign non-procity_
drift note). Provenance: Adobe Mixamo royalty-free, same zone as the shipped walk/idle; LANE_E_NOTES
v3.1 amendment marker.

-> Lane D: sit.glb ready at web/models/peds/sit.glb (+ on depot); canon binds 195/195; seated confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:37:27 +10:00

135 lines
6.2 KiB
Python

"""PROCITY mixamo-clip converter — a Mixamo `.fbx` animation → a mesh-free house clip GLB, the exact
shape of the shipped `web/models/peds/walk.glb` / `idle.glb`: armature + one animation, NO mesh, but
WITH the (now dangling) skin that Blender's glTF I/O leaves behind — 65 `mixamorig` joints +
inverseBindMatrices, referenced by zero nodes. That vestigial skin is what makes glTF tooling (and
Blender re-import) reconstruct the file as an armature; three.js binds the clip by track name via
Lane D's `rigs.js` `_canon` regardless, so no bone rename is needed.
Runs headless on this box's Blender:
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python fbx_to_clip.py -- IN.fbx OUT.glb [ClipName]
Two stages, one command: (1) Blender imports the FBX (mesh+armature+anim) and exports a SKINNED GLB
(the skin needs the mesh to be emitted); (2) a pure-python glTF strip removes the mesh + its geometry
accessors, re-indexing/compacting so only the skeleton + dangling skin + animation remain (≈100 KB).
"""
import bpy, sys, os, json, struct, tempfile
ARGV = sys.argv[sys.argv.index("--") + 1:]
FBX, OUT = ARGV[0], ARGV[1]
CLIP = ARGV[2] if len(ARGV) > 2 else "Clip"
os.makedirs(os.path.dirname(os.path.abspath(OUT)), exist_ok=True)
def wipe():
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
for coll in (bpy.data.meshes, bpy.data.materials, bpy.data.images, bpy.data.armatures, bpy.data.actions):
for d in list(coll):
if d.users == 0:
coll.remove(d)
# ── stage 2: pure-python glTF mesh-strip (keep skeleton + dangling skin + animation) ──────────────
def read_glb(p):
d = open(p, "rb").read()
off = 12; js = None; binblob = b""
while off < len(d):
clen, ctype = struct.unpack("<II", d[off:off + 8]); off += 8
chunk = d[off:off + clen]; off += clen
if ctype == 0x4E4F534A: js = json.loads(chunk)
elif ctype == 0x004E4942: binblob = chunk
return js, binblob
def write_glb(js, binblob, out):
jb = json.dumps(js, separators=(",", ":")).encode()
jb += b" " * ((4 - len(jb) % 4) % 4)
bb = binblob + b"\x00" * ((4 - len(binblob) % 4) % 4)
total = 12 + 8 + len(jb) + 8 + len(bb)
with open(out, "wb") as f:
f.write(b"glTF"); f.write(struct.pack("<II", 2, total))
f.write(struct.pack("<II", len(jb), 0x4E4F534A)); f.write(jb)
f.write(struct.pack("<II", len(bb), 0x004E4942)); f.write(bb)
def strip_mesh(inpath, outpath):
js, binblob = read_glb(inpath)
keep = set() # accessors to retain: skin IBMs + all anim I/O
for sk in js.get("skins", []):
if "inverseBindMatrices" in sk:
keep.add(sk["inverseBindMatrices"])
for an in js.get("animations", []):
for s in an["samplers"]:
keep.add(s["input"]); keep.add(s["output"])
mesh_nodes = {i for i, n in enumerate(js["nodes"]) if "mesh" in n}
js.pop("meshes", None) # drop mesh defs; skins[] stay (become dangling)
for n in js["nodes"]:
n.pop("mesh", None); n.pop("skin", None)
# orphan the former-mesh nodes from the scene graph (leave them in the array so node indices —
# skin.joints, animation channel targets — stay valid; three.js ignores unreachable nodes)
for sc in js.get("scenes", []):
if "nodes" in sc:
sc["nodes"] = [x for x in sc["nodes"] if x not in mesh_nodes]
for n in js["nodes"]:
if "children" in n:
n["children"] = [x for x in n["children"] if x not in mesh_nodes]
if not n["children"]:
n.pop("children")
# re-index accessors → keep only the retained set
old_acc = js["accessors"]
keep_sorted = sorted(keep)
accmap = {old: i for i, old in enumerate(keep_sorted)}
js["accessors"] = [old_acc[i] for i in keep_sorted]
# keep bufferViews referenced by retained accessors; compact the binary
bv_keep = sorted({old_acc[i]["bufferView"] for i in keep_sorted if "bufferView" in old_acc[i]})
bvmap = {old: i for i, old in enumerate(bv_keep)}
old_bv = js["bufferViews"]
newbin = bytearray(); newbvs = []
for old in bv_keep:
bv = dict(old_bv[old]); start = bv.get("byteOffset", 0); length = bv["byteLength"]
pad = (4 - len(newbin) % 4) % 4; newbin += b"\x00" * pad
bv["byteOffset"] = len(newbin); newbin += binblob[start:start + length]
newbvs.append(bv)
js["bufferViews"] = newbvs
for a in js["accessors"]:
if "bufferView" in a:
a["bufferView"] = bvmap[a["bufferView"]]
for sk in js.get("skins", []):
if "inverseBindMatrices" in sk:
sk["inverseBindMatrices"] = accmap[sk["inverseBindMatrices"]]
for an in js.get("animations", []):
for s in an["samplers"]:
s["input"] = accmap[s["input"]]; s["output"] = accmap[s["output"]]
js["buffers"] = [{"byteLength": len(newbin)}]
write_glb(js, bytes(newbin), outpath)
return js
def main():
wipe()
bpy.ops.import_scene.fbx(filepath=FBX)
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
if not arms:
raise SystemExit("no armature in FBX")
arm = arms[0]
n_mesh = len([o for o in bpy.data.objects if o.type == "MESH"])
if arm.animation_data and arm.animation_data.action:
arm.animation_data.action.name = CLIP
bones = [b.name for b in arm.data.bones]
mixamo = [b for b in bones if b.startswith("mixamorig")]
bpy.ops.object.select_all(action="SELECT") # armature + mesh → Blender emits a skin
bpy.context.view_layer.objects.active = arm
tmp = tempfile.NamedTemporaryFile(suffix=".glb", delete=False).name
bpy.ops.export_scene.gltf(filepath=tmp, export_format="GLB", use_selection=True,
export_animations=True, export_yup=True, export_skins=True)
js = strip_mesh(tmp, OUT)
os.unlink(tmp)
sz = os.path.getsize(OUT)
print(f"CLIP_DONE {os.path.basename(OUT)} bones={len(bones)} (mixamorig {len(mixamo)}) "
f"mesh_stripped={n_mesh} meshes_out={len(js.get('meshes', []))} skins_out={len(js.get('skins', []))} "
f"nodes={len(js['nodes'])} anims={len(js.get('animations', []))} {sz // 1024}KB")
main()