Spaces:
Running on Zero
Running on Zero
| """Modal backend — LLM + image generation on cloud GPUs, single app deployment. | |
| Deploy once: | |
| modal deploy modal_app.py | |
| Download models to volumes before first run: | |
| modal run modal_app.py::download_model | |
| modal run modal_app.py::download_image_model | |
| Then launch locally: | |
| uv run python app.py | |
| """ | |
| from __future__ import annotations | |
| import modal | |
| hf_secret = modal.Secret.from_name("huggingface") | |
| # =========================================================================== # | |
| # Container images | |
| # =========================================================================== # | |
| llm_image = ( | |
| modal.Image.from_registry("nvidia/cuda:12.4.0-devel-ubuntu22.04", add_python="3.12") | |
| .pip_install( | |
| "llama-cpp-python", | |
| extra_options="--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124", | |
| ) | |
| .pip_install("huggingface_hub") | |
| ) | |
| painter_image = ( | |
| modal.Image.from_registry("nvidia/cuda:12.4.0-devel-ubuntu22.04", add_python="3.12") | |
| .pip_install( | |
| "torch==2.5.1", | |
| "torchvision==0.20.1", | |
| extra_options="--index-url https://download.pytorch.org/whl/cu124", | |
| ) | |
| .pip_install( | |
| "diffusers==0.35.1", | |
| "transformers==4.47.1", | |
| "accelerate==1.8.1", | |
| "peft", # required by diffusers load_lora_weights / fuse_lora | |
| "safetensors", | |
| "Pillow", | |
| "huggingface_hub", | |
| "rembg", # background removal for sprites | |
| "onnxruntime", # rembg runtime dep | |
| ) | |
| ) | |
| # =========================================================================== # | |
| # Volumes (model weights persist across cold starts) | |
| # =========================================================================== # | |
| llm_volume = modal.Volume.from_name("vn-models", create_if_missing=True) | |
| LLM_DIR = "/models" | |
| image_volume = modal.Volume.from_name("vn-image-models", create_if_missing=True) | |
| IMAGE_DIR = "/image-models" | |
| # =========================================================================== # | |
| # Single app — both classes deploy together with `modal deploy modal_app.py` | |
| # =========================================================================== # | |
| app = modal.App("vn-app") | |
| GGUF_REPO = "Qwen/Qwen3-14B-GGUF" | |
| GGUF_FILE = "Qwen3-14B-Q8_0.gguf" | |
| # Painter: SDXL-base-1.0 + ByteDance Lightning LoRA (matches local SdxlLightningPainter) | |
| SDXL_BASE_REPO = "stabilityai/stable-diffusion-xl-base-1.0" | |
| SDXL_BASE_LOCAL = "sdxl-base" | |
| LIGHTNING_LORA_REPO = "ByteDance/SDXL-Lightning" | |
| LIGHTNING_LORA_FILE = "sdxl_lightning_4step_lora.safetensors" | |
| # --------------------------------------------------------------------------- # | |
| # Download helpers (run once each) | |
| # --------------------------------------------------------------------------- # | |
| def download_model() -> None: | |
| from huggingface_hub import hf_hub_download | |
| print(f"Downloading {GGUF_REPO}/{GGUF_FILE} ...") | |
| path = hf_hub_download(repo_id=GGUF_REPO, filename=GGUF_FILE, local_dir=LLM_DIR) | |
| llm_volume.commit() | |
| print(f"Saved -> {path}") | |
| def download_image_model() -> None: | |
| """Download SDXL-base-1.0 weights (~6.5 GB) to the volume. | |
| The Lightning LoRA (ByteDance/SDXL-Lightning) is small (~400 MB) and loaded | |
| at inference time directly from HF Hub — no need to pre-download it. | |
| """ | |
| from huggingface_hub import snapshot_download | |
| print(f"Downloading {SDXL_BASE_REPO} -> {IMAGE_DIR}/{SDXL_BASE_LOCAL} ...") | |
| snapshot_download(SDXL_BASE_REPO, local_dir=f"{IMAGE_DIR}/{SDXL_BASE_LOCAL}") | |
| image_volume.commit() | |
| print("SDXL-base saved to volume.") | |
| # --------------------------------------------------------------------------- # | |
| # LLM backend (llama.cpp + Qwen3-14B on A10G) | |
| # --------------------------------------------------------------------------- # | |
| class ModalLLMBackend: | |
| def load(self) -> None: | |
| from llama_cpp import Llama | |
| self.llm = Llama( | |
| model_path=f"{LLM_DIR}/{GGUF_FILE}", | |
| n_ctx=8192, | |
| n_gpu_layers=-1, | |
| verbose=False, | |
| ) | |
| print("[modal] LLM loaded on GPU") | |
| def complete(self, messages: list[dict], **kw) -> str: | |
| out = self.llm.create_chat_completion(messages=messages, **kw) | |
| return out["choices"][0]["message"]["content"] | |
| def complete_json(self, messages: list[dict], schema: dict, **kw) -> dict: | |
| import json | |
| prompt_chars = sum(len(m.get("content", "")) for m in messages) | |
| print(f"[modal] LLM complete_json: {len(messages)} msgs, {prompt_chars} chars in") | |
| out = self.llm.create_chat_completion( | |
| messages=messages, | |
| response_format={"type": "json_object", "schema": schema}, | |
| temperature=kw.get("temperature", 0.7), | |
| top_p=kw.get("top_p", 0.9), | |
| max_tokens=kw.get("max_tokens", 512), | |
| presence_penalty=kw.get("presence_penalty", 0.0), | |
| ) | |
| content = out["choices"][0]["message"]["content"] | |
| print(f"[modal] LLM complete_json: {len(content)} chars out") | |
| return json.loads(content) | |
| # --------------------------------------------------------------------------- # | |
| # Painter backend (SDXL-base-1.0 + Lightning LoRA on A10G) | |
| # --------------------------------------------------------------------------- # | |
| class ModalPainterBackend: | |
| """SDXL-base-1.0 + ByteDance SDXL-Lightning 4-step LoRA. | |
| Mirrors the local SdxlLightningPainter exactly: | |
| - EulerDiscreteScheduler with trailing timestep spacing | |
| - LoRA fused into weights (fuse_lora) for faster inference | |
| - guidance_scale=0.0 (required by Lightning distillation) | |
| - 4 inference steps | |
| - Optional rembg background removal for sprites | |
| """ | |
| def load(self) -> None: | |
| import torch | |
| from diffusers import AutoencoderKL, EulerDiscreteScheduler, StableDiffusionXLPipeline | |
| print(f"[modal] Loading SDXL-base from {IMAGE_DIR}/{SDXL_BASE_LOCAL} ...") | |
| # fp16-safe VAE: decodes natively in fp16 (no "weird pixels", no per-render | |
| # fp32 upcast like the deprecated pipe.upcast_vae() workaround) | |
| vae = AutoencoderKL.from_pretrained( | |
| "madebyollin/sdxl-vae-fp16-fix", torch_dtype=torch.float16 | |
| ) | |
| self.pipe = StableDiffusionXLPipeline.from_pretrained( | |
| f"{IMAGE_DIR}/{SDXL_BASE_LOCAL}", | |
| torch_dtype=torch.float16, | |
| variant="fp16", | |
| vae=vae, | |
| ).to("cuda") | |
| # Lightning requires EulerDiscrete with trailing timestep spacing | |
| self.pipe.scheduler = EulerDiscreteScheduler.from_config( | |
| self.pipe.scheduler.config, timestep_spacing="trailing" | |
| ) | |
| # Load Lightning LoRA from HF Hub (~400 MB, fast) | |
| print(f"[modal] Loading Lightning LoRA from {LIGHTNING_LORA_REPO} ...") | |
| self.pipe.load_lora_weights(LIGHTNING_LORA_REPO, weight_name=LIGHTNING_LORA_FILE) | |
| self.pipe.fuse_lora() # bake into weights for faster inference | |
| self.torch = torch | |
| print("[modal] SDXL-Lightning ready on GPU") | |
| def render( | |
| self, | |
| prompt: str, | |
| negative_prompt: str, | |
| seed: int, | |
| size: int, | |
| steps: int, | |
| guidance_scale: float = 0.0, | |
| remove_bg: bool = False, | |
| ) -> bytes: | |
| """Generate an image and return PNG bytes. | |
| Args: | |
| prompt: Positive prompt. | |
| negative_prompt: Negative prompt. | |
| seed: RNG seed for reproducibility. | |
| size: Square image side in pixels. | |
| steps: Inference steps (4 for Lightning). | |
| guidance_scale: CFG scale — 0.0 for Lightning sprites, >1.0 for backdrops. | |
| remove_bg: Run rembg on the output (sprites only). | |
| """ | |
| import io | |
| gen = self.torch.Generator(device="cuda").manual_seed(seed) | |
| result = self.pipe( | |
| prompt=prompt, | |
| negative_prompt=negative_prompt or None, | |
| num_inference_steps=steps, | |
| guidance_scale=guidance_scale, | |
| height=size, | |
| width=size, | |
| generator=gen, | |
| ) | |
| img = result.images[0] | |
| if remove_bg: | |
| from rembg import new_session, remove # noqa: PLC0415 | |
| # Reuse one ONNX session per container — remove() without a session | |
| # reloads the ~170MB u2net model on every sprite. Lazy (not in | |
| # @modal.enter()) so backdrop-only requests never pay for it. | |
| if getattr(self, "_rembg_session", None) is None: | |
| self._rembg_session = new_session() | |
| img = remove(img, session=self._rembg_session) # returns RGBA PIL image | |
| buf = io.BytesIO() | |
| img.save(buf, format="PNG") # PNG supports RGBA transparency | |
| return buf.getvalue() | |
| # =========================================================================== # | |
| # Smoke tests | |
| # =========================================================================== # | |
| def smoke() -> None: | |
| backend = ModalLLMBackend() | |
| reply = backend.complete.remote( | |
| [{"role": "user", "content": "Say hello in one word."}], max_tokens=10 | |
| ) | |
| print("LLM smoke:", reply) | |
| def smoke_painter() -> None: | |
| import io | |
| from PIL import Image | |
| backend = ModalPainterBackend() | |
| # Backdrop test | |
| png = backend.render.remote( | |
| prompt="Japanese anime forest, glowing mushrooms, painterly background", | |
| negative_prompt="text, watermark, characters, person", | |
| seed=42, | |
| size=512, | |
| steps=4, | |
| guidance_scale=0.0, | |
| remove_bg=False, | |
| ) | |
| img = Image.open(io.BytesIO(png)) | |
| img.save("smoke_backdrop.png") | |
| print(f"Backdrop smoke OK -> smoke_backdrop.png {img.size}") | |
| # Sprite test (with rembg background removal) | |
| png_sprite = backend.render.remote( | |
| prompt="anime girl, school uniform, happy expression, white background", | |
| negative_prompt="text, watermark, scenery, complex background", | |
| seed=7, | |
| size=512, | |
| steps=4, | |
| guidance_scale=0.0, | |
| remove_bg=True, | |
| ) | |
| img_sprite = Image.open(io.BytesIO(png_sprite)) | |
| img_sprite.save("smoke_sprite.png") | |
| print(f"Sprite smoke OK -> smoke_sprite.png {img_sprite.size} mode={img_sprite.mode}") | |