From d3206f6ce2f214bd05a3804f29371671f61f484c Mon Sep 17 00:00:00 2001 From: Jeremy Kashkett Date: Thu, 16 Jul 2026 13:48:09 -0700 Subject: [PATCH] =?UTF-8?q?feat(qc):=20loop=5Fqc=20tool=20+=20loop-qc=20ag?= =?UTF-8?q?ent=20=E2=80=94=20measure=20loop-seam=20smoothness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pose gap / velocity gap / root drift at the LoopMode.Linear wrap; exit-code gated. Skill updated: loop QC is workflow step 5, with the Blender-side fixer playbook. Co-Authored-By: Claude Fable 5 --- .claude/agents/loop-qc.md | 48 ++++++++++++ .claude/skills/animation/SKILL.md | 38 ++++++++- tools/loop_qc.py | 123 ++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 .claude/agents/loop-qc.md create mode 100644 tools/loop_qc.py diff --git a/.claude/agents/loop-qc.md b/.claude/agents/loop-qc.md new file mode 100644 index 0000000..04f4936 --- /dev/null +++ b/.claude/agents/loop-qc.md @@ -0,0 +1,48 @@ +--- +name: loop-qc +description: Quality-control gate for looping dance/animation clips. Given one or more GLB clips (or a glob), it measures whether each loops smoothly — the pose, motion velocity, and root drift at the loop seam — and reports a SMOOTH / NOT SMOOTH verdict per clip with the exact numbers, the worst-offending bones, and a diagnosis of why it pops plus the iClone-side fix. Use before shipping any clip meant to loop, or when a dance visibly jumps/snaps on repeat. +tools: Bash, Read, Glob +--- + +# Loop QC agent + +You verify that animation clips loop smoothly. The game loops clips with Godot +`LoopMode.Linear` — after the last keyframe it wraps straight back to frame 0 — so a +clip loops cleanly only when the **pose**, the **motion velocity**, and the **root +position** are continuous across that seam. Your job is to measure that and report it +plainly, not to guess from the clip name. + +## How to run + +The measurement tool is `tools/loop_qc.py` (Blender headless). For each clip: + +```bash +/Applications/Blender.app/Contents/MacOS/Blender --background --python tools/loop_qc.py -- \ + --src [--name ] +``` + +- Exit code **0 = SMOOTH**, **1 = NOT SMOOTH**, **2 = error**. Capture it (`; echo $?`) — but never pipe the tool through `grep` when you need the exit code, since the pipe reports grep's status, not the tool's. +- Batch: loop over the GLBs given (or discover them with Glob, e.g. `exchange/converted-glb/*.glb` or ariki-game's `assets/quaternius/dancegen/*.glb`). Run them and collect verdicts. +- Blender is at `/Applications/Blender.app/Contents/MacOS/Blender`. + +## Thresholds (defaults; overridable via flags) + +| Metric | Flag | Default limit | What a failure means | +|---|---|---|---| +| pose gap (max per-bone local-rot Δ, end→start) | `--pose-deg` | 5° | Character snaps to a different pose on wrap | +| velocity gap (per-bone angular-vel mismatch across seam) | `--vel-deg` | 3°/frame | Motion direction jerks even if the pose matches | +| root drift (pelvis world-pos end vs start) | `--drift-cm` | 3 cm | Body teleports sideways/vertically on wrap | + +## How to read the result and diagnose + +The three metrics separate the failure modes — say which one(s) tripped: + +- **High root drift, low pose gap** → the performer *travels across the floor*; the clip is fine in place but walks away from the origin. Fix: author/trim so it returns to the start, or remove the horizontal root translation (in-place). Never rely on `*RM`/root-motion variants — the game is in-place. +- **High pose gap, ~0 velocity gap** → the clip starts and ends in *still holds* that are different poses. Fix in iClone: match the end pose to the start pose (copy the first-frame pose onto the last frame), or trim to a full motion cycle. +- **High pose gap AND high velocity gap** → the clip begins from a held pose and ends mid-motion (or vice versa). Fix in iClone: trim to a segment that starts and ends at the same point in the motion cycle, or crossfade/blend the ends (Animation Layer with a keyframed weight ramp, or bracket clean keys around the seam). + +Always name the specific worst bones the tool lists — that tells the animator where to look. + +## Output + +Return, per clip: the verdict, the three metric lines (with pass/fail and numbers), the top offending bones, and a one-line diagnosis + recommended fix keyed to the pattern above. Finish with a summary table of all clips and how many passed. Do not modify any files — you are read-only QC. If asked to fix, hand off to the iClone workflow (`iclone-video-mocap` skill), don't edit the GLB directly. diff --git a/.claude/skills/animation/SKILL.md b/.claude/skills/animation/SKILL.md index cc3e48d..309deff 100644 --- a/.claude/skills/animation/SKILL.md +++ b/.claude/skills/animation/SKILL.md @@ -16,10 +16,11 @@ This skill is the accumulated **do-it-this-way** knowledge from real runs. 2. **Retarget each FBX** with the exact command below → GLB in `exchange/converted-glb/`. 3. **Commit + push the GLBs** in *this* repo (use `tinqs push`, not raw `git push` — it self-heals auth and uploads LFS direct-to-S3). 4. **Copy GLBs into ariki-game** `assets/quaternius/dancegen/`, write a dance JSON per clip. -5. **Import** so Godot generates sidecars: `bash tools/game.sh import` (from ariki-game root). -6. **Verify** clip names, then commit in ariki-game. -7. **Launch the test bed only after asking** (see below). -8. Leave `exchange/` cleanup until Jeremy confirms the batch looks right in-game. +5. **Loop QC gate** — run `loop_qc.py` on each converted GLB (see below). Flag anything that isn't smooth *before* it ships. +6. **Import** so Godot generates sidecars: `bash tools/game.sh import` (from ariki-game root). +7. **Verify** clip names, then commit in ariki-game. +8. **Launch the test bed only after asking** (see below). +9. Leave `exchange/` cleanup until Jeremy confirms the batch looks right in-game. ### The retarget command (canonical) ```bash @@ -50,6 +51,35 @@ Default **bpm 100, loops 1** to match the sibling `hakadance1`. bpm only matters - **Commit the `.glb.import` sidecar with the GLB.** Godot embeds the `uid://` inside the `.import` file — there is **no separate `.uid`** for GLBs. Commit GLB + `.import` + JSON together. - **`game.sh import` shows scary warnings** (tree impostors, meta drift, stale bakes, hair-texture case mismatch, missing `res://src/polynesia/data`) — those are **pre-existing and unrelated**. Only care about `errors`, not `warnings`. +## Loop QC (clips are meant to loop — most raw takes don't) +The game loops clips with Godot `LoopMode.Linear` (wraps last keyframe → frame 0), so a +clip only looks right if the **pose, motion velocity, and root position** are continuous +across that seam. Raw iClone/mocap takes almost never satisfy this — they start from a +held pose and end mid-motion, or the performer walks across the floor. **QC every clip +before shipping** with the `loop-qc` agent, or directly: + +```bash +/Applications/Blender.app/Contents/MacOS/Blender --background --python tools/loop_qc.py -- \ + --src exchange/converted-glb/.glb +# exit 0 = SMOOTH, 1 = NOT SMOOTH, 2 = error. Don't pipe through grep if you need the code. +``` +Three metrics, three failure modes (the tool ranks the worst bones so you know where): +- **root drift high, pose gap low** → performer travels; needs in-place authoring / trim back to origin. +- **pose gap high, velocity gap ~0** → starts & ends in different *still* holds; match end pose to start in iClone. +- **both high** → starts held / ends mid-move; trim to a full cycle or blend the seam (Animation Layer weight ramp, or bracket clean keys — see `iclone-video-mocap`). + +Two Blender-side fixers exist (no iClone round-trip needed): +- `tools/loop_fix.py` — de-drift + trim to best natural loop point + motion-graph seam + blend (crossfade toward the frames before the loop start). Keeps 60–90% of the clip. + Outputs to `exchange/looped-glb/`. See `plans/loop-fix-plan-2026-07-16.md` + results. +- `tools/pingpong_bake.py` — palindrome bake (forward then reversed as ONE clip): + end frame == start frame by construction, drift self-cancels, full choreography kept, + but motion visibly reverses at the turn point. Outputs `exchange/pingpong-glb/*_pp.glb`, + shipped as separate `_pp` packs. Prefer for sway-heavy dances (hula); judge + per-dance for percussive ones (haka) where reverse playback can read as moonwalking. +Both verified by re-running `loop_qc.py` to exit 0. iClone re-authoring remains the +fallback for clips where neither result looks right in the test bed. + ## Launching the game - **Always ask Jeremy before launching** (this session he said "let's launch" — that's the go-ahead; don't assume it next time). - Command: `SCENE=dance_test_bed bash tools/game.sh spawn` from `ariki-game/` root. Also `animation_showcase` for paging every clip. diff --git a/tools/loop_qc.py b/tools/loop_qc.py new file mode 100644 index 0000000..d5ec42e --- /dev/null +++ b/tools/loop_qc.py @@ -0,0 +1,123 @@ +"""loop_qc.py — measure how cleanly a clip loops. + +The game loops dance clips with Godot LoopMode.Linear: after the last keyframe it +wraps straight back to frame 0. A loop is smooth only if, at that seam, both the +POSE and the MOTION are continuous: + + 1. pose gap — per-bone local-rotation difference (deg) between last & first frame. + Large gap => the character visibly snaps to a new pose on wrap. + 2. velocity gap — per-bone angular velocity just BEFORE the seam vs just AFTER it. + Mismatch => motion direction jerks even if the pose matches. + 3. root drift — pelvis world-position difference end vs start. Nonzero => the body + teleports sideways/vertically on wrap (only matters for in-place clips). + +Run headless: + blender --background --python tools/loop_qc.py -- --src [--name ] \ + [--pose-deg 5] [--vel-deg 3] [--drift-cm 3] + +Exit code 0 = SMOOTH (within thresholds), 1 = NOT SMOOTH, 2 = error. So it can gate a +pipeline step. Prints a ranked list of the worst-offending bones to say WHERE the pop is. +""" +import bpy, sys, math, argparse +from mathutils import Quaternion + +def parse(): + argv = sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--src", required=True) + p.add_argument("--name", default=None, help="clip/action name; default = first action") + p.add_argument("--pose-deg", type=float, default=5.0, help="max per-bone pose gap (deg)") + p.add_argument("--vel-deg", type=float, default=3.0, help="max per-bone velocity gap (deg/frame)") + p.add_argument("--drift-cm", type=float, default=3.0, help="max pelvis drift (cm)") + p.add_argument("--top", type=int, default=8, help="how many worst bones to list") + return p.parse_args(argv) + +def load(src, name): + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.gltf(filepath=src) + arm = next(o for o in bpy.data.objects if o.type == 'ARMATURE') + if name: + act = bpy.data.actions.get(name) + if act is None: + acts = [a.name for a in bpy.data.actions] + raise SystemExit(f"[loop_qc] no action '{name}'; have {acts}") + arm.animation_data.action = act + else: + act = arm.animation_data.action + return arm, act + +def local_quats(arm, frame): + bpy.context.scene.frame_set(frame) + out = {} + for pb in arm.pose.bones: + q = pb.matrix_basis.to_quaternion().normalized() + out[pb.name] = q + return out + +def ang(q1, q2): + d = abs(max(-1.0, min(1.0, q1.dot(q2)))) + return math.degrees(2.0 * math.acos(d)) + +def main(): + a = parse() + arm, act = load(a.src, a.name) + f0, f1 = int(act.frame_range[0]), int(act.frame_range[1]) + scene = bpy.context.scene + + q_first = local_quats(arm, f0) + q_last = local_quats(arm, f1) + q_first1 = local_quats(arm, f0 + 1) # for velocity just after the seam + q_last1 = local_quats(arm, f1 - 1) # for velocity just before the seam + + bones = [b for b in q_first if b in q_last] + + # 1. pose gap + pose = sorted(((ang(q_last[b], q_first[b]), b) for b in bones), reverse=True) + pose_max, pose_bone = pose[0] + pose_mean = sum(v for v, _ in pose) / len(pose) + + # 2. velocity gap: v_before = last-1 -> last ; v_after = first -> first+1 + vel = [] + for b in bones: + v_before = ang(q_last1[b], q_last[b]) + v_after = ang(q_first[b], q_first1[b]) + vel.append((abs(v_before - v_after), b, v_before, v_after)) + vel.sort(reverse=True) + vel_max, vel_bone = vel[0][0], vel[0][1] + vel_mean = sum(v for v, *_ in vel) / len(vel) + + # 3. root drift (pelvis world pos) + root = 'pelvis' if 'pelvis' in arm.pose.bones else arm.pose.bones[0].name + scene.frame_set(f0); p0 = (arm.matrix_world @ arm.pose.bones[root].head).copy() + scene.frame_set(f1); p1 = (arm.matrix_world @ arm.pose.bones[root].head).copy() + drift_cm = (p1 - p0).length * 100.0 + + ok_pose = pose_max <= a.pose_deg + ok_vel = vel_max <= a.vel_deg + ok_drift = drift_cm <= a.drift_cm + smooth = ok_pose and ok_vel and ok_drift + + print("=== LOOP QC ===") + print(f"clip={act.name} frames={f0}..{f1}") + print(f"[{'PASS' if ok_pose else 'FAIL'}] pose gap : max {pose_max:6.2f}° (limit {a.pose_deg}) mean {pose_mean:.2f}° worst={pose_bone}") + print(f"[{'PASS' if ok_vel else 'FAIL'}] velocity gap: max {vel_max:6.2f}°/f (limit {a.vel_deg}) mean {vel_mean:.2f} worst={vel_bone}") + print(f"[{'PASS' if ok_drift else 'FAIL'}] root drift : {drift_cm:6.2f} cm (limit {a.drift_cm}) bone={root}") + print(f"\nVERDICT: {'SMOOTH ✓' if smooth else 'NOT SMOOTH ✗ — will pop on loop'}") + + print(f"\nworst {a.top} bones by pose gap (deg end→start):") + for v, b in pose[:a.top]: + print(f" {v:7.2f} {b}") + print(f"\nworst {a.top} bones by velocity mismatch (deg/frame across seam):") + for v, b, vb, va in vel[:a.top]: + print(f" {v:7.2f} {b} (before {vb:.2f} / after {va:.2f})") + + sys.exit(0 if smooth else 1) + +if __name__ == "__main__": + try: + main() + except SystemExit: + raise + except Exception as e: + print(f"[loop_qc] ERROR: {e}") + sys.exit(2)