the-apprentice / oracles /images.py
AndrewRqy
Memoize hot HTML helpers and lazy-load image module sprites for faster transitions
079fead
Raw
History Blame Contribute Delete
29.7 kB
"""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 -----------------------------------------------------------
@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 <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'&#9670; {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)