| 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'<img class="vc-bg" src="{bg_uri}" alt="" />' if bg_uri else "" |
|
|
| return f""" |
| <div class="vc-stage2 vc-motion-{motion_class}" style="--accent:{accent}; --bg:{background}; --talk-glow:{talk_glow}; --focus:{focus};"> |
| <style> |
| .vc-stage2 {{ |
| height: min(72vh, 680px); |
| min-height: 560px; |
| position: relative; |
| overflow: hidden; |
| border-radius: 8px; |
| background: |
| linear-gradient(180deg, rgba(5,10,18,.06), rgba(5,10,18,.30)), |
| var(--bg-image), |
| radial-gradient(circle at 74% 18%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 30%), |
| linear-gradient(145deg, color-mix(in srgb, var(--bg) 88%, #27272a), #09090b 74%); |
| background-size: cover, cover, auto, auto; |
| background-position: center, center, center, center; |
| color: #eef2ff; |
| font-family: Inter, "Microsoft YaHei", system-ui, sans-serif; |
| isolation: isolate; |
| }} |
| .vc-stage2::before {{ |
| content: ""; |
| position: absolute; |
| inset: 0; |
| background: |
| linear-gradient(90deg, rgba(255,255,255,.07) 1px, transparent 1px), |
| linear-gradient(0deg, rgba(255,255,255,.05) 1px, transparent 1px); |
| background-size: 72px 72px; |
| mask-image: linear-gradient(180deg, transparent, #000 18%, #000 72%, transparent); |
| opacity: {"0.06" if bg_uri else ".18"}; |
| transform: perspective(700px) rotateX(58deg) translateY(140px) scale(1.4); |
| transform-origin: 50% 100%; |
| }} |
| .vc-stage2::after {{ |
| content: ""; |
| position: absolute; |
| inset: auto 0 0 0; |
| height: 38%; |
| background: linear-gradient(0deg, rgba(5,10,18,.62), rgba(5,10,18,.01)); |
| z-index: 1; |
| pointer-events: none; |
| }} |
| .vc-stage-top {{ |
| position: absolute; |
| z-index: 4; |
| left: 18px; |
| right: 18px; |
| top: 16px; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 12px; |
| font-size: 13px; |
| color: #f8fafc; |
| pointer-events: none; |
| }} |
| .vc-name {{ |
| display: inline-flex; |
| align-items: center; |
| gap: 10px; |
| font-weight: 700; |
| letter-spacing: 0; |
| min-height: 32px; |
| max-width: min(58%, 280px); |
| padding: 6px 12px; |
| border: 1px solid rgba(103,232,249,.42); |
| border-radius: 999px; |
| background: linear-gradient(135deg, rgba(7,15,25,.82), rgba(15,23,42,.66)); |
| color: #f8fafc; |
| box-shadow: 0 10px 28px rgba(2,6,23,.32), inset 0 1px 0 rgba(255,255,255,.10); |
| text-shadow: 0 1px 8px rgba(0,0,0,.72); |
| backdrop-filter: blur(12px); |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| }} |
| .vc-name::before {{ |
| content: ""; |
| width: 10px; |
| height: 10px; |
| border-radius: 999px; |
| background: var(--accent); |
| box-shadow: 0 0 18px var(--accent); |
| }} |
| .vc-status {{ |
| min-height: 32px; |
| max-width: min(42%, 240px); |
| padding: 6px 12px; |
| border: 1px solid rgba(251,191,36,.44); |
| border-radius: 999px; |
| background: linear-gradient(135deg, rgba(24,16,10,.82), rgba(63,40,12,.60)); |
| color: #fff7ed; |
| box-shadow: 0 10px 28px rgba(2,6,23,.30), inset 0 1px 0 rgba(255,255,255,.10); |
| text-shadow: 0 1px 8px rgba(0,0,0,.74); |
| backdrop-filter: blur(12px); |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| }} |
| .vc-spotlight {{ |
| position: absolute; |
| z-index: 1; |
| left: 50%; |
| top: 8%; |
| width: 420px; |
| height: 460px; |
| transform: translateX(-50%); |
| border-radius: 999px; |
| background: radial-gradient(circle, color-mix(in srgb, var(--accent) 38%, transparent), transparent 68%); |
| filter: blur(10px); |
| opacity: var(--talk-glow); |
| animation: vc2-pulse 3.2s ease-in-out infinite; |
| }} |
| .vc-portrait-wrap {{ |
| position: absolute; |
| z-index: 3; |
| left: 50%; |
| bottom: 10px; |
| width: min(76%, 470px); |
| height: calc(100% - 66px); |
| display: flex; |
| align-items: flex-end; |
| justify-content: center; |
| transform: translateX(-50%) scale(var(--focus)); |
| transform-origin: 50% 92%; |
| filter: drop-shadow(0 34px 42px rgba(0,0,0,.46)); |
| animation: vc2-breathe 4.6s ease-in-out infinite; |
| }} |
| .vc-portrait {{ |
| display: block; |
| width: auto; |
| max-width: 100%; |
| max-height: 100%; |
| height: auto; |
| object-fit: contain; |
| user-select: none; |
| pointer-events: none; |
| }} |
| .vc-ground {{ |
| position: absolute; |
| z-index: 2; |
| left: 50%; |
| bottom: 12px; |
| width: 390px; |
| height: 42px; |
| transform: translateX(-50%); |
| border-radius: 999px; |
| background: radial-gradient(ellipse, rgba(0,0,0,.46), transparent 70%); |
| filter: blur(3px); |
| }} |
| .vc-motion-talk .vc-portrait-wrap {{ |
| animation: vc2-talk 1.15s ease-in-out infinite; |
| }} |
| .vc-motion-focus .vc-portrait-wrap, |
| .vc-motion-look .vc-portrait-wrap {{ |
| animation: vc2-focus 2.8s ease-in-out infinite; |
| }} |
| .vc-motion-sway .vc-portrait-wrap {{ |
| animation: vc2-sway 4s ease-in-out infinite; |
| }} |
| .vc-motion-blink .vc-portrait-wrap {{ |
| animation: vc2-blink 3.4s ease-in-out infinite; |
| }} |
| @keyframes vc2-breathe {{ |
| 0%, 100% {{ transform: translateX(-50%) translateY(0) scale(var(--focus)); }} |
| 50% {{ transform: translateX(-50%) translateY(-7px) scale(calc(var(--focus) + .012)); }} |
| }} |
| @keyframes vc2-talk {{ |
| 0%, 100% {{ transform: translateX(-50%) translateY(0) scale(var(--focus)); filter: brightness(1); }} |
| 42% {{ transform: translateX(-50%) translateY(-8px) scale(calc(var(--focus) + .015)); filter: brightness(1.06); }} |
| 70% {{ transform: translateX(-50%) translateY(-3px) scale(calc(var(--focus) + .006)); }} |
| }} |
| @keyframes vc2-focus {{ |
| 0%, 100% {{ transform: translateX(-50%) translateY(0) rotate(-.4deg) scale(var(--focus)); }} |
| 50% {{ transform: translateX(-50%) translateY(-6px) rotate(.7deg) scale(calc(var(--focus) + .01)); }} |
| }} |
| @keyframes vc2-sway {{ |
| 0%, 100% {{ transform: translateX(-50%) rotate(-1deg) scale(var(--focus)); }} |
| 50% {{ transform: translateX(-50%) rotate(1.2deg) translateY(-5px) scale(calc(var(--focus) + .01)); }} |
| }} |
| @keyframes vc2-blink {{ |
| 0%, 100% {{ transform: translateX(-50%) translateY(0) scale(var(--focus)); opacity: 1; }} |
| 48% {{ transform: translateX(-50%) translateY(-4px) scale(var(--focus)); opacity: .98; }} |
| 52% {{ transform: translateX(-50%) translateY(-4px) scale(1.006); opacity: .94; }} |
| }} |
| @keyframes vc2-pulse {{ |
| 0%, 100% {{ opacity: var(--talk-glow); transform: translateX(-50%) scale(.98); }} |
| 50% {{ opacity: calc(var(--talk-glow) + .08); transform: translateX(-50%) scale(1.04); }} |
| }} |
| </style> |
| {bg_markup} |
| <div class="vc-stage-top"> |
| <div class="vc-name">{name}</div> |
| <div class="vc-status">{label} / {html.escape(motion)}</div> |
| </div> |
| <div class="vc-spotlight"></div> |
| <div class="vc-ground"></div> |
| <div class="vc-portrait-wrap"> |
| <img class="vc-portrait" src="{data_uri}" alt="{name} {label}" /> |
| </div> |
| </div> |
| """ |
|
|
|
|
| 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)) |
|
|