import base64 import html from pathlib import Path from character import OWN_FRAGMENTS from sting import (flatline_wav_bytes, heartbeat_wav_bytes, sigh_wav_bytes, sting_wav_bytes) def _tone_class(tone: int) -> str: """Map the tone accumulator to a scene CSS class.""" if tone >= 15: return "tone-warm" if tone <= -22: return "tone-hostile" if tone <= -15: return "tone-wounded" return "tone-neutral" _PLACEHOLDER = "...nothing yet. tell me something true." _PANEL = ( "background:#0c0a12;border:1px solid #2a1f3d;border-radius:4px;" "padding:10px;" ) _HEADER = ( "font-size:10px;letter-spacing:2px;text-transform:uppercase;color:#5a4570;" "padding-bottom:6px;border-bottom:1px solid #1e1628;margin-bottom:8px;" ) _ITEM = ( "background:#110d1a;border:1px solid #2a1f3d;border-left:2px solid #5a3a7a;" "border-radius:0 3px 3px 0;padding:5px 8px;margin-bottom:6px;" "font-size:11px;color:#9a80b8;line-height:1.4;" ) _EMPTY = "font-size:11px;color:#4a3f56;font-style:italic;" _STRUCK = "text-decoration:line-through;opacity:0.35;" _MINE = "font-size:12px;color:#7a5f96;font-style:italic;letter-spacing:2px;" _CLAIMED = "border-left-color:#3a2a50;color:#5a4f72;" _WOUND = "border-left-color:#5a2a2a;color:#6a3a3a;letter-spacing:2px;" _WOUND_REVEALED = "border-left-color:#8a3030;color:#b87878;" _ASSETS = Path(__file__).parent / "assets" def _load_image(name: str) -> str: path = _ASSETS / f"hollow_{name}_cut.webp" if not path.is_file(): raise FileNotFoundError( f"Entity silhouette missing: {path} — assets/hollow_*_cut.webp must be committed." ) return base64.b64encode(path.read_bytes()).decode() _IMG = {name: _load_image(name) for name in ("base", "terror", "almost", "end", "rage", "peace")} _STING = base64.b64encode(sting_wav_bytes()).decode() _HEARTBEAT = base64.b64encode(heartbeat_wav_bytes()).decode() _SIGH = base64.b64encode(sigh_wav_bytes()).decode() _FLATLINE = base64.b64encode(flatline_wav_bytes()).decode() def _src(name: str) -> str: return f"data:image/webp;base64,{_IMG[name]}" def _face_for(affinity: int, mode: str) -> str: """Which cut-out face represents the child in this scene state.""" if mode in ("rage", "frenzy"): return "rage" if mode in ("end", "end_settle", "convulse_loop"): return "end" if mode in ("peace", "peace_settle", "peace_dissolve", "convulse_good"): return "peace" return "almost" if affinity >= 76 else "base" def render_entity(affinity: int, mode: str = "idle", seq: int = 0, tone: int = 0, cue: str = "") -> str: """The entity panel: Hollow as a free-standing silhouette in the scene. mode: "idle" | "flash" (memory captured) | "flash_strong" (recall turn) | "end" (visitor loop) | "rage" (bad finale) | "peace" / "peace_dissolve" (redemption — first smile, then release) seq: turn counter — alternates one-shot animation names (flash-0/flash-1) so the flash restarts even when Gradio morphs the DOM in place. tone: the world's emotional temperature, rendered through the scene CSS. """ a = max(0, min(100, int(affinity))) t = min(a, 45) / 45 # fully materialized by bond 45 # veil the SHARP child in shadow instead of blurring it (blur reads as # a broken/loading image): dark and fog-shrouded at low bond, emerging brightness = round(0.72 + 0.28 * t, 2) opacity = round(0.82 + 0.18 * t, 2) almost_on = a >= 76 flash_key = f"flash-{seq % 2}" tone_class = _tone_class(tone) # a stronger inner/outer glow so the silhouette reads against the fog look = ( f"filter:brightness({brightness}) " f"drop-shadow(0 0 20px rgba(140,120,170,{round(0.25 + 0.25*t,2)})) " f"drop-shadow(0 0 50px rgba(100,80,140,{round(0.15 + 0.20*t,2)}));" f"opacity:{opacity};" ) ghost = "" tint = "" flash = "" if mode == "build": # finale suspense bed: the child stays the materialized smudge while a # heartbeat loops and a red pulse throbs. The HTML is byte-identical # across recital steps, so Gradio doesn't remount it and the looped #