"""Image client for The Wizard's Oracles. Two paths: MOCK (default) — produces a stylized SVG card showing the obstacle's first ~50 chars, an oracle quote (~80 chars), and the tactic, on a pixel-art-feel background made from a deterministic 32x32 grid of muted earth-tone squares. Dragon trials get a darker red/black palette. LIVE (future, behind use_klein=True + klein_endpoint) — POSTs a short pixel-art prompt to a Klein-4B Modal HTTP endpoint, expects PNG bytes back. Any failure (network, timeout, non-200, empty body) silently falls back to the MOCK path so the demo never breaks. Cairosvg is used opportunistically to convert SVG → PNG; if unavailable, the raw SVG is written and the path points at the .svg (Gradio's gr.HTML can render that). """ from __future__ import annotations import html import json import os import random import re import urllib.request import urllib.error from dataclasses import dataclass from typing import Optional from oracles.state import Resolution # Klein-generated character sprites live here. Each .png is the # chroma-keyed (RGBA) sprite; the loader caches them as base64 data URIs at # module load so embedding into the SVG card is cheap. _SPRITES_DIR = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "sprites", ) _SPRITE_NAMES = ("hero", "wizard", "dragon", "giant", "troll", "sphinx") _SPRITE_DATA_URIS: dict[str, str] = {} # Full-frame Klein scene textures (NO transparency) — used as the SVG # background for trial cards. Loaded once at module import like sprites. _SCENE_TEXTURE_NAMES = ( "night_sky_texture", "stormy_plain_texture", "forest_dusk_texture", ) _SCENE_TEXTURE_DATA_URIS: dict[str, str] = {} # Optional mid-ground decor sprites (chroma-keyed). May not yet exist — # the loader silently skips missing ones, callers must check. _SCENE_DECOR_NAMES = ( "village_dawn_silhouette", "forest_path", "mountain_pass", "sphinx_temple_pillars", "river_crossing", "dragon_throne_room", ) _SCENE_DECOR_DATA_URIS: dict[str, str] = {} def _png_to_data_uri(path: str) -> Optional[str]: """Read a PNG file and return its base64 data URI, or None on failure.""" import base64 try: with open(path, "rb") as fh: data = fh.read() except OSError: return None return "data:image/png;base64," + base64.b64encode(data).decode("ascii") def _load_sprite_data_uris() -> None: """Read every available sprite PNG into a base64 data URI, once.""" for name in _SPRITE_NAMES: path = os.path.join(_SPRITES_DIR, name + ".png") if not os.path.exists(path): continue uri = _png_to_data_uri(path) if uri: _SPRITE_DATA_URIS[name] = uri def _load_scene_texture_data_uris() -> None: """Read every available full-frame scene texture into a data URI, once.""" for name in _SCENE_TEXTURE_NAMES: path = os.path.join(_SPRITES_DIR, name + ".png") if not os.path.exists(path): continue uri = _png_to_data_uri(path) if uri: _SCENE_TEXTURE_DATA_URIS[name] = uri def _load_scene_decor_data_uris() -> None: """Read every available mid-ground decor sprite into a data URI, once.""" for name in _SCENE_DECOR_NAMES: path = os.path.join(_SPRITES_DIR, name + ".png") if not os.path.exists(path): continue uri = _png_to_data_uri(path) if uri: _SCENE_DECOR_DATA_URIS[name] = uri # Lazy initialization — these loaders read 15 PNGs and base64-encode them # into RAM. Doing that at module import time (the old behavior) added 1-3s # to every cold start on HF Spaces' shared CPU. The accessors below call # ``_ensure_loaded()`` on first use instead. _LOADED = False def _ensure_loaded() -> None: global _LOADED if _LOADED: return _load_sprite_data_uris() _load_scene_texture_data_uris() _load_scene_decor_data_uris() _LOADED = True def sprite_data_uri(name: str) -> Optional[str]: """Return a data URI for a Klein sprite, or None if it doesn't exist.""" _ensure_loaded() return _SPRITE_DATA_URIS.get(name) def _sprite_svg_image( name: str, x: int, y: int, height: int, flip: bool = False, ) -> str: """Embed a Klein sprite into the parent SVG as an element. Returns ``""`` if the sprite PNG is missing — caller should use the inline-SVG fallback in that case. """ uri = sprite_data_uri(name) if not uri: return "" # 256x256 source → square width = height width = height if not flip: return ( f'' ) # Horizontal flip via transform: scale(-1,1) then translate. cx = x + width return ( f'' ) def _pick_klein_obstacle(setup: str, is_dragon: bool) -> Optional[str]: """Return the Klein sprite NAME for an obstacle, or None if no Klein PNG fits (caller should fall back to inline SVG or omit the sprite). Different from the inline-sprite picker in oracles/sprites.py because Klein PNGs only cover {hero, wizard, dragon, giant, troll, sphinx}. Water / stone obstacles return None — only the scene shows. """ if is_dragon: return "dragon" if "dragon" in _SPRITE_DATA_URIS else None setup_lc = (setup or "").lower() if "giant" in setup_lc and "giant" in _SPRITE_DATA_URIS: return "giant" if any(w in setup_lc for w in ("troll", "ogre", "witch", "creature", "beast")) \ and "troll" in _SPRITE_DATA_URIS: return "troll" if any(w in setup_lc for w in ("sphinx", "guardian", "stone", "ancient", "monolith", "obelisk", "tablet", "rune")) \ and "sphinx" in _SPRITE_DATA_URIS: return "sphinx" return None def _pick_scene_background(obstacle_setup: str, is_dragon: bool) -> str: """Return the filename (without dir) of the best full-frame texture for this trial's background. Dragon trials always use the night sky base (with a separate ember overlay added in _build_svg). Storm/wind/cloud obstacles use the dramatic stormy plain. Forest/brute obstacles use the dusk forest. Everything else defaults to the calm night sky. """ if is_dragon: return "night_sky_texture.png" setup_lc = (obstacle_setup or "").lower() if any(w in setup_lc for w in ("storm", "wind", "cloud", "force", "lightning", "thunder", "gale")): return "stormy_plain_texture.png" if any(w in setup_lc for w in ("forest", "thicket", "wood", "trees", "giant", "troll", "ogre")): return "forest_dusk_texture.png" return "night_sky_texture.png" def _pick_scene_decor_sprite(obstacle_setup: str, is_dragon: bool) -> Optional[str]: """Return the NAME (no extension) of an optional mid-ground decor sprite to composite ABOVE the background but BELOW the hero/obstacle characters, or None if no decor fits / exists. Only returns a name that is actually loaded in _SCENE_DECOR_DATA_URIS so callers can rely on it being available. """ setup_lc = (obstacle_setup or "").lower() if is_dragon: for cand in ("dragon_throne_room", "mountain_pass"): if cand in _SCENE_DECOR_DATA_URIS: return cand return None if any(w in setup_lc for w in ("river", "water", "stream", "ford", "eel", "chasm")): if "river_crossing" in _SCENE_DECOR_DATA_URIS: return "river_crossing" if any(w in setup_lc for w in ("sphinx", "temple", "pillar", "guardian", "monolith", "tablet", "rune")): if "sphinx_temple_pillars" in _SCENE_DECOR_DATA_URIS: return "sphinx_temple_pillars" if any(w in setup_lc for w in ("mountain", "peak", "stone", "pass", "ridge", "cliff")): if "mountain_pass" in _SCENE_DECOR_DATA_URIS: return "mountain_pass" if any(w in setup_lc for w in ("forest", "thicket", "wood", "trees", "path", "road")): if "forest_path" in _SCENE_DECOR_DATA_URIS: return "forest_path" if any(w in setup_lc for w in ("village", "town", "hamlet", "dawn")): if "village_dawn_silhouette" in _SCENE_DECOR_DATA_URIS: return "village_dawn_silhouette" return None # --- Public types ----------------------------------------------------------- @dataclass class ImageResult: path: str # absolute filesystem path (PNG or SVG); "" if write failed caption: str # always populated; text fallback for the UI is_mock: bool # --- Constants -------------------------------------------------------------- _WIDTH = 768 _HEIGHT = 432 # Cozy-night palette (matches the app's CSS variables). _NIGHT_PALETTE = { "sky_top": "#1a1530", # deep night blue "sky_bot": "#2a2438", # softer purple "horizon": "#0c0a13", # near-black silhouette "fg_tree": "#15101e", # darker silhouette "star": "#ede4d3", # cream "moon": "#f0c060", # amber "moon_glow": "rgba(240,192,96,0.18)", "frame": "#f0c060", "panel": "#2a2438", "panel_dim": "#18141f", "text": "#ede4d3", "accent": "#f0c060", } # Dragon's-mountain palette (red/black, no moon, no stars — embers). _DRAGON_PALETTE = { "sky_top": "#1a0606", "sky_bot": "#3a0e0e", "horizon": "#0c0203", "fg_tree": "#1a0606", "star": "#ff8a50", # embers, not stars "moon": "#c84a3a", # dragon's eye "moon_glow": "rgba(200,74,58,0.32)", "frame": "#c84a3a", "panel": "#260606", "panel_dim": "#100303", "text": "#ffd9c3", "accent": "#ff8a50", } # --- Helpers ---------------------------------------------------------------- def _slugify(text: str, max_len: int = 30) -> str: """Lowercase, non-alnum -> '_', collapse runs, truncate.""" if not text: return "scene" s = text.lower() s = re.sub(r"[^a-z0-9]+", "_", s) s = s.strip("_") if not s: return "scene" return s[:max_len].rstrip("_") or "scene" def _truncate(text: str, max_chars: int) -> str: text = (text or "").strip() if len(text) <= max_chars: return text # Try to cut on a word boundary. cut = text[:max_chars].rsplit(" ", 1)[0] if len(cut) < max_chars * 0.6: cut = text[:max_chars] return cut + "…" def _build_svg(resolution: Resolution) -> str: """Build a pixel-art night-scene SVG card. HTML-escapes all user content. Layout (top to bottom): • chunky outer frame in the trial's accent color (amber / dragon-red) • gradient sky panel • pixel stars (or embers, for the dragon) • moon / dragon-eye disc top-right • silhouette horizon and a row of conifer trees • title band with TRIAL N in pixel-style monospace • central quote band with the oracle text • tactic line bottom-right """ is_dragon = bool(resolution.obstacle.is_dragon) p = _DRAGON_PALETTE if is_dragon else _NIGHT_PALETTE rng = random.Random(resolution.trial_index * 1009 + 7) # ---- Sky gradient + shared defs -------------------------------------- # We always emit the (moonGlow, sky gradient, dragonEmber) so they # are available whether we use the CSS-gradient fallback path or the # texture-background path. The dragonEmber radial is only painted when # is_dragon is True. defs = ( '' f'' f'' f'' '' f'' f'' f'' '' '' '' '' '' '' '' ) sky_fallback = ( f'{defs}' f'' ) # ---- Texture background (preferred) ---------------------------------- # If a Klein scene texture is loaded, use it as a single full-frame # ; this replaces the procedural sky+stars+moon+mountains+trees # for the new path. We still emit so the dragon-ember radial is # available when needed. If the chosen texture is NOT loaded, fall back # to the original CSS-gradient sky+stars+moon+mountains+trees layers. texture_filename = _pick_scene_background( resolution.obstacle.setup, is_dragon ) texture_key = os.path.splitext(texture_filename)[0] texture_uri = _SCENE_TEXTURE_DATA_URIS.get(texture_key) use_texture = texture_uri is not None if use_texture: # Single covers the whole card; preserveAspectRatio="none" # so we don't get letterboxing if the source aspect ratio doesn't # match our 768x432 canvas. sky = ( f'{defs}' f'' ) # Dragon trials get an amber-red ember overlay over the night sky. if is_dragon: sky += ( f'' ) else: sky = sky_fallback # ---- Stars / embers (only in fallback path) --------------------------- # When a texture is loaded, the texture itself contains the stars/moon, # so we skip these procedural layers to avoid double-painting. if not use_texture: star_parts = [] n_stars = 60 if not is_dragon else 35 for _ in range(n_stars): sx = rng.randint(8, _WIDTH - 8) sy = rng.randint(8, _HEIGHT - 200) # only above the horizon line size = rng.choice([2, 2, 3, 3, 4]) star_parts.append( f'' ) stars = "".join(star_parts) else: stars = "" # ---- Moon / dragon-eye (only in fallback path) ------------------------ moon_cx, moon_cy = _WIDTH - 110, 110 moon_r = 36 moon_block_size = 6 # pixel-style by stepping if not use_texture: # Glow halo halo = ( f'' ) # Pixel moon: draw a 12x12 grid of squares inside the disc moon_parts = [] span = moon_r step = moon_block_size for ox in range(-span, span, step): for oy in range(-span, span, step): if ox * ox + oy * oy <= (span - step) * (span - step): moon_parts.append( f'' ) moon = halo + "".join(moon_parts) # For the dragon, add a vertical "slit" pupil if is_dragon: moon += ( f'' ) else: moon = "" # ---- Horizon silhouette (mountains + trees) --------------------------- # Pushed low so the foreground sprite layer fits cleanly between the # oracle quote band (ends ~y=280) and the tactic band (starts y=380). horizon_y = _HEIGHT - 72 if not use_texture: horizon = ( f'' ) # Mountain triangles (deterministic, per trial). n_mtns = 4 mtn_parts = [] for i in range(n_mtns): peak_x = int((i + 0.5) * (_WIDTH / n_mtns)) + rng.randint(-30, 30) peak_h = rng.randint(70, 130) base_w = rng.randint(140, 220) left = peak_x - base_w // 2 right = peak_x + base_w // 2 peak_y = horizon_y - peak_h mtn_parts.append( f'' ) mountains = "".join(mtn_parts) # Conifer trees: simple triangles along the horizon line. We skip the # zones where the hero / obstacle sprites will stand so the sprites # don't grow trees out of their heads. hero_zone = (60, 220) # x range reserved for the hero sprite obstacle_zone = (480, 720) if not is_dragon else (440, 760) tree_parts = [] tx = 12 while tx < _WIDTH - 20: tw = rng.choice([18, 22, 26, 30]) th = rng.choice([28, 36, 44]) # Skip if this tree would overlap a sprite zone. tx_mid = tx + tw // 2 in_hero = hero_zone[0] <= tx_mid <= hero_zone[1] in_obstacle = obstacle_zone[0] <= tx_mid <= obstacle_zone[1] if not in_hero and not in_obstacle: tree_parts.append( f'' ) tree_parts.append( f'' ) tx += tw + rng.randint(8, 24) trees = "".join(tree_parts) else: horizon = "" mountains = "" trees = "" # ---- Mid-ground decor sprite (texture path only) ---------------------- # An optional pixel-art decor sprite composited above the texture but # below the hero/obstacle characters. Sits roughly on the horizon line, # centered horizontally, sized to fit between hero (~x<=220) and # obstacle (~x>=480) zones. Silent skip if no sprite fits / is loaded. decor_svg = "" decor_name = _pick_scene_decor_sprite(resolution.obstacle.setup, is_dragon) if decor_name and decor_name in _SCENE_DECOR_DATA_URIS: decor_uri = _SCENE_DECOR_DATA_URIS[decor_name] # Width ~260px, anchored center, sat just above the horizon line. decor_w = 260 decor_h = 130 decor_x = (_WIDTH - decor_w) // 2 decor_y = horizon_y - decor_h + 14 decor_svg = ( f'' ) # ---- Foreground sprites (Klein-generated PNG embeds) ----------------- # Hero stands at left foreground. For trials 1-4 he faces the obstacle. # For the dragon trial the dragon dominates and the hero is smaller # (emphasizes scale). Sprites sit between the quote band end (~y=280) # and the tactic band start (y=380); slight overlap with the band edges # is OK — bands are semi-transparent. if is_dragon: hero_h = 90 hero_x = 60 hero_y = horizon_y - hero_h + 10 ob_h = 170 ob_x = _WIDTH - ob_h - 30 ob_y = horizon_y - ob_h + 30 else: hero_h = 105 hero_x = 60 hero_y = horizon_y - hero_h + 10 ob_h = 120 ob_x = _WIDTH - ob_h - 50 ob_y = horizon_y - ob_h + 15 hero_svg = _sprite_svg_image("hero", hero_x, hero_y, hero_h, flip=False) obstacle_name = _pick_klein_obstacle(resolution.obstacle.setup, is_dragon) if obstacle_name: # Most creatures face left (toward the hero) — flip them so they # look at the hero instead of away. obstacle_svg = _sprite_svg_image(obstacle_name, ob_x, ob_y, ob_h, flip=True) else: obstacle_svg = "" foreground = hero_svg + obstacle_svg # ---- Text content ----------------------------------------------------- trial_n = resolution.trial_index obstacle_setup = _truncate(resolution.obstacle.setup, 56) oracle_text = _truncate(resolution.oracle.text, 88) tactic_text = _truncate(resolution.tactic, 56) # Title band (across the top) — chunky monospace title_label = "TRIAL V — THE DRAGON" if is_dragon else f"TRIAL {_roman(trial_n)}" title_band = ( f'' f'' ) title_text = ( f'' f'{html.escape(title_label, quote=True)}' ) # Obstacle subtitle line (small, below title) obstacle_text = ( f'' f'{html.escape(obstacle_setup, quote=True)}' ) # ---- Central quote band ---------------------------------------------- quote_lines = _wrap_lines(oracle_text, max_chars_per_line=46, max_lines=3) line_h = 30 quote_h = line_h * len(quote_lines) + 36 quote_y0 = 150 quote_band = ( f'' ) quote_text_parts = [ f'' f'~~ THE ORACLE SPEAKS ~~' ] for i, line in enumerate(quote_lines): line_esc = html.escape(f"'{line}'", quote=True) quote_text_parts.append( f'' f'{line_esc}' ) quote_text = "".join(quote_text_parts) # ---- Tactic line, bottom right --------------------------------------- tactic_band = ( f'' f'' ) tactic_text_svg = ( f'' f'◆ {html.escape(tactic_text, quote=True)}' ) # ---- Chunky outer frame ---------------------------------------------- frame = ( f'' ) svg = ( f'' f'' f'{sky}' f'{stars}' f'{moon}' f'{mountains}' f'{horizon}' f'{trees}' f'{decor_svg}' f'{foreground}' f'{title_band}' f'{title_text}' f'{obstacle_text}' f'{quote_band}' f'{quote_text}' f'{tactic_band}' f'{tactic_text_svg}' f'{frame}' f'' ) return svg def _roman(n: int) -> str: return {1: "I", 2: "II", 3: "III", 4: "IV", 5: "V"}.get(n, str(n)) def _wrap_lines(text: str, max_chars_per_line: int, max_lines: int) -> list: """Naive greedy word-wrap. Returns up to `max_lines` lines.""" words = text.split() lines = [] current = "" for w in words: candidate = (current + " " + w).strip() if len(candidate) <= max_chars_per_line: current = candidate else: if current: lines.append(current) current = w if len(lines) >= max_lines: break if current and len(lines) < max_lines: lines.append(current) # Truncate last line with ellipsis if we ran out of room and text remains. consumed = sum(len(ln) + 1 for ln in lines) if consumed < len(text) and lines: last = lines[-1] if len(last) > max_chars_per_line - 1: last = last[: max_chars_per_line - 1] lines[-1] = last + "…" return lines or [""] def _try_svg_to_png(svg_str: str, png_path: str) -> bool: """Try cairosvg → PNG. Returns True if PNG was written.""" try: import cairosvg # type: ignore except ImportError: return False except Exception: return False try: cairosvg.svg2png(bytestring=svg_str.encode("utf-8"), write_to=png_path) return os.path.exists(png_path) and os.path.getsize(png_path) > 0 except Exception: return False def _try_klein( resolution: Resolution, out_dir: str, klein_endpoint: str, timeout: float = 10.0, ) -> Optional[str]: """POST a short prompt to klein_endpoint, expect PNG bytes. Returns the saved path on success, None on any failure.""" try: prompt = ( f"{resolution.tactic}, pixel art, cozy fantasy, 256x256" ) body = json.dumps({"prompt": prompt}).encode("utf-8") req = urllib.request.Request( klein_endpoint, data=body, headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=timeout) as resp: if resp.status != 200: return None png_bytes = resp.read() if not png_bytes or len(png_bytes) < 8: return None # Cheap PNG magic check. if png_bytes[:8] != b"\x89PNG\r\n\x1a\n": return None fname = f"trial_{resolution.trial_index}_klein.png" png_path = os.path.abspath(os.path.join(out_dir, fname)) with open(png_path, "wb") as f: f.write(png_bytes) return png_path except (urllib.error.URLError, TimeoutError, OSError, ValueError): return None except Exception: return None # --- Public API ------------------------------------------------------------- def generate_scene_image( resolution: Resolution, out_dir: str, use_klein: bool = False, klein_endpoint: Optional[str] = None, ) -> ImageResult: """Generate a scene image for `resolution`. Triggers the lazy sprite/texture/decor data-uri load on first call so cold start doesn't pay for it. After this returns once, the dicts stay populated for subsequent calls. MOCK path (default): always returns an SVG (or PNG via cairosvg) card. LIVE path: try Klein endpoint; on any failure, silently fall back to mock. Caption is always populated even if file writes fail. """ _ensure_loaded() os.makedirs(out_dir, exist_ok=True) caption = f"Trial {resolution.trial_index}: {resolution.tactic}" # Try LIVE path if requested + endpoint provided. if use_klein and klein_endpoint: klein_path = _try_klein(resolution, out_dir, klein_endpoint) if klein_path: return ImageResult(path=klein_path, caption=caption, is_mock=False) # Fall through to mock on failure. # MOCK path. svg_str = _build_svg(resolution) slug = _slugify(resolution.tactic) base = f"trial_{resolution.trial_index}_{slug}" svg_path = os.path.abspath(os.path.join(out_dir, base + ".svg")) png_path = os.path.abspath(os.path.join(out_dir, base + ".png")) # Write SVG first (cheap insurance). written_svg = False try: with open(svg_path, "w", encoding="utf-8") as f: f.write(svg_str) written_svg = True except OSError: written_svg = False # Try PNG conversion. if _try_svg_to_png(svg_str, png_path): return ImageResult(path=png_path, caption=caption, is_mock=True) if written_svg: return ImageResult(path=svg_path, caption=caption, is_mock=True) # Both writes failed — caption-only mode. return ImageResult(path="", caption=caption, is_mock=True)