multimodalart HF Staff commited on
Commit
af3622c
·
verified ·
1 Parent(s): cc1a83b

Un-mix against the raw matte (kills the last halo), real progress bar, native-res sprites, filter retry

Browse files
Files changed (4) hide show
  1. app.py +67 -26
  2. backends.py +35 -1
  3. moves.py +14 -4
  4. spritekit.py +16 -3
app.py CHANGED
@@ -20,7 +20,6 @@ import shutil
20
  import tempfile
21
  import time
22
  import traceback
23
- from concurrent.futures import ThreadPoolExecutor
24
 
25
  import gradio as gr
26
 
@@ -91,6 +90,7 @@ def forge(
91
  playback_fps,
92
  sprite_height,
93
  request: gr.Request,
 
94
  ):
95
  """Character -> one clip per move -> keyed extremes -> packed sheet -> playable demo, as one generator.
96
 
@@ -128,10 +128,11 @@ def forge(
128
  # ---- 2. one clip per move, concurrently ----
129
  # These requests are fully independent, so they are fired together: the wall clock becomes the slowest single
130
  # clip instead of the sum. Each gets its own seed so two moves never collapse onto the same motion.
131
- def one(index_preset):
132
- index, preset = index_preset
133
- return preset, backends.animate(
134
- prompt=preset.prompt(subject if not uploaded else "the character"),
 
135
  image_path=character,
136
  canvas_label=canvas_label,
137
  duration=float(duration),
@@ -140,19 +141,68 @@ def forge(
140
  ip_token=ip_token,
141
  )
142
 
143
- clips, failures = [], []
144
- with ThreadPoolExecutor(max_workers=max(1, len(presets))) as pool:
145
- for preset, result in zip(presets, pool.map(_safe(one), enumerate(presets))):
146
- if isinstance(result, Exception):
147
- failures.append(f"**{preset.label}**: `{type(result).__name__}: {result}`")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  continue
149
- _preset, (video, report) = result
150
  clips.append({
151
  "key": preset.key,
152
  "label": preset.label,
153
  "video": _adopt(video, f"{preset.key}.mp4"),
154
  "frames": preset.frames,
155
  })
 
 
 
156
 
157
  if not clips:
158
  raise gr.Error("Every move failed. " + (failures[0] if failures else ""))
@@ -162,7 +212,8 @@ def forge(
162
 
163
  # ---- 3. key, cut to extremes, pack ----
164
  packed, previews, key_hex = [], [], None
165
- for clip in clips:
 
166
  frames, _fps = sk.read_video(clip["video"])
167
  keyed, key_rgb = sk.key_clip(
168
  frames,
@@ -215,19 +266,6 @@ def forge(
215
  )
216
 
217
 
218
- def _safe(fn):
219
- """Let one failed move return its exception instead of cancelling the others."""
220
-
221
- def wrapped(item):
222
- try:
223
- return fn(item)
224
- except Exception as error: # noqa: BLE001 — surfaced per move in the status line
225
- traceback.print_exc()
226
- return error
227
-
228
- return wrapped
229
-
230
-
231
  # ---------------------------------------------------------------------------
232
  # ui
233
  # ---------------------------------------------------------------------------
@@ -293,7 +331,10 @@ with gr.Blocks(title="Sprite Forge") as demo:
293
  steps = gr.Slider(4, 8, value=6, step=1, label="H3 steps")
294
  with gr.Row():
295
  playback_fps = gr.Slider(4, 24, value=12, step=1, label="Playback fps")
296
- sprite_height = gr.Slider(64, 512, value=256, step=32, label="Max sprite height")
 
 
 
297
  with gr.Row():
298
  tolerance = gr.Slider(0.02, 0.30, value=0.10, step=0.01, label="Key tolerance")
299
  softness = gr.Slider(0.01, 0.25, value=0.09, step=0.01, label="Edge softness")
 
20
  import tempfile
21
  import time
22
  import traceback
 
23
 
24
  import gradio as gr
25
 
 
90
  playback_fps,
91
  sprite_height,
92
  request: gr.Request,
93
+ progress=gr.Progress(),
94
  ):
95
  """Character -> one clip per move -> keyed extremes -> packed sheet -> playable demo, as one generator.
96
 
 
128
  # ---- 2. one clip per move, concurrently ----
129
  # These requests are fully independent, so they are fired together: the wall clock becomes the slowest single
130
  # clip instead of the sum. Each gets its own seed so two moves never collapse onto the same motion.
131
+ who = subject if not uploaded else "the character"
132
+
133
+ def submit(preset, index, minimal=False):
134
+ return backends.animate_job(
135
+ prompt=preset.prompt(who, minimal=minimal),
136
  image_path=character,
137
  canvas_label=canvas_label,
138
  duration=float(duration),
 
141
  ip_token=ip_token,
142
  )
143
 
144
+ def await_all(pending, label):
145
+ """Poll a batch of in-flight jobs, driving the progress bar off the slowest one."""
146
+ started = time.time()
147
+ while not all(job.done() for _p, _i, job in pending):
148
+ finished = sum(1 for _p, _i, job in pending if job.done())
149
+ elapsed = time.time() - started
150
+ # All the requests start together, so the batch takes as long as its slowest member and elapsed time is
151
+ # a fair proxy for how far along that one is. The completed count is the ground truth and always wins;
152
+ # the clock only fills the gap between completions, and is capped short of 1 so the bar never claims to
153
+ # be finished before the work is.
154
+ clock = min(elapsed / max(ANIMATE_SECONDS, 1.0), 0.95)
155
+ fraction = max(finished / len(pending), clock)
156
+ progress(0.1 + 0.65 * fraction, desc=f"{label} — {finished}/{len(pending)} done")
157
+ time.sleep(0.4)
158
+ return pending
159
+
160
+ ANIMATE_SECONDS = 18 + 2.5 * int(steps) + 5.0 * float(duration)
161
+
162
+ progress(0.1, desc=f"Animating {len(presets)} moves")
163
+ inflight = [(preset, i, submit(preset, i)) for i, preset in enumerate(presets)]
164
+ await_all(inflight, f"Animating {len(presets)} moves")
165
+
166
+ clips, failures, refused = [], [], []
167
+ for preset, index, job in inflight:
168
+ try:
169
+ video, report = backends.unwrap(job.result())
170
+ except Exception as error: # noqa: BLE001 — reported per move in the status line
171
+ # The H3 Space runs a content classifier over the prompt of every keyframed request, and it is touchy in
172
+ # ways the wording does not predict — "the foot sweeping up and out" is refused where "a front kick to
173
+ # the right" is not. It runs *before* the GPU booking, so a refusal costs no quota and retrying is cheap.
174
+ if "content filter" in str(error):
175
+ refused.append((preset, index))
176
+ else:
177
+ failures.append(f"**{preset.label}**: `{type(error).__name__}: {error}`")
178
+ continue
179
+ clips.append({
180
+ "key": preset.key,
181
+ "label": preset.label,
182
+ "video": _adopt(video, f"{preset.key}.mp4"),
183
+ "frames": preset.frames,
184
+ })
185
+
186
+ if refused:
187
+ # One retry wave, plainer phrasing and no character description.
188
+ print(f"[forge] retrying {[p.key for p, _ in refused]} minimally", flush=True)
189
+ retries = [(preset, i, submit(preset, i, minimal=True)) for preset, i in refused]
190
+ await_all(retries, "Retrying refused moves")
191
+ for preset, _index, job in retries:
192
+ try:
193
+ video, report = backends.unwrap(job.result())
194
+ except Exception as error: # noqa: BLE001
195
+ failures.append(f"**{preset.label}**: `{type(error).__name__}: {error}`")
196
  continue
 
197
  clips.append({
198
  "key": preset.key,
199
  "label": preset.label,
200
  "video": _adopt(video, f"{preset.key}.mp4"),
201
  "frames": preset.frames,
202
  })
203
+ # Keep sheet order matching the order the moves were requested in.
204
+ order = {preset.key: i for i, preset in enumerate(presets)}
205
+ clips.sort(key=lambda c: order[c["key"]])
206
 
207
  if not clips:
208
  raise gr.Error("Every move failed. " + (failures[0] if failures else ""))
 
212
 
213
  # ---- 3. key, cut to extremes, pack ----
214
  packed, previews, key_hex = [], [], None
215
+ for i, clip in enumerate(clips):
216
+ progress(0.75 + 0.2 * i / len(clips), desc=f"Keying {clip['label']}")
217
  frames, _fps = sk.read_video(clip["video"])
218
  keyed, key_rgb = sk.key_clip(
219
  frames,
 
266
  )
267
 
268
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  # ---------------------------------------------------------------------------
270
  # ui
271
  # ---------------------------------------------------------------------------
 
331
  steps = gr.Slider(4, 8, value=6, step=1, label="H3 steps")
332
  with gr.Row():
333
  playback_fps = gr.Slider(4, 24, value=12, step=1, label="Playback fps")
334
+ sprite_height = gr.Slider(
335
+ 128, 768, value=512, step=32, label="Max sprite height",
336
+ info="Only ever downscales. 512 keeps a 544px clip at native size.",
337
+ )
338
  with gr.Row():
339
  tolerance = gr.Slider(0.02, 0.30, value=0.10, step=0.01, label="Key tolerance")
340
  softness = gr.Slider(0.01, 0.25, value=0.09, step=0.01, label="Edge softness")
backends.py CHANGED
@@ -124,11 +124,45 @@ def _as_path(result) -> str:
124
  return value
125
 
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  def animate(
128
  prompt: str,
129
  image_path: str,
130
  canvas_label: str,
131
- duration: float = 2.0,
132
  steps: int = 6,
133
  seed: int = 42,
134
  ip_token: str | None = None,
 
124
  return value
125
 
126
 
127
+ def animate_job(
128
+ prompt: str,
129
+ image_path: str,
130
+ canvas_label: str,
131
+ duration: float = 3.0,
132
+ steps: int = 6,
133
+ seed: int = 42,
134
+ ip_token: str | None = None,
135
+ ):
136
+ """Same request as `animate`, submitted rather than awaited, so several can be in flight and polled.
137
+
138
+ Returns a `gradio_client` Job. `.done()` and `.result()` are the whole interface the caller needs.
139
+ """
140
+ return _client(H3_SPACE, ip_token).submit(
141
+ prompt=prompt,
142
+ image_path=handle_file(image_path),
143
+ last_image_path=handle_file(image_path),
144
+ canvas=CANVASES[canvas_label][0],
145
+ duration=duration,
146
+ steps=steps,
147
+ seed=seed,
148
+ upsample=False,
149
+ use_lora=True,
150
+ lora="larry",
151
+ api_name="/generate",
152
+ )
153
+
154
+
155
+ def unwrap(result) -> tuple[str, str]:
156
+ """`(video path, the report line the H3 Space emits)` out of a raw prediction tuple."""
157
+ report = result[1] if isinstance(result, (list, tuple)) and len(result) > 1 else ""
158
+ return _as_path(result), str(report)
159
+
160
+
161
  def animate(
162
  prompt: str,
163
  image_path: str,
164
  canvas_label: str,
165
+ duration: float = 3.0,
166
  steps: int = 6,
167
  seed: int = 42,
168
  ip_token: str | None = None,
moves.py CHANGED
@@ -82,8 +82,17 @@ class MovePreset:
82
  label: str
83
  action: str
84
  frames: int = 6
 
 
85
 
86
- def prompt(self, subject: str) -> str:
 
 
 
 
 
 
 
87
  who = (subject or "the character").strip().rstrip(".,")
88
  return f"{who}. {self.action} {ANIMATION_RULES}"
89
 
@@ -97,8 +106,9 @@ PRESETS: list[MovePreset] = [
97
  "idle",
98
  "Idle",
99
  "The character stands in place with a subtle idle animation, bobbing gently up and down, "
100
- "swaying slightly, with a small head movement. The feet never leave their spot.",
101
  frames=4,
 
102
  ),
103
  MovePreset(
104
  "walk",
@@ -131,9 +141,9 @@ PRESETS: list[MovePreset] = [
131
  MovePreset(
132
  "kick",
133
  "Kick",
134
- "The character throws a high kick forward to the right, the foot sweeping up and out, "
135
- "then plants back down into the standing pose.",
136
  frames=6,
 
137
  ),
138
  MovePreset(
139
  "block",
 
82
  label: str
83
  action: str
84
  frames: int = 6
85
+ #: A plainer phrasing of the same move, used only to retry a request the content classifier refused.
86
+ fallback: str = ""
87
 
88
+ def prompt(self, subject: str, minimal: bool = False) -> str:
89
+ """The animation prompt. ``minimal`` swaps in the fallback phrasing and drops the character description.
90
+
91
+ The subject is safe to drop on a retry because H3 is *also* given the character as its conditioning
92
+ keyframe — the description is there to keep the model on-model, not to define who it is.
93
+ """
94
+ if minimal:
95
+ return f"{self.fallback or self.action} {ANIMATION_RULES}"
96
  who = (subject or "the character").strip().rstrip(".,")
97
  return f"{who}. {self.action} {ANIMATION_RULES}"
98
 
 
106
  "idle",
107
  "Idle",
108
  "The character stands in place with a subtle idle animation, bobbing gently up and down, "
109
+ "swaying slightly, with a small head movement.",
110
  frames=4,
111
+ fallback="The character holds a relaxed standing pose with a gentle breathing motion.",
112
  ),
113
  MovePreset(
114
  "walk",
 
141
  MovePreset(
142
  "kick",
143
  "Kick",
144
+ "The character performs a front kick to the right, then returns to the standing pose.",
 
145
  frames=6,
146
+ fallback="The character performs a martial arts kick to the right, then recovers.",
147
  ),
148
  MovePreset(
149
  "block",
spritekit.py CHANGED
@@ -165,9 +165,22 @@ def key_frame(
165
  shrink: float = 0.2,
166
  feather: float = 0.6,
167
  ) -> np.ndarray:
168
- """matte -> refine -> unmix, in that order. Returns RGBA float 0..1."""
169
- alpha = refine_matte(matte(frame, key_rgb, tolerance, softness), despeckle, shrink, feather)
170
- return np.concatenate([unmix(frame, alpha, key_rgb, despill), alpha[..., None]], axis=-1).astype(np.float32)
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
 
173
  def key_clip(
 
165
  shrink: float = 0.2,
166
  feather: float = 0.6,
167
  ) -> np.ndarray:
168
+ """Returns RGBA float 0..1.
169
+
170
+ The two alphas are deliberately different, and mixing them up is what leaves a pink rim:
171
+
172
+ * ``raw`` is the measured coverage, and it is the only alpha for which ``observed = a*F + (1-a)*key`` holds. It
173
+ is therefore what `unmix` must solve against.
174
+ * ``refined`` is the *output* matte — despeckled, choked, feathered. Feeding it back into the un-mixing equation
175
+ asks it to invert a composite that never happened: a choked rim pixel claims 6% coverage where the real
176
+ composite had 25%, the division extrapolates wildly, gets clipped, and the pixel stays the backdrop's color
177
+ while remaining faintly opaque. That is the halo, reintroduced one step after being removed.
178
+
179
+ So: recover color against the truth, then hand out the tidied matte.
180
+ """
181
+ raw = matte(frame, key_rgb, tolerance, softness)
182
+ refined = refine_matte(raw, despeckle, shrink, feather)
183
+ return np.concatenate([unmix(frame, raw, key_rgb, despill), refined[..., None]], axis=-1).astype(np.float32)
184
 
185
 
186
  def key_clip(