Claude Skill

scenario-blender-previs-storyboard

Use when turning a script or story into previs, layout, storyboards or an animatic in Blender: beats to shot list, camera and lens per shot, staging, camera height, 180-degree rule, screen direction, shot sizes, when to cut, camera moves, timing stills in the Video Sequencer, mar

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-previs-storyboard-dc592af.zip · 57 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-previs-storyboard
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

Previs, layout and storyboarding

Expert previs conveys an idea well enough to be judged, as cheaply as possible: one fact per beat, every shot designed for what the audience must feel, timing found in the edit from stills. The camera is the audience's body; where it stands, how high and through which lens decides the emotion, so every non-neutral choice needs a stated reason. 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).

Toolkit: scripts/bx_previs.py (tested on 5.2.1): import sys; sys.path += ["<this skill>/scripts", "<scenario-blender-expert>/scripts"]; import bx_previs as P.

Which skill. This one: story previs and animatics judged by a director and an editor. text-image-to-blender-blockout instead when the playblast guides a render-to-real video model (Seedance): saturated color identities, no lens jump on a cut, eased moves, a hand-off sheet; an approved animatic that must become a video guide is rebuilt there. Shared: metric Z-up, roots at the feet, one camera per shot bound to a marker, to_track_quat('-Z','Y') look-at. Strokes: scenario-blender-grease-pencil. Animation after layout: scenario-blender-animation. Real rigs: scenario-blender-rigging.

Stance (the expert delta)

  • Hjalti Hjalmarsson: write the beats-of-information list before any shot. One fact per line, because a viewer takes in one thing at a time. Group consecutive beats into shots; a new treatment re-orders the same facts. A missing beat means the idea never gets a fair test.
  • Hjalti: theater first, then cinematic; shots cost time. A locked-off version judges the acting; the same beats told in shots ran about 2x (up to 3x) because every cut must be read (13/22/25 s vs 47/40 s). The oversimplified version is always too short and hides logistics (a character cannot cross the room in 10 frames).
  • Charge recipe (Hjalti): treat every frame as a storyboard drawing. 10 frames per shot, keys CONSTANT, an A/B alternative on the next frame, the winner on the decade frame, stills exported with versioned names, timing done in a separate edit.
  • Hjalti: when in doubt, eye height; lock the lens early by A/B. Two characters: split their eye heights. Low or high is a statement. Poses built for one lens do not survive a lens change.
  • Dillon Gu: composition and focal length are separate decisions, and cuts can kill a mood. The audience stands where the camera is. Leaving the space removes the danger; two inanimate inserts in a row lower tension.
  • Cheat for the camera, but key before you cheat (Hjalti, Charge, Renato Roldan). Resize, float, swap props per shot; in a shared timeline an unkeyed cheat breaks the earlier shots.
  • Previs is the cheapest version that still conveys the idea (Hjalti, Charge). Animate only shots whose camera move is the storytelling; everything else is one still, push-ins faked on the still in the edit. Grays in Workbench, no mood lighting, mannequins before rigs. P.audit refuses a camera moving inside a still shot, keys between block starts and a non-Workbench engine.
  • After each cut the eye must find one thing (Hjalti). Restage a character in front of a competing subject; keep the angle that preserves the emotion (he refused a side cut that turned Rex into a silhouette).

Establish first

Input Changes Default when silent
Format every composition (Charge: lock it first) 1920x1080, 24 fps, stills at 50 %
Treatment shot count, runtime theater pass, then cinematic
Method agent has no tablet 3D previs with mannequins; drawn boards only on provided art; Rigify proxies only on request (P.rigify_proxy, Rigify is off under --factory-startup: P.ensure_rigify() first)
Mood or director style lens, height, motion per shot neutral grammar, deviations stated per shot
Runtime budget durations about 1.6 to 1.9 s per beat for a cinematic cut (derived from Hjalti's slide: 25 beats, 8 shots, 40 to 47 s)
Journeys screen direction out left to right, back right to left (Charge)
Deliverable outputs shot list, contact sheet, floor plan, animatic MP4 clean plus burn-in, versioned .blend

Workflow

  1. Script to beats. Asset list, then numbered beats, one fact each; mark every beat you add. GATE: P.shot_list(BEATS, SHOTS) raises on a missing, duplicated or reordered beat.
  2. Set and proxies. P.setup_scene (format, Workbench, grays, Standard view transform); P.prop for real-scale set pieces; P.mannequin for characters (root at the feet with eye_height, four pivot controls as the layout subset). Grays, one pop color for the hero (Renato: red). GATE: scale reads next to a door (2.1 m) and counter (1.0 m).
  3. Theater pass. One locked-off camera (cam shared, fit="all", 35 mm, mean eye height) framing the action's extremes; one block per beat group. GATE: in the P.audit rows every subject's head_in and feet_in is true at every block; theater edit timed.
  4. Cinematic plan. List several openings, re-order beats, group them into shots with intent, size, lens, height, dur. P.floor_plan with the action line and journey paths before judging cameras. GATE: one side of the line; journeys follow the direction rule.
  5. Blocking. For each block: sc.frame_set(f) (holds the previous pose), set only what changes, P.key_block(all_movables, f); cheats go through the same keys; then P.make_stepped. GATE: P.stepped_report == {}; the cheat check at neighboring shots.
  6. Cameras. P.build_shots places each camera from size, lens and eye height (or explicit loc/aim), sets an opaque passepartout (Charge: see only the composition; P.passepartout(sc) for other cameras) and binds a marker at its first frame. P.ab_lens on key shots before posing further. GATE: P.audit(...)["problems"] == [].
  7. Look. P.contact_sheet and open it. Characters merging with a dim set: P.fog_for_shot(sc, t, chars) (Hjalti: depth fog for readability, not lighting; behind the subjects, keyed to that shot), P.values_at hero vs backdrop. GATE: the visual list under Quality gates (in S6 only the pictures caught the occlusions and the cut-off wide).
  8. Edit. P.render_stills, then P.animatic (image strips held dur seconds, push on stills, live shots as scene strips, sounds, burn_in). One continuous action from several angles (Pablo, Story Tools): a live shot plus cover=True shots become one scene strip soft-split per camera; retime with P.retime_cut (the marker follows), never by moving keys. GATE: runtime within budget; frames pulled from the MP4 match the cut list.
  9. Iterate in a new scene pair (02_blend/02_edit) and new version numbers; nothing overwritten.

Numbers

Value Relative to Source
10 frames per shot block, 20 only when justified; starts on 10, 20, 30 previs timeline Charge
Shots named in tens (sh010, sh020) markers Hjalti on screen
35 mm theater frame; about 60 mm for an isolating key shot 36 mm sensor Hjalti
F-stop 5.6, clip 0.01 to 200 m on the camera rig Hjalti's rig Hjalti
13 / 22 / 25 s theater vs 47 / 40 s cinematic same script Hjalti slide 00:46:01
3 poses for an anger burst (anticipation, overshoot, settle) one beat Hjalti
6 fps boarding, retimed in the edit board scene BouncyBrain
Eye tolerance 0.15 m; drawing breaks past 70 degrees from camera audit thresholds [added]
Visible height = subject height x 4 / 2 / 1.15 / 0.8 / 0.6 / 0.4 / 0.25 / 0.12 ews, wide, full, mws, medium, mcu, cu, ecu [added]
Distance = visible height x lens / sensor height (20.25 mm at 16:9) lens swap at equal size: d2 = d1 x f2 / f1 [added]
Handheld NOISE 0.010 to 0.018 rad, 18 to 26 frame scale camera rotation blockout skill; peak-to-peak measured 0.66 x strength

Camera language

Choice Reads as Source
Eye level, medium lens, centered, static neutral; the start for every A/B Dillon, Hjalti
Low or tucked in a corner hidden observer Dillon
Wide lens from far, then a quick zoom in being watched; someone fixing on a target Dillon
Extreme wide lens close up "not okay", distortion Dillon
Long lens informational, flat, isolates, intensifies a two-character moment Dillon, Hjalti, Spitfire
Top view; dolly zoom (linear keys) pressure from above; loss of control Dillon
Off-center from behind isolation Dillon
Side-on movement the most neutral screen motion Dillon
Tilt or move ending on a detail that detail becomes the subject Hjalti
Faceless framing, longer shots, significant object at the edge carried by sound routine Dillon

Moves that feel physical (Dillon): handheld noise when a watcher or a groggy body holds the camera, a camera operator's quick zoom, the camera dropping with the character's shoulders, less motion for laziness, more for urgency; a product shot is the stillest shot. Hjalti pulls focus across a handoff (dof.focus_object). Cuts: one side of the action line; an over-the-shoulder uses the shoulder the camera already occupies, and a jump cut can mean elided routine time (Dillon); a POV sits just off the line on the camera side [added, S6].

Quality gates

Measurable: P.audit(sc, table, chars, line=(a, b), travel={name: "right"}, stepped=movables): marker switch at each shot frame and not one frame before, main subject's eye line in frame and not blocked (ray cast names the blocker, fog included), measured vs intended size, height class with an intent for every non-eye camera, one side of the action line (POV and cross exempt), screen direction per journey, block length, two inserts in a row, non-constant keys outside live blocks, opaque passepartout, cost gates; warnings lists lights. Plus the edit runtime, ffprobe streams and duration, and for live shots an edit frame rendered against the previs frame.

Visual (open each image): contact sheet (every beat visible, one focal point per shot, hero readable, cheats and occluders invisible, pose readable from this camera), floor plan (camera side, journeys), A/B sheet (isolation vs context), frames pulled from the MP4 (order, burn-ins, push, live-shot content). Comic timing and sound stay a human call (Hjalti).

Common mistakes

Mistake Looks like Fix
Shots sprinkled over a finished performance generic coverage, wrong framings design each shot from its beats (Hjalti)
Wide framing that ignores the extremes the tallest pose or entrance clips fit="all" and the extremes audit
POV from the true eye point the counter or the character's own head blocks the view cheat the POV forward and up (S6: 0.6 m forward, 1.72 m high, over the counter); the audit reports the blocker
Door or prop between camera and hero hero hidden in the key frame swing it away or move the camera; the contact sheet catches it
Over-the-shoulder from the far side line crossed, hero out of frame stay on the camera side; audit eye line
Unkeyed cheat earlier shot changes key_block every movable at every block
6 fps board scene as a scene strip in a 24 fps edit plays 4x fast export stills or retime
Every shot animated, mood lighting, full rigs slow, the edit fights the moves, nothing judged sooner stills plus push, live only when the move is the meaning, Workbench grays, mannequins
Coverage as separate strips or moved keys cuts drift from the action cover=True run, P.retime_cut
Fog world-aligned or in front hero tinted, fog in every shot P.fog_for_shot (behind the subjects, one shot)

Blender 5.2 notes

  • bpy.ops.render.opengl fails in --background; use Workbench render.render (headless) or a camera-locked viewport in a live session.
  • keyframe_insert() ignores the new-key interpolation preference (keys stay BEZIER) though "Only Insert Available" is on: call P.make_stepped.
  • frame_set on a scene that is not the window scene leaves transforms stale, and a write_still render of such a scene uses the window scene's frame: P.activate, P.render_frame.
  • VSE: strips, left_handle/right_handle, content_start; SceneStrip.frame_start and frame_final_* warn (removal in 6.0). Strip.split(frame, split_method='SOFT') splits from data. New scenes default to AgX: set the edit scene to the source view transform.
  • marker.camera = cam binds headless; bpy.ops.marker.camera_bind needs a timeline context.
  • Rigify is off under --factory-startup: bpy.ops.object.armature_basic_human_metarig_add raises "could not be found" until P.ensure_rigify().
  • Selection Sets, Storypencil and Grease Pencil Tools are not bundled; use a bone collection layout. The Storyboarding app template ships an Edit scene with sync on and auto-key off in Edit (pin off in its Storyboarding workspace).
  • GP v3 strokes render in a headless Workbench still.

References

Files (skills)
  • references
    • critique.md 10.4 KB
      # Critique rubric: judging a previs like the experts
      
      Use it at every Look gate: open the contact sheet (and the floor plan, the A/B sheet, frames pulled from the MP4), run `P.audit`, then go through the list. Each item is pass, or a named fix. Report what could not be judged (comic timing with sound, mood with a human audience).
      
      ## 1. Beats and structure (Hjalti)
      
      - Every fact of the script is a numbered beat, one fact per line; every added beat is marked and justified. Measurable: `P.shot_list` accepted the grouping.
      - Every beat is visible in some thumbnail. Put the beats list next to the contact sheet and tick them. A beat that exists only in the intent text is missing.
      - Logistics hold: the distances characters cover fit the time the edit gives them (Jay cannot cross the room in 10 frames). An oversimplified version is too short by construction.
      - Props read instantly (a snack reads as a snack from this camera; the sprinkle shaker did not).
      
      ## 2. Treatment and length (Hjalti)
      
      - A theater version exists (or a reason for skipping it) and was judged for acting before cameras.
      - Runtime is budgeted: cinematic about 2x theater, up to 3x; about 1.6 to 1.9 s per beat for a first cinematic cut [derived]. Too long means gags over-milked; too short means beats missing.
      - Only the camera moves that carry meaning are animated; every other shot is one still. Measurable: `audit` flags a camera moving inside a still shot and keys between block starts outside live shots.
      - Cost: Workbench grays, no mood lighting (a non-Workbench engine is an audit problem, lights a warning); mannequins unless the brief asks for Rigify (then `ensure_rigify` first).
      
      ## 3. Each shot
      
      - **Intent written** for the shot (what the audience must feel or learn), and the camera choice follows from it. A non-eye-height camera without an intent is an audit problem.
      - **Size fits the beat:** measured size (`size_measured`) close to the plan; the hero readable at thumbnail size. The robot at 0.55 m covered 0.15 of the frame height in the S6 wide: fine for a return-to-neutral, too small for a reveal.
      - **One thing to look at** after the cut (Hjalti). Two comparable subjects in a reveal: restage one in front of the other.
      - **Nothing blocks the hero:** audit `eye_visible` and `blockers`, then look (the ray only tests two points).
      - **Extremes contained** in a locked-off frame: tallest pose, entrance path, widest travel (audit head/feet in frame over every block).
      - **Cheats invisible:** no hovering feet, clipped props or giant furniture in frame; the neighboring shots unchanged (the cheat was keyed).
      - **Pose readable from this camera:** a pose built from the front can vanish from a high angle (S6: arms forward hidden behind the head; arms out to the sides read).
      
      ## 4. Camera language and mood (Dillon, Hjalti)
      
      - Neutral grammar where nothing is meant: eye level, medium lens, centered, static.
      - Each statement matches the table in SKILL.md (low = hidden observer, top = pressure, dolly zoom = loss of control, long lens = informational or isolating, wide from far plus zoom = watched).
      - A move ends on the intended subject (a tilt that ends on a cherry makes the cherry the subject).
      - The emotional read survives the angle (Hjalti refused a side cut that turned menace into a silhouette).
      - Mood survives each transition: no cut out of the space when tension is wanted; no two inanimate inserts in a row (audit flags the second).
      
      ## 5. Continuity
      
      - One side of the action line for every camera that sees both subjects; POVs just off the line on the camera side (audit `side`).
      - Over-the-shoulder on the shoulder the camera already occupies (Dillon).
      - Screen direction per journey, reversed only when the character reverses (Charge; audit `travel`, per-shot override for a return).
      - A jump cut in the same framing is a choice (elided routine time), not an accident.
      
      ## 6. Readability
      
      - Grays, one pop color for the hero; opaque passepartout on every shot camera (audit), nothing outside the frame counts.
      - Silhouettes separate from the background (Workbench outlines on). Where characters and set share a value, `fog_for_shot` behind the subjects; `values_at` hero vs backdrop before and after; the fog must not sit in front of the hero (the audit names it as a blocker) nor show in other shots.
      - Stamped thumbnails legible: shot, lens, height class, size, seconds, intent.
      
      ## 7. Edit and timing
      
      - The edit plays the shots in beat order; frames pulled from the MP4 match the cut list; burn-in version and clean version both rendered.
      - Holds carry the joke (a freeze needs a hold); pushes and live shots land where the beat is.
      - Sound, when used, sits on the beat frame; the length after sound is checked again (sound adds beats, Hjalti).
      - Edit scene view transform equals the stills' (Standard), or the animatic drifts from the stills.
      - A continuous action covered from several angles is a coverage run (one scene strip, soft cuts, a camera per piece); retime it with `retime_cut`, and the markers follow.
      
      ## 8. Hybrid boards (Spitfire, Renato, BouncyBrain)
      
      - Drawings sit on the floor at contacts and read from the chosen camera.
      - No carrier turns a flat drawing past about 70 degrees from the camera (`facing_report`) unless a redraw is planned.
      - Parallax from separated planes reads; world effects stay in world space, only the followed hero rides the glass.
      
      ## 9. Honesty of the delivery
      
      - The report states what was measured, what was looked at, what was cheated, and what remains a human call (comic timing, sound, performance nuance).
      - Nothing overwritten: versioned stills, contact sheets, MP4s, scene pairs.
      
      ## What the numbers cannot judge
      
      Whether it is funny, whether the mood lands on a human audience, whether a gag is milked too long, whether the performance is right. The agent can prepare the evidence (contact sheet, animatic, beat-aligned sound) but must say these are open.
      
      ## Worked example: S6 ("tired shopkeeper, robot steals a snack, freezes like a toy")
      
      Built by `tests/code/blender-previs-storyboard/test_01_s6_end_to_end.py`. 15 beats (two added and marked), a theater pass (10 blocks, one 35 mm camera at 1.03 m, the mean of the two eye heights, 11.8 s) and a cinematic cut (11 shots, 24.5 s = 2.1x the theater pass, 1.63 s per beat).
      
      | shot  | beats | lens          | camera                   | intent                                                           |
      | ----- | ----- | ------------- | ------------------------ | ---------------------------------------------------------------- |
      | sh010 | 1-3   | 24            | eye 1.63 m               | neutral wide: door left, keeper right with his back to the floor |
      | sh020 | 4-5   | 28            | robot eye 0.42 m         | the camera drops into the robot's world                          |
      | sh030 | 6     | 50            | 0.6 m, over its shoulder | long lens: the snack is the goal                                 |
      | sh040 | 7     | 28            | 0.55 m                   | irony two-shot: robot sneaks in front, keeper's back behind      |
      | sh050 | 8     | 60            | 0.46 m                   | insert on the grab, snack cheated to 1.4x                        |
      | sh060 | 9-10  | 50            | eye 1.63 m               | neutral MCU: he hears, starts to turn                            |
      | sh070 | 11    | 35 to 50 zoom | POV 1.72 m, pitch -51    | live: the snap to a toy pose, quick zoom, handheld               |
      | sh080 | 12    | 35            | 0.8 m, low               | he looms (cheated up to clear the counter)                       |
      | sh090 | 13    | 60            | POV 1.72 m               | tighter POV, push on the still (1.12x)                           |
      | sh100 | 14    | 24            | eye 1.63 m               | back to the neutral wide: he shrugs                              |
      | sh110 | 15    | 24            | eye 1.63 m               | same frame, jump cut: robot tiptoes out, right to left           |
      
      **What the review caught** (outputs of each run kept under `archive/tests/previs/skill_out_run1/`):
      
      | Run | Seen on the contact sheet                                                  | Measured?                                                        | Fix                                                                  |
      | --- | -------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------- |
      | 1   | Keeper cut off at the edge of the wide                                     | no (his eye point was just inside)                               | camera back and re-aimed                                             |
      | 1   | Door swinging inward hides the robot (sh020)                               | no                                                               | door swings out; occlusion gate added, now names "door"              |
      | 1   | POV blocked by the counter and by the keeper's own head (sh070, sh090)     | no                                                               | POV cheated forward and up over the counter; gate names "counter"    |
      | 1   | Over-the-shoulder from the far side (sh030)                                | yes: line crossed, wrong screen direction                        | camera moved to the audience side                                    |
      | 1   | Insert too tight: two colored blocks, no hand                              | no                                                               | framed robot and snack together                                      |
      | 1   | Push-in still shrank with black borders                                    | no (a test comparing frames flagged a change, not its direction) | multiply the FIT scale                                               |
      | 2   | Robot out of frame in the new over-the-shoulder                            | yes: eye line out of frame                                       | camera further back                                                  |
      | 2   | Toy pose unreadable from the high POV (arms hidden)                        | no                                                               | arms out to the sides                                                |
      | 3   | All shots read; theater robot tiny (0.55 m at 6.6 m: 15 % of frame height) | yes (size)                                                       | expected: the reason the cinematic cut drops to the robot's eye line |
      
      Lesson: the audit found the continuity errors; only the pictures found the occlusions, the cut-off and the unreadable poses. Look at every sheet.
      
    • expert-notes.md 23.8 KB
      # Expert notes: previs, layout, storyboarding, camera language
      
      The depth behind SKILL.md, by expert. Timestamps are [hh:mm:ss] in the source video (URLs in sources.md). Items marked [added] are this skill's own additions, not the experts'.
      
      ## Hjalti Hjalmarsson, Blender Studio director (Charge, Sprite Fright)
      
      Sources: BCON24 Animation Previs/Layout Workshop (XZtJ64g5VQQ, the anchor) and the Charge previz overview (cTx-n4OZXYE).
      
      **What the job is.** Convey the idea well enough that it can be judged (is it funny or not), with the fewest poses that still carry every beat [00:15:33] [00:45:17]. "No matter how well you do, you will always have a producer saying you could have done it cheaper" [00:48:40]. Previs is disposable art: mannequins, primitives, props modeled inside the previs file because their survival is uncertain, only stable characters linked (Charge [00:01:00] [00:01:34]).
      
      **Process, script to edit** (workshop, ice-cream parlor script: Rex and Jay, a small cup vs a huge sundae):
      
      1. Half-page script, asset list from it [00:03:09] [00:05:52]. Invented proportions may be adopted by design ("80% of that") [00:06:30]. Props must be instantly recognizable (the sprinkle shaker was "a mistake in hindsight") [00:04:15].
      2. **Beats list** [00:07:26]-[00:08:33]: "beats of information coming to the viewer", not story beats or animation poses; "the rule of thumb in filmmaking is one information at a time"; even chaos is one bit of information. Split or merge lines as needed. He always does it, nobody taught him [00:27:30] [00:32:27].
      3. **Floor plan** before the camera [00:09:04]; later with camera positions (#1a, #1b, #2 to #8) and the action line [00:29:11]-[00:30:49]. Floor plans stop two people discussing "the shot" from opposite sides [00:32:27].
      4. **Rig hygiene:** decide the bone subset previs touches and make a "layout" selection set. He skipped it for Jay: "a nightmare of clicking the wrong bone half the time" [00:09:36]-[00:10:41].
      5. **Depth fog:** a plane with an Array modifier and a semi-transparent viewport color placed in depth, to separate backdrop from characters in unrendered previews; readability, not lighting [00:11:13]. Charge adds: scale it to adjust depth [00:04:32]. Measured on 5.2.1 Workbench [added]: the fog pulls what is behind it toward its own shaded value, so it separates a dim set from characters of the same value (backdrop 0.373 to 0.499 display, characters 0.374 unchanged) and does almost nothing on a mid-gray backdrop (0.529 to 0.533). `fog_for_shot` keeps it behind the subjects and keyed to one shot.
      6. **Theater treatment first** (locked-off, Laurel and Hardy staging) to judge the acting without camera language [00:06:51]. Camera at eye height, split between two characters' eye heights; low or high is a statement; A/B from neutral [00:12:18]. Lens: 35 mm (more backdrop), 60 mm (isolation), back to 35 mm because the frame must contain the action's extremes: Jay walking in, Rex standing up and towering [00:12:50]-[00:13:23]. Camera rig: 35 mm, F5.6, clip 0.010 to 200 (frame 00:13:01).
      7. **Minimal version:** no faces, no fingers, "the cheapest version possible": 13 s, "already a bit dull" [00:13:52]-[00:15:33]. **Detailed version:** a few pose-library faces, extra steps so Jay visibly covers the distance, one added beat (the slam), a three-pose anger burst (anticipation, overshoot, settle; two lacked impact) [00:16:07]-[00:18:21]: 22 s.
      8. **Export single images**, time them in the edit; only shots with an important or complex camera move are animated [00:18:21] [00:40:46].
      9. **An oversimplified version is always too short** and viewers push to shorten it more; it hides logistics (Jay cannot cross that distance in 10 frames). Adding the missing beat troubleshoots later departments [00:19:30]-[00:20:37].
      10. **Sound pass:** some beats got funnier, the cut got longer (25 s); then cherry-pick which gags to milk, because milking all of them is "long and boring" [00:20:37]-[00:23:05].
      11. **Cinematic treatment:** "it's a medium not a genre" [00:23:37]. List the ways the scene could open, each relaying the same information differently (exterior establishing, interior wide, follow the ice cream, a waitress handoff, trucking over the display, Rex folding a menu); he chose the handoff, warning it is harder than it sounds [00:24:12]-[00:26:56].
      12. **Re-order the beats and group them into shots:** 8 shots, shot 1 carrying 11 beats (slide 00:29:36: 25 beats in total) [00:27:30]-[00:28:37]. Crude thumbnails, optional [00:31:21] [00:47:31].
      13. **Shot 1:** plausible height for a camera coming off a handoff; a wider lens made the foreground slam bigger but pulled in background information ("in 2D you just draw whatever you want, in 3D you get the actual information"); a tilt up after the slam made the cherry the subject and implied Rex envied the cherry. Settled flatter, "60 or something" [00:34:07]-[00:36:21].
      14. **Cheats:** the sundae is not on the table, the table is resized, Rex floats, Jay hovers in the reverse, a taller sundae is swapped in so it towers over Jay's head, characters stretched or squished to fix leg cut-offs; the waitress is a crude tray with a Noise modifier placed to hide Rex's hovering feet; a focus pull across the handoff [00:36:21]-[00:39:38]. "As long as it's being conveyed properly and it's plausible in the shot I'm happy" [00:36:54].
      15. **Solve prop problems in story:** Rex tosses the shaker away, in character [00:39:38]-[00:40:46].
      16. **Edit v1:** kept one angle instead of a side cut because the side view went to silhouette and lost menace [00:41:47]. **v2 after feedback:** faster, less emphasis on what he found funny; fixed a reveal with two focal points by having Jay sit in front of the sundae [00:42:19]-[00:45:10].
      17. **Edit comparison** (slide 00:46:01): theater simple 13 s, detailed 22 s, detailed + sound 25 s; cinematic v1 47 s, v2 + sound 40 s. "Twice the length, sometimes three times", because every cut puts people in a new perspective they must read and puts emphasis on something [00:46:23]-[00:46:58].
      
      **Takeaways he lists** [00:47:31]-[00:49:45]: always write the beat list; A/B lenses and angles at the start because poses built for one lens must be redone after a switch [00:48:05]; fewest bones; give the idea just enough to live (a cheap smile can make or break a joke) [00:49:12]; sound will change the edit.
      
      **On screen, not narrated:** one .blend with a scene pair per iteration (`01_blend theatre simple` / `01_edit theatre simple` up to `05_blend cinematic v2` / `05_edit cinematic v2 +sound`), blend scenes 1 to 1500 with markers labeling blocks, shots numbered in tens (010, 020, 050, 070, 080), collections parlor, CH-jay, CH-rex, CA-camera_rig, extra; edit scenes 1920x1080, 24 fps. Playback: never judge timing on "play every frame" [00:39:05].
      
      **Charge recipe** (cTx-n4OZXYE): floor-plan sketches to understand the space for acting beats and camera [00:00:00]; a global screen-direction rule (Anar left to right all the way to the battery, right to left home) [00:00:22]; method chosen per project, "it depends" (Agent 327 mixed media, Spring concept art plus previs, Sprite Fright drawn animatic, Charge 3D previs without a storyboard artist) [00:00:22]-[00:00:55] [00:06:27]; mannequins with a Subdivision Surface modifier, fewest bones [00:01:00]; aspect ratio locked "as soon as possible because it heavily affects the cinematography" [00:02:11]; camera sensor set, **opaque passepartout** ("a more accurate representation of the composition"), a few shades of gray [00:02:11]; Ctrl+B binds the camera to a marker [00:02:44]; auto-key on, key all main things, all keys Constant; an alternative version on the next frame, toggle for A/B, keep the winner on the first frame of "a full decade" [00:02:52]-[00:03:24]; "a maximum of 10 frames per shot, this way I force myself to treat every frame like a drawing and a storyboard", 20 only when warranted [00:03:24]; frames exported with incremental numbering, never overwritten [00:03:24]; a separate edit file to time images "until it feels just right" [00:03:59]; next shot = duplicate the camera, bind it 10 frames later, cheat freely but key everything you touch first [00:03:59]-[00:04:31]; "camera movement can get in the way of timing the acting beats in the edit": simple dolly-ins are animated on the still in the edit [00:04:32]; stop when you run out of improvements or the producer drags you away [00:05:11]; layout rebuilds every shot from scratch in its own file with previs as the guide [00:05:50].
      
      ## Dillon Gu, animation director
      
      Source: BCON22 How to improve your cameras (AfWETpPKq-Q). He keeps the animation fixed and changes only cameras to hit three moods, stating the reason for each choice. Few numbers.
      
      **Stance.** "The audience is actually part of your scene; every time you film something they are there" [00:09:15]: people read a shot as if standing where the camera is (someone taller than the camera makes you feel weaker) [00:09:46]. Purpose first: decide what the audience should feel, then choose lens, height, motion [00:08:10]. "You can fill in the same space, you can get the rule of thirds, but you could have the wrong focal length and you give a different vibe" [00:07:38]. Eye level plus a medium lens is neutral, used only when you want nothing in particular [00:16:44]. Work within the animation you have; framing hides what was never animated [00:13:34] [00:38:08].
      
      **Mood 1, vulnerable (watched, unaware).** Creeper POV: camera somewhere unexpected, a corner looking up, handheld, so we know something she does not [00:05:29]-[00:06:34]. Wide lens from far (she is small, the watcher keeps distance), then a quick zoom in, "mimicking a camera operator" and someone fixing on a target [00:10:17]-[00:11:22]. The next cut to the exterior killed the danger, "when you're outside you're not in the action anymore": replaced by interior close-ups that hide information (feet round a corner) [00:12:29]-[00:14:06]. Feet close-up on a longer lens that flattens it, "more of an informational type of shot" [00:14:38]. Window shot low "because they're hidden", slight handheld, linear interpolation for a slide [00:15:11]-[00:15:43]. The eye-level medium city shot was too safe: top view with a slow zoom ("you do want some motion"), a dolly zoom with linear keys then exaggerated [00:16:44]-[00:22:51]. Review: constant zooming and moving shots add urgency; the dolly zoom makes her world spin out of her control; the top view "feels like someone's coming down on top of her" [00:23:34]-[00:25:47]. Compare with the original on equal terms, sound off [00:24:07]. A clip start of 5.5 cut through a ceiling light, then clipped her head [00:20:35] [?].
      
      **Mood 2, daily routine.** Off-center and from behind implies isolation and "something's a little weird today": replace it [00:27:45]. Do not show the face: her reaction does not matter, just another day [00:28:22]. Keep the painful hand off to the side so it draws no attention [00:29:27]. Centered, even, middle distance: too far feels isolated, too close means "something important is going to happen" [00:30:32]-[00:31:40]. "Sideways movement, it's the most neutral way you can move across the screen" [00:33:21]; his opinion: a neutral side angle on a grieving character kills the tension unless you want distance, as in comedy [00:33:54]. Less camera motion, the audience "doesn't want to run and jog alongside"; keep shots alive longer [00:34:32]-[00:35:42]. Ignore what used to be significant: significant things at the frame edge, sound design instead of visual design [00:35:42]. Over-the-shoulder is intimate; slight handheld when too still [00:36:44]. A jump cut from sitting to standing signifies elided everyday time [00:38:08]. 180-degree rule: the over-the-shoulder stays on her right shoulder because the camera was already on her right side; keep the camera on one side so the audience always knows where she is [00:39:17]-[00:40:21].
      
      **Mood 3, coffee commercial.** In her face, handheld and slightly dizzy for grogginess, the camera animated with her shoulder drop [00:42:36]-[00:43:46]; blown-out exposure for "I hate mornings" [00:44:19]; shakier walk for agitation [00:45:23]; rejected a TV insert because "two inanimate objects in a row" lower the tension [00:45:57]; an extremely low focal length "where it doesn't really make sense" to show she is not okay [00:46:28]; the product shot is the most stationary shot of the piece [00:48:45]; cut the calm shots, "people aren't calm in the mornings" [00:49:25].
      
      ## Pablo Fournier (Blender Studio) and Falk David (Blender developer)
      
      Source: BCON25 The development story of Story Tools (4flnlr39S1U).
      
      - The historic blocker: "you can edit your sequence but you can't do both at the same time" [00:02:21]. The 5.0 answer is minimal: a scene selector for the Sequencer and scene-time sync [00:11:48]-[00:12:56]; a separate sequence data-block stays long-term [00:36:21].
      - **Setup trio** (Pablo): Sync on in the footer, "otherwise nothing will work" [00:15:49]; pin the Edit scene or you export the wrong thing [00:16:24]; auto-key on in shot scenes, off in Edit, "two kinds of auto keys" [00:17:30].
      - Scene assets live in their own files (duplicates break in the same file) and need the library path set [00:19:07] [00:19:39]. Shift+D on a scene strip is a full copy, Alt+D a linked one [00:22:30].
      - **Coverage:** one scene, one action, two cameras: split the scene strip, a camera per piece, the animation updates in both; drag the cut to move the camera switch, slip to shift the action: retime "by doing two shortcuts" instead of moving keys [00:23:03]-[00:25:59]. In this skill: a `live` shot plus `cover=True` shots, cut by `animatic` as one soft-split scene strip, retimed with `retime_cut` (moves the marker too); verified pixel-identical to the previs frames (test_04).
      - **2.5D boards:** Grease Pencil strokes on rigs parented to the characters, drawn over rough poses, so strokes follow without animating their position; thumbnails placed on the camera to match timing [00:26:33]-[00:32:26]. 3D translation is "the easy part" once the 2D beats are right [00:29:54].
      - Deleting a scene strip never deletes the scene: "scenes don't have users" [00:39:37]. Compositing and NLA are respected inside scene strips [00:42:23]. First playback after a scene switch is slow (depsgraph build) [00:49:46]; heavy scenes: edit rendered image sequences.
      - Features called future in the talk (strip-range Dope Sheet overlay, a storyboarding app template) ship in 5.2.1 (verified).
      
      ## Renato Roldan, storyboard artist (The Third Floor, Star Wars)
      
      Source: BCON24 Live Storyboarding Session (CPprHO2R0VI).
      
      - "If it works for the camera, it works" and "the faster the better" [00:16:19]; "nobody's going to move the camera and see all your tricks" [00:24:32]; legs placed in front of bodies for parallax [00:23:21].
      - Boards should carry timing, framing and camera movement even if the editor changes them [00:18:41].
      - Flat drawing split into Grease Pencil objects at different depths plus a camera move gives parallax for little effort [00:12:17]-[00:15:11]; red as the one popping color [00:17:28].
      - Sculpt a 2D drawing for secondary motion and loops instead of redrawing [00:20:25]-[00:22:13] (tablet only).
      - Parent a drawing to a moving 3D plane proxy; plan the proxy's motion from the camera: edge-on means the shot will be hard for 2D animation, and the board has already told you [00:25:10]-[00:28:38].
      - Posable mannequins through the real lens solve extreme wide-lens drawing and give 2D artists a matching layout [00:29:06]-[00:31:25].
      - First-person narrative boards: remove competing attention near the speaking character [00:33:06].
      
      ## Paul Coulthard, Spitfire Storyboards
      
      Source: Grease Pencil 2D/3D hybrid storyboard animatic (H10rQoSg41c, Blender 2.8x era).
      
      - A GP object is "a 2D animation layer of as many drawings as you want contained within a three-dimensional object" [00:04:03]: lay out the performance in real space, then shoot it with any camera.
      - Order: draw the path on the floor (Surface placement, raise the offset to stop clipping) [00:15:58] [00:08:33]; key the object along it (keys about 40 frames apart in the demo) [00:14:43]; rough poses on a new layer with onion skin [00:18:09]; commit to a camera, only then refine [00:19:47]-[00:20:21]. Default drawing plane: Origin plus View [00:05:13] [00:43:12].
      - Constant interpolation for foot contact, linear only for flying or sliding [00:32:23]-[00:33:28].
      - A 3D proxy man carries hard paths (terrain, flight); parent the drawing with Keep Transform; redraw when the proxy's rotation makes the flat art look wrong [00:34:10]-[00:44:22]. The proxy also survives hand-off to departments using other software [00:36:04].
      - Camera: Lock Camera to View to fly it; 24 mm in the example; low mm = wide, distortion close up; high mm compresses and intensifies two characters; wide for vistas [00:25:26]-[00:27:05]. Track To on the proxy (-Z, up Y) keeps it centered while the path is edited [00:41:44].
      - Very high angles read as cardboard cutouts but the spatial information stays valid for layout [00:31:25]. Cuts by markers bound to cameras [00:44:46].
      
      ## BouncyBrain, story artist (billed as DreamWorks)
      
      Source: Storyboarding in Blender masterclass (Xx68qcqMXS4), mostly wordless time-lapse.
      
      - "Drawing on glass": the hero drawing and the camera are both children of a circle empty over the subject; orbit by rotating the circle, dolly by moving the camera along its local Z (the drawing changes size); world-space effects (an explosion) stay in the world [00:09:45]-[00:12:01].
      - Boards at 6 fps, retimed in an editor [00:08:03]; shots as duplicated scenes [00:08:37]. His F1 to F4 mode keymap is not worth copying (F2 and F3 are defaults people rely on) [added].
      
      ## Disagreements and deciding conditions
      
      | Choice                         | Option A                                                      | Option B                                                                                                      | Decides                                                                                                                                                                  |
      | ------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
      | Animate the camera in previs   | no: stills, push-ins faked in the edit (Charge [00:04:32])    | yes when the move carries story: handoffs, focus pulls (Hjalti [00:18:54]); zooms as mood (Dillon [00:10:49]) | is the move the meaning or incidental?                                                                                                                                   |
      | Lens for a two-character scene | 35 mm to contain the extremes, locked off (Hjalti [00:13:23]) | 60 mm to isolate and compress (Hjalti [00:35:46]; Spitfire [00:27:05])                                        | theater framing vs cinematic isolation; in 3D a wide lens imports background you must manage                                                                             |
      | Wide lens meaning              | drama, vistas, big foreground (Spitfire, Hjalti)              | distance and stalking from far; "not okay" when extreme and close (Dillon)                                    | camera distance                                                                                                                                                          |
      | Frames per shot                | 1 still, 10-frame blocks (Charge)                             | a few more beats per shot to test logistics (Hjalti detailed [00:16:07])                                      | idea vetting vs checking actions physically fit                                                                                                                          |
      | Method                         | 3D previs, semi-storyboard (Charge)                           | drawn boards into an animatic; hybrids (Renato, Spitfire, Pablo)                                              | team skills, budget, deadline, final style; an agent without a tablet defaults to 3D                                                                                     |
      | Camera switching               | markers bound to cameras (Charge, Spitfire, Hjalti)           | scene strips split per camera (Pablo)                                                                         | stills and 10-frame blocks: markers; one continuous action seen from several angles, retimed in the edit: a coverage run (`cover=True`), which keeps the markers in step |
      | Where the edit lives           | separate edit file (Charge)                                   | scene pairs in one file (Hjalti on screen) or an Edit scene with sync (5.0+)                                  | Blender version, scene weight, hand-off                                                                                                                                  |
      | Hero drawing placement         | glass on the camera rig pivot (BouncyBrain)                   | world space on proxies (Spitfire, Renato)                                                                     | camera follows one character and never sees it side-on vs action seen from several angles                                                                                |
      | Neutral vs statement camera    | neutral start, A/B (Hjalti)                                   | statements when the mood is chosen (Dillon)                                                                   | is the mood decided? Neutral side angles on grief kill tension (Dillon)                                                                                                  |
      | Lens across a cut              | change per shot (Hjalti, Dillon)                              | no lens jump on a cut (text-image-to-blender-blockout)                                                        | human edit vs a render-to-real model's guide video                                                                                                                       |
      
      ## Additions in this skill [added]
      
      - Size table (visible height as a multiple of subject height), distance = visible height x lens / sensor height, headroom band, eye tolerance 0.15 m, 70-degree edge-on threshold, 5 % screen-width threshold for screen direction: standard framing grammar and practical thresholds, calibrated on the S6 contact sheet, not from the talks.
      - Runtime budget of about 1.6 to 1.9 s per beat: derived from Hjalti's slide (25 beats, 8 shots, 40 s and 47 s). S6 landed at 1.63 s per beat.
      - Ray-cast occlusion gate: added after the S6 contact sheet showed a door and a counter hiding the hero that no numeric check had flagged.
      - POV cameras just off the line on the camera side, cheated forward and up past foreground furniture: S6 finding.
      - Color: grays plus one pop color follows Charge and Renato; the blockout skill's saturated identity palette is for video models, not for directors.
      - Cost gates in `audit` (from the S6 blind grade, which flagged an answer that animated every shot continuously, lit for mood and used full Rigify rigs): camera moving inside a still shot, keys between block starts outside live shots, a non-Workbench engine are problems; lights are a warning. Grounded in Charge [cTx-n4OZXYE 00:04:32] (camera moves get in the way of timing) and Hjalti [XZtJ64g5VQQ 00:18:54] (export single images) and [00:48:40] (the cheapest version).
      - Rigify is off under `--factory-startup` (its metarig operator "could not be found"): `ensure_rigify` before any Rigify proxy. `rigify_proxy` (Basic Human, bone-parented boxes, `layout` bones torso, chest, head, IK hands and feet) is for briefs that ask for Rigify; Hjalti and Charge use the simplest rig that poses.
      - Passepartout: `build_shots` and `passepartout()` set it opaque, `audit` flags any shot camera without it (Charge [00:02:11]); it is a viewport display setting, so it matters in a live session, not in headless renders.
      
    • procedures.md 28.3 KB
      # Tested procedures (Blender 5.2.1)
      
      Every code block below runs, in order, in one namespace: `tests/code/blender-previs-storyboard/test_03_procedures.py` extracts the blocks from this file and executes them verbatim (only the two paths in P0 are substituted). The asserts inside the blocks are the gates. Deeper checks of the same functions: `test_01_s6_end_to_end.py` (scenario S6 built completely, 27 checks), `test_02_api_traps.py` (traps, coverage, hybrids, 27 checks) and `test_04_grading_gates.py` (passepartout, fog, coverage runs, cost gates, Rigify proxies, 22 checks). Run any of them with:
      
      ```
      blender --background --factory-startup --python-exit-code 1 --python tests/code/blender-previs-storyboard/<test>.py
      ```
      
      The example is S6 cut down: a keeper, a small robot, a snack, three cinematic shots.
      
      ## P0. Import the toolkit
      
      Verified on 5.2.1: test_03 block 0.
      
      ```python
      import math, os, sys
      SKILLS = "/abs/path/to/skills"      # folder holding scenario-blender-previs-storyboard and scenario-blender-expert
      OUT = "/abs/path/to/output"         # any writable folder
      sys.path += [os.path.join(SKILLS, "scenario-blender-previs-storyboard", "scripts"),
                   os.path.join(SKILLS, "scenario-blender-expert", "scripts")]
      import bpy
      from mathutils import Vector
      import bx_previs as P
      os.makedirs(OUT, exist_ok=True)
      ```
      
      ## P1. Beats of information, then shots (Hjalti)
      
      One fact per line; beats you add are marked. Shots group consecutive beats; `shot_list` refuses a missing, duplicated or reordered beat and assigns previs frames on decades (Charge: 10-frame blocks; a live shot's longer block pushes the next start to the next decade). Verified on 5.2.1: test_03 block 1; test_01 sections 1, 4, 5.
      
      ```python
      BEATS = ["The shopkeeper has his back to the floor.",
               "A little robot sneaks toward a snack.",
               "The shopkeeper turns around.",
               "The robot freezes like a toy."]
      try:
          P.shot_list(BEATS, [{"id": "sh010", "beats": [1, 3]}])
          raise AssertionError("a missing beat must be refused")
      except ValueError as err:
          print("refused:", err)
      SHOTS = [
          dict(id="sh010", beats=[1, 2], subjects=["robot", "keeper"], loc=(-1.5, -1.4, 0.55),
               aim=(1.2, 0.9, 0.75), lens=28, size="wide", dur=3.0, intent="irony two-shot at robot height"),
          dict(id="sh020", beats=[3], subjects=["keeper"], size="mcu", lens=50, height="eye",
               azimuth=-35, dur=1.5, intent="neutral MCU at his eye line"),
          dict(id="sh030", beats=[4], subjects=["robot"], loc=(2.0, 0.55, 1.72), aim="robot", lens=35,
               dur=2.0, block=48, live=True, pov=True, intent="his POV, quick zoom: he fixes on it"),
      ]
      table = P.shot_list(BEATS, SHOTS, start=10)
      assert [t["frame"] for t in table] == [10, 20, 30]
      ```
      
      ## P2. Scene with the format locked first (Charge)
      
      `setup_scene` sets resolution, fps, Workbench with object colors and outlines, the Standard view transform (grays render as set), AUDIO_SYNC playback (never judge timing on play-every-frame, Hjalti) and makes the scene the window scene. Raw equivalent of the key lines below it. Verified on 5.2.1: test_03 block 2; test_01 section 2.
      
      ```python
      sc = P.setup_scene("01_blend procedures", res=(1920, 1080), fps=24, pct=20)
      # raw bpy equivalent of what matters
      sc.render.resolution_x, sc.render.resolution_y, sc.render.fps = 1920, 1080, 24
      sc.render.engine = "BLENDER_WORKBENCH"
      sc.display.shading.light, sc.display.shading.color_type = "STUDIO", "OBJECT"
      sc.view_settings.view_transform = "Standard"
      bpy.context.window.scene = sc          # measuring needs the window scene (see P16)
      assert bpy.context.scene == sc
      ```
      
      ## P3. Set pieces and proxy characters
      
      Real scale, origins at the bottom, grays, one pop color (Renato). A mannequin is a root empty at the feet with `height` and `eye_height` custom properties and four pivoted parts that are the only controls previs keys (Hjalti's layout selection set). Verified on 5.2.1: test_03 block 3; test_01 section 2.
      
      ```python
      P.prop(sc, "floor", (9.0, 7.5, 0.02), (0, -0.25, -0.02), (0.3, 0.3, 0.3, 1))
      P.prop(sc, "counter", (0.5, 2.4, 1.0), (2.0, 0.6, 0), (0.68, 0.68, 0.68, 1))
      P.prop(sc, "snack_bin", (0.45, 0.6, 0.35), (1.35, 0.5, 0), (0.6, 0.6, 0.6, 1))
      snack = P.prop(sc, "snack", (0.16, 0.06, 0.22), (1.22, 0.3, 0.35), (0.95, 0.78, 0.1, 1), collection="PROPS")
      keeper = P.mannequin(sc, "keeper", 1.75, (2.85, 1.0, 0), facing=90, color=P.BLUE_GREY)
      robot = P.mannequin(sc, "robot", 0.55, (-0.2, 0.75, 0), facing=75, color=P.POP, kind="robot")
      chars = {"keeper": keeper, "robot": robot, "snack": snack}
      MOV = P.movables(keeper, robot) + [snack]
      assert sorted(P.controls(robot)) == ["arm_L", "arm_R", "chest", "head"]
      assert abs(keeper["eye_height"] - 0.93 * 1.75) < 1e-6
      ```
      
      ## P4. Stepped blocking; key before you cheat (Charge)
      
      Per block: `frame_set` first (holds the previous pose), set only what changes, key every movable. A cheat (the snack at 1.4x for one shot) is just another keyed value. Python keys ignore the new-key interpolation preference, so force CONSTANT through the channelbag. Raw loop shown after the module call. Verified on 5.2.1: test_03 block 4; test_01 section 6; test_02 section 2.
      
      ```python
      def block(frame, **poses):
          sc.frame_set(frame)
          for obj, kw in poses.items():
              P.pose(chars[obj], **kw) if obj != "snack" else setattr(snack, "scale", (kw["scale"],) * 3)
          P.key_block(MOV, frame)
      
      block(10, robot=dict(loc=(-0.2, 0.75, 0), facing=75, chest=(18, 0, 0), arm_L=(-70, 0, 0), arm_R=(-60, 0, 0)),
            keeper=dict(facing=90, chest=(12, 0, 0), head=(18, 0, 0), arm_R=(-80, 0, 0)), snack=dict(scale=1.0))
      block(20, keeper=dict(loc=(2.85, 1.0, 0), facing=90, chest=(5, 0, -40), head=(0, 0, -50)),
            robot=dict(loc=(0.97, 0.36, 0), facing=90, arm_R=(-120, 0, 0)), snack=dict(scale=1.4))   # cheat
      block(30, keeper=dict(loc=(2.75, 0.95, 0), facing=-70, chest=(28, 0, 0), head=(22, 0, 0), arm_R=(-20, 0, 0)),
            robot=dict(facing=90, head=(-20, 0, 35), arm_R=(-150, 0, 0)), snack=dict(scale=1.0))
      block(36, robot=dict(facing=109, head=(0, 12, 0), arm_L=(0, -80, 0), arm_R=(0, 80, 0)))  # the snap, inside the live shot
      
      P.make_stepped(MOV)
      # raw equivalent (5.x slotted actions; action.fcurves is gone)
      from bpy_extras import anim_utils
      for ob in MOV:
          ad = ob.animation_data
          cb = anim_utils.action_get_channelbag_for_slot(ad.action, ad.action_slot)
          for fc in cb.fcurves:
              for k in fc.keyframe_points:
                  k.interpolation = "CONSTANT"
      assert P.stepped_report(MOV) == {}
      sc.frame_set(10); assert abs(snack.scale.x - 1.0) < 1e-6
      sc.frame_set(20); assert abs(snack.scale.x - 1.4) < 1e-6
      ```
      
      ## P5. One camera per shot, markers bound to cameras
      
      `build_shots` places each camera (default: mean eye height of the subjects; distance = visible height x lens / sensor height, visible height from the size table and the subjects' spread; explicit `loc`/`aim` wins), sets a 36 mm horizontal sensor, an opaque passepartout (Hjalti, Charge: "it will give your brain a more accurate representation of the composition"; viewport display only, renders are unaffected) and clip 0.01 to 200, and binds a marker at the shot's first frame. `P.passepartout(cams_or_scene)` does the same for cameras made any other way, and `audit` flags a shot camera without it. The raw binding and look-at follow. Camera moves that carry meaning are keyed on the camera afterwards. Verified on 5.2.1: test_03 block 5; test_01 section 7; test_02 section 5.
      
      ```python
      cams = P.build_shots(sc, table, chars)
      assert all(c.data.show_passepartout and c.data.passepartout_alpha == 1.0 for c in cams.values())
      cam30 = cams["sh030"]
      P.key_lens(cam30, [(38, 35), (46, 50)])              # quick zoom, linear (Dillon)
      P.handheld(cam30, strength=0.012, scale=22, frame=30)  # a person's POV
      
      # raw equivalents
      m = sc.timeline_markers["sh020"]
      assert m.camera == cams["sh020"] and m.frame == 20
      extra = bpy.data.objects.new("CAM-extra", bpy.data.cameras.new("CAM-extra"))
      sc.collection.objects.link(extra)
      extra.location = (0, -5, 1.6)
      extra.rotation_euler = (Vector((0, 0, 1.0)) - extra.location).to_track_quat("-Z", "Y").to_euler()
      extra.data.show_passepartout, extra.data.passepartout_alpha = True, 1.0   # opaque, as P.passepartout does
      sc.timeline_markers.new("tmp", frame=90).camera = extra      # data-level Ctrl+B
      sc.frame_set(90); assert sc.camera == extra
      sc.timeline_markers.remove(sc.timeline_markers["tmp"])
      bpy.data.objects.remove(extra)
      sc.frame_set(19); assert sc.camera == cams["sh010"]
      sc.frame_set(20); assert sc.camera == cams["sh020"]
      ```
      
      ## P6. Measurable gates
      
      `audit` per shot: camera switch at the frame and not before, lens, height class and pitch, main subject's eye line in frame and not blocked (ray cast names the blocker), coverage and measured size, action-line side, screen direction for a journey, block length, two inserts in a row, non-constant keys outside live blocks. Cost and camera gates (P20): opaque passepartout, no camera move inside a still shot, no keys between block starts outside live shots, Workbench engine; light objects come back as `warnings`. Verified on 5.2.1: test_03 block 6; test_01 section 8 (including a regression that re-creates the door and counter occlusions and sees them flagged).
      
      ```python
      rep = P.audit(sc, table, chars, line=("robot", "keeper"), stepped=MOV)
      for r in rep["rows"]:
          print(r["id"], r["lens"], r["height"], r["cam_z"], r.get("side"),
                {n: (s["size"], s["eye_visible"]) for n, s in r["subjects"].items()})
      assert rep["problems"] == [], rep["problems"]
      print("warnings:", rep["warnings"])
      ```
      
      ## P7. Stills, contact sheet, single frames
      
      `render_stills` renders one Workbench still per shot at its first frame, versioned `<id>_v###.png`, never overwritten; `contact_sheet` stamps each (shot, lens, camera height, size, seconds, intent), adds the last frame of live shots and tiles them. `render_frame` renders any scene at any frame safely (P16). Verified on 5.2.1: test_03 block 7; test_01 section 9.
      
      ```python
      stills = P.render_stills(sc, table, os.path.join(OUT, "stills"))
      sheet = P.contact_sheet(sc, table, OUT, cols=4, name="procedures_contact", chars=chars)
      print("open:", sheet)
      one = P.render_frame(sc, 40, os.path.join(OUT, "sh030_f40.png"))
      assert os.path.exists(sheet) and os.path.exists(one) and set(stills) == {"sh010", "sh020", "sh030"}
      ```
      
      ## P8. Lens A/B at equal subject size (Hjalti)
      
      Non-destructive: renders the shot at each focal length, moving the camera along its view axis so the subject keeps its size (d2 = d1 x f2 / f1 [added]), side by side. Not for shots whose lens or location is keyed. Verified on 5.2.1: test_03 block 8; test_01 section 9.
      
      ```python
      ab = P.ab_lens(sc, table[1], (35, 50, 85), os.path.join(OUT, "ab"))
      assert os.path.exists(ab) and cams["sh020"].data.lens == 50
      ```
      
      ## P9. Floor plan
      
      Top orthographic render in a temporary scene: camera glyphs with their horizontal field of view and labels (co-located cameras share one label), the action line in red, journeys in green. Verified on 5.2.1: test_03 block 9; test_01 section 9.
      
      ```python
      fp = P.floor_plan(sc, table, chars, os.path.join(OUT, "floor_plan.png"), line=("robot", "keeper"),
                        paths=["robot"], frame=30)
      assert os.path.exists(fp) and "BX_FloorPlan" not in bpy.data.scenes
      ```
      
      ## P10. The animatic edit
      
      `animatic` builds `NN_edit` beside `NN_blend`: image strips held `round(dur * fps)` frames (fit to frame), `push` keys a fake dolly-in on a still by multiplying the FIT scale (Charge), `live` shots become scene strips through their camera with the source frame aligned to the cut, sounds go on upper channels, `burn_in` stamps strip name and frame, the edit gets the Standard view transform, and `bx_review.playblast` renders the MP4. The raw VSE calls follow. Verified on 5.2.1: test_03 block 10; test_01 section 10 (MP4 streams and duration by ffprobe, live frame equal to the previs frame, push rendered).
      
      ```python
      table[0]["push"] = 1.1
      ed = P.animatic(sc, table, stills, P._versioned(OUT, "procedures_animatic", ".mp4"),
                      edit_name="01_edit procedures", burn_in=True)
      print(ed["seconds"], ed["cuts"])
      assert abs(ed["seconds"] - 6.5) < 1e-6 and os.path.exists(ed["mp4"])
      
      # raw VSE equivalents (5.1+ names)
      e2 = bpy.data.scenes.new("raw_edit")
      e2.render.fps = 24
      e2.view_settings.view_transform = "Standard"
      sed = e2.sequence_editor_create()
      s1 = sed.strips.new_image("sh010", stills["sh010"], 1, 1, fit_method="FIT")
      s1.right_handle = 1 + 72                                 # hold 3 s
      live = table[2]
      s2 = sed.strips.new_scene("sh030", sc, 1, 73)
      s2.content_start = 73 - (live["frame"] - sc.frame_start) # source frame 30 at edit frame 73
      s2.left_handle, s2.right_handle = 73, 73 + 48
      s2.scene_camera = cams["sh030"]
      e2.render.image_settings.media_type = "VIDEO"            # before FFMPEG (5.0)
      e2.render.image_settings.file_format = "FFMPEG"
      e2.render.ffmpeg.format, e2.render.ffmpeg.codec = "MPEG4", "H264"
      assert (s1.duration, s2.left_handle, s2.right_handle) == (72, 73, 121)
      bpy.data.scenes.remove(e2)
      ```
      
      ## P11. Coverage from one action (Story Tools, Pablo Fournier)
      
      One scene strip split with `Strip.split(frame, split_method='SOFT')`, each piece through its own camera; move the camera switch with `move_cut`, shift the action inside a piece with `slip`. Editing the animation updates every angle. Verified on 5.2.1: test_03 block 11; test_02 section 7 (edit frames pixel-identical to the source through the right camera, before and after move and slip).
      
      ```python
      cov_edit = bpy.data.scenes.new("coverage_edit")
      pieces = P.coverage(cov_edit, sc, [cams["sh010"], cams["sh020"]], [20])
      P.move_cut(pieces[0], pieces[1], 24)
      P.slip(pieces[1], -3)
      assert pieces[0].right_handle == 24 and pieces[1].left_handle == 24 and pieces[1].scene_camera == cams["sh020"]
      # raw split
      s = cov_edit.sequence_editor.strips.new_scene("raw", sc, 3, 1)
      right = s.split(frame=15, split_method="SOFT")
      assert (s.right_handle, right.left_handle) == (15, 15)
      ```
      
      ## P12. Story Tools wiring for a live session
      
      Sequencer shows the Edit scene (pinned), scrubbing syncs the main window to the shot scene under the playhead, auto-key off in Edit (Pablo: "two kinds of auto keys"). Headless these are plain properties. Verified on 5.2.1: test_03 block 12; test_02 section 12.
      
      ```python
      st = P.story_tools(bpy.data.scenes[ed["edit"]])
      ws = bpy.context.workspace
      assert ws.sequencer_scene.name == ed["edit"] and ws.use_scene_time_sync and ws.use_pin_scene
      ```
      
      ## P13. Camera moves that feel physical (Dillon Gu)
      
      Quick zoom = keyed lens, linear; dolly zoom = lens and distance keyed together so the subject keeps its size (distance proportional to focal length [added]); handheld = NOISE modifiers on rotation. REPLACE (default) centers the noise on the curve, ADD shifts it by about strength/2; measured peak-to-peak about 0.66 x strength at scale 22 over 400 frames. Verified on 5.2.1: test_03 block 13; test_02 section 10.
      
      ```python
      from bpy_extras.object_utils import world_to_camera_view
      dz = bpy.data.objects.new("CAM-dolly_zoom", bpy.data.cameras.new("CAM-dolly_zoom"))
      sc.collection.objects.link(dz)
      target = Vector((*keeper.matrix_world.translation.xy, P.eye_z(keeper)))
      back = Vector((0, -1, 0))
      for f, mm in ((100, 24), (124, 70)):
          dz.data.lens = mm
          dz.location = target + back * (1.2 * mm / 24)          # distance scales with focal length
          dz.rotation_euler = (target - dz.location).to_track_quat("-Z", "Y").to_euler()
          dz.data.keyframe_insert("lens", frame=f)
          dz.keyframe_insert("location", frame=f)
          dz.keyframe_insert("rotation_euler", frame=f)
      P.make_stepped([dz, dz.data], "LINEAR")                    # Dillon: linear for the dolly zoom
      sizes = []
      for f in (100, 112, 124):
          sc.frame_set(f)
          top = world_to_camera_view(sc, dz, target + Vector((0, 0, 0.1)))
          bot = world_to_camera_view(sc, dz, target - Vector((0, 0, 0.1)))
          sizes.append(top.y - bot.y)
      print("subject size through the dolly zoom:", [round(x, 4) for x in sizes])
      assert max(sizes) - min(sizes) < 0.01 * max(sizes)
      bpy.data.objects.remove(dz)
      ```
      
      ## P14. 2D/3D hybrid boards: carriers, glass rig, facing
      
      A Grease Pencil drawing rides a 3D proxy (Spitfire, Renato, Pablo) or a pivot over the subject with the camera ("drawing on glass", BouncyBrain). Flag frames where the carrier turns the flat drawing more than 70 degrees from the camera [added threshold]. Strokes: scenario-blender-grease-pencil; `gp_polyline` is only for guides. Drawing-plane settings for a live session: `ts.gpencil_stroke_placement_view3d = 'ORIGIN'`, `ts.gpencil_sculpt.lock_axis = 'VIEW'` (Spitfire's default). Verified on 5.2.1: test_03 block 14; test_02 section 11 (strokes render headless in Workbench).
      
      ```python
      proxy = P.prop(sc, "proxy_plane", (0.6, 0.02, 1.0), (-2.5, 0.0, 0), P.GREY)
      gp = P.gp_carrier(sc, "GP-board", parent=proxy, loc=(-2.5, -0.03, 0))
      P.gp_polyline(gp, [(-0.25, 0, 0.1), (0, 0, 0.9), (0.25, 0, 0.1)], radius=0.015)
      proxy.rotation_euler.z = 0
      proxy.keyframe_insert("rotation_euler", frame=1)
      proxy.rotation_euler.z = math.radians(-85)
      proxy.keyframe_insert("rotation_euler", frame=9)
      rep_f = P.facing_report(gp, sc, cams["sh010"], [1, 5, 9])
      print(rep_f)
      assert 9 in rep_f["flagged"] and 1 not in rep_f["flagged"]
      gcam = bpy.data.objects.new("CAM-glass", bpy.data.cameras.new("CAM-glass"))
      sc.collection.objects.link(gcam)
      gcam.location = (-2.5, -4.0, 0.6)
      pivot = P.glass_rig(sc, gcam, (-2.5, 0.0, 0.5), drawing=gp)
      assert gp.parent == pivot and gcam.parent == pivot
      ```
      
      ## P15. Depth fog planes (Hjalti, Charge)
      
      A plane with an ARRAY modifier and a translucent object color, placed in depth to separate the backdrop from the characters in unrendered grays (Hjalti: readability, not lighting). `fog_planes` places one in world space; `fog_for_shot` faces the shot camera, starts `behind` meters past the farthest subject (so it can never tint the hero), covers the frame, and keys `hide_render`/`hide_viewport` so it exists only in that shot's block (a keyed cheat). Measured on 5.2.1 Workbench: the fog pulls what is behind it toward its own shaded value, so a light fog lifts a dim backdrop (0.373 to 0.499 display with the characters at 0.374: separation 0.002 to 0.125) but barely changes a mid-gray one (0.529 to 0.533); pick a fog value away from the characters' and check with `values_at`. Verified on 5.2.1: test_03 block 15; test_02 section 9; test_04 section 2 (characters unchanged, other shots unchanged, fog put in front is reported by the audit as the blocker).
      
      ```python
      fog = P.fog_planes(sc, "FX-fog", (0, 2.0, 0), 8, 3, count=4, spacing=0.4, alpha=0.25)
      assert fog.modifiers["depth"].count == 4 and abs(fog.color[3] - 0.25) < 1e-6
      fog.hide_render = fog.hide_viewport = True           # keep the world-space example out of the shots
      hero_pt = keeper.matrix_world.translation + Vector((0, 0, 1.2))
      f10 = P.fog_for_shot(sc, table[0], chars, behind=0.8, depth=3.0, count=4, alpha=0.3)
      vals = P.values_at(sc, table[0]["frame"], [hero_pt], os.path.join(OUT, "sh010_fog.png"))
      sc.frame_set(table[1]["frame"]); assert f10.hide_render            # only in its own shot
      sc.frame_set(table[0]["frame"]); assert not f10.hide_render and f10["near"] < f10["far"]
      print("hero value with fog:", vals)
      ```
      
      ## P16. The traps, reproduced
      
      Verified on 5.2.1: test_03 block 16; test_02 sections 1 to 6.
      
      ```python
      # 1. viewport render is impossible headless
      try:
          bpy.ops.render.opengl(write_still=True)
      except RuntimeError as err:
          assert "background mode" in str(err)
      # 2. the new-key interpolation preference does not reach keyframe_insert()
      bpy.context.preferences.edit.keyframe_new_interpolation_type = "CONSTANT"
      probe = bpy.data.objects.new("probe", None); sc.collection.objects.link(probe)
      probe.keyframe_insert("location", frame=1)
      assert {k.interpolation for fc in P.fcurves(probe) for k in fc.keyframe_points} == {"BEZIER"}
      bpy.context.preferences.edit.keyframe_new_interpolation_type = "BEZIER"
      bpy.data.objects.remove(probe)
      # 3. measure only in the window scene: frame_set elsewhere leaves transforms stale
      other = bpy.data.scenes.new("other"); e = bpy.data.objects.new("e", None); other.collection.objects.link(e)
      e.keyframe_insert("location", frame=1); e.location.x = 10; e.keyframe_insert("location", frame=11)
      other.frame_set(6); assert e.matrix_world.translation.x != 5.0
      P.activate(other); other.frame_set(6); assert abs(e.matrix_world.translation.x - 5.0) < 1e-6
      P.activate(sc)
      # 4. marker binding by operator needs a timeline context; the data path works anywhere
      try:
          bpy.ops.marker.camera_bind()
      except RuntimeError as err:
          assert "context" in str(err)
      # 5. a 6 fps board scene cut into a 24 fps edit keeps its frame count: 4x fast
      b6 = bpy.data.scenes.new("boards6"); b6.render.fps, b6.frame_start, b6.frame_end = 6, 1, 12
      tmp_edit = bpy.data.scenes.new("tmp_edit"); tmp_edit.render.fps = 24
      assert tmp_edit.sequence_editor_create().strips.new_scene("b6", b6, 1, 1).duration == 12
      ```
      
      ## P17. Layout bone subset on a real rig (Selection Sets substitute)
      
      The Selection Sets add-on is not bundled in 5.2.1; a bone collection named `layout` does the job, and scripts key only its bones (Hjalti: fewer bones, fewer misclicks). Verified on 5.2.1: test_03 block 17; test_02 section 8.
      
      ```python
      arm = bpy.data.armatures.new("RIG-proxy")
      rig = bpy.data.objects.new("RIG-proxy", arm)
      sc.collection.objects.link(rig)
      bpy.context.view_layer.objects.active = rig
      bpy.ops.object.mode_set(mode="EDIT")
      for name, head, tail in (("root", (0, 0, 0), (0, 0.3, 0)), ("chest", (0, 0, 1.1), (0, 0, 1.4)),
                               ("finger", (0.6, 0, 1.0), (0.65, 0, 1.0))):
          b = arm.edit_bones.new(name); b.head, b.tail = head, tail
      bpy.ops.object.mode_set(mode="OBJECT")
      layout = arm.collections.new("layout")
      for name in ("root", "chest"):
          layout.assign(rig.pose.bones[name])
      for pb in rig.pose.bones:
          if any(c.name == "layout" for c in pb.bone.collections):
              pb.keyframe_insert("rotation_quaternion", frame=1)
      assert sorted({fc.data_path.split('"')[1] for fc in P.fcurves(rig)}) == ["chest", "root"]
      ```
      
      ## P18. Coverage run in the animatic: one scene, several scene strips, retime by moving the cut (Story Tools)
      
      Pablo Fournier's multi-camera trick from one scene [4flnlr39S1U 00:23:03]: a `live` shot followed by `cover=True` shots is one continuous action; `shot_list` starts each cover shot where the previous block ends, `build_shots` binds its marker there, and `animatic` cuts the run as ONE scene strip soft-split at the cuts, each piece through its own camera (edit length must equal the block inside a run). `retime_cut` moves a cut without touching keys and moves the right shot's marker (and the table) with it, so stills, audit and scene playback agree with the edit. Editing the animation updates every angle. Verified on 5.2.1: test_03 block 18; test_04 section 3 (edit frames pixel-identical to the previs frames through the right camera before and after the retime; one key change shows in both angles).
      
      ```python
      cv = P.setup_scene("02_blend coverage", pct=10)
      P.prop(cv, "floor", (10, 10, 0.02), (0, 0, -0.02), P.GREY_DARK)
      k2 = P.mannequin(cv, "keeper2", 1.75, (1.2, 1.0, 0), facing=-90)
      r2 = P.mannequin(cv, "robot2", 0.55, (-1.5, 1.0, 0), facing=90, color=P.POP, kind="robot")
      ch2, MOV2 = {"keeper": k2, "robot": r2}, P.movables(k2, r2)
      T2 = P.shot_list(["The robot rolls toward the keeper.", "The keeper sees it coming."],
                       [dict(id="sh010", beats=[1], subjects=["robot"], size="full", lens=35, azimuth=-20, block=24,
                             dur=1.0, live=True, intent="follows the roll"),
                        dict(id="sh020", beats=[2], subjects=["keeper", "robot"], size="full", lens=50, azimuth=30,
                             block=24, dur=1.0, cover=True, intent="his reaction while it rolls")], start=10)
      assert [(t["frame"], t["block"]) for t in T2] == [(10, 24), (34, 24)]
      cv.frame_set(10); P.key_block(MOV2, 10)
      r2.location.x = 0.4; P.key_block(MOV2, 57)
      P.make_stepped(MOV2)
      for fc in P.fcurves(r2):
          for k in fc.keyframe_points:
              if round(k.co.x) == 10:
                  k.interpolation = "LINEAR"                   # the roll itself is continuous
      cams2 = P.build_shots(cv, T2, ch2)
      ed2 = P.animatic(cv, T2, {}, os.path.join(OUT, "coverage_animatic.mp4"), edit_name="02_edit coverage", render=False)
      e2 = bpy.data.scenes[ed2["edit"]]
      a, b = e2.sequence_editor.strips["sh010"], e2.sequence_editor.strips["sh020"]
      assert a.scene == b.scene == cv and a.content_start == b.content_start and b.scene_camera == cams2["sh020"]
      src = P.retime_cut(e2, "sh010", b.left_handle + 6, src=cv, table=T2)   # hold the roll 6 frames longer
      assert src == 40 and cv.timeline_markers["sh020"].frame == 40 and a.right_handle == b.left_handle
      assert P.audit(cv, T2, ch2, stepped=MOV2)["problems"] == []
      ```
      
      ## P19. Rigify proxies: enable Rigify first
      
      Rigify ships with Blender but is OFF in a factory startup and under `--factory-startup`: `bpy.ops.object.armature_basic_human_metarig_add` raises "could not be found" until it is enabled (S6 grader flag). `ensure_rigify` enables it (`addon_utils.enable("rigify", default_set=True)`). `rigify_proxy` builds a Basic Human scaled to height with one bone-parented gray box per deform bone (no weights, Charge), deletes the metarig, and adds a `layout` bone collection (torso, chest, head, IK hands and feet [added]) that `key_block` keys and `pose(bones=...)` sets (quaternion controls converted). Mannequins stay the cheaper default; use this only when the brief asks for Rigify rigs. Full rigging: scenario-blender-rigging. Verified on 5.2.1: test_03 block 19; test_04 section 5 (operator missing before, audit and still work with the proxy).
      
      ```python
      import addon_utils
      assert P.ensure_rigify() and addon_utils.check("rigify")[1]
      hero = P.rigify_proxy(cv, "hero", 1.7, (0.0, 3.0, 0), facing=0)
      assert {pb.name for pb in P.layout_bones(hero)} == set(P.RIGIFY_LAYOUT) and abs(hero["height"] - 1.7) < 1e-6
      cv.frame_set(70)
      P.pose(hero, bones={"chest": (15, 0, 0), "hand_ik.R": {"loc": (0, -0.2, 0.3)}})
      P.key_block([hero], 70)
      P.make_stepped([hero])
      keyed = {fc.data_path.split('"')[1] for fc in P.fcurves(hero) if fc.data_path.startswith("pose.bones")}
      assert keyed <= set(P.RIGIFY_LAYOUT) and "chest" in keyed and P.stepped_report([hero]) == {}
      ```
      
      ## P20. Cost and camera gates: what the audit refuses
      
      Previs is the cheapest version that conveys the idea (Hjalti [XZtJ64g5VQQ 00:48:40]): stills, not continuous animation of every shot [00:18:54]; camera moves only when the move is the story [cTx-n4OZXYE 00:04:32]; grays, not mood lighting. `audit` turns these into problems: a camera moving inside a still shot, keys between block starts outside live shots, a render engine other than Workbench; light objects are a warning (Workbench ignores them). Compare against the scene's own baseline so unrelated findings do not mask the new one. Verified on 5.2.1: test_03 block 20; test_04 section 4.
      
      ```python
      base = set(P.audit(sc, table, chars, stepped=MOV)["problems"])
      c20, loc0 = cams["sh020"], cams["sh020"].location.copy()
      c20.keyframe_insert("location", frame=20); c20.location.z += 0.3; c20.keyframe_insert("location", frame=29)
      new = set(P.audit(sc, table, chars, stepped=MOV)["problems"]) - base
      assert any("sh020: camera moves inside a still shot" in p for p in new), new
      c20.animation_data_clear(); c20.location = loc0
      P.key_block(MOV, 15)
      assert any("keys outside shot starts" in p for p in set(P.audit(sc, table, chars, stepped=MOV)["problems"]) - base)
      for o in MOV:
          for path in ("location", "rotation_euler", "scale"):
              o.keyframe_delete(path, frame=15)
      sc.render.engine = "BLENDER_EEVEE"
      assert any("render engine" in p for p in P.audit(sc, table, chars, stepped=MOV)["problems"])
      sc.render.engine = "BLENDER_WORKBENCH"
      lamp = bpy.data.objects.new("mood", bpy.data.lights.new("mood", "SPOT")); sc.collection.objects.link(lamp)
      rep_l = P.audit(sc, table, chars, stepped=MOV)
      assert rep_l["warnings"] and set(rep_l["problems"]) == base
      bpy.data.objects.remove(lamp)
      ```
      
      ## P21. Storyboarding app template (replaces the open file, run last)
      
      Ships an `Edit` scene (1 to 240, 24 fps) with scene strips of `Shot.001` and `Shot.002`, three shot scenes each with a Grease Pencil object and a 50 mm camera, the Storyboarding workspace on Edit with sync on (pin off there, on in Video Editing), auto-key on in shots and off in Edit. From the command line: `blender --app-template Storyboarding`. Verified on 5.2.1: test_03 block 21; test_02 section 13.
      
      ```python
      bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT, "procedures.blend"), copy=True)
      bpy.ops.wm.read_homefile(app_template="Storyboarding")
      ws = bpy.data.workspaces["Storyboarding"]
      assert ws.sequencer_scene.name == "Edit" and ws.use_scene_time_sync
      assert not bpy.data.scenes["Edit"].tool_settings.use_keyframe_insert_auto
      assert all(bpy.data.scenes[f"Shot.00{i}"].tool_settings.use_keyframe_insert_auto for i in range(3))
      ```
      
    • sources.md 6.7 KB
      # Sources
      
      Seven videos, distilled in `notes/previs/` (digest `_digest_previs_storyboard.md`). Blender versions on screen range from 2.8x (Spitfire) to 5.0 alpha (Story Tools); every API fact used in this skill was re-checked on 5.2.1.
      
      | Video                                                                                         | Expert and credential                                                                                                         | Length, date      | Best for                                                                                                                                                            | Best timestamps                                                                                                                                                                                                                                                                                            |
      | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | [Animation Previs/Layout Workshop, BCON24](https://www.youtube.com/watch?v=XZtJ64g5VQQ)       | Hjalti Hjalmarsson, Blender Studio director (writer/director of Charge, director of Sprite Fright; previs, layout, animation) | 50:42, 2024-10-24 | The whole process twice (theater and cinematic) with edit lengths; beats list; lens and height choices; every cheat shown in 3D                                     | 00:07:26 beats; 00:12:18 eye height; 00:12:50 lens A/B and extremes; 00:18:21 stills not animation; 00:19:30 too short; 00:27:30 beats to shots (slide 00:29:36); 00:34:07 shot 1 lens; 00:36:21 cheats; 00:41:47 silhouette; 00:43:32 one focal point; 00:46:01 edit comparison slide; 00:47:31 takeaways |
      | [Charge: Previz Workflow Overview](https://www.youtube.com/watch?v=cTx-n4OZXYE)               | Hjalti Hjalmarsson, writer/director of the Charge open movie                                                                  | 7:11, 2023-02-02  | The most scriptable recipe: 10-frame blocks, constant keys, A/B on the next frame, markers bound to cameras, versioned stills, separate edit                        | 00:00:22 screen direction and method; 00:02:11 aspect, passepartout, grays; 00:02:52 A/B; 00:03:24 10 frames; 00:03:59 key before cheat; 00:04:32 fog and camera moves; 00:05:50 layout                                                                                                                    |
      | [How to improve your cameras, BCON22](https://www.youtube.com/watch?v=AfWETpPKq-Q)            | Dillon Gu, animation director (his own Halloween short)                                                                       | 51:25, 2022-10-28 | Camera language: the same animation re-shot for three moods, a reason for every lens, height, motion and cut                                                        | 00:07:38 composition vs lens; 00:09:15 audience in the scene; 00:10:17 wide plus zoom; 00:12:29 exterior cut kills danger; 00:16:44 neutral; 00:21:41 dolly zoom; 00:27:45 routine grammar; 00:33:21 side-on; 00:39:17 180-degree rule; 00:45:57 two inserts; 00:48:45 product shot                        |
      | [The development story of Story Tools, BCON25](https://www.youtube.com/watch?v=4flnlr39S1U)   | Falk David (Blender developer, Grease Pencil module) and Pablo Fournier (Blender Studio, 2.5D layout and boards)              | 51:05, 2025-09-17 | The 5.0 Sequencer scene selector and sync, the setup trio, coverage by splitting one scene strip, scene assets, deletion and performance behavior                   | 00:02:21 the blocker; 00:15:16 setup; 00:18:35 scene assets; 00:23:03 split and slip coverage; 00:26:33 GP on rigs; 00:39:37 scenes have no users                                                                                                                                                          |
      | [Live Storyboarding Session, BCON24](https://www.youtube.com/watch?v=CPprHO2R0VI)             | Renato Roldan, storyboard artist, 20+ years (The Third Floor on Star Wars, King, Terraform)                                   | 47:19, 2024-10-24 | 2D in 3D ("story blendering"): planes in depth for parallax, drawings parented to moving proxies, edge-on warning, mannequins through the real lens                 | 00:12:17 planes in depth; 00:16:19 mantras; 00:18:41 boards carry timing and camera; 00:25:10 proxy carriers; 00:26:53 edge-on; 00:29:06 mannequins                                                                                                                                                        |
      | [Grease Pencil 2D 3D Hybrid Storyboard Animatic](https://www.youtube.com/watch?v=H10rQoSg41c) | Paul Coulthard, professional storyboard artist (Spitfire Storyboards)                                                         | 53:18, 2020-03-31 | Hybrid order of work: floor path, keyed GP object, roughs, camera commitment, proxy men, Track To, constant vs linear, markers for cuts (v2-era UI, API translated) | 00:05:13 drawing planes; 00:15:58 path first; 00:19:47 commit to camera; 00:26:33 lenses; 00:32:23 interpolation; 00:34:10 proxy parenting; 00:41:44 Track To; 00:44:46 markers                                                                                                                            |
      | [Storyboarding in Blender masterclass](https://www.youtube.com/watch?v=Xx68qcqMXS4)           | BouncyBrain, story artist billed as DreamWorks (boarded a 360-degree shot on the Super Mario movie)                           | 19:23, 2024-07-25 | The drawing-on-glass rig and 6 fps boarding; the rest is wordless time-lapse                                                                                        | 00:08:03 6 fps; 00:09:45 glass rig; 00:11:27 world effects                                                                                                                                                                                                                                                 |
      
      Related skill outside this set: text-image-to-blender-blockout (guide playblasts for render-to-real video models); its camera rig grammar, handheld amplitudes and hero-in-frame audits are reused where they agree with the experts above.
      
  • scripts
    • bx_previs.py 67.9 KB
      """
      bx_previs: story previs helpers for Blender 5.2 (script -> beats -> shots -> animatic).
      
      The method is Hjalti Hjalmarsson's (Blender Studio, Charge / Sprite Fright): a numbered
      list of beats of information, beats grouped into shots, one camera per shot bound to a
      marker at the first frame of a 10-frame block, stepped (constant) blocking keys, one
      still per shot, timing found in a separate edit. Everything here is headless-safe
      (Workbench renders, data-level VSE), tested on 5.2.1 by
      tests/code/blender-previs-storyboard/test_01_s6_end_to_end.py, test_02_api_traps.py,
      test_03_procedures.py and test_04_grading_gates.py (passepartout, fog, coverage runs,
      cost gates, Rigify proxies).
      
        import sys
        sys.path += ["<skills>/scenario-blender-previs-storyboard/scripts", "<skills>/scenario-blender-expert/scripts"]
        import bx_previs as P
        sc = P.setup_scene("01_blend cinematic")                    # format first, Workbench, greys
        keeper = P.mannequin(sc, "keeper", 1.75, (2.8, 1, 0), facing=90, color=P.BLUE_GREY)
        robot = P.mannequin(sc, "robot", 0.55, (-3, 2.4, 0), color=P.POP, kind="robot")
        table = P.shot_list(BEATS, SHOTS, start=10)                 # validates beats, assigns frames
        for t in table:                                              # block: pose, then key everything
            sc.frame_set(t["frame"]); POSES[t["id"]](); P.key_block(P.movables(keeper, robot), t["frame"])
        P.make_stepped(P.movables(keeper, robot))                    # prefs do not apply to Python keys
        P.build_shots(sc, table, {"keeper": keeper, "robot": robot}) # cameras + markers bound
        rep = P.audit(sc, table, chars, line=("robot", "keeper"), travel={"robot": "right"})
        sheet = P.contact_sheet(sc, table, out_dir)                  # look at it
        stills = P.render_stills(sc, table, out_dir + "/stills")
        P.animatic(sc, table, stills, out_dir + "/animatic_v001.mp4", edit_name="01_edit cinematic")
        P.fog_for_shot(sc, table[0], chars)                          # depth fog behind the subjects, one shot
        P.retime_cut(bpy.data.scenes["01_edit cinematic"], "sh030", 88, src=sc, table=table)  # cover runs
      
      Shot spec keys (dicts in SHOTS): id, beats (1-based, consecutive over the list), subjects
      (names in `chars`), size (see SIZES), lens (mm, 36 mm horizontal sensor), height ("eye",
      "eye:<name>" or metres), azimuth (deg; 0 = camera on -Y looking +Y, 90 = on +X looking -X),
      aim (name or xyz), loc (xyz, overrides placement), cam (shared camera name), fit ("all" =
      frame the extremes over every shot sharing the camera), dur (edit seconds), block (previs
      frames, default 10), live (True = animated shot, cut in as a scene strip), cover (True =
      another angle on the previous live shot's continuous action: it starts where that block
      ends, and the animatic cuts the run as soft-split pieces of ONE scene strip, retimed with
      retime_cut; Story Tools, Pablo Fournier), push (end scale of a fake dolly-in on the still,
      Charge), intent (why the camera is where it is), cross/pov (allowed exceptions to the
      action-line check).
      
      Rigify proxies: Rigify is off under --factory-startup; ensure_rigify() first, or build them
      with rigify_proxy() (layout bone collection keyed by key_block, posed with pose(bones=...)).
      """
      
      __version__ = "0.1"  # Blender Expert Skills v0.1 (2026-09-24)
      import json
      import math
      import os
      
      import bmesh
      import bpy
      from bpy_extras import anim_utils
      from bpy_extras.object_utils import world_to_camera_view
      from mathutils import Vector
      
      SENSOR_W = 36.0
      # Visible frame height as a multiple of the subject's height. [added: standard framing
      # grammar, calibrated on the S6 contact sheet; the source talks give no numbers]
      SIZES = {"ews": 4.0, "wide": 2.0, "full": 1.15, "mws": 0.8, "medium": 0.6,
               "mcu": 0.4, "cu": 0.25, "ecu": 0.12}
      EYE_TOL = 0.15          # metres: camera within this of an eye line counts as eye height [added]
      EDGE_ON_DEG = 70.0      # 2D drawing facing more than this away from camera reads flat [added]
      
      GREY_DARK, GREY, GREY_LIGHT = (0.3, 0.3, 0.3, 1), (0.5, 0.5, 0.5, 1), (0.72, 0.72, 0.72, 1)
      BLUE_GREY = (0.32, 0.4, 0.55, 1)
      POP = (0.85, 0.1, 0.08, 1)          # Renato Roldan: red as the one popping colour
      INK = (0.04, 0.04, 0.04, 1)
      
      
      # ----------------------------------------------------------------------------- scene
      def activate(sc):
          """Make `sc` the window scene. Needed before frame_set-based measuring: frame_set on a
          scene that is not the window scene does not write animated values back to the
          original objects (verified 5.2.1: location and matrix_world stay stale)."""
          win = bpy.context.window
          if win is not None and win.scene != sc:
              win.scene = sc
          return sc
      
      
      def coll(sc, name):
          c = bpy.data.collections.get(name) or bpy.data.collections.new(name)
          if c.name not in sc.collection.children:
              sc.collection.children.link(c)
          return c
      
      
      def setup_scene(name, res=(1920, 1080), fps=24, pct=50, background=0.09):
          """Previs scene with the format locked first (Charge): resolution, fps, Workbench,
          object colours, Standard view transform so greys render as set, AUDIO_SYNC playback
          (Hjalti: never judge timing with play-every-frame). Makes it the window scene."""
          sc = bpy.data.scenes.get(name) or bpy.data.scenes.new(name)
          r = sc.render
          r.resolution_x, r.resolution_y = res
          r.resolution_percentage = pct
          r.fps, r.fps_base = fps, 1.0
          r.engine = "BLENDER_WORKBENCH"
          sh = sc.display.shading
          sh.light, sh.color_type = "STUDIO", "OBJECT"
          sh.show_object_outline = True
          sh.show_cavity = False
          sh.show_shadows = False
          sc.view_settings.view_transform = "Standard"
          w = bpy.data.worlds.get("BX_previs_world") or bpy.data.worlds.new("BX_previs_world")
          w.color = (background,) * 3
          sc.world = w
          sc.frame_start = 1
          sc.sync_mode = "AUDIO_SYNC"
          for c in ("SET", "CA-cameras", "FX"):
              coll(sc, c)
          return activate(sc)
      
      
      def store_text(name, text):
          """Keep the beats list / shot table inside the .blend (Text datablock)."""
          t = bpy.data.texts.get(name) or bpy.data.texts.new(name)
          t.clear()
          t.write(text)
          return t
      
      
      # ----------------------------------------------------------------------------- primitives
      def _bm_mesh(name, kind, dims, center=(0, 0, 0), segments=16):
          bm = bmesh.new()
          if kind == "box":
              bmesh.ops.create_cube(bm, size=1.0)
          elif kind == "cyl":
              bmesh.ops.create_cone(bm, cap_ends=True, segments=segments, radius1=0.5, radius2=0.5, depth=1.0)
          elif kind == "cone":
              bmesh.ops.create_cone(bm, cap_ends=True, segments=segments, radius1=0.5, radius2=0.0, depth=1.0)
          elif kind == "sphere":
              bmesh.ops.create_uvsphere(bm, u_segments=segments, v_segments=max(6, segments // 2), radius=0.5)
          else:
              raise ValueError(kind)
          d, c = Vector(dims), Vector(center)
          for v in bm.verts:
              v.co = Vector((v.co.x * d.x, v.co.y * d.y, v.co.z * d.z)) + c
          me = bpy.data.meshes.new(name)
          bm.to_mesh(me)
          bm.free()
          return me
      
      
      def prop(sc, name, dims, loc, color=GREY_LIGHT, kind="box", collection="SET", rot_z=0.0):
          """Real-scale primitive with its origin at the bottom centre (props, set pieces).
          Stores height / eye_height so a prop can be a shot subject (eye_height = its centre)."""
          ob = bpy.data.objects.get(name)
          if ob is None:
              ob = bpy.data.objects.new(name, _bm_mesh(name, kind, dims, (0, 0, dims[2] / 2)))
              coll(sc, collection).objects.link(ob)
          ob.location, ob.rotation_euler.z, ob.color = loc, math.radians(rot_z), color
          ob["height"], ob["eye_height"] = float(dims[2]), float(dims[2]) * 0.5
          return ob
      
      
      def fog_planes(sc, name, loc, width, height, count=4, spacing=0.5, alpha=0.25, facing=0.0,
                     color=(0.85, 0.87, 0.95)):
          """Hjalti / Charge depth fog: one vertical plane, ARRAY modifier stepping away from the
          camera, translucent object colour. Workbench renders it translucent (verified)."""
          ob = bpy.data.objects.get(name)
          if ob is None:
              me = _bm_mesh(name, "box", (width, 0.0001, height), (0, 0, height / 2))
              ob = bpy.data.objects.new(name, me)
              coll(sc, "FX").objects.link(ob)
              arr = ob.modifiers.new("depth", "ARRAY")
              arr.use_relative_offset, arr.use_constant_offset = False, True
          arr = ob.modifiers["depth"]
          arr.count = count
          arr.constant_offset_displace = (0, spacing, 0)      # local +Y = away from a front camera
          ob.location, ob.rotation_euler.z = loc, math.radians(facing)
          ob.color = (*color, alpha)
          return ob
      
      
      # ----------------------------------------------------------------------------- characters
      def mannequin(sc, name, height, loc, facing=0.0, color=BLUE_GREY, kind="human"):
          """Proxy character at real scale: root empty at the feet (custom props height,
          eye_height, kind) and a handful of pivoted parts that ARE the layout controls
          (CTL-<name>-chest/head/arm_L/arm_R), the previs equivalent of Hjalti's 'layout'
          selection set. Faces local -Y; `facing` is the world heading in degrees
          (0 = toward -Y / the front camera, 90 = toward +X, -90 = toward -X, 180 = +Y)."""
          root = bpy.data.objects.get(f"CH-{name}")
          if root is not None:
              pose(root, loc=loc, facing=facing)
              return root
          c = coll(sc, f"CH-{name}")
          H = float(height)
          root = bpy.data.objects.new(f"CH-{name}", None)
          root.empty_display_type, root.empty_display_size = "PLAIN_AXES", H * 0.3
          c.objects.link(root)
      
          def part(pname, kind_, dims, center, parent, joint, col=color, ctl=False):
              n = f"CTL-{name}-{pname}" if ctl else f"{name}-{pname}"
              ob = bpy.data.objects.new(n, _bm_mesh(n, kind_, [H * x for x in dims], [H * x for x in center]))
              c.objects.link(ob)
              ob.parent, ob.location, ob.color = parent, [H * x for x in joint], col
              return ob
      
          dark = (color[0] * 0.3, color[1] * 0.3, color[2] * 0.3, 1)
          if kind == "human":
              for s, x in (("L", 0.055), ("R", -0.055)):
                  part(f"leg_{s}", "box", (0.075, 0.085, 0.46), (0, 0, 0.23), root, (x, 0, 0))
              chest = part("chest", "box", (0.24, 0.13, 0.34), (0, 0, 0.17), root, (0, 0, 0.48), ctl=True)
              head = part("head", "sphere", (0.11, 0.12, 0.13), (0, 0, 0.105), chest, (0, 0, 0.34), ctl=True)
              part("nose", "box", (0.03, 0.05, 0.03), (0, -0.06, 0), head, (0, 0, 0.1), col=dark)
              arm_len, shoulder = 0.36, (0.145, 0, 0.31)
              eye = 0.93
          elif kind == "robot":
              for s, x in (("L", 0.1), ("R", -0.1)):
                  part(f"leg_{s}", "box", (0.12, 0.14, 0.25), (0, 0, 0.125), root, (x, 0, 0))
              chest = part("chest", "box", (0.42, 0.3, 0.33), (0, 0, 0.165), root, (0, 0, 0.25), ctl=True)
              head = part("head", "box", (0.5, 0.36, 0.3), (0, 0, 0.17), chest, (0, 0, 0.33), ctl=True)
              for s, x in (("L", 0.11), ("R", -0.11)):
                  part(f"eye_{s}", "box", (0.09, 0.02, 0.07), (0, -0.185, 0), head, (x, 0, 0.19), col=INK)
              part("antenna", "cyl", (0.02, 0.02, 0.1), (0, 0, 0.05), head, (0, 0, 0.32), col=dark)
              arm_len, shoulder = 0.3, (0.24, 0, 0.27)
              eye = 0.77
          else:
              raise ValueError(kind)
          for s, sx in (("L", 1), ("R", -1)):
              a = part(f"arm_{s}", "box", (0.06, 0.065, arm_len), (0, 0, -arm_len / 2), chest,
                       (sx * shoulder[0], shoulder[1], shoulder[2]), ctl=True)
              a["length"] = arm_len * H
          root["height"], root["eye_height"], root["kind"] = H, eye * H, kind
          pose(root, loc=loc, facing=facing)
          return root
      
      
      def controls(root):
          """{'chest': obj, 'head': obj, 'arm_L': obj, 'arm_R': obj} (recursive CTL- children)."""
          prefix = f"CTL-{root.name[3:]}-"
          out, stack = {}, list(root.children)
          while stack:
              o = stack.pop()
              if o.name.startswith(prefix):
                  out[o.name[len(prefix):]] = o
              stack.extend(o.children)
          return out
      
      
      def movables(*items):
          """Everything blocking keys must cover: roots + their controls, plus plain objects."""
          out = []
          for it in items:
              out.append(it)
              if it.name.startswith("CH-"):
                  out.extend(controls(it).values())
          return out
      
      
      def pose(root, loc=None, facing=None, scale=None, bones=None, **parts):
          """Set a pose: root location / heading / scale (cheats), and part rotations in degrees,
          e.g. pose(robot, loc=(1, 0.5, 0), facing=90, arm_R=(-150, 0, 0), head=(20, 0, 0)).
          Rigify proxy: bones={"chest": (10, 0, 0), "hand_ik.R": {"loc": (0, -0.2, 0.3)}}
          (rotation in degrees, or a dict with rot and/or loc; quaternion bones are converted)."""
          from mathutils import Euler
          for bname, v in (bones or {}).items():
              pb = root.pose.bones[bname]
              rot, bl = (v.get("rot"), v.get("loc")) if isinstance(v, dict) else (v, None)
              if rot is not None:
                  e = Euler([math.radians(a) for a in rot])
                  if pb.rotation_mode == "QUATERNION":
                      pb.rotation_quaternion = e.to_quaternion()
                  elif pb.rotation_mode == "AXIS_ANGLE":
                      q = e.to_quaternion()
                      pb.rotation_axis_angle = (q.angle, *q.axis)
                  else:
                      pb.rotation_euler = e
              if bl is not None:
                  pb.location = bl
          if loc is not None:
              root.location = loc
          if facing is not None:
              root.rotation_euler.z = math.radians(facing)
          if scale is not None:
              root.scale = (scale,) * 3 if isinstance(scale, (int, float)) else scale
          ctl = controls(root) if parts else {}
          for k, rot in parts.items():
              ctl[k].rotation_euler = [math.radians(a) for a in rot]
          return root
      
      
      def eye_z(obj):
          """World height of a character's (or prop's) eye line, including cheats on z/scale."""
          return obj.matrix_world.translation.z + obj["eye_height"] * obj.matrix_world.to_scale().z
      
      
      def hand_point(root, side="R"):
          """World position of a mannequin's hand (end of the arm part), after a view-layer update."""
          bpy.context.view_layer.update()
          arm = controls(root)[f"arm_{side}"]
          return arm.matrix_world @ Vector((0, 0, -arm["length"]))
      
      
      # ----------------------------------------------------------------------------- keys
      def fcurves(id_):
          """F-curves of an ID (object, camera data, scene) through the 5.x channelbag."""
          ad = getattr(id_, "animation_data", None)
          if not ad or not ad.action or ad.action_slot is None:
              return []
          cb = anim_utils.action_get_channelbag_for_slot(ad.action, ad.action_slot)
          return list(cb.fcurves) if cb else []
      
      
      def layout_bones(rig):
          """Pose bones of the armature's 'layout' bone collection (Hjalti's layout selection set;
          the Selection Sets add-on is not bundled in 5.2.1)."""
          col = rig.data.collections_all.get("layout") if rig.type == "ARMATURE" else None
          return [rig.pose.bones[b.name] for b in col.bones] if col else []
      
      
      def key_block(objs, frame, paths=("location", "rotation_euler", "scale")):
          """Key every listed object on every channel at `frame`, plus the 'layout' bones of any
          armature (location and rotation in the bone's own rotation mode). Key everything you
          touch before cheating it for a shot (Charge): unkeyed cheats leak into earlier shots."""
          for o in objs:
              for p in paths:
                  o.keyframe_insert(p, frame=frame)
              for pb in layout_bones(o):
                  pb.keyframe_insert("location", frame=frame)
                  rp = {"QUATERNION": "rotation_quaternion", "AXIS_ANGLE": "rotation_axis_angle"}.get(pb.rotation_mode, "rotation_euler")
                  pb.keyframe_insert(rp, frame=frame)
      
      
      def make_stepped(ids, interpolation="CONSTANT"):
          """Force interpolation on every key. The preference keyframe_new_interpolation_type
          is ignored by keyframe_insert() from Python (verified 5.2.1), so always call this."""
          n = 0
          for i in ids:
              for fc in fcurves(i):
                  for k in fc.keyframe_points:
                      if k.interpolation != interpolation:
                          k.interpolation = interpolation
                          n += 1
          return n
      
      
      def stepped_report(ids):
          """{name: number of non-CONSTANT keys} for the ids that have any."""
          out = {}
          for i in ids:
              bad = sum(1 for fc in fcurves(i) for k in fc.keyframe_points if k.interpolation != "CONSTANT")
              if bad:
                  out[i.name] = bad
          return out
      
      
      def key_lens(cam, keys, interpolation="LINEAR"):
          """Animate focal length: keys = [(frame, mm), ...]. Dillon Gu: a quick zoom in reads as
          someone fixing on a target; linear for zoom slides and dolly zooms."""
          for f, mm in keys:
              cam.data.lens = mm
              cam.data.keyframe_insert("lens", frame=f)
          for fc in fcurves(cam.data):
              if fc.data_path == "lens":
                  for k in fc.keyframe_points:
                      k.interpolation = interpolation
          return cam
      
      
      def handheld(cam, strength=0.012, scale=22.0, frame=None):
          """Operator feel: NOISE F-modifiers on the camera's X/Z rotation (one key is enough).
          Default blend REPLACE centres the noise on the curve; ADD offsets it by strength/2
          (both measured on 5.2.1). Starting values from text-image-to-blender-blockout
          (0.010 to 0.018 rad at an 18 to 26 frame scale). animation_data_clear() removes them."""
          f = bpy.context.scene.frame_current if frame is None else frame
          if not any(fc.data_path == "rotation_euler" for fc in fcurves(cam)):
              cam.keyframe_insert("rotation_euler", frame=f)
          mods = []
          for fc in fcurves(cam):
              if fc.data_path == "rotation_euler" and fc.array_index in (0, 2):
                  m = fc.modifiers.new("NOISE")
                  m.strength, m.scale, m.phase = strength, scale, 1.0 + 7.3 * fc.array_index
                  mods.append(m)
          return mods
      
      
      # ----------------------------------------------------------------------------- shot list
      def shot_list(beats, shots, start=10, block=10):
          """Validate the beat grouping and assign previs frames. Every beat (1-based) must appear
          exactly once, in order (Hjalti: a missing beat means the idea never gets a fair test).
          Shots start on round frames, 10 apart (Charge: 10 frames per shot, the winner on the
          first frame of the decade); a longer block (live shot) pushes the next start to the
          following decade. A `cover` shot starts where the previous live block ends (continuous
          action seen from another camera). Returns a new list of dicts with frame, block and
          beat_text."""
          used = [b for s in shots for b in s["beats"]]
          n = len(beats)
          missing = sorted(set(range(1, n + 1)) - set(used))
          dups = sorted({b for b in used if used.count(b) > 1})
          extra = sorted(b for b in set(used) if not 1 <= b <= n)
          if missing or dups or extra or used != sorted(used):
              raise ValueError(f"beats: missing {missing}, duplicated {dups}, unknown {extra}, "
                               f"in order {used == sorted(used)}")
          f, table = start, []
          for s in shots:
              t = dict(s)
              if s.get("cover"):                  # another angle on the previous live action
                  if not table or not table[-1].get("live"):
                      raise ValueError(f"{s['id']}: cover=True continues the previous LIVE shot's action")
                  f = table[-1]["frame"] + table[-1]["block"]
                  t["live"] = True
              t["frame"], t["block"] = f, int(s.get("block", block))
              t["beat_text"] = [beats[b - 1] for b in s["beats"]]
              table.append(t)
              f = int(math.ceil((f + t["block"]) / 10.0) * 10)
          return table
      
      
      def shot_list_markdown(beats, table, title="Shot list"):
          """Deliverable table: shot, previs frame, edit in-out, seconds, beats, size (planned /
          measured by audit), lens and camera height (measured by audit when run), intent."""
          lines = [f"# {title}", "", "Beats of information (one fact per line):", ""]
          lines += [f"{i}. {b}" for i, b in enumerate(beats, 1)]
          lines += ["", "| shot | previs frame | edit in-out | s | beats | size planned/measured | lens | camera height | intent |",
                    "|---|---|---|---|---|---|---|---|---|"]
          for t in table:
              edit = f"{t['edit_in']}-{t['edit_out']}" if "edit_in" in t else ""
              size = "/".join(x for x in (t.get("size", "-"), t.get("size_measured", "")) if x)
              lines.append(f"| {t['id']} | {t['frame']} | {edit} | {t.get('dur', '')} | "
                           f"{','.join(map(str, t['beats']))} | {size} | {t.get('lens_measured', t.get('lens', ''))} | "
                           f"{t.get('height_measured', t.get('height', 'eye'))} | {t.get('intent', '')} |")
          return "\n".join(lines) + "\n"
      
      
      def look_at(obj, target):
          d = Vector(target) - obj.location
          obj.rotation_euler = d.to_track_quat("-Z", "Y").to_euler()
      
      
      def _sensor_h(sc):
          return SENSOR_W * sc.render.resolution_y / sc.render.resolution_x
      
      
      def _subject_samples(sc, chars, names, frames):
          out = []
          for f in frames:
              sc.frame_set(f)
              for n in names:
                  o = chars[n]
                  s = o.matrix_world.to_scale().z
                  out.append((o.matrix_world.translation.copy(), o["height"] * s, eye_z(o)))
          return out
      
      
      def _height(h, samples, chars):
          if isinstance(h, (int, float)):
              return float(h)
          if h == "eye":
              return sum(e for _, _, e in samples) / len(samples)
          if h.startswith("eye:"):
              return eye_z(chars[h[4:]])
          raise ValueError(h)
      
      
      def place_camera(sc, cam, spec, chars, frames=None):
          """Put a shot camera where the spec says. Default (Hjalti): camera at the mean eye
          height of the subjects, level-ish, distance from shot size and lens:
          visible height v = SIZES[size] * subject height (widened to fit the subjects' spread),
          distance = v * lens / sensor_height. Frames: those whose subject positions must fit
          (the shot's own frame, or every frame of a shared locked-off camera)."""
          activate(sc)
          fr = spec["frame"]
          frames = frames or [fr]
          names = spec.get("subjects", [])
          cam.data.lens = spec.get("lens", 35)
          samples = _subject_samples(sc, chars, names, frames) if names else []
          sc.frame_set(fr)
          now = _subject_samples(sc, chars, names, [fr]) if names else []
          aim = spec.get("aim")
          if "loc" in spec:
              cam.location = spec["loc"]
              if isinstance(aim, str):
                  o = chars[aim]
                  target = Vector((*o.matrix_world.translation.xy, eye_z(o)))
              else:
                  target = Vector(aim)
          else:
              az = math.radians(spec.get("azimuth", 0.0))
              back = Vector((math.sin(az), -math.cos(az), 0.0))       # from subject toward camera
              right = Vector((math.cos(az), math.sin(az), 0.0))
              H = max(h for _, h, _ in samples)
              base_z = min(p.z for p, _, _ in samples)
              along = [p.dot(right) for p, _, _ in samples]
              depth = [p.dot(-back) for p, _, _ in samples]
              aspect = sc.render.resolution_x / sc.render.resolution_y
              v = max(SIZES[spec.get("size", "full")] * H, (max(along) - min(along) + 0.6 * H) / aspect)
              c_xy = right * ((max(along) + min(along)) / 2) - back * (sum(depth) / len(depth))
              zc = base_z + max(0.5 * H, H - 0.4 * v)                 # 10 % headroom band [added]
              if isinstance(aim, str):
                  o = chars[aim]
                  target = Vector((*o.matrix_world.translation.xy, eye_z(o)))
              elif aim is not None:
                  target = Vector(aim)
              else:
                  target = Vector((c_xy.x, c_xy.y, zc))
              dist = v * cam.data.lens / _sensor_h(sc)
              z = _height(spec.get("height", "eye"), now or samples, chars)
              loc = Vector((target.x, target.y, 0)) + back * dist
              cam.location = (loc.x, loc.y, z)
          look_at(cam, target)
          cam["aim"] = list(target)
          return cam
      
      
      def build_shots(sc, table, chars, prefix="CAM-"):
          """One camera per shot (or a shared `cam`), placed by place_camera, and a timeline
          marker per shot at its first frame with marker.camera bound (the data-level Ctrl+B).
          Cameras: 36 mm horizontal sensor, opaque passepartout (Charge), clip 0.01 to 200
          (Hjalti's rig). Extends the scene end frame and stores the table as a Text block.
          Run after blocking: placement reads the subjects' keyed positions."""
          activate(sc)
          ids = {t["id"] for t in table}
          for m in list(sc.timeline_markers):
              if m.name in ids:
                  sc.timeline_markers.remove(m)
          cc = coll(sc, "CA-cameras")
          placed, cams = set(), {}
          for t in table:
              name = t.get("cam") or f"{prefix}{t['id']}"
              cam = bpy.data.objects.get(name)
              if cam is None:
                  cam = bpy.data.objects.new(name, bpy.data.cameras.new(name))
                  cc.objects.link(cam)
              d = cam.data
              d.sensor_fit, d.sensor_width = "HORIZONTAL", SENSOR_W
              d.passepartout_alpha, d.show_passepartout = 1.0, True
              d.show_composition_thirds = True
              d.clip_start, d.clip_end = 0.01, 200.0
              if name not in placed:
                  frames = [x["frame"] for x in table if x.get("cam") == name] if t.get("fit") == "all" else None
                  place_camera(sc, cam, t, chars, frames)
                  placed.add(name)
              m = sc.timeline_markers.new(t["id"], frame=t["frame"])
              m.camera = cam
              t["camera"] = cam.name
              cams[t["id"]] = cam
          end = max(t["frame"] + t["block"] - 1 for t in table)
          sc.frame_end = max(end, sc.frame_end if sc.get("bx_previs_built") else 0)
          sc["bx_previs_built"] = True
          store_text(f"PREVIS {table[0]['id']}-{table[-1]['id']}.json",
                     json.dumps([{k: v for k, v in t.items()} for t in table], indent=1, default=str))
          return cams
      
      
      # ----------------------------------------------------------------------------- review renders
      def _versioned(out_dir, label, ext=".png"):
          v = 1
          while os.path.exists(os.path.join(out_dir, f"{label}_v{v:03d}{ext}")):
              v += 1
          return os.path.join(out_dir, f"{label}_v{v:03d}{ext}")
      
      
      STAMP_OFF = ("use_stamp_date", "use_stamp_time", "use_stamp_render_time", "use_stamp_frame",
                   "use_stamp_frame_range", "use_stamp_camera", "use_stamp_lens", "use_stamp_scene",
                   "use_stamp_filename", "use_stamp_memory", "use_stamp_hostname", "use_stamp_marker",
                   "use_stamp_sequencer_strip")
      
      
      def _stamp(r, text, size=None):
          for a in STAMP_OFF:
              setattr(r, a, False)
          r.use_stamp, r.use_stamp_note, r.stamp_note_text = True, True, text
          r.stamp_font_size = size or max(12, int(r.resolution_y * r.resolution_percentage / 100 / 22))
      
      
      def height_class(z, eyes):
          """'eye' / 'eye:<name>' / 'low' / 'high' for a camera height against subject eye lines."""
          if not eyes:
              return "n/a"
          mean = sum(eyes.values()) / len(eyes)
          if abs(z - mean) <= EYE_TOL:
              return "eye"
          for n, e in eyes.items():
              if abs(z - e) <= EYE_TOL:
                  return f"eye:{n}"
          return "low" if z < min(eyes.values()) else "high" if z > max(eyes.values()) else "between"
      
      
      def render_stills(sc, table, out_dir, stamp=False, live_ends=False, chars=None):
          """One Workbench still per shot at its first frame (the markers switch the camera),
          versioned <id>_v###.png, never overwritten (Charge). live_ends adds the last frame of
          live shots. stamp burns a note (shot, lens, camera height, size, seconds, intent).
          Headless path; bpy.ops.render.opengl fails in --background."""
          activate(sc)
          os.makedirs(out_dir, exist_ok=True)
          r = sc.render
          saved = (r.filepath, r.use_stamp, r.use_stamp_note, r.stamp_note_text, r.stamp_font_size,
                   r.image_settings.media_type, sc.frame_current)
          r.image_settings.media_type = "IMAGE"
          r.image_settings.file_format = "PNG"
          paths = {}
          try:
              for t in table:
                  frames = [t["frame"]]
                  if live_ends and t.get("live"):
                      frames.append(t["frame"] + t["block"] - 1)
                  for i, f in enumerate(frames):
                      sc.frame_set(f)
                      label = t["id"] + ("_end" if i else "")
                      if stamp:
                          cam = sc.camera
                          eyes = {n: eye_z(chars[n]) for n in t.get("subjects", []) if chars and n in chars}
                          hc = height_class(cam.matrix_world.translation.z, eyes)
                          _stamp(r, f"{t['id']}{' end' if i else ''} | {cam.data.lens:.0f}mm | cam {hc} "
                                    f"{cam.matrix_world.translation.z:.2f}m | {t.get('size', '')} | "
                                    f"{t.get('dur', '')}s | {t.get('intent', '')}")
                      else:
                          r.use_stamp = False
                      p = _versioned(out_dir, label)
                      r.filepath = p
                      bpy.ops.render.render(write_still=True, scene=sc.name)
                      paths[label] = p
          finally:
              (r.filepath, r.use_stamp, r.use_stamp_note, r.stamp_note_text, r.stamp_font_size,
               media, cur) = saved
              r.image_settings.media_type = media
              sc.frame_set(cur)
          return paths
      
      
      def render_frame(scene, frame, path):
          """Single still of any scene (previs or edit) at `frame`. A write_still render of a
          scene that is not the window scene used the WINDOW scene's frame, not its own
          frame_current (verified 5.2.1), so this activates the scene first and restores the
          previous window scene afterwards. Animation renders are not affected."""
          prev = bpy.context.window.scene if bpy.context.window else None
          activate(scene)
          r = scene.render
          saved = (scene.frame_current, r.filepath, r.image_settings.media_type)
          try:
              scene.frame_set(frame)
              r.image_settings.media_type, r.image_settings.file_format = "IMAGE", "PNG"
              r.filepath = path
              bpy.ops.render.render(write_still=True, scene=scene.name)
          finally:
              scene.frame_set(saved[0])
              r.filepath, r.image_settings.media_type = saved[1], saved[2]
              if prev is not None:
                  activate(prev)
          return path
      
      
      def _tile(paths, cols, out, gutter=6, bg=0.06):
          import numpy as np
          imgs = []
          for p in paths:
              im = bpy.data.images.load(p, check_existing=False)
              w, h = im.size
              imgs.append(np.array(im.pixels[:], dtype=np.float32).reshape(h, w, 4))
              bpy.data.images.remove(im)
          h, w = imgs[0].shape[:2]
          rows = int(math.ceil(len(imgs) / cols))
          H, W = rows * h + (rows + 1) * gutter, cols * w + (cols + 1) * gutter
          sheet = np.ones((H, W, 4), dtype=np.float32)
          sheet[..., :3] = bg
          for i, a in enumerate(imgs):
              r, c = divmod(i, cols)
              y0 = H - (r + 1) * (h + gutter)           # images are stored bottom-up
              x0 = gutter + c * (w + gutter)
              sheet[y0:y0 + h, x0:x0 + w] = a[:h, :w]
          img = bpy.data.images.new("BX_previs_sheet", W, H, alpha=True)
          img.pixels = sheet.ravel()
          img.filepath_raw, img.file_format = out, "PNG"
          img.save()
          bpy.data.images.remove(img)
          return out
      
      
      def contact_sheet(sc, table, out_dir, cols=4, name="contact_sheet", chars=None):
          """Stamped thumbnail per shot (plus the end frame of live shots) tiled into one sheet,
          versioned. The agent's equivalent of Hjalti's thumbnails and of scrubbing the edit:
          open it and check every beat is visible, one focal point per shot, cheats invisible."""
          thumbs = render_stills(sc, table, os.path.join(out_dir, "thumbs"), stamp=True,
                                 live_ends=True, chars=chars)
          return _tile(list(thumbs.values()), cols, _versioned(out_dir, name))
      
      
      def ab_lens(sc, t, lenses, out_dir, keep_size=True, chars=None):
          """Hjalti's lens A/B before posing further: render the shot at each focal length,
          moving the camera along its view axis so the subject keeps its size (d2 = d1*f2/f1
          [added]), side by side in one image. Non-destructive (camera restored). Not for
          shots whose lens or location is keyed (the render would re-evaluate the keys)."""
          activate(sc)
          cam = bpy.data.objects[t["camera"]]
          sc.frame_set(t["frame"])
          aim, loc0, lens0 = Vector(cam["aim"]), cam.location.copy(), cam.data.lens
          d0, back = (loc0 - aim).length, (loc0 - aim).normalized()
          os.makedirs(out_dir, exist_ok=True)
          r = sc.render
          saved = (r.filepath, r.use_stamp)
          paths = []
          try:
              for mm in lenses:
                  cam.data.lens = mm
                  if keep_size:
                      cam.location = aim + back * d0 * mm / lens0
                  _stamp(r, f"{t['id']} A/B {mm:.0f}mm  dist {d0 * (mm / lens0 if keep_size else 1):.2f}m")
                  p = _versioned(out_dir, f"{t['id']}_ab_{mm:.0f}mm")
                  r.filepath = p
                  bpy.ops.render.render(write_still=True, scene=sc.name)
                  paths.append(p)
          finally:
              cam.location, cam.data.lens = loc0, lens0
              r.filepath, r.use_stamp = saved
          return _tile(paths, len(paths), _versioned(out_dir, f"{t['id']}_ab"))
      
      
      def floor_plan(sc, table, chars, out_path, line=None, paths=(), frame=None, hide=(), res=1400,
                     margin=1.0):
          """Top orthographic plan (Hjalti: floor plan with camera positions and the action line
          before the cameras; stops two people imagining 'the shot' from opposite sides).
          Draws each camera as a cone with its horizontal field of view and shot label, the
          action line between two subjects in red, and subject paths across shots in green.
          Renders in a temporary scene; the previs scene is left untouched."""
          activate(sc)
          frame = frame or table[-1]["frame"]
          tmp = bpy.data.scenes.new("BX_FloorPlan")
          for c in sc.collection.children:
              tmp.collection.children.link(c)
          for o in sc.collection.objects:
              tmp.collection.objects.link(o)
          ann = bpy.data.collections.new("BX_FloorPlan_ann")
          tmp.collection.children.link(ann)
          made = []
      
          def add(ob, color):
              ann.objects.link(ob)
              ob.color = color
              made.append(ob)
              return ob
      
          def seg(name, a, b, width, color, z):
              a, b = Vector((a.x, a.y, z)), Vector((b.x, b.y, z))
              L = max((b - a).length, 1e-4)
              ob = add(bpy.data.objects.new(name, _bm_mesh(name, "box", (L, width, 0.01))), color)
              ob.location = (a + b) / 2
              ob.rotation_euler.z = math.atan2(b.y - a.y, b.x - a.x)
              return ob
      
          def label(name, text, xy, z, size, color=INK):
              cu = bpy.data.curves.new(name, "FONT")
              cu.body, cu.size, cu.align_x, cu.align_y = text, size, "CENTER", "CENTER"
              ob = add(bpy.data.objects.new(name, cu), color)
              ob.location = (xy.x, xy.y, z)
              return ob
      
          saved_hide = [(o, o.hide_render) for o in hide]
          try:
              for o in hide:
                  o.hide_render = True
              sc.frame_set(frame)
              dg = bpy.context.evaluated_depsgraph_get()
              pts = []
              for o in sc.objects:
                  if o.type == "MESH" and not o.hide_render:
                      eo = o.evaluated_get(dg)
                      pts += [eo.matrix_world @ Vector(c) for c in eo.bound_box]
              cams = {}
              for t in table:
                  cams.setdefault(t["camera"], []).append(t["id"])
              for cn in cams:
                  pts.append(bpy.data.objects[cn].matrix_world.translation)
              lo = Vector([min(p[i] for p in pts) for i in range(3)])
              hi = Vector([max(p[i] for p in pts) for i in range(3)])
              top = hi.z + 0.2
              span = max(hi.x - lo.x, hi.y - lo.y) + 2 * margin
              scale = span / 12.0
              spots = {}                                  # co-located cameras share one label
              for cn, ids in cams.items():
                  key = tuple(round(c / 0.1) for c in bpy.data.objects[cn].matrix_world.translation.xy)
                  spots.setdefault(key, []).append(cn)
              for cn, ids in cams.items():
                  cam = bpy.data.objects[cn]
                  p = cam.matrix_world.translation
                  key = tuple(round(c / 0.1) for c in p.xy)
                  fwd = cam.matrix_world.to_quaternion() @ Vector((0, 0, -1))
                  yaw = math.atan2(fwd.y, fwd.x)
                  cone = add(bpy.data.objects.new(f"fp_{cn}", _bm_mesh("fp", "cone", (0.35 * scale, 0.35 * scale, 0.5 * scale))),
                             (1.0, 0.55, 0.05, 1))
                  cone.location = (p.x, p.y, top + 0.05)
                  cone.rotation_euler = (0, math.pi / 2, yaw)
                  half = math.atan(SENSOR_W / (2 * cam.data.lens))
                  for s in (-1, 1):
                      d = Vector((math.cos(yaw + s * half), math.sin(yaw + s * half), 0)) * 1.6 * scale
                      seg(f"fp_{cn}_fov{s}", p, p + d, 0.03 * scale, (1.0, 0.55, 0.05, 1), top)
                  if spots[key][0] == cn:
                      txt = "  ".join("/".join(cams[c]) + f" {bpy.data.objects[c].data.lens:.0f}" for c in spots[key])
                      label(f"fp_{cn}_lbl", txt, p.xy - fwd.xy.normalized() * 0.55 * scale, top + 0.45 * scale, 0.28 * scale)
              if line:
                  a, b = (chars[n].matrix_world.translation.copy() for n in line)
                  dvec = (b - a).normalized() * 2.0
                  seg("fp_line", a - dvec, b + dvec, 0.05 * scale, (0.9, 0.05, 0.05, 1), top)
              for n in paths:
                  seq = []
                  for t in table:
                      sc.frame_set(t["frame"])
                      seq.append(chars[n].matrix_world.translation.copy())
                  for i in range(len(seq) - 1):
                      if (seq[i + 1] - seq[i]).length > 0.05:
                          seg(f"fp_path_{n}_{i}", seq[i], seq[i + 1], 0.04 * scale, (0.1, 0.7, 0.2, 1), top + 0.02)
                  sc.frame_set(frame)
              cd = bpy.data.cameras.new("BX_fp_cam")
              cd.type, cd.ortho_scale = "ORTHO", span
              cd.clip_start, cd.clip_end = 0.1, (top - lo.z) + 50
              cam = bpy.data.objects.new("BX_fp_cam", cd)
              ann.objects.link(cam)
              made.append(cam)
              cam.location = ((lo.x + hi.x) / 2, (lo.y + hi.y) / 2, top + 20)
              tmp.camera = cam
              tmp.frame_current = frame
              r = tmp.render
              r.engine = "BLENDER_WORKBENCH"
              r.resolution_x, r.resolution_y, r.resolution_percentage = res, res, 100
              r.image_settings.media_type, r.image_settings.file_format = "IMAGE", "PNG"
              tmp.display.shading.light, tmp.display.shading.color_type = "FLAT", "OBJECT"
              tmp.view_settings.view_transform = "Standard"
              tmp.world = sc.world
              r.filepath = out_path
              bpy.ops.render.render(write_still=True, scene=tmp.name)
          finally:
              for o, h in saved_hide:
                  o.hide_render = h
              for ob in made:
                  data = ob.data
                  bpy.data.objects.remove(ob)
                  if data is not None and data.users == 0:
                      for coll_ in (bpy.data.meshes, bpy.data.curves, bpy.data.cameras):
                          if data.name in coll_ and coll_[data.name] == data:
                              coll_.remove(data)
                              break
              bpy.data.collections.remove(ann)
              bpy.data.scenes.remove(tmp)
              activate(sc)
          return out_path
      
      
      # ----------------------------------------------------------------------------- edit
      def animatic(sc, table, stills, out_path, edit_name=None, sounds=(), burn_in=False, res_pct=None,
                   channel=1, render=True):
          """Build the edit scene (Hjalti's NN_edit beside NN_blend) and render it with
          bx_review.playblast. Stills become image strips held for round(dur * fps) frames;
          `push` keys a fake dolly-in on the still (Charge); `live` shots become scene strips of
          the previs scene through their camera, source frame t['frame'] aligned to the cut; a live
          shot followed by `cover` shots becomes ONE scene strip soft-split at the cuts, each piece
          through its camera (Story Tools coverage: the action stays continuous, retime_cut moves
          a cut without touching keys). Stills with no `dur` hold block / fps seconds.
          sounds: [(wav_path, shot_id, frame_offset), ...] on channels above the picture.
          Returns the cut list (edit in/out per shot) and writes edit_in/edit_out into table."""
          import bx_review
          fps = sc.render.fps
          ed = bpy.data.scenes.get(edit_name or sc.name.replace("_blend", "_edit")) or \
              bpy.data.scenes.new(edit_name or sc.name.replace("_blend", "_edit"))
          r = ed.render
          r.resolution_x, r.resolution_y = sc.render.resolution_x, sc.render.resolution_y
          r.resolution_percentage = res_pct or sc.render.resolution_percentage
          r.fps, r.fps_base = fps, 1.0
          ed.sync_mode = "AUDIO_SYNC"
          ed.view_settings.view_transform = "Standard"   # new scenes default to AgX, applied on top of the stills
          ed.tool_settings.use_keyframe_insert_auto = False      # Story Tools: no auto-key in Edit
          sed = ed.sequence_editor or ed.sequence_editor_create()
          for s in list(sed.strips):
              sed.strips.remove(s)
          if ed.animation_data:
              ed.animation_data_clear()
          t, cuts, i = 1, [], 0
          frames_of = lambda x: int(round(x.get("dur", x["block"] / fps) * fps))
          while i < len(table):
              sh = table[i]
              if sh.get("live"):
                  run = [sh]                              # a live shot plus its cover angles
                  while i + len(run) < len(table) and table[i + len(run)].get("cover"):
                      run.append(table[i + len(run)])
                  durs = [frames_of(x) for x in run]
                  for x, d in zip(run[:-1], durs[:-1]):
                      if d != x["block"]:
                          raise ValueError(f"{x['id']}: in a coverage run the edit length ({d}) must equal the previs "
                                           f"block ({x['block']}) so the action stays continuous across the cut")
                  if durs[-1] > run[-1]["block"]:
                      raise ValueError(f"{run[-1]['id']}: live shot needs {durs[-1]} frames, previs block has {run[-1]['block']}")
                  s = sed.strips.new_scene(sh["id"], sc, channel, t)
                  s.content_start = t - (sh["frame"] - sc.frame_start)
                  s.left_handle, s.right_handle = t, t + sum(durs)
                  pieces, cut = [s], t
                  for d in durs[:-1]:                     # Story Tools: soft cuts on ONE scene strip
                      cut += d
                      pieces.append(pieces[-1].split(frame=cut, split_method="SOFT"))
                  for p, x, d in zip(pieces, run, durs):
                      p.name = x["id"]
                      p.scene_camera = bpy.data.objects[x["camera"]]
                      x["edit_in"], x["edit_out"] = p.left_handle, p.left_handle + d - 1
                      cuts.append({"id": x["id"], "in": x["edit_in"], "out": x["edit_out"], "frames": d, "live": True,
                                   "run": sh["id"] if len(run) > 1 else None})
                  t += sum(durs)
                  i += len(run)
                  continue
              dur = frames_of(sh)
              s = sed.strips.new_image(sh["id"], stills[sh["id"]], channel, t, fit_method="FIT")
              s.right_handle = t + dur
              if sh.get("push"):
                  base = s.transform.scale_x         # FIT already scaled the still: multiply it
                  for f, v in ((t, base), (t + dur - 1, base * float(sh["push"]))):
                      s.transform.scale_x = s.transform.scale_y = v
                      s.transform.keyframe_insert("scale_x", frame=f)
                      s.transform.keyframe_insert("scale_y", frame=f)
              sh["edit_in"], sh["edit_out"] = t, t + dur - 1
              cuts.append({"id": sh["id"], "in": t, "out": t + dur - 1, "frames": dur, "live": False, "run": None})
              t += dur
              i += 1
          for fc in fcurves(ed):
              for k in fc.keyframe_points:
                  k.interpolation = "LINEAR"
          starts = {c["id"]: c["in"] for c in cuts}
          for i, (path, sid, off) in enumerate(sounds):
              sed.strips.new_sound(f"snd{i:02d}_{sid}", path, channel + 1 + i, starts[sid] + off)
          ed.frame_start, ed.frame_end = 1, t - 1
          r.ffmpeg.audio_codec = "AAC" if sounds else "NONE"
          if burn_in:
              _stamp(r, os.path.splitext(os.path.basename(out_path))[0])
              r.use_stamp_sequencer_strip = r.use_stamp_frame = True
          else:
              r.use_stamp = False
          if render:
              bx_review.playblast(out_path, scene=ed, res_pct=r.resolution_percentage, color_type="OBJECT")
          return {"mp4": out_path if render else None, "edit": ed.name, "frames": t - 1,
                  "seconds": round((t - 1) / fps, 2), "cuts": cuts}
      
      
      def coverage(edit, src, cams, cut_frames, channel=1, start=1):
          """Story Tools coverage (Pablo Fournier): one scene, one action, one scene strip split
          at the edit cut frames with Strip.split(SOFT); piece i sees through cams[i]. Editing
          the animation updates every angle; retime with move_cut() and slip()."""
          sed = edit.sequence_editor or edit.sequence_editor_create()
          edit.view_settings.view_transform = src.view_settings.view_transform
          s = sed.strips.new_scene(f"{src.name}_cov", src, channel, start)
          pieces = [s]
          for f in cut_frames:
              pieces.append(pieces[-1].split(frame=f, split_method="SOFT"))
          for p, cam in zip(pieces, cams):
              p.scene_camera = cam
          return pieces
      
      
      def move_cut(left, right, frame):
          """Move the camera switch between two adjacent soft-split pieces without touching keys."""
          left.right_handle, right.left_handle = frame, frame
          return frame
      
      
      def slip(strip, frames):
          """Shift which part of the source plays inside a strip; its cut points stay put."""
          lh, rh = strip.left_handle, strip.right_handle
          strip.content_start += frames
          strip.left_handle, strip.right_handle = lh, rh
          return strip
      
      
      def story_tools(edit, workspace=None):
          """5.0+ in-file animatic wiring for a live session: the Sequencer shows the Edit scene
          (pinned), scrubbing it switches the main window to the shot under the playhead (sync),
          auto-key off in Edit. The Storyboarding app template ships this preconfigured."""
          ws = workspace or bpy.context.workspace or bpy.data.workspaces[0]
          ws.sequencer_scene, ws.use_scene_time_sync, ws.use_pin_scene = edit, True, True
          edit.tool_settings.use_keyframe_insert_auto = False
          return {"workspace": ws.name, "sequencer_scene": ws.sequencer_scene.name,
                  "sync": ws.use_scene_time_sync, "pinned": ws.use_pin_scene}
      
      
      def retime_cut(edit, left_id, frame, src=None, table=None):
          """Story Tools retime (Pablo Fournier: "two shortcuts" instead of moving keys): move the
          cut between shot `left_id` and the next piece of the same coverage run to edit `frame`.
          Both pieces share the scene and the content offset, so the action does not move, only
          which camera sees it. With `src` (the previs scene) the right shot's marker follows the
          cut, so render_stills, audit and scene playback agree with the edit; with `table` the
          shot frames, blocks and edit in/out are updated too. Returns the new source frame."""
          sed = edit.sequence_editor
          left = sed.strips[left_id]
          right = next((x for x in sed.strips if x != left and x.channel == left.channel
                        and x.left_handle == left.right_handle), None)
          if right is None or left.type != "SCENE" or right.type != "SCENE" or right.scene != left.scene \
                  or abs(right.content_start - left.content_start) > 1e-6:
              raise ValueError(f"{left_id}: no adjacent piece of the same coverage run (cover=True shots)")
          if not left.left_handle < frame < right.right_handle:
              raise ValueError(f"cut {frame} outside {left.left_handle}..{right.right_handle}")
          move_cut(left, right, frame)
          source = int(round(left.scene.frame_start + frame - left.content_start))
          if src is not None and right.name in src.timeline_markers:
              src.timeline_markers[right.name].frame = source
          if table is not None:
              by = {x["id"]: x for x in table}
              a, b = by.get(left.name), by.get(right.name)
              if a and b:
                  end = b["frame"] + b["block"]
                  a["block"], b["frame"], b["block"] = source - a["frame"], source, end - source
                  a["edit_out"], b["edit_in"] = frame - 1, frame
                  a["dur"], b["dur"] = (frame - a["edit_in"]) / edit.render.fps, (b["edit_out"] + 1 - frame) / edit.render.fps
          return source
      
      
      def passepartout(cams, alpha=1.0):
          """Charge (Hjalti): the shot camera's passepartout NOT see-through, "it will give your
          brain a more accurate representation of the composition" (cTx-n4OZXYE 00:02:11).
          build_shots sets it; call this on any camera made another way. Viewport display only:
          renders are unaffected, it matters when judging through the camera in a live session.
          `cams`: cameras or a scene (every camera in it). audit() flags a shot camera without it."""
          if isinstance(cams, bpy.types.Scene):
              cams = [o for o in cams.objects if o.type == "CAMERA"]
          for c in cams:
              c.data.show_passepartout, c.data.passepartout_alpha = True, alpha
          return len(cams)
      
      
      def fog_for_shot(sc, t, chars, behind=0.6, depth=6.0, count=4, alpha=0.25, color=(0.85, 0.87, 0.95),
                       only_this_shot=True, name=None):
          """Depth fog for one shot (Hjalti, XZtJ64g5VQQ 00:11:13: separate backdrop from characters
          in unrendered greys; readability, not lighting). Planes face the shot camera and step away
          from it, starting `behind` metres past the farthest subject along the view axis, so the
          backdrop is hazed and the characters are not; sized to cover the frame at the far plane.
          only_this_shot keys hide_render / hide_viewport so the planes exist only in this shot's
          block (a cheat, keyed like any other: Charge). Measure with values_at()."""
          from mathutils import Matrix
          activate(sc)
          sc.frame_set(t["frame"])
          cam = bpy.data.objects[t["camera"]]
          mw = cam.matrix_world.copy()
          fwd = mw.to_quaternion() @ Vector((0, 0, -1))
          far_subject = max(((chars[n].matrix_world.translation + Vector((0, 0, chars[n]["height"] * 0.5)))
                             - mw.translation).dot(fwd) for n in t.get("subjects", [])) if t.get("subjects") else 1.0
          near = far_subject + behind
          far = near + depth
          half_w = far * SENSOR_W / (2 * cam.data.lens) * 1.1
          half_h = half_w * sc.render.resolution_y / sc.render.resolution_x
          nm = name or f"FX-fog_{t['id']}"
          ob = bpy.data.objects.get(nm)
          if ob is None:
              ob = bpy.data.objects.new(nm, _bm_mesh(nm, "box", (2 * half_w, 2 * half_h, 0.0001)))
              coll(sc, "FX").objects.link(ob)
              arr = ob.modifiers.new("depth", "ARRAY")
              arr.use_relative_offset, arr.use_constant_offset = False, True
          arr = ob.modifiers["depth"]
          arr.count = count
          arr.constant_offset_displace = (0, 0, -(far - near) / max(count - 1, 1))    # local -Z = away from the lens
          ob.matrix_world = mw @ Matrix.Translation((0, 0, -near))
          ob.color = (*color, alpha)
          if ob.animation_data:
              ob.animation_data_clear()
          if only_this_shot:
              f0, f1 = t["frame"], t["frame"] + t["block"]
              for f, hide in ((min(f0 - 1, sc.frame_start - 1), True), (f0, False), (f1, True)):
                  ob.hide_render = ob.hide_viewport = hide
                  ob.keyframe_insert("hide_render", frame=f)
                  ob.keyframe_insert("hide_viewport", frame=f)
              make_stepped([ob])
              sc.frame_set(t["frame"])
          ob["near"], ob["far"] = near, far
          return ob
      
      
      def values_at(sc, frame, points, out_path):
          """Render `sc` at `frame` (the marker camera) to out_path and return the display value
          (Rec.709 luma, 0..1) at each world point: the measurable side of depth separation
          (hero vs backdrop) and of 'the eye finds one thing' checks."""
          import numpy as np
          render_frame(sc, frame, out_path)
          activate(sc)
          sc.frame_set(frame)
          im = bpy.data.images.load(out_path, check_existing=False)
          w, h = im.size
          a = np.array(im.pixels[:], dtype=np.float32).reshape(h, w, 4)
          bpy.data.images.remove(im)
          out = []
          for p in points:
              v = world_to_camera_view(sc, sc.camera, Vector(p))
              x, y = min(w - 1, max(0, int(v.x * w))), min(h - 1, max(0, int(v.y * h)))
              c = a[max(0, y - 1):y + 2, max(0, x - 1):x + 2, :3].reshape(-1, 3).mean(axis=0)
              out.append(round(float(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]), 4))
          return out
      
      
      def ensure_rigify():
          """Rigify ships with Blender but is OFF in a factory startup (and under --factory-startup):
          its metarig operators do not exist until it is enabled (AttributeError). Enable it before
          building or re-generating Rigify proxies. Returns True when enabled."""
          import addon_utils
          if not addon_utils.check("rigify")[1]:
              addon_utils.enable("rigify", default_set=True)
          return bool(addon_utils.check("rigify")[1])
      
      
      RIGIFY_LAYOUT = ("torso", "chest", "head", "hand_ik.L", "hand_ik.R", "foot_ik.L", "foot_ik.R")   # [added]
      _PART_T = (("DEF-spine.006", 0.12), ("DEF-spine.00", 0.17), ("DEF-spine", 0.17), ("DEF-thigh", 0.075),
                 ("DEF-shin", 0.06), ("DEF-upper_arm", 0.05), ("DEF-forearm", 0.045), ("DEF-hand", 0.05), ("DEF-foot", 0.05))
      
      
      def rigify_proxy(sc, name, height, loc, facing=0.0, color=BLUE_GREY):
          """Rigify Basic Human proxy for previs, when the characters are (or will be) Rigify rigs.
          Enables Rigify first, scales the metarig to `height`, generates, deletes the metarig,
          and skins nothing: one grey box per deform bone, bone-parented (Charge: no weight
          painting, fewest bones). A bone collection 'layout' holds the only controls previs keys
          (RIGIFY_LAYOUT; Hjalti's layout selection set); root motion stays on the object like a
          mannequin. The rig object is `CH-<name>` with height / eye_height / kind props, so it is
          a subject for place_camera, audit and floor_plan. Mannequins are cheaper; use this only
          when the brief calls for Rigify."""
          from mathutils import Matrix
          activate(sc)
          ensure_rigify()
          if bpy.context.object is not None and bpy.context.object.mode != "OBJECT":
              bpy.ops.object.mode_set(mode="OBJECT")
          sc.cursor.location = (0, 0, 0)
          bpy.ops.object.armature_basic_human_metarig_add()
          meta = bpy.context.active_object
          top = max((meta.matrix_world @ b.tail_local).z for b in meta.data.bones)
          meta.scale = (height / top,) * 3
          bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
          bpy.ops.pose.rigify_generate()
          rig = meta.data.rigify_target_rig
          arm_data = meta.data
          bpy.data.objects.remove(meta)
          bpy.data.armatures.remove(arm_data)
          rig.name = rig.data.name = f"CH-{name}"
          c = coll(sc, f"CH-{name}")
          for u in list(rig.users_collection):
              u.objects.unlink(rig)
          c.objects.link(rig)
          lay = rig.data.collections.new("layout")
          for b in RIGIFY_LAYOUT:
              lay.assign(rig.pose.bones[b])
          bpy.context.view_layer.update()
          H = float(height)
          for b in rig.data.bones:
              if not b.use_deform:
                  continue
              th = next((f for pre, f in _PART_T if b.name.startswith(pre)), None)
              if th is None:
                  continue
              L = b.length
              me = _bm_mesh(f"{name}-{b.name}", "box", (th * H, L, th * H), (0, L / 2, 0))
              ob = bpy.data.objects.new(f"{name}-{b.name}", me)
              c.objects.link(ob)
              ob.parent, ob.parent_type, ob.parent_bone = rig, "BONE", b.name
              ob.matrix_basis = Matrix.Translation((0, -L, 0))      # bone parenting starts at the tail
              ob.color = color
          head = rig.data.bones["DEF-spine.006"]
          nose = bpy.data.objects.new(f"{name}-nose", _bm_mesh(f"{name}-nose", "box", (0.03 * H, 0.05 * H, 0.03 * H)))
          c.objects.link(nose)
          nose.parent, nose.parent_type, nose.parent_bone = rig, "BONE", head.name
          at_rest = head.matrix_local @ Matrix.Translation((0, head.length, 0))
          nose.matrix_basis = at_rest.inverted() @ Matrix.Translation((0, -0.07 * H, head.head_local.z + 0.45 * head.length))
          nose.color = (color[0] * 0.3, color[1] * 0.3, color[2] * 0.3, 1)
          rig["height"], rig["eye_height"], rig["kind"] = H, 0.93 * H, "rigify"
          pose(rig, loc=loc, facing=facing)
          return rig
      
      
      # ----------------------------------------------------------------------------- gates
      def size_class(cover):
          """Nearest SIZES name for a subject covering `cover` of the frame height."""
          if cover <= 0:
              return "off"
          m = 1.0 / cover
          return min(SIZES, key=lambda k: abs(math.log(SIZES[k] / m)))
      
      
      def _cam_view(sc, cam, p):
          return world_to_camera_view(sc, cam, p)
      
      
      def _belongs(ob, subject):
          while ob is not None:
              if ob == subject:
                  return True
              ob = ob.parent
          return False
      
      
      def line_of_sight(sc, origin, target, subject):
          """(visible, blocker_name) for a ray from the camera to a point on the subject: the first
          hit must be the subject itself (any of its parts) or lie beyond the point. Catches doors,
          counters and a POV character's own head between the lens and the hero."""
          dg = bpy.context.evaluated_depsgraph_get()
          d = Vector(target) - Vector(origin)
          L = d.length
          hit, loc, _n, _i, ob, _m = sc.ray_cast(dg, Vector(origin), d.normalized(), distance=L + 1e-3)
          if not hit:
              return True, None
          ob = getattr(ob, "original", ob)
          if _belongs(ob, subject) or (loc - Vector(origin)).length >= L - 0.02:
              return True, None
          return False, ob.name
      
      
      def audit(sc, table, chars, line=None, travel=None, block_max=10, stepped=()):
          """Measurable previs gates per shot: marker switches to the shot camera at its frame
          and not before; lens; camera height class vs subject eye lines and pitch; subject
          head/feet/eye in frame, eye and mid-body line of sight (ray cast: what blocks the
          hero), frame-height coverage and measured size; side of the action
          line (line = (nameA, nameB)); screen direction of a travelling subject
          (travel = {name: 'right'|'left'}: the move from the previous shot's pose into this
          shot, seen through this shot's camera, ignored under 5 % of frame width; a shot's own
          'travel' overrides it, Charge: reverse only when the character reverses); previs block
          length; consecutive shots without a character; non-constant blocking keys outside live
          blocks (a live or cover shot may interpolate its action).
          Cost and camera gates (previs is cheap, Hjalti/Charge): opaque passepartout on every shot
          camera; a still (non-live) shot whose camera moves inside its block; blocking keys on
          `stepped` objects at frames, within this table's range, that are neither a shot's first
          frame nor inside a live block (continuous animation of every shot; delete the losing A/B
          frame first; other treatments on the same timeline are ignored); a render
          engine other than Workbench (mood lighting). Light objects in the scene are a warning.
          Returns {'rows': [...], 'problems': [...], 'warnings': [...]}."""
          activate(sc)
          table = sorted(table, key=lambda x: x["frame"])
          rows, problems, sides = [], [], {}
          for i, t in enumerate(table):
              f = t["frame"]
              expected = bpy.data.objects[t["camera"]]
              sc.frame_set(f)
              row = {"id": t["id"], "frame": f, "switch_ok": sc.camera == expected}
              if i:
                  sc.frame_set(f - 1)
                  row["prev_ok"] = sc.camera == bpy.data.objects[table[i - 1]["camera"]]
                  sc.frame_set(f)
              cam = expected
              p = cam.matrix_world.translation.copy()
              fwd = cam.matrix_world.to_quaternion() @ Vector((0, 0, -1))
              eyes = {n: eye_z(chars[n]) for n in t.get("subjects", []) if n in chars}
              row.update(lens=round(cam.data.lens, 1), cam_z=round(p.z, 2),
                         pitch=round(math.degrees(math.asin(max(-1, min(1, fwd.z)))), 1),
                         height=height_class(p.z, eyes))
              t["height_measured"] = f"{row['height']} {row['cam_z']}m"
              t["lens_measured"] = row["lens"]
              frame_info = {}
              for n in eyes:
                  o = chars[n]
                  base = o.matrix_world.translation.copy()
                  top = base + Vector((0, 0, o["height"] * o.matrix_world.to_scale().z))
                  eye = Vector((base.x, base.y, eye_z(o)))
                  b, tp, e = (_cam_view(sc, cam, q) for q in (base, top, eye))
                  inside = lambda v: v.z > 0 and 0 <= v.x <= 1 and 0 <= v.y <= 1
                  cover = (tp.y - b.y) if (b.z > 0 and tp.z > 0) else 0.0
                  mid = Vector((base.x, base.y, (
  • SKILL.md 19.4 KB
    ---
    name: scenario-blender-previs-storyboard
    description: "Use when turning a script or story into previs, layout, storyboards or an animatic in Blender: beats to shot list, camera and lens per shot, staging, camera height, 180-degree rule, screen direction, shot sizes, when to cut, camera moves, timing stills in the Video Sequencer, markers bound to cameras, Story Tools, Grease Pencil or 2D/3D hybrid boards. Also when an animatic shows the wrong frame, blocking keys are not stepped, Rigify operators are missing, or viewport render fails headless."
    license: MIT
    ---
    
    # Previs, layout and storyboarding
    
    Expert previs conveys an idea well enough to be judged, as cheaply as possible: one fact per beat, every shot designed for what the audience must feel, timing found in the edit from stills. The camera is the audience's body; where it stands, how high and through which lens decides the emotion, so every non-neutral choice needs a stated reason. 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).
    
    Toolkit: [`scripts/bx_previs.py`](scripts/bx_previs.py) (tested on 5.2.1): `import sys; sys.path += ["<this skill>/scripts", "<scenario-blender-expert>/scripts"]; import bx_previs as P`.
    
    **Which skill.** This one: story previs and animatics judged by a director and an editor. **text-image-to-blender-blockout** instead when the playblast guides a render-to-real video model (Seedance): saturated color identities, no lens jump on a cut, eased moves, a hand-off sheet; an approved animatic that must become a video guide is rebuilt there. Shared: metric Z-up, roots at the feet, one camera per shot bound to a marker, `to_track_quat('-Z','Y')` look-at. Strokes: scenario-blender-grease-pencil. Animation after layout: scenario-blender-animation. Real rigs: scenario-blender-rigging.
    
    ## Stance (the expert delta)
    
    - **Hjalti Hjalmarsson: write the beats-of-information list before any shot.** One fact per line, because a viewer takes in one thing at a time. Group consecutive beats into shots; a new treatment re-orders the same facts. A missing beat means the idea never gets a fair test.
    - **Hjalti: theater first, then cinematic; shots cost time.** A locked-off version judges the acting; the same beats told in shots ran about 2x (up to 3x) because every cut must be read (13/22/25 s vs 47/40 s). The oversimplified version is always too short and hides logistics (a character cannot cross the room in 10 frames).
    - **Charge recipe (Hjalti): treat every frame as a storyboard drawing.** 10 frames per shot, keys CONSTANT, an A/B alternative on the next frame, the winner on the decade frame, stills exported with versioned names, timing done in a separate edit.
    - **Hjalti: when in doubt, eye height; lock the lens early by A/B.** Two characters: split their eye heights. Low or high is a statement. Poses built for one lens do not survive a lens change.
    - **Dillon Gu: composition and focal length are separate decisions, and cuts can kill a mood.** The audience stands where the camera is. Leaving the space removes the danger; two inanimate inserts in a row lower tension.
    - **Cheat for the camera, but key before you cheat** (Hjalti, Charge, Renato Roldan). Resize, float, swap props per shot; in a shared timeline an unkeyed cheat breaks the earlier shots.
    - **Previs is the cheapest version that still conveys the idea** (Hjalti, Charge). Animate only shots whose camera move is the storytelling; everything else is one still, push-ins faked on the still in the edit. Grays in Workbench, no mood lighting, mannequins before rigs. `P.audit` refuses a camera moving inside a still shot, keys between block starts and a non-Workbench engine.
    - **After each cut the eye must find one thing** (Hjalti). Restage a character in front of a competing subject; keep the angle that preserves the emotion (he refused a side cut that turned Rex into a silhouette).
    
    ## Establish first
    
    | Input                  | Changes                                   | Default when silent                                                                                                                                                                 |
    | ---------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | Format                 | every composition (Charge: lock it first) | 1920x1080, 24 fps, stills at 50 %                                                                                                                                                   |
    | Treatment              | shot count, runtime                       | theater pass, then cinematic                                                                                                                                                        |
    | Method                 | agent has no tablet                       | 3D previs with mannequins; drawn boards only on provided art; Rigify proxies only on request (`P.rigify_proxy`, Rigify is off under `--factory-startup`: `P.ensure_rigify()` first) |
    | Mood or director style | lens, height, motion per shot             | neutral grammar, deviations stated per shot                                                                                                                                         |
    | Runtime budget         | durations                                 | about 1.6 to 1.9 s per beat for a cinematic cut (derived from Hjalti's slide: 25 beats, 8 shots, 40 to 47 s)                                                                        |
    | Journeys               | screen direction                          | out left to right, back right to left (Charge)                                                                                                                                      |
    | Deliverable            | outputs                                   | shot list, contact sheet, floor plan, animatic MP4 clean plus burn-in, versioned .blend                                                                                             |
    
    ## Workflow
    
    1. **Script to beats.** Asset list, then numbered beats, one fact each; mark every beat you add. GATE: `P.shot_list(BEATS, SHOTS)` raises on a missing, duplicated or reordered beat.
    2. **Set and proxies.** `P.setup_scene` (format, Workbench, grays, Standard view transform); `P.prop` for real-scale set pieces; `P.mannequin` for characters (root at the feet with `eye_height`, four pivot controls as the layout subset). Grays, one pop color for the hero (Renato: red). GATE: scale reads next to a door (2.1 m) and counter (1.0 m).
    3. **Theater pass.** One locked-off camera (`cam` shared, `fit="all"`, 35 mm, mean eye height) framing the action's extremes; one block per beat group. GATE: in the `P.audit` rows every subject's `head_in` and `feet_in` is true at every block; theater edit timed.
    4. **Cinematic plan.** List several openings, re-order beats, group them into shots with `intent`, `size`, `lens`, `height`, `dur`. `P.floor_plan` with the action line and journey paths before judging cameras. GATE: one side of the line; journeys follow the direction rule.
    5. **Blocking.** For each block: `sc.frame_set(f)` (holds the previous pose), set only what changes, `P.key_block(all_movables, f)`; cheats go through the same keys; then `P.make_stepped`. GATE: `P.stepped_report == {}`; the cheat check at neighboring shots.
    6. **Cameras.** `P.build_shots` places each camera from size, lens and eye height (or explicit `loc`/`aim`), sets an opaque passepartout (Charge: see only the composition; `P.passepartout(sc)` for other cameras) and binds a marker at its first frame. `P.ab_lens` on key shots before posing further. GATE: `P.audit(...)["problems"] == []`.
    7. **Look.** `P.contact_sheet` and open it. Characters merging with a dim set: `P.fog_for_shot(sc, t, chars)` (Hjalti: depth fog for readability, not lighting; behind the subjects, keyed to that shot), `P.values_at` hero vs backdrop. GATE: the visual list under Quality gates (in S6 only the pictures caught the occlusions and the cut-off wide).
    8. **Edit.** `P.render_stills`, then `P.animatic` (image strips held `dur` seconds, `push` on stills, `live` shots as scene strips, sounds, `burn_in`). One continuous action from several angles (Pablo, Story Tools): a `live` shot plus `cover=True` shots become one scene strip soft-split per camera; retime with `P.retime_cut` (the marker follows), never by moving keys. GATE: runtime within budget; frames pulled from the MP4 match the cut list.
    9. **Iterate** in a new scene pair (`02_blend`/`02_edit`) and new version numbers; nothing overwritten.
    
    ## Numbers
    
    | Value                                                                          | Relative to                                | Source                                                |
    | ------------------------------------------------------------------------------ | ------------------------------------------ | ----------------------------------------------------- |
    | 10 frames per shot block, 20 only when justified; starts on 10, 20, 30         | previs timeline                            | Charge                                                |
    | Shots named in tens (sh010, sh020)                                             | markers                                    | Hjalti on screen                                      |
    | 35 mm theater frame; about 60 mm for an isolating key shot                     | 36 mm sensor                               | Hjalti                                                |
    | F-stop 5.6, clip 0.01 to 200 m on the camera rig                               | Hjalti's rig                               | Hjalti                                                |
    | 13 / 22 / 25 s theater vs 47 / 40 s cinematic                                  | same script                                | Hjalti slide 00:46:01                                 |
    | 3 poses for an anger burst (anticipation, overshoot, settle)                   | one beat                                   | Hjalti                                                |
    | 6 fps boarding, retimed in the edit                                            | board scene                                | BouncyBrain                                           |
    | Eye tolerance 0.15 m; drawing breaks past 70 degrees from camera               | audit thresholds                           | [added]                                               |
    | Visible height = subject height x 4 / 2 / 1.15 / 0.8 / 0.6 / 0.4 / 0.25 / 0.12 | ews, wide, full, mws, medium, mcu, cu, ecu | [added]                                               |
    | Distance = visible height x lens / sensor height (20.25 mm at 16:9)            | lens swap at equal size: d2 = d1 x f2 / f1 | [added]                                               |
    | Handheld NOISE 0.010 to 0.018 rad, 18 to 26 frame scale                        | camera rotation                            | blockout skill; peak-to-peak measured 0.66 x strength |
    
    ## Camera language
    
    | Choice                                                                          | Reads as                                                          | Source                   |
    | ------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------ |
    | Eye level, medium lens, centered, static                                        | neutral; the start for every A/B                                  | Dillon, Hjalti           |
    | Low or tucked in a corner                                                       | hidden observer                                                   | Dillon                   |
    | Wide lens from far, then a quick zoom in                                        | being watched; someone fixing on a target                         | Dillon                   |
    | Extreme wide lens close up                                                      | "not okay", distortion                                            | Dillon                   |
    | Long lens                                                                       | informational, flat, isolates, intensifies a two-character moment | Dillon, Hjalti, Spitfire |
    | Top view; dolly zoom (linear keys)                                              | pressure from above; loss of control                              | Dillon                   |
    | Off-center from behind                                                          | isolation                                                         | Dillon                   |
    | Side-on movement                                                                | the most neutral screen motion                                    | Dillon                   |
    | Tilt or move ending on a detail                                                 | that detail becomes the subject                                   | Hjalti                   |
    | Faceless framing, longer shots, significant object at the edge carried by sound | routine                                                           | Dillon                   |
    
    **Moves that feel physical** (Dillon): handheld noise when a watcher or a groggy body holds the camera, a camera operator's quick zoom, the camera dropping with the character's shoulders, less motion for laziness, more for urgency; a product shot is the stillest shot. Hjalti pulls focus across a handoff (`dof.focus_object`). **Cuts:** one side of the action line; an over-the-shoulder uses the shoulder the camera already occupies, and a jump cut can mean elided routine time (Dillon); a POV sits just off the line on the camera side [added, S6].
    
    ## Quality gates
    
    **Measurable:** `P.audit(sc, table, chars, line=(a, b), travel={name: "right"}, stepped=movables)`: marker switch at each shot frame and not one frame before, main subject's eye line in frame and not blocked (ray cast names the blocker, fog included), measured vs intended size, height class with an intent for every non-eye camera, one side of the action line (POV and `cross` exempt), screen direction per journey, block length, two inserts in a row, non-constant keys outside live blocks, opaque passepartout, cost gates; `warnings` lists lights. Plus the edit runtime, `ffprobe` streams and duration, and for live shots an edit frame rendered against the previs frame.
    
    **Visual** (open each image): contact sheet (every beat visible, one focal point per shot, hero readable, cheats and occluders invisible, pose readable from this camera), floor plan (camera side, journeys), A/B sheet (isolation vs context), frames pulled from the MP4 (order, burn-ins, push, live-shot content). Comic timing and sound stay a human call (Hjalti).
    
    ## Common mistakes
    
    | Mistake                                             | Looks like                                              | Fix                                                                                                            |
    | --------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
    | Shots sprinkled over a finished performance         | generic coverage, wrong framings                        | design each shot from its beats (Hjalti)                                                                       |
    | Wide framing that ignores the extremes              | the tallest pose or entrance clips                      | `fit="all"` and the extremes audit                                                                             |
    | POV from the true eye point                         | the counter or the character's own head blocks the view | cheat the POV forward and up (S6: 0.6 m forward, 1.72 m high, over the counter); the audit reports the blocker |
    | Door or prop between camera and hero                | hero hidden in the key frame                            | swing it away or move the camera; the contact sheet catches it                                                 |
    | Over-the-shoulder from the far side                 | line crossed, hero out of frame                         | stay on the camera side; audit eye line                                                                        |
    | Unkeyed cheat                                       | earlier shot changes                                    | `key_block` every movable at every block                                                                       |
    | 6 fps board scene as a scene strip in a 24 fps edit | plays 4x fast                                           | export stills or retime                                                                                        |
    | Every shot animated, mood lighting, full rigs       | slow, the edit fights the moves, nothing judged sooner  | stills plus `push`, `live` only when the move is the meaning, Workbench grays, mannequins                      |
    | Coverage as separate strips or moved keys           | cuts drift from the action                              | `cover=True` run, `P.retime_cut`                                                                               |
    | Fog world-aligned or in front                       | hero tinted, fog in every shot                          | `P.fog_for_shot` (behind the subjects, one shot)                                                               |
    
    ## Blender 5.2 notes
    
    - `bpy.ops.render.opengl` fails in `--background`; use Workbench `render.render` (headless) or a camera-locked viewport in a live session.
    - `keyframe_insert()` ignores the new-key interpolation preference (keys stay BEZIER) though "Only Insert Available" is on: call `P.make_stepped`.
    - `frame_set` on a scene that is not the window scene leaves transforms stale, and a `write_still` render of such a scene uses the window scene's frame: `P.activate`, `P.render_frame`.
    - VSE: `strips`, `left_handle`/`right_handle`, `content_start`; `SceneStrip.frame_start` and `frame_final_*` warn (removal in 6.0). `Strip.split(frame, split_method='SOFT')` splits from data. New scenes default to AgX: set the edit scene to the source view transform.
    - `marker.camera = cam` binds headless; `bpy.ops.marker.camera_bind` needs a timeline context.
    - Rigify is off under `--factory-startup`: `bpy.ops.object.armature_basic_human_metarig_add` raises "could not be found" until `P.ensure_rigify()`.
    - Selection Sets, Storypencil and Grease Pencil Tools are not bundled; use a bone collection `layout`. The Storyboarding app template ships an Edit scene with sync on and auto-key off in Edit (pin off in its Storyboarding workspace).
    - GP v3 strokes render in a headless Workbench still.
    
    ## References
    
    - [`references/procedures.md`](references/procedures.md): tested code for every stage (with and without the module) and the trap tests; load before writing code.
    - [`references/expert-notes.md`](references/expert-notes.md): each expert's principles, disagreements and deciding conditions, with timestamps; load when choosing a treatment, lens or cut.
    - [`references/critique.md`](references/critique.md): the review rubric and the S6 findings; load at every Look gate.
    - [`references/sources.md`](references/sources.md): the seven 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