대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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
This commit is contained in:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
|
|
@ -1,5 +1,5 @@
|
|||
/* =====================================================================
|
||||
BodySilhouette — 어깨~목 실루엣 (디테일 X, 평면 벡터)
|
||||
BodySilhouette — 어깨/상반신 실루엣 (디테일 X, 평면 벡터)
|
||||
§4.5: 자세(어깨선)가 정서 채널. 저항=반쯤 돌아선 각도(turn 8°).
|
||||
§4.4: 어깨 호흡(translateY 1.5~2.5px). 여기선 transform 을 부모 <g> 가 줌.
|
||||
|
||||
|
|
@ -7,15 +7,13 @@
|
|||
===================================================================== */
|
||||
|
||||
interface BodySilhouetteProps {
|
||||
/** 헤어/의상 실루엣 색 (1차는 hair.color 재사용) */
|
||||
/** 의상 실루엣 색 */
|
||||
color: string;
|
||||
/** 피부톤 (목) */
|
||||
skin: string;
|
||||
/** 어깨 돌아선 각도 deg (이미 부모 회전에 일부 반영, 여기선 비대칭만) */
|
||||
shoulderTurn: number;
|
||||
}
|
||||
|
||||
export function BodySilhouette({ color, skin, shoulderTurn }: BodySilhouetteProps) {
|
||||
export function BodySilhouette({ color, shoulderTurn }: BodySilhouetteProps) {
|
||||
// 저항(turn)일수록 어깨 한쪽을 살짝 앞으로 — 비대칭으로 "돌아선" 느낌(과장 금지)
|
||||
const skew = Math.min(8, shoulderTurn) * 0.6; // 최대 ~4.8 단위
|
||||
return (
|
||||
|
|
@ -28,8 +26,6 @@ export function BodySilhouette({ color, skin, shoulderTurn }: BodySilhouetteProp
|
|||
fill={color}
|
||||
opacity={0.9}
|
||||
/>
|
||||
{/* 목 */}
|
||||
<rect x="88" y="118" width="24" height="26" rx="10" fill={skin} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,20 +9,27 @@
|
|||
interface BrowsProps {
|
||||
/** 안쪽 끝 기울기 px (양수=안쪽 처짐/우울, 음수=안쪽 올라/긴장) */
|
||||
browTilt: number;
|
||||
/** 눈썹 전체 들어올림 px (양수=위로) */
|
||||
browLift: number;
|
||||
/** 눈썹 안쪽 모임 px */
|
||||
browPinch: number;
|
||||
/** 눈썹 색 (헤어 색) */
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function Brows({ browTilt, color }: BrowsProps) {
|
||||
export function Brows({ browTilt, browLift, browPinch, color }: BrowsProps) {
|
||||
// 안쪽 끝 y 를 browTilt 만큼 이동(좌우 대칭). 바깥 끝은 고정.
|
||||
const innerY = 79 + browTilt;
|
||||
const lift = Math.max(-1.5, Math.min(3, browLift));
|
||||
const pinch = Math.max(0, Math.min(2.4, browPinch));
|
||||
const outerY = 80 - lift;
|
||||
const innerY = 79 + browTilt - lift;
|
||||
return (
|
||||
<>
|
||||
{/* 왼 눈썹: 바깥(74) → 안쪽(90) */}
|
||||
<line
|
||||
x1="74"
|
||||
y1="80"
|
||||
x2="90"
|
||||
x1={74 - pinch * 0.4}
|
||||
y1={outerY}
|
||||
x2={90 + pinch}
|
||||
y2={innerY}
|
||||
stroke={color}
|
||||
strokeWidth="2.4"
|
||||
|
|
@ -30,10 +37,10 @@ export function Brows({ browTilt, color }: BrowsProps) {
|
|||
/>
|
||||
{/* 오른 눈썹: 안쪽(110) → 바깥(126) */}
|
||||
<line
|
||||
x1="110"
|
||||
x1={110 - pinch}
|
||||
y1={innerY}
|
||||
x2="126"
|
||||
y2="80"
|
||||
x2={126 + pinch * 0.4}
|
||||
y2={outerY}
|
||||
stroke={color}
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ import { useEffect, useMemo, useState } from "react";
|
|||
import {
|
||||
ageLookFor,
|
||||
baseResistanceOf,
|
||||
expressionLabelFor,
|
||||
resolveAffectParams,
|
||||
type AvatarAffect,
|
||||
type AvatarHairStyle,
|
||||
type AvatarPersona,
|
||||
type AvatarState,
|
||||
} from "./persona";
|
||||
|
|
@ -31,10 +33,21 @@ import { BodySilhouette } from "./BodySilhouette";
|
|||
import { Eyes } from "./Eyes";
|
||||
import { Brows } from "./Brows";
|
||||
import { Mouth } from "./Mouth";
|
||||
import { live2dModel3Path, live2dModelForPersonaCode, live2dMotionForExpression } from "./live2dModel";
|
||||
import { useExpressionTransition } from "./useExpressionTransition";
|
||||
import { RasterBust } from "./RasterBust";
|
||||
|
||||
/* ── 공개 타입 재노출 (기존 import 경로 호환) ──────────────────────────
|
||||
Session.tsx 등이 ClientAvatar 모듈에서 타입을 가져갈 수 있으므로 유지. */
|
||||
export type { AvatarState, AvatarAffect, AvatarPersona } from "./persona";
|
||||
export {
|
||||
AVATAR_EXPRESSION_LIBRARY,
|
||||
expressionLabelFor,
|
||||
type AvatarState,
|
||||
type AvatarAffect,
|
||||
type AvatarExpression,
|
||||
type AvatarPersona,
|
||||
} from "./persona";
|
||||
export type { Live2DExpressionMotion, Live2DPersonaModel } from "./live2dModel";
|
||||
|
||||
export interface ClientAvatarProps {
|
||||
persona: AvatarPersona;
|
||||
|
|
@ -55,6 +68,12 @@ export interface ClientAvatarProps {
|
|||
/** px 지름 (기본 220 — §5.2 아바타 220px) */
|
||||
size?: number;
|
||||
className?: string;
|
||||
/** Disable rAF-driven breathing/blinking for dense QA grids. */
|
||||
animated?: boolean;
|
||||
/** Hide the top teaching label when the avatar is used as a thumbnail. */
|
||||
showCaption?: boolean;
|
||||
/** Hide persona/state metadata when the avatar is used as a thumbnail. */
|
||||
showMeta?: boolean;
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
|
|
@ -84,6 +103,112 @@ const STATE_TEXT: Record<AvatarState, string> = {
|
|||
speaking: "이야기하는 중",
|
||||
};
|
||||
|
||||
function HairBack({
|
||||
style,
|
||||
color,
|
||||
jawWidth,
|
||||
}: {
|
||||
style: AvatarHairStyle;
|
||||
color: string;
|
||||
jawWidth: number;
|
||||
}) {
|
||||
switch (style) {
|
||||
case "long-straight":
|
||||
return (
|
||||
<path
|
||||
d={`M${55 - (1 - jawWidth) * 5} 94 C54 60 75 42 100 42 C125 42 146 60 145 94 L139 146 C128 153 72 153 61 146 Z`}
|
||||
fill={color}
|
||||
opacity={0.96}
|
||||
/>
|
||||
);
|
||||
case "soft-wave":
|
||||
return (
|
||||
<path
|
||||
d="M57 96 C53 65 73 43 100 42 C128 42 146 64 143 98 C146 121 137 145 121 151 C116 140 84 140 78 151 C62 145 54 121 57 96 Z"
|
||||
fill={color}
|
||||
opacity={0.96}
|
||||
/>
|
||||
);
|
||||
case "bob":
|
||||
return (
|
||||
<path
|
||||
d="M58 97 C56 64 76 45 100 45 C124 45 144 64 142 97 C141 121 132 136 118 141 C111 134 88 134 81 141 C67 136 59 121 58 97 Z"
|
||||
fill={color}
|
||||
opacity={0.96}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function HairFront({ style, color }: { style: AvatarHairStyle; color: string }) {
|
||||
switch (style) {
|
||||
case "messy":
|
||||
return (
|
||||
<path
|
||||
d="M58 91 C58 59 78 44 101 44 C123 44 142 59 142 91 C134 78 126 73 116 70 L112 62 L104 72 L94 62 L88 72 L77 70 C68 74 62 81 58 91 Z"
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
case "side-part":
|
||||
return (
|
||||
<path
|
||||
d="M58 92 C58 59 78 44 101 44 C124 44 143 59 142 92 C134 77 119 69 99 68 C85 70 69 77 58 92 Z M98 46 C94 56 92 64 91 73"
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
case "bob":
|
||||
return (
|
||||
<path
|
||||
d="M58 92 C58 61 78 47 100 47 C122 47 142 61 142 92 C133 80 124 73 112 71 C103 76 89 74 76 72 C67 77 61 83 58 92 Z"
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
case "long-straight":
|
||||
return (
|
||||
<path
|
||||
d="M58 92 C58 58 78 44 100 44 C122 44 142 58 142 92 C135 78 124 71 109 69 C99 75 83 72 70 73 C63 78 59 84 58 92 Z"
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
case "soft-wave":
|
||||
return (
|
||||
<path
|
||||
d="M57 92 C58 58 78 44 100 44 C122 44 143 59 143 92 C134 78 123 71 111 69 C105 76 94 76 88 70 C74 72 63 79 57 92 Z"
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
case "short":
|
||||
default:
|
||||
return (
|
||||
<path
|
||||
d="M58 92 C58 58 78 44 100 44 C122 44 142 58 142 92 C142 78 128 70 100 70 C72 70 58 78 58 92 Z"
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function NeckBridge({ skin }: { skin: string }) {
|
||||
return (
|
||||
<g data-avatar-neck="true">
|
||||
<path
|
||||
d="M88 121 C90 119 110 119 112 121 L114 154 C109 160 91 160 86 154 Z"
|
||||
fill={skin}
|
||||
/>
|
||||
<path
|
||||
d="M89 149 C94 154 106 154 111 149"
|
||||
fill="none"
|
||||
stroke="#8A6B5D"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.2"
|
||||
opacity="0.18"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientAvatar({
|
||||
persona,
|
||||
state,
|
||||
|
|
@ -93,23 +218,40 @@ export function ClientAvatar({
|
|||
speakingProgress = null,
|
||||
size = 220,
|
||||
className,
|
||||
animated = true,
|
||||
showCaption = true,
|
||||
showMeta = true,
|
||||
}: ClientAvatarProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const motionEnabled = animated && !reduced;
|
||||
|
||||
// 외형 안전값
|
||||
const skin = persona.skinTone;
|
||||
const hairColor = persona.hair.color;
|
||||
const hairStyle = persona.hair.style ?? "short";
|
||||
const outfitColor = persona.outfitColor ?? hairColor;
|
||||
const irisColor = persona.eyeColor ?? "#2B2B2B";
|
||||
const realism = Math.min(0.45, Math.max(0.3, persona.realism)); // 안전 클램프
|
||||
const useRaster = Boolean(persona.rasterArtSet);
|
||||
const age = ageLookFor(persona.ageBand);
|
||||
const expressionLabel = expressionLabelFor(affect);
|
||||
const live2dModel = useMemo(() => live2dModelForPersonaCode(persona.code), [persona.code]);
|
||||
const live2dMotion = useMemo(
|
||||
() => live2dMotionForExpression(live2dModel, affect),
|
||||
[affect, live2dModel],
|
||||
);
|
||||
const live2dModelUrl = useMemo(() => live2dModel3Path(live2dModel), [live2dModel]);
|
||||
|
||||
// 6파라미터 정서 → 라포/상태 반영 (라포는 baseResistance 와 합성)
|
||||
const baseRes = baseResistanceOf(persona, affect);
|
||||
// 라포가 저항을 푼다: 실효 라포 = rapport (저항 페르소나일 때 더 큰 시각 효과)
|
||||
const effectiveRapport = baseRes > 0 ? Math.min(1, rapport) : rapport * 0.4;
|
||||
const params = useMemo(
|
||||
const targetParams = useMemo(
|
||||
() => resolveAffectParams(affect, state, effectiveRapport),
|
||||
[affect, state, effectiveRapport],
|
||||
);
|
||||
const transition = useExpressionTransition(targetParams, live2dMotion.fadeInMs, motionEnabled);
|
||||
const params = motionEnabled ? transition.params : targetParams;
|
||||
|
||||
// 모션 루프 (reduced 면 정지)
|
||||
const frame = useAvatarMotion({
|
||||
|
|
@ -117,25 +259,23 @@ export function ClientAvatar({
|
|||
params,
|
||||
analyser,
|
||||
speakingProgress,
|
||||
enabled: !reduced,
|
||||
enabled: motionEnabled,
|
||||
});
|
||||
|
||||
// reduced-motion: 정적 프레임(호흡 0, 눈 뜸, 입 닫힘)
|
||||
const breath = reduced ? 0 : frame.breath;
|
||||
const blink = reduced ? 1 : frame.blink;
|
||||
const mouth = reduced ? 0 : frame.mouth;
|
||||
const saccadeX = reduced ? 0 : frame.saccadeX;
|
||||
const saccadeY = reduced ? 0 : frame.saccadeY;
|
||||
const breath = motionEnabled ? frame.breath : 0;
|
||||
const blink = motionEnabled ? frame.blink : 1;
|
||||
const mouth = motionEnabled ? frame.mouth : 0;
|
||||
const saccadeX = motionEnabled ? frame.saccadeX : 0;
|
||||
const saccadeY = motionEnabled ? frame.saccadeY : 0;
|
||||
|
||||
// 시선 = 정서 회피(gazeAvert) + saccade 미세 이동
|
||||
const gazeX = -params.gazeAvert + saccadeX;
|
||||
const gazeY = saccadeY;
|
||||
|
||||
// 홍채 색: 헤어보다 약간 진한 잉크(순흑 금지 → ink 톤)
|
||||
const irisColor = "#2B2B2B";
|
||||
|
||||
// 흉상 회전: 어깨 돌아선 각도(저항). 회전 중심 = 어깨 위.
|
||||
const shoulderRotate = params.shoulderTurn * 0.4;
|
||||
const mouthOpen = Math.max(mouth, params.mouthOpen);
|
||||
|
||||
return (
|
||||
<figure
|
||||
|
|
@ -143,16 +283,31 @@ export function ClientAvatar({
|
|||
style={{ width: size }}
|
||||
data-state={state}
|
||||
data-affect={affect}
|
||||
data-expression-count={live2dModel.expressions.length}
|
||||
data-live2d-schema={live2dModel.schemaVersion}
|
||||
data-live2d-model={live2dModel.modelId}
|
||||
data-live2d-model-url={live2dModelUrl}
|
||||
data-live2d-motion={live2dMotion.name}
|
||||
data-live2d-motion-file={live2dMotion.file}
|
||||
data-live2d-fade-in-ms={live2dMotion.fadeInMs}
|
||||
data-live2d-transition-active={transition.active ? "true" : "false"}
|
||||
data-live2d-transition-progress={transition.progress.toFixed(2)}
|
||||
data-live2d-expression-count={live2dModel.expressions.length}
|
||||
data-persona-code={persona.code ?? ""}
|
||||
data-avatar-animated={motionEnabled ? "true" : "false"}
|
||||
data-render-mode={useRaster ? "raster" : "svg"}
|
||||
data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */
|
||||
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}`}
|
||||
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}, ${expressionLabel}`}
|
||||
>
|
||||
<style>{AVATAR_CSS}</style>
|
||||
|
||||
{/* 상단 라벨 — 실존 인물 오인 차단(상시, §4.1) */}
|
||||
<figcaption className="vg-avatar__label">
|
||||
<span className="vg-avatar__label-dot" aria-hidden="true" />
|
||||
교육용 가상 내담자
|
||||
</figcaption>
|
||||
{showCaption ? (
|
||||
<figcaption className="vg-avatar__label">
|
||||
<span className="vg-avatar__label-dot" aria-hidden="true" />
|
||||
교육용 가상 내담자
|
||||
</figcaption>
|
||||
) : null}
|
||||
|
||||
<div className="vg-avatar__stage" style={{ height: size }}>
|
||||
{/* 호흡하는 광배 */}
|
||||
|
|
@ -165,51 +320,82 @@ export function ClientAvatar({
|
|||
reduced={reduced}
|
||||
/>
|
||||
|
||||
<svg
|
||||
className="vg-avatar__svg"
|
||||
viewBox="0 0 200 200"
|
||||
width={size}
|
||||
height={size}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{useRaster ? (
|
||||
<RasterBust
|
||||
artSet={persona.rasterArtSet as string}
|
||||
affect={affect}
|
||||
state={state}
|
||||
breath={breath}
|
||||
blink={blink}
|
||||
mouth={mouthOpen}
|
||||
gazeX={gazeX}
|
||||
gazeY={gazeY}
|
||||
headTilt={params.headTilt}
|
||||
reduced={reduced}
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
className="vg-avatar__svg"
|
||||
viewBox="0 0 200 200"
|
||||
width={size}
|
||||
height={size}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* 흉상 그룹: 어깨 호흡(translateY) + 저항 시 미세 회전 */}
|
||||
<g transform={`translate(0 ${-breath}) rotate(${shoulderRotate} 100 150)`}>
|
||||
{/* 어깨/목 실루엣 */}
|
||||
<BodySilhouette color={hairColor} skin={skin} shoulderTurn={params.shoulderTurn} />
|
||||
{/* 어깨/상반신 실루엣 */}
|
||||
<BodySilhouette color={outfitColor} shoulderTurn={params.shoulderTurn} />
|
||||
|
||||
{/* 머리 (양식화 — 코·주름·모공 없음) */}
|
||||
<ellipse cx="100" cy="92" rx={42 * age.jawWidth} ry="46" fill={skin} />
|
||||
<g transform={`rotate(${params.headTilt} 100 101)`}>
|
||||
<HairBack style={hairStyle} color={hairColor} jawWidth={age.jawWidth} />
|
||||
<NeckBridge skin={skin} />
|
||||
|
||||
{/* 헤어 (단순 캡 형태) */}
|
||||
<path
|
||||
d="M58 92 C58 58 78 44 100 44 C122 44 142 58 142 92 C142 78 128 70 100 70 C72 70 58 78 58 92 Z"
|
||||
fill={hairColor}
|
||||
/>
|
||||
{/* 머리 (양식화 — 코·주름·모공 없음) */}
|
||||
<ellipse cx="100" cy="92" rx={42 * age.jawWidth} ry="46" fill={skin} />
|
||||
|
||||
{/* 얼굴 그룹: 시선 회피/saccade (translate) */}
|
||||
<g transform={`translate(${gazeX} ${gazeY})`}>
|
||||
<Brows browTilt={params.browTilt} color={hairColor} />
|
||||
<Eyes
|
||||
eyeSize={age.eyeSize}
|
||||
blink={blink}
|
||||
eyelidDrop={params.eyelidDrop}
|
||||
irisColor={irisColor}
|
||||
/>
|
||||
<Mouth open={mouth} curve={params.mouthCurve} color="#9B5B52" />
|
||||
<HairFront style={hairStyle} color={hairColor} />
|
||||
|
||||
{/* 얼굴 그룹: 시선 회피/saccade (translate) */}
|
||||
<g transform={`translate(${gazeX} ${gazeY})`}>
|
||||
<Brows
|
||||
browTilt={params.browTilt}
|
||||
browLift={params.browLift}
|
||||
browPinch={params.browPinch}
|
||||
color={hairColor}
|
||||
/>
|
||||
<Eyes
|
||||
eyeSize={age.eyeSize}
|
||||
blink={blink}
|
||||
eyeOpen={params.eyeOpen}
|
||||
eyelidDrop={params.eyelidDrop}
|
||||
pupilScale={params.pupilScale}
|
||||
irisColor={irisColor}
|
||||
/>
|
||||
<Mouth
|
||||
open={mouthOpen}
|
||||
curve={params.mouthCurve}
|
||||
width={params.mouthWidth}
|
||||
tension={params.mouthTension}
|
||||
color="#9B5B52"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* 페르소나 메타 + 상태 텍스트 */}
|
||||
<div className="vg-avatar__meta">
|
||||
<span className="vg-avatar__persona">{persona.label}</span>
|
||||
<span className="vg-avatar__state">
|
||||
지금: <b>{STATE_TEXT[state]}</b>
|
||||
</span>
|
||||
</div>
|
||||
{showMeta ? (
|
||||
<div className="vg-avatar__meta">
|
||||
<span className="vg-avatar__persona">{persona.label}</span>
|
||||
<span className="vg-avatar__state">
|
||||
지금: <b>{STATE_TEXT[state]}</b> · {expressionLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
|
@ -234,6 +420,30 @@ const AVATAR_CSS = `
|
|||
.vg-avatar__aura.is-reduced{animation:none;}
|
||||
@keyframes vgAuraBreathe{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}}
|
||||
.vg-avatar__svg{position:relative;z-index:1;display:block;transition:opacity var(--dur-base) var(--ease-out);}
|
||||
/* ── 래스터(Live2D식) 렌더: 레이어 합성 흉상 ── */
|
||||
.vg-raster{position:absolute;inset:0;z-index:1;pointer-events:none;will-change:transform;}
|
||||
.vg-raster__layer{
|
||||
position:absolute;left:50%;top:var(--vg-raster-top,-15%);
|
||||
height:var(--vg-raster-h,136%);width:auto;max-width:none;
|
||||
transform:translateX(-50%);object-fit:contain;
|
||||
user-select:none;-webkit-user-drag:none;opacity:0;
|
||||
}
|
||||
.vg-raster__layer--shoulders{opacity:1;z-index:1;}
|
||||
.vg-raster__layer--neck{opacity:1;z-index:2;}
|
||||
.vg-raster__layer--hairback{opacity:1;z-index:3;}
|
||||
.vg-raster__layer--ear{opacity:1;z-index:4;}
|
||||
.vg-raster__layer--face{opacity:1;z-index:5;}
|
||||
.vg-raster__layer--forehead{opacity:1;z-index:6;}
|
||||
.vg-raster__layer--hair{opacity:1;z-index:7;will-change:transform;}
|
||||
.vg-raster__layer--bangs{opacity:1;z-index:8;will-change:transform;}
|
||||
.vg-raster__layer--brow{z-index:9;will-change:opacity,transform;}
|
||||
.vg-raster__layer--eyes{z-index:10;}
|
||||
.vg-raster__layer--eyelid{z-index:11;}
|
||||
.vg-raster__layer--nose{opacity:1;z-index:12;}
|
||||
.vg-raster__layer--mouth{z-index:13;}
|
||||
.vg-raster__layer--mouthopen{z-index:14;}
|
||||
.vg-raster__layer--base{opacity:1;z-index:1;}
|
||||
.vg-raster__layer--upperface{z-index:2;}
|
||||
.vg-avatar__meta{display:flex;flex-direction:column;align-items:center;gap:3px;text-align:center;}
|
||||
.vg-avatar__persona{font-size:var(--fs-sm);font-weight:600;color:var(--text-strong);}
|
||||
.vg-avatar__state{font-size:var(--fs-xs);color:var(--text-muted);}
|
||||
|
|
|
|||
|
|
@ -11,38 +11,62 @@ interface EyesProps {
|
|||
eyeSize: number;
|
||||
/** 깜빡임 scaleY (1=뜸, 0.08=감김) */
|
||||
blink: number;
|
||||
/** 표정에 따른 눈 세로 개방 배율 */
|
||||
eyeOpen: number;
|
||||
/** 눈꺼풀 하강 px (우울) */
|
||||
eyelidDrop: number;
|
||||
/** 동공/홍채 배율 */
|
||||
pupilScale: number;
|
||||
/** 홍채 색(헤어보다 약간 진한 잉크) */
|
||||
irisColor: string;
|
||||
}
|
||||
|
||||
/** 단일 눈 — 아몬드 흰자 위에 작은 홍채. blink 는 scaleY 로만. */
|
||||
function Eye({ cx, eyeSize, blink, eyelidDrop, irisColor }: {
|
||||
function Eye({ cx, eyeSize, blink, eyelidDrop, eyeOpen, pupilScale, irisColor }: {
|
||||
cx: number;
|
||||
eyeSize: number;
|
||||
blink: number;
|
||||
eyelidDrop: number;
|
||||
eyeOpen: number;
|
||||
pupilScale: number;
|
||||
irisColor: string;
|
||||
}) {
|
||||
const openScale = Math.max(0.08, Math.min(1.35, blink * eyeOpen));
|
||||
const pupil = Math.max(0.82, Math.min(1.12, pupilScale));
|
||||
return (
|
||||
// 눈꺼풀 하강은 translateY, 깜빡임은 scaleY. (동시에 layout 속성 안 건드림)
|
||||
<g transform={`translate(${cx} ${92 + eyelidDrop}) scale(${eyeSize} ${blink})`}>
|
||||
<g transform={`translate(${cx} ${92 + eyelidDrop}) scale(${eyeSize} ${openScale})`}>
|
||||
{/* 아몬드(미세 흰자) — 양식화, 외곽선 없음 */}
|
||||
<ellipse cx="0" cy="0" rx="7.5" ry="5.2" fill="#F3EEE6" opacity={0.7} />
|
||||
{/* 홍채/동공 */}
|
||||
<ellipse cx="0" cy="0" rx="4.6" ry="4.6" fill={irisColor} opacity={0.92} />
|
||||
<ellipse cx="0" cy="0" rx={4.6 * pupil} ry={4.6 * pupil} fill={irisColor} opacity={0.92} />
|
||||
{/* 미세 하이라이트(생체감) */}
|
||||
<circle cx="-1.4" cy="-1.4" r="1.1" fill="#FBFAF8" opacity={0.55} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function Eyes({ eyeSize, blink, eyelidDrop, irisColor }: EyesProps) {
|
||||
export function Eyes({ eyeSize, blink, eyeOpen, eyelidDrop, pupilScale, irisColor }: EyesProps) {
|
||||
return (
|
||||
<>
|
||||
<Eye cx={82} eyeSize={eyeSize} blink={blink} eyelidDrop={eyelidDrop} irisColor={irisColor} />
|
||||
<Eye cx={118} eyeSize={eyeSize} blink={blink} eyelidDrop={eyelidDrop} irisColor={irisColor} />
|
||||
<Eye
|
||||
cx={82}
|
||||
eyeSize={eyeSize}
|
||||
blink={blink}
|
||||
eyelidDrop={eyelidDrop}
|
||||
eyeOpen={eyeOpen}
|
||||
pupilScale={pupilScale}
|
||||
irisColor={irisColor}
|
||||
/>
|
||||
<Eye
|
||||
cx={118}
|
||||
eyeSize={eyeSize}
|
||||
blink={blink}
|
||||
eyelidDrop={eyelidDrop}
|
||||
eyeOpen={eyeOpen}
|
||||
pupilScale={pupilScale}
|
||||
irisColor={irisColor}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,28 +9,37 @@
|
|||
interface MouthProps {
|
||||
/** 입 열림 0~1 (립싱크) */
|
||||
open: number;
|
||||
/** 입꼬리 각도 deg (양수=상향, 음수=하향) */
|
||||
/** 입꼬리 곡률 (양수=미소, 음수=처짐) */
|
||||
curve: number;
|
||||
/** 입 너비 px 보정 */
|
||||
width: number;
|
||||
/** 입술 긴장도 0~1 */
|
||||
tension: number;
|
||||
/** 입술 색 */
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function Mouth({ open, curve, color }: MouthProps) {
|
||||
export function Mouth({ open, curve, width, tension, color }: MouthProps) {
|
||||
// 닫힘=scaleY 0.18(얇은 호) → 열림=1.0(타원). §4.3 데드존은 모션 훅이 처리.
|
||||
const scaleY = 0.18 + open * 0.82;
|
||||
// 입꼬리: 미세 회전으로 표현(과장 금지, ±2° 내외)
|
||||
const rot = Math.max(-2.2, Math.min(2.2, curve));
|
||||
const openAmount = Math.max(0, Math.min(1, open));
|
||||
const scaleY = 0.16 + openAmount * 0.86;
|
||||
const curveAmount = Math.max(-3, Math.min(4.2, curve));
|
||||
const tensionAmount = Math.max(0, Math.min(1, tension));
|
||||
const rx = Math.max(8, Math.min(14, 11 + width));
|
||||
const controlY = curveAmount >= 0 ? 0.2 + curveAmount * 0.82 : curveAmount * 1.05;
|
||||
const cornerY = curveAmount >= 0 ? -0.6 : 0.5;
|
||||
const strokeWidth = 1.45 + tensionAmount * 0.45;
|
||||
|
||||
return (
|
||||
<g transform={`translate(100 116) rotate(${-rot})`}>
|
||||
<g transform="translate(100 116)">
|
||||
{/* 입 안쪽(어두운 톤) — 열릴 때만 살짝 보임 */}
|
||||
<ellipse
|
||||
cx="0"
|
||||
cy="0"
|
||||
rx="11"
|
||||
cy={openAmount > 0.14 ? 0.8 : 0}
|
||||
rx={rx}
|
||||
ry="5.5"
|
||||
fill={color}
|
||||
opacity={0.5}
|
||||
opacity={0.22 + openAmount * 0.36}
|
||||
style={{
|
||||
transform: `scaleY(${scaleY})`,
|
||||
transformOrigin: "center",
|
||||
|
|
@ -39,12 +48,12 @@ export function Mouth({ open, curve, color }: MouthProps) {
|
|||
/>
|
||||
{/* 윗입술 라인(얇은 호) — 닫혀도 입의 존재감 */}
|
||||
<path
|
||||
d="M-11 0 Q0 -1.5 11 0"
|
||||
d={`M${-rx} ${cornerY} Q0 ${controlY} ${rx} ${cornerY}`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.6"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
opacity={0.45}
|
||||
opacity={0.42 + tensionAmount * 0.16}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
|
|
|
|||
268
apps/web/src/components/avatar/RasterBust.tsx
Normal file
268
apps/web/src/components/avatar/RasterBust.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
308
apps/web/src/components/avatar/live2dModel.ts
Normal file
308
apps/web/src/components/avatar/live2dModel.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import {
|
||||
AVATAR_EXPRESSION_LIBRARY,
|
||||
expressionLabelFor,
|
||||
resolveAffectParams,
|
||||
type AvatarExpression,
|
||||
type AvatarHairStyle,
|
||||
} from "./persona";
|
||||
|
||||
export type Live2DPersonaCode = "P4" | "P5" | "P6" | "P7";
|
||||
|
||||
type Live2DParameterId =
|
||||
| "ParamAngleX"
|
||||
| "ParamAngleY"
|
||||
| "ParamAngleZ"
|
||||
| "ParamBodyAngleX"
|
||||
| "ParamBodyAngleZ"
|
||||
| "ParamEyeLOpen"
|
||||
| "ParamEyeROpen"
|
||||
| "ParamEyeBallX"
|
||||
| "ParamEyeBallY"
|
||||
| "ParamBrowLY"
|
||||
| "ParamBrowRY"
|
||||
| "ParamBrowLAngle"
|
||||
| "ParamBrowRAngle"
|
||||
| "ParamMouthForm"
|
||||
| "ParamMouthOpenY"
|
||||
| "ParamBreath";
|
||||
|
||||
export interface Live2DParameterDefinition {
|
||||
id: Live2DParameterId;
|
||||
min: number;
|
||||
max: number;
|
||||
defaultValue: number;
|
||||
}
|
||||
|
||||
export interface Live2DExpressionParameter {
|
||||
id: Live2DParameterId;
|
||||
value: number;
|
||||
blend: "Add" | "Multiply" | "Overwrite";
|
||||
}
|
||||
|
||||
export interface Live2DExpressionMotion {
|
||||
name: AvatarExpression;
|
||||
label: string;
|
||||
group: string;
|
||||
file: string;
|
||||
fadeInMs: number;
|
||||
fadeOutMs: number;
|
||||
durationMs: number;
|
||||
parameters: Live2DExpressionParameter[];
|
||||
}
|
||||
|
||||
export interface Live2DPersonaModel {
|
||||
schemaVersion: "vignette.live2d.v1";
|
||||
renderer: "svg-live2d-parameter-rig";
|
||||
modelId: string;
|
||||
personaCode: string;
|
||||
displayName: string;
|
||||
defaultExpression: AvatarExpression;
|
||||
art: {
|
||||
hairStyle: AvatarHairStyle;
|
||||
hairColor: string;
|
||||
skinTone: string;
|
||||
outfitColor: string;
|
||||
accentColor: string;
|
||||
};
|
||||
hitAreas: readonly { id: "Head" | "Body"; name: string }[];
|
||||
parameters: readonly Live2DParameterDefinition[];
|
||||
expressions: readonly Live2DExpressionMotion[];
|
||||
}
|
||||
|
||||
interface Live2DPersonaProfile {
|
||||
code: Live2DPersonaCode;
|
||||
displayName: string;
|
||||
defaultExpression: AvatarExpression;
|
||||
intensity: number;
|
||||
motionBiasMs: number;
|
||||
art: Live2DPersonaModel["art"];
|
||||
}
|
||||
|
||||
const PARAMETER_DEFINITIONS: readonly Live2DParameterDefinition[] = [
|
||||
{ id: "ParamAngleX", min: -30, max: 30, defaultValue: 0 },
|
||||
{ id: "ParamAngleY", min: -30, max: 30, defaultValue: 0 },
|
||||
{ id: "ParamAngleZ", min: -30, max: 30, defaultValue: 0 },
|
||||
{ id: "ParamBodyAngleX", min: -10, max: 10, defaultValue: 0 },
|
||||
{ id: "ParamBodyAngleZ", min: -10, max: 10, defaultValue: 0 },
|
||||
{ id: "ParamEyeLOpen", min: 0, max: 1.4, defaultValue: 1 },
|
||||
{ id: "ParamEyeROpen", min: 0, max: 1.4, defaultValue: 1 },
|
||||
{ id: "ParamEyeBallX", min: -1, max: 1, defaultValue: 0 },
|
||||
{ id: "ParamEyeBallY", min: -1, max: 1, defaultValue: 0 },
|
||||
{ id: "ParamBrowLY", min: -1, max: 1, defaultValue: 0 },
|
||||
{ id: "ParamBrowRY", min: -1, max: 1, defaultValue: 0 },
|
||||
{ id: "ParamBrowLAngle", min: -1, max: 1, defaultValue: 0 },
|
||||
{ id: "ParamBrowRAngle", min: -1, max: 1, defaultValue: 0 },
|
||||
{ id: "ParamMouthForm", min: -1, max: 1, defaultValue: 0 },
|
||||
{ id: "ParamMouthOpenY", min: 0, max: 1, defaultValue: 0 },
|
||||
{ id: "ParamBreath", min: 0, max: 1, defaultValue: 0.5 },
|
||||
];
|
||||
|
||||
const HIT_AREAS: Live2DPersonaModel["hitAreas"] = [
|
||||
{ id: "Head", name: "Head" },
|
||||
{ id: "Body", name: "Body" },
|
||||
];
|
||||
|
||||
const PERSONA_PROFILES: Record<Live2DPersonaCode, Live2DPersonaProfile> = {
|
||||
P4: {
|
||||
code: "P4",
|
||||
displayName: "하늘 · 시험불안",
|
||||
defaultExpression: "anxious",
|
||||
intensity: 1.08,
|
||||
motionBiasMs: -40,
|
||||
art: {
|
||||
hairStyle: "long-straight",
|
||||
hairColor: "#35261F",
|
||||
skinTone: "#E5C0A0",
|
||||
outfitColor: "#5F6F88",
|
||||
accentColor: "#3B6E8F",
|
||||
},
|
||||
},
|
||||
P5: {
|
||||
code: "P5",
|
||||
displayName: "도윤 · 또래관계 갈등",
|
||||
defaultExpression: "guarded",
|
||||
intensity: 1.02,
|
||||
motionBiasMs: 20,
|
||||
art: {
|
||||
hairStyle: "messy",
|
||||
hairColor: "#28231F",
|
||||
skinTone: "#D7AD8D",
|
||||
outfitColor: "#6F766F",
|
||||
accentColor: "#7D8784",
|
||||
},
|
||||
},
|
||||
P6: {
|
||||
code: "P6",
|
||||
displayName: "하린 · 진로갈등",
|
||||
defaultExpression: "conflicted",
|
||||
intensity: 1,
|
||||
motionBiasMs: 0,
|
||||
art: {
|
||||
hairStyle: "soft-wave",
|
||||
hairColor: "#2D241F",
|
||||
skinTone: "#E6BE9A",
|
||||
outfitColor: "#776F85",
|
||||
accentColor: "#6B5E7D",
|
||||
},
|
||||
},
|
||||
P7: {
|
||||
code: "P7",
|
||||
displayName: "도현 · 입시 번아웃",
|
||||
defaultExpression: "tired",
|
||||
intensity: 0.94,
|
||||
motionBiasMs: 80,
|
||||
art: {
|
||||
hairStyle: "side-part",
|
||||
hairColor: "#23211E",
|
||||
skinTone: "#D5AA88",
|
||||
outfitColor: "#596367",
|
||||
accentColor: "#6F7777",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK_PROFILE: Live2DPersonaProfile = {
|
||||
code: "P4",
|
||||
displayName: "내담자 · 기본 모델",
|
||||
defaultExpression: "neutral",
|
||||
intensity: 1,
|
||||
motionBiasMs: 0,
|
||||
art: {
|
||||
hairStyle: "short",
|
||||
hairColor: "#7A7068",
|
||||
skinTone: "#D7CCC3",
|
||||
outfitColor: "#6A6874",
|
||||
accentColor: "#3E7A6E",
|
||||
},
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function scaled(value: number, intensity: number, min: number, max: number): number {
|
||||
return clamp(value * intensity, min, max);
|
||||
}
|
||||
|
||||
function expressionDurationMs(expression: AvatarExpression, profile: Live2DPersonaProfile): number {
|
||||
const quickExpressions: readonly AvatarExpression[] = [
|
||||
"startled",
|
||||
"panic",
|
||||
"irritated",
|
||||
"angry",
|
||||
"rage",
|
||||
];
|
||||
const slowExpressions: readonly AvatarExpression[] = ["sad", "grief", "lonely", "tired", "bored"];
|
||||
const base = quickExpressions.includes(expression)
|
||||
? 420
|
||||
: slowExpressions.includes(expression)
|
||||
? 980
|
||||
: 720;
|
||||
return Math.max(280, base + profile.motionBiasMs);
|
||||
}
|
||||
|
||||
function createMotion(
|
||||
expression: AvatarExpression,
|
||||
profile: Live2DPersonaProfile,
|
||||
): Live2DExpressionMotion {
|
||||
const params = resolveAffectParams(expression, "idle", 0);
|
||||
const intensity = profile.intensity;
|
||||
const eyeOpen = clamp(params.eyeOpen - Math.max(0, params.eyelidDrop) * 0.065, 0.08, 1.4);
|
||||
const browY = scaled(params.browLift / 3 - params.eyelidDrop / 5, intensity, -1, 1);
|
||||
const browAngle = scaled((-params.browTilt - params.browPinch) / 3.4, intensity, -1, 1);
|
||||
|
||||
return {
|
||||
name: expression,
|
||||
label: expressionLabelFor(expression),
|
||||
group: AVATAR_EXPRESSION_LIBRARY.find((item) => item.id === expression)?.group ?? "cognitive",
|
||||
file: `expressions/${expression}.exp3.json`,
|
||||
fadeInMs: 260,
|
||||
fadeOutMs: 320,
|
||||
durationMs: expressionDurationMs(expression, profile),
|
||||
parameters: [
|
||||
{ id: "ParamAngleX", value: scaled(-params.gazeAvert * 5, intensity, -30, 30), blend: "Add" },
|
||||
{ id: "ParamAngleY", value: scaled(-params.eyelidDrop * 4, intensity, -30, 30), blend: "Add" },
|
||||
{ id: "ParamAngleZ", value: scaled(params.headTilt * 4, intensity, -30, 30), blend: "Add" },
|
||||
{
|
||||
id: "ParamBodyAngleX",
|
||||
value: scaled(-params.shoulderTurn * 1.2, intensity, -10, 10),
|
||||
blend: "Add",
|
||||
},
|
||||
{
|
||||
id: "ParamBodyAngleZ",
|
||||
value: scaled(params.shoulderTurn * 0.9, intensity, -10, 10),
|
||||
blend: "Add",
|
||||
},
|
||||
{ id: "ParamEyeLOpen", value: eyeOpen, blend: "Overwrite" },
|
||||
{ id: "ParamEyeROpen", value: eyeOpen, blend: "Overwrite" },
|
||||
{ id: "ParamEyeBallX", value: scaled(-params.gazeAvert / 4.5, intensity, -1, 1), blend: "Add" },
|
||||
{
|
||||
id: "ParamEyeBallY",
|
||||
value: scaled((params.eyelidDrop < 0 ? 0.24 : -params.eyelidDrop / 4) + params.headTilt / 18, intensity, -1, 1),
|
||||
blend: "Add",
|
||||
},
|
||||
{ id: "ParamBrowLY", value: browY, blend: "Add" },
|
||||
{ id: "ParamBrowRY", value: browY, blend: "Add" },
|
||||
{ id: "ParamBrowLAngle", value: browAngle, blend: "Add" },
|
||||
{ id: "ParamBrowRAngle", value: -browAngle, blend: "Add" },
|
||||
{ id: "ParamMouthForm", value: scaled(params.mouthCurve / 4.2, intensity, -1, 1), blend: "Overwrite" },
|
||||
{
|
||||
id: "ParamMouthOpenY",
|
||||
value: clamp(params.mouthOpen + Math.max(0, -params.mouthCurve) * 0.035, 0, 1),
|
||||
blend: "Overwrite",
|
||||
},
|
||||
{ id: "ParamBreath", value: clamp(params.breathAmp / 2.5, 0, 1), blend: "Overwrite" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createModel(profile: Live2DPersonaProfile): Live2DPersonaModel {
|
||||
return {
|
||||
schemaVersion: "vignette.live2d.v1",
|
||||
renderer: "svg-live2d-parameter-rig",
|
||||
modelId: `vignette-${profile.code.toLowerCase()}-live2d`,
|
||||
personaCode: profile.code,
|
||||
displayName: profile.displayName,
|
||||
defaultExpression: profile.defaultExpression,
|
||||
art: profile.art,
|
||||
hitAreas: HIT_AREAS,
|
||||
parameters: PARAMETER_DEFINITIONS,
|
||||
expressions: AVATAR_EXPRESSION_LIBRARY.map((expression) =>
|
||||
createMotion(expression.id, profile),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const PERSONA_LIVE2D_MODELS: Record<Live2DPersonaCode, Live2DPersonaModel> = {
|
||||
P4: createModel(PERSONA_PROFILES.P4),
|
||||
P5: createModel(PERSONA_PROFILES.P5),
|
||||
P6: createModel(PERSONA_PROFILES.P6),
|
||||
P7: createModel(PERSONA_PROFILES.P7),
|
||||
};
|
||||
|
||||
export const FALLBACK_LIVE2D_MODEL: Live2DPersonaModel = {
|
||||
...createModel(FALLBACK_PROFILE),
|
||||
modelId: "vignette-generic-live2d",
|
||||
personaCode: "GENERIC",
|
||||
};
|
||||
|
||||
export function live2dModelForPersonaCode(code?: string): Live2DPersonaModel {
|
||||
const key = (code ?? "").trim().toUpperCase() as Live2DPersonaCode;
|
||||
return PERSONA_LIVE2D_MODELS[key] ?? FALLBACK_LIVE2D_MODEL;
|
||||
}
|
||||
|
||||
export function live2dModel3Path(model: Live2DPersonaModel): string {
|
||||
const code = model.personaCode.toLowerCase();
|
||||
return `/live2d/personas/${code}/${code}.model3.json`;
|
||||
}
|
||||
|
||||
export function live2dMotionForExpression(
|
||||
model: Live2DPersonaModel,
|
||||
expression: AvatarExpression,
|
||||
): Live2DExpressionMotion {
|
||||
return (
|
||||
model.expressions.find((motion) => motion.name === expression) ??
|
||||
model.expressions.find((motion) => motion.name === model.defaultExpression) ??
|
||||
model.expressions[0]
|
||||
);
|
||||
}
|
||||
|
|
@ -1,50 +1,154 @@
|
|||
/* =====================================================================
|
||||
ClientAvatar — 페르소나 / 정서 파라미터 타입 + 6파라미터 상태머신 테이블
|
||||
ClientAvatar — 페르소나 / 표정 파라미터 타입 + Live2D식 상태머신 테이블
|
||||
출처: docs/DESIGN_CONCEPT.md §4.5(6파라미터 정서 상태머신) / §4.6(페르소나 시스템)
|
||||
|
||||
── Rive 2차 교체 지점 ─────────────────────────────────────────────
|
||||
SVG 1차안은 아래 6파라미터를 매 프레임 transform 으로 인코딩한다.
|
||||
Rive 전환 시: 이 파일의 (state, affect, resistance, rapport) 입력 계약은
|
||||
그대로 두고, AffectParams → .riv state-machine input 으로만 다시 wiring 한다.
|
||||
컴포넌트(ClientAvatar/useAvatarMotion)는 AffectParams 만 소비하므로 교체 표면이 좁다.
|
||||
── Rive/Live2D 2차 교체 지점 ─────────────────────────────────────
|
||||
SVG 1차안은 아래 파라미터를 매 프레임 transform 으로 인코딩한다.
|
||||
Cubism/Rive 전환 시: 이 파일의 (state, expression, resistance, rapport)
|
||||
입력 계약은 그대로 두고, AffectParams → 모델 파라미터 입력으로 wiring 한다.
|
||||
===================================================================== */
|
||||
|
||||
/* ── 음성 UI 4-state (§4.2) ─────────────────────────────────────────── */
|
||||
export type AvatarState = "idle" | "listening" | "thinking" | "speaking";
|
||||
|
||||
/* ── 내담자 정서 4종 (§4.5) ────────────────────────────────────────── */
|
||||
export type AvatarAffect = "neutral" | "depressed" | "anxious" | "resistant";
|
||||
/* ── 내담자 표정 프리셋: 모든 캐릭터가 최소 20개 이상 사용 가능 ─────── */
|
||||
export type AvatarExpression =
|
||||
| "neutral"
|
||||
| "calm"
|
||||
| "warm"
|
||||
| "joy"
|
||||
| "delight"
|
||||
| "relief"
|
||||
| "hopeful"
|
||||
| "sad"
|
||||
| "grief"
|
||||
| "lonely"
|
||||
| "anxious"
|
||||
| "panic"
|
||||
| "confused"
|
||||
| "conflicted"
|
||||
| "startled"
|
||||
| "embarrassed"
|
||||
| "ashamed"
|
||||
| "guilty"
|
||||
| "resistant"
|
||||
| "guarded"
|
||||
| "skeptical"
|
||||
| "irritated"
|
||||
| "angry"
|
||||
| "rage"
|
||||
| "tired"
|
||||
| "bored"
|
||||
| "overwhelmed"
|
||||
| "determined";
|
||||
|
||||
/* 기존 import 표면 호환용 이름. */
|
||||
export type AvatarAffect = AvatarExpression;
|
||||
|
||||
export interface AvatarExpressionMeta {
|
||||
id: AvatarExpression;
|
||||
label: string;
|
||||
group: "positive" | "negative" | "defensive" | "cognitive" | "energy";
|
||||
}
|
||||
|
||||
export const AVATAR_EXPRESSION_LIBRARY: readonly AvatarExpressionMeta[] = [
|
||||
{ id: "neutral", label: "중립", group: "cognitive" },
|
||||
{ id: "calm", label: "차분함", group: "positive" },
|
||||
{ id: "warm", label: "온화함", group: "positive" },
|
||||
{ id: "joy", label: "기쁨", group: "positive" },
|
||||
{ id: "delight", label: "반가움", group: "positive" },
|
||||
{ id: "relief", label: "안도", group: "positive" },
|
||||
{ id: "hopeful", label: "희망", group: "positive" },
|
||||
{ id: "sad", label: "슬픔", group: "negative" },
|
||||
{ id: "grief", label: "비탄", group: "negative" },
|
||||
{ id: "lonely", label: "외로움", group: "negative" },
|
||||
{ id: "anxious", label: "불안", group: "negative" },
|
||||
{ id: "panic", label: "공황", group: "negative" },
|
||||
{ id: "confused", label: "혼란", group: "cognitive" },
|
||||
{ id: "conflicted", label: "갈등", group: "cognitive" },
|
||||
{ id: "startled", label: "놀람", group: "negative" },
|
||||
{ id: "embarrassed", label: "당황", group: "negative" },
|
||||
{ id: "ashamed", label: "수치심", group: "negative" },
|
||||
{ id: "guilty", label: "죄책감", group: "negative" },
|
||||
{ id: "resistant", label: "저항", group: "defensive" },
|
||||
{ id: "guarded", label: "경계", group: "defensive" },
|
||||
{ id: "skeptical", label: "회의", group: "defensive" },
|
||||
{ id: "irritated", label: "짜증", group: "defensive" },
|
||||
{ id: "angry", label: "분노", group: "defensive" },
|
||||
{ id: "rage", label: "격노", group: "defensive" },
|
||||
{ id: "tired", label: "피곤", group: "energy" },
|
||||
{ id: "bored", label: "무심함", group: "energy" },
|
||||
{ id: "overwhelmed", label: "압도감", group: "negative" },
|
||||
{ id: "determined", label: "결심", group: "positive" },
|
||||
];
|
||||
|
||||
const EXPRESSION_LABELS: Record<AvatarExpression, string> = Object.fromEntries(
|
||||
AVATAR_EXPRESSION_LIBRARY.map((expression) => [expression.id, expression.label]),
|
||||
) as Record<AvatarExpression, string>;
|
||||
|
||||
export function expressionLabelFor(expression: AvatarExpression): string {
|
||||
return EXPRESSION_LABELS[expression];
|
||||
}
|
||||
|
||||
/* ── 연령대 (§4.6 외형 규칙) ───────────────────────────────────────── */
|
||||
export type AvatarAgeBand = "teen" | "youngAdult" | "adult" | "senior";
|
||||
|
||||
export type AvatarHairStyle =
|
||||
| "short"
|
||||
| "bob"
|
||||
| "long-straight"
|
||||
| "soft-wave"
|
||||
| "messy"
|
||||
| "side-part";
|
||||
|
||||
export interface AvatarPersona {
|
||||
/** "서연 · 17세 · 고2" 같은 메타 1줄 라벨 */
|
||||
label: string;
|
||||
/** DB persona code. 외형 시드와 테스트 식별에 사용 */
|
||||
code?: string;
|
||||
/** 연령대 (외형 규칙 §4.6) */
|
||||
ageBand: AvatarAgeBand;
|
||||
/** 피부톤 hex */
|
||||
skinTone: string;
|
||||
/** 헤어 (스타일 + 색) — 1차는 색만 사용 */
|
||||
hair: { style?: string; color: string };
|
||||
/** 헤어 스타일/색 */
|
||||
hair: { style?: AvatarHairStyle; color: string };
|
||||
/** 의상/상반신 실루엣 색 */
|
||||
outfitColor?: string;
|
||||
/** 눈동자 색 */
|
||||
eyeColor?: string;
|
||||
/** 캐릭터 고유 보조색 */
|
||||
accentColor?: string;
|
||||
/**
|
||||
* 래스터(비트맵) 아트셋 코드. 지정하면 SVG 파라미터 리그 대신
|
||||
* `/avatar/<artSet>/<variant>.png` 레이어 합성(Live2D식)으로 렌더링한다.
|
||||
* 미지정 시 기존 SVG 리그. asset 은 imagegen(gpt-image-2)+BiRefNet 누끼로 생성.
|
||||
*/
|
||||
rasterArtSet?: string;
|
||||
/** 사실성 0.35~0.45 고정 (불쾌한 골짜기 회피) */
|
||||
realism: number;
|
||||
/**
|
||||
* 저항도 0~1 (§4.6). 라포가 쌓이면 감소한다.
|
||||
* 미지정 시 affect="resistant" → 0.6, 그 외 0 으로 본다.
|
||||
* 미지정 시 defensive expression → 0.6, 그 외 0 으로 본다.
|
||||
*/
|
||||
resistance?: number;
|
||||
/** 해당 페르소나의 기본 정서 경향 */
|
||||
expressionBias?: AvatarExpression;
|
||||
}
|
||||
|
||||
/* ── 6파라미터 상태머신 (§4.5) ──────────────────────────────────────
|
||||
상태머신이 이 6개 숫자만 바꾸면 정서가 전환된다:
|
||||
(eyelidDrop, gazeAvert, shoulderTurn, breathRate, auraHue, blinkInterval)
|
||||
여기서는 모션 루프가 바로 쓰도록 호흡 주기/진폭, 깜빡임 min/max 까지 펼친다. */
|
||||
/* ── 표정 파라미터 상태머신 (§4.5) ──────────────────────────────────
|
||||
Live2D/Cubism 파라미터처럼 작은 수치들을 조합한다. 과장 금지:
|
||||
눈물/홍조/땀/만화식 효과 없이 "감지되되 단언되지 않는" 수준. */
|
||||
export interface AffectParams {
|
||||
/** 눈꺼풀 하강 px (우울 1~2px) */
|
||||
/** 눈꺼풀 하강 px (우울 1~2px, 놀람은 음수) */
|
||||
eyelidDrop: number;
|
||||
/** 시선 회피 x 오프셋 px (저항/사고 시) */
|
||||
/** 눈 세로 개방 배율 */
|
||||
eyeOpen: number;
|
||||
/** 동공/홍채 배율 */
|
||||
pupilScale: number;
|
||||
/** 시선 회피 x 오프셋 px */
|
||||
gazeAvert: number;
|
||||
/** 머리 기울기 deg */
|
||||
headTilt: number;
|
||||
/** 어깨 돌아선 각도 deg (저항 8° → 라포로 0°) */
|
||||
shoulderTurn: number;
|
||||
/** 호흡 주기 초 (= breathRate 역수 표현) */
|
||||
|
|
@ -59,70 +163,468 @@ export interface AffectParams {
|
|||
blinkMin: number;
|
||||
/** 깜빡임 최대 간격 초 */
|
||||
blinkMax: number;
|
||||
/** 입꼬리 각도 deg (양수=상향, 음수=하향). 미세값만. */
|
||||
/** 입꼬리 곡률 (양수=미소, 음수=처짐) */
|
||||
mouthCurve: number;
|
||||
/** 눈썹 안쪽 끝 기울기 px (우울=안쪽 살짝 올라/처짐) */
|
||||
/** 기본 입 열림 0~1. 발화 립싱크와 max 처리 */
|
||||
mouthOpen: number;
|
||||
/** 입 너비 px 보정 */
|
||||
mouthWidth: number;
|
||||
/** 입술 긴장도 0~1 */
|
||||
mouthTension: number;
|
||||
/** 눈썹 안쪽 끝 기울기 px (양수=안쪽 처짐, 음수=안쪽 올라감) */
|
||||
browTilt: number;
|
||||
/** 눈썹 전체 들어올림 px (양수=위로) */
|
||||
browLift: number;
|
||||
/** 눈썹 안쪽 모임 px */
|
||||
browPinch: number;
|
||||
}
|
||||
|
||||
/* ── 정서별 기준 파라미터 표 (§4.5) ──────────────────────────────────
|
||||
과장 절대 금지: 눈물/홍조/땀 없음. "감지되되 단언되지 않는" 수준. */
|
||||
const AFFECT_TABLE: Record<AvatarAffect, AffectParams> = {
|
||||
// 중립/라포 — 세이지 4%, 안정 4s
|
||||
neutral: {
|
||||
eyelidDrop: 0,
|
||||
gazeAvert: 0,
|
||||
shoulderTurn: 0,
|
||||
breathPeriod: 4,
|
||||
breathAmp: 2,
|
||||
auraHue: "#3E7A6E", // --accent (세이지-틸)
|
||||
auraOpacity: 0.04,
|
||||
blinkMin: 4,
|
||||
blinkMax: 7,
|
||||
mouthCurve: 1.2, // 미세 상향
|
||||
browTilt: 0,
|
||||
const DEFAULT_PARAMS: AffectParams = {
|
||||
eyelidDrop: 0,
|
||||
eyeOpen: 1,
|
||||
pupilScale: 1,
|
||||
gazeAvert: 0,
|
||||
headTilt: 0,
|
||||
shoulderTurn: 0,
|
||||
breathPeriod: 4,
|
||||
breathAmp: 2,
|
||||
auraHue: "#3E7A6E",
|
||||
auraOpacity: 0.04,
|
||||
blinkMin: 4,
|
||||
blinkMax: 7,
|
||||
mouthCurve: 1.2,
|
||||
mouthOpen: 0,
|
||||
mouthWidth: 0,
|
||||
mouthTension: 0.2,
|
||||
browTilt: 0,
|
||||
browLift: 0,
|
||||
browPinch: 0,
|
||||
};
|
||||
|
||||
const AFFECT_TABLE: Record<AvatarExpression, Partial<AffectParams>> = {
|
||||
neutral: {},
|
||||
calm: {
|
||||
eyelidDrop: 0.4,
|
||||
eyeOpen: 0.96,
|
||||
breathPeriod: 4.8,
|
||||
breathAmp: 1.7,
|
||||
auraHue: "#4F837A",
|
||||
mouthCurve: 0.8,
|
||||
blinkMin: 4.8,
|
||||
blinkMax: 7.5,
|
||||
},
|
||||
// 우울(저각성·부정) — 청회색 5%, 느리고 얕게 5.5s
|
||||
depressed: {
|
||||
warm: {
|
||||
eyeOpen: 1.02,
|
||||
breathPeriod: 4.2,
|
||||
auraHue: "#6E8B82",
|
||||
auraOpacity: 0.055,
|
||||
mouthCurve: 2.2,
|
||||
mouthWidth: 1,
|
||||
browLift: 0.4,
|
||||
},
|
||||
joy: {
|
||||
eyeOpen: 1.06,
|
||||
pupilScale: 1.04,
|
||||
breathPeriod: 3.7,
|
||||
breathAmp: 2.3,
|
||||
auraHue: "#5F968B",
|
||||
auraOpacity: 0.07,
|
||||
mouthCurve: 3.2,
|
||||
mouthOpen: 0.08,
|
||||
mouthWidth: 1.5,
|
||||
browLift: 0.9,
|
||||
},
|
||||
delight: {
|
||||
eyeOpen: 1.12,
|
||||
pupilScale: 1.08,
|
||||
breathPeriod: 3.3,
|
||||
breathAmp: 2.4,
|
||||
auraHue: "#7A9464",
|
||||
auraOpacity: 0.075,
|
||||
blinkMin: 3.3,
|
||||
blinkMax: 5.5,
|
||||
mouthCurve: 3.8,
|
||||
mouthOpen: 0.16,
|
||||
mouthWidth: 2,
|
||||
browLift: 1.3,
|
||||
},
|
||||
relief: {
|
||||
eyelidDrop: 0.7,
|
||||
eyeOpen: 0.94,
|
||||
headTilt: 1,
|
||||
breathPeriod: 5.2,
|
||||
breathAmp: 1.9,
|
||||
auraHue: "#6E8B82",
|
||||
auraOpacity: 0.06,
|
||||
mouthCurve: 1.7,
|
||||
browLift: 0.2,
|
||||
},
|
||||
hopeful: {
|
||||
eyeOpen: 1.08,
|
||||
pupilScale: 1.04,
|
||||
gazeAvert: -0.8,
|
||||
breathPeriod: 4,
|
||||
auraHue: "#4F8C78",
|
||||
auraOpacity: 0.065,
|
||||
mouthCurve: 2.3,
|
||||
mouthWidth: 1,
|
||||
browLift: 1,
|
||||
},
|
||||
sad: {
|
||||
eyelidDrop: 1.5,
|
||||
gazeAvert: 1.5, // 시선 아래(주로 y, x는 미세)
|
||||
eyeOpen: 0.88,
|
||||
gazeAvert: 1.5,
|
||||
headTilt: -1,
|
||||
shoulderTurn: 1,
|
||||
breathPeriod: 5.5,
|
||||
breathAmp: 1.5,
|
||||
auraHue: "#5B6B73", // 청회색
|
||||
auraHue: "#5B6B73",
|
||||
auraOpacity: 0.05,
|
||||
blinkMin: 5,
|
||||
blinkMax: 8,
|
||||
mouthCurve: -0.6, // 수평~미세 하향
|
||||
browTilt: 1.2, // 안쪽 끝 살짝 처짐
|
||||
mouthCurve: -1.2,
|
||||
browTilt: 1.2,
|
||||
browPinch: 0.5,
|
||||
},
|
||||
// 불안(고각성·부정) — 차가운 청 5% 빠른 펄스, 깜빡임↑, 빠르고 얕게 3s
|
||||
anxious: {
|
||||
eyelidDrop: 0,
|
||||
grief: {
|
||||
eyelidDrop: 2.2,
|
||||
eyeOpen: 0.82,
|
||||
gazeAvert: 2,
|
||||
headTilt: -1.6,
|
||||
shoulderTurn: 1.5,
|
||||
breathPeriod: 6.2,
|
||||
breathAmp: 1.25,
|
||||
auraHue: "#56636C",
|
||||
auraOpacity: 0.055,
|
||||
blinkMin: 5.5,
|
||||
blinkMax: 8.5,
|
||||
mouthCurve: -2.2,
|
||||
mouthWidth: -1,
|
||||
browTilt: 1.8,
|
||||
browPinch: 1,
|
||||
},
|
||||
lonely: {
|
||||
eyelidDrop: 1.4,
|
||||
eyeOpen: 0.9,
|
||||
gazeAvert: 2.8,
|
||||
headTilt: -2,
|
||||
shoulderTurn: 2.5,
|
||||
breathPeriod: 5.8,
|
||||
breathAmp: 1.35,
|
||||
auraHue: "#66727A",
|
||||
auraOpacity: 0.045,
|
||||
mouthCurve: -0.8,
|
||||
mouthWidth: -0.8,
|
||||
browTilt: 1,
|
||||
},
|
||||
anxious: {
|
||||
eyeOpen: 1.1,
|
||||
pupilScale: 0.96,
|
||||
gazeAvert: 2,
|
||||
headTilt: 0.8,
|
||||
shoulderTurn: 2,
|
||||
breathPeriod: 3,
|
||||
breathAmp: 1.5,
|
||||
auraHue: "#3B6E8F", // 차가운 청
|
||||
auraHue: "#3B6E8F",
|
||||
auraOpacity: 0.05,
|
||||
blinkMin: 2,
|
||||
blinkMax: 4,
|
||||
mouthCurve: 0, // 다묾, 긴장
|
||||
mouthCurve: -0.1,
|
||||
mouthTension: 0.6,
|
||||
browTilt: 0.4,
|
||||
browLift: 0.5,
|
||||
browPinch: 1,
|
||||
},
|
||||
panic: {
|
||||
eyelidDrop: -0.8,
|
||||
eyeOpen: 1.24,
|
||||
pupilScale: 0.9,
|
||||
gazeAvert: 3,
|
||||
headTilt: 1.6,
|
||||
shoulderTurn: 3.5,
|
||||
breathPeriod: 2.35,
|
||||
breathAmp: 1.35,
|
||||
auraHue: "#2E5A7A",
|
||||
auraOpacity: 0.075,
|
||||
blinkMin: 1.6,
|
||||
blinkMax: 3,
|
||||
mouthCurve: -0.8,
|
||||
mouthOpen: 0.2,
|
||||
mouthWidth: 0.5,
|
||||
mouthTension: 0.85,
|
||||
browTilt: -0.8,
|
||||
browLift: 2.4,
|
||||
browPinch: 1.4,
|
||||
},
|
||||
confused: {
|
||||
eyeOpen: 1.02,
|
||||
gazeAvert: 2.4,
|
||||
headTilt: 3,
|
||||
shoulderTurn: 1.5,
|
||||
breathPeriod: 4.2,
|
||||
auraHue: "#5B6B73",
|
||||
auraOpacity: 0.045,
|
||||
blinkMin: 4.5,
|
||||
blinkMax: 7,
|
||||
mouthCurve: -0.2,
|
||||
mouthOpen: 0.04,
|
||||
browTilt: -0.8,
|
||||
browLift: 0.8,
|
||||
browPinch: 0.8,
|
||||
},
|
||||
conflicted: {
|
||||
eyelidDrop: 0.6,
|
||||
eyeOpen: 0.96,
|
||||
gazeAvert: 2.2,
|
||||
headTilt: -2.6,
|
||||
shoulderTurn: 2,
|
||||
breathPeriod: 4.8,
|
||||
breathAmp: 1.45,
|
||||
auraHue: "#776F85",
|
||||
auraOpacity: 0.045,
|
||||
mouthCurve: -0.4,
|
||||
mouthWidth: -0.4,
|
||||
mouthTension: 0.55,
|
||||
browTilt: 0.6,
|
||||
browPinch: 1.2,
|
||||
},
|
||||
startled: {
|
||||
eyelidDrop: -1.2,
|
||||
eyeOpen: 1.22,
|
||||
pupilScale: 0.95,
|
||||
headTilt: 1.2,
|
||||
shoulderTurn: 1,
|
||||
breathPeriod: 2.8,
|
||||
breathAmp: 1.7,
|
||||
auraHue: "#8A6B42",
|
||||
auraOpacity: 0.07,
|
||||
blinkMin: 2.2,
|
||||
blinkMax: 4.2,
|
||||
mouthCurve: -0.2,
|
||||
mouthOpen: 0.24,
|
||||
browLift: 2.2,
|
||||
},
|
||||
embarrassed: {
|
||||
eyelidDrop: 0.9,
|
||||
eyeOpen: 0.9,
|
||||
gazeAvert: 3.2,
|
||||
headTilt: 2.2,
|
||||
shoulderTurn: 2.4,
|
||||
breathPeriod: 3.4,
|
||||
auraHue: "#8B6F6B",
|
||||
auraOpacity: 0.05,
|
||||
blinkMin: 2.7,
|
||||
blinkMax: 4.6,
|
||||
mouthCurve: 0.6,
|
||||
mouthWidth: -0.5,
|
||||
browTilt: 0.5,
|
||||
browLift: -0.2,
|
||||
},
|
||||
ashamed: {
|
||||
eyelidDrop: 1.8,
|
||||
eyeOpen: 0.82,
|
||||
gazeAvert: 4,
|
||||
headTilt: -2.8,
|
||||
shoulderTurn: 4,
|
||||
breathPeriod: 5.5,
|
||||
breathAmp: 1.2,
|
||||
auraHue: "#716873",
|
||||
auraOpacity: 0.04,
|
||||
mouthCurve: -1.5,
|
||||
mouthWidth: -1,
|
||||
mouthTension: 0.5,
|
||||
browTilt: 1.4,
|
||||
browPinch: 0.9,
|
||||
},
|
||||
guilty: {
|
||||
eyelidDrop: 1.2,
|
||||
eyeOpen: 0.88,
|
||||
gazeAvert: 3.5,
|
||||
headTilt: -1.8,
|
||||
shoulderTurn: 3,
|
||||
breathPeriod: 5.2,
|
||||
breathAmp: 1.25,
|
||||
auraHue: "#6D7074",
|
||||
auraOpacity: 0.045,
|
||||
mouthCurve: -1.1,
|
||||
mouthWidth: -0.6,
|
||||
browTilt: 1,
|
||||
browPinch: 1.1,
|
||||
},
|
||||
// 저항/방어 — 중립 회색 무펄스, 반쯤 돌아선 8°, 정지에 가깝게
|
||||
resistant: {
|
||||
eyelidDrop: 0.5,
|
||||
gazeAvert: 4, // 시선 회피(옆/아래 고정)
|
||||
shoulderTurn: 8, // ★ 교육적 핵심: 라포로 0° 까지 완화
|
||||
eyeOpen: 0.96,
|
||||
gazeAvert: 4,
|
||||
headTilt: 0.6,
|
||||
shoulderTurn: 8,
|
||||
breathPeriod: 5,
|
||||
breathAmp: 1,
|
||||
auraHue: "#93A09C", // --ink-3 중립 회색
|
||||
auraHue: "#93A09C",
|
||||
auraOpacity: 0.03,
|
||||
blinkMin: 4,
|
||||
blinkMax: 7,
|
||||
mouthCurve: -0.2, // 굳게 닫힘
|
||||
mouthCurve: -0.4,
|
||||
mouthWidth: -0.8,
|
||||
mouthTension: 0.65,
|
||||
browTilt: -0.4,
|
||||
browPinch: 0.6,
|
||||
},
|
||||
guarded: {
|
||||
eyelidDrop: 0.3,
|
||||
eyeOpen: 0.92,
|
||||
gazeAvert: 3.4,
|
||||
headTilt: 1.5,
|
||||
shoulderTurn: 6,
|
||||
breathPeriod: 4.8,
|
||||
breathAmp: 1.1,
|
||||
auraHue: "#7D8784",
|
||||
auraOpacity: 0.035,
|
||||
mouthCurve: -0.3,
|
||||
mouthWidth: -1.2,
|
||||
mouthTension: 0.75,
|
||||
browTilt: -0.2,
|
||||
browPinch: 1,
|
||||
},
|
||||
skeptical: {
|
||||
eyelidDrop: 0.6,
|
||||
eyeOpen: 0.9,
|
||||
gazeAvert: 1.8,
|
||||
headTilt: 2.8,
|
||||
shoulderTurn: 3,
|
||||
breathPeriod: 4.6,
|
||||
auraHue: "#7E8176",
|
||||
auraOpacity: 0.035,
|
||||
mouthCurve: -0.6,
|
||||
mouthWidth: -0.3,
|
||||
mouthTension: 0.7,
|
||||
browTilt: -1.2,
|
||||
browLift: -0.5,
|
||||
browPinch: 0.8,
|
||||
},
|
||||
irritated: {
|
||||
eyelidDrop: 0.4,
|
||||
eyeOpen: 0.95,
|
||||
gazeAvert: 1.2,
|
||||
headTilt: -1.2,
|
||||
shoulderTurn: 3.5,
|
||||
breathPeriod: 3.6,
|
||||
breathAmp: 1.25,
|
||||
auraHue: "#8A5A00",
|
||||
auraOpacity: 0.055,
|
||||
blinkMin: 3,
|
||||
blinkMax: 5.5,
|
||||
mouthCurve: -0.9,
|
||||
mouthWidth: -0.5,
|
||||
mouthTension: 0.85,
|
||||
browTilt: -1.4,
|
||||
browPinch: 1.4,
|
||||
},
|
||||
angry: {
|
||||
eyelidDrop: 0.1,
|
||||
eyeOpen: 1.0,
|
||||
pupilScale: 0.95,
|
||||
gazeAvert: 0.6,
|
||||
headTilt: -1,
|
||||
shoulderTurn: 4,
|
||||
breathPeriod: 3.1,
|
||||
breathAmp: 1.6,
|
||||
auraHue: "#9A5A3D",
|
||||
auraOpacity: 0.07,
|
||||
blinkMin: 3.2,
|
||||
blinkMax: 5,
|
||||
mouthCurve: -1.2,
|
||||
mouthOpen: 0.06,
|
||||
mouthWidth: 0.6,
|
||||
mouthTension: 0.9,
|
||||
browTilt: -2,
|
||||
browPinch: 1.8,
|
||||
},
|
||||
rage: {
|
||||
eyelidDrop: -0.2,
|
||||
eyeOpen: 1.12,
|
||||
pupilScale: 0.9,
|
||||
gazeAvert: 0.2,
|
||||
headTilt: -1.4,
|
||||
shoulderTurn: 5,
|
||||
breathPeriod: 2.7,
|
||||
breathAmp: 1.9,
|
||||
auraHue: "#A8352C",
|
||||
auraOpacity: 0.09,
|
||||
blinkMin: 2.8,
|
||||
blinkMax: 4.5,
|
||||
mouthCurve: -1.8,
|
||||
mouthOpen: 0.22,
|
||||
mouthWidth: 1.2,
|
||||
mouthTension: 1,
|
||||
browTilt: -2.4,
|
||||
browPinch: 2,
|
||||
},
|
||||
tired: {
|
||||
eyelidDrop: 2,
|
||||
eyeOpen: 0.78,
|
||||
gazeAvert: 1,
|
||||
headTilt: -1.8,
|
||||
shoulderTurn: 1,
|
||||
breathPeriod: 6,
|
||||
breathAmp: 1,
|
||||
auraHue: "#6F7777",
|
||||
auraOpacity: 0.035,
|
||||
blinkMin: 5.8,
|
||||
blinkMax: 9,
|
||||
mouthCurve: -0.7,
|
||||
mouthWidth: -0.8,
|
||||
browTilt: 0.3,
|
||||
browLift: -0.6,
|
||||
},
|
||||
bored: {
|
||||
eyelidDrop: 1.6,
|
||||
eyeOpen: 0.82,
|
||||
gazeAvert: 2,
|
||||
headTilt: 2,
|
||||
shoulderTurn: 2,
|
||||
breathPeriod: 5.4,
|
||||
breathAmp: 1,
|
||||
auraHue: "#7B827C",
|
||||
auraOpacity: 0.03,
|
||||
blinkMin: 5,
|
||||
blinkMax: 8.5,
|
||||
mouthCurve: -0.2,
|
||||
mouthWidth: -1,
|
||||
mouthTension: 0.5,
|
||||
browLift: -0.8,
|
||||
},
|
||||
overwhelmed: {
|
||||
eyelidDrop: 1,
|
||||
eyeOpen: 0.94,
|
||||
gazeAvert: 3,
|
||||
headTilt: -2.4,
|
||||
shoulderTurn: 3.5,
|
||||
breathPeriod: 3.2,
|
||||
breathAmp: 1.15,
|
||||
auraHue: "#6B5E7D",
|
||||
auraOpacity: 0.06,
|
||||
blinkMin: 2.4,
|
||||
blinkMax: 4,
|
||||
mouthCurve: -1.1,
|
||||
mouthOpen: 0.05,
|
||||
mouthTension: 0.65,
|
||||
browTilt: 1.1,
|
||||
browPinch: 1.5,
|
||||
},
|
||||
determined: {
|
||||
eyeOpen: 1.03,
|
||||
gazeAvert: 0,
|
||||
headTilt: 0,
|
||||
shoulderTurn: 0.4,
|
||||
breathPeriod: 3.8,
|
||||
breathAmp: 1.8,
|
||||
auraHue: "#3E7A6E",
|
||||
auraOpacity: 0.06,
|
||||
mouthCurve: 0.4,
|
||||
mouthWidth: 0.2,
|
||||
mouthTension: 0.75,
|
||||
browTilt: -0.8,
|
||||
browPinch: 0.8,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -147,47 +649,47 @@ export function ageLookFor(ageBand: AvatarAgeBand): AgeBandLook {
|
|||
return AGE_TABLE[ageBand];
|
||||
}
|
||||
|
||||
/* ── 정서 → 파라미터 해석 (라포 완화 + 상태 보정 포함) ──────────────────
|
||||
rapport 0~1: 세션 진행으로 쌓이는 라포. 저항 파라미터를 서서히 푼다.
|
||||
"라포가 쌓이면 shoulderTurn 8°→0°, gazeAvert→눈맞춤" 이게 이 도구의 교육적 핵심(§4.5). */
|
||||
/* ── 표정 → 파라미터 해석 (라포 완화 + 상태 보정 포함) ─────────────────
|
||||
rapport 0~1: 세션 진행으로 쌓이는 라포. 저항 파라미터를 서서히 푼다. */
|
||||
export function resolveAffectParams(
|
||||
affect: AvatarAffect,
|
||||
state: AvatarState,
|
||||
rapport: number,
|
||||
): AffectParams {
|
||||
// 표를 복사(불변)
|
||||
const out: AffectParams = { ...AFFECT_TABLE[affect] };
|
||||
const out: AffectParams = { ...DEFAULT_PARAMS, ...AFFECT_TABLE[affect] };
|
||||
|
||||
// 라포 완화 (0~1). 저항/회피 성격 파라미터를 비례 감쇠.
|
||||
const r = Math.min(1, Math.max(0, rapport));
|
||||
if (r > 0) {
|
||||
out.shoulderTurn = out.shoulderTurn * (1 - r); // 8° → 0°
|
||||
out.gazeAvert = out.gazeAvert * (1 - 0.85 * r); // 회피 → 눈맞춤
|
||||
out.eyelidDrop = out.eyelidDrop * (1 - 0.5 * r);
|
||||
// 라포가 쌓이면 광배가 중립 회색 → 세이지 쪽으로 살짝 온기
|
||||
if (affect === "resistant" && r > 0.5) out.auraHue = "#6E8B82";
|
||||
out.shoulderTurn = out.shoulderTurn * (1 - r);
|
||||
out.gazeAvert = out.gazeAvert * (1 - 0.85 * r);
|
||||
out.eyelidDrop = out.eyelidDrop * (1 - 0.35 * r);
|
||||
out.browPinch = out.browPinch * (1 - 0.45 * r);
|
||||
if (affect === "resistant" || affect === "guarded" || affect === "skeptical") {
|
||||
out.auraHue = r > 0.5 ? "#6E8B82" : out.auraHue;
|
||||
out.mouthCurve += r * 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 보정 (§4.2)
|
||||
switch (state) {
|
||||
case "thinking":
|
||||
// 시선 살짝 아래/옆, 깜빡임 느려짐, 광배 안쪽 수축(채도 -8%)
|
||||
out.gazeAvert = Math.max(out.gazeAvert, 2);
|
||||
out.blinkMin = out.blinkMin * 1.3;
|
||||
out.blinkMax = out.blinkMax * 1.3;
|
||||
// 생각 중 표현을 또렷하게: 시선 회피(위/옆 응시) + 고개 기울임 + 사색 눈썹.
|
||||
out.gazeAvert = Math.max(out.gazeAvert, 3.5);
|
||||
out.blinkMin = out.blinkMin * 1.35;
|
||||
out.blinkMax = out.blinkMax * 1.35;
|
||||
out.auraOpacity *= 0.85;
|
||||
out.breathAmp *= 0.6; // 정지에 가깝게
|
||||
out.breathAmp *= 0.6;
|
||||
out.headTilt += out.headTilt >= 0 ? 1.6 : -1.6;
|
||||
out.browLift += 0.3;
|
||||
break;
|
||||
case "listening":
|
||||
// 학습자 쪽 응시(회피 해제), 차분 펄스
|
||||
out.gazeAvert = out.gazeAvert * (1 - 0.6); // 응시로 당김(완전 0은 아님)
|
||||
out.gazeAvert = out.gazeAvert * 0.4;
|
||||
out.browLift += 0.25;
|
||||
break;
|
||||
case "speaking":
|
||||
// 정면 복귀
|
||||
out.gazeAvert = out.gazeAvert * 0.4;
|
||||
out.gazeAvert = out.gazeAvert * 0.35;
|
||||
out.mouthOpen = Math.max(out.mouthOpen, 0.02);
|
||||
break;
|
||||
case "idle":
|
||||
// 느린 호흡 3.5s 하한
|
||||
out.breathPeriod = Math.max(out.breathPeriod, 3.5);
|
||||
break;
|
||||
}
|
||||
|
|
@ -195,8 +697,9 @@ export function resolveAffectParams(
|
|||
return out;
|
||||
}
|
||||
|
||||
/** persona.resistance → 초기 저항도. 미지정 시 affect 로 추정. */
|
||||
/** persona.resistance → 초기 저항도. 미지정 시 defensive expression 으로 추정. */
|
||||
export function baseResistanceOf(persona: AvatarPersona, affect: AvatarAffect): number {
|
||||
if (typeof persona.resistance === "number") return persona.resistance;
|
||||
return affect === "resistant" ? 0.6 : 0;
|
||||
const expression = AVATAR_EXPRESSION_LIBRARY.find((item) => item.id === affect);
|
||||
return expression?.group === "defensive" ? 0.6 : 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,11 @@ function fallbackMouthTarget(tSec: number, progress: number | null): number {
|
|||
const base = 0.18 + 0.42 * syl * jitter;
|
||||
return Math.min(0.85, base);
|
||||
}
|
||||
return 0;
|
||||
// analyser·speakingProgress 모두 없을 때(예: 텍스트 턴) — 차분한 의사 발화 모션을
|
||||
// 기본 제공해 'speaking' 상태에서 입이 보이게 움직이도록 한다(이전엔 0으로 정지).
|
||||
const syl = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 4.2);
|
||||
const jitter = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 8.5 + 0.7);
|
||||
return Math.min(0.7, 0.12 + 0.34 * syl * jitter);
|
||||
}
|
||||
|
||||
export interface AvatarMotionOptions {
|
||||
|
|
|
|||
83
apps/web/src/components/avatar/useExpressionTransition.ts
Normal file
83
apps/web/src/components/avatar/useExpressionTransition.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AffectParams } from "./persona";
|
||||
|
||||
const NUMERIC_PARAM_KEYS = [
|
||||
"eyelidDrop",
|
||||
"eyeOpen",
|
||||
"pupilScale",
|
||||
"gazeAvert",
|
||||
"headTilt",
|
||||
"shoulderTurn",
|
||||
"breathPeriod",
|
||||
"breathAmp",
|
||||
"auraOpacity",
|
||||
"blinkMin",
|
||||
"blinkMax",
|
||||
"mouthCurve",
|
||||
"mouthOpen",
|
||||
"mouthWidth",
|
||||
"mouthTension",
|
||||
"browTilt",
|
||||
"browLift",
|
||||
"browPinch",
|
||||
] as const satisfies readonly (keyof AffectParams)[];
|
||||
|
||||
export interface ExpressionTransitionFrame {
|
||||
params: AffectParams;
|
||||
progress: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
function interpolateParams(from: AffectParams, to: AffectParams, progress: number): AffectParams {
|
||||
const eased = easeOutCubic(progress);
|
||||
const next: AffectParams = { ...to };
|
||||
for (const key of NUMERIC_PARAM_KEYS) {
|
||||
next[key] = from[key] + (to[key] - from[key]) * eased;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function useExpressionTransition(
|
||||
target: AffectParams,
|
||||
fadeInMs: number,
|
||||
enabled: boolean,
|
||||
): ExpressionTransitionFrame {
|
||||
const [frame, setFrame] = useState<ExpressionTransitionFrame>({
|
||||
params: target,
|
||||
progress: 1,
|
||||
active: false,
|
||||
});
|
||||
const currentRef = useRef(target);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
currentRef.current = target;
|
||||
setFrame({ params: target, progress: 1, active: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let raf = 0;
|
||||
const from = currentRef.current;
|
||||
const duration = Math.max(80, fadeInMs);
|
||||
const startedAt = performance.now();
|
||||
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - startedAt) / duration);
|
||||
const params = interpolateParams(from, target, progress);
|
||||
currentRef.current = params;
|
||||
setFrame({ params, progress, active: progress < 1 });
|
||||
if (progress < 1) {
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [enabled, fadeInMs, target]);
|
||||
|
||||
return frame;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue