CyberWaifu commited on
Commit
7c44ffe
·
verified ·
1 Parent(s): 7282a31

Sking image-to-Minecraft-skin pipeline (AGPL-3.0)

Browse files

Two-stage reproduction of EntropyDrop's open image-to-skin path.

stage 1 FLUX.2 [klein] 4B Base + sking_v73_flux_4b_000027000 LoRA
stage 2 EntropyDrop SKING_DDJ_v104 (BiRefNet foreground + Dense UV Parser)

Stage 2 runs in-process via runpy rather than subprocess so it works on
ZeroGPU, where child processes get no CUDA context. Dependency pins are
deviated from the bundle's tested_environments.json in three places to keep
the resolver satisfiable alongside diffusers==0.40.0; see DEPLOY.md.

Files changed (2) hide show
  1. app.py +139 -112
  2. sking_pipeline/weights.py +14 -0
app.py CHANGED
@@ -1,22 +1,18 @@
1
  """Gradio front end for the Sking image -> Minecraft skin pipeline.
2
 
3
  Startup order is load-bearing:
4
- 0. import spaces (installs ZeroGPU's CUDA emulation shim -- MUST be
5
- before anything touches torch)
6
- 1. weights.bootstrap() (sets HF_HOME, downloads ~20 GB, verifies SHA-256)
7
- 2. stage1_flux.load() (imports torch/diffusers; .to("cuda") at module scope,
8
- which the emulation shim makes safe)
 
 
 
 
9
  """
10
 
11
  # --- 0. `spaces` MUST be imported before torch ------------------------------
12
- # On ZeroGPU there is no GPU at container start. The `spaces` package installs a
13
- # PyTorch CUDA *emulation* shim when it is imported, which is what allows
14
- # `.to("cuda")` at module scope to succeed and be materialised later when a real
15
- # GPU is attached inside an @spaces.GPU function. Importing torch first (or
16
- # touching cuda before this import) fails with:
17
- # UserWarning: Can't initialize NVML
18
- # RuntimeError: No CUDA GPUs are available
19
- # so this block has to stay above the weights bootstrap, which pulls in torch.
20
  try:
21
  import spaces # type: ignore
22
 
@@ -32,51 +28,109 @@ except ImportError: # dedicated-GPU Docker Space, or a local run
32
 
33
  ZERO_GPU = False
34
 
35
- # --- 1. bootstrap BEFORE any torch / transformers / diffusers import --------
36
- from sking_pipeline import weights # must come after `spaces`, before torch
37
-
38
- BOOT = weights.bootstrap(progress=lambda m: print(m, flush=True))
39
-
40
  import html # noqa: E402
41
  import tempfile # noqa: E402
 
 
42
  from pathlib import Path # noqa: E402
43
 
44
  import gradio as gr # noqa: E402
45
  from PIL import Image # noqa: E402
46
 
47
- from sking_pipeline import stage1_flux, stage2_uv # noqa: E402
48
-
49
- # --- 2. load stage 1 at module scope, if this container can hold it ---------
50
- # FLUX.2 [klein] 4B Base needs ~16 GB of host RAM (8.05 GB Mistral text encoder
51
- # + 7.75 GB transformer, held simultaneously). The free ZeroGPU container has
52
- # 16 GB total and gets OOM-killed during weight materialisation -- no traceback,
53
- # the process just dies. weights.stage1_viable() gates on measured RAM so the
54
- # Space still boots and serves stage 2, which is the byte-identical production
55
- # UV parser and fits easily.
56
- STAGE1_OK = bool(BOOT.get("stage1_viable")) and BOOT.get("flux") is not None
57
- if STAGE1_OK:
58
- stage1_flux.load(
59
- BOOT["flux"], BOOT["lora"], progress=lambda m: print(m, flush=True)
60
- )
61
- else:
62
- print(
63
- f"stage 1 disabled: {BOOT.get('stage1_reason')}\n"
64
- " stage 2 (SKING_DDJ_v104 render -> 64x64 skin) remains available.\n"
65
- " to enable stage 1, move to a GPU tier with >= 20 GiB RAM\n"
66
- " (1x L4 = 30 GiB, A10G-large = 46 GiB) or set SKING_STAGE1_MIN_RAM_GB\n"
67
- " lower to force an attempt.",
68
- flush=True,
69
- )
70
 
71
  GPU_SECONDS = 180 # Flux @ 50 steps + BiRefNet + Dense UV Parser, with headroom
72
  STAGE2_SECONDS = 120
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  # ---------------------------------------------------------------------------
76
  # helpers
77
  # ---------------------------------------------------------------------------
78
 
79
- # Fixed paths are safe because we run with default_concurrency_limit=1.
80
  _OUT_DIR = Path(tempfile.gettempdir()) / "sking-out"
81
  _OUT_DIR.mkdir(parents=True, exist_ok=True)
82
  SKIN_OUT = _OUT_DIR / "skin.png"
@@ -84,7 +138,6 @@ RENDER_OUT = _OUT_DIR / "render.png"
84
 
85
 
86
  def _upscale(img: Image.Image, factor: int = 8) -> Image.Image:
87
- """Nearest-neighbour upscale so a 64x64 skin is actually visible."""
88
  w, h = img.size
89
  return img.resize((w * factor, h * factor), Image.NEAREST)
90
 
@@ -99,12 +152,11 @@ def _empty_viewer() -> str:
99
 
100
 
101
  def _viewer_iframe(skin_b64: str) -> str:
102
- """3D preview via skinview3d, served through an iframe srcdoc.
103
 
104
- An iframe is used deliberately: Gradio updates gr.HTML through innerHTML, and
105
- browsers do not execute <script> tags inserted that way. A srcdoc document is
106
- parsed normally, so its scripts do run. Degrades to a blank box if the CDN is
107
- unreachable -- the 2D outputs are unaffected.
108
  """
109
  if not skin_b64:
110
  return _empty_viewer()
@@ -158,17 +210,10 @@ def _validate_square(img: Image.Image) -> Image.Image:
158
  @gpu(GPU_SECONDS)
159
  def image_to_skin(image, seed, steps, guidance, size):
160
  """Full pipeline: reference image -> edited render -> 64x64 RGBA skin."""
161
- import base64
162
-
163
- if not STAGE1_OK:
164
- raise gr.Error(
165
- "Stage 1 (FLUX.2 [klein] 4B) is disabled on this hardware: the "
166
- "container does not have enough RAM to hold the text encoder and "
167
- "transformer together. Use the 'Render → Skin (debug)' tab, which "
168
- "runs the identical production UV parser on a render you supply."
169
- )
170
  if image is None:
171
  raise gr.Error("Upload a reference image first.")
 
172
 
173
  render = stage1_flux.generate(
174
  image, seed=int(seed), steps=int(steps),
@@ -177,11 +222,10 @@ def image_to_skin(image, seed, steps, guidance, size):
177
  render.save(RENDER_OUT, format="PNG")
178
 
179
  stage2_uv.render_to_uv(
180
- RENDER_OUT, SKIN_OUT, BOOT["bundle"], release=BOOT.get("release")
181
  )
182
  skin = Image.open(SKIN_OUT).convert("RGBA")
183
  skin.load()
184
-
185
  with open(SKIN_OUT, "rb") as fh:
186
  skin_b64 = base64.b64encode(fh.read()).decode()
187
 
@@ -195,16 +239,20 @@ def image_to_skin(image, seed, steps, guidance, size):
195
 
196
  @gpu(STAGE2_SECONDS)
197
  def render_to_skin_only(render):
198
- """Debug tab: feed a combined front/back render straight into v104."""
199
- import base64
200
-
 
 
 
201
  if render is None:
202
  raise gr.Error("Upload a combined front/back render (even square, 256-4096 px).")
 
203
 
204
  src = _OUT_DIR / "debug_render.png"
205
  _validate_square(render).save(src, format="PNG")
206
  out = _OUT_DIR / "debug_skin.png"
207
- stage2_uv.render_to_uv(src, out, BOOT["bundle"], release=BOOT.get("release"))
208
 
209
  skin = Image.open(out).convert("RGBA")
210
  skin.load()
@@ -234,8 +282,8 @@ Co., Ltd.) — [entropydrop.com](https://entropydrop.com) ·
234
  - `EntropyDrop/Sking` and `SKING_DDJ_v104` — **AGPL-3.0**. This Space is
235
  therefore AGPL-3.0 too; its complete source is this public repository.
236
  - `black-forest-labs/FLUX.2-klein-base-4B` — **Apache-2.0**.
237
- - BiRefNet and SigLIP2 keep their own licenses; see `NOTICE.md` and the
238
- bundle's `THIRD_PARTY_NOTICES.md`.
239
 
240
  Not affiliated with or endorsed by EntropyDrop, Mojang or Microsoft.
241
  Uploads are processed in memory and are not stored or used for training.
@@ -250,51 +298,25 @@ with gr.Blocks(
250
  """
251
  # Sking · image → Minecraft skin
252
 
253
- An open reimplementation of **EntropyDrop**'s two-stage pipeline, running
254
- on free ZeroGPU.
255
 
256
- **Stage 1** — FLUX.2 [klein] 4B Base + the `sking_v73` LoRA turns your
257
  reference image into a combined front/back character render.
258
  **Stage 2** — `SKING_DDJ_v104` (BiRefNet foreground + Dense UV Parser)
259
- converts that render into a real **64×64 RGBA** skin with inner and outer
260
- layers, ready to drop into Minecraft.
261
-
262
- > ⏱️ Free ZeroGPU quota is **5 minutes/day**. A 50-step run costs roughly
263
- > 2–3 minutes of GPU time, so expect one or two skins per day. Lower the
264
- > step count to stretch it; PRO ($9/mo) gets 8× the quota.
265
- >
266
- > 🐢 First load after a rebuild re-downloads the weights and can take
267
- > several minutes before the UI appears.
268
  """
269
  )
270
 
271
- if not STAGE1_OK:
272
- gr.Markdown(
273
- f"""
274
- > ### ⚠️ Stage 1 is disabled on this hardware
275
- >
276
- > {BOOT.get('stage1_reason','')}
277
- >
278
- > FLUX.2 [klein] 4B Base holds an **8.05 GB** Mistral text encoder and
279
- > a **7.75 GB** transformer in host memory at the same time. The text
280
- > encoder cannot be dropped or cached — FLUX.2 conditions on your
281
- > reference *image* through it, so its output differs per request.
282
- > Loading it here gets the container OOM-killed.
283
- >
284
- > **Still fully working:** the *Render → Skin* tab below runs
285
- > `SKING_DDJ_v104`, which is byte-identical to EntropyDrop's
286
- > production UV parser. Feed it any combined front/back render —
287
- > including the real production examples provided — and it returns a
288
- > genuine 64×64 RGBA skin.
289
- >
290
- > **To enable stage 1:** Settings → Space hardware → **1× L4**
291
- > ($0.80/hr, 30 GiB RAM) or **A10G-large** ($1.50/hr, 46 GiB), then
292
- > restart. Nothing else needs to change; the code detects the RAM
293
- > and turns stage 1 on.
294
- """
295
- )
296
 
297
- with gr.Tab("Image → Skin" if STAGE1_OK else "Image → Skin (unavailable)"):
298
  with gr.Row():
299
  with gr.Column(scale=5):
300
  inp = gr.Image(type="pil", label="Reference image", height=340)
@@ -329,15 +351,18 @@ with gr.Blocks(
329
  fn=image_to_skin,
330
  inputs=[inp, seed, steps, guidance, size],
331
  outputs=[out_render, out_preview, out_file, out_info, out_3d],
332
- )
333
 
334
- with gr.Tab("Render → Skin (debug)"):
335
  gr.Markdown(
336
- "Skip stage 1 and feed the v104 parser directly. Input must be an "
337
- "**even square between 256 and 4096 px**, left half = front view, "
338
- "right half = back view — the layout EntropyDrop calls `front_left` "
339
- "and `back_left`. Handy for parsing their own published renders, or "
340
- "yours from another generator. Uploads are centre-cropped to a square."
 
 
 
341
  )
342
  with gr.Row():
343
  with gr.Column(scale=5):
@@ -354,18 +379,20 @@ with gr.Blocks(
354
  examples=[[str(p)] for p in _fixtures],
355
  inputs=[d_in],
356
  label=(
357
- "Real production renders captured from entropydrop.com "
358
  "(CC BY-NC 4.0 — see testdata/README.md)"
359
  ),
360
  )
361
  d_run.click(
362
  fn=render_to_skin_only, inputs=[d_in], outputs=[d_preview, d_file, d_3d]
363
- )
364
 
365
  gr.Markdown(_CREDITS)
366
 
367
 
368
  if __name__ == "__main__":
 
 
369
  demo.queue(default_concurrency_limit=1).launch(
370
  server_name="0.0.0.0", server_port=7860, show_api=False
371
  )
 
1
  """Gradio front end for the Sking image -> Minecraft skin pipeline.
2
 
3
  Startup order is load-bearing:
4
+ 0. import spaces installs ZeroGPU's CUDA emulation shim -- MUST be
5
+ before anything touches torch, or `.to("cuda")`
6
+ dies with "No CUDA GPUs are available".
7
+ 1. build + launch Gradio immediately, so the container binds port 7860 well
8
+ inside HF's health-check window.
9
+ 2. bootstrap in a thread downloads ~19 GB and verifies SHA-256 in the
10
+ background. Blocking startup on this is what caused
11
+ RUNTIME_ERROR with no traceback: the platform killed
12
+ the container before it ever became healthy.
13
  """
14
 
15
  # --- 0. `spaces` MUST be imported before torch ------------------------------
 
 
 
 
 
 
 
 
16
  try:
17
  import spaces # type: ignore
18
 
 
28
 
29
  ZERO_GPU = False
30
 
31
+ import base64 # noqa: E402
 
 
 
 
32
  import html # noqa: E402
33
  import tempfile # noqa: E402
34
+ import threading # noqa: E402
35
+ import traceback # noqa: E402
36
  from pathlib import Path # noqa: E402
37
 
38
  import gradio as gr # noqa: E402
39
  from PIL import Image # noqa: E402
40
 
41
+ from sking_pipeline import stage1_flux, stage2_uv, weights # noqa: E402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  GPU_SECONDS = 180 # Flux @ 50 steps + BiRefNet + Dense UV Parser, with headroom
44
  STAGE2_SECONDS = 120
45
 
46
+ # ---------------------------------------------------------------------------
47
+ # background bootstrap
48
+ # ---------------------------------------------------------------------------
49
+
50
+ STATE = {
51
+ "stage": "starting",
52
+ "log": [],
53
+ "ready": False,
54
+ "stage1": False,
55
+ "boot": None,
56
+ "error": None,
57
+ }
58
+ _LOCK = threading.Lock()
59
+
60
+
61
+ def _progress(msg: str) -> None:
62
+ with _LOCK:
63
+ STATE["log"].append(msg)
64
+ if len(STATE["log"]) > 200:
65
+ del STATE["log"][:100]
66
+ print(msg, flush=True)
67
+
68
+
69
+ def _bootstrap() -> None:
70
+ try:
71
+ boot = weights.bootstrap(progress=_progress)
72
+ with _LOCK:
73
+ STATE["boot"] = boot
74
+ if boot.get("stage1_viable") and boot.get("flux"):
75
+ _progress("loading FLUX.2 [klein] 4B Base (background thread)")
76
+ stage1_flux.load(boot["flux"], boot["lora"], progress=_progress)
77
+ with _LOCK:
78
+ STATE["stage1"] = True
79
+ else:
80
+ _progress(f"stage 1 skipped: {boot.get('stage1_reason')}")
81
+ with _LOCK:
82
+ STATE["stage"] = "ready"
83
+ STATE["ready"] = True
84
+ _progress("READY")
85
+ except BaseException: # noqa: BLE001 - surface anything, incl. MemoryError
86
+ with _LOCK:
87
+ STATE["error"] = traceback.format_exc()
88
+ STATE["stage"] = "error"
89
+ print(STATE["error"], flush=True)
90
+
91
+
92
+ threading.Thread(target=_bootstrap, name="sking-bootstrap", daemon=True).start()
93
+
94
+
95
+ def _bundle():
96
+ boot = STATE.get("boot")
97
+ if not boot:
98
+ raise gr.Error("Models are still downloading — see the status panel.")
99
+ return boot
100
+
101
+
102
+ def _require_stage1():
103
+ if not STATE["ready"]:
104
+ raise gr.Error("Models are still downloading — see the status panel.")
105
+ if not STATE["stage1"]:
106
+ raise gr.Error(
107
+ "Stage 1 (FLUX.2 [klein] 4B) is not available on this hardware. "
108
+ "Use the 'Render → Skin' tab, which runs the identical production "
109
+ "UV parser on a render you supply."
110
+ )
111
+
112
+
113
+ def status_md() -> str:
114
+ with _LOCK:
115
+ stage, err, log = STATE["stage"], STATE["error"], list(STATE["log"])
116
+ s1, ready = STATE["stage1"], STATE["ready"]
117
+ icon = {"starting": "⏳", "ready": "✅", "error": "❌"}.get(stage, "•")
118
+ tail = "\n".join(f" {line}" for line in log[-14:])
119
+ head = (
120
+ f"### {icon} Status: `{stage}`\n\n"
121
+ f"- stage 1 (Flux + LoRA): **{'available' if s1 else 'not loaded'}**\n"
122
+ f"- stage 2 (SKING_DDJ_v104): **{'available' if ready else 'loading'}**\n"
123
+ f"- ZeroGPU: **{'yes' if ZERO_GPU else 'no (dedicated/local)'}**\n"
124
+ )
125
+ if err:
126
+ head += f"\n**Error**\n\n```\n{err.strip()[-2000:]}\n```\n"
127
+ return head + f"\n<details><summary>startup log</summary>\n\n```\n{tail}\n```\n</details>"
128
+
129
 
130
  # ---------------------------------------------------------------------------
131
  # helpers
132
  # ---------------------------------------------------------------------------
133
 
 
134
  _OUT_DIR = Path(tempfile.gettempdir()) / "sking-out"
135
  _OUT_DIR.mkdir(parents=True, exist_ok=True)
136
  SKIN_OUT = _OUT_DIR / "skin.png"
 
138
 
139
 
140
  def _upscale(img: Image.Image, factor: int = 8) -> Image.Image:
 
141
  w, h = img.size
142
  return img.resize((w * factor, h * factor), Image.NEAREST)
143
 
 
152
 
153
 
154
  def _viewer_iframe(skin_b64: str) -> str:
155
+ """3D preview via skinview3d inside an iframe srcdoc.
156
 
157
+ Gradio updates gr.HTML through innerHTML, and browsers do not execute
158
+ <script> inserted that way; a srcdoc document is parsed normally, so its
159
+ scripts do run. Degrades to a blank box if the CDN is unreachable.
 
160
  """
161
  if not skin_b64:
162
  return _empty_viewer()
 
210
  @gpu(GPU_SECONDS)
211
  def image_to_skin(image, seed, steps, guidance, size):
212
  """Full pipeline: reference image -> edited render -> 64x64 RGBA skin."""
213
+ _require_stage1()
 
 
 
 
 
 
 
 
214
  if image is None:
215
  raise gr.Error("Upload a reference image first.")
216
+ boot = _bundle()
217
 
218
  render = stage1_flux.generate(
219
  image, seed=int(seed), steps=int(steps),
 
222
  render.save(RENDER_OUT, format="PNG")
223
 
224
  stage2_uv.render_to_uv(
225
+ RENDER_OUT, SKIN_OUT, boot["bundle"], release=boot.get("release")
226
  )
227
  skin = Image.open(SKIN_OUT).convert("RGBA")
228
  skin.load()
 
229
  with open(SKIN_OUT, "rb") as fh:
230
  skin_b64 = base64.b64encode(fh.read()).decode()
231
 
 
239
 
240
  @gpu(STAGE2_SECONDS)
241
  def render_to_skin_only(render):
242
+ """Feed a combined front/back render straight into v104."""
243
+ if not STATE["ready"]:
244
+ raise gr.Error(
245
+ "Stage 2 is still loading its 2.58 GB bundle — check the status "
246
+ "panel and refresh. This usually takes 1-3 minutes."
247
+ )
248
  if render is None:
249
  raise gr.Error("Upload a combined front/back render (even square, 256-4096 px).")
250
+ boot = _bundle()
251
 
252
  src = _OUT_DIR / "debug_render.png"
253
  _validate_square(render).save(src, format="PNG")
254
  out = _OUT_DIR / "debug_skin.png"
255
+ stage2_uv.render_to_uv(src, out, boot["bundle"], release=boot.get("release"))
256
 
257
  skin = Image.open(out).convert("RGBA")
258
  skin.load()
 
282
  - `EntropyDrop/Sking` and `SKING_DDJ_v104` — **AGPL-3.0**. This Space is
283
  therefore AGPL-3.0 too; its complete source is this public repository.
284
  - `black-forest-labs/FLUX.2-klein-base-4B` — **Apache-2.0**.
285
+ - BiRefNet and SigLIP2 keep their own licenses; see `NOTICE.md`.
286
+ - The images in `testdata/` are real entropydrop.com generations, **CC BY-NC 4.0**.
287
 
288
  Not affiliated with or endorsed by EntropyDrop, Mojang or Microsoft.
289
  Uploads are processed in memory and are not stored or used for training.
 
298
  """
299
  # Sking · image → Minecraft skin
300
 
301
+ An open reimplementation of **EntropyDrop**'s two-stage pipeline.
 
302
 
303
+ **Stage 1** — FLUX.2 [klein] 4B Base + the `sking_v73` LoRA turns a
304
  reference image into a combined front/back character render.
305
  **Stage 2** — `SKING_DDJ_v104` (BiRefNet foreground + Dense UV Parser)
306
+ converts that render into a real **64×64 RGBA** skin with inner and
307
+ outer layers, ready to drop into Minecraft.
308
+
309
+ > ⏳ **Models download in the background on first start** (~19 GB, 1–4
310
+ > min). Watch the status panel below and hit *Refresh* — the UI is live
311
+ > before the weights are.
 
 
 
312
  """
313
  )
314
 
315
+ status = gr.Markdown(status_md())
316
+ refresh = gr.Button("Refresh status", size="sm")
317
+ refresh.click(fn=status_md, inputs=None, outputs=status)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
 
319
+ with gr.Tab("Image → Skin"):
320
  with gr.Row():
321
  with gr.Column(scale=5):
322
  inp = gr.Image(type="pil", label="Reference image", height=340)
 
351
  fn=image_to_skin,
352
  inputs=[inp, seed, steps, guidance, size],
353
  outputs=[out_render, out_preview, out_file, out_info, out_3d],
354
+ ).then(fn=status_md, inputs=None, outputs=status)
355
 
356
+ with gr.Tab("Render → Skin"):
357
  gr.Markdown(
358
+ "Runs **stage 2 only** — EntropyDrop's `SKING_DDJ_v104` UV parser, "
359
+ "byte-identical to their production release. Input must be an **even "
360
+ "square between 256 and 4096 px**, left half = front view, right "
361
+ "half = back view (their `front_left` / `back_left` layout). "
362
+ "Uploads are centre-cropped to a square.\n\n"
363
+ "This tab works even when stage 1 is unavailable, and the examples "
364
+ "below are **real renders captured from entropydrop.com**, so you "
365
+ "can compare directly against their published output."
366
  )
367
  with gr.Row():
368
  with gr.Column(scale=5):
 
379
  examples=[[str(p)] for p in _fixtures],
380
  inputs=[d_in],
381
  label=(
382
+ "Real production renders from entropydrop.com "
383
  "(CC BY-NC 4.0 — see testdata/README.md)"
384
  ),
385
  )
386
  d_run.click(
387
  fn=render_to_skin_only, inputs=[d_in], outputs=[d_preview, d_file, d_3d]
388
+ ).then(fn=status_md, inputs=None, outputs=status)
389
 
390
  gr.Markdown(_CREDITS)
391
 
392
 
393
  if __name__ == "__main__":
394
+ # Launch immediately: binding 7860 fast is what keeps HF's health check
395
+ # happy while ~19 GB downloads in the background thread.
396
  demo.queue(default_concurrency_limit=1).launch(
397
  server_name="0.0.0.0", server_port=7860, show_api=False
398
  )
sking_pipeline/weights.py CHANGED
@@ -218,6 +218,17 @@ def system_memory_gb() -> float:
218
  return min(candidates) / (1024 ** 3)
219
 
220
 
 
 
 
 
 
 
 
 
 
 
 
221
  def memory_report() -> str:
222
  """Diagnostic line for the startup log."""
223
  try:
@@ -267,6 +278,7 @@ def bootstrap(progress=print) -> dict:
267
 
268
  viable, ram, reason = stage1_viable(progress)
269
  progress(f"memory: {memory_report()}")
 
270
  progress(f"stage 1 {'ENABLED' if viable else 'DISABLED'} ({reason})")
271
 
272
  bundle = download_bundle(progress)
@@ -275,10 +287,12 @@ def bootstrap(progress=print) -> dict:
275
  f" verified {len(release['sha256'])} files "
276
  f"({release['model_version']})"
277
  )
 
278
  link_offline_cache(bundle, progress)
279
 
280
  if viable:
281
  flux_dir, lora = download_flux(progress)
 
282
  else:
283
  # Do not waste 16 GB of ephemeral disk and cold-start time on weights
284
  # this container cannot hold in memory. Stage 2 alone is the
 
218
  return min(candidates) / (1024 ** 3)
219
 
220
 
221
+ def disk_report(path: str = "/tmp") -> str:
222
+ """Free/total disk on the volume holding the weights."""
223
+ import shutil
224
+
225
+ try:
226
+ u = shutil.disk_usage(path)
227
+ return f"disk({path}) free={u.free/1024**3:.1f} GiB total={u.total/1024**3:.1f} GiB"
228
+ except OSError as exc:
229
+ return f"disk({path}) unreadable: {exc}"
230
+
231
+
232
  def memory_report() -> str:
233
  """Diagnostic line for the startup log."""
234
  try:
 
278
 
279
  viable, ram, reason = stage1_viable(progress)
280
  progress(f"memory: {memory_report()}")
281
+ progress(disk_report(str(WEIGHTS_ROOT.parent)))
282
  progress(f"stage 1 {'ENABLED' if viable else 'DISABLED'} ({reason})")
283
 
284
  bundle = download_bundle(progress)
 
287
  f" verified {len(release['sha256'])} files "
288
  f"({release['model_version']})"
289
  )
290
+ progress(" " + disk_report(str(WEIGHTS_ROOT.parent)))
291
  link_offline_cache(bundle, progress)
292
 
293
  if viable:
294
  flux_dir, lora = download_flux(progress)
295
+ progress(" " + disk_report(str(WEIGHTS_ROOT.parent)))
296
  else:
297
  # Do not waste 16 GB of ephemeral disk and cold-start time on weights
298
  # this container cannot hold in memory. Stage 2 alone is the