CoolFace
Apppublic

build-small-hackathon/Hackathon-IA-VisualNovel

sourceHugging Faceapache-2.0updated 3mo agoView on Hugging Face
5likes
modal_app.py314 linesDownload Raw Back to root
1"""Modal backend — LLM + image generation on cloud GPUs, single app deployment.2 3Deploy once:4    modal deploy modal_app.py5 6Download models to volumes before first run:7    modal run modal_app.py::download_model8    modal run modal_app.py::download_image_model9 10Then launch locally:11    uv run python app.py12"""13 14from __future__ import annotations15 16import modal17 18hf_secret = modal.Secret.from_name("huggingface")19 20# =========================================================================== #21#  Container images22# =========================================================================== #23 24llm_image = (25    modal.Image.from_registry("nvidia/cuda:12.4.0-devel-ubuntu22.04", add_python="3.12")26    .pip_install(27        "llama-cpp-python",28        extra_options="--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124",29    )30    .pip_install("huggingface_hub")31)32 33painter_image = (34    modal.Image.from_registry("nvidia/cuda:12.4.0-devel-ubuntu22.04", add_python="3.12")35    .pip_install(36        "torch==2.5.1",37        "torchvision==0.20.1",38        extra_options="--index-url https://download.pytorch.org/whl/cu124",39    )40    .pip_install(41        "diffusers==0.35.1",42        "transformers==4.47.1",43        "accelerate==1.8.1",44        "peft",  # required by diffusers load_lora_weights / fuse_lora45        "safetensors",46        "Pillow",47        "huggingface_hub",48        "rembg",  # background removal for sprites49        "onnxruntime",  # rembg runtime dep50    )51)52 53# =========================================================================== #54#  Volumes  (model weights persist across cold starts)55# =========================================================================== #56 57llm_volume = modal.Volume.from_name("vn-models", create_if_missing=True)58LLM_DIR = "/models"59 60image_volume = modal.Volume.from_name("vn-image-models", create_if_missing=True)61IMAGE_DIR = "/image-models"62 63# =========================================================================== #64#  Single app — both classes deploy together with `modal deploy modal_app.py`65# =========================================================================== #66 67app = modal.App("vn-app")68 69GGUF_REPO = "Qwen/Qwen3-14B-GGUF"70GGUF_FILE = "Qwen3-14B-Q8_0.gguf"71# Painter: SDXL-base-1.0 + ByteDance Lightning LoRA (matches local SdxlLightningPainter)72SDXL_BASE_REPO = "stabilityai/stable-diffusion-xl-base-1.0"73SDXL_BASE_LOCAL = "sdxl-base"74LIGHTNING_LORA_REPO = "ByteDance/SDXL-Lightning"75LIGHTNING_LORA_FILE = "sdxl_lightning_4step_lora.safetensors"76 77 78# --------------------------------------------------------------------------- #79#  Download helpers (run once each)80# --------------------------------------------------------------------------- #81 82 83@app.function(image=llm_image, volumes={LLM_DIR: llm_volume}, timeout=600, secrets=[hf_secret])84def download_model() -> None:85    from huggingface_hub import hf_hub_download86 87    print(f"Downloading {GGUF_REPO}/{GGUF_FILE} ...")88    path = hf_hub_download(repo_id=GGUF_REPO, filename=GGUF_FILE, local_dir=LLM_DIR)89    llm_volume.commit()90    print(f"Saved -> {path}")91 92 93@app.function(94    image=painter_image, volumes={IMAGE_DIR: image_volume}, timeout=900, secrets=[hf_secret]95)96def download_image_model() -> None:97    """Download SDXL-base-1.0 weights (~6.5 GB) to the volume.98 99    The Lightning LoRA (ByteDance/SDXL-Lightning) is small (~400 MB) and loaded100    at inference time directly from HF Hub — no need to pre-download it.101    """102    from huggingface_hub import snapshot_download103 104    print(f"Downloading {SDXL_BASE_REPO} -> {IMAGE_DIR}/{SDXL_BASE_LOCAL} ...")105    snapshot_download(SDXL_BASE_REPO, local_dir=f"{IMAGE_DIR}/{SDXL_BASE_LOCAL}")106    image_volume.commit()107    print("SDXL-base saved to volume.")108 109 110# --------------------------------------------------------------------------- #111#  LLM backend  (llama.cpp + Qwen3-14B on A10G)112# --------------------------------------------------------------------------- #113 114 115@app.cls(116    image=llm_image,117    gpu="A10G",118    volumes={LLM_DIR: llm_volume},119    timeout=300,120    scaledown_window=600,  # keep warm 10 min — reloading the 15 GB GGUF mid-session is worse121)122class ModalLLMBackend:123    @modal.enter()124    def load(self) -> None:125        from llama_cpp import Llama126 127        self.llm = Llama(128            model_path=f"{LLM_DIR}/{GGUF_FILE}",129            n_ctx=8192,130            n_gpu_layers=-1,131            verbose=False,132        )133        print("[modal] LLM loaded on GPU")134 135    @modal.method()136    def complete(self, messages: list[dict], **kw) -> str:137        out = self.llm.create_chat_completion(messages=messages, **kw)138        return out["choices"][0]["message"]["content"]139 140    @modal.method()141    def complete_json(self, messages: list[dict], schema: dict, **kw) -> dict:142        import json143 144        prompt_chars = sum(len(m.get("content", "")) for m in messages)145        print(f"[modal] LLM complete_json: {len(messages)} msgs, {prompt_chars} chars in")146        out = self.llm.create_chat_completion(147            messages=messages,148            response_format={"type": "json_object", "schema": schema},149            temperature=kw.get("temperature", 0.7),150            top_p=kw.get("top_p", 0.9),151            max_tokens=kw.get("max_tokens", 512),152            presence_penalty=kw.get("presence_penalty", 0.0),153        )154 155        content = out["choices"][0]["message"]["content"]156        print(f"[modal] LLM complete_json: {len(content)} chars out")157        return json.loads(content)158 159 160# --------------------------------------------------------------------------- #161#  Painter backend  (SDXL-base-1.0 + Lightning LoRA on A10G)162# --------------------------------------------------------------------------- #163 164 165@app.cls(166    image=painter_image,167    gpu="A10G",168    volumes={IMAGE_DIR: image_volume},169    timeout=120,170    scaledown_window=600,  # keep warm 10 min — avoids cold-start during a play session171)172class ModalPainterBackend:173    """SDXL-base-1.0 + ByteDance SDXL-Lightning 4-step LoRA.174 175    Mirrors the local SdxlLightningPainter exactly:176      - EulerDiscreteScheduler with trailing timestep spacing177      - LoRA fused into weights (fuse_lora) for faster inference178      - guidance_scale=0.0 (required by Lightning distillation)179      - 4 inference steps180      - Optional rembg background removal for sprites181    """182 183    @modal.enter()184    def load(self) -> None:185        import torch186        from diffusers import AutoencoderKL, EulerDiscreteScheduler, StableDiffusionXLPipeline187 188        print(f"[modal] Loading SDXL-base from {IMAGE_DIR}/{SDXL_BASE_LOCAL} ...")189        # fp16-safe VAE: decodes natively in fp16 (no "weird pixels", no per-render190        # fp32 upcast like the deprecated pipe.upcast_vae() workaround)191        vae = AutoencoderKL.from_pretrained(192            "madebyollin/sdxl-vae-fp16-fix", torch_dtype=torch.float16193        )194        self.pipe = StableDiffusionXLPipeline.from_pretrained(195            f"{IMAGE_DIR}/{SDXL_BASE_LOCAL}",196            torch_dtype=torch.float16,197            variant="fp16",198            vae=vae,199        ).to("cuda")200 201        # Lightning requires EulerDiscrete with trailing timestep spacing202        self.pipe.scheduler = EulerDiscreteScheduler.from_config(203            self.pipe.scheduler.config, timestep_spacing="trailing"204        )205 206        # Load Lightning LoRA from HF Hub (~400 MB, fast)207        print(f"[modal] Loading Lightning LoRA from {LIGHTNING_LORA_REPO} ...")208        self.pipe.load_lora_weights(LIGHTNING_LORA_REPO, weight_name=LIGHTNING_LORA_FILE)209        self.pipe.fuse_lora()  # bake into weights for faster inference210 211        self.torch = torch212        print("[modal] SDXL-Lightning ready on GPU")213 214    @modal.method()215    def render(216        self,217        prompt: str,218        negative_prompt: str,219        seed: int,220        size: int,221        steps: int,222        guidance_scale: float = 0.0,223        remove_bg: bool = False,224    ) -> bytes:225        """Generate an image and return PNG bytes.226 227        Args:228            prompt: Positive prompt.229            negative_prompt: Negative prompt.230            seed: RNG seed for reproducibility.231            size: Square image side in pixels.232            steps: Inference steps (4 for Lightning).233            guidance_scale: CFG scale — 0.0 for Lightning sprites, >1.0 for backdrops.234            remove_bg: Run rembg on the output (sprites only).235        """236        import io237 238        gen = self.torch.Generator(device="cuda").manual_seed(seed)239        result = self.pipe(240            prompt=prompt,241            negative_prompt=negative_prompt or None,242            num_inference_steps=steps,243            guidance_scale=guidance_scale,244            height=size,245            width=size,246            generator=gen,247        )248        img = result.images[0]249 250        if remove_bg:251            from rembg import new_session, remove  # noqa: PLC0415252 253            # Reuse one ONNX session per container — remove() without a session254            # reloads the ~170MB u2net model on every sprite. Lazy (not in255            # @modal.enter()) so backdrop-only requests never pay for it.256            if getattr(self, "_rembg_session", None) is None:257                self._rembg_session = new_session()258            img = remove(img, session=self._rembg_session)  # returns RGBA PIL image259 260        buf = io.BytesIO()261        img.save(buf, format="PNG")  # PNG supports RGBA transparency262        return buf.getvalue()263 264 265# =========================================================================== #266#  Smoke tests267# =========================================================================== #268 269 270@app.local_entrypoint()271def smoke() -> None:272    backend = ModalLLMBackend()273    reply = backend.complete.remote(274        [{"role": "user", "content": "Say hello in one word."}], max_tokens=10275    )276    print("LLM smoke:", reply)277 278 279@app.local_entrypoint()280def smoke_painter() -> None:281    import io282 283    from PIL import Image284 285    backend = ModalPainterBackend()286 287    # Backdrop test288    png = backend.render.remote(289        prompt="Japanese anime forest, glowing mushrooms, painterly background",290        negative_prompt="text, watermark, characters, person",291        seed=42,292        size=512,293        steps=4,294        guidance_scale=0.0,295        remove_bg=False,296    )297    img = Image.open(io.BytesIO(png))298    img.save("smoke_backdrop.png")299    print(f"Backdrop smoke OK -> smoke_backdrop.png  {img.size}")300 301    # Sprite test (with rembg background removal)302    png_sprite = backend.render.remote(303        prompt="anime girl, school uniform, happy expression, white background",304        negative_prompt="text, watermark, scenery, complex background",305        seed=7,306        size=512,307        steps=4,308        guidance_scale=0.0,309        remove_bg=True,310    )311    img_sprite = Image.open(io.BytesIO(png_sprite))312    img_sprite.save("smoke_sprite.png")313    print(f"Sprite smoke OK  -> smoke_sprite.png   {img_sprite.size}  mode={img_sprite.mode}")314