virtual-characters / src /stage_driver.py
ShadowInk's picture
Upload complete Space runtime files
6bcddd0 verified
Raw
History Blame Contribute Delete
10.3 kB
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))