#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.10,<3.11" # dependencies = [ # "torch==2.9.1+cu128", # "torchvision==0.24.1+cu128", # "torchaudio==2.9.1+cu128", # "diffusers @ git+https://github.com/huggingface/diffusers.git", # "transformers>=4.57.0", # "accelerate>=1.0.0", # "safetensors>=0.4.0", # "huggingface_hub>=0.25.0", # "numpy>=1.26.0", # "pillow>=10.0.0", # "fastapi>=0.115.0", # "uvicorn>=0.29.0", # ] # [tool.uv] # extra-index-url = ["https://download.pytorch.org/whl/cu128"] # /// import argparse import base64 import binascii import io import os import random import threading import time from typing import Any os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1") import torch from diffusers import Flux2KleinPipeline from fastapi import FastAPI, HTTPException from fastapi.responses import HTMLResponse, Response from pydantic import BaseModel, Field from PIL import Image def _resolve_model_size() -> str: parser = argparse.ArgumentParser(add_help=False) parser.add_argument("--model", choices=["4b", "9b"], default="9b") args, _ = parser.parse_known_args() return args.model def _checkpoint_for(model_size: str) -> str: if model_size == "4b": return "black-forest-labs/FLUX.2-klein-4B" return "black-forest-labs/FLUX.2-klein-9B" MODEL_SIZE = _resolve_model_size() CHECKPOINT = _checkpoint_for(MODEL_SIZE) USE_COMPILE = os.environ.get("FLUX2_COMPILE", "1") != "0" WARMUP_RUNS = int(os.environ.get("FLUX2_WARMUP_RUNS", "2")) WARMUP_STEPS = int(os.environ.get("FLUX2_WARMUP_STEPS", "4")) WARMUP_PROMPT = "A high-resolution photo of a red fox in natural daylight" MAX_SEED = (2**31) - 1 MAX_PIXELS = 4 * 1024 * 1024 DEFAULT_STEPS = 4 DEFAULT_GUIDANCE = 1.0 app = FastAPI(title="FLUX2 Klein Private Server") PIPE: Flux2KleinPipeline | None = None PIPE_LOCK = threading.Lock() class GenerateRequest(BaseModel): prompt: str = Field(..., min_length=1) width: int = 1024 height: int = 1024 num_inference_steps: int = DEFAULT_STEPS guidance_scale: float = DEFAULT_GUIDANCE seed: int = 0 randomize_seed: bool = False image_data_url: str | None = None class CompatRequest(BaseModel): model: str | None = None prompt: str = Field(..., min_length=1) imageDataUrl: str | None = None size: str | None = None width: int | None = None height: int | None = None num_inference_steps: int | None = None guidance_scale: float | None = None seed: int | None = None randomize_seed: bool = False response_format: str = "b64_json" n: int = 1 negative_prompt: str | None = None negativePrompt: str | None = None def _ensure_cuda() -> None: if not torch.cuda.is_available(): raise RuntimeError("CUDA is required for this server") def _compile_pipe(pipe: Flux2KleinPipeline) -> bool: if not USE_COMPILE: return False print("Compiling transformer and VAE decoder...") start = time.perf_counter() pipe.transformer = torch.compile( pipe.transformer, mode="max-autotune", dynamic=False, fullgraph=True, ) pipe.vae.decode = torch.compile( pipe.vae.decode, mode="max-autotune", dynamic=False, fullgraph=True, ) print(f"Compile finished in {time.perf_counter() - start:.2f}s") return True def _warmup_pipe(pipe: Flux2KleinPipeline) -> None: if WARMUP_RUNS <= 0: return print(f"Warmup started ({WARMUP_RUNS} runs)...") start = time.perf_counter() for run_idx in range(WARMUP_RUNS): generator = torch.Generator(device="cuda").manual_seed(run_idx) pipe( prompt=WARMUP_PROMPT, height=1024, width=1024, num_inference_steps=WARMUP_STEPS, guidance_scale=1.0, generator=generator, ).images[0] torch.cuda.synchronize() print(f"Warmup finished in {time.perf_counter() - start:.2f}s") def _load_pipe(ckpt: str) -> Flux2KleinPipeline: print(f"Loading checkpoint: {ckpt}") pipe = Flux2KleinPipeline.from_pretrained(ckpt, torch_dtype=torch.bfloat16) pipe = pipe.to("cuda") pipe.transformer.fuse_qkv_projections() pipe.vae.fuse_qkv_projections() pipe.vae.to(memory_format=torch.channels_last) pipe.transformer.set_attention_backend("_native_flash") if _compile_pipe(pipe): _warmup_pipe(pipe) return pipe def _normalize_dims(width: int, height: int) -> tuple[int, int]: if width <= 0 or height <= 0: raise HTTPException(status_code=422, detail="width and height must be > 0") if width % 16 != 0 or height % 16 != 0: raise HTTPException( status_code=422, detail="width and height must be multiples of 16" ) if width * height > MAX_PIXELS: raise HTTPException(status_code=422, detail="max resolution is 4 megapixels") return width, height def _parse_size( size: str | None, width: int | None, height: int | None ) -> tuple[int, int]: if width and height: return _normalize_dims(width, height) if not size: return _normalize_dims(1024, 1024) parts = size.lower().split("x") if len(parts) != 2: raise HTTPException(status_code=422, detail="size must look like 1024x1024") try: parsed_w = int(parts[0]) parsed_h = int(parts[1]) except ValueError as exc: raise HTTPException( status_code=422, detail="size values must be integers" ) from exc return _normalize_dims(parsed_w, parsed_h) def _decode_image_data_url(image_data_url: str | None) -> Image.Image | None: if not image_data_url: return None if "," not in image_data_url: raise HTTPException(status_code=422, detail="imageDataUrl must be a data URL") _, payload = image_data_url.split(",", 1) try: raw = base64.b64decode(payload) except (ValueError, binascii.Error) as exc: raise HTTPException( status_code=422, detail="invalid base64 in imageDataUrl" ) from exc with Image.open(io.BytesIO(raw)) as image: return image.convert("RGB") def _generate_png_bytes( prompt: str, width: int, height: int, num_inference_steps: int, guidance_scale: float, seed: int, image: Image.Image | None, ) -> tuple[bytes, int]: if PIPE is None: raise HTTPException(status_code=503, detail="model not loaded yet") generator = torch.Generator(device="cuda").manual_seed(seed) kwargs: dict[str, Any] = { "prompt": prompt, "height": height, "width": width, "num_inference_steps": num_inference_steps, "guidance_scale": guidance_scale, "generator": generator, } with PIPE_LOCK: try: if image is not None: try: output = PIPE(image=image, **kwargs) except TypeError: output = PIPE(images=[image], **kwargs) else: output = PIPE(**kwargs) except Exception as exc: raise HTTPException( status_code=500, detail=f"generation failed: {exc}" ) from exc result_image = output.images[0] buf = io.BytesIO() result_image.save(buf, format="PNG") return buf.getvalue(), seed @app.on_event("startup") def _startup() -> None: global PIPE _ensure_cuda() PIPE = _load_pipe(CHECKPOINT) print("Server ready") @app.get("/", response_class=HTMLResponse) def index() -> str: return """
Private single-user UI at /. Distilled defaults: 4 steps, guidance 1.0.
Tip: right-click image to save.