from __future__ import annotations import base64 import hashlib import hmac import io import random from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import Any, Protocol import httpx from huggingface_hub import InferenceClient from PIL import Image, ImageDraw, ImageFilter, ImageOps from compliment_forest.schema import ForestStyle @dataclass(frozen=True) class StyleProfile: label: str trigger: str prefix: str suffix: str negative_prompt: str _COMMON_NEGATIVE = ( "text, letters, watermark, logo, frame, collage, duplicate animal, extra limbs, " "deformed face, distorted hands, photorealistic stock photo, 3d render" ) STYLE_PROFILES: dict[str, StyleProfile] = { "watercolor": StyleProfile( label="Watercolor Storybook", trigger="cmprst_watercolor", prefix=( "cmprst_watercolor, soft watercolor storybook illustration, loose wet-on-wet " "washes, visible cold-press paper grain, feathered edges, " ), suffix=", warm dappled light, muted natural palette, generous negative space", negative_prompt=f"hard outlines, neon, oversaturated, {_COMMON_NEGATIVE}", ), "paper_cut": StyleProfile( label="Layered Paper Cut", trigger="cmprst_papercut", prefix=( "cmprst_papercut, handcrafted layered paper-cut illustration, deckled cut " "edges, overlapping botanical silhouettes, tactile paper fibers, " ), suffix=", soft dimensional shadows, moss and ochre palette, clean composition", negative_prompt=f"paint splashes, glossy plastic, neon, {_COMMON_NEGATIVE}", ), "moonlit_gouache": StyleProfile( label="Moonlit Gouache", trigger="cmprst_moonlit", prefix=( "cmprst_moonlit, moonlit gouache storybook painting, opaque velvety brushwork, " "deep indigo woodland, silver rim light, " ), suffix=", quiet luminous atmosphere, restrained jewel tones, simple composition", negative_prompt=f"daylight, washed-out contrast, digital gradients, {_COMMON_NEGATIVE}", ), "botanical_ink": StyleProfile( label="Botanical Ink Wash", trigger="cmprst_inkwash", prefix=( "cmprst_inkwash, botanical ink-wash illustration, expressive sepia and moss " "brush lines, diluted ink blooms, cream washi paper, " ), suffix=", sparse wildflower details, calm asymmetry, airy negative space", negative_prompt=f"thick cartoon outlines, saturated blocks, neon, {_COMMON_NEGATIVE}", ), } STYLE_LORA_V1_IDS = { "watercolor": "thangvip/compliment-forest-watercolor-flux-lora", "paper_cut": "thangvip/compliment-forest-paper-cut-flux-lora", "moonlit_gouache": "thangvip/compliment-forest-moonlit-gouache-flux-lora", "botanical_ink": "thangvip/compliment-forest-botanical-ink-flux-lora", } STYLE_LORA_IDS = { style: f"{model_id}-v2" for style, model_id in STYLE_LORA_V1_IDS.items() } STYLE_ADAPTER_WEIGHT = 0.45 STYLE_PREFIX = ( "cmprst_forest, cmprst_watercolor, soft watercolor storybook illustration, " "loose wet-on-wet washes, " "visible cold-press paper grain, no hard outlines, soft feathered edges, " ) STYLE_SUFFIX = ( ", warm dappled light, kind expression, lots of negative space, muted warm palette" ) NEGATIVE_PROMPT = ( "hard outlines, photorealistic, 3d render, neon, oversaturated, busy background, " "text, watermark, deformed" ) class ImageBackend(Protocol): def generate(self, prompt: str, seed: int, style: ForestStyle) -> str: ... def resolve_style(style: ForestStyle, seed: int) -> str: if style != "surprise": return style style_ids = tuple(STYLE_PROFILES) return style_ids[seed % len(style_ids)] def compose_flux_prompt( creature_prompt: str, style: ForestStyle = "watercolor", *, seed: int = 3407, ) -> str: resolved = resolve_style(style, seed) profile = STYLE_PROFILES[resolved] subject = creature_prompt.strip() style_language = profile.prefix.removeprefix(f"{profile.trigger}, ").rstrip(", ") return ( f"{profile.trigger}, primary subject: {subject}. " "Preserve this exact subject, action, and setting as the clear focal point. " "No text, lettering, signature, logo, or watermark anywhere. " f"{style_language}{profile.suffix}" ) def image_to_data_uri(image: Image.Image) -> str: buffer = io.BytesIO() image.save(buffer, format="PNG", optimize=True) encoded = base64.b64encode(buffer.getvalue()).decode("ascii") return f"data:image/png;base64,{encoded}" def clean_generated_image(image: Image.Image) -> Image.Image: """Trim common synthetic signature zones while preserving output dimensions.""" width, height = image.size crop = image.crop( ( round(width * 0.035), round(height * 0.02), round(width * 0.965), round(height * 0.935), ) ) return ImageOps.fit( crop, image.size, method=Image.Resampling.LANCZOS, ) def sign_modal_request(key: str, prompt: str, seed: int, style: str) -> str: message = f"{prompt}\n{seed}\n{style}".encode() return hmac.new(key.encode(), message, hashlib.sha256).hexdigest() class DemoImageBackend: """Creates deterministic procedural placeholders for offline development.""" def __init__( self, width: int = 768, height: int = 576, asset_dir: str | Path | None = None, ) -> None: self.width = width self.height = height self.asset_dir = Path(asset_dir) if asset_dir is not None else None def generate( self, prompt: str, seed: int, style: ForestStyle = "surprise", ) -> str: resolved_style = resolve_style(style, seed) prompt_seed = int.from_bytes( hashlib.sha256(f"{resolved_style}:{prompt}".encode()).digest()[:4], byteorder="big", ) rng = random.Random(seed ^ prompt_seed) image = Image.new("RGB", (self.width, self.height), "#f5f0df") wash = Image.new("RGBA", image.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(wash, "RGBA") palette = [ (124, 145, 112, 28), (77, 105, 78, 24), (208, 151, 139, 22), (225, 184, 101, 22), (132, 172, 181, 18), ] if resolved_style == "moonlit_gouache": palette = [(36, 49, 92, 42), (70, 82, 124, 36), (194, 184, 151, 26)] elif resolved_style == "paper_cut": palette = [(83, 104, 61, 40), (205, 151, 96, 34), (201, 143, 131, 28)] elif resolved_style == "botanical_ink": palette = [(73, 79, 55, 32), (120, 96, 67, 28), (148, 161, 132, 20)] for _ in range(100): color = rng.choice(palette) x = rng.randint(-100, self.width) y = rng.randint(-80, self.height) radius_x = rng.randint(35, 160) radius_y = rng.randint(25, 110) draw.ellipse( (x - radius_x, y - radius_y, x + radius_x, y + radius_y), fill=color, ) wash = wash.filter(ImageFilter.GaussianBlur(radius=22)) image = Image.alpha_composite(image.convert("RGBA"), wash) creature = Image.new("RGBA", image.size, (0, 0, 0, 0)) creature_draw = ImageDraw.Draw(creature, "RGBA") center_x = self.width // 2 + rng.randint(-30, 30) ground_y = int(self.height * 0.72) body_color = rng.choice( [(169, 105, 69, 205), (128, 105, 78, 205), (154, 135, 96, 205)] ) shadow = (76, 91, 67, 35) creature_draw.ellipse( (center_x - 145, ground_y - 10, center_x + 145, ground_y + 38), fill=shadow, ) creature_draw.ellipse( (center_x - 96, ground_y - 170, center_x + 90, ground_y + 5), fill=body_color, ) creature_draw.ellipse( (center_x - 72, ground_y - 245, center_x + 74, ground_y - 100), fill=body_color, ) creature_draw.polygon( [ (center_x - 62, ground_y - 210), (center_x - 88, ground_y - 290), (center_x - 20, ground_y - 235), ], fill=body_color, ) creature_draw.polygon( [ (center_x + 58, ground_y - 210), (center_x + 88, ground_y - 290), (center_x + 20, ground_y - 235), ], fill=body_color, ) creature_draw.ellipse( (center_x - 38, ground_y - 195, center_x - 15, ground_y - 169), fill=(51, 53, 44, 220), ) creature_draw.ellipse( (center_x + 18, ground_y - 195, center_x + 41, ground_y - 169), fill=(51, 53, 44, 220), ) creature_draw.ellipse( (center_x - 6, ground_y - 163, center_x + 9, ground_y - 150), fill=(72, 57, 47, 190), ) creature = creature.filter(ImageFilter.GaussianBlur(radius=1.2)) image = Image.alpha_composite(image, creature) grain = Image.new("RGBA", image.size, (0, 0, 0, 0)) grain_pixels = grain.load() for y in range(self.height): for x in range(self.width): value = rng.randint(0, 10) grain_pixels[x, y] = (75, 66, 50, value) image = Image.alpha_composite(image, grain) return image_to_data_uri(image.convert("RGB")) class FluxImageBackend: """Lazy FLUX.1-dev pipeline that loads a local or Hub LoRA.""" def __init__( self, model_id: str = "black-forest-labs/FLUX.1-dev", lora_id: str = "build-small-hackathon/compliment-forest-flux-lora", *, local_files_only: bool = False, width: int = 768, height: int = 768, steps: int = 28, ) -> None: self.model_id = model_id self.lora_id = lora_id self.local_files_only = local_files_only self.width = width self.height = height self.steps = steps self._pipeline = None def _load(self): if self._pipeline is not None: return self._pipeline import torch from diffusers import FluxPipeline pipeline = FluxPipeline.from_pretrained( self.model_id, torch_dtype=torch.bfloat16, local_files_only=self.local_files_only, ) pipeline.load_lora_weights( self.lora_id, local_files_only=self.local_files_only, ) pipeline.enable_model_cpu_offload() self._pipeline = pipeline return pipeline def generate( self, prompt: str, seed: int, style: ForestStyle = "watercolor", ) -> str: import torch pipeline = self._load() generator = torch.Generator(device="cpu").manual_seed(seed) image = pipeline( prompt=compose_flux_prompt(prompt, style, seed=seed), negative_prompt=NEGATIVE_PROMPT, width=self.width, height=self.height, num_inference_steps=self.steps, guidance_scale=3.5, generator=generator, ).images[0] return image_to_data_uri(clean_generated_image(image.convert("RGB"))) class HfInferenceImageBackend: """Fresh FLUX.1-schnell generation through Hugging Face Inference Providers.""" def __init__( self, model: str = "black-forest-labs/FLUX.1-schnell", *, client: Any | None = None, provider: str = "auto", width: int = 768, height: int = 768, steps: int = 4, timeout: float = 180, ) -> None: self.model = model self.client = client or InferenceClient(provider=provider, timeout=timeout) self.width = width self.height = height self.steps = steps def generate( self, prompt: str, seed: int, style: ForestStyle = "surprise", ) -> str: resolved = resolve_style(style, seed) profile = STYLE_PROFILES[resolved] image = self.client.text_to_image( compose_flux_prompt(prompt, resolved, seed=seed), model=self.model, negative_prompt=profile.negative_prompt, width=self.width, height=self.height, num_inference_steps=self.steps, guidance_scale=0.0, seed=seed, ) return image_to_data_uri(clean_generated_image(image.convert("RGB"))) class ZeroGpuImageBackend: """Route resolved styles to a Space GPU function with a hosted fallback.""" def __init__( self, generator: Callable[[str, int, str], str], *, fallback: ImageBackend | None = None, ) -> None: self.generator = generator self.fallback = fallback def generate( self, prompt: str, seed: int, style: ForestStyle = "surprise", ) -> str: resolved = resolve_style(style, seed) try: return self.generator(prompt, seed, resolved) except Exception: if self.fallback is None: raise return self.fallback.generate(prompt, seed, resolved) class ModalImageBackend: """Call the private-token Modal service that hosts the trained adapters.""" def __init__( self, endpoint: str, signing_key: str, *, client: Any | None = None, fallback: ImageBackend | None = None, timeout: float = 600, ) -> None: self.endpoint = endpoint self.signing_key = signing_key self.client = client or httpx.Client(timeout=timeout, follow_redirects=True) self.fallback = fallback def generate( self, prompt: str, seed: int, style: ForestStyle = "surprise", ) -> str: resolved = resolve_style(style, seed) try: response = self.client.post( self.endpoint, json={ "prompt": prompt, "seed": seed, "style": resolved, "signature": sign_modal_request( self.signing_key, prompt, seed, resolved, ), }, ) response.raise_for_status() image = response.json()["image"] if not isinstance(image, str) or not image.startswith("data:image/png;base64,"): raise ValueError("Modal image response is not a PNG data URI") return image except Exception: if self.fallback is None: raise return self.fallback.generate(prompt, seed, resolved)