multimodalart HF Staff commited on
Commit
d154283
·
verified ·
1 Parent(s): 50f95f8

Simplify to one generate->playable flow: Citrus theme, parallel H3 calls, un-mixing despill, 3s default

Browse files
Files changed (2) hide show
  1. app.py +189 -189
  2. spritekit.py +16 -12
app.py CHANGED
@@ -3,11 +3,13 @@
3
  An implementation of the workflow Victor Mustar described: design one character on a flat magenta backdrop, generate
4
  one short H3 clip per move, cut each clip down to its pose extremes, key out the magenta, and pack the result.
5
 
6
- Two things are specific to doing it here rather than by hand:
7
 
8
- * **Every clip loops.** Each move is generated with the character still passed as *both* the first and the last
9
- keyframe, so H3's `fl2va` branch has to leave the rest pose and arrive back at it. A sprite that does not return
10
- to its start pose visibly pops every time the animation repeats.
 
 
11
  * **This Space owns no GPU.** It runs on `cpu-basic` and calls other Spaces for both models. See `backends.py`.
12
  """
13
 
@@ -17,6 +19,7 @@ import os
17
  import tempfile
18
  import time
19
  import traceback
 
20
 
21
  import gradio as gr
22
 
@@ -29,6 +32,23 @@ OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "sprite-forge")
29
  os.makedirs(OUTPUT_DIR, exist_ok=True)
30
 
31
  DEFAULT_SUBJECT = "a scrappy alley cat in a bomber jacket"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
 
34
  def _out(name: str) -> str:
@@ -36,109 +56,91 @@ def _out(name: str) -> str:
36
 
37
 
38
  # ---------------------------------------------------------------------------
39
- # step 1 — the character design still
40
  # ---------------------------------------------------------------------------
41
 
42
 
43
- def design_character(subject, style, backend_label, canvas_label, seed, request: gr.Request):
44
- if not (subject or "").strip():
45
- raise gr.Error("Describe a character first.")
46
-
47
- backend = backends.BY_LABEL[backend_label]
48
- _, width, height = backends.CANVASES[canvas_label]
49
- prompt = move_defs.character_prompt(subject, style)
50
-
51
- try:
52
- path = backend.generate(prompt, width, height, int(seed), backends.ip_token_of(request))
53
- except Exception as error:
54
- traceback.print_exc()
55
- raise gr.Error(f"{backend.space} failed: {type(error).__name__}: {error}") from error
56
-
57
- return path, gr.update(value=f"Designed with `{backend.space}`. Regenerate until the pose reads clearly — "
58
- f"every move starts and ends here.")
59
-
60
-
61
- # ---------------------------------------------------------------------------
62
- # step 2 — one clip per move
63
- # ---------------------------------------------------------------------------
64
-
65
-
66
- def animate_moves(subject, character, selected, canvas_label, duration, steps, seed, clips, request: gr.Request):
67
- """Generate the selected moves one at a time, streaming each clip back as it lands.
68
-
69
- Sequential rather than parallel on purpose: every call books a ZeroGPU slot on the visitor's own quota, and
70
- firing five at once mostly earns five places in the same queue.
71
  """
72
- if not character:
73
- raise gr.Error("Design or upload a character first.")
74
  if not selected:
75
- raise gr.Error("Pick at least one move.")
76
 
77
  ip_token = backends.ip_token_of(request)
78
- clips = list(clips or [])
79
- existing = {clip["label"] for clip in clips}
80
- todo = [move_defs.BY_LABEL[label] for label in selected if label not in existing]
81
- skipped = len(selected) - len(todo)
82
-
83
- if not todo:
84
- yield clips, _clip_gallery(clips), "Every selected move is already generated. Clear them to redo."
85
- return
86
-
87
- log = []
88
- if skipped:
89
- log.append(f"Skipped {skipped} already-generated move(s).")
90
-
91
- for i, preset in enumerate(todo, 1):
92
- note = f"Generating **{preset.label}** ({i}/{len(todo)}) — about a minute per move."
93
- yield clips, _clip_gallery(clips), "\n\n".join(log + [note])
94
  try:
95
- video, report = backends.animate(
96
- prompt=preset.prompt(subject),
97
- image_path=character,
98
- canvas_label=canvas_label,
99
- duration=float(duration),
100
- steps=int(steps),
101
- # Vary the seed per move so two moves never collapse onto the same motion.
102
- seed=int(seed) + i,
103
- ip_token=ip_token,
104
  )
105
  except Exception as error:
106
  traceback.print_exc()
107
- log.append(f"**{preset.label}** failed: `{type(error).__name__}: {error}`")
108
- yield clips, _clip_gallery(clips), "\n\n".join(log)
109
- continue
110
-
111
- clips.append({"key": preset.key, "label": preset.label, "video": video, "frames": preset.frames})
112
- log.append(f"**{preset.label}** — {report}")
113
- yield clips, _clip_gallery(clips), "\n\n".join(log)
114
-
115
- yield clips, _clip_gallery(clips), "\n\n".join(log + ["Done. Build the sheet below."])
116
-
117
-
118
- def _clip_gallery(clips):
119
- return [(clip["video"], clip["label"]) for clip in clips]
120
-
121
-
122
- def clear_clips():
123
- return [], [], "Cleared."
124
-
125
-
126
- # ---------------------------------------------------------------------------
127
- # step 3 — key, cut to extremes, pack
128
- # ---------------------------------------------------------------------------
129
 
 
 
 
 
 
 
 
 
130
 
131
- def build_sprites(clips, tolerance, softness, despill, despeckle, playback_fps, sprite_height,
132
- progress=gr.Progress()):
133
  if not clips:
134
- raise gr.Error("Generate at least one move first.")
135
 
136
- packed: list[sk.Move] = []
137
- previews: list[tuple[str, str]] = []
138
- key_hex = None
139
 
140
- for i, clip in enumerate(clips):
141
- progress((i) / max(len(clips), 1), desc=f"Keying {clip['label']}")
 
142
  frames, _fps = sk.read_video(clip["video"])
143
  keyed, key_rgb = sk.key_clip(
144
  frames,
@@ -149,18 +151,15 @@ def build_sprites(clips, tolerance, softness, despill, despeckle, playback_fps,
149
  )
150
  key_hex = "#%02X%02X%02X" % tuple((key_rgb * 255).astype(int))
151
 
152
- indices = sk.pose_extremes(keyed, int(clip.get("frames", 6)))
153
  box = sk.content_bbox(keyed)
154
  sprites = sk.fit_height([sk.to_pil(keyed[j]).crop(box) for j in indices], int(sprite_height))
155
 
156
- move = sk.Move(name=clip["key"], frames=sprites, fps=float(playback_fps))
157
- packed.append(move)
158
-
159
  preview = _out(f"{clip['key']}.webp")
160
  sk.save_animation(sprites, preview, fps=float(playback_fps))
161
  previews.append((preview, f"{clip['label']} · {len(sprites)} extremes"))
162
 
163
- progress(0.9, desc="Packing sheet")
164
  sheet, atlas = sk.pack_sheet(packed)
165
  atlas["source"] = {"model": "MiniMaxAI/MiniMax-H3", "lora": "larryvrh/MiniMax-H3-Turbo-Lora", "keyed": key_hex}
166
 
@@ -174,129 +173,130 @@ def build_sprites(clips, tolerance, softness, despill, despeckle, playback_fps,
174
  handle.write(demo_html)
175
 
176
  total = sum(len(m.frames) for m in packed)
177
- summary = (
178
- f"**{len(packed)} moves · {total} frames** · cell {atlas['cell']['width']}x{atlas['cell']['height']} · "
179
- f"sheet {atlas['sheet']['width']}x{atlas['sheet']['height']} · keyed `{key_hex}`"
 
180
  )
181
- return (
 
 
 
 
 
 
182
  sheet_path,
183
  previews,
184
- player.as_iframe(demo_html),
185
  [sheet_path, atlas_path, demo_path],
186
- summary,
187
  )
188
 
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  # ---------------------------------------------------------------------------
191
  # ui
192
  # ---------------------------------------------------------------------------
193
 
194
  CSS = """
195
- .step h3 { margin-top: 0; }
196
- footer { display: none !important; }
197
  """
198
 
199
- with gr.Blocks(title="Sprite Forge", css=CSS, fill_width=True) as demo:
200
- clips_state = gr.State([])
201
-
202
- gr.Markdown(
203
- """
204
- # 🕹️ Sprite Forge
205
- Game sprite sheets from a single character design, using **MiniMax-H3** with the 6-step
206
- [Turbo LoRA](https://huggingface.co/larryvrh/MiniMax-H3-Turbo-Lora).
207
-
208
- One character still on a flat magenta backdrop → one short clip per move → cut to pose extremes →
209
- key out the magenta → packed sheet + a playable demo. Every clip is generated with the character as **both
210
- the first and last frame**, so every animation loops seamlessly.
211
-
212
- *This Space has no GPU of its own — it calls other Spaces, and the bookings run on your own ZeroGPU quota.*
213
- """
214
- )
215
 
216
- with gr.Row():
217
- # ---------------- step 1 ----------------
218
- with gr.Column(scale=4, elem_classes="step"):
219
- gr.Markdown("### 1 · Character")
220
  subject = gr.Textbox(
221
  label="Character",
222
  value=DEFAULT_SUBJECT,
223
- lines=2,
224
  placeholder="a scrappy alley cat in a bomber jacket",
 
 
225
  )
 
 
 
226
  with gr.Row():
227
- style = gr.Dropdown(
228
- list(move_defs.STYLES), value=move_defs.DEFAULT_STYLE, label="Style", scale=1
229
- )
230
- canvas = gr.Dropdown(
231
- list(backends.CANVASES), value=backends.DEFAULT_CANVAS, label="Canvas", scale=1
232
  )
233
- image_backend = gr.Dropdown(
234
- backends.BACKEND_LABELS, value=backends.BACKEND_LABELS[0], label="Image model"
235
- )
236
- seed = gr.Number(value=1234, label="Seed", precision=0)
237
- design_btn = gr.Button("Design character", variant="primary")
238
- character = gr.Image(
239
- label="Character (upload your own to skip this step)",
240
- type="filepath",
241
- height=340,
242
- )
243
- character_note = gr.Markdown()
244
-
245
- # ---------------- step 2 ----------------
246
- with gr.Column(scale=4, elem_classes="step"):
247
- gr.Markdown("### 2 · Moves")
248
- selected_moves = gr.CheckboxGroup(
249
- move_defs.LABELS, value=move_defs.DEFAULT_MOVES, label="Moves to generate"
250
- )
251
  with gr.Row():
252
- duration = gr.Slider(2, 5, value=2, step=0.5, label="Clip seconds")
 
 
 
253
  steps = gr.Slider(4, 8, value=6, step=1, label="H3 steps")
 
 
254
  with gr.Row():
255
- animate_btn = gr.Button("Generate moves", variant="primary", scale=3)
256
- clear_btn = gr.Button("Clear", scale=1)
257
- clip_gallery = gr.Gallery(label="Clips", columns=3, height=260, object_fit="contain")
258
- move_log = gr.Markdown()
259
-
260
- # ---------------- step 3 ----------------
261
- with gr.Column(scale=5, elem_classes="step"):
262
- gr.Markdown("### 3 · Sprites")
263
- with gr.Accordion("Keying", open=False):
264
  tolerance = gr.Slider(0.02, 0.30, value=0.10, step=0.01, label="Key tolerance")
265
  softness = gr.Slider(0.01, 0.25, value=0.09, step=0.01, label="Edge softness")
266
  despill = gr.Slider(0, 1, value=1.0, step=0.05, label="Despill")
267
  despeckle = gr.Slider(0, 4, value=2, step=1, label="Despeckle")
268
- playback_fps = gr.Slider(4, 24, value=12, step=1, label="Playback fps")
269
- sprite_height = gr.Slider(
270
- 64, 512, value=256, step=32, label="Max sprite height (px)",
271
- info="Caps the cell size. The clip resolves at 544px, so 256 is a downscale, never an upscale.",
272
- )
273
- build_btn = gr.Button("Build sprite sheet", variant="primary")
274
- summary = gr.Markdown()
275
- sheet_image = gr.Image(label="Sprite sheet (transparent PNG)", height=260)
276
- sprite_previews = gr.Gallery(label="Looping moves", columns=4, height=210, object_fit="contain")
277
- downloads = gr.Files(label="Download")
278
-
279
- gr.Markdown("### Playable demo")
280
- demo_frame = gr.HTML(
281
- "<p style='opacity:.6'>Build a sheet to get a playable demo — arrow keys walk and jump, "
282
- "J/K/L punch, kick and block.</p>"
283
- )
284
 
285
- design_btn.click(
286
- design_character,
287
- [subject, style, image_backend, canvas, seed],
288
- [character, character_note],
289
- )
290
- animate_btn.click(
291
- animate_moves,
292
- [subject, character, selected_moves, canvas, duration, steps, seed, clips_state],
293
- [clips_state, clip_gallery, move_log],
294
- )
295
- clear_btn.click(clear_clips, None, [clips_state, clip_gallery, move_log])
296
- build_btn.click(
297
- build_sprites,
298
- [clips_state, tolerance, softness, despill, despeckle, playback_fps, sprite_height],
299
- [sheet_image, sprite_previews, demo_frame, downloads, summary],
 
 
 
 
 
300
  )
301
 
302
  if __name__ == "__main__":
 
3
  An implementation of the workflow Victor Mustar described: design one character on a flat magenta backdrop, generate
4
  one short H3 clip per move, cut each clip down to its pose extremes, key out the magenta, and pack the result.
5
 
6
+ Three things are specific to doing it here rather than by hand:
7
 
8
+ * **Every clip loops.** Each move is generated with the character passed as *both* the first and the last keyframe,
9
+ 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
10
+ pose visibly pops every time the animation repeats.
11
+ * **The moves are generated concurrently.** They are independent conditioning-identical requests, so the wall clock
12
+ is one clip rather than the sum of them.
13
  * **This Space owns no GPU.** It runs on `cpu-basic` and calls other Spaces for both models. See `backends.py`.
14
  """
15
 
 
19
  import tempfile
20
  import time
21
  import traceback
22
+ from concurrent.futures import ThreadPoolExecutor
23
 
24
  import gradio as gr
25
 
 
32
  os.makedirs(OUTPUT_DIR, exist_ok=True)
33
 
34
  DEFAULT_SUBJECT = "a scrappy alley cat in a bomber jacket"
35
+ EXAMPLES = [
36
+ "a scrappy alley cat in a bomber jacket",
37
+ "a round little wizard with an oversized pointed hat",
38
+ "a rusty barrel-chested robot with one glowing eye",
39
+ "a punk frog in a leather vest",
40
+ "a knight in dented armor holding a short sword",
41
+ ]
42
+
43
+ PENDING = (
44
+ "<div style='display:flex;align-items:center;justify-content:center;height:560px;opacity:.55;"
45
+ "border:1px dashed var(--border-color-primary);border-radius:8px'>Building your character…</div>"
46
+ )
47
+ IDLE_PANEL = (
48
+ "<div style='display:flex;align-items:center;justify-content:center;height:560px;opacity:.55;"
49
+ "border:1px dashed var(--border-color-primary);border-radius:8px'>"
50
+ "Your playable character will appear here.</div>"
51
+ )
52
 
53
 
54
  def _out(name: str) -> str:
 
56
 
57
 
58
  # ---------------------------------------------------------------------------
59
+ # the one flow
60
  # ---------------------------------------------------------------------------
61
 
62
 
63
+ def forge(
64
+ subject,
65
+ uploaded,
66
+ style,
67
+ backend_label,
68
+ canvas_label,
69
+ selected,
70
+ duration,
71
+ steps,
72
+ seed,
73
+ tolerance,
74
+ softness,
75
+ despill,
76
+ despeckle,
77
+ playback_fps,
78
+ sprite_height,
79
+ request: gr.Request,
80
+ ):
81
+ """Character -> one clip per move -> keyed extremes -> packed sheet -> playable demo, as one generator.
82
+
83
+ Yields on every step so the demo panel holds Gradio's pending state from the moment the button is pressed rather
84
+ than only lighting up at the end of a multi-minute run.
 
 
 
 
 
 
85
  """
86
+ if not uploaded and not (subject or "").strip():
87
+ raise gr.Error("Describe a character, or upload one.")
88
  if not selected:
89
+ raise gr.Error("Pick at least one move under Advanced.")
90
 
91
  ip_token = backends.ip_token_of(request)
92
+ presets = [move_defs.BY_LABEL[label] for label in selected]
93
+
94
+ # ---- 1. the character design still ----
95
+ if uploaded:
96
+ character = uploaded
97
+ yield PENDING, character, [], None, [], None, "Using your uploaded character."
98
+ else:
99
+ yield PENDING, None, [], None, [], None, "Designing the character…"
100
+ backend = backends.BY_LABEL[backend_label]
101
+ _, width, height = backends.CANVASES[canvas_label]
 
 
 
 
 
 
102
  try:
103
+ character = backend.generate(
104
+ move_defs.character_prompt(subject, style), width, height, int(seed), ip_token
 
 
 
 
 
 
 
105
  )
106
  except Exception as error:
107
  traceback.print_exc()
108
+ raise gr.Error(f"{backend.space} failed: {type(error).__name__}: {error}") from error
109
+ yield PENDING, character, [], None, [], None, f"Character designed. Animating {len(presets)} moves…"
110
+
111
+ # ---- 2. one clip per move, concurrently ----
112
+ # These requests are fully independent, so they are fired together: the wall clock becomes the slowest single
113
+ # clip instead of the sum. Each gets its own seed so two moves never collapse onto the same motion.
114
+ def one(index_preset):
115
+ index, preset = index_preset
116
+ return preset, backends.animate(
117
+ prompt=preset.prompt(subject if not uploaded else "the character"),
118
+ image_path=character,
119
+ canvas_label=canvas_label,
120
+ duration=float(duration),
121
+ steps=int(steps),
122
+ seed=int(seed) + index + 1,
123
+ ip_token=ip_token,
124
+ )
 
 
 
 
 
125
 
126
+ clips, failures = [], []
127
+ with ThreadPoolExecutor(max_workers=max(1, len(presets))) as pool:
128
+ for preset, result in zip(presets, pool.map(_safe(one), enumerate(presets))):
129
+ if isinstance(result, Exception):
130
+ failures.append(f"**{preset.label}**: `{type(result).__name__}: {result}`")
131
+ continue
132
+ _preset, (video, report) = result
133
+ clips.append({"key": preset.key, "label": preset.label, "video": video, "frames": preset.frames})
134
 
 
 
135
  if not clips:
136
+ raise gr.Error("Every move failed. " + (failures[0] if failures else ""))
137
 
138
+ gallery = [(clip["video"], clip["label"]) for clip in clips]
139
+ yield PENDING, character, gallery, None, [], None, f"{len(clips)} clips generated. Cutting to pose extremes…"
 
140
 
141
+ # ---- 3. key, cut to extremes, pack ----
142
+ packed, previews, key_hex = [], [], None
143
+ for clip in clips:
144
  frames, _fps = sk.read_video(clip["video"])
145
  keyed, key_rgb = sk.key_clip(
146
  frames,
 
151
  )
152
  key_hex = "#%02X%02X%02X" % tuple((key_rgb * 255).astype(int))
153
 
154
+ indices = sk.pose_extremes(keyed, int(clip["frames"]))
155
  box = sk.content_bbox(keyed)
156
  sprites = sk.fit_height([sk.to_pil(keyed[j]).crop(box) for j in indices], int(sprite_height))
157
 
158
+ packed.append(sk.Move(name=clip["key"], frames=sprites, fps=float(playback_fps)))
 
 
159
  preview = _out(f"{clip['key']}.webp")
160
  sk.save_animation(sprites, preview, fps=float(playback_fps))
161
  previews.append((preview, f"{clip['label']} · {len(sprites)} extremes"))
162
 
 
163
  sheet, atlas = sk.pack_sheet(packed)
164
  atlas["source"] = {"model": "MiniMaxAI/MiniMax-H3", "lora": "larryvrh/MiniMax-H3-Turbo-Lora", "keyed": key_hex}
165
 
 
173
  handle.write(demo_html)
174
 
175
  total = sum(len(m.frames) for m in packed)
176
+ status = (
177
+ f"**{len(packed)} moves · {total} frames** · cell {atlas['cell']['width']}×{atlas['cell']['height']} · "
178
+ f"sheet {atlas['sheet']['width']}×{atlas['sheet']['height']} · keyed `{key_hex}` · "
179
+ f"click the demo, then use the keys."
180
  )
181
+ if failures:
182
+ status += "\n\nFailed: " + "; ".join(failures)
183
+
184
+ yield (
185
+ player.as_iframe(demo_html),
186
+ character,
187
+ gallery,
188
  sheet_path,
189
  previews,
 
190
  [sheet_path, atlas_path, demo_path],
191
+ status,
192
  )
193
 
194
 
195
+ def _safe(fn):
196
+ """Let one failed move return its exception instead of cancelling the others."""
197
+
198
+ def wrapped(item):
199
+ try:
200
+ return fn(item)
201
+ except Exception as error: # noqa: BLE001 — surfaced per move in the status line
202
+ traceback.print_exc()
203
+ return error
204
+
205
+ return wrapped
206
+
207
+
208
  # ---------------------------------------------------------------------------
209
  # ui
210
  # ---------------------------------------------------------------------------
211
 
212
  CSS = """
213
+ #col-container { max-width: 1300px; margin: 0 auto; }
214
+ .dark .gradio-container { color: var(--body-text-color); }
215
  """
216
 
217
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="Sprite Forge") as demo:
218
+ with gr.Column(elem_id="col-container"):
219
+ gr.Markdown(
220
+ """
221
+ # 🕹️ Sprite Forge
222
+ Describe a character, get a **playable** one. Powered by
223
+ [MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) with the 6-step
224
+ [Turbo LoRA](https://huggingface.co/larryvrh/MiniMax-H3-Turbo-Lora), after
225
+ [@victormustar's workflow](https://x.com/victormustar/status/2089310616854892818).
226
+
227
+ One clip per move, each generated with the same first *and* last frame so it loops — then keyed, cut to
228
+ its pose extremes and packed into a sprite sheet. Runs on your own ZeroGPU quota.
229
+ """
230
+ )
 
 
231
 
232
+ with gr.Row():
 
 
 
233
  subject = gr.Textbox(
234
  label="Character",
235
  value=DEFAULT_SUBJECT,
 
236
  placeholder="a scrappy alley cat in a bomber jacket",
237
+ scale=5,
238
+ container=True,
239
  )
240
+ run = gr.Button("Generate", variant="primary", scale=1)
241
+
242
+ with gr.Accordion("Advanced", open=False):
243
  with gr.Row():
244
+ uploaded = gr.Image(
245
+ label="Or start from your own character",
246
+ type="filepath",
247
+ height=220,
248
+ scale=1,
249
  )
250
+ with gr.Column(scale=2):
251
+ selected_moves = gr.CheckboxGroup(
252
+ move_defs.LABELS, value=move_defs.DEFAULT_MOVES, label="Moves"
253
+ )
254
+ with gr.Row():
255
+ style = gr.Dropdown(
256
+ list(move_defs.STYLES), value=move_defs.DEFAULT_STYLE, label="Style"
257
+ )
258
+ image_backend = gr.Dropdown(
259
+ backends.BACKEND_LABELS, value=backends.BACKEND_LABELS[0], label="Image model"
260
+ )
261
+ with gr.Row():
262
+ canvas = gr.Dropdown(
263
+ list(backends.CANVASES), value=backends.DEFAULT_CANVAS, label="Canvas"
264
+ )
265
+ seed = gr.Number(value=1234, label="Seed", precision=0)
 
 
266
  with gr.Row():
267
+ duration = gr.Slider(
268
+ 2, 5, value=3, step=0.5, label="Clip seconds",
269
+ info="H3 generates a 5s clip per move; 3s keeps each move punchy.",
270
+ )
271
  steps = gr.Slider(4, 8, value=6, step=1, label="H3 steps")
272
+ playback_fps = gr.Slider(4, 24, value=12, step=1, label="Playback fps")
273
+ sprite_height = gr.Slider(64, 512, value=256, step=32, label="Max sprite height")
274
  with gr.Row():
 
 
 
 
 
 
 
 
 
275
  tolerance = gr.Slider(0.02, 0.30, value=0.10, step=0.01, label="Key tolerance")
276
  softness = gr.Slider(0.01, 0.25, value=0.09, step=0.01, label="Edge softness")
277
  despill = gr.Slider(0, 1, value=1.0, step=0.05, label="Despill")
278
  despeckle = gr.Slider(0, 4, value=2, step=1, label="Despeckle")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
+ demo_frame = gr.HTML(IDLE_PANEL, label="Playable")
281
+ status = gr.Markdown()
282
+
283
+ gr.Examples(EXAMPLES, inputs=subject, label="Try a character")
284
+
285
+ with gr.Accordion("Sprite sheet, clips and downloads", open=False):
286
+ with gr.Row():
287
+ character_preview = gr.Image(label="Character still", height=250)
288
+ clip_gallery = gr.Gallery(label="H3 clips", columns=3, height=250, object_fit="contain")
289
+ sheet_image = gr.Image(label="Sprite sheet (transparent PNG)", height=250)
290
+ sprite_previews = gr.Gallery(label="Looping moves", columns=5, height=210, object_fit="contain")
291
+ downloads = gr.Files(label="Download — sheet, atlas.json, standalone demo.html")
292
+
293
+ run.click(
294
+ forge,
295
+ [
296
+ subject, uploaded, style, image_backend, canvas, selected_moves, duration, steps, seed,
297
+ tolerance, softness, despill, despeckle, playback_fps, sprite_height,
298
+ ],
299
+ [demo_frame, character_preview, clip_gallery, sheet_image, sprite_previews, downloads, status],
300
  )
301
 
302
  if __name__ == "__main__":
spritekit.py CHANGED
@@ -114,19 +114,23 @@ def key_out(
114
 
115
  out = frame
116
  if despill > 0:
117
- # Spill is backdrop light bounced onto the subject: chroma displaced along the backdrop's own direction.
118
- # Remove the component parallel to it, keeping the perpendicular part that is the subject's real color.
119
  #
120
- # Weighted by `1 - alpha`, and that weight is load-bearing rather than a refinement. A magenta backdrop keys
121
- # to a chroma direction that is almost pure +Cr, and *every warm color shares it* — subtracting the full
122
- # projection everywhere turns an orange cat green, which is exactly what an unweighted version of this line
123
- # did. Real spill only lands where the backdrop shows through, so confining the correction to the matte's
124
- # transition band both fixes the hue and still cleans the fringe, which is all this needs to do on flat art.
125
- proj = np.einsum("...c,c->...", ycc[..., 1:], key_dir)
126
- excess = np.maximum(proj, 0.0) * despill * (1.0 - alpha)
127
- cleaned = ycc.copy()
128
- cleaned[..., 1:] -= excess[..., None] * key_dir
129
- out = to_rgb(cleaned)
 
 
 
 
 
130
 
131
  return np.concatenate([out, alpha[..., None]], axis=-1).astype(np.float32)
132
 
 
114
 
115
  out = frame
116
  if despill > 0:
117
+ # Un-mix the backdrop out of the edge instead of merely desaturating it.
 
118
  #
119
+ # An edge pixel is a real alpha composite of the character over the backdrop,
120
+ # ``observed = a*F + (1-a)*key``, so the character's own color is recoverable exactly:
121
+ # ``F = (observed - (1-a)*key) / a``. Solving it removes the fringe rather than trading it for a grey rim,
122
+ # and it is an identity at ``a == 1``, so opaque interior pixels are provably untouched.
123
+ #
124
+ # That last property is why this replaced a chroma-projection despill. Projection subtracts the backdrop's
125
+ # chroma direction from *every* pixel, and a magenta key points almost exactly along +Cr, which every warm
126
+ # color shares — the first version of this turned an orange cat green. Scaling it by `1 - alpha` fixed the
127
+ # hue but then only removed a fraction of the spill on the edge, leaving the 1px magenta halo this does not.
128
+ safe = np.maximum(alpha, 1e-3)[..., None]
129
+ unmixed = np.clip((frame - (1.0 - alpha)[..., None] * key_rgb) / safe, 0.0, 1.0)
130
+ # Below a few percent coverage the division is mostly amplifying noise, and those pixels are invisible
131
+ # anyway — fade the correction out rather than letting it produce confetti.
132
+ trust = np.clip(alpha / 0.15, 0.0, 1.0)[..., None] * despill
133
+ out = frame * (1.0 - trust) + unmixed * trust
134
 
135
  return np.concatenate([out, alpha[..., None]], axis=-1).astype(np.float32)
136