#!/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 """ FLUX.2 Klein Playground

FLUX.2 Klein Playground

Private single-user UI at /. Distilled defaults: 4 steps, guidance 1.0.

Idle

Output

\"Generated

Tip: right-click image to save.

""" @app.get("/health") def health() -> dict[str, str]: return {"status": "ok", "model": CHECKPOINT} @app.post("/generate") def generate(req: GenerateRequest) -> Response: width, height = _normalize_dims(req.width, req.height) seed = req.seed if not req.randomize_seed else random.randint(0, MAX_SEED) image = _decode_image_data_url(req.image_data_url) png_bytes, used_seed = _generate_png_bytes( prompt=req.prompt, width=width, height=height, num_inference_steps=req.num_inference_steps, guidance_scale=req.guidance_scale, seed=seed, image=image, ) return Response( content=png_bytes, media_type="image/png", headers={"X-Seed": str(used_seed)}, ) @app.post("/v1/images/generations") def compat_generate(req: CompatRequest) -> dict[str, Any]: width, height = _parse_size(req.size, req.width, req.height) seed_value = req.seed if req.seed is not None else 0 seed = seed_value if not req.randomize_seed else random.randint(0, MAX_SEED) image = _decode_image_data_url(req.imageDataUrl) png_bytes, used_seed = _generate_png_bytes( prompt=req.prompt, width=width, height=height, num_inference_steps=req.num_inference_steps or DEFAULT_STEPS, guidance_scale=req.guidance_scale or DEFAULT_GUIDANCE, seed=seed, image=image, ) b64_png = base64.b64encode(png_bytes).decode("ascii") return { "created": int(time.time()), "model": CHECKPOINT, "seed": used_seed, "data": [{"b64_json": b64_png}], } if __name__ == "__main__": import uvicorn parser = argparse.ArgumentParser(description="Private FLUX.2 Klein FastAPI server") parser.add_argument("--model", choices=["4b", "9b"], default=MODEL_SIZE) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=6006) args = parser.parse_args() MODEL_SIZE = args.model CHECKPOINT = _checkpoint_for(MODEL_SIZE) uvicorn.run(app, host=args.host, port=args.port)