fix(audit): converge on the 2026-07-23 animation standard — tool sync, QuatSkin INBOX, registry corrections

From the conformance audit (ariki-game .agents/audits/animation-standard-assessment-2026-07-23.md):
- tools/: cc_retarget + mixamo_retarget re-synced from ariki-game (were stale forks
  missing --despike/--fingers/--map ual); kevin_retarget path now repo-relative;
  mirror rule documented in exchange/GUIDE.md
- INBOX/lena: add canonical Ariki_Female_QuatSkin.glb; README marks MixamoSkin superseded
- REGISTRY: tide_dance_01 flagged (no DanceType enum entry); rain_dance note corrected
  (RainDance IS in the enum); boat table notes live canoedismount1/run_to_dive; QC split
  (loop_qc pre-commit vs anim_qc post-ingest) documented
- docs/iclone-bridge.md: stub created (GUIDE referenced it but it never existed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:37:55 -07:00
parent 998e306804
commit b4d962167f
8 changed files with 204 additions and 16 deletions
+40 -9
View File
@@ -57,6 +57,22 @@ def detect_prefix(arm):
raise RuntimeError("no *Hips bone found — not a Mixamo rig?")
# ── UAL/Quaternius-source mode (2026-07-21): retarget stock-skeleton clips (UAL,
# Kevin) onto a DIFFERENT-convention skeleton (e.g. the renamed Mixamo rig — its
# bone local frames differ, so raw clip playback tips her face-down). Identity
# bone map (names already match), source frame bones are the Quaternius names.
QUAT_BONES = ["pelvis", "spine_01", "spine_02", "spine_03", "neck_01", "Head"]
for _T in ("l", "r"):
QUAT_BONES += [f"clavicle_{_T}", f"upperarm_{_T}", f"lowerarm_{_T}", f"hand_{_T}",
f"thigh_{_T}", f"calf_{_T}", f"foot_{_T}", f"ball_{_T}"]
for _i in (1, 2, 3):
QUAT_BONES += [f"index_0{_i}_{_T}", f"middle_0{_i}_{_T}", f"ring_0{_i}_{_T}",
f"pinky_0{_i}_{_T}", f"thumb_0{_i}_{_T}"]
UAL_MAP = {n: n for n in QUAT_BONES}
# Per-mode source-frame bones (see retarget_one): mixamo | ual
SRC_FRAME_BONES = ("Hips", "Head", "LeftArm")
def apply_object_transform(obj):
"""Bake importer object-level rotation/scale into the armature data (meters, Z-up)."""
bpy.ops.object.select_all(action="DESELECT")
@@ -109,11 +125,12 @@ def retarget_one(src_arm, tgt_arm, action, name, scn, prefix, unit):
tgt_rest_q = {n: m.to_quaternion().normalized() for n, m in tgt_rest.items()}
C = (char_frame(tgt_rest, "pelvis", "Head", "upperarm_l")
@ char_frame(src_rest, prefix + "Hips", prefix + "Head", prefix + "LeftArm").inverted()).normalized()
@ char_frame(src_rest, prefix + SRC_FRAME_BONES[0], prefix + SRC_FRAME_BONES[1],
prefix + SRC_FRAME_BONES[2]).inverted()).normalized()
Cinv = C.inverted()
# Uniform size ratio from hips→head world distance (unit/scale agnostic).
h_src = (src_rest[prefix + "Head"].translation - src_rest[prefix + "Hips"].translation).length
h_src = (src_rest[prefix + SRC_FRAME_BONES[1]].translation - src_rest[prefix + SRC_FRAME_BONES[0]].translation).length
h_tgt = (tgt_rest["Head"].translation - tgt_rest["pelvis"].translation).length
tscale = h_tgt / h_src if h_src > 1e-5 else 1.0
@@ -127,7 +144,7 @@ def retarget_one(src_arm, tgt_arm, action, name, scn, prefix, unit):
for pb in tgt_arm.pose.bones:
pb.rotation_mode = "QUATERNION"
src_hips_rest_t = src_rest[prefix + "Hips"].translation
src_hips_rest_t = src_rest[prefix + SRC_FRAME_BONES[0]].translation
for f in range(f0, f1 + 1):
scn.frame_set(f)
@@ -181,18 +198,23 @@ def retarget_one(src_arm, tgt_arm, action, name, scn, prefix, unit):
def main():
global BONE_MAP, SRC_FRAME_BONES
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
args = dict(zip(argv[::2], argv[1::2]))
srcs, out = args["--src"].split(","), args["--out"]
limit = int(args.get("--limit", 0))
target = args.get("--target", DEFAULT_TARGET_GLTF)
clips_filter = [c.strip().lower() for c in args.get("--clips", "").split(",") if c.strip()]
if args.get("--map") == "ual":
BONE_MAP = UAL_MAP # identity: source bones already quat-named
SRC_FRAME_BONES = ("pelvis", "Head", "upperarm_l")
fbx_files = []
for s in srcs:
s = s.strip()
if os.path.isdir(s):
for root, _, files in os.walk(s):
fbx_files += [os.path.join(root, x) for x in sorted(files) if x.lower().endswith(".fbx")]
fbx_files += [os.path.join(root, x) for x in sorted(files) if x.lower().endswith((".fbx", ".glb", ".gltf"))]
else:
fbx_files.append(s)
if limit:
@@ -218,22 +240,31 @@ def main():
name = clip_name(f)
pre = set(bpy.data.objects)
pre_actions = set(bpy.data.actions)
bpy.ops.import_scene.fbx(filepath=f)
if f.lower().endswith((".glb", ".gltf")):
bpy.ops.import_scene.gltf(filepath=f)
else:
bpy.ops.import_scene.fbx(filepath=f)
new_objs = [o for o in bpy.data.objects if o not in pre]
src_arm = next((o for o in new_objs if o.type == "ARMATURE"), None)
new_acts = [a for a in bpy.data.actions if a not in pre_actions]
if clips_filter:
new_acts = [a for a in new_acts if any(c in a.name.lower() for c in clips_filter)]
if src_arm is None or not new_acts:
print(f"[mixamo] SKIP (no armature/action): {f}")
else:
bpy.context.view_layer.update()
prefix = detect_prefix(src_arm)
prefix = "" if args.get("--map") == "ual" else detect_prefix(src_arm)
# Importer unit scale (cm→m 0.01) — capture BEFORE transform_apply wipes it;
# the hips translation math needs it (fcurves stay in source units).
unit = src_arm.scale.x
apply_object_transform(src_arm)
act = retarget_one(src_arm, tgt_arm, new_acts[0], name, scn, prefix, unit)
done.append(act)
print(f"[mixamo] {i + 1}/{len(fbx_files)} {name} ({int(act.frame_range[1])}f, prefix='{prefix}')")
for act_src in new_acts:
out_name = act_src.name if args.get("--map") == "ual" else name
if args.get("--map") == "ual":
act_src.name += "__src" # free the clean name — Blender dedupes the output to .001 otherwise
act = retarget_one(src_arm, tgt_arm, act_src, out_name, scn, prefix, unit)
done.append(act)
print(f"[mixamo] {i + 1}/{len(fbx_files)} {out_name} ({int(act.frame_range[1])}f, prefix='{prefix}')")
for o in new_objs:
bpy.data.objects.remove(o, do_unlink=True)
for a in new_acts: