vignette/apps/web/src/components/avatar/RasterBust.tsx
Yun Chan 085460b5e0 대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
2026-06-27 02:30:46 +09:00

268 lines
7.2 KiB
TypeScript

import { useMemo } from "react";
import type { AvatarExpression, AvatarState } from "./persona";
export type RasterVariant = "neutral" | "sad" | "tired" | "anxious" | "warm" | "startled";
const VARIANT_FOR_EXPRESSION: Record<AvatarExpression, RasterVariant> = {
neutral: "neutral",
calm: "neutral",
bored: "neutral",
determined: "neutral",
warm: "warm",
joy: "warm",
delight: "warm",
relief: "warm",
hopeful: "warm",
sad: "sad",
grief: "sad",
lonely: "sad",
ashamed: "sad",
guilty: "sad",
overwhelmed: "sad",
anxious: "anxious",
confused: "anxious",
conflicted: "anxious",
embarrassed: "anxious",
resistant: "anxious",
guarded: "anxious",
skeptical: "anxious",
irritated: "anxious",
angry: "anxious",
rage: "anxious",
startled: "startled",
panic: "startled",
tired: "tired",
};
export function rasterVariantFor(expression: AvatarExpression): RasterVariant {
return VARIANT_FOR_EXPRESSION[expression];
}
export interface RasterBustProps {
artSet: string;
affect: AvatarExpression;
state: AvatarState;
breath: number;
blink: number;
mouth: number;
gazeX: number;
gazeY: number;
headTilt: number;
reduced: boolean;
}
const TX = "opacity 220ms cubic-bezier(0.22,0.61,0.36,1), transform 220ms cubic-bezier(0.22,0.61,0.36,1)";
function clamp01(value: number): number {
return Math.min(1, Math.max(0, value));
}
function layerTransform(extra = ""): string {
return `translateX(-50%)${extra ? ` ${extra}` : ""}`;
}
function mouthPartFor(variant: RasterVariant): string {
return variant === "startled" ? "mouth-startled" : `mouth-${variant}`;
}
function browPoseFor(variant: RasterVariant): { y: number; rotate: number } {
switch (variant) {
case "sad":
return { y: 1.4, rotate: -0.35 };
case "tired":
return { y: 1.8, rotate: -0.2 };
case "anxious":
return { y: -1.1, rotate: 0.25 };
case "warm":
return { y: -0.7, rotate: 0.12 };
case "startled":
return { y: -3.2, rotate: 0.1 };
default:
return { y: 0, rotate: 0 };
}
}
export function RasterBust({
artSet,
affect,
state,
breath,
blink,
mouth,
gazeX,
gazeY,
headTilt,
reduced,
}: RasterBustProps) {
const variant = rasterVariantFor(affect);
const speaking = state === "speaking" && !reduced;
const blinkClosed = reduced ? 0 : clamp01((1 - blink) / 0.92);
const speakOverlay = speaking ? clamp01(mouth) : 0;
const browPose = browPoseFor(variant);
const stackTransform = `translate(${gazeX.toFixed(2)}px, ${(gazeY - breath).toFixed(
2,
)}px) rotate(${headTilt.toFixed(2)}deg)`;
const part = useMemo(
() => (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/parts/${name}.png`,
[artSet],
);
const hairPhase = reduced ? 0 : breath;
const hairLeftTransform = layerTransform(
`translate(${(hairPhase * 0.36).toFixed(2)}px, ${(hairPhase * -0.05).toFixed(
2,
)}px) rotate(${(hairPhase * -0.16).toFixed(2)}deg)`,
);
const hairRightTransform = layerTransform(
`translate(${(hairPhase * -0.32).toFixed(2)}px, ${(hairPhase * -0.04).toFixed(
2,
)}px) rotate(${(hairPhase * 0.15).toFixed(2)}deg)`,
);
const bangsTransform = layerTransform(
`translate(${(hairPhase * 0.12).toFixed(2)}px, ${(hairPhase * 0.08).toFixed(
2,
)}px) rotate(${(hairPhase * 0.08).toFixed(2)}deg)`,
);
const eyesTransform = layerTransform(
`translate(${(gazeX * 0.22).toFixed(2)}px, ${(gazeY * 0.16).toFixed(2)}px)`,
);
const browTransform = layerTransform(
`translate(${(gazeX * 0.08).toFixed(2)}px, ${(browPose.y + gazeY * 0.05).toFixed(
2,
)}px) rotate(${browPose.rotate.toFixed(2)}deg)`,
);
return (
<div
className="vg-raster"
data-raster-art-set={artSet}
data-raster-variant={variant}
data-raster-speaking={speaking ? "true" : "false"}
style={{ transform: stackTransform, transformOrigin: "50% 86%" }}
aria-hidden="true"
>
<img
className="vg-raster__layer vg-raster__layer--base"
src={part("base-faceless")}
alt=""
loading="eager"
decoding="async"
draggable={false}
/>
<img
className="vg-raster__layer vg-raster__layer--hair"
src={part("hair-left")}
alt=""
loading="eager"
decoding="async"
draggable={false}
style={{
opacity: reduced ? 0 : 0.16,
transform: hairLeftTransform,
transformOrigin: "34% 28%",
transition: TX,
}}
/>
<img
className="vg-raster__layer vg-raster__layer--hair"
src={part("hair-right")}
alt=""
loading="eager"
decoding="async"
draggable={false}
style={{
opacity: reduced ? 0 : 0.16,
transform: hairRightTransform,
transformOrigin: "66% 28%",
transition: TX,
}}
/>
<img
className="vg-raster__layer vg-raster__layer--bangs"
src={part("hair-bangs")}
alt=""
loading="eager"
decoding="async"
draggable={false}
style={{
opacity: reduced ? 0 : 0.2,
transform: bangsTransform,
transformOrigin: "50% 22%",
transition: TX,
}}
/>
<img
className="vg-raster__layer vg-raster__layer--brow"
src={part(`brow-${variant}`)}
alt=""
loading="eager"
decoding="async"
draggable={false}
style={{ opacity: 1, transform: browTransform, transformOrigin: "50% 30%", transition: TX }}
/>
<img
className="vg-raster__layer vg-raster__layer--eyes"
src={part(`eyes-${variant}`)}
alt=""
loading="eager"
decoding="async"
draggable={false}
style={{
opacity: 1 - blinkClosed * 0.95,
transform: eyesTransform,
transformOrigin: "50% 32%",
transition: TX,
}}
/>
<img
className="vg-raster__layer vg-raster__layer--eyelid"
src={part("eyelid-closed")}
alt=""
loading="eager"
decoding="async"
draggable={false}
style={{ opacity: blinkClosed, transform: layerTransform(), transition: TX }}
/>
<img
className="vg-raster__layer vg-raster__layer--nose"
src={part("nose-neutral")}
alt=""
loading="eager"
decoding="async"
draggable={false}
style={{ transform: layerTransform() }}
/>
<img
className="vg-raster__layer vg-raster__layer--mouth"
src={part(mouthPartFor(variant))}
alt=""
loading="eager"
decoding="async"
draggable={false}
style={{
opacity: 1 - speakOverlay * 0.78,
transform: layerTransform(),
transition: TX,
}}
/>
<img
className="vg-raster__layer vg-raster__layer--mouthopen"
src={part("mouth-open")}
alt=""
loading="eager"
decoding="async"
draggable={false}
style={{
opacity: speakOverlay,
transform: layerTransform(`translateY(${(speakOverlay * 1.2).toFixed(2)}px)`),
transformOrigin: "50% 40%",
transition: TX,
}}
/>
</div>
);
}