Claude Skill

scenario-maya-animation

Use when animating a character or prop in Maya through Python: a walk or run cycle, a jump or landing, a heavy lift, throw or punch, an acting or dialogue shot, lip sync, eye darts and blinks, stepped blocking, splining, polish, Graph Editor tangents, animation layers, mocap clea

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download scenario-labs-skills-skills_dcc_maya_scenario-maya-animation-dc592af.zip · 81 KB
Part of scenario-labs/skills — 97 skills

Install

skills CLI npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/maya/scenario-maya-animation
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
Git git clone https://github.com/scenario-labs/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Maya animation (animator)

Expert animation means every frame is a choice: poses that read as drawings from the camera, holds and snaps placed on purpose, designed spacing, weight shown by resistance and a caught stop. Work big to small (story, body, face, detail); never add detail to a stage that does not read yet. An agent cannot scrub, so it measures (world and screen tracks, spacing, holds, contacts, a ballistic fit, overlap lags), then looks at a playblast or a headless sheet before calling anything done. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.

REQUIRED BACKGROUND: scenario-maya-expert (execution channel, review loop, 2027 version traps).

Toolkit: scripts/mx_anim.py. Its analysis and image layer is pure Python and ran offline.

Stance (the expert delta)

  • Pick the workflow per shot; blocking ends when the information is in. Neistadt: pose to pose for acting, straight ahead or extremes for flow; blocking is done when all five key categories exist (main, anticipation, overshoot and follow-through, breakdown, hold). Elver splines physical shots from the start; Camporota proves timing on two boxes first.
  • Maya is "the worst in-betweener in the world" (Newman). Feed it keys: ones on fast actions, 2 to 4 frames apart on slow parts. Spline readiness is a test: spline the range, play; mush means back to stepped with more poses. Native Set Key reshapes neighboring curves (Wade): insert keys, prove it with a curve diff, and share keys across every control before splining.
  • Weight is resistance, then release, then a caught stop. Wade: show what the character is up against (a delay, acceleration from rest) before its strength; strength is a frame count. Elver: more up and down reads as a balloon, not weight; weight is a fall caught abruptly, a bigger hold in the squash, residual jiggle. Heavy air is gravity-true [added physics]; Wade's weighted apex adds hang, for light or cartoony bodies.
  • Feet and spine sell every weight shift (jump, hop, landing, lift). Camporota: animate the foot roll in every crouch, it sells the compression; in a big drop the spine drags behind the hips and only the arms drop; first passes overdo the anticipation, so dial it back; a touch-down goes flat "almost in one frame". Elver: on the first airborne frame the toe tip points back to where it left the ground.
  • Judge in camera space, at speed. Elver: a motion trail can show a perfect 3D arc that fails on screen. Lazare: the eye goes to the biggest motion, so the non-focus character stays "appropriately still". Project tracks through the shot camera; read the playblast before any metric.
  • The base layer is the animation; mocap is precious. Newman: a layer is a time-boxed experiment with marker keys and a protective key; never clean curves on it, merge with Smart Bake, then clean (Elver: only a late global note or an additive tremble, then bake). On mocap fix only the defect asked for, or the cascade ends "not hand key and not mocap"; after a merge, Euler filter, then delete keys down to extremes.
  • Lip sync animates sounds, not words. Santos: jaw only first (the muppet pass), then the big five (jaw, two corners, top and bottom lip) for 90%; lead by place of articulation, bilabials on the frame. Lazare: shift every mouth key 1 to 2 frames earlier only when the whole line plays late.
  • Polish is triage; review is top down. Elver ranks the notes, spends on the ones that pay, owns every contact frame. Newman grades Intent, Mechanics, Performance, Integration; a playblast does not prove game feel.

Establish first

Input Why it changes the plan Default when the brief is silent
Style and destination squash, stops, closeness to reference: cartoony pushes; live action needs pose squash and no dead stops (Elver); VFX stays near reference (Neistadt) realistic feature quality
Frame rate timing numbers, currentUnit 24 fps (film)
Weight and strength frames of resistance and of overcome (Wade: decide both numbers first) heavy: resistance beat of 2+ frames, no hang
Rig control names, IK/FK, spaces, foot roll attribute (map once with listAttr(keyable=True)) hands in IK, no IK/FK or space switches inside a shot (Newman)
Camera a locked shot camera is the only judge; games also need every angle and the engine build the shot camera, lock it
Reference, audio landmarks (contacts, takeoff, apex, landing, blinks, darts) give rhythm, then push (Neistadt) beat list from the brief
Game metrics speeds and heights agreed in round numbers before animating (Newman); root motion or in place (Epic) in place, metrics sheet requested

Workflow

  1. Plan in numbers. Beats with frames, the leading part per beat, key categories, contacts, windows (crouch, push, air, impacts), style; A.store_plan(plan). Physical shots: A.air_frames(rise, drop) budgets the air. Dialogue: phrases with subtext and an action verb, ruled sounds (M, B, P, F, V, L, T, D), 2 to 4 tentpoles (Santos, Wade). GATE: frame counts add up.
  2. Set up. fps, range, sound, shot camera locked; query the tangent defaults (2027 notes). GATE: mx_validate.validate(profile="shot", rules={"fps": 24}) has no fail.
  3. Proxy pass (big, fast or camera-bound moves). A.proxy_boxes(), key translate and lean, air with A.ballistic_keys(), rotation 1 to 2 frames behind (Camporota). The first anticipation is usually too big: A.scale_curve(root, "translateY", 0.8, rest_y, time=crouch). GATE: A.evaluate_gates() on the proxy (ballistic, catch, resistance, push_fastest); proxy_curves.png looked at.
  4. Blocking, stepped. A.set_key_defaults("blocking"); A.key_pose(pose, f, fill=ctrls) keys every control on every pose; passes: main, anticipations, overshoots, breakdowns (favor a neighbor, never 50/50), holds (A.key_hold()); A.share_keys(ctrls). Never act from the neutral rig pose (Neistadt). Crouch poses: foot roll keyed, spine dragging, arms hanging (Camporota). GATE: every key category present; stepped playblast (GUI) or tracks.png; write the story it reads, then check silhouettes.
  5. Spline pass 1, the core. snap = A.snapshot(ctrls) (the buffer curve); A.to_spline() on COG, spine, legs only; face, eyes, fingers stay stepped (Camporota, Santos). Air gravity-true; broken tangent into a hard landing (Newman); planted feet flat. Mush: A.restore(snap), add poses. Every later key: A.insert_key(), then A.curve_diff() on sampled snapshots is ok outside its two segments (P4). GATE: A.run_gates(plan) ballistic, hang, catch, slide, penetration, foot_roll, spine_drag, heel_to_flat pass; no A.overshoots() through holds.
  6. Spline pass 2, overlap. Arms, head, secondary: correlated chains edited together first (Newman), then A.offset_keys() by 1 to 2 frames down the chain. GATE: overlap lags not all zero; pose_to_pose_tell low; tracks clean in camera.
  7. Face, if the shot has one. Body first, then the mask (eyes, brows), then the mouth: muppet jaw keyed at A.mouth_keys(phonemes), big five, tongue, connection by A.copy_curve() (nostrils 1 frame behind the corners, dampened). Eyes linear, 2-frame darts; blinks close faster than they open. GATE: A.jaw_report(), A.lip_lag() against A.audio_envelope(), A.blink_report(), A.transitions(); close-ups on tentpoles.
  8. Polish, by triage. Fix the top-ranked notes, one section at a time. Contacts and fast moves on ones; peel-off on every takeoff; impacts get a pushed squash and a bigger hold; pops become 3 frames on an arc plus an overshoot; A.retime() tightens a section; moving holds; a motion-blurred render before final (all Elver). GATE: every gate passes (peel_off, impact_hold included), layers merged, no sub-frames, Euler filtered, critique.md on the final playblast.

Variants. Cycle: poses every 3 frames, loop with A.cycle() before polish, offsets inside the cycle keep the period. Game clip: metrics, A.cycle_speed(), loop seam, root rules, then scenario-maya-pipeline-scripting. Mocap: procedures P12; retarget: P13; heavy interactions: P9.

Numbers

Value Relative to Source
Gravity 1.703 cm/frame^2 (981/576) 24 fps, scene in cm physics [added]
Walk keys every 3 frames: contact 1, down 4, pass 7, up 10, contact 13; loop 1 to 25 24-frame cycle Camporota
Touch-down to flat foot about 1 frame, at most 2; walk arm offsets 0, 1, 2, head 1 every step and landing Camporota; body digest
Rotation 1 to 2 frames behind translation proxy and body Camporota
Keys on ones on fast actions; 2 to 4 frames apart on slow ones blocking Newman
Blink close 2 / open 4 (or 3 / 5; big eyes one more) frames Elver
Eye dart 2 frames, linear; brows lead lids by 1 to 2, never 5 frames Elver
Snappy move: 3 frames on an arc plus a small overshoot, never 1 frames Elver
Global lip shift 1 to 2 frames earlier; opens near full on frame 1 when late at speed Lazare
Big five = 90% of lip sync; jaw plus corners 50 to 60% controls Santos, Elver
Game speeds: walk 2, run 4 to 6, sprint 12 to 15 m/s stylized player Newman
Playblast default scale 0.5: pass percent=100 active view Maya 2027 Help

Quality gates

Measurable (A.run_gates(plan); pure logic A.evaluate_gates), for any jump, hop, landing or weight shift in the plan: fps; heavy air g_ratio 0.8 to 1.25, hang excess at most 1 frame, constant horizontal speed; catch (60%+ of the fall speed gone in 2 frames, decel shorter than recovery), then a 3+ frame hold at the bottom; push fastest; heavy resistance beat of 3+ still frames; every crouch: foot roll moves, spine lags the hips; touch-downs flat within 2 frames; lift-off toe within 30 degrees of the takeoff spot; planted feet at most 0.2 cm/frame, penetration under 0.5 cm; pre-landing stretch; no pops outside impacts; overlap lags; moving end hold; tell under 90% after splining; no sub-frames; no layers at polish. [added] thresholds are toolkit defaults. Visual: A.review_images(plan, out) (headless: curves.png stacked lanes, tracks.png camera-space tracks with onion-skin poses); in the GUI A.playblast_pack() (shot camera plus a no-lights silhouette) and a stepped-preview playblast. Read frames in time order; write the first-view read before the numbers (critique.md).

Common mistakes

Mistake What it looks like Fix
Splining before the poses are in mush, arms tweened through the body A.restore(snap), add breakdowns stepped
Even spacing, whole body on the same frames floaty, "hitting poses" favor neighbors, holds, offset_keys down the chains
First-pass anticipation kept the crouch upstages the action A.scale_curve(..., time=crouch) about the rest value
Torso locked to the hips, arms swung back in the crouch stiff, generic anticipation spine drags behind the hips, only the arms drop
Feet flat in the crouch, pointing forward at takeoff, slow to flatten on landing weak compression, no push, slapping feet foot roll; toe aimed back at the takeoff spot; flat within 1 to 2 frames
Stylized hang on a heavy body floaty apex ballistic_keys, flat apex, broken contact tangent
No resistance before the effort styrofoam weight 2+ frames of strain before the push
Symmetric dip, or a bounce straight out of the squash light landing sudden catch, bigger hold, one overshoot, slow recovery
S or bare setKeyframe on a splined curve neighbors reshape, holds drift A.insert_key, then A.curve_diff
Spline through a hold drifting hold key_hold pillars, flat or plateau sides
Arcs checked on a 3D trail clean trail, broken arc on screen camera_track pixel tracks
Lip sync keyed on the sound late at speed per-sound lead, then a global shift

Handoffs

Receives from scenario-maya-rigging and scenario-maya-deformation: the rig referenced (never imported), validate(profile="rig") clean, the control list with IK/FK, space and foot roll attributes, a range-of-motion playblast. Rig problems (knee pop, flipping space) go back as a note with frames. Delivers shots to scenario-maya-lighting-rendering: a new saved version, validate(profile="shot") clean, layers merged, helpers deleted (A.delete_helpers()), run_gates JSON with no fail, the final playblast the agent looked at, what was not verified. Game clips to scenario-maya-pipeline-scripting: skeleton baked per clip, clip names and ranges (bookmarks), root rule (in place or root motion), loop seam and speed reports.

Maya 2027 notes

  • Default tangent: Auto Span (Legacy) on one doc page, Clamped on another: query keyTangent(q=True, g=True, itt=True). Lock/Free Tangent Weight is now Tangent Length; Butterworth is Smooth Filter (Butterworth).
  • Time Slider > Enable Stepped Preview; A.stepped_preview() is the scripted twin. Dope Sheet Time Snap is off since 2025.1 and scaleKey leaves sub-frames: A.snap_subframes().
  • Ghost Editor and Motion Trail Editor sit under Visualize; ghosting a skinned mesh needs Cached Playback (disabled by motion blur, Trax, classic dynamics).
  • Playblast needs a model panel (GUI via mx_bridge); in mayapy use review_images or Arnold stills.
  • Sequencer (ex Camera Sequencer): lock every camera. HumanIK bakes from its own window, not Bake Simulation. Time Editor clips never mix with animation layers. Blue Pencil replaced Grease Pencil.

References

Files (skills)
  • references
    • critique.md 19.6 KB
      # Critique rubric: how these animators judge a shot
      
      Use at every gate. Supervisors judge what reads on first viewing (Lazare, m8YPvVtfLhw [00:13:36]), so the agent looks first and measures second, then reports which items were measured and which were judged by eye.
      
      ## 1. The review pack (render it, then open every image)
      
      1. **Time-ordered frames through the shot camera.** GUI: `A.playblast_pack(out, s, e, cameras=("shotCam",))`, plus the stepped-preview pass in blocking. Headless: `tracks.png` from `A.review_images(plan, out)`. Read every 2nd frame for the whole shot, then every frame on flagged ranges.
      2. **Silhouette pass** (no lights, black shapes; Camporota FA7fPB7qUhE [00:06:07], Elver jzuxAmadcm8 [01:03:43]).
      3. **Stacked curve lanes** `curves.png` (Wade TMSpauVphNs [00:03:55]): holds flat but not dead, eases where planned, face still stepped in body passes.
      4. **Numbers**: `A.format_gates(A.run_gates(plan))`, plus `A.format_report(A.motion_report(track, frames))` on COG, head, hands, feet, in world and in camera pixels.
      5. **Close-ups** for faces: frames on tentpoles and ruled sounds (M, B, P, F, V, L, T, D).
      6. **Progression**: the previous version and this one side by side (never overwrite a playblast folder; version it).
      7. **Rendered read** before final polish: motion-blurred low-resolution frames around fast moves (Elver [00:27:19]).
      
      ## 2. First-view read (write it before any metric)
      
      Write three lines from the time-ordered frames alone: what happens, where the eye goes on each beat, how many beats read. Then compare with the plan's intent sentence and beat count. A mismatch is an Intent note and outranks everything below it (Newman 7r_Czt3-SKE [00:05:23]). "I caught it on the second or third viewing" means restage, not more animation (Lazare [00:13:36]).
      
      ## 3. The rubric, in supervisor order
      
      Each row: what the expert looks at, the source, the measurable proxy, what to look for in the images.
      
      **Intent and staging**
      
      | Look at                                                          | Source                                             | Measure                                                                                  | See                                           |
      | ---------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------- |
      | The shot says what the brief says; story readable stepped        | Newman [00:05:23]; Neistadt GMTet6nd_iM [00:03:14] | plan beats vs key poses                                                                  | stepped pass tells the gist                   |
      | Attention: the biggest motion is on the focus                    | Lazare [00:05:07]                                  | screen-space motion energy per character per beat (`camera_track` speeds summed) [added] | the non-focus character "appropriately still" |
      | Lead with what should read (hand before body, head out of frame) | Lazare [00:14:09] [00:24:46]                       | `peak_order` of the leading part vs the body                                             | the lead is visible first                     |
      | Key actions not occluded                                         | Lazare [00:43:58]                                  | [added] ID-color playblast pixel count, optional                                         | wings, hands, props in clear view             |
      
      **Beats and timing**
      
      | Look at                                                                      | Source                        | Measure                                            | See                             |
      | ---------------------------------------------------------------------------- | ----------------------------- | -------------------------------------------------- | ------------------------------- |
      | Holds exist and let the audience breathe                                     | Neistadt [00:31:14]           | `holds()` on COG and head per beat                 | poses register                  |
      | One beat vs three: sub-poses held slightly                                   | Newman [00:10:49]             | hold count vs intended beats                       | each beat readable              |
      | No double beats; progressions go further                                     | Lazare [00:08:28]             | `double_beats(values, frames)`                     | second hit bigger               |
      | No hitching on every word; payoffs held long enough (6+ frames on the laugh) | Lazare [00:26:34] [00:34:25]  | beats per second; payoff hold length               | not crammed                     |
      | Even spacing of poses is always wrong                                        | Neistadt [00:05:24]           | `motion_report()["constant_spacing"]` inside moves | no drift driven by the computer |
      | Fast parts keyed densely in blocking                                         | Newman QiOGugW8Ips [00:15:27] | `key_density()`                                    | no mush when splined            |
      
      **Body mechanics and weight**
      
      | Look at                                                                           | Source                                          | Measure                                                                                  | See                                                      |
      | --------------------------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------- |
      | COG as a ball; chest and hips two balls                                           | Elver [00:02:44] [00:07:40]                     | `cog_proxy()` track, `motion_report`                                                     | the ball alone reads the move                            |
      | Resistance before release; strength as frame count                                | Wade ZYKAMCZq2UI [00:01:49] [00:05:00]          | gate `resistance`; frames resistance to extension                                        | stone, not styrofoam                                     |
      | Anticipation eases in, releases fast; not overdone (first passes usually are)     | Camporota FA7fPB7qUhE [00:03:11] [00:03:46]     | gate `push_fastest`; crouch depth vs the previous version                                | energy on the push; the crouch does not upstage the jump |
      | Foot roll in every crouch sells the compression                                   | Camporota [00:07:55] [00:08:28]                 | gate `foot_roll`                                                                         | heels and toes working, not planks                       |
      | In a big drop the spine drags behind the hips; only the arms drop                 | Camporota [00:08:28]                            | gate `spine_drag`                                                                        | bent over, arms hanging, not a rigid block               |
      | Air: gravity for heavy bodies, hang only when stylized                            | physics [added]; Wade TMSpauVphNs [00:07:29]    | gates `ballistic`, `hang`, `air_horizontal`                                              | no float at the apex                                     |
      | Landing: sudden catch, residual jiggle, one overshoot                             | Elver [00:58:46]; Newman TIBzcsOt2FU [00:13:56] | gate `catch`                                                                             | felt, "you don't really see it"                          |
      | Impact: squash pushed, a bigger hold before the recovery                          | Elver [00:13:54] [00:14:29]                     | gate `impact_hold` (3+ frames [added])                                                   | the landing registers, no bounce straight out            |
      | Legs reach before landing                                                         | Camporota [00:02:32]                            | gate `pre_landing_stretch`                                                               | stretch on the last air key                              |
      | Momentum kept in camera space                                                     | Elver [00:06:01] [00:35:45]                     | camera-space speed near zero while world speed is not                                    | no stop-start on screen                                  |
      | Line of action through hips, shoulders, neck, head; no flat shoulder or hip lines | Camporota [00:14:13]; Lazare [00:40:55]         | shoulder and hip line angle in camera space on key poses (under 2 degrees flags) [added] | curves, not flat lines                                   |
      | Live action: pose squash, no dead stops                                           | Elver [00:45:32] [00:54:54]                     | gate `moving_hold`                                                                       | nothing freezes                                          |
      
      **Contacts**
      
      | Look at                                                                           | Source                                        | Measure                                                      | See                                     |
      | --------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------ | --------------------------------------- |
      | Plants locked on every frame                                                      | Elver [00:41:45]                              | gates `slide_*` (0.2 cm/frame [added])                       | feet and hands do not skate             |
      | No penetration of floor or box                                                    | Camporota ynXadXE9UjU [00:09:26]              | gates `penetration_*` (0.5 cm [added])                       | soles on the surface                    |
      | Peel-off: on the first airborne frame the toe tip points back to the takeoff spot | Elver [00:10:32] [00:11:04]                   | gates `peel_off_*` (`peel_angle` at most 30 degrees [added]) | push sells, even with the leg stretched |
      | Feet flat within 1 to 2 frames of every touch-down                                | Camporota ynXadXE9UjU [00:09:26]; body digest | gates `heel_to_flat_*` (`foot_events`)                       | no slapping or slow-rolling feet        |
      | Hand plants splay on impact                                                       | Lazare [00:38:33]                             | none                                                         | fingers spread                          |
      
      **Arcs, spacing, silhouette**
      
      | Look at                                        | Source                                 | Measure                                                                         | See                                 |
      | ---------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------- |
      | Arcs judged on screen, not on 3D trails        | Elver [00:08:17]                       | `camera_track` + `motion_report` sharp turns                                    | smooth dotted paths in `tracks.png` |
      | No one-frame pops; snappy = 3 frames on an arc | Elver [01:02:04]                       | gates `spikes_*` (local outliers)                                               | no jumps between dots               |
      | Falls and pushes not evenly spaced             | Elver [00:09:55]                       | constant-spacing runs inside falls                                              | dots spread as speed grows          |
      | Knee pops where the IK leg straightens         | Camporota [00:14:10]                   | hip-to-ankle distance over 98% of leg length with an acceleration spike [added] | no kink in the knee track           |
      | Leg and body silhouettes clear                 | Elver [00:12:46]; Camporota [00:06:07] | none                                                                            | black shapes read                   |
      
      **Overlap and chains**
      
      | Look at                                    | Source                                           | Measure                                            | See                     |
      | ------------------------------------------ | ------------------------------------------------ | -------------------------------------------------- | ----------------------- |
      | Not everything on the same frames          | Neistadt [00:07:02]; Wade ZYKAMCZq2UI [00:09:34] | gates `pose_to_pose_tell`, `overlap`               | follow-through visible  |
      | Force core outward                         | Wade [00:07:39]                                  | `peak_order` pelvis, chest, shoulder, elbow, wrist | the body pushes the arm |
      | Secondary objects land 1 to 2 frames later | Lazare [00:32:07]                                | `lag_frames`                                       | weight in the follower  |
      
      **Performance, face, lip sync**
      
      | Look at                                                                 | Source                                                    | Measure                                                             | See                         |
      | ----------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------- |
      | Right level for the character; secondary quieter; consistent            | Newman [00:06:31]                                         | eye and brow key density per second ("eyes doing too much") [added] | reads as this character     |
      | Eyes: linear darts of 2 frames, locked holds, no accidental camera look | Elver [00:19:29] [00:25:37]                               | `transitions()` lengths; eye aim to camera angle [added]            | sharp, not floating         |
      | Blinks close faster than they open, small-then-big spacing              | Elver [00:20:02]                                          | `blink_report()`                                                    | cushioned blinks            |
      | Brows lead lids by at most 1 to 2 frames; no drifting inner brow        | Elver [00:22:16] [00:30:43]                               | `lag_frames(brow, lid)`                                             | connected face              |
      | Lip sync not late; opens punchy                                         | Lazare [00:19:33] [00:21:21]                              | `lip_lag()`, `jaw_report()["opens"]` first-frame share              | in sync at speed            |
      | No chatter; 2 to 4 tentpoles per line                                   | Santos S1MRs3XJVPI [00:13:54] [00:34:28]                  | `jaw_report()` reversals per second, tentpole ratio                 | "a word, a word, a word"    |
      | Closures on M, B, P, F, V; tongue on L, T, D, TH; teeth anchored        | Wade 5cIxEZwZmS4 [00:08:49] [00:37:24]; Santos [00:36:03] | lip distance on ruled frames [added]; tongue single-frame spikes    | shapes drawn and asymmetric |
      | Corners flow, never TZ; width set by neighbors                          | Santos [00:48:24]; Wade [00:23:31]                        | corner TZ has no keys; direction changes inside a word              | no football mouth           |
      | Face connected to the mouth and body                                    | Santos [00:19:24] [00:37:40]                              | `lag_frames(corner, nostril)` about 1                               | fleshy                      |
      
      **Rendered read and integration**
      
      | Look at                             | Source                      | Measure                                         | See                                    |
      | ----------------------------------- | --------------------------- | ----------------------------------------------- | -------------------------------------- |
      | Motion blur and fur change the read | Elver [00:27:51]            | adjacent keys with a big jump (half-frame fix)  | fast moves soft, not smeared wrong     |
      | Game feel is judged in engine       | Newman [00:07:20]           | `cycle_speed` vs metrics, loop seam, root rules | mark done, implement, expect revisions |
      | Plate integration, CFX follow-up    | Elver [00:44:28] [00:54:19] | gross interpenetration count [added]            | sits in the plate; cloth can simulate  |
      
      **Technical hygiene (always)**
      Layers merged at polish (gate `layers`); no sub-frame keys (gate `subframes`); no rotation flips (`rotation_flips`, Euler filter); scripted keys left their neighbors alone (`curve_diff(before, after, ignore)` ok; Wade TMSpauVphNs [00:02:18]); face stepped until its pass; helpers deleted (`delete_helpers`); scene fps equals the plan (gate `fps`).
      
      ## 4. What done means per stage
      
      - **Blocking:** every key category present; stepped pass tells the story; silhouettes read; no Intent notes left.
      - **Spline:** no mush (readiness passed), all mechanics gates pass, overshoots only where planned, face still stepped if it has its own pass.
      - **Polish:** every gate passes; the top-ranked notes fixed; blurred render looked at; the final playblast looked at frame by frame on contacts and fast moves.
      
      ## 5. Writing notes (and reading human ones)
      
      Format each note: bucket (Intent, Mechanics, Performance, Integration), frame range, what reads, the concrete change, priority. Start with what works (Lazare [00:01:13]). Keep it about the work, never the person (Newman [00:04:33]). Rank and fix only the top items per iteration (Elver [00:03:49]). Store every human note in the project's notes file by character and action, and read it before similar work (Newman [00:11:50]).
      
      Director lingo, both ways (Newman [00:09:43] to [00:10:49]):
      
      | They say                             | It means                             | The agent does                                      |
      | ------------------------------------ | ------------------------------------ | --------------------------------------------------- |
      | "The anticipation reads thin"        | not bold enough                      | bigger counter-move, longer hold before the release |
      | "The eyes are doing too much"        | trying too hard on the face          | fewer eye and brow keys, smaller amplitude          |
      | "One beat instead of two (or three)" | sub-actions blur together            | hold each sub-pose a few frames                     |
      | "Floaty"                             | spacing driven by interpolation      | favor keys, add holds, gravity-true air             |
      | "It feels like hitting poses"        | everything starts and stops together | offsets, overlap, break key columns                 |
      | "Late" (lip sync)                    | shapes form on the sound             | per-sound lead, then shift 1 to 2 frames earlier    |
      
      ## 6. Calibration of the [added] thresholds
      
      Tuned offline on a synthetic M4 jump (`tests/code/maya-animation/_m4_motion.py`): a physically consistent version passes all 23 gates; half gravity fails `ballistic` (g_ratio 0.50); a 0.5 cm/frame planted slide fails `slide_R`; a crouch with no beat fails `resistance`; a frozen ending warns `moving_hold`; flat feet in the crouch warn `foot_roll`; a chest locked to the hips warns `spine_drag`; a flat foot on the first airborne frame (90 degrees) warns `peel_off_*`; heels taking 4 frames warn `heel_to_flat_*`; a bounce out of the lowest frame (hold 1) warns `impact_hold`. The foot, spine and hold gates are warnings: they flag a detail to look at, the playblast decides. They are starting values for a human-scale rig in cm at 24 fps: scale them with the rig and the style, and record any change in the plan.
      
    • expert-notes.md 29.4 KB
      # Expert notes: animation (by expert, with sources)
      
      The depth behind SKILL.md. Timestamps are `[hh:mm:ss]` in the video named in `sources.md`; frame references `f_00582` are contact-sheet frames of that video. Items marked [added] are this skill's own method or physics, not the expert's words. Captions of most videos are auto-generated; where they were garbled the note says so.
      
      ## Sir Wade Neistadt (ex-DreamWorks production trainer)
      
      **Workflows** (GMTet6nd_iM, 2021)
      
      - No single workflow; change it per shot and hide it: "the workflow that you use should not be obvious to someone watching your shot" [00:07:02] [00:32:21].
      - Seven workflows: pose to pose (thumbnail first, "it's faster to draw it than it is to pose it" [00:02:42]); straight ahead, usually in spline [00:08:27]; layered, one component finished before the next (ball: translate Y until perfect, then X) [00:11:36]; 2D sketch blocking [00:14:21]; reference blocking, poses every 2, 4, 5 or 7 frames [00:19:09]; extremes per body part and channel, keys scattered but organic [00:25:35] [00:27:45]; key categories in layered passes [00:28:37].
      - Blocking is complete when all five key categories exist: main keys, anticipations, overlap and follow-through (overshoots), breakdowns, holds [00:29:08] [00:31:48]. Holds are a category: the character must "get somewhere and stay there, act in the pose, give the audience time to breathe" [00:31:14].
      - Even spacing of key poses is always wrong: 1, 10, 20 is floaty [00:05:24]. Keying the whole body on the same frames is the pose-to-pose tell [00:07:02].
      - Reference: key the landmarks (contacts, takeoff, apex, landing, blinks, eye darts) to capture rhythm, then push [00:23:38]. Rotomated too closely, his own Hiccup shot "looked like mocap data" [00:21:30]. Style decides how far: stylized features push, Call of Duty pushes only for clarity, VFX stays close [00:20:25] [00:20:57].
      - Rigs arrive neutral and symmetric: never act from the default pose; decide the asymmetric shape first [00:16:02] [00:17:07].
      - Anticipation is optional in games: a jump may start on the button frame [00:30:10].
      
      **Dialogue** (5cIxEZwZmS4, 2021)
      
      - Break the line down on paper: phrases, emotional cues, then every sound with a hard rule: M closes at least one frame [00:08:49]; B and P are built-up pressure released [00:09:21]; O is a circle; J and CH compress with air [00:09:53].
      - Components in order: jaw, corners in/out, lip shape, tongue [00:13:42]. The jaw alone ("muppet version") must read the rhythm [00:21:19]; it can go past neutral into compression on closures [00:20:16]; group sounds, do not flap on every syllable [00:21:52]; jaw reversals are flags to check [00:22:25].
      - The blanket "two frames early" is not detailed enough: a sound made when the shape forms (M) differs from one made when it breaks (B, P, K, L) [00:15:30] [00:17:06]; B/P lips closed before the sound frame, opening on it [00:19:14].
      - Corners follow the neighboring sounds more than the current one [00:23:31]; no width changes inside a word [00:30:34]. Never record slow mirror reference [00:25:11].
      - Tongue: L raised early, released with a big one-frame spacing; "the tongue is a very very fast muscle, it does not do a lot of easing"; R floats mid-mouth [00:37:24] [00:39:33]. Skipping the tongue makes "half the words not make any sense" [00:36:18].
      - Four polish steps: breath, connectivity and fleshiness (lips fight the jaw, the M peel), graphic appealing shapes, mouth flow across the face [00:41:27] to [00:45:40].
      
      **Graph Editor** (TMSpauVphNs, 2022, Maya 2020.4 on screen)
      
      - Set Key reshapes the curve around a new key; enable Set Key > Set key preserves curve shape, or insert keys [00:01:45] [00:02:18]. A keying command that changes neighboring curves is a workflow bug, not something to live with. [added] The agent inserts (`A.insert_key`) and proves it with `A.curve_diff` on sampled snapshots before and after: existing keys and the curve outside the new key's two segments unchanged (the body digest's "Set key damage" gate).
      - Stacked view is "an essential tool", especially for cycles; absolute view squashes everything under one traveling control [00:02:48] [00:03:55]. [added] `curve_lanes_png` is the agent's stacked view.
      - Weighted tangents: shape by weight, never re-point handles: collapse the contact weights, lengthen the apex weight for hang time; "you change the weight, you never ever need to change the direction" [00:06:57] [00:07:29] [00:08:00]. Ball in f_00582: contacts near frames 1, 24, 47, apex frame 12.
      - [added math] A gravity parabola between contact and apex is a cubic Bezier with one-third handles, contact slope 2H/T and a flat apex; Wade's recipe adds hang beyond gravity. Stylized or light: use it. Heavy or realistic: no.
      - Palette (f_00933): translates red, green, blue; rotates orange, yellow, cyan; scales pinks and violets; select curves by channel name across all controls so no curve is forgotten [00:11:15] [00:15:37].
      
      **Heavy weight** (ZYKAMCZq2UI, 2024; captions read like a machine-translation round trip)
      
      - Two numbers first: object weight and character strength [00:00:00] [00:09:03].
      - Resistance before release, even for gods: "It's impossible to fully reveal a character's strength until you show what they're facing" [00:01:49]; a boulder lifted without effort reads as styrofoam [00:06:37].
      - Weight is "this break in the flow, this delay in motion", acceleration from rest [00:03:05]; strength is fewer frames from resistance to full extension [00:05:00] [00:06:20].
      - Throws: the object lags the hand; "if it moves with your hand the whole time, it's not that heavy" [00:05:48]. Punches: lean in, then a short A to B [00:08:04].
      - Force from the core outward (back, shoulders, arms), never all on one key [00:07:39] [00:09:34]. Scale and interpenetration can sell compression [00:08:32]; locked animation can be helped by sound, shake, dust [00:07:09].
      
      **animBot catalog** (G49wexNmQ-Q, 2020)
      
      - Learn every operation in bare Maya first [00:01:38]. Native S drops auto keys into stepped blocking; smart key inserts and keeps the range's tangent type [00:06:22] [00:07:57].
      - Tween by blending toward a neighbor, not to the 50% midpoint [00:12:31] [00:14:10]; Euler-filter while tweening [00:15:10].
      - Share keys so pole vectors and floating controls do not interpolate across the shot [00:21:41] [00:23:19]. After scaling a range keys sit on sub-frames; Maya's snap will not resolve two keys competing for one frame [00:29:34] [00:30:07].
      - Suspend viewport refresh while editing heavy rigs [00:33:42]; checkpoints for recovery [00:20:01].
      
      ## Ere Santos (animation supervisor, Walt Disney Animation Studios; ex-Pixar, Sony) (S1MRs3XJVPI, 2024)
      
      - "Lip sync isn't about animating words, it's about animating sounds" [00:03:55]. Fast speech collapses words [00:44:31].
      - Lead by place of articulation: bilabials (M, B, P) on the frame, alveolars (D, T, L, S, SH) about one frame early, sounds further back earlier; every open shape exists before its sound [00:06:08] [00:07:47]. The back-of-mouth number is implied (2 frames in his quiz options), not stated [00:06:41].
      - Passes: body first [00:30:07], mask (eyes, brows) before the mouth [00:24:13]; phrase pass (subtext, action verb, one shape per action) [00:09:26]; muppet pass (jaw rotate X only, tentpoles biggest; three tentpoles in his five-second line) [00:13:20] [00:34:28]; big five (jaw, corners, top and bottom lip: "90% of your lip sync") [00:15:31]; polish with the tongue first [00:52:16].
      - Corners: translate X in/out, Y up/down, never Z ("a disaster") [00:48:24]. Runs of M, B, P: jaw tight, bottom lip rises; visible gums need a reason [00:46:44] [00:47:17].
      - Keep keys stacked until polish; early offsets scatter keys and make notes expensive [00:48:59].
      - Connection by curve copy: corners onto nostrils one frame later, scaled down and dampened selectively; corners' TY onto brows, cheeks onto lower lids [00:37:40] [00:55:01].
      - Upper teeth stay anchored to the skull; hide a teeth move inside a big head move [00:36:03]. The tongue must not flash on one frame [00:52:49].
      - "Would I draw it this way?": four-sided, asymmetric mouths, no football [00:39:18] [00:40:25].
      - Pose libraries are reference; pose complex mouths by hand [00:28:25]. Animation layers helped a chewing-while-talking gorilla: chew on one layer, lips on another, bake before polish [00:26:13].
      
      ## Arslan Elver (senior animation supervisor, Framestore; Rocky in Project Hail Mary) (jzuxAmadcm8, 2026)
      
      - Polish is triage: list what could improve, "put my money into the most important ones", one section at a time on a short timeline [00:03:49] [00:04:22].
      - COG first; everything is a bouncing ball, chest and hips two balls [00:02:44] [00:07:40]. Ball proxy: a sphere parent-constrained to the COG without offset, character hidden [00:06:34]. When stuck, hide all but the torso [00:37:57].
      - Physical shots in spline from the start, his personal way [00:03:18]; delete keys that fight an arc even in polish [00:09:55].
      - Arcs and momentum in camera space: "it might look like a perfect arc but in the camera space it might not work well" [00:08:17]; a character matching the camera's speed reads as stopping [00:06:01] [00:35:45]; motion trails are "a little bit like a fake thing" [00:36:50].
      - Falls spaced frame by frame, never even [00:09:55]. Peel-off, "a big deal for me": on the frame after a takeoff the tip of the toe points to where it took off, even with the leg stretched a lot; it gives "a great push feeling" and students overlook it [00:10:32] [00:11:04] [00:11:38]. [added] Vector check: `A.peel_angle(ball, toe, takeoff_point)` on the first airborne frame (gate `peel_off`, 30 degrees). Leg silhouettes separate in the air [00:12:46].
      - Impact: a jump that "doesn't have any impact" reads linear; push the squash moments, keep the body down a touch and give a bigger hold [00:13:19] [00:13:54] [00:14:29]. [added] Gate `impact_hold`: frames around the deepest compression moving under 10% of the incoming speed, warn under 3; the agent lengthens the hold and compares playblasts, since he gives no frame count.
      - Retime in the Dope Sheet: key everything at 134, tighten the 134 to 152 section by two frames [00:16:44] [00:17:18].
      - Eyes: locked mode, linear tangents, "whenever I see something on spline that's wrong", holds then 2-frame darts [00:19:29] [00:20:02]. Blinks close 2 / open 4 or 3 / 5, small then big spacing, a cushion frame, one more frame for big eyes [00:20:02] to [00:21:44]. Brows lead lids by a frame or two, never five [00:22:16]; inner brows never drift for 15 frames [00:30:43].
      - Asymmetry along the look direction [00:23:21]; hide an accidental camera look with a blink a frame earlier [00:25:37]; head turns tracked on the nose frame by frame [00:26:10].
      - Render with motion blur before final polish: blur softens moves that look too fast; adjacent keys with a big jump blur badly, fix with a half-frame key [00:27:19] [00:39:35].
      - Snappy without popping: 3 frames on an arc plus an overshoot, never a one-frame jump; a micro-anticipation of the anticipation [01:02:04] [01:02:38].
      - Weight: "if I do more up and down it's more heavy. No, it's more balloon" (about bigger bounces, not apex hang); a sudden caught stop plus residual jiggle; heavy takes longer to speed up and slow down [00:58:13] [00:59:53].
      - Live action: squash from the pose silhouette, not deformation [00:45:32]; "cannot stop immediately" [00:54:54]; Rocky had no ease but a sharp bounce-back [00:55:25]; continuity across cuts need not be perfect [00:56:30]. Interpenetration can break CFX downstream [00:54:19].
      - Layers: never in blocking or polish; only a late drastic note or an additive tremble, then bake (baking puts every control on ones) [00:48:48] [00:50:24]. People who work with mocap "don't hesitate to just delete chunks" of keys [00:48:14].
      - Silhouette: press 7 in a scene without lights for black shapes [01:03:43].
      
      ## Jeremy Lazare (animator, Pixar) (m8YPvVtfLhw, 2024; 11 Second Club critique)
      
      - Review order: acting, then mechanics, then detail; replay each shot [00:00:37].
      - "Wherever the most movement is happening, that's where you're inviting the audience to look"; "if you can get away with nothing, do that" [00:05:07] [00:03:27].
      - Double beat to progression: get there, overshoot, settle, then go further in the same direction; stay in the fist and tighten it [00:08:28] [00:10:48] [00:18:25]. Straight jabs read aggressive, circles soft [00:10:14].
      - Lead with what you want read (hand before body) [00:14:09]; arrive a frame or two early so the pose registers [00:17:17]. "The stuff in between is just noise" [00:29:54]; hold the payoff at least 6 frames longer [00:34:25].
      - Lip sync late at speed: shift everything 1 to 2 frames earlier; punch the opens (near full on the first frame instead of three soft ones) [00:19:33] [00:21:21].
      - Mechanics: hips land and level out with a sharper drop [00:37:20]; a planted hand must not slide, splay the fingers on impact [00:38:33]; the neck continues the line of action [00:40:55]; secondary objects land 1 to 2 frames later [00:32:07]; restage occluded actions [00:43:58].
      
      ## Harvey Newman (AAA animation and game director)
      
      - **Dailies** (7r_Czt3-SKE, 2026): four buckets, Intent, Body mechanics, Performance, In-game integration [00:05:23] to [00:07:20]; "they can look beautiful on a 2D plane, but until I don't see it in game..." [00:07:20]. Lingo: "the anticipation reads thin" = bolder [00:09:43]; "the eyes are doing too much" = dial down [00:10:16]; "one beat instead of two" = hold the sub-poses slightly [00:10:49]. Bottle the notes where others can reuse them [00:11:50].
      - **Idea to blocking** (QiOGugW8Ips, 2020): a 5-second clip is the middle of a shot, not a shot [00:03:46]; draw-overs every 2 to 3 frames [00:10:00]; fast parts keyed on ones, slow parts 2 to 4 keys apart [00:15:27] [00:16:30]; pull elements out of stepped (the ball first) to judge arcs [00:14:39].
      - **Blocking to spline** (TIBzcsOt2FU, 2021): "Maya is the worst in-betweener in the world" [00:04:53]; the spline test on a range (mush means back to stepped) [00:07:00]; "spline should be just about making your animation better" [00:07:52]; first pass hips first, translates before rotates, translate Z first [00:10:01]; holds with a slight slope [00:10:34]; axis-facing camera to see snaps [00:11:06]; a hard landing with broken tangents, "you feel it but you don't really see it" [00:13:56]; correlated spine controls edited together before offsets [00:16:58].
      - **Layers** (5RmqWjfU80s, 2020): all real animation on the base layer [00:02:35]; key before moving, pre-key the frames to change, a protective key after the last change ("don't mess with anything after this") [00:03:08] [00:05:07]; never clean on a layer ("false positives") [00:07:24]; merge with Smart Bake or the result "looks like mocap" [00:08:23]; time-box 30 to 40 minutes [00:10:29].
      - **Mocap vs hand key** (7gZt3RCmgfM, 2020): preserve momentum and tempo, "that's what the studio is paying for" [00:07:31]; the cascade makes it "not hand key and not mocap" [00:08:03]; typical defects: feet through the floor, broken wrists, flat hands [00:05:56]; quick hand-key blocking half a day with 4 to 6 keys, polish 2 to 3 days [00:10:25] [00:11:31].
      - **Re-edit mocap** (JzwfomndbMA, 2021): big notes on a layer with sparse keys (8 or 9 on an arm) [00:04:09]; merge, Euler filter first [00:08:31]; delete keys down to extremes, then favor, hold, overshoot [00:10:07] [00:11:11]; some wobbles are the performance [00:09:03].
      - **Plug-ins** (t-YsIXaPvEg, 2021): about five tools; 50/50 tweens teach even timing [00:05:07]; hands in IK "at all times pretty much", no IK/FK or parent switching [00:07:25]; props tracked to the wrist with an offset over the held range only [00:10:45] [00:11:50].
      - **Ghosts and trails** (Ajx1p5cAzXU, aNYr9nc9iM4, 2021): ghosts show spacing and rotation a trail cannot [00:10:17]; ghost the controller, merge layers first [00:09:12] [00:16:03]; trail settings: Time Slider range, increment 1 for mocap or the blocking interval, 10 frames pre and post, Always Draw, thickness 2, key size 3 [00:03:39] to [00:05:14]; edit mocap trails on a layer [00:05:45].
      - **Gameplay** (-vUpqWi8b50, 2020): several clips per file, one bookmark each [00:06:12]; judge in cached playback at real rate [00:02:14].
      - **Metrics** (w4F6vbmcxZ8, 2024): metrics before motion, player first, round numbers in a shared document [00:06:59] [00:11:09]; walk 1 then 2 m/s, run 4 then 6, sprint 12 then about 15 for a stylized game [00:04:45] to [00:06:29]; variable jumps are takeoff, a stretchable loop and a landing with engineering parabolas [00:14:51].
      - **Sequencer 2027** (SGjxnw6c-IQ, 2026): shots from the Time Slider or the options dialog with name, range and camera [00:06:20]; lock every camera [00:08:33]; Time Scale gives slow motion "without editing your animations" [00:17:24]; Ripple Edit once the structure is settled [00:20:20].
      
      ## Alessandro Camporota (lead animator, Riot Games; ex-Sony Imageworks, Ubisoft)
      
      - **Walk** (ynXadXE9UjU, 2019): legs and the four poses every 3 frames (contact, down, pass, up) on a 24-frame cycle [00:05:15]; loop with Cycle infinity before any polish [00:04:08]; first error: too much bounce, scale the curve down [00:03:35] [00:12:05]; side shift peaks between pass and up [00:05:29]; the swinging foot arcs out [00:07:26]; heel to flat "doesn't happen in three frames, basically almost in one frame", with toe drag and compression on the push-off scaled by style [00:09:26] [00:09:59] (the body digest's gate allows 1 or 2; [added] applied to every touch-down, gate `heel_to_flat`); supporting-side hip goes up, the reverse is the student mistake [00:10:45] [00:11:27]; spine counters the pelvis on Y and Z [00:15:22] [00:16:31]; breakdowns favor the next key [00:20:13]; arm offsets 1 and 2 frames, head 1 [00:22:33] [00:32:02]; head in neck space [00:30:07]; knee pops show on a motion trail of the knee [00:14:42].
      - **Jump** (FA7fPB7qUhE, 2019): two boxes first, because posing a big action first "may take weeks" if the timing is wrong [00:00:38]; anticipation eases in slowly and snaps out [00:01:18]; first passes overdo it: on his own proxy "the anticipation is a bit too much", reduced in the Graph Editor [00:03:11] [00:03:46] (agent: `A.scale_curve(..., time=crouch)` about the rest value); the last air key sits slightly higher because the legs reach [00:02:32]; land, a no-overshoot key, then an overshoot [00:02:32]; cartoony means fewer frames on takeoff and landing [00:04:21]; rotation 1 to 2 frames behind translation [00:04:58]; lights-off silhouette panel always on, fingers included [00:06:07]; "always make sure you animate the roll here on the feet", those details "help the compression of the weight" [00:07:55] [00:08:28]; in a big jump the spine keeps dragging on the way down, "only the arms going down" (frame: bent over, arms hanging) [00:08:28]; no flat shoulder or hip lines [00:14:13]; in-between pelvis, spine and legs first, directly in spline, pops later [00:15:32] [00:16:59]; the landing is used as an overshoot, spine pushed in the direction of travel [00:18:17].
      
      ## Autodesk Maya 2027 Help and Epic (documentation)
      
      - Tangents (animation__graph-editor-tangents-curves): Plateau keeps extremes on keys; Clamped fixes pelvis and foot slip in cycles; Stepped Next jumps early; Quaternion curves with clamped tangents interpolate stepped; filters (Euler, Key Reducer, Smooth Butterworth keeps min and max, Gaussian, Peak Removal, Resample, Simplify) work on time curves only; Enable Stepped Preview on the Time Slider.
      - Layers (animation__animation-layers): Additive sums, Override replaces, Passthrough makes weight an opacity; booleans and enums always override; mute an empty Override layer before keying it; muted layers are locked by default; wrist stabilized with an Override layer and IK Blend 0, 0, 1 at frames 0, 20, 27 on linear keys; merges follow stack order.
      - Cached Playback (animation__cached-playback): on by default, needs the Evaluation Manager; yellow means Safe Mode; motion blur, Trax, classic dynamics disable it; playblast remains the review medium.
      - Ghosting and trails (animation__ghosting-motion-trails): skinned meshes need Cached Playback; no ghosts of simulation; trails editable only on keyframes; do not mix editor ghosts with layer or Time Editor ghosts.
      - HumanIK (animation__humanik-retargeting): bake from the HumanIK window; a Control-rig layer overrides the bake; Match Source gives a large character tiny steps; Knee/Elbow Max Extension 50 damps snapping; Mass Center Compensation 80; custom rig mapping (IK effectors T and R, FK R only, up vectors translation only).
      - Time Editor (animation__time-editor): clips, transitions, relocators on similar frames; never with animation layers; remap needs identical skeletons and bone lengths.
      - Sequencer and playblast (animation__sequencer-playblast-2027): playblast defaults to the active view, the Time Slider range, scale 0.5; `-frame` for single frames; `-offScreen`; a playblast faster than the viewport means the viewport was not real time.
      - Root motion (animation__unreal-root-motion, UE 5.8): off until enabled per asset; Z root motion ignored while Walking or Falling; extraction runs on the Game Thread.
      
      ## Disagreements and the deciding condition
      
      | Choice                | Option A                                                                             | Option B                                                                                                                                                        | Deciding condition                                                                                                               |
      | --------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
      | Stepped or spline     | stepped until every key category is in (Wade [00:29:08]); spline test first (Newman) | spline from the start on physical shots (Elver [00:03:18]); core in spline after the proxy (Camporota [00:15:32])                                               | acting and unproven timing: stepped; physics with proven spacing: spline early                                                   |
      | Proxy or poses first  | two boxes (Camporota [00:00:38])                                                     | poses first, thumbnails (Wade)                                                                                                                                  | big, fast or camera-driven moves: proxy; acting beats: poses                                                                     |
      | Offsets               | early inside a cycle (Camporota [00:22:33])                                          | stacked until polish (Santos [00:48:59])                                                                                                                        | cycles wrap offsets; shots that will get notes offset late                                                                       |
      | Layers                | useful for separable additive motion (Santos [00:26:13])                             | never in blocking or polish (Elver [00:49:20]); one idea at a time (Newman)                                                                                     | late global note or additive tremble: layer then bake; anything else: base layer                                                 |
      | Lip lead              | global 1 to 2 frames earlier (Lazare)                                                | per sound, by place of articulation (Santos, Wade)                                                                                                              | per sound first; global only if every shape is late                                                                              |
      | Hang at the apex      | weighted apex (Wade [00:07:29])                                                      | gravity-true parabola [added physics]; Wade's heavy video: heavy is slower overall [00:03:05]. Elver's "balloon" line is about more bounce, not hang [00:59:53] | light or cartoony: hang; heavy or realistic: gravity-true                                                                        |
      | Anticipation size     | first passes overdo it, dial it down (Camporota [00:03:46])                          | "the anticipation reads thin" means bolder (Newman 7r_Czt3-SKE [00:09:43])                                                                                      | the read on the proxy or stepped playblast; the animator's own first pass tends to err big, a director's thin note asks for more |
      | Reference closeness   | push, avoid mocap look (Wade [00:21:30])                                             | work close first to learn (Elver [00:12:46])                                                                                                                    | style and destination; learning shots close                                                                                      |
      | Squash and stops      | cartoony can "go bonkers", dead stops in full CG                                     | live action: pose squash, no dead stop, sharp bounce-back (Rocky)                                                                                               | integration with a plate and the director's taste                                                                                |
      | Anticipation in games | on the button frame (Wade [00:30:10])                                                | "reads thin" is a standard note (Newman)                                                                                                                        | gameplay responsiveness vs readability; decided in engine                                                                        |
      | Ghosts or trails      | ghosts for spacing and rotation (Newman 2021-08)                                     | trails fast enough to replace plug-ins, best for path edits on a layer (Newman 2021-12)                                                                         | judge spacing with ghosts, reshape a path with a trail; the agent measures both                                                  |
      | Retarget route        | MotionBuilder or Red9 (Newman [00:04:17])                                            | HumanIK custom rig in Maya (doc)                                                                                                                                | tool availability; identical skeletons can use Time Editor remap                                                                 |
      | Mocap edits           | Additive offsets on a layer (Newman)                                                 | Override plus IK to pin a contact (doc)                                                                                                                         | timing and offsets: Additive; absolute plants: Override                                                                          |
      
      ## Body-mechanics rules for any jump, hop, landing or weight shift
      
      The same rules hold beyond the box jump; the gates read them wherever the plan names the window (`crouch` may be a list, `impacts` lists every landing, every touch-down and lift-off of every foot is checked). The generalization is [added]; each rule's source is the expert's.
      
      | Rule                                                              | Expert                                                         | Gate (`evaluate_gates`)                                                                                       |
      | ----------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
      | Foot roll animated in every crouch or load                        | Camporota FA7fPB7qUhE [00:07:55] [00:08:28]                    | `foot_roll`: rig attribute range (plan `foot_roll`) or heel-to-toe pitch range above 1 degree [added]         |
      | In a big drop the spine drags behind the hips; only the arms drop | Camporota [00:08:28]                                           | `spine_drag`: chest (plan `spine`, default `chain[0]`) lags the COG by 1+ frame in the crouch [added measure] |
      | First passes overdo the anticipation                              | Camporota [00:03:11] [00:03:46]                                | none: judged on the playblast; `scale_curve(time=)` is the dial                                               |
      | Toe points back to the takeoff spot on the first airborne frame   | Elver jzuxAmadcm8 [00:10:32] [00:11:04]                        | `peel_off_<side>`: `peel_angle` at most 30 degrees [added]                                                    |
      | Feet flat within 1 to 2 frames of touch-down                      | Camporota ynXadXE9UjU [00:09:26]; body digest gate             | `heel_to_flat_<side>`: touch-down to heel and ball planted, at most 2 frames                                  |
      | Push the squash, a bigger hold after the impact                   | Elver [00:13:54] [00:14:29]                                    | `impact_hold`: 3+ near-still frames at the deepest compression [added]                                        |
      | Insert keys; neighboring curves must not move                     | Wade TMSpauVphNs [00:01:45] [00:02:18]; G49wexNmQ-Q [00:06:22] | `curve_diff(before, after, ignore)` after any scripted keying                                                 |
      
    • gui-paths.md 10.1 KB
      # GUI paths: menus, hotkeys and editor settings (for a computer-use agent or a human)
      
      Provenance per line: **(doc)** Maya 2027 Help saved in `sources/docs/`; **(video, year)** shown in an expert video on an older Maya, may have moved; **[added]** long-standing Maya knowledge, not checked in 2027; **[verify]** unsure in 2027. On macOS, Maya's Ctrl is Control and Alt is Option (version deltas 2.13). Menus below assume the Animation menu set (F4) (doc).
      
      ## Editors
      
      | Editor                 | Path                                                                                                  | Notes                                                                       |
      | ---------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
      | Graph Editor           | Windows > Animation Editors > Graph Editor (doc)                                                      | Shift+S tangent marking menu: swipe right tangents, left keys (doc)         |
      | Dope Sheet             | Windows > Animation Editors > Dope Sheet (doc)                                                        | summary row retimes every control (Elver jzuxAmadcm8 00:16:44)              |
      | Animation Layer Editor | Channel Box / Layer Editor > Anim tab (video 2020)                                                    | green "traffic light" = the layer that receives keys (doc)                  |
      | Ghost Editor           | Visualize > Ghost Editor; also Visualize > Ghost Selected (doc)                                       | 2022 video: Windows > Animation Editors > Ghosting Editor                   |
      | Motion Trail Editor    | Visualize > Motion Trail Editor (doc, since 2025)                                                     | replaces the Create Editable Motion Trail option box                        |
      | Time Editor            | Windows > Animation Editors > Time Editor (doc)                                                       | never with animation layers on the same animation                           |
      | Sequencer              | Windows > Animation Editors > Sequencer, Panels > Panel > Sequencer, or the Sequencer workspace (doc) | colors still under "Camera Sequencer" in Color Settings (doc)               |
      | HumanIK                | Skeleton > HumanIK [verify]                                                                           | Definition tab, Character and Source menus, Custom Rig tab, Bake menu (doc) |
      | Playblast              | Windows > Playblast, or Playback > Playblast option box (doc)                                         | Sequencer has its own Playblast menu in 2027 (doc)                          |
      | Preferences            | Windows > Settings/Preferences > Preferences > Animation (doc)                                        | Default in/out tangent, Default Tangent Weight                              |
      
      ## Keying and playback hotkeys
      
      - S set key on all keyable channels; Shift+W, Shift+E, Shift+R key translate, rotate, scale only [added].
      - Insert a key without reshaping: Graph Editor right-click > Insert Key, or hold I (Wade G49wexNmQ-Q 00:06:53, TMSpauVphNs 00:01:45). Key > Set Key option box > "Set key preserves curve shape" (video 2022) [verify location in 2027]. Scripted twin: `A.insert_key`, then `A.curve_diff` on sampled snapshots (procedures P4).
      - Alt+V play or stop, Esc stop, . and , next and previous key, Alt+. and Alt+, next and previous frame, K + drag in a viewport to scrub [added].
      - Time Slider right-click: Enable Stepped Preview (doc), Cached Playback modes (doc), audio selection (video 2021).
      - Time Slider bookmarks: bookmark icon beside the Time Slider; Frame Bookmark sets the range; double-click the Time Slider toggles the full range (Newman -vUpqWi8b50 00:06:47).
      - Hold on real time: Playback Speed Real-time in the Time Slider preferences [added]; Display > Heads Up Display > Frame Rate to confirm (doc).
      
      ## Graph Editor (2027 names)
      
      - Tangents: Auto (Auto Span Legacy, Auto Ease, Auto Mix, Auto Custom in a collapsed toolbar group), Spline, Clamped, Linear, Flat, Stepped, Stepped Next, Plateau, Fixed; In Tangent and Out Tangent separately after Break Tangents; Unify; Lock/Free Tangent Length (was Tangent Weight) (doc).
      - Curves: Pre Infinity and Post Infinity > Cycle, Cycle with Offset, Oscillate, Linear, Constant (doc); View > Infinity is on by default since 2025.3 (deltas).
      - Curves > Euler Filter, Key Reducer Filter, Smooth Filter (Butterworth), Smooth Filter (Gaussian), Peak Removal (object selected in the viewport), Resample Curve, Simplify Curve, Key Sync (doc).
      - Curves > Buffer Curve > Snapshot, Swap Buffer Curve; View > Show Buffer Curves (doc, Elver 00:05:27).
      - Curves > Weighted Tangents / Non-Weighted Tangents; Curves > Change Rotation Interp (Independent Euler default, Synchronized Euler, Quaternion Slerp, Squad, Tangent Dependent) (doc).
      - Dial an exaggeration back without re-keying: select the section's keys, Region tool (or Scale Keys) about the rest value (Camporota ynXadXE9UjU 00:12:36; the overdone first anticipation, FA7fPB7qUhE 00:03:46) [added path]; scripted: `A.scale_curve(..., time=)`.
      - Curves > Isolate Curve; Bake Channel (not for IK or dynamics; use Key > Bake Animation); Lock and unlock channel H and J (doc).
      - View > Absolute, Stacked, Normalized (hotkeys 1, 2, 3 per Wade TMSpauVphNs 00:03:21) [verify]; View > Curve Name > Active Only (video 2022); View > Highlight Affected Curves; View > Theme (video 2020).
      - Channel Box > Channels > Sync Selection in Graph Editor, Sync Timeline Display: click "Translate X" to select every translate X curve (Wade 00:11:47); Channel Sets and View > Show Pins (2026.3); View > Show Lock/Mute Buttons (2027.2) (deltas).
      - Curve colors: Windows > Settings/Preferences > Color Settings > Graph Editor, not Edit > Set Curve Colors (Wade 00:15:05).
      
      ## Dope Sheet
      
      - Time Snap is off by default since 2025.1 (sub-frames survive interactive retimes): turn it on for whole-frame work (deltas); Ripple Edit, channel sets, precision mode (2025); Add and Remove Inbetween hotkeys (2025) (deltas).
      - Retime a section: key all controls at the section start (summary row), select the section, move the tail (Elver 00:16:44).
      
      ## Animation layers
      
      - Create Layer from Selected; Layers > Create Override Layer From Selected (doc).
      - Mute, Solo, Lock buttons; Options > Lock Muted Layers is on by default (doc).
      - Layers > Merge Layers option box: turn on Smart Bake once (the setting sticks), Apply (Newman 5RmqWjfU80s 00:08:23; doc for the menu).
      - Middle-drag a layer onto another to parent it (takes) (doc).
      
      ## Arc and spacing tools
      
      - Ghost Editor: Create Ghost from Selection; Ghost mode Before and After; Frame Step 2 on a hand control; ghost the controller, not the mesh; merge layers first; skinned meshes need Cached Playback (Newman Ajx1p5cAzXU; doc).
      - Motion Trail Editor: Pinning/Draw Always Draw, Path Mode Before and After, Trail Thickness, Show Keyframes, then W to move a trail key; Delete Selected Trail, or delete `motionTrail1Handle` in the Outliner (doc). Newman's 2021 settings: Time Slider range, increment 1 for mocap or the blocking interval, 10 frames pre and post, thickness 2, key size 3, frame numbers on (aNYr9nc9iM4 00:03:39).
      - Drawovers: Blue Pencil (commands `bluePencil*` since 2023) [verify menu]; or SyncSketch or RV outside Maya.
      
      ## Viewport review
      
      - Panels > Layouts > Four Panes with one lights-off pane: Lighting > Use All Lights (7) in a scene with no lights gives black silhouettes (Camporota FA7fPB7qUhE 00:06:07; Elver 01:03:43) [added mechanism].
      - Panels > Orthographic > side or front to see snaps on one axis (Newman TIBzcsOt2FU 00:11:06).
      - Show > Isolate Select to hide all but the torso (Elver 00:37:57) [added path].
      - Cached Playback icon in the playback controls: right-click for mode, preferences, flush; trust playback only with a full blue stripe; yellow is Safe Mode (doc).
      
      ## Playblast options (doc)
      
      Format (image for frames, movie formats per OS), Encoding, Quality, Display Size (From Window, From Render Settings, Custom), Scale (default 0.5: set 1.0), Render offscreen, frame padding; sound on the Time Slider goes into movies; gamma and exposure follow the viewport toolbar (2025.1).
      
      ## Sequencer (2027) (doc; Newman SGjxnw6c-IQ)
      
      - Right-click a Time Slider range > Create Shot; or Create Shot from Options (name, start, end, camera, placement at the current frame).
      - Sync Timeline on: the Time Slider follows the current shot; Time Scale Mode: drag a shot edge to scale (slow motion without touching animation); Ripple Edit once structure is settled; colors, labels, custom thumbnails; Playblast menu: selected shot, all shots on a track, the whole sequence, Re-Playblast Selected Shot.
      - Lock cameras: look through the camera, View > Select Camera, lock its channels (Newman 00:08:33).
      
      ## HumanIK window (doc)
      
      Definition tab (lock the definition) > Character menu (target) > Source menu (source); Custom Rig tab: right-click a cell > Assign Selected Effector; HumanIK menu > Edit Character Definition > Edit HIK Properties (Retarget Specific); Bake menu: Bake To Control Rig, Bake To Skeleton, Bake To Custom Rig; never Edit > Keys > Bake Simulation for HumanIK.
      
      ## Time Editor hotkeys (doc)
      
      I add animation, W split, E trim, R scale, T loop, Y hold, Ctrl+G group, V show keys, M mute track, N solo, Q mute the Time Editor, K release the cursor lock to scrub, A frame all, F frame selection, G frame the playback range.
      
      ## Feet (posing)
      
      - Foot roll and toe controls live on the rig's IK foot control (Animation Mentor rig: Foot Roll, Foot Break, Toe Tap, Heel Twist, ynXadXE9UjU f_00045); drag them in the Channel Box. Key the roll in every crouch (Camporota FA7fPB7qUhE 00:07:55); on the frame after a takeoff rotate the foot so the toe tip points at the takeoff spot (Elver jzuxAmadcm8 00:11:04) (video).
      
      ## Other
      
      - Modify > Match Transformations snaps an object to another (position, rotation) [added]; Newman's 2021 request for a native snap is met.
      - Edit > Delete by Type > Static Channels before playback judgment (doc).
      - Autosave: Windows > Settings/Preferences > Preferences > Files/Projects [verify]; the agent uses versioned saves instead.
      
    • procedures.md 30.9 KB
      # Procedures (maya.cmds and mx_anim), with their tests
      
      The pure-Python parts of `mx_anim` (physics, analysis, gates, images, lip timing) **ran offline** with python3 3.14 in `test_mx_anim_offline.py` (101 hard checks passed on 2026-09-24 after the body-mechanics refactor; logs in `tests/code/maya-animation/logs/`). Run `test_00_anim_probe.py` first once Maya is in: it answers every [verify] below.
      
      Import, in mayapy, a mx_run job or the GUI bridge:
      
      ```python
      import sys
      sys.path[:0] = ["<project>/skills/scenario-maya-animation/scripts", "<project>/skills/scenario-maya-expert/scripts"]
      import maya.cmds as cmds
      import mx_anim as A
      ```
      
      Headless job: `python3 <project>/skills/scenario-maya-expert/scripts/mx_run.py --scene shot_v003.ma job.py -- args` (see scenario-maya-expert). GUI: `mx_bridge.Bridge().run(code)`; playblasts only there.
      
      ## P0. mx_anim API (one line each; docstrings carry the details)
      
      Pure (no Maya, offline-tested):
      
      - `gravity_per_frame(fps, unit)` 1.703 cm/frame^2 at 24; `air_frames(rise, drop)`; `solve_jump(y0, y1, frames)`; `parabola_values(t0, t1, y0, y1)`.
      - `motion_report(points, frames, contacts=)` spacing, holds, moves, pops (local outliers), sharp turns, constant spacing; `format_report()`.
      - `ballistic_fit(heights, frames)` g_ratio, apex, hang excess; `landing_catch(heights, frames, contact)` catch, overshoots, `bottom_hold` (frames held at the deepest compression).
      - `contact_spans(heights, frames, ground, offset=)`, `foot_slide()`, `penetration()`; ground may be `[[start, end, height]]`.
      - `foot_pitch(heel, toe)` heel-to-toe pitch per frame (foot roll seen from the joints); `foot_events(spans, frames, heel, ball, tip=)` touch-downs with frames to flat, lift-offs; `peel_angle(ball, toe, takeoff)` degrees between the toe line and the takeoff spot.
      - `curve_diff(before, after, ignore=)` what a keying pass changed (keys, tangent types and angles, sampled curve) outside the spans you meant to edit.
      - `lag_frames(a, b)`, `overlap_report(signals, chain)`, `peak_order(signals, frames)`, `holds()`, `dead_holds()`.
      - `pose_to_pose_tell(key_times)`, `tween_fractions(times, values)`, `extreme_indices()`, `double_beats()`, `overshoots()`, `hold_drift()`, `key_density()`.
      - `transitions()`, `blink_report()`, `jaw_report()`, `mouth_keys(phonemes)`, `audio_envelope(wav)`, `lip_lag(env, jaw)`.
      - `loop_seam()`, `rotation_flips()`, `cycle_speed()`; `project_point()`, `fit_view()`.
      - `curve_lanes_png(path, curves, keys, spans, marks)`, `tracks_png(path, tracks, frames, size, bones, pose_frames, image=)`, `Canvas`.
      - `evaluate_gates(samples, plan)`, `format_gates()`.
      
      Maya:
      
      - keys: `set_key_defaults()`, `restore_key_defaults()`, `key_pose(pose, f, stepped, fill)`, `key_hold()`, `insert_key()`, `share_keys()`, `key_times()`, `plug_keys()`, `subframe_keys()`.
      - curves: `snapshot(nodes, attrs, samples=)`, `restore()`, `stepped_preview()`, `set_tangents()`, `to_spline()`, `break_tangent()`, `weighted_hang()`, `ballistic_keys()`, `cycle()`, `offset_keys()`, `retime()`, `snap_subframes()`, `scale_curve(node, attr, factor, pivot, time=)`, `copy_curve()`, `keep_extremes()`, `euler_filter()`.
      - sampling: `sample_matrices()`, `sample_world()`, `sample_attrs()`, `sample_curves()`, `camera_track()`.
      - helpers: `cog_proxy()`, `proxy_boxes()`, `delete_helpers()`, `store_plan()`, `load_plan()`.
      - layers and mocap: `layer_audit()`, `layer_try()`, `layer_offset()`, `merge_layer()`, `bake_track()`.
      - review: `run_gates(plan)`, `review_images(plan, out)`, `playblast_pack(out, s, e, cameras)` (GUI), `main()` (mx_run CLI).
      
      ## P1. Shot setup and plan
      
      Tests: `test_00_anim_probe.py` (defaults), `test_sequencer_sound_blur.py` (sound, cameras), `test_sampling_contacts.py` (plan round trip).
      
      ```python
      cmds.currentUnit(time="film")                                   # 24 fps; games: "ntsc" 30, "ntscf" 60
      cmds.playbackOptions(minTime=1, maxTime=72, animationStartTime=1, animationEndTime=72)
      print(cmds.keyTangent(q=True, g=True, inTangentType=True), cmds.keyTangent(q=True, g=True, outTangentType=True))
      cmds.sound(file="/abs/audio/line.wav", offset=1, name="dlg")    # dialogue; the envelope: A.audio_envelope(path, 24, 1)
      cam = cmds.camera(name="shotCam", focalLength=35)[0]
      cmds.xform(cam, worldSpace=True, translation=(520, 140, 45), rotation=(0, 90, 0))
      for a in ("tx", "ty", "tz", "rx", "ry", "rz"):
          cmds.setAttr(cam + "." + a, lock=True)                     # lock once framed (Newman SGjxnw6c-IQ 00:08:33)
      plan = {"name": "M4 heavy jump", "range": [1, 72], "fps": 24, "style": "heavy", "stage": "blocking",
              "cog": "hips_jnt", "feet": {"L": ["L_heel_jnt", "L_ball_jnt", "L_toe_jnt"], "R": ["R_heel_jnt", "R_ball_jnt", "R_toe_jnt"]},
              "ground": [[1, 44, 0.0], [45, 72, 100.0]], "rest_frame": 1, "air": [33, 45], "push": [27, 33], "crouch": [8, 27],
              "impacts": [45], "foot_roll": ["L_foot_ctrl.footRoll", "R_foot_ctrl.footRoll"], "spine": "chest_jnt",
              "chain": ["chest_jnt", "neck_jnt", "head_jnt"], "track": ["hips_jnt", "head_jnt", "L_wrist_jnt"],
              "controls": ["COG_ctrl", "chest_jnt", "neck_jnt", "head_jnt", "L_foot_ctrl", "R_foot_ctrl"],
              "camera": "shotCam", "poses": [1, 8, 16, 24, 27, 30, 33, 36, 39, 41, 44, 45, 47, 50, 57, 64, 66, 69, 72],
              "bones": [["hips_jnt", "chest_jnt"], ["chest_jnt", "neck_jnt"], ["neck_jnt", "head_jnt"]]}
      A.store_plan(plan)                                               # network node mxAnim_plan, string attribute
      ```
      
      Plan fields read by `run_gates`: range, fps, style (heavy, realistic, cartoony, acting), stage (blocking, spline, polish), cog, feet [heel, ball, toe], ground, rest_frame or foot_offsets, air [takeoff, landing], push, crouch (one window or a list: every anticipation that goes down), impacts (every landing, default air[1]), foot_roll (rig plugs; without them the heel-to-toe pitch is read), spine (default chain[0]), chain, track, controls, camera, poses, bones. Map the rig's controls once with `cmds.listAttr(ctrl, keyable=True)`; the foot roll attribute name is rig-specific (the Animation Mentor rig has Foot Roll and Foot Break on the IK foot control, ynXadXE9UjU f_00045). A walk, a hop or a lift uses the same fields: the foot gates run on every touch-down and lift-off in the range.
      
      **Worked frame plan for M4** (a synthesis [added] built on Camporota's jump anatomy, Wade's resistance beat and Elver's catch; the gates were tuned on it offline):
      
      | Frames   | Phase                                                                                                                                                            | Rule                                                                                  |
      | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
      | 1 to 8   | settle, look at the box                                                                                                                                          | moving hold                                                                           |
      | 8 to 24  | crouch, slow ease in, not overdone; foot roll keyed (gate `foot_roll`); spine drags behind the hips, only the arms drop (gate `spine_drag`)                      | Camporota FA7fPB7qUhE 00:03:46, 00:07:55, 00:08:28                                    |
      | 24 to 27 | resistance beat, near stillness with strain                                                                                                                      | Wade ZYKAMCZq2UI 00:03:05                                                             |
      | 27 to 33 | push, fastest vertical section; force core outward; toes leave last                                                                                              | Camporota 00:01:18; Wade 00:07:39                                                     |
      | 34       | first airborne frame: toe tip aimed back at the takeoff spot (gate `peel_off_*`)                                                                                 | Elver 00:10:32, 00:11:04                                                              |
      | 33 to 45 | air, gravity-true, apex near 41, legs reach on the last key                                                                                                      | `air_frames(60, 10)` about 12 frames; Camporota 00:02:32                              |
      | 45 to 50 | catch on the box: most fall speed gone in 2 frames, one overshoot; balls land, heels flat by 46 (gate `heel_to_flat_*`); hold at the bottom (gate `impact_hold`) | Elver 00:58:46, 00:14:29; Newman TIBzcsOt2FU 00:13:56; Camporota ynXadXE9UjU 00:09:26 |
      | 50 to 64 | slow heavy recovery                                                                                                                                              | Wade 00:03:05                                                                         |
      | 64 to 72 | small overshoot, moving hold, no dead stop                                                                                                                       | Elver 00:54:54                                                                        |
      
      ## P2. Proxy boxes pass (Camporota)
      
      Test: `test_m4_jump.py` (step 2).
      
      ```python
      px = A.proxy_boxes(hip_height=96.0)            # root at hip height (the lean pivot), body and head boxes, tagged
      root = px["root"]
      for f, (y, z) in sorted(path.items()):         # path: {frame: (height, forward)} from the plan, air from solve_jump
          cmds.setKeyframe(root, attribute="translateY", time=f, value=y)
          cmds.setKeyframe(root, attribute="translateZ", time=f, value=z)
      for f, v in LEAN.items():
          cmds.setKeyframe(root, attribute="rotateX", time=f, value=float(v))
      A.offset_keys([root], 1, attrs=["rotateX"])    # rotation 1 frame behind translation (FA7fPB7qUhE 00:04:58)
      pw = A.sample_world([root], list(range(1, 73)))
      pg = A.evaluate_gates({"frames": list(range(1, 73)), "fps": 24.0, "unit": "cm", "up": 1, "world": pw},
                            dict(plan, cog=root, feet={}, chain=[], track=[root]))
      print(A.format_gates(pg))
      A.curve_lanes_png("/abs/out/proxy_curves.png", A.sample_curves([root], range(1, 73)), spans=[(33, 45, "air")])
      ```
      
      Cartoony option: fewer frames on takeoff and landing, `cmds.scaleKey(root, time=(24, 33), timeScale=0.75, timePivot=33)`, then `A.snap_subframes([root])`. Heavy: keep the gravity-true air (`A.ballistic_keys(root, "translateY", 33, 45)`). First passes overdo the anticipation (Camporota 00:03:46): after the proxy playblast, dial the crouch depth back without touching the takeoff, `A.scale_curve(root, "translateY", 0.8, pivot=96.0, time=(8, 27))`, re-run the gates (resistance and push_fastest must still pass), compare the two playblasts. Test: `test_keys.py` (range-limited scale_curve).
      
      ## P3. Stepped blocking with key categories (Neistadt, Newman)
      
      Tests: `test_keys.py` (pose columns, holds, share keys, stepped insert), `test_m4_jump.py` (step 3).
      
      ```python
      prev = A.set_key_defaults("blocking")          # in linear, out step; returns the old defaults
      for f in plan["poses"]:
          pose = {"COG_ctrl": {"translateY": path[f][0], "translateZ": path[f][1], "rotateX": LEAN[f]},
                  "L_foot_ctrl": {"translateY": 8.0, "translateZ": 0.0}}          # per pose, from the plan
          A.key_pose(pose, f, stepped=True, fill=ctrls)   # every control keyed on every key pose
      A.key_hold(ctrls, 24, 27)                      # closing key of a hold: the pose at 24 kept to 27
      A.share_keys(ctrls)                            # union of key times on every control, inserted (no reshaping)
      A.restore_key_defaults(prev)
      kt = A.key_times(ctrls)                        # check against the plan's key categories
      ```
      
      Pass order: main keys, anticipations, overshoots and follow-through, breakdowns, holds (GMTet6nd_iM 00:29:08). Crouch and landing poses carry the feet and spine: foot roll keyed on the IK foot control, the chest a frame behind the hips on the way down, the arms hanging (Camporota FA7fPB7qUhE 00:07:55, 00:08:28). Breakdowns favor a neighbor: set the value from `prev + (next - prev) * 0.7` or 0.3, never 0.5 (t-YsIXaPvEg 00:05:07; check with `A.tween_fractions`). Fast sections on ones (`A.key_density(times, speeds, frames)` flags gaps). Insert single keys with `A.insert_key(node, attr, f)`, never a bare `setKeyframe` on a shaped curve.
      
      ## P4. Spline readiness and spline pass 1 (the core)
      
      Tests: `test_keys.py` (snapshot and restore, to_spline through holds, stepped preview, ballistic keys, broken tangent), `test_m4_jump.py` (step 4).
      
      ```python
      snap = A.snapshot(ctrls)                                  # the buffer curve, JSON-serializable
      A.to_spline(["COG_ctrl", "L_foot_ctrl", "R_foot_ctrl"], tangent="auto")    # flat sides on equal-key holds
      A.copy_curve("mxAnim_proxy", "translateY", "COG_ctrl", "translateY")      # trust the proxy spacing (Camporota)
      A.ballistic_keys("COG_ctrl", "translateY", 33, 45)       # or: gravity-true air keyed per frame
      A.set_tangents(["COG_ctrl"], "linear", "linear", time=(33, 45), attrs=["translateZ"])   # nothing pushes in the air
      A.break_tangent("COG_ctrl", "translateY", 45, in_angle=-70.0, out_angle=-20.0)       # hard landing [angles by eye]
      # readiness: sample, look, decide
      pts = A.sample_world(["head_jnt", "L_wrist_jnt"], range(1, 73))
      res = A.run_gates(plan)
      if not res["ok"]:
          A.restore(snap)                                       # back to stepped, add poses (TIBzcsOt2FU 00:07:00)
      ```
      
      **Keys added after splining (breakdowns, contact fixes).** Native Set Key reshapes neighboring curves (Wade TMSpauVphNs 00:01:45; G49wexNmQ-Q 00:06:22). Insert, then prove nothing else moved. Test: `test_keys.py` (insert vs a bare key, a breakdown edit, all soft rows until 2027 answers).
      
      ```python
      fr = [45 + i * 0.25 for i in range(89)]                     # quarter frames 45..67 around the edit
      before = A.snapshot(["COG_ctrl"], ["translateY"], samples=fr)
      A.insert_key("COG_ctrl", "translateY", 54)                  # recovery keys at 50, 57, 64; setKeyframe(insert=True)
      crv = cmds.keyframe("COG_ctrl", attribute="translateY", query=True, name=True)[0]
      cmds.keyframe(crv, edit=True, time=(54, 54), absolute=True, valueChange=158.0)   # breakdown favoring the low key
      d = A.curve_diff(before, A.snapshot(["COG_ctrl"], ["translateY"], samples=fr), ignore=[(50, 57)])
      if not d["ok"]:
          print(d["changed"], d["moved_samples"][:5])             # neighbors moved: lock them (fixed tangents) or restore(before)
      ```
      
      `ignore` is the new key's two segments (previous key to next key); only their interior is ignored, so the neighboring keys must not move. An insert on a weighted curve changes the neighbors' weights to keep the shape: weights are not compared [added].
      
      Tangent choice by stage: blocking step; core auto (2027 Auto Span family [verify token]); holds flat or plateau (Plateau keeps extremes on keys); planted feet flat, linear or clamped with equal values (Clamped fixes cycle slip, Maya 2027 Help); air per-frame keys or a flat apex; hard contact broken; eyes linear; face stepped until its pass. Heavy bodies: never `A.weighted_hang` (it adds hang beyond gravity; light or cartoony only).
      
      ## P5. Spline pass 2: overlap
      
      Tests: `test_keys.py` (offset inside a cycle), `test_m4_jump.py` (step 5).
      
      ```python
      A.to_spline(["chest_jnt", "neck_jnt", "head_jnt"], tangent="auto")
      A.offset_keys(["neck_jnt"], 1)                  # chains 0, 1, 2 frames; head 1 (ynXadXE9UjU 00:22:33, 00:32:02)
      A.offset_keys(["head_jnt"], 2)
      sig = A.sample_world(plan["chain"], range(1, 73))
      spd = {n: [A._dist(p[i + 1], p[i]) for i in range(len(p) - 1)] for n, p in sig.items()}
      print(A.overlap_report(spd, plan["chain"], mode="value"))
      print(A.pose_to_pose_tell(A.key_times(ctrls)))  # shared_fraction high = everything starts and stops together
      ```
      
      Correlated controls (three spine controls) move together first, offsets after (TIBzcsOt2FU 00:16:58). Force chains: `A.peak_order({"pelvis": ..., "shoulder": ..., "wrist": ...}, frames)` must come out core first (ZYKAMCZq2UI 00:07:39).
      
      ## P6. Gates and headless images (the agent's eyes without a viewport)
      
      Tests: `test_sampling_contacts.py`, `test_m4_jump.py` (step 6, CLI). Pure gate logic: `test_mx_anim_offline.py` (M4 good passes all 23 gates; floaty fails ballistic; a sliding foot fails slide_R; no resistance fails resistance; a frozen end warns moving_hold; feet flat in the crouch warn foot_roll; a chest locked to the hips warns spine_drag; a flat foot on the first airborne frame warns peel_off; heels 4 frames to flat warn heel_to_flat; a bounce out of the squash warns impact_hold, each without breaking another gate).
      
      Body-mechanics gates, for any jump, hop, landing or weight shift (the plan fields decide where they run):
      
      | Gate                  | Reads                                                                        | Pass                                                  | Source                         |
      | --------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------ |
      | `foot_roll`           | every crouch window: `plan["foot_roll"]` plugs, else `foot_pitch(heel, toe)` | range above 0 (attribute) or 1 degree (pitch) [added] | Camporota FA7fPB7qUhE 00:07:55 |
      | `spine_drag`          | every crouch window: `lag_frames(COG height, spine height)`                  | spine lags 1+ frame                                   | Camporota 00:08:28             |
      | `heel_to_flat_<side>` | every touch-down from `foot_events`                                          | heel and ball planted within 2 frames                 | Camporota ynXadXE9UjU 00:09:26 |
      | `peel_off_<side>`     | every lift-off: `peel_angle` on the first airborne frame                     | at most 30 degrees [added]                            | Elver jzuxAmadcm8 00:10:32     |
      | `impact_hold`         | every `plan["impacts"]` frame: `landing_catch()["bottom_hold"]`              | 3+ frames [added]                                     | Elver 00:13:54, 00:14:29       |
      
      ```python
      res = A.run_gates(plan)                          # samples with currentTime (robust with IK and constraints)
      print(A.format_gates(res))
      imgs = A.review_images(plan, "/abs/out/review_v003")   # curves.png (stacked lanes), tracks.png (camera space)
      ```
      
      Shell, headless:
      
      ```
      python3 <project>/skills/scenario-maya-expert/scripts/mx_run.py --scene /abs/shot_v003.ma \
          <project>/skills/scenario-maya-animation/scripts/mx_anim.py -- --plan /abs/plan.json --out /abs/out/review_v003
      ```
      
      Open both PNGs with the image reader, every time. `tracks.png` draws a dot per frame (gaps are spacing) and gray-to-black stick figures at `plan["poses"]` (an onion skin). Without a camera it falls back to a schematic side view (`fit_view`). A trajectory can be drawn over any playblast frame: `A.tracks_png(out, tracks, frames, (1280, 720), image=frame_png)`.
      
      ## P7. GUI review pack (playblasts through the bridge)
      
      Test: `gui_playblast_review.py` (needs GUI Maya with `mx_bridge_server.start()`).
      
      ```python
      b = mx_bridge.Bridge()
      b.run("""
      import mx_anim as A, mx_review as R
      pack = A.playblast_pack("/abs/out/pb_v003", 1, 72, cameras=("shotCam",), silhouette=True, width=1280, height=720)
      with A.stepped_preview(A.load_plan()["controls"]):        # Enable Stepped Preview, scripted and restored
          R.playblast("/abs/out/pb_v003/stepped", 1, 72, camera="shotCam", width=1280, height=720,
                      display={"grid": False, "nurbsCurves": False, "joints": False})
      result = pack
      """)
      ```
      
      Check the output: 72 PNGs per pass at 1280 x 720 (`mx_review.png_size`), then read them in time order (every 2nd frame for the first-view read, every frame on flagged ranges). The silhouette pass uses `displayLights="none"` [verify value]. Movie with sound for a human: `cmds.playblast(format="avfoundation" or "qt", sound="dlg", ...)` [verify macOS format names with `cmds.playblast(q=True, format=True)`]. Playblast defaults to half size: always pass `percent=100` and an explicit size.
      
      ## P8. Walk cycle (Camporota) and game clip QA (Newman, Epic)
      
      Tests: `test_keys.py` (cycle, offsets keep the period), pure seam and speed in `test_mx_anim_offline.py`.
      
      ```python
      start, step = 1, 12
      frames = {start + s * step + i * 3: (p, side) for s, side in ((0, "L"), (1, "R"))
                for i, p in enumerate(("contact", "down", "pass", "up"))}
      frames[start + 24] = ("contact", "L")                     # loop key: a copy of frame 1
      # key feet and COG on these frames (legs first), then loop before any polish:
      issues = A.cycle(["COG_ctrl", "L_foot_ctrl", "R_foot_ctrl"], travel=["COG_ctrl.translateZ"])   # cycleRelative on travel
      A.scale_curve("COG_ctrl", "translateY", 0.6, pivot=rest_y)       # too much bounce: scale, do not re-key
      A.offset_keys(["L_forearm_ctrl"], 1); A.offset_keys(["L_hand_ctrl"], 2); A.offset_keys(["head_ctrl"], 1)
      ```
      
      Checks: side shift peaks between pass and up; supporting hip up on pass and up (ynXadXE9UjU 00:11:27); heel to flat in about 1 frame; `A.loop_seam(pose_at(1), pose_at(25))`; for games `A.cycle_speed(root_track, frames)` equals the metric (cm/s), root at the origin on the first frame, in-place clips keep the root still, root motion only on the root and no root Z for walking content (UE 5.8). Bake the loop range before export so infinity becomes keys (`cmds.bakeResults(ctrls, time=(1, 25), simulation=False)` [verify]), then scenario-maya-pipeline-scripting.
      
      ## P9. Heavy interaction beat (lift, throw, punch)
      
      Not yet run in Maya (keys as P3; gates pure and offline-tested).
      
      - Plan both numbers: object weight and strength (1 to 5) [added scale]; resistance frames grow with weight minus strength, overcome frames shrink with strength (ZYKAMCZq2UI 00:09:03, mapping [added]).
      - Resistance: contact, then 2+ frames where the object barely moves while the body loads (`A.holds()` on the object track); a tremble on a small additive layer, baked before polish (Elver 00:50:56).
      - Overcome: keys ordered back, shoulders, arms with 1-frame offsets; `A.peak_order()` core first; the object lags the hand in a throw (measure the hand-to-object distance along the motion); a punch leans in, then a short A to B.
      
      ## P10. Lip sync passes (Santos, Wade, Lazare) and the face
      
      Tests: `test_sequencer_sound_blur.py` (sound node, muppet pass from `mouth_keys`, `jaw_report`, `lip_lag`), `test_keys.py` (`copy_curve`), pure in `test_mx_anim_offline.py`.
      
      ```python
      phon = [("M", 12), ("AA1", 14), ("N", 18), ("IY1", 20), ("P", 26), ("OW1", 28)]   # onsets from a forced aligner [added]
      tent = {"AA1": 18.0, "OW1": 22.0}                     # 2 to 4 tentpoles per line; the rest subordinate
      prev = A.set_key_defaults("blocking")
      for m in A.mouth_keys(phon):                          # lead by place of articulation: bilabial 0, alveolar 1...
          v = -1.0 if m["class"] == "bilabial" else (tent.get(m["sound"], 4.0) if m["class"] == "vowel" else 3.0)
          cmds.setKeyframe("jaw_ctrl", attribute="rotateX", time=m["key_frame"], value=v, outTangentType="step")
      A.restore_key_defaults(prev)
      jv = [cmds.getAttr("jaw_ctrl.rotateX", time=f) for f in range(1, 49)]
      print(A.jaw_report(jv, list(range(1, 49))))          # reversals per second (chatter), tentpole ratio, first-frame share
      env = [v for _, v in A.audio_envelope("/abs/audio/line.wav", 24.0, 1)]
      print(A.lip_lag(env[:48], jv))                        # lag > 0: the mouth is late; shift all mouth keys 1 to 2 earlier
      A.copy_curve("L_mouthCorner_ctrl", "translateY", "L_nostril_ctrl", value_scale=0.3, time_offset=1.0)   # connection
      ```
      
      Order: body, mask (eyes, brows), phrase pass, muppet (jaw only), big five (corners TX then TY, never TZ; top and bottom lip for M, B, P, F, V), polish (tongue first, teeth anchored, breath, connection). Global late fix (Lazare): `A.offset_keys(mouth_ctrls, -2)` or move the sound node 2 frames later. Eyes: `A.set_tangents(["eye_aim_ctrl"], "linear", "linear")`, darts of 2 frames between locked holds, checked with `A.transitions()`; blinks with `A.blink_report(lid, frames, closed=..., opened=...)` (closing frames under opening frames, first closing frame under half the travel). Keys stay stacked until the face polish (Santos 00:48:59).
      
      ## P11. Layer protocol and merge (Newman, Elver, doc)
      
      Test: `test_layers_mocap.py`.
      
      ```python
      lyr = A.layer_try(["L_shoulder_ctrl", "R_shoulder_ctrl"], "try_idea", [18, 26, 34, 41, 50, 62])  # last = protective key
      A.layer_offset(lyr, "L_shoulder_ctrl", "rotateZ", 26, 8.0)   # offsets only at marker frames [verify value semantics]
      # playblast, review; time-box 30 to 40 minutes or N iterations, then:
      print(A.layer_audit())                                       # layers, mode, mute, weight; flags
      info = A.merge_layer(lyr, 1, 72)                             # bakeResults onto the base layer, smart [verify], Euler filter
      ```
      
      Never block or clean on a layer; merge first (5RmqWjfU80s 00:07:24). An empty Override layer is muted until keyed (doc). Use Additive for offsets and timing, Override plus an IK handle with IK Blend 0, 0, 1 on linear keys to pin a mocap contact (doc: frames 0, 20, 27).
      
      ## P12. Mocap re-edit and a prop in the hand
      
      Test: `test_layers_mocap.py`.
      
      ```python
      A.euler_filter(ctrls)                                        # first, after any merge or bake (JzwfomndbMA 00:08:31)
      cut = A.keep_extremes("L_elbow_ctrl", "rotateY", 120, 160)   # delete to extremes, then favor, hold, overshoot by hand
      n = A.bake_track("knife_loc", "R_wrist_jnt", grab, release)  # constraint with offset over the held range only, baked
      ```
      
      Guard the performance: compare untouched joints with the raw capture (RMS of world tracks, `A.sample_world`); contacts must not move unless the note asks. Pops: `A.motion_report(track, frames)["spikes"]`, judged by eye before deleting (some wobbles are performance).
      
      ## P13. Retarget with HumanIK (outline) and the fallback
      
      Outline only: HumanIK scripting is MEL procedures shipped with `mayaHIK`, names unverified (the probe records `hikCreateCharacter`, `setCharacterObject`, `hikBakeCharacter`). Steps (Maya 2027 Help): definition for source and target, lock them; Character and Source menus; Custom Rig mapping (IK effectors T and R, FK R only, up vectors translation only, offsets zero), saved as XML; Retarget Specific attributes (Match Source off for a bigger target, Knee/Elbow Max Extension 50, Mass Center Compensation 80); bake from the HumanIK Bake menu; mute or delete layers on the Control rig. Fallback for matching skeletons: `parentConstraint(src, ctrl, maintainOffset=True)` per mapped pair from a matched T-pose, `bakeResults(simulation=True)`, delete constraints, `A.euler_filter` (same mechanics as `bake_track`, tested in `test_layers_mocap.py`). QA: stride ratio vs leg-length ratio (tiny steps), no knee or elbow snapping at full extension, hips between the feet, `A.foot_slide` on contacts.
      
      ## P14. Retime a section and snap sub-frames (Elver, Wade)
      
      Test: `test_keys.py`.
      
      ```python
      A.retime(ctrls, 134, 152, -2)       # keys inserted at both ends, section scaled, tail shifted, sub-frames snapped
      A.snap_subframes(ctrls)             # after any scaleKey: collisions keep one key with the curve's value
      ```
      
      ## P15. Motion-blurred review render (Elver) and Sequencer shots (Newman)
      
      Test: `test_sequencer_sound_blur.py` (with `--plugins mtoa`).
      
      ```python
      cmds.setAttr("defaultArnoldRenderOptions.motion_blur_enable", 1)   # [verify attribute name]
      cmds.file(rename="/abs/out/blur_review_v001.ma"); cmds.file(save=True, type="mayaAscii")
      # shell: Render -r arnold -cam shotCam -s 40 -e 46 -x 960 -y 540 -rd /abs/out/blur /abs/out/blur_review_v001.ma
      cmds.shot("shot010", startTime=1, endTime=34, sequenceStartTime=1, sequenceEndTime=34, currentCamera="cam1")  # [verify]
      ```
      
      Look at the blurred frames around fast moves and contacts: two keys on adjacent frames with a big jump blur badly; a half-frame key (`cmds.setKeyframe(ctrl, time=42.5)`) fixes it (00:39:35). Render settings beyond this belong to scenario-maya-lighting-rendering. Sequencer checks: every shot has a camera, cameras locked, shots contiguous per track, scaled shots listed.
      
      ## GUI-only techniques and their substitutes
      
      | Technique                          | Why GUI            | Substitute the agent uses                                                                                                        | Test                               |
      | ---------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
      | Scrubbing, playback                | viewport           | playblast frames in time order; `tracks.png`                                                                                     | gui_playblast_review, test_m4_jump |
      | Ghosts, onion skin                 | viewport drawing   | `tracks_png(..., bones, pose_frames)`                                                                                            | offline, test_m4_jump              |
      | Motion trails                      | viewport, 3D only  | `camera_track` pixel tracks, `motion_report(dims 2)`                                                                             | test_sampling_contacts             |
      | Graph Editor stacked view          | editor             | `curve_lanes_png`                                                                                                                | offline, test_m4_jump              |
      | Buffer curves                      | editor             | `snapshot` / `restore` (JSON)                                                                                                    | test_keys                          |
      | Enable Stepped Preview             | Time Slider toggle | `stepped_preview()` context                                                                                                      | test_keys, gui_playblast_review    |
      | Drawover (Blue Pencil, SyncSketch) | tablet             | `tracks_png(image=playblast_frame)`                                                                                              | offline, gui_playblast_review      |
      | Tween sliders, blend to neighbor   | slider             | value = prev + (next - prev) * t, `tween_fractions` check                                                                        | offline                            |
      | Dope Sheet summary retime          | editor drag        | `retime()`                                                                                                                       | test_keys                          |
      | Lights-off silhouette (hotkey 7)   | viewport           | `playblast_pack(silhouette=True)`; headless `mx_review.review(modes=("silhouette",))` at key frames [verify deformed duplicates] | gui_playblast_review               |
      | Cached Playback real-time check    | viewport           | not needed: timing is judged from playblasts and numbers                                                                         | none                               |
      | Temp pivot                         | manipulator        | world matrix math: `M_new = M * P^-1 * R * P`, written with `xform(ws=True, m=...)` [added]                                      | none yet                           |
      
    • sources.md 17.4 KB
      # Sources
      
      Distilled notes for every source are in `notes/animation/` (file name = video ID or doc key), with the three digests `_digest_animation_workflow_acting.md`, `_digest_animation_maya_tools.md`, `_digest_animation_body_mechanics.md`. Captions are auto-generated unless stated; frames exist only for the Camporota and Graph Editor videos.
      
      ## Videos
      
      | Expert, credential                                                                                                       | Title (year, length)                                                                           | URL                                         | Best for                                                                                                                                   | Best timestamps                                                                                              |
      | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
      | Sir Wade Neistadt, former DreamWorks Animation production trainer (Trolls, The Boss Baby)                                | The 7 Workflows of Professional Animators (2021, 34:42)                                        | https://www.youtube.com/watch?v=GMTet6nd_iM | choosing a workflow; the five key categories; reference landmarks                                                                          | 00:02:09, 00:07:02, 00:23:07, 00:24:28, 00:29:08, 00:31:14                                                   |
      | Sir Wade Neistadt                                                                                                        | The Secret Workflow for Animating Dialogue (2021, 47:49)                                       | https://www.youtube.com/watch?v=5cIxEZwZmS4 | breakdown on paper, ruled sounds, jaw, corners, lip shape, tongue, facial polish                                                           | 00:08:49, 00:13:42, 00:15:30, 00:19:14, 00:23:31, 00:37:24, 00:41:27                                         |
      | Sir Wade Neistadt                                                                                                        | Why Graph Editor is an Animator's HOLY GRAIL (2022, 17:55, Maya 2020.4)                        | https://www.youtube.com/watch?v=TMSpauVphNs | Set Key reshaping, stacked view, weighted-tangent bounce, channel sync, curve colors                                                       | 00:02:18, 00:03:55, 00:06:57, 00:08:00, 00:11:47, 00:15:37                                                   |
      | Sir Wade Neistadt                                                                                                        | Animating HEAVY Weight (Objects, Punches, Throwing) (2024, 10:16; captions machine-translated) | https://www.youtube.com/watch?v=ZYKAMCZq2UI | weight vs strength formula, resistance, object lag, force from the core                                                                    | 00:00:00, 00:01:49, 00:03:05, 00:05:00, 00:05:48, 00:07:39, 00:09:34                                         |
      | Sir Wade Neistadt                                                                                                        | How to Use AnimBot (2020, 41:43)                                                               | https://www.youtube.com/watch?v=G49wexNmQ-Q | the operations animators run constantly and where native Maya fails (smart key, blend to neighbor, share keys, smart snap)                 | 00:06:22, 00:14:10, 00:21:41, 00:29:34, 00:33:42                                                             |
      | Ere Santos, animation supervisor Walt Disney Animation Studios (Moana 2); ex-Pixar, Sony Imageworks, MPC, DNEG           | Mastering Lip Sync (Animation Mentor workshop, 2024, 1:01:13)                                  | https://www.youtube.com/watch?v=S1MRs3XJVPI | lead by place of articulation, muppet and big five passes, curve-copy connection, teeth and tongue                                         | 00:06:08, 00:13:20, 00:15:31, 00:34:28, 00:37:40, 00:48:24, 00:52:16                                         |
      | Arslan Elver, senior animation supervisor Framestore London (Rocky in Project Hail Mary; animation director on IF)       | Polishing Passes (Animation Mentor workshop, 2026, 1:33:37; craft ends about 1:04:50)          | https://www.youtube.com/watch?v=jzuxAmadcm8 | polish triage, COG ball, camera-space arcs, eyes and blinks, motion-blur check, weight as a caught stop, live-action limits, layers policy | 00:03:49, 00:06:34, 00:08:17, 00:10:32, 00:16:44, 00:19:29, 00:20:02, 00:27:19, 00:48:48, 00:58:13, 01:02:04 |
      | Jeremy Lazare, animator Pixar (Inside Out 2, Lightyear); ex-Blue Sky, Framestore, MPC                                    | 11 Second Club feedback (Animation Mentor, 2024, 47:31)                                        | https://www.youtube.com/watch?v=m8YPvVtfLhw | a supervisor's review order, attention rule, progression vs double beat, global lip shift                                                  | 00:00:37, 00:05:07, 00:08:28, 00:14:09, 00:19:33, 00:34:25, 00:37:20                                         |
      | Harvey Newman, AAA animation and game director (Dune Awakening, GoldenEye, Dead Space, Crysis, Battlefront, Battlefield) | What Animation Directors Really Look For in Dailies (2026, 14:03)                              | https://www.youtube.com/watch?v=7r_Czt3-SKE | the four review buckets, director lingo, bottling notes                                                                                    | 00:05:23, 00:07:20, 00:09:43, 00:10:49, 00:11:50                                                             |
      | Harvey Newman                                                                                                            | Animation Power Tips S01E07: From Idea to Blocking (2020, 18:02)                               | https://www.youtube.com/watch?v=QiOGugW8Ips | start-middle-end, draw-overs, key density by speed                                                                                         | 00:03:46, 00:10:00, 00:14:39, 00:15:27                                                                       |
      | Harvey Newman                                                                                                            | S01E05: Animation Layers (2020, 12:28)                                                         | https://www.youtube.com/watch?v=5RmqWjfU80s | the layer protocol: marker and protective keys, merge with Smart Bake, time box                                                            | 00:03:08, 00:05:07, 00:07:24, 00:08:23, 00:10:29                                                             |
      | Harvey Newman                                                                                                            | S01E06: Tips for Gameplay Animation (2020, 12:41)                                              | https://www.youtube.com/watch?v=-vUpqWi8b50 | cached playback, bookmarks per clip                                                                                                        | 00:02:14, 00:06:12                                                                                           |
      | Harvey Newman                                                                                                            | S01E08: Motion Capture vs Hand-key (2020, 16:23)                                               | https://www.youtube.com/watch?v=7gZt3RCmgfM | the mocap mindset, typical defects, the cascade, game time budgets                                                                         | 00:05:56, 00:07:31, 00:08:03, 00:10:25                                                                       |
      | Harvey Newman                                                                                                            | S02E01: How to Re-edit Motion Capture (2021, 14:26)                                            | https://www.youtube.com/watch?v=JzwfomndbMA | two-stage mocap retime: sparse layer, merge, Euler, delete to extremes                                                                     | 00:03:36, 00:08:31, 00:10:07, 00:12:23                                                                       |
      | Harvey Newman                                                                                                            | S02E04: When to Go from Blocking to Spline (2021, 20:23)                                       | https://www.youtube.com/watch?v=TIBzcsOt2FU | the spline readiness test, first spline pass order, broken landing tangents                                                                | 00:04:53, 00:07:00, 00:10:01, 00:13:56, 00:16:58                                                             |
      | Harvey Newman                                                                                                            | S02E05: Ghosting Editor (2021, 17:07, Maya 2022)                                               | https://www.youtube.com/watch?v=Ajx1p5cAzXU | ghosts vs trails, settings                                                                                                                 | 00:08:08, 00:10:17, 00:16:03                                                                                 |
      | Harvey Newman                                                                                                            | S02E10: Using Motion Trails (2021, 11:36, Maya 2022)                                           | https://www.youtube.com/watch?v=aNYr9nc9iM4 | trail settings, editing on a layer, deleting trails                                                                                        | 00:03:39, 00:05:45, 00:06:16                                                                                 |
      | Harvey Newman                                                                                                            | S02E02: My Plugins and Scripts (2021, 18:10)                                                   | https://www.youtube.com/watch?v=t-YsIXaPvEg | IK hands without switching, prop tracking over a range, 50/50 tween trap                                                                   | 00:05:07, 00:07:25, 00:10:45, 00:11:50                                                                       |
      | Harvey Newman                                                                                                            | The NEW Maya 2027 Animation Sequencer (2026, 22:12, Maya 2027)                                 | https://www.youtube.com/watch?v=SGjxnw6c-IQ | 2027 Sequencer shots, camera locks, Time Scale, Ripple Edit                                                                                | 00:06:20, 00:08:33, 00:17:24, 00:20:20                                                                       |
      | Harvey Newman                                                                                                            | Game Animation Essentials: Metrics and Motion (2024, 19:09, manual captions)                   | https://www.youtube.com/watch?v=w4F6vbmcxZ8 | game metrics, speeds, interaction dimensions, variable jumps                                                                               | 00:04:45, 00:06:59, 00:11:09, 00:14:51                                                                       |
      | Alessandro Camporota, Lead Animator Riot Games; ex-Sony Imageworks, Ubisoft, Method                                      | how to animate a walk cycle (100% polish) (2019, 32:39, Maya 2019; frames)                     | https://www.youtube.com/watch?v=ynXadXE9UjU | walk built from the legs outward, key schedule, infinity, hip tilt, offsets, knee pop                                                      | 00:04:08, 00:05:15, 00:09:26, 00:11:27, 00:14:42, 00:22:33, 00:30:07                                         |
      | Alessandro Camporota                                                                                                     | How To Animate a Jump: Part 1 (2019, 18:54; frames)                                            | https://www.youtube.com/watch?v=FA7fPB7qUhE | proxy boxes, jump anatomy, silhouette panel, core-first in-betweens                                                                        | 00:00:38, 00:01:18, 00:02:32, 00:04:58, 00:06:07, 00:15:32                                                   |
      
      ## Documentation
      
      | Source                                                                     | URL                                                                                                                      | Best for                                                                                              |
      | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
      | Maya 2027 Help: Graph Editor tangents, Curves menu, rotation interpolation | https://help.autodesk.com/cloudhelp/2027/ENU/Maya-Animation/files/GUID-43A4FE2C-4863-4EA6-B6AE-6D2B6757F6C7.htm          | tangent semantics (Plateau, Clamped, Stepped Next), filters, infinity, buffer curves, Stepped Preview |
      | Maya 2027 Help: Animation Layers                                           | https://help.autodesk.com/cloudhelp/2027/ENU/Maya-Animation/files/GUID-5C202CB8-EB3C-4ADE-B203-5F93A9FD9104.htm          | Additive, Override, Passthrough; mocap wrist fix with IK Blend; takes; merge order                    |
      | Maya 2027 Help: Cached Playback and optimization                           | https://help.autodesk.com/cloudhelp/2027/ENU/Maya-Animation/files/GUID-C5EEF1E7-C24F-4BBC-8BB1-5036CA7D7D02.htm          | when viewport playback can be trusted; what disables caching                                          |
      | Maya 2027 Help: Ghosting Editor and Motion Trails                          | https://help.autodesk.com/cloudhelp/2027/ENU/Maya-Animation/files/GUID-2656574F-FBC6-457B-B0C1-5C1249DA89EF.htm          | 2027 menus and limits of ghosts and trails                                                            |
      | Maya 2027 Help: HumanIK retargeting and baking                             | https://help.autodesk.com/cloudhelp/2027/ENU/Maya-CharacterAnimation/files/GUID-B2E9F8B9-F188-4E27-8820-BB0C8541701F.htm | definitions, custom rig mapping, Retarget Specific defaults, bake rules                               |
      | Maya 2027 Help: Time Editor                                                | https://help.autodesk.com/cloudhelp/2027/ENU/Maya-Animation/files/GUID-E4B5DB7D-7351-4561-BD8B-60AC9D48DDF6.htm          | clips, transitions, relocators, clip layers, remap limits                                             |
      | Maya 2027 Help: Sequencer, Playblast, MotionMaker horses                   | https://help.autodesk.com/cloudhelp/2027/ENU/Maya-Animation/files/GUID-FDCA1426-D7FE-41A5-9563-5628C736BCCC.htm          | playblast defaults and flags, 2027 Sequencer                                                          |
      | Epic Games, Root Motion in Unreal Engine (UE 5.8)                          | https://dev.epicgames.com/documentation/en-us/unreal-engine/root-motion-in-unreal-engine                                 | in place vs root motion, Z ignored while walking, performance                                         |
      | `sources/maya-version-deltas.md` section 2.4 (project file)                | local                                                                                                                    | every 2027 animation rename and default, and the verify list (section 4)                              |
      
      ## Project inputs used for method, not content
      
      - `tests/baseline/answers.md` M4 and `tests/scenarios.md` M4: the unskilled baseline (physics, parabola check and foot drift were already there; resistance, catch, camera-space arcs, layer policy, key categories and spline readiness were missing, and it suggested layers for polish overlap, which Elver rejects).
      - Sister skill `../2026-09-24 Blender Expert Skills/skills/scenario-blender-animation/`: ideas only (measurement loop, onion-skin and curve images, hold pillars); no Blender specifics carried over.
      
      ## Revision history
      
      - 2026-09-24, v2 (refactor after blind grade `tests/grading/M4_grade.md`; v1 kept in `skills/_versions/maya-animation v1 2026-09-24/`): added, from FA7fPB7qUhE [00:03:11] [00:03:46] [00:07:55] [00:08:28], ynXadXE9UjU [00:09:26], jzuxAmadcm8 [00:10:32] [00:11:04] [00:13:54] [00:14:29], TMSpauVphNs [00:01:45] [00:02:18] and G49wexNmQ-Q [00:06:22], the foot roll in every crouch, the overdone first anticipation, the spine drag with only the arms dropping, the peel-off vector check, feet flat within 1 to 2 frames of touch-down, insert plus curve diff after splining, and a bigger hold after impacts, as stance, workflow, mistakes, and five new gates in `mx_anim` (`foot_roll`, `spine_drag`, `peel_off_*`, `heel_to_flat_*`, `impact_hold`) plus `curve_diff`, `foot_pitch`, `foot_events`, `peel_angle`, `snapshot(samples=)` and `scale_curve(time=)`, generalized to any jump, hop, landing or weight shift; fixed the stretch that tied Elver's "balloon" line (about bounce) to apex hang (now physics [added]); offline test 101 checks passed.
      
  • scripts
    • mx_anim.py 109.2 KB
      """
      mx_anim: keying, curve surgery and motion measurement for an animator agent that cannot
      scrub (Maya 2027). Part of the scenario-maya-animation skill; builds on the scenario-maya-expert toolkit
      (mx_run for headless jobs, mx_review for PNG writing and GUI playblasts, mx_bridge).
      
      Why: animators judge by scrubbing, ghosts, motion trails and the Graph Editor. An agent
      measures instead (per-frame world and screen positions, spacing, velocity, holds, spikes,
      contacts, a ballistic fit, overlap lags) and looks at images it can open: a stacked curve
      plot (Wade Neistadt's stacked view) and a tracks / onion-skin sheet drawn from projected
      joints (the programmatic drawover Arslan Elver does by hand), or a GUI playblast.
      
        import sys; sys.path[:0] = ["<skills>/scenario-maya-animation/scripts", "<skills>/scenario-maya-expert/scripts"]
        import mx_anim as A
        prev = A.set_key_defaults("blocking")                  # new keys: stepped out tangents
        A.key_pose({"COG_ctrl": {"translateY": 72.0}}, 24, fill=ctrls)   # stepped pose column
        A.key_hold(ctrls, 24, 27)                              # closing key of a hold ("pillar")
        A.share_keys(ctrls)                                    # every control keyed on every key time
        snap = A.snapshot(ctrls); A.to_spline(body_ctrls); ...; A.restore(snap)   # buffer curve
        b = A.snapshot(ctrls, samples=fr); A.insert_key(c, a, 38); A.curve_diff(b, A.snapshot(ctrls, samples=fr))
        pts = A.sample_world(["hips_jnt", "L_toe_jnt"], range(1, 73))
        print(A.format_report(A.motion_report(pts["hips_jnt"], range(1, 73))))
        A.ballistic_fit([p[1] for p in pts["hips_jnt"][32:45]], range(33, 46))
        gates = A.run_gates(plan); print(A.format_gates(gates))   # the shot's code gates
        A.review_images(plan, "/abs/out/v003")                  # curves.png + tracks.png (headless)
      
      Headless, through mx_run (plan schema in run_gates):
        python3 <scenario-maya-expert>/scripts/mx_run.py --scene shot_v003.ma mx_anim.py -- \
                --plan /abs/plan.json --out /abs/out/review_v003
      
      Units: positions in the scene's linear unit (keep cm; gravity helpers take unit=).
      Frames are scene frames. Rotations in degrees (cmds convention). Thresholds marked
      [added] are this toolkit's defaults, not expert numbers; override them per show.
      """
      
      __version__ = "0.1"  # Maya Expert Skills v0.1 (2026-09-24)
      import json
      import math
      import os
      import re
      import sys
      
      HERE = os.path.dirname(os.path.abspath(__file__))
      EXPERT_SCRIPTS = os.path.normpath(os.path.join(HERE, "..", "..", "scenario-maya-expert", "scripts"))
      
      G_CM_S2 = 981.0
      UNIT_TO_CM = {"mm": 0.1, "cm": 1.0, "m": 100.0, "km": 1e5, "in": 2.54, "ft": 30.48, "yd": 91.44}
      FPS_UNITS = {"game": 15.0, "film": 24.0, "pal": 25.0, "ntsc": 30.0, "show": 48.0,
                   "palf": 50.0, "ntscf": 60.0}
      TAG_ATTR = "mxAnimTag"
      PLAN_NODE = "mxAnim_plan"
      SKIP_ATTRS = ("visibility",)          # [added] never keyed by the pose helpers unless named
      
      # =========================================================================== small math
      def _as_points(seq):
          out = []
          for p in seq:
              if isinstance(p, (int, float)):
                  out.append((float(p),))
              else:
                  out.append(tuple(float(x) for x in p))
          return out
      
      
      def _sub(a, b):
          return tuple(x - y for x, y in zip(a, b))
      
      
      def _norm(a):
          return math.sqrt(sum(x * x for x in a))
      
      
      def _dist(a, b):
          return _norm(_sub(a, b))
      
      
      def _mean(xs):
          xs = list(xs)
          return sum(xs) / len(xs) if xs else 0.0
      
      
      def _median(xs):
          s = sorted(xs)
          n = len(s)
          if not n:
              return 0.0
          return s[n // 2] if n % 2 else 0.5 * (s[n // 2 - 1] + s[n // 2])
      
      
      def _runs(mask):
          """Inclusive (i0, i1) index runs where mask is True."""
          out, start = [], None
          for i, m in enumerate(mask):
              if m and start is None:
                  start = i
              elif not m and start is not None:
                  out.append((start, i - 1))
                  start = None
          if start is not None:
              out.append((start, len(mask) - 1))
          return out
      
      
      def _pearson(xs, ys):
          n = len(xs)
          if n < 3:
              return 0.0
          mx, my = _mean(xs), _mean(ys)
          sxy = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
          sxx = sum((x - mx) ** 2 for x in xs)
          syy = sum((y - my) ** 2 for y in ys)
          if sxx <= 0 or syy <= 0:
              return 0.0
          return sxy / math.sqrt(sxx * syy)
      
      
      def _solve3(m, v):
          """Solve a 3x3 linear system with partial pivoting."""
          a = [list(m[i]) + [v[i]] for i in range(3)]
          for c in range(3):
              p = max(range(c, 3), key=lambda r: abs(a[r][c]))
              if abs(a[p][c]) < 1e-12:
                  raise ValueError("singular system (need at least 3 distinct frames)")
              a[c], a[p] = a[p], a[c]
              for r in range(3):
                  if r != c:
                      k = a[r][c] / a[c][c]
                      for j in range(c, 4):
                          a[r][j] -= k * a[c][j]
          return [a[i][3] / a[i][i] for i in range(3)]
      
      
      def polyfit2(ts, ys):
          """Least-squares y = a t^2 + b t + c. Returns (a, b, c)."""
          ts, ys = [float(t) for t in ts], [float(y) for y in ys]
          n = len(ts)
          if n < 3 or n != len(ys):
              raise ValueError("need at least 3 samples of equal length")
          tm = sum(ts) / n
          x = [t - tm for t in ts]
          s = [sum(xi ** k for xi in x) for k in range(5)]
          sy = [sum(y * xi ** k for xi, y in zip(x, ys)) for k in range(3)]
          c, b, a = _solve3([[s[0], s[1], s[2]], [s[1], s[2], s[3]], [s[2], s[3], s[4]]], sy)
          return a, b - 2 * a * tm, a * tm * tm - b * tm + c
      
      
      def _mat_inverse(m):
          """Inverse of a 4x4 matrix given as 16 floats (row-major, Maya order)."""
          a = [[float(m[r * 4 + c]) for c in range(4)] + [1.0 if r == c else 0.0 for c in range(4)]
               for r in range(4)]
          for c in range(4):
              p = max(range(c, 4), key=lambda r: abs(a[r][c]))
              if abs(a[p][c]) < 1e-12:
                  raise ValueError("singular matrix")
              a[c], a[p] = a[p], a[c]
              piv = a[c][c]
              a[c] = [x / piv for x in a[c]]
              for r in range(4):
                  if r != c and a[r][c] != 0.0:
                      k = a[r][c]
                      a[r] = [x - k * y for x, y in zip(a[r], a[c])]
          return [a[r][4 + c] for r in range(4) for c in range(4)]
      
      
      def gravity_per_frame(fps=24.0, unit="cm", g_cm_s2=G_CM_S2):
          """Gravity in scene units per frame squared: 981 / 576 = 1.703 cm/frame^2 at 24 fps."""
          return g_cm_s2 / UNIT_TO_CM[unit] / float(fps) ** 2
      
      
      # =========================================================================== physics planning
      def air_frames(rise, drop, fps=24.0, unit="cm"):
          """Frames up and down for a gravity-true jump: rise = apex above takeoff COG,
          drop = apex above landing COG. air_frames(60, 10) -> about (8.4, 3.4, 11.8) at 24 fps."""
          g = gravity_per_frame(fps, unit)
          up, down = math.sqrt(2.0 * max(rise, 0.0) / g), math.sqrt(2.0 * max(drop, 0.0) / g)
          return up, down, up + down
      
      
      def solve_jump(y_takeoff, y_land, frames_in_air, fps=24.0, unit="cm"):
          """Exact parabola from the takeoff and landing COG heights and the air time (frames).
          Returns v0 (units/frame), t_apex (frames after takeoff), y_apex and values[0..T]."""
          g = gravity_per_frame(fps, unit)
          T = float(frames_in_air)
          if T <= 0:
              raise ValueError("frames_in_air must be > 0")
          v0 = (y_land - y_takeoff + 0.5 * g * T * T) / T
          t_apex = v0 / g
          y_apex = y_takeoff + v0 * v0 / (2.0 * g)
          vals = [y_takeoff + v0 * t - 0.5 * g * t * t for t in range(int(round(T)) + 1)]
          return {"v0": v0, "t_apex": t_apex, "y_apex": y_apex, "values": vals, "g": g}
      
      
      def parabola_values(t0, t1, y0, y1, fps=24.0, unit="cm"):
          """{frame: value} for every whole frame from takeoff t0 to landing t1 (gravity-true)."""
          s = solve_jump(y0, y1, t1 - t0, fps, unit)
          return {int(t0) + i: v for i, v in enumerate(s["values"])}
      
      
      # =========================================================================== motion analysis
      def motion_report(points, frames, fps=24.0, hold_eps=None, spike_k=4.0, spike_floor=None,
                        turn_deg=30.0, even_tol=0.1, contacts=(), min_hold=3):
          """Spacing, speed, holds, acceleration spikes, sharp turns and constant-spacing runs of
          one track (3D, 2D pixels or scalars). Spacing i is between frames[i] and frames[i+1].
          hold_eps default: 5% of the largest spacing [added]. Spikes (pops) are LOCAL outliers:
          the second difference minus the median of its 4 neighbours, above spike_k x the median
          residual and above spike_floor (default 15% of the largest spacing) [added]; a steady
          push or fall is not a spike. Spikes within 1 frame of a contact are listed apart."""
          P, F = _as_points(points), [float(f) for f in frames]
          if len(P) != len(F) or len(P) < 3:
              raise ValueError("points and frames must have the same length (>= 3)")
          steps = [_sub(P[i + 1], P[i]) for i in range(len(P) - 1)]
          spacing = [_norm(s) for s in steps]
          smax = max(spacing)
          eps = hold_eps if hold_eps is not None else max(1e-9, 0.05 * smax)
          still = [s < eps for s in spacing]
          holds = [(F[a], F[b + 1], b - a + 2) for a, b in _runs(still) if b - a + 2 >= min_hold]
          moves = [(F[a], F[b + 1]) for a, b in _runs([not s for s in still])]
          accv = [_sub(steps[i + 1], steps[i]) for i in range(len(steps) - 1)]   # at F[i+1]
          dim = len(P[0])
          resid = []
          for i in range(len(accv)):
              nb = [accv[j] for j in range(max(0, i - 2), min(len(accv), i + 3)) if j != i]
              medv = tuple(_median([v[d] for v in nb]) for d in range(dim)) if nb else accv[i]
              resid.append(_norm(_sub(accv[i], medv)))
          med = _median(resid)
          floor = spike_floor if spike_floor is not None else 0.15 * smax
          cset = [float(c) for c in contacts]
      
          def near(f):
              return any(abs(f - c) <= 1.0 for c in cset)
      
          spikes, contact_spikes = [], []
          for i, a in enumerate(resid):
              if a > spike_k * med and a > floor:
                  (contact_spikes if near(F[i + 1]) else spikes).append((F[i + 1], round(a, 4)))
          turns = []
          for i in range(len(steps) - 1):
              if spacing[i] >= eps and spacing[i + 1] >= eps:
                  c = sum(x * y for x, y in zip(steps[i], steps[i + 1])) / (spacing[i] * spacing[i + 1])
                  ang = math.degrees(math.acos(max(-1.0, min(1.0, c))))
                  if ang > turn_deg and not near(F[i + 1]):
                      turns.append((F[i + 1], round(ang, 1)))
          ratio_ok = [spacing[i] >= eps and spacing[i + 1] >= eps and
                      abs(spacing[i + 1] / spacing[i] - 1.0) <= even_tol for i in range(len(spacing) - 1)]
          even = [(F[a], F[b + 2], b - a + 3) for a, b in _runs(ratio_ok) if b - a + 1 >= 3]
          return {
              "frames": (F[0], F[-1]), "samples": len(P), "path_length": round(sum(spacing), 4),
              "spacing": [round(s, 4) for s in spacing],
              "max_spacing": round(smax, 4), "max_speed_per_s": round(smax * fps, 4),
              "hold_eps": eps, "holds": holds, "moves": moves,
              "spikes": spikes, "contact_spikes": contact_spikes, "sharp_turns": turns,
              "constant_spacing": even, "pop_residual_median": round(med, 5),
          }
      
      
      def format_report(rep, width=60):
          """Short text summary of motion_report for the agent's notes."""
          lines = ["frames %s-%s  path %.2f  max spacing %.3f/frame" % (
              rep["frames"][0], rep["frames"][1], rep["path_length"], rep["max_spacing"])]
          lines.append("holds %s" % (rep["holds"] or "none"))
          lines.append("moves %s" % (rep["moves"] or "none"))
          for key in ("spikes", "contact_spikes", "sharp_turns", "constant_spacing"):
              if rep.get(key):
                  lines.append("%s %s" % (key.replace("_", " "), rep[key][:12]))
          s = rep["spacing"]
          if s:
              top = max(s) or 1.0
              bars = "".join(" .:-=+*#%@"[min(9, int(9 * v / top))] for v in s[:width])
              lines.append("spacing |%s|" % bars)
          return "\n".join(lines)
      
      
      def ballistic_fit(heights, frames, fps=24.0, unit="cm", hang_band=0.05):
          """Fit y = a t^2 + b t + c to airborne COG heights. g_ratio = implied / real gravity
          (1.0 = physical; below about 0.8 floats). hang_frames counts frames within hang_band of
          the rise under the apex; hang_expected is what real gravity gives; excess = stylized
          hang (Wade's weighted apex) [added method]."""
          a, b, c = polyfit2(frames, heights)
          real = gravity_per_frame(fps, unit)
          implied = -2.0 * a
          F = [float(f) for f in frames]
          res = [h - (a * f * f + b * f + c) for f, h in zip(F, heights)]
          rms = math.sqrt(sum(r * r for r in res) / len(res))
          apex_f = -b / (2.0 * a) if a else None
          apex_h = (c - b * b / (4.0 * a)) if a else max(heights)
          rise = max(heights) - min(heights)
          band = hang_band * rise
          hang = sum(1 for h in heights if h >= max(heights) - band)
          expected = None
          if implied > 0 and apex_f is not None:
              w = math.sqrt(2.0 * band / real)
              expected = max(1, int(math.floor(apex_f + w)) - int(math.ceil(apex_f - w)) + 1)
          return {"g_ratio": implied / real if real else None, "implied_g": implied, "real_g": real,
                  "rms": rms, "max_residual": max(abs(r) for r in res), "apex_frame": apex_f,
                  "apex_height": apex_h, "hang_frames": hang, "hang_expected": expected,
                  "hang_excess": (hang - expected) if expected is not None else None,
                  "coeffs": (a, b, c)}
      
      
      def _ground_fn(ground):
          """ground: number, or [[start, end, height], ...] -> f(frame) -> support height."""
          if isinstance(ground, (int, float)):
              g = float(ground)
              return lambda f: g
          spans = sorted([(float(s), float(e), float(h)) for s, e, h in ground])
      
          def fn(f):
              for s, e, h in spans:
                  if s <= f <= e:
                      return h
              before = [sp for sp in spans if sp[1] < f]
              return (before[-1] if before else spans[0])[2]
          return fn
      
      
      def contact_spans(heights, frames, ground=0.0, height_tol=0.5, speed_tol=0.5, min_len=2,
                        offset=0.0):
          """Frame spans where a point is planted: within height_tol of the support (plus its
          planted offset, e.g. an ankle joint's height) and vertical speed under speed_tol per
          frame. Defaults 0.5 cm and 0.5 cm/frame [added, cm rig at 24 fps]."""
          gf = _ground_fn(ground)
          F = [float(f) for f in frames]
          h = [float(v) for v in heights]
          planted = []
          for i in range(len(h)):
              # one-sided: the first and last planted frames are still frames on one side
              back = abs(h[i] - h[i - 1]) if i > 0 else None
              fwd = abs(h[i + 1] - h[i]) if i < len(h) - 1 else None
              vs = min(v for v in (back, fwd) if v is not None) if len(h) > 1 else 0.0
              planted.append(h[i] - gf(F[i]) - offset <= height_tol and vs <= speed_tol)
          spans = []
          for a, b in _runs(planted):
              start = a
              for i in range(a + 1, b + 1):                 # a new support or a jump splits the span
                  if abs(h[i] - h[i - 1]) > speed_tol or gf(F[i]) != gf(F[i - 1]):
                      spans.append((start, i - 1))
                      start = i
              spans.append((start, b))
          return [(F[a], F[b]) for a, b in spans if b - a + 1 >= min_len]
      
      
      def foot_slide(points, frames, spans, up=1, tol=0.2):
          """Horizontal travel of a planted point inside each contact span. tol: cm per frame
          (0.2 [added, digest]). Returns per span max step and drift, plus flagged frames."""
          idx = {float(f): i for i, f in enumerate(frames)}
          P = _as_points(points)
          out, flagged, worst = [], [], 0.0
          for s, e in spans:
              i0, i1 = idx[float(s)], idx[float(e)]
              steps = []
              for i in range(i0, i1):
                  a, b = list(P[i]), list(P[i + 1])
                  a[up], b[up] = 0.0, 0.0
                  d = _dist(a, b)
                  steps.append(d)
                  if d > tol:
                      flagged.append((float(frames[i + 1]), round(d, 4)))
              a, b = list(P[i0]), list(P[i1])
              a[up], b[up] = 0.0, 0.0
              m = max(steps) if steps else 0.0
              worst = max(worst, m)
              out.append({"span": (s, e), "max_step": round(m, 4), "drift": round(_dist(a, b), 4)})
          return {"spans": out, "max_step": round(worst, 4), "flagged": flagged, "tol": tol}
      
      
      def penetration(heights, frames, ground=0.0, tol=0.5, offset=0.0):
          """Frames where the point sits below the support by more than tol: [(frame, depth)]."""
          gf = _ground_fn(ground)
          out = []
          for f, h in zip(frames, heights):
              d = gf(float(f)) + offset - float(h)
              if d > tol:
                  out.append((float(f), round(d, 4)))
          return out
      
      
      def landing_catch(heights, frames, contact, window=2, settle_frac=0.1, hold_frac=0.1):
          """How a landing absorbs the fall (Elver: a sudden caught stop plus residual jiggle, not
          a symmetric dip). removed_on_first / removed_in_window: share of the incoming vertical
          speed gone 1 and `window` frames after contact. catch_ratio = frames contact->lowest
          over frames lowest->recovered; below 1 is a catch, about 1 a sine dip [added].
          bottom_hold: frames around the lowest point moving under hold_frac of the incoming
          speed (Elver 00:13:54, 00:14:29: push the squash, give the impact a bigger hold);
          1 means the body bounces straight out of the compression [added measure]."""
          F = [float(f) for f in frames]
          h = [float(v) for v in heights]
          i = F.index(float(contact))
          if i < 1 or i + window >= len(h):
              raise ValueError("contact too close to the ends of the samples")
          v_in = h[i] - h[i - 1]
          j = min(range(i, len(h)), key=lambda k: h[k])
          comp = h[i] - h[j]
          thr = hold_frac * abs(v_in)
          lo = hi = j
          while lo - 1 >= i and abs(h[lo] - h[lo - 1]) < thr:
              lo -= 1
          while hi + 1 < len(h) and abs(h[hi + 1] - h[hi]) < thr:
              hi += 1
      
          def removed(k):
              v = h[k] - h[k - 1]
              return (1.0 - abs(v) / abs(v_in)) if v_in < 0 else None     # None: not falling into the contact
      
          end_h = h[-1]
          rec = None
          for k in range(j, len(h)):
              if h[k] >= end_h - settle_frac * max(comp, 1e-9):
                  rec = k
                  break
          extrema = 0
          for k in range(max(j, 1) + 1, len(h) - 1):
              if (h[k] - h[k - 1]) * (h[k + 1] - h[k]) < 0 and abs(h[k] - end_h) > 0.02 * max(comp, 1e-9):
                  extrema += 1
          decel = F[j] - F[i]
          recov = (F[rec] - F[j]) if rec is not None else None
          return {"v_in": v_in, "lowest_frame": F[j], "compression": comp,
                  "removed_on_first": removed(i + 1), "removed_in_window": removed(i + window),
                  "decel_frames": decel, "recovery_frames": recov,
                  "catch_ratio": (decel / recov) if recov else None, "overshoots": extrema,
                  "bottom_hold": int(round(F[hi] - F[lo])) + 1, "bottom_span": (F[lo], F[hi])}
      
      
      def foot_pitch(heel, toe, up=1):
          """Per-frame pitch (degrees) of the heel-to-toe line: positive when the heel is above the
          toe (rolled onto the ball), negative when the toes are up (heel strike). Subtract the
          rest-frame value. The rig's foot roll seen from the joints, when the rig attribute is
          unknown [added]."""
          out = []
          for h, t in zip(_as_points(heel), _as_points(toe)):
              d = _sub(t, h)
              hor = [x for k, x in enumerate(d) if k != up]
              out.append(math.degrees(math.atan2(-d[up], _norm(hor))))
          return out
      
      
      def peel_angle(ball, toe, takeoff):
          """Elver's peel-off (jzuxAmadcm8 00:10:32, 00:11:04): on the first airborne frame the tip
          of the toe points back to where it left the ground, even with the leg stretched. Angle
          (degrees) between the ball-to-toe line and the ball-to-takeoff-point line; near 0 sells
          the push, near 90 is a flat foot that forgot the ground [added measure]."""
          a, b = _sub(toe, ball), _sub(takeoff, ball)
          na, nb = _norm(a), _norm(b)
          if na < 1e-9 or nb < 1e-9:
              return 0.0
          c = sum(x * y for x, y in zip(a, b)) / (na * nb)
          return math.degrees(math.acos(max(-1.0, min(1.0, c))))
      
      
      def foot_events(spans, frames, heel, flat_with, tip=None, flat_window=6):
          """Touch-downs and lift-offs of one foot from contact_spans() per joint, {joint: [(s, e)]}.
          touchdowns: [{touch, flat, frames}], touch = first frame any joint is planted after none
          was, flat = first frame heel and flat_with (the ball) are both planted within
          flat_window, frames = flat - touch (None: not flat in time). Camporota: heel to flat
          "almost in one frame" (ynXadXE9UjU 00:09:26); digest gate: at most 1 or 2.
          liftoffs: [{last, first_air}] where the tip (toe) is the last joint planted before a
          frame with no joint planted: the frame the peel-off check reads."""
          F = [float(f) for f in frames]
          n = len(F)
          planted = {}
          for j, sp in spans.items():
              planted[j] = [any(float(s) <= f <= float(e) for s, e in sp) for f in F]
          if not planted:
              return {"touchdowns": [], "liftoffs": []}
          anyp = [any(p[i] for p in planted.values()) for i in range(n)]
          none = [False] * n
          ph, pb = planted.get(heel, none), planted.get(flat_with, none)
          downs = []
          for i in range(1, n):
              if anyp[i] and not anyp[i - 1]:
                  flat = next((F[k] for k in range(i, min(n, i + flat_window + 1)) if ph[k] and pb[k]), None)
                  downs.append({"touch": F[i], "flat": flat, "frames": (flat - F[i]) if flat is not None else None})
          ups = []
          pt = planted.get(tip, none) if tip else none
          for i in range(n - 1):
              if pt[i] and not anyp[i + 1]:
                  ups.append({"last": F[i], "first_air": F[i + 1]})
          return {"touchdowns": downs, "liftoffs": ups}
      
      
      def lag_frames(a, b, max_lag=6, mode="velocity"):
          """Frames by which signal b follows signal a (positive: b lags), by cross-correlation of
          their velocities (mode='velocity') or values ('value'). Returns {lag, corr}."""
          x = [float(v) for v in a]
          y = [float(v) for v in b]
          if mode == "velocity":
              x = [x[i + 1] - x[i] for i in range(len(x) - 1)]
              y = [y[i + 1] - y[i] for i in range(len(y) - 1)]
          best = (0, -2.0)
          for L in range(-max_lag, max_lag + 1):
              xs, ys = [], []
              for t in range(len(x)):
                  if 0 <= t + L < len(y):
                      xs.append(x[t])
                      ys.append(y[t + L])
              c = _pearson(xs, ys)
              if c > best[1] + 1e-9 or (abs(c - best[1]) <= 1e-9 and abs(L) < abs(best[0])):
                  best = (L, c)
          return {"lag": best[0], "corr": round(best[1], 4)}
      
      
      def overlap_report(signals, chain, max_lag=6, mode="velocity"):
          """Lags down an ordered chain (leader first), e.g. chest, neck, head. Flags
          'all_zero' (everything on one key: Neistadt, Wade's heavy video) and 'leads' (a
          follower moving before its parent)."""
          pairs = []
          for p, c in zip(chain[:-1], chain[1:]):
              r = lag_frames(signals[p], signals[c], max_lag, mode)
              pairs.append({"leader": p, "follower": c, "lag": r["lag"], "corr": r["corr"]})
          flags = []
          if pairs and all(p["lag"] == 0 for p in pairs):
              flags.append("all_zero")
          if any(p["lag"] < 0 for p in pairs):
              flags.append("leads")
          return {"pairs": pairs, "flags": flags}
      
      
      def peak_order(signals, frames, window=None):
          """Frame of peak speed per signal (scalar or point tracks), sorted: checks a force chain
          (pelvis, chest, shoulder, elbow, wrist; Wade) or who leads an action (Lazare)."""
          F = [float(f) for f in frames]
          out = []
          for name, sig in signals.items():
              P = _as_points(sig)
              sp = [_dist(P[i + 1], P[i]) for i in range(len(P) - 1)]
              idx = range(len(sp))
              if window:
                  idx = [i for i in idx if window[0] <= F[i] <= window[1]]
              if not idx:
                  continue
              i = max(idx, key=lambda k: sp[k])
              out.append((name, F[i], round(sp[i], 4)))
          out.sort(key=lambda r: r[1])
          return out
      
      
      def holds(values, frames, eps=None, min_frames=3):
          """(start, end, n_frames) spans where the track barely moves."""
          return motion_report(values, frames, hold_eps=eps, min_hold=min_frames)["holds"]
      
      
      def dead_holds(tracks, frames, eps=0.01, min_frames=6):
          """Spans where EVERY track is still (below eps per frame) for >= min_frames: the hold
          that dies. Elver: in live action a character cannot stop immediately; Newman Harvey
          and Wade: holds must still breathe (not 100% flat). eps and min_frames [added]."""
          F = [float(f) for f in frames]
          still = None
          for t in tracks.values():
              P = _as_points(t)
              s = [_dist(P[i + 1], P[i]) < eps for i in range(len(P) - 1)]
              still = s if still is None else [a and b for a, b in zip(still, s)]
          if not still:
              return []
          return [(F[a], F[b + 1], b - a + 2) for a, b in _runs(still) if b - a + 2 >= min_frames]
      
      
      def pose_to_pose_tell(key_times, share=0.9):
          """Neistadt: the whole body keyed on the same frames reads as 'hitting poses'. Returns
          shared_fraction (keys that sit on a time keyed by >= share of the controls) and
          identical_sets (controls whose key times equal the most common set). High after
          splining = everything starts and stops together [threshold added]."""
          sets = {c: tuple(sorted(round(float(t), 3) for t in ts)) for c, ts in key_times.items() if ts}
          n = len(sets)
          if not n:
              return {"controls": 0, "shared_fraction": 0.0, "identical_sets": 0.0}
          count = {}
          for ts in sets.values():
              for t in set(ts):
                  count[t] = count.get(t, 0) + 1
          shared = {t for t, k in count.items() if k >= share * n}
          total = sum(len(ts) for ts in sets.values())
          on_shared = sum(1 for ts in sets.values() for t in ts if t in shared)
          modal = max(set(sets.values()), key=lambda s: list(sets.values()).count(s))
          ident = sum(1 for s in sets.values() if s == modal)
          return {"controls": n, "key_times": len(count), "shared_times": len(shared),
                  "shared_fraction": on_shared / float(total), "identical_sets": ident / float(n)}
      
      
      def tween_fractions(times, values, band=(0.45, 0.55)):
          """For each in-between key (value strictly between its neighbours) its value and time
          fraction. Newman: 50/50 tweens give even timing; favour a neighbour instead. Returns
          rows and the share of in-betweens inside `band` [band added]."""
          rows = []
          for i in range(1, len(values) - 1):
              v0, v, v1 = float(values[i - 1]), float(values[i]), float(values[i + 1])
              if (v0 < v < v1) or (v1 < v < v0):
                  t0, t, t1 = float(times[i - 1]), float(times[i]), float(times[i + 1])
                  rows.append({"time": t, "value_frac": (v - v0) / (v1 - v0),
                               "time_frac": (t - t0) / (t1 - t0) if t1 != t0 else None})
          mid = [r for r in rows if band[0] <= r["value_frac"] <= band[1]]
          return {"inbetweens": rows, "fifty_fifty_share": (len(mid) / float(len(rows))) if rows else 0.0}
      
      
      def extreme_indices(values, eps=1e-9, keep_ends=True):
          """Indices of local extrema (plateaus collapse to their first sample)."""
          v = [float(x) for x in values]
          out = [0] if keep_ends and v else []
          last_sign, last_end = 0, 0
          for i in range(len(v) - 1):
              d = v[i + 1] - v[i]
              s = 0 if abs(d) <= eps else (1 if d > 0 else -1)
              if s == 0:
                  continue
              if last_sign and s != last_sign:
                  out.append(last_end)          # first sample of the plateau the last move reached
              last_sign, last_end = s, i + 1
          if keep_ends and len(v) > 1:
              out.append(len(v) - 1)
          return sorted(set(out))
      
      
      def double_beats(values, frames, window=8, min_return=None):
          """Lazare: hit a pose, relax, hit it again = double beat; the second must go further
          (progression). Looks for hit, return, re-hit within `window` frames; min_return
          default 10% of the value range [added]."""
          v = [float(x) for x in values]
          F = [float(f) for f in frames]
          rng = (max(v) - min(v)) or 1.0
          mr = 0.1 * rng if min_return is None else min_return
          ext = [i for i in extreme_indices(v, keep_ends=False)]
          out = []
          for k in range(len(ext) - 2):
              e1, r, e2 = ext[k], ext[k + 1], ext[k + 2]
              is_max = v[e1] > v[r]
              if (v[e2] > v[r]) != is_max or F[e2] - F[e1] > window:
                  continue
              if abs(v[e1] - v[r]) < mr:
                  continue
              further = v[e2] > v[e1] if is_max else v[e2] < v[e1]
              out.append({"hit": F[e1], "return": F[r], "rehit": F[e2],
                          "kind": "progression" if further else "double_beat"})
          return out
      
      
      def overshoots(key_times, key_values, samples, tol=None, holds_only=False, eq_tol=1e-4):
          """Sampled curve values outside the [min, max] of their neighbouring keys (a spline
          overshooting through a hold or a contact). samples: {frame: value}, sub-frames allowed.
          Plateau keeps extremes on keys, Clamped fixes near-equal keys (Maya 2027 Help)."""
          kt = [float(t) for t in key_times]
          kv = [float(v) for v in key_values]
          span = (max(kv) - min(kv)) if kv else 0.0
          tol = tol if tol is not None else max(1e-4, 1e-3 * span)
          out = []
          for i in range(len(kt) - 1):
              t0, t1, v0, v1 = kt[i], kt[i + 1], kv[i], kv[i + 1]
              if holds_only and abs(v1 - v0) > eq_tol:
                  continue
              lo, hi = min(v0, v1), max(v0, v1)
              worst = 0.0
              for t, v in samples.items():
                  if t0 < float(t) < t1:
                      worst = max(worst, float(v) - hi, lo - float(v))
              if worst > tol:
                  out.append({"segment": (t0, t1), "excess": round(worst, 5), "hold": abs(v1 - v0) <= eq_tol})
          return out
      
      
      def hold_drift(samples, spans):
          """Largest departure from the value at the start of each hold span:
          [{"span": (start, end), "drift": d}] (samples: {frame: value})."""
          out = []
          for s, e in spans:
              base = samples[s] if s in samples else samples[float(s)]
              vals = [float(v) for t, v in samples.items() if float(s) <= float(t) <= float(e)]
              out.append({"span": (s, e), "drift": max(abs(v - float(base)) for v in vals) if vals else 0.0})
          return out
      
      
      def curve_diff(before, after, ignore=(), value_tol=1e-4, angle_tol=0.05, sample_tol=1e-3):
          """What a keying pass changed, from two snapshot() dicts. Wade: native Set Key reshapes
          neighbouring curves (TMSpauVphNs 00:01:45, G49wexNmQ-Q 00:06:22); digest gate 'Set key
          damage': after scripted keying, neighbouring keys and tangents unchanged. Reports keys at
          the same time whose value, tangent type or angle moved ('changed'), keys added and
          removed, and, when both snapshots carry samples (snapshot(samples=...)), the frames
          where the evaluated curve moved. ignore: [(t0, t1)] spans you meant to edit (a new
          breakdown's two segments, from the previous key to the next one); only their interior is
          ignored, so the neighbouring keys at t0 and t1 must stay put. Weights are not compared:
          an insert on a weighted curve shortens the neighbours' handles to keep the shape
          [added]. ok: nothing changed or was removed outside ignore (added keys are listed, not
          failed)."""
          spans = [(float(a), float(b)) for a, b in ignore]
      
          def inside(t):
              return any(a + 1e-6 < float(t) < b - 1e-6 for a, b in spans)
      
          bmap = {r["plug"]: r for r in before.get("curves", [])}
          amap = {r["plug"]: r for r in after.get("curves", [])}
          changed, added, removed, moved = [], [], [], []
          worst = 0.0
          for plug in sorted(set(bmap) | set(amap)):
              rb, ra = bmap.get(plug), amap.get(plug)
              if rb is None:
                  added.append((plug, None))
                  continue
              if ra is None:
                  removed.append((plug, None))
                  continue
              kb = {round(float(t), 4): i for i, t in enumerate(rb["times"])}
              ka = {round(float(t), 4): i for i, t in enumerate(ra["times"])}
              added += [(plug, t) for t in sorted(set(ka) - set(kb))]
              removed += [(plug, t) for t in sorted(set(kb) - set(ka)) if not inside(t)]
              for t in sorted(set(kb) & set(ka)):
                  if inside(t):
                      continue
                  i, k = kb[t], ka[t]
                  for field, tol in (("values", value_tol), ("ia", angle_tol), ("oa", angle_tol)):
                      vb, va = rb.get(field) or [], ra.get(field) or []
                      if i < len(vb) and k < len(va) and abs(float(va[k]) - float(vb[i])) > tol:
                          changed.append((plug, t, field, round(float(vb[i]), 5), round(float(va[k]), 5)))
                  for field in ("itt", "ott"):
                      vb, va = rb.get(field) or [], ra.get(field) or []
                      if i < len(vb) and k < len(va) and vb[i] != va[k]:
                          changed.append((plug, t, field, vb[i], va[k]))
              sb = {round(float(t), 4): float(v) for t, v in (rb.get("samples") or [])}
              sa = {round(float(t), 4): float(v) for t, v in (ra.get("samples") or [])}
              for t in sorted(set(sb) & set(sa)):
                  d = abs(sa[t] - sb[t])
                  if d > sample_tol and not inside(t):
                      moved.append((plug, t, round(d, 5)))
                      worst = max(worst, d)
          return {"ok": not (changed or removed or moved), "changed": changed, "added": added,
                  "removed": removed, "moved_samples": moved[:50], "max_sample_dev": round(worst, 6)}
      
      
      def transitions(values, frames, eps=None, min_hold=2):
          """Moves between holds of a scalar channel: [{start, end, frames, delta}]. Used for eye
          darts (Elver: 2 frames, linear), brows, blinks, snaps."""
          F = [float(f) for f in frames]
          v = [float(x) for x in values]
          rep = motion_report(v, F, hold_eps=eps, min_hold=min_hold)
          out = []
          for s, e in rep["moves"]:
              i0, i1 = F.index(s), F.index(e)
              out.append({"start": s, "end": e, "frames": e - s, "delta": round(v[i1] - v[i0], 5)})
          return out
      
      
      def blink_report(lid, frames, closed, opened, tol=0.1):
          """Blinks on a lid channel (closed / opened values are the rig's). Elver: close faster
          than open (2/4 or 3/5), small-then-big spacing, cushion frame on the way out."""
          F = [float(f) for f in frames]
          n = [(float(v) - opened) / (closed - opened) for v in lid]
          N, out, i = len(n), [], 0
          while i < N:
              if n[i] < 1 - tol:
                  i += 1
                  continue
              c0 = c1 = i                               # closed plateau c0..c1
              while c1 + 1 < N and n[c1 + 1] >= 1 - tol:
                  c1 += 1
              s = c0                                    # last open frame before closing
              while s > 0 and n[s] > tol:
                  s -= 1
              e = c1                                    # first open frame after opening
              while e < N - 1 and n[e] > tol:
                  e += 1
              first = n[s + 1] if s < c0 else None
              out.append({"start": F[s], "closed": F[c0], "closed_end": F[c1], "open_again": F[e],
                          "closing_frames": F[c0] - F[s], "opening_frames": F[e] - F[c1],
                          "first_close_fraction": round(first, 3) if first is not None else None})
              i = e + 1
          return out
      
      
      def jaw_report(jaw, frames, fps=24.0, eps=None):
          """Jaw open channel (bigger = more open). Reversals per second (chatter, Santos and
          Wade), first-frame share of each open (Lazare: punch opens, most of the way on the
          first frame), and tentpole ratio (biggest open over the median open)."""
          F = [float(f) for f in frames]
          v = [float(x) for x in jaw]
          ext = extreme_indices(v, eps=eps or 1e-6, keep_ends=True)
          dur = (F[-1] - F[0]) / float(fps) if F[-1] > F[0] else 1.0
          reversals = max(0, len(ext) - 2)
          opens = []
          for a, b in zip(ext[:-1], ext[1:]):
              if v[b] > v[a] and b > a:
                  first = (v[a + 1] - v[a]) / (v[b] - v[a])
                  opens.append({"start": F[a], "peak": F[b], "size": v[b] - v[a], "frames": F[b] - F[a],
                                "first_frame_share": round(first, 3)})
          sizes = [o["size"] for o in opens]
          return {"reversals_per_s": round(reversals / dur, 3), "opens": opens,
                  "tentpole_ratio": (max(sizes) / _median(sizes)) if sizes and _median(sizes) else None}
      
      
      # Lead of the mouth shape before the sound, by where the sound is made (Santos S1MRs3XJVPI
      # 00:06:08): bilabials on the frame, alveolars about one frame early, further back earlier
      # (2 is implied by his quiz, not stated), open shapes set before the sound. Labiodental,
      # dental and vowel leads are [added] readings of the same rule.
      SOUND_CLASS = {"M": "bilabial", "B": "bilabial", "P": "bilabial", "F": "labiodental", "V": "labiodental",
                     "TH": "dental", "DH": "dental", "D": "alveolar", "T": "alveolar", "L": "alveolar", "S": "alveolar",
                     "Z": "alveolar", "N": "alveolar", "SH": "alveolar", "ZH": "alveolar", "CH": "alveolar", "JH": "alveolar",
                     "J": "alveolar", "R": "alveolar", "K": "velar", "G": "velar", "NG": "velar", "HH": "vowel",
                     "W": "bilabial", "Y": "vowel"}
      LEAD_FRAMES = {"bilabial": 0, "labiodental": 0, "dental": 1, "alveolar": 1, "velar": 2, "vowel": 1}
      SOUND_RULES = {"M": "lips closed at least 1 frame (can close early; opens slower than B, P)",
                     "B": "lips closed BEFORE the sound frame; opens on the sound", "P": "lips closed BEFORE the sound frame; opens on the sound",
                     "F": "upper teeth on the bottom lip", "V": "upper teeth on the bottom lip",
                     "L": "tongue up early, release with a big one-frame spacing", "T": "tongue kisses the roof",
                     "D": "tongue kisses the roof", "TH": "tongue tip visible between the teeth",
                     "R": "tongue floats mid-mouth; zip or sneer", "O": "round", "OO": "zip", "CH": "compress plus air",
                     "J": "compress plus air", "K": "release with the mouth open, often to one side"}
      
      
      def mouth_keys(phonemes, fps=24.0):
          """Key frames for mouth shapes from sound onsets: [(sound, onset_frame)] ->
          [{sound, onset, key_frame, lead, class, rule}]. Sounds are ARPAbet-like (M, B, AA, IY...);
          vowels default to the 'vowel' class. Use a forced aligner outside Maya for onsets [added]
          and check them against the waveform; this sets the lead, the eye judges the result."""
          out = []
          for sound, onset in phonemes:
              s = str(sound).upper().rstrip("012")
              cls = SOUND_CLASS.get(s, "vowel")
              lead = LEAD_FRAMES[cls]
              out.append({"sound": s, "onset": float(onset), "key_frame": float(onset) - lead, "lead": lead,
                          "class": cls, "rule": SOUND_RULES.get(s)})
          return out
      
      
      def audio_envelope(path, fps=24.0, start_frame=1):
          """Per-frame RMS loudness (0..1) of a PCM WAV: [(frame, level)]. Pure Python (wave)."""
          import wave
          import struct as _st
          w = wave.open(path, "rb")
          try:
              n, ch, sw, rate = w.getnframes(), w.getnchannels(), w.getsampwidth(), w.getframerate()
              raw = w.readframes(n)
          finally:
              w.close()
          if sw != 2:
              raise ValueError("16-bit PCM expected, got %d bytes per sample" % sw)
          vals = _st.unpack("<%dh" % (len(raw) // 2), raw)
          mono = [sum(vals[i:i + ch]) / float(ch) for i in range(0, len(vals), ch)]
          per = rate / float(fps)
          out, k = [], 0
          while int(k * per) < len(mono):
              seg = mono[int(k * per):int((k + 1) * per)] or [0.0]
              out.append((start_frame + k, math.sqrt(sum(x * x for x in seg) / len(seg)) / 32768.0))
              k += 1
          top = max(v for _, v in out) or 1.0
          return [(f, v / top) for f, v in out]
      
      
      def lip_lag(envelope, jaw, max_lag=4):
          """Frames by which the jaw opening follows the audio loudness (positive = the mouth is
          late). Mouth shapes form slightly before the sound (Lazare [00:19:33]): a positive lag
          means shift the mouth keys earlier by about lag + 1 frames [added measure]."""
          return lag_frames([float(v) for v in envelope], [float(v) for v in jaw], max_lag, mode="value")
      
      
      def loop_seam(pose_a, pose_b, tol=1e-3, exclude=()):
          """Compare two poses {plug: value} (first frame and first frame + cycle length)."""
          out = []
          for k, va in pose_a.items():
              if k in exclude or k not in pose_b:
                  continue
              if abs(float(va) - float(pose_b[k])) > tol:
                  out.append((k, float(va), float(pose_b[k])))
          return out
      
      
      def rotation_flips(rot_tracks, frames, max_step=90.0):
          """Frame-to-frame rotation jumps above max_step degrees on any channel (flips after
          IK/FK or space switches, mocap, merges): run Euler Filter."""
          F = [float(f) for f in frames]
          out = []
          for name, tr in rot_tracks.items():
              P = _as_points(tr)
              for i in range(len(P) - 1):
                  for ax, (a, b) in enumerate(zip(P[i], P[i + 1])):
                      if abs(b - a) > max_step:
                          out.append((name, F[i + 1], ax, round(b - a, 2)))
          return out
      
      
      def key_density(key_times, speeds, frames, pct=0.75, max_gap=2.0):
          """Newman: fast actions need keys on ones in blocking, slow parts tolerate 2 to 4.
          Flags fast frames (speed above the pct quantile) lying in a key gap over max_gap."""
          kt = sorted(float(t) for t in key_times)
          F = [float(f) for f in frames]
          s = sorted(speeds)
          thr = s[min(len(s) - 1, int(pct * len(s)))] if s else 0.0
          out = []
          for f, sp in zip(F, speeds):
              if sp < thr or sp <= 0:
                  continue
              before = [t for t in kt if t <= f]
              after = [t for t in kt if t > f]
              if before and after and after[0] - before[-1] > max_gap:
                  out.append((f, before[-1], after[0]))
          return {"threshold": thr, "flagged": out}
      
      
      def cycle_speed(points, frames, fps=24.0, up=1):
          """Horizontal speed (units/s) of a root or pelvis track over the clip: compare with
          the game's metric sheet (Newman: round numbers, player first)."""
          P = _as_points(points)
          a, b = list(P[0]), list(P[-1])
          a[up], b[up] = 0.0, 0.0
          dt = (float(frames[-1]) - float(frames[0])) / float(fps)
          return _dist(a, b) / dt if dt else 0.0
      
      
      # =========================================================================== projection
      FILM_FIT = {0: "fill", 1: "horizontal", 2: "vertical", 3: "overscan",
                  "fill": "fill", "horizontal": "horizontal", "vertical": "vertical", "overscan": "overscan"}
      
      
      def project_point(p, cam_inverse, focal_mm=35.0, h_aperture_in=1.417, v_aperture_in=0.945,
                        res=(1920, 1080), film_fit="fill", ortho_width=None):
          """World point -> (px, py, depth) in pixels (origin top-left, y down) through a Maya
          camera (looks down -Z). cam_inverse: the camera's worldInverseMatrix, 16 floats
          row-major (p' = p * M). Film offsets, overscan and lens squeeze ignored [added];
          filmFit enum order 0 fill, 1 horizontal, 2 vertical, 3 overscan [verify]."""
          m = cam_inverse
          x, y, z = float(p[0]), float(p[1]), float(p[2])
          pc = [x * m[0 + j] + y * m[4 + j] + z * m[8 + j] + m[12 + j] for j in range(3)]
          w, h = float(res[0]), float(res[1])
          depth = -pc[2]
          if ortho_width:
              ppu = w / float(ortho_width)
              return (w / 2.0 + pc[0] * ppu, h / 2.0 - pc[1] * ppu, depth)
          if depth <= 1e-9:
              return (float("nan"), float("nan"), depth)
          hmm, vmm = h_aperture_in * 25.4, v_aperture_in * 25.4
          xf, yf = focal_mm * pc[0] / depth, focal_mm * pc[1] / depth
          fit = FILM_FIT.get(film_fit, "fill")
          ppm_h, ppm_v = w / hmm, h / vmm
          ppm = {"horizontal": ppm_h, "vertical": ppm_v, "fill": max(ppm_h, ppm_v),
                 "overscan": min(ppm_h, ppm_v)}[fit]
          return (w / 2.0 + xf * ppm, h / 2.0 - yf * ppm, depth)
      
      
      def fit_view(world, view="side", size=(960, 540), margin=0.08):
          """Schematic orthographic projection of {name: [(x, y, z)]} auto-framed into pixels.
          side: screen x = world Z, y = world Y; front: X, Y; top: X, -Z. Not Maya's camera
          conventions: a drawing aid for tracks and stick figures [added]."""
          ax = {"side": (2, 1, 1.0), "front": (0, 1, 1.0), "top": (0, 2, -1.0)}[view]
          uv = {n: [(p[ax[0]], ax[2] * p[ax[1]]) for p in pts] for n, pts in world.items()}
          us = [u for pts in uv.values() for u, _ in pts]
          vs = [v for pts in uv.values() for _, v in pts]
          W, H = size
          du, dv = (max(us) - min(us)) or 1.0, (max(vs) - min(vs)) or 1.0
          s = min(W * (1 - 2 * margin) / du, H * (1 - 2 * margin) / dv)
          uc, vc = (max(us) + min(us)) / 2.0, (max(vs) + min(vs)) / 2.0
          return {n: [(W / 2.0 + (u - uc) * s, H / 2.0 - (v - vc) * s) for u, v in pts] for n, pts in uv.items()}
      
      
      # =========================================================================== images (pure Python)
      def _review():
          if EXPERT_SCRIPTS not in sys.path:
              sys.path.insert(0, EXPERT_SCRIPTS)
          import mx_review
          return mx_review
      
      
      # Wade Neistadt's Graph Editor palette (TMSpauVphNs f_00933): translates red/green/blue,
      # rotates orange/yellow/cyan, scales pink/violet. Yellow darkened for a white background.
      CHANNEL_COLORS = {
          "translateX": (215, 40, 40), "translateY": (35, 160, 55), "translateZ": (40, 90, 215),
          "rotateX": (235, 130, 20), "rotateY": (185, 160, 0), "rotateZ": (0, 165, 185),
          "scaleX": (225, 95, 165), "scaleY": (165, 115, 215), "scaleZ": (135, 85, 165),
      }
      _SHORT = {"tx": "translateX", "ty": "translateY", "tz": "translateZ", "rx": "rotateX",
                "ry": "rotateY", "rz": "rotateZ", "sx": "scaleX", "sy": "scaleY", "sz": "scaleZ"}
      TRACK_COLORS = [(215, 40, 40), (40, 90, 215), (35, 150, 55), (235, 130, 20), (140, 60, 180),
                      (0, 150, 170), (120, 120, 120), (200, 60, 140), (130, 90, 30), (0, 0, 0),
                      (90, 170, 230), (170, 200, 40)]
      
      
      def _channel_color(name):
          attr = name.rsplit(".", 1)[-1]
          attr = _SHORT.get(attr, attr)
          return CHANNEL_COLORS.get(attr, (60, 60, 60))
      
      
      class Canvas(object):
          """Tiny RGB raster (top row first) with clipped drawing. save() uses mx_review.write_png."""
      
          def __init__(self, w, h, bg=(250, 250, 250), image=None):
              self.w, self.h = int(w), int(h)
              self.buf = bytearray(image) if image is not None else bytearray(bytes(bg) * (self.w * self.h))
      
          def px(self, x, y, c):
              x, y = int(round(x)), int(round(y))
              if 0 <= x < self.w and 0 <= y < self.h:
                  o = (y * self.w + x) * 3
                  self.buf[o:o + 3] = bytes(c)
      
          def dot(self, x, y, r, c):
              if x != x or y != y:          # NaN from a point behind the camera
                  return
              r = max(0, int(r))
              for dy in range(-r, r + 1):
                  for dx in range(-r, r + 1):
                      if dx * dx + dy * dy <= r * r + r:
                          self.px(x + dx, y + dy, c)
      
          def line(self, x0, y0, x1, y1, c, width=1):
              if any(v != v for v in (x0, y0, x1, y1)):
                  return
              n = int(max(abs(x1 - x0), abs(y1 - y0))) + 1
              if n > 20000:
                  return
              for i in range(n + 1):
                  t = i / float(n)
                  x, y = x0 + (x1 - x0) * t, y0 + (y1 - y0) * t
                  if width <= 1:
                      self.px(x, y, c)
                  else:
                      self.dot(x, y, width // 2, c)
      
          def rect(self, x0, y0, x1, y1, c):
              x0, x1 = sorted((max(0, int(x0)), min(self.w - 1, int(x1))))
              y0, y1 = sorted((max(0, int(y0)), min(self.h - 1, int(y1))))
              row = bytes(c) * (x1 - x0 + 1)
              for y in range(y0, y1 + 1):
                  o = (y * self.w + x0) * 3
                  self.buf[o:o + len(row)] = row
      
          def text(self, x, y, s, scale=2, c=(20, 20, 20)):
              x, y = int(x), int(y)
              if y < 0 or y + 7 * scale > self.h or x < 0 or self.w - x < 5 * scale:
                  return
              nmax = int((self.w - x - 5 * scale) // (6 * scale)) + 1
              s = str(s)[:max(0, nmax)]
              if s:
                  _review().draw_text(self.buf, self.w, x, y, s, scale, c)
      
          def save(self, path):
              d = os.path.dirname(os.path.abspath(path))
              if not os.path.isdir(d):
                  os.makedirs(d)
              _review().write_png(path, self.w, self.h, bytes(self.buf), channels=3)
              return path
      
      
      def _tick_step(n):
          for s in (1, 2, 4, 6, 12, 24, 48, 96, 240, 480):
              if n / float(s) <= 24:
                  return s
          return 1000
      
      
      def curve_lanes_png(path, curves, keys=None, spans=(), marks=(), width=1200, lane_h=56,
                          title=None, label_w=240):
          """Stacked-view curve plot (Wade: one lane per curve, min-max normalized, so relative
          timing across channels reads at a glance). curves: {name: [(frame, value)]} or a list
          of (name, samples); keys: {name: [key frames]} drawn as dots; spans: [(start, end,
          label)] shaded (contacts, air); marks: frames drawn as vertical lines (key poses)."""
          items = list(curves.items()) if isinstance(curves, dict) else list(curves)
          if not items:
              raise ValueError("no curves to plot")
          allf = [float(f) for _, s in items for f, _ in s]
          f0, f1 = min(allf), max(allf)
          if f1 <= f0:
              f1 = f0 + 1.0
          head, foot = 34, 22
          W, H = int(width), head + lane_h * len(items) + foot
          cv = Canvas(W, H)
          plot_w = W - label_w - 12
      
          def px(f):
              return label_w + (float(f) - f0) / (f1 - f0) * plot_w
      
          for sp in spans:
              cv.rect(px(sp[0]), head, px(sp[1]), H - foot, (226, 234, 248))
              if len(sp) > 2 and sp[2]:
                  cv.text(px(sp[0]) + 2, head - 16, str(sp[2]), 1, (60, 80, 140))
          step = _tick_step(f1 - f0)
          f = math.ceil(f0 / step) * step
          while f <= f1:
              cv.line(px(f), head, px(f), H - foot, (225, 225, 225))
              cv.text(px(f) - 6, H - foot + 6, "%d" % f, 1, (90, 90, 90))
              f += step
          for m in marks:
              cv.line(px(m), head - 4, px(m), H - foot, (150, 150, 150))
          if title:
              cv.text(6, 6, title, 2, (0, 0, 0))
          for li, (name, samples) in enumerate(items):
              y0 = head + li * lane_h
              cv.line(0, y0, W, y0, (205, 205, 205))
              vals = [float(v) for _, v in samples]
              lo, hi = min(vals), max(vals)
              flat = hi - lo < 1e-9
      
              def py(v):
                  if flat:
                      return y0 + lane_h / 2.0
                  return y0 + lane_h - 7 - (float(v) - lo) / (hi - lo) * (lane_h - 16)
      
              col = _channel_color(name)
              pts = [(px(fr), py(v)) for fr, v in samples]
              for a, b in zip(pts[:-1], pts[1:]):
                  cv.line(a[0], a[1], b[0], b[1], col, 2)
              for kf in (keys or {}).get(name, []):
                  near = min(samples, key=lambda s: abs(float(s[0]) - float(kf)))
                  cv.dot(px(kf), py(near[1]), 3, (0, 0, 0))
              cv.text(4, y0 + 6, name, 1, (10, 10, 10))
              cv.text(4, y0 + 20, ("FLAT %.3g" % lo) if flat else ("%.3g .. %.3g" % (lo, hi)), 1, (90, 90, 90))
          cv.save(path)
          return {"path": path, "size": (W, H), "frames": (f0, f1), "lanes": len(items)}
      
      
      def tracks_png(path, tracks, frames=None, size=(960, 540), bones=(), pose_frames=(), image=None,
                     title=None, label_every=None, dot=2):
          """Tracks with a dot per frame (spacing reads as dot gaps) and optional stick figures
          at pose_frames, light to dark (an onion skin). tracks: {name: [(px, py[, depth])]}
          in pixels, e.g. from camera_track() or fit_view(). image: optional PNG to draw over
          (a playblast frame): the programmatic drawover."""
          W, H = int(size[0]), int(size[1])
          bg = None
          if image:
              R = _review()
              w, h, rgba = R.read_rgba(image)
              rgb = R.rgba_to_rgb(rgba)
              bg = rgb if (w, h) == (W, H) else R.downsample(w, h, rgb, W, H)
          cv = Canvas(W, H, image=bg)
          names = list(tracks.keys())
          n = len(tracks[names[0]])
          frames = list(frames) if frames is not None else list(range(n))
          fidx = {float(f): i for i, f in enumerate(frames)}
          pf = [p for p in pose_frames if float(p) in fidx]
          pf_set = set(float(p) for p in pf)
          for k, f in enumerate(pf):
              i = fidx[float(f)]
              g = int(200 - 170 * (k / float(max(1, len(pf) - 1))))
              for a, b in bones:
                  if a in tracks and b in tracks:
                      pa, pb = tracks[a][i], tracks[b][i]
                      cv.line(pa[0], pa[1], pb[0], pb[1], (g, g, g), 3)
          for ni, name in enumerate(names):
              col = TRACK_COLORS[ni % len(TRACK_COLORS)]
              pts = tracks[name]
              for a, b in zip(pts[:-1], pts[1:]):
                  cv.line(a[0], a[1], b[0], b[1], col, 1)
              for i, p in enumerate(pts):
                  cv.dot(p[0], p[1], dot + (1 if float(frames[i]) in pf_set else 0), col)
              cv.rect(8, 30 + ni * 16, 18, 40 + ni * 16, col)
              cv.text(24, 31 + ni * 16, name, 1, (20, 20, 20))
          if label_every:
              p0 = tracks[names[0]]
              for i in range(0, n, int(label_every)):
                  cv.text(p0[i][0] + 5, p0[i][1] - 12, "%d" % frames[i], 1, (0, 0, 0))
          if title:
              cv.text(8, 8, title, 2, (0, 0, 0))
          cv.save(path)
          return {"path": path, "size": (W, H), "tracks": names, "poses": pf}
      
      
      # =========================================================================== gates (pure)
      def _gate(rows, gid, status, message, frames=None, fix=None, data=None, source=None):
          rows.append({"id": gid, "status": status, "message": message, "frames": frames,
                       "fix": fix, "data": data, "source": source})
      
      
      def _windows(w):
          """A window [start, end] or a list of windows -> [(start, end)]."""
          if not w:
              return []
          if isinstance(w[0], (list, tuple)):
              return [(float(a), float(b)) for a, b in w]
          return [(float(w[0]), float(w[1]))]
      
      
      def _gid(base, k, n):
          return base if n == 1 else "%s_%d" % (base, k + 1)
      
      
      def evaluate_gates(samples, plan):
          """Code gates of a shot from sampled data (pure). samples: {frames, fps, unit, up,
          world {node: [(x, y, z)]}, attrs {plug: [values]}?, keys {ctrl: [times]}?,
          subframe_keys?, layers?, scene_fps?}. plan: see run_gates. Returns {gates: [...],
          summary, ok}. Status: pass, warn, fail, skip. Thresholds cite their source; [added] ones
          are toolkit defaults. The body-mechanics gates apply to any jump, landing, hop or
          weight shift the plan describes: every crouch window (foot_roll, spine_drag, resistance
          when heavy), every touch-down and lift-off of every foot (heel_to_flat, peel_off), every
          landing in plan["impacts"] (impact_hold, default the air landing)."""
          F = [float(f) for f in samples["frames"]]
          W = samples.get("world", {})
          up = int(samples.get("up", 1))
          fps = float(samples.get("fps") or plan.get("fps") or 24.0)
          unit = samples.get("unit", "cm")
          cm = UNIT_TO_CM.get(unit, 1.0)
          style = plan.get("style", "realistic")
          stage = plan.get("stage", "spline")
          heavy = style in ("heavy", "realistic", "live_action")
          gf = _ground_fn(plan.get("ground", 0.0))
          rows = []
          idx = {f: i for i, f in enumerate(F)}
      
          def seg(track, a, b):
              return [track[idx[float(f)]] for f in F if float(a) <= f <= float(b)]
      
          # scene rate
          if samples.get("scene_fps") and plan.get("fps"):
              ok = abs(float(samples["scene_fps"]) - float(plan["fps"])) < 1e-3
              _gate(rows, "fps", "pass" if ok else "fail", "scene %s fps, plan %s fps" % (
                  samples["scene_fps"], plan["fps"]), fix=None if ok else "cmds.currentUnit(time=...)",
                  source="brief")
          cog = plan.get("cog")
          ch = [p[up] for p in W[cog]] if cog in W else None
          air = plan.get("air")
          contact_frames = [float(x) for x in (air or [])]
          # impacts reach followers (head, hands) a few frames late through overlap: excuse
          # spikes from 1 frame before to 3 frames after each contact [added]
          impact_frames = sorted(set(c + d for c in contact_frames for d in (-1.0, 0.0, 1.0, 2.0, 3.0)))
          if ch and air:
              a0, a1 = float(air[0]), float(air[1])
              hs = seg(ch, a0, a1)
              fr = [f for f in F if a0 <= f <= a1]
              if len(hs) >= 4:
                  b = ballistic_fit(hs, fr, fps, unit)
                  lo, hi = (0.8, 1.25) if heavy else (0.8, 9.0)
                  st = "pass" if lo <= b["g_ratio"] <= hi else ("fail" if heavy else "warn")
                  _gate(rows, "ballistic", st, "air g_ratio %.2f (1.0 = real gravity), residual %.2f" % (
                      b["g_ratio"], b["max_residual"]), (a0, a1),
                      None if st == "pass" else "retime the air: frames from air_frames(); key it with ballistic_keys()",
                      {k: b[k] for k in ("g_ratio", "apex_frame", "apex_height", "hang_frames", "hang_expected")},
                      "physics [added]: 981/576 cm/frame^2 at 24 fps")
                  if b["hang_excess"] is not None:
                      st = "pass" if b["hang_excess"] <= 1 or not heavy else "fail"
                      _gate(rows, "hang", st, "hang %s frames near the apex, gravity gives %s" % (
                          b["hang_frames"], b["hang_expected"]), (a0, a1),
                          None if st == "pass" else "no stylized hang on a heavy body: flat apex, one-third handles",
                          source="physics [added]; Neistadt's weighted apex (TMSpauVphNs 00:07:29) adds hang beyond gravity")
                  if cog in W:
                      P = seg(W[cog], a0, a1)
                      hsp = []
                      for p, q in zip(P[:-1], P[1:]):
                          u, v = list(p), list(q)
                          u[up] = v[up] = 0.0
                          hsp.append(_dist(u, v))
                      m = _mean(hsp)
                      cv = (math.sqrt(_mean([(x - m) ** 2 for x in hsp])) / m) if m > 1e-9 else 0.0
                      st = "pass" if cv <= 0.15 else "warn"
                      _gate(rows, "air_horizontal", st, "horizontal air speed CV %.2f" % cv, (a0, a1),
                            None if st == "pass" else "nothing pushes in the air: linear horizontal travel",
                            source="physics [added]")
              else:
                  _gate(rows, "ballistic", "skip", "fewer than 4 air frames", (a0, a1))
              try:
                  lc = landing_catch(ch, F, a1)
                  good = (lc["removed_in_window"] or 0) >= 0.6 and (lc["catch_ratio"] is None or lc["catch_ratio"] < 1.0)
                  _gate(rows, "catch", "pass" if good else "warn",
                        ("landing: %.0f%% of fall speed gone in 2 frames, catch ratio %s, %d overshoots" % (
                            100 * lc["removed_in_window"], None if lc["catch_ratio"] is None else round(lc["catch_ratio"], 2),
                            lc["overshoots"])) if lc["removed_in_window"] is not None else
                        "landing: the COG is not falling into the contact (floaty arrival)", (a1, lc["lowest_frame"]),
                        None if good else "sharper catch: broken tangent into the contact, shorter decel than recovery",
                        lc, "Elver jzuxAmadcm8 00:58:46; Newman TIBzcsOt2FU 00:13:56")
              except ValueError as exc:
                  _gate(rows, "catch", "skip", str(exc))
              if plan.get("push"):
                  p0 = float(plan["push"][0])
                  vel = [(F[i], ch[i + 1] - ch[i]) for i in range(len(ch) - 1)]
                  fmax = max(vel, key=lambda r: r[1])[0]
                  ok = p0 <= fmax <= a0
                  _gate(rows, "push_fastest", "pass" if ok else "warn",
                        "fastest upward COG move starts at frame %s (push %s to takeoff %s)" % (fmax, p0, a0),
                        (p0, a0), None if ok else "snap out of the anticipation: ease in slow, release fast",
                        source="Camporota FA7fPB7qUhE 00:01:18, 00:03:46")
          # hold at the deepest compression of every landing (plan "impacts", default the air landing)
          impacts = [float(x) for x in (plan.get("impacts") or ([air[1]] if air else []))]
          for k, c in enumerate(impacts):
              if not ch or c not in idx:
                  continue
              try:
                  lc = landing_catch(ch, F, c)
              except ValueError:
                  continue
              ok = lc["bottom_hold"] >= 3                        # 3 frames: at least two near-still steps [added]
              _gate(rows, _gid("impact_hold", k, len(impacts)), "pass" if ok else "warn",
                    "hold at the deepest compression after %s: %d frames (%s-%s)" % (
                        c, lc["bottom_hold"], lc["bottom_span"][0], lc["bottom_span"][1]), lc["bottom_span"],
                    None if ok else "push the squash and give the impact a bigger hold before the recovery",
                    {"bottom_hold": lc["bottom_hold"], "lowest_frame": lc["lowest_frame"]},
                    "Elver jzuxAmadcm8 00:13:54, 00:14:29; threshold [added]")
          # crouch windows (any anticipation down: plan "crouch" i
  • SKILL.md 17.8 KB
    ---
    name: scenario-maya-animation
    description: "Use when animating a character or prop in Maya through Python: a walk or run cycle, a jump or landing, a heavy lift, throw or punch, an acting or dialogue shot, lip sync, eye darts and blinks, stepped blocking, splining, polish, Graph Editor tangents, animation layers, mocap cleanup or retargeting, game clips and root motion, or reviewing a playblast. Also when motion looks floaty, weightless, mushy, poppy, posey, sliding or chattery, or feet slap or skate."
    license: MIT
    ---
    
    # Maya animation (animator)
    
    Expert animation means every frame is a choice: poses that read as drawings from the camera, holds and snaps placed on purpose, designed spacing, weight shown by resistance and a caught stop. Work big to small (story, body, face, detail); never add detail to a stage that does not read yet. An agent cannot scrub, so it measures (world and screen tracks, spacing, holds, contacts, a ballistic fit, overlap lags), then looks at a playblast or a headless sheet before calling anything done. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap.
    
    **REQUIRED BACKGROUND:** scenario-maya-expert (execution channel, review loop, 2027 version traps).
    
    Toolkit: [`scripts/mx_anim.py`](scripts/mx_anim.py). Its analysis and image layer is pure Python and ran offline.
    
    ## Stance (the expert delta)
    
    - **Pick the workflow per shot; blocking ends when the information is in.** Neistadt: pose to pose for acting, straight ahead or extremes for flow; blocking is done when all five key categories exist (main, anticipation, overshoot and follow-through, breakdown, hold). Elver splines physical shots from the start; Camporota proves timing on two boxes first.
    - **Maya is "the worst in-betweener in the world" (Newman).** Feed it keys: ones on fast actions, 2 to 4 frames apart on slow parts. Spline readiness is a test: spline the range, play; mush means back to stepped with more poses. Native Set Key reshapes neighboring curves (Wade): insert keys, prove it with a curve diff, and share keys across every control before splining.
    - **Weight is resistance, then release, then a caught stop.** Wade: show what the character is up against (a delay, acceleration from rest) before its strength; strength is a frame count. Elver: more up and down reads as a balloon, not weight; weight is a fall caught abruptly, a bigger hold in the squash, residual jiggle. Heavy air is gravity-true [added physics]; Wade's weighted apex adds hang, for light or cartoony bodies.
    - **Feet and spine sell every weight shift (jump, hop, landing, lift).** Camporota: animate the foot roll in every crouch, it sells the compression; in a big drop the spine drags behind the hips and only the arms drop; first passes overdo the anticipation, so dial it back; a touch-down goes flat "almost in one frame". Elver: on the first airborne frame the toe tip points back to where it left the ground.
    - **Judge in camera space, at speed.** Elver: a motion trail can show a perfect 3D arc that fails on screen. Lazare: the eye goes to the biggest motion, so the non-focus character stays "appropriately still". Project tracks through the shot camera; read the playblast before any metric.
    - **The base layer is the animation; mocap is precious.** Newman: a layer is a time-boxed experiment with marker keys and a protective key; never clean curves on it, merge with Smart Bake, then clean (Elver: only a late global note or an additive tremble, then bake). On mocap fix only the defect asked for, or the cascade ends "not hand key and not mocap"; after a merge, Euler filter, then delete keys down to extremes.
    - **Lip sync animates sounds, not words.** Santos: jaw only first (the muppet pass), then the big five (jaw, two corners, top and bottom lip) for 90%; lead by place of articulation, bilabials on the frame. Lazare: shift every mouth key 1 to 2 frames earlier only when the whole line plays late.
    - **Polish is triage; review is top down.** Elver ranks the notes, spends on the ones that pay, owns every contact frame. Newman grades Intent, Mechanics, Performance, Integration; a playblast does not prove game feel.
    
    ## Establish first
    
    | Input                 | Why it changes the plan                                                                                                                              | Default when the brief is silent                               |
    | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
    | Style and destination | squash, stops, closeness to reference: cartoony pushes; live action needs pose squash and no dead stops (Elver); VFX stays near reference (Neistadt) | realistic feature quality                                      |
    | Frame rate            | timing numbers, `currentUnit`                                                                                                                        | 24 fps (`film`)                                                |
    | Weight and strength   | frames of resistance and of overcome (Wade: decide both numbers first)                                                                               | heavy: resistance beat of 2+ frames, no hang                   |
    | Rig                   | control names, IK/FK, spaces, foot roll attribute (map once with `listAttr(keyable=True)`)                                                           | hands in IK, no IK/FK or space switches inside a shot (Newman) |
    | Camera                | a locked shot camera is the only judge; games also need every angle and the engine                                                                   | build the shot camera, lock it                                 |
    | Reference, audio      | landmarks (contacts, takeoff, apex, landing, blinks, darts) give rhythm, then push (Neistadt)                                                        | beat list from the brief                                       |
    | Game metrics          | speeds and heights agreed in round numbers before animating (Newman); root motion or in place (Epic)                                                 | in place, metrics sheet requested                              |
    
    ## Workflow
    
    1. **Plan in numbers.** Beats with frames, the leading part per beat, key categories, contacts, windows (crouch, push, air, impacts), style; `A.store_plan(plan)`. Physical shots: `A.air_frames(rise, drop)` budgets the air. Dialogue: phrases with subtext and an action verb, ruled sounds (M, B, P, F, V, L, T, D), 2 to 4 tentpoles (Santos, Wade). GATE: frame counts add up.
    2. **Set up.** fps, range, sound, shot camera locked; query the tangent defaults (2027 notes). GATE: `mx_validate.validate(profile="shot", rules={"fps": 24})` has no fail.
    3. **Proxy pass (big, fast or camera-bound moves).** `A.proxy_boxes()`, key translate and lean, air with `A.ballistic_keys()`, rotation 1 to 2 frames behind (Camporota). The first anticipation is usually too big: `A.scale_curve(root, "translateY", 0.8, rest_y, time=crouch)`. GATE: `A.evaluate_gates()` on the proxy (ballistic, catch, resistance, push_fastest); `proxy_curves.png` looked at.
    4. **Blocking, stepped.** `A.set_key_defaults("blocking")`; `A.key_pose(pose, f, fill=ctrls)` keys every control on every pose; passes: main, anticipations, overshoots, breakdowns (favor a neighbor, never 50/50), holds (`A.key_hold()`); `A.share_keys(ctrls)`. Never act from the neutral rig pose (Neistadt). Crouch poses: foot roll keyed, spine dragging, arms hanging (Camporota). GATE: every key category present; stepped playblast (GUI) or `tracks.png`; write the story it reads, then check silhouettes.
    5. **Spline pass 1, the core.** `snap = A.snapshot(ctrls)` (the buffer curve); `A.to_spline()` on COG, spine, legs only; face, eyes, fingers stay stepped (Camporota, Santos). Air gravity-true; broken tangent into a hard landing (Newman); planted feet flat. Mush: `A.restore(snap)`, add poses. Every later key: `A.insert_key()`, then `A.curve_diff()` on sampled snapshots is ok outside its two segments (P4). GATE: `A.run_gates(plan)` ballistic, hang, catch, slide, penetration, foot_roll, spine_drag, heel_to_flat pass; no `A.overshoots()` through holds.
    6. **Spline pass 2, overlap.** Arms, head, secondary: correlated chains edited together first (Newman), then `A.offset_keys()` by 1 to 2 frames down the chain. GATE: `overlap` lags not all zero; `pose_to_pose_tell` low; tracks clean in camera.
    7. **Face, if the shot has one.** Body first, then the mask (eyes, brows), then the mouth: muppet jaw keyed at `A.mouth_keys(phonemes)`, big five, tongue, connection by `A.copy_curve()` (nostrils 1 frame behind the corners, dampened). Eyes linear, 2-frame darts; blinks close faster than they open. GATE: `A.jaw_report()`, `A.lip_lag()` against `A.audio_envelope()`, `A.blink_report()`, `A.transitions()`; close-ups on tentpoles.
    8. **Polish, by triage.** Fix the top-ranked notes, one section at a time. Contacts and fast moves on ones; peel-off on every takeoff; impacts get a pushed squash and a bigger hold; pops become 3 frames on an arc plus an overshoot; `A.retime()` tightens a section; moving holds; a motion-blurred render before final (all Elver). GATE: every gate passes (peel_off, impact_hold included), layers merged, no sub-frames, Euler filtered, critique.md on the final playblast.
    
    **Variants.** Cycle: poses every 3 frames, loop with `A.cycle()` before polish, offsets inside the cycle keep the period. Game clip: metrics, `A.cycle_speed()`, loop seam, root rules, then scenario-maya-pipeline-scripting. Mocap: procedures P12; retarget: P13; heavy interactions: P9.
    
    ## Numbers
    
    | Value                                                                                | Relative to            | Source                 |
    | ------------------------------------------------------------------------------------ | ---------------------- | ---------------------- |
    | Gravity 1.703 cm/frame^2 (981/576)                                                   | 24 fps, scene in cm    | physics [added]        |
    | Walk keys every 3 frames: contact 1, down 4, pass 7, up 10, contact 13; loop 1 to 25 | 24-frame cycle         | Camporota              |
    | Touch-down to flat foot about 1 frame, at most 2; walk arm offsets 0, 1, 2, head 1   | every step and landing | Camporota; body digest |
    | Rotation 1 to 2 frames behind translation                                            | proxy and body         | Camporota              |
    | Keys on ones on fast actions; 2 to 4 frames apart on slow ones                       | blocking               | Newman                 |
    | Blink close 2 / open 4 (or 3 / 5; big eyes one more)                                 | frames                 | Elver                  |
    | Eye dart 2 frames, linear; brows lead lids by 1 to 2, never 5                        | frames                 | Elver                  |
    | Snappy move: 3 frames on an arc plus a small overshoot, never 1                      | frames                 | Elver                  |
    | Global lip shift 1 to 2 frames earlier; opens near full on frame 1                   | when late at speed     | Lazare                 |
    | Big five = 90% of lip sync; jaw plus corners 50 to 60%                               | controls               | Santos, Elver          |
    | Game speeds: walk 2, run 4 to 6, sprint 12 to 15 m/s                                 | stylized player        | Newman                 |
    | Playblast default scale 0.5: pass `percent=100`                                      | active view            | Maya 2027 Help         |
    
    ## Quality gates
    
    **Measurable (`A.run_gates(plan)`; pure logic `A.evaluate_gates`), for any jump, hop, landing or weight shift in the plan:** fps; heavy air g_ratio 0.8 to 1.25, hang excess at most 1 frame, constant horizontal speed; catch (60%+ of the fall speed gone in 2 frames, decel shorter than recovery), then a 3+ frame hold at the bottom; push fastest; heavy resistance beat of 3+ still frames; every crouch: foot roll moves, spine lags the hips; touch-downs flat within 2 frames; lift-off toe within 30 degrees of the takeoff spot; planted feet at most 0.2 cm/frame, penetration under 0.5 cm; pre-landing stretch; no pops outside impacts; overlap lags; moving end hold; tell under 90% after splining; no sub-frames; no layers at polish. [added] thresholds are toolkit defaults.
    **Visual:** `A.review_images(plan, out)` (headless: `curves.png` stacked lanes, `tracks.png` camera-space tracks with onion-skin poses); in the GUI `A.playblast_pack()` (shot camera plus a no-lights silhouette) and a stepped-preview playblast. Read frames in time order; write the first-view read before the numbers (critique.md).
    
    ## Common mistakes
    
    | Mistake                                                                          | What it looks like                       | Fix                                                                      |
    | -------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------ |
    | Splining before the poses are in                                                 | mush, arms tweened through the body      | `A.restore(snap)`, add breakdowns stepped                                |
    | Even spacing, whole body on the same frames                                      | floaty, "hitting poses"                  | favor neighbors, holds, `offset_keys` down the chains                    |
    | First-pass anticipation kept                                                     | the crouch upstages the action           | `A.scale_curve(..., time=crouch)` about the rest value                   |
    | Torso locked to the hips, arms swung back in the crouch                          | stiff, generic anticipation              | spine drags behind the hips, only the arms drop                          |
    | Feet flat in the crouch, pointing forward at takeoff, slow to flatten on landing | weak compression, no push, slapping feet | foot roll; toe aimed back at the takeoff spot; flat within 1 to 2 frames |
    | Stylized hang on a heavy body                                                    | floaty apex                              | `ballistic_keys`, flat apex, broken contact tangent                      |
    | No resistance before the effort                                                  | styrofoam weight                         | 2+ frames of strain before the push                                      |
    | Symmetric dip, or a bounce straight out of the squash                            | light landing                            | sudden catch, bigger hold, one overshoot, slow recovery                  |
    | S or bare `setKeyframe` on a splined curve                                       | neighbors reshape, holds drift           | `A.insert_key`, then `A.curve_diff`                                      |
    | Spline through a hold                                                            | drifting hold                            | `key_hold` pillars, flat or plateau sides                                |
    | Arcs checked on a 3D trail                                                       | clean trail, broken arc on screen        | `camera_track` pixel tracks                                              |
    | Lip sync keyed on the sound                                                      | late at speed                            | per-sound lead, then a global shift                                      |
    
    ## Handoffs
    
    **Receives** from scenario-maya-rigging and scenario-maya-deformation: the rig referenced (never imported), `validate(profile="rig")` clean, the control list with IK/FK, space and foot roll attributes, a range-of-motion playblast. Rig problems (knee pop, flipping space) go back as a note with frames.
    **Delivers** shots to scenario-maya-lighting-rendering: a new saved version, `validate(profile="shot")` clean, layers merged, helpers deleted (`A.delete_helpers()`), `run_gates` JSON with no fail, the final playblast the agent looked at, what was not verified. Game clips to scenario-maya-pipeline-scripting: skeleton baked per clip, clip names and ranges (bookmarks), root rule (in place or root motion), loop seam and speed reports.
    
    ## Maya 2027 notes
    
    - Default tangent: Auto Span (Legacy) on one doc page, Clamped on another: query `keyTangent(q=True, g=True, itt=True)`. Lock/Free Tangent Weight is now Tangent Length; Butterworth is Smooth Filter (Butterworth).
    - Time Slider > Enable Stepped Preview; `A.stepped_preview()` is the scripted twin. Dope Sheet Time Snap is off since 2025.1 and `scaleKey` leaves sub-frames: `A.snap_subframes()`.
    - Ghost Editor and Motion Trail Editor sit under Visualize; ghosting a skinned mesh needs Cached Playback (disabled by motion blur, Trax, classic dynamics).
    - Playblast needs a model panel (GUI via mx_bridge); in mayapy use `review_images` or Arnold stills.
    - Sequencer (ex Camera Sequencer): lock every camera. HumanIK bakes from its own window, not Bake Simulation. Time Editor clips never mix with animation layers. Blue Pencil replaced Grease Pencil.
    
    ## References
    
    - [`references/expert-notes.md`](references/expert-notes.md): principles and numbers by expert, timestamps, deciding conditions. Load to choose an approach or write notes.
    - [`references/procedures.md`](references/procedures.md): the `mx_anim` API and full procedures with test paths. Load before writing code.
    - [`references/critique.md`](references/critique.md): review rubric (first-view read, four buckets), director lingo, note format. Load at every gate.
    - [`references/gui-paths.md`](references/gui-paths.md): menus, hotkeys, editor settings. Load for a computer-use agent or a human.
    - [`references/sources.md`](references/sources.md): every source, credential, URL and best timestamp.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related