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"""