from __future__ import annotations from dataclasses import asdict from math import ceil, sqrt from pathlib import Path from typing import Sequence import hashlib import textwrap from .prompt_rewriter import RewriteResult, STYLE_LIBRARY from . import flux_backend def _require_pillow(): try: from PIL import Image, ImageDraw, ImageFont except Exception as exc: # pragma: no cover - only triggered in non-runtime environments raise RuntimeError( "Pillow is required for quilt rendering. Run scripts/bootstrap_venv.sh first or install requirements.txt." ) from exc return Image, ImageDraw, ImageFont def _digest(result: RewriteResult) -> bytes: payload = "|".join( [ result.memory_text, result.location_tag, result.style, result.caption, result.flux_prompt, result.selected_model_id, ] ) return hashlib.sha256(payload.encode("utf-8")).digest() def _rgb_from_digest(digest: bytes, offset: int, mix: float = 1.0) -> tuple[int, int, int]: r = int(digest[offset % len(digest)] * mix) % 256 g = int(digest[(offset + 1) % len(digest)] * mix) % 256 b = int(digest[(offset + 2) % len(digest)] * mix) % 256 return (r, g, b) def _palette(result: RewriteResult) -> dict[str, tuple[int, int, int]]: digest = _digest(result) base = _rgb_from_digest(digest, 0) accent = _rgb_from_digest(digest, 3, 1.3) soft = _rgb_from_digest(digest, 6, 0.8) border = _rgb_from_digest(digest, 9, 0.65) text = (246, 241, 229) return {"base": base, "accent": accent, "soft": soft, "border": border, "text": text} def _load_font(ImageFont, size: int): try: return ImageFont.truetype("DejaVuSans.ttf", size) except Exception: return ImageFont.load_default() def _draw_stitch_border(draw, size: int, color: tuple[int, int, int]) -> None: step = 16 for x in range(8, size - 8, step): draw.rectangle((x, 6, x + 8, 10), fill=color) draw.rectangle((x, size - 10, x + 8, size - 6), fill=color) for y in range(8, size - 8, step): draw.rectangle((6, y, 10, y + 8), fill=color) draw.rectangle((size - 10, y, size - 6, y + 8), fill=color) def _draw_motif(draw, result: RewriteResult, palette: dict[str, tuple[int, int, int]], size: int) -> None: Image, ImageDraw, ImageFont = _require_pillow() digest = _digest(result) motif = digest[0] % 4 cx = size // 2 cy = int(size * 0.46) if motif == 0: # House silhouette. roof = [(cx - 120, cy + 20), (cx, cy - 92), (cx + 120, cy + 20)] draw.polygon(roof, fill=palette["accent"]) draw.rectangle((cx - 92, cy + 20, cx + 92, cy + 132), fill=palette["soft"]) draw.rectangle((cx - 24, cy + 74, cx + 24, cy + 132), fill=palette["text"]) draw.rectangle((cx - 70, cy + 46, cx - 34, cy + 82), fill=palette["text"]) draw.rectangle((cx + 34, cy + 46, cx + 70, cy + 82), fill=palette["text"]) elif motif == 1: # Tree and bench. draw.line((cx, cy + 40, cx, cy + 128), fill=palette["accent"], width=14) for shift in (-70, -28, 26, 68): draw.ellipse((cx + shift - 42, cy - 60, cx + shift + 42, cy + 24), fill=palette["soft"]) draw.rectangle((cx - 92, cy + 92, cx + 92, cy + 114), fill=palette["border"]) draw.rectangle((cx - 70, cy + 70, cx - 52, cy + 114), fill=palette["border"]) draw.rectangle((cx + 52, cy + 70, cx + 70, cy + 114), fill=palette["border"]) elif motif == 2: # Window grid. draw.rounded_rectangle((cx - 120, cy - 86, cx + 120, cy + 90), radius=24, fill=palette["accent"]) draw.rectangle((cx - 102, cy - 68, cx + 102, cy + 72), outline=palette["text"], width=6) draw.line((cx, cy - 68, cx, cy + 72), fill=palette["text"], width=6) draw.line((cx - 102, cy + 2, cx + 102, cy + 2), fill=palette["text"], width=6) else: # Porch light and street. draw.ellipse((cx - 76, cy - 86, cx + 76, cy + 62), fill=palette["soft"]) draw.line((cx, cy - 98, cx, cy - 140), fill=palette["border"], width=8) draw.line((cx - 120, cy + 74, cx + 120, cy + 74), fill=palette["text"], width=10) draw.arc((cx - 100, cy + 24, cx + 100, cy + 148), start=180, end=360, fill=palette["accent"], width=10) def _draw_caption_band(image: "object", result: RewriteResult, palette: dict[str, tuple[int, int, int]]) -> None: Image, ImageDraw, ImageFont = _require_pillow() size = image.size[0] draw = ImageDraw.Draw(image) band_height = 112 band_top = size - band_height draw.rectangle((0, band_top, size, size), fill=(22, 21, 19, 210)) title_font = _load_font(ImageFont, 24) body_font = _load_font(ImageFont, 16) small_font = _load_font(ImageFont, 13) caption = textwrap.fill(result.caption, width=20) prompt_excerpt = textwrap.fill(result.flux_prompt, width=38) draw.text((18, band_top + 10), caption, fill=palette["text"], font=title_font) draw.text((18, band_top + 48), prompt_excerpt, fill=(233, 228, 218), font=body_font) draw.text((18, band_top + 89), f"{result.selected_model_id} · {result.prompt_source}", fill=(202, 196, 185), font=small_font) def _render_tile_flux(result: RewriteResult, size: int = 512) -> "object": Image, ImageDraw, ImageFont = _require_pillow() palette = _palette(result) base, meta = flux_backend.try_generate(result.flux_prompt, size=size) image = base.convert("RGBA").resize((size, size)) _draw_caption_band(image, result, palette) image.info["backend"] = str(meta.get("backend", "flux-diffusers")) if meta.get("adapter_name"): image.info["adapter_name"] = str(meta["adapter_name"]) if meta.get("model_id"): image.info["model_id"] = str(meta["model_id"]) if meta.get("generation_stats") is not None: image.info["generation_stats"] = meta["generation_stats"] return image def render_tile(result: RewriteResult, size: int = 512) -> "object": if not flux_backend.available(): raise RuntimeError( "P5_FLUX_MODEL_DIR is not set or does not point to a local FLUX checkpoint; " "deterministic tile fallback has been removed" ) return _render_tile_flux(result, size=size) def assemble_quilt(results: Sequence[RewriteResult], columns: int | None = None, tile_size: int = 256) -> "object": Image, ImageDraw, ImageFont = _require_pillow() cards = list(results) if not cards: raise ValueError("assemble_quilt requires at least one card") columns = columns or (3 if len(cards) >= 6 else 2 if len(cards) > 1 else 1) columns = max(1, min(columns, len(cards))) rows = ceil(len(cards) / columns) padding = 24 banner_height = 104 tile_span = tile_size + 112 width = padding * 2 + columns * tile_size + (columns - 1) * padding height = banner_height + padding + rows * tile_span + max(0, rows - 1) * padding image = Image.new("RGBA", (width, height), (244, 238, 228, 255)) draw = ImageDraw.Draw(image) header_font = _load_font(ImageFont, 30) body_font = _load_font(ImageFont, 16) draw.rounded_rectangle((16, 16, width - 16, banner_height - 12), radius=28, fill=(78, 62, 46, 255)) draw.text((36, 32), "Memory Quilt", fill=(250, 245, 236), font=header_font) subtitle = f"{len(cards)} stitched contribution(s) · local model renderer" draw.text((36, 68), subtitle, fill=(239, 226, 205), font=body_font) for index, result in enumerate(cards): row = index // columns col = index % columns x = padding + col * (tile_size + padding) y = banner_height + padding + row * (tile_span + padding) tile = render_tile(result, size=tile_size).convert("RGBA") image.paste(tile, (x, y), tile) return image def save_image(image: "object", path: str | Path) -> Path: path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) image.save(path, format="PNG") return path def card_summaries(results: Sequence[RewriteResult]) -> list[dict[str, object]]: summaries: list[dict[str, object]] = [] for index, result in enumerate(results, start=1): summary = asdict(result) summary["index"] = index summaries.append(summary) return summaries