scenario-blender-2d-animation
Use when a 2D side-scroller shot, platformer cutscene, game trailer or parallax animation has to be built in Blender from Scenario-generated pictures (flat image cards, an orthographic camera, keyed jumps, rigid-body props), or when a finished shot needs a new theme or look witho
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/scenario-blender-2d-animation
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
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.
README
scenario-blender-2d-animation: maintainer notes
Not read at runtime. How the skill was built and why it makes its choices.
Origin
Distilled from the Blender 2D animation exploration on scenario.com: a 7.7 second side-scroller shot in three looks (a red panda in a golden-hour jungle, a black cat on a moonlit Halloween street, a snow leopard cub in a winter valley), ten Scenario generations per look, built and rendered by one Blender script. The page offers the skill as a download; this directory is that skill brought to the repository's authoring contract.
The exploration used one text-to-image model for every picture and one image-to-video model at 768p for the run clips. The body names neither and discovers both lanes with recommend (txt2img, img2video), because availability differs per team and both generations will be superseded. The page's credit figures are left out for the same reason: the body prices with dry_run.
Why a core skill, not a Blender expert tool
Every paid step (ten generations per look) is a Scenario MCP call; Blender only lays out and renders, locally. The expert tier exempts its skills from the MCP rules, which are exactly what this skill must be graded on, and the Blender family's specialists share the lead's toolkit and provenance, neither of which applies here. scenario-patina-retexture set the precedent: MCP generation, then bundled Blender scripts run headless. The name keeps the download's, so the exploration page and the repository agree.
Changes from the download
- Description cut to triggers only; the body names no generative model; discovery moved from a newest-first
searchtorecommend; payload field names hedged as schema examples. scenario-side-view-game-kitandscenario-sprite-animationnamed as the sprite-strip alternatives. The download's other related skills are not named: the model-family ones point at a model, and the workflow hands nothing to the rest, whose bodies would only push the fan-out past the re-attach pool.build_scene.py --liveused to empty whatever file was open. It now refuses unless the file is new (nothing beyond the startup cube, light and camera) or the script's own output.bpy.data.is_dirtycannot make that call: Blender reports a new file as dirty.- The jump still's scale was hard-coded as 768 / 960 (the clip's height over the reframed canvas), which is wrong for any video model that answers at another resolution.
run_loop.pynow printsrun_canvas_pxandbuild_scene.pyreads it with the jump still's own height. - The common mistakes that the scripts now prevent moved out of the body;
references/pipeline.mdalready records them for anyone changing the scene script.
Scripts
reframe_stills.py is a byte-for-byte copy of the one in scenario-side-view-game-kit, kept so each skill installs alone; tests/scenario-blender-2d-animation/test_reframe_stills.py fails when the two drift, so a fix to one reaches the other. run_loop.py shares that kit's loop-window idea (seam over in-window motion) but adds the border key and writes a Blender image sequence instead of a pixel strip.
The suite runs the five helpers on synthetic inputs. test_build_scene.py builds, bakes and saves a synthetic set in a real Blender and is skipped unless BLENDER names the binary or blender is on the PATH, which CI does not provide; it passed on Blender 5.0.1.
Skill manifest
Scenario Blender 2D animation
Overview
Generate the art once per look; animate it once in Blender. A concept frame (an imagined in-game screenshot) fixes the style, and the eight other pictures (sky, far and mid layers, ground strip, foreground plants, props sheet, tree, the hero in two poses) take it as their reference, so the set holds together. An image-to-video clip of the hero running in place on magenta becomes a looping image sequence. One Blender script lays the pictures out as flat cards in depth, drives their parallax from an orthographic camera that follows the hero, keys the jumps and runs rigid-body physics on the props. A new look is ten new generations and a few measured numbers. The reference result is a 7.7 second, 1080p, 24 fps shot in three looks. The deliverable is the film and its .blend; for sprite strips a game engine plays, use scenario-side-view-game-kit or scenario-sprite-animation.
Discover both lanes with recommend, the user's words as prompt: capability: "txt2img" for the pictures, which need a reference-image input, a transparent background and exact width and height (if the pick lacks one, or is a specialty for another style than the look, such as pixel art for a painted cartoon, take the next ranked pick), and capability: "img2video" for the run clip, which needs a first frame. model_schema_get each pick. Payload names below (referenceImages, background, numOutputs) are examples: use the fields your pick's schema lists. Before generating, tell the user the total for the nine pictures and the clip: dry_run the concept frame and quote the rest from recommend, since a dry_run naming an asset that does not exist yet returns 404. Then dry_run each later payload once its input exists, before running it.
Connection, uploads and the core loop: the scenario skill. 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.
The scripts run locally and never call Scenario. Blender 5.x runs the scene script, headless or in an open session (through a Blender MCP connection, for example); in a session, start from File > New, since --live empties the open file and refuses any file but a new one or its own output. Python 3 with Pillow, NumPy and SciPy runs the helpers, and ffmpeg reads the clip. Every helper prints its usage when run without arguments and refuses to overwrite an existing output (or its input) unless given --force.
Quick reference
| Step | How |
|---|---|
| Pick models | recommend (txt2img, img2video), model_schema_get, then model_run with dry_run: true per distinct payload |
| Concept frame | Text to image, 16:9, opaque, two takes: an in-game screenshot, strict side view, layers named in order (prompts) |
| Layers | One call per layer with the concept as reference; transparent except the sky; wide (3072 x 1152) for tiled layers |
| Sheets | Props, tree and foreground on transparent backgrounds with wide gaps, numbered in reading order |
| Hero | Two poses (rest, jump) side by side on flat magenta; reframe_stills.py makes square first frames |
| Run clip | Image to video from the rest pose: "like a treadmill", locked camera, magenta that never changes; 5 s is enough |
| Run loop | run_loop.py: key, find the cleanest one-period loop, write hero_run/run_001.png... |
| Cut and key | prep_assets.py cut (sheets), key (magenta stills), tile (seamless wide layers) |
| Hulls | trace_hulls.py: convex hull of each prop's alpha into shapes.json |
| Measure | measure_set.py: horizon, ground line, island and pedestal tops (example config) |
| Build | build_scene.py -- --set SET_DIR --review, then --final |
| New look | New concept frame, the same eight pictures and clip, same file names, a new config.json; nothing else changes |
| Wait | jobs_wait with up to 32 job_ids; on timeout re-call with the returned pending_job_ids |
Worked example: a jungle run, then a snow day
Pass team_id and project_id on every call (the scenario skill covers picking them).
- Concept frame.
recommend { "capability": "txt2img", "prompt": "<the user's request>; needs reference image input, transparent background, exact width and height" },model_schema_getthe pick, price two takes withmodel_run { "model_id": "<pick>", "dry_run": true, "parameters": { "prompt": "...", "width": 1536, "height": 864, "background": "opaque", "numOutputs": 2 } }and the screenshot prompt from references/prompts.md section 1. Run it,jobs_waitif it returns ajob_id,asset_displayboth takes and keep the one whose layers you can name one by one. Its asset id is the reference for everything that follows. - Layers, one call each. The same pick with
referenceImages: ["<concept id>"]: the sky (2560 x 1440, opaque), the far layer and the mid layer (3072 x 1152, transparent), the ground strip (2048 x 768, transparent, a flat walkable top at about 30 percent from the top, no end caps) and a foreground sheet of five plant clusters (3072 x 1024, transparent). Price them, then run them withwait: falsein waves within the team's concurrency ceiling (theparallel-custom-jobsrow in thescenarioskill: a launch refused with that 429 created no job, so relaunch it in the next wave), retiring each wave withjobs_wait. - Props and tree. A props sheet (2048 x 1536, transparent): numbered props in a loose grid with wide gaps (a pot, a tall vessel, a lidded jar, a heavy statue, the fruit that will fall, a coin, a floating island, a flat pedestal), and the tree alone (1024 x 1536) with no fruit in its crown.
- Hero. Two poses on flat magenta (1536 x 1024, opaque, two takes): rest on the left, jump on the right, both the same size, pure side profile facing right.
asset_downloadthe kept take, thenpython3 scripts/reframe_stills.py hero.png --name hero --out frameswriteshero_rest.pngandhero_action.png(lower--figurefor a long tail). Upload the rest frame withupload_assetandupload_asset_complete. - Run clip.
recommend { "capability": "img2video", "prompt": "<the user's request>; first frame input, 5 seconds" },model_schema_get, price a five-second clip with the rest frame's asset id in the schema's first-frame field and the treadmill prompt (prompts.md section 5), with audio off and a 1:1 aspect ratio where the schema has those fields (the loop keeps only silent frames, and the jump still's scale assumes the clip is the square first frame resized), run it,jobs_wait(minutes, re-called as needed),asset_download. - Process. Into
jungle/game/(the sky stays one wide card: copy it assky.png):python3 scripts/prep_assets.py tile raw/sea.png jungle/game/sea_tile.png 384, the same for the mid layer (jungle_tile.png; a fade of one eighth of the width) and the ground (ground_tile.png,256for 2048 px);python3 scripts/prep_assets.py cut raw/props.png jungle/game pot_round,amphora,jar,idol,coconut,coin,island,pedestal;CUT_GROW=2 python3 scripts/prep_assets.py cut raw/foreground.png jungle/game fg_bush,fg_fern,fg_hibiscus,fg_boulder,fg_curl;python3 scripts/prep_assets.py key frames/hero_action.png jungle/game/hero_jump.png;python3 scripts/run_loop.py clips/hero_run.mp4 jungle/game/hero_run(it printsrun_n,run_fig_px,run_pad_pxandrun_canvas_px, the clip's height);python3 scripts/trace_hulls.py jungle/game. Copy the tree (keyed or already transparent) aspalm.png. - Measure.
python3 scripts/measure_set.py jungle/game --ground-h 4.3(the ground card's height in meters,ground_hin the config) prints the horizon, the walkable line (already moved 10 cm down so the feet sink into the grass lip), the island and pedestal tops and the jump still's feet row. Write them with the run numbers intojungle/game/config.json. Layer heights, the fruit positions and the hero's height in meters are set by eye on the review stills: start from examples/config.json (references/pipeline.md lists every key). - Build and review.
blender -b --factory-startup --python-exit-code 1 -P scripts/build_scene.py -- --set jungle --review. Outputs land in the folder holdingjungle/(or--out DIR):platformer_scene<tag>.blend,review<tag>/and, with--final,platformer<tag>.mp4; a rebuild over them needs--force. Look at the 11 review stills: feet on the ground, the tree standing on the ground, no seam, the tower standing until the hero reaches it, every piece inside the frame. Then--finalrenders the film. Before delivery, walk references/checks.md. - A new look. Generate a new concept frame (a white winter valley, a snow leopard cub in a red hat), the same eight pictures and clip with it as reference, process into
snow/game/with the same names, measure a newconfig.json(with"tag": "_snow") and run the same build with--set snow. Two looks line up frame for frame. The snow clip's background drifted, so its loop came fromrun_loop.py clip.mp4 snow/game/hero_run --key border --from 60 --to 124 --skip 0 --periods 8:16 --clean. - Ledger. One line per Scenario job with model, parameters, inputs, outputs, job id, cost, kept or not, and why.
Common mistakes
- Trusting the grass tips as the ground line: every look's hero floated 3 to 8 cm plus the run bob until the line moved about 10 cm into the lip, which
measure_set.pynow applies. - Keying a drifting clip on fixed magenta: one run clip went from magenta through red and yellow to green. Use
--key borderand take the loop from a stretch away from any red or pink on the character. - Cutting leafy sheets with the default grow: the five foreground plants came out as four pieces until the cut ran with
CUT_GROW=2. Check the printed count against the names. - Not checking enclosed pink after keying:
prep_assets.py keykeeps pink enclosed by the silhouette (a cat's inner ears), but look at ears, tongues and flowers. - Hard-coding the run length: the cat and the snow leopard looped in 12 frames, the red panda in 9. Copy
run_nfromrun_loop.py's output. - A landmark at the end of a wide layer: the tile seam goes where the silhouette is lowest, so ask for content spread across the width.
- Passing a local path or URL to
model_run: upload the file first and pass the asset id. - Hard-coding model ids or prices, or polling
job_getinstead of re-callingjobs_wait.
Changing build_scene.py itself (depths, parallax factors, physics): read references/pipeline.md first, which records the decisions the reference shot paid for.
Files (skills)
-
examples
-
config.json 423 B
{ "tag": "_snow", "sea_h": 10.0, "sea_horizon": 0.469, "sea_base_z": 3.0, "jungle_h": 6.0, "jungle_z0": -1.4, "ground_h": 5.5, "ground_top": 0.613, "island_top_px": 27, "pedestal_top_px": 43, "nut_size": 0.42, "hero_h": 1.6, "run_fig_px": 384, "run_pad_px": 8, "jump_base_px": 787, "run_n": 12, "run_canvas_px": 768, "nuts": [ [-0.44, 3.44], [0.54, 3.21], [0.84, 2.77] ] }
-
-
references
-
checks.md 2.6 KB
# Checks Run these before calling a look finished. The problems named here are the ones the reference shot ran into. ## Pictures (before any Blender work) - The concept frame is a strict side view with a flat walkable ground line. Two takes; keep the one whose layers you can name one by one. - Every layer except the sky has real transparency above its content: check it on a checkerboard. - The far and mid layers reach the bottom edge of the image, so the ground strip always covers their lower end. - The ground strip's top edge is flat over the whole width. Its height in the image, as a share from the top, becomes `ground_top` in `config.json`. - Props and foreground sheets: every piece is separated by clear gaps. `prep_assets.py cut` prints one line per sprite; a count that does not match the names means two pieces touched or a speck survived. - The hero sheet: both poses the same size, side profile facing right, no magenta on the character itself. ## Run cycle - The kept loop is one stride period (9 to 12 frames at 24 fps for a small cartoon animal), and its last frame flows into the first. Play the frames as a GIF before building. - Key the background on each frame's own border color if the clip's background drifts; a fixed magenta key leaves colored halos on the frames where it changed. - No colored fringe on the outline (check over black and over white), and red or pink parts of the character (a scarf, a hat, a tongue) are intact. - The run frame's feet line matches the rest pose's: measure `run_fig_px` (figure height in the run frames) and `run_pad_px` (empty rows under the feet) and write them into `config.json`. ## Scene (`build_scene.py --review`) - The hero's feet sit slightly into the ground lip, not on the grass tips: the reference shot moved `ground_top` down by about 10 cm per look after the heroes looked like they floated. - The tree stands on the ground: its image can have empty rows under the trunk, which the script reads from `shapes.json` (`bbox`) to offset it. - The tiled layers show no seam and no repeated landmark cut in half while the camera pans. - Physics: the tower stands still until the hero reaches it, the pieces fall inside the picture plane, nothing shoots off screen, and the fruit drops when the hero runs under the tree. The script prints `HIT`, `EVENTS` and the `FINAL` resting place of every piece; compare them between looks. - The baked cache is saved in the .blend, so any frame can be opened directly. ## Film - 1920 x 1080, 24 fps, 184 frames (7.7 s) per look, EEVEE. - Frame 1 is a full, lit frame (never black). - The same frame of two looks lines up when overlaid: only the pictures differ. -
pipeline.md 7 KB
# Pipeline: the decisions that mattered The reference shot is a 7.7-second side-scroller take: a hero runs right, jumps onto a floating island for three coins, drops back down, runs under a tree that drops its fruit, and jumps into a stacked tower that collapses with real physics. It exists in three looks (jungle, Halloween street, snow valley) built by one script. ## Flat cards in depth, one orthographic camera Every image is a flat plane in Blender's XZ plane, facing the camera (emission shader mixed with a transparent BSDF through the image's alpha, blended render method, so nothing is lit or shaded twice). The camera is orthographic (`ortho_scale` 16 for a 16 m wide view), looking along +Y, and follows the hero with a little lag: | Layer | Depth (Y) | Follows the camera by | What it means | | ------------------------------------------ | --------- | ---------------------- | ----------------------------------------------- | | Sky | 60 | 0.97 | almost glued to the camera: infinitely far | | Far layer | 50 | 0.85 | tiled; slides slowly | | Mid layer | 40 | 0.6 | tiled | | Ground | 8 | 0 (fixed in the world) | tiled; the walkable line | | Tree, island, pedestal, coins, hero, props | 0 to 9 | 0 | the action plane | | Foreground | -10 | -0.45 | moves against the camera: faster than the world | With an orthographic camera depth alone never moves a layer: the parallax comes from a driver on each layer's X location (`c * k`, where `c` is the camera's X). The Y depths only set the draw order and look right when the scene is orbited for a breakdown. ## The numbers that change per look Everything in `build_scene.py` is shared except the pictures and a short `config.json` (see [examples/config.json](../examples/config.json)). Any key it leaves out keeps the script's default: | Key | Meaning | | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tag` | suffix of the output files (`_snow` gives `platformer_scene_snow.blend`) | | `sea_h`, `sea_horizon`, `sea_base_z` | far layer height in m, its horizon row as a share from the top, where the horizon sits | | `jungle_h`, `jungle_z0` | mid layer height in m and its bottom | | `ground_h`, `ground_top` | ground strip height in m, and its walkable line as a share from the top | | `island_top_px`, `pedestal_top_px` | pixel row of the walkable top in the island and pedestal sprites | | `palm_h`, `nuts`, `nut_size` | tree height, where each fruit hangs (x, z offsets from the tree), fruit size | | `hero_h`, `run_fig_px`, `run_pad_px`, `run_canvas_px`, `jump_base_px`, `run_n` | hero height in m, the figure's height and the empty rows under the feet in the run frames, the clip's height (it sets the jump still's scale against the run frames), the feet row of the jump still, the run frame count | `ground_top` deserves care: putting the feet exactly on the grass tips makes the hero float. Set it so the feet sink about 10 cm into the lip. ## Hero: an image sequence and one still The run is an image sequence on one plane (`hero_run/run_001.png` and on, cyclic, `run_n` frames). Blender reports 0 x 0 for a sequence until it plays, so the script measures the first frame. In the air the plane swaps to the jump still. Squash and stretch are three keyed scales per jump (anticipation, launch, landing). A soft blob shadow follows the hero and shrinks with height. The hero's speed (0.2 m per frame) is matched to the run cycle's stride, so the feet do not slide. ## Physics in a picture plane - Every prop is a rigid body whose collision shape is its sprite's own outline: a convex hull traced from the alpha (`shapes.json`, normalized 0 to 1 per sprite), extruded through the plane, with the origin at the hull's centroid and the mass from its area, so a round pot rolls like a round pot. - Two frictionless invisible walls per lane keep bodies in the picture plane (Blender has no 2D lock). - The tower starts deactivated, so it stands until touched. The fruit are kinematic in the tree crown and released one by one when the hero passes. - The hero's collider is a kinematic sphere that lives outside the props lane and slides in sideways just before the tower jump, then out once nothing is within reach. A collider that teleports into place flings pieces. - `transform_apply` on collision meshes moved origins in the reference shot; build hull meshes directly at their centroid instead. - The simulation is baked and saved with the file, so any frame can be opened or rendered out of order. ## A new look 1. A new concept frame, then the same nine roles with it as the reference ([prompts.md](prompts.md)). 2. Process into `SET_DIR/game/` with the same file names. 3. Measure the per-look numbers into `SET_DIR/game/config.json`. 4. `build_scene.py -- --set SET_DIR --review`, check, then `--final`. Nothing in the animation, camera or physics changes, so two looks line up frame for frame and can be compared with a split screen. -
prompts.md 7 KB
# Prompt patterns Ten generations make one look: a concept frame, eight pictures that take the concept as their reference image, and a run clip whose first frame is the hero's reframed rest pose, never the concept. The patterns below are the ones that worked for the three looks of the reference shot (a red panda in a golden-hour jungle, a black cat on a moonlit Halloween street, a snow leopard cub in a white winter valley). Replace the bracketed parts; keep the structure and the "no ..." lists, they carry most of the work. ## 1. Concept frame (text to image, opaque, 16:9, two takes) The one picture that locks the look. Write it as a screenshot of the finished game, not as an illustration. > In-game screenshot of a premium polished 2D side-scrolling mobile platformer, the art quality and finish of a > top-grossing casual mobile game: bold clean cartoon shapes, glossy soft painterly shading, thick dark outlines on > characters and props, [palette and light]. > > Scene, strictly side view, layered for parallax: [sky]; [far layer, e.g. a calm sea with distant islands on the > horizon]; [mid layer, e.g. jungle hills with ruins]; a ground strip of [material] with a clear flat walkable top > edge running across the lower quarter; [foreground plants] in the foreground corners. > > Gameplay elements: [hero: species, size, two or three memorable details, a small accessory], running to the > right; a floating [platform] with three [coins] above it; [a tree that will drop things]; on the right, a small > tower stacked from [props that belong to this world]. > > No text, no UI, no logo, no HUD. Name the layers in the order the scene will stack them. "Strictly side view" and "flat walkable top edge" are what make the frame usable as a game camera. Name the props you want, and say which materials you do not want if a generic tower would read as some other game. ## 2. Layers (image edit with the concept as reference, one layer per call) Every layer prompt starts the same way and differs only in what it keeps: > [Role] layer for a 2D side-scrolling mobile platformer, in exactly the same art style, colors and [light] as the > reference screenshot, on a fully transparent background. [What is in it, spread across the whole width]. > [Where it sits in the image]. Everything above [its top] is completely transparent: no [the layers behind it]. > No [the layers in front of it], no characters, no text. Wide panoramic layer, strictly side view. | Role | Background | Size (px) | What makes it work | | ---------- | ----------- | ----------- | ----------------------------------------------------------------------------------------------------------------------- | | Sky | opaque | 2560 x 1440 | "ONLY THE SKY"; no land, sea or trees; the sun placed where the light comes from | | Far layer | transparent | 3072 x 1152 | the horizon line "at about the middle of the image"; content "continues down to the bottom edge" | | Mid layer | transparent | 3072 x 1152 | "fills the bottom 55% solidly from edge to edge"; "slightly hazy and softer" to read as far | | Ground | transparent | 2048 x 768 | "a perfectly flat, level walkable top edge ... at about 30% from the top"; "no rounded end caps, so it can be repeated" | | Foreground | transparent | 3072 x 1024 | five numbered clusters "each standing on its own base, with wide empty transparent gaps" | Wide layers are tiled later ([prep_assets.py](../scripts/prep_assets.py) `tile`), so ask for content "spread across the whole width" and keep any single landmark (a big arch, a castle) away from the ends. ## 3. Props sheet and the tree (image edit, transparent) > Game props sprite sheet for a 2D side-scrolling mobile physics platformer set in [world], in exactly the same art > style, colors and lighting as the reference screenshot, on a fully transparent background. Every prop is drawn > flat, strictly side view with no perspective and no shadow, each one separate with wide empty transparent gaps > between them so they can be cut apart, arranged in a loose grid: 1) [pot]; 2) [tall vessel]; 3) [short jar with a > flat lid]; 4) [small heavy statue]; 5) [round fruit that will fall]; 6) [coin with an emblem], seen face on; 7) a > floating chunk of [island], flat walkable top, about three times wider than tall; 8) a flat [pedestal], wide and > short. No characters, no ground, no text. The numbered order is the reading order [prep_assets.py](../scripts/prep_assets.py) `cut` uses for the names. Things that will fall from the tree are their own props: draw the tree separately and say "NO [fruit] in the crown". ## 4. Hero, two poses (image edit, opaque magenta, two takes) > Game character sprite sheet, two poses of the same character side by side, full body, on a perfectly flat solid > magenta (#FF00FF) background, no shadows on the ground, no text. Character: the [hero] from the reference > screenshot, in exactly the same polished cartoon style: [details]. Camera: classic 2D side-scrolling platformer, > pure side profile view facing right, no perspective. Left pose: standing ready to run, facing right, weight slightly > forward, knees slightly bent. Right pose: the same character mid-air in a jump, facing right, knees tucked up, arms > raised. Both figures the same size, each about 60% of the image height, generous empty space around each. No pink, > no purple, no magenta anywhere on the character. The magenta field is for keying; a character with pink or red parts (a scarf, a hat) needs the "no magenta" line and care when keying. The left pose becomes the first frame of the run clip ([reframe_stills.py](../scripts/reframe_stills.py)). ## 5. Run cycle (image to video, first frame = the reframed left pose) > 2D cartoon game sprite animation, like a treadmill: the [hero] immediately starts running in place and keeps > running for the whole clip, a steady fast repeating run cycle: [how this body runs: knees high and arms pumping, > or a four-legged gallop], both feet leave the ground between steps, body bobbing slightly, [tail, accessory] > bouncing. [He] always faces right in a pure side profile view like a 2D side-scrolling platformer, the same angle as > the first frame, never turning toward the camera. [He] stays centered and does not travel across the frame. Locked > static camera, no zoom, flat solid magenta background that never changes, no ground, no ground shadow, same > character design, colors and clean cartoon outlines as the first frame, no motion blur. "Like a treadmill", "stays centered", "locked static camera" and "background that never changes" each closed a failure. A five-second clip is enough: only one clean period (9 to 12 frames at 24 fps) is kept. ## A new look Same ten roles, same order, a new concept frame. Keep the layer sizes, the hero's share of the frame and the ground's walkable line close to the first look, so the measured numbers in `config.json` change little.
-
-
scripts
-
build_scene.py 27.9 KB
"""Side-scrolling platformer shot built in Blender from Scenario-generated assets. Every image comes from Scenario; this script only lays them out and animates: - 5 parallax planes (sky, sea, jungle, ground, foreground plants) driven by the camera - hero: a 9-frame run cycle (image sequence from a Scenario video) and a jump pose, one image per frame (no body parts), with squash and stretch on take-off and landing - a floating island with 3 paw coins to collect - coconuts that drop from the palm (rigid bodies released at a frame) - a tower of clay pots and a golden idol on a temple pedestal that the hero smashes (rigid body physics, constrained to the 2D plane) Usage (headless): blender -b --factory-startup --python-exit-code 1 -P build_scene.py -- --set SET_DIR [--out DIR] [--review] [--final] [--force] Inside an open Blender (Python console, or a Blender MCP execute_python call), where a full file reset may be blocked, from a new file (File > New) or this script's own output, since --live empties the open file and refuses any other: import sys; sys.argv = ["blender", "--", "--set", "/abs/SET_DIR", "--live"]; exec(open("/abs/build_scene.py").read()) --set SET_DIR the art set: a folder holding game/ (the processed sprites, fixed file names, see below) --out DIR where the .blend, the review stills and the film go (default: the folder that holds SET_DIR) --review render 11 review stills at 40 percent to DIR/review<tag>/ --final render the 1920x1080, 24 fps film to DIR/platformer<tag>.mp4 --live build in the open file (its objects and data are removed first) instead of a factory reset; refused unless the open file is new or is this script's own output --force overwrite an existing .blend or film Art sets: the animation, camera, timing and physics are the same for every set; only the pictures change. SET_DIR/game/ holds sky.png, sea_tile.png, jungle_tile.png, ground_tile.png, fg_bush.png, fg_fern.png, fg_hibiscus.png, fg_boulder.png, fg_curl.png, island.png, palm.png, pedestal.png, coin.png, jar.png, pot_round.png, idol.png, amphora.png, coconut.png, hero_jump.png, hero_run/run_001.png..., shapes.json (from trace_hulls.py) and config.json, which holds the few numbers measured from its pictures (horizon and ground lines, the island and pedestal tops, the hero's size); any number it leaves out keeps the default below. """ import bpy, math, os, sys from mathutils import Vector import json ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] def usage(msg): print(__doc__) print("error:", msg) sys.exit(1) if "--set" not in ARGS or ARGS.index("--set") + 1 >= len(ARGS): usage("pass --set SET_DIR") SET = os.path.abspath(ARGS[ARGS.index("--set") + 1]) A = os.path.join(SET, "game") if not os.path.isfile(os.path.join(A, "shapes.json")): usage(f"{A}/shapes.json not found (run trace_hulls.py on the props first)") ROOT = os.path.abspath(ARGS[ARGS.index("--out") + 1]) if "--out" in ARGS else os.path.dirname(SET) os.makedirs(ROOT, exist_ok=True) with open(os.path.join(A, "shapes.json"), encoding="utf-8") as f: SHAPES = json.load(f) CFG = { # the jungle set's numbers; a set's game/config.json overrides any of them "tag": "", "sea_h": 6.0, "sea_horizon": 0.585, "sea_base_z": 2.1, "jungle_h": 7.0, "jungle_z0": -1.6, "ground_h": 4.3, "ground_top": 0.501, "island_top_px": 33, "pedestal_top_px": 48, "palm_h": 7.4, "nuts": [[-0.35, 6.25], [0.25, 6.15], [0.05, 6.45]], "nut_size": 0.5, "hero_h": 1.62, "run_fig_px": 483, "run_pad_px": 8, "jump_base_px": 785, "run_n": 9, "run_canvas_px": 768, } if os.path.exists(os.path.join(A, "config.json")): with open(os.path.join(A, "config.json"), encoding="utf-8") as f: CFG.update(json.load(f)) END, FPS = 184, 24 out_blend = os.path.join(ROOT, f"platformer_scene{CFG['tag']}.blend") out_film = os.path.join(ROOT, f"platformer{CFG['tag']}.mp4") for path in (out_blend, out_film if "--final" in ARGS else None): if path and os.path.exists(path) and "--force" not in ARGS: usage(f"{path} exists (pass --force to replace it)") if "--live" in ARGS: # an open session: empty the current file instead of a factory reset, but never the user's own work. Only a # new file (nothing beyond the startup cube, light and camera) or this script's own output qualifies; # bpy.data.is_dirty cannot tell, since a new file already reports unsaved changes if bpy.data.filepath: own = os.path.abspath(bpy.data.filepath) == out_blend else: own = not {o.name for o in bpy.data.objects} - {"Cube", "Light", "Camera"} if not own: usage("--live empties the open file: save your work, then start from File > New") sc = bpy.context.scene if sc.rigidbody_world: bpy.ops.rigidbody.world_remove() for data in (bpy.data.objects, bpy.data.meshes, bpy.data.materials, bpy.data.images, bpy.data.cameras, bpy.data.lights, bpy.data.worlds, bpy.data.actions): for block in list(data): data.remove(block) for coll in list(bpy.data.collections): bpy.data.collections.remove(coll) else: bpy.ops.wm.read_factory_settings(use_empty=True) sc = bpy.context.scene sc.name = "Platformer" sc.frame_start, sc.frame_end = 1, END r = sc.render r.engine = "BLENDER_EEVEE" r.resolution_x, r.resolution_y, r.fps = 1920, 1080, FPS sc.view_settings.view_transform = "Standard" sc.world = bpy.data.worlds.new("World") sc.world.color = (0, 0, 0) # ----------------------------------------------------------------- helpers def image(path, sequence=False): im = bpy.data.images.load(path, check_existing=True) im.alpha_mode = "STRAIGHT" if sequence: im.source = "SEQUENCE" return im def sprite_material(name, img, seq_len=0, seq_start=1): m = bpy.data.materials.new(name) m.use_nodes = True m.surface_render_method = "BLENDED" m.use_backface_culling = True nt = m.node_tree nt.nodes.clear() tex = nt.nodes.new("ShaderNodeTexImage") tex.image = img tex.extension = "CLIP" if seq_len: u = tex.image_user u.frame_duration, u.frame_start, u.frame_offset = seq_len, seq_start, 0 u.use_cyclic = True u.use_auto_refresh = True em = nt.nodes.new("ShaderNodeEmission") tr = nt.nodes.new("ShaderNodeBsdfTransparent") mix = nt.nodes.new("ShaderNodeMixShader") out = nt.nodes.new("ShaderNodeOutputMaterial") L = nt.links L.new(tex.outputs["Color"], em.inputs["Color"]) L.new(tex.outputs["Alpha"], mix.inputs["Fac"]) L.new(tr.outputs[0], mix.inputs[1]) L.new(em.outputs[0], mix.inputs[2]) L.new(mix.outputs[0], out.inputs["Surface"]) return m def quads_mesh(name, quads): """quads: [(x0, z0, x1, z1)] in the XZ plane, each UV-mapped 0..1.""" me = bpy.data.meshes.new(name) verts, faces, uvs = [], [], [] for x0, z0, x1, z1 in quads: i = len(verts) verts += [(x0, 0, z0), (x1, 0, z0), (x1, 0, z1), (x0, 0, z1)] faces.append((i, i + 1, i + 2, i + 3)) uvs += [(0, 0), (1, 0), (1, 1), (0, 1)] me.from_pydata(verts, [], faces) uv = me.uv_layers.new() for li, loop in enumerate(me.loops): uv.data[li].uv = uvs[loop.vertex_index] # normals face -Y (towards the camera) for p in me.polygons: p.flip() if p.normal.y > 0 else None return me def sprite(name, path, height=None, width=None, loc=(0, 0, 0), anchor="bottom", parent=None, img=None, mat=None): img = img or image(path) w_px, h_px = img.size if height: width = height * w_px / h_px else: height = width * h_px / w_px x0, x1 = -width / 2, width / 2 z0, z1 = (0, height) if anchor == "bottom" else (-height / 2, height / 2) ob = bpy.data.objects.new(name, quads_mesh(name, [(x0, z0, x1, z1)])) ob.data.materials.append(mat or sprite_material(name, img)) sc.collection.objects.link(ob) ob.location = loc ob.parent = parent return ob def tiled(name, path, height, x_from, x_to, z0, y, img=None): img = img or image(path) w = height * img.size[0] / img.size[1] quads, x = [], x_from while x < x_to: quads.append((x, z0, x + w - 0.002, z0 + height)) # tiny overlap guard x += w - 0.004 ob = bpy.data.objects.new(name, quads_mesh(name, quads)) ob.data.materials.append(sprite_material(name, img)) sc.collection.objects.link(ob) ob.location.y = y return ob def empty(name, loc=(0, 0, 0)): e = bpy.data.objects.new(name, None) sc.collection.objects.link(e) e.location = loc return e def key(ob, path, f, value=None, index=-1, interp=None): if value is not None: if index >= 0: getattr(ob, path)[index] = value else: setattr(ob, path, value) ob.keyframe_insert(path, index=index, frame=f) def smooth(t): return t * t * (3 - 2 * t) # ----------------------------------------------------------------- hero path (x, z per frame) and events SPEED = 0.2 # m per frame, matched to the run cycle stride ISLAND_X, ISLAND_W, ISLAND_TOP = 9.0, 4.4, 2.2 # island center x, width, walkable top JUMPS = [ # (take-off frame, landing frame, x0, x1, z0, z1, peak height above the higher end) (46, 61, None, None, 0.0, ISLAND_TOP, 1.1), (76, 88, None, None, ISLAND_TOP, 0.0, 0.5), (118, 146, None, None, 0.0, 0.0, 2.35), (152, 175, None, None, 0.0, 0.0, 2.1), ] X0 = -4.0 def hero_x(f): return X0 + SPEED * (f - 1) def hero_z(f): for f0, f1, _, _, z0, z1, hpk in JUMPS: if f0 <= f <= f1: t = (f - f0) / (f1 - f0) top = max(z0, z1) + hpk # two half-parabolas meeting at the peak (t = 0.5): up from z0, then down to z1 if t <= 0.5: return z0 + (top - z0) * (1 - (1 - t * 2) ** 2) return z1 + (top - z1) * (1 - ((t - 0.5) * 2) ** 2) if JUMPS[0][1] < f < JUMPS[1][0]: return ISLAND_TOP return 0.0 def airborne(f): return any(f0 < f < f1 for f0, f1, *_ in JUMPS) # ----------------------------------------------------------------- camera cam = bpy.data.objects.new("Camera", bpy.data.cameras.new("Camera")) sc.collection.objects.link(cam) sc.camera = cam cam.data.type = "ORTHO" cam.data.ortho_scale = 16.0 cam.data.clip_start, cam.data.clip_end = 0.1, 400 cam.rotation_euler = (math.radians(90), 0, 0) cx, cz = hero_x(1) + 2.5, 2.4 CAM = {} for f in range(1, END + 1): tx = hero_x(f) + 2.5 tz = 2.4 + 0.35 * max(0.0, hero_z(f) - 0.4) cx += (tx - cx) * 0.25 if f > 1 else 0 cz += (tz - cz) * 0.08 cam.location = (cx, -100, cz) cam.keyframe_insert("location", frame=f) CAM[f] = (cx, cz) def follow(ob, k): """Parallax: the layer moves k times as far as the camera (k=1: infinitely far).""" fc = ob.driver_add("location", 0) d = fc.driver d.type = "SCRIPTED" v = d.variables.new() v.name, v.type = "c", "TRANSFORMS" v.targets[0].id = cam v.targets[0].transform_type = "LOC_X" d.expression = f"c*{k}" assert d.is_simple_expression # ----------------------------------------------------------------- parallax layers CAM_MIN, CAM_MAX = -2.0, hero_x(END) + 3.0 def span(k, pad=9.5): return (1 - k) * CAM_MIN - pad, (1 - k) * CAM_MAX + pad sky_img = image(os.path.join(A, "sky.png")) sky_h = 10.6 sky = sprite("L1_Sky", None, height=sky_h, loc=(0, 60, 3.0), anchor="center", img=sky_img) sky.location.x = (span(0.97)[0] + span(0.97)[1]) / 2 follow(sky, 0.97) SEA_H, SEA_HORIZON = CFG["sea_h"], CFG["sea_horizon"] # horizon row as a fraction from the top of the image x0, x1 = span(0.85) sea = tiled("L2_Sea", os.path.join(A, "sea_tile.png"), SEA_H, x0, x1, CFG["sea_base_z"] - SEA_H * (1 - SEA_HORIZON), 50) follow(sea, 0.85) JUN_H = CFG["jungle_h"] x0, x1 = span(0.6) jungle = tiled("L3_Jungle", os.path.join(A, "jungle_tile.png"), JUN_H, x0, x1, CFG["jungle_z0"], 40) follow(jungle, 0.6) GR_H, GR_TOP = CFG["ground_h"], CFG["ground_top"] # walkable line as a fraction from the top x0, x1 = span(0.0) ground = tiled("L4_Ground", os.path.join(A, "ground_tile.png"), GR_H, x0 - 2, x1 + 2, -GR_H * (1 - GR_TOP), 8) # foreground plants: big, dark, close to the camera, moving faster than the ground FG = ["fg_bush", "fg_fern", "fg_hibiscus", "fg_boulder", "fg_curl"] fg_root = empty("L5_Foreground", (0, -10, 0)) follow(fg_root, -0.45) x0, x1 = span(-0.45, pad=12) x, i = x0, 0 heights = [2.6, 3.0, 2.3, 2.1, 2.4] while x < x1: name = FG[i % len(FG)] s = sprite(f"FG_{i:02d}_{name}", os.path.join(A, name + ".png"), height=heights[i % 5] * (1.0 + 0.12 * ((i * 7) % 3)), loc=(x, 0, -3.0), parent=fg_root) x += 5.5 + 2.5 * ((i * 5) % 3) / 2 i += 1 # ----------------------------------------------------------------- level set pieces (k = 0, world space) island = sprite("Island", os.path.join(A, "island.png"), width=ISLAND_W, loc=(ISLAND_X, 1.0, 0), anchor="bottom") iw, ih = island.data.materials[0].node_tree.nodes["Image Texture"].image.size isl_h = ISLAND_W * ih / iw island.location.z = ISLAND_TOP - isl_h * (1 - CFG["island_top_px"] / ih) # gentle bob for f, dz in ((1, 0), (48, 0.05), (96, 0), (144, 0.05), (174, 0.02)): island.delta_location = (0, 0, dz) island.keyframe_insert("delta_location", index=2, frame=f) PALM_X = 16.0 PALM_H = CFG["palm_h"] pb = SHAPES["palm"] palm_gap = PALM_H * (pb["h"] - 1 - pb["bbox"][3]) / pb["h"] # empty space under the trunk in the image palm = sprite("Palm", os.path.join(A, "palm.png"), height=PALM_H, loc=(PALM_X, 9.0, -0.35 - palm_gap)) # y = 9: just behind the ground strip, so the grass lip overlaps the base of the trunk PED_X = 22.4 ped_w = 3.0 ped = sprite("Pedestal", os.path.join(A, "pedestal.png"), width=ped_w, loc=(PED_X, 0.6, -0.12)) pw, ph_ = ped.data.materials[0].node_tree.nodes["Image Texture"].image.size ped_top = -0.12 + ped_w * ph_ / pw * (1 - CFG["pedestal_top_px"] / ph_) # coins above the island coins = [] coin_img = image(os.path.join(A, "coin.png")) coin_mat = sprite_material("Coin", coin_img) for j, cxp in enumerate((ISLAND_X - 1.1, ISLAND_X, ISLAND_X + 1.1)): c = sprite(f"Coin_{j}", None, height=0.62, loc=(cxp, 0.5, ISLAND_TOP + 0.95), anchor="center", img=coin_img, mat=coin_mat) coins.append(c) # ----------------------------------------------------------------- hero hero = empty("Hero", (hero_x(1), 0, 0)) RUN_N = CFG["run_n"] run_img = image(os.path.join(A, "hero_run", "run_001.png"), sequence=True) MPP = CFG["hero_h"] / CFG["run_fig_px"] # meters per pixel in the run frames _probe = bpy.data.images.load(os.path.join(A, "hero_run", "run_001.png")) # a sequence reports 0x0 until played run_w, run_h = _probe.size[0] * MPP, _probe.size[1] * MPP bpy.data.images.remove(_probe) pad = CFG["run_pad_px"] * MPP run_plane = bpy.data.objects.new("Hero_Run", quads_mesh("Hero_Run", [(-run_w / 2, -pad, run_w / 2, run_h - pad)])) run_plane.data.materials.append(sprite_material("Hero_Run", run_img, seq_len=RUN_N)) sc.collection.objects.link(run_plane) run_plane.parent = hero jump_img = image(os.path.join(A, "hero_jump.png")) # the jump still keeps its reframed canvas while the clip comes back at the video model's own resolution, so the # two scales differ by the canvas ratio (768 / 960 in the reference shot) jump_w_px, jump_h_px = jump_img.size JMPP = MPP * CFG["run_canvas_px"] / jump_h_px jw, jh = jump_w_px * JMPP, jump_h_px * JMPP jb = CFG["jump_base_px"] jump_plane = bpy.data.objects.new("Hero_Jump", quads_mesh("Hero_Jump", [(-jw / 2, -(jump_h_px - jb) * JMPP, jw / 2, jh - (jump_h_px - jb) * JMPP)])) jump_plane.data.materials.append(sprite_material("Hero_Jump", jump_img)) sc.collection.objects.link(jump_plane) jump_plane.parent = hero # hero shadow (a soft dark ellipse drawn as an image made here) sh_path = os.path.join(A, "blob_shadow.png") if not os.path.exists(sh_path): S = 128 simg = bpy.data.images.new("blob", S, S // 4, alpha=True) px = [] for yy in range(S // 4): for xx in range(S): d = math.hypot((xx - S / 2) / (S / 2), (yy - S / 8) / (S / 8)) a = max(0.0, 1 - d) ** 1.5 * 0.55 px += [0.12, 0.08, 0.05, a] simg.pixels = px simg.filepath_raw = sh_path simg.file_format = "PNG" simg.save() shadow = sprite("Hero_Shadow", sh_path, width=1.5, loc=(0, 0.9, 0), anchor="center") # per-frame hero animation: position, run/jump swap, squash and stretch, shadow prev_air = False for f in range(1, END + 1): x, z = hero_x(f), hero_z(f) hero.location = (x, 0, z) hero.keyframe_insert("location", frame=f) air = airborne(f) run_plane.hide_render = run_plane.hide_viewport = air jump_plane.hide_render = jump_plane.hide_viewport = not air for ob in (run_plane, jump_plane): ob.keyframe_insert("hide_render", frame=f) ob.keyframe_insert("hide_viewport", frame=f) # squash on landing / take-off, stretch while rising sx = sz = 1.0 for f0, f1, *_ in JUMPS: if f0 - 2 <= f < f0: sx, sz = 1.12, 0.88 # anticipation elif f0 <= f < f0 + 4: sx, sz = 0.9, 1.12 # launch stretch elif f1 <= f < f1 + 3: sx, sz = 1.18, 0.84 # landing squash hero.scale = (sx, 1, sz) hero.keyframe_insert("scale", frame=f) support = ISLAND_TOP if (ISLAND_X - ISLAND_W / 2 + 0.2 < x < ISLAND_X + ISLAND_W / 2 - 0.2 and z >= ISLAND_TOP - 0.05) else 0.0 h = max(0.0, z - support) shadow.location = (x + 0.05, 0.9 if support == 0 else 0.45, support + 0.02) s = max(0.35, 1 - 0.25 * h) shadow.scale = (s, 1, s) shadow.keyframe_insert("location", frame=f) shadow.keyframe_insert("scale", frame=f) # coins: spin while waiting; when collected each one flips and flies forward and upward from where it # was (growing as it comes towards the camera), on its own path, and leaves through the top of the screen COIN_FLY = 20 for j, c in enumerate(coins): home = (c.location.x, ISLAND_TOP + 0.95) got = next(f for f in range(1, END + 1) if hero_x(f) >= c.location.x - 0.25) drift = (-0.9, 0.2, 1.3)[j] # each coin fans out a little differently for f in range(1, END + 1): if f < got: c.location = (home[0], 0.5, home[1] + 0.06 * math.sin(f * 0.2 + home[0])) c.scale = (abs(math.cos(f * 0.18 + home[0])) * 0.9 + 0.1, 1, 1) else: t = (f - got) / COIN_FLY if t >= 1: c.scale = (0, 1, 0) c.location = (home[0], -30, home[1]) else: cxf, czf = CAM[f] sx0 = home[0] - CAM[got][0] # screen position when collected sz0 = home[1] - CAM[got][1] rise = (4.5 + 2.5 - sz0) * t ** 1.6 # accelerates up and out past the top edge sx_ = sx0 + drift * math.sin(t * math.pi / 2) size = 1 + 3.2 * t ** 0.8 # comes towards the camera: grows flip = math.cos(t * math.pi * 5) # fast flips c.location = (cxf + sx_, -30 + 0.01 * j, czf + sz0 + 0.35 * math.sin(t * math.pi) + rise) c.scale = (size * max(0.08, abs(flip)), 1, size) c.keyframe_insert("scale", frame=f) c.keyframe_insert("location", frame=f) # ----------------------------------------------------------------- physics (2D: locked to the XZ plane) bpy.ops.rigidbody.world_add() rbw = sc.rigidbody_world rbw.point_cache.frame_start, rbw.point_cache.frame_end = 1, END rbw.substeps_per_frame, rbw.solver_iterations = 20, 30 rbw.collection = bpy.data.collections.new("RigidBodies") sc.collection.children.link(rbw.collection) def collider(name, size, loc, kind="PASSIVE", shape="BOX", mass=1.0): bpy.ops.mesh.primitive_cube_add(size=1, location=loc) ob = bpy.context.active_object ob.name = name ob.scale = size bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) bpy.ops.rigidbody.object_add(type=kind) rb = ob.rigid_body rb.collision_shape = shape rb.mass = mass rb.friction, rb.restitution = 0.6, 0.1 rb.collision_margin = 0.005 ob.hide_render = True ob.display_type = "WIRE" return ob def lane_walls(y, half=0.36, x0=-20, x1=80): """Two invisible frictionless walls that keep a lane of bodies in the picture plane.""" for side in (-1, 1): w = collider(f"LaneWall_{y:+.1f}_{side:+d}", (x1 - x0, 0.05, 30), ((x0 + x1) / 2, y + side * half, 10)) w.rigid_body.friction = 0.0 w.rigid_body.restitution = 0.0 def hull_body(name, height, y, depth, density=1.0, restitution=0.1, friction=0.6): """A rigid body whose collision shape is the sprite's own silhouette (convex hull), extruded through the picture plane, with its origin (= center of mass) at the hull centroid. Returns (body, sprite, visible width, visible height, bottom offset from origin).""" img = image(os.path.join(A, name + ".png")) W, H = img.size w = height * W / H hull = [((u - 0.5) * w, v * height) for u, v in SHAPES[name]["hull"]] # polygon area centroid A2 = cx_ = cz_ = 0.0 for (x0, z0), (x1, z1) in zip(hull, hull[1:] + hull[:1]): cr = x0 * z1 - x1 * z0 A2 += cr cx_ += (x0 + x1) * cr cz_ += (z0 + z1) * cr area = abs(A2) / 2 cx_, cz_ = cx_ / (3 * A2), cz_ / (3 * A2) verts = [] for yy in (-depth / 2, depth / 2): verts += [(x - cx_, yy, z - cz_) for x, z in hull] me = bpy.data.meshes.new(f"Col_{name}") n = len(hull) faces = [tuple(range(n))[::-1], tuple(range(n, 2 * n))] + [(i, (i + 1) % n, n + (i + 1) % n, n + i) for i in range(n)] me.from_pydata(verts, [], faces) me.validate() ob = bpy.data.objects.new(f"Col_{name}", me) sc.collection.objects.link(ob) bpy.context.view_layer.objects.active = ob for o in sc.objects: o.select_set(o == ob) bpy.ops.rigidbody.object_add(type="ACTIVE") rb = ob.rigid_body rb.collision_shape = "CONVEX_HULL" rb.mass = area * density rb.friction, rb.restitution = friction, restitution rb.collision_margin = 0.004 rb.linear_damping, rb.angular_damping = 0.04, 0.12 # close to real air drag ob.hide_render = True ob.display_type = "WIRE" spr = sprite(f"Spr_{name}", None, height=height, img=img, anchor="bottom") spr.parent = ob spr.location = (-cx_ + 0.0, -depth / 2 - 0.01, -cz_) zmin = min(z for _, z in hull) zmax = max(z for _, z in hull) xmin = min(x for x, _ in hull) xmax = max(x for x, _ in hull) ob["bottom"] = zmin - cz_ ob["radius"] = max(math.hypot(x - cx_, z - cz_) for x, z in hull) return ob, (xmin - cx_, xmax - cx_, zmin - cz_, zmax - cz_) ground_col = collider("Ground_Collider", (200, 30, 2), (30, 0, -1.0)) ground_col.rigid_body.friction = 0.8 ground_col.rigid_body.restitution = 0.6 lane_walls(0.3) lane_walls(3.6, half=0.3) ped_col = collider("Pedestal_Collider", (ped_w * 0.9, 2.0, ped_top + 0.12), (PED_X, 0.3, (ped_top - 0.12) / 2)) ped_col.rigid_body.friction = 0.8 # tower: clay pots + gold idol stacked on the pedestal, asleep until something touches them pieces = [] def place(name, height, x_center, z_bottom, density=1.0, restitution=0.1): ob, (x0, x1, z0, z1) = hull_body(name, height, 0.3, 0.56, density, restitution) ob.location = (x_center, 0.3, z_bottom - z0 + 0.003) ob.rigid_body.use_deactivation = True ob.rigid_body.use_start_deactivated = True pieces.append(ob) return z_bottom + (z1 - z0) # a stable stack: everything rests on a flat surface top_jar = place("jar", 1.0, PED_X - 0.62, ped_top, density=1.0) # flat base, flat lid place("pot_round", 1.02, PED_X + 0.66, ped_top, density=1.0, restitution=0.12) place("idol", 1.05, PED_X - 0.62, top_jar, density=2.6) # solid gold, on the lid place("amphora", 1.35, PED_X + 4.6, 0.0, density=0.9) # on the grass, past the landing # coconuts: held in the crown (kinematic) until the hero runs under the palm, then they drop nuts = [] drop_f = next(f for f in range(1, END + 1) if hero_x(f) >= PALM_X - 3.2) for j, (dx, dz) in enumerate(CFG["nuts"]): ob, _ = hull_body("coconut", CFG["nut_size"], 3.6, 0.5, density=1.4, restitution=0.75, friction=0.7) ob.name = f"Col_coconut{j}" ob.location = (PALM_X + dx, 3.6, dz) ob.rigid_body.angular_damping = 0.35 # husk: rolls, but not forever ob.rigid_body.kinematic = True ob.keyframe_insert("rigid_body.kinematic", frame=1) ob.keyframe_insert("rigid_body.kinematic", frame=drop_f + j * 5) ob.rigid_body.kinematic = False ob.keyframe_insert("rigid_body.kinematic", frame=drop_f + j * 5 + 1) nuts.append(ob) # the hero's body: a kinematic sphere that follows the hero. It lives outside the props lane (y = 5) # and slides in sideways before the tower jump; it slides out only once nothing is within reach # (EXIT_F comes from a first probe run), so it never pinches a piece against a lane wall. # contact frame: first frame where the hero's body reaches a piece (pieces are static until hit) def body_pos(f): return Vector((hero_x(f) + 0.2, 0.3, hero_z(f) + 0.78)) HIT_F = next(f for f in range(JUMPS[2][0], JUMPS[2][1]) if min((p.location - body_pos(f)).length - p["radius"] - 0.62 for p in pieces[:3]) < 0.0) KICK = {0: 0.0, 1: 0.04, 2: 0.06, 3: 0.04, 4: 0.0} # a slight push forward on contact, then back EXIT_F = HIT_F + 5 T0 = JUMPS[2][0] - 2 bpy.ops.mesh.primitive_uv_sphere_add(radius=0.62, location=(0, 5, 0)) hb = bpy.context.active_object hb.name = "Hero_Collider" bpy.ops.rigidbody.object_add(type="ACTIVE") hb.rigid_body.kinematic = True hb.rigid_body.collision_shape = "SPHERE" hb.rigid_body.friction = 0.4 hb.rigid_body.restitution = 0.5 hb.hide_render = True hb.display_type = "WIRE" for f in range(1, END + 1): # outside the props lane (y = 5) except from just before the jump until right after the strike if f < T0 - 6 or f > EXIT_F + 6: y = 5.0 elif f < T0: y = 5.0 + (0.3 - 5.0) * smooth((f - (T0 - 6)) / 6) elif f <= EXIT_F: y = 0.3 else: y = 0.3 + (5.0 - 0.3) * smooth((f - EXIT_F) / 6) pos = body_pos(f) hb.location = (pos.x + KICK.get(f - HIT_F, 0.0), y, pos.z) hb.keyframe_insert("location", frame=f) # ----------------------------------------------------------------- simulate in order, save, review, final clear = [] for f in range(1, END + 1): sc.frame_set(f) if f > JUMPS[2][0]: hp = Vector((hero_x(f) + 0.2, 0.3, hero_z(f) + 0.78)) gap = min((p.matrix_world.translation - hp).length - p["radius"] - 0.62 for p in pieces) clear.append((f, gap)) ok = [f for f, g in clear if f >= JUMPS[2][1] - 6 and all(g2 > 0.35 for f2, g2 in clear if f <= f2 <= f + 8)] print("HIT", HIT_F, "EXIT", EXIT_F, "PROBE first clear exit frame", ok[0] if ok else None, "min gaps", [(f, round(g, 2)) for f, g in clear[::4]]) # bake the simulation into the file, so any frame can be opened or rendered directly (not only in order) with bpy.context.temp_override(scene=sc): bpy.ops.ptcache.bake_all(bake=True) sc.frame_set(1) bpy.ops.wm.save_as_mainfile(filepath=out_blend) print("EVENTS coins at", [next(f for f in range(1, END + 1) if hero_x(f) >= c.location.x - 0.25) for c in coins], "coconut drop", drop_f, "tower jump", JUMPS[2][:2]) for p in pieces + nuts: sc.frame_set(END) print("FINAL", p.name, tuple(round(v, 2) for v in p.matrix_world.translation)) rv = os.path.join(ROOT, "review" + CFG["tag"]) os.makedirs(rv, exist_ok=True) if "--review" in ARGS: r.resolution_percentage = 40 for f in (1, 55, 70, 90, 110, 122, 128, 134, 140, 150, 172): sc.frame_set(f) r.filepath = os.path.join(rv, f"f{f:03d}.png") bpy.ops.render.render(write_still=True) r.resolution_percentage = 100 if "--final" in ARGS: r.image_settings.media_type = "VIDEO" r.image_settings.file_format = "FFMPEG" r.ffmpeg.format, r.ffmpeg.codec, r.ffmpeg.constant_rate_factor = "MPEG4", "H264", "HIGH" r.filepath = out_film bpy.ops.render.render(animation=True) print("RESULT ok", out_blend) -
measure_set.py 3.4 KB
#!/usr/bin/env python3 """Measure the per-look numbers build_scene.py needs from the processed pictures, and print them for config.json. sea_horizon the far layer's horizon: first row where alpha covers more than 90% of the width, as a share of the height (sea_tile.png) ground_top the ground strip's walkable line: its 90% row as a share of the height, moved down by --sink meters (default 0.1) so the feet sit in the lip rather than on the grass tips island_top_px first row where alpha covers more than 60% of island.png's width pedestal_top_px first row where alpha covers more than 80% of pedestal.png's width jump_base_px the feet row of hero_jump.png (lowest opaque row); reframe_stills.py also prints the feet line it placed ("feet at y=..."), which is an equally good value The layer heights in meters (sea_h, jungle_h, ground_h and their offsets), the fruit positions (nuts) and the hero height (hero_h) were set by eye on review renders in the reference shot: start from the example config and adjust. The run numbers (run_n, run_fig_px, run_pad_px) come from run_loop.py. Prints a JSON fragment; writes nothing. Example: python3 measure_set.py jungle/game --ground-h 4.3 Needs Python 3 with Pillow and NumPy. """ import argparse import json import os import sys try: import numpy as np from PIL import Image except ImportError: sys.exit('This script needs Pillow and NumPy: python3 -m pip install pillow numpy') def opaque(path): if not os.path.isfile(path): sys.exit(f'error: {path} not found') return np.asarray(Image.open(path).convert('RGBA'))[..., 3] > 128 def first_row(a, cover, name): rows = a.mean(1) > cover if not rows.any(): # argmax of all-False is 0, which would read as a line at the very top sys.exit(f'error: no row of {name} is more than {cover:.0%} opaque; check its transparency and cut') return int(np.argmax(rows)) def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('game_dir', help='folder holding sea_tile.png, ground_tile.png, island.png, pedestal.png, hero_jump.png') parser.add_argument('--ground-h', type=float, default=4.3, help='height of the ground strip in the scene, meters (default 4.3, the script default)') parser.add_argument('--sink', type=float, default=0.1, help='how far the feet sink below the 90%% line, meters (default 0.1)') if len(sys.argv) == 1: parser.print_help() sys.exit(1) args = parser.parse_args() g = args.game_dir sea = opaque(os.path.join(g, 'sea_tile.png')) ground = opaque(os.path.join(g, 'ground_tile.png')) island = opaque(os.path.join(g, 'island.png')) pedestal = opaque(os.path.join(g, 'pedestal.png')) jump = opaque(os.path.join(g, 'hero_jump.png')) ground90 = first_row(ground, 0.9, 'ground_tile.png') / ground.shape[0] values = { 'sea_horizon': round(first_row(sea, 0.9, 'sea_tile.png') / sea.shape[0], 3), 'ground_h': args.ground_h, 'ground_top': round(ground90 + args.sink / args.ground_h, 3), 'island_top_px': first_row(island, 0.6, 'island.png'), 'pedestal_top_px': first_row(pedestal, 0.8, 'pedestal.png'), 'jump_base_px': int(np.where(jump.any(1))[0].max()), } print(json.dumps(values, indent=1)) if __name__ == '__main__': main() -
prep_assets.py 6 KB
"""Turn the raw Scenario renders into game-ready sprites and layers. - cut: split a transparent sprite sheet into one PNG per sprite (connected components), reading order = rows top to bottom, then left to right. - key: magenta (#FF00FF) background -> soft alpha + despill (HD cartoon, no pixelation). - tile: make a layer repeat horizontally without a seam (cross-fade its two ends). python3 prep_assets.py cut sheet.png out_dir name1,name2,... [--force] python3 prep_assets.py key in.png out.png [--force] python3 prep_assets.py tile in.png out.png [fade_px] [--force] cut: names are given in reading order; sprites smaller than 4000 opaque px are dropped as specks, and pieces closer than CUT_GROW px (environment variable, default 12) are kept together. Ask for wide gaps on the sheet. tile: the seam is moved to the stretch where the layer's silhouette is lowest, then fade_px (default 256) of the two ends are cross-faded, so the output is fade_px narrower than the input. Existing outputs are kept unless --force is given. Needs Python 3 with Pillow, NumPy and SciPy. """ import os, sys import numpy as np from PIL import Image from scipy import ndimage def cut(sheet, out_dir, names, pad=6): im = np.asarray(Image.open(sheet).convert("RGBA")) a = im[..., 3] > 16 grown = ndimage.binary_dilation(a, iterations=int(os.environ.get("CUT_GROW", "12"))) lab, n = ndimage.label(grown) boxes = [] for i, sl in enumerate(ndimage.find_objects(lab), 1): ys, xs = sl area = int((a[sl] & (lab[sl] == i)).sum()) if area < 4000: # stray specks continue boxes.append((ys.start, ys.stop, xs.start, xs.stop, i)) # rows: group by vertical center, then sort by x boxes.sort(key=lambda b: (b[0] + b[1]) / 2) rows, cur = [], [] for b in boxes: if cur and (b[0] + b[1]) / 2 - (cur[-1][0] + cur[-1][1]) / 2 > 0.5 * (b[1] - b[0]): rows.append(cur); cur = [] cur.append(b) rows.append(cur) ordered = [b for r in rows for b in sorted(r, key=lambda b: b[2])] os.makedirs(out_dir, exist_ok=True) for name, (y0, y1, x0, x1, i) in zip(names, ordered): if os.path.exists(os.path.join(out_dir, name + ".png")) and not FORCE: sys.exit(f"error: {os.path.join(out_dir, name + '.png')} exists (pass --force to replace it)") y0, x0 = max(0, y0 - pad), max(0, x0 - pad) y1, x1 = min(im.shape[0], y1 + pad), min(im.shape[1], x1 + pad) crop = im[y0:y1, x0:x1].copy() crop[..., 3] = np.where(lab[y0:y1, x0:x1] == i, crop[..., 3], 0) Image.fromarray(crop).save(os.path.join(out_dir, name + ".png")) print(name, crop.shape[1], "x", crop.shape[0]) if len(ordered) != len(names): print("WARNING: found", len(ordered), "sprites for", len(names), "names") def key_rgb(rgb, protect=True): f = rgb.astype(np.float32) / 255 r, g, b = f[..., 0], f[..., 1], f[..., 2] mag = np.minimum(r, b) - g # > 0 where red and blue both beat green close = np.abs(r - b) < 0.45 t = np.clip((mag - 0.10) / (0.30 - 0.10), 0, 1) * close alpha = (1 - t) alpha = ndimage.grey_erosion(alpha, size=(3, 3)) # eat the 1px magenta fringe out = rgb.astype(np.float32) spill = np.clip(np.minimum(out[..., 0], out[..., 2]) - out[..., 1], 0, None) * 0.7 out[..., 0] -= spill out[..., 2] -= spill if protect: # pinkish colors enclosed by the figure (inner ears, tongues) are part of the character, not the # background: inside the filled silhouette, anything that is not near-pure magenta keeps its color solid = alpha > 0.5 inside = ndimage.binary_fill_holes(solid) & ~solid keep = ndimage.binary_dilation(inside & (mag < 0.35), iterations=1) & ndimage.binary_fill_holes(solid) alpha = np.where(keep, 1.0, alpha) out = np.where(keep[..., None], rgb.astype(np.float32), out) return np.dstack([np.clip(out, 0, 255), alpha * 255]).astype(np.uint8) def key(src, dst): Image.fromarray(key_rgb(np.asarray(Image.open(src).convert("RGB")))).save(dst) def tile(src, dst, fade=256): im = np.asarray(Image.open(src).convert("RGBA")).astype(np.float32) w = im.shape[1] # put the repeat seam where the silhouette is lowest (plain foliage, open sea), never through a # landmark: roll the image so that stretch sits at the edges, then cross-fade only there a = im[..., 3] > 128 top = np.where(a.any(0), a.argmax(0), a.shape[0]) # first opaque row per column (bigger = lower) win = 2 * fade score = np.array([top[np.arange(x, x + win) % w].min() for x in range(0, w, 8)]) best = int(np.argmax(score)) * 8 # window whose tallest feature is lowest im = np.roll(im, -(best + fade), axis=1) # its center becomes the left/right edge out = im[:, :w - fade].copy() t = np.linspace(0, 1, fade)[None, :, None] out[:, :fade] = im[:, w - fade:] * (1 - t) + im[:, :fade] * t # left edge continues the right edge Image.fromarray(np.clip(out, 0, 255).astype(np.uint8)).save(dst) FORCE = "--force" in sys.argv if __name__ == "__main__": argv = [a for a in sys.argv if a != "--force"] need = {"cut": 5, "key": 4, "tile": 4} if len(argv) < 2 or argv[1] not in need or len(argv) < need[argv[1]]: sys.exit(__doc__) sys.argv = argv cmd = sys.argv[1] if not os.path.isfile(sys.argv[2]): sys.exit(f"error: {sys.argv[2]} not found") if cmd in ("key", "tile"): if os.path.abspath(sys.argv[3]) == os.path.abspath(sys.argv[2]): sys.exit("error: the output would overwrite the input") if os.path.exists(sys.argv[3]) and not FORCE: sys.exit(f"error: {sys.argv[3]} exists (pass --force to replace it)") if cmd == "cut": cut(sys.argv[2], sys.argv[3], sys.argv[4].split(",")) elif cmd == "key": key(sys.argv[2], sys.argv[3]) elif cmd == "tile": tile(sys.argv[2], sys.argv[3], int(sys.argv[4]) if len(sys.argv) > 4 else 256) -
reframe_stills.py 4.8 KB
#!/usr/bin/env python3 """Split a two-pose still into two square first frames with headroom. The still holds one character twice on a flat magenta (#FF00FF) field, in side profile facing right: the rest pose on the left half, the jump (hero) or attack wind-up (enemy) on the right half. Each pose is cut out, scaled so the figure is a fixed share of the frame height, and pasted centered on a fresh magenta square with its feet on a fixed baseline. The empty space above the head leaves room for a raised weapon or a jump in the animated clip. Writes <out>/<name>_rest.png (the left pose, first frame of every ground clip) and <out>/<name>_action.png (the right pose). Existing files are kept unless --force is given. Examples: python3 reframe_stills.py hero_two_pose.png --name hero --out frames python3 reframe_stills.py hero.png --name hero --out frames --figure 0.55 --baseline 0.84 Needs Python 3 with Pillow and NumPy. """ import argparse import sys from pathlib import Path try: import numpy as np from PIL import Image except ImportError: sys.exit('This script needs Pillow and NumPy: python3 -m pip install pillow numpy') def magenta_mask(rgb, tolerance): """True where a pixel belongs to the flat magenta field.""" r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2] return (r > 255 - tolerance) & (b > 255 - tolerance) & (g < tolerance + 25) def figure_box(keep, min_pixels): """Bounding box of the figure, ignoring rows and columns with only a few stray pixels.""" rows = np.where(keep.sum(1) >= min_pixels)[0] cols = np.where(keep.sum(0) >= min_pixels)[0] if len(rows) == 0 or len(cols) == 0: return None return cols.min(), rows.min(), cols.max() + 1, rows.max() + 1 def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('still', type=Path, help='two-pose image: rest pose on the left half, jump or attack wind-up on the right half') parser.add_argument('--name', required=True, help='character name used in the output file names') parser.add_argument('--out', type=Path, required=True, help='output folder (created if missing)') parser.add_argument('--size', type=int, default=960, help='side of the square first frame in px (default 960)') parser.add_argument('--figure', type=float, default=0.58, help='figure height as a share of the frame (default 0.58)') parser.add_argument('--baseline', type=float, default=0.82, help='feet line as a share of the frame from the top (default 0.82)') parser.add_argument('--tolerance', type=int, default=85, help='how far from pure magenta still counts as background (default 85)') parser.add_argument('--force', action='store_true', help='overwrite existing first frames') args = parser.parse_args() if not args.still.is_file(): sys.exit(f'error: {args.still} not found') if not 0.2 <= args.figure <= 0.9 or not args.figure < args.baseline <= 0.98: sys.exit('error: need 0.2 <= --figure <= 0.9 and --figure < --baseline <= 0.98') targets = {p: args.out / f'{args.name}_{p}.png' for p in ('rest', 'action')} existing = [str(p) for p in targets.values() if p.exists()] if existing and not args.force: sys.exit('error: would overwrite ' + ', '.join(existing) + ' (pass --force to replace)') im = Image.open(args.still).convert('RGB') rgb = np.array(im).astype(int) keep = ~magenta_mask(rgb, args.tolerance) width = im.width size = args.size fig_h = int(size * args.figure) base = int(size * args.baseline) args.out.mkdir(parents=True, exist_ok=True) for pose, x0, x1 in (('rest', 0, width // 2), ('action', width // 2, width)): box = figure_box(keep[:, x0:x1], min_pixels=3) if box is None: sys.exit(f'error: no figure found in the {pose} half; is the background flat magenta?') bx0, by0, bx1, by1 = box[0] + x0, box[1], box[2] + x0, box[3] if bx0 == x0 or bx1 == x1: print(f'warning: the {pose} figure touches the middle or the edge of the image; ' 'check that the two poses do not overlap', file=sys.stderr) fig = im.crop((bx0, by0, bx1, by1)) scale = fig_h / fig.height new_w = round(fig.width * scale) if new_w > size: sys.exit(f'error: the {pose} figure is wider than the frame at --figure {args.figure}; lower it') fig = fig.resize((new_w, fig_h), Image.LANCZOS) alpha = Image.fromarray((keep[by0:by1, bx0:bx1] * 255).astype('uint8')).resize(fig.size, Image.LANCZOS) out = Image.new('RGB', (size, size), (255, 0, 255)) out.paste(fig, ((size - new_w) // 2, base - fig_h), alpha) out.save(targets[pose]) print(f'wrote {targets[pose]} (figure {new_w}x{fig_h}, feet at y={base})') if __name__ == '__main__': main() -
run_loop.py 7.5 KB
#!/usr/bin/env python3 """Turn a run-in-place clip on a flat background into a keyed, looping image sequence for Blender. The clip is an image-to-video result: the hero runs like on a treadmill, centered, on a flat color (magenta). Every frame is keyed to alpha, then the loop is the window [s, s+P) whose seam (frame s against frame s+P) is smallest compared with the mean frame-to-frame motion inside it; every start s in the search range and every period P in --periods is tried. The kept frames are cropped to their common bounds plus --pad px (no scaling: Blender sizes them from config.json) and written as <out>/run_001.png, run_002.png, ... Keys: magenta a soft magenta key with despill; pink or purple parts enclosed by the figure (inner ears) are kept. Needs prep_assets.py in the same folder. border each frame is keyed against its own border color (the median of an 8 px border). Use it when the clip's background drifts (one reference clip went from magenta through red and yellow to green), and search only the frames whose background is far from the character's colors (--from/--to). --clean tightens the alpha edge by 1 px and pulls a 3 px edge band toward gray, except strongly red pixels (a red scarf or hat), to remove a colored fringe. Prints the numbers config.json needs: run_n (frames), run_fig_px (mean figure height) and run_pad_px. Existing frames are kept unless --force is given. Examples: python3 run_loop.py clips/hero_run.mp4 game/hero_run python3 run_loop.py clips/hero_run.mp4 game/hero_run --key border --from 60 --to 124 --skip 0 --periods 8:16 --clean Needs Python 3 with Pillow, NumPy and SciPy, and ffmpeg on the PATH. """ import argparse import os import subprocess import sys import tempfile try: import numpy as np from PIL import Image, ImageFilter from scipy import ndimage except ImportError: sys.exit('This script needs Pillow, NumPy and SciPy: python3 -m pip install pillow numpy scipy') def small(rgba, s=120): im = Image.fromarray(rgba).resize((s, s), Image.BOX) a = np.array(im).astype(np.float32) return a[..., :3] * (a[..., 3:] / 255), a[..., 3] / 255 def dist(a, b): return float(np.abs(a[0] - b[0]).mean() + 80 * np.abs(a[1] - b[1]).mean()) def key_border(rgb): a = rgb.astype(float) b = np.concatenate([a[:8].reshape(-1, 3), a[-8:].reshape(-1, 3), a[:, :8].reshape(-1, 3), a[:, -8:].reshape(-1, 3)]) bg = np.median(b, 0) d = np.sqrt(((a - bg) ** 2).sum(-1)) al = np.clip((d - 45) / (110 - 45), 0, 1) # keep only the figure (largest blob), drop specks lab, n = ndimage.label(al > 0.5) if n > 1: sz = ndimage.sum(al > 0.5, lab, range(1, n + 1)) keep = lab == (np.argmax(sz) + 1) keep = ndimage.binary_dilation(keep, iterations=3) al = al * keep # unmix the background out of soft edges aa = np.maximum(al, 1e-3)[..., None] fg = np.clip((a - (1 - aa) * bg) / aa, 0, 255) return np.dstack([np.where(al[..., None] > 0, fg, 0), al * 255]).astype(np.uint8) def clean(im): a = np.asarray(im).astype(float) al = np.asarray(im.getchannel('A').filter(ImageFilter.MinFilter(3))).astype(float) # 1 px tighter edge solid = al > 250 band = ~ndimage.binary_erosion(solid, iterations=3) & (al > 0) r, g, b = a[..., 0], a[..., 1], a[..., 2] red = (r > g + 50) & (r > b + 50) lum = 0.3 * r + 0.55 * g + 0.15 * b fix = band & ~red for c in range(3): a[..., c] = np.where(fix, np.minimum(a[..., c], lum) * 0.92, a[..., c]) a[..., 3] = al return Image.fromarray(a.astype(np.uint8)) def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('clip', help='the run clip (mp4)') parser.add_argument('out', help='output folder for run_NNN.png (created if missing)') parser.add_argument('--key', choices=('magenta', 'border'), default='magenta', help='how to key the background (default magenta)') parser.add_argument('--periods', default='7:15', help='loop lengths to try, MIN:MAX frames (default 7:15)') parser.add_argument('--from', dest='first', type=int, default=0, help='first frame of the search range, 0-based (default 0)') parser.add_argument('--to', dest='last', type=int, default=None, help='end of the search range, exclusive (default: the last frame)') parser.add_argument('--skip', type=int, default=6, help='earliest loop start inside the range (default 6: the clip settles first)') parser.add_argument('--pad', type=int, default=8, help='empty px kept around the figure (default 8; this is run_pad_px)') parser.add_argument('--clean', action='store_true', help='tighten the edge by 1 px and remove a colored fringe (keeps red)') parser.add_argument('--force', action='store_true', help='replace existing run frames in the output folder') if len(sys.argv) == 1: parser.print_help() sys.exit(1) args = parser.parse_args() if not os.path.isfile(args.clip): sys.exit(f'error: {args.clip} not found') pmin, pmax = (int(v) for v in args.periods.split(':')) old = [f for f in os.listdir(args.out) if f.startswith('run_') and f.endswith('.png')] if os.path.isdir(args.out) else [] if old and not args.force: sys.exit(f'error: {args.out} already holds {len(old)} run frames (pass --force to replace them)') if args.key == 'magenta': sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) try: from prep_assets import key_rgb except ImportError: sys.exit('error: --key magenta needs prep_assets.py next to this script') key = key_rgb else: key = key_border with tempfile.TemporaryDirectory() as tmp: subprocess.run(['ffmpeg', '-v', 'error', '-y', '-i', args.clip, os.path.join(tmp, 'f_%04d.png')], check=True) files = sorted(os.listdir(tmp))[args.first:args.last] rgba = [key(np.asarray(Image.open(os.path.join(tmp, f)).convert('RGB'))) for f in files] sm = [small(a) for a in rgba] best = None for period in range(pmin, pmax + 1): for s in range(args.skip, len(sm) - period): step = np.mean([dist(sm[i], sm[i + 1]) for i in range(s, s + period)]) seam = dist(sm[s], sm[s + period]) score = seam / step if best is None or score < best[0]: best = (score, s, period, seam, step) if best is None: sys.exit('error: the search range is shorter than the shortest period') score, s, period, seam, step = best print(f'loop score {score:.3f} (seam / motion, lower is better; the reference loops scored 0.10 to 0.27) start {args.first + s} period {period}') win = rgba[s:s + period] al = np.stack([w[..., 3] for w in win]).max(0) > 20 ys, xs = np.where(al) y0, y1, x0, x1 = ys.min() - args.pad, ys.max() + args.pad, xs.min() - args.pad, xs.max() + args.pad y0, x0 = max(0, y0), max(0, x0) os.makedirs(args.out, exist_ok=True) for f in old: os.remove(os.path.join(args.out, f)) for i, w in enumerate(win): im = Image.fromarray(w[y0:y1, x0:x1]) if args.clean: im = clean(im.convert('RGBA')) im.save(os.path.join(args.out, f'run_{i + 1:03d}.png')) figs = [int(np.ptp(np.where((w[..., 3] > 128).any(1))[0])) for w in win] print(f'wrote {period} frames of {x1 - x0}x{y1 - y0} px to {args.out}') print(f'config.json: "run_n": {period}, "run_fig_px": {round(sum(figs) / len(figs))}, "run_pad_px": {args.pad}, ' f'"run_canvas_px": {rgba[0].shape[0]}') if __name__ == '__main__': main() -
trace_hulls.py 2.9 KB
#!/usr/bin/env python3 """Trace each prop sprite's outline into shapes.json, the collision shapes build_scene.py reads. For every physics prop: the opaque pixels (alpha > 128), every 7th one fed to a convex hull, the hull thinned to about 20 points and normalized (u 0..1 from left to right, v 0..1 from bottom to top). For the tree and the pedestal only the size and the opaque bounds are written: build_scene.py uses the tree's bounds to plant its trunk on the ground when the image has empty rows under it. Writes <game_dir>/shapes.json. An existing file is kept unless --force is given. Examples: python3 trace_hulls.py jungle/game python3 trace_hulls.py snow/game --props jar,pot_round,amphora,idol,coconut --bounds palm,pedestal --force Needs Python 3 with Pillow, NumPy and SciPy. """ import argparse import json import os import sys try: import numpy as np from PIL import Image from scipy.spatial import ConvexHull except ImportError: sys.exit('This script needs Pillow, NumPy and SciPy: python3 -m pip install pillow numpy scipy') def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('game_dir', help='folder holding the sprites (<name>.png)') parser.add_argument('--props', default='jar,pot_round,amphora,idol,coconut', help='sprites that get a hull (comma-separated)') parser.add_argument('--bounds', default='palm,pedestal', help='sprites that get only size and bounds') parser.add_argument('--force', action='store_true', help='replace an existing shapes.json') if len(sys.argv) == 1: parser.print_help() sys.exit(1) args = parser.parse_args() target = os.path.join(args.game_dir, 'shapes.json') if os.path.exists(target) and not args.force: sys.exit(f'error: {target} exists (pass --force to replace it)') props = [n for n in args.props.split(',') if n] bounds = [n for n in args.bounds.split(',') if n] out = {} for name in props + bounds: path = os.path.join(args.game_dir, name + '.png') if not os.path.isfile(path): sys.exit(f'error: {path} not found') a = np.asarray(Image.open(path).convert('RGBA'))[..., 3] > 128 H, W = a.shape ys, xs = np.where(a) entry = {'w': W, 'h': H, 'bbox': [int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())]} if name in props: pts = np.stack([xs, ys], 1)[::7] hp = pts[ConvexHull(pts).vertices] # simplify to about 20 points, normalized: u in 0..1 left->right, v in 0..1 bottom->top step = max(1, len(hp) // 20) hp = hp[::step] entry['hull'] = [[float(x) / W, 1 - float(y) / H] for x, y in hp] out[name] = entry print(name, entry['bbox'], (W, H), len(entry.get('hull', [])), 'hull points') with open(target, 'w') as f: json.dump(out, f) print('wrote', target) if __name__ == '__main__': main()
-
-
README.md 3.4 KB
# scenario-blender-2d-animation: maintainer notes Not read at runtime. How the skill was built and why it makes its choices. ## Origin Distilled from the [Blender 2D animation exploration](https://www.scenario.com/explorations/blender-2d-animation/) on scenario.com: a 7.7 second side-scroller shot in three looks (a red panda in a golden-hour jungle, a black cat on a moonlit Halloween street, a snow leopard cub in a winter valley), ten Scenario generations per look, built and rendered by one Blender script. The page offers the skill as a download; this directory is that skill brought to the repository's authoring contract. The exploration used one text-to-image model for every picture and one image-to-video model at 768p for the run clips. The body names neither and discovers both lanes with `recommend` (`txt2img`, `img2video`), because availability differs per team and both generations will be superseded. The page's credit figures are left out for the same reason: the body prices with `dry_run`. ## Why a core skill, not a Blender expert tool Every paid step (ten generations per look) is a Scenario MCP call; Blender only lays out and renders, locally. The expert tier exempts its skills from the MCP rules, which are exactly what this skill must be graded on, and the Blender family's specialists share the lead's toolkit and provenance, neither of which applies here. `scenario-patina-retexture` set the precedent: MCP generation, then bundled Blender scripts run headless. The name keeps the download's, so the exploration page and the repository agree. ## Changes from the download - Description cut to triggers only; the body names no generative model; discovery moved from a newest-first `search` to `recommend`; payload field names hedged as schema examples. - `scenario-side-view-game-kit` and `scenario-sprite-animation` named as the sprite-strip alternatives. The download's other related skills are not named: the model-family ones point at a model, and the workflow hands nothing to the rest, whose bodies would only push the fan-out past the re-attach pool. - `build_scene.py --live` used to empty whatever file was open. It now refuses unless the file is new (nothing beyond the startup cube, light and camera) or the script's own output. `bpy.data.is_dirty` cannot make that call: Blender reports a new file as dirty. - The jump still's scale was hard-coded as 768 / 960 (the clip's height over the reframed canvas), which is wrong for any video model that answers at another resolution. `run_loop.py` now prints `run_canvas_px` and `build_scene.py` reads it with the jump still's own height. - The common mistakes that the scripts now prevent moved out of the body; `references/pipeline.md` already records them for anyone changing the scene script. ## Scripts `reframe_stills.py` is a byte-for-byte copy of the one in `scenario-side-view-game-kit`, kept so each skill installs alone; `tests/scenario-blender-2d-animation/test_reframe_stills.py` fails when the two drift, so a fix to one reaches the other. `run_loop.py` shares that kit's loop-window idea (seam over in-window motion) but adds the border key and writes a Blender image sequence instead of a pixel strip. The suite runs the five helpers on synthetic inputs. `test_build_scene.py` builds, bakes and saves a synthetic set in a real Blender and is skipped unless `BLENDER` names the binary or `blender` is on the `PATH`, which CI does not provide; it passed on Blender 5.0.1. -
SKILL.md 12 KB
--- name: scenario-blender-2d-animation description: "Use when a 2D side-scroller shot, platformer cutscene, game trailer or parallax animation has to be built in Blender from Scenario-generated pictures (flat image cards, an orthographic camera, keyed jumps, rigid-body props), or when a finished shot needs a new theme or look without re-animating. Also use when image planes in Blender float above the ground, a tiled parallax layer shows a seam, or physics props leave the picture plane." license: MIT --- # Scenario Blender 2D animation ## Overview Generate the art once per look; animate it once in Blender. A concept frame (an imagined in-game screenshot) fixes the style, and the eight other pictures (sky, far and mid layers, ground strip, foreground plants, props sheet, tree, the hero in two poses) take it as their reference, so the set holds together. An image-to-video clip of the hero running in place on magenta becomes a looping image sequence. One Blender script lays the pictures out as flat cards in depth, drives their parallax from an orthographic camera that follows the hero, keys the jumps and runs rigid-body physics on the props. A new look is ten new generations and a few measured numbers. The reference result is a 7.7 second, 1080p, 24 fps shot in three looks. The deliverable is the film and its `.blend`; for sprite strips a game engine plays, use `scenario-side-view-game-kit` or `scenario-sprite-animation`. Discover both lanes with `recommend`, the user's words as `prompt`: `capability: "txt2img"` for the pictures, which need a reference-image input, a transparent background and exact width and height (if the pick lacks one, or is a specialty for another style than the look, such as pixel art for a painted cartoon, take the next ranked pick), and `capability: "img2video"` for the run clip, which needs a first frame. `model_schema_get` each pick. Payload names below (`referenceImages`, `background`, `numOutputs`) are examples: use the fields your pick's schema lists. Before generating, tell the user the total for the nine pictures and the clip: `dry_run` the concept frame and quote the rest from `recommend`, since a `dry_run` naming an asset that does not exist yet returns 404. Then `dry_run` each later payload once its input exists, before running it. Connection, uploads and the core loop: the `scenario` skill. 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. The scripts run locally and never call Scenario. Blender 5.x runs the scene script, headless or in an open session (through a Blender MCP connection, for example); in a session, start from File > New, since `--live` empties the open file and refuses any file but a new one or its own output. Python 3 with Pillow, NumPy and SciPy runs the helpers, and ffmpeg reads the clip. Every helper prints its usage when run without arguments and refuses to overwrite an existing output (or its input) unless given `--force`. ## Quick reference | Step | How | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | Pick models | `recommend` (`txt2img`, `img2video`), `model_schema_get`, then `model_run` with `dry_run: true` per distinct payload | | Concept frame | Text to image, 16:9, opaque, two takes: an in-game screenshot, strict side view, layers named in order ([prompts](references/prompts.md)) | | Layers | One call per layer with the concept as reference; transparent except the sky; wide (3072 x 1152) for tiled layers | | Sheets | Props, tree and foreground on transparent backgrounds with wide gaps, numbered in reading order | | Hero | Two poses (rest, jump) side by side on flat magenta; [reframe_stills.py](scripts/reframe_stills.py) makes square first frames | | Run clip | Image to video from the rest pose: "like a treadmill", locked camera, magenta that never changes; 5 s is enough | | Run loop | [run_loop.py](scripts/run_loop.py): key, find the cleanest one-period loop, write `hero_run/run_001.png`... | | Cut and key | [prep_assets.py](scripts/prep_assets.py) `cut` (sheets), `key` (magenta stills), `tile` (seamless wide layers) | | Hulls | [trace_hulls.py](scripts/trace_hulls.py): convex hull of each prop's alpha into `shapes.json` | | Measure | [measure_set.py](scripts/measure_set.py): horizon, ground line, island and pedestal tops ([example config](examples/config.json)) | | Build | [build_scene.py](scripts/build_scene.py) `-- --set SET_DIR --review`, then `--final` | | New look | New concept frame, the same eight pictures and clip, same file names, a new `config.json`; nothing else changes | | Wait | `jobs_wait` with up to 32 `job_ids`; on timeout re-call with the returned `pending_job_ids` | ## Worked example: a jungle run, then a snow day Pass `team_id` and `project_id` on every call (the `scenario` skill covers picking them). 1. **Concept frame.** `recommend { "capability": "txt2img", "prompt": "<the user's request>; needs reference image input, transparent background, exact width and height" }`, `model_schema_get` the pick, price two takes with `model_run { "model_id": "<pick>", "dry_run": true, "parameters": { "prompt": "...", "width": 1536, "height": 864, "background": "opaque", "numOutputs": 2 } }` and the screenshot prompt from [references/prompts.md](references/prompts.md) section 1. Run it, `jobs_wait` if it returns a `job_id`, `asset_display` both takes and keep the one whose layers you can name one by one. Its asset id is the reference for everything that follows. 2. **Layers, one call each.** The same pick with `referenceImages: ["<concept id>"]`: the sky (2560 x 1440, opaque), the far layer and the mid layer (3072 x 1152, transparent), the ground strip (2048 x 768, transparent, a flat walkable top at about 30 percent from the top, no end caps) and a foreground sheet of five plant clusters (3072 x 1024, transparent). Price them, then run them with `wait: false` in waves within the team's concurrency ceiling (the `parallel-custom-jobs` row in the `scenario` skill: a launch refused with that 429 created no job, so relaunch it in the next wave), retiring each wave with `jobs_wait`. 3. **Props and tree.** A props sheet (2048 x 1536, transparent): numbered props in a loose grid with wide gaps (a pot, a tall vessel, a lidded jar, a heavy statue, the fruit that will fall, a coin, a floating island, a flat pedestal), and the tree alone (1024 x 1536) with no fruit in its crown. 4. **Hero.** Two poses on flat magenta (1536 x 1024, opaque, two takes): rest on the left, jump on the right, both the same size, pure side profile facing right. `asset_download` the kept take, then `python3 scripts/reframe_stills.py hero.png --name hero --out frames` writes `hero_rest.png` and `hero_action.png` (lower `--figure` for a long tail). Upload the rest frame with `upload_asset` and `upload_asset_complete`. 5. **Run clip.** `recommend { "capability": "img2video", "prompt": "<the user's request>; first frame input, 5 seconds" }`, `model_schema_get`, price a five-second clip with the rest frame's asset id in the schema's first-frame field and the treadmill prompt ([prompts.md](references/prompts.md) section 5), with audio off and a 1:1 aspect ratio where the schema has those fields (the loop keeps only silent frames, and the jump still's scale assumes the clip is the square first frame resized), run it, `jobs_wait` (minutes, re-called as needed), `asset_download`. 6. **Process.** Into `jungle/game/` (the sky stays one wide card: copy it as `sky.png`): `python3 scripts/prep_assets.py tile raw/sea.png jungle/game/sea_tile.png 384`, the same for the mid layer (`jungle_tile.png`; a fade of one eighth of the width) and the ground (`ground_tile.png`, `256` for 2048 px); `python3 scripts/prep_assets.py cut raw/props.png jungle/game pot_round,amphora,jar,idol,coconut,coin,island,pedestal`; `CUT_GROW=2 python3 scripts/prep_assets.py cut raw/foreground.png jungle/game fg_bush,fg_fern,fg_hibiscus,fg_boulder,fg_curl`; `python3 scripts/prep_assets.py key frames/hero_action.png jungle/game/hero_jump.png`; `python3 scripts/run_loop.py clips/hero_run.mp4 jungle/game/hero_run` (it prints `run_n`, `run_fig_px`, `run_pad_px` and `run_canvas_px`, the clip's height); `python3 scripts/trace_hulls.py jungle/game`. Copy the tree (keyed or already transparent) as `palm.png`. 7. **Measure.** `python3 scripts/measure_set.py jungle/game --ground-h 4.3` (the ground card's height in meters, `ground_h` in the config) prints the horizon, the walkable line (already moved 10 cm down so the feet sink into the grass lip), the island and pedestal tops and the jump still's feet row. Write them with the run numbers into `jungle/game/config.json`. Layer heights, the fruit positions and the hero's height in meters are set by eye on the review stills: start from [examples/config.json](examples/config.json) ([references/pipeline.md](references/pipeline.md) lists every key). 8. **Build and review.** `blender -b --factory-startup --python-exit-code 1 -P scripts/build_scene.py -- --set jungle --review`. Outputs land in the folder holding `jungle/` (or `--out DIR`): `platformer_scene<tag>.blend`, `review<tag>/` and, with `--final`, `platformer<tag>.mp4`; a rebuild over them needs `--force`. Look at the 11 review stills: feet on the ground, the tree standing on the ground, no seam, the tower standing until the hero reaches it, every piece inside the frame. Then `--final` renders the film. Before delivery, walk [references/checks.md](references/checks.md). 9. **A new look.** Generate a new concept frame (a white winter valley, a snow leopard cub in a red hat), the same eight pictures and clip with it as reference, process into `snow/game/` with the same names, measure a new `config.json` (with `"tag": "_snow"`) and run the same build with `--set snow`. Two looks line up frame for frame. The snow clip's background drifted, so its loop came from `run_loop.py clip.mp4 snow/game/hero_run --key border --from 60 --to 124 --skip 0 --periods 8:16 --clean`. 10. **Ledger.** One line per Scenario job with model, parameters, inputs, outputs, job id, cost, kept or not, and why. ## Common mistakes - Trusting the grass tips as the ground line: every look's hero floated 3 to 8 cm plus the run bob until the line moved about 10 cm into the lip, which `measure_set.py` now applies. - Keying a drifting clip on fixed magenta: one run clip went from magenta through red and yellow to green. Use `--key border` and take the loop from a stretch away from any red or pink on the character. - Cutting leafy sheets with the default grow: the five foreground plants came out as four pieces until the cut ran with `CUT_GROW=2`. Check the printed count against the names. - Not checking enclosed pink after keying: `prep_assets.py key` keeps pink enclosed by the silhouette (a cat's inner ears), but look at ears, tongues and flowers. - Hard-coding the run length: the cat and the snow leopard looped in 12 frames, the red panda in 9. Copy `run_n` from `run_loop.py`'s output. - A landmark at the end of a wide layer: the tile seam goes where the silhouette is lowest, so ask for content spread across the width. - Passing a local path or URL to `model_run`: upload the file first and pass the asset id. - Hard-coding model ids or prices, or polling `job_get` instead of re-calling `jobs_wait`. Changing `build_scene.py` itself (depths, parallax factors, physics): read [references/pipeline.md](references/pipeline.md) first, which records the decisions the reference shot paid for.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.