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>
7.5 KiB
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:
-
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. -
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 isang(orig[a-1], orig[a])vsang(orig[a], orig[a+1])— the original mocap's own local velocity discontinuity ata. So the tool:- picks anchor
ato minimizemax 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 founda=185for dance1/firedance1 instead of the bad defaulta=24wherehand_laccelerates from 6°/f to 15.7°/f).ais constrained toa ≥ W(so the W blend-source frames exist) and to leave room to keep ≥min-keepof the clip. - picks cut
bin[a+K-1, end]for the best core-bone pose match toa(purely cosmetic — it governs how much the crossfade region deviates; the seam pose itself is 0 regardless), preferring a largerbto keep duration.
- picks anchor
-
Stage C — seam blend. Standard motion-graph crossfade. Output = frames
[a, b]rebased to start at 0. Fork in [0, W], original frameb−W+kis blended toward original framea−W+k(the frames just before the loop start) with a smoothstep weight3t²−2t³: quaternion slerp with hemisphere correction (negate one quat ifdot < 0) for rotation, lerp for location, applied to all animated bones. Atk = Wthe blended last frame equalsorig[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.fcurvesdoes not exist in 5.1 (data lives inaction.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 asloop_qc.pydoes) and writes bykeyframe_insertinto a fresh action (exactly ascc_retarget.pydoes). - 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 getsDance1, notDance1.001). - "No mesh" criterion is satisfied at the file level. The plan assumed the inputs are
meshless; in fact every
converted-glband everylooped-glbfile containsmeshes: [](66 skeleton nodes, 1 skin, zero mesh primitives — confirmed by parsing the glb JSON). TheIcosphereseen 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 reportframe_range = (1, N).loop_qc.pymeasures gaps relative to itsframe_range, so this is harmless.
Acceptance criteria check
- ✅
tools/loop_fix.pyexists, 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). - ✅ All seven
exchange/looped-glb/<clip>.glbexist andloop_qc.pyexits 0 on each with default thresholds. - ✅ Animation name and file basename unchanged; output files are mesh-free; every clip keeps ≥ 60% of original duration (minimum 60.6% — dance1 / firedance1).
- ✅
tools/loop_qc.pyand its thresholds were not modified (emptygit diff). - ✅ 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).