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>
330 lines
15 KiB
Python
330 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""PROCITY R41 §41.1 — stage 2 of 2: per-clip skeleton-only GLBs -> <=6 GROUPED clip GLBs.
|
|
|
|
python3 pipeline/clips_pack.py --stage <dir from clips_export.py> \
|
|
--list pipeline/clips_r41.json --out web/models/clips \
|
|
--manifest web/assets/motion_manifest.json
|
|
|
|
Pure python3 stdlib — no Blender, no deps. Operates at the glTF level, which is safe here because
|
|
every per-clip GLB in the staging dir carries the IDENTICAL 66-node mixamorig skeleton (asserted,
|
|
not assumed: `--check` fails loudly on any node-name mismatch). Merging is therefore: keep group
|
|
clip #1's node graph + skin verbatim, then append every other clip's animation with its channel
|
|
targets remapped by node NAME.
|
|
|
|
TRACK FILTER (`--tracks`, default `rot`).
|
|
PROCITY's consumer is `web/js/citizens/rigs.js` `_rotOnly`, which keeps ONLY `.quaternion` tracks and
|
|
additionally drops `Hips.quaternion` — every translation and scale track a clip GLB ships is decoded
|
|
by three.js and then thrown away on the same frame. Shipping them is pure boot cost. So the default
|
|
packs rotation channels only. `--tracks all` reproduces the unfiltered T/R/S file if a future round
|
|
needs root motion back (the pipeline is re-runnable from `clips_r41.json` in ~1 minute).
|
|
|
|
LOOP SEAM is measured, not asserted: for every clip we compute the maximum per-bone angle between
|
|
the first and last rotation keyframe (`loopSeamDeg`). Small = the clip cycles cleanly and can be
|
|
played on repeat; large = it is a one-shot and should be played once and blended out. The manifest
|
|
carries both the curated `loopable` flag and the measured number so Lane D can second-guess it.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import struct
|
|
|
|
COMP = {5120: ("b", 1), 5121: ("B", 1), 5122: ("h", 2), 5123: ("H", 2),
|
|
5125: ("I", 4), 5126: ("f", 4)}
|
|
NCOMP = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
|
|
|
|
|
|
# ---------------------------------------------------------------- GLB read/write
|
|
def glb_read(path):
|
|
with open(path, "rb") as f:
|
|
data = f.read()
|
|
if data[:4] != b"glTF":
|
|
raise ValueError("%s: not a GLB" % path)
|
|
js, bin_ = None, b""
|
|
off = 12
|
|
while off < len(data):
|
|
ln, ty = struct.unpack_from("<II", data, off)
|
|
off += 8
|
|
if ty == 0x4E4F534A:
|
|
js = json.loads(data[off:off + ln])
|
|
elif ty == 0x004E4942:
|
|
bin_ = data[off:off + ln]
|
|
off += ln
|
|
return js, bin_
|
|
|
|
|
|
def glb_write(path, js, bin_):
|
|
j = json.dumps(js, separators=(",", ":")).encode()
|
|
j += b" " * ((4 - len(j) % 4) % 4)
|
|
b = bytes(bin_) + b"\0" * ((4 - len(bin_) % 4) % 4)
|
|
total = 12 + 8 + len(j) + (8 + len(b) if b else 0)
|
|
out = bytearray()
|
|
out += struct.pack("<4sII", b"glTF", 2, total)
|
|
out += struct.pack("<II", len(j), 0x4E4F534A) + j
|
|
if b:
|
|
out += struct.pack("<II", len(b), 0x004E4942) + b
|
|
with open(path, "wb") as f:
|
|
f.write(out)
|
|
return total
|
|
|
|
|
|
def acc_bytes(js, bin_, ai):
|
|
"""Return (tightly packed bytes, count, type, componentType, normalized) for accessor ai."""
|
|
a = js["accessors"][ai]
|
|
ct, n = a["componentType"], a["count"]
|
|
fmt, sz = COMP[ct]
|
|
nc = NCOMP[a["type"]]
|
|
elem = sz * nc
|
|
bv = js["bufferViews"][a["bufferView"]]
|
|
base = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
|
|
stride = bv.get("byteStride") or elem
|
|
if stride == elem:
|
|
raw = bin_[base:base + elem * n]
|
|
else:
|
|
raw = b"".join(bin_[base + i * stride: base + i * stride + elem] for i in range(n))
|
|
return raw, n, a["type"], ct, a.get("normalized", False), a.get("min"), a.get("max")
|
|
|
|
|
|
def acc_floats(js, bin_, ai):
|
|
raw, n, ty, ct, _, _, _ = acc_bytes(js, bin_, ai)
|
|
fmt, sz = COMP[ct]
|
|
nc = NCOMP[ty]
|
|
v = struct.unpack("<%d%s" % (n * nc, fmt), raw)
|
|
return [v[i * nc:(i + 1) * nc] for i in range(n)]
|
|
|
|
|
|
def quantize_rot(raw, count, ct):
|
|
"""float32 VEC4 quaternions -> int16 normalized VEC4 (glTF-legal animation output).
|
|
|
|
Halves the dominant payload. glTF 2.0 dequantises SHORT as max(c/32767, -1.0) and three.js
|
|
r175 does exactly that in GLTFLoader `_getArrayFromAccessor` (verified in
|
|
web/vendor/addons/loaders/GLTFLoader.js). Worst-case angular error is ~0.006 deg — three
|
|
orders of magnitude under anything a 1.7 m citizen can show on screen.
|
|
"""
|
|
if ct != 5126:
|
|
return raw, ct, False
|
|
v = struct.unpack("<%df" % (count * 4), raw)
|
|
q = [max(-32767, min(32767, int(round(max(-1.0, min(1.0, x)) * 32767.0)))) for x in v]
|
|
return struct.pack("<%dh" % len(q), *q), 5122, True
|
|
|
|
|
|
class Sink:
|
|
"""Accumulates tightly packed accessor payloads into one BIN chunk, deduped by content."""
|
|
|
|
def __init__(self):
|
|
self.bin = bytearray()
|
|
self.views = []
|
|
self.accs = []
|
|
self._seen = {}
|
|
self.dedup_saved = 0
|
|
|
|
def add(self, raw, count, ty, ct, normalized, mn, mx):
|
|
key = (bytes(raw), count, ty, ct, normalized)
|
|
if key in self._seen: # identical payload already in this file
|
|
self.dedup_saved += len(raw)
|
|
return self._seen[key]
|
|
while len(self.bin) % 4:
|
|
self.bin += b"\0"
|
|
off = len(self.bin)
|
|
self.bin += raw
|
|
self.views.append({"buffer": 0, "byteOffset": off, "byteLength": len(raw)})
|
|
a = {"bufferView": len(self.views) - 1, "componentType": ct,
|
|
"count": count, "type": ty}
|
|
if normalized:
|
|
a["normalized"] = True
|
|
if mn is not None:
|
|
a["min"], a["max"] = mn, mx
|
|
self.accs.append(a)
|
|
self._seen[key] = len(self.accs) - 1
|
|
return len(self.accs) - 1
|
|
|
|
|
|
# The bank is not uniform about the mixamorig namespace: 45 of the 46 R41 clips export as
|
|
# `mixamorig1:Bone`, one (Idle_Smoking) as `mixamorig:Bone` — the exact split the house `_canon`
|
|
# trick in rigs.js exists to absorb (and the same split already shipped: idle.glb is mixamorig4:,
|
|
# sit.glb is mixamorig:). Joint NAMES and node ORDER are identical across all 46 (asserted below).
|
|
# Normalise to the bare `mixamorig:` namespace so the merge is name-exact and every group file
|
|
# reads the same; three.js sanitises the colon out anyway, so runtime binding is unchanged.
|
|
_NS = re.compile(r"mixamorig\d*:")
|
|
|
|
|
|
def canon_name(n):
|
|
return _NS.sub("mixamorig:", n) if n else n
|
|
|
|
|
|
def quat_angle_deg(q0, q1):
|
|
d = abs(sum(a * b for a, b in zip(q0, q1)))
|
|
return math.degrees(2.0 * math.acos(max(-1.0, min(1.0, d))))
|
|
|
|
|
|
# ---------------------------------------------------------------- pack one group
|
|
FINGER = re.compile(r"Hand(Thumb|Index|Middle|Ring|Pinky)\d")
|
|
|
|
|
|
def pack_group(gname, clips, stage, out_dir, tracks, quant=True, drop_fingers=False):
|
|
keep = {"rotation"} if tracks == "rot" else {"rotation", "translation", "scale"}
|
|
base_js, base_bin = glb_read(os.path.join(stage, "%s.glb" % clips[0]["id"]))
|
|
for n in base_js["nodes"]:
|
|
if n.get("name"):
|
|
n["name"] = canon_name(n["name"])
|
|
names = [n.get("name") for n in base_js["nodes"]]
|
|
if len(set(names)) != len(names):
|
|
raise SystemExit("%s: duplicate node names in base skeleton" % gname)
|
|
idx_of = {n: i for i, n in enumerate(names)}
|
|
|
|
sink = Sink()
|
|
out = {
|
|
"asset": {"version": "2.0",
|
|
"generator": "PROCITY pipeline/clips_pack.py (R41 41.1) from %s"
|
|
% base_js["asset"].get("generator", "?")},
|
|
"scene": base_js.get("scene", 0),
|
|
"scenes": base_js["scenes"],
|
|
"nodes": base_js["nodes"],
|
|
"animations": [],
|
|
"buffers": [{"byteLength": 0}],
|
|
}
|
|
if "skins" in base_js:
|
|
skins = json.loads(json.dumps(base_js["skins"]))
|
|
for sk in skins:
|
|
if "inverseBindMatrices" in sk:
|
|
sk["inverseBindMatrices"] = sink.add(*acc_bytes(base_js, base_bin,
|
|
sk["inverseBindMatrices"]))
|
|
out["skins"] = skins
|
|
|
|
report = []
|
|
for c in clips:
|
|
p = os.path.join(stage, "%s.glb" % c["id"])
|
|
js, bin_ = glb_read(p)
|
|
nm = [canon_name(n.get("name")) for n in js["nodes"]]
|
|
if nm != names:
|
|
raise SystemExit("%s/%s: skeleton mismatch vs group base (%d vs %d nodes)"
|
|
% (gname, c["id"], len(nm), len(names)))
|
|
if len(js.get("animations", [])) != 1:
|
|
raise SystemExit("%s/%s: expected 1 animation, got %d"
|
|
% (gname, c["id"], len(js.get("animations", []))))
|
|
anim = js["animations"][0]
|
|
smap, chans, samps = {}, [], []
|
|
dur, seam, nrot = 0.0, 0.0, 0
|
|
for ch in anim["channels"]:
|
|
path = ch["target"]["path"]
|
|
if path not in keep or "node" not in ch["target"]:
|
|
continue
|
|
tname = nm[ch["target"]["node"]]
|
|
if drop_fingers and FINGER.search(tname):
|
|
continue
|
|
si = ch["sampler"]
|
|
if si not in smap:
|
|
s = anim["samplers"][si]
|
|
t = acc_floats(js, bin_, s["input"])
|
|
dur = max(dur, t[-1][0] if t else 0.0)
|
|
oraw, on, oty, oct, onorm, omn, omx = acc_bytes(js, bin_, s["output"])
|
|
if path == "rotation":
|
|
q = acc_floats(js, bin_, s["output"])
|
|
if len(q) > 1:
|
|
seam = max(seam, quat_angle_deg(q[0], q[-1]))
|
|
nrot += 1
|
|
if quant and s.get("interpolation", "LINEAR") != "CUBICSPLINE":
|
|
oraw, oct, onorm = quantize_rot(oraw, on, oct)
|
|
omn = omx = None
|
|
smap[si] = len(samps)
|
|
samps.append({"input": sink.add(*acc_bytes(js, bin_, s["input"])),
|
|
"output": sink.add(oraw, on, oty, oct, onorm, omn, omx),
|
|
"interpolation": s.get("interpolation", "LINEAR")})
|
|
chans.append({"sampler": smap[si],
|
|
"target": {"node": idx_of[tname], "path": path}})
|
|
out["animations"].append({"name": c["id"], "channels": chans, "samplers": samps})
|
|
report.append(dict(id=c["id"], duration=round(dur, 4), channels=len(chans),
|
|
samplers=len(samps), rot_bones=nrot, loopSeamDeg=round(seam, 2)))
|
|
|
|
out["bufferViews"] = sink.views
|
|
out["accessors"] = sink.accs
|
|
out["buffers"][0]["byteLength"] = len(sink.bin) + ((4 - len(sink.bin) % 4) % 4)
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
path = os.path.join(out_dir, "%s.glb" % gname)
|
|
size = glb_write(path, out, sink.bin)
|
|
return path, size, report, len(names), sink.dedup_saved
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--stage", required=True)
|
|
ap.add_argument("--list", default="pipeline/clips_r41.json")
|
|
ap.add_argument("--out", default="web/models/clips")
|
|
ap.add_argument("--manifest", default="web/assets/motion_manifest.json")
|
|
ap.add_argument("--tracks", choices=["rot", "all"], default="rot")
|
|
ap.add_argument("--no-quantize", action="store_true",
|
|
help="keep float32 rotation outputs (default: int16 normalized, ~2x smaller)")
|
|
ap.add_argument("--drop-fingers", action="store_true",
|
|
help="drop the 40 finger-bone channels (~half the payload; hands then hold "
|
|
"their bind pose). Off by default — fidelity kept, lever documented.")
|
|
ap.add_argument("--loop-seam-max", type=float, default=25.0,
|
|
help="a clip curated loopable but seaming worse than this is demoted")
|
|
a = ap.parse_args()
|
|
|
|
sel = json.load(open(a.list))
|
|
man = {
|
|
"_schema": ("PROCITY motion manifest v1 (R41 41.1). groups{<file>} -> one GLB under "
|
|
"web/models/clips/, holding N named animations. clips{<clipId>} -> "
|
|
"{group,category,duration,loopable,source,mixamo,pair?}. Load a group ONCE "
|
|
"(gltf.animations[] are named by clipId); index by clips[id].group."),
|
|
"_licence": ("Mixamo (Adobe) via ultra ~/Documents/MOTIONLIB/green/mixamo_full/. GREEN: "
|
|
"free to use inside games/projects, NO attribution required. Never "
|
|
"redistribute as a clip pack. R41 ships this licence regime only (ruling 2)."),
|
|
"_tracks": ("rotation-only" if a.tracks == "rot" else "translation+rotation+scale"),
|
|
"_encoding": ("rotation outputs are %s; %s"
|
|
% ("float32" if a.no_quantize else "int16 NORMALIZED (glTF-legal, three.js "
|
|
"r175 de-normalises in GLTFLoader._getArrayFromAccessor)",
|
|
"finger channels DROPPED" if a.drop_fingers else
|
|
"all 65 joints animated incl. fingers")),
|
|
"_note": ("Skeleton-only, 0 tris, 66 nodes (65 mixamorig joints + Armature) — the same "
|
|
"byte-shape as web/models/peds/idle.glb. rigs.js `_canon` folds the mixamorigN: "
|
|
"prefix, `_rotOnly` keeps quaternions and drops Hips.quaternion."),
|
|
"fps": 30,
|
|
"groups": {},
|
|
"clips": {},
|
|
}
|
|
total, dedup = 0, 0
|
|
for gname, g in sel["groups"].items():
|
|
path, size, rep, nnodes, dd = pack_group(gname, g["clips"], a.stage, a.out, a.tracks,
|
|
quant=not a.no_quantize,
|
|
drop_fingers=a.drop_fingers)
|
|
total += size
|
|
dedup += dd
|
|
byid = {r["id"]: r for r in rep}
|
|
man["groups"]["%s.glb" % gname] = {
|
|
"category": g["category"], "desc": g["desc"], "clips": [c["id"] for c in g["clips"]],
|
|
"bytes": size, "nodes": nnodes,
|
|
"durationSum": round(sum(r["duration"] for r in rep), 3),
|
|
}
|
|
for c in g["clips"]:
|
|
r = byid[c["id"]]
|
|
loop = bool(c.get("loop")) and r["loopSeamDeg"] <= a.loop_seam_max
|
|
e = {"group": "%s.glb" % gname, "category": g["category"],
|
|
"duration": r["duration"], "loopable": loop,
|
|
"loopSeamDeg": r["loopSeamDeg"],
|
|
"source": c["file"], "mixamo": c["mixamo"],
|
|
"channels": r["channels"], "rotBones": r["rot_bones"]}
|
|
if c.get("loop") and not loop:
|
|
e["loopDemoted"] = "curated loopable but loop seam %.1f deg > %.1f" % (
|
|
r["loopSeamDeg"], a.loop_seam_max)
|
|
if c.get("pair"):
|
|
e["pair"] = c["pair"]
|
|
man["clips"][c["id"]] = e
|
|
print("[pack] %-11s %2d clips %7d B seam max %.1f deg"
|
|
% (gname, len(rep), size, max(r["loopSeamDeg"] for r in rep)))
|
|
man["totalBytes"] = total
|
|
man["clipCount"] = len(man["clips"])
|
|
man["groupCount"] = len(man["groups"])
|
|
man["dedupSavedBytes"] = dedup
|
|
os.makedirs(os.path.dirname(a.manifest), exist_ok=True)
|
|
with open(a.manifest, "w") as f:
|
|
json.dump(man, f, indent=1)
|
|
f.write("\n")
|
|
print("\n%d clips in %d group GLBs, %d B total (%.2f MB) -> %s"
|
|
% (man["clipCount"], man["groupCount"], total, total / 1048576.0, a.out))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|