feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG

web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
Yun Chan 2026-06-25 23:37:22 +09:00
parent 859ab26314
commit 24b1b7a6e1
84 changed files with 19645 additions and 107 deletions

View file

@ -0,0 +1,45 @@
/* =====================================================================
AuraLayer (aura)
§4.4: 광배 opacity ±0.04~0.08, 5~6s. speaking ().
§4.5: 정서별 (// / ).
transform/opacity . radial-gradient .
Rive 2 SVG/CSS ( ).
===================================================================== */
import type { AvatarState } from "./persona";
interface AuraLayerProps {
/** 광배 색조 (CSS 색) */
hue: string;
/** 기본 opacity */
opacity: number;
/** 채도 가중(연령대 생기) */
saturation: number;
state: AvatarState;
/** speaking 시 입 열림(0~1) — 광배 미세 약동 동기 */
mouth: number;
/** reduced-motion 이면 펄스 정지 */
reduced: boolean;
}
export function AuraLayer({ hue, opacity, saturation, state, mouth, reduced }: AuraLayerProps) {
// speaking 일 때만 입 열림에 미세 동기(0.04~0.12 범위 추가). 그 외엔 CSS 호흡 펄스.
const speakBoost = state === "speaking" ? mouth * 0.06 : 0;
const finalOpacity = Math.min(0.16, (opacity + speakBoost) * saturation);
return (
<span
className={"vg-avatar__aura" + (reduced ? " is-reduced" : "")}
aria-hidden="true"
style={{
// 색 면적은 작게(68% 안쪽에서 사라짐) — 산만 방지
background: `radial-gradient(circle, color-mix(in srgb, ${hue} ${Math.round(
finalOpacity * 100,
)}%, transparent) 0%, transparent 68%)`,
// speaking 일 때 호흡 펄스를 살짝 빠르게(발화 리듬감)
animationDuration: state === "speaking" ? "5s" : "6s",
}}
/>
);
}

View file

@ -0,0 +1,35 @@
/* =====================================================================
BodySilhouette ~ ( X, )
§4.5: 자세() . = (turn 8°).
§4.4: 어깨 (translateY 1.5~2.5px). transform <g> .
Rive 2 / .riv .
===================================================================== */
interface BodySilhouetteProps {
/** 헤어/의상 실루엣 색 (1차는 hair.color 재사용) */
color: string;
/** 피부톤 (목) */
skin: string;
/** 어깨 돌아선 각도 deg (이미 부모 회전에 일부 반영, 여기선 비대칭만) */
shoulderTurn: number;
}
export function BodySilhouette({ color, skin, shoulderTurn }: BodySilhouetteProps) {
// 저항(turn)일수록 어깨 한쪽을 살짝 앞으로 — 비대칭으로 "돌아선" 느낌(과장 금지)
const skew = Math.min(8, shoulderTurn) * 0.6; // 최대 ~4.8 단위
return (
<>
{/* 어깨/몸 실루엣 — 한쪽 어깨를 turn 만큼 미세 비대칭 */}
<path
d={`M${40 - skew} 200
C${40 - skew} 158 64 142 100 142
C136 142 ${160 + skew} 158 ${160 + skew} 200 Z`}
fill={color}
opacity={0.9}
/>
{/* 목 */}
<rect x="88" y="118" width="24" height="26" rx="10" fill={skin} />
</>
);
}

View file

@ -0,0 +1,43 @@
/* =====================================================================
Brows ( 2~3px)
§4.5: 눈썹 () / () / () / ().
1~3px .
Rive 2 brow / input .
===================================================================== */
interface BrowsProps {
/** 안쪽 끝 기울기 px (양수=안쪽 처짐/우울, 음수=안쪽 올라/긴장) */
browTilt: number;
/** 눈썹 색 (헤어 색) */
color: string;
}
export function Brows({ browTilt, color }: BrowsProps) {
// 안쪽 끝 y 를 browTilt 만큼 이동(좌우 대칭). 바깥 끝은 고정.
const innerY = 79 + browTilt;
return (
<>
{/* 왼 눈썹: 바깥(74) → 안쪽(90) */}
<line
x1="74"
y1="80"
x2="90"
y2={innerY}
stroke={color}
strokeWidth="2.4"
strokeLinecap="round"
/>
{/* 오른 눈썹: 안쪽(110) → 바깥(126) */}
<line
x1="110"
y1={innerY}
x2="126"
y2="80"
stroke={color}
strokeWidth="2.4"
strokeLinecap="round"
/>
</>
);
}

View file

@ -0,0 +1,248 @@
/* =====================================================================
ClientAvatar ( )
출처: docs/DESIGN_CONCEPT.md §4 (4.1~4.7)
"사실성 35~45% 양식화 반구상 흉상 + 호흡하는 광배." 30~50
( 2),
(·· , ), "감지되되 단언되지 않게".
(§4.7): SVG + CSS/Web Animations + Web Audio (1).
- rAF useAvatarMotion: 호흡 + + (RMS) + saccade
- 6 (persona.ts): eyelidDrop/gazeAvert/shoulderTurn/
breathRate/auraHue/blinkInterval. 8°0° ( ).
- transform/opacity (layout ). 2 .
- prefers-reduced-motion: 호흡/ + .
- analyser=null: / .
Rive 2
.riv ,
(persona / state / affect / analyser / rapport)
<svg>+useAvatarMotion <RiveAvatar> .
AffectParams(persona.ts) .riv state-machine input wiring.
===================================================================== */
import { useEffect, useMemo, useState } from "react";
import {
ageLookFor,
baseResistanceOf,
resolveAffectParams,
type AvatarAffect,
type AvatarPersona,
type AvatarState,
} from "./persona";
import { useAvatarMotion } from "./useAvatarMotion";
import { AuraLayer } from "./AuraLayer";
import { BodySilhouette } from "./BodySilhouette";
import { Eyes } from "./Eyes";
import { Brows } from "./Brows";
import { Mouth } from "./Mouth";
/* ( import )
Session.tsx ClientAvatar . */
export type { AvatarState, AvatarAffect, AvatarPersona } from "./persona";
export interface ClientAvatarProps {
persona: AvatarPersona;
state: AvatarState;
affect: AvatarAffect;
/** speaking 립싱크용 Web Audio AnalyserNode (없으면 텍스트/타이핑 폴백) */
analyser?: AnalyserNode | null;
/**
* 0~1 (§4.5 ). .
* shoulderTurn 8°0°, gazeAvert .
*/
rapport?: number;
/**
* analyser 0~1.
* null/ speaking .
*/
speakingProgress?: number | null;
/** px 지름 (기본 220 — §5.2 아바타 220px) */
size?: number;
className?: string;
}
function prefersReducedMotion(): boolean {
return (
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true
);
}
/** prefers-reduced-motion 을 반응형으로 구독 */
function useReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(prefersReducedMotion);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = () => setReduced(mq.matches);
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
return reduced;
}
const STATE_TEXT: Record<AvatarState, string> = {
idle: "대기 중",
listening: "당신의 말을 듣는 중",
thinking: "잠시 생각하는 중",
speaking: "이야기하는 중",
};
export function ClientAvatar({
persona,
state,
affect,
analyser = null,
rapport = 0,
speakingProgress = null,
size = 220,
className,
}: ClientAvatarProps) {
const reduced = useReducedMotion();
// 외형 안전값
const skin = persona.skinTone;
const hairColor = persona.hair.color;
const realism = Math.min(0.45, Math.max(0.3, persona.realism)); // 안전 클램프
const age = ageLookFor(persona.ageBand);
// 6파라미터 정서 → 라포/상태 반영 (라포는 baseResistance 와 합성)
const baseRes = baseResistanceOf(persona, affect);
// 라포가 저항을 푼다: 실효 라포 = rapport (저항 페르소나일 때 더 큰 시각 효과)
const effectiveRapport = baseRes > 0 ? Math.min(1, rapport) : rapport * 0.4;
const params = useMemo(
() => resolveAffectParams(affect, state, effectiveRapport),
[affect, state, effectiveRapport],
);
// 모션 루프 (reduced 면 정지)
const frame = useAvatarMotion({
state,
params,
analyser,
speakingProgress,
enabled: !reduced,
});
// 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;
// 시선 = 정서 회피(gazeAvert) + saccade 미세 이동
const gazeX = -params.gazeAvert + saccadeX;
const gazeY = saccadeY;
// 홍채 색: 헤어보다 약간 진한 잉크(순흑 금지 → ink 톤)
const irisColor = "#2B2B2B";
// 흉상 회전: 어깨 돌아선 각도(저항). 회전 중심 = 어깨 위.
const shoulderRotate = params.shoulderTurn * 0.4;
return (
<figure
className={"vg-avatar" + (className ? " " + className : "")}
style={{ width: size }}
data-state={state}
data-affect={affect}
data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}`}
>
<style>{AVATAR_CSS}</style>
{/* 상단 라벨 — 실존 인물 오인 차단(상시, §4.1) */}
<figcaption className="vg-avatar__label">
<span className="vg-avatar__label-dot" aria-hidden="true" />
</figcaption>
<div className="vg-avatar__stage" style={{ height: size }}>
{/* 호흡하는 광배 */}
<AuraLayer
hue={params.auraHue}
opacity={params.auraOpacity}
saturation={age.auraSaturation}
state={state}
mouth={mouth}
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} />
{/* 머리 (양식화 — 코·주름·모공 없음) */}
<ellipse cx="100" cy="92" rx={42 * age.jawWidth} ry="46" fill={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}
/>
{/* 얼굴 그룹: 시선 회피/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" />
</g>
</g>
</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>
</figure>
);
}
const AVATAR_CSS = `
.vg-avatar{display:flex;flex-direction:column;align-items:center;gap:var(--sp-3);margin:0;}
.vg-avatar__label{
display:inline-flex;align-items:center;gap:7px;
font-family:var(--font-num);font-size:var(--fs-xs);font-weight:600;letter-spacing:0.06em;
color:var(--text-muted);text-transform:none;
}
.vg-avatar__label-dot{width:6px;height:6px;border-radius:50%;background:var(--clay);flex:none;}
.vg-avatar__stage{
position:relative;width:100%;border-radius:50%;
display:flex;align-items:center;justify-content:center;overflow:hidden;
background:var(--bg-stage);
}
.vg-avatar__aura{
position:absolute;inset:-12%;border-radius:50%;pointer-events:none;
animation:vgAuraBreathe 6s var(--ease-in-out) infinite;
}
.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;}
.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);}
.vg-avatar__state b{font-weight:600;color:var(--text-body);}
@media (prefers-reduced-motion: reduce){
.vg-avatar__aura{animation:none;}
}
`;

View file

@ -0,0 +1,48 @@
/* =====================================================================
Eyes ( scaleY + gaze + )
§4.1: 흰자 /. ·· (35% ).
§4.4: 깜빡임 scaleY 10.081. §4.5: eyelidDrop(), gazeAvert().
Rive 2 /blink input .
===================================================================== */
interface EyesProps {
/** 눈 크기 배율 (연령대 §4.6) */
eyeSize: number;
/** 깜빡임 scaleY (1=뜸, 0.08=감김) */
blink: number;
/** 눈꺼풀 하강 px (우울) */
eyelidDrop: number;
/** 홍채 색(헤어보다 약간 진한 잉크) */
irisColor: string;
}
/** 단일 눈 — 아몬드 흰자 위에 작은 홍채. blink 는 scaleY 로만. */
function Eye({ cx, eyeSize, blink, eyelidDrop, irisColor }: {
cx: number;
eyeSize: number;
blink: number;
eyelidDrop: number;
irisColor: string;
}) {
return (
// 눈꺼풀 하강은 translateY, 깜빡임은 scaleY. (동시에 layout 속성 안 건드림)
<g transform={`translate(${cx} ${92 + eyelidDrop}) scale(${eyeSize} ${blink})`}>
{/* 아몬드(미세 흰자) — 양식화, 외곽선 없음 */}
<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} />
{/* 미세 하이라이트(생체감) */}
<circle cx="-1.4" cy="-1.4" r="1.1" fill="#FBFAF8" opacity={0.55} />
</g>
);
}
export function Eyes({ eyeSize, blink, eyelidDrop, 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} />
</>
);
}

View file

@ -0,0 +1,51 @@
/* =====================================================================
Mouth (RMSscaleY + )
§4.3: 닫힘= , =. scaleY . · .
§4.5: 입꼬리(mouthCurve) / .
Rive 2 viseme/mouth-open input ( 1).
===================================================================== */
interface MouthProps {
/** 입 열림 0~1 (립싱크) */
open: number;
/** 입꼬리 각도 deg (양수=상향, 음수=하향) */
curve: number;
/** 입술 색 */
color: string;
}
export function Mouth({ open, curve, 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));
return (
<g transform={`translate(100 116) rotate(${-rot})`}>
{/* 입 안쪽(어두운 톤) — 열릴 때만 살짝 보임 */}
<ellipse
cx="0"
cy="0"
rx="11"
ry="5.5"
fill={color}
opacity={0.5}
style={{
transform: `scaleY(${scaleY})`,
transformOrigin: "center",
transition: "none", // rAF 가 직접 구동 — CSS transition 금지(이중 보간 방지)
}}
/>
{/* 윗입술 라인(얇은 호) — 닫혀도 입의 존재감 */}
<path
d="M-11 0 Q0 -1.5 11 0"
fill="none"
stroke={color}
strokeWidth="1.6"
strokeLinecap="round"
opacity={0.45}
/>
</g>
);
}

View file

@ -0,0 +1,202 @@
/* =====================================================================
ClientAvatar / + 6
출처: 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 .
===================================================================== */
/* ── 음성 UI 4-state (§4.2) ─────────────────────────────────────────── */
export type AvatarState = "idle" | "listening" | "thinking" | "speaking";
/* ── 내담자 정서 4종 (§4.5) ────────────────────────────────────────── */
export type AvatarAffect = "neutral" | "depressed" | "anxious" | "resistant";
/* ── 연령대 (§4.6 외형 규칙) ───────────────────────────────────────── */
export type AvatarAgeBand = "teen" | "youngAdult" | "adult" | "senior";
export interface AvatarPersona {
/** "서연 · 17세 · 고2" 같은 메타 1줄 라벨 */
label: string;
/** 연령대 (외형 규칙 §4.6) */
ageBand: AvatarAgeBand;
/** 피부톤 hex */
skinTone: string;
/** 헤어 (스타일 + 색) — 1차는 색만 사용 */
hair: { style?: string; color: string };
/** 사실성 0.35~0.45 고정 (불쾌한 골짜기 회피) */
realism: number;
/**
* 0~1 (§4.6). .
* affect="resistant" 0.6, 0 .
*/
resistance?: number;
}
/* 6 (§4.5)
6 :
(eyelidDrop, gazeAvert, shoulderTurn, breathRate, auraHue, blinkInterval)
/, min/max . */
export interface AffectParams {
/** 눈꺼풀 하강 px (우울 1~2px) */
eyelidDrop: number;
/** 시선 회피 x 오프셋 px (저항/사고 시) */
gazeAvert: number;
/** 어깨 돌아선 각도 deg (저항 8° → 라포로 0°) */
shoulderTurn: number;
/** 호흡 주기 초 (= breathRate 역수 표현) */
breathPeriod: number;
/** 호흡 진폭 px (translateY 1.5~2.5) */
breathAmp: number;
/** 광배 색조 (CSS 색/변수) */
auraHue: string;
/** 광배 기본 opacity */
auraOpacity: number;
/** 깜빡임 최소 간격 초 */
blinkMin: number;
/** 깜빡임 최대 간격 초 */
blinkMax: number;
/** 입꼬리 각도 deg (양수=상향, 음수=하향). 미세값만. */
mouthCurve: number;
/** 눈썹 안쪽 끝 기울기 px (우울=안쪽 살짝 올라/처짐) */
browTilt: 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,
},
// 우울(저각성·부정) — 청회색 5%, 느리고 얕게 5.5s
depressed: {
eyelidDrop: 1.5,
gazeAvert: 1.5, // 시선 아래(주로 y, x는 미세)
shoulderTurn: 1,
breathPeriod: 5.5,
breathAmp: 1.5,
auraHue: "#5B6B73", // 청회색
auraOpacity: 0.05,
blinkMin: 5,
blinkMax: 8,
mouthCurve: -0.6, // 수평~미세 하향
browTilt: 1.2, // 안쪽 끝 살짝 처짐
},
// 불안(고각성·부정) — 차가운 청 5% 빠른 펄스, 깜빡임↑, 빠르고 얕게 3s
anxious: {
eyelidDrop: 0,
gazeAvert: 2,
shoulderTurn: 2,
breathPeriod: 3,
breathAmp: 1.5,
auraHue: "#3B6E8F", // 차가운 청
auraOpacity: 0.05,
blinkMin: 2,
blinkMax: 4,
mouthCurve: 0, // 다묾, 긴장
browTilt: 0.4,
},
// 저항/방어 — 중립 회색 무펄스, 반쯤 돌아선 8°, 정지에 가깝게
resistant: {
eyelidDrop: 0.5,
gazeAvert: 4, // 시선 회피(옆/아래 고정)
shoulderTurn: 8, // ★ 교육적 핵심: 라포로 0° 까지 완화
breathPeriod: 5,
breathAmp: 1,
auraHue: "#93A09C", // --ink-3 중립 회색
auraOpacity: 0.03,
blinkMin: 4,
blinkMax: 7,
mouthCurve: -0.2, // 굳게 닫힘
browTilt: -0.4,
},
};
/* ── 연령대 외형 규칙 (§4.6) ────────────────────────────────────────── */
export interface AgeBandLook {
/** 눈 크기 배율 */
eyeSize: number;
/** 턱 폭 배율 (작을수록 좁은 턱) */
jawWidth: number;
/** 광배 채도 가중(생기) */
auraSaturation: number;
}
const AGE_TABLE: Record<AvatarAgeBand, AgeBandLook> = {
teen: { eyeSize: 1.15, jawWidth: 0.92, auraSaturation: 1.1 },
youngAdult: { eyeSize: 1.0, jawWidth: 1.0, auraSaturation: 1.0 },
adult: { eyeSize: 0.95, jawWidth: 1.06, auraSaturation: 0.9 },
senior: { eyeSize: 0.92, jawWidth: 1.0, auraSaturation: 0.85 },
};
export function ageLookFor(ageBand: AvatarAgeBand): AgeBandLook {
return AGE_TABLE[ageBand];
}
/* ( + )
rapport 0~1: 세션 . .
"라포가 쌓이면 shoulderTurn 8°→0°, gazeAvert→눈맞춤" (§4.5). */
export function resolveAffectParams(
affect: AvatarAffect,
state: AvatarState,
rapport: number,
): AffectParams {
// 표를 복사(불변)
const out: AffectParams = { ...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";
}
// 상태 보정 (§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.auraOpacity *= 0.85;
out.breathAmp *= 0.6; // 정지에 가깝게
break;
case "listening":
// 학습자 쪽 응시(회피 해제), 차분 펄스
out.gazeAvert = out.gazeAvert * (1 - 0.6); // 응시로 당김(완전 0은 아님)
break;
case "speaking":
// 정면 복귀
out.gazeAvert = out.gazeAvert * 0.4;
break;
case "idle":
// 느린 호흡 3.5s 하한
out.breathPeriod = Math.max(out.breathPeriod, 3.5);
break;
}
return out;
}
/** persona.resistance → 초기 저항도. 미지정 시 affect 로 추정. */
export function baseResistanceOf(persona: AvatarPersona, affect: AvatarAffect): number {
if (typeof persona.resistance === "number") return persona.resistance;
return affect === "resistant" ? 0.6 : 0;
}

View file

@ -0,0 +1,223 @@
/* =====================================================================
useAvatarMotion requestAnimationFrame
출처: docs/DESIGN_CONCEPT.md §4.3() / §4.4( ) / §4.7(rAF 1)
채널: 어깨호흡(translateY) + (scaleY) + (RMSmouth scaleY) + saccade( )
(§4.4): 2. transform/opacity (layout ).
prefers-reduced-motion (ClientAvatar) .
코드: performance.now()/Math.random() OK ( ).
Rive 2
Rive .riv state-machine input(speak amplitude, blink,
gaze, breath) wiring . MotionFrame
ClientAvatar SVG .
===================================================================== */
import { useEffect, useRef, useState } from "react";
import type { AffectParams, AvatarState } from "./persona";
/** 매 프레임 컴포넌트로 내려가는 transform 값 묶음 */
export interface MotionFrame {
/** 어깨/몸 호흡 translateY px (음수=올라감) */
breath: number;
/** 깜빡임 눈 scaleY (1=뜸, 0.08=감김) */
blink: number;
/** 입 열림 0~1 (립싱크) */
mouth: number;
/** saccade 시선 x 미세 오프셋 px */
saccadeX: number;
/** saccade 시선 y 미세 오프셋 px */
saccadeY: number;
}
const IDLE_FRAME: MotionFrame = {
breath: 0,
blink: 1,
mouth: 0,
saccadeX: 0,
saccadeY: 0,
};
/* RMS ( τ180ms) + 0.04 (§4.3)
raw RMS . dt alpha . */
function smoothMouthFromRMS(
analyser: AnalyserNode,
buf: Float32Array<ArrayBuffer>,
prev: number,
dtSec: number,
): number {
analyser.getFloatTimeDomainData(buf);
let sum = 0;
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
const rms = Math.sqrt(sum / buf.length);
// 데드존: 무음 구간 입 떨림 제거
const target = rms < 0.04 ? 0 : Math.min(1, rms * 3.2);
// τ≈180ms 지수평활 (dt 보정 → 프레임레이트 무관)
const tau = 0.18;
const alpha = 1 - Math.exp(-dtSec / tau);
return prev + (target - prev) * alpha;
}
/* analyser (§ : / )
speakingProgress(0~1, ) ,
RMS ( ). */
function fakeMouthTarget(tSec: number, progress: number | null): number {
if (progress !== null) {
// 타이핑 진행 중에만 입을 움직임. 진행이 멈추면(완료) 닫힘.
if (progress >= 1) return 0;
// 음절 리듬 ~6Hz, 미세 변주
const syl = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 5.5);
const jitter = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 11 + 1.3);
const base = 0.18 + 0.42 * syl * jitter;
return Math.min(0.85, base);
}
// progress 미제공: 차분한 의사 발화 (말하는 듯한 진폭)
const a = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 4.5);
const b = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 9.2 + 0.7);
return Math.min(0.8, 0.15 + 0.45 * a * b);
}
export interface AvatarMotionOptions {
state: AvatarState;
params: AffectParams;
analyser: AnalyserNode | null;
/** analyser 없을 때 가짜 립싱크용 타이핑 진행도 0~1 (null=의사 발화) */
speakingProgress?: number | null;
/** false 면 루프 정지(reduced-motion) — IDLE_FRAME 고정 */
enabled: boolean;
}
export function useAvatarMotion({
state,
params,
analyser,
speakingProgress = null,
enabled,
}: AvatarMotionOptions): MotionFrame {
const [frame, setFrame] = useState<MotionFrame>(IDLE_FRAME);
// 최신값을 rAF 클로저가 리렌더 없이 읽도록 ref 미러
const stateRef = useRef(state);
const paramsRef = useRef(params);
const analyserRef = useRef(analyser);
const progressRef = useRef(speakingProgress);
stateRef.current = state;
paramsRef.current = params;
analyserRef.current = analyser;
progressRef.current = speakingProgress;
useEffect(() => {
if (!enabled) {
setFrame(IDLE_FRAME);
return;
}
let raf = 0;
const t0 = performance.now();
let lastT = t0;
// 깜빡임 스케줄 (랜덤 간격, 120ms 감김)
const p0 = paramsRef.current;
let nextBlinkAt = 1200 + Math.random() * (p0.blinkMax - p0.blinkMin) * 1000;
let blinkUntil = 0;
// saccade 스케줄 (6~10s 간격, 0.3s 이동, ±3px)
let nextSaccadeAt = 4000 + Math.random() * 4000;
let saccadeStart = 0;
let saccadeTargetX = 0;
let saccadeTargetY = 0;
let saccadeFromX = 0;
let saccadeFromY = 0;
let saccadeX = 0;
let saccadeY = 0;
let mouthPrev = 0;
const audioBuf = new Float32Array(1024);
const loop = (t: number) => {
const p = paramsRef.current;
const st = stateRef.current;
const elapsed = t - t0;
const dtSec = Math.min(0.05, (t - lastT) / 1000); // 탭 비활성 복귀 시 점프 클램프
lastT = t;
const tSec = elapsed / 1000;
// ── 1) 어깨/몸 호흡 (translateY 1.5~2.5px, 3.5~5.5s) ──
const breath = Math.sin((tSec * 2 * Math.PI) / p.breathPeriod) * p.breathAmp;
// ── 2) 깜빡임 (scaleY 1→0.08→1, 120ms, 간격 4~7s 랜덤) ──
let blink = 1;
if (blinkUntil === 0 && elapsed >= nextBlinkAt) {
blinkUntil = elapsed + 120;
}
if (blinkUntil > 0) {
if (elapsed >= blinkUntil) {
blinkUntil = 0;
nextBlinkAt =
elapsed + p.blinkMin * 1000 + Math.random() * (p.blinkMax - p.blinkMin) * 1000;
blink = 1;
} else {
// 0→감김(0.08)→복귀 삼각 근사
const phase = 1 - Math.abs((blinkUntil - elapsed) / 120 - 0.5) * 2;
blink = 1 - phase * 0.92;
}
}
// ── 3) saccade (시선 미세 이동 ±3px, 0.3s, 간격 6~10s; thinking 빈도↑) ──
const saccadeInterval = st === "thinking" ? 3000 : 7000;
if (saccadeStart === 0 && elapsed >= nextSaccadeAt) {
saccadeStart = elapsed;
saccadeFromX = saccadeX;
saccadeFromY = saccadeY;
// listening 은 학습자 응시 → saccade 거의 없음(중앙 복귀)
const range = st === "listening" ? 1 : 3;
saccadeTargetX = (Math.random() * 2 - 1) * range;
saccadeTargetY = (Math.random() * 2 - 1) * (range * 0.6);
}
if (saccadeStart > 0) {
const k = Math.min(1, (elapsed - saccadeStart) / 300);
// ease-out
const e = 1 - Math.pow(1 - k, 2);
saccadeX = saccadeFromX + (saccadeTargetX - saccadeFromX) * e;
saccadeY = saccadeFromY + (saccadeTargetY - saccadeFromY) * e;
if (k >= 1) {
saccadeStart = 0;
nextSaccadeAt =
elapsed + saccadeInterval * 0.6 + Math.random() * saccadeInterval * 0.6;
}
}
// ── 4) 립싱크 (speaking 시만; analyser 우선, 없으면 폴백) ──
let mouth = mouthPrev;
if (st === "speaking") {
const a = analyserRef.current;
if (a) {
mouthPrev = smoothMouthFromRMS(a, audioBuf, mouthPrev, dtSec);
} else {
const target = fakeMouthTarget(tSec, progressRef.current ?? null);
const tau = 0.12;
const alpha = 1 - Math.exp(-dtSec / tau);
mouthPrev = mouthPrev + (target - mouthPrev) * alpha;
}
mouth = mouthPrev;
} else if (mouthPrev > 0.001) {
// 발화 종료 → 부드럽게 닫힘
const alpha = 1 - Math.exp(-dtSec / 0.1);
mouthPrev = mouthPrev * (1 - alpha);
if (mouthPrev < 0.005) mouthPrev = 0;
mouth = mouthPrev;
} else {
mouth = 0;
}
setFrame({ breath, blink, mouth, saccadeX, saccadeY });
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [enabled]);
return frame;
}