TinyNarrator / modal_workers /klein_image.py
cvpfus
Add CPU Spaces deployment with Modal runtimes
41a3f6b
Raw
History Blame Contribute Delete
7 kB
"""Modal worker for black-forest-labs/FLUX.2-klein-4B image generation.
Deploy:
modal deploy modal_workers/klein_image.py
The deployed ASGI app exposes one base URL with:
POST /generate - accepts {"prompt": str, "seed": int | None}
GET /health - reports worker readiness
GET /media/{filename} - serves generated PNG files
Tiny Narrator's app.py calls these routes through KLEIN_MODAL_ENDPOINT.
"""
import io
import os
from pathlib import Path
from uuid import uuid4
import modal
APP_NAME = "tiny-narrator-klein"
CACHE_DIR = Path("/cache")
MEDIA_DIR = Path("/outputs")
IMAGE_MODEL_ID = "black-forest-labs/FLUX.2-klein-4B"
app = modal.App(APP_NAME)
model_cache = modal.Volume.from_name("tiny-narrator-klein-cache", create_if_missing=True)
output_volume = modal.Volume.from_name("tiny-narrator-klein-outputs", create_if_missing=True)
klein_image = (
modal.Image.debian_slim(python_version="3.12")
.apt_install("git")
.pip_install(
"torch==2.9.1",
index_url="https://download.pytorch.org/whl/cu128",
)
.pip_install(
"accelerate==1.12.0",
"diffusers @ git+https://github.com/huggingface/diffusers.git",
"fastapi[standard]==0.136.3",
"huggingface-hub[hf-transfer]==0.36.0",
"pillow==12.2.0",
"safetensors>=0.8.0rc0",
"sentencepiece==0.2.1",
"transformers==4.57.3",
)
.env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
)
def _secret_names() -> list[modal.Secret]:
"""Return the fixed secrets used by Modal deployments.
Modal requires dependencies to be identical between local deploy-time import
and remote container import. Keep this list static; do not derive it from
environment variables.
"""
return [modal.Secret.from_name("tiny-narrator-klein-token")]
async def _media_file_response(filename: str):
from fastapi import HTTPException
from fastapi.responses import Response
path = MEDIA_DIR / filename
if not path.exists() or not path.is_file():
try:
await output_volume.reload.aio()
except RuntimeError as exc:
if not path.exists() or not path.is_file():
raise HTTPException(status_code=503, detail=f"Media volume reload failed: {exc}") from exc
if not path.exists() or not path.is_file():
raise HTTPException(status_code=404, detail=f"Media file not found: {filename}")
content_type = "image/png"
if filename.endswith(".webp"):
content_type = "image/webp"
elif filename.endswith((".jpg", ".jpeg")):
content_type = "image/jpeg"
return Response(content=path.read_bytes(), media_type=content_type)
@app.cls(
image=klein_image,
gpu=os.getenv("KLEIN_MODAL_GPU", "A10G"),
volumes={str(CACHE_DIR): model_cache, str(MEDIA_DIR): output_volume},
secrets=_secret_names(),
timeout=900,
scaledown_window=300,
max_containers=1,
)
class KleinImageModel:
@modal.enter()
def load(self) -> None:
import torch
from diffusers import Flux2KleinPipeline
self.model_id = os.environ.get("KLEIN_IMAGE_MODEL_ID", IMAGE_MODEL_ID)
token = os.environ.get("HF_TOKEN")
self.pipe = Flux2KleinPipeline.from_pretrained(
self.model_id,
cache_dir=str(CACHE_DIR / "huggingface"),
torch_dtype=torch.bfloat16,
token=token,
)
self.pipe.to("cuda")
@modal.method()
def generate(self, prompt: str, seed: int | None = None) -> dict:
import torch
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
generator = None
if seed is not None:
generator = torch.Generator(device="cuda").manual_seed(seed)
result = self.pipe(
prompt=prompt,
num_inference_steps=int(os.environ.get("KLEIN_MODAL_STEPS", "4")),
guidance_scale=float(os.environ.get("KLEIN_MODAL_GUIDANCE", "1.0")),
generator=generator,
)
image = result.images[0]
filename = f"klein-{uuid4().hex}.png"
output_path = MEDIA_DIR / filename
buffer = io.BytesIO()
image.save(buffer, format="PNG")
output_path.write_bytes(buffer.getvalue())
output_volume.commit()
return {
"filename": filename,
"model": self.model_id,
}
@app.function(
image=klein_image,
volumes={str(MEDIA_DIR): output_volume},
secrets=_secret_names(),
timeout=300,
)
@modal.concurrent(max_inputs=20)
@modal.asgi_app(label="tiny-narrator-klein")
def klein_api():
from fastapi import FastAPI, HTTPException, Request
import time
api = FastAPI(title="Tiny Narrator Klein Worker")
model = KleinImageModel()
def _check_token(request: Request) -> None:
"""Reject the request if a token is configured but not provided or mismatched."""
expected = os.getenv("KLEIN_MODAL_TOKEN", "")
if not expected:
return
auth_header = request.headers.get("authorization", "")
token_header = request.headers.get("x-tiny-narrator-token", "")
provided = ""
if auth_header.startswith("Bearer "):
provided = auth_header[len("Bearer "):]
elif token_header:
provided = token_header
if provided != expected:
raise HTTPException(status_code=401, detail="Unauthorized")
@api.get("/health")
async def health(request: Request) -> dict:
_check_token(request)
return {
"ok": True,
"model": IMAGE_MODEL_ID,
"runtime": "modal-klein",
}
@api.get("/media/{filename}")
async def media(filename: str):
return await _media_file_response(filename)
@api.post("/generate")
async def generate(request: Request) -> dict:
_check_token(request)
start = time.perf_counter()
body = await request.json()
prompt = str(body.get("prompt") or "").strip()
if not prompt:
raise HTTPException(status_code=400, detail="prompt is required")
seed = body.get("seed")
if seed is not None:
try:
seed = int(seed)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="seed must be an integer") from exc
result = await model.generate.remote.aio(prompt, seed)
model_id = result.get("model") or IMAGE_MODEL_ID
if model_id != IMAGE_MODEL_ID:
raise HTTPException(status_code=500, detail=f"unexpected model: {model_id}")
filename = result["filename"]
image_url = str(request.url_for("media", filename=filename))
return {
"ok": True,
"runtime": "modal-klein",
"model": IMAGE_MODEL_ID,
"image_url": image_url,
"prompt": prompt,
"seed": seed,
"elapsed_ms": round((time.perf_counter() - start) * 1000),
}
return api