#!/usr/bin/env python3 """PROCITY R41 §41.1 — gate: motion_manifest.json <-> web/models/clips/*.glb integrity. python3 pipeline/clips_verify.py # 0 errors = green python3 pipeline/clips_verify.py --stage # + quantisation fidelity vs the float source Checks, all measured from the bytes (Lane F: this is the "every referenced clip resolves" gate): 1. every group file named in the manifest exists, and its byte size matches the recorded one 2. every clip id resolves to a NAMED animation inside its group GLB, and nothing extra rides along 3. every group GLB is ZERO-DRAW — 0 meshes, 0 materials, 0 images, no Draco 4. every group carries the same 66-node mixamorig skeleton (65 joints + Armature) 5. every animation channel targets a node that exists, on a path the manifest declares 6. recorded duration matches the max keyframe time in the file 7. `_rotOnly` survivability: every clip keeps >=1 non-Hips quaternion track, i.e. the clip still says something after web/js/citizens/rigs.js filters it 8. with --stage: max angular error of the int16 rotation quantisation vs the float32 source """ import argparse import json import math import os import struct import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from clips_pack import acc_floats, canon_name, glb_read # noqa: E402 def main(): ap = argparse.ArgumentParser() ap.add_argument("--manifest", default="web/assets/motion_manifest.json") ap.add_argument("--clips", default="web/models/clips") ap.add_argument("--stage", help="staging dir of per-clip float32 GLBs (fidelity check)") a = ap.parse_args() man = json.load(open(a.manifest)) err, warn = [], [] total = 0 skel = None for gfile, g in man["groups"].items(): p = os.path.join(a.clips, gfile) if not os.path.exists(p): err.append("%s: MISSING" % gfile) continue size = os.path.getsize(p) total += size if size != g["bytes"]: err.append("%s: size %d != manifest %d" % (gfile, size, g["bytes"])) js, bin_ = glb_read(p) if js.get("meshes") or js.get("materials") or js.get("images"): err.append("%s: NOT zero-draw (meshes=%d materials=%d images=%d)" % (gfile, len(js.get("meshes", [])), len(js.get("materials", [])), len(js.get("images", [])))) if any("draco" in e.lower() for e in js.get("extensionsUsed", [])): err.append("%s: Draco present" % gfile) names = [canon_name(n.get("name")) for n in js["nodes"]] if skel is None: skel = names elif names != skel: err.append("%s: skeleton differs from %s" % (gfile, list(man["groups"])[0])) joints = [n for n in names if n.startswith("mixamorig:")] if len(names) != 66 or len(joints) != 65: err.append("%s: expected 66 nodes / 65 mixamorig joints, got %d / %d" % (gfile, len(names), len(joints))) anims = {an.get("name"): an for an in js.get("animations", [])} if sorted(anims) != sorted(g["clips"]): err.append("%s: animations %s != manifest clips %s" % (gfile, sorted(anims), sorted(g["clips"]))) for cid in g["clips"]: if cid not in anims: continue m = man["clips"][cid] if m["group"] != gfile: err.append("%s: clips[%s].group=%s" % (gfile, cid, m["group"])) an = anims[cid] dur, keeps = 0.0, 0 for ch in an["channels"]: ni = ch["target"]["node"] if not (0 <= ni < len(names)): err.append("%s/%s: channel targets node %d out of range" % (gfile, cid, ni)) continue if ch["target"]["path"] != "rotation" and man["_tracks"] == "rotation-only": err.append("%s/%s: %s channel in a rotation-only build" % (gfile, cid, ch["target"]["path"])) if ch["target"]["path"] == "rotation" and not names[ni].endswith(":Hips"): keeps += 1 t = acc_floats(js, bin_, an["samplers"][ch["sampler"]]["input"]) if t: dur = max(dur, t[-1][0]) if abs(dur - m["duration"]) > 1e-3: err.append("%s/%s: duration %.4f != manifest %.4f" % (gfile, cid, dur, m["duration"])) if keeps == 0: err.append("%s/%s: nothing survives rigs.js `_rotOnly`" % (gfile, cid)) if len(an["channels"]) != m["channels"]: err.append("%s/%s: %d channels != manifest %d" % (gfile, cid, len(an["channels"]), m["channels"])) if total != man["totalBytes"]: err.append("totalBytes %d != measured %d" % (man["totalBytes"], total)) if len(man["groups"]) > 6: err.append("ruling 6: %d group files > 6" % len(man["groups"])) if man["clipCount"] != len(man["clips"]): err.append("clipCount mismatch") # 8. quantisation fidelity against the float32 staging GLBs if a.stage: worst, worst_id = 0.0, None for cid, m in man["clips"].items(): sp = os.path.join(a.stage, "%s.glb" % cid) if not os.path.exists(sp): warn.append("no staged source for %s" % cid) continue sj, sb = glb_read(sp) gj_, gb = glb_read(os.path.join(a.clips, m["group"])) snames = [canon_name(n.get("name")) for n in sj["nodes"]] gnames = [canon_name(n.get("name")) for n in gj_["nodes"]] ga = next(x for x in gj_["animations"] if x.get("name") == cid) gmap = {gnames[c["target"]["node"]]: c for c in ga["channels"] if c["target"]["path"] == "rotation"} for ch in sj["animations"][0]["channels"]: if ch["target"]["path"] != "rotation": continue bone = snames[ch["target"]["node"]] if bone not in gmap: continue q0 = acc_floats(sj, sb, sj["animations"][0]["samplers"][ch["sampler"]]["output"]) q1 = acc_floats(gj_, gb, ga["samplers"][gmap[bone]["sampler"]]["output"]) if len(q0) != len(q1): err.append("%s/%s: keyframe count %d != %d" % (cid, bone, len(q0), len(q1))) continue for x, y in zip(q0, q1): if man["_encoding"].startswith("rotation outputs are int16"): y = tuple(max(v / 32767.0, -1.0) for v in y) d = abs(sum(p * q for p, q in zip(x, y))) d /= (math.sqrt(sum(p * p for p in x)) * math.sqrt(sum(q * q for q in y)) or 1) ang = math.degrees(2 * math.acos(max(-1.0, min(1.0, d)))) if ang > worst: worst, worst_id = ang, "%s/%s" % (cid, bone) print("quantisation: worst angular error %.6f deg (%s)" % (worst, worst_id)) if worst > 0.05: err.append("quantisation error %.4f deg exceeds 0.05" % worst) print("%d clips / %d groups / %d bytes (%.2f MB)" % (man["clipCount"], len(man["groups"]), total, total / 1048576.0)) for w in warn: print("WARN %s" % w) for e in err: print("ERROR %s" % e) print("clips_verify: %s (%d errors, %d warnings)" % ("GREEN" if not err else "RED", len(err), len(warn))) return 1 if err else 0 if __name__ == "__main__": sys.exit(main())