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>
This commit is contained in:
2026-07-16 13:48:09 -07:00
parent d3206f6ce2
commit d8e2f2782c
10 changed files with 628 additions and 0 deletions
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)