Spaces:
Running on Zero
Running on Zero
Download app.py from CyberWaifu/test: direct link, hf CLI and curl.
- Browser
- Download file 30.9 kB
-
https://huggingface.co/spaces/CyberWaifu/test/resolve/main/app.py
- Command line
-
hf download hf://spaces/CyberWaifu/test/app.py
-
curl -L -o app.py https://huggingface.co/spaces/CyberWaifu/test/resolve/main/app.py
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) | |
| 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") | |
| 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") | |
| 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") | |
| 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) | |
| 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() | |