- Per-animal manifest defining the entire animation contract (artboard, ViewModel, state machine, group map, viseme inventory, phoneme→viseme mapping)
- Wiring step that emits ~1,200 keyframe calls into a
.rivfrom a 10-prompt deterministic sequence — no hardcoded gesture names anywhere - Kotlin loader that addresses the same state-machine inputs by name — Android consumes the same contract Python produced
- One orchestrator command rebuilds an animal end-to-end
Config-driven from manifest upstream to Kotlin downstream
§1The 7 configs that flow through the pipeline
Every step in the pipeline is parameterized by config. The graph of who reads what:
| Config file | Role | Written by | Read by |
|---|---|---|---|
data/gestures_config.json |
Gesture → video map | Hand-edited (or --add) |
rest_pose_model · schema_v3 |
data/rest_pose.json |
Universal rest baseline | rest_pose_model.py |
generate_gesture_schema_v3.py |
data/gesture_schema_v3.json |
Per-gesture magnitudes | generate_gesture_schema_v3.py |
motion_curves · auto_gesture_specs · audit · postwire_eval |
data/motion_curves.json |
Temporal motion shapes | extract_motion_curves.py |
auto_gesture_specs.py |
data/auto_gesture_specs.json |
Engine-ready track specs | auto_gesture_specs.py |
wire_from_manifest.py |
configs/parameter_mapping_spec.json |
Gesture ↔ runtime input contract | Hand-authored (per animal) | Wiring, Kotlin (indirectly via runtime inputs) |
samples/<animal>_rive_assets/ |
Per-animal asset contract | Hand-authored when SVG art lands | Wiring, validators, Kotlin loader |
Three contracts I'll show on this page: the per-animal manifest (§2), the wiring step that consumes it (§3), and the Kotlin loader that reads the same state-machine inputs the manifest declares (§5).
§2The per-animal manifest
One file per animal. Hand-authored once, when the SVG art lands. It declares everything the rest of the pipeline needs to know about this animal — artboard size, ViewModel name, state machine name, the names of the SVG groups for each body part, the viseme inventory, and the phoneme-to-viseme mapping that lip-syncs the animal to the phonics audio.
// new_pipeline/schemas/canonical_manifest_example.json { "asset_name": "Example Animal", "artboard": { "name": "Main", "width": 1024, "height": 1024 }, "view_model": { "name": "ExampleVM" }, "state_machine":{ "name": "State Machine 1" }, "group_map": { "root": "root", "body": "body", "head": "head", "arm_left": "arm_left", "arm_right": "arm_right", "leg_left": "leg_left", "leg_right": "leg_right", "ear_left": "ear_left", "ear_right": "ear_right", "pupil_left": "pupil_left", "pupil_right": "pupil_right", "mouth_visemes": "mouth_visemes", "tail": "tail", "trunk": null, "shadow": "shadow", "sparkles": "sparkles" }, "viseme_groups": { "0": "mouthViseme_0_closed", "1": "mouthViseme_1_narrow", "2": "mouthViseme_2_open", "3": "mouthViseme_3_wide", "4": "mouthViseme_4_rounded", "5": "mouthViseme_5_smile" }, "phoneme_viseme_indices": { "P": "0", "B": "0", "M": "0", // closed mouth "S": "5", "Z": "5", "IY": "5", // smile "AA": "3", "AE": "3", "AY": "3", // wide-open ... }, "parts_metadata": { "head": { "parent": "root", "pivot": [512, 320], "bounds": [320, 160, 704, 512], "allowed_props": ["rotation", "x", "y"], "role_class": "core" } } }
The manifest is the contract. Anything that wants to talk about "the chick's left arm" goes through group_map["arm_left"]; anything that wants to celebrate goes through state_machine's isCelebrating input. Renaming a group in the SVG = one manifest edit, no code touched.
§3Wiring as a 10-prompt deterministic sequence
The wiring step is the bridge from data to .riv. It loads a copy of the asset, then emits ~1,200 keyframe calls in a fixed order. The order is mechanical, not heuristic — every animal goes through the same 10 phases:
# scripts/wire_from_manifest.py:780 def build_calls(manifest: dict, riv_path: str, output_path: str, intensity: float = 1.0, speed: float = 1.0) -> list: """Build the complete 10-prompt MCP call list.""" cb = CallBuilder() gm = _build_gm(manifest) # group_map resolution mo = _get_motion(manifest) # motion overrides per animal cb.call("load_riv", {"path": riv_path}) prompt_1_vm_and_sm(cb, manifest) # ViewModel + State Machine prompt_2_verify_groups(cb, manifest) # Verify SVG groups match manifest prompt_3_idle(cb, manifest, gm, mo) # Idle animation prompt_4_blink(cb, manifest, gm) # Blink overlay (independent layer) prompt_5_visemes(cb, manifest, gm) # 6 mouth shapes (independent layer) prompt_6_poses(cb, manifest, gm, mo) # All gesture poses prompt_7_celebrating(cb, manifest, gm, mo) # Celebration (largest gesture) prompt_8_state_machine(cb, manifest) # SM transitions, 3 layers prompt_9_verify(cb, manifest) # Post-wire structural audit prompt_10_export(cb, manifest, output_path) # Export wired .riv return cb.calls
parameter_mapping_spec.group_map name resolves to an SVG group. Fail loud, not silent..riv to the output path. Final phase, gated on prompt 9 passing.The wiring step never touches the original art file — it loads a copy, emits keyframes into the copy, and exports. If anything fails, the original is intact. (This was a Step-0 lesson learned the hard way.)
§4Schema-driven everything — no hardcoded gesture names
The wiring step iterates the schema, not a hardcoded list. The contract module reads gestures dynamically from the manifest:
# new_pipeline/contracts/gestures.py:36 — fallback only, NOT the source of truth _LEGACY_BOOL_INPUTS: dict[str, str] = { "walk_in": "isWalkingIn", "look_at_letter": "isLookingAtTarget", "ask_child": "isLookingAtChild", "waiting": "isListening", "thinking": "isThinking", "encouraging": "isEncouraging", "helping": "isHelping", "celebrating": "isCelebrating", } def gesture_catalog(manifest: Mapping[str, Any] | None = None, defaults: Mapping[str, Any] = DEFAULTS): """Read gestures from manifest dynamically. Legacy dict above is a fallback only.""" return _gesture_catalog(manifest, defaults)
The fallback dict only fires when an old manifest pre-dates the gesture-catalog schema. New manifests carry their own gesture list; the engine reads it.
§5Kotlin consumption — same contract, runtime side
The Android app loads assets/animals/<animal>.riv and addresses the state-machine inputs by name. The names are the same names declared in the per-animal manifest. The contract crosses the Python ↔ Kotlin boundary as a stable schema.
// app/src/main/java/com/readingpractice/ui/components/RiveGuideAnimal.kt:105 private fun resolveRiveSource(animalName: String, context: Context): RiveSource? { if (animalName in ANIMALS_WITH_INDIVIDUAL_RIV) { val individualFile = "animals/$animalName.riv" val exists = try { context.assets.open(individualFile).use { true } } catch (_: IOException) { false } if (exists) return RiveSource(individualFile, "Main", true) } return null } // RiveAnimalController — sets state-machine inputs by name fun setState(state: GuideAnimalState) { if (!hasInputs) return safeBool("isLookingAtTarget", state == GuideAnimalState.WALKING_IN) safeBool("isCelebrating", state == GuideAnimalState.CELEBRATING) safeBool("isEncouraging", state == GuideAnimalState.ENCOURAGING) safeBool("isThinking", state == GuideAnimalState.THINKING) safeBool("isLookingAtChild", state == GuideAnimalState.ASK_CHILD) ... } fun setMouthVisemeRaw(index: Int) { view.setNumberState(STATE_MACHINE, "mouthViseme", index.toFloat()) }
Same string keys end-to-end: isCelebrating declared in the manifest → wired into the state machine by Python → addressed by name from Kotlin. No mapping table, no version skew, no rename surprises.
§6One command — end-to-end rebuild
The orchestrator (scripts/gesture_factory.py) drives the whole pipeline. From a fresh recording to a wired .riv ready to drop into the Android assets:
What runs:
- Step 0 — append
stompingtogestures_config.json - Step 1 —
rest_pose_model.pyrecomputes universal rest if missing - Step 2 —
generate_gesture_schema_v3.pymeasures the new gesture (--gesture stomping) intogesture_schema_v3.json - Step 3 —
extract_motion_curves.pyturns the schema into temporal curves - Step 4 —
auto_gesture_specs.pyturns curves into engine-ready track specs - Step 5 —
gesture_audit.pyvalidates physical limits + semantic rules - Step 6 —
wire_from_manifest.pyemits the 10-prompt sequence into a copy ofchick.rivand exports the wired version toworkbench/chick/chick_wired.riv
Reproducible end-to-end. Every step is independent — failures show which step. Every step writes a config that the next step reads — no implicit state.
§7The May refactor — manifest-first from scratch
The legacy pipeline above was prompt-era — built around mutating an existing .riv file with the Rive scripting interface. The May refactor (new_pipeline/) is the long-term architecture: full .riv construction from scratch in one session, manifest-first.
// new_pipeline/README.md This subtree contains the long-term manifest-first architecture for Rive animal automation. It exists separately from the current prompt-era scripts so the migration is explicit and low-risk. Scope: - canonical asset contract - canonical manifest schema - pack preflight validation - geometry-aware validation - motion-aware validation - manifest-first validation/orchestration - from-scratch .riv build target The production target for new animals is now full .riv construction from scratch in one session.
Subdirectories: builder/, contracts/, validators/, fixtures/, runner/, tests/ — 96 Python files, organized by responsibility. The legacy scripts still drive production today; new_pipeline/ is the destination, with a deliberate parallel-run period to keep migration low-risk.