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>
8.5 KiB
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}.glbEach contains one armature, no mesh, one action, 720 frames @24fps scene (baked from 60fps source), every frame keyed (export_force_samplingwas 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.pydoes (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 endb = F(from stage B). The output clip is frames[a, b]rebased to start at 0. - For
k in [0, W]: replace frameb−W+kwithblend( original[b−W+k], original[a−W+k], w(k/W) )wherewis smoothstep (3t²−2t³), rotations via quaternion slerp with hemisphere correction (ifdot(q1,q2) < 0negate one first — skipping this causes spins), locations via lerp. - Why this works: at
k=Wthe blended framebequals original frameaexactly, and the frames leading intobblend toward the frames leading intoa— so both pose AND velocity are continuous when the game wrapsb → 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 starta). - 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)
tools/loop_fix.pyexists, runs headless, no errors, matches the CLI spec.- For all seven clips:
exchange/looped-glb/<clip>.glbexists andloop_qc.py --src exchange/looped-glb/<clip>.glbexits 0 with default thresholds. Capture exit codes directly — do NOT pipe the Blender run throughgrep/tailwhen reading$?(the pipe's status wins). - Animation name and file basename unchanged; output has no mesh; ≥60% of original duration kept.
- Do not modify
tools/loop_qc.pyor its thresholds — the gate defines success. - 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.pyandcc_retarget.pydo. Frame range comes fromaction.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_rangestarts at 0. - Quaternion fcurves are 4 channels (
rotation_quaternionw,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.glbandhakaOG.glbare 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).