Compare commits
4 Commits
95331a8c36
...
da0567b79d
| Author | SHA1 | Date | |
|---|---|---|---|
| da0567b79d | |||
| ac691dee90 | |||
| d8e2f2782c | |||
| d3206f6ce2 |
@@ -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.
|
||||
@@ -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/<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 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 `<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
|
||||
- **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.
|
||||
|
||||
@@ -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** | ~49–50 m each | The stars of the dance — huge, near-constant sweeps |
|
||||
| 2 | Forearms | ~32–33 m | Big arcs from the elbow reinforce the hand work |
|
||||
| 3 | Shins | ~25–26 m | Busy legs — steps, lifts, direction changes |
|
||||
| 4 | Upper arms / feet | ~18–21 m | Full-shoulder arm swings; active but grounded feet |
|
||||
| 5 | Head / spine / pelvis | ~16–17 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.7–2.5 m per axis — meaning full-wingspan sweeps that go both
|
||||
**high** (≈1.5–1.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 **~100–103 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.
@@ -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 25–142 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 `b−W+k` with
|
||||
`blend( original[b−W+k], original[a−W+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 | 13–19 | 4–8 |
|
||||
| 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`).
|
||||
@@ -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 `b−W+k` is blended toward original frame
|
||||
`a−W+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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user