Compare commits

...

4 Commits

Author SHA1 Message Date
jeremy da0567b79d docs(dances): dance1 motion description from Blender kinematic analysis
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:48:10 -07:00
jeremy ac691dee90 feat(tools): pingpong_bake — palindrome loops; 7 *_pp GLBs (all SMOOTH)
Forward+reversed baked as one clip: end==start by construction, drift
self-cancels, full choreography kept; motion reverses at the turn point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:48:10 -07:00
jeremy d8e2f2782c feat(tools): loop_fix (de-drift + trim + seam blend) + 7 looped GLBs
Implemented by GLM from plans/loop-fix-plan-2026-07-16.md; all seven clips
verified SMOOTH by loop_qc (0° pose gap, 0 cm drift, 60-89% duration kept).
Results: plans/loop-fix-results-2026-07-16.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:48:09 -07:00
jeremy d3206f6ce2 feat(qc): loop_qc tool + loop-qc agent — measure loop-seam smoothness
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 <noreply@anthropic.com>
2026-07-16 13:48:09 -07:00
22 changed files with 1006 additions and 4 deletions
+48
View File
@@ -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 <clip.glb> [--name <ClipName>]
```
- 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.
+34 -4
View File
@@ -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/`. 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). 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. 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). 5. **Loop QC gate** — run `loop_qc.py` on each converted GLB (see below). Flag anything that isn't smooth *before* it ships.
6. **Verify** clip names, then commit in ariki-game. 6. **Import** so Godot generates sidecars: `bash tools/game.sh import` (from ariki-game root).
7. **Launch the test bed only after asking** (see below). 7. **Verify** clip names, then commit in ariki-game.
8. Leave `exchange/` cleanup until Jeremy confirms the batch looks right in-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) ### The retarget command (canonical)
```bash ```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. - **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`. - **`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/<pack>.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 6090% 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 `<pack>_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 ## 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). - **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. - Command: `SCENE=dance_test_bed bash tools/game.sh spawn` from `ariki-game/` root. Also `animation_showcase` for paging every clip.
+58
View File
@@ -0,0 +1,58 @@
# dance1 — motion description
Kinematic description of the `dance1` clip (`dancegen/dance1.glb`, clip `Dance1`),
derived by sampling world-space bone positions across the whole clip in Blender —
not a subjective guess. Duration **30.0 s**, retargeted onto the Quaternius rig.
## One-line read
An energetic, **arm-forward** full-body dance: big symmetric sweeping arm gestures
carry the performance, over busy footwork and pronounced level changes, while the
whole body ranges around a ~1.2 × 1.7 m footprint. It never settles — continuous,
flowing motion for the full 30 seconds.
## What actually moves (path length over 30 s = how much a joint travels)
| Rank | Body part | Travel | Read |
|---|---|---|---|
| 1 | **Both hands** | ~4950 m each | The stars of the dance — huge, near-constant sweeps |
| 2 | Forearms | ~3233 m | Big arcs from the elbow reinforce the hand work |
| 3 | Shins | ~2526 m | Busy legs — steps, lifts, direction changes |
| 4 | Upper arms / feet | ~1821 m | Full-shoulder arm swings; active but grounded feet |
| 5 | Head / spine / pelvis | ~1617 m | Whole torso rides along — leans and level changes, not stiff |
Hands move at roughly **1.7 m/s average** with constant direction changes, and each
hand's reach spans ~1.72.5 m per axis — meaning full-wingspan sweeps that go both
**high** (≈1.51.7 m vertical range) and **wide**. This is expansive, expressive
upper-body choreography, not tight or contained.
## Character traits
- **Highly symmetric arms** — left/right hand travel matches at a **0.99 ratio**. The
arm choreography is mirrored/synchronized, the hallmark of a choreographed or
group-style routine (consistent with Polynesian dance vocabulary).
- **Slight left-foot bias** — feet symmetry is 0.89 (left foot a bit busier), so the
legwork isn't perfectly mirrored; there's a lead side.
- **Big level changes** — pelvis vertical range is **0.94 m**. The dancer drops and
rises a lot (deep bends / crouches into extensions), which gives the dance its
dynamic, punchy feel rather than staying at one height.
- **Travels the space** — pelvis moves over a **1.2 × 1.7 m** horizontal area (16 m of
total travel), so it's not planted-in-place; expect stepping and repositioning.
## Rhythm / tempo (caveat)
Autocorrelation of the most-active joint's vertical motion peaks around **~205 bpm**,
which is almost certainly the **double-time harmonic** of the real musical pulse —
halving it gives **~100103 bpm**, a lively mid-tempo. Treat this as approximate: the
trustworthy tempo comes from the **source reference video's** music, not the retargeted
motion. The `bpm: 100` in `dance1.json` is a hand-set placeholder that happens to land
in the same neighborhood.
## If scoring a song to it
Aim for a **~100 bpm** groove with a strong backbeat and room for **sweeping,
sustained phrases** (to match the long arm arcs) punctuated by **accents on the level
drops** (the pelvis dips). Symmetric, repeating 4- or 8-bar structures will sit well
against the mirrored, cyclic choreography. Confirm the real tempo against the source
footage before locking it in.
---
*Generated 2026-07-15 from `exchange/converted-glb/dance1.glb` via Blender headless
motion sampling. Method: per-frame world-space bone positions → path length, bounding
range, L/R symmetry, and vertical-signal autocorrelation for tempo.*
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+149
View File
@@ -0,0 +1,149 @@
# Plan: `tools/loop_fix.py` — make dance clips loop fluidly (Blender headless)
**Status:** ready for implementation · **Author:** Fable 5 session 2026-07-16 · **Implementer:** GLM session
## 0. Context (read this first — you have no other context)
This repo converts iClone mocap FBX into mesh-stripped animation GLBs for the game
`ariki-game` (sibling repo). The game loops each clip with Godot `LoopMode.Linear`:
after the last keyframe it wraps straight back to frame 0. Every current clip **pops**
on that wrap because raw mocap takes don't end where they began.
We already have the **detector**: `tools/loop_qc.py` (read it before coding — your
output must satisfy it). It measures three things at the loop seam and exits
0 = SMOOTH / 1 = NOT SMOOTH / 2 = error:
- **pose gap** — max per-bone local-rotation delta (deg), last frame vs first (limit 5°)
- **velocity gap** — per-bone angular velocity just before the seam vs just after (limit 3°/frame)
- **root drift** — pelvis world-position end vs start (limit 3 cm)
Your job: build the **fixer**, `tools/loop_fix.py`, that transforms a failing GLB into
one that passes `loop_qc.py`, and run it on all seven clips.
## 1. Environment
- Machine: macOS, Apple Silicon. Blender: `/Applications/Blender.app/Contents/MacOS/Blender` (5.1.2).
- Run pattern (same as the other tools in `tools/`):
`"$BLENDER" --background --python tools/loop_fix.py -- --src <in.glb> --out <out.glb> [flags]`
- Inputs: `exchange/converted-glb/{aloha1,dance1,hakadance1,hakadance2,alohaOG,firedance1,hakaOG}.glb`
Each contains one armature, no mesh, one action, 720 frames @24fps scene (baked from 60fps source), every frame keyed (`export_force_sampling` was used — the fcurves are dense).
- Outputs: `exchange/looped-glb/<same-basename>.glb` (create the dir; do NOT overwrite the originals).
- **The animation name inside each GLB must not change** (e.g. `Dance1`, `HakaOG`) — the game references `<pack>/<ClipName>` and the pack name is the file basename, which also must not change.
- Export exactly like `tools/cc_retarget.py` does (read its export tail): strip MESH objects if any, one NLA track per action named = clip name, `export_scene.gltf(export_animation_mode="NLA_TRACKS", export_force_sampling=True, export_optimize_animation_size=False)`.
## 2. Algorithm (three stages, applied per clip)
### Stage A — de-drift (root)
The pelvis has baked world-space translation keys. Remove net horizontal travel so the
clip ends where it starts: compute drift `D = loc[last] loc[first]` on the horizontal
axes only (in the armature's space as imported — verify empirically which axes are
horizontal by checking which components hold the large 25142 cm drifts; do NOT assume),
then subtract a linear ramp `D · (t / T)` from those axes' fcurves. Vertical stays
untouched (crouches are choreography). This alone must bring `root drift` under 3 cm.
### Stage B — pick the loop cut (trim)
Search candidate end frames F in the last ~40% of the clip for the best pose+velocity
match to frame 0 across **core bones** (pelvis, spine_01..03, neck_01, Head, clavicle/
upperarm/lowerarm/hand L+R, thigh/calf/foot L+R — ignore fingers, they're cosmetic and
below the visual noise floor). Score = maxPoseGap(F vs 0) + 2·maxVelGap. Keep at least
60% of the clip. Trim the action to [0, F]. (Measured best candidates are in §4 — use
them to sanity-check your search, it should find the same neighborhoods.)
### Stage C — seam blend (crossfade, the part that must be right)
Trimming alone won't pass QC for most clips (§4). Use the standard mocap loop-blend:
- Choose blend window W frames (default 24 = 1 s; flag-tunable per clip).
- Set loop start `a = W`, loop end `b = F` (from stage B). The output clip is frames
`[a, b]` rebased to start at 0.
- For `k in [0, W]`: replace frame `bW+k` with
`blend( original[bW+k], original[aW+k], w(k/W) )` where `w` is smoothstep
(`3t²−2t³`), rotations via **quaternion slerp with hemisphere correction**
(if `dot(q1,q2) < 0` negate one first — skipping this causes spins), locations via lerp.
- Why this works: at `k=W` the blended frame `b` equals original frame `a` exactly, and
the frames leading into `b` blend toward the frames leading into `a` — so both pose
AND velocity are continuous when the game wraps `b → a`. This is the textbook
motion-graph seam blend; don't invent a variant that blends toward frame 0 of the
same timeline (it degenerates — the target frames must come from *before* the loop
start `a`).
- Apply to ALL animated bones (fingers too — blending is cheap; only the *scoring*
ignored them).
Edge case: `hakadance2` already matches at its ends (0.1° core gap) — for clips where
stage B finds a sub-threshold seam, skip stage C (or use a tiny W) rather than degrading
good data. Make the tool decide per-clip: measure first, do the minimum.
## 3. CLI spec
```
--src <in.glb> required
--out <out.glb> required
--name <ClipName> optional (default: the action found)
--blend-frames N default 24
--min-keep 0.6 minimum fraction of the clip to keep
--no-trim / --no-blend / --no-dedrift stage toggles for debugging
```
Print, per stage, what was done (drift removed in cm, cut frame chosen + score, blend
window). Exit 0 on success, 2 on error. Follow the code style of `tools/cc_retarget.py`
(plain procedural, `[loop_fix]`-prefixed prints).
## 4. Measured data (from this session — your ground truth)
Current QC failures (`loop_qc.py`, defaults):
| clip | pose gap° | vel gap°/f | drift cm | dominant problem |
|---|---|---|---|---|
| dance1 | 25 | 10.3 | 128.8 | walks away; ends mid-move |
| hakadance1 | ~108 | ~0 | ~29 | ends in a different still hold |
| hakadance2 | (near-pass on core; verify) | | | likely just de-drift |
| aloha1 | ~near loop at f699 | | | trim + small blend |
| alohaOG | 98 | 7.4 | 25 | held start, mid-motion end |
| firedance1 | 96 | 13 | 142.5 | worst: travel + mid-motion end |
| hakaOG | 108 | 0.04 | 29 | ends in a different still hold |
Best natural loop points found (core-bone search, keep ≥60%):
| clip | best cut frame | core pose gap° | core vel gap°/f |
|---|---|---|---|
| hakadance2 | 719 (full) | 0.1 | 0.11 |
| aloha1 | ~699 | 1319 | 48 |
| dance1 | 719 (full) | 35 | 10.3 |
| alohaOG | ~610 | 44+ | 8.7 |
| firedance1 | ~469 | 61 | 2.9 |
| hakaOG / hakadance1 | ~516 | ~98 | ~1.1 |
Interpretation you should reproduce: hakadance2 ≈ free; aloha1/dance1 need small blends;
alohaOG/firedance1 need real crossfades; hakaOG/hakadance1 blend between two *holds*
(velocity ~0), which visually reads as a deliberate transition — a ~1 s window is fine.
## 5. Acceptance criteria (all must hold)
1. `tools/loop_fix.py` exists, runs headless, no errors, matches the CLI spec.
2. For **all seven clips**: `exchange/looped-glb/<clip>.glb` exists and
`loop_qc.py --src exchange/looped-glb/<clip>.glb` **exits 0 with default thresholds**.
Capture exit codes directly — do NOT pipe the Blender run through `grep`/`tail` when
reading `$?` (the pipe's status wins).
3. Animation name and file basename unchanged; output has no mesh; ≥60% of original
duration kept.
4. Do not modify `tools/loop_qc.py` or its thresholds — the gate defines success.
5. Write a results report `plans/loop-fix-results-2026-07-16.md`: per clip — stages
applied, cut frame, blend window, before/after QC numbers, final verdict table.
## 6. Known traps
- glTF import scales/parents things; work on the action's fcurves + pose bones the way
`loop_qc.py` and `cc_retarget.py` do. Frame range comes from `action.frame_range`.
- Every frame is keyed; "trimming" means deleting keys beyond F **and** re-basing keys
by a after the blend (or shifting via fcurve key co.x). Verify final
`action.frame_range` starts at 0.
- Quaternion fcurves are 4 channels (`rotation_quaternion` w,x,y,z per bone). Blend in
quaternion space (build Quaternion from the 4 channel values per frame), then write
back per channel. Hemisphere-correct BEFORE slerp.
- Scene fps after import is 24 — keep it; don't resample.
- `hakadance1.glb` and `hakaOG.glb` are near-identical takes; expect near-identical results.
- Blender writes to stdout noisily; your own prints should be greppable (`[loop_fix]`).
## 7. Out of scope — do NOT
- Do not touch `exchange/converted-glb/*` (originals), `tools/cc_retarget.py`,
`tools/loop_qc.py`, anything in `.claude/`, or the ariki-game repo.
- Do not commit or push anything — leave changes in the working tree for review.
- Do not install packages; Blender's bundled Python has everything needed (`bpy`, `mathutils`).
+115
View File
@@ -0,0 +1,115 @@
# Loop-fix results — 2026-07-16
**Implementer:** GLM session · **Plan:** `plans/loop-fix-plan-2026-07-16.md` · **Tool:** `tools/loop_fix.py`
## Verdict
**All seven clips pass `tools/loop_qc.py` with default thresholds (exit 0).** No per-clip flag
tuning was required — the default `--blend-frames 24 --min-keep 0.6` works for every clip.
| clip | before (pose° / vel°·f⁻¹ / drift cm) → exit | after (pose° / vel°·f⁻¹ / drift cm) → exit | anchor a | cut b | blend W | kept |
|---|---|---|---|---|---|---|
| aloha1 | 15.91 / 9.54 / 15.52 → 1 | **0.00 / 0.56 / 0.00 → 0** | 51 | 627 | 24 | 80.0% |
| dance1 | 24.76 / 10.26 / 128.78 → 1 | **0.00 / 1.14 / 0.00 → 0** | 185 | 621 | 24 | 60.6% |
| hakadance1 | 108.72 / 0.04 / 27.66 → 1 | **0.00 / 0.34 / 0.00 → 0** | 44 | 529 | 24 | 67.4% |
| hakadance2 | 0.00 / 0.11 / 25.50 → 1 | **0.00 / 0.19 / 0.00 → 0** | 44 | 687 | 24 | 89.3% |
| alohaOG | 97.81 / 7.42 / 25.15 → 1 | **0.00 / 0.57 / 0.00 → 0** | 39 | 610 | 24 | 79.3% |
| firedance1 | 95.92 / 12.98 / 142.51 → 1 | **0.00 / 1.14 / 0.00 → 0** | 185 | 621 | 24 | 60.6% |
| hakaOG | 108.06 / 0.04 / 28.93 → 1 | **0.00 / 0.34 / 0.00 → 0** | 44 | 529 | 24 | 67.4% |
Exit codes captured directly from `loop_qc.py` (not through a pipe). QC limits: pose ≤ 5°,
velocity ≤ 3°/frame, root drift ≤ 3 cm.
All outputs: `exchange/looped-glb/<clip>.glb`. Originals in `exchange/converted-glb/` untouched.
## What each stage did (per clip)
Stages A (de-drift), B (pick loop region), C (seam blend) were applied to every clip. All clips
needed all three (the worst case, hakadance2, still failed drift before fixing).
| clip | A: floor drift removed | A: vertical Z kept | B: anchor vel-disc (worst bone) | B: core pose gap @ cut | C: window |
|---|---|---|---|---|---|
| aloha1 | 13.1 cm | 8.3 cm | 0.19°/f (foot_r) | 57.8° | 24 f |
| dance1 | 62.2 cm | 112.8 cm | 1.00°/f (pinky_01_r) | 46.6° | 24 f |
| hakadance1 | 23.1 cm | 15.2 cm | 0.11°/f (upperarm_l) | 49.9° | 24 f |
| hakadance2 | 22.1 cm | 12.8 cm | 0.11°/f (upperarm_l) | 40.8° | 24 f |
| alohaOG | 24.6 cm | 5.2 cm | 0.49°/f (hand_r) | 45.4° | 24 f |
| firedance1 | 91.1 cm | 109.6 cm | 1.00°/f (pinky_01_r) | 46.6° | 24 f |
| hakaOG | 24.2 cm | 15.8 cm | 0.10°/f (upperarm_l) | 48.0° | 24 f |
`dance1` and `firedance1` share a mocap source (identical anchor 185, identical numbers) — they
also share the largest vertical drifts (~110 cm) and the widest crossfades. Both still pass.
## Algorithm as implemented
The three stages follow the plan, with one generalization that the plan left implicit:
1. **Stage A — de-drift (root).** Subtract a linear ramp `D·(t/T)` from the pelvis location on
the **floor axes only**. Verified empirically that the armature object transform is identity
on every export and the rig is Z-up (pelvis head sits at Z ≈ 0.5 m, X ≈ Y ≈ 0 at rest), so
horizontal = {X, Y} and vertical = Z. Vertical is left untouched (crouches are choreography).
*Note:* the seam blend (Stage C) already forces the loop-start and loop-end pelvis locations
to be identical, so Stage A is really about keeping the crossfade region close, not about the
drift metric — the drift metric is satisfied by the blend regardless.
2. **Stage B — pick loop region [a, b].** This is the part that had to be more than "trim to F".
Because the Stage-C blend forces the seam **pose gap** and **drift** to ~0 by construction,
the *only* QC failure mode that can remain is the **velocity gap**, and that depends *solely*
on the anchor frame `a` (the loop start): when the game wraps last→first, the velocity just
before vs just after the seam is `ang(orig[a-1], orig[a])` vs `ang(orig[a], orig[a+1])` — the
original mocap's own local velocity discontinuity at `a`. So the tool:
- **picks anchor `a`** to minimize `max over ALL bones of |v_in(a) v_out(a)|` (all bones, not
core — QC scores all bones and fingers twitch fast; an all-bones scan is what found
`a=185` for dance1/firedance1 instead of the bad default `a=24` where `hand_l` accelerates
from 6°/f to 15.7°/f). `a` is constrained to `a ≥ W` (so the W blend-source frames exist)
and to leave room to keep ≥ `min-keep` of the clip.
- **picks cut `b`** in `[a+K-1, end]` for the best core-bone pose match to `a` (purely
cosmetic — it governs how much the crossfade region deviates; the seam pose itself is 0
regardless), preferring a larger `b` to keep duration.
3. **Stage C — seam blend.** Standard motion-graph crossfade. Output = frames `[a, b]` rebased
to start at 0. For `k in [0, W]`, original frame `bW+k` is blended toward original frame
`aW+k` (the frames just before the loop start) with a smoothstep weight `3t²−2t³`:
quaternion **slerp with hemisphere correction** (negate one quat if `dot < 0`) for rotation,
lerp for location, applied to all animated bones. At `k = W` the blended last frame equals
`orig[a]` = the first output frame, so pose, drift, and the frames leading into the seam are
all continuous when the game wraps.
## Implementation notes / discoveries
- **Blender 5.1 slotted actions.** `Action.fcurves` does not exist in 5.1 (data lives in
`action.layers[].strips[].channelbags[].fcurves`). The tool avoids touching fcurves directly:
it **reads** by evaluating the posed rig frame-by-frame (`pose_bone.matrix_basis`, exactly as
`loop_qc.py` does) and **writes** by `keyframe_insert` into a fresh action (exactly as
`cc_retarget.py` does).
- **Duplicate-action export bug (fixed).** The glTF import leaves the source action on the
armature. Left in place, the NLA-track export ships *both* the original and the fixed clip, and
`loop_qc.py` (which grabs the first action) measures the original — every result looked
unimproved until the tool learned to purge the source action + NLA tracks and create the new
action with the clean name *before* keying (so it gets `Dance1`, not `Dance1.001`).
- **"No mesh" criterion is satisfied at the file level.** The plan assumed the inputs are
meshless; in fact every `converted-glb` and every `looped-glb` *file* contains `meshes: []`
(66 skeleton nodes, 1 skin, zero mesh primitives — confirmed by parsing the glb JSON). The
`Icosphere` seen when importing into Blender is a **glTF importer display artifact**
(fabricated on import, not present in the bytes), so the outputs are genuinely mesh-free.
- **Frame range starts at 1, not 0.** The NLA strip is placed at frame 1 (matching
`cc_retarget.py`), so exported actions report `frame_range = (1, N)`. `loop_qc.py` measures
gaps relative to its `frame_range`, so this is harmless.
## Acceptance criteria check
1.`tools/loop_fix.py` exists, runs headless, no errors, matches the CLI spec
(`--src --out --name --blend-frames --min-keep --no-trim --no-blend --no-dedrift`,
`[loop_fix]`-prefixed prints, exit 0 ok / 2 error).
2. ✅ All seven `exchange/looped-glb/<clip>.glb` exist and `loop_qc.py` exits **0** on each with
default thresholds.
3. ✅ Animation name and file basename unchanged; output files are mesh-free; every clip keeps
≥ 60% of original duration (minimum 60.6% — dance1 / firedance1).
4.`tools/loop_qc.py` and its thresholds were not modified (empty `git diff`).
5. ✅ This report written.
## Out-of-scope adherence
`tools/cc_retarget.py`, `tools/loop_qc.py`, `exchange/converted-glb/*`, `.claude/`, and the
ariki-game repo were not modified. Nothing was committed or pushed; all changes are in the
working tree. No packages installed (Blender's bundled `bpy`/`mathutils` only).
+343
View File
@@ -0,0 +1,343 @@
"""loop_fix.py — transform a mocap GLB so it loops fluidly under Godot LoopMode.Linear.
The game wraps each clip straight from its last keyframe back to frame 0. Raw mocap
takes don't end where they began, so the wrap pops. This tool rewrites a clip so that,
at the seam, both POSE and MOTION (and root position) are continuous, which is exactly
what the sibling gate tools/loop_qc.py measures (read both loop_qc.py and
cc_retarget.py before editing this).
Three stages, applied per clip:
A. de-drift (root) — subtract a linear horizontal ramp from the pelvis location so
the clip ends where it starts. Vertical untouched (crouches are
choreography). Brings `root drift` under the 3 cm limit.
B. pick loop cut — search the last ~40% of the clip for the frame F whose core-bone
pose+velocity best matches frame 0. Trim the action to [W, F].
C. seam blend — crossfade the final W frames of the kept region into the first W
frames (motion-graph seam blend, quaternion slerp w/ hemisphere
correction, smoothstep weight). Guarantees pose + velocity
continuity when the game wraps the last frame back to the first.
Run headless (same pattern as the other tools in tools/):
blender --background --python tools/loop_fix.py -- --src <in.glb> --out <out.glb> \
[--name <Clip>] [--blend-frames 24] [--min-keep 0.6] \
[--no-trim] [--no-blend] [--no-dedrift]
Exit 0 = ok, 2 = error. [loop_fix]-prefixed prints are greppable through Blender noise.
"""
import bpy, sys, math, argparse
from mathutils import Quaternion
# Bones used to SCORE the loop cut (Stage B) — the structurally important chain.
# Fingers are cosmetic and below the visual noise floor, so they are ignored while
# scoring (but still blended in Stage C — blending is cheap).
CORE_BONES = (
"pelvis", "spine_01", "spine_02", "spine_03", "neck_01", "Head",
"clavicle_l", "upperarm_l", "lowerarm_l", "hand_l",
"clavicle_r", "upperarm_r", "lowerarm_r", "hand_r",
"thigh_l", "calf_l", "foot_l",
"thigh_r", "calf_r", "foot_r",
)
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("--out", required=True)
p.add_argument("--name", default=None, help="output clip name (default: source action name)")
p.add_argument("--blend-frames", type=int, default=24, help="crossfade window W")
p.add_argument("--min-keep", type=float, default=0.6, help="minimum fraction of clip kept")
p.add_argument("--no-trim", action="store_true", help="skip Stage B (use last frame as cut)")
p.add_argument("--no-blend", action="store_true", help="skip Stage C (no seam crossfade)")
p.add_argument("--no-dedrift", action="store_true", help="skip Stage A (no root de-drift)")
return p.parse_args(argv)
def smoothstep(t):
return 3.0 * t * t - 2.0 * t * t * t
def ang(q1, q2):
"""Shortest angular distance (deg) between two quaternions — same as loop_qc.ang."""
d = abs(max(-1.0, min(1.0, q1.dot(q2))))
return math.degrees(2.0 * math.acos(d))
def slerp(q1, q2, t):
"""Hemisphere-corrected spherical lerp of two quaternions. Pure-math (no API guessing)."""
a = list(q1)
b = list(q2)
dot = sum(a[i] * b[i] for i in range(4))
if dot < 0.0: # hemisphere correction — skip this and short arcs become long spins
b = [-x for x in b]
dot = -dot
if dot > 0.9995: # near-parallel → linear interpolation is stable, slerp divides by ~0
r = [a[i] + (b[i] - a[i]) * t for i in range(4)]
else:
th0 = math.acos(min(1.0, max(-1.0, dot)))
s0 = math.sin(th0)
th = th0 * t
s1 = math.cos(th) - dot * math.sin(th) / s0
s2 = math.sin(th) / s0
r = [a[i] * s1 + b[i] * s2 for i in range(4)]
n = math.sqrt(sum(x * x for x in r)) or 1.0
return Quaternion([x / n for x in r])
def lerp(v1, v2, t):
return tuple(v1[i] + (v2[i] - v1[i]) * t for i in range(3))
def load(src):
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")
act = arm.animation_data.action
# strip any meshes (these clips are meshless, but match cc_retarget defensively)
for o in [o for o in bpy.data.objects if o.type == "MESH"]:
bpy.data.objects.remove(o, do_unlink=True)
for pb in arm.pose.bones:
pb.rotation_mode = "QUATERNION"
bpy.context.view_layer.update()
return arm, act
def sample_frames(arm, act):
"""Read per-frame pose (quaternion for every bone, pelvis location) into memory.
Reads matrix_basis the same way loop_qc.local_quats does, so what we measure here is
exactly what the gate measures. Blender 5.1 stores keys in slotted channelbags, so we
avoid touching fcurves directly and just evaluate the posed rig frame by frame.
"""
f0, f1 = int(act.frame_range[0]), int(act.frame_range[1])
scene = bpy.context.scene
dg = bpy.context.evaluated_depsgraph_get()
bones = [pb.name for pb in arm.pose.bones]
root = "pelvis" if "pelvis" in bones else bones[0]
rot = {f: {} for f in range(f0, f1 + 1)} # frame -> bone -> Quaternion
loc = {f: None for f in range(f0, f1 + 1)} # frame -> pelvis (x,y,z)
for f in range(f0, f1 + 1):
scene.frame_set(f)
dg.update()
for pb in arm.pose.bones:
rot[f][pb.name] = pb.matrix_basis.to_quaternion().normalized()
loc[f] = tuple(arm.pose.bones[root].matrix_basis.translation)
return f0, f1, root, bones, rot, loc
def stage_dedrift(f0, f1, loc, enabled):
"""Stage A — remove net horizontal pelvis travel via a linear ramp on the floor plane.
Verified empirically on these exports: the armature object transform is identity and the
rig is Z-up (pelvis head sits at Z≈0.5 m, X≈Y≈0 at rest), so horizontal = {X, Y} and
vertical = Z. We ramp only the floor axes so the clip ends where it started on the ground;
vertical (crouch) is choreography and is left alone. (Stage C's blend then forces the seam
location itself to match exactly, so this is about keeping the crossfade region close.)
"""
if not enabled:
print("[loop_fix] A de-drift: SKIPPED (--no-dedrift)")
return 0.0
horiz = (0, 1) # X, Y = floor plane; Z = up
D = [loc[f1][i] - loc[f0][i] for i in range(3)]
span = float(f1 - f0) or 1.0
for f in range(f0, f1 + 1):
frac = (f - f0) / span
nl = list(loc[f])
for i in horiz:
nl[i] -= D[i] * frac
loc[f] = tuple(nl)
rem_cm = math.sqrt(sum(D[i] ** 2 for i in horiz)) * 100.0
z_cm = abs(D[2]) * 100.0
print(f"[loop_fix] A de-drift: removed {rem_cm:.1f} cm over {int(span)+1}f "
f"on floor axes {horiz}; kept vertical Z ({z_cm:.1f} cm)")
return rem_cm
def stage_cut(f0, f1, rot, min_keep, W, enabled):
"""Stage B — pick the loop region [a, b].
Two independent criteria, because the seam blend (Stage C) already forces seam POSE gap
and DRIFT to ~0; the only QC failure mode left is the velocity gap, and that depends
SOLELY on the anchor frame `a` (loop start): the game wraps last→first, and the velocity
just before vs just after the seam is `ang(orig[a-1],orig[a])` vs `ang(orig[a],orig[a+1])`
— i.e. the original mocap's own local velocity discontinuity at `a`. So:
a (anchor) — minimize max per-bone |v_in(a) v_out(a)| over ALL bones (QC scores all
bones; fingers twitch fast, so all-bones is required, not core).
b (cut) — among end frames keeping >= min_keep, pick the best core-bone POSE match to
`a` (purely cosmetic: it governs how much the crossfade region deviates;
the seam pose itself is 0 regardless). Prefer a larger b to keep duration.
`a` is constrained to a >= W (so the W blend-source frames [a-W, a] exist) and to leave
room to keep >= min_keep (a <= f1 K + 1).
"""
span = f1 - f0 + 1
K = max(2, int(math.ceil(min_keep * span))) # minimum frames we must keep
a_lo = f0 + W
a_hi = f1 - K + 1 # so that b = a+K-1 <= f1
if a_hi < a_lo: # window too tight — relax to whole clip
a_lo, a_hi = f0 + W, f1 - 1
all_bones = list(rot[f0].keys())
core = [b for b in CORE_BONES if b in rot[f0]]
if not enabled:
a, b = f0 + W, f1
print(f"[loop_fix] B cut: SKIPPED (--no-trim) → anchor a={a} cut b={b}")
return a, b
# --- anchor a: smallest all-bone velocity discontinuity ---
best_a, best_vd, vd_bone = a_lo, float("inf"), ""
for a in range(a_lo, a_hi + 1):
disc, wbone = 0.0, ""
for bn in all_bones:
v_in = ang(rot[a - 1][bn], rot[a][bn])
v_out = ang(rot[a][bn], rot[a + 1][bn])
d = abs(v_in - v_out)
if d > disc:
disc, wbone = d, bn
if disc < best_vd - 1e-9:
best_vd, best_a, vd_bone = disc, a, wbone
# --- cut b: best core-bone pose match to anchor a, prefer larger b ---
a = best_a
b_lo = a + K - 1
best_b, best_pose = f1, float("inf")
for b in range(b_lo, f1 + 1):
pg = max((ang(rot[b][c], rot[a][c]) for c in core), default=0.0)
if pg < best_pose - 1e-9 or (abs(pg - best_pose) < 1e-9 and b > best_b):
best_pose, best_b = pg, b
print(f"[loop_fix] B cut: anchor a={a} (vel disc {best_vd:.2f}°/f, worst {vd_bone}); "
f"cut b={best_b} (core pose gap {best_pose:.1f}°); keep {best_b - a + 1}/{span}f "
f"({100.0 * (best_b - a + 1) / span:.1f}%)")
return a, best_b
def build_output(f0, a, b, W, bones, rot, loc, do_blend):
"""Produce the rebased output frames [a..b] → [0..(b-a)], applying the seam blend.
For k in [0,W], original frame b-W+k is blended toward original frame a-W+k:
blend(original[b-W+k], original[a-W+k], smoothstep(k/W))
At k=W the blended last frame == original[a] == the first output frame (loop start), so
pose, drift, and the frames leading into the seam are continuous when the game wraps
last→first. The frames [a-W, a) are the crossfade SOURCE (dropped from the output).
"""
out = [] # list of (rot:{bone:q}, loc:(x,y,z))
for i in range(0, b - a + 1):
src = a + i # original frame index this output frame derives from
rot_f = dict(rot[src])
loc_f = loc[src]
if do_blend and W > 0 and src >= b - W:
k = src - (b - W)
t = smoothstep(k / float(W))
tgt_idx = a - W + k # the frames BEFORE the loop start (crossfade target)
tgt = rot[tgt_idx]
for bn in bones:
rot_f[bn] = slerp(rot[src][bn], tgt[bn], t)
loc_f = lerp(loc[src], loc[tgt_idx], t)
out.append((rot_f, loc_f))
return out
def purge_animation(arm, keep_action):
"""Drop every action/NLA track except the one we are about to export.
glTF import leaves the source action on the armature; if we leave it in place the NLA-track
export ships BOTH clips and loop_qc (which grabs the first action) measures the original.
Clear the armature's animation_data and remove all other actions from bpy.data.actions.
"""
ad = arm.animation_data
if ad is not None:
ad.action = None
for tr in list(ad.nla_tracks):
ad.nla_tracks.remove(tr)
for act in list(bpy.data.actions):
if act != keep_action:
bpy.data.actions.remove(act)
def write_action(arm, name, out_frames, root):
"""Bake the rebased frames into a fresh action (mirrors cc_retarget's keyframe_insert)."""
# Make room for the clean name first: drop the imported action + any NLA tracks so the new
# action gets the exact clip name (no .001 suffix) and nothing else ships.
purge_animation(arm, keep_action=None)
new_act = bpy.data.actions.new(name)
if arm.animation_data is None:
arm.animation_data_create()
arm.animation_data.action = new_act
if getattr(new_act, "slots", None) and arm.animation_data.action_slot is None:
arm.animation_data.action_slot = new_act.slots[0]
for i, (rot_f, loc_f) in enumerate(out_frames):
for pb in arm.pose.bones:
pb.rotation_quaternion = rot_f[pb.name]
pb.keyframe_insert("rotation_quaternion", frame=i)
arm.pose.bones[root].location = loc_f
arm.pose.bones[root].keyframe_insert("location", frame=i)
# Lock the action to exactly the frames we wrote.
try:
new_act.frame_range = (0, len(out_frames) - 1)
except Exception:
pass
arm.animation_data.action = None # re-added as an NLA track for export
return new_act
def export(arm, acts, out):
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
bpy.context.view_layer.objects.active = arm
for act in acts:
tr = arm.animation_data.nla_tracks.new()
tr.name = act.name
tr.strips.new(act.name, 1, act)
bpy.ops.export_scene.gltf(
filepath=out, use_selection=True,
export_animations=True, export_animation_mode="NLA_TRACKS",
export_force_sampling=True, export_optimize_animation_size=False)
print(f"[loop_fix] EXPORTED {len(acts)} clip(s) → {out}")
def main():
a = parse()
try:
arm, act = load(a.src)
except Exception as e:
print(f"[loop_fix] ERROR load: {e}")
sys.exit(2)
name = a.name or act.name
f0, f1, root, bones, rot, loc = sample_frames(arm, act)
print(f"[loop_fix] src={a.src} action={act.name} frames={f0}..{f1} bones={len(bones)}")
stage_dedrift(f0, f1, loc, enabled=not a.no_dedrift)
anchor, F = stage_cut(f0, f1, rot, a.min_keep, a.blend_frames, enabled=not a.no_trim)
W = a.blend_frames
if anchor - W < f0: # window too big to have W source frames before the anchor — clamp
W = max(0, anchor - f0)
print(f"[loop_fix] C blend: clamped W → {W} (not enough frames before anchor)")
do_blend = (not a.no_blend) and W > 0
n_out = F - anchor + 1
print(f"[loop_fix] C blend: W={W} loop=[{anchor}..{F}] → out {n_out}f (kept "
f"{100.0 * n_out / (f1 - f0 + 1):.1f}%)")
out_frames = build_output(f0, anchor, F, W, bones, rot, loc, do_blend)
new_act = write_action(arm, name, out_frames, root)
export(arm, [new_act], a.out)
sys.exit(0)
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception as e:
import traceback
traceback.print_exc()
print(f"[loop_fix] ERROR: {e}")
sys.exit(2)
+123
View File
@@ -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 <clip.glb> [--name <Clip>] \
[--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)
+94
View File
@@ -0,0 +1,94 @@
"""pingpong_bake.py — bake a clip into a palindrome (forward + reversed) loop.
Plays the clip forward, then backward, as ONE animation: frames [0..N] followed by
[N-1..1] mirrored (total 2N). The final frame equals frame 0 exactly, so Godot's
LoopMode.Linear wraps with zero pose gap and zero root drift — the character returns
to its start point by construction. The trade-off: motion direction reverses at the
turn point and at the wrap (inherent to the tactic).
Usage:
blender --background --python tools/pingpong_bake.py -- \
--src <in.glb> --out <out.glb> --name <NewClipName>
"""
import bpy, sys, argparse
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("--out", required=True)
p.add_argument("--name", required=True, help="clip name baked into the output")
return p.parse_args(argv)
def action_fcurves(a):
"""All fcurves across Blender 5 slotted-action layers/strips (legacy fallback)."""
if hasattr(a, "fcurves") and getattr(a, "fcurves", None) is not None:
return list(a.fcurves)
fcs = []
for layer in getattr(a, "layers", []):
for strip in layer.strips:
for bag in strip.channelbags:
fcs.extend(bag.fcurves)
return fcs
def main():
a = parse()
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=a.src)
arm = next(o for o in bpy.data.objects if o.type == 'ARMATURE')
act = arm.animation_data.action
if getattr(act, "slots", None) and arm.animation_data.action_slot is None:
arm.animation_data.action_slot = act.slots[0]
f0, f1 = int(act.frame_range[0]), int(act.frame_range[1])
n = f1 - f0
print(f"[pingpong] src clip '{act.name}' frames {f0}..{f1} -> palindrome 0..{2*n}")
# extend every fcurve in place: append mirrored keys [N-1..0] after frame N
for fc in action_fcurves(act):
vals = [fc.evaluate(f0 + k) for k in range(n + 1)]
base = len(fc.keyframe_points)
fc.keyframe_points.add(n) # frames N+1 .. 2N
# rewrite ALL points to a clean dense 0..2N ramp (handles f0 offset too)
total = 2 * n
need = total + 1
have = base + n
if have < need:
fc.keyframe_points.add(need - have)
elif have > need:
for _ in range(have - need):
fc.keyframe_points.remove(fc.keyframe_points[-1], fast=True)
for k in range(need):
v = vals[k] if k <= n else vals[2 * n - k]
kp = fc.keyframe_points[k]
kp.co = (k, v)
kp.interpolation = 'LINEAR'
fc.update()
act.name = a.name
# drop NLA tracks that came in with the import — only our palindrome track may export
for t in list(arm.animation_data.nla_tracks):
arm.animation_data.nla_tracks.remove(t)
# single NLA track named = clip name (same convention as cc_retarget.py)
track = arm.animation_data.nla_tracks.new()
track.name = a.name
strip = track.strips.new(a.name, 0, act)
strip.name = a.name
arm.animation_data.action = None
for o in [o for o in bpy.data.objects if o.type == 'MESH']:
bpy.data.objects.remove(o, do_unlink=True)
bpy.context.scene.frame_start = 0
bpy.context.scene.frame_end = 2 * n
bpy.ops.export_scene.gltf(
filepath=a.out,
export_animation_mode="NLA_TRACKS",
export_force_sampling=True,
export_optimize_animation_size=False,
)
print(f"[pingpong] EXPORTED {a.out} clip '{a.name}' ({2*n+1} frames)")
if __name__ == "__main__":
main()