Files
animation/.agents/plans/loop-fix-results-2026-07-16.md
T
jeremy 854dbb5ce4 chore(agents): migrate to agents.md protocol — .agents/ wiki/plans/rules/skills
Root AGENTS.md is now a thin entry point + knowledge map; the batch-workflow
detail it duplicated already lived in .claude/skills/animation/SKILL.md.
Folded docs/ (dances registry + iclone-bridge stub), root plans/, and
devops-reports/ into .agents/wiki/ and .agents/plans/ per the per-repo .agents/
convention. Added .agents/SOUL.md, .agents/AGENTS.md, wiki/ARCHITECTURE.md,
wiki/architecture/, and wiki/master-plan.md (derived from the closed plans +
the dance registry's live ceremony-slot table). Fixed doc-path references in
exchange/GUIDE.md, the animation skill, and the iclone-bridge stub. All moves
via mv (git detected as renames) — no content deleted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:02:47 -07:00

7.5 KiB
Raw Blame History

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).