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:
parent
859ab26314
commit
24b1b7a6e1
84 changed files with 19645 additions and 107 deletions
223
apps/web/src/components/avatar/useAvatarMotion.ts
Normal file
223
apps/web/src/components/avatar/useAvatarMotion.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/* =====================================================================
|
||||
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, 발화 타이핑 진행) 를 주면 그걸 따라가고,
|
||||
없으면 음절감 있는 의사 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue