Spaces:
Running on Zero
Running on Zero
| import os | |
| import random | |
| import spaces | |
| import torch | |
| from diffusers import QwenImagePipeline | |
| from fastapi.responses import HTMLResponse | |
| from gradio import Server | |
| from gradio.data_classes import FileData | |
| torch.set_float32_matmul_precision("high") | |
| MODEL_ID = "nvidia/Qwen-Image-Flash" | |
| MAX_SEED = 2**31 - 1 | |
| # Load the pipeline at module top, *not* inside @spaces.GPU. Reasons: | |
| # 1. ZeroGPU docs explicitly say lazy-loading inside @spaces.GPU is | |
| # discouraged — module-level placement uses PyTorch's CUDA emulation | |
| # mode outside the decorator, so `pipe.to("cuda")` works on the | |
| # CPU-only Space builder, and the real-CUDA transfers are optimized | |
| # for placements done at startup rather than inside a handler. | |
| # 2. Crucially, `from_pretrained` runs during *module import* — which the | |
| # Space builder executes as part of the build stage, BEFORE the runtime | |
| # container reports "Running." That means the ~20 GB checkpoint lands | |
| # in the cache during build, so the runtime container starts up with | |
| # the weights already on disk and the first request can begin GPU work | |
| # immediately rather than downloading first. | |
| pipe = QwenImagePipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16) | |
| pipe.to("cuda") | |
| def _get_duration( | |
| prompt: str, | |
| negative_prompt: str | None, | |
| width: int, | |
| height: int, | |
| num_inference_steps: int, | |
| seed: int, | |
| randomize_seed: bool, | |
| ) -> int: | |
| """Return the GPU time budget the ZeroGPU scheduler should grant. | |
| 4 denoising steps over 1024^2 is ~30-60 s on Blackwell. We size the budget | |
| loosely around `steps * 15s + 30s` (clamped 60-180 s) so larger step counts | |
| and resolutions don't get cut off, but stay under the 3-min ceiling where | |
| queue priority starts to suffer. | |
| """ | |
| steps = max(1, int(num_inference_steps)) | |
| return min(180, max(60, steps * 15 + 30)) | |
| app = Server() | |
| def generate_image( | |
| prompt: str, | |
| negative_prompt: str | None, | |
| width: int, | |
| height: int, | |
| num_inference_steps: int, | |
| seed: int, | |
| randomize_seed: bool, | |
| ) -> tuple[FileData, int, str]: | |
| """Generate an image from a text prompt with Qwen-Image-Flash. | |
| The DMD2 student internalized CFG=4.0 during distillation, so inference runs | |
| at ``true_cfg_scale=1.0`` with ``guidance_scale=None`` / ``negative_prompt=None`` | |
| to avoid applying guidance a second time. The shift-3 FlowMatch Euler | |
| scheduler produces the required 4-step trajectory. Width and height must be | |
| divisible by 16; ``1024 x 1024`` is the tested setting. | |
| """ | |
| if not prompt or not prompt.strip(): | |
| raise ValueError("Prompt cannot be empty.") | |
| # Snap to a multiple of 16 (the VAE constraint) and clamp to a sane range. | |
| width = max(256, min(2048, (int(width) // 16) * 16)) | |
| height = max(256, min(2048, (int(height) // 16) * 16)) | |
| if randomize_seed: | |
| seed = random.randint(0, MAX_SEED) | |
| seed = int(seed) % (MAX_SEED + 1) | |
| negative = negative_prompt.strip() if negative_prompt and negative_prompt.strip() else None | |
| generator = torch.Generator(device="cuda").manual_seed(seed) | |
| result = pipe( | |
| prompt=prompt, | |
| negative_prompt=negative, | |
| width=width, | |
| height=height, | |
| num_inference_steps=int(num_inference_steps), | |
| true_cfg_scale=1.0, | |
| guidance_scale=None, | |
| generator=generator, | |
| ) | |
| image = result.images[0] | |
| # Persist to disk so the queue can ship it back to the JS client as a FileData URL. | |
| out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "outputs") | |
| os.makedirs(out_dir, exist_ok=True) | |
| out_path = os.path.join(out_dir, f"qwen-image-flash-{seed}-{width}x{height}.png") | |
| image.save(out_path) | |
| info = ( | |
| f"seed={seed} | {width}x{height} | {num_inference_steps} steps | " | |
| f"true_cfg_scale=1.0" | |
| ) | |
| return FileData(path=out_path), seed, info | |
| async def homepage() -> HTMLResponse: | |
| """Serve the custom vanilla-HTML frontend.""" | |
| html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html") | |
| with open(html_path, "r", encoding="utf-8") as f: | |
| return HTMLResponse(content=f.read(), media_type="text/html") | |
| if __name__ == "__main__": | |
| app.launch(show_error=True) |