AndrewRqy
Memoize hot HTML helpers and lazy-load image module sprites for faster transitions
079fead | """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 <name>.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 <image> 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'<image href="{uri}" x="{x}" y="{y}" width="{width}" ' | |
| f'height="{height}" style="image-rendering:pixelated"/>' | |
| ) | |
| # Horizontal flip via transform: scale(-1,1) then translate. | |
| cx = x + width | |
| return ( | |
| f'<image href="{uri}" x="0" y="0" width="{width}" height="{height}" ' | |
| f'style="image-rendering:pixelated" ' | |
| f'transform="translate({cx},{y}) scale(-1,1)"/>' | |
| ) | |
| 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 ----------------------------------------------------------- | |
| 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 <defs> (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 = ( | |
| '<defs>' | |
| f'<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">' | |
| f'<stop offset="0%" stop-color="{p["sky_top"]}"/>' | |
| f'<stop offset="100%" stop-color="{p["sky_bot"]}"/>' | |
| '</linearGradient>' | |
| f'<radialGradient id="moonGlow" cx="50%" cy="50%" r="50%">' | |
| f'<stop offset="0%" stop-color="{p["moon_glow"]}"/>' | |
| f'<stop offset="100%" stop-color="rgba(0,0,0,0)"/>' | |
| '</radialGradient>' | |
| '<radialGradient id="dragonEmber" cx="50%" cy="62%" r="65%">' | |
| '<stop offset="0%" stop-color="rgba(255,108,52,0.55)"/>' | |
| '<stop offset="55%" stop-color="rgba(200,74,58,0.28)"/>' | |
| '<stop offset="100%" stop-color="rgba(0,0,0,0)"/>' | |
| '</radialGradient>' | |
| '</defs>' | |
| ) | |
| sky_fallback = ( | |
| f'{defs}' | |
| f'<rect x="0" y="0" width="{_WIDTH}" height="{_HEIGHT}" fill="url(#sky)"/>' | |
| ) | |
| # ---- Texture background (preferred) ---------------------------------- | |
| # If a Klein scene texture is loaded, use it as a single full-frame | |
| # <image>; this replaces the procedural sky+stars+moon+mountains+trees | |
| # for the new path. We still emit <defs> 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 <image> 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'<image href="{texture_uri}" x="0" y="0" ' | |
| f'width="{_WIDTH}" height="{_HEIGHT}" ' | |
| f'preserveAspectRatio="none" ' | |
| f'style="image-rendering:pixelated"/>' | |
| ) | |
| # Dragon trials get an amber-red ember overlay over the night sky. | |
| if is_dragon: | |
| sky += ( | |
| f'<rect x="0" y="0" width="{_WIDTH}" height="{_HEIGHT}" ' | |
| f'fill="url(#dragonEmber)"/>' | |
| ) | |
| 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'<rect x="{sx}" y="{sy}" width="{size}" height="{size}" ' | |
| f'fill="{p["star"]}"/>' | |
| ) | |
| 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'<circle cx="{moon_cx}" cy="{moon_cy}" r="{moon_r * 3}" ' | |
| f'fill="url(#moonGlow)"/>' | |
| ) | |
| # 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'<rect x="{moon_cx + ox}" y="{moon_cy + oy}" ' | |
| f'width="{step}" height="{step}" fill="{p["moon"]}"/>' | |
| ) | |
| moon = halo + "".join(moon_parts) | |
| # For the dragon, add a vertical "slit" pupil | |
| if is_dragon: | |
| moon += ( | |
| f'<rect x="{moon_cx - 3}" y="{moon_cy - 20}" ' | |
| f'width="6" height="40" fill="#0c0203"/>' | |
| ) | |
| 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'<rect x="0" y="{horizon_y}" width="{_WIDTH}" ' | |
| f'height="{_HEIGHT - horizon_y}" fill="{p["horizon"]}"/>' | |
| ) | |
| # 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'<polygon points="{left},{horizon_y} {peak_x},{peak_y} ' | |
| f'{right},{horizon_y}" fill="{p["fg_tree"]}"/>' | |
| ) | |
| 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'<polygon points="{tx},{horizon_y + 4} ' | |
| f'{tx + tw // 2},{horizon_y - th + 4} ' | |
| f'{tx + tw},{horizon_y + 4}" fill="{p["fg_tree"]}"/>' | |
| ) | |
| tree_parts.append( | |
| f'<rect x="{tx + tw // 2 - 2}" y="{horizon_y + 4}" ' | |
| f'width="4" height="6" fill="{p["fg_tree"]}"/>' | |
| ) | |
| 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'<image href="{decor_uri}" x="{decor_x}" y="{decor_y}" ' | |
| f'width="{decor_w}" height="{decor_h}" ' | |
| f'preserveAspectRatio="xMidYMax meet" ' | |
| f'style="image-rendering:pixelated"/>' | |
| ) | |
| # ---- 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'<rect x="0" y="0" width="{_WIDTH}" height="62" ' | |
| f'fill="{p["panel_dim"]}" opacity="0.85"/>' | |
| f'<rect x="0" y="60" width="{_WIDTH}" height="3" fill="{p["frame"]}"/>' | |
| ) | |
| title_text = ( | |
| f'<text x="32" y="42" ' | |
| f'font-family="\'Press Start 2P\', \'Courier New\', monospace" ' | |
| f'font-size="20" fill="{p["accent"]}" ' | |
| f'letter-spacing="3" font-weight="bold">' | |
| f'{html.escape(title_label, quote=True)}</text>' | |
| ) | |
| # Obstacle subtitle line (small, below title) | |
| obstacle_text = ( | |
| f'<text x="32" y="92" ' | |
| f'font-family="\'VT323\', \'Courier New\', monospace" ' | |
| f'font-size="22" fill="{p["text"]}">' | |
| f'{html.escape(obstacle_setup, quote=True)}</text>' | |
| ) | |
| # ---- 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'<rect x="48" y="{quote_y0}" width="{_WIDTH - 96}" ' | |
| f'height="{quote_h}" fill="{p["panel"]}" opacity="0.78" ' | |
| f'stroke="{p["frame"]}" stroke-width="2"/>' | |
| ) | |
| quote_text_parts = [ | |
| f'<text x="{_WIDTH // 2}" y="{quote_y0 + 26}" text-anchor="middle" ' | |
| f'font-family="\'VT323\', \'Courier New\', monospace" ' | |
| f'font-size="18" fill="{p["accent"]}">' | |
| f'~~ THE ORACLE SPEAKS ~~</text>' | |
| ] | |
| for i, line in enumerate(quote_lines): | |
| line_esc = html.escape(f"'{line}'", quote=True) | |
| quote_text_parts.append( | |
| f'<text x="{_WIDTH // 2}" y="{quote_y0 + 56 + i * line_h}" ' | |
| f'text-anchor="middle" ' | |
| f'font-family="\'VT323\', \'Courier New\', monospace" ' | |
| f'font-size="24" fill="{p["text"]}" font-style="italic">' | |
| f'{line_esc}</text>' | |
| ) | |
| quote_text = "".join(quote_text_parts) | |
| # ---- Tactic line, bottom right --------------------------------------- | |
| tactic_band = ( | |
| f'<rect x="0" y="{_HEIGHT - 52}" width="{_WIDTH}" ' | |
| f'height="52" fill="{p["panel_dim"]}" opacity="0.82"/>' | |
| f'<rect x="0" y="{_HEIGHT - 52}" width="{_WIDTH}" ' | |
| f'height="3" fill="{p["frame"]}"/>' | |
| ) | |
| tactic_text_svg = ( | |
| f'<text x="{_WIDTH - 24}" y="{_HEIGHT - 22}" text-anchor="end" ' | |
| f'font-family="\'VT323\', \'Courier New\', monospace" ' | |
| f'font-size="22" font-style="italic" fill="{p["accent"]}">' | |
| f'◆ {html.escape(tactic_text, quote=True)}</text>' | |
| ) | |
| # ---- Chunky outer frame ---------------------------------------------- | |
| frame = ( | |
| f'<rect x="6" y="6" width="{_WIDTH - 12}" height="{_HEIGHT - 12}" ' | |
| f'fill="none" stroke="{p["frame"]}" stroke-width="6"/>' | |
| ) | |
| svg = ( | |
| f'<?xml version="1.0" encoding="UTF-8"?>' | |
| f'<svg xmlns="http://www.w3.org/2000/svg" ' | |
| f'width="{_WIDTH}" height="{_HEIGHT}" ' | |
| f'viewBox="0 0 {_WIDTH} {_HEIGHT}" ' | |
| f'shape-rendering="crispEdges">' | |
| 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'</svg>' | |
| ) | |
| 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) | |