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

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

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

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

224 lines
8.6 KiB
TypeScript

/* =====================================================================
useAvatarMotion — 단일 requestAnimationFrame 모션 루프
출처: docs/DESIGN_CONCEPT.md §4.3(립싱크) / §4.4(모션 진폭) / §4.7(rAF 1루프)
채널: 어깨호흡(translateY) + 깜빡임(scaleY) + 립싱크(RMS→mouth 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, 발화 타이핑 진행)를 명시적으로 줄 때만
낮은 진폭으로 움직인다. 실제 음성 분석이 없으면 합성 발화처럼 보이지 않게 닫아 둔다. */
function fallbackMouthTarget(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);
}
// 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 {
state: AvatarState;
params: AffectParams;
analyser: AnalyserNode | null;
/** analyser 없을 때 선택적으로 쓰는 타이핑 진행도 0~1 */
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 = fallbackMouthTarget(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;
}