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
268 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|