Claude Skill

scenario-blender-animation

Use when animating characters or objects in Blender through Python, a bouncing ball, walk or run cycle, jump, acting shot, lip sync, eye darts or facial animation, blocking in stepped keys, splining, polish, timing and spacing, arcs, overlap, squash and stretch, graph editor hand

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_blender_scenario-blender-animation-dc592af.zip · 74 KB
Part of scenario-labs/skills — 97 skills

Install

skills CLI npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/blender/scenario-blender-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

Blender animation (character and object)

Expert animation means every frame on screen is a choice: key poses the audience reads, holds and snaps placed by hand, spacing designed, arcs checked. The Blender Studio stance is to block stepped, spline late and in parts, and judge from the shot camera at real speed. An agent cannot scrub, so it measures instead: per-frame positions, spacing charts, arc checks, framing, an onion-skin image, a curve plot, and a playblast it actually looks at. 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-blender-expert (execution channel, review loop, 5.2 API changes). Shot planning, cameras and animatics belong to scenario-blender-previs-storyboard; building the rig to scenario-blender-rigging.

Toolkit: scripts/bx_anim.py (keying, blocking-to-spline, curve checks, measurement, staging, playblast light, knee-pop, flip and balance checks, onion-skin and curve images, one-object bouncing_ball; API in procedures.md, tested on 5.2.1) and bx_review.playblast.

Stance (the expert delta)

  • Stay stepped long ("Blocking+"). Hjalti Hjalmarsson: juniors spline too early; computer in-betweens are "unintentional interpolated poses" that make notes hard. Keep adding hand-made breakdowns while stepped; splining is short and late.
  • Never convert the whole blocking to Bezier. Rik Schutte: key every main control on every key pose, copy each pose to the end of its hold ("pillars"), then spline the body only; face and fingers stay stepped until their own pass. Measured on 5.2.1: the naive conversion drifted 84% of the next move across a hold, the pillars held at exactly 0.
  • Snaps are authored. Rik: hold, then change pose in 2 to 3 frames; only big deliberate moves are left to interpolation. [added, measured] A contact inside continuous motion (a jump landing) is not a hold: pillaring it cut the body's speed at impact from 0.101 to 0.007 m per frame, a floaty landing.
  • Odd frames, even gaps; breakdowns shape arcs and steer the eye. Alex Nagy: key poses on odd frames with even spacing so a true middle frame exists. The breakdown sets the arc first, the timing second; favoring it late leaves more travel for the last frames, and the fastest part draws the eye.
  • The handle type is a timing decision. AUTO_CLAMPED keeps holds flat; AUTO overshoots between two equal keys (Alex uses it to push a pose or cushion a settle); VECTOR for hard hits, contacts and take-offs (Alex, Dillon Gu); FREE for burst spacing and exact arcs (Raymond Luc, Joey Carlino).
  • Lead with one part, layer the rest. Rik blocks a torso-only rhythm pass first; Dillon leads body mechanics with the COG and acting with the head or eyes. Overlap goes into the blocking, and a head never turns on one axis only (Rik).
  • Lip sync is rhythm before shapes. Rik: jaw first, stepped, opened big only on accents; off-sync is a missing preparation, not a global offset; M, B, P closed at least 2 frames and closed before the sound.
  • Spacing is felt, arcs are checked, the shot must read. Raymond: the audience should feel spacing tricks, not see them. Pablo Fournier: arcs are the most important check (root, hands, feet, nose), in camera space. Hjalti (in scenario-blender-previs-storyboard): after each cut the eye must find one thing, so a subject lost in an empty frame fails however good the curves are.

Establish first

Input Why it changes the plan Default when silent
Medium, fps Film 24 (Hjalti); games often 30 with fixed hit or release frames (Picaut) 24 fps
Camera Locked acting shot: judge only through the camera (Rik). Moving, re-framable or gameplay camera: poses work from every direction (Leo Silly-Pelissier, Picaut) locked camera
Style Stepped on twos and never splined (Sprite Fright shots, Rik) vs splined; stylized vs naturalistic (Leo) splined, stylized
Rig Control names, IK/FK, rotation modes. Generated Rigify [verified]: torso, hips, chest, head, foot_ik.L, upper_arm_fk.L, jaw_master; ["IK_FK"] on upper_arm_parent.L (0 IK, 1 FK); legs have IK_Stretch 1.0; rest legs are 99.6% straight; main controls QUATERNION FK arms when arcs matter, IK when hands touch things (Joey, Tony Garcia)
Named object A brief that names the animated object expects every channel on it one object, bouncing_ball(obj=...)
Audio, transcript Beats, accents, phoneme preparation beats from the brief
Reference Timing and beats, never a trace (Hjalti) none: write a beat list
Budget Rik: about 3 s a week in big studios, 5 to 6 s on his acting shot; Flow: 2 s per animator per day state what the budget buys

Workflow

  1. Plan. Beat list with frames: every acting beat in order (Hjalti counts about 8 per shot), sub-beats notice, react, realize, respond (Rik), the leading part per beat. With audio, audio_envelope + accent_frames; hit a pose just before its audio peak (Alex). Start mid-action (Rik). GATE: beat table written.
  2. Set up and stage the shot (P1, P18): fps, range, sound, passepartout 0.99, Simplify for previews. Place the camera, then staging_report(subject, frames) with the real render size: subject height, how much of the frame the action covers, entry, final pose margin. Playblast look: playblast_lighting(key_dir=(0, 0, 1)) (overhead key converted to Workbench's camera space: the shadow sits under the subject, the gap is its height, contact is where they meet), floor_slab, rotation_texture on anything that rolls. GATE: flags empty; shadow_frames visible in the air and touching at a contact; camera stills looked at.
  3. Block A, key poses stepped. Strongest, longest-held pose first (Alex). Build each pose COG, feet (weight over the support foot, feet never twins, knees soft), hips, chest, head, arms. Pose in world terms with world_pose (rig axes are arbitrary), key with key_pose(..., keytype='EXTREME'), CONSTANT, all main controls. GATE: pose strip onion_skin(frames=keys, spread=...) shows clear silhouettes and contrast; balance_report margin >= 0 on held poses.
  4. Blocking+ (still stepped). breakdown at the middle frame with per-part factors (COG dips, extremities delayed at 0.2 to 0.3, directed part favored late; after a take-off the body is ballistic, about 2/3 of the rise in 3 of 7 frames, and the feet must follow); ease_inbetweens for 20/80 (Pablo); overshoots and settles; overlap offsets; key types as a confidence map (Hjalti). GATE: stepped playblast approved; notes on intent stopped (Leo: none after Block B).
  5. Spline the body with blocking_to_spline(rig, body_bones, transition=3, interpolate={...}): big moves and continuous contacts in interpolate; VECTOR on foot contacts and take-offs. GATE: hold_drift < 1e-5, overshoots only wanted settles, face still CONSTANT, motion_report without unintended constant spacing, ballistic_fit on the airborne COG with g_ratio near 1.0 and a small residual, limb_extension never above 1.0.
  6. Re-inject energy (Leo: splining softens): timing accents, overshoots, desynchronized parts, offset_keys 1 to 2 frames per chain link (Dillon), head lag 2 to 3 frames in cycles (Joey), contacts that slide rather than glue (Rik). Fingers: little overlap, no bounce. GATE: arcs clean in camera pixels (to_camera(..., res=(w, h)), motion_report(dims=2)).
  7. Face and lip sync, stepped (P6, P7): jaw rhythm, corners (never dead center), shapes from a pose library on the lower face minus the jaw, accents and asymmetry in small doses, tongue last, then spline the face. Eyes dart in 1 to 2 frames and lead the head; brows 3 to 10 frames (Rik). GATE: transitions meets those counts; accents clearly fewer than syllables.
  8. Polish. foot_slip under 1 mm, lowest_point for penetration, rotation_flips empty (fix_rotation_flips), limb_extension under 0.99 wherever the leg should stay bent, smears 1 frame (Pablo), burst spacing only now (Raymond), subdivision on. GATE: critique.md rubric passes on the final playblast, onion skin and graph image.

Variants. Bouncing ball (P16, P18): bouncing_ball(obj=ANIM_ball, first_apex=-3, fit=False, ...) keys ONE object: exact parabolas under one gravity through whole-frame contacts, odd or even flights so small hops keep their height, squash on the contact frame and stretch along the velocity through a world-aligned squash modifier (the roll is never twisted), roll = distance / radius, constant-deceleration roll, a 6-frame hold. first_apex before start throws it in from off-screen. Weight mapping is [added]: tune by render. Walk (Joey, 30 fps): contact 0, down 5, passing 10, up 15, 40-frame cycle; heel curve straight during contact, root linear with REPEAT_OFFSET (P5). Jump: tests/code/blender-animation/test_rigify_jump.py is the worked example (Rigify human, blocking to spline).

Numbers

Item Value Source
Snappy pose change 2 to 3 frames after the hold Rik
Aggressive change 4 frames instead of 8 Alex
Hold to read an idea 6 frames Alex
Settle 1 to 2 frames, 4 soft Alex
Reaction after stimulus about 6 frames Rik
Eye dart / brow change 1 to 2 / 3 to 10 frames Rik
Ease rule 80% of the remaining way per in-between (slow-in); mirror 20% Pablo
Smear 1 frame, sometimes 2 Pablo
Micro-anticipation last 1 to 2 frames before hit or release pushed to an extreme Picaut
Head lag in a cycle / foot offset 2 to 3 frames / 1 frame Joey / Rik
Real gravity at 24 fps acceleration 0.01703 m/frame^2; a quadratic fit's leading coefficient is half (-0.00852). gravity() physics [added]
Subject size in frame 12% of frame height minimum, 15% or more for a single subject [added] E3: both runs measured 10%
IK leg extension under 0.99 of full length while meant bent; above 1.0 = IK stretch [added]
Pinwheel on a rolling object roll per frame under 360 / sectors degrees [added] wagon-wheel

Quality gates

Measurable: slot named after the character; key poses CONSTANT and typed before approval; hold_drift ~0; overshoots only on settles; foot_slip < 1 mm; lowest_point never below the floor (sample sub-frames); transitions for darts, brows, snaps, plosives; ballistic_fit g_ratio near 1 at real scale; motion_report: sharp turns only at contacts, no arc wobble or start-stops inside a move; staging_report flags empty with shadow frames visible; limb_extension, rotation_flips, balance_report as above; roll_aliasing ok. Visual (open every image): pose strip, onion skin through the shot camera with tracks, graph_image, playblast frame sheets (subject size, entry, shadow gap closing at contacts, spin readable), orbit views when the camera is not locked.

Common mistakes

Mistake What it looks like Fix
Whole blocking to Bezier "floaty spaghetti" blocking_to_spline with pillars, body only
Pillar on a continuous contact body brakes before impact put that frame in interpolate; VECTOR on the foot
AUTO handles on a hold hold drifts past the pose AUTO_CLAMPED; AUTO only for a settle
Heel curve eased during contact foot slides (5.8 cm per step measured) VECTOR or zero-length FREE handles
Jaw open on every syllable chattering mouth big opens on accent_frames only
BOUNCE easing for a ball no squash frame ever shows bouncing_ball or keyed whole-frame contacts
Squash on a parent that also aims pattern twists on stretch frames (55 degrees measured on the old rig) squash_modifier (world axes) on the object itself
Flights rounded to even frames hops lose or gain height, gravity changes per bounce (0.98 to 1.31 measured) whole-frame contacts, odd flights allowed, one gravity
Framing the whole set subject 10% of frame, top 40% empty move in or shorten the travel until staging_report is clean
display.light_direction read as world shadow hidden behind the subject (0 px) playblast_lighting(key_dir=...)
Arcs checked in normalized camera coords false sharp turns at apexes (aspect squeezes x) to_camera(..., res=(w, h))
Legs straight at stand or push-off IK stretch lengthens the leg (18% in the first jump draft) soft knees, feet breakdown after take-off, VECTOR take-off

Blender 5.2 notes

  • action.fcurves is gone: channelbags via bpy_extras.anim_utils (bx_anim.channelbag). Assigning an action animates only if a slot matches; otherwise set ad.action_slot.
  • One layer per action (layers.new raises); layer with the NLA plus an ADD or COMBINE action (push_layer).
  • keyframe_points.insert gives BEZIER AUTO_CLAMPED; a Keyframe reference dies at the next insert on that curve.
  • Headless: anim.keyframe_insert fails; pose.breakdown and pose.blend_to_neighbor need explicit prev_frame/next_frame; render.opengl refuses, so playblasts are Workbench renders.
  • Workbench scene.display.light_direction is camera-relative; matrix_world is stale until view_layer.update(), so convert after it.
  • GN modifier inputs key at modifiers["X"].properties.inputs.Socket_n.value.
  • Playblast audio: sound strip plus render.ffmpeg.audio_codec = 'AAC'.
  • Keymap: Up = previous key, Down = next (5.0). Not in 5.2: action layers, paste-pose blending, range world-space copy (5.3).

References

  • references/expert-notes.md: principles, numbers and disagreements by expert, with timestamps; load when choosing between approaches or writing notes.
  • references/procedures.md: full tested code for every procedure (setup, blocking, spline, walk, lip sync, facial timing, contact pin, slots, layers, pose library, playblast, measurement, ball, staging, knee/flip/balance); load before writing code.
  • references/critique.md: the review rubric per stage, staging thresholds and their calibration, how these animators phrase notes; load at every gate.
  • references/sources.md: the 14 source videos, what each is best for, best timestamps.
Files (skills)
  • references
    • critique.md 29.2 KB
      # Critique rubric: how these animators judge a shot
      
      Use at every gate. Each item: what the expert looks at (source), the measurable proxy (bx_anim call), and what to look for in the renders. Measure first, then look; report which items were measured and which were judged by eye.
      
      ## The review pack (render it, then open every image)
      
      1. **Pose strip** of the key poses: `onion_skin(frames=key_poses, spread=300, view=...)` (the agent's version of flipping between poses; Alex Nagy, Hjalti Hjalmarsson flip extremes with arrow keys or markers).
      2. **Shot-camera playblast** at the scene fps with dialogue: `bx_review.playblast` with `ffmpeg.audio_codec='AAC'`; pull a frame sheet with ffmpeg and look at it. Everything is judged from the shot camera when it is locked (Rik Schutte); add front, side and top orbit renders when the camera moves or may be re-framed (Leo Silly-Pelissier, Pierrick Picaut).
      3. **Onion skin with tracks** on root or COG, hands, feet, head or nose: `onion_skin(track=[...])` (Pablo Fournier's arc check).
      4. **Curve plot** of the main controls: `graph_image` (holds flat, eases where planned, face still stepped).
      5. **Numbers**: `format_report(motion_report(...))` per tracked part, in world and in camera pixels (`to_camera(..., res=(w, h))`, `dims=2`), plus `hold_drift`, `overshoots`, `transitions`, `foot_slip`, `lowest_point`, `ballistic_fit`, `limb_extension`, `rotation_flips`, `balance_report`.
      6. **Staging**: `format_staging(staging_report(subject, frames, shadow_frames=[airborne, contact]))` with the shot camera and render size, and two ffmpeg frame sheets from the playblast (every third frame; the frames around the hardest contact).
      7. **Progression**: the stepped blocking, the previous version and the current one side by side (Rik plays blocking, earlier spline and current back to back, hnHemwjznPc [02:40:00]).
      
      Preview speed: Simplify with max subdivision 0 (Tony Garcia: a production scene went from 5 to 24 fps, fbfODZ8PO4E [00:17:43]); polish review with subdivision back on (Rik).
      
      ## 1. Intent and staging (Block A)
      
      | Look at                                                                        | Source                                                            | Measure                                                                                                                                                                 | See                                                                           |
      | ------------------------------------------------------------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
      | Every beat present, in order; sub-beats (notice, react, realize, respond)      | Hjalti eOxVdBgoehk [00:13:40]; Rik NEZVF93fKsM [00:22:43]         | beat table vs key frames                                                                                                                                                | each beat has a distinct pose in the strip                                    |
      | Pose reads from the camera: clear silhouette, no arm crossing the body         | Alex p8Bi7k60IS0 [00:36:38]; Rik hnHemwjznPc [01:28:36]           | none                                                                                                                                                                    | silhouettes in the strip, black shapes read without detail                    |
      | Contrast between consecutive key poses; sizes vary (small, small, big, medium) | Alex [00:05:55], [00:27:55]                                       | COG and hand distance between key poses                                                                                                                                 | neighboring poses in the strip are clearly different                          |
      | Leading part per beat (COG for mechanics, head or eyes for acting)             | Dillon Gu fbfODZ8PO4E [00:27:37]; Rik zhktftWbumE [00:05:43]      | `transitions` start frames: leading part moves first                                                                                                                    | the lead is visible in blocking                                               |
      | Weight over the support foot, feet never twins                                 | Alex [00:30:06]-[00:34:26]; Joey Carlino nRtT7Gr6S2o [00:07:10]   | horizontal COG vs support-foot position per key pose                                                                                                                    | the body would not tip over                                                   |
      | Shot starts in the middle of an action                                         | Rik zhktftWbumE [00:05:21]                                        | first 5 frames not a dead hold                                                                                                                                          |                                                                               |
      | Reference used for beats and timing, not traced                                | Hjalti [00:01:45]                                                 |                                                                                                                                                                         | principles visible (squash, overshoot), not rotoscope-flat                    |
      | Camera-only vs all directions                                                  | Rik zhktftWbumE [00:27:32] vs Leo fxz6p-QATfs [00:26:26]          |                                                                                                                                                                         | orbit renders only when the camera is not locked                              |
      | The eye finds the subject: big enough, the frame used by the action            | Hjalti (previs notes: after each cut the eye must find one thing) | `staging_report`: `size_median` >= 0.12 of frame height (0.15+ for one subject), `action_w`/`action_h`, top of frame not empty for the whole shot (> 30% flags) [added] | the subject reads on the frame sheet without searching                        |
      | An entering action enters                                                      | Rik (start mid-action) [added for entries]                        | `entry` off-screen or partial on frame 1; `edge_frames` empty after the entry; `end_margin` >= 3%                                                                       | first frame sheet image empty or cut by the edge; rest pose clear of the edge |
      | Height and contact read on a ball or a jump                                    | [added, E3 run and baseline]                                      | shadow probe: visible in the air with a gap, visible and touching (gap 0) at the contact                                                                                | overhead key: shadow under the subject, gap closes on the contact frame       |
      | Spin reads on a rolling object                                                 | [added, E3 baseline pinwheel]                                     | `roll_aliasing` ok: roll per frame under 360 / sectors                                                                                                                  | wedges turn forward on the frame sheet, no strobing                           |
      
      ## 2. Timing and spacing
      
      | Look at                                                                             | Source                                        | Measure                                                                 | See                                    |
      | ----------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------- |
      | Floaty, "too even", "too linear", slow drift                                        | Rik hnHemwjznPc [01:53:23], [02:18:07]        | `motion_report` moves with "constant spacing"; long moves with no holds | playblast feels driven by the computer |
      | Holds read and stay put (6 frames to read an idea)                                  | Alex [00:32:48]; Rik [00:13:36]               | `hold_drift` ~0 on dead holds, small on moving holds                    | graph plateaus flat                    |
      | Snaps: 2 to 3 frames after a hold; aggressive moves 4 frames, not 8                 | Rik [00:20:44]; Alex [01:03:42]               | `transitions` lengths                                                   |                                        |
      | Reaction about 6 frames after its stimulus                                          | Rik NEZVF93fKsM [00:30:26]                    | key-frame difference                                                    |                                        |
      | The part that should draw the eye moves fastest at the end of its move              | Alex [00:50:55]                               | `peak_at` near 1.0 for hands or face; `last_ratio` high                 |                                        |
      | Snappiness survives splining                                                        | Hjalti fbfODZ8PO4E [00:39:15]                 | compare transition lengths blocking vs spline                           | progression side by side               |
      | Spacing tricks felt, not seen (burst, chug); only after timing and weight are final | Raymond Luc fbfODZ8PO4E [00:04:19]-[00:05:36] | `format_report` spacing chart before and after                          |                                        |
      | Unintended overshoot                                                                | Alex [00:42:54]                               | `overshoots` lists only wanted settles                                  | no bulge on holds in `graph_image`     |
      
      ## 3. Arcs and paths
      
      | Look at                                                                 | Source                                    | Measure                                                                                                                                                                                                                                                                                                                                 | See                                       |
      | ----------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
      | Arcs on root, hands, feet, nose, in camera space                        | Pablo fbfODZ8PO4E [00:16:04]              | `motion_report(to_camera(..., res=(w, h)), dims=2)`: no `arc_wobble` inside a move (0..1 coordinates squeeze x and add false sharp turns at apexes, measured)                                                                                                                                                                           | tracks in the onion skin are clean curves |
      | Starts and stops ("ticks right then down")                              | Rik hnHemwjznPc [00:55:08]                | `start_stops` empty inside intended moves                                                                                                                                                                                                                                                                                               |                                           |
      | Sharp direction changes only at contacts and hits                       | [added]                                   | `sharp_turns` frames = contact frames                                                                                                                                                                                                                                                                                                   |                                           |
      | Pops                                                                    | Rik sRYVR92FqSw [02:07:49]                | `pops` only where a snap is intended                                                                                                                                                                                                                                                                                                    |                                           |
      | Silhouette aligned with the motion path; every part points the same way | Picaut yIPMxxTueIM [00:12:59], [00:10:14] | limb direction vs path tangent (dot close to 1) [added metric]                                                                                                                                                                                                                                                                          |                                           |
      | Airborne body follows a parabola                                        | [added, physics]                          | `ballistic_fit`: `g_ratio` near 1 at real scale, small residual. `g_ratio` compares the ACCELERATION (9.81/fps^2 = 0.01703 m/frame^2 at 24 fps); a raw quadratic fit's leading coefficient is half of it (-0.00852): comparing that to g is a factor-2 false failure. On a ball, every flight has the same ratio (one gravity per shot) | the jump does not float                   |
      | Arcs relative to the body for in-place motion                           | Nacho de Andres M2J_fQNLDfg [00:22:16]    | subtract the root path before `motion_report`                                                                                                                                                                                                                                                                                           | `onion_skin(spread=...)`                  |
      
      ## 4. Weight, contacts, physics
      
      | Look at                                                | Source                                                   | Measure                                                                                                               | See                                |
      | ------------------------------------------------------ | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
      | Planted feet do not slide                              | Joey [00:29:16]; digest threshold about 1 mm             | `foot_slip` slide < 0.001 m                                                                                           |                                    |
      | Contacts slide with the body, do not glue, do not lift | Rik hnHemwjznPc [00:28:00]-[00:30:12]                    | elbow or hand height vs surface, small slide allowed                                                                  |                                    |
      | Rotation happens at the physical pivot                 | Rik [01:39:49]                                           | contact point displacement ~0 (`world_pose(pivot=...)`)                                                               | glass tips on its base             |
      | Force fits the character's state (tipsy, not drunk)    | Rik [01:48:09]                                           |                                                                                                                       |                                    |
      | Weight shift raises the loaded shoulder                | Rik [00:46:41]                                           |                                                                                                                       |                                    |
      | Weight over the support foot on held poses             | Alex p8Bi7k60IS0 [00:30:06]-[00:34:26]; digest checklist | `balance_report`: margin >= 0 on held poses and settles (locomotion may be outside: the fall that drives the step)    | the body would not tip over        |
      | Knees and elbows never snap straight                   | [added]                                                  | `limb_extension`: ratio < 0.99 while meant bent, never > 1.0 (IK stretch); fast bends near straight listed for review | no knee pop at push-off or landing |
      | Body drops faster than it rises ("shark fin")          | Joey [00:40:15]                                          | frames from up to down < frames from down to up                                                                       |                                    |
      | Landing is not a dead stop; hard contacts use VECTOR   | [added, measured]; Alex [01:09:31]                       | speed at the contact frame; foot's last gap before contact                                                            |                                    |
      | No penetration                                         | Leo [00:15:56] (polish)                                  | `lowest_point` never below the floor                                                                                  |                                    |
      | Limb stretch only if the camera shows it               | Rik [01:27:53]                                           | shoulder-to-wrist length vs rest, projected                                                                           |                                    |
      
      ## 5. Overlap, secondary, attention
      
      | Look at                                                                       | Source                                 | Measure                                                | See                      |
      | ----------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------ | ------------------------ |
      | Parts offset, never twins; feet offset by a frame                             | Rik jLkc4EgHWj8 [00:31:30]; Alex       | child transitions start 1 to 2 frames after the parent |                          |
      | Head never rotates on one axis only                                           | Rik NEZVF93fKsM [00:27:56]             | head rotation keys change more than one component      |                          |
      | Fingers stiff: little overlap, no bounce                                      | Rik zhktftWbumE [00:29:24]             | no overshoot on finger channels                        | hands do not look broken |
      | Secondary feels connected to the body                                         | Rik hnHemwjznPc [00:44:21]             |                                                        |                          |
      | Nothing competes with the face (elbow, tongue, twitches)                      | Rik [00:53:58]; sRYVR92FqSw [02:06:08] |                                                        |                          |
      | Ears, eyes, tails alive after the spline                                      | Leo [00:15:56]                         |                                                        |                          |
      | Behavior matches the species (head dart, ears before head, tail up on a jump) | Leo [00:20:15]                         |                                                        |                          |
      
      ## 6. Squash, stretch, smears
      
      | Look at                                                     | Source                                 | Measure                                 | See                      |
      | ----------------------------------------------------------- | -------------------------------------- | --------------------------------------- | ------------------------ |
      | Shape change reads, volume plausible                        | Rik jLkc4EgHWj8 [00:34:36], [00:44:13] | product of scales ~1 on squash controls |                          |
      | Squash frame actually shown                                 | [added, measured]                      | contact on a whole frame                |                          |
      | Smears 1 frame (2 at most), along the arc between the poses | Pablo [00:10:19]; Picaut [00:24:07]    |                                         | felt, not seen, at speed |
      
      ## 7. Face and lip sync
      
      | Look at                                                                                       | Source                                             | Measure                                                     | See                            |
      | --------------------------------------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------- | ------------------------------ |
      | Eyes dart in 1 to 2 frames, lead the head; head follows a little                              | Rik NEZVF93fKsM [00:05:43]; hnHemwjznPc [01:57:16] | `transitions` on eye controls                               |                                |
      | Eye line hits the target on camera, not wall-eyed or cross-eyed                               | Rik NEZVF93fKsM [00:06:49], [00:33:19]             |                                                             | close-up still from the camera |
      | Lids: resting on the iris relaxed, more white intense, never below half the iris when annoyed | Rik [00:21:36], [00:43:44]                         |                                                             | close-up still                 |
      | Brows 3 to 10 frames, inner brow more than outer, on a diagonal                               | Rik [00:07:22], [00:01:43]                         | `transitions` on brow controls                              |                                |
      | Straight lines read tension, curves relaxation                                                | Rik [00:13:38], [00:35:01]                         |                                                             | mouth and lid shapes           |
      | Symmetric for involuntary expressions, asymmetric for voluntary ones                          | Rik [00:09:08]                                     | left/right control difference                               |                                |
      | Jaw rhythm first; big opens only on accents                                                   | Rik sRYVR92FqSw [00:04:44]                         | `accent_frames`: big jaw opens clearly fewer than syllables | no chatter at speed            |
      | Shapes prepared before the sound (F, OO, W, closures)                                         | Rik [00:25:12]                                     | shape reached at or before the phoneme start                |                                |
      | M, B, P closed at least 2 frames, closed before the sound; milked a little                    | Rik NEZVF93fKsM [00:08:34]; sRYVR92FqSw [00:38:38] | `transitions`: closed hold >= 2 frames                      |                                |
      | Corners never dead center; arc between shapes; asymmetry in small doses                       | Rik [01:34:56], [01:37:11], [01:42:45]             | corner controls never at rest during dialogue               |                                |
      | Shapes not too big for connected speech; pops blend                                           | Rik [01:08:20], [02:07:49]                         |                                                             |                                |
      | Tongue seen, not shown (N hidden, R back, T/L up)                                             | Rik [01:52:45]-[02:06:08]                          |                                                             |                                |
      | Face stepped until the body is splined                                                        | Rik hnHemwjznPc [00:15:42]                         | face keys CONSTANT during body spline                       |                                |
      
      ## 8. Technical hygiene
      
      - Slot assigned and named after the character (module talk ypWk6ZgQ-iM [00:07:35]); `ad.action_slot is not None`.
      - Before approval all key poses CONSTANT and typed (EXTREME, BREAKDOWN, MOVING_HOLD); key types as a confidence map (Hjalti fbfODZ8PO4E [00:46:30]).
      - No partial key columns on main controls (Rik keys every main control together, sRYVR92FqSw [01:48:55]).
      - No Euler jumps over 180 degrees between keys, no quaternion sign flips between keys (digest checklist): `rotation_flips` empty, `fix_rotation_flips` repairs on keys; quaternion rigs keyed as quaternions.
      - Cycles: Cycles modifiers on all cyclic channels, equal values at the seam, root REPEAT_OFFSET with LINEAR keys (Joey [00:32:39]).
      - fps matches the medium (24 unless told otherwise); `use_simplify` on only for previews.
      
      ## Staging thresholds: calibration [added]
      
      Measured with `staging_report` on the E3 evaluation scenes (heavy ball, 72 frames, 1280 x 720):
      
      | Run                                                                                                                                                                                                                                                                                                                                                                                                                              | Subject size (median) | Action covers | Top empty | Entry      | Shadow airborne / contact                               |
      | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ------------- | --------- | ---------- | ------------------------------------------------------- |
      | E3 with skill (reviewer: weak staging)                                                                                                                                                                                                                                                                                                                                                                                           | 10.4%                 | 73% x 45%     | 34%       | partial    | faint (0.043 darkening) / hidden behind the ball (0 px) |
      | E3 baseline (reviewer: better staging)                                                                                                                                                                                                                                                                                                                                                                                           | 9.8%                  | 74% x 49%     | 17%       | off-screen | visible, 71 px gap / visible, 0 gap                     |
      | v2 ball shot (test_ball_shot.py)                                                                                                                                                                                                                                                                                                                                                                                                 | 17.2%                 | 73% x 55%     | 27%       | off-screen | visible, 104 px gap / visible, 0 gap                    |
      | The E3 note "about 40 px" was the 50% playblast; at full size the with-skill ball was 75 px, the same size as the baseline's. What separated the two runs was the entry, the empty top of frame and a contact shadow hidden behind the ball, which is what the flags now catch. The 12% size floor sits just above both E3 runs so a single-subject shot is pushed to reframe; lower it for a wide establishing shot on purpose. |
      
      ## Severity and phrasing
      
      - Order of importance: intent and staging, then timing, then arcs and weight, then overlap, then polish. Do not polish what is not approved (Rik: "animate drunk and edit sober", jLkc4EgHWj8 [00:27:21]; Pablo: show broken work early, fbfODZ8PO4E [00:47:04]).
      - Write notes the way these animators give them: a frame range, what the audience sees, the principle, the fix. Example: "f10 to f13, hand: sharp turns at 11 and 12 and a pop 11 to 12, reads as a jerk; the swing needs a breakdown through the bottom of its arc at f11 (`breakdown` with the hand favored late)". Rik's vocabulary: floaty, too even, starts and stops, pops, drift, connected, too big, lazy, early, late.
      - When keys fight, delete down to the essentials and look at the clean interpolation before adding more (Rik hnHemwjznPc [01:05:47]).
      - Report honestly: say which checks were measured, which were judged on renders, and what could not be verified (no audio, no reference, GUI-only feel).
      
    • expert-notes.md 26.4 KB
      # Expert notes: animation (by expert, with sources)
      
      Video ids and timestamps point to `references/sources.md`. Claims are the expert's unless marked [added] (my addition, measured or reasoned) or [verified] (checked in Blender 5.2.1). Most tutorials were recorded on Blender 2.93 to 4.1: UI details are translated to 5.2 at the end.
      
      ## Rik Schutte (Blender Studio animator; Sprite Fright, Wing It director; earlier Smallfoot, Spider-Verse)
      
      Sources: jLkc4EgHWj8 (BCON22 cube show), NEZVF93fKsM (BCON23 facial), zhktftWbumE (Snow acting recap), hnHemwjznPc (acting live pt.3, splining), sRYVR92FqSw (acting live pt.4, lip sync).
      
      **Workflow**
      
      - Explore the rig first; a new production starts with a week of rig learning plus pose libraries and a character bible of do's and don'ts (jLkc4EgHWj8 [00:47:02]). Plan constraints (hand resting on the bar, glass pick-up) before blocking or you fight your setup later (zhktftWbumE [00:02:34]).
      - Blocking 1: the main beats with the torso bone only, "like playing with a Lego puppet", to test rhythm and energy before posing anything else; do not push that base bone too far (zhktftWbumE [00:03:42]-[00:04:48]).
      - Blocking 2: decide what leads each beat (brows here, head there) and put it in the blocking as notes to self (zhktftWbumE [00:05:43]). Start the shot in the middle of an action, not waiting for the line [00:05:21].
      - Key everything (Location, Rotation, Scale, Custom Properties) on every key so holds lock to a frame (jLkc4EgHWj8 [00:26:47]; hnHemwjznPc [01:15:50]); 398 keys on frame 1 of the Snow rig (hnHemwjznPc [00:18:29]). Exception: adding a one-channel overlap to a splined curve, where keying everything damages the other curves [02:23:37].
      - "Animate drunk, edit sober" (jLkc4EgHWj8 [00:27:21]). Show rough work early [00:41:12]. Blend to Neighbor is his tween machine, "multiple times per minute" [00:28:34]; restricted to one channel and one axis it is "breakdowner 2.0" (hnHemwjznPc [00:29:06], [02:43:31]).
      
      **Blocking to spline** (hnHemwjznPc [00:14:12]-[00:23:36]; zhktftWbumE [00:09:42]-[00:18:58])
      
      - Select all and switch to Bezier is the classic mistake: "floaty spaghetti", hard to recover. "The preparation of going into spline is the most important thing."
      - Separate face and body: hide face and finger controls, spline only the body; the face stays stepped because lip sync redoes it. Hide unused controls too.
      - Pillars: for every pose that should hold, key all controls again at the end of the hold. Big deliberate moves are left to interpolation; snappy moves hold then move in about 3 frames (held to 44, arrives at 47). "Instead of going from here to here which creates a slow floaty movement, you want to hold the key and then it's moving faster" [00:20:44]. Rule of thumb 2 to 3 frames, "a bit arbitrary" (sRYVR92FqSw [01:26:43]).
      - A sub-part (head, hand) can have its own holds; put a head key a couple of frames before its next pose to avoid float [00:21:18].
      - After splining: fix wobbly arcs and IK/FK space-switch pops per move [00:23:36].
      - Verified in 5.2.1 [verified]: with AUTO_CLAMPED handles a pillar gives an exactly flat hold and the move completes in the chosen frames; without it the value drifts from the start of the interval.
      - [added, measured] A contact inside continuous motion (landing of a jump) is not a hold; pillaring it brakes the body to almost zero speed at impact.
      
      **Contacts, pivots, texture** (hnHemwjznPc)
      
      - Contacts slide, they do not glue and do not lift: a leaning elbow slides a little with the body; a lifted elbow "is not a resting pose" [00:28:00]-[00:30:12]. If it slides in too early, add a not-fully-arrived breakdown [00:31:54].
      - Weight shift raises the loaded shoulder and hides limb stretch [00:46:41].
      - Pin with a Copy Location (not Copy Transforms) to a helper bone at influence 0.5 for a little give, key influence to 0 where contact breaks, when manual keys fight the IK pole and stretch [01:19:52]-[01:26:06]. Toggle it off to see the unconstrained pose [01:34:48].
      - Rotate around the physical contact point (3D cursor as pivot) for a glass tipping on a table, then clean translation with Blend to Neighbor [01:39:15]-[01:46:59].
      - Remove starts and stops ("ticks to the right and then goes down"); a staccato move needs anticipation [00:55:08]. Delete down to essentials when keys fight: "don't be afraid to delete keys" [01:05:47]; sRYVR92FqSw [00:34:23].
      - Attention hierarchy: the face is where the audience looks; an elbow must not steal it [00:53:58]. Stretch the camera does not show is acceptable [01:27:53]. Only the camera view matters on an acting shot, "it's not a video game" (zhktftWbumE [00:27:32]).
      - Texture = small rhythm inside beats: the head rises and slows while the eyes move faster; head dip on the stressed word, shifted a frame; arrive a couple of frames early into a pose to get a resting moment [02:04:58], [02:20:26], [02:38:02]. Do not over-texture the body before lip sync exists [01:55:35].
      - Fingers: little overlap, no bounce; the thumb barely drags; floppy hands look broken (zhktftWbumE [00:29:24]).
      - Blocking-only is valid by style: many Sprite Fright shots were on twos (about 12 fps) and never splined; they are just harder to adjust later [01:30:18].
      
      **Face** (NEZVF93fKsM)
      
      - Anatomy first: frontalis pulls the brow on a diagonal (ask riggers to pre-tilt brow bones) [00:01:43]; corrugator pulls inward; muscles fire together; raising one brow fires part of the other [00:03:58]; volume is kept (lips thin when spread) and a smile pushes the cheeks into the eyes [00:04:34].
      - Eyes: the fastest muscle, dart in 1 to 2 frames; slower reads floaty; eyes stagger in steps when scanning unless tracking a moving object [00:05:43]. Lids follow the cornea bulge [00:06:17]. Eye direction through the camera is an illusion: pose graphically, avoid wall-eyed or cross-eyed reads [00:06:49]; re-aim the far eye after a head turn [00:33:19].
      - Brows: 3 to 10 frames; inner brow moves more; a slight inner curl already reads worried [00:07:22].
      - Lip sync: blend phonemes, do not chatter; M, B, P at least 2 frames closed, closed before the sound [00:07:58]-[00:08:34].
      - Appeal is readability, not attractiveness [00:08:34]. Involuntary expressions tend to be symmetric, voluntary ones allow asymmetry [00:09:08]. A big mouth opening pushes up the lower lids [00:09:42].
      - Closed eyes: U curve relaxed, reversed curve intense [00:13:38]; pressure needs straight shapes [00:15:21]; straight lines tension, curves relaxation [00:35:01].
      - Upper lid resting on the iris reads relaxed (the fix for a Spring smile) [00:21:02]; for annoyed or skeptical looks never below half the iris or it reads sleepy [00:43:44].
      - Sub-beats: notice, react, realize, respond, each shown [00:22:43]. Reaction about 6 frames after the stimulus, "that's where it enters the brain" [00:30:26]. Never rotate the head on one axis only [00:27:56]. Go a couple of frames past the extreme and push, then settle [00:34:26]; push the whole face on the settle key for a pop [00:37:20]. Blend to Neighbor on rotation only, eyes closed about 80% in a sigh [00:46:39]. Block overlap and offsets into the blocking itself [00:47:54].
      - Pose library: poses never include the eyes or the head hinge and body, so applying one keeps the eye line [00:32:11].
      
      **Lip sync passes** (sRYVR92FqSw; zhktftWbumE [00:19:34]-[00:28:04])
      
      1. Jaw only, stepped: "like animating a muppet" [00:08:21]; open a little before the first word so the inhale reads [00:06:39]; a breath gets a small opening [00:17:41]; fast syllables blended, not hit [00:30:28]; some passages are "a lot with the lips instead of the jaw" [00:20:57]. The most common mistake is opening the jaw on every beat [00:04:44].
      2. Corners (width): in when the jaw opens, out again; Relative X-Mirror, or both sides by hand when mirroring fights existing keys [00:39:13]-[00:44:36].
      3. Shapes from the pose library on the lower face minus the jaw, dialed back (a pronounced F is too big in a sentence; no "full M") [01:07:03]-[01:13:22].
      4. Accents and design: corners never dead center (favor up or down), arc corners between shapes, subtle asymmetry and a sneer for personality (overdone reads drunk), register fast words without accenting them [01:34:56]-[01:44:01].
      5. Tongue: one middle control first; R toward the back, N hidden against the roof so it does not read as L, T and L against the upper teeth; delete flashy flicks [01:51:41]-[02:06:08].
      6. Then spline the face; polish contacts, fingers, fleshy squash in the face [02:10:50].
      
      - Off-sync: "don't simply offset your lip sync but look at the preparation of your phonemes" [00:25:47]. Milk M, B, P, F a little: "a pop of air that ties the beats together" [00:38:38]. Big opens need jaw translation, not only rotation [00:11:45]. Keep the smirk from blocking; a neutral mouth reads too serious [00:45:07]. He gives no fixed global lead in frames.
      - Stylized characters: use the accepted appealing mouth shapes; copying reference frame by frame reads like bland mocap [00:50:36].
      
      ## Hjalti Hjalmarsson (Blender Studio veteran animator; many open movies)
      
      Sources: fbfODZ8PO4E (panel), eOxVdBgoehk (reference).
      
      - Pipeline: Planning, Blocking, Blocking+, Splining, Polish; long planning and blocking, very short splining, modest polish. Junior anti-pattern: short blocking, long splining, very long polish: "everything swimming", hard for feedback (fbfODZ8PO4E [00:37:25]-[00:40:56]).
      - "Please stop splining too early... you'll get these poses in between that are coming from the computer and not from you" [00:38:08]. He once handed off a shot on ones still stepped underneath [00:39:48].
      - Key-type colors as a confidence map: Extreme (pink) for poses he is sure of, Breakdown (blue), Jitter (green); uncolored = in progress [00:46:30].
      - Reference is a rehearsal and a conversation with the director; blind copying gives a rotoscoped look; never use other people's finished animation as reference (eOxVdBgoehk [00:00:38]-[00:04:20]).
      - Listen until "your ears bleed", write dialogue phonetically with emphases; write the beats down [00:06:34]-[00:07:42]. Shoot at 24 fps, tripod, fitted contrasting clothes, simple light, mimic the shot's camera angle [00:07:58]-[00:11:19]; full body for mechanics, medium shots for face [00:11:19]. All beats present in order (about 8, up to 20) [00:13:40]. Do the real mechanics (a heavy box, real sawing) [00:14:14]. Perform open to camera [00:15:19]. 3 to 5 takes per recording [00:18:26]. Edit the reference with the layout inset and a frame counter [00:19:32].
      
      ## Pablo Fournier (Blender Studio animator)
      
      Source: fbfODZ8PO4E.
      
      - Plan with a rough Grease Pencil animatic of a simple shape (a ball with two legs) to find timing and beats before posing the rig [00:06:47]. (Grease Pencil planning: scenario-blender-grease-pencil.)
      - Pose library in preproduction: an animation default pose (not the rest pose), emotions at maximum range so mixing has room, lip-sync shapes if needed, hands included, pose one side at 3/4 then mirror [00:12:00]-[00:13:42].
      - Blocking+: Blend to Neighbor for delay, root favoring pose B while an arm favors pose A [00:15:27]. 20/80 rule: slow-in in-betweens at 80% of the remaining way, slow-out at 20% [00:15:27].
      - Arcs are the most important check (root, hands, feet, nose), in camera view as a 2D plane [00:16:04].
      - Smears: lines, multiples, blobs, shapes; follow the arc between the poses before and after; colored like the part; small far from the object, big near it; 1 frame, sometimes 2: "you need to feel, not see" [00:09:05]-[00:10:52]. Stylized shows only.
      - Do not fall in love with your poses; show broken work early [00:47:04].
      
      ## Raymond Luc (DillonGoo Studios; Mandalorian, Ahsoka)
      
      Source: fbfODZ8PO4E.
      
      - Burst spacing: make the frame just before contact stick to the previous frame so the gap to contact is large; the brain reads the missing data as a harder hit; Free handles [00:04:31]. [verified] `bouncing_ball(burst=0.6)` widened that gap 1.5x.
      - Flatten handles for a mechanical chug, after timing and weight are final [00:05:03]. "The goal is to have the audience feel it but not see it" [00:04:19].
      - Proportional editing in the Graph Editor sculpts many keys at once, on mocap and baked cycles [00:02:50]. Mouse muppeting (real-time recording of mouse moves) for rough passes and handheld cameras [00:01:41].
      
      ## Dillon Gu (founder, DillonGoo Studios)
      
      Source: fbfODZ8PO4E.
      
      - Lead with the action bone: COG for body mechanics, head or eyes for acting, hands for a cartwheel; layer the rest as "a conversation between bones" [00:27:37].
      - Layered body mechanics (COG arc, legs, arms) as an alternative to pose to pose [00:27:37].
      - Stair-step: drag each FK chain link's keys a frame or so after its parent for tails, ears, hair, simple cloth [00:29:45].
      - Vector handles on a bouncing ball's contacts from the Dope Sheet; the Bounce easing with two keys ("don't tell your teachers") [00:30:52]-[00:31:58]. [verified] Both are shortcuts: VECTOR contacts deviate up to 0.09 m from a parabola on a 1.2 m drop, and Bounce easing puts its contacts between frames so no squash frame is ever shown.
      - He worked six years with the Dope Sheet and handle types only [00:30:16].
      
      ## Tony Garcia (DillonGoo; animation, rigging, action design)
      
      Source: fbfODZ8PO4E.
      
      - Pivot tricks: Active Element to rotate IK limbs like FK; pivot a flip around the chest; "Only Locations" to widen a stance or scrunch brows without rotating controls [00:19:55]-[00:22:39]. [added] Headless equivalent: `world_pose(pivot=...)`.
      - Depth sells action: 35 mm and below; roughly 50 to 60% of dynamic motion in action scenes goes toward or away from the camera; fake depth with limb scale if the lens is locked [00:23:13]-[00:24:19]. (Lens choice itself: scenario-blender-previs-storyboard.)
      - Simplify with viewport max subdivision 0 took a production scene from 5.05 to 24 fps [00:17:43].
      
      ## Alex Nagy (character animator, "Alex on Story")
      
      Source: p8Bi7k60IS0.
      
      - Start from "how do I want the audience to feel?" [00:01:06]. Key poses are the ones held longest; work all poses roughly together [00:04:18]. Formula: key pose, breakdown, settle, repeated; optional blending after approval [00:08:40].
      - Setup: camera view clean, passepartout 0.99, audio waveform in the VSE, scrubbing on, end frame = audio length [00:09:43]-[00:15:15]. Key Location and Rotation only [00:20:22].
      - First the strongest, longest-held pose, then the biggest extreme away from it; work backwards when the ending is the anchor [00:19:17]. Build COG, feet (offset, weight under them), hips, chest, arms, hands; drag built into the pose (wrist bent back) [00:21:26]-[00:32:16]. Hit the pose just before the audio spike [00:24:09].
      - Flip between poses; he increased travel after flipping showed too little contrast [00:27:22]. Holds: copy the pose 6 frames later (7 to 13) [00:32:16]. Overshoot pose after a big extreme [00:38:15].
      - Push a pose with the computer: two identical keys spaced apart with Automatic handles overshoot; take the pushed frame [00:42:54]. [verified] AUTO overshot a 4.0 hold to 5.0; AUTO_CLAMPED stayed flat.
      - Key poses on odd frames, even spacing between them, so middle frames exist [00:45:05]. The breakdown defines the arc first, the timing second; COG dips in the middle over the support leg; feet: extend contact, peel heel then toe; delay arms, hands and head; the later the breakdown favors the end, the faster that part finishes and the more the eye goes there [00:44:32]-[00:50:55], [01:11:43]. Push past a pose in the breakdown for energy; snap into an overshoot with a Vector key for a harder hit, then Auto Clamped into the final pose [01:09:31].
      - Settles: a key inside the hold with Automatic handles gives a moving hold; 1 to 2 frames usual, 4 for soft [00:52:34]-[01:03:08]. Aggressive change: 4 frames instead of 8 [01:03:42]. TV style stops at pose, breakdown, settle; style is set by pose aggression and the number of in-betweens (4 vs 12) [00:53:40].
      - Blending pass (optional): keys one frame after each pose, delete the original poses (keep the foot timing) so the body never stops; only after approval [01:13:45].
      - Face: eyes pose to pose with blinks as breakdowns; mouth layered: jaw through the dialogue, then corners, then shapes, all on the jaw's timing [01:18:08].
      
      ## Joey Carlino (Blender educator)
      
      Source: nRtT7Gr6S2o (Blender 2.93).
      
      - One step then flip; mirror the full cycle only at the end [00:08:45]. 30 fps (his habit), step 20 frames, cycle 40; first key on frame 0 so frame 10 is the middle [00:07:10]-[00:08:45].
      - Contact 0: front heel down, leg almost straight, body centered [00:05:04]. Down 5: hips lowest, front foot flat, back toe past 45 degrees [00:10:43]. Passing 10 [00:09:24]. Up 15: hips highest, back leg almost straight [00:12:38]. Hips rotate toward the lowest foot and yaw with the forward leg; shoulders counter [00:13:52]. Arms last (FK for arcs), opposite arm to leg [00:15:24]-[00:19:49].
      - Key only needed channels for a clean graph [00:09:24]. Head rotation lag 2 to 3 frames, more looks odd [00:24:41].
      - Slip-free: the planted heel's forward curve must be straight during contact (Vector, linear, or his Free handles with zero-length contact-side handles); the root travels exactly the heel's distance, linear, Cycles Repeat with Offset [00:25:25]-[00:33:41]. [verified] AUTO_CLAMPED contact keys slipped 5.8 cm per step, VECTOR 0.0.
      - Heel drifts back a little as it lifts (overshoot) [00:36:10]; the body drops faster than it rises, "shark fin" [00:40:15]; variations: turned-out feet, deeper downs, inverted bob for sneaking [00:38:39].
      
      ## Leo Silly-Pelissier (animation director, Flow)
      
      Source: fxz6p-QATfs.
      
      - Naturalistic, not realistic: real behavior with "a touch of freedom and randomness" [00:08:14].
      - Four steps validated one by one: Block A (intention: key poses, expressions, shifts, constraints and paths set, references shown), Block B (transitions, library actions, cycles; no notes on intent after this), Spline (re-inject energy: timing, overshoot, desynchronized parts, variation, ears, eyes, tails, because splining softens), Polish (bugs, penetration, tails; little change) [00:13:39]-[00:17:06].
      - Poses must work from every direction: the camera moves and the director may re-frame after animation [00:25:18]-[00:26:26].
      - Behavior rules written down: cats turn the head (a "head dart") rather than look sideways with the eyes; ears move before the head; tail rises on jumps [00:20:15]. Rig controls for shape variation rather than animation layers [00:33:50]. Action library per character in the asset browser [00:23:29]. Long shots: split at moments without characters or during camera moves; the lead character animated furthest ahead [00:36:40]. 2 s per animator per day; average shot 380 frames [00:05:59].
      
      ## Pierrick Picaut (art director, lead rigger and animator, Noara)
      
      Source: yIPMxxTueIM.
      
      - Cinematics can cheat for the camera; gameplay cameras cannot, especially top-down [00:02:46]-[00:05:14]. Gameplay timing wins: release within about 1/3 s, hits every 5 frames from frame 15; freedom lives in recovery [00:06:24]-[00:07:30].
      - Micro-anticipation: push the last 1 or 2 frames before a hit or release to an extreme [00:09:42]. Directionality: every part points the same way [00:10:14]. Deaths snappy: "the first frame is that you're dead" [00:10:47].
      - Align the silhouette with the motion path, checked from several camera angles; motion paths "all the time" [00:12:59]-[00:14:40]. Fit a move to a simple shape (a circle for a spin) [00:15:13]. Stretch limbs toward the target and the landing point [00:16:18].
      - Smears during polish: stretch toward the previous frame, whip joints, bend rigid props along the arc [00:24:07]-[00:30:35]. Locomotion first: contacts linear, match ground speed [00:05:49]. Export dense baked keys, one NLA strip per clip [00:31:08]. He regretted 30 fps for cinematics [00:51:24].
      
      ## Nacho de Andres (game animator; Blizzard, Manor Lords; tool developer)
      
      Source: M2J_fQNLDfg (BCON26).
      
      - Dense (baked, mocap) animation edited by propagating a pose change over a frame range with a falloff (10 to 50 frames) instead of re-keying [00:18:58]-[00:23:57]; procedure P11.
      - Arcs judged in space and relative to the body ("wormhole": past left, future right) [00:22:16]; `onion_skin(spread=...)`.
      - Why native motion paths are slow: the depsgraph evaluates one point in time per cycle [00:04:31]; the Graph Editor is an abstraction from before real-time paths [00:30:03].
      
      ## Animation and Rigging module (Sybren Stuvel, Christoph Lendenfeld, Nathan Vegdahl)
      
      Sources: ypWk6ZgQ-iM (BCON25), TxqnoafBdzg (BCON26).
      
      - Slots: one action can animate several IDs, each with its own channelbag; name slots consistently (the character's name) because Blender picks slots by remembered name; it deliberately does not auto-assign an action's only slot to a new user (ypWk6ZgQ-iM [00:07:35], [00:13:12]). Slots are not for layering [00:15:29].
      - Layered actions exist in the data model but are not exposed; use the NLA until layers reach parity (TxqnoafBdzg [00:11:49], [00:45:39]). 5.2 adds in-between tools in Object Mode, Graph Editor local view `/`, playback loop modes (TxqnoafBdzg [00:04:28]-[00:10:08]); 5.3 (not 5.2) adds paste-pose blending, rotation-mode conversion of animation, range world-space copy.
      
      ## Disagreements and the deciding condition
      
      | Choice                | Position A                               | Position B                                                                                   | Decide by                                                                                                      |
      | --------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
      | Which views must work | camera only (Rik, Picaut cinematics)     | all directions (Leo, Picaut gameplay)                                                        | locked final camera or not                                                                                     |
      | Blocking method       | stepped pose to pose (Hjalti, Rik, Alex) | layered COG first (Dillon); sculpt dense data (Nacho)                                        | acting shot for a director vs solo body mechanics vs editing mocap                                             |
      | When to spline        | as late as possible, even never (Hjalti) | body after pillars, face stepped (Rik); Bezier blocking with Automatic pushes (Alex)         | spline once notes on intent stop (Leo)                                                                         |
      | Graph Editor          | heavy use (Raymond, Tony, Joey)          | Dope Sheet and handle types only (Dillon); never, edit paths (Nacho)                         | curve-shape problems (spacing, cycles, heel curves) need it; blocking and retiming do not                      |
      | What to key           | everything, every time (Rik)             | needed channels only (Joey, Alex)                                                            | stepped blocking and holds: all; cycles and curve work: minimal; one-channel overlap on a spline: that channel |
      | Arms in cycles        | FK for arcs (Joey, Alex)                 | IK moved like FK with a pivot (Tony)                                                         | FK when arcs matter, IK when hands contact things                                                              |
      | Frame rate            | 24 (Hjalti, Rik)                         | 30 (Joey habit, games)                                                                       | the delivery medium                                                                                            |
      | Variation             | rig controls (Leo)                       | additive layers (module)                                                                     | 5.2 has no action layers: NLA ADD or rig controls                                                              |
      | Handles               | Automatic for pushes and settles (Alex)  | Auto Clamped holds, Vector hits (Alex, Dillon), Free burst and flat contacts (Raymond, Joey) | the job of that key                                                                                            |
      | Contact solution      | manual keys                              | Copy Location helper with animated influence (Rik)                                           | switch when keys keep fighting IK pole and stretch                                                             |
      | Reference for mouths  | reference for acting and beats (Rik)     | accepted appealing shapes, not reference (Rik, stylized)                                     | stylized character: library shapes                                                                             |
      | Mirror posing         | Relative X-Mirror                        | both sides by hand                                                                           | when existing per-side keys differ, go manual (Rik)                                                            |
      
      ## Production numbers
      
      | Item             | Value                                                                   | Source                        |
      | ---------------- | ----------------------------------------------------------------------- | ----------------------------- |
      | Big studio pace  | about 3 s of animation per week                                         | Rik sRYVR92FqSw [01:00:54]    |
      | Acting shot pace | 5 to 6 s per week (10 to 12 s shot in about 1.5 weeks)                  | Rik [01:01:26]                |
      | Flow             | 2 s per animator per day; average shot 380 frames; longest 6,606 frames | Leo [00:05:59], [00:38:23]    |
      | Reference        | 24 fps, tripod, 3 to 5 takes                                            | Hjalti [00:08:01], [00:18:26] |
      | Beats per shot   | about 8, up to 20                                                       | Hjalti [00:13:40]             |
      
      ## 5.2 translation of old UI in these videos
      
      - `I` keys the preference channels directly (Location, Rotation, Scale, Custom Properties by default, Rik's setup); `K` opens the keying menu; "Only Insert Available" is on by default in 5.2.
      - Up arrow = previous key, Down = next (reversed in 5.0).
      - Pose Mode Shift+E Breakdowner, Shift+Alt+E Blend to Neighbor (Rik's Shift+E is a custom binding); Graph Editor Blend to Neighbor exists.
      - Bone layers and groups are bone collections (4.0); the Bone Selection Sets add-on is not bundled in 5.2.1: use bone collections or name lists.
      - Pose library lives in the asset shelf (4.0); `poselib.apply_pose_asset(blend_factor=...)` needs an asset context.
      - Copy Global Transform is built in (5.0). Grease Pencil is v3 (4.3).
      - Scripts: slotted actions (4.4) and no `action.fcurves` (5.0); see the SKILL.md 5.2 notes.
      
    • procedures.md 40.4 KB
      # Tested procedures (Blender 5.2.1 LTS, headless)
      
      Every block here was run with `blender --background --factory-startup --python <test>` on 5.2.1. Test scripts (kept, rerun them after any change):
      
      - `tests/code/blender-animation/test_bx_anim.py` (58 checks: module, ball, measurement, layers, gravity units, flips)
      - `tests/code/blender-animation/test_rigify_jump.py` (19 checks: generated Rigify human, jump blocking to spline, knee pop, flips, balance, renders)
      - `tests/code/blender-animation/test_procedures.py` (15 checks)
      - `tests/code/blender-animation/test_staging.py` (17 checks: world squash vs the v1 rig, one gravity per shot, projection, staging_report, Workbench light and floor traps, pinwheel aliasing)
      - `tests/code/blender-animation/test_ball_shot.py` (11 checks: a full E3-style ball shot with the staging loop and the review pack)
      
      Blocks marked "verbatim" are copied from `# --- Pn begin/end` markers in those scripts.
      
      Renders from the last run are in `tests/code/blender-animation/out/` (ball onion skins, curve plots, jump pose strips, playblasts; `out/staging/` shadow probes; `out/ball_shot/` the full shot: playblast, contact sheets, onion skin through the camera, curve plot, .blend).
      
      Import pattern:
      
      ```python
      import sys
      sys.path.append("<skills>/scenario-blender-animation/scripts"); sys.path.append("<skills>/scenario-blender-expert/scripts")
      import bx_anim as A, bx_review
      ```
      
      ## bx_anim API (one line each; docstrings have the details)
      
      | Function                                                                                                                        | Does                                                                                                                                                                                  |
      | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | `ensure_action(idb)` / `channelbag(idb, create)` / `fcurves(idb, bones, exclude_bones, data_paths)`                             | slotted-action access; slot named after the ID; filter F-curves by bone or path                                                                                                       |
      | `key_frames(idb, bones, keytypes)`                                                                                              | frames that carry keys (optionally only key poses: `A.POSE_TYPES`)                                                                                                                    |
      | `key_pose(idb, pose, frame, keytype, interpolation='CONSTANT', handle)`                                                         | key a pose dict (object props, bone dicts, custom props, `{index: v}` single channels)                                                                                                |
      | `capture_pose(obj, bones)`                                                                                                      | current local transforms as a pose dict (pose library as data)                                                                                                                        |
      | `world_pose(obj, bone, translate, rotate, pivot, frame)`                                                                        | channel values for a world-space move or rotation about any pivot                                                                                                                     |
      | `set_interpolation(idb, interpolation, frames, bones, data_paths, handle, easing, keytype)`                                     | per-key interpolation / handle / easing on a frame range                                                                                                                              |
      | `fill_key_columns(idb, bones, frames, create_missing)`                                                                          | key every channel of the body on every key-pose frame                                                                                                                                 |
      | `auto_holds(frames, transition, interpolate, transitions, all_frames)`                                                          | hold intervals `pose -> next - transition`                                                                                                                                            |
      | `hold_copy(idb, holds, bones)`                                                                                                  | pillars: copy each pose to its hold end (never overwrites a key)                                                                                                                      |
      | `spline(idb, bones, handle)` / `blocking_to_spline(idb, body_bones, holds, transition, interpolate)`                            | Bezier on the body only; the full Rik conversion                                                                                                                                      |
      | `breakdown(idb, frame, prev, next, factor, bones, factors)`                                                                     | hand breakdown, per-bone favoring, quaternion slerp                                                                                                                                   |
      | `ease_inbetweens(idb, a, b, frames, mode='slow_in'/'slow_out', ratio=0.8)`                                                      | Pablo's 20/80 rule                                                                                                                                                                    |
      | `offset_keys(idb, frames, bones, data_paths)` / `add_cycles(idb, bones, data_paths, mode)`                                      | overlap offsets; Cycles REPEAT / REPEAT_OFFSET (replaces existing)                                                                                                                    |
      | `push_layer(idb, name, blend='ADD')`                                                                                            | NLA base strip + additive action on top                                                                                                                                               |
      | `hold_drift(idb, holds, bones)` / `overshoots(idb, bones)` / `transitions(idb, bones, data_paths)`                              | curve checks: dead holds, handle overshoot, move lengths between holds                                                                                                                |
      | `sample_world(obj, frames, bone, point)` / `motion_path(obj, f0, f1, bone)`                                                     | world positions per frame (sampling or Blender's own path)                                                                                                                            |
      | `lowest_point(objs, frames)` / `foot_slip(obj, bone, ranges)`                                                                   | floor contact and penetration; planted-foot slide and lift                                                                                                                            |
      | `to_camera(points, camera, res)`                                                                                                | camera-space projection; pass `res=(w, h)` for pixels before measuring angles (0..1 coordinates squeeze x by the aspect and invent sharp turns at apexes)                             |
      | `motion_report(points, frames, dims)` / `format_report(rep, table)`                                                             | spacing, velocity, holds, moves with ease shape, sharp turns, arc wobble, pops, start-stops; ASCII spacing chart                                                                      |
      | `ballistic_fit(points, frames)` / `gravity(fps)`                                                                                | parabola fit of an airborne path: `accel` (units/s^2), `accel_per_frame2`, `quad_coeff_per_frame2` (half the acceleration), `g_ratio`, residual; `gravity` gives the reference values |
      | `limb_extension(obj, chain, frames, threshold=0.99)`                                                                            | knee or elbow pop: extension ratio and bend per frame, straight, stretched (> 1.0) and fast-bend-near-straight frames                                                                 |
      | `rotation_flips(idb, bones)` / `fix_rotation_flips(idb, bones)`                                                                 | Euler jumps over 180 degrees, quaternion sign flips, turns over 90 degrees per frame; repair on keys                                                                                  |
      | `balance_report(objs, frames, floor_z, contact_tol)`                                                                            | COG (volume centroid) against the support polygon per frame: margin, airborne, off-balance frames                                                                                     |
      | `staging_report(objs, frames, camera, shadow_frames, out_dir)` / `format_staging(rep)`                                          | subject size in frame, action coverage, entry, end margin, cut frames, rendered shadow probe; plain-language `flags`                                                                  |
      | `playblast_lighting(key_dir)` / `floor_slab(...)`                                                                               | Workbench MATCAP look with a world-space key converted to camera space; closed floor                                                                                                  |
      | `rotation_texture(obj, sectors)` / `roll_aliasing(obj, frames, sectors)`                                                        | pinwheel with a marker wedge; wagon-wheel check                                                                                                                                       |
      | `squash_modifier(obj)`                                                                                                          | world-aligned volume-kept squash and stretch (GN) on the object itself; keyable paths                                                                                                 |
      | `audio_envelope(path, start_frame)` / `accent_frames(env, floor, min_gap, count)`                                               | per-frame loudness; syllable peaks and accents                                                                                                                                        |
      | `onion_skin(out, frames, objects, camera, view, track, highlight, spread)`                                                      | onion-skin contact image with projected tracks and frame numbers                                                                                                                      |
      | `graph_image(out, idb, bones, data_paths)`                                                                                      | curve plot with key-type colored keys (the agent's Graph Editor)                                                                                                                      |
      | `bouncing_ball(obj, weight, start, end, contacts, height, rebound, travel, first_apex, fit, hold, burst, contact_handles, ...)` | one-object ball animation: one gravity, whole-frame contacts, odd or even flights, world squash, roll = distance / radius                                                             |
      
      Conventions: a key's interpolation governs the segment after it. `key_pose` writes into the channelbag directly (no 3D view, no preferences); on an ADD layer the values are deltas. Never keep a Keyframe reference across an insert on the same F-curve.
      
      ---
      
      ## P1 Shot setup (verbatim, test_procedures.py)
      
      ```python
      sc.render.fps, sc.render.fps_base = 24, 1.0          # film/series; games often 30
      sc.frame_start, sc.frame_end = 1, 53
      sc.camera.data.show_passepartout = True
      sc.camera.data.passepartout_alpha = 0.99             # Alex Nagy: darken outside frame
      se = sc.sequence_editor_create()
      snd = se.strips.new_sound("dialogue", WAV, channel=1, frame_start=1)
      snd.show_waveform = True
      sc.use_audio_scrub = True                            # GUI sessions
      sc.sync_mode = "AUDIO_SYNC"                          # Rik: real-time playback with sound
      sc.render.use_simplify = True
      sc.render.simplify_subdivision = 0                   # Tony Garcia: judge at real speed
      sc.render.use_stamp = True
      sc.render.use_stamp_frame = True                     # frame counter on review renders
      sc.render.ffmpeg.audio_codec = "AAC"                 # playblast carries the dialogue
      ```
      
      Verified on 5.2.1. A sound-only sequencer does not replace the 3D render; the playblast MP4 had both a video and an AAC audio stream (ffprobe).
      
      ## P2 Stepped key-pose blocking on a rig (verbatim, test_rigify_jump.py)
      
      Pose in world terms, compute every pose from the rest pose before keying anything (no compounding, no stale evaluation), then key stepped with key type EXTREME. Forward is -Y for Rigify; `proxies` are the meshes to render, `OUT` an output folder.
      
      ```python
      X = lambda deg: Euler((math.radians(deg), 0, 0))    # world rotation about X (+ = lean forward)
      POSES = {  # frame: (torso offset, lean deg, arm swing deg (+ = back), foot (y, z)); forward is -Y
          1: ((0, 0, -0.03), 0, 0, (0, 0)),                # stand, knees soft (rest legs are 99.6% straight)
          9: ((0, 0.05, -0.28), 22, 45, (0, 0)),           # anticipation: crouch, arms back
          13: ((0, -0.10, -0.01), 12, -120, (0, 0)),       # push-off: extended, just short of straight
          20: ((0, -0.35, 0.42), 0, -150, (-0.30, 0.52)),  # apex centered in the air time, knees tucked
          27: ((0, -0.60, -0.02), 10, -80, (-0.60, 0)),    # landing contact
          31: ((0, -0.58, -0.30), 25, -55, (-0.60, 0)),    # absorb, deeper than the anticipation
          39: ((0, -0.60, -0.01), -3, -12, (-0.60, 0)),    # recover with a small overshoot up
          45: ((0, -0.60, -0.03), 0, 0, (-0.60, 0)),       # settle, knees soft
      }
      pose_dicts = {}                                      # every pose from the REST pose, before keying
      for f, (t, lean, swing, foot) in POSES.items():
          pose = A.world_pose(rig, "torso", translate=t, rotate=X(lean))
          for s, sign in (("L", 1), ("R", -1)):
              down = Euler((0, math.radians(70 * sign), 0)).to_matrix()   # T-pose arms down
              pose.update(A.world_pose(rig, f"upper_arm_fk.{s}", rotate=X(swing).to_matrix() @ down))
              pose.update(A.world_pose(rig, f"foot_ik.{s}", translate=(0, foot[0], foot[1])))
              pose[f"forearm_fk.{s}"] = {"rotation_quaternion": Euler((math.radians(-25), 0, 0)).to_quaternion()}
          pose_dicts[f] = pose
      for s in ("L", "R"):
          A.key_pose(rig, {f"upper_arm_parent.{s}": {'["IK_FK"]': 1.0}}, 1, keytype="EXTREME")  # FK arms
      for f, pose in pose_dicts.items():
          A.key_pose(rig, pose, f, keytype="EXTREME")      # stepped (CONSTANT) by default
      A.onion_skin(os.path.join(OUT, "jump_blocking_poses.png"), sorted(POSES), objects=proxies,
                   view="right", res=(420, 520), spread=300, highlight=sorted(POSES))   # pose strip
      ```
      
      Verified: world_pose reproduced the offset between the stand and the anticipation exactly after keying; the pose strip is `tests/code/blender-animation/out/jump_blocking_poses.png`. The Rigify rest legs are 99.6% straight, so the stand and settle poses lower the torso 3 cm (soft knees); the first draft raised it 2 to 6 cm and `limb_extension` measured the IK stretch at 1.18.
      
      ## P3 Blocking+ (verbatim, test_bx_anim.py)
      
      ```python
      A.breakdown(r2, 5, 1, 9, factor=0.5, factors={"hand": 0.25})   # COG in the middle, hand delayed
      fr = A.ease_inbetweens(ob, 1, 9, [3, 5, 7], mode="slow_in")    # 0.8, 0.96, 0.992 of the way
      ```
      
      `mode="slow_out"` mirrors it (0.008, 0.04, 0.2). Verified: slow_in gave 8.0, 9.6, 9.92 on a 0 to 10 move, slow_out 0.08, 0.4, 2.0; in-betweens stay CONSTANT and are typed BREAKDOWN; the quaternion breakdown is a true slerp (w = cos 22.5 deg at 0.25 of a 180 deg turn).
      
      Handles, measured with `A.overshoots` on keys 0, 4, 4, 0 at frames 1, 9, 17, 25: AUTO_CLAMPED no overshoot; AUTO overshoots the 9 to 17 hold by 1.0 (25%); VECTOR none. That AUTO bulge is what Alex Nagy exploits for a pushed pose or a settle: `A.set_interpolation(obj, frames=(f, f), handle="AUTO")` on the key inside a hold (same call as in the test, per-key range).
      
      Take-off breakdown on the jump (verbatim, test_rigify_jump.py):
      
      ```python
      # Blocking+ (still stepped): after take-off the body is ballistic (about 2/3 of the rise in
      # 3 of the 7 rising frames); the feet must follow or the IK legs stretch (limb_extension
      # measured 1.10 without this key)
      A.breakdown(rig, 16, 13, 20, factor=0.67, bones=body, factors={"foot_ik.L": 0.6, "foot_ik.R": 0.6})
      ```
      
      Verified: without it the legs stretched to 1.10 over frames 14 to 17 (the torso splines up faster than the flat-handled feet); with it and a VECTOR take-off (P4) the maximum is 0.992 at frame 14 and nothing stretches.
      
      ## P4 Blocking to spline (verbatim, test_rigify_jump.py)
      
      ```python
      # 13 -> 20 -> 27 -> 31 is one continuous move (push-off, air, landing, absorb): no pillars
      holds = A.blocking_to_spline(rig, body, transition=3, interpolate={13, 20, 27})
      A.set_interpolation(rig, frames=(27, 27), bones=["foot_ik.L", "foot_ik.R"], handle="VECTOR")  # hard contact
      A.set_interpolation(rig, frames=(13, 13), bones=["foot_ik.L", "foot_ik.R"], handle="VECTOR")  # hard take-off
      drift = A.hold_drift(rig, holds, body)                # dead holds: worst change ~0
      over = A.overshoots(rig, body)                        # [] unless a settle is wanted
      slip = A.foot_slip(rig, "foot_ik.L", [(1, 13), (27, 48)])   # planted: slide and lift ~0
      face_stepped = all(k.interpolation == "CONSTANT" for fc in A.fcurves(rig, face) for k in fc.keyframe_points)
      ```
      
      With `body` = torso, chest, hips, neck, head, both IK feet, FK upper arms and forearms, and the two `upper_arm_parent` bones (IK_FK switch); face = `jaw_master`. Result: holds [(1, 6), (9, 10), (31, 36), (39, 42)], worst hold drift 0.0, no overshoot, feet slide and lift 0.0 m, face still CONSTANT.
      
      Measured: with a pillar on the landing (`interpolate={13, 20}`) the torso's speed at contact was 0.007 m/frame, without it 0.101. Hand breakdowns (the take-off key at 16) stay breakdowns: `fill_key_columns` gives a filled key its column's type, so `auto_holds` never pillars a breakdown column (it did before v2). VECTOR on the foot contact raised the foot's last gap before contact from 0.030 to 0.117 m. On a toy rig (test_bx_anim.py) the naive whole conversion drifted 0.84 of the next move across a hold; pillars kept 0.0 and the move completed in the 3 chosen frames (0, 0.259, 0.741, 1.0).
      
      Subset splining in general: `A.spline(rig, bones=body)` leaves every other channel alone; `A.set_interpolation(rig, "BEZIER", bones=face_bones)` splines the face after lip-sync sign-off.
      
      ## P5 Walk: planted heel and root travel (verbatim, test_procedures.py)
      
      ```python
      # planted heel: its forward curve must be a straight line during contact (Joey Carlino)
      A.set_interpolation(foot, frames=(0, 0), data_paths=["location"], handle=handle_on_contact)
      A.set_interpolation(foot, frames=(20, 20), data_paths=["location"], handle=handle_on_contact)
      A.add_cycles(foot)                                        # limbs: REPEAT
      # root travels exactly the planted foot's distance, linear, repeating with offset
      step = 0.6                                                # |y(0)| + |y(20)|
      A.key_pose(root, {"location": {1: 0.0}}, 0, interpolation="LINEAR")
      A.key_pose(root, {"location": {1: -step}}, 20, interpolation="LINEAR")
      A.add_cycles(root, mode="REPEAT_OFFSET")
      ```
      
      Foot keys (root space, walking toward -Y): (0, -0.3, 0), (20, +0.3, 0), (30, 0, 0.12), (40, -0.3, 0). Measured world slip in the second cycle's contact: VECTOR 0.0, AUTO_CLAMPED 5.8 cm. The planted foot slides opposite to the travel in root space; the reverse makes the character moonwalk (Rik fixes pasted keys with a Y scale of -1). Head lag: `A.offset_keys(rig, 2, bones=["head"])` with Cycles wraps cleanly (verified: offset of 2 frames evaluated identically one cycle later).
      
      ## P6 Lip sync, jaw pass from audio (verbatim, test_procedures.py)
      
      ```python
      env = A.audio_envelope(WAV, start_frame=1)                     # frame -> loudness 0..1
      peaks = A.accent_frames(env, floor=0.2, min_gap=4)
      BIG, SMALL = math.radians(18), math.radians(6)
      A.key_pose(jaw, {"rotation_euler": {0: 0.0}}, 1)
      first = peaks["syllables"][0]
      A.key_pose(jaw, {"rotation_euler": {0: SMALL * 0.5}}, first - 3)  # open a little before the line (inhale)
      for f in peaks["syllables"]:
          big = f in peaks["accents"]
          A.key_pose(jaw, {"rotation_euler": {0: BIG if big else SMALL}}, f - 1,   # shape just before the sound
                     keytype="EXTREME" if big else "KEYFRAME")
          A.key_pose(jaw, {"rotation_euler": {0: SMALL * 0.3}}, f + 2, keytype="BREAKDOWN")
      A.key_pose(jaw, {"rotation_euler": {0: 0.0}}, max(env) - 2)   # stepped (CONSTANT) until approved
      ```
      
      Verified on a synthetic line with 8 syllables, 3 stressed: syllables [7, 11, 15, 20, 26, 30, 35, 41], accents [11, 26, 41]. The one-frame lead is a starting point [added]: Rik gives no global lead and retimes per phoneme (F, OO, W and closures formed before the sound). Angles are placeholders; use the rig's jaw range. Next passes (Rik): corners (in when the jaw opens, never dead center, slight asymmetry), shapes from a library (P12) on the lower face minus the jaw, accents, tongue (one middle control first), then spline the face. Phoneme timings need an external aligner (for example Rhubarb Lip Sync or Montreal Forced Aligner) [added]; the envelope alone gives rhythm and accents, not phonemes.
      
      ## P7 Facial timing check (verbatim, test_procedures.py)
      
      ```python
      dart = A.transitions(eyes, data_paths=["location"])["location[0]"]
      slow_darts = [t for t in dart if t[2] > 2]                     # Rik: eyes dart in 1 to 2 frames
      brows = A.transitions(brow, data_paths=["location"])["location[2]"]
      bad_brows = [t for t in brows if not 3 <= t[2] <= 10]         # brows change over 3 to 10 frames
      ```
      
      Verified: darts [(20, 22, 2), (40, 41, 1)] pass; a 1-frame brow pop is flagged. Same call checks snappy body transitions (2 to 3 frames) and plosive holds (closed for at least 2 frames).
      
      ## P8 Contact pin with animated influence (verbatim, test_procedures.py)
      
      ```python
      pb = rig.pose.bones["elbow"]
      pin = pb.constraints.new("COPY_LOCATION")                     # not Copy Transforms
      pin.name, pin.target, pin.subtarget = "Pin", rig, "helper"   # helper keyed where the elbow rests
      path = 'constraints["Pin"].influence'
      A.key_pose(rig, {"elbow": {path: 0.5}}, 1, interpolation="BEZIER")   # 0.5 keeps a little give
      A.key_pose(rig, {"elbow": {path: 0.5}}, 12, interpolation="BEZIER")
      A.key_pose(rig, {"elbow": {path: 0.0}}, 16, interpolation="BEZIER")  # contact breaks
      ```
      
      Verified: influence 0.5 held, 0 after frame 16, and the elbow returns to its own animation. Rik uses this when manual keys keep fighting the IK pole and stretch.
      
      ## P9 Slots when reusing an action (verbatim, test_procedures.py)
      
      ```python
      act = hero.animation_data.action
      prop.animation_data_create()
      prop.animation_data.action = act                      # does NOT animate yet: no slot picked
      if prop.animation_data.action_slot is None:
          prop.animation_data.action_slot = act.slots.new(id_type="OBJECT", name=prop.name)
      # or reuse one on purpose: prop.animation_data.action_slot = act.slots["OBHero"]
      ```
      
      Verified: Blender picks a slot automatically only by name (the ID's name or its last used slot); keep one slot name per character across takes (module talk, BCON25).
      
      ## P10 Layering in 5.2 (verbatim, test_bx_anim.py)
      
      ```python
      A.key_pose(ob, {"location": (0, 0, 0)}, 1, interpolation="LINEAR")
      A.key_pose(ob, {"location": (0, 0, 2)}, 11, interpolation="LINEAR")
      lay = A.push_layer(ob, "noise", blend="ADD")          # COMBINE for quaternion rigs
      A.key_pose(ob, {"location": {0: 0.0}}, 1, interpolation="LINEAR")
      A.key_pose(ob, {"location": {0: 0.5}}, 11, interpolation="LINEAR")   # a delta on top
      ```
      
      Verified: result at frame 11 = (0.5, 0, 2); `lay.layers.new()` raises "An Action may not have more than one layer"; `ob.keyframe_insert` on the ADD layer stored the remapped delta (posed 2.5 over a base of 2.0 stored 0.5).
      
      ## P11 Pose propagation on dense or baked keys (verbatim, test_procedures.py)
      
      ```python
      def propagate(idb, frame, deltas, back=10, fwd=10, data_paths=None):
          """Nacho de Andres / Raymond Luc: change a pose on dense (baked, mocap) keys and
          spread the change over a range with a smooth falloff instead of re-keying."""
          for fc in A.fcurves(idb, data_paths=data_paths):
              d = deltas.get((fc.data_path, fc.array_index))
              if not d:
                  continue
              for k in fc.keyframe_points:
                  x = k.co.x - frame
                  r = back if x < 0 else fwd
                  if abs(x) < r:
                      t = 1 - abs(x) / r
                      w = t * t * (3 - 2 * t)                       # smoothstep
                      k.co.y += d * w
                      k.handle_left.y += d * w
                      k.handle_right.y += d * w
              fc.update()
      
      
      before = [A.channelbag(ob).fcurves[0].evaluate(f) for f in (20, 30, 40, 50)]
      propagate(ob, 30, {("location", 2): -0.25}, back=10, fwd=10)
      ```
      
      Verified: full delta at frame 30, untouched at 20 and 40 and beyond.
      
      ## P12 Pose library as data, partial blend (verbatim, test_procedures.py)
      
      ```python
      def apply_library_pose(obj, pose, frame, factor=1.0, bones=None, keytype="KEYFRAME"):
          """Blend a stored pose onto a bone subset (Rik: lower face minus the jaw, then dial
          the shape back; a full F or M is too big inside a sentence)."""
          bones = bones or list(pose)
          bpy.context.scene.frame_set(frame)                # read the pose as it is at that frame
          cur = A.capture_pose(obj, bones)
          blended = {}
          for b in bones:
              if b not in pose:
                  continue
              blended[b] = {}
              for prop, target in pose[b].items():
                  now = cur[b][prop]
                  blended[b][prop] = tuple(n + (t - n) * factor for n, t in zip(now, target))
          A.key_pose(obj, blended, frame, keytype=keytype)
      
      
      apply_library_pose(face, LIB["F"], 12, factor=0.4, bones=["lip_up", "lip_low"])  # jaw excluded
      ```
      
      Build library poses from an animation default pose, at maximum range, one side at 3/4 then mirrored, hands included, never eyes or the head hinge (Pablo, Rik). Lerping quaternion tuples is fine for small blends; slerp for large ones. `poselib.apply_pose_asset(blend_factor=...)` exists but needs an asset context (not run headless).
      
      ## P13 Playblast with audio (test_procedures.py)
      
      ```python
      sc.render.ffmpeg.audio_codec = "AAC"                  # with the P1 sound strip
      mp4 = bx_review.playblast("/abs/out/shot.mp4", res_pct=50)   # Workbench, scene camera
      ```
      
      Light it first so contacts read: `A.playblast_lighting(key_dir=(0, 0, 1))` (P18).
      Verified: ffprobe shows video + audio. Then look at it: `ffmpeg -i shot.mp4 -vf "select='eq(n\,12)+eq(n\,19)',tile=2x1" -frames:v 1 sheet.png` and open the sheet.
      
      ## P14 Rotate about a contact point (verbatim, test_procedures.py)
      
      ```python
      contact = rig.matrix_world @ rig.pose.bones["hand"].tail   # the point that must not move
      tilt = Euler((0.0, 0.0, 0.0))
      tilt.x = math.radians(30)
      A.key_pose(rig, A.world_pose(rig, "hand", rotate=tilt, pivot=contact), 10, keytype="EXTREME")
      ```
      
      Verified: the contact moved 1e-7 m while the wrist moved 8 cm. This is Rik's glass-on-the-table pivot (3D cursor as pivot) without a viewport.
      
      ## P15 Measurement loop (verbatim, test_rigify_jump.py)
      
      ```python
      frames = list(range(1, 49))
      torso_pts = A.sample_world(rig, frames, bone="torso")
      print(A.format_report(A.motion_report(torso_pts, frames)))
      cam2d = A.to_camera(torso_pts, sc.camera, res=(1920, 1080))   # camera pixels: angles stay true
      print(A.format_report(A.motion_report(cam2d, frames, dims=2)))
      air = list(range(14, 27))                            # feet off the ground
      bal = A.ballistic_fit([torso_pts[f - 1] for f in air], air)
      low = A.lowest_point(proxies, frames)                # floor contact / penetration per frame
      A.onion_skin(os.path.join(OUT, "jump_onion_side.png"), frames[::2], objects=proxies, view="right",
                   res=(1000, 700), track=[(rig, "torso", "head"), (rig, "hand_fk.L", "tail"),
                                           (rig, "foot_ik.L", "head")], highlight=sorted(POSES))
      A.onion_skin(os.path.join(OUT, "jump_spline_strip.png"), frames[::3], objects=proxies,
                   view="right", res=(420, 520), spread=110, track=[(rig, "torso", "head")])
      A.graph_image(os.path.join(OUT, "jump_graph_torso.png"), rig, bones=["torso"], data_paths=["location"])
      mp4 = bx_review.playblast(os.path.join(OUT, "jump_playblast.mp4"), res_pct=50)
      ```
      
      Reading `format_report` (real output, the P16 ball, world space):
      
      ```
      move 1-47 (46 fr, dist 3.88): eases out of start and into end; peak spacing 0.2216 at f11 (23% through), first/last 0.29/0.02
         |oooo-oo-o--o-o--o--o---o-o-o-oooooooooo-o-o-o-ooooooooooooooo|
      sharp turns: f12 (144.9 deg), f27 (139.6 deg), f37 (133.8 deg), f44 (81.3 deg)
      arc wobble (curvature flips): f46
      pops: 43->44
      ```
      
      Sharp turns should sit exactly on contacts and hits; wobble inside a flight or a swing means a lumpy arc (here only the settle jiggle after the last contact); the pop into f44 is the last impact handing over to a slow roll, expected; a start-stop inside a move means a missing or misplaced breakdown; "constant spacing" means mechanical motion. On the splined jump the hand showed sharp turns at 8, 11, 12 and 20, a pop at 11 to 12 and a start-stop at 19: the arm swing needs breakdowns through the bottom of its arc (still open in the worked example). `motion_path` returns the same points as `sample_world` (verified for objects and bones, max difference < 1e-5 m). The default hold threshold is 1e-3 x the path extent (3.5 mm on a 3.5 m path): pass `hold_eps` to see a small settle. Camera space: with `to_camera(..., res=(1920, 1080))` the ball's sharp turns are exactly the contacts; with 0..1 coordinates the same path also showed false turns at the apexes f32, f40, f41 (aspect squeeze).
      
      ## P16 Bouncing ball on one object (verbatim, test_bx_anim.py)
      
      ```python
      res = A.bouncing_ball(weight=0.7, start=1, end=48, contacts=4, height=1.2, radius=0.15, travel=2.0)
      ball = res["object"]                        # every channel lives on this one object
      A.onion_skin(os.path.join(OUT, "ball_onion.png"), range(1, 49), objects=[ball], view="front",
                   track=[(ball, None, "head")], highlight=res["contacts"])
      ```
      
      Result: contacts [12, 27, 37, 44], flights 11, 15, 10, 7 frames (odd allowed), real apex heights 1.2, 0.549, 0.239, 0.115 m for 1.2, 0.546, 0.248, 0.113 requested, gravity_ratio 1.19 (fit=True squeezes four bounces into 48 frames; use fit=False for real gravity). Channels, all on the one object: location x, y, z, rotation_euler[1], and the Squash and Aim inputs of the `Squash` GN modifier (`res["paths"]`). Verified: every flight is the analytic parabola of one gravity (residual 4e-7), contact bottoms exactly on the floor (9e-9), the evaluated mesh never below the floor at quarter frames (2e-6), sharp turns exactly at the contacts. `burst=0.6` widened the last pre-contact gap from 0.141 to 0.216 m and the path stayed on the arc (0.0 deviation); `contact_handles='VECTOR'` deviates 0.09 m from a parabola; `weight='light'` vs `'heavy'`: second apex 0.78 vs 0.48 m, contact squash 0.69 vs 0.88.
      
      Measured against the v1 rig (root, aiming squash empty, rolling child; kept in `skills/_versions/blender-animation v1 2026-09-24/`), test_staging.py, E3 inputs (drop 1.03 m, rebound 0.405, heavy, real gravity):
      
      |                            | v1                                 | v2                                                                 |
      | -------------------------- | ---------------------------------- | ------------------------------------------------------------------ |
      | Pattern twist from the aim | up to 55.3 degrees, over 36 frames | 0 (unsquash of the evaluated mesh = roll of the rest mesh, 3e-7 m) |
      | Flights after the first    | 14, 8, 6 (even only)               | 14, 9, 6                                                           |
      | Gravity ratio per flight   | 1.035, 1.312, 0.975                | 1.017, 1.017, 1.017                                                |
      | Stretch axis vs velocity   | not aligned with the texture       | 0.03 degrees                                                       |
      | Animated objects           | 3 (root, squash empty, mesh)       | 1                                                                  |
      
      `obj=` animates an existing mesh (radius from its size, verified on a named `ANIM_ball`). The weight mapping (rebound 0.7 to 0.35, squash 0.35 to 0.08, stretch 0.25 to 0.04, friction 0.9 to 0.7) is [added]: the heavy end moved from 0.25 to 0.35 after the E3 run, where 0.25 left a 1.6 cm third hop and about 0.4 read heavy but visible. The BOUNCE easing (two keys) was measured to place its contacts between frames (lowest sampled heights 0.013, 0.013, 0.004), so no squash frame is ever shown.
      
      ## P17 Knee pop, rotation flips, balance (verbatim, test_rigify_jump.py)
      
      ```python
      legs = {s: A.limb_extension(rig, [f"ORG-thigh.{s}", f"ORG-shin.{s}"], frames) for s in ("L", "R")}
      flips = A.rotation_flips(rig, bones=body)             # quaternion sign flips, Euler jumps, fast turns
      bal = A.balance_report(proxies, frames)               # COG over the support polygon
      ```
      
      Verified on the generated Rigify jump (proxy boxes plus heel-to-toe soles, so the support polygon is the real footprint): legs peak at 0.992 of full length (frame 14, just after take-off), never stretched; fast bends near straight at the push-off, the landing absorb and the recovery are listed for review, not failures; no rotation flips; COG margin +3.9 cm standing and settled, +0.9 cm at the push-off; frames 14 to 26 airborne. Deliberate faults were caught: the torso pushed 0.4 m forward over planted feet gave a margin of -0.15 m; raised 0.25 m it gave an extension of 1.22 (straight and stretched). In test_bx_anim.py a quaternion key stored with the wrong sign was found (sign flip plus 147 degrees per frame turns) and a 350 degree Euler key meant as -10 was found; `fix_rotation_flips` repaired both and kept the pose. Thresholds 0.99 and 20 degrees per frame are [added]; the flip rule and COG-over-support are digest checklist items (Alex Nagy builds COG then feet with the weight over the support foot).
      
      ## P18 A ball shot staged and reviewed (verbatim, test_ball_shot.py)
      
      ```python
      # 1. animation: real gravity (fit=False), enters already falling from off-screen left
      res = A.bouncing_ball(name="ANIM_ball", start=1, end=72, contacts=4, height=0.95, rebound=0.42,
                            weight="heavy", radius=0.15, travel=2.4, origin=(-1.6, 0.0), first_apex=-3,
                            fit=False, hold=6, floor_plane=False)
      ball = res["object"]
      floor = A.floor_slab("floor", x=(-40.0, 40.0), y=(-2.5, 60.0))   # closed, starts before the camera
      frames = list(range(1, res["end"] + 1))
      
      
      # 2. staging: measure a camera, change it, measure again
      def camera(name, loc, target, lens):
          cam = bpy.data.objects.new(name, bpy.data.cameras.new(name))
          sc.collection.objects.link(cam)
          cam.location, cam.data.lens = loc, lens
          cam.rotation_euler = (Vector(target) - Vector(loc)).to_track_quat("-Z", "Y").to_euler()
          cam.data.show_passepartout, cam.data.passepartout_alpha = True, 0.99
          return cam
      
      
      wide = camera("CAM_wide", (0.0, -6.94, 1.9), (0.0, 0.0, 0.85), 50)     # the E3 framing
      sc.camera = wide
      bpy.context.view_layer.update()
      rep_wide = A.staging_report([ball], frames)
      x_end = A.sample_world(ball, [res["end"]])[0].x
      frame_w = 3.1                                   # frame width at the ball plane (m)
      cx = x_end - 0.18 * frame_w                     # rest pose at about 68% of the width
      shot = camera("CAM_shot", (cx, -frame_w * 35 / 36, 0.75), (cx, 0.0, 0.56), 35)
      sc.camera = shot
      bpy.context.view_layer.update()
      
      # 3. playblast look: overhead key converted to Workbench's camera space, contact shadow
      A.playblast_lighting(key_dir=(0.0, 0.0, 1.0))
      c1 = res["contacts"][0]
      rep = A.staging_report([ball], frames, shadow_frames=[c1 - 4, c1],
                             out_dir=os.path.join(OUT, "shadow"))
      print(A.format_staging(rep))
      ```
      
      Measured: the E3-like wide camera (50 mm, 6.9 m back, 1.9 m high) gave a subject of 10.4% of frame height, the top 40% empty, the ball fully in frame on frame 1: three flags. The designed camera (frame 3.1 m wide at the ball, rest pose at 68% of the width, 35 mm, 0.75 m high) gave 17.2% median, action over 73% x 55% of the frame, off-screen entry (fully in at f7), end margin 18%, no flags; shadow at f4 visible 104 px below the ball, at the f8 contact visible and touching (gap 0). Real gravity: flights 11, 14, 9, 6, gravity ratio 0.94 on every flight. Camera-pixel sharp turns exactly at the contacts 8, 22, 31, 37. Pinwheel of 8 wedges: 32 degrees per frame at most, under the 45 limit. Looked at: `out/ball_shot/contact_sheet.png` (every third frame), `contact_c1_sheet.png` (f5 to f10: stretch along the fall, squash meeting the shadow at f8, stretch leaving), `onion_camera.png` (clean arcs, squash ellipses at the contacts, spacing opening into each contact, labels not piling up), `playblast.mp4` (72 frames, ffprobe).
      
      Traps measured on the way (test_staging.py): Workbench `display.light_direction` is camera-relative, (0, 0, 1) lit from the camera and hid the shadow completely (0 px against 311 px converted); `matrix_world` read right after setting a camera transform is stale until `view_layer.update()`, so a conversion done too early silently used the identity. MATCAP `clay_studio.exr` kept a light floor at 0.65 to 0.68 luminance from 2 to 30 degrees of camera pitch, STUDIO 0.41 to 0.55. With the converted key, an open floor plane running behind the camera showed no stray shadow (0 px), unlike the E3 run's camera-relative setting that threw a trapezoid; the closed slab stays the default. The pinwheel is assigned per face, so wedge edges are stepped on a 48 x 24 sphere: fine for a playblast, use a UV texture for a render.
      
      ## GUI-only techniques and their substitutes
      
      | Technique (expert)                                                     | Substitute here                                                                                                                                                                 |
      | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | Audio scrubbing, waveform reading (Rik, Alex)                          | `audio_envelope`, `accent_frames`; external phoneme aligner                                                                                                                     |
      | Blend to Neighbor slider on one channel and axis (Rik)                 | `breakdown(..., bones=[b])` or a direct lerp on one F-curve index; `pose.blend_to_neighbor(factor, prev_frame, next_frame, channels, axis_lock)` needs explicit frames headless |
      | Flipping between key poses (Alex, Hjalti)                              | `onion_skin(frames=keys, spread=...)` pose strip                                                                                                                                |
      | Motion paths while posing, wormhole onion skins (Pablo, Picaut, Nacho) | `sample_world` + `motion_report`, `onion_skin(track=..., spread=...)`                                                                                                           |
      | Graph Editor proportional editing (Raymond)                            | `propagate` (P11)                                                                                                                                                               |
      | 3D cursor pivot, Active Element (Rik, Tony)                            | `world_pose(pivot=...)` (P14)                                                                                                                                                   |
      | Mouse muppeting (Raymond)                                              | procedural rough path, then keys; or ask for a live session                                                                                                                     |
      | Pose library drag with selection sets (Rik, Pablo)                     | pose dicts + bone lists (P12)                                                                                                                                                   |
      
    • sources.md 8.8 KB
      # Sources (14 videos)
      
      Per-video notes: `notes/animation/<id> - <title>.md`; batch digests `notes/animation/_digest_animation_talks.md` and `_digest_animation_acting_lives.md`. Distiller API probes: `archive/tests/animation_talks/probe1.py` to `probe10.py`, `archive/tests/anim_acting_probe/`.
      
      | Video                                                                                                                                     | Expert and credential                                                                                        | Best for                                                                                                                                  | Best timestamps                                                                                                                              |
      | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
      | [Live Character Animation "Acting" pt.3 (splining)](https://www.youtube.com/watch?v=hnHemwjznPc), Blender Studio, 2022-02-10, 2:44:53     | Rik Schutte, Blender Studio animator (Sprite Fright; feature work incl. Smallfoot)                           | blocking to spline with pillars, face kept stepped, 2 to 3 frame snaps, contacts, pivots, texture                                         | [00:14:12] wrong way, [00:16:49]-[00:20:44] pillars and snaps, [00:28:00] contacts, [01:19:52] pin, [01:39:15] pivot, [02:04:58] texture     |
      | [Live Character Animation "Acting" pt.4 (lip sync)](https://www.youtube.com/watch?v=sRYVR92FqSw), Blender Studio, 2022-02-11, 2:14:00     | Rik Schutte                                                                                                  | lip-sync order of passes, phoneme preparation, corners, tongue                                                                            | [00:04:44] jaw restraint, [00:25:47] off-sync, [00:38:38] milk M/B/P, [01:08:52] library shapes, [01:34:56] corners, [01:52:45] tongue       |
      | [Character Animation in Blender (Snow acting shot recap)](https://www.youtube.com/watch?v=zhktftWbumE), Blender Studio, 2022-03-25, 31:18 | Rik Schutte                                                                                                  | the whole acting workflow in 30 minutes: torso rhythm pass, leading part, pillars, lip sync, finger polish                                | [00:03:42] torso pass, [00:10:16]-[00:16:58] spline prep, [00:20:05] jaw, [00:29:24] fingers                                                 |
      | [Tackling Facial Animation](https://www.youtube.com/watch?v=NEZVF93fKsM), BCON23, 2023-10-26, 50:39                                       | Rik Schutte, lead animator Blender Studio, director of Wing It, ex Sony Imageworks                           | facial anatomy, eye/brow/lid rules with frame counts, sub-beats, reaction delay, pose library rules                                       | [00:05:43] eyes, [00:07:22] brows, [00:08:34] plosives, [00:21:02] lids, [00:22:43] sub-beats, [00:30:26] 6-frame reaction                   |
      | [Animating Everything (live cube show)](https://www.youtube.com/watch?v=jLkc4EgHWj8), BCON22, 2022-10-28, 52:13                           | Rik Schutte                                                                                                  | principles on a cube, keying habits, Blend to Neighbor, world-space cycle travel, squash volume                                           | [00:18:18] traveling cycle, [00:26:47] key everything, [00:28:34] tween tool, [00:30:16] jump squash                                         |
      | [Animation Tricks of the Trade](https://www.youtube.com/watch?v=fbfODZ8PO4E), BCON23 panel, 2023-10-27, 49:33                             | Raymond Luc, Tony Garcia, Dillon Gu (DillonGoo Studios); Pablo Fournier, Hjalti Hjalmarsson (Blender Studio) | burst spacing, flat handles, 20/80, smears, pose libraries, leading bone, stair-step, pivots, depth, do not spline early, key-type colors | [00:04:31] burst, [00:10:19] smears, [00:15:27] 20/80, [00:16:04] arcs, [00:27:37] leading bone, [00:36:59] Blocking+, [00:46:30] key colors |
      | [The Reference of Animation](https://www.youtube.com/watch?v=eOxVdBgoehk), BCON23, 2023-10-26, 21:47                                      | Hjalti Hjalmarsson, Blender Studio animator                                                                  | planning, beat lists, shooting and editing reference                                                                                      | [00:06:34] dialogue, [00:08:01] 24 fps, [00:13:40] beats, [00:19:32] edit                                                                    |
      | [Pose to Pose: Blender Animation Workflow](https://www.youtube.com/watch?v=p8Bi7k60IS0), Alex on Story, 2023-07-16, 1:19:45               | Alex Nagy, professional character animator                                                                   | a complete pose-to-pose recipe with frame numbers, holds, breakdown favoring, settles, handle types                                       | [00:21:26] pose order, [00:32:16] holds, [00:42:54] Automatic push, [00:45:05] odd frames, [00:49:19] favoring, [00:52:34] settles           |
      | [Easy Walk Cycle](https://www.youtube.com/watch?v=nRtT7Gr6S2o), 2021-11-23, 41:52                                                         | Joey Carlino, Blender educator                                                                               | walk timing, key poses, slip-free travel, heel curves                                                                                     | [00:07:44] timing, [00:10:43] down, [00:24:41] head lag, [00:25:25]-[00:33:41] travel, [00:40:15] shark fin                                  |
      | [The Animation of Flow](https://www.youtube.com/watch?v=fxz6p-QATfs), BCON24, 2024-10-24, 57:35                                           | Leo Silly-Pelissier, animation director on Flow                                                              | four-step validation method, all-directions posing, spline energy checklist, behavior rules, long shots                                   | [00:13:39] method, [00:15:56] spline step, [00:20:15] behavior, [00:25:18] all directions, [00:36:40] long shots                             |
      | [Barely 3D: The animation of Noara](https://www.youtube.com/watch?v=yIPMxxTueIM), BCON22, 2022-10-29, 51:41                               | Pierrick Picaut, art director, lead rigger and animator on Noara                                             | game constraints, micro-anticipation, directionality, silhouette on the motion path, smears in 3D                                         | [00:06:24] gameplay timing, [00:09:42] micro-anticipation, [00:12:59] silhouette, [00:24:07] smears                                          |
      | [Motion Sculpting](https://www.youtube.com/watch?v=M2J_fQNLDfg), BCON26, 2026-09-23, 54:48                                                | Nacho de Andres, game animator (Blizzard, Manor Lords), tool developer                                       | propagating edits on dense animation, relative-space arcs, why motion paths are slow                                                      | [00:18:58] propagation, [00:22:16] wormhole, [00:49:03] depsgraph                                                                            |
      | [Slot it like it's hot (slotted actions)](https://www.youtube.com/watch?v=ypWk6ZgQ-iM), BCON25, 2025-09-18, 51:37                         | Sybren Stuvel, Christoph Lendenfeld, Nathan Vegdahl, Animation and Rigging module                            | the 5.x action data model, slot naming and assignment, 4.3 to 5.0 features                                                                | [00:03:07] slots, [00:07:35] naming, [00:13:12] no auto-assign                                                                               |
      | [Layered Actions and Other Niceties](https://www.youtube.com/watch?v=TxqnoafBdzg), BCON26, 2026-09-23, 49:00                              | same module developers                                                                                       | what exists in 5.0, 5.1, 5.2 vs 5.3, layers status                                                                                        | [00:04:28] feature list, [00:11:49] layers not exposed                                                                                       |
      
      Known gaps: BCON24 "Animation Critique Session" and BCON25 "Animation Hangout" were never uploaded; Blender Studio's paid Animation Fundamentals course is out of reach (project README).
      
  • scripts
    • bx_anim.py 100.8 KB
      """
      bx_anim: keyframe animation helpers for an agent that cannot scrub.
      
      An animator judges timing by scrubbing and arcs by watching motion paths. An agent
      cannot, so this module keys through the Blender 5.x slotted-action API (no
      `action.fcurves`), converts blocking to spline the way Blender Studio animators do
      (key columns, hold copies, body splined while face and fingers stay stepped), and
      MEASURES motion: world positions per frame, spacing, velocity, holds, eases, pops,
      sharp turns and arc wobble, plus two images the agent opens with its image reader
      (an onion-skin contact image and a graph-editor style curve plot).
      
        import sys; sys.path.append("<skills>/scenario-blender-animation/scripts"); import bx_anim as A
        A.key_pose(rig, {"torso": {"location": (0, 0, -0.2)}}, 9, keytype='EXTREME')
        A.blocking_to_spline(rig, body_bones, transition=3)       # Rik Schutte's pillars
        pts = A.sample_world(rig, range(1, 49), bone="hand_ik.L")
        print(A.format_report(A.motion_report(pts, range(1, 49))))
        A.onion_skin("/abs/out/onion.png", range(1, 49), objects=[mesh], view="front")
        A.graph_image("/abs/out/graph.png", rig, bones=["torso"])
        A.bouncing_ball(weight=0.7, start=1, end=48, contacts=4)   # one object, world squash
        A.playblast_lighting(key_dir=(0, 0, 1)); print(A.format_staging(A.staging_report([ball], frames)))
        A.limb_extension(rig, ["ORG-thigh.L", "ORG-shin.L"], frames); A.rotation_flips(rig)
        A.balance_report(meshes, frames); A.ballistic_fit(pts, frames)   # see gravity()
      
      Conventions
        * `idb` is any animatable ID (object, armature object, shape-key datablock...).
        * Frames are scene frames. A key's interpolation applies to the segment AFTER it.
        * Pose dicts: {"location": (x, y, z), "rotation_euler": (...), '["prop"]': 1.0} for
          ID properties; on an armature object, a key that names a pose bone maps to that
          bone's own dict: {"torso": {"location": (...), "rotation_quaternion": (w, x, y, z)}}.
          A value can be a full vector, a scalar, or {index: value} to key single channels.
        * Keys are written straight into the channelbag (deterministic, no preferences, no
          3D view needed). On an NLA layer with blend type ADD these values are DELTAS.
        * Never hold a Keyframe reference across an insert on the same F-curve: the array
          reallocates and the old reference silently reads garbage (verified 5.2.1).
      
      Verified on Blender 5.2.1 LTS headless: tests/code/blender-animation/test_bx_anim.py,
      test_rigify_jump.py (generated Rigify human), test_procedures.py, test_staging.py and
      test_ball_shot.py (a full ball shot with the review pack).
      """
      
      __version__ = "0.1"  # Blender Expert Skills v0.1 (2026-09-24)
      import math
      import os
      
      import bpy
      from bpy_extras import anim_utils
      from mathutils import Euler, Matrix, Quaternion, Vector
      
      KEY_COLORS = {  # Dope Sheet key-type colours, used by graph_image
          "KEYFRAME": (0.95, 0.95, 0.95), "EXTREME": (0.95, 0.45, 0.6),
          "BREAKDOWN": (0.35, 0.65, 0.95), "JITTER": (0.45, 0.85, 0.45),
          "MOVING_HOLD": (0.55, 0.55, 0.55), "GENERATED": (0.75, 0.75, 0.75),
      }
      _TRANSFORMS = {"location", "rotation_euler", "rotation_quaternion", "rotation_axis_angle",
                     "scale"}
      
      
      # --------------------------------------------------------------------------------------
      # action, slot, channelbag
      # --------------------------------------------------------------------------------------
      def ensure_action(idb, name=None):
          """Return (action, slot) for `idb`, creating the action and a slot named after the
          ID when missing. Blender only auto-picks a slot by name, so always check the slot."""
          ad = idb.animation_data or idb.animation_data_create()
          if ad.action is None:
              ad.action = bpy.data.actions.new(name or f"{idb.name}Action")
          act = ad.action
          if ad.action_slot is None:
              slot = next((s for s in act.slots if s.name_display == idb.name
                           and s.target_id_type == idb.id_type), None)
              ad.action_slot = slot or act.slots.new(id_type=idb.id_type, name=idb.name)
          return act, ad.action_slot
      
      
      def channelbag(idb, create=False):
          """The channelbag (F-curve container) of the assigned action slot, or None."""
          ad = idb.animation_data
          if create:
              act, slot = ensure_action(idb)
              return anim_utils.action_ensure_channelbag_for_slot(act, slot)
          if not ad or not ad.action or ad.action_slot is None:
              return None
          return anim_utils.action_get_channelbag_for_slot(ad.action, ad.action_slot)
      
      
      def bone_of(fc):
          """Bone name of a pose-bone F-curve, else None."""
          dp = fc.data_path
          return dp.split('"')[1] if dp.startswith('pose.bones["') else None
      
      
      def fcurves(idb, bones=None, exclude_bones=None, data_paths=None):
          """F-curves of the assigned slot, filtered by bone names and/or data-path substrings.
          `bones=None` keeps everything (object channels included); a list keeps only those
          bones' channels."""
          cb = channelbag(idb)
          if cb is None:
              return []
          bones = set(bones) if bones is not None else None
          exclude = set(exclude_bones or ())
          out = []
          for fc in cb.fcurves:
              b = bone_of(fc)
              if bones is not None and b not in bones:
                  continue
              if b in exclude:
                  continue
              if data_paths and not any(s in fc.data_path for s in data_paths):
                  continue
              out.append(fc)
          return out
      
      
      def key_frames(idb, bones=None, exclude_bones=None, data_paths=None, keytypes=None):
          """Sorted list of frames that carry at least one key (optionally of `keytypes`)."""
          return sorted({round(k.co.x, 3) for fc in fcurves(idb, bones, exclude_bones, data_paths)
                         for k in fc.keyframe_points if not keytypes or k.type in keytypes})
      
      
      POSE_TYPES = ("KEYFRAME", "EXTREME")      # key poses; BREAKDOWN/MOVING_HOLD/JITTER are not
      
      
      def _key_at(fc, frame):
          for k in fc.keyframe_points:
              if abs(k.co.x - frame) < 1e-3:
                  return k
          return None
      
      
      def _style(k, interpolation=None, handle=None, easing=None):
          if interpolation:
              k.interpolation = interpolation
          if handle:
              k.handle_left_type = k.handle_right_type = handle
          if easing:
              k.easing = easing
      
      
      def _insert(fc, frame, value, keytype="KEYFRAME", interpolation=None, handle=None):
          fc.keyframe_points.insert(frame, value, options={"FAST"}, keyframe_type=keytype)
          k = _key_at(fc, frame)                      # fresh reference after the insert
          _style(k, interpolation, handle)
          return k
      
      
      # --------------------------------------------------------------------------------------
      # keying
      # --------------------------------------------------------------------------------------
      def _flatten(idb, pose):
          """Pose dict -> list of (data_path, index, value, group)."""
          items = []
      
          def add(owner, base, props, group):
              for name, val in props.items():
                  if val is None:
                      continue
                  dp = (base + name if name.startswith("[") else
                        (f"{base}.{name}" if base else name))
                  if name in ("rotation_euler", "rotation_quaternion", "rotation_axis_angle") \
                          and hasattr(owner, "rotation_mode"):
                      mode = owner.rotation_mode
                      ok = {"rotation_quaternion": mode == "QUATERNION",
                            "rotation_axis_angle": mode == "AXIS_ANGLE"}.get(
                          name, mode not in ("QUATERNION", "AXIS_ANGLE"))
                      if not ok:
                          raise ValueError(f"{dp}: rotation_mode is {mode}, key the matching "
                                           "rotation property")
                  try:
                      current = idb.path_resolve(dp)
                  except ValueError:
                      raise ValueError(f"cannot resolve '{dp}' on {idb.name}")
                  if isinstance(val, dict):
                      pairs = list(val.items())
                  elif hasattr(val, "__len__") and not isinstance(val, str):
                      pairs = list(enumerate(val))
                  else:
                      pairs = [(0, val)]
                  if not hasattr(current, "__len__") and len(pairs) > 1:
                      raise ValueError(f"{dp} is a scalar")
                  for i, v in pairs:
                      items.append((dp, i, float(v), group))
      
          if getattr(idb, "type", None) == "ARMATURE":
              obj_props = {}
              for key, val in pose.items():
                  if key in idb.pose.bones:
                      pb = idb.pose.bones[key]
                      add(pb, f'pose.bones["{key}"]', val, key)
                  else:
                      obj_props[key] = val
              add(idb, "", obj_props, "Object Transforms")
          else:
              add(idb, "", pose, "Object Transforms")
          return items
      
      
      def key_pose(idb, pose, frame, keytype="KEYFRAME", interpolation="CONSTANT",
                   handle="AUTO_CLAMPED"):
          """Key a pose dict at `frame` (see module docstring). Blocking default: stepped
          (CONSTANT). Returns the list of (data_path, index) keyed. Does not change the
          current frame; call scene.frame_set() before reading evaluated transforms."""
          cb = channelbag(idb, create=True)
          touched = []
          for dp, i, v, group in _flatten(idb, pose):
              grp = group if (group != "Object Transforms" or dp in _TRANSFORMS) else None
              fc = cb.fcurves.find(dp, index=i) or cb.fcurves.ensure(dp, index=i, group_name=grp or "")
              _insert(fc, frame, v, keytype, interpolation, handle)
              touched.append((dp, i))
          for fc in {cb.fcurves.find(dp, index=i) for dp, i in touched}:
              fc.update()
          return touched
      
      
      def capture_pose(obj, bones=None):
          """Current (evaluated at this frame) local transforms as a pose dict, for pose
          libraries kept as data. Armature: per bone; other objects: the object itself."""
          def props(o):
              d = {"location": tuple(o.location), "scale": tuple(o.scale)}
              if o.rotation_mode == "QUATERNION":
                  d["rotation_quaternion"] = tuple(o.rotation_quaternion)
              elif o.rotation_mode == "AXIS_ANGLE":
                  d["rotation_axis_angle"] = tuple(o.rotation_axis_angle)
              else:
                  d["rotation_euler"] = tuple(o.rotation_euler)
              return d
          if obj.type == "ARMATURE":
              names = bones or [pb.name for pb in obj.pose.bones]
              return {n: props(obj.pose.bones[n]) for n in names}
          return props(obj)
      
      
      def world_pose(obj, bone, translate=None, rotate=None, pivot=None, frame=None):
          """Channel values that move a pose bone by a WORLD-space translation and/or a world
          rotation (Quaternion, Euler or Matrix) about `pivot` (default: the bone head), from
          its pose at `frame` (default: current). Rig bone axes are arbitrary; pose in world
          terms and key the returned {bone: {...}} with key_pose. With a pivot at a contact
          point this is Rik Schutte's 'rotate from the table contact' (3D-cursor pivot).
          Constraints on the bone itself are ignored."""
          sc = bpy.context.scene
          if frame is not None:
              sc.frame_set(int(frame))
          pb = obj.pose.bones[bone]
          M = obj.matrix_world @ pb.matrix
          if rotate is not None:
              R = rotate if isinstance(rotate, Matrix) else rotate.to_matrix()
              p = Vector(pivot) if pivot is not None else M.translation.copy()
              M = Matrix.Translation(p) @ R.to_4x4() @ Matrix.Translation(-p) @ M
          if translate is not None:
              M = Matrix.Translation(Vector(translate)) @ M
          L = obj.convert_space(pose_bone=pb, matrix=M, from_space="WORLD", to_space="LOCAL")
          loc, rot, _ = L.decompose()
          d = {"location": tuple(loc)}
          if pb.rotation_mode == "QUATERNION":
              d["rotation_quaternion"] = tuple(rot)
          elif pb.rotation_mode == "AXIS_ANGLE":
              ax, ang = rot.to_axis_angle()
              d["rotation_axis_angle"] = (ang, *ax)
          else:
              d["rotation_euler"] = tuple(rot.to_euler(pb.rotation_mode, pb.rotation_euler))
          return {bone: d}
      
      
      def set_interpolation(idb, interpolation=None, frames=None, bones=None, exclude_bones=None,
                            data_paths=None, handle=None, easing=None, keytype=None):
          """Set interpolation / handle type / easing on keys whose frame lies in
          `frames=(first, last)` (inclusive; None = all). `keytype` limits to one key type.
          Handle types: AUTO_CLAMPED (holds stay flat), AUTO (free overshoot), VECTOR (hard
          hit), FREE / ALIGNED (manual). Returns the number of keys changed."""
          n = 0
          for fc in fcurves(idb, bones, exclude_bones, data_paths):
              for k in fc.keyframe_points:
                  if frames and not (frames[0] - 1e-3 <= k.co.x <= frames[1] + 1e-3):
                      continue
                  if keytype and k.type != keytype:
                      continue
                  _style(k, interpolation, handle, easing)
                  n += 1
              fc.update()
          return n
      
      
      def fill_key_columns(idb, bones=None, frames=None, create_missing=False):
          """Key every channel of `bones` on every key-pose frame (Rik Schutte keys all main
          controls on every key so holds lock to a frame). Values come from the curve as it
          evaluates now (stepped values while blocking); a filled key takes the type of its
          column (a BREAKDOWN column stays a breakdown). `create_missing` also creates
          location/rotation/scale channels for listed bones that were never keyed.
          Returns the number of keys added."""
          if create_missing and bones is not None and idb.type == "ARMATURE":
              f0 = (frames or key_frames(idb) or [bpy.context.scene.frame_current])[0]
              for b in bones:
                  idb.pose.bones[b]
                  have = {fc.data_path for fc in fcurves(idb, [b])}
                  want = {k: v for k, v in capture_pose(idb, [b])[b].items()
                          if f'pose.bones["{b}"].{k}' not in have}
                  if want:
                      key_pose(idb, {b: want}, f0)
          frames = frames or key_frames(idb, bones)
          fcs = fcurves(idb, bones)
          col_type = {}                     # a filled key takes its column's type, so a breakdown
          for f in frames:                  # column never turns into a key pose for auto_holds
              types = [k.type for fc in fcs for k in fc.keyframe_points if abs(k.co.x - f) < 1e-3]
              col_type[f] = next((t for t in types if t not in POSE_TYPES), None) \
                  if types and all(t not in POSE_TYPES for t in types) else "KEYFRAME"
          added = 0
          for fc in fcs:
              vals = {f: fc.evaluate(f) for f in frames if _key_at(fc, f) is None}
              for f, v in vals.items():
                  prev = [k for k in fc.keyframe_points if k.co.x < f]
                  interp = prev[-1].interpolation if prev else "CONSTANT"
                  _insert(fc, f, v, col_type.get(f) or "KEYFRAME", interp)
                  added += 1
              fc.update()
          return added
      
      
      def auto_holds(frames, transition=3, interpolate=(), transitions=None, all_frames=None):
          """Hold intervals for a stepped blocking: each key pose holds until
          `next_pose - transition` (Rik: 2 to 3 frames for a snappy change). Start frames in
          `interpolate` are big, deliberate moves left to the spline (no hold);
          `transitions={start: n}` overrides n per move. Intervals that already contain other
          keys in `all_frames` (hand-made breakdowns, authored holds) are left alone.
          Returns [(pose_frame, hold_end)]."""
          out = []
          transitions = transitions or {}
          inner = set(all_frames or ())
          for a, b in zip(frames, frames[1:]):
              if a in interpolate or any(a < f < b for f in inner):
                  continue
              end = b - transitions.get(a, transition)
              if end > a:
                  out.append((a, end))
          return out
      
      
      def hold_copy(idb, holds, bones=None, keytype="MOVING_HOLD"):
          """Copy each key pose to the end of its hold (the 'pillar'), on every channel of
          `bones`, keeping the pose's interpolation. Do this BEFORE splining. An existing key
          at the hold end is never overwritten. Returns keys added."""
          n = 0
          for fc in fcurves(idb, bones):
              for a, e in holds:
                  if _key_at(fc, e) is not None:
                      continue
                  v = fc.evaluate(a)
                  prev = [k for k in fc.keyframe_points if k.co.x <= a + 1e-3]
                  interp = prev[-1].interpolation if prev else "CONSTANT"
                  _insert(fc, e, v, keytype, interp)
                  n += 1
              fc.update()
          return n
      
      
      def spline(idb, bones=None, exclude_bones=None, handle="AUTO_CLAMPED"):
          """Bezier on the given channels only (body), AUTO_CLAMPED so holds stay flat."""
          return set_interpolation(idb, "BEZIER", bones=bones, exclude_bones=exclude_bones,
                                   handle=handle)
      
      
      def blocking_to_spline(idb, body_bones, holds=None, transition=3, interpolate=(),
                             handle="AUTO_CLAMPED"):
          """Rik Schutte's conversion in one call: key columns on the body, pillars at the
          end of every hold, then Bezier on the body only. Face and finger bones (anything
          not in `body_bones`) stay CONSTANT. Returns the holds used."""
          fill_key_columns(idb, body_bones, create_missing=True)
          if holds is None:
              holds = auto_holds(key_frames(idb, body_bones, keytypes=POSE_TYPES), transition,
                                 interpolate, all_frames=key_frames(idb, body_bones))
          hold_copy(idb, holds, body_bones)
          spline(idb, body_bones, handle=handle)
          return holds
      
      
      def _quat_paths(fcs):
          groups = {}
          for fc in fcs:
              if fc.data_path.endswith("rotation_quaternion"):
                  groups.setdefault(fc.data_path, {})[fc.array_index] = fc
          return {dp: g for dp, g in groups.items() if len(g) == 4}
      
      
      def breakdown(idb, frame, prev_frame, next_frame, factor=0.5, bones=None, factors=None,
                    keytype="BREAKDOWN"):
          """Hand-made breakdown at `frame`: 0 = previous pose, 1 = next pose (same meaning
          as pose.breakdown). `factors={bone: f}` favours parts differently (Alex Nagy: delay
          extremities with a low factor; favour late the part that should draw the eye).
          Quaternions are slerped. Interpolation follows the previous key (stays stepped)."""
          factors = factors or {}
          fcs = fcurves(idb, bones)
          quats = _quat_paths(fcs)
          done = set()
          for dp, g in quats.items():
              f = factors.get(bone_of(g[0]), factor)
              q0 = Quaternion([g[i].evaluate(prev_frame) for i in range(4)])
              q1 = Quaternion([g[i].evaluate(next_frame) for i in range(4)])
              if q0.dot(q1) < 0:
                  q1 = -q1
              q = q0.slerp(q1, f)
              for i in range(4):
                  _bd_insert(g[i], frame, q[i], keytype)
                  done.add(g[i])
          for fc in fcs:
              if fc in done:
                  continue
              f = factors.get(bone_of(fc), factor)
              v = fc.evaluate(prev_frame) * (1 - f) + fc.evaluate(next_frame) * f
              _bd_insert(fc, frame, v, keytype)
          for fc in fcs:
              fc.update()
      
      
      def _bd_insert(fc, frame, v, keytype):
          prev = [k for k in fc.keyframe_points if k.co.x < frame]
          _insert(fc, frame, v, keytype, prev[-1].interpolation if prev else "CONSTANT")
      
      
      def ease_inbetweens(idb, a, b, frames, mode="slow_in", ratio=0.8, bones=None):
          """Pablo Fournier's 20/80 rule as keyed in-betweens between key poses a and b.
          slow_in (decelerate into b): each in-between goes `ratio` of the remaining way
          (0.8, 0.96, 0.992...). slow_out (accelerate out of a): the mirror (..., 0.04, 0.2).
          `frames` are the in-between frames in order. Returns the fractions used."""
          n = len(frames)
          rest = 1.0 - ratio
          if mode == "slow_in":
              fr = [1 - rest ** (k + 1) for k in range(n)]
          elif mode == "slow_out":
              fr = [rest ** (n - k) for k in range(n)]
          else:
              raise ValueError("mode is slow_in or slow_out")
          for f, t in zip(frames, fr):
              breakdown(idb, f, a, b, t, bones=bones)
          return fr
      
      
      def offset_keys(idb, frames, bones=None, data_paths=None):
          """Shift keys (and handles) by `frames`: overlap by stair-stepping chain links
          (Dillon Gu) or a 2 to 3 frame head lag in a cycle (Joey Carlino)."""
          for fc in fcurves(idb, bones, data_paths=data_paths):
              for k in fc.keyframe_points:
                  k.co.x += frames
                  k.handle_left.x += frames
                  k.handle_right.x += frames
              fc.update()
      
      
      def add_cycles(idb, bones=None, data_paths=None, mode="REPEAT"):
          """Cycles modifier on channels, replacing existing ones. REPEAT for limbs,
          REPEAT_OFFSET for the travelling root channel (with LINEAR keys)."""
          n = 0
          for fc in fcurves(idb, bones, data_paths=data_paths):
              for m in [m for m in fc.modifiers if m.type == "CYCLES"]:
                  fc.modifiers.remove(m)
              m = fc.modifiers.new("CYCLES")
              m.mode_before = m.mode_after = mode
              n += 1
          return n
      
      
      def push_layer(idb, name="Layer", blend="ADD", influence=1.0):
          """5.2 has one layer per action: push the current action to an NLA strip and start
          a new action on top with `blend` (ADD, or COMBINE for quaternion rigs). key_pose on
          it writes deltas; obj.keyframe_insert on it stores remapped deltas. Returns it."""
          ad = idb.animation_data
          base = ad.action
          tr = ad.nla_tracks.new()
          tr.name = base.name
          tr.strips.new(base.name, int(base.frame_range[0]), base)
          ad.action = None
          ad.action = bpy.data.actions.new(name)
          ensure_action(idb)
          ad.action_blend_type = blend
          ad.action_influence = influence
          return ad.action
      
      
      # --------------------------------------------------------------------------------------
      # checks on curves
      # --------------------------------------------------------------------------------------
      def hold_drift(idb, holds, bones=None):
          """Largest change of any channel inside each hold (after splining). A dead hold is
          ~0; a moving hold is small but not zero. Returns {"worst": (...), "holds": {...}}."""
          per = {}
          worst = (0.0, None, None, None)
          for fc in fcurves(idb, bones):
              for a, e in holds:
                  v0 = fc.evaluate(a)
                  d = max(abs(fc.evaluate(a + i * 0.25) - v0) for i in range(int((e - a) * 4) + 1))
                  per[(a, e)] = max(per.get((a, e), 0.0), d)
                  if d > worst[0]:
                      worst = (d, fc.data_path, fc.array_index, (a, e))
          return {"worst": worst, "holds": per}
      
      
      def overshoots(idb, bones=None, data_paths=None, tol=1e-4, steps=8):
          """Segments whose curve leaves the range of its two keys (AUTO handles overshoot,
          AUTO_CLAMPED do not). Wanted on a settle, a bug on a hold. Returns a list of
          (amount, data_path, index, (frame0, frame1)) sorted by amount."""
          out = []
          for fc in fcurves(idb, bones, data_paths=data_paths):
              ks = sorted((k.co.x, k.co.y, k.interpolation) for k in fc.keyframe_points)
              for (x0, y0, it), (x1, y1, _) in zip(ks, ks[1:]):
                  if it == "CONSTANT" or x1 - x0 < 1e-6:
                      continue
                  lo, hi = min(y0, y1), max(y0, y1)
                  n = max(2, int((x1 - x0) * steps))
                  amt = max(max(fc.evaluate(x0 + (x1 - x0) * i / n) - hi,
                                lo - fc.evaluate(x0 + (x1 - x0) * i / n)) for i in range(n + 1))
                  if amt > tol:
                      out.append((amt, fc.data_path, fc.array_index, (x0, x1)))
          return sorted(out, reverse=True)
      
      
      # --------------------------------------------------------------------------------------
      # measurement
      # --------------------------------------------------------------------------------------
      def _frame_set(sc, f):
          fi = math.floor(f)
          sc.frame_set(int(fi), subframe=float(f - fi))
      
      
      def sample_world(obj, frames, bone=None, point="head", scene=None):
          """World positions per frame of an object origin, an object-local offset, a pose
          bone's head/tail, or a bone-local offset (Y runs along the bone). Restores the frame."""
          sc = scene or bpy.context.scene
          cur = sc.frame_current
          out = []
          try:
              for f in frames:
                  _frame_set(sc, f)
                  if bone:
                      pb = obj.pose.bones[bone]
                      if point == "head":
                          p = obj.matrix_world @ pb.head
                      elif point == "tail":
                          p = obj.matrix_world @ pb.tail
                      else:
                          p = obj.matrix_world @ pb.matrix @ Vector(point)
                  else:
                      p = obj.matrix_world.translation.copy() if point == "head" else \
                          obj.matrix_world @ Vector(point)
                  out.append(p.copy())
          finally:
              sc.frame_set(cur)
          return out
      
      
      def lowest_point(objs, frames, axis=2, scene=None):
          """Lowest world coordinate (default Z) of the evaluated meshes per frame: ground
          contact and penetration checks through any parent/armature/modifier chain."""
          import numpy as np
          sc = scene or bpy.context.scene
          objs = objs if isinstance(objs, (list, tuple)) else [objs]
          cur = sc.frame_current
          out = []
          try:
              for f in frames:
                  _frame_set(sc, f)
                  dg = bpy.context.evaluated_depsgraph_get()
                  low = math.inf
                  for o in objs:
                      eo = o.evaluated_get(dg)
                      me = eo.to_mesh()
                      co = np.empty(len(me.vertices) * 3, dtype=np.float32)
                      me.vertices.foreach_get("co", co)
                      co = co.reshape(-1, 3)
                      mw = np.array(eo.matrix_world, dtype=np.float64)
                      w = co @ mw[:3, :3].T + mw[:3, 3]
                      low = min(low, float(w[:, axis].min()))
                      eo.to_mesh_clear()
                  out.append(low)
          finally:
              sc.frame_set(cur)
          return out
      
      
      def motion_path(obj, frame_start, frame_end, bone=None, keep=True):
          """Blender's own motion path (computed headless via temp_override; bones enter Pose
          Mode briefly, mode and active object are restored). Returns world positions; the
          path stays visible in a GUI session unless keep=False. sample_world() gives the
          same numbers without touching modes."""
          mp = obj.pose.animation_visualization.motion_path if bone else \
              obj.animation_visualization.motion_path
          mp.range = "MANUAL"
          mp.frame_start, mp.frame_end = int(frame_start), int(frame_end)
          if bone:
              vl = bpy.context.view_layer
              prev_active, prev_mode = vl.objects.active, obj.mode
              vl.objects.active = obj
              if obj.mode != "POSE":                      # the pose operator polls for Pose Mode
                  bpy.ops.object.mode_set(mode="POSE")
              pb = obj.pose.bones[bone]
              pb.select = True
              try:
                  with bpy.context.temp_override(object=obj, active_object=obj,
                                                 selected_pose_bones=[pb], active_pose_bone=pb):
                      bpy.ops.pose.paths_calculate(display_type="RANGE", range="MANUAL")
              finally:
                  if prev_mode != "POSE":
                      bpy.ops.object.mode_set(mode=prev_mode)
                  vl.objects.active = prev_active
              path = pb.motion_path
          else:
              with bpy.context.temp_override(object=obj, active_object=obj,
                                             selected_objects=[obj], selected_editable_objects=[obj]):
                  bpy.ops.object.paths_calculate(display_type="RANGE", range="MANUAL")
              path = obj.motion_path
          pts = [Vector(v.co) for v in path.points]
          if not keep:
              if bone:
                  with bpy.context.temp_override(object=obj, active_object=obj,
                                                 selected_pose_bones=[pb]):
                      bpy.ops.pose.paths_clear(only_selected=True)
              else:
                  with bpy.context.temp_override(object=obj, active_object=obj,
                                                 selected_objects=[obj]):
                      bpy.ops.object.paths_clear(only_selected=True)
          return pts
      
      
      def to_camera(points, camera=None, scene=None, res=None):
          """Project world points through a camera: (x, y, depth) with x, y in 0..1 from the
          bottom-left, or pixel coordinates from the top-left when `res=(w, h)`. Measure
          angles and arcs on pixels: 0..1 squeezes x by the aspect ratio and motion_report
          then invents sharp turns at the apexes of a bouncing ball (measured)."""
          from bpy_extras.object_utils import world_to_camera_view
          sc = scene or bpy.context.scene
          cam = camera or sc.camera
          out = []
          for p in points:
              v = world_to_camera_view(sc, cam, Vector(p))
              out.append((v.x * res[0], (1 - v.y) * res[1], v.z) if res else (v.x, v.y, v.z))
          return out
      
      
      def _runs(mask):
          runs, start = [], None
          for i, m in enumerate(mask):
              if m and start is None:
                  start = i
              if not m and start is not None:
                  runs.append((start, i - 1))
                  start = None
          if start is not None:
              runs.append((start, len(mask) - 1))
          return runs
      
      
      def motion_report(points, frames, fps=None, hold_eps=None, turn_deg=45.0, dims=None):
          """Spacing and arc analysis of a sampled path (world 3D or camera 2D points).
          Returns a dict: spacing (per frame), velocity (units/s), holds, moves (with ease
          shape, peak position, evenness), sharp turns (contacts, hits), arc wobble
          (curvature sign flips inside a move), pops and start-stops. For to_camera() output
          pass dims=2 so the depth column is ignored, and use res=(w, h) pixels. The default
          hold threshold is 1e-3 x the path extent: pass `hold_eps` to see a small settle."""
          import numpy as np
          sc = bpy.context.scene
          fps = fps or sc.render.fps / sc.render.fps_base
          P = np.array([tuple(p)[:dims or 3] for p in points], dtype=np.float64)
          F = np.array(list(frames), dtype=np.float64)
          d = np.diff(P, axis=0)
          dt = np.diff(F)
          sp = np.linalg.norm(d, axis=1) / dt
          extent = float(max(np.ptp(P, axis=0).max(), 1e-9))
          eps = hold_eps if hold_eps is not None else 1e-3 * extent
          moving = sp > eps
          holds = [(float(F[i0]), float(F[i1 + 1])) for i0, i1 in _runs(~moving)]
          moves = []
          for i0, i1 in _runs(moving):
              s = sp[i0:i1 + 1]
              mx, im = float(s.max()), int(s.argmax())
              first, last = float(s[0] / mx), float(s[-1] / mx)
              cv = float(s.std() / max(s.mean(), 1e-12))
              if len(s) >= 4 and cv < 0.08:
                  shape = "constant spacing (mechanical unless intended)"
              elif first < 0.5 and last < 0.5:
                  shape = "eases out of start and into end"
              elif first < 0.5:
                  shape = "eases out of start, fastest at the end"
              elif last < 0.5:
                  shape = "starts fast, eases into end"
              else:
                  shape = "fast at both ends"
              cum = np.concatenate([[0.0], np.cumsum(s * dt[i0:i1 + 1])])
              moves.append({"start": float(F[i0]), "end": float(F[i1 + 1]), "frames": float(F[i1 + 1] - F[i0]),
                            "distance": float(cum[-1]), "max_spacing": mx,
                            "peak_frame": float(F[i0 + im]), "peak_at": (im + 0.5) / len(s),
                            "first_ratio": first, "last_ratio": last, "evenness_cv": cv,
                            "shape": shape, "ticks": (cum / max(cum[-1], 1e-12)).tolist()})
          # turning angles and curvature sign in the best-fit plane
          if P.shape[1] == 3 and len(P) >= 3:
              _, _, vt = np.linalg.svd(P - P.mean(axis=0))
              normal = vt[2]
          else:
              normal = np.array([0.0, 0.0, 1.0])
          D3 = np.zeros((len(d), 3))
          D3[:, :d.shape[1]] = d
          sharp, turns = [], []
          for i in range(len(d) - 1):
              if not (moving[i] and moving[i + 1]):
                  continue
              a, b = D3[i], D3[i + 1]
              c = float(np.clip(a.dot(b) / (np.linalg.norm(a) * np.linalg.norm(b)), -1, 1))
              ang = math.degrees(math.acos(c))
              if ang > turn_deg:
                  sharp.append((float(F[i + 1]), round(ang, 1)))
              elif ang > 0.5:
                  turns.append((i, float(F[i + 1]), float(np.sign(np.cross(a, b).dot(normal)))))
          wobble = []
          for (i, f, s), (j, g, t) in zip(turns, turns[1:]):
              if j == i + 1 and s != t and s != 0 and t != 0:
                  wobble.append(g)
          # pops and start-stops
          pops, stops = [], []
          for i in range(len(sp)):
              win = np.concatenate([sp[max(0, i - 2):i], sp[i + 1:i + 3]])
              win = win[win > eps]              # judge against moving neighbours: a snap out of
              if len(win) >= 2 and sp[i] > 2.5 * float(np.median(win)) and sp[i] > 10 * eps:
                  pops.append((float(F[i]), float(F[i + 1])))   # a dead hold is not a pop
              if 1 < i < len(sp) - 2 and moving[i]:
                  if sp[i] < 0.5 * min(sp[i - 2:i].max(), sp[i + 1:i + 3].max()) and \
                          sp[i] <= sp[i - 1] and sp[i] <= sp[i + 1]:
                      stops.append(float(F[i]))
          return {"fps": fps, "frames": F.tolist(), "extent": extent, "hold_eps": eps,
                  "spacing": sp.tolist(), "velocity": (sp * fps).tolist(), "holds": holds,
                  "moves": moves, "sharp_turns": sharp, "arc_wobble": wobble, "pops": pops,
                  "start_stops": stops}
      
      
      def format_report(rep, width=60, table=False):
          """Human/agent-readable text of motion_report, with an animator's spacing chart per
          move (one 'o' per frame, placed by distance travelled)."""
          F = rep["frames"]
          lines = [f"frames {F[0]:g}-{F[-1]:g} @ {rep['fps']:g} fps, path extent {rep['extent']:.4g}"]
          lines.append("holds: " + (", ".join(f"{a:g}-{b:g}" for a, b in rep["holds"]) or "none"))
          for m in rep["moves"]:
              lines.append(f"move {m['start']:g}-{m['end']:g} ({m['frames']:g} fr, dist {m['distance']:.4g}): "
                           f"{m['shape']}; peak spacing {m['max_spacing']:.4g} at f{m['peak_frame']:g} "
                           f"({m['peak_at'] * 100:.0f}% through), first/last {m['first_ratio']:.2f}/"
                           f"{m['last_ratio']:.2f}")
              row = ["-"] * (width + 1)
              for t in m["ticks"]:
                  row[int(round(t * width))] = "o"
              lines.append("   |" + "".join(row) + "|")
          lines.append("sharp turns: " + (", ".join(f"f{f:g} ({a:g} deg)" for f, a in rep["sharp_turns"]) or "none"))
          lines.append("arc wobble (curvature flips): " + (", ".join(f"f{f:g}" for f in rep["arc_wobble"]) or "none"))
          lines.append("pops: " + (", ".join(f"{a:g}->{b:g}" for a, b in rep["pops"]) or "none"))
          lines.append("start-stops: " + (", ".join(f"f{f:g}" for f in rep["start_stops"]) or "none"))
          if table:
              lines.append("frame  spacing   velocity/s")
              for f, s, v in zip(F, rep["spacing"], rep["velocity"]):
                  lines.append(f"{f:5g}  {s:8.4f}  {v:9.3f}")
          return "\n".join(lines)
      
      
      def gravity(fps=None, g=9.81, scale_length=None):
          """Real gravity in the units an agent compares against, so the factor 2 never bites:
          'accel_per_frame2' is the ACCELERATION (second difference of height per frame^2,
          9.81 / fps^2 = 0.01703 m at 24 fps); 'quad_coeff_per_frame2' is the LEADING
          COEFFICIENT of a quadratic fit z = a t^2 + b t + c with t in frames (half of it,
          negative: -0.00852 at 24 fps). Scene unit scale is respected."""
          sc = bpy.context.scene
          fps = fps or sc.render.fps / sc.render.fps_base
          g_u = g / (scale_length or sc.unit_settings.scale_length or 1.0)
          return {"accel_per_s2": g_u, "accel_per_frame2": g_u / fps ** 2,
                  "quad_coeff_per_frame2": -g_u / (2 * fps ** 2), "fps": fps}
      
      
      def ballistic_fit(points, frames, fps=None, axis=2, g=9.81):
          """Least-squares parabola through the vertical component of an airborne path
          (centre of gravity between take-off and landing, a thrown prop).
      
          Returns, without ambiguity:
            accel               vertical ACCELERATION in units/s^2 (= 2 x the quadratic's
                                leading coefficient); compare with -9.81 at real scale
            accel_per_frame2    the same acceleration per frame^2 (compare with -9.81/fps^2)
            quad_coeff_per_frame2  the raw leading coefficient with t in frames
                                (compare with -9.81/(2 fps^2), half the acceleration)
            g_ratio             -accel / g: 1.0 = physical at this scale, < 1 floaty
            residual_max        largest deviation from the fitted parabola (lumpy arc)
            apex_time_frame     frame of the fitted apex
          Scene unit scale is respected (gravity() gives the reference values)."""
          import numpy as np
          sc = bpy.context.scene
          fps = fps or sc.render.fps / sc.render.fps_base
          g_u = g / (sc.unit_settings.scale_length or 1.0)
          t = np.array(list(frames), dtype=np.float64) / fps
          z = np.array([p[axis] for p in points], dtype=np.float64)
          a, b, c = np.polyfit(t, z, 2)
          resid = z - (a * t * t + b * t + c)
          return {"accel": float(2 * a), "accel_per_frame2": float(2 * a / fps ** 2),
                  "quad_coeff_per_frame2": float(a / fps ** 2), "g_ratio": float(-2 * a / g_u),
                  "residual_max": float(np.abs(resid).max()),
                  "apex_time_frame": float(-b / (2 * a) * fps) if a else None}
      
      
      def limb_extension(obj, chain, frames, threshold=0.99, pop_deg=20.0):
          """Knee (or elbow) pop check for an IK limb [added]. `chain` = pose bones from root
          to tip that carry the final pose, e.g. Rigify ["ORG-thigh.L", "ORG-shin.L"] (the ORG
          bones follow IK or FK; DEF thighs are split in two). Per frame: extension ratio =
          root-head to tip-tail distance / sum of rest lengths, and the bend angle between
          the first and last bone. An IK chain near full extension snaps: keep the ratio under
          `threshold` (0.99) on every frame the leg is meant to stay bent; > 1.0 means IK
          stretch is lengthening the limb. A bend change of more than `pop_deg` in one frame
          while the ratio is above 0.97 is reported as a pop. Returns a dict."""
          sc = bpy.context.scene
          cur = sc.frame_current
          full = sum(obj.data.bones[b].length for b in chain)
          rows = []
          try:
              for f in frames:
                  _frame_set(sc, f)
                  mw = obj.matrix_world
                  a = obj.pose.bones[chain[0]]
                  z = obj.pose.bones[chain[-1]]
                  root, tip = mw @ a.head, mw @ z.tail
                  scale = mw.to_scale()[0] or 1.0
                  ratio = (tip - root).length / scale / full
                  v1 = (mw @ a.tail) - root
                  v2 = tip - (mw @ z.head)
                  bend = math.degrees(v1.angle(v2)) if v1.length > 1e-9 and v2.length > 1e-9 else 0.0
                  rows.append({"frame": f, "ratio": ratio, "bend_deg": bend})
          finally:
              sc.frame_set(cur)
          straight = [r["frame"] for r in rows if r["ratio"] >= threshold]
          stretched = [r["frame"] for r in rows if r["ratio"] > 1.001]
          pops = [(p["frame"], q["frame"], round(abs(q["bend_deg"] - p["bend_deg"]), 1))
                  for p, q in zip(rows, rows[1:])
                  if abs(q["bend_deg"] - p["bend_deg"]) > pop_deg and max(p["ratio"], q["ratio"]) > 0.97]
          return {"chain": list(chain), "full_length": full, "rows": rows,
                  "max_ratio": max(r["ratio"] for r in rows), "straight_frames": straight,
                  "stretched_frames": stretched, "pops": pops}
      
      
      def rotation_flips(idb, bones=None, data_paths=None, max_step_deg=90.0, frames=None):
          """Euler and quaternion flip check (digest checklist 'no jumps greater than 180
          degrees'). Key level: an Euler channel whose consecutive keys differ by more than
          180 degrees ('euler_jump'), a quaternion whose consecutive keys have a negative dot
          product ('quat_sign_flip': Blender interpolates the components, so it swings the
          long way). Sampled on whole frames: orientation change above `max_step_deg` in one
          frame ('fast_turn'). Returns a list of dicts; fix with fix_rotation_flips()."""
          out = []
          fcs = [fc for fc in fcurves(idb, bones, data_paths=data_paths)
                 if "rotation_euler" in fc.data_path or "rotation_quaternion" in fc.data_path]
          groups = {}
          for fc in fcs:
              groups.setdefault(fc.data_path, {})[fc.array_index] = fc
          for dp, g in groups.items():
              if dp.endswith("rotation_euler"):
                  for i, fc in g.items():
                      ks = sorted((k.co.x, k.co.y) for k in fc.keyframe_points)
                      for (x0, y0), (x1, y1) in zip(ks, ks[1:]):
                          if abs(y1 - y0) > math.pi:
                              out.append({"data_path": dp, "index": i, "frames": (x0, x1),
                                          "kind": "euler_jump", "deg": round(math.degrees(abs(y1 - y0)), 1)})
              elif len(g) == 4:
                  xs = sorted({round(k.co.x, 3) for fc in g.values() for k in fc.keyframe_points})
                  qs = [Quaternion([g[i].evaluate(x) for i in range(4)]) for x in xs]
                  for (x0, q0), (x1, q1) in zip(zip(xs, qs), zip(xs[1:], qs[1:])):
                      if q0.dot(q1) < 0:
                          out.append({"data_path": dp, "index": None, "frames": (x0, x1),
                                      "kind": "quat_sign_flip", "deg": None})
              ks = [k.co.x for fc in g.values() for k in fc.keyframe_points]
              f0, f1 = frames or (math.floor(min(ks)), math.ceil(max(ks)))
              try:
                  owner = idb.path_resolve(dp.rsplit(".", 1)[0]) if "." in dp else idb
              except ValueError:
                  owner = idb
              mode = getattr(owner, "rotation_mode", "XYZ")
              mode = mode if mode not in ("QUATERNION", "AXIS_ANGLE") else "XYZ"
              prev = None
              for f in range(int(f0), int(f1) + 1):
                  if dp.endswith("rotation_euler"):
                      q = Euler([g[i].evaluate(f) if i in g else 0.0 for i in range(3)], mode).to_quaternion()
                  elif len(g) == 4:
                      q = Quaternion([g[i].evaluate(f) for i in range(4)])
                      if q.magnitude < 1e-9:
                          q = Quaternion()
                      q.normalize()
                  else:
                      break
                  if prev is not None:
                      ang = math.degrees(prev.rotation_difference(q).angle)
                      ang = min(ang, 360 - ang)
                      if ang > max_step_deg:
                          out.append({"data_path": dp, "index": None, "frames": (f - 1, f),
                                      "kind": "fast_turn", "deg": round(ang, 1)})
                  prev = q
          return out
      
      
      def fix_rotation_flips(idb, bones=None, data_paths=None):
          """Repair what rotation_flips reports on keys: quaternion keys are negated (same
          orientation) so consecutive keys keep a positive dot product; Euler keys get a
          multiple of 360 degrees per axis so each key is within 180 degrees of the previous
          one (a per-axis unwrap, simpler than Blender's Euler filter). Handles move with
          their keys. Returns the number of keys changed."""
          n = 0
          fcs = [fc for fc in fcurves(idb, bones, data_paths=data_paths)
                 if "rotation_euler" in fc.data_path or "rotation_quaternion" in fc.data_path]
          groups = {}
          for fc in fcs:
              groups.setdefault(fc.data_path, {})[fc.array_index] = fc
          for dp, g in groups.items():
              if dp.endswith("rotation_euler"):
                  for fc in g.values():
                      ks = sorted(fc.keyframe_points, key=lambda k: k.co.x)
                      for k0, k1 in zip(ks, ks[1:]):
                          d = k1.co.y - k0.co.y
                          turns = round(d / (2 * math.pi))
                          if turns:
                              off = -turns * 2 * math.pi
                              k1.co.y += off
                              k1.handle_left.y += off
                              k1.handle_right.y += off
                              n += 1
                      fc.update()
              elif len(g) == 4:
                  xs = sorted({round(k.co.x, 3) for fc in g.values() for k in fc.keyframe_points})
                  prev = None
                  for x in xs:
                      keys = [_key_at(g[i], x) for i in range(4)]
                      q = Quaternion([g[i].evaluate(x) for i in range(4)])
                      if prev is not None and prev.dot(q) < 0 and all(keys):
                          for k in keys:
                              k.co.y, k.handle_left.y, k.handle_right.y = -k.co.y, -k.handle_left.y, -k.handle_right.y
                          q = -q
                          n += 1
                      prev = q
                  for fc in g.values():
                      fc.update()
          return n
      
      
      def _world_verts(objs, dg):
          """Evaluated world-space vertex array (N, 3) of the meshes, plus the per-object
          (start, count, triangle index array) needed for volume centroids."""
          import numpy as np
          pts, tris, off = [], [], 0
          for o in objs:
              eo = o.evaluated_get(dg)
              me = eo.to_mesh()
              co = np.empty(len(me.vertices) * 3, dtype=np.float64)
              me.vertices.foreach_get("co", co)
              co = co.reshape(-1, 3)
              mw = np.array(eo.matrix_world, dtype=np.float64)
              w = co @ mw[:3, :3].T + mw[:3, 3]
              me.calc_loop_triangles()
              t = np.empty(len(me.loop_triangles) * 3, dtype=np.int64)
              me.loop_triangles.foreach_get("vertices", t)
              tris.append(t.reshape(-1, 3) + off)
              pts.append(w)
              off += len(w)
              eo.to_mesh_clear()
          return (np.concatenate(pts) if pts else np.zeros((0, 3))), \
              (np.concatenate(tris) if tris else np.zeros((0, 3), dtype=np.int64))
      
      
      def _hull2d(p):
          """Convex hull (counter-clockwise) of 2D points, monotone chain."""
          pts = sorted(set(map(tuple, p)))
          if len(pts) <= 2:
              return pts
      
          def cross(o, a, b):
              return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
          lo, hi = [], []
          for q in pts:
              while len(lo) >= 2 and cross(lo[-2], lo[-1], q) <= 0:
                  lo.pop()
              lo.append(q)
          for q in reversed(pts):
              while len(hi) >= 2 and cross(hi[-2], hi[-1], q) <= 0:
                  hi.pop()
              hi.append(q)
          return lo[:-1] + hi[:-1]
      
      
      def _signed_margin(pt, hull):
          """Signed distance from a 2D point to a convex CCW polygon (positive inside); for a
          point or segment 'hull' the negative distance to it."""
          def seg_dist(p, a, b):
              ax, ay, bx, by = a[0], a[1], b[0], b[1]
              dx, dy = bx - ax, by - ay
              L = dx * dx + dy * dy
              t = 0.0 if L < 1e-18 else max(0.0, min(1.0, ((p[0] - ax) * dx + (p[1] - ay) * dy) / L))
              return math.hypot(p[0] - ax - t * dx, p[1] - ay - t * dy)
          if len(hull) == 1:
              return -math.hypot(pt[0] - hull[0][0], pt[1] - hull[0][1])
          if len(hull) == 2:
              return -seg_dist(pt, hull[0], hull[1])
          inside = True
          for a, b in zip(hull, hull[1:] + hull[:1]):
              if (b[0] - a[0]) * (pt[1] - a[1]) - (b[1] - a[1]) * (pt[0] - a[0]) < 0:
                  inside = False
          d = min(seg_dist(pt, a, b) for a, b in zip(hull, hull[1:] + hull[:1]))
          return d if inside else -d
      
      
      def balance_report(objs, frames, floor_z=None, contact_tol=0.02, cog=None):
          """Weight and balance check (digest checklist 'COG over the support foot'; Alex
          Nagy builds poses COG then feet with the weight over the support foot). Per frame:
          centre of gravity = volume centroid of the evaluated meshes (closed meshes; open
          ones fall back to the vertex mean), or `cog=(obj, bone_or_None)` to use a control;
          support polygon = convex hull of the vertices within `contact_tol` of the floor
          (`floor_z` default: the lowest point over `frames`); margin = signed horizontal
          distance of the COG to that polygon (positive inside, units). Airborne frames have
          no support. Held poses and settles should be inside; locomotion is allowed outside
          (the fall that drives the next step). Returns a dict with rows and a summary."""
          import numpy as np
          sc = bpy.context.scene
          objs = objs if isinstance(objs, (list, tuple)) else [objs]
          frames = list(frames)
          if floor_z is None:
              floor_z = min(lowest_point(objs, frames))
          cur = sc.frame_current
          rows = []
          try:
              for f in frames:
                  _frame_set(sc, f)
                  dg = bpy.context.evaluated_depsgraph_get()
                  P, T = _world_verts(objs, dg)
                  if cog is not None:
                      c = sample_world(cog[0], [f], bone=cog[1])[0]
                      _frame_set(sc, f)
                      c = np.array(c)
                  else:
                      a, b, cc = P[T[:, 0]], P[T[:, 1]], P[T[:, 2]]
                      vol = np.einsum("ij,ij->i", a, np.cross(b, cc)) / 6.0
                      V = vol.sum()
                      c = ((a + b + cc) / 4.0 * vol[:, None]).sum(axis=0) / V \
                          if abs(V) > 1e-12 else P.mean(axis=0)
                  low = P[P[:, 2] <= floor_z + contact_tol]
                  if len(low) == 0:
                      rows.append({"frame": f, "cog": tuple(c), "contacts": 0, "margin": None,
                                   "balanced": None})
                      continue
                  hull = _hull2d(np.round(low[:, :2], 6))
                  m = _signed_margin((c[0], c[1]), hull)
                  rows.append({"frame": f, "cog": tuple(float(v) for v in c), "contacts": len(low),
                               "margin": m, "balanced": m >= 0.0, "support": hull})
          finally:
              sc.frame_set(cur)
          grounded = [r for r in rows if r["margin"] is not None]
          return {"floor_z": floor_z, "rows": rows,
                  "airborne": [r["frame"] for r in rows if r["margin"] is None],
                  "off_balance": [r["frame"] for r in grounded if not r["balanced"]],
                  "min_margin": min((r["margin"] for r in grounded), default=None)}
      
      
      def _project_px(sc, cam, P, res):
          """World points (N, 3) -> pixel x (from left), y (from top), in-front mask, using
          the camera's own projection (perspective or ortho, shift, sensor fit)."""
          import numpy as np
          W, H = res
          dg = bpy.context.evaluated_depsgraph_get()
          Pm = np.array(cam.calc_matrix_camera(dg, x=W, y=H, scale_x=sc.render.pixel_aspect_x,
                                               scale_y=sc.render.pixel_aspect_y), dtype=np.float64)
          Vm = np.array(cam.matrix_world.inverted(), dtype=np.float64)
          h = np.c_[P, np.ones(len(P))] @ (Pm @ Vm).T
          w = h[:, 3]
          front = w > 1e-9
          ws = np.where(front, w, 1.0)
          x = (h[:, 0] / ws + 1.0) / 2.0 * W
          y = (1.0 - (h[:, 1] / ws + 1.0) / 2.0) * H
          return x, y, front
      
      
      def staging_report(objs, frames, camera=None, res=None, shadow_frames=(), out_dir=None,
                         min_size=0.12, min_fill=0.5, edge_margin=0.03, dark_eps=0.03):
          """Readability of a shot through its camera [added; thresholds calibrated on the E3
          ball runs, see critique.md]. Per frame, from the evaluated meshes: the subject's
          screen bounding box in pixels, its size (box height / frame height), how much of it
          is inside the frame. Summary:
            size_median, size_min     subject height as a fraction of frame height
            action_w, action_h        how much of the frame the whole action covers
            entry                     'off-screen', 'partial' or 'in frame' at the first frame
            first_full, last_inside   first frame fully in frame; inside fraction at the end
            end_margin, end_x         final pose: distance to the nearest edge (fraction of the
                                      frame width) and screen x of its centre (0..1)
            edge_frames               frames (after the entry) where the subject is cut
          `shadow_frames`: frames rendered three times with the scene's own engine and
          settings at 50% (with the subject, without it, subject mask) to prove a contact
          shadow is visible: shadow pixels = darker by more than `dark_eps` outside the
          subject; visible = at least 5% of the subject's pixel area and a mean darkening of
          0.04 or more (0.043 was faint but visible in the E3 run); reports area, darkening
          and the gap between subject bottom and shadow (0 px at a contact, growing with
          height). Writes PNGs to `out_dir`.
          `flags` lists what to fix, in plain words."""
          import numpy as np
          sc = bpy.context.scene
          objs = objs if isinstance(objs, (list, tuple)) else [objs]
          cam = camera or sc.camera
          r = sc.render
          res = res or (int(r.resolution_x * r.resolution_percentage / 100),
                        int(r.resolution_y * r.resolution_percentage / 100))
          W, H = res
          frames = list(frames)
          cur = sc.frame_current
          rows = []
          try:
              for f in frames:
                  _frame_set(sc, f)
                  dg = bpy.context.evaluated_depsgraph_get()
                  P, _ = _world_verts(objs, dg)
                  x, y, front = _project_px(sc, cam, P, res)
                  if not front.any():
                      rows.append({"frame": f, "box": None, "size": 0.0, "inside": 0.0})
                      continue
                  x0, x1, y0, y1 = x[front].min(), x[front].max(), y[front].min(), y[front].max()
                  area = max((x1 - x0) * (y1 - y0), 1e-9)
                  ix = max(0.0, min(x1, W) - max(x0, 0.0))
                  iy = max(0.0, min(y1, H) - max(y0, 0.0))
                  rows.append({"frame": f, "box": (float(x0), float(y0), float(x1), float(y1)),
                               "size": float((y1 - y0) / H), "inside": float(ix * iy / area),
                               "centre": (float((x0 + x1) / 2 / W), float((y0 + y1) / 2 / H))})
          finally:
              sc.frame_set(cur)
          seen = [q for q in rows if q["inside"] > 0]
          sizes = [q["size"] for q in seen] or [0.0]
          boxes = [q["box"] for q in seen]
          if boxes:
              ax0 = max(0.0, min(b[0] for b in boxes)); ax1 = min(W, max(b[2] for b in boxes))
              ay0 = max(0.0, min(b[1] for b in boxes)); ay1 = min(H, max(b[3] for b in boxes))
          else:
              ax0 = ax1 = ay0 = ay1 = 0.0
          first = rows[0]["inside"]
          entry = "off-screen" if first == 0 else ("partial" if first < 0.999 else "in frame")
          first_full = next((q["frame"] for q in rows if q["inside"] >= 0.999), None)
          last = rows[-1]
          end_margin = end_x = None
          if last["box"] is not None:
              b = last["box"]
              end_margin = float(min(b[0], W - b[2], b[1] * W / H, (H - b[3]) * W / H) / W)
              end_x = last["centre"][0]
          edge = [q["frame"] for q in rows if first_full is not None and q["frame"] > first_full
                  and q["inside"] < 0.999]
          rep = {"res": res, "rows": rows, "size_median": float(np.median(sizes)),
                 "size_min": float(min(sizes)), "size_max": float(max(sizes)),
                 "action_box": (ax0 / W, ay0 / H, ax1 / W, ay1 / H),
                 "action_w": (ax1 - ax0) / W, "action_h": (ay1 - ay0) / H,
                 "entry": entry, "first_full": first_full, "last_inside": last["inside"],
                 "end_margin": end_margin, "end_x": end_x, "edge_frames": edge, "shadow": []}
          flags = []
          if rep["size_median"] < min_size:
              flags.append(f"subject small: median {rep['size_median']:.1%} of frame height "
                           f"(< {min_size:.0%}); move the camera in, use a longer lens or shorten the travel")
          if max(rep["action_w"], rep["action_h"]) < min_fill:
              flags.append(f"action covers only {rep['action_w']:.0%} x {rep['action_h']:.0%} of the frame; "
                           "frame the action, not the set")
          if rep["action_box"][1] > 0.3:
              flags.append(f"top {rep['action_box'][1]:.0%} of the frame is empty all shot; "
                           "lower or tilt the camera, or tighten")
          if entry == "in frame" and len(rows) > 1:
              flags.append("subject already fully in frame on the first frame; an entering action "
                           "reads better from off-screen or cut by the edge")
          if last["inside"] < 0.999:
              flags.append("final pose is cut by the frame edge")
          elif end_margin is not None and end_margin < edge_margin:
              flags.append(f"final pose within {end_margin:.1%} of the frame edge")
          if edge:
              flags.append(f"subject cut by the frame edge after its entry on frames {edge[:8]}")
          if shadow_frames:
              rep["shadow"] = _shadow_probe(sc, cam, objs, list(shadow_frames), out_dir, dark_eps)
              for s in rep["shadow"]:
                  if not s["visible"]:
                      flags.append(f"f{s['frame']}: no readable contact shadow "
                                   f"({s['shadow_px']} px, darkening {s['darkening']:.3f})")
          rep["flags"] = flags
          return rep
      
      
      def _shadow_probe(sc, cam, objs, frames, out_dir, dark_eps):
          import numpy as np
          import tempfile
          out_dir = out_dir or tempfile.mkdtemp(prefix="bx_shadow_")
          os.makedirs(out_dir, exist_ok=True)
          r = sc.render
          sh = sc.display.shading
          saved = dict(engine=r.engine, fp=r.filepath, pct=r.resolution_percentage,
                       transp=r.film_transparent, media=r.image_settings.media_type,
                       fmt=r.image_settings.file_format, cmode=r.image_settings.color_mode,
                       light=sh.light, ctype=sh.color_type, scol=tuple(sh.single_color),
                       shadows=sh.show_shadows, outline=sh.show_object_outline, cam=sc.camera,
                       frame=sc.frame_current, stamp=r.use_stamp)
          others = [o for o in sc.objects if o.type not in {"CAMERA", "LIGHT"} and o not in objs
                    and not o.hide_render]
          out = []
          try:
              sc.camera = cam
              r.resolution_percentage = max(1, saved["pct"] // 2)
              r.image_settings.media_type = "IMAGE"
              r.image_settings.file_format = "PNG"
              r.image_settings.color_mode = "RGBA"
              r.use_stamp = False
              for f in frames:
                  _frame_set(sc, f)
                  paths = {k: os.path.join(out_dir, f"shadow_f{int(f):04d}_{k}.png")
                           for k in ("with", "without", "mask")}
                  r.filepath = paths["with"]
                  bpy.ops.render.render(write_still=True)
                  for o in objs:
                      o.hide_render = True
                  r.filepath = paths["without"]
                  bpy.ops.render.render(write_still=True)
                  for o in objs:
                      o.hide_render = False
                  for o in others:
                      o.hide_render = True
                  r.engine = "BLENDER_WORKBENCH"
                  r.film_transparent = True
                  sh.light, sh.color_type, sh.single_color = "FLAT", "SINGLE", (0.0, 0.0, 0.0)
                  sh.show_shadows, sh.show_object_outline = False, False
                  r.filepath = paths["mask"]
                  bpy.ops.render.render(write_still=True)
                  for o in others:
                      o.hide_render = False
                  r.engine, r.film_transparent = saved["engine"], saved["transp"]
                  sh.light, sh.color_type, sh.single_color = saved["light"], saved["ctype"], saved["scol"]
                  sh.show_shadows, sh.show_object_outline = saved["shadows"], saved["outline"]
                  A_, B_, M_ = (_load_rgba(paths[k]) for k in ("with", "without", "mask"))
                  la, lb = A_[..., :3].mean(axis=2), B_[..., :3].mean(axis=2)
                  m = M_[..., 3] > 0.5
                  md = m.copy()
                  for _ in range(2):                       # dilate the subject mask 2 px
                      d = md.copy()
                      d[1:] |= md[:-1]; d[:-1] |= md[1:]; d[:, 1:] |= md[:, :-1]; d[:, :-1] |= md[:, 1:]
                      md = d
                  dark = (lb - la > dark_eps) & ~md
                  n = int(dark.sum())
                  ys, xs = np.nonzero(dark)
                  mys, mxs = np.nonzero(m)
                  gap = None
                  if n and len(mys):
                      bottom = mys.max()
                      cols = (xs >= mxs.min() - 2) & (xs <= mxs.max() + 2)
                      below = ys[cols] if cols.any() else ys
                      gap = float(max(0, below.min() - bottom)) if len(below) else None
                  dk = float((lb - la)[dark].mean()) if n else 0.0
                  out.append({"frame": f, "shadow_px": n, "subject_px": int(m.sum()), "darkening": dk,
                              "gap_px": gap, "visible": bool(n >= 0.05 * max(int(m.sum()), 1) and dk >= 0.04),
                              "images": paths})
          finally:
              for o in objs + others:
                  o.hide_render = False
              r.engine, r.filepath = saved["engine"], saved["fp"]
              r.resolution_percentage = saved["pct"]
              r.film_transparent = saved["transp"]
              r.image_settings.media_type = saved["media"]
              r.image_settings.file_format = saved["fmt"]
              r.image_settings.color_mode = saved["cmode"]
              r.use_stamp = saved["stamp"]
              sh.light, sh.color_type, sh.single_color = saved["light"], saved["ctype"], saved["scol"]
              sh.show_shadows, sh.show_object_outline = saved["shadows"], saved["outline"]
              sc.camera = saved["cam"]
              sc.frame_set(saved["frame"])
          return out
      
      
      def format_staging(rep):
          """Short text of staging_report for the agent's notes."""
          L = [f"subject size: median {rep['size_median']:.1%}, min {rep['size_min']:.1%}, "
               f"max
  • SKILL.md 19.8 KB
    ---
    name: scenario-blender-animation
    description: "Use when animating characters or objects in Blender through Python, a bouncing ball, walk or run cycle, jump, acting shot, lip sync, eye darts or facial animation, blocking in stepped keys, splining, polish, timing and spacing, arcs, overlap, squash and stretch, graph editor handles, slotted actions or NLA layers, staging a playblast, or when animation looks floaty, stiff, slides, pops, chatters, the subject is too small in frame, or a knee pops and needs an expert review."
    license: MIT
    ---
    
    # Blender animation (character and object)
    
    Expert animation means every frame on screen is a choice: key poses the audience reads, holds and snaps placed by hand, spacing designed, arcs checked. The Blender Studio stance is to block stepped, spline late and in parts, and judge from the shot camera at real speed. An agent cannot scrub, so it measures instead: per-frame positions, spacing charts, arc checks, framing, an onion-skin image, a curve plot, and a playblast it actually looks at. 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-blender-expert (execution channel, review loop, 5.2 API changes). Shot planning, cameras and animatics belong to scenario-blender-previs-storyboard; building the rig to scenario-blender-rigging.
    
    Toolkit: [`scripts/bx_anim.py`](scripts/bx_anim.py) (keying, blocking-to-spline, curve checks, measurement, staging, playblast light, knee-pop, flip and balance checks, onion-skin and curve images, one-object `bouncing_ball`; API in procedures.md, tested on 5.2.1) and `bx_review.playblast`.
    
    ## Stance (the expert delta)
    
    - **Stay stepped long ("Blocking+").** Hjalti Hjalmarsson: juniors spline too early; computer in-betweens are "unintentional interpolated poses" that make notes hard. Keep adding hand-made breakdowns while stepped; splining is short and late.
    - **Never convert the whole blocking to Bezier.** Rik Schutte: key every main control on every key pose, copy each pose to the end of its hold ("pillars"), then spline the body only; face and fingers stay stepped until their own pass. Measured on 5.2.1: the naive conversion drifted 84% of the next move across a hold, the pillars held at exactly 0.
    - **Snaps are authored.** Rik: hold, then change pose in 2 to 3 frames; only big deliberate moves are left to interpolation. [added, measured] A contact inside continuous motion (a jump landing) is not a hold: pillaring it cut the body's speed at impact from 0.101 to 0.007 m per frame, a floaty landing.
    - **Odd frames, even gaps; breakdowns shape arcs and steer the eye.** Alex Nagy: key poses on odd frames with even spacing so a true middle frame exists. The breakdown sets the arc first, the timing second; favoring it late leaves more travel for the last frames, and the fastest part draws the eye.
    - **The handle type is a timing decision.** AUTO_CLAMPED keeps holds flat; AUTO overshoots between two equal keys (Alex uses it to push a pose or cushion a settle); VECTOR for hard hits, contacts and take-offs (Alex, Dillon Gu); FREE for burst spacing and exact arcs (Raymond Luc, Joey Carlino).
    - **Lead with one part, layer the rest.** Rik blocks a torso-only rhythm pass first; Dillon leads body mechanics with the COG and acting with the head or eyes. Overlap goes into the blocking, and a head never turns on one axis only (Rik).
    - **Lip sync is rhythm before shapes.** Rik: jaw first, stepped, opened big only on accents; off-sync is a missing preparation, not a global offset; M, B, P closed at least 2 frames and closed before the sound.
    - **Spacing is felt, arcs are checked, the shot must read.** Raymond: the audience should feel spacing tricks, not see them. Pablo Fournier: arcs are the most important check (root, hands, feet, nose), in camera space. Hjalti (in scenario-blender-previs-storyboard): after each cut the eye must find one thing, so a subject lost in an empty frame fails however good the curves are.
    
    ## Establish first
    
    | Input             | Why it changes the plan                                                                                                                                                                                                                                                                | Default when silent                                                      |
    | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
    | Medium, fps       | Film 24 (Hjalti); games often 30 with fixed hit or release frames (Picaut)                                                                                                                                                                                                             | 24 fps                                                                   |
    | Camera            | Locked acting shot: judge only through the camera (Rik). Moving, re-framable or gameplay camera: poses work from every direction (Leo Silly-Pelissier, Picaut)                                                                                                                         | locked camera                                                            |
    | Style             | Stepped on twos and never splined (Sprite Fright shots, Rik) vs splined; stylized vs naturalistic (Leo)                                                                                                                                                                                | splined, stylized                                                        |
    | Rig               | Control names, IK/FK, rotation modes. Generated Rigify [verified]: `torso`, `hips`, `chest`, `head`, `foot_ik.L`, `upper_arm_fk.L`, `jaw_master`; `["IK_FK"]` on `upper_arm_parent.L` (0 IK, 1 FK); legs have `IK_Stretch` 1.0; rest legs are 99.6% straight; main controls QUATERNION | FK arms when arcs matter, IK when hands touch things (Joey, Tony Garcia) |
    | Named object      | A brief that names the animated object expects every channel on it                                                                                                                                                                                                                     | one object, `bouncing_ball(obj=...)`                                     |
    | Audio, transcript | Beats, accents, phoneme preparation                                                                                                                                                                                                                                                    | beats from the brief                                                     |
    | Reference         | Timing and beats, never a trace (Hjalti)                                                                                                                                                                                                                                               | none: write a beat list                                                  |
    | Budget            | Rik: about 3 s a week in big studios, 5 to 6 s on his acting shot; Flow: 2 s per animator per day                                                                                                                                                                                      | state what the budget buys                                               |
    
    ## Workflow
    
    1. **Plan.** Beat list with frames: every acting beat in order (Hjalti counts about 8 per shot), sub-beats notice, react, realize, respond (Rik), the leading part per beat. With audio, `audio_envelope` + `accent_frames`; hit a pose just before its audio peak (Alex). Start mid-action (Rik). GATE: beat table written.
    2. **Set up and stage the shot** (P1, P18): fps, range, sound, passepartout 0.99, Simplify for previews. Place the camera, then `staging_report(subject, frames)` with the real render size: subject height, how much of the frame the action covers, entry, final pose margin. Playblast look: `playblast_lighting(key_dir=(0, 0, 1))` (overhead key converted to Workbench's camera space: the shadow sits under the subject, the gap is its height, contact is where they meet), `floor_slab`, `rotation_texture` on anything that rolls. GATE: `flags` empty; `shadow_frames` visible in the air and touching at a contact; camera stills looked at.
    3. **Block A, key poses stepped.** Strongest, longest-held pose first (Alex). Build each pose COG, feet (weight over the support foot, feet never twins, knees soft), hips, chest, head, arms. Pose in world terms with `world_pose` (rig axes are arbitrary), key with `key_pose(..., keytype='EXTREME')`, CONSTANT, all main controls. GATE: pose strip `onion_skin(frames=keys, spread=...)` shows clear silhouettes and contrast; `balance_report` margin >= 0 on held poses.
    4. **Blocking+ (still stepped).** `breakdown` at the middle frame with per-part `factors` (COG dips, extremities delayed at 0.2 to 0.3, directed part favored late; after a take-off the body is ballistic, about 2/3 of the rise in 3 of 7 frames, and the feet must follow); `ease_inbetweens` for 20/80 (Pablo); overshoots and settles; overlap offsets; key types as a confidence map (Hjalti). GATE: stepped playblast approved; notes on intent stopped (Leo: none after Block B).
    5. **Spline the body** with `blocking_to_spline(rig, body_bones, transition=3, interpolate={...})`: big moves and continuous contacts in `interpolate`; VECTOR on foot contacts and take-offs. GATE: `hold_drift` < 1e-5, `overshoots` only wanted settles, face still CONSTANT, `motion_report` without unintended constant spacing, `ballistic_fit` on the airborne COG with `g_ratio` near 1.0 and a small residual, `limb_extension` never above 1.0.
    6. **Re-inject energy** (Leo: splining softens): timing accents, overshoots, desynchronized parts, `offset_keys` 1 to 2 frames per chain link (Dillon), head lag 2 to 3 frames in cycles (Joey), contacts that slide rather than glue (Rik). Fingers: little overlap, no bounce. GATE: arcs clean in camera pixels (`to_camera(..., res=(w, h))`, `motion_report(dims=2)`).
    7. **Face and lip sync, stepped** (P6, P7): jaw rhythm, corners (never dead center), shapes from a pose library on the lower face minus the jaw, accents and asymmetry in small doses, tongue last, then spline the face. Eyes dart in 1 to 2 frames and lead the head; brows 3 to 10 frames (Rik). GATE: `transitions` meets those counts; accents clearly fewer than syllables.
    8. **Polish.** `foot_slip` under 1 mm, `lowest_point` for penetration, `rotation_flips` empty (`fix_rotation_flips`), `limb_extension` under 0.99 wherever the leg should stay bent, smears 1 frame (Pablo), burst spacing only now (Raymond), subdivision on. GATE: critique.md rubric passes on the final playblast, onion skin and graph image.
    
    **Variants.** Bouncing ball (P16, P18): `bouncing_ball(obj=ANIM_ball, first_apex=-3, fit=False, ...)` keys ONE object: exact parabolas under one gravity through whole-frame contacts, odd or even flights so small hops keep their height, squash on the contact frame and stretch along the velocity through a world-aligned squash modifier (the roll is never twisted), roll = distance / radius, constant-deceleration roll, a 6-frame hold. `first_apex` before `start` throws it in from off-screen. Weight mapping is [added]: tune by render. Walk (Joey, 30 fps): contact 0, down 5, passing 10, up 15, 40-frame cycle; heel curve straight during contact, root linear with REPEAT_OFFSET (P5). Jump: `tests/code/blender-animation/test_rigify_jump.py` is the worked example (Rigify human, blocking to spline).
    
    ## Numbers
    
    | Item                              | Value                                                                                                 | Source                             |
    | --------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------- |
    | Snappy pose change                | 2 to 3 frames after the hold                                                                          | Rik                                |
    | Aggressive change                 | 4 frames instead of 8                                                                                 | Alex                               |
    | Hold to read an idea              | 6 frames                                                                                              | Alex                               |
    | Settle                            | 1 to 2 frames, 4 soft                                                                                 | Alex                               |
    | Reaction after stimulus           | about 6 frames                                                                                        | Rik                                |
    | Eye dart / brow change            | 1 to 2 / 3 to 10 frames                                                                               | Rik                                |
    | Ease rule                         | 80% of the remaining way per in-between (slow-in); mirror 20%                                         | Pablo                              |
    | Smear                             | 1 frame, sometimes 2                                                                                  | Pablo                              |
    | Micro-anticipation                | last 1 to 2 frames before hit or release pushed to an extreme                                         | Picaut                             |
    | Head lag in a cycle / foot offset | 2 to 3 frames / 1 frame                                                                               | Joey / Rik                         |
    | Real gravity at 24 fps            | acceleration 0.01703 m/frame^2; a quadratic fit's leading coefficient is half (-0.00852). `gravity()` | physics [added]                    |
    | Subject size in frame             | 12% of frame height minimum, 15% or more for a single subject                                         | [added] E3: both runs measured 10% |
    | IK leg extension                  | under 0.99 of full length while meant bent; above 1.0 = IK stretch                                    | [added]                            |
    | Pinwheel on a rolling object      | roll per frame under 360 / sectors degrees                                                            | [added] wagon-wheel                |
    
    ## Quality gates
    
    **Measurable:** slot named after the character; key poses CONSTANT and typed before approval; `hold_drift` ~0; `overshoots` only on settles; `foot_slip` < 1 mm; `lowest_point` never below the floor (sample sub-frames); `transitions` for darts, brows, snaps, plosives; `ballistic_fit` g_ratio near 1 at real scale; `motion_report`: sharp turns only at contacts, no arc wobble or start-stops inside a move; `staging_report` flags empty with shadow frames visible; `limb_extension`, `rotation_flips`, `balance_report` as above; `roll_aliasing` ok.
    **Visual** (open every image): pose strip, onion skin through the shot camera with tracks, `graph_image`, playblast frame sheets (subject size, entry, shadow gap closing at contacts, spin readable), orbit views when the camera is not locked.
    
    ## Common mistakes
    
    | Mistake                                  | What it looks like                                                           | Fix                                                           |
    | ---------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------- |
    | Whole blocking to Bezier                 | "floaty spaghetti"                                                           | `blocking_to_spline` with pillars, body only                  |
    | Pillar on a continuous contact           | body brakes before impact                                                    | put that frame in `interpolate`; VECTOR on the foot           |
    | AUTO handles on a hold                   | hold drifts past the pose                                                    | AUTO_CLAMPED; AUTO only for a settle                          |
    | Heel curve eased during contact          | foot slides (5.8 cm per step measured)                                       | VECTOR or zero-length FREE handles                            |
    | Jaw open on every syllable               | chattering mouth                                                             | big opens on `accent_frames` only                             |
    | BOUNCE easing for a ball                 | no squash frame ever shows                                                   | `bouncing_ball` or keyed whole-frame contacts                 |
    | Squash on a parent that also aims        | pattern twists on stretch frames (55 degrees measured on the old rig)        | `squash_modifier` (world axes) on the object itself           |
    | Flights rounded to even frames           | hops lose or gain height, gravity changes per bounce (0.98 to 1.31 measured) | whole-frame contacts, odd flights allowed, one gravity        |
    | Framing the whole set                    | subject 10% of frame, top 40% empty                                          | move in or shorten the travel until `staging_report` is clean |
    | `display.light_direction` read as world  | shadow hidden behind the subject (0 px)                                      | `playblast_lighting(key_dir=...)`                             |
    | Arcs checked in normalized camera coords | false sharp turns at apexes (aspect squeezes x)                              | `to_camera(..., res=(w, h))`                                  |
    | Legs straight at stand or push-off       | IK stretch lengthens the leg (18% in the first jump draft)                   | soft knees, feet breakdown after take-off, VECTOR take-off    |
    
    ## Blender 5.2 notes
    
    - `action.fcurves` is gone: channelbags via `bpy_extras.anim_utils` (`bx_anim.channelbag`). Assigning an action animates only if a slot matches; otherwise set `ad.action_slot`.
    - One layer per action (`layers.new` raises); layer with the NLA plus an ADD or COMBINE action (`push_layer`).
    - `keyframe_points.insert` gives BEZIER AUTO_CLAMPED; a Keyframe reference dies at the next insert on that curve.
    - Headless: `anim.keyframe_insert` fails; `pose.breakdown` and `pose.blend_to_neighbor` need explicit `prev_frame`/`next_frame`; `render.opengl` refuses, so playblasts are Workbench renders.
    - Workbench `scene.display.light_direction` is camera-relative; `matrix_world` is stale until `view_layer.update()`, so convert after it.
    - GN modifier inputs key at `modifiers["X"].properties.inputs.Socket_n.value`.
    - Playblast audio: sound strip plus `render.ffmpeg.audio_codec = 'AAC'`.
    - Keymap: Up = previous key, Down = next (5.0). Not in 5.2: action layers, paste-pose blending, range world-space copy (5.3).
    
    ## References
    
    - [`references/expert-notes.md`](references/expert-notes.md): principles, numbers and disagreements by expert, with timestamps; load when choosing between approaches or writing notes.
    - [`references/procedures.md`](references/procedures.md): full tested code for every procedure (setup, blocking, spline, walk, lip sync, facial timing, contact pin, slots, layers, pose library, playblast, measurement, ball, staging, knee/flip/balance); load before writing code.
    - [`references/critique.md`](references/critique.md): the review rubric per stage, staging thresholds and their calibration, how these animators phrase notes; load at every gate.
    - [`references/sources.md`](references/sources.md): the 14 source videos, what each is best for, best timestamps.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related