"""Sprite Forge — game sprite sheets from one character design, via MiniMax-H3.
An implementation of the workflow Victor Mustar described: design one character on a flat magenta backdrop, generate
one short H3 clip per move, cut each clip down to its pose extremes, key out the magenta, and pack the result.
Three things are specific to doing it here rather than by hand:
* **Every clip loops.** Each move is generated with the character passed as *both* the first and the last keyframe,
so H3's `fl2va` branch has to leave the rest pose and arrive back at it. A sprite that does not return to its start
pose visibly pops every time the animation repeats.
* **The moves are generated concurrently.** They are independent conditioning-identical requests, so the wall clock
is one clip rather than the sum of them.
* **This Space owns no GPU.** It runs on `cpu-basic` and calls other Spaces for both models. See `backends.py`.
"""
from __future__ import annotations
import os
import shutil
import tempfile
import time
import traceback
import gradio as gr
import numpy as np
from PIL import Image
import backends
import moves as move_defs
import player
import spritekit as sk
OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "sprite-forge")
os.makedirs(OUTPUT_DIR, exist_ok=True)
DEFAULT_SUBJECT = "a scrappy alley cat in a bomber jacket"
EXAMPLES = [
"a scrappy alley cat in a bomber jacket",
"a round little wizard with an oversized pointed hat",
"a rusty barrel-chested robot with one glowing eye",
"a punk frog in a leather vest",
"a knight in dented armor holding a short sword",
]
PENDING = (
"
Designing your character…
"
)
IDLE_PANEL = (
""
"Your playable character will appear here.
"
)
def building_panel(character_path: str | None, message: str) -> str:
"""The output panel while the clips are still generating: the character, and what is happening to it.
The still is the first thing worth looking at and it exists a couple of minutes before the playable demo does,
so it goes straight into the main panel rather than staying folded away in the downloads accordion.
Embedded as a data URI, downscaled first. A file path here would have to go through `/gradio_api/file=`, which
is the route that already 403'd on this Space once, and the panel is re-yielded on every step — a 1024px PNG
would be a megabyte of base64 each time.
"""
if not character_path:
return PENDING
try:
thumbnail = Image.open(character_path).convert("RGBA")
thumbnail.thumbnail((460, 460))
source = sk.data_uri(thumbnail)
except Exception: # noqa: BLE001 — the panel is cosmetic; never fail a run over it
return PENDING
return (
""
""
f"

"
f"
{message}
"
"
"
)
def _out(name: str) -> str:
return os.path.join(OUTPUT_DIR, f"{int(time.time() * 1000)}-{name}")
def _adopt(path: str, name: str) -> str:
"""Copy a file fetched from a remote Space into this Space's own output directory.
`gradio_client` drops downloads in gradio's temp cache, and `/gradio_api/file=` only serves whitelisted
directories — handing a component a path under someone else's cache returns 403 to the browser. Copying makes
the file ours to serve, and incidentally keeps it alive past gradio's cache cleanup.
"""
destination = _out(name)
shutil.copyfile(path, destination)
return destination
# ---------------------------------------------------------------------------
# the one flow
# ---------------------------------------------------------------------------
def forge(
subject,
uploaded,
style,
backend_label,
canvas_label,
selected,
duration,
steps,
seed,
tolerance,
softness,
despill,
despeckle,
shrink,
palette_size,
flatten,
playback_fps,
sprite_height,
request: gr.Request,
progress=gr.Progress(),
):
"""Character -> one clip per move -> keyed extremes -> packed sheet -> playable demo, as one generator.
Yields on every step so the demo panel holds Gradio's pending state from the moment the button is pressed rather
than only lighting up at the end of a multi-minute run.
"""
if not uploaded and not (subject or "").strip():
raise gr.Error("Describe a character, or upload one.")
if not selected:
raise gr.Error("Pick at least one move under Advanced.")
ip_token = backends.ip_token_of(request)
presets = [move_defs.BY_LABEL[label] for label in selected]
moves_label = f"{len(presets)} move" + ("s" if len(presets) != 1 else "")
# ---- 1. the character design still ----
if uploaded:
character = uploaded
message = f"Animating {moves_label}…"
yield building_panel(character, message), character, [], None, [], None, "Using your uploaded character."
else:
yield PENDING, None, [], None, [], None, "Designing the character…"
backend = backends.BY_LABEL[backend_label]
_, width, height = backends.CANVASES[canvas_label]
try:
character = _adopt(
backend.generate(
move_defs.character_prompt(subject, style), width, height, int(seed), ip_token
),
"character.png",
)
except Exception as error:
traceback.print_exc()
raise gr.Error(f"{backend.space} failed: {type(error).__name__}: {error}") from error
yield (
building_panel(character, f"Animating {moves_label}…"),
character, [], None, [], None, f"Character designed. Animating {moves_label}…",
)
# ---- 1b. flatten the still before it is animated ----
# Snapping the still to a small palette *before* H3 sees it attacks the artifact at the source rather than
# cleaning it up afterwards: the backdrop becomes one exact value, and the encoder gets large constant-color
# regions instead of gradients to smear. The palette is then reused verbatim by the keyer.
palette = None
if int(palette_size) > 0:
still = np.asarray(Image.open(character).convert("RGB"), np.float32) / 255.0
still_key = sk.detect_key_color(still)
palette = sk.extract_palette(sk.sample_interior(still, still_key, 8000), int(palette_size), still_key)
print(f"[forge] palette: {len(palette)} colors from the character still", flush=True)
if bool(flatten) and len(palette):
flat = sk.quantize_to_palette(still, palette, still_key)
character = _out("character-flat.png")
sk.to_pil(np.concatenate([flat, np.ones((*flat.shape[:2], 1), np.float32)], axis=-1)).convert(
"RGB"
).save(character)
yield (
building_panel(character, f"Flattened to {len(palette)} colors. Animating {moves_label}…"),
character, [], None, [], None, f"Flattened to {len(palette)} colors.",
)
# ---- 2. one clip per move, concurrently ----
# These requests are fully independent, so they are fired together: the wall clock becomes the slowest single
# clip instead of the sum. Each gets its own seed so two moves never collapse onto the same motion.
who = subject if not uploaded else "the character"
def submit(preset, index, minimal=False):
return backends.animate_job(
prompt=preset.prompt(who, minimal=minimal),
image_path=character,
canvas_label=canvas_label,
duration=float(duration),
steps=int(steps),
seed=int(seed) + index + 1,
ip_token=ip_token,
)
def await_all(pending, label):
"""Poll a batch of in-flight jobs, driving the progress bar off the slowest one."""
started = time.time()
while not all(job.done() for _p, _i, job in pending):
finished = sum(1 for _p, _i, job in pending if job.done())
elapsed = time.time() - started
# All the requests start together, so the batch takes as long as its slowest member and elapsed time is
# a fair proxy for how far along that one is. The completed count is the ground truth and always wins;
# the clock only fills the gap between completions, and is capped short of 1 so the bar never claims to
# be finished before the work is.
clock = min(elapsed / max(ANIMATE_SECONDS, 1.0), 0.95)
fraction = max(finished / len(pending), clock)
progress(0.1 + 0.65 * fraction, desc=f"{label} — {finished}/{len(pending)} done")
time.sleep(0.4)
return pending
ANIMATE_SECONDS = 18 + 2.5 * int(steps) + 5.0 * float(duration)
progress(0.1, desc=f"Animating {moves_label}")
inflight = [(preset, i, submit(preset, i)) for i, preset in enumerate(presets)]
await_all(inflight, f"Animating {moves_label}")
clips, failures, refused = [], [], []
for preset, index, job in inflight:
try:
video, report = backends.unwrap(job.result())
except Exception as error: # noqa: BLE001 — reported per move in the status line
# The H3 Space runs a content classifier over the prompt of every keyframed request, and it is touchy in
# ways the wording does not predict — "the foot sweeping up and out" is refused where "a front kick to
# the right" is not. It runs *before* the GPU booking, so a refusal costs no quota and retrying is cheap.
if "content filter" in str(error):
refused.append((preset, index))
else:
failures.append(f"**{preset.label}**: `{type(error).__name__}: {error}`")
continue
clips.append({
"key": preset.key,
"label": preset.label,
"video": _adopt(video, f"{preset.key}.mp4"),
"frames": preset.frames,
"cyclic": preset.cyclic,
})
if refused:
# One retry wave, plainer phrasing and no character description.
print(f"[forge] retrying {[p.key for p, _ in refused]} minimally", flush=True)
retries = [(preset, i, submit(preset, i, minimal=True)) for preset, i in refused]
await_all(retries, "Retrying refused moves")
for preset, _index, job in retries:
try:
video, report = backends.unwrap(job.result())
except Exception as error: # noqa: BLE001
failures.append(f"**{preset.label}**: `{type(error).__name__}: {error}`")
continue
clips.append({
"key": preset.key,
"label": preset.label,
"video": _adopt(video, f"{preset.key}.mp4"),
"frames": preset.frames,
"cyclic": preset.cyclic,
})
# Keep sheet order matching the order the moves were requested in.
order = {preset.key: i for i, preset in enumerate(presets)}
clips.sort(key=lambda c: order[c["key"]])
if not clips:
raise gr.Error("Every move failed. " + (failures[0] if failures else ""))
gallery = [(clip["video"], clip["label"]) for clip in clips]
yield (
building_panel(character, "Cutting to pose extremes…"),
character, gallery, None, [], None, f"{len(clips)} clip" + ("s" if len(clips) != 1 else "") + " generated. Cutting to pose extremes…",
)
# ---- 3. key, cut to extremes, pack ----
# `palette` was read in 1b, from the still and never from the clips. The still is a PNG: no chroma subsampling,
# and a backdrop that is genuinely one flat color. H3's backdrop drifts slightly across a frame, and a darker
# patch of magenta carries *less* chroma, so the interior test mistakes it for character — sampling the clips
# put a near-backdrop color in the palette and turned whole backgrounds opaque.
selected_frames, key_hex = [], None
for i, clip in enumerate(clips):
progress(0.75 + 0.15 * i / len(clips), desc=f"Keying {clip['label']}")
frames, _fps = sk.read_video(clip["video"])
keyed, key_rgb, reliable = sk.key_clip(
frames,
tolerance=float(tolerance),
softness=float(softness),
despill=float(despill),
despeckle=int(despeckle),
shrink=float(shrink),
palette=palette,
)
key_hex = "#%02X%02X%02X" % tuple((key_rgb * 255).astype(int))
chooser = sk.cycle_extremes if clip.get("cyclic") else sk.pose_extremes
indices = chooser(keyed, int(clip["frames"]), candidates=reliable)
selected_frames.append((clip, [keyed[j] for j in indices]))
del keyed, frames
# One crop box and therefore one scale, across every frame of every move.
#
# Cropping per move and scaling each to a fixed height is the obvious thing and it is wrong: a crouch has a
# smaller bounding box, so normalising it to the same height renders it *larger* than the idle stance. A single
# shared box also keeps the character in one coordinate system, so a jump genuinely rises inside its cell, a
# crouch genuinely sinks, and a punch whose arm widens the silhouette does not shove the body sideways relative
# to the other rows.
progress(0.92, desc="Packing the sheet")
box = sk.content_bbox([f for _clip, group in selected_frames for f in group])
packed, previews = [], []
for clip, group in selected_frames:
sprites = sk.fit_height([sk.to_pil(f).crop(box) for f in group], int(sprite_height))
packed.append(sk.Move(name=clip["key"], frames=sprites, fps=float(playback_fps)))
preview = _out(f"{clip['key']}.webp")
sk.save_animation(sprites, preview, fps=float(playback_fps))
previews.append((preview, f"{clip['label']} · {len(sprites)} extremes"))
sheet, atlas = sk.pack_sheet(packed)
atlas["source"] = {"model": "MiniMaxAI/MiniMax-H3", "lora": "larryvrh/MiniMax-H3-Turbo-Lora", "keyed": key_hex}
sheet_path = _out("spritesheet.png")
sheet.save(sheet_path)
atlas_path = sk.write_atlas(atlas, _out("atlas.json"))
demo_html = player.build_demo(atlas, sk.data_uri(sheet), title="Sprite demo")
demo_path = _out("demo.html")
with open(demo_path, "w", encoding="utf-8") as handle:
handle.write(demo_html)
total = sum(len(m.frames) for m in packed)
status = (
f"**{len(packed)} moves · {total} frames** · cell {atlas['cell']['width']}×{atlas['cell']['height']} · "
f"sheet {atlas['sheet']['width']}×{atlas['sheet']['height']} · keyed `{key_hex}` · "
f"click the demo, then use the keys."
)
if failures:
status += "\n\nFailed: " + "; ".join(failures)
yield (
player.as_iframe(demo_html),
character,
gallery,
sheet_path,
previews,
[sheet_path, atlas_path, demo_path],
status,
)
# ---------------------------------------------------------------------------
# ui
# ---------------------------------------------------------------------------
CSS = """
#col-container { max-width: 1300px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""
# Gradio 6 moved `theme` and `css` off the Blocks constructor onto `launch()`; passing them here is only a warning,
# so the theme silently does not apply.
with gr.Blocks(title="Sprite Forge") as demo:
with gr.Column(elem_id="col-container"):
gr.Markdown(
"""
# 🕹️ Sprite Forge
Describe a character, get a **playable** one. Powered by
[MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) with the 6-step
[Turbo LoRA](https://huggingface.co/larryvrh/MiniMax-H3-Turbo-Lora), after
[@victormustar's workflow](https://x.com/victormustar/status/2089310616854892818).
One clip per move, each generated with the same first *and* last frame so it loops — then keyed, cut to
its pose extremes and packed into a sprite sheet. Runs on your own ZeroGPU quota.
"""
)
with gr.Row(equal_height=False):
# Controls left, the thing you actually came for on the right.
with gr.Column(scale=2):
subject = gr.Textbox(
label="Character",
value=DEFAULT_SUBJECT,
placeholder="a scrappy alley cat in a bomber jacket",
lines=2,
)
run = gr.Button("Generate", variant="primary", size="lg")
gr.Examples(EXAMPLES, inputs=subject, label="Try a character")
with gr.Accordion("Advanced", open=False):
uploaded = gr.Image(
label="Or start from your own character", type="filepath", height=200
)
selected_moves = gr.CheckboxGroup(
move_defs.LABELS, value=move_defs.DEFAULT_MOVES, label="Moves"
)
with gr.Row():
style = gr.Dropdown(
list(move_defs.STYLES), value=move_defs.DEFAULT_STYLE, label="Style"
)
image_backend = gr.Dropdown(
backends.BACKEND_LABELS, value=backends.BACKEND_LABELS[0], label="Image model"
)
with gr.Row():
canvas = gr.Dropdown(
list(backends.CANVASES), value=backends.DEFAULT_CANVAS, label="Canvas"
)
seed = gr.Number(value=1234, label="Seed", precision=0)
with gr.Row():
duration = gr.Slider(
2, 5, value=3, step=0.5, label="Clip seconds",
info="H3 does 5s per move; 3s keeps each move punchy.",
)
steps = gr.Slider(4, 8, value=6, step=1, label="H3 steps")
with gr.Row():
playback_fps = gr.Slider(4, 24, value=12, step=1, label="Playback fps")
sprite_height = gr.Slider(
128, 768, value=512, step=32, label="Max sprite height",
info="Only ever downscales. 512 keeps a 544px clip at native size.",
)
with gr.Row():
tolerance = gr.Slider(0.02, 0.30, value=0.10, step=0.01, label="Key tolerance")
softness = gr.Slider(0.01, 0.25, value=0.09, step=0.01, label="Edge softness")
with gr.Row():
despill = gr.Slider(0, 1, value=1.0, step=0.05, label="Despill")
despeckle = gr.Slider(0, 4, value=2, step=1, label="Despeckle")
palette_size = gr.Slider(
0, 32, value=16, step=2, label="Palette colors",
info="Flat-art keying: 0 falls back to plain chroma keying.",
)
shrink = gr.Slider(
0, 0.5, value=0.3, step=0.05, label="Matte shrink",
info="Chokes the matte inward against 4:2:0 chroma bleed.",
)
flatten = gr.Checkbox(
value=True, label="Flatten the character before animating",
info="Snaps the still to its palette first, so H3 animates genuinely flat art.",
)
with gr.Column(scale=3):
demo_frame = gr.HTML(IDLE_PANEL, label="Playable")
status = gr.Markdown()
with gr.Accordion("Sprite sheet, clips and downloads", open=False):
with gr.Row():
character_preview = gr.Image(label="Character still", height=250)
clip_gallery = gr.Gallery(label="H3 clips", columns=3, height=250, object_fit="contain")
sheet_image = gr.Image(label="Sprite sheet (transparent PNG)", height=250)
sprite_previews = gr.Gallery(label="Looping moves", columns=5, height=210, object_fit="contain")
downloads = gr.Files(label="Download — sheet, atlas.json, standalone demo.html")
run.click(
forge,
[
subject, uploaded, style, image_backend, canvas, selected_moves, duration, steps, seed,
tolerance, softness, despill, despeckle, shrink, palette_size, flatten, playback_fps, sprite_height,
],
[demo_frame, character_preview, clip_gallery, sheet_image, sprite_previews, downloads, status],
)
if __name__ == "__main__":
demo.queue(default_concurrency_limit=4).launch(
theme=gr.themes.Citrus(),
css=CSS,
ssr_mode=False,
show_error=True,
allowed_paths=[OUTPUT_DIR],
)