Spaces:
Running
Running
Un-mix against the raw matte (kills the last halo), real progress bar, native-res sprites, filter retry
Browse files- app.py +67 -26
- backends.py +35 -1
- moves.py +14 -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 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
| 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 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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.
|
| 101 |
frames=4,
|
|
|
|
| 102 |
),
|
| 103 |
MovePreset(
|
| 104 |
"walk",
|
|
@@ -131,9 +141,9 @@ PRESETS: list[MovePreset] = [
|
|
| 131 |
MovePreset(
|
| 132 |
"kick",
|
| 133 |
"Kick",
|
| 134 |
-
"The character
|
| 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 |
-
"""
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|