Hackathon-IA-VisualNovel / modal_app.py
WillHbx's picture
fix(painter, modal): fp16-safe VAE, rembg session reuse, scaledown window
fe6747e
Raw
History Blame Contribute Delete
11 kB
"""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)
# --------------------------------------------------------------------------- #
@app.function(image=llm_image, volumes={LLM_DIR: llm_volume}, timeout=600, secrets=[hf_secret])
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}")
@app.function(
image=painter_image, volumes={IMAGE_DIR: image_volume}, timeout=900, secrets=[hf_secret]
)
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)
# --------------------------------------------------------------------------- #
@app.cls(
image=llm_image,
gpu="A10G",
volumes={LLM_DIR: llm_volume},
timeout=300,
scaledown_window=600, # keep warm 10 min — reloading the 15 GB GGUF mid-session is worse
)
class ModalLLMBackend:
@modal.enter()
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")
@modal.method()
def complete(self, messages: list[dict], **kw) -> str:
out = self.llm.create_chat_completion(messages=messages, **kw)
return out["choices"][0]["message"]["content"]
@modal.method()
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)
# --------------------------------------------------------------------------- #
@app.cls(
image=painter_image,
gpu="A10G",
volumes={IMAGE_DIR: image_volume},
timeout=120,
scaledown_window=600, # keep warm 10 min — avoids cold-start during a play session
)
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
"""
@modal.enter()
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")
@modal.method()
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
# =========================================================================== #
@app.local_entrypoint()
def smoke() -> None:
backend = ModalLLMBackend()
reply = backend.complete.remote(
[{"role": "user", "content": "Say hello in one word."}], max_tokens=10
)
print("LLM smoke:", reply)
@app.local_entrypoint()
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}")