test / app.py
CyberWaifu's picture
Sking image-to-Minecraft-skin pipeline (AGPL-3.0)
5a362f4 verified
Raw History Blame Contribute Delete
30.9 kB
"""Gradio front end for the Sking image -> Minecraft skin pipeline.
Startup order is load-bearing:
0. import spaces installs ZeroGPU's CUDA emulation shim -- MUST be
before anything touches torch, or `.to("cuda")`
dies with "No CUDA GPUs are available".
1. build + launch Gradio immediately, so the container binds port 7860 well
inside HF's health-check window.
2. bootstrap in a thread downloads ~19 GB and verifies SHA-256 in the
background. Blocking startup on this is what caused
RUNTIME_ERROR with no traceback: the platform killed
the container before it ever became healthy.
"""
# --- 0. `spaces` MUST be imported before torch ------------------------------
try:
import spaces # type: ignore
def gpu(duration: int):
return spaces.GPU(duration=duration)
ZERO_GPU = True
except ImportError: # dedicated-GPU Docker Space, or a local run
def gpu(duration: int):
def identity(fn):
return fn
return identity
ZERO_GPU = False
import base64 # noqa: E402
import os # noqa: E402
import html # noqa: E402
import tempfile # noqa: E402
import threading # noqa: E402
import traceback # noqa: E402
from pathlib import Path # noqa: E402
import gradio as gr # noqa: E402
from PIL import Image # noqa: E402
# `weights` first: it owns HF_HOME, which huggingface_hub.constants freezes at
# import time. sking_pipeline/__init__.py now guarantees this regardless, but
# keep the order explicit so the intent survives a future "tidy up imports".
from sking_pipeline import weights # noqa: E402,F401 (isort: skip)
from sking_pipeline import stage1_flux, stage2_uv # noqa: E402
# ZeroGPU platform caps, measured live + documented:
# * a call RESERVES 1.5x the declared duration against the caller's daily
# quota before anything runs (225s requested for duration=150, etc.);
# * a FREE account may not declare more than 120s per call -- duration=300
# raises "ZeroGPU illegal duration ... larger than the maximum allowed";
# only PRO (1500s/day) or dedicated hardware may exceed it;
# * daily quota: ~60-180s anonymous, 300s signed-in free, 1500s PRO.
# Production parity (entropydrop.com sends n_step=100, guidance=4, 1024px)
# costs ~160s of GPU on the measured free path (~1.48 s/step @1024 + stage2),
# so the single-call PRO path (300s) still owns true 100-step parity. Free gets
# closer by SPLITTING Flux and stage 2 into two 120s GPU calls so Flux keeps
# the whole free envelope for itself (~70 steps @1024).
GPU_SECONDS = int(os.environ.get("SKING_GPU_SECONDS", "120")) # free Flux call
GPU_SECONDS_PROD = int(os.environ.get("SKING_GPU_SECONDS_PROD", "300")) # PRO one-shot
STAGE2_SECONDS = int(os.environ.get("SKING_STAGE2_SECONDS", "60")) # free stage-2 call
# Live zero-a10g measurement 2026-09-14 (free button log):
# 33 steps @1024 → 48.5 s wall ⇒ ~1.48 s/step (fixed overhead absorbed)
# Prior model of 1.25 s/step @768 scaled by (1024/768)^2 = 2.22 s/step was
# ~50% pessimistic and clamped free far below what the GPU actually delivers.
# Recalibrate to measured 1024 rate; scale by area for other sizes. Keep a
# small fixed cost + stage-2 estimate so the clamp still has headroom.
_FLUX_FIXED_S = float(os.environ.get("SKING_FLUX_FIXED_S", "4"))
_FLUX_PER_STEP_S_1024 = float(os.environ.get("SKING_FLUX_PER_STEP_S", "1.50"))
_STAGE2_EST_S = float(os.environ.get("SKING_STAGE2_EST_S", "25"))
# Free path: Flux alone owns the 120s call (stage 2 is a separate call).
# Use 0.90 of the envelope so a slow first-of-session step doesn't OOM the
# reservation; the kill still burns quota.
_BUDGET_FRACTION = float(os.environ.get("SKING_BUDGET_FRACTION", "0.90"))
def _per_step_s(size: int) -> float:
"""Seconds per Flux step at `size`×`size`, calibrated at 1024."""
return _FLUX_PER_STEP_S_1024 * (size / 1024.0) ** 2
def _fit_gpu_budget(
steps: int,
size: int,
budget_s: int,
clamp: bool,
*,
include_stage2: bool = True,
):
"""Optionally clamp `steps` so the run fits the declared GPU duration.
`clamp=False` is the production-parity path: runs exactly what was asked
under the (PRO/paid-only) 300s declaration.
`include_stage2=False` is the free split path: Flux owns the whole 120s
envelope and stage 2 runs as a second @spaces.GPU call, so we do not
subtract stage-2 time from the Flux budget. That is what pushes free from
~33 → ~70 steps @1024.
"""
if not clamp:
return steps, size, ""
reserved = _STAGE2_EST_S if include_stage2 else 0.0
budget = budget_s * _BUDGET_FRACTION - _FLUX_FIXED_S - reserved
per_step = _per_step_s(size)
if per_step <= 0:
return steps, size, ""
max_steps = int(budget / per_step)
if steps <= max_steps:
return steps, size, ""
clamped = max(8, max_steps)
path = "single-call" if include_stage2 else "split (Flux-only envelope)"
note = (
f" · ⚠ steps clamped {steps}→{clamped} for free ZeroGPU {path} "
f"(budget {budget_s}s, ~{per_step:.2f}s/step @ {size}px). "
f"True production parity is 100 steps @1024 on the PRO button."
)
return clamped, size, note
# ---------------------------------------------------------------------------
# background bootstrap
# ---------------------------------------------------------------------------
STATE = {
"stage": "starting",
"log": [],
"ready": False,
"stage1": False,
"boot": None,
"error": None,
"last_error": None,
}
_LOCK = threading.Lock()
def _progress(msg: str) -> None:
if STATE.get("halt_logging"):
return
with _LOCK:
STATE["log"].append(msg)
if len(STATE["log"]) > 200:
del STATE["log"][:100]
print(msg, flush=True)
def _bootstrap() -> None:
"""Download, verify and load the models -- at startup, on the main thread.
Both constraints are hard and they pull in opposite directions:
* ZeroGPU's CUDA emulation only intercepts `.to("cuda")` during *startup*,
before the app begins serving. Loading later -- from a worker thread, or
after `launch()` -- falls through to the real `torch._C._cuda_init`, which
the `spaces` patch refuses: "Low-level CUDA init reached ... emulation mode
did not intercept a CUDA operation".
* So the weights must be resident before `launch()` is called.
Downloads are fast on HF's Xet cache (~900 MB/s), so ~19 GB costs seconds;
the model materialisation dominates. Failure here is recorded, not fatal:
stage 2 still serves.
"""
try:
boot = weights.bootstrap(progress=_progress)
except BaseException: # noqa: BLE001
with _LOCK:
STATE["error"] = traceback.format_exc()
STATE["stage"] = "error"
print(STATE["error"], flush=True)
return
with _LOCK:
STATE["boot"] = boot
# Stage 2 needs nothing but the verified bundle, so publish readiness
# here. A stage-1 failure must not take the working half of the app
# down with it -- that bug left the Render tab reporting "still
# loading" even though all 127 files had verified.
STATE["ready"] = True
if boot.get("stage1_viable") and boot.get("flux"):
try:
_progress("loading FLUX.2 [klein] 4B Base during startup")
stage1_flux.load(boot["flux"], boot["lora"], progress=_progress)
with _LOCK:
STATE["stage1"] = True
_progress("stage 1 ready")
except BaseException: # noqa: BLE001 - stage 2 keeps working
with _LOCK:
STATE["error"] = traceback.format_exc()
print(STATE["error"], flush=True)
else:
_progress(f"stage 1 skipped: {boot.get('stage1_reason')}")
with _LOCK:
STATE["stage"] = "ready"
_progress("READY")
def _bundle():
boot = STATE.get("boot")
if not boot:
raise gr.Error("Models are still downloading — see the status panel.")
return boot
def _require_stage1():
if not STATE["ready"]:
raise gr.Error("Models are still downloading — see the status panel.")
if not STATE["stage1"]:
raise gr.Error(
"Stage 1 (FLUX.2 [klein] 4B) is not available on this hardware. "
"Use the 'Render → Skin' tab, which runs the identical production "
"UV parser on a render you supply."
)
def _record_endpoint_error(where: str) -> None:
"""Capture an endpoint traceback and surface it to the *caller*.
Two channels are needed because both passive ones fail here:
* HF's run-log endpoint is a rolling SSE window saturated by Xet progress
bars, and errorMessage is only a tail slice -- both truncated the real
exception mid-frame, repeatedly.
* ZeroGPU executes @spaces.GPU tasks in a forked worker, so writes to our
module-level STATE never reach the parent process that serves
/status_md. Recording there looked like "nothing was captured".
So: print for the log, store for the panel, and re-raise as gr.Error whose
message is the traceback -- that one always reaches the client.
"""
tb = f"[{where}]\n" + traceback.format_exc()
with _LOCK:
STATE["last_error"] = tb
print(tb, flush=True)
raise gr.Error(tb[-1800:])
def status_md() -> str:
with _LOCK:
stage, err, log = STATE["stage"], STATE["error"], list(STATE["log"])
s1, ready = STATE["stage1"], STATE["ready"]
last = STATE.get("last_error")
icon = {"starting": "⏳", "ready": "✅", "error": "❌"}.get(stage, "•")
tail = "\n".join(f" {line}" for line in log[-14:])
head = (
f"### {icon} Status: `{stage}`\n\n"
f"- stage 1 (Flux + LoRA): **{'available' if s1 else 'not loaded'}**\n"
f"- stage 2 (SKING_DDJ_v104): **{'available' if ready else 'loading'}**\n"
f"- ZeroGPU: **{'yes' if ZERO_GPU else 'no (dedicated/local)'}**\n"
)
if err:
head += f"\n**Startup error**\n\n```\n{err.strip()[-2000:]}\n```\n"
if last:
head += f"\n**Last request error**\n\n```\n{last.strip()[-2500:]}\n```\n"
return head + f"\n<details><summary>startup log</summary>\n\n```\n{tail}\n```\n</details>"
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
_OUT_DIR = Path(tempfile.gettempdir()) / "sking-out"
_OUT_DIR.mkdir(parents=True, exist_ok=True)
SKIN_OUT = _OUT_DIR / "skin.png"
RENDER_OUT = _OUT_DIR / "render.png"
def _upscale(img: Image.Image, factor: int = 8) -> Image.Image:
w, h = img.size
return img.resize((w * factor, h * factor), Image.NEAREST)
def _empty_viewer() -> str:
return (
'<div style="height:380px;background:#141414;border-radius:8px;display:flex;'
'align-items:center;justify-content:center;color:#777;font:13px/1.5 '
'ui-sans-serif,system-ui,sans-serif;">3D preview appears here after you '
'generate a skin</div>'
)
def _viewer_iframe(skin_b64: str) -> str:
"""3D preview via skinview3d inside an iframe srcdoc.
Gradio updates gr.HTML through innerHTML, and browsers do not execute
<script> inserted that way; a srcdoc document is parsed normally, so its
scripts do run. Degrades to a blank box if the CDN is unreachable.
"""
if not skin_b64:
return _empty_viewer()
doc = f"""<!doctype html>
<html><head><meta charset="utf-8"><style>
html,body{{margin:0;padding:0;background:#141414;height:100%;overflow:hidden}}
#c{{width:100vw;height:100vh}}
#e{{color:#f88;font:12px/1.5 ui-monospace,monospace;padding:12px}}
</style></head>
<body><div id="c"></div><div id="e"></div>
<script type="module">
try {{
const s = await import("https://cdn.jsdelivr.net/npm/skinview3d@3.4.2/+esm");
const host = document.getElementById("c");
const v = new s.SkinViewer({{width: host.clientWidth, height: host.clientHeight}});
v.animation = new s.IdleAnimation();
await v.loadSkin("data:image/png;base64,{skin_b64}");
}} catch (err) {{
document.getElementById("e").textContent = "3D preview unavailable: " + err;
}}
</script></body></html>"""
return (
'<iframe srcdoc="' + html.escape(doc, quote=True) + '" '
'style="width:100%;height:380px;border:0;border-radius:8px;background:#141414;" '
'sandbox="allow-scripts"></iframe>'
)
def _as_pil(x):
"""Coerce whatever Gradio hands us into a PIL Image.
`gr.Image(type="pil")` normally preprocesses to a PIL Image, but API clients
(gradio_client / curl) can surface a path, a dict, or a file-like instead --
and `img.size` on a str is an AttributeError with no useful context.
"""
if x is None:
return None
if isinstance(x, Image.Image):
return x
if isinstance(x, (str, Path)):
return Image.open(x)
if isinstance(x, dict):
ref = x.get("path") or x.get("url") or x.get("name")
if ref:
return Image.open(ref)
name = getattr(x, "name", None)
if isinstance(name, (str, Path)):
return Image.open(name)
raise gr.Error(f"Unsupported image input: {type(x).__name__}")
def _validate_square(img: Image.Image) -> Image.Image:
"""Coerce an upload into the v104 contract: even square, 256..4096 px."""
w, h = img.size
side = min(w, h)
left, top = (w - side) // 2, (h - side) // 2
sq = img.crop((left, top, left + side, top + side))
if side % 2:
side -= 1
sq = sq.crop((0, 0, side, side))
side = max(256, min(side, 4096))
if sq.size[0] != side:
sq = sq.resize((side, side), Image.LANCZOS)
return sq.convert("RGB")
# ---------------------------------------------------------------------------
# pipeline
# ---------------------------------------------------------------------------
def _pack_outputs(render: Image.Image, steps: int, guidance: float,
seed: int, clamp_note: str, path_tag: str):
"""Shared post-stage-2 packaging: skin file + 8x preview + 3D viewer."""
skin = Image.open(SKIN_OUT).convert("RGBA")
skin.load()
with open(SKIN_OUT, "rb") as fh:
skin_b64 = base64.b64encode(fh.read()).decode()
info = (
f"64×64 RGBA · {SKIN_OUT.stat().st_size} bytes · "
f"seed {int(seed)} · {steps} steps · guidance {float(guidance):.1f} · "
f"SKING_DDJ_v104 · {path_tag}{clamp_note}"
)
return render, _upscale(skin, 8), str(SKIN_OUT), info, _viewer_iframe(skin_b64)
@gpu(GPU_SECONDS)
def _stage1_free(image, seed, steps, guidance, size):
"""Free-tier Flux only. Owns the full 120s envelope (no stage 2).
Returns (render, steps_used, guidance, seed, clamp_note) so the chained
stage-2 call — which may run in a *different* ZeroGPU worker with no
shared /tmp or STATE — gets everything it needs through Gradio's
serialised outputs rather than the filesystem.
"""
try:
image = _as_pil(image)
_require_stage1()
if image is None:
raise gr.Error("Upload a reference image first.")
steps, size, clamp_note = _fit_gpu_budget(
int(steps), int(size), GPU_SECONDS, True, include_stage2=False,
)
_progress(
f"stage1_free type={type(image).__name__} steps={steps} "
f"size={size} budget={GPU_SECONDS}s"
)
render = stage1_flux.generate(
image, seed=int(seed), steps=steps,
guidance=float(guidance), size=size,
)
# Best-effort local cache (useful on dedicated GPU / same-worker runs).
try:
render.save(RENDER_OUT, format="PNG")
except Exception: # noqa: BLE001
pass
return render, int(steps), float(guidance), int(seed), clamp_note
except gr.Error:
raise
except Exception:
_record_endpoint_error("stage1_free")
@gpu(STAGE2_SECONDS)
def _stage2_free(render, steps, guidance, seed, clamp_note):
"""Free-tier stage 2. Takes the render + knobs from `_stage1_free` via Gradio."""
try:
if not STATE["ready"]:
raise gr.Error(
"Stage 2 is still loading its 2.58 GB bundle — check the status "
"panel and refresh."
)
render = _as_pil(render)
if render is None:
raise gr.Error(
"Stage 1 returned no render — try Generate again, or use the "
"Render → Skin tab with a saved front/back sheet."
)
boot = _bundle()
# Write the Gradio-delivered render to a worker-local path; do NOT rely
# on RENDER_OUT from stage 1 (ZeroGPU workers do not share /tmp).
local_render = _OUT_DIR / f"free_render_{os.getpid()}.png"
_validate_square(render).save(local_render, format="PNG")
_progress(
f"stage2_free pid={os.getpid()} render={local_render} "
f"size={local_render.stat().st_size} steps={steps}"
)
stage2_uv.render_to_uv(
local_render, SKIN_OUT, boot["bundle"], release=boot.get("release")
)
try:
local_render.unlink(missing_ok=True)
except Exception: # noqa: BLE001
pass
steps_i = int(steps) if steps is not None else 0
guidance_f = float(guidance) if guidance is not None else 4.0
seed_i = int(seed) if seed is not None else 0
note = clamp_note if isinstance(clamp_note, str) else ""
return _pack_outputs(
render.convert("RGB") if render.mode != "RGB" else render,
steps_i, guidance_f, seed_i, note,
path_tag=f"free-split · {steps_i}st",
)
except gr.Error:
raise
except Exception:
_record_endpoint_error("stage2_free")
@gpu(GPU_SECONDS)
def image_to_skin(image, seed, steps, guidance, size):
"""Free-tier single-call fallback (API clients / old bookmarks).
UI free button uses the split path (`_stage1_free` → `_stage2_free`) which
keeps ~70 steps @1024. This oneshot still works but clamps harder (~45
@1024) because Flux and stage 2 share one 120s envelope.
"""
try:
return _image_to_skin_oneshot(
image, seed, steps, guidance, size,
budget_s=GPU_SECONDS, clamp=True, path_tag="free-oneshot",
)
except gr.Error:
raise
except Exception:
_record_endpoint_error("image_to_skin")
@gpu(GPU_SECONDS_PROD)
def image_to_skin_production(image, seed, steps, guidance, size):
"""Production parity: exactly what entropydrop.com sends (100 steps @1024).
Measured cost ~160s GPU (100 × 1.48 s/step @1024 + stage 2), reserved 450s
under the 1.5x rule -- beyond BOTH the free per-call cap (120s) and the free
daily quota (300s). Free accounts are refused by the platform with
"ZeroGPU illegal duration"; PRO (1500s/day) and dedicated-GPU Spaces run it
unclamped.
"""
try:
return _image_to_skin_oneshot(
image, seed, steps, guidance, size,
budget_s=GPU_SECONDS_PROD, clamp=False, path_tag="production-parity",
)
except gr.Error:
raise
except Exception:
_record_endpoint_error("image_to_skin_production")
def _image_to_skin_oneshot(image, seed, steps, guidance, size, budget_s, clamp,
path_tag: str):
"""Body of a single @spaces.GPU call doing Flux + stage 2.
Called FROM an already-decorated endpoint (image_to_skin /
image_to_skin_production). Do not decorate this helper itself -- nesting
@spaces.GPU is undefined.
"""
_progress(f"image_to_skin_oneshot type={type(image).__name__} "
f"budget={budget_s}s clamp={clamp} tag={path_tag}")
image = _as_pil(image)
_require_stage1()
if image is None:
raise gr.Error("Upload a reference image first.")
boot = _bundle()
steps, size, clamp_note = _fit_gpu_budget(
int(steps), int(size), budget_s, clamp, include_stage2=True,
)
render = stage1_flux.generate(
image, seed=int(seed), steps=steps,
guidance=float(guidance), size=size,
)
render.save(RENDER_OUT, format="PNG")
stage2_uv.render_to_uv(
RENDER_OUT, SKIN_OUT, boot["bundle"], release=boot.get("release")
)
return _pack_outputs(render, steps, guidance, seed, clamp_note, path_tag)
@gpu(STAGE2_SECONDS)
def render_to_skin_only(render):
"""Feed a combined front/back render straight into v104."""
try:
return _render_to_skin_impl(render)
except gr.Error:
raise
except Exception:
_record_endpoint_error("render_to_skin_only")
def _render_to_skin_impl(render):
_progress(f"render_to_skin input type={type(render).__name__}")
render = _as_pil(render)
if not STATE["ready"]:
raise gr.Error(
"Stage 2 is still loading its 2.58 GB bundle — check the status "
"panel and refresh. This usually takes 1-3 minutes."
)
if render is None:
raise gr.Error("Upload a combined front/back render (even square, 256-4096 px).")
boot = _bundle()
src = _OUT_DIR / "debug_render.png"
_validate_square(render).save(src, format="PNG")
out = _OUT_DIR / "debug_skin.png"
stage2_uv.render_to_uv(src, out, boot["bundle"], release=boot.get("release"))
skin = Image.open(out).convert("RGBA")
skin.load()
with open(out, "rb") as fh:
skin_b64 = base64.b64encode(fh.read()).decode()
return _upscale(skin, 8), str(out), _viewer_iframe(skin_b64)
# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------
_CSS = """
footer { display: none !important; }
.gradio-container { max-width: 1180px !important; }
"""
_CREDITS = """
---
### Credits and license
Model, dataset and pipeline © **EntropyDrop** (Shanghai EqualRank Technology
Co., Ltd.) — [entropydrop.com](https://entropydrop.com) ·
[huggingface.co/EntropyDrop](https://huggingface.co/EntropyDrop) ·
[github.com/EntropyDrop](https://github.com/EntropyDrop)
- `EntropyDrop/Sking` and `SKING_DDJ_v104` — **AGPL-3.0**. This Space is
therefore AGPL-3.0 too; its complete source is this public repository.
- `black-forest-labs/FLUX.2-klein-base-4B` — **Apache-2.0**.
- BiRefNet and SigLIP2 keep their own licenses; see `NOTICE.md`.
- The images in `testdata/` are real entropydrop.com generations, **CC BY-NC 4.0**.
Not affiliated with or endorsed by EntropyDrop, Mojang or Microsoft.
Uploads are processed in memory and are not stored or used for training.
"""
# theme/css go to launch(), not here: Gradio 6 relocated them and warns loudly.
with gr.Blocks(title="Sking — Image to Minecraft Skin") as demo:
gr.Markdown(
"""
# Sking · image → Minecraft skin
An open reimplementation of **EntropyDrop**'s two-stage pipeline.
**Stage 1** — FLUX.2 [klein] 4B Base + the `sking_v73` LoRA turns a
reference image into a combined front/back character render.
**Stage 2** — `SKING_DDJ_v104` (BiRefNet foreground + Dense UV Parser)
converts that render into a real **64×64 RGBA** skin with inner and
outer layers, ready to drop into Minecraft.
> ⏳ **Models download in the background on first start** (~19 GB, 1–4
> min). Watch the status panel below and hit *Refresh* — the UI is live
> before the weights are.
>
> ⚖️ **Two generate buttons, because true 100-step parity is a paid
> feature.** entropydrop.com runs **100 steps @ 1024**. Free ZeroGPU
> forbids declaring more than **120 s per call**. The free button
> **splits** Flux and stage 2 into two GPU calls so Flux keeps the
> whole 120 s envelope — measured ceiling **~70 steps @1024** /
> **~100 steps @768** (was ~33 before the split). The *production
> parity* button declares 300 s and runs 100 steps unclamped (PRO or
> a paid GPU Space only).
"""
)
status = gr.Markdown(status_md())
refresh = gr.Button("Refresh status", size="sm")
refresh.click(fn=status_md, inputs=None, outputs=status)
with gr.Tab("Image → Skin"):
with gr.Row():
with gr.Column(scale=5):
inp = gr.Image(type="pil", label="Reference image", height=340)
seed = gr.Number(value=42, label="Seed", precision=0)
# Defaults mirror what entropydrop.com actually sends: the HAR
# shows n_step=100 and guidance=4.0 in all 185 captured records,
# and 1024px renders on the two-stage path.
steps = gr.Slider(
20, 100, value=100, step=5,
label="Inference steps (production: 100)",
)
guidance = gr.Slider(
1.0, 8.0, value=4.0, step=0.1,
label="Guidance scale (production: 4.0)",
)
size = gr.Slider(
768, 1024, value=1024, step=64,
label="Render size (production: 1024)",
info="1024 = what production emits. 768 is the v73 LoRA's "
"published training setting and ~1.8x cheaper.",
)
with gr.Row():
run_prod = gr.Button(
"Generate · production parity\n100 steps @ 1024 · needs PRO / paid GPU",
variant="primary",
)
run_free = gr.Button(
"Generate · free-tier (split)\n~70 steps @1024 · two 120s GPU calls",
variant="secondary",
)
with gr.Column(scale=7):
out_preview = gr.Image(
type="pil", label="Skin (64×64, upscaled 8×, nearest)"
)
out_render = gr.Image(
type="pil", label="Stage 1 · combined front/back render"
)
out_file = gr.File(label="skin.png (64×64 RGBA) — download this")
out_info = gr.Textbox(label="Output", interactive=False)
out_3d = gr.HTML(_empty_viewer(), label="3D preview")
_IO = [inp, seed, steps, guidance, size]
_OUT = [out_render, out_preview, out_file, out_info, out_3d]
# PRO: single 300s GPU call, unclamped (platform refuses this on free).
run_prod.click(fn=image_to_skin_production, inputs=_IO, outputs=_OUT).then(
fn=status_md, inputs=None, outputs=status
)
# FREE: chain two @spaces.GPU calls. Flux returns the render + the
# resolved knobs under its own 120s envelope (~70 steps @1024); stage 2
# consumes those Gradio-serialised values under a separate 60s envelope.
# Handing state through Gradio (not /tmp or module STATE) is load-
# bearing: ZeroGPU forks a fresh worker per @spaces.GPU call.
# Hidden components carry the non-image knobs between the two calls.
_h_steps = gr.State(0)
_h_guidance = gr.State(4.0)
_h_seed = gr.State(0)
_h_note = gr.State("")
run_free.click(
fn=_stage1_free,
inputs=_IO,
outputs=[out_render, _h_steps, _h_guidance, _h_seed, _h_note],
).then(
fn=_stage2_free,
inputs=[out_render, _h_steps, _h_guidance, _h_seed, _h_note],
outputs=_OUT,
).then(
fn=status_md, inputs=None, outputs=status
)
with gr.Tab("Render → Skin"):
gr.Markdown(
"Runs **stage 2 only** — EntropyDrop's `SKING_DDJ_v104` UV parser, "
"byte-identical to their production release. Input must be an **even "
"square between 256 and 4096 px**, left half = front view, right "
"half = back view (their `front_left` / `back_left` layout). "
"Uploads are centre-cropped to a square.\n\n"
"This tab works even when stage 1 is unavailable, and the examples "
"below are **real renders captured from entropydrop.com**, so you "
"can compare directly against their published output."
)
with gr.Row():
with gr.Column(scale=5):
d_in = gr.Image(type="pil", label="Combined front/back render", height=320)
d_run = gr.Button("Parse to skin", variant="primary")
with gr.Column(scale=7):
d_preview = gr.Image(type="pil", label="Skin (upscaled 8×)")
d_file = gr.File(label="skin.png (64×64 RGBA)")
d_3d = gr.HTML(_empty_viewer(), label="3D preview")
_fixtures = sorted(Path("testdata").glob("*_render.png"))
if _fixtures:
gr.Examples(
examples=[[str(p)] for p in _fixtures],
inputs=[d_in],
label=(
"Real production renders from entropydrop.com "
"(CC BY-NC 4.0 — see testdata/README.md)"
),
)
d_run.click(
fn=render_to_skin_only, inputs=[d_in], outputs=[d_preview, d_file, d_3d]
).then(fn=status_md, inputs=None, outputs=status)
gr.Markdown(_CREDITS)
print(f"[sking] module executed as __name__={__name__!r} ZERO_GPU={ZERO_GPU}",
flush=True)
def _launch() -> None:
"""Bind 7860. Gradio 6.27 dropped `show_api` from launch(), and `spaces`
monkeypatches `gr.Blocks.launch` to forward kwargs verbatim, so an
unsupported one is a fatal TypeError. Walk richest -> bare minimum; each
rejection happens before binding, so retrying is safe.
"""
print("[sking] calling demo.launch()", flush=True)
try:
demo.queue(default_concurrency_limit=1)
except TypeError:
demo.queue()
base = dict(server_name="0.0.0.0", server_port=7860)
attempts = [
{**base, "show_api": False, "theme": gr.themes.Soft(), "css": _CSS},
{**base, "theme": gr.themes.Soft(), "css": _CSS},
{**base, "show_api": False},
dict(base),
]
last = None
for kwargs in attempts:
try:
demo.launch(**kwargs)
print(f"[sking] demo.launch() returned (accepted {sorted(kwargs)})",
flush=True)
return
except TypeError as exc:
last = exc
print(f"[sking] launch rejected {sorted(kwargs)}: {exc}", flush=True)
raise last
_bootstrap()
_launch()