scenario-sprite-animation
Use when animating 2D game art with Scenario: walk, idle or attack cycles in 4 or 8 directions, isometric sprite sheets, animated GIFs, frame-by-frame pixel art animation, VFX loops such as fire or smoke, looping a sprite seamlessly, animating an existing character image, a full
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/scenario-sprite-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.
Skill manifest
Scenario Sprite Animation
Overview
A bare prompt to a generic image model for "a sprite sheet, walk cycle, 8 frames" returns a picture of one; a written grid spec on a high-adherence image model returns the sheet (the single-sheet row below). Frame sequences come from routes picked by the control the task needs, not sprite size alone. This skill routes between lanes and carries the frame math; depth lives with siblings: scenario-video (video model families), scenario-consistency and scenario-model-training (one character across an action set), scenario-game-assets (statics, pixel cleanup, upscaling; a sheet whose cells are one character's frames stays here), scenario-refine-loop (iterate to brief). Connection 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.
Quick reference
| You need | Route |
|---|---|
| Pixel cycles or VFX, style-locked canvas | Purpose-trained animation model (recommend with the sprite-animation need in the user's own words, non-deprecated pick): GIF preview, same-seed spritesheet re-run, slice |
| An exact grid, chosen frame count, scripted beats | High-adherence general image model (recommend with the grid spec in the user's own words; expect ask_user against the purpose-trained pick): one model_run, slice, snap per tile, assemble |
| A seamless loop from a still | First+last-frame video model (recommend with the loop requirement): the same flattened still at both ends plus explicit motion language, extract, seam-check |
| Poses at chosen moments | Keyframe video model (recommend with the keyframe-pinning need): pin pose stills to frame indices or timestamps, extract |
| Animate this sprite, larger or styled | Image-to-video on the flattened still, prompt anchored in a sprite-animation style with scripted motion beats; model per scenario-video |
| One character across an action set | Turnaround plus reference-to-video per scenario-consistency; reuse references and settings for every action |
| One character, several facings, a cycle set, one sheet | Pose sheet or turnarounds from the user's art, then one first-and-last-frame image-to-video clip per facing and cycle, finished locally by the shipped cycle scripts (directional cycles) |
| Frames to sheet, sheet to frames | Tool models via model_run: model_scenario-video-to-image-seq, model_scenario-grid-maker, model_scenario-image-slicer, model_scenario-image-seq-to-video (fixed first-party ids: each is Scenario's single deterministic tool for its step) |
The schema is the contract, read with model_schema_get on the pick: re-discover the generative members each session rather than hardcode them (the tool ids above are fixed). Per-lane schema notes, covering the Retro Diffusion canvas lock and returnSpritesheet seed pairing on the pixel lane, the single-sheet schema shape and prices, the video-lane control surfaces, the alpha routes, and how to read a deprecation tag: references/lane-schemas.md.
The single-sheet lane trades the pixel lane's native pixels, locked canvas and seed pairing for a grid honored as written; the pixel look is emulated, so the snapping pass is part of the lane. Unattended, the task decides between the table's first two rows: an exact grid, frame count or scripted beats in the brief takes the top-ranked general model over specialty.model_id, whose when_general_better names exactly those needs. Write the spec for the pipeline that will read it: rows and columns within the slicer's 6x6 (16 frames as 4x4, never 2 by 8, which is a local cut), a canvas the grid divides evenly, one scale, camera, facing and ground baseline, the beats per frame range with the last frame matching the first, generous margins, no labels. Leave GIF production lines out: an image model returns stills, and a hold is the same pose across extra frames. A background enum names no color, so a solid field is prompt wording; an existing character goes through the reference-image field; with no seed a re-run is a new character, so candidates come from the sample-count parameter and one sheet is approved before slicing. Read the delivered sheet before slicing: count the cells, read the cell edges, and count distinct poses, since a beat range naming a single end state can come back as one held pose. Then slice, seam-check the tiles per step 5, align the body across tiles, snap every kept tile with the same color count and seed, check that the snapped tiles share one size, and assemble. Alignment is a post step, not a prompt: the body drifts between rows (in one run the grounded feet sat 87 px higher in the last row than in the first, and neither anchoring language nor a layout template passed as reference image moved that), so measure each tile's body box, shift it onto one baseline and one horizontal center on a wider canvas so nothing clips, then upload_asset the re-laid sheet and slice it, since frames edited locally never become assets.
For reusable layouts, read the grid manifest and template usage guide. Resolve uploaded assets and publish new versions through the scenario skill's shared asset lifecycle. When none is accessible, the grid builder, run by a maintainer or an agent needing local geometry, creates the selected preset for MCP upload; it does not generate animation. Preserve the user's frame count and canvas over any preset. Templates do not replace the alignment, edge, and loop checks above.
For a character in several facings (isometric diagonals, side or top-down) with walk, run, idle and attack rows, follow the directional cycles lane: it covers the user's own art, the key-color check, mirroring and weapon hands, and engine import. Its three scripts, run by the agent, keep the MCP steps in the lane and do only the local geometry: cycle_prompts.py writes model-agnostic requests to map onto a discovered schema, cycle_frames.py makes first frames from any art, and cycle_sheets.py keys, loop-searches, registers and pixelates a character's clips into sheets, which are then uploaded with upload_asset.
The frames pipeline is tool models run with model_run, not separate MCP tools: a video-to-image-sequence extractor (extractAllFrames, or every Nth frame via frameInterval; the order of its frames is verified, never assumed, see below), a grid maker (images, up to 100, kept in input order; columns, default 3, and rows, computed from the count when unset, so a delivered 2 by 8 is the finished frames at columns: 8; backgroundColor takes a hex string, transparent, black or white), a grid slicer (image plus xSubdivisions and ySubdivisions, 1 to 6 each and defaulting to 2 when omitted, so 6x6 is the cap; no output-size parameter; tiles return in row-major order but not always at cell size, so check one with asset_get, and a resized tile or a sheet past 6x6 means cutting the sheet locally on the grid you counted), a sequence-to-video assembler (model_scenario-image-seq-to-video, a first-party id stable enough to name; fps 1 to 120, loopCount, pingpong, outputFormat gif or mp4), and a pixel snapper (re-detects the art's grid and quantizes to the requested color count, the one palette control in the cleanup chain; priced per tile, so probe one before the set). GIF frame delays quantize to 10 ms, so ask the assembler for an fps that divides 100 (10, 20 or 25; 8 lands at 125 ms and 12 at 83 ms, neither on the grid), or it re-times and duplicates frames to hold the duration. Fps is the only timing lever after generation, so plan frame counts up front and count what came back. Analysis is fine locally: differencing, seam math, edge reads, and window searches need pixels on disk.
Frame order is verified, never assumed. Extracted frames share one createdAt and carry no index in metadata, so their order exists only in the job row's assetIds, and that list has come back out of time order (a confirmed defect at authoring time). Before any stride math, slice, or assembly, pass the ids to the grid maker in returned order and read the contact sheet against the clip's free firstFrame and lastFrame (asset_get) and its motion: a frame that breaks continuity is out of place, and the corrected order is carried as your own id list from then on. Never rebuild order from listings, timestamps, or search.
Worked example: a seamless idle loop from a still
- Create a dedicated collection first (
collection_create, tool catalog) andcollection_add_assets(collection_id,asset_ids) every kept output as it lands. With no still to hand, generate and approve one first: it anchors every later step, so check subject-critical detail against the brief before animating, and budget it apart from the loop (references/lane-schemas.md). Flatten the character onto a plain field (a local edit is fine; assume video inputs drop transparency as reference inputs do) andupload_assetit. Confirm lane and budget with the user; unattended, take them from the task instructions, else the cheapest lane that fits, anddry_runbefore every paid run. recommendwith the loop requirement in the user's own words, prefer a non-deprecated pick, thenmodel_schema_get. The schema decides the levers: duration and fps enums, a camera lock such asstatic, an audio toggle, and a native loop flag where present, replacing the end-frame anchor: never send both (dry_runaccepts combinations that still fail at run time).model_runwith the same still as first and last frame and a prompt that names the motion explicitly ("weight shifts, cloak sways; the character stays in place").jobs_waitre-called withpending_job_idswhile the job runs.- If the sprite needs alpha, expect no video lane to emit it, so budget a removal pass; verify alpha survived on one downloaded frame before batching: inline display composites it away, and a transparent request can come back in a container without alpha (references/lane-schemas.md). Extract with the video-to-image-sequence tool and verify the frame order first (above). Confirm what was delivered before computing any stride (requests are not always honored):
asset_getreportsproperties.duration,frameRate,nbFrames,width, andheight;nbFramesis the delivered frame count the math needs, so read those and leave the rest of the record alone. At authoring time the extractor exposed onlyextractAllFramesandframeInterval, so confirm withmodel_schema_get: no start offset, end, or range. Any non-uniform selection (trimmed window, stride off an offset, hand-picked subset) meansextractAllFrames: truethen subsetting by asset id; aframeIntervalthat cannot express the window is never a reason to leave the platform. Frame math: extracted count = delivered frames /frameInterval, endpoint-inclusive; where the schema exposes fps, generating at sprite cadence (8 to 12 fps: a cycle typically needs 8 to 16 frames) beats decimating after. - Seam-check against the sequence's own motion, not by eye: a seam reads as a loop only relative to how much the frames already move. Compute the mean absolute pixel delta between consecutive frames (the motion baseline) and the delta across the seam; the loop closes when the seam sits at or below the baseline of the window being looped, not the whole sequence's, since per-step delta swings by orders of magnitude across a scene loop. On a near-static idle,
asset_displayof first and last frame is enough; on locomotion (hop, walk, attack) eyeballing passes visibly broken loops. If the seam is wide, do not re-run first: image-to-video models re-render off the input still, so frame 0 often diverges from every later frame (a large 0 to 1 delta is the tell) and no re-run fixes a loop anchored to it. Search start and end pairs inside the model's own rendering, excluding frame 0, and take the tightest closing window that still carries real motion. Trim to where the motion is: a duration longer than the action leaves a static tail, and the stride comes from the active window, not the delivered length. Only after a window search fails is a re-run (newseedwhere the schema has one, else reworded motion) worth the credits. Unattended, allow one re-run, then flag and stop. - Finish and package: a pixel-art target needs a snapping pass, which has traps of its own and is sometimes the wrong move (references/lane-schemas.md). Sheet via the grid maker,
transparentbackground or a color when the brief is opaque; preview via sequence-to-video as GIF; the assembler has no background parameter, so a deliverable GIF takes opaque frames already on its field.asset_downloadframes withformat="png"and the preview withformat="gif", and omitformaton a video asset, which rejectsmp4.
Common mistakes
- Identical first and last frames with a bare prompt: that asks for a freeze, not a loop; the motion must be named.
- Treating margin or wording as containment for a traveling effect: what crosses the cell edge is gone, margin gets filled and crossed anyway, and containment wording cancels the throw. Keep the frames whose delivered edge band reads empty; the detached projectile is its own sprite.
- Assuming every model has a
seed: many video schemas and several high-adherence image schemas have none, so reproducibility there is per-asset, not per-run. - Counting on an interpolation filter: none exists in the catalog, and fps is the timing lever. Pixelating a non-pixel image is a different job from cleaning pixel art: the catalog carries a pixelate tool (grid size, palette) for the first and the snapper for the second, both found with
search(target: "models",public: true,filterstags: ["tool"],query: "pixel"returns both; the descriptions tell them apart). - Photo upscalers on pixel frames: they invent texture; use the pixel-preserving routes in
scenario-game-assets. - Shipping a sheet unstepped: per-frame steps return new ids and order lives only in the lists you pass, so carry the frame-to-output mapping (never rebuild it from listings or timestamps), then compare sheet to preview before delivery.
- Assembling deliverables locally because the frames are already on disk: analysis pulls the clip down, local assembly then looks free, and the frames never become assets, leaving the collection holding a sheet nobody else can reach.
- Ping-pong or loop flags set blind: watch the downloaded GIF and match the assembler to it.
- Launching every frame, direction, or clip at once: past the team's concurrency ceiling (the
parallel-custom-jobsrow in thescenarioskill) a launch is refused with that 429 and creates no job, and on some video lanes the burst is accepted and comes back as failed, refunded jobs instead (directional cycles). Either way, launch in waves and relaunch every refused or failed item in the next one rather than dropping it. - Softening an attack brief after a moderation block: the filter is the provider's, so try another general image model first per
scenario-moderation, grid spec unchanged; rewording the beats loses what the lane exists to honor.
Files (skills)
-
references
-
directional-cycles.md 12.3 KB
# Directional character cycles One character, several facings (isometric SE, SW, NE, NW by default, or side-view and top-down facings), and a set of cycles (walk, run, idle, attack), each a looping 8-frame row on one registered sheet. The route: an image model draws every facing as a still (from a description, or from the user's own art), three shipped scripts handle the geometry, and one image-to-video clip per facing and cycle supplies the motion. The scripts run locally because the finishing needs all of a character's clips at once (one shared canvas so cycles register, one palette across every cycle); the finished sheets are uploaded, so they become assets like any other output. Authoring-time observations below come from a production run of five characters, 40 clips, on one first-and-last-frame image-to-video member. Availability differs per team, so discover members with `recommend` and read every schema with `model_schema_get`. ## Pick the situation first | The user has | Start at | | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | a question ("how many frames", "how do I import this") | Answer from the fundamentals below; spend nothing, then offer the route | | an idea, no art | Step 2, pose sheet | | their own art in one of the game's facings | Step 3 on their art; step 2 draws only the missing facings, as turnarounds from it | | art in another view (3/4 art for a side-scroller) | Step 3 with `--facing ref`, upload it as `reference`; every facing becomes a turnaround | | clips already (another tool, a 3D render) on a flat magenta, green or blue field | Step 5 with `"fig_frac": "auto"` | Their art is the master: generated facings are drawn from it and checked against it, never restyled. ## The cast file One JSON file drives all three scripts. Top level (all optional): `facings` (default `["se", "ne"]`; 8 directions `["s", "se", "e", "ne", "n"]`; side-scroller `["e"]`; top-down `["s", "n", "e"]`), `camera` (`isometric`, `side`, `topdown`, or any camera sentence), `key` (`magenta`, `green` or `blue`), `cycles`, `fig_frac` (0.58, the same value as the `--fig` given to the frame script, or `auto` for clips made elsewhere), `style`. Per hero: `character` (text heroes), `who` and `pronoun` (clip prompts), `back` and `back_view` (what stays visible from behind), `extra` (walk and run detail), `walk_motion` and `run_motion` (the gait, two-legged by default: set them for a quadruped, a flyer or a slithering hero), `idle` (one secondary motion), `attack` (one named move, start to rest), `attack_body_only` (`true` when the attack throws something that detaches from the body), `notes` (what must survive a redraw, such as the weapon hand), `height` (sprite px), `palette` (colors), `pixel` (`false` for painted art), `ranges` (loop search override), `reference` and `first_frames` (asset ids, filled in as you go). Mirroring: SW, NW and W are flips of SE, NE and E, which halves the work and swaps sides. For an asymmetric character (sword hand, eyepatch, one pauldron), list the mirrored facings so they are generated, and say which hand in `notes`: the turnaround prompt then states where that hand sits on screen for each facing. ## Steps 1. `collection_create` (tool catalog) and add every kept output as it lands. Write the cast with the user. 2. Stills. [cycle_prompts.py](../scripts/cycle_prompts.py) writes `requests.json`: one pose sheet per text hero (every facing side by side, so identity matches) or one turnaround per missing facing, drawn from the `reference_images` it lists. Discover with `recommend`: the pose sheet is a layout-heavy text-to-image need, a turnaround is reference-image editing that must keep the design. For the pose sheet, take the top-ranked general model over `specialty.model_id` when the specialty is the canvas-locked pixel model: it cannot hold every facing side by side. Map each request's fields onto the pick's schema, `dry_run`, `model_run`, `jobs_wait` on the job ids (re-called with `job_ids` set to any returned `pending_job_ids`), then `asset_display` both candidates (and, for turnarounds, the user's art beside them) and let the user pick. Check the facing each pose claims; everything downstream inherits the still. 3. First frames. `asset_download` the pick and run [cycle_frames.py](../scripts/cycle_frames.py) (`--facings se,ne` to split a sheet, `--facing sw` for one figure, `--flip` only for a symmetric character). It centers the figure at 58% of a 960 px square with the feet at 82%: full-frame stills lose a raised weapon off the top of the clip. It reads transparency or a flat backdrop (backdrop color is removed only where it connects to the border, so white armor on white survives), scales small pixel art by a whole number, and prints the pixel art's native height and color count for `height` and `palette`, plus the `--fig` value that places the generated facings at the same size. A pose sheet is split at the empty columns between figures. Use one `--fig` for every facing, lowered when a long side view does not fit (0.52 held a four-legged dragon) or when the attack reaches out (a breath, a lunge, a long slash): whatever leaves the video frame is cut off with a straight edge, and `attack_body_only` only drops what detaches. Its **key check** reports how much of the figure each key color would erase; on a warning, re-run with the recommended key and set `key` before writing clip prompts, since the key color is written into every prompt. Upload each frame (`upload_asset`, then `upload_asset_complete`, per the `scenario` skill) and fill `first_frames`. 4. Clips. Re-run the prompt script. `recommend` with capability `img2video`, `features: ["endImage"]` and the need in the user's own words (anchored on a first frame and a last frame, locked camera, a character staying in place), then pick a member whose schema has an end-image field, a negative-prompt field, a square aspect and durations near 3 and 5 s, and that is not deprecated: a member without an end frame cannot bring idle and attack back to rest or hold the S, N and back-view clips, and one that crops a square first frame to its own aspect breaks registration. `recommend` prices members at their defaults (often longer, with audio on), so `dry_run` the lane's own payload on each such candidate before ruling one out on cost: at authoring time the top-ranked family's standard tier priced 24 CU for a 3 s clip against a 55 CU `recommend` figure, while cheaper members that lacked those fields zoomed, cropped or drifted the backdrop. Then `model_schema_get` and map: `first_frame` and `last_frame` onto the start and end image fields (a `null` last frame means leave that field out), `negative_prompt` where the schema has one (otherwise leave it out: the prompt already names the facing), `duration_s` onto the nearest allowed value, square aspect, audio off. `dry_run` one payload per duration and quote the batch total before running anything. Launch a few clips at a time: at authoring time a burst past the team's concurrent-job limit came back as failed jobs with an internal error (refunded), not as a rate-limit refusal. Run one hero's clips first and read a contact sheet of each before the rest (`ffmpeg -i clip.mp4 -vf "fps=4,scale=240:-1,tile=5x4" -frames:v 1 sheet.png`): a turn can start mid-clip, which one frame misses. 5. Finish. `asset_download` the clips as `<hero>_<facing>_<cycle>.mp4` and run [cycle_sheets.py](../scripts/cycle_sheets.py) `cast.json --clips clips --out sheets --gif`. It keys the field (and the ground shadow the model adds anyway), searches each loop's start and period for the tightest seam relative to the window's own motion, takes the active window for attacks, samples 8 frames, crops every cycle to one canvas (the union of every kept frame, so a thrown projectile would widen every cell: `attack_body_only` keeps the largest blob, and the projectile ships as its own sprite), downscales, and for pixel art builds one palette and redraws the outline the downscale averaged away. It reads each clip's own frame rate and pads non-square clips rather than stretching them. Seam scores (seam over motion baseline): under 0.5 is clean, 0.5 to 0.8 check the GIF, over 0.8 on walk or run usually means the stride outgrew the search window, so widen that hero's `ranges` before regenerating (one heavy character went from 1.08 to 0.29). Idle often scores near 1.0 on tiny motion; accept it after a look. A key warning means the backdrop drifted off the key color and stayed in the frame: regenerate that clip. A size warning means the figure walked toward or away from the camera during the clip: one shared scale would ship that row too big or too small, so regenerate that clip rather than widening its range. 6. Deliver. `upload_asset` each sheet and preview GIF and add them to the collection. `upload_asset` takes images, video, audio and 3D only, so `meta.json` ships beside the sheets rather than in the collection. `meta.json` carries what an engine needs: `cell` [w, h], `rows` (row order, SE, SW, NE, NW by default), `fps` per cycle and row (each facing loops at its own period; the preview GIF plays every row at the first facing's rate), and `pivot` [x, y] at the feet. ## Prompt rules (baked into the prompt script) - Treadmill wording ("starts walking in place and keeps walking for the whole clip, stays centered and does not travel across the frame"), or the character walks out of frame. - A facing sentence in every prompt, and a negative prompt that names the wrong angle for that facing: side and profile views for diagonals, front and three-quarter views for a side profile. - Last frame pinned for idle and attack, which return to rest; left free for short diagonal and side-view walk and run clips, where a pinned end made the model start and stop the gait inside the clip, leaving standing frames and drift. - Back-view runs (NE, NW, N) drifted into a side profile on every character while walks, idles and attacks held the angle. What held it: "runs away from the viewer toward the top-right corner", naming what stays visible, the last frame pinned, and the longer duration (5 s against 3 s), so the start and stop land at the ends and the loop comes from the middle. - Straight front and back walks and runs (S, N) turned to a three-quarter view mid-clip on a four-legged dragon, and a golem's S run walked toward the camera and grew 1.45 times in the frame. The same pinned end frame and 5 s held the dragon's S walk, S run and N walk; the S prompt also says the character never comes closer. One golem S run on a model with no negative-prompt field still failed to loop after that, so read the contact sheet of every S clip. - A flat key field and "no ground shadow". The model added a tinted shadow anyway; the key removes it. ## Fundamentals, for advice and hand-over Frames and speed: walk 8 at 7 to 10 fps, run 6 to 8 at 9 to 12, idle 4 to 8 at 4 to 6, attack 6 to 10 at 8 to 12 played once. Genre decides the set: action games need run and attack, tactics games move tile to tile and want idle, walk, attack, cast, hurt and death instead. Generated frames read well from about 64 px tall; at 32 to 48 px they are usable but want a touch-up pass. Keep every cell one size with the feet on one baseline, and display pixel art at whole-number zoom with nearest filtering. Engine import, numbers from `meta.json`. Unity: Sprite Mode Multiple, Filter Point (Bilinear for painted art), Compression None, Sprite Editor Slice Grid By Cell Size, custom pivot (x / w, 1 minus y / h). Godot 4: Canvas Textures default filter Nearest, Stretch Mode viewport with integer scale, Snap 2D Transforms to Pixel; AnimatedSprite2D, add frames from a sprite sheet (8 across, rows down), one animation per row, loop off for attack, `flip_h` for mirrored rows if you store only SE and NE. Phaser: `pixelArt: true`, `load.spritesheet` with the cell size, frames `row * 8` to `row * 8 + 7`. Engines mirror around the cell center, and these sheets are centered. -
lane-schemas.md 14.2 KB
# Per-lane schema notes Authoring-time observations from live catalog hits. Model availability differs per team and schemas change, so treat every name and number here as a starting point and confirm with `model_schema_get` before running. ## Purpose-trained animation models (pixel cycles and VFX) Retro Diffusion Animation: a `style` enum picks the animation type and locks the canvas (`four_angle_walking` and `walking_and_idle` 48px, `small_sprites` 32px, `vfx` 24 to 96px). A requested 64px walk cycle is a schema violation, not a prompt problem: generate at the locked size and upscale after on a pixel-preserving route per `scenario-game-assets`. `returnSpritesheet` flips the output from animated GIF to spritesheet PNG. Set an explicit `seed` on the preview run and repeat the identical payload with `returnSpritesheet` for the sheet, since a seedless preview cannot be re-run to match. Judge the preview from the downloaded file (`asset_download` with `format="gif"`): `asset_display` shows a still, and the png default flattens it to one frame. Never slice the GIF preview for engine frames: those come from the same-seed `returnSpritesheet` re-run, sliced on the grid you counted. Verify row order and facing before naming frames. The `image` reference is converted to RGB without transparency, so flatten a transparent sprite onto a plain field deliberately. Retro Diffusion Plus takes a reference palette image via `inputPalette` for palette-guided generation, which is the one palette control outside the pixel snapper's color count. The style enum is the whole motion vocabulary: given an attack brief and a character reference through `image`, `small_sprites` returned a 20-frame generic cycle (arms up, turn, walk, collapse) at 150 ms per frame with the held weapon gone, and `walking_and_idle` returned a 44-frame four-direction idle and walk set at 200 and 400 ms with outfit and prop kept. Both GIFs came back transparent. Scripted beats, an exact frame count or a weapon-in-hand action belong to the single-sheet lane below. ## Single sheet from a general image model At authoring time `recommend`, given a 16-frame 4x4 grid spec under capability txt2img, returned `ask_user` between the purpose-trained animation model, which it said does not honor an arbitrary grid or frame count, and a top-ranked general image model whose tradeoff read that the pixel look is emulated rather than native. The GPT Image family that pick belonged to exposed `width` and `height` in steps of 16 (pick a canvas the grid divides evenly, 2048 over 4 being 512 px cells), `numOutputs` for several candidate sheets in one run, `referenceImages` for an existing character, `quality`, `background` (`auto`, `opaque`, `transparent`, no color value) and no `seed`. Size, quality and sample count carry `cost_impact`, so `dry_run` the exact payload: the same 2048x2048 high-quality sheet priced 15 CU on the family's two newest members and 59 CU on the member before them, against a `recommend` quote of 12 CU taken at defaults. Those newest members, listed the day of the read, were absent from `recommend` and present in `search`, so a member the user names is reached by name. Confirm with `model_schema_get`. The live run that grounds the lane: two candidates from one `numOutputs: 2` call both came back as exact 4x4 grids on a near-white field (RGB 253; the snapper later flattened it to one value, 254, 253, 253, still not 255, so a brief that needs exact white has to accept near-white or be flagged) with one consistent character, one weapon per frame and no grid lines or labels. One candidate clipped its projectiles at two cell edges and the other kept every effect inside its cell, which is why the sheet is read before slicing. In both, frames 13 to 16 were one held idle pose, and the 16-to-1 seam measured 1.06 times the largest in-cycle step, above the window's baseline, while a 13-frame window (3 to 15) closed within it: ship the full count when the brief fixes it and the closing window beside it. Slicing 4x4 returned sixteen 512 px tiles in row-major order, each a pixel-exact copy of its cell. Position drift is the lane's jitter source. In that sheet the grounded feet sat at 487 px in the first row, 447 in the third and 400 in the fourth, and the body's horizontal center wandered 59 px (standard deviation 26 px), which reads as the character hopping about in the GIF. Two generation-side fixes failed on the same model: anchoring language ("an invisible ground line at the same height in every cell, feet on it in grounded frames") made the model draw the line itself, at a different height per row (462, 422, 402), and a 4x4 layout template with ground and center lines passed through `referenceImages` was honored for the grid and left out of the output as asked, yet the rows still landed at 486, 461 and 420. Alignment in post fixed it: take the median grounded-frame bottom per row as that row's baseline (an all-airborne row borrows its neighbor's, so jump height survives), shift every tile of the row by the difference to one common baseline, center each tile's body box horizontally (find the body as the widest run of columns whose occupancy clears a few percent of the cell height, which skips thin projectiles), and do it on a wider canvas (640x512 for 512 px tiles) so shifted projectiles do not clip. Center spread fell to 0.3 px and grounded-baseline spread to 7 px. Re-lay the aligned tiles as a sheet, `upload_asset` it, slice on the platform and assemble; the assembler scaled those 640x512 frames to 512x408, so expect a 512 px wide GIF from wider inputs. ## Alpha and background removal Native transparency is an image-lane property. On the still, prefer a model whose schema carries a background option and read it (`recommend` with the transparent-background need in the user's own words): at authoring time the GPT Image family exposed `background` with `auto`, `opaque`, and `transparent`, the transparent value marked Preview, and the same discovery also surfaced a dedicated transparent generator. Confirm with `model_schema_get` and check one frame before committing a batch. A sheet generated with `background: transparent` would be the one route where alpha reaches engine frames without a removal pass, if the slicer keeps it, which was not verified at authoring time: read one tile's alpha before slicing the rest. The purpose-trained animation model's GIFs came back transparent; the `returnSpritesheet` PNG that engine frames are sliced from was not read for alpha at authoring time, so check one sheet before skipping the removal pass on that lane. No video lane observed at authoring time emitted alpha: every image-to-video and text-to-video schema read exposed no transparency output, so on a video-derived sprite the removal pass is the route rather than a fallback. Budget it from the start instead of hunting for a generator that skips it, and confirm with `model_schema_get`. Two schema features read like alpha and are not. A `background` parameter can composite a backdrop in rather than cut one out: on one avatar model it takes `color`, `image`, or `video`. A `mov` container choice is about color fidelity unless the schema says otherwise, since a `mov` carries alpha only with an alpha-capable profile such as ProRes 4444. Extract first, then run an image background remover on each frame: per-frame cutouts came back cleaner than the video-level pass in live runs. The dedicated removers default to alpha out but spell it differently (`preserveAlpha`, a `backgroundType` of `rgba`, or no control at all), so read the schema rather than assuming. Discover them by tag (`search` with `target: "models"`, `filters` `tags: ["remove-background"]`, and `public=true`) and read each hit's schema, because that tag also returns the video removers and a background replacer, which swaps a backdrop instead of removing it. A remover's HD mode silently no-ops below its input floor, so upscale past it for better mattes on glows. Removing at video level before extraction is the one-run alternative, and the container is the whole decision. One video remover's schema states it outright: "Transparency (alpha): VP9 (.webm) and ProRes 4444 (.mov). MP4 has no alpha. GIF: limited transparency only." Set the output format explicitly, since one remover's description invites leaving it empty while its schema declares a default and lists no empty value, and copy the token from `allowed_values` rather than from the label, which read ProRes 4444 for a value spelled `mov_proresks`. Whichever route, verify alpha on one downloaded frame before batching. A transparent request can come back stored without it: one run asked for VP9 and the delivered asset carried no alpha, the subject flattened onto black. The extractor's output format is not in its schema either, so check one extracted frame, which is why the frame check in step 4 is not optional. Assembly flattens what is left. Both runs that assembled transparent frames got an opaque preview back, so treat that as expected rather than a defect to chase: the frames and the sheet carry the alpha, and the preview is for motion review. ## Video lanes The live hits were keyframe pinning on a 24fps grid (FLUX.3 Keyframes), start plus end frames with an fps enum (LTX), and a style preset with a seed (PixVerse). Turn prompt rewriting off (expansion and optimizer toggles) when exact wording carries the character or motion script. ## Reading a deprecation tag A deprecation tag names a replacement (`deprecated:model_x`) or stands bare, and the pointer can cross capabilities, so prefer the replacement only when its schema still covers the need. ## Packaging pixel frames Snap each frame individually with the same color count and seed, never the assembled sheet: the snapper collapses a sheet to one global grid and loses per-frame detail. The snapper is priced per tile (5 CU at authoring time, so a 16-frame set is 80 CU): `dry_run` one tile and multiply before committing the set. Skip the pass altogether when the frames already sit on the art's native grid. The snapper re-detects a grid of its own, and on frames that were already pixel-native it regridded 384x288 down to 69x52 and took the scene with it. Probe one frame before running the set. On emulated pixel art it is the right move: a 512 px tile from a general image model came back 104 px at 32 colors with the character intact. Where snapping does apply, the snapped frames land on their own native grids a few pixels apart and need rescaling to one common size before assembly, since padding without rescaling leaves the character pulsing in size across frames. Scenario Resize Image (`model_scenario-resize-image`) takes `images` (an array, up to 10 per call, so a 16-frame set is two calls; a bare value is ignored), `width`, `height`, and `fit`, and exposes no interpolation control, so check one rescaled frame rather than assuming it snapped cleanly. ## Assembler timing GIF frame delays quantize to 10ms, so any fps that is not a divisor of 100 is silently re-timed: a 12fps request lands at 100ms per frame, which is 10fps. Build the engine deliverable as mp4, which holds exact timing, and treat the GIF as a preview. Count the frames that came back, because the assembler trades frame accuracy to hold total duration and does it in both directions: one run returned 7 frames from 8 inputs, another returned 20 from 16, duplicating frames and re-timing an 8fps request to 100ms each. The same 16 inputs at 10 fps came back as 16 frames at 100 ms, one to one, so an fps on the 10 ms grid is what keeps the mapping. ## An aspect ratio the model ignored A video model can ignore both `aspectRatio` and a resolution enum and snap to its own latent grid: a 4:3 720p request came back 1088x800, which is neither. Probing the delivered file is what catches it. The geometric fix is a resize: Scenario Resize Video (`model_scenario-resize-video`) for clips and Scenario Resize Image (`model_scenario-resize-image`) for extracted frames take the same `width`, `height`, and `fit` (their input fields differ, so read each schema; fixed first-party ids: each is Scenario's single exact-dimension resize tool for its asset type). `fit: "cover"` scales the picture to fill the box and center-crops the overflow, so 1088x800 lands on an exact 384x288 with nothing squashed; `contain` (the default) fits inside the box without cropping, so the output can come back smaller than the box, and `stretch` forces the box size at the cost of geometry. Confirm with `model_schema_get`. There is no crop offset: the crop is centered, so a subject off to one side may need the aspect picked at generation time instead. Scenario Padding Remover trims uniform borders from images only. The reframe models recompose generatively instead of cropping, so they break frame-to-frame consistency and do not belong inside a sequence. So `cover` is the answer for an exact ratio. The fallbacks, for when the crop would cut into the subject, are `stretch` with the distortion recorded (1088 against the 1066.67 that exact 4:3 wants is 2.0%) or `contain`, which keeps the whole picture but does not land the ratio (no bars are added; the output is simply smaller than the box). Either way, confirm the delivered canvas before any downstream work: every stride and seam number depends on it. ## Sourcing the still for a scene loop The loop lanes assume a still already exists. Where it does not, treat it as a separate job with its own budget: it anchors every frame that follows, and no amount of animation work rescues a still that misses the brief. Discover a pixel-art image model (`recommend` with the pixel-art still need in the user's own words) and read its ceiling before planning a canvas. At authoring time Retro Diffusion Plus capped `width` and `height` at 384, so the largest exact 4:3 it delivers is 384x288, and `removeBg` defaults to false. Confirm with `model_schema_get`. Check subject-critical detail (counts, poses, who is holding what) against the brief before animating. These models are weak at exact counts: a five-child campfire came back as four across three prompting strategies (a count word, an explicit seating layout, then five children enumerated by shirt color), and the platform's own auto-caption confirmed the miss independently. Where a count or composition has to be exact, iterate the still per `scenario-refine-loop` and keep those attempts on their own budget line rather than spending the animation allowance on them.
-
-
scripts
-
build_grid_templates.py 2.5 KB
#!/usr/bin/env python3 """Build sprite layout guides from the adjacent JSON manifest; requires Pillow.""" import argparse import hashlib import json from pathlib import Path from PIL import Image, ImageDraw MANIFEST = Path(__file__).resolve().parent.parent / "grid-templates.json" def render(template, role): width, height = template["canvas"] cell_width, cell_height = template["cell_size"] image = Image.new("RGB", (width, height), "white") draw = ImageDraw.Draw(image) for x in range(0, width, cell_width): draw.line((x, 0, x, height - 1), fill="#6b7280") for y in range(0, height, cell_height): draw.line((0, y, width - 1, y), fill="#6b7280") draw.rectangle((0, 0, width - 1, height - 1), outline="#6b7280") if role == "alignment-guide": guide = template["cell_guides"] inset = guide["inset"] for cell in template["cells"]: x, y, w, h = cell["box"] right, bottom = x + w - inset - 1, y + h - inset - 1 draw.rectangle((x + inset, y + inset, right, bottom), outline="#d1d5db") baseline = y + guide["baseline_y"] draw.line((x + inset, baseline, right, baseline), fill="#2563eb", width=2) center = x + guide["center_x"] for dy in range(inset, h - inset, 12): draw.line((center, y + dy, center, min(y + dy + 5, bottom)), fill="#9ca3af") elif role != "grid": raise ValueError(f"Unknown role: {role}") return image def build(destination, template_name=None): manifest = json.loads(MANIFEST.read_text()) selected = [t for t in manifest["templates"] if template_name is None or t["name"] == template_name] if not selected: raise ValueError(f"Unknown template: {template_name}") destination.mkdir(parents=True, exist_ok=True) for template in selected: for role, entry in template["files"].items(): entry.pop("asset_id", None) path = destination / entry["file"] render(template, role).save(path) entry["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() manifest["templates"] = selected (destination / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") return manifest if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("output", type=Path) parser.add_argument("--template", help="One template name from grid-templates.json; default: all") args = parser.parse_args() build(args.output, args.template) -
cycle_frames.py 10.5 KB
"""Make square first frames for image-to-video from character art. The figure is scaled to --fig of the frame height with its feet on --base, centered on a flat key-color field. The headroom keeps a raised weapon inside the clip. Input is either a generated pose sheet (split at the empty columns between figures, one per facing) or the user's own art: one figure on a transparent or flat backdrop. Small pixel art is scaled by a whole number with nearest-neighbor so its pixels stay crisp. Prints a key check: how much of the figure each key color would erase when the clips are keyed. Usage: python3 cycle_frames.py <hero> <image> [--facings se,ne] # split a pose sheet python3 cycle_frames.py <hero> <image> --facing se [--flip] # one figure options: --out frames --canvas 960 --fig 0.58 --base 0.82 --key magenta|green|blue --nearest|--smooth Writes <out>/<hero>_<facing>_first.png """ import argparse from pathlib import Path import numpy as np from PIL import Image KEY_RGB = {"magenta": (255, 0, 255), "green": (0, 255, 0), "blue": (0, 0, 255)} def keyed(rgb): """Per key color, the pixels cycle_sheets.py would erase (same thresholds).""" f = rgb.astype(np.float32) / 255 r, g, b = f[..., 0], f[..., 1], f[..., 2] sat = (f.max(-1) - f.min(-1)) / (f.max(-1) + 1e-6) return {"magenta": ((np.minimum(r, b) - g) > 0.18) & (sat > 0.28) & (np.abs(r - b) < 0.45), "green": ((g - np.maximum(r, b)) > 0.18) & (sat > 0.28), "blue": ((b - np.maximum(r, g)) > 0.18) & (sat > 0.28)} def components(mask): """4-connected components of a boolean mask, as (ys, xs) index arrays. Pure Python on the masked pixels only, so there is no optional dependency to fall back from.""" seen = np.zeros_like(mask) h, w = mask.shape for y0, x0 in zip(*np.nonzero(mask)): if seen[y0, x0]: continue seen[y0, x0] = True stack, ys, xs = [(y0, x0)], [], [] while stack: y, x = stack.pop() ys.append(y) xs.append(x) for ny, nx in ((y - 1, x), (y + 1, x), (y, x - 1), (y, x + 1)): if 0 <= ny < h and 0 <= nx < w and mask[ny, nx] and not seen[ny, nx]: seen[ny, nx] = True stack.append((ny, nx)) yield np.array(ys), np.array(xs) def foreground(im): """(figure mask, backdrop-colored mask or None). Alpha when the art is a cut-out; otherwise everything that differs from the flat color around the border.""" rgba = np.array(im.convert("RGBA")).astype(int) if (rgba[..., 3] < 128).mean() > 0.02: return rgba[..., 3] >= 128, None rgb = rgba[..., :3] border = np.concatenate([rgb[0], rgb[-1], rgb[:, 0], rgb[:, -1]]) bg = np.median(border, 0) spread = np.abs(border - bg).max(1) tol = max(40, int(np.percentile(spread, 90)) + 25) # gradients and JPEG noise in the backdrop diff = np.abs(rgb - bg).max(-1) near = diff <= tol # only backdrop color connected to the border is background, so white armor on white survives region = np.zeros_like(near) region[[0, -1], :] = near[[0, -1], :] region[:, [0, -1]] |= near[:, [0, -1]] while True: grown = region.copy() grown[1:] |= region[:-1] grown[:-1] |= region[1:] grown[:, 1:] |= region[:, :-1] grown[:, :-1] |= region[:, 1:] grown &= near if (grown == region).all(): break region = grown fg = ~region # backdrop trapped inside the silhouette (bow and string, arm and body): flat patches of # the backdrop color are background too. A neutral backdrop (white, gray) gets a tight # tolerance so off-white clothing survives; a saturated generated field gets a looser one saturated = (bg.max() - bg.min()) > 128 trapped = fg & (diff <= (12 if saturated else 4)) min_size = 0.002 * fg.sum() for comp in components(trapped): if len(comp[0]) > min_size: fg[comp] = False return fg, near def key_check(im, fg, near): """Share of the figure's own colors each key color would erase.""" core = fg.copy() cut_out = near is None # art cut from a flat backdrop has a rim of backdrop-tinted pixels that keys out anyway for _ in range(0 if cut_out else max(2, im.height // 200)): core[1:-1, 1:-1] &= core[:-2, 1:-1] & core[2:, 1:-1] & core[1:-1, :-2] & core[1:-1, 2:] if near is not None: core &= ~near if not core.any(): return {k: 0.0 for k in KEY_RGB} return {k: float(v[core].mean()) for k, v in keyed(np.array(im.convert("RGB"))).items()} def place(fig, mask, canvas, fig_frac, base_frac, key, nearest): """Scale one figure onto a square key-color canvas. Returns (image, scale note).""" fh, base = int(canvas * fig_frac), int(canvas * base_frac) if nearest: # whole-number scale keeps every source pixel a crisp block; the figure then lands near, # not on, fig_frac, so cycle_sheets.py should measure it ("fig_frac": "auto") k = max(1, round(fh / fig.height)) while k > 1 and (fig.height * k > base or fig.width * k > canvas): k -= 1 fig, mask = (x.resize((fig.width * k, fig.height * k), Image.NEAREST) for x in (fig, mask)) note = f"x{k} nearest" else: size = (round(fig.width * fh / fig.height), fh) fig, mask = fig.resize(size, Image.LANCZOS), mask.resize(size, Image.LANCZOS) note = "smooth" if fig.width > canvas: raise ValueError(f"figure is wider than the canvas at fig {fig_frac}; lower --fig, and use the same " "value for every facing so the rows keep one scale") out = Image.new("RGB", (canvas, canvas), KEY_RGB[key]) out.paste(fig.convert("RGB"), ((canvas - fig.width) // 2, base - fig.height), mask) return out, note def figure_spans(fg, n): """Column spans of the n figures on a pose sheet, split at the empty columns between them. Equal columns cut a long side view or a wide front view in two. Specks (a spark, a loose flame) join their nearest figure; if the figures touch, equal columns are the fallback.""" w = fg.shape[1] mass = fg.sum(0) occupied = mass >= max(3, 0.002 * fg.shape[0]) runs, x = [], 0 while x < w: if occupied[x]: x0 = x while x < w and occupied[x]: x += 1 runs.append([x0, x, int(mass[x0:x].sum())]) x += 1 total = sum(r[2] for r in runs) or 1 def merge(i): # merge run i into its nearer neighbor left = runs[i][0] - runs[i - 1][1] if i > 0 else None right = runs[i + 1][0] - runs[i][1] if i + 1 < len(runs) else None j = i - 1 if right is None or (left is not None and left <= right) else i + 1 a, b = sorted((i, j)) runs[a:b + 1] = [[runs[a][0], runs[b][1], runs[a][2] + runs[b][2]]] while len(runs) > 1 and min(r[2] for r in runs) < 0.02 * total: merge(min(range(len(runs)), key=lambda i: runs[i][2])) while len(runs) > n: gaps = [runs[i + 1][0] - runs[i][1] for i in range(len(runs) - 1)] merge(gaps.index(min(gaps))) if len(runs) < n: print(f"WARNING: found {len(runs)} separate figures for {n} facings; splitting into equal columns, " "check every first frame (or regenerate the sheet with more space between poses)") return [(w * i // n, w * (i + 1) // n) for i in range(n)] return [(r[0], r[1]) for r in runs] def first_frames(im, facings, canvas=960, fig_frac=0.58, base_frac=0.82, key="magenta", nearest=None, flip=False): """{facing: first-frame image} plus the key check, from one pose sheet or one figure.""" if flip: im = im.transpose(Image.FLIP_LEFT_RIGHT) rgba = im.convert("RGBA") fg, near = foreground(im) hits = key_check(rgba, fg, near) w = im.width out, notes = {}, {} spans = figure_spans(fg, len(facings)) if len(facings) > 1 else [(0, w)] for f, (x0, x1) in zip(facings, spans): ys, xs = np.where(fg[:, x0:x1]) if not len(ys): raise ValueError(f"{f}: no figure found in columns {x0}-{x1}") box = (xs.min() + x0, ys.min(), xs.max() + x0 + 1, ys.max() + 1) fig = rgba.crop(box) mask = Image.fromarray((fg[box[1]:box[3], box[0]:box[2]] * 255).astype("uint8")) use_nearest = nearest if nearest is not None else fig.height < 256 out[f], notes[f] = place(fig, mask, canvas, fig_frac, base_frac, key, use_nearest) if use_nearest: colors = len(np.unique(np.array(fig.convert("RGB"))[np.array(mask) > 127], axis=0)) placed = np.where((np.array(out[f]) != KEY_RGB[key]).any(-1).any(1))[0] notes[f] += (f", native height {fig.height} px, {colors} colors; place generated facings with " f"--fig {(placed.max() - placed.min() + 1) / canvas:.4f} so every row matches") return out, notes, hits def main(): ap = argparse.ArgumentParser() ap.add_argument("hero") ap.add_argument("src") ap.add_argument("--facings", default="se,ne", help="pose order, left to right, when splitting a sheet") ap.add_argument("--facing", help="the image holds one figure with this facing") ap.add_argument("--flip", action="store_true", help="mirror first; symmetric characters only") ap.add_argument("--out", default="frames") ap.add_argument("--canvas", type=int, default=960) ap.add_argument("--fig", type=float, default=0.58) ap.add_argument("--base", type=float, default=0.82) ap.add_argument("--key", default="magenta", choices=list(KEY_RGB)) g = ap.add_mutually_exclusive_group() g.add_argument("--nearest", action="store_true") g.add_argument("--smooth", action="store_true") a = ap.parse_args() facings = [a.facing] if a.facing else a.facings.split(",") nearest = True if a.nearest else False if a.smooth else None frames, notes, hits = first_frames(Image.open(a.src), facings, a.canvas, a.fig, a.base, a.key, nearest, a.flip) print("key check, share of the figure each key would erase:", ", ".join(f"{k} {v:.1%}" for k, v in hits.items())) if hits[a.key] > 0.01: best = min(hits, key=hits.get) print(f"WARNING: --key {a.key} would erase {hits[a.key]:.1%} of the character; " f"re-run with --key {best} and set \"key\": \"{best}\" in the cast file") Path(a.out).mkdir(parents=True, exist_ok=True) for f, im in frames.items(): p = Path(a.out) / f"{a.hero}_{f}_first.png" im.save(p) print(p, notes[f]) if __name__ == "__main__": main() -
cycle_prompts.py 15.3 KB
"""Write every generation request for a cast of sprite characters: the stills each hero still needs and one image-to-video clip per hero x facing x cycle. Usage: python3 cycle_prompts.py cast.json [-o requests.json] Model-agnostic on purpose: each request carries the prompt, negative prompt, anchor frames and duration; the agent maps them onto the fields of the member it discovered (read with model_schema_get) and prices the payload with dry_run. Stills: - hero described in text, no art: one pose sheet with every facing side by side, so identity matches between facings; - hero with a first frame for some facings, or a "reference" image (their own art): one turnaround per missing facing, drawn from that art; - hero with every facing: nothing. Missing asset ids become "<upload ...>" placeholders; fill the cast and re-run. """ import argparse import json from pathlib import Path KEY = {"magenta": "magenta (#FF00FF)", "green": "green (#00FF00)", "blue": "blue (#0000FF)"} MIRROR = {"se": "sw", "ne": "nw", "e": "w"} # facings the sheet gets by flipping, unless listed explicitly BACK = {"ne", "nw", "n"} # back views: need the away-from-viewer run recipe CAMERA = { "isometric": "classic isometric RPG view, seen from about 30 degrees above", "topdown": "top-down RPG view, seen from about 45 degrees above", "side": "straight side-on view, camera at the character's height, like a 2D platformer", } STILL_SE_NE = ("{medium} game sprite sheet, two poses of the same character side by side, standing neutral, full body, " "on a perfectly flat solid {key} background, no shadows on the ground, no text.\n\n" "Character: {character}\n\n" "Camera: {camera}.\n" "Left pose: body turned three-quarters toward the viewer, facing down and to the right (south-east).\n" "Right pose: body turned three-quarters away from the viewer, facing up and to the right (north-east), {back}.\n\n" "Style: {style}, each figure about 60% of the image height, both figures the same size and aligned on the " "same baseline, generous empty space around each.") STILL_MULTI = ("{medium} game sprite sheet, {lead}, standing neutral, " "full body, on a perfectly flat solid {key} background, no shadows on the ground, no text.\n\n" "Character: {character}\n\nCamera: {camera}.\n{poses}\n\n" "Style: {style}, each figure about 60% of the image height, all figures the same size and aligned on the " "same baseline, generous empty space around each.") TURNAROUND = ("Redraw the exact same character as in the reference image: same design, proportions, colors, outfit, " "props and art style (if it is pixel art, keep the same pixel size, outline and palette). One single " "full-body figure standing neutral, {pose}. Camera: {camera}.{about} The figure is centered, about 60% of " "the image height, on a perfectly flat solid {key} background, no ground shadow, no text, nothing else.") # where a character's right hand appears on screen per facing (for "notes" such as weapon hand): the right # hand points 90 degrees clockwise from the facing seen from above; screen-down is toward the viewer RIGHT_SIDE = {"se": "on the left side of the image, nearer to us", "sw": "on the left side of the image, farther from us", "ne": "on the right side of the image, nearer to us", "nw": "on the right side of the image, farther from us", "e": "nearer to us", "w": "farther from us", "s": "on the left side of the image", "n": "on the right side of the image"} POSE = { "se": "body turned three-quarters toward the viewer, facing down and to the right (south-east)", "sw": "body turned three-quarters toward the viewer, facing down and to the left (south-west)", "ne": "body turned three-quarters away from the viewer, facing up and to the right (north-east), {back}", "nw": "body turned three-quarters away from the viewer, facing up and to the left (north-west), {back}", "e": "full side profile facing right", "w": "full side profile facing left", "s": "facing straight toward the viewer", "n": "facing straight away from the viewer, {back}", } FACING = { "se": "{he} always faces diagonally toward the bottom-right corner of the frame (isometric three-quarter front view from above, the same angle as the first frame), never turning to a side view.", "sw": "{he} always faces diagonally toward the bottom-left corner of the frame (isometric three-quarter front view from above, the same angle as the first frame), never turning to a side view.", "ne": "{he} always faces diagonally toward the top-right corner of the frame, seen three-quarters from behind (isometric view from above, the same angle as the first frame), never turning around.", "nw": "{he} always faces diagonally toward the top-left corner of the frame, seen three-quarters from behind (isometric view from above, the same angle as the first frame), never turning around.", "e": "{he} always faces right in a flat side-on profile, the same angle as the first frame, never turning toward the camera.", "w": "{he} always faces left in a flat side-on profile, the same angle as the first frame, never turning toward the camera.", "s": "{he} always faces straight toward the viewer, the same angle as the first frame, never turning to a side view.", "n": "{he} always faces straight away from the viewer, seen from behind, the same angle as the first frame, never turning around.", } CYCLE = { "walk": "{medium} game sprite animation, like a treadmill: the {who} immediately starts walking in place and keeps walking for the whole clip, a steady repeating walk cycle at a relaxed pace ({walk_motion}{extra}).", "run": "{medium} game sprite animation, like a treadmill: the {who} immediately starts running in place and keeps running for the whole clip, a steady repeating run cycle ({run_motion}{extra}).", "idle": "{medium} game sprite animation: the {who} stands idle in place, a subtle breathing loop: chest rises and falls, a slight weight shift, {idle}. Feet stay planted. Calm, small motion.", "attack": "{medium} game sprite animation: the {who} performs one {attack}, then returns to the starting ready pose. Snappy anticipation, fast strike, short follow-through.", } TAIL = " {he} stays centered and does not travel across the frame. Locked static camera, flat solid {keyname} background, no ground shadow, {crisp}, same character design as the first frame." # what "wrong angle" means depends on the facing: a profile is the goal for E/W, the failure for diagonals WRONG_ANGLE = {"e": "front view, back view, three-quarter view, facing the camera, turning around", "s": "side view, profile view, back view, turning around", "n": "side view, profile view, facing the camera, turning around"} WRONG_ANGLE["w"] = WRONG_ANGLE["e"] NEG_REST = "camera movement, zoom, travelling across the frame, ground shadow, drop shadow, background change, extra characters, blur, 3D render" NEG_LOCO = ", slowing down, stopping, standing still" # Back-view run: a fast run from a back three-quarter frame drifts into a side-on # profile. Describing it as running *away* from the viewer, naming what stays # visible, pinning the end frame and giving it 5 s holds the angle; the loop # search then takes the middle of the clip. AWAY = {"ne": ("toward the top-right corner of the screen", "{poss} left shoulder is closer to us than {poss} right"), "nw": ("toward the top-left corner of the screen", "{poss} right shoulder is closer to us than {poss} left"), "n": ("straight up the screen", "both shoulders stay level")} BACK_RUN = ("{medium} game sprite animation seen from behind. The {who} runs away from the viewer, {toward}, on a " "treadmill so {it} stays in place. We keep seeing {back_view} at the same {angle} as the first frame for " "the whole clip: {shoulders}. Steady repeating run cycle ({run_motion}{extra}). Locked static camera, flat solid {keyname} background, no ground " "shadow, {crisp}.") BACK_RUN_NEG = ("side view, profile view, facing right, facing the camera, turning, rotating, camera movement, zoom, " "travelling across the frame, ground shadow, background change, blur, 3D render, slowing down, stopping") # Straight front and back locomotion (S, N) turned to a three-quarter view mid-clip, or walked # toward the camera and grew in the frame, on a short free-ended clip. Pinning the end frame on a # 5 s clip held the angle on the walks and runs it was tried on; the loop search takes the middle. STRAIGHT = {"s", "n"} TOWARD = (" {he} treads in place facing the viewer and never comes closer: {it} stays the same size in the frame" " for the whole clip.") TOWARD_NEG = ", walking toward the camera, coming closer, growing larger" # default gait wording is two-legged; a quadruped, a flyer or a slithering hero overrides it in the cast WALK_MOTION = "heel-to-toe steps, arms swing opposite the legs, slight up-and-down bob" RUN_MOTION = "knees high, arms pumping, torso leaning forward, both feet leave the ground between steps" POSS = {"he": "his", "she": "her", "it": "its", "they": "their"} def words(h, hid, keyname): d = dict(h) d.setdefault("who", "character") d["he"] = h.get("pronoun", "It") d["it"] = d["he"].lower() d["poss"] = POSS.get(d["it"], "its") d.setdefault("extra", "") d.setdefault("walk_motion", WALK_MOTION) d.setdefault("run_motion", RUN_MOTION) d.setdefault("idle", "a gentle sway") d.setdefault("attack", f"quick strike with {d['poss']} weapon, or a punch if {d['it']} has none") d.setdefault("back", "we see the back") d.setdefault("back_view", f"{d['poss']} back") d["keyname"] = keyname # painted / HD heroes ("pixel": false) must not be pushed toward pixel art by the prompts pixel = h.get("pixel", True) d["medium"] = "Pixel art" if pixel else "2D" d["crisp"] = "crisp pixel art" if pixel else "the exact art style, detail and sharpness of the first frame" return d def clip_request(hid, d, facing, cy, first): """One clip. Back-view runs, and walks and runs facing straight toward or away from the viewer, pin both ends and run longer; loops that return to rest pin the last frame; other walks and runs leave it free.""" if facing in BACK and cy == "run": toward, shoulders = AWAY[facing] prompt = BACK_RUN.format(toward=toward, shoulders=shoulders.format(**d), angle="angle" if facing == "n" else "diagonal angle", **d) return {"id": f"{hid}_{facing}_{cy}", "prompt": prompt, "negative_prompt": BACK_RUN_NEG, "first_frame": first, "last_frame": first, "duration_s": 5, "aspect_ratio": "1:1", "audio": False} neg = WRONG_ANGLE.get(facing, "side view, profile view, turning around") + ", " + NEG_REST loco = cy in ("walk", "run") straight = loco and facing in STRAIGHT prompt = CYCLE[cy] + " " + FACING[facing] if loco: neg += NEG_LOCO if straight and facing == "s": prompt += TOWARD neg += TOWARD_NEG return {"id": f"{hid}_{facing}_{cy}", "prompt": (prompt + TAIL).format(**d), "negative_prompt": neg, "first_frame": first, "last_frame": None if loco and not straight else first, "duration_s": 5 if straight else 3, "aspect_ratio": "1:1", "audio": False} def build(cast): style = cast.get("style", "crisp hand-placed pixel art, 1px dark outline, limited palette of about 24 colors") facings = cast.get("facings", ["se", "ne"]) camera = CAMERA.get(cast.get("camera", "isometric"), cast.get("camera")) keyname = cast.get("key", "magenta") key = KEY[keyname] out = {"stills": [], "clips": [], "notes": []} for hid, h in cast["heroes"].items(): d = words(h, hid, keyname) firsts = h.get("first_frames", {}) missing = [f for f in facings if f not in firsts] reference = h.get("reference") if missing and not firsts and not reference: if "character" not in h: raise ValueError(f"{hid}: needs a 'character' description, a 'reference' image or a first frame") if facings == ["se", "ne"]: prompt = STILL_SE_NE.format(key=key, camera=camera, style=style, **d) elif len(facings) == 1: prompt = STILL_MULTI.format(lead="one pose of the character", key=key, camera=camera, style=style, poses="Pose: " + POSE[facings[0]].format(**d) + ".", **d) else: poses = "Poses from left to right:\n" + "\n".join( f"{i + 1}. {POSE[f].format(**d)}" for i, f in enumerate(facings)) prompt = STILL_MULTI.format(lead=f"{len(facings)} poses of the same character side by side in one row", key=key, camera=camera, style=style, poses=poses, **d) wide = len(facings) > 1 out["stills"].append({"id": f"{hid}_sheet", "kind": "pose-sheet", "split": facings, "prompt": prompt, "reference_images": [], "width": 512 * (len(facings) + 1) if wide else 1024, "height": 1024, "candidates": 2}) elif missing: ref = reference or firsts.get("se") or next(iter(firsts.values())) for f in missing: about = f" The character: {h['character']}" if h.get("character") else "" if h.get("notes"): about += (f" Important: {h['notes']} Do not mirror the character: in this pose " f"{d['poss']} right hand is {RIGHT_SIDE[f]}.") out["stills"].append({"id": f"{hid}_{f}_turnaround", "kind": "turnaround", "facing": f, "prompt": TURNAROUND.format(pose=POSE[f].format(**d), camera=camera, key=key, about=about), "reference_images": [ref], "width": 1024, "height": 1024, "candidates": 2}) for f in facings: for cy in cast.get("cycles", list(CYCLE)): if cy not in CYCLE: raise ValueError(f"no prompt template for cycle '{cy}': add one to CYCLE") out["clips"].append(clip_request(hid, d, f, cy, firsts.get(f, f"<upload {hid}_{f}_first.png>"))) for f in facings: if f in MIRROR and MIRROR[f] not in facings: out["notes"].append(f"{MIRROR[f].upper()} is a mirror of {f.upper()}; for an asymmetric character " f"(weapon hand, eyepatch) add '{MIRROR[f]}' to facings to generate it instead") return out def main(): ap = argparse.ArgumentParser() ap.add_argument("cast") ap.add_argument("-o", "--out", default="requests.json") a = ap.parse_args() out = build(json.loads(Path(a.cast).read_text())) Path(a.out).write_text(json.dumps(out, indent=1)) long = sum(c["duration_s"] == 5 for c in out["clips"]) print(f"{len(out['stills'])} stills, {len(out['clips'])} clips ({len(out['clips']) - long} short, {long} long) " f"-> {a.out}; price the exact payloads with dry_run before running") for n in out["notes"]: print("note:", n) if __name__ == "__main__": main() -
cycle_sheets.py 18.4 KB
"""Turn image-to-video clips into looping sprite sheets, one character at a time. For every clip: key the flat field (magenta, green or blue) to alpha, find the tightest closing loop window (walk, run, idle) or the active strike window (attack), sample N frames, crop all of a character's cycles to one shared canvas so they register, and downscale to the target height. Pixel-art heroes get one palette across every cycle and a redrawn 1 px outline; heroes with "pixel": false keep a clean downscale. Mirrored facings (SW, NW, W) are flips. Usage: python3 cycle_sheets.py cast.json [hero ...] [--clips clips] [--out sheets] [--frames-dir DIR] [--gif] Clips are named <hero>_<facing>_<cycle>.mp4. Writes <out>/<hero>_<cycle>.png (one row per facing and mirror, N columns) and merges per-hero data (cell, pivot, rows, fps per row, loop window and seam score per clip) into <out>/meta.json. A hero with "attack_body_only": true keeps only the largest blob in its attack frames, so a thrown projectile does not widen the shared canvas. "fig_frac": "auto" in the cast measures the figure height from the clips. """ import argparse, json, subprocess from collections import deque from pathlib import Path import numpy as np from PIL import Image CANVAS = 960 FRAMES = 8 RANGES = {"walk": (12, 34), "run": (8, 24), "idle": (24, 66)} # loop period search, in 24 fps frames RANGES_BY_HERO = {} # heavy heroes stride slower: a longer cycle needs a longer window KEY = "magenta" MIRROR = {"se": "sw", "ne": "nw", "e": "w"} def read_frames(path): """Frames as a square CANVAS stack (non-square clips are padded with the key color, not stretched) and the clip fps.""" raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(path), "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True, check=True).stdout probe = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,r_frame_rate", "-of", "csv=p=0", str(path)], capture_output=True, text=True, check=True).stdout.strip().split(",") w, h = int(probe[0]), int(probe[1]) num, _, den = probe[2].partition("/") fps = float(num) / float(den or 1) arr = np.frombuffer(raw, np.uint8).reshape(-1, h, w, 3) if w != h: n = max(w, h) sq = np.empty((len(arr), n, n, 3), np.uint8) sq[:] = {"magenta": (255, 0, 255), "green": (0, 255, 0), "blue": (0, 0, 255)}[KEY] y0, x0 = n - h if h < n else 0, (n - w) // 2 # keep the feet on the bottom edge sq[:, y0:y0 + h, x0:x0 + w] = arr arr, w, h = sq, n, n if (w, h) != (CANVAS, CANVAS): arr = np.stack([np.array(Image.fromarray(f).resize((CANVAS, CANVAS), Image.LANCZOS)) for f in arr]) return arr, fps def key(rgb): """Alpha from the key-color field, including the ground shadow video models add (purple on magenta) and the haze around the figure, then despill.""" f = rgb.astype(np.float32) / 255 r, g, b = f[..., 0], f[..., 1], f[..., 2] mx, mn = f.max(-1), f.min(-1) sat = (mx - mn) / (mx + 1e-6) out = rgb.astype(np.float32) if KEY in ("green", "blue"): i = 1 if KEY == "green" else 2 # the key channel beats both others others = np.maximum(*[f[..., j] for j in range(3) if j != i]) bg = ((f[..., i] - others) > 0.18) & (sat > 0.28) spill = np.clip(out[..., i] - np.maximum(*[out[..., j] for j in range(3) if j != i]), 0, None) * 0.6 out[..., i] -= spill else: magenta = (np.minimum(r, b) - g) # >0 when R and B both beat G bg = (magenta > 0.18) & (sat > 0.28) & (np.abs(r - b) < 0.45) # despill: pull residual magenta tint out of kept pixels spill = np.clip(np.minimum(out[..., 0], out[..., 2]) - out[..., 1], 0, None) * 0.6 out[..., 0] -= spill out[..., 2] -= spill alpha = np.where(bg, 0, 255).astype(np.uint8) return np.dstack([np.clip(out, 0, 255).astype(np.uint8), alpha]) 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 loop_window(sm, pmin, pmax, first=3, min_base=0.35): """Tightest closing window [s, s+P): seam delta vs the window's own motion baseline.""" n = len(sm) step = [dist(sm[i], sm[i + 1]) for i in range(n - 1)] best = None for P in range(pmin, pmax + 1): for s in range(first, n - P): seam = dist(sm[s], sm[s + P]) base = np.mean(step[s:s + P]) if base < min_base: # not enough motion to be a cycle continue score = seam / base if best is None or score < best[0] - 1e-9: best = (score, s, P, seam, base) return best def attack_window(sm): rest = sm[0] d = np.array([dist(f, rest) for f in sm]) thr = max(1.5, 0.12 * d.max()) active = np.where(d > thr)[0] if len(active) == 0: # nothing left the rest pose: sample the whole clip return 0, len(sm), d s, e = int(active.min()), int(active.max()) s = max(0, s - 2) e = min(len(sm) - 1, e + 2) return s, e - s + 1, d def body_only(rgba, grid=4): """Keep the largest opaque blob. The shared canvas is the union of every kept frame, so a thrown projectile or breath cloud would widen every cell of every cycle. Labeled on a grid-times-smaller mask, so gaps under grid px count as touching.""" h, w = rgba.shape[:2] a = np.array(Image.fromarray(rgba[..., 3]).resize((max(1, w // grid), max(1, h // grid)), Image.BOX)) > 0 label = np.zeros(a.shape, np.int32) sizes = [0] for y, x in zip(*np.nonzero(a)): if label[y, x]: continue sizes.append(0) n = len(sizes) - 1 label[y, x] = n todo = deque([(y, x)]) while todo: cy, cx = todo.popleft() sizes[n] += 1 for ny, nx in ((cy - 1, cx), (cy + 1, cx), (cy, cx - 1), (cy, cx + 1)): if 0 <= ny < a.shape[0] and 0 <= nx < a.shape[1] and a[ny, nx] and not label[ny, nx]: label[ny, nx] = n todo.append((ny, nx)) if len(sizes) <= 2: return rgba keep = np.array(Image.fromarray(((label == int(np.argmax(sizes))) * 255).astype(np.uint8)) .resize((w, h), Image.NEAREST)) > 0 out = rgba.copy() out[~keep, 3] = 0 return out def fig_height(rgba): ys = np.where(rgba[..., 3].any(1))[0] return float(ys.max() - ys.min() + 1) if len(ys) else 0.0 def pick(clip_id, cycle, frames, src_fps=24.0, attack_body_only=False): rgba = [key(f) for f in frames] k = src_fps / 24 # loop ranges are tuned in 24 fps frames sm = [small(x) for x in rgba] if cycle == "attack": s, P, _ = attack_window(sm) idx = [s + round(k * (P - 1) / (FRAMES - 1)) for k in range(FRAMES)] info = {"start": s, "length": P, "mode": "active-window"} else: rng = RANGES_BY_HERO.get(clip_id.rsplit("_", 2)[0], {}).get(cycle, RANGES.get(cycle, RANGES["walk"])) rng = (round(rng[0] * k), round(rng[1] * k)) found = loop_window(sm, *rng, min_base=0.05 if cycle == "idle" else 0.35) if found is None: # small figure or very subtle motion: drop the motion floor; clip too short: shrink the range found = loop_window(sm, *rng, min_base=0) or loop_window(sm, rng[0] // 2, len(sm) - 4, first=1, min_base=0) print(f" {clip_id}: little motion or short clip for a {cycle} loop; relaxed the search, check the GIF") if found is None: raise SystemExit(f"{clip_id}: clip too short to find a {cycle} loop ({len(sm)} frames)") score, s, P, seam, base = found idx = [s + round(k * P / FRAMES) for k in range(FRAMES)] info = {"start": s, "length": P, "mode": "loop", "seam_over_baseline": round(float(score), 3), "seam": round(seam, 2), "baseline": round(float(base), 2)} fps = round(FRAMES * src_fps / info["length"], 1) fps = max(5.0, min(fps, 14.0)) if cycle != "attack" else max(8.0, min(fps, 12.0)) info.update(indices=idx, fps=fps) kept = [rgba[i] for i in idx] # a backdrop the model drifted off the key color stays opaque, and the shared canvas then # grows to the whole frame; the figure never reaches the frame border, the backdrop does border = float(np.mean([np.concatenate([f[0, :, 3], f[-1, :, 3], f[:, 0, 3], f[:, -1, 3]]).mean() / 255 for f in kept])) info["opaque_border"] = round(border, 2) if border > 0.25: print(f" {clip_id}: {border:.0%} of the frame border survived the {KEY} key: the backdrop drifted " "off the key color; regenerate this clip") if cycle != "attack": # a treadmill clip that walks toward or away from the camera changes the figure's size, # and one shared scale then ships that row too big or too small h0 = fig_height(rgba[0]) drift = float(np.median([fig_height(f) for f in kept])) / h0 if h0 else 1.0 info["size_vs_first_frame"] = round(drift, 2) if not 0.88 <= drift <= 1.12: print(f" {clip_id}: the figure is {drift:.2f}x its first-frame height in the loop window " "(it moved toward or away from the camera); regenerate this clip") if cycle == "attack" and attack_body_only: kept = [body_only(f) for f in kept] return kept, info def bbox(frames): a = np.any(np.stack([f[..., 3] > 0 for f in frames]), 0) ys, xs = np.where(a) return xs.min(), ys.min(), xs.max() + 1, ys.max() + 1 def crop_padded(f, box): """Crop that tolerates a box reaching past the frame edge (pads with transparency).""" x0, y0, x1, y1 = box out = np.zeros((y1 - y0, x1 - x0, 4), np.uint8) sx0, sy0 = max(x0, 0), max(y0, 0) sx1, sy1 = min(x1, f.shape[1]), min(y1, f.shape[0]) out[sy0 - y0:sy1 - y0, sx0 - x0:sx1 - x0] = f[sy0:sy1, sx0:sx1] return out def area_resize(ch, size): return np.array(Image.fromarray(ch.astype(np.float32)).resize(size, Image.BOX)) def pixelate(frames, box, scale): """Area-average down to the sprite grid (premultiplied, so edges don't pick up the key color).""" x0, y0, x1, y1 = box size = (max(1, round((x1 - x0) * scale)), max(1, round((y1 - y0) * scale))) out = [] for f in frames: crop = crop_padded(f, box).astype(np.float32) a = crop[..., 3] / 255 al = area_resize(a, size) rgb = np.dstack([area_resize(crop[..., i] * a, size) for i in range(3)]) rgb = rgb / np.maximum(al, 1e-6)[..., None] out.append((np.clip(rgb, 0, 255).astype(np.uint8), al)) return out def build_palette(pix_frames, ncol): px = np.concatenate([punch(rgb)[m > 0.5] for rgb, m in pix_frames]) im = Image.fromarray(px.reshape(1, -1, 3)) return im.quantize(colors=ncol, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE) def punch(rgb, contrast=1.12, sat=1.12): """Small contrast/saturation lift: area-averaging flattens pixel art.""" f = rgb.astype(np.float32) grey = f.mean(-1, keepdims=True) f = grey + (f - grey) * sat f = (f - 128) * contrast + 128 return np.clip(f, 0, 255).astype(np.uint8) def outline(rgba, strength=0.38): """Re-draw the 1px dark outline the downscale averaged away: darken every opaque pixel that touches transparency (4-neighbour), inside the silhouette.""" a = rgba[..., 3] > 0 pad = np.pad(a, 1) edge = a & ~(pad[:-2, 1:-1] & pad[2:, 1:-1] & pad[1:-1, :-2] & pad[1:-1, 2:]) out = rgba.copy() out[edge, :3] = (out[edge, :3].astype(np.float32) * strength).astype(np.uint8) return out def apply_palette(rgb, mask, pal): q = Image.fromarray(punch(rgb)).quantize(palette=pal, dither=Image.Dither.NONE).convert("RGB") a = np.array(q) return outline(np.dstack([a, np.where(mask > 0.5, 255, 0).astype(np.uint8)])) def apply_hd(rgb, mask): """Painted / HD art: keep the smooth downscale and soft edges, no palette or outline.""" return np.dstack([rgb, np.clip(mask * 255, 0, 255).astype(np.uint8)]) def figure_height(path): """Height of the keyed figure in a clip's first frame, in CANVAS pixels.""" f = key(read_frames(path)[0][0]) ys = np.where(f[..., 3].any(1))[0] return float(ys.max() - ys.min() + 1) def main(): global CANVAS, FRAMES, KEY ap = argparse.ArgumentParser() ap.add_argument("cast") ap.add_argument("heroes", nargs="*") ap.add_argument("--clips", default="clips") ap.add_argument("--out", default="sheets") ap.add_argument("--frames-dir") ap.add_argument("--gif", action="store_true") a = ap.parse_args() cast = json.loads(Path(a.cast).read_text()) CANVAS = cast.get("canvas", 960) FRAMES = cast.get("frames", 8) KEY = cast.get("key", "magenta") fig_frac = cast.get("fig_frac", 0.58) cycles = cast.get("cycles", ["walk", "run", "idle", "attack"]) facings = cast.get("facings", ["se", "ne"]) # sheet rows: each facing, then its mirror unless that direction was generated itself rows = [] for f in facings: rows.append((f, f, False)) if f in MIRROR and MIRROR[f] not in facings: rows.append((MIRROR[f], f, True)) heroes = a.heroes or list(cast["heroes"]) for h in heroes: RANGES_BY_HERO[h] = {k: tuple(v) for k, v in cast["heroes"][h].get("ranges", {}).items()} out = Path(a.out) out.mkdir(parents=True, exist_ok=True) meta_path = out / "meta.json" meta = json.loads(meta_path.read_text()) if meta_path.exists() else {} for c in heroes: hc = cast["heroes"][c] height, ncol, pixel = hc.get("height", 64), hc.get("palette", 24), hc.get("pixel", True) have = [cy for cy in cycles if all((Path(a.clips) / f"{c}_{f}_{cy}.mp4").exists() for f in facings)] for cy in set(cycles) - set(have): print(f"skip {c} {cy}: missing " + ", ".join(f"{c}_{f}_{cy}.mp4" for f in facings if not (Path(a.clips) / f"{c}_{f}_{cy}.mp4").exists())) if not have: continue picked, infos = {}, {} for f in facings: for cy in have: cid = f"{c}_{f}_{cy}" frames, src_fps = read_frames(Path(a.clips) / f"{cid}.mp4") picked[cid], infos[cid] = pick(cid, cy, frames, src_fps, hc.get("attack_body_only", False)) flag = "" if infos[cid]["mode"] == "loop" and infos[cid]["seam_over_baseline"] > 0.8 and cy != "idle": flag = " <-- weak seam: widen this hero's range or regenerate" print(cid, {k: infos[cid][k] for k in ("mode", "start", "length", "fps")}, infos[cid].get("seam_over_baseline", ""), flag) # figure height in the first frames: cycle_frames.py puts it at fig_frac; clips made # elsewhere are measured so the sprite still lands on the requested height if fig_frac == "auto": fig_h = figure_height(Path(a.clips) / f"{c}_{facings[0]}_{have[0]}.mp4") else: fig_h = fig_frac * CANVAS # one canvas per hero so every cycle and facing registers allf = [x for v in picked.values() for x in v] x0, y0, x1, y1 = bbox(allf) mid = CANVAS // 2 half = max(mid - x0, x1 - mid) box = (mid - half - 8, y0 - 8, mid + half + 8, y1 + 8) scale = height / fig_h pix = {k: pixelate(v, box, scale) for k, v in picked.items()} if pixel: pal = build_palette([p for v in pix.values() for p in v], ncol) finish = lambda rgb, m: apply_palette(rgb, m, pal) else: finish = apply_hd first = pix[f"{c}_{facings[0]}_{have[0]}"][0][0] cw, ch = first.shape[1], first.shape[0] names = [r[0] for r in rows] for cy in have: sheet = Image.new("RGBA", (cw * FRAMES, ch * len(rows)), (0, 0, 0, 0)) for r, (name, f, flip) in enumerate(rows): for k, (rgb, m) in enumerate(pix[f"{c}_{f}_{cy}"]): im = Image.fromarray(finish(rgb, m)) if flip: im = im.transpose(Image.FLIP_LEFT_RIGHT) sheet.paste(im, (k * cw, r * ch)) if a.frames_dir: d = Path(a.frames_dir) / c / cy d.mkdir(parents=True, exist_ok=True) im.save(d / f"{name}_{k}.png") sheet.save(out / f"{c}_{cy}.png") if a.gif: # rows play side by side here, so the preview runs at the first facing's rate; # each row's own rate is in meta.json fps = infos[f"{c}_{facings[0]}_{cy}"]["fps"] gif = [Image.new("RGBA", (cw * len(rows), ch), (40, 42, 54, 255)) for _ in range(FRAMES)] for r in range(len(rows)): for k in range(FRAMES): cell = sheet.crop((k * cw, r * ch, (k + 1) * cw, (r + 1) * ch)) gif[k].alpha_composite(cell, (r * cw, 0)) zoom = max(1, 512 // ch) gif = [g.resize((g.width * zoom, g.height * zoom), Image.NEAREST).convert("RGB") for g in gif] gif[0].save(out / f"{c}_{cy}_preview.gif", save_all=True, append_images=gif[1:], duration=round(1000 / fps), loop=0) # pivot: horizontal center of the cell, y at the feet in the first frame of the first cycle a0 = np.array(Image.fromarray(finish(*pix[f"{c}_{facings[0]}_{have[0]}"][0])))[..., 3] feet = int(np.where(a0.any(1))[0].max()) + 1 meta[c] = {"cell": [cw, ch], "pivot": [cw // 2, feet], "height": height, "rows": names, "frames": FRAMES, "palette": ncol if pixel else None, "box": [int(v) for v in box], "scale": round(scale, 4), # each facing loops at its own period, so fps is per row (a mirror plays at its source's) "cycles": {cy: {"fps": {name: infos[f"{c}_{f}_{cy}"]["fps"] for name, f, _ in rows}, **{f: infos[f"{c}_{f}_{cy}"] for f in facings}} for cy in have}} meta_path.write_text(json.dumps(meta, indent=1, default=int)) print(c, "cell", cw, ch, "rows", " ".join(names)) if __name__ == "__main__": main()
-
-
grid-templates.json 7.7 KB
{ "version": 1, "tag": "skill:scenario-sprite-animation:grid", "purpose": "Optional layout and alignment guides; not a guarantee of generated pose alignment.", "coordinate_convention": "Top-left origin, pixels, cell boxes exclude right and bottom bounds.", "templates": [ { "name": "sprite-grid-4x4-v1", "canvas": [1024, 1024], "columns": 4, "rows": 4, "frame_count": 16, "order": "row-major", "cell_size": [256, 256], "padding": 0, "gutter": 0, "cell_guides": { "inset": 32, "center_x": 128, "baseline_y": 208 }, "cells": [ { "index": 0, "box": [0, 0, 256, 256], "anchor": [128, 208] }, { "index": 1, "box": [256, 0, 256, 256], "anchor": [384, 208] }, { "index": 2, "box": [512, 0, 256, 256], "anchor": [640, 208] }, { "index": 3, "box": [768, 0, 256, 256], "anchor": [896, 208] }, { "index": 4, "box": [0, 256, 256, 256], "anchor": [128, 464] }, { "index": 5, "box": [256, 256, 256, 256], "anchor": [384, 464] }, { "index": 6, "box": [512, 256, 256, 256], "anchor": [640, 464] }, { "index": 7, "box": [768, 256, 256, 256], "anchor": [896, 464] }, { "index": 8, "box": [0, 512, 256, 256], "anchor": [128, 720] }, { "index": 9, "box": [256, 512, 256, 256], "anchor": [384, 720] }, { "index": 10, "box": [512, 512, 256, 256], "anchor": [640, 720] }, { "index": 11, "box": [768, 512, 256, 256], "anchor": [896, 720] }, { "index": 12, "box": [0, 768, 256, 256], "anchor": [128, 976] }, { "index": 13, "box": [256, 768, 256, 256], "anchor": [384, 976] }, { "index": 14, "box": [512, 768, 256, 256], "anchor": [640, 976] }, { "index": 15, "box": [768, 768, 256, 256], "anchor": [896, 976] } ], "files": { "grid": { "file": "sprite-grid-4x4-v1-grid.png", "sha256": "0b24a6aebfe588c06c5b5437a7296d789041a9006353328a89283bd287faea32", "asset_id": "asset_8U1kggSG5ypxJBnVRxdcd5WU" }, "alignment-guide": { "file": "sprite-grid-4x4-v1-alignment-guide.png", "sha256": "da73b6d7a10a4b9c74d9d1bc76b75ff6782865052fb31fd4c0dcaa4599c35575", "asset_id": "asset_nGE3ePf8NkdUxmiTF5jDnGgS" } } }, { "name": "sprite-grid-6x6-v1", "canvas": [1536, 1536], "columns": 6, "rows": 6, "frame_count": 36, "order": "row-major", "cell_size": [256, 256], "padding": 0, "gutter": 0, "cell_guides": { "inset": 32, "center_x": 128, "baseline_y": 208 }, "cells": [ { "index": 0, "box": [0, 0, 256, 256], "anchor": [128, 208] }, { "index": 1, "box": [256, 0, 256, 256], "anchor": [384, 208] }, { "index": 2, "box": [512, 0, 256, 256], "anchor": [640, 208] }, { "index": 3, "box": [768, 0, 256, 256], "anchor": [896, 208] }, { "index": 4, "box": [1024, 0, 256, 256], "anchor": [1152, 208] }, { "index": 5, "box": [1280, 0, 256, 256], "anchor": [1408, 208] }, { "index": 6, "box": [0, 256, 256, 256], "anchor": [128, 464] }, { "index": 7, "box": [256, 256, 256, 256], "anchor": [384, 464] }, { "index": 8, "box": [512, 256, 256, 256], "anchor": [640, 464] }, { "index": 9, "box": [768, 256, 256, 256], "anchor": [896, 464] }, { "index": 10, "box": [1024, 256, 256, 256], "anchor": [1152, 464] }, { "index": 11, "box": [1280, 256, 256, 256], "anchor": [1408, 464] }, { "index": 12, "box": [0, 512, 256, 256], "anchor": [128, 720] }, { "index": 13, "box": [256, 512, 256, 256], "anchor": [384, 720] }, { "index": 14, "box": [512, 512, 256, 256], "anchor": [640, 720] }, { "index": 15, "box": [768, 512, 256, 256], "anchor": [896, 720] }, { "index": 16, "box": [1024, 512, 256, 256], "anchor": [1152, 720] }, { "index": 17, "box": [1280, 512, 256, 256], "anchor": [1408, 720] }, { "index": 18, "box": [0, 768, 256, 256], "anchor": [128, 976] }, { "index": 19, "box": [256, 768, 256, 256], "anchor": [384, 976] }, { "index": 20, "box": [512, 768, 256, 256], "anchor": [640, 976] }, { "index": 21, "box": [768, 768, 256, 256], "anchor": [896, 976] }, { "index": 22, "box": [1024, 768, 256, 256], "anchor": [1152, 976] }, { "index": 23, "box": [1280, 768, 256, 256], "anchor": [1408, 976] }, { "index": 24, "box": [0, 1024, 256, 256], "anchor": [128, 1232] }, { "index": 25, "box": [256, 1024, 256, 256], "anchor": [384, 1232] }, { "index": 26, "box": [512, 1024, 256, 256], "anchor": [640, 1232] }, { "index": 27, "box": [768, 1024, 256, 256], "anchor": [896, 1232] }, { "index": 28, "box": [1024, 1024, 256, 256], "anchor": [1152, 1232] }, { "index": 29, "box": [1280, 1024, 256, 256], "anchor": [1408, 1232] }, { "index": 30, "box": [0, 1280, 256, 256], "anchor": [128, 1488] }, { "index": 31, "box": [256, 1280, 256, 256], "anchor": [384, 1488] }, { "index": 32, "box": [512, 1280, 256, 256], "anchor": [640, 1488] }, { "index": 33, "box": [768, 1280, 256, 256], "anchor": [896, 1488] }, { "index": 34, "box": [1024, 1280, 256, 256], "anchor": [1152, 1488] }, { "index": 35, "box": [1280, 1280, 256, 256], "anchor": [1408, 1488] } ], "files": { "grid": { "file": "sprite-grid-6x6-v1-grid.png", "sha256": "68148db38f0e058efbe145725d8fe7cdb534faa9fd5ae4943647c3e0bd7a918d", "asset_id": "asset_vpSevkPui9Dfg87ysZMbqWT3" }, "alignment-guide": { "file": "sprite-grid-6x6-v1-alignment-guide.png", "sha256": "db6285994ce60db596bd91b9cf3ec67e801c25db98c0a38e882020067ee6f57b", "asset_id": "asset_HdGHjXquUF7yQwJAkea8pqYC" } } } ] } -
grid-templates.md 1.8 KB
# Sprite grid templates Use the JSON manifest to select a preset matching the requested frame count and canvas: 4x4 has sixteen 256 px cells on a 1024 px square; 6x6 has thirty-six on a 1536 px square. The user's frame count and dimensions take precedence. Other layouts need their own grid specification, not extra invented frames to fill a preset. Confirm the selected model accepts the canvas before generation. ## Resolve a template asset Follow the shared asset lifecycle linked from SKILL.md to find an accessible asset or stage a new version. If none matches, run `uv run --with pillow python scripts/build_grid_templates.py <output-directory> --template <template-name>` from this skill directory. This local builder only draws geometry; upload the required PNGs through MCP. ## Use and inspect - `grid`: plain cell boundaries. Supply it as an optional layout reference alongside the approved character when the schema has enough image slots. State each image's role: character identity versus cell arrangement; request no visible guide lines in the generated sheet. With one slot, prioritize the character and put the grid specification in the prompt. - `alignment-guide`: inset, horizontal center, and baseline markers for inspection. It is not a generated frame, character reference, or inpainting mask. Its baseline is a preset for grounded poses; preserve intentional airborne motion. The manifest records cell boxes, row-major order, and anchors. Check delivered dimensions, cell count, body centers, grounded feet, clipping, and accidental guide marks before slicing. Measure against the selected layout; do not assume the output followed it. Correct alignment as taught in the skill and upload any locally re-laid sheet before platform slicing. Neither guide guarantees pose alignment or loop closure. -
SKILL.md 17.2 KB
--- name: scenario-sprite-animation description: "Use when animating 2D game art with Scenario: walk, idle or attack cycles in 4 or 8 directions, isometric sprite sheets, animated GIFs, frame-by-frame pixel art animation, VFX loops such as fire or smoke, looping a sprite seamlessly, animating an existing character image, a full animation sheet from one prompt, extracting animation frames from a generated video, or slicing a sprite sheet into engine-ready frames for Unity, Godot, or Unreal. Keywords: spritesheet, keyframes, frame grid, GIF." license: MIT --- # Scenario Sprite Animation ## Overview A bare prompt to a generic image model for "a sprite sheet, walk cycle, 8 frames" returns a picture of one; a written grid spec on a high-adherence image model returns the sheet (the single-sheet row below). Frame sequences come from routes picked by the control the task needs, not sprite size alone. This skill routes between lanes and carries the frame math; depth lives with siblings: `scenario-video` (video model families), `scenario-consistency` and `scenario-model-training` (one character across an action set), `scenario-game-assets` (statics, pixel cleanup, upscaling; a sheet whose cells are one character's frames stays here), `scenario-refine-loop` (iterate to brief). Connection 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. ## Quick reference | You need | Route | | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Pixel cycles or VFX, style-locked canvas | Purpose-trained animation model (`recommend` with the sprite-animation need in the user's own words, non-deprecated pick): GIF preview, same-seed spritesheet re-run, slice | | An exact grid, chosen frame count, scripted beats | High-adherence general image model (`recommend` with the grid spec in the user's own words; expect `ask_user` against the purpose-trained pick): one `model_run`, slice, snap per tile, assemble | | A seamless loop from a still | First+last-frame video model (`recommend` with the loop requirement): the same flattened still at both ends plus explicit motion language, extract, seam-check | | Poses at chosen moments | Keyframe video model (`recommend` with the keyframe-pinning need): pin pose stills to frame indices or timestamps, extract | | Animate this sprite, larger or styled | Image-to-video on the flattened still, prompt anchored in a sprite-animation style with scripted motion beats; model per `scenario-video` | | One character across an action set | Turnaround plus reference-to-video per `scenario-consistency`; reuse references and settings for every action | | One character, several facings, a cycle set, one sheet | Pose sheet or turnarounds from the user's art, then one first-and-last-frame image-to-video clip per facing and cycle, finished locally by the shipped cycle scripts ([directional cycles](references/directional-cycles.md)) | | Frames to sheet, sheet to frames | Tool models via `model_run`: `model_scenario-video-to-image-seq`, `model_scenario-grid-maker`, `model_scenario-image-slicer`, `model_scenario-image-seq-to-video` (fixed first-party ids: each is Scenario's single deterministic tool for its step) | The schema is the contract, read with `model_schema_get` on the pick: re-discover the generative members each session rather than hardcode them (the tool ids above are fixed). Per-lane schema notes, covering the Retro Diffusion canvas lock and `returnSpritesheet` seed pairing on the pixel lane, the single-sheet schema shape and prices, the video-lane control surfaces, the alpha routes, and how to read a deprecation tag: [references/lane-schemas.md](references/lane-schemas.md). The single-sheet lane trades the pixel lane's native pixels, locked canvas and seed pairing for a grid honored as written; the pixel look is emulated, so the snapping pass is part of the lane. Unattended, the task decides between the table's first two rows: an exact grid, frame count or scripted beats in the brief takes the top-ranked general model over `specialty.model_id`, whose `when_general_better` names exactly those needs. Write the spec for the pipeline that will read it: rows and columns within the slicer's 6x6 (16 frames as 4x4, never 2 by 8, which is a local cut), a canvas the grid divides evenly, one scale, camera, facing and ground baseline, the beats per frame range with the last frame matching the first, generous margins, no labels. Leave GIF production lines out: an image model returns stills, and a hold is the same pose across extra frames. A `background` enum names no color, so a solid field is prompt wording; an existing character goes through the reference-image field; with no `seed` a re-run is a new character, so candidates come from the sample-count parameter and one sheet is approved before slicing. Read the delivered sheet before slicing: count the cells, read the cell edges, and count distinct poses, since a beat range naming a single end state can come back as one held pose. Then slice, seam-check the tiles per step 5, align the body across tiles, snap every kept tile with the same color count and seed, check that the snapped tiles share one size, and assemble. Alignment is a post step, not a prompt: the body drifts between rows (in one run the grounded feet sat 87 px higher in the last row than in the first, and neither anchoring language nor a layout template passed as reference image moved that), so measure each tile's body box, shift it onto one baseline and one horizontal center on a wider canvas so nothing clips, then `upload_asset` the re-laid sheet and slice it, since frames edited locally never become assets. For reusable layouts, read the [grid manifest](grid-templates.json) and [template usage guide](grid-templates.md). Resolve uploaded assets and publish new versions through the `scenario` skill's [shared asset lifecycle](../scenario/references/shared-assets.md). When none is accessible, the [grid builder](scripts/build_grid_templates.py), run by a maintainer or an agent needing local geometry, creates the selected preset for MCP upload; it does not generate animation. Preserve the user's frame count and canvas over any preset. Templates do not replace the alignment, edge, and loop checks above. For a character in several facings (isometric diagonals, side or top-down) with walk, run, idle and attack rows, follow the [directional cycles lane](references/directional-cycles.md): it covers the user's own art, the key-color check, mirroring and weapon hands, and engine import. Its three scripts, run by the agent, keep the MCP steps in the lane and do only the local geometry: [cycle_prompts.py](scripts/cycle_prompts.py) writes model-agnostic requests to map onto a discovered schema, [cycle_frames.py](scripts/cycle_frames.py) makes first frames from any art, and [cycle_sheets.py](scripts/cycle_sheets.py) keys, loop-searches, registers and pixelates a character's clips into sheets, which are then uploaded with `upload_asset`. The frames pipeline is tool models run with `model_run`, not separate MCP tools: a video-to-image-sequence extractor (`extractAllFrames`, or every Nth frame via `frameInterval`; the order of its frames is verified, never assumed, see below), a grid maker (`images`, up to 100, kept in input order; `columns`, default 3, and `rows`, computed from the count when unset, so a delivered 2 by 8 is the finished frames at `columns: 8`; `backgroundColor` takes a hex string, `transparent`, `black` or `white`), a grid slicer (`image` plus `xSubdivisions` and `ySubdivisions`, 1 to 6 each and defaulting to 2 when omitted, so 6x6 is the cap; no output-size parameter; tiles return in row-major order but not always at cell size, so check one with `asset_get`, and a resized tile or a sheet past 6x6 means cutting the sheet locally on the grid you counted), a sequence-to-video assembler (`model_scenario-image-seq-to-video`, a first-party id stable enough to name; fps 1 to 120, `loopCount`, `pingpong`, `outputFormat` `gif` or `mp4`), and a pixel snapper (re-detects the art's grid and quantizes to the requested color count, the one palette control in the cleanup chain; priced per tile, so probe one before the set). GIF frame delays quantize to 10 ms, so ask the assembler for an fps that divides 100 (10, 20 or 25; 8 lands at 125 ms and 12 at 83 ms, neither on the grid), or it re-times and duplicates frames to hold the duration. Fps is the only timing lever after generation, so plan frame counts up front and count what came back. Analysis is fine locally: differencing, seam math, edge reads, and window searches need pixels on disk. Frame order is verified, never assumed. Extracted frames share one `createdAt` and carry no index in `metadata`, so their order exists only in the job row's `assetIds`, and that list has come back out of time order (a confirmed defect at authoring time). Before any stride math, slice, or assembly, pass the ids to the grid maker in returned order and read the contact sheet against the clip's free `firstFrame` and `lastFrame` (`asset_get`) and its motion: a frame that breaks continuity is out of place, and the corrected order is carried as your own id list from then on. Never rebuild order from listings, timestamps, or `search`. ## Worked example: a seamless idle loop from a still 1. Create a dedicated collection first (`collection_create`, tool catalog) and `collection_add_assets` (`collection_id`, `asset_ids`) every kept output as it lands. With no still to hand, generate and approve one first: it anchors every later step, so check subject-critical detail against the brief before animating, and budget it apart from the loop ([references/lane-schemas.md](references/lane-schemas.md)). Flatten the character onto a plain field (a local edit is fine; assume video inputs drop transparency as reference inputs do) and `upload_asset` it. Confirm lane and budget with the user; unattended, take them from the task instructions, else the cheapest lane that fits, and `dry_run` before every paid run. 2. `recommend` with the loop requirement in the user's own words, prefer a non-deprecated pick, then `model_schema_get`. The schema decides the levers: duration and fps enums, a camera lock such as `static`, an audio toggle, and a native loop flag where present, replacing the end-frame anchor: never send both (`dry_run` accepts combinations that still fail at run time). 3. `model_run` with the same still as first and last frame and a prompt that names the motion explicitly ("weight shifts, cloak sways; the character stays in place"). `jobs_wait` re-called with `pending_job_ids` while the job runs. 4. If the sprite needs alpha, expect no video lane to emit it, so budget a removal pass; verify alpha survived on one downloaded frame before batching: inline display composites it away, and a transparent request can come back in a container without alpha ([references/lane-schemas.md](references/lane-schemas.md)). Extract with the video-to-image-sequence tool and verify the frame order first (above). Confirm what was delivered before computing any stride (requests are not always honored): `asset_get` reports `properties.duration`, `frameRate`, `nbFrames`, `width`, and `height`; `nbFrames` is the delivered frame count the math needs, so read those and leave the rest of the record alone. At authoring time the extractor exposed only `extractAllFrames` and `frameInterval`, so confirm with `model_schema_get`: no start offset, end, or range. Any non-uniform selection (trimmed window, stride off an offset, hand-picked subset) means `extractAllFrames: true` then subsetting by asset id; a `frameInterval` that cannot express the window is never a reason to leave the platform. Frame math: extracted count = delivered frames / `frameInterval`, endpoint-inclusive; where the schema exposes fps, generating at sprite cadence (8 to 12 fps: a cycle typically needs 8 to 16 frames) beats decimating after. 5. Seam-check against the sequence's own motion, not by eye: a seam reads as a loop only relative to how much the frames already move. Compute the mean absolute pixel delta between consecutive frames (the motion baseline) and the delta across the seam; the loop closes when the seam sits at or below the baseline of the window being looped, not the whole sequence's, since per-step delta swings by orders of magnitude across a scene loop. On a near-static idle, `asset_display` of first and last frame is enough; on locomotion (hop, walk, attack) eyeballing passes visibly broken loops. If the seam is wide, do not re-run first: image-to-video models re-render off the input still, so frame 0 often diverges from every later frame (a large 0 to 1 delta is the tell) and no re-run fixes a loop anchored to it. Search start and end pairs inside the model's own rendering, excluding frame 0, and take the tightest closing window that still carries real motion. Trim to where the motion is: a duration longer than the action leaves a static tail, and the stride comes from the active window, not the delivered length. Only after a window search fails is a re-run (new `seed` where the schema has one, else reworded motion) worth the credits. Unattended, allow one re-run, then flag and stop. 6. Finish and package: a pixel-art target needs a snapping pass, which has traps of its own and is sometimes the wrong move ([references/lane-schemas.md](references/lane-schemas.md)). Sheet via the grid maker, `transparent` background or a color when the brief is opaque; preview via sequence-to-video as GIF; the assembler has no background parameter, so a deliverable GIF takes opaque frames already on its field. `asset_download` frames with `format="png"` and the preview with `format="gif"`, and omit `format` on a video asset, which rejects `mp4`. ## Common mistakes - Identical first and last frames with a bare prompt: that asks for a freeze, not a loop; the motion must be named. - Treating margin or wording as containment for a traveling effect: what crosses the cell edge is gone, margin gets filled and crossed anyway, and containment wording cancels the throw. Keep the frames whose delivered edge band reads empty; the detached projectile is its own sprite. - Assuming every model has a `seed`: many video schemas and several high-adherence image schemas have none, so reproducibility there is per-asset, not per-run. - Counting on an interpolation filter: none exists in the catalog, and fps is the timing lever. Pixelating a non-pixel image is a different job from cleaning pixel art: the catalog carries a pixelate tool (grid size, palette) for the first and the snapper for the second, both found with `search` (`target: "models"`, `public: true`, `filters` `tags: ["tool"]`, `query: "pixel"` returns both; the descriptions tell them apart). - Photo upscalers on pixel frames: they invent texture; use the pixel-preserving routes in `scenario-game-assets`. - Shipping a sheet unstepped: per-frame steps return new ids and order lives only in the lists you pass, so carry the frame-to-output mapping (never rebuild it from listings or timestamps), then compare sheet to preview before delivery. - Assembling deliverables locally because the frames are already on disk: analysis pulls the clip down, local assembly then looks free, and the frames never become assets, leaving the collection holding a sheet nobody else can reach. - Ping-pong or loop flags set blind: watch the downloaded GIF and match the assembler to it. - Launching every frame, direction, or clip at once: past the team's concurrency ceiling (the `parallel-custom-jobs` row in the `scenario` skill) a launch is refused with that 429 and creates no job, and on some video lanes the burst is accepted and comes back as failed, refunded jobs instead ([directional cycles](references/directional-cycles.md)). Either way, launch in waves and relaunch every refused or failed item in the next one rather than dropping it. - Softening an attack brief after a moderation block: the filter is the provider's, so try another general image model first per `scenario-moderation`, grid spec unchanged; rewording the beats loses what the lane exists to honor.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.