from __future__ import annotations import base64 import html from functools import lru_cache from pathlib import Path ROOT = Path(__file__).resolve().parents[1] ASSET_ROOT = ROOT / "assets" / "characters" BACKGROUND_ROOT = ROOT / "assets" / "backgrounds" EXPRESSION_LABELS = { "idle": "待机", "listening": "聆听", "thinking": "思考", "worried": "担心", "smile": "微笑", "happy": "开心", } VALID_EXPRESSIONS = set(EXPRESSION_LABELS) @lru_cache(maxsize=96) def _asset_data_uri(avatar: str, expression: str, motion: str) -> str: action_name = _action_asset_name(expression, motion) path = ASSET_ROOT / avatar / f"{action_name}.png" if not path.exists(): path = ASSET_ROOT / avatar / f"{expression}.png" if not path.exists(): path = ASSET_ROOT / avatar / "idle.png" if not path.exists(): path = ASSET_ROOT / "star" / "idle.png" encoded = base64.b64encode(path.read_bytes()).decode("ascii") return f"data:image/png;base64,{encoded}" @lru_cache(maxsize=16) def _background_data_uri(name: str) -> str: path = BACKGROUND_ROOT / f"{name}.png" if not path.exists(): return "" encoded = base64.b64encode(path.read_bytes()).decode("ascii") return f"data:image/png;base64,{encoded}" def render_character_stage(character: dict, stage_state: dict) -> str: visual = character.get("visual", {}) accent = visual.get("accent", "#7dd3fc") background = visual.get("background", "#111827") background_image = visual.get("background_image") or "communication_room" avatar = visual.get("avatar", "star") expression = stage_state.get("expression", "idle") if expression not in VALID_EXPRESSIONS: expression = "idle" motion = stage_state.get("motion", "breathe") intensity = _clamp(stage_state.get("intensity", 0.35), 0.0, 1.0) name = html.escape(character.get("name") or character.get("display_name", "角色")) label = html.escape(EXPRESSION_LABELS.get(expression, expression)) data_uri = _asset_data_uri(avatar, expression, motion) bg_uri = _background_data_uri(background_image) if background_image else "" motion_class = _motion_class(motion) talk_glow = 0.30 + intensity * 0.42 if motion == "talk" else 0.12 + intensity * 0.16 focus = "1.025" if motion in {"talk", "focus", "look_at_user"} else "1" bg_markup = f'' if bg_uri else "" return f"""
{bg_markup}
{name}
{label} / {html.escape(motion)}
{name} {label}
""" def _motion_class(motion: str) -> str: if motion == "talk": return "talk" if motion == "focus": return "focus" if motion == "look_at_user": return "look" if motion == "soft_sway": return "sway" if motion == "gentle_blink": return "blink" return "breathe" def _action_asset_name(expression: str, motion: str) -> str: if motion == "talk": return "talk" if motion == "focus": return "focus" return expression def _clamp(value: object, low: float, high: float) -> float: try: number = float(value) except (TypeError, ValueError): number = (low + high) / 2 return max(low, min(high, number))