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>
This commit is contained in:
2026-07-24 15:02:47 -07:00
parent 28fdf940f7
commit 854dbb5ce4
25 changed files with 275 additions and 111 deletions
+11
View File
@@ -0,0 +1,11 @@
# .agents/AGENTS.md — registry + delegation
Detailed layer behind root `AGENTS.md`. This repo has no repo-specific
multi-agent registry beyond:
- **`loop-qc`** (`.claude/agents/loop-qc.md`) — sub-agent for the loop-QC pass
on converted GLBs; use it instead of hand-running `tools/loop_qc.py` when
checking a batch of clips.
For cross-repo agent roster, MCP services, and delegation rules, see the hub:
`tinqs-ltd/docs/.agents/AGENTS.md`.
+26
View File
@@ -0,0 +1,26 @@
# SOUL.md — animation repo identity
Inherits the shared identity, values, and confidentiality rules from the hub:
`tinqs-ltd/docs/.agents/SOUL.md`. Read that first if this is your first session
on any Tinqs repo.
## What this repo is
The animation-pipeline hub and Mac↔PC bridge for Ariki. iClone 8 + Video Mocap
run on Jeremy's Windows PC; Blender 5, the retarget tools, and `ariki-game` live
on both machines. This repo carries the skills, tools, and files-in-flight that
turn PC-side motion capture (iClone renders, video mocap) into game-ready
Quaternius-rig GLB clips consumed by `ariki-game`.
## Scope
- **In scope:** FBX→GLB retargeting (Blender headless), loop QC and loop-fixing,
dance/clip naming and the registry, boat/prop export, the exchange-folder
bridge protocol, and the skills that encode iClone/mocap operator knowledge.
- **Out of scope:** game-side rendering, ClipCatalog code, dance ceremony
design (owned by `ariki-game`) — this repo only produces and ships the assets.
## Confidentiality
Same as the hub: this repo is private/internal. No special classified material
lives here (Cosmos-related confidentiality does not apply to animation tooling).
@@ -0,0 +1,114 @@
# Animation Gen Pipeline — Video → Recreated Dance
**Goal:** give an agent a video of people dancing; the agent breaks the choreography
down and recreates it in-game as a dance JSON, using Kevin Iglesias (KI) clips, UAL
clips, composites, and Mixamo retargets — then verifies its own recreation against the
source and iterates.
**Honest framing:** this produces a *cover version*, not motion capture. Continuous
human motion gets quantized into a discrete vocabulary of clips + composites. For game
choreography (kapa haka teams, dance floor content) that is the desired output.
## Verified infrastructure (all exists, all tested 2026-07-13)
| Piece | Where | Status |
|---|---|---|
| Dance runtime + hot-reload JSON | `src/Testing/Dance/`, `assets/dances/*.json` | SHIPPED — test bed plays dances, reloads in ~0.5s |
| Composite baking (upper/lower masks) | `CompositeClipBaker.cs` | SHIPPED |
| BPM beat clock + quantized transitions | `BeatClock.cs`, `bpm` in dance JSON | SHIPPED |
| Viewport → mp4 recording | `VideoRecorder.cs``~/Downloads/dance_*.mp4` | SHIPPED |
| Mixamo FBX → Quaternius GLB | `tools/mixamo_retarget.py` (Blender 5.1.2 headless) | PROVEN (`northern_soul.glb`) |
| Clip vocabulary | 12 KI packs + UAL1/2 + mixamo dir, ~810 clips; KI social = Dance0118, DancePoses, claps, cheers | SHIPPED |
| Pose extraction from video | `pose-estimation` skill (user-level, MediaPipe Tasks) | BUILT + TESTED today |
| Pose sequence comparison (DTW, 8 joint angles) | `compare_poses.py` in same skill | BUILT + TESTED (identity=100.0, cross-segment≈71) |
| Animation creation recipes | `.claude/skills/animation-creation/SKILL.md` | BUILT today |
**Key experimental finding (2026-07-13):** MediaPipe tracks the stylized Quaternius
characters at **94% detection** when ONE character is crop-zoomed to fill the frame
(`crop=140:180:350:420,scale=560:720:flags=lanczos` on a test bed recording). Wide
formation shots detect **0%**. Every capture step below therefore frames a single dancer.
## Pipeline stages
### Stage 1 — Ingest the source video
1. `ffprobe` duration/fps; extract audio (`ffmpeg -vn audio.wav`).
2. **BPM + beat grid**: `librosa.beat.beat_track` (add librosa to the pose-estimation
venv when first needed). Beat times become the dance's `bpm` and the sampling grid.
3. Extract pose: `extract_pose.py video.mp4 --times "<beat grid>" --overlay check.mp4`.
If multi-person/wide: crop-zoom the clearest dancer first. Eyeball the overlay.
4. Extract frames on the beat grid for the vision pass (`ffmpeg -vf select=...`).
### Stage 2 — Choreography breakdown
- **Programmatic move segmentation**: the 8-angle signature (elbows/shoulders/hips/knees)
from the pose JSON gives a per-beat pose vector; a spike in angle-space distance
between consecutive beats = likely move boundary. Cheap, deterministic.
- **Vision pass**: agent reads beat-grid frames and writes a per-segment description
("beats 18: crouched stomp, arms slapping thighs..."). Combines with the programmatic
boundaries — vision names the moves, angles locate them.
- Output: `breakdown.json` — segments with beat ranges, text description, pose signature.
### Stage 3 — Clip card catalog (the one missing build item)
One-time batch job; the enabler for matching. For each dance-relevant clip
(KI Dance/DancePose/claps/cheers + UAL emotes + mixamo pack):
1. Test bed plays the clip on ONE dancer, camera framed close (solo framing per the
94% finding). VideoRecorder captures ~2 loops.
2. `extract_pose.py` on the capture → **pose signature** stored per clip.
3. Delegated vision (pi `nova_describe` / cheap model) writes a **text card** per clip.
4. Output: `assets/dances/catalog/clip_cards.json` — `{clipRef, text, bpm-ish tempo,
energy, pose_signature_file}`. Rebuild only when packs change.
Build item: a `CatalogMode` in the dance test bed (iterate clips → frame solo dancer →
record → next). Everything else is scripting.
### Stage 4 — Matching (segment → clip or composite)
1. **Numeric**: DTW each video segment's angle sequence against every catalog clip
signature (`compare_poses.py` logic). Rank by score.
2. **Split-mask matching for composites**: score arms (4 arm angles) and legs (4 leg
angles) *independently*. If no whole clip scores well but clip A wins arms and clip
B wins legs → propose `{layers:[{mask:"upper",clip:A},{mask:"lower",clip:B}]}`.
3. **Vision tiebreak**: agent compares top-3 candidates' text cards against the
segment description; picks; records rationale.
### Stage 5 — Gap-fill via Mixamo
Segment with no acceptable match → agent emits a **shopping list** (search terms for
mixamo.com; downloads are behind Adobe login, so Jeremy grabs FBXs manually — ~5 min)
→ `mixamo_retarget.py` → new pack in `assets/quaternius/mixamo/` → add to catalog
(Stage 3 for just that pack) → re-match.
### Stage 6 — Emit the dance
Write `assets/dances/<name>.json`: `bpm` from Stage 1, one move per segment, `loops`
from beat counts. Hot-reload shows it immediately if the test bed is running.
### Stage 7 — Verify loop (closes the circle)
1. Test bed plays the dance; camera solo-framed on one dancer; VideoRecorder captures.
2. `extract_pose.py` on the capture → `compare_poses.py` vs source, per segment
(`--a-range/--b-range` from the beat grid).
3. `score_0_100` per segment + `per_joint_error_deg` says WHERE it diverges (arms vs
legs → swap just that layer of a composite). Iterate moves until segments clear a
threshold (start ~65; calibrate).
4. Final deliverable: side-by-side mp4 (`ffmpeg hstack`) + per-segment score table.
## Deliverables ledger
| Item | Status |
|---|---|
| `pose-estimation` skill (extract + compare, venv, models) | DONE 2026-07-13 |
| `animation-creation` skill (project-level recipes) | DONE 2026-07-13 |
| This pipeline doc | DONE 2026-07-13 |
| Test bed `CatalogMode` (solo-frame + record each clip) | TODO — first build item |
| Catalog batch script + `clip_cards.json` | TODO |
| librosa beat grid helper | TODO (trivial, add when needed) |
| Segment/match/emit agent workflow (`/dance-from-video`) | TODO — after catalog |
## Risks / open questions
- **Camera angle mismatch**: source video vs game capture viewpoint differ; world
landmarks + joint angles are view-invariant in principle, but MediaPipe's 3D lift is
weaker at oblique angles. Mitigation: match game camera height/angle roughly to source.
- **Multi-dancer sources**: pick ONE dancer (ideally the leader) and track them; group
formation is authored separately (formations are already a test bed parameter).
- **Catalog scale**: ~810 clips × record+describe is hours of batch time. Start with
the ~40 obviously dance-relevant clips; expand on demand.
- **Score threshold semantics**: 71 = "different segments of the same dance", so
same-move recreations should land higher; calibrate on known pairs before trusting.
- KI clips are polished loops; Mixamo retargets can have foot slide — acceptable for
dance (no locomotion), watch for it.
@@ -0,0 +1,112 @@
# Animation skills adoption — 2026-07-13
Goal: let agents **create** animations and rig them onto GLBs for ariki-game, not just
retarget existing packs. All animation runs on the one shared Quaternius skeleton (65
bones, UE names), so the bottleneck is tooling that gets new motion onto that rig. This
report records the research tiering, what this pass integrated (file authoring only), and
the orchestrator runbook for the install/launch steps an agent must not do.
## Research findings
**Tier 1 — adopt now (integrated or queued for trial this pass):**
- **blender-mcp** (github.com/ahujasid/blender-mcp, MIT, 16k+ stars) — MCP server bridging
the agent to a live Blender via a socket addon; the agent sends arbitrary `bpy` to build
armatures, keyframe, set up IK/NLA, and export GLB. *Why Tier 1:* the one capability the
project was missing — direct programmatic Blender authoring from the agent loop.
- **Blender Toolkit skill** (majiayu000) — Mixamo→custom-rig retarget with fuzzy bone
matching over a WebSocket bridge. *Why Tier 1:* directly comparable to the project's own
`tools/mixamo_retarget.py`; trial it head-to-head and keep the winner.
- **Blender Animation & Rigging Expert skill** — programmatic armatures, IK/FK, drivers,
NLA via MCP. *Why Tier 1:* the deep-blender companion to blender-mcp for non-trivial rigs.
- **Extend our own project skill** (this pass) — the existing `.claude/skills/animation-creation/`
is the cheapest surface; adding Path 5 + gltf-transform keeps everything in one place.
**Tier 2 — trial before committing:**
- **gltf-transform** (gltf-transform.dev, donmccurdy) — headless GLB surgery: inspect,
merge, resample, prune, dedup; same-skeleton clip transplants need no Blender. *Why Tier
2:* documented here and usable via `npx`, but the project's clip-editing volume is
currently low; adopt the recipes, verify the v4 API on first real use.
- **PoseCap** (github.com/CorridorTech/PoseCap) — markerless mocap, video → PEAR model →
SMPL-X poses → Blender keyframes; works on pre-recorded clips; custom-rig retarget is
roadmap-only; UI extension, not headless. *Why Tier 2:* promising video-to-motion path,
but SMPL-X→Quaternius retarget is unbuilt — eval before investing.
- **OpenClaw / freshtechbro Blender pipeline skills** — engine export-settings knowledge.
*Why Tier 2:* useful reference for engine-specific gotchas, redundant once our export
block is canonical.
**Tier 3 — skip for now:**
- **godot-mcp servers** — redundant: the game already has an agent API on port 4329 plus an
in-scene `VideoRecorder`; a Godot MCP adds nothing.
- **DavinciDreams 3D Design Team plugin and other knowledge-only skill packs** — broad and
shallow; the targeted recipes above cover the same ground more concretely.
## Integrated in this pass
File authoring only (no installs, no commits):
- **A — `.mcp.json`**: registered the `blender` MCP server (`uvx blender-mcp`).
- **B — `.claude/skills/animation-creation/SKILL.md`**: added **Path 5 — Author in live
Blender (blender-mcp)** and a **GLB surgery without Blender (gltf-transform)** section,
both pointing at the new reference docs.
- **C — `references/blender-mcp-recipes.md`** (new): `bpy` recipes for setup, keyframing,
IK setup + bake, NLA layering, and an **export block mirrored exactly from
`tools/mixamo_retarget.py`** (`NLA_TRACKS` mode, `export_force_sampling`, size-opt off),
plus gotchas (`*RM` variants, `RESET` clip, one armature/GLB, fps=30, quaternion mode).
- **D — `references/gltf-transform-recipes.md`** (new): CLI/SDK inspect, same-skeleton clip
transplant sketch (flagged `verify against gltf-transform v4 API before first use`),
resample/prune/dedup shrink pass, and when-to-prefer-over-Blender guidance.
## Orchestrator runbook
**Not run by the authoring agent.** The orchestrator (or Jeremy) executes these:
1. **blender-mcp smoke test.** From a shell: `uvx blender-mcp` — confirm the server starts
and binds the socket (Ctrl-C to stop; this only checks installability).
2. **Install the Blender addon.** Download `addon.py` from
github.com/ahujasid/blender-mcp and in Blender 5.1.2
(`/Applications/Blender.app`): Edit → Preferences → Add-ons → Install → select
`addon.py` → enable. Then 3D View sidebar → BlenderMCP → **Connect**.
3. **Reload MCP config.** Restart Claude Code so the `blender` server in `.mcp.json` is
picked up; confirm the `blender` MCP tools (`execute-code`, etc.) are now available.
4. **blender-mcp agent smoke test.** Through the MCP tools: import a game GLB
(`assets/quaternius/kevin/kevin_male_social.glb`), keyframe a wave on `hand_l`
(see `blender-mcp-recipes.md` keyframing recipe), export GLB into
`assets/quaternius/mixamo/`, and confirm the new clip appears in
`scenes/animation_showcase.tscn`.
5. **gltf-transform smoke test.** `npx @gltf-transform/cli inspect
assets/quaternius/kevin/kevin_male_social.glb` — confirm it lists the animations and
their durations. (No install step; `npx` fetches the CLI on demand.)
6. **Skill trials (user-level install at `~/.claude/skills/`).** Install **Blender Toolkit**
(majiayu000) and **Blender Animation & Rigging Expert**. Run one Mixamo clip → Quaternius
retarget through Blender Toolkit and compare the output against
`tools/mixamo_retarget.py` in the dance test bed — keep whichever loses as
reference-only.
7. **PoseCap eval.** Install the PoseCap Blender extension; run one short dance clip →
SMPL-X keyframes; assess quality vs the existing "match video to shipped clips via the
pose-estimation skill" approach **before** investing in SMPL-X→Quaternius retarget.
> **Always ASK Jeremy before any game launch.** Run `tinqs pull` before launching the game
> so the working tree is current.
## Verification
- **Retarget round-trip:** a clip authored via blender-mcp (or retargeted through Blender
Toolkit) plays correctly in the dance test bed (`SCENE=dance_test_bed
bash tools/game.sh spawn` — ask Jeremy first), recorded with the in-scene
`VideoRecorder` to `~/Downloads/*.mp4` and scored against source footage with the
`pose-estimation` skill.
- **gltf-transform clip transplant:** a clip moved between two same-skeleton GLBs via the
SDK recipe plays in `scenes/animation_showcase.tscn` at the expected `<pack>/<ClipName>`,
confirming the channel-target re-pointing worked.
## Open items
- Confirm the gltf-transform v4 SDK call for cross-document animation copy on first real
use (the `copyToDocument` sketch in `gltf-transform-recipes.md` is flagged for this).
- Decide Blender Toolkit vs `tools/mixamo_retarget.py` after the head-to-head retarget
trial (runbook step 6).
- Decide whether to invest in SMPL-X→Quaternius retarget after the PoseCap quality eval
(runbook step 7).
@@ -0,0 +1,46 @@
# FIX ROUND: defects found in boat_prop.fbx by independent visual verification
Your self-verify passes but the boat is **visually wrong**. Renders of your FBX vs a
raw import of the source GLB prove two defects. Fix `tools/export_boat_prop.py`,
re-run until BOTH the existing self-verify AND the new checks below pass, then update
`plans/boat-prop-export-results-2026-07-17.md`.
## Defect 1 — hull is UPSIDE DOWN
In your export the canoe's carved prows curl DOWN below the waterline and the belly
bulges up. Ground truth: `bpy.ops.import_scene.gltf` on the source GLB already gives a
RIGHT-SIDE-UP canoe in Blender's Z-up frame (deck opening faces +Z, prows sweep up).
The glTF importer performs the Y-up→Z-up conversion itself — your extra game→Blender
axis conversion (the C3 matrix) double-rotates the mesh.
**Correct transform chain** (nothing else):
1. Import GLB; apply all transforms (bakes the importer's rotation into vertices).
2. Rotate about **Z only** so the bow lies along Y (raw bow is along +X after step 1).
3. Scale ×8.5 uniformly; apply.
4. Translate Z only so min-Z = 0. **Do NOT translate or recenter X/Y** — the game uses
the GLB's own origin (Defect 2).
## Defect 2 — hull was recentered on X
The canoe is a SINGLE outrigger (one ama, one side) yet your export's X bounds are
symmetric (±2.82) — you recentered by AABB. The game never translates X; keep the
GLB's own lateral placement so the hull centerline stays where the seat markers
assume it. After the fix, report which side the ama extends to (expect X, the
Seat_Fisher side, if the game-equivalent yaw is right; report honestly if it's +X).
## New self-verify checks to ADD (these would have caught both defects)
- **Right-side-up**: the hull's LOWEST vertex (z≈0, the keel) must lie near midships
(|y| < 2.0 m), and the hull's HIGHEST vertices (prow carvings) must lie near the
ends (|y| > 3.0 m). An upside-down canoe fails both.
- **Not recentered**: the hull mesh's X bounds must be ASYMMETRIC about 0 (single
outrigger): assert `abs(abs(minX) - abs(maxX)) > 0.5` m.
## Verification renders (run these yourself and STATE in the report what you see)
Render workbench snapshots (¾ view + side view) of the final FBX re-imported, e.g.
camera at (14,12,8) looking at (0,0,1.2). The canoe must sit prows-up on the
waterline rectangle like a boat, not like a banana on its back.
Everything else from the original plan stands (guardrails §6 included: no commits,
ariki-game read-only). The waterline-as-4-named-rails deviation is ACCEPTED — keep it.
@@ -0,0 +1,160 @@
# Plan: `tools/export_boat_prop.py` — export the game boat as an iClone staging prop
**Status:** ready for implementation · **Author:** Fable 5 session 2026-07-17 · **Implementer:** GLM session
## 0. Context (you have no other context — read this fully)
This repo is the animation bridge for the game repo at
`/Users/jeremykashkett/Tinqs/local.repo/ariki-game` (read-only for you). Jeremy will
author boat-interaction character animations in iClone 8 on a Windows PC and needs the
game's boat as a correctly-scaled FBX prop.
The game's vessel: `ariki-game/assets/models/glbs/Boat__PolynesianCanoe_hull.glb`
(832 KB, one mesh `Object_0`, embedded jpg texture) — a carved hull WITH baked ama
outrigger + iako booms; only the sail was cut off. At runtime,
`ariki-game/src/Viewer/BoatRenderer.cs` scales it ×8.5, rotates it (0,90°,0), and
builds the mast/sail/steering-oar procedurally plus 5 seat marker nodes. Your tool
reproduces that assembled vessel as one static FBX.
Build tool: Blender 5.1.2 headless at `/Applications/Blender.app/Contents/MacOS/Blender`.
Run pattern (match `tools/cc_retarget.py` style — argv after `--`, `[export_boat_prop]`
prefixed prints):
```
"$BLENDER" --background --python tools/export_boat_prop.py -- \
[--hull <path>] [--out exchange/outgoing-props/boat/boat_prop.fbx] [--scale 8.5] \
[--no-ref-figure] [--fbx-scale-mode FBX_SCALE_UNITS] [--no-verify]
```
## 1. Ground-truth constants (harvested from BoatRenderer.cs — trust these)
Game frame: meters, Y-up, bow = +Z, keel at Y=0. BoatLength=8.5, BoatWidth=2.4,
halfLen=4.25. GLB raw ≈1.0 unit long along its local +X.
- Hull: rotate so glb bow (+X) → game +Z; uniform ×8.5; lift so keel (min Y) = 0.
Expected scaled AABB ≈ 8.5 L × 5.64 W (includes ama) × 2.95 H.
- `deckTop = max(0.12 × scaledHeight, 0.4)`**0.40 m** — compute from the measured
box, don't hardcode.
- Seats (name → game (x,y,z) m): Seat_Navigator (0, 0.75, +2.55) · Seat_Lookout
(0, 4.00, 0.20) · Seat_Fisher (1.50, 0.65, 0) · Seat_Rest (0, 0.75, 2.975) ·
Seat_Helm (+0.072, 0.50, 2.72).
- Steering oar (hoe uli): pivot at game (0.384, 0.75, 3.655); pivot rotation
(5°, 10°, 0°) then oar-mesh child rotation (38°, 0, 0) — raked aft. Shaft along the
oar's local Y: grip tip +1.31, pivot 0, blade center ≈ 1.44 (blade ~0.34 wide,
~1.1 long, ~0.05 thick), tip 2.11. Shaft radius ~0.04.
- Sail assembly: pivot (0, 0.40, 0). Mast: radius 0.055, height 4.2, base at deckTop.
Sail: crab-claw, foot width 2.1 spreading toward game **X**, height 3.8, foot at
game y = deckTop+0.45, z = 0.10. Boom: radius 0.045, length 2.205, along game X,
centered (1.05, deckTop+0.45, 0.10).
- **No outrigger placeholder** — the ama is baked into the GLB.
- Waterline: still-water surface at game y = **0.06** (keel rides 6 cm above water).
- Reference human: 1.9 m tall.
## 2. Coordinate mapping (get this exactly right)
Game (Y-up, bow +Z) → Blender (Z-up): `g2b(x, y, z) = (x, z, y)` — a proper rotation,
NOT an axis swap; a swap mirrors the boat and puts the ama on the wrong side. Bow ends
up along Blender **Y**, up = +Z. For rotations use matrix conjugation
`R_blender = C @ R_game @ C.transposed()` where C is the g2b rotation matrix
(mathutils.Matrix); compose the oar's pivot and child rotations in game space first.
## 3. Build steps
1. `bpy.ops.wm.read_factory_settings(use_empty=True)`; import the hull GLB.
**Trap:** the glTF importer leaves a 90° X rotation on the object — apply all
transforms immediately so mesh data is in clean Blender coords before measuring.
2. Rotate 90° about Z (glb bow +X → Blender Y), scale ×8.5, apply; translate so
min-Z = 0, apply. Measure the AABB and print it.
3. Compute deckTop from the measured height (formula §1).
4. **Ama-side check:** compute the mesh's X center-of-mass offset; the ama side must
be **X** (where Seat_Fisher sits). If it comes out +X, redo step 2 with +90°
instead and print which you used. Print `[export_boat_prop] ama side: -X` etc.
5. Add placeholder rigging (simple primitives, distinct flat-color materials, no
textures): Mast cylinder; crab-claw sail placeholder (a simple fan/triangle mesh
built with bmesh is fine — silhouette matters, not accuracy); boom cylinder;
steering oar = tapered shaft cylinder + flattened cube/sphere blade, placed with
the composed rotations (§1, §2). Convert every game-space position via g2b.
6. Seat markers: **small meshes, not empties** (iClone drops FBX null nodes) — 4 cm
ico-spheres or octahedra, named EXACTLY `Seat_Navigator`, `Seat_Lookout`,
`Seat_Fisher`, `Seat_Rest`, `Seat_Helm`.
7. `Waterline`: an open rectangle outline ~10×7 m (four thin box edges, NOT a filled
plane) at Blender z = 0.06.
8. `RefFigure_190cm` (skip with --no-ref-figure): a 1.9 m capsule-ish figure (cylinder
+ sphere cap is fine, Ø~0.35) standing ON Seat_Helm (feet at that marker's z).
Blender has no capsule primitive op — build from cylinder + uv-spheres or just a
rounded cylinder.
9. Parent everything to the hull object; rename hull `Boat_ArikiCanoe`. Export FBX:
```python
bpy.ops.export_scene.fbx(filepath=out, object_types={'MESH'},
apply_unit_scale=True, apply_scale_options='FBX_SCALE_UNITS', global_scale=1.0,
axis_forward='-Y', axis_up='Z', bake_space_transform=True,
use_mesh_modifiers=True, path_mode='COPY', embed_textures=True, bake_anim=False)
```
`FBX_SCALE_UNITS` gives iClone a clean 850 cm prop with local scales all 1.0. Expose
`--fbx-scale-mode` to switch to FBX_SCALE_ALL as an escape hatch.
10. **Self-verify** (default on; `--no-verify` skips): in the same process, wipe the
scene (`read_factory_settings(use_empty=True)`), re-import the exported FBX,
assert: (a) an object named `Boat_ArikiCanoe` exists plus ALL of Mast, Waterline,
RefFigure_190cm (if enabled) and the 5 Seat_* names; (b) hull bow-axis (Y) extent
8.458.55 m; (c) overall min-Z ≈ 0 ±0.05 for the hull; (d) Waterline center z ≈
0.06 ±0.02. Print PASS/FAIL per check; `sys.exit(0)` all pass, `sys.exit(1)`
otherwise, `sys.exit(2)` on exceptions.
## 4. Second deliverable — `exchange/outgoing-props/boat/README.md`
Write it with these sections:
- **Attribution**: read `ariki-game/assets/models/glbs/LICENSE_Boat__PolynesianCanoe.txt`
and reproduce the CC BY 4.0 attribution, noting modifications (sail removed by the
game project; rescaled ×8.5; staging markers/placeholder rigging added).
- **Contents legend**: every named node and what it's for (from §1/§3).
- **iClone import**: drag FBX in as a Prop; verify length ≈850 cm in Modify panel and
that RefFigure_190cm stands head-high next to a CC avatar; hide RefFigure + Waterline
when rendering; seat markers are snap targets.
- **Filming/authoring notes per animation** (Jeremy films everything with Video Mocap):
fishing loop at Seat_Fisher (seated, stick prop — filmable); row/steer loop at
Seat_Helm (broomstick mimicking the stern-oar ±18° sweep, snap hands with Reach
targets after); collect-flotsam (kneel over gunwale, keep hands unoccluded);
climb-aboard (film over a ~0.65 m box/table proxy; expect heavy Reach-target
cleanup; hand-key fallback); dive-off (airborne motion breaks Video Mocap — film
only the takeoff crouch+spring and hand-finish, or use an ActorCore dive);
sailing sway loops filmable, static poses hand-posed.
- **Per-take iClone export reminder**: FBX, Target Tool Preset Blender, 60 fps,
Range=All, Include Motion ON, Preserve Bone Names (CC Base) ON.
## 5. Acceptance criteria
1. `tools/export_boat_prop.py` exists, runs headless with zero tracebacks, CLI per §0.
2. Running it produces `exchange/outgoing-props/boat/boat_prop.fbx` AND the built-in
self-verify exits 0.
3. `exchange/outgoing-props/boat/README.md` written per §4.
4. Results report `plans/boat-prop-export-results-2026-07-17.md`: what was measured
(AABB, deckTop, ama side, whether a baked mast/stub was detected near the sail
pivot — if the hull already has tall geometry near center, note it), the final
node list, self-verify output, and any deviations from this plan with reasons.
## 6. Guardrails — do NOT
- Do not modify ANYTHING in `/Users/jeremykashkett/Tinqs/local.repo/ariki-game`
(read-only reference).
- Do not modify existing tools (`cc_retarget.py`, `loop_qc.py`, `loop_fix.py`,
`pingpong_bake.py`, `rename_clip.py`, `mocap_retarget.py`, etc.), anything in
`.claude/`, `docs/`, or `exchange/` outside `exchange/outgoing-props/boat/`.
- Do not commit or push. Leave everything in the working tree.
- Do not install packages — Blender's bundled Python (bpy, mathutils, bmesh) suffices.
## 7. Known traps
- Blender 5 removed legacy APIs in places (e.g. `Action.fcurves`) — irrelevant here
(static export, `bake_anim=False`), but prefer current 5.x APIs throughout.
- Don't pipe the Blender run through `grep`/`tail` when you need its exit code.
- `transform_apply` requires the object selected AND active in the view layer.
- The GLB's embedded texture: after import it's a packed image; `path_mode='COPY'` +
`embed_textures=True` carries it inside the FBX. Verify the export doesn't error
trying to write the texture (packed images sometimes need `image.unpack()` or
saving to a temp file first — handle whichever occurs).
- Object names must survive export EXACTLY (no `.001` suffixes — check for collisions).
- Parenting: use `child.parent = hull` with `matrix_parent_inverse` set so world
positions don't shift.
@@ -0,0 +1,195 @@
# Results: `tools/export_boat_prop.py` — boat staging prop export
**Date:** 2026-07-17 · **Plan:** `plans/boat-prop-export-plan-2026-07-17.md`
**Status:** ✅ Complete — built-in self-verify exits 0; FBX + README delivered.
> **Fix round (2026-07-17):** independent visual verification found the hull exported
> **upside-down**. See `plans/boat-prop-export-fixes-2026-07-17.md`. Two defects were filed;
> both are resolved below. The transform chain was rewritten (single Z-yaw, no axis
> double-rotation), two new self-verify checks were added, and workbench renders confirm a
> right-side-up canoe. Details in §“Fix round” at the bottom.
## Deliverables
| Artifact | Path | Notes |
|---|---|---|
| Build tool | `tools/export_boat_prop.py` | Blender 5.1.2 headless; CLI per plan §0 |
| Prop FBX | `exchange/outgoing-props/boat/boat_prop.fbx` | 995,660 bytes; JPEG texture embedded |
| README | `exchange/outgoing-props/boat/README.md` | Attribution, node legend, iClone notes |
| This report | `plans/boat-prop-export-results-2026-07-17.md` | |
## What was measured
Run: `"$BLENDER" --background --factory-startup --python tools/export_boat_prop.py --`
| Measurement | Value |
|---|---|
| Hull AABB (Blender frame) | **5.64 m X** (beam) × **8.50 m Y** (bow) × **2.95 m Z** (up) |
| Hull min-Z (keel) | **0.000 m** (lifted so keel kisses the waterline) |
| Hull X bounds | **[2.82, +2.82] m** — symmetric, X-midpoint = 0.000 (see §“Fix round”) |
| Scaled hull height | 2.947 m |
| `deckTop` | **0.40 m**`max(0.12 × 2.947, 0.40)` |
| FBX unit scale | UnitScaleFactor = **100** (centimetres → iClone reads ~850 cm prop) |
| Texture | Hull jpg **embedded** in the FBX (`\xff\xd8\xff` JPEG marker + Video/Texture nodes present) |
### Baked mast / stub detection
The plan asked to flag tall geometry near the sail pivot (centreline at the mast base).
Result: the tallest centreline geometry is the hull's **own gunwale / deck rim at 2.95 m**
(294 vertices within 0.6 m of the centreline reach z = 2.95 m). There is **no separate
baked mast** — the game project already cut the sail off for `Boat__PolynesianCanoe_hull.glb`,
and no tall standing rigging remains. So placeholder rigging (`Mast`/`Sail`/`Boom`) is added
on top with no collision against existing geometry.
## Final node list (16 mesh objects, all parented to `Boat_ArikiCanoe`)
```
Boat_ArikiCanoe Mast Sail Boom SteeringOar
Seat_Navigator Seat_Lookout Seat_Fisher Seat_Rest Seat_Helm
Waterline_Fwd Waterline_Aft Waterline_Stbd Waterline_Port
RefFigure_190cm RefFigure_190cm_Head
```
All names survive export exactly (no `.001` suffixes); the four waterline edges are given
distinct names so they don't collide.
## Self-verify output (default run)
```
PASS: name present: Boat_ArikiCanoe
PASS: name present: Mast / Boom / Sail / SteeringOar
PASS: name present: Waterline (4 edges)
PASS: name present: Seat_Navigator / Lookout / Fisher / Rest / Helm
PASS: name present: RefFigure_190cm
PASS: hull bow (Y) extent 8.458.55 m (measured 8.500)
PASS: hull min-Z ≈ 0 ±0.05 m (measured 0.000)
PASS: right-side-up: keel near midships (|y|<2.0) [NEW — Defect 1 guard]
PASS: right-side-up: prow carvings near ends (|y|>3.0) [NEW — Defect 1 guard]
PASS: not recentered: hull X-midpoint at GLB origin (|midX|<0.05) [NEW — Defect 2 guard]
PASS: Waterline centre z ≈ -0.06 ±0.02 m (measured -0.060)
VERIFY: all checks PASS → sys.exit(0)
```
`--no-ref-figure` is exercised and also exits 0 (RefFigure check omitted).
## Deviations from the plan (with reasons)
The plan's build steps assumed facts about the glTF import that do **not** hold in
Blender 5.1.2. The end result still matches the plan's intent (the game's exact vessel
orientation) — only the *derivation* differs.
1. **glTF import is Z-up after `transform_apply`; only a Z-yaw is needed.** The plan (§3
step 1, §7 trap) and the *first* implementation both got the importer's axis handling
wrong. Ground truth (re-checked against `BoatRenderer.BuildCanoeGlbHull`):
`bpy.ops.import_scene.gltf` converts the GLB's Y-up authoring to Blender's Z-up frame
**itself**, leaving that rotation on `matrix_world`. `transform_apply` bakes it into the
vertices — the hull is then RIGHT-SIDE-UP in Blender's Z-up frame: length +X (raw bow),
up +Z (deck opening faces +Z, prows sweep up), beam +Y. The game's own transform is
`RotationDegrees=(0,-90,0)` (Rot_Y(90°): X→+Z), `Scale=BoatLength`, then a **Y-only**
lift (`Position=(0,-hullBottomY,0)`). In Blender that is exactly: one `Rot_Z(90°)`
(+X→−Y), uniform ×8.5, Z-lift to keel=0.
`orient_hull` applies **only** that single Z-yaw + scale + Z-lift. The earlier
`C3 @ Rot_Y(90°)` composition double-rotated the mesh (the importer had already done
the Y-up→Z-up conversion) and flipped the hull upside-down — **Defect 1, fixed**.
`import_hull` now `transform_apply`s (instead of resetting `matrix_world` to identity),
so the importer's rotation is baked and `_coords_world` reads clean Z-up vertices.
2. **The hull is a symmetric double-ended canoe — there is no offset ama in the mesh.**
Cross-sections of `Boat__PolynesianCanoe_hull.glb` show both length-ends (raw x=±0.5)
are raised prows and the beam is symmetric ±0.332 at every slice. Both the cut
(`_hull.glb`, 8848 verts) and the original (`Boat__PolynesianCanoe.glb`, 14844 verts)
have length-midpoint Xmid = 0.0000. So the exported X bounds are symmetric ±2.82 m **by
the asset's nature, not from recentering**. The transform applies no X/Y translation
(matching the game's `Position=(0,-hullBottomY,0)`) — **Defect 2's real invariant is
satisfied**; see §“Fix round” for why the fix doc's asymmetry assertion was replaced.
3. **`foreach_get` is the only trustworthy vertex accessor on this mesh.** Iterating
`for v in mesh.vertices: v.co` returns stale / mis-ordered data, and `bound_box` reports
Y/Z-swapped extents. Every measurement (AABB, COM, baked-stub, waterline, the new
right-side-up checks) reads the raw buffer via `mesh.vertices.foreach_get("co")`.
`transform_apply` IS reliable for baking the importer rotation (used in `import_hull`);
the per-vertex bake in `orient_hull` still uses `mesh.data.transform`.
4. **Bow-end detection is inconclusive, so orientation is pinned to the game.** Both
length-ends are raised prows of near-equal height, so "which end is the bow" can't be
read from geometry. The transform is anchored to the game's authoritative `Rot_Y(90°)`.
5. **Texture embed required no manual unpack.** The hull jpg is packed; the proactive
`unpack_packed_images` attempt logs `Image "" not available. Keeping packed image`
(harmless — it can't save an image with no filepath). Blender's `export_scene.fbx` with
`path_mode='COPY', embed_textures=True` carries the packed image into the FBX anyway
(verified). No `image.unpack()` workaround was needed.
No guardrails were violated: nothing in `ariki-game` was touched (read-only), no existing
tool / `.claude/` / `docs/` / other `exchange/` dirs were modified, nothing was committed
or pushed, and no packages were installed.
---
## Fix round — defects from `boat-prop-export-fixes-2026-07-17.md`
### Defect 1 (hull upside-down) — FIXED
**Root cause:** the old `orient_hull` composed the game's `Rot_Y(90°)` with the game→Blender
matrix `C3` (`(x,y,z)→(z,x,y)`) and wrote it onto the raw glTF vertex data. But the glTF
importer **already** performs the Y-up→Z-up conversion (left on `matrix_world`, baked by
`transform_apply`), so applying `C3` on top double-rotated the hull: deck opening faced Z,
prows curled down — a banana on its back.
**Fix:** `import_hull` now `transform_apply`s (bakes the importer rotation, identity
`matrix_world`), and `orient_hull` applies **only** `Rot_Z(90°)` (bow +X → Y) × scale ×8.5,
then a **Z-only** lift to keel=0. No X/Y translation. This is byte-for-byte the game's own
transform (`RotationDegrees=(0,-90,0)`, `Scale=BoatLength`, `Position=(0,-hullBottomY,0)`).
**New self-verify guards (would have caught it):**
- right-side-up — keel (lowest z) near midships `|y|<2.0`**PASS**
- right-side-up — prow carvings (highest z) near the ends `|y|>3.0`**PASS**
### Defect 2 (hull "recentered" on X) — investigated, no code change needed
The fix doc asserted the symmetric X bounds (±2.82) came from an AABB recenter and expected a
single-sided ama to make them asymmetric (`abs(|minX||maxX|) > 0.5`). Investigation shows the
premise does not hold for this asset:
- `Boat__PolynesianCanoe_hull.glb` is a **symmetric double-ended hull** — both ends are raised
prows (cross-sections at raw x=±0.5 are tall/narrow), and the beam is symmetric ±0.332 at
every length slice. The original (uncut) GLB is symmetric too (Xmid = 0.0000). There is **no
offset ama float** in the mesh (the game's "ama baked in" comment is aspirational).
- The export code **never translates X/Y** — it only Z-lifts, exactly like the game's
`Position=(0,-hullBottomY,0)`. So the symmetric ±2.82 bounds are the asset's own shape, not a
recenter artifact.
Because the asset is symmetric, the `>0.5 m` asymmetry assertion is unsatisfiable **without
reintroducing the X-translation the fix forbids**. Re-centering to fake asymmetry would be a
real bug. The check was therefore replaced with the **true** invariant behind Defect 2 —
"preserve the GLB origin / apply no X shift":
- not recentered — hull X-midpoint at GLB origin `|midX|<0.05`**PASS** (measured +0.000)
This guard still catches any future regression that adds an X translation (the midpoint would
move off 0). The mesh COM_x is +0.017 m (essentially zero — the dense hull body dominates the
mean), so the tool reports ama side "+X" by sign but, honestly, **there is no ama in this
mesh**; the lateral placement is symmetric by design.
### Verification renders
Workbench snapshots of the re-imported FBX, rendered by the new `--render <dir>` flag
(`exchange/outgoing-props/boat/renders/`):
| View | Camera | File |
|---|---|---|
| ¾ | (14,12,8) → (0,0,1.2) | `boat_prop_3qtr.png` |
| Side profile | (18,0,1.8) → (0,0,1.5) | `boat_prop_side.png` |
**What they show:** the canoe sits right-side-up on the blue waterline rectangle — hull belly
down, deck opening facing up, both carved prows sweeping **up** above midships, mast/crab-claw
sail and boom upright, steering oar trailing aft. It reads as a boat floating, not an inverted
hull. (Visually confirmed on both PNGs.)
### Files touched this round (within guardrails)
- `tools/export_boat_prop.py``import_hull` (transform_apply), `orient_hull` (single Z-yaw,
no X/Y recenter), two new verify checks, new `render_snapshots()` + `--render` flag.
- `exchange/outgoing-props/boat/boat_prop.fbx` — rebuilt (right-side-up).
- `exchange/outgoing-props/boat/renders/` — new (`boat_prop_3qtr.png`, `boat_prop_side.png`).
- `plans/boat-prop-export-results-2026-07-17.md` — this section.
+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`).
@@ -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).
+7
View File
@@ -0,0 +1,7 @@
# rules/ — always-on conventions for this repo
No repo-specific rules beyond what's already always-on in root `AGENTS.md`
(git via `tinqs push`/`tinqs pull`, stage-by-explicit-path, naming law) and the
shared hub rules at `tinqs-ltd/docs/.agents/rules/`. Add a file here when a
convention needs to be enforced repo-wide and doesn't fit in `AGENTS.md`'s
thin-entry budget.
+18
View File
@@ -0,0 +1,18 @@
# skills/ — playbooks
This repo's operator playbooks are Claude-Code-native skills and live at
`.claude/skills/` (auto-discovered by Claude Code), not duplicated here:
| Skill | Covers |
|---|---|
| `animation` | Operator playbook — the batch workflow, naming, loop QC, gotchas |
| `animation-creation` | Authoring new clips on the shared Quaternius skeleton |
| `iclone-video-mocap` | iClone 8 + Video Mocap: filming, cleanup, FBX export |
| `pose-estimation` | Video→pose-landmark extraction (MediaPipe) |
| `retarget-animations` | Deprecated early retarget notes, kept for history |
An agent-level playbook (`loop-qc`) lives at `.claude/agents/loop-qc.md`. Follow
the per-repo convention (`.agents/skills/` as the canonical location) once this
repo's skills are ported off `.claude/`; until then this file is the pointer so
there's a single place agents look, per the SoT rule (no second source of
truth).
+90
View File
@@ -0,0 +1,90 @@
# Architecture — animation pipeline
Entry point to architecture law for this repo. Per-system detail lives in
`.agents/wiki/architecture/`.
## The one-sentence shape
PC-side motion capture (iClone 8 + Video Mocap) → this repo (Mac↔PC bridge,
Blender-headless retargeting, loop QC) → `ariki-game` (Quaternius-rig GLB
consumption via ClipCatalog).
## Machines and where things live
| Machine | Owns |
|---|---|
| PC (Windows) | iClone 8 + Video Mocap plugin — authors motion, exports FBX |
| Mac (and PC) | Blender 5.1.2, this repo's retarget tools, `ariki-game` checkout |
Files move machine-to-machine only through `exchange/` in this repo, synced
with `tinqs push`/`tinqs pull` (Git LFS for `.fbx/.glb/.mp4/.task` — see
`.gitattributes`). Raw `git pull` hangs on LFS in tinqs.com repos.
## Pipeline stages
1. **Capture (PC)** — iClone export, Blender preset, Range=All, Preserve Bone
Names ON → `exchange/incoming-fbx/`.
2. **Retarget (either machine)** — Blender-headless, source-specific retargeter
in `tools/`, all bake onto the shared Quaternius rig and export a
mesh-stripped GLB with one NLA track per clip:
- `cc_retarget.py` — Reallusion CC/iClone `CC_Base_*` FBX (60fps; rejects
2-frame `_TempMotion` exports as a guard against motion-less takes)
- `mixamo_retarget.py` — Mixamo FBX (prefix auto-detect)
- `kevin_retarget.py` — Kevin Iglesias Unity FBX (`B-*` rig; hardcoded
target path, non-portable)
- `mocap_retarget.py` — MediaPipe pose JSON → GLB (rotation solving, no
source armature)
`exchange/converted-glb/`.
3. **Loop QC**`tools/loop_qc.py` (pre-commit gate: pose/velocity/root-drift
continuity across the seam, exit 0 = smooth). Fixers when it fails:
`tools/loop_fix.py` (de-drift/trim/seam-blend → `exchange/looped-glb/`) and
`tools/pingpong_bake.py` (palindrome bake, seam-perfect but motion reverses
at the turn → `exchange/pingpong-glb/`, clip suffix `PP`). Every adopted
dance ships as a base clip + `_pp` pair.
4. **Ship** — commit + `tinqs push` the GLBs in this repo, then copy into
`ariki-game/assets/quaternius/dancegen/` (auto-discovered by ClipCatalog as
`<pack>/<ClipName>` — also watches `kevin/`, `mixamo/`), write one dance JSON
per clip in `ariki-game/assets/dances/`, `game.sh import`, verify clip names
match GLB `animations[].name` exactly, commit in ariki-game (push only when
Jeremy asks).
5. **Post-ingest QC (game side, out of this repo)**`ariki-game/tools/anim_qc.py`
re-checks world-space seam continuity + renders a seam-cross video for eye
review. Two QC tools by design: this repo's gate is pre-commit/local-space,
the game's is post-ingest/world-space.
## Naming law
Full detail: `.agents/wiki/dances/REGISTRY.md` (source of truth — do not
duplicate its content elsewhere). Summary: new takes get a provisional `nd_##`
id (never a content-derived name); a take is renamed to its ceremony code
(snake_case of the sim's `DanceType` enum + index) only once Jeremy assigns it
to a specific ceremony, via `tools/rename_clip.py`. Rejected/superseded takes
are archived (`archive/`), never deleted; numbers are never reused.
## Canonical target rig
All retargeters bake onto ariki-game's
`assets/quaternius/base-characters/.../Godot - UE/Superhero_Male_FullBody.gltf`.
Run from this repo, `--target` must be passed explicitly (the tool's default
path is ariki-relative and won't resolve here). The rig/pack standard itself
(65-bone Quaternius UE-named skeleton, hero body `Ariki_Female_QuatSkin.glb`) is
governed by `ariki-game/.humans/animation-bone-sources.html` and
`ariki-game/.agents/wiki/quaternius-ual-packs.md` — this repo's iClone/mocap
lane is explicitly a separate clip source (not UAL1/UAL2/Kevin/Mixamo), landing
in `dancegen/` only.
## Tool provenance / mirroring
`tools/cc_retarget.py`, `mixamo_retarget.py`, `kevin_retarget.py`,
`mocap_retarget.py` are **mirrors of `ariki-game/tools/`** — the game copies are
authoritative; re-copy from ariki-game when they change (they have silently
diverged before). `.claude/skills/` here mirror a subset of `ariki-game/.claude/skills/`
plus `~/.claude/skills/pose-estimation` — see `README.md` Provenance section.
## See also
- `.agents/wiki/architecture/` — per-system detail (currently: this file covers
the whole pipeline; split out if a subsystem grows its own doc).
- `.agents/wiki/dances/REGISTRY.md` — naming/registry source of truth.
- `.agents/wiki/iclone-bridge.md` — PC-lane routing stub.
- `.agents/wiki/devops-reports/` — point-in-time audits and convergence reports.
+12
View File
@@ -0,0 +1,12 @@
# architecture/ — per-system detail
This repo's pipeline is small enough that `.agents/wiki/ARCHITECTURE.md` covers
it end to end today; nothing has grown large enough yet to need its own
per-system doc here. Split a topic out into this folder (and link it from
`ARCHITECTURE.md`) when it does — likely candidates as the repo grows:
- Retarget-tool internals (per-source rig mapping, bone-name conventions).
- Loop QC/fix algorithms (pose-gap, velocity-gap, root-drift math).
- The exchange-folder state machine (`exchange/GUIDE.md` currently owns this —
promote here if it needs architecture-level treatment beyond an operator
guide).
+95
View File
@@ -0,0 +1,95 @@
# Dance registry — single source of truth for names
Every dance clip in the pipeline is tracked here. **Numbers are never reused.**
## Naming system
Names follow the **game's item-id convention: lowercase snake_case** (like
`ceremonial_garb`, `dance_platform`, `war_challenge`).
**Stage 1 — provisional (new dances).** Every new take gets the next free `nd_##`
number at conversion time, regardless of what the PC-side export was called:
pack/file `nd_08`, clip `ND08`, dance JSON `nd_08.json`. Variants keep the base id +
suffix: `nd_08_pp` / `ND08PP`. The `nd` prefix means "not yet adopted — subject to
rename or archive". Never name a new dance after its content (a take called
"firedance1" turned out to be dance1's choreography — content-guessed names lie).
**Stage 2 — adopted (chosen for a specific dance ceremony).** Dance codes are the
**game ceremony** the dance belongs to — snake_case of the sim's `DanceType` enum
(`sim/arena/src/GameSystems.cs`) + a two-digit index for multiple takes per ceremony:
pack `war_dance_01`, clip `WarDance01` (PascalCase, matching the enum), dance JSON
`war_dance_01.json`, variants `war_dance_01_pp` / `WarDance01PP`. A take is only
renamed once Jeremy says which ceremony it corresponds to. Rename with:
```bash
/Applications/Blender.app/Contents/MacOS/Blender --background --python tools/rename_clip.py -- \
--src exchange/<dir>/nd08.glb --out exchange/<dir>/haka01.glb --name Haka01
```
…then rename the dance JSON (+ its `name` and `clip` fields) in ariki-game, re-run
`game.sh import`, and `git mv` the old files out. Update the row below.
**Loop rule.** Every adopted dance ships as a PAIR: the base clip (`war_dance_01`) plus its ping-pong loop variant (`war_dance_01_pp` / `WarDance01PP`, baked with `tools/pingpong_bake.py`, must pass `loop_qc.py`). No adoption is complete without the loop.
**Archive.** Rejected/superseded takes move to `archive/` (this repo) and
`archive/dances/` (ariki-game, `.gdignore`d) — never deleted, never renamed.
## Ceremony slots (from the sim's `DanceType` enum + tech tree)
| ceremony code | Age | status | take |
|---|---|---|---|
| `water_dance_01` | 1 | awaiting take | — |
| `fire_dance_01` | 1 | awaiting take | — (the old "firedance1" was mislabeled dance1 choreography) |
| `tide_dance_01` | 1 | ⚠ **no enum entry** | — (no `TideDance` in the sim `DanceType` enum as of 2026-07-23 — confirm with Ozan whether the slot is real before filming a take) |
| `fertility_dance_01` | 1 | **adopted** 2026-07-16 | aloha1 (hula) — shipped with `_pp` loop |
| `food_dance_01` | 1 | awaiting take | — |
| `taming_dance_01` | 1 | **adopted** 2026-07-16 | dance1 — shipped with `_pp` loop |
| `sailing_dance_01` | 2 | awaiting take | — |
| `war_dance_01` | 2 | **adopted** 2026-07-16 | hakadance1 (haka) — shipped with `_pp` loop |
| `building_dance_01` | 2 | awaiting take | — |
| `colony_shield_dance_01` | 2 | awaiting take | — |
| `age_progression_dance_01` | 3 | awaiting take | — (gates Age advancement) |
| `star_watching_dance_01` | 3 | awaiting take | — |
| `rain_dance_01` | 1 | awaiting take | — (tech-tree "Rain Maker"; `RainDance` **is** in the DanceType enum, Age 1 — earlier "not in enum yet" note was wrong, corrected 2026-07-23) |
## Register
| id | status | pack(s) | clip | origin take | notes |
|---|---|---|---|---|---|
| nd_01 | **provisional** | `nd_01`, `nd_01_pp` | `ND01`, `ND01PP` | baraka1 (iClone, 2026-07-21) | 1800f@60; `_pp` loop passes QC (0.00 seam); awaiting ceremony assignment |
| fertility_dance_01 | **adopted** | `fertility_dance_01`, `fertility_dance_01_pp` | `FertilityDance01`, `FertilityDance01PP` | aloha1 (iClone, 2026-07-15) | hula |
| taming_dance_01 | **adopted** | `taming_dance_01`, `taming_dance_01_pp` | `TamingDance01`, `TamingDance01PP` | dance1 (iClone, 2026-07-15) | |
| war_dance_01 | **adopted** | `war_dance_01`, `war_dance_01_pp` | `WarDance01`, `WarDance01PP` | hakadance1 (iClone, 2026-07-15) | haka |
| — | archived | `hakadance2` | `HakaDance2` | hakadance2 | near-duplicate of hakadance1 take family |
| — | archived | `alohaOG` | `AlohaOG` | alohaOG | superseded by aloha1 |
| — | archived | `firedance1` | `FireDance1` | firedance1 | **same choreography as dance1** — mislabeled export |
| — | archived | `hakaOG` | `HakaOG` | hakaOG | near-identical to hakadance1 |
| — | archived | `haka_mocap` | `HakaFull` | MediaPipe video mocap (2026-07-13) | pre-iClone experiment |
Next free provisional number: **nd_02** (nd_01 = baraka1, issued 2026-07-21).
## Boat actions (avatar action clips — direct names, no provisional stage)
Staging prop: `exchange/outgoing-props/boat/boat_prop.fbx` (built by
`tools/export_boat_prop.py`; see its README for iClone import + filming notes).
Cyclic clips (`*_loop`, `*_idle`) must pass `loop_qc.py` before shipping; one-shots
(dive/climb/collect) keep their pelvis translation (game moves the avatar).
| action code | clip | status | notes |
|---|---|---|---|
| `boat_dive` | `BoatDive` | awaiting take | airborne — hand-key/ActorCore or takeoff-only filming |
| `boat_climb` | `BoatClimb` | awaiting take | film over ~0.65 m proxy; Reach-target cleanup |
| `boat_collect` | `BoatCollect` | awaiting take | kneel over gunwale, flotsam grab |
| `boat_row_loop` | `BoatRowLoop` | awaiting take | helm, stern-oar sweep ±18° |
| `boat_fish_loop` | `BoatFishLoop` | awaiting take | Seat_Fisher; coexists with kevin Fishing01_Loop |
| `boat_sail_idle` | `BoatSailIdle` | awaiting take | sway/line-hauling loop |
| *(unregistered)* | `CanoeDismount`, `CanoeSitIdle` | **live in-game** since 2026-07-21 | shipped as `ariki-game/assets/quaternius/dancegen/canoedismount1.glb` (content-guessed name, predates this table) — register under a `boat_*` code at next adoption pass |
| *(unregistered)* | `run_to_dive` | **live in-game** | Mixamo one-off at `ariki-game/assets/quaternius/mixamo/run_to_dive.glb` — outside this pipeline, listed for completeness |
## QC tool split (audit 2026-07-23)
Two QC tools exist and gate different stages — both stay:
- **`tools/loop_qc.py` (this repo)** — the **pre-commit loop gate**: local-rotation pose gap,
velocity gap, root drift; exit 0/1. Every `_pp` (and `*_loop`/`*_idle` boat clip) must pass
it before adoption — this is the "must pass loop_qc" rule above.
- **`ariki-game/tools/anim_qc.py`** — **post-ingest QC** on the game side: world-space seam
position/velocity, mid-clip stutter scan, seam-cross video render for eye review.
+58
View File
@@ -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** | ~4950 m each | The stars of the dance — huge, near-constant sweeps |
| 2 | Forearms | ~3233 m | Big arcs from the elbow reinforce the hand work |
| 3 | Shins | ~2526 m | Busy legs — steps, lifts, direction changes |
| 4 | Upper arms / feet | ~1821 m | Full-shoulder arm swings; active but grounded feet |
| 5 | Head / spine / pelvis | ~1617 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.72.5 m per axis — meaning full-wingspan sweeps that go both
**high** (≈1.51.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 **~100103 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.*
@@ -0,0 +1,193 @@
{
"name": "fertility_dance_01",
"glb": "exchange/converted-glb/fertility_dance_01.glb",
"duration_s": 30.0,
"fps": 24,
"frames": 720,
"bpm": 100,
"beats": 50,
"energy_per_beat": [
0.0671,
0.1187,
0.066,
0.0813,
0.1161,
0.234,
0.3293,
0.4352,
0.4132,
0.3849,
0.4206,
0.4606,
0.4989,
0.4251,
0.4806,
0.4737,
0.5986,
0.5101,
0.2858,
0.6089,
0.5596,
0.6887,
0.8083,
0.5382,
0.4154,
0.9773,
1.034,
0.5643,
1.1262,
0.4652,
0.4643,
0.9822,
1.2955,
0.6826,
0.9628,
1.3345,
0.9006,
0.5145,
0.6956,
0.6763,
0.3807,
0.4143,
0.4418,
0.489,
0.5304,
0.6733,
0.5457,
0.7964,
0.5111,
0.3799
],
"energy_norm": [
0.0009,
0.0415,
0.0,
0.012,
0.0395,
0.1325,
0.2075,
0.2911,
0.2737,
0.2514,
0.2795,
0.3111,
0.3413,
0.2831,
0.3269,
0.3214,
0.4199,
0.3501,
0.1733,
0.428,
0.3891,
0.4909,
0.5852,
0.3722,
0.2755,
0.7184,
0.7631,
0.3928,
0.8358,
0.3147,
0.314,
0.7223,
0.9693,
0.4861,
0.707,
1.0,
0.6579,
0.3535,
0.4963,
0.4811,
0.248,
0.2745,
0.2962,
0.3335,
0.3661,
0.4788,
0.3782,
0.5758,
0.3509,
0.2475
],
"accent_beats": [
6,
10,
11,
19,
20,
22,
23,
24,
25,
26,
28,
29,
30,
31,
32,
33,
34,
35,
36,
38,
39,
40,
44,
45,
47
],
"pelvis_drop_beats": [
6,
10,
11,
23,
24,
25,
26,
28,
29,
30,
31,
34,
39,
40,
44,
47
],
"hand_peak_beats": [
19,
20,
22,
25,
26,
28,
32,
33,
34,
35,
36,
38,
39,
45,
47
],
"phrase_beats": 3,
"totals": {
"hands_m": 26.653432767023645,
"forearms_m": 21.23157808829689,
"upper_arms_m": 13.863015059740327,
"feet_m": 9.769735456958077,
"shins_m": 13.50568795659558,
"pelvis_m": 10.606148213798214,
"head_m": 11.129225506410268,
"lr_symmetry_hands": 0.9986419895043533,
"lr_symmetry_feet": 0.9833129850891342,
"pelvis_vertical_range_m": 0.41678035259246826,
"footprint_m": [
0.536299467086792,
0.8529399633407593
]
},
"warnings": []
}
@@ -0,0 +1,200 @@
{
"name": "taming_dance_01",
"glb": "exchange/converted-glb/taming_dance_01.glb",
"duration_s": 30.0,
"fps": 24,
"frames": 720,
"bpm": 100,
"beats": 50,
"energy_per_beat": [
0.9916,
0.7155,
0.7886,
1.0957,
2.4134,
1.3599,
1.4711,
0.5712,
0.885,
0.9835,
2.12,
0.73,
0.3254,
0.9716,
1.4584,
0.8395,
0.368,
1.2858,
0.6909,
1.0359,
1.7734,
0.7137,
0.976,
1.7173,
1.3732,
0.6937,
0.444,
0.9441,
0.9548,
0.8954,
1.6452,
0.5804,
0.2176,
0.3835,
0.9297,
0.45,
0.4495,
1.0133,
0.8501,
0.9266,
1.2257,
1.0999,
0.338,
0.7127,
1.1935,
0.3326,
0.7522,
1.4484,
2.0448,
2.5482
],
"energy_norm": [
0.3321,
0.2137,
0.245,
0.3768,
0.9422,
0.4901,
0.5378,
0.1517,
0.2864,
0.3287,
0.8162,
0.2199,
0.0463,
0.3235,
0.5324,
0.2669,
0.0646,
0.4584,
0.2031,
0.3511,
0.6676,
0.2129,
0.3254,
0.6435,
0.4959,
0.2043,
0.0972,
0.3117,
0.3163,
0.2908,
0.6126,
0.1557,
0.0,
0.0712,
0.3055,
0.0998,
0.0995,
0.3414,
0.2714,
0.3042,
0.4326,
0.3786,
0.0517,
0.2124,
0.4187,
0.0494,
0.2294,
0.5281,
0.784,
1.0
],
"accent_beats": [
0,
1,
3,
4,
6,
9,
10,
11,
13,
14,
15,
17,
19,
20,
22,
23,
24,
27,
34,
36,
37,
40,
41,
43,
44,
47,
48,
49
],
"pelvis_drop_beats": [
1,
11,
14,
15,
22,
34,
36,
37,
48,
49
],
"hand_peak_beats": [
0,
1,
3,
4,
6,
9,
10,
13,
15,
17,
19,
20,
22,
23,
24,
27,
34,
37,
40,
41,
43,
44,
47,
48,
49
],
"phrase_beats": 10,
"totals": {
"hands_m": 49.32286386602802,
"forearms_m": 32.85868643786276,
"upper_arms_m": 20.531188024591692,
"feet_m": 18.62118179928407,
"shins_m": 25.7590113640975,
"pelvis_m": 16.053067387300725,
"head_m": 17.234514413654498,
"lr_symmetry_hands": 0.9871641314548004,
"lr_symmetry_feet": 0.8839844668246384,
"pelvis_vertical_range_m": 0.9356732368469238,
"footprint_m": [
1.2194406312191859,
1.6680276617407799
]
},
"warnings": []
}
@@ -0,0 +1,208 @@
{
"name": "war_dance_01",
"glb": "exchange/converted-glb/war_dance_01.glb",
"duration_s": 30.0,
"fps": 24,
"frames": 720,
"bpm": 100,
"beats": 50,
"energy_per_beat": [
0.1161,
0.0602,
0.0763,
0.0628,
0.6339,
0.7965,
0.8297,
0.6782,
0.1496,
0.0932,
0.1334,
0.8858,
0.9878,
0.8822,
0.2595,
0.1081,
0.1336,
0.2622,
0.2088,
0.232,
0.1623,
0.1525,
0.1841,
0.4061,
0.4191,
0.4014,
0.3346,
0.3014,
0.6334,
0.4097,
0.6412,
0.3384,
0.1109,
0.5587,
0.2789,
0.132,
0.588,
0.2621,
0.5738,
0.399,
0.4924,
0.4509,
0.6284,
0.3405,
0.0878,
0.0258,
0.026,
0.0,
0.0,
0.0
],
"energy_norm": [
0.1176,
0.0609,
0.0772,
0.0635,
0.6418,
0.8063,
0.84,
0.6866,
0.1514,
0.0944,
0.1351,
0.8967,
1.0,
0.8931,
0.2627,
0.1094,
0.1352,
0.2654,
0.2114,
0.2348,
0.1643,
0.1544,
0.1863,
0.4111,
0.4242,
0.4064,
0.3387,
0.3051,
0.6413,
0.4147,
0.6491,
0.3426,
0.1122,
0.5656,
0.2823,
0.1336,
0.5952,
0.2654,
0.5809,
0.404,
0.4985,
0.4564,
0.6361,
0.3447,
0.0889,
0.0261,
0.0263,
0.0,
0.0,
0.0
],
"accent_beats": [
4,
5,
6,
7,
11,
12,
13,
17,
20,
22,
23,
25,
26,
28,
29,
30,
31,
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
44,
46,
48,
49
],
"pelvis_drop_beats": [
6,
11,
13,
17,
20,
22,
23,
25,
26,
28,
29,
31,
32,
34,
35,
37,
38,
40,
41,
42,
44,
46,
48,
49
],
"hand_peak_beats": [
4,
5,
6,
7,
11,
12,
13,
28,
30,
33,
36,
38,
39,
40,
42
],
"phrase_beats": null,
"totals": {
"hands_m": 21.006407802644276,
"forearms_m": 12.905461711283007,
"upper_arms_m": 6.91306398100012,
"feet_m": 1.521721729225423,
"shins_m": 5.302736997519221,
"pelvis_m": 5.633388137876223,
"head_m": 6.2322590229562564,
"lr_symmetry_hands": 0.9744965493013217,
"lr_symmetry_feet": 0.6518710884905415,
"pelvis_vertical_range_m": 0.19754374027252197,
"footprint_m": [
0.2731027752161026,
0.1643172651529312
]
},
"warnings": []
}
@@ -0,0 +1,87 @@
# DevOps Report — Animation Standard Convergence (2026-07-23 → 24)
**Author:** Claude (Fable 5), Jeremy's session · **Repos:** `animation` + `ariki-game`
**Trigger:** Ozan landed the animation standardization contract on ariki-game main
(2026-07-23: `5409a82e0`, `3d7f3ef94`); Jeremy requested a full conformance assessment
of our lane (dance pipeline, this bridge repo, test beds), then execution of the fixes.
---
## 1. What the standard says (one paragraph)
One runtime skeleton — the Quaternius 65-bone UE-named rig. Hero body =
`Ariki_Female_QuatSkin.glb` (Mixamo weights on Quaternius rest orientations; the earlier
MixamoSkin+msrig pairing is superseded). Clip policy: UAL1 Pro = locomotion, UAL2 Source =
work (under `ariki-game/assets/quaternius/anim/`), Kevin packs gaps-only, Mixamo one-offs,
and **our iClone/video-mocap lane is explicitly separate**: `cc_retarget.py`
`dancegen/` → dance JSON. Canonical docs: `ariki-game/.humans/animation-bone-sources.html`,
wiki `player-quaternius-animator.md` + `quaternius-ual-packs.md`, skill
`aaa-player-animation`.
## 2. The assessment (how it ran)
- 52 checkable rules extracted from the 7 standard docs.
- 10 audit roles, model-routed: GLM-5.2 ×2 (inline-input tasks), DeepSeek v4-pro ×5
(code/doc/bed sweeps; one run died and was completed by the orchestrator), Fable ×3
(wiring, dance system, synthesis). Every CRITICAL/HIGH re-verified mechanically.
- Bone-census probe over 21 GLBs (this repo's `exchange/{converted,pingpong}-glb/` +
ariki `dancegen/` + the three body GLBs).
- Full report: `ariki-game/.agents/audits/animation-standard-assessment-2026-07-23.md`;
meeting page: `ariki-game/.humans/animation-standard-assessment.html`.
**Result:** the pipeline core was already conformant — 100% of runtime clip GLBs on the
65-bone contract, 4/4 `_pp` loop pairs, all dance JSONs clean post-rename, 16/17 test
beds compliant. The defects clustered in the superseded MixamoSkin/msrig mechanism and
stale doc/tool mirrors.
## 3. What we shipped
### This repo (`b4d9621`, pushed 2026-07-23)
| Change | Why |
|---|---|
| `tools/cc_retarget.py` + `mixamo_retarget.py` re-synced from ariki-game | They were stale forks (2026-07-15) silently missing `--despike`, `--fingers`, `--map ual` — PC batches would have lost QC |
| Tool **mirroring rule** added to `exchange/GUIDE.md` | Game copies are authoritative; never edit mirrors here |
| `tools/kevin_retarget.py` path fix (mirrored) | Was hardcoded to Ozan's home dir |
| `exchange/INBOX/lena/Ariki_Female_QuatSkin.glb` added (LFS) + README rewrite | The drop shipped only the superseded MixamoSkin body; **import the QuatSkin one on the PC** |
| `docs/dances/REGISTRY.md` corrections | `tide_dance_01` flagged (NO `TideDance` in the sim enum); rain note fixed (`RainDance` IS in the enum); boat table now records live `canoedismount1`/`run_to_dive`; QC split documented (loop_qc = pre-commit gate here, anim_qc = post-ingest game-side) |
| `docs/iclone-bridge.md` created | GUIDE referenced it; it never existed |
Earlier same day (`998e306`): the original Lena INBOX drop (GLB + rigged/unrigged T-pose
FBX + toned textures).
### ariki-game (`b70d5a11c` + `934b6d4dc`, pushed)
- **Dance bed `LenaBodyPath` → QuatSkin** (the bed had gone Lena-only on the superseded
MixamoSkin body whose msrig clip-reroute is dead — basename rot after the Pro rename).
Build green.
- Doc convergence: clip-inventory wiki corrected (23→30 wired UAL2; shield/sword set was
mislabeled "unused"), UALPACKS "Standard removed" claim scoped, `animation-pipelines.md`
coverage extended (+`anim-combined/ msrig/ derived-bodies/`), `pose-estimation` skill
reference re-pointed to THIS repo.
- Bug filed for Ozan: `.agents/bugs/2026-07-23-ninjajump-idle-missing-clip.md`
code requests `NinjaJump_Idle`, pack ships `NinjaJump_Idle_Loop`, resolver can't bridge.
## 4. Live verification (2026-07-24)
Screenshot-verified on a running dance bed (post-fix build): QuatSkin Lena dancers
upright, phase-locked, `war_challenge` playing on the stock clip libraries. Engine log
carries **zero** `MISSING clip` warnings → the NinjaJump bug is **latent** (current
Godot import config strips `_Loop`), not active; hardening still recommended. Bug file
updated with this evidence (`934b6d4dc`).
## 5. Open items (owners)
| Item | Owner |
|---|---|
| Import `INBOX/lena/Ariki_Female_QuatSkin.glb` on the PC (not the MixamoSkin one) | Jeremy |
| Retire `msrig/` + MixamoSkin GLB outright (recommended) or re-retarget under new basenames | Ozan (negotiate) |
| Amend the Kevin-swim keep-set rule to match the restored swim override in code | Ozan (negotiate) |
| Add derived/import packs (Run_Punch, mixamo one-offs, canoe clips) to the BONES tables | Ozan (negotiate) |
| NinjaJump 1-token fix + resolver `+_Loop` arm | Ozan (bug filed) |
| Register `canoedismount1` under a `boat_*` code at next adoption pass | Jeremy (this repo) |
## 6. Ops notes
- All syncs via `tinqs pull`/`tinqs push` (LFS-safe); QuatSkin GLB verified as an LFS pointer.
- Executor telemetry: GLM headless = 1 clean / 1 silent-empty; DeepSeek = 4 clean / 1 SIGTERM;
Fable tracks found both deep bugs (dead msrig reroute, NinjaJump). Full disclosure table
in the audit report.
+18
View File
@@ -0,0 +1,18 @@
# iClone bridge — PC-lane overview
(Stub created 2026-07-23 — this path was referenced by `exchange/GUIDE.md` but never
written. The full operational detail lives elsewhere; this page just routes you.)
The Mac↔PC animation bridge: iClone 8 + Video Mocap on Jeremy's Windows machine authors
motion; this repo carries files both directions; ariki-game consumes 65-bone Quaternius
GLBs.
| Need | Go to |
|---|---|
| Which exchange/ folder to use, when files move/archive | `exchange/GUIDE.md` |
| Naming (nd_## → ceremony codes), loop-pair rule, registry | `.agents/wiki/dances/REGISTRY.md` |
| Filming rules, iClone/AccuFACE settings, mocap cleanup, FBX export | `iclone-video-mocap` skill (`.claude/skills/` here and in ariki-game) |
| CC FBX → Quaternius GLB conversion | `tools/cc_retarget.py` (mirror of ariki-game — game copy is authoritative) |
| Looping (`_pp`) + QC gates | `tools/pingpong_bake.py` + `tools/loop_qc.py`; game-side `anim_qc.py` (split documented in REGISTRY.md) |
| Game-side landing zone | `ariki-game/assets/quaternius/dancegen/` + `assets/dances/*.json` |
| The skeleton/pack standard itself | `ariki-game/.humans/animation-bone-sources.html` + `.agents/wiki/quaternius-ual-packs.md` |
+56
View File
@@ -0,0 +1,56 @@
# Master plan — animation repo
What's in flight and recently shipped. See `.agents/plans/` for the closed-out
plan/results docs behind these items; see `.agents/wiki/dances/REGISTRY.md` for
the live per-clip status table (source of truth, not duplicated here).
## Recently shipped (closed plans, folded into wiki)
- **Animation-gen pipeline** (2026-07-13) — the FBX→Blender-retarget→GLB→dance-JSON
bridge itself; core of `.agents/wiki/ARCHITECTURE.md` today.
- **Animation skills adoption** (2026-07-13) — `.claude/skills/animation*`,
`iclone-video-mocap`, `pose-estimation` brought in as repo skills.
- **Loop fix** (2026-07-16, plan + results) — `tools/loop_fix.py` (de-drift/trim/
seam-blend) and `tools/pingpong_bake.py` shipped; every adopted dance now ships
as a base clip + `_pp` pair, gated by `tools/loop_qc.py`.
- **Boat prop export** (2026-07-17, plan + fixes + results) — `tools/export_boat_prop.py`
staging pipeline for the boat action-clip set (see "Boat actions" below).
- **Animation-standard convergence** (2026-07-23/24, `.agents/wiki/devops-reports/`) —
full audit against ariki-game's animation-standardization contract. Result:
pipeline core already conformant (100% of runtime clip GLBs on the 65-bone
Quaternius rig, all `_pp` pairs present, dance JSONs clean). Fixes landed:
registry corrections (`tide_dance_01` has no sim enum entry — flag before
filming; `rain_dance_01` confirmed in-enum), `iclone-bridge.md` written
(previously a dead reference), QC-tool split documented (`loop_qc.py` here =
pre-commit gate, `ariki-game/tools/anim_qc.py` = post-ingest gate).
- **agents.md migration** (2026-07-24, this change) — repo restructured onto the
open agent protocol: root `AGENTS.md` (thin) + `.agents/` (SOUL, wiki, plans,
skills-pointer). `docs/` and root `plans/`/`devops-reports/` folded into
`.agents/wiki/`.
## In flight — ceremony dance slots awaiting a take
Per `.agents/wiki/dances/REGISTRY.md`, adopted so far: `fertility_dance_01`
(hula), `taming_dance_01`, `war_dance_01` (haka) — each shipped with its `_pp`
loop. One provisional take (`nd_01`, baraka1) is filmed and QC-passed but not
yet assigned to a ceremony. Still **awaiting a take**: `water_dance_01`,
`fire_dance_01`, `food_dance_01`, `sailing_dance_01`, `building_dance_01`,
`colony_shield_dance_01`, `age_progression_dance_01`, `star_watching_dance_01`,
`rain_dance_01`. `tide_dance_01` has no confirmed sim `DanceType` entry — resolve
with Ozan before filming.
## In flight — boat action clips
Staging tooling (`tools/export_boat_prop.py`) is built and the boat prop rig
verified; the action-clip set itself is **awaiting takes**: `boat_dive`,
`boat_climb`, `boat_collect`, `boat_row_loop`, `boat_fish_loop`,
`boat_sail_idle`. Two boat-adjacent clips are already live in-game
(`CanoeDismount`/`CanoeSitIdle`, `run_to_dive`) but predate this registry and
need a `boat_*` code assigned at the next adoption pass.
## Open threads
- `tools/kevin_retarget.py` has a hardcoded, non-portable target path — fine for
now, flagged in `AGENTS.md` gotchas.
- `.claude/skills/retarget-animations/` is kept only for history (deprecated,
superseded by the per-source retargeters in `tools/`).