vignette/apps/web/src/pages/AvatarLab.tsx
Yun Chan ded312acdc P2~P7 리노컷 캐스트 원화·리그와 세션 활성화
- 추천 원화(P2 b·P3 v2b·P4 v2b·P5 v2a·P6 v2b·P7 v2b)로 얼굴 없는 기본형·몸 원본 codex 편집, 정렬·정규화 후 공통 파이프라인 실행
- 게시 자산 apps/web/public/avatar/v3/p2~p7, 리그 rigs/p2Rig~p7Rig
- 세션 레지스트리 P1~P7 전원 활성, Lab은 생성된 리그 자동 수집
- P3·P6 기본 눈 감김을 줄여 중립 렌더를 원화 눈매에 맞춤
- 표정 E2E를 v3 렌더러 기대로 갱신(검사 의도 유지)
2026-10-01 22:38:45 +09:00

675 lines
24 KiB
TypeScript

/* =====================================================================
AvatarLab — 아바타 v3 표현 엔진 dev Lab (무인증). /dev/avatar-lab.
실 서비스 라우트 아님 — 채널·표정·반응 클립·지문 파싱·엔진 상태를
눈으로 확인하는 도구다(결정문 §9 1단계 완료 기준).
===================================================================== */
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import DebugFace from "../components/avatar/v3/DebugFace";
import LinocutAvatar from "../components/avatar/v3/LinocutAvatar";
import LinocutMotif from "../components/avatar/v3/LinocutMotif";
import { labLinocutRigs } from "../components/avatar/v3/rigs";
import type { LinocutRig } from "../components/avatar/v3/linocutRig";
import { CHANNEL_IDS } from "../components/avatar/engine/channels";
import { createAvatarEngine, type AvatarEngine, type DebugSnapshot } from "../components/avatar/engine/engine";
import { REACTION_CLIPS, REACTION_CLIP_IDS, type ReactionClipId } from "../components/avatar/engine/clipCatalog";
import { demeanorFor } from "../components/avatar/engine/demeanorDefaults";
import type { Performance, PerformanceCue } from "../components/avatar/engine/performance";
import type { PhraseKind, SpeechShape, VisemeId } from "../components/avatar/engine/lipsync";
import { startSpeech, type SpeechHandle } from "../components/avatar/engine/speechDriver";
import { AVATAR_EXPRESSION_LIBRARY, type AvatarExpression, type AvatarState } from "../components/avatar/persona";
import "./avatar-lab.css";
const PERSONA_OPTIONS: Array<{ code: string; label: string }> = [
{ code: "P1", label: "P1 서연" },
{ code: "P2", label: "P2 민재" },
{ code: "P3", label: "P3 지우" },
{ code: "P4", label: "P4 하늘" },
{ code: "P5", label: "P5 도윤" },
{ code: "P6", label: "P6 하린" },
{ code: "P7", label: "P7 도현" },
];
/** 리노컷 리그가 있는 페르소나만. 없는 페르소나는 debug 렌더러를 강제한다.
공통 파이프라인이 만든 pNRig.ts를 전부 모은 값이라, 새 페르소나가 빌드되는
즉시(재빌드 없이) 이 Lab에서 보인다 — v3/rigs/index.ts labLinocutRigs() 참고. */
const LINOCUT_RIGS: Partial<Record<string, LinocutRig>> = labLinocutRigs();
type RendererMode = "linocut" | "debug";
type FeedbackMode = "immersive" | "ambient" | "coached";
const STATE_OPTIONS: Array<{ id: AvatarState; label: string }> = [
{ id: "idle", label: "대기" },
{ id: "listening", label: "경청" },
{ id: "thinking", label: "생각" },
{ id: "speaking", label: "발화" },
];
interface Clock {
now(): number;
pause(): void;
resume(): void;
isPaused(): boolean;
}
function createClock(): Clock {
let offset = 0;
let paused = false;
let frozen = 0;
return {
now(): number {
return paused ? frozen : performance.now() - offset;
},
pause(): void {
if (paused) return;
frozen = performance.now() - offset;
paused = true;
},
resume(): void {
if (!paused) return;
offset = performance.now() - frozen;
paused = false;
},
isPaused(): boolean {
return paused;
},
};
}
function seedFromQuery(): number {
const raw = new URLSearchParams(window.location.search).get("seed");
const parsed = raw === null ? NaN : Number(raw);
return Number.isFinite(parsed) ? parsed : 1;
}
/** ?persona=P3 같은 쿼리로 처음 고를 페르소나를 정한다(캡처 도구용). 모르는 코드거나
없으면 기본 P1. */
function personaFromQuery(): string {
const raw = new URLSearchParams(window.location.search).get("persona");
const upper = raw?.toUpperCase() ?? "";
return PERSONA_OPTIONS.some((p) => p.code === upper) ? upper : "P1";
}
export default function AvatarLab() {
const seed = useMemo(seedFromQuery, []);
const clock = useRef<Clock>(createClock()).current;
const [personaCode, setPersonaCode] = useState(personaFromQuery);
const [avatarState, setAvatarState] = useState<AvatarState>("idle");
const [openness, setOpenness] = useState(0.4);
const [reducedMotion, setReducedMotion] = useState(false);
const [paused, setPaused] = useState(false);
const [selectedExpression, setSelectedExpression] = useState<AvatarExpression>("neutral");
const [intensity, setIntensity] = useState(0.7);
const [mirrorClip, setMirrorClip] = useState(false);
const rig = LINOCUT_RIGS[personaCode];
const [rendererMode, setRendererMode] = useState<RendererMode>(rig ? "linocut" : "debug");
const [feedbackMode, setFeedbackMode] = useState<FeedbackMode>("coached");
const [linocutLoadState, setLinocutLoadState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
if (!rig) setRendererMode("debug");
}, [rig]);
const backdropGroup = useMemo(
() => AVATAR_EXPRESSION_LIBRARY.find((e) => e.id === selectedExpression)?.group ?? "cognitive",
[selectedExpression],
);
const [speechText, setSpeechText] = useState("(한숨) 그냥요. 별로 할 말 없어요.");
const [parsedCues, setParsedCues] = useState<PerformanceCue[]>([]);
const [unmatched, setUnmatched] = useState<string[]>([]);
const [snapshot, setSnapshot] = useState<DebugSnapshot | null>(null);
const speechHandleRef = useRef<SpeechHandle | null>(null);
const meterRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
/* 발화층(립싱크) — 원시 SpeechShape·비짐 표시용. 채널 미터와 달리 매 프레임 갱신하지
않고(React state 금지, 결정문 §3) 아래 100ms interval에서 ref로 직접 쓴다. */
const speechDisplayRef = useRef<{ viseme: VisemeId; shape: SpeechShape }>({
viseme: "X",
shape: { open: 0, wide: 0, round: 0, press: 0 },
});
const speechMeterRefs = useRef<{
viseme: HTMLElement | null;
open: HTMLElement | null;
wide: HTMLElement | null;
round: HTMLElement | null;
press: HTMLElement | null;
}>({ viseme: null, open: null, wide: null, round: null, press: null });
/* 발화 동반층(§5.5) 표시용 — 현재 구 종류·강세 박 여부. speechDisplayRef와 같은 이유로
React state가 아니라 ref로 갱신하고 100ms interval에서 DOM에 직접 쓴다. */
const coSpeechDisplayRef = useRef<{ phraseKind: PhraseKind | null; stressed: boolean }>({ phraseKind: null, stressed: false });
const coSpeechMeterRefs = useRef<{ phraseKind: HTMLElement | null; stressed: HTMLElement | null }>({
phraseKind: null,
stressed: null,
});
const audioCtxRef = useRef<AudioContext | null>(null);
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
const audioFileInputRef = useRef<HTMLInputElement | null>(null);
const [audioFileName, setAudioFileName] = useState<string | null>(null);
const engine: AvatarEngine = useMemo(
() => createAvatarEngine({ demeanor: demeanorFor(personaCode), seed, reducedMotion }),
[personaCode, seed, reducedMotion],
);
useEffect(() => {
engine.setState(avatarState, clock.now());
engine.setOpenness(openness);
engine.setSurface(selectedExpression, intensity);
}, [engine]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
engine.setState(avatarState, clock.now());
}, [engine, avatarState]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
engine.setOpenness(openness);
}, [engine, openness]);
useEffect(() => {
engine.setSurface(selectedExpression, intensity);
}, [engine, selectedExpression, intensity]);
useEffect(() => {
setSnapshot(engine.debugSnapshot());
const interval = window.setInterval(() => {
const v = engine.evaluate(clock.now());
for (const id of CHANNEL_IDS) {
const cell = meterRefs.current[id];
if (cell) cell.textContent = v[id].toFixed(2);
}
setSnapshot(engine.debugSnapshot());
const sd = speechDisplayRef.current;
const sm = speechMeterRefs.current;
if (sm.viseme) sm.viseme.textContent = sd.viseme;
if (sm.open) sm.open.textContent = sd.shape.open.toFixed(2);
if (sm.wide) sm.wide.textContent = sd.shape.wide.toFixed(2);
if (sm.round) sm.round.textContent = sd.shape.round.toFixed(2);
if (sm.press) sm.press.textContent = sd.shape.press.toFixed(2);
const cd = coSpeechDisplayRef.current;
const cm = coSpeechMeterRefs.current;
if (cm.phraseKind) cm.phraseKind.textContent = cd.phraseKind ?? "-";
if (cm.stressed) cm.stressed.textContent = cd.stressed ? "예" : "아니오";
}, 100);
return () => window.clearInterval(interval);
}, [engine]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
return () => {
speechHandleRef.current?.stop();
try {
audioSourceRef.current?.stop();
} catch {
/* 이미 정지된 소스면 무시한다. */
}
};
}, []);
function togglePause(): void {
if (clock.isPaused()) {
clock.resume();
setPaused(false);
} else {
clock.pause();
setPaused(true);
}
}
function handleClipClick(id: ReactionClipId): void {
engine.triggerClip(id, clock.now(), { mirror: mirrorClip });
}
const ZERO_SPEECH_SHAPE: SpeechShape = { open: 0, wide: 0, round: 0, press: 0 };
function endSpeech(nowMs: number): void {
engine.setSpeechShape(null);
engine.setSpeechMotion(null);
engine.setState("listening", nowMs);
setAvatarState("listening");
speechDisplayRef.current = { viseme: "X", shape: ZERO_SPEECH_SHAPE };
coSpeechDisplayRef.current = { phraseKind: null, stressed: false };
}
function playSpeech(): void {
speechHandleRef.current?.stop();
const nowMs = clock.now();
const speech = demeanorFor(personaCode).speech;
setAvatarState("speaking");
engine.setState("speaking", nowMs);
const handle = startSpeech({
engine,
text: speechText,
expression: selectedExpression,
intensity,
openness,
seed,
speech,
now: clock.now,
onFrame: ({ viseme, shape, phraseKind, stressed }) => {
speechDisplayRef.current = { viseme, shape };
coSpeechDisplayRef.current = { phraseKind, stressed };
},
onEnd: () => endSpeech(clock.now()),
});
speechHandleRef.current = handle;
setParsedCues(handle.cues);
setUnmatched(handle.unmatched);
}
function handleAudioFileChange(e: ChangeEvent<HTMLInputElement>): void {
setAudioFileName(e.target.files?.[0]?.name ?? null);
}
/** "오디오 파일로 말하기" — 로컬 오디오를 디코드해 speechDriver에 넘긴다(포락선 선분석·
오디오 시계 표본은 startSpeech 안에서 한다). */
async function playSpeechWithAudioFile(): Promise<void> {
const file = audioFileInputRef.current?.files?.[0];
if (!file) return;
const AudioContextCtor =
window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextCtor) return;
const ctx = audioCtxRef.current ?? new AudioContextCtor();
audioCtxRef.current = ctx;
if (ctx.state === "suspended") await ctx.resume();
const arrayBuffer = await file.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer.slice(0));
try {
audioSourceRef.current?.stop();
} catch {
/* 이미 정지된 소스면 무시한다. */
}
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
audioSourceRef.current = source;
const startAtCtx = ctx.currentTime + 0.3;
source.start(startAtCtx);
speechHandleRef.current?.stop();
const nowMs = clock.now();
const speech = demeanorFor(personaCode).speech;
setAvatarState("speaking");
engine.setState("speaking", nowMs);
const handle = startSpeech({
engine,
text: speechText,
expression: selectedExpression,
intensity,
openness,
seed,
speech,
now: clock.now,
audio: { buffer: audioBuffer, context: ctx, startAt: startAtCtx },
onFrame: ({ viseme, shape, phraseKind, stressed }) => {
speechDisplayRef.current = { viseme, shape };
coSpeechDisplayRef.current = { phraseKind, stressed };
},
onEnd: () => endSpeech(clock.now()),
});
speechHandleRef.current = handle;
setParsedCues(handle.cues);
setUnmatched(handle.unmatched);
}
function playLeakTest(): void {
const nowMs = clock.now();
const perf: Performance = {
surface: { expression: "neutral", intensity: 0.3 },
leak: { expression: "sad", intensity: 0.8, atMs: 500, durationMs: 350 },
cues: [],
};
setAvatarState("listening");
engine.setState("listening", nowMs);
engine.playPerformance(perf, { speechStartMs: nowMs, speechDurationMs: 1200 }, nowMs);
setParsedCues([]);
setUnmatched([]);
}
return (
<div className="al" data-avatar-lab="true">
<header className="al__head">
<h1>아바타 v3 표현 엔진 Lab</h1>
<p>채널·표정·반응 클립·지문 파서를 렌더러와 분리해 확인하는 개발 도구다(실 서비스 아님).</p>
</header>
<section className="al__controls">
<label>
페르소나
<select value={personaCode} onChange={(e) => setPersonaCode(e.target.value)}>
{PERSONA_OPTIONS.map((p) => (
<option key={p.code} value={p.code}>
{p.label}
</option>
))}
</select>
</label>
<label>
상태
<select value={avatarState} onChange={(e) => setAvatarState(e.target.value as AvatarState)}>
{STATE_OPTIONS.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</label>
<label>
개방도 {openness.toFixed(2)}
<input
type="range"
min={0}
max={1}
step={0.01}
value={openness}
onChange={(e) => setOpenness(Number(e.target.value))}
data-testid="openness-slider"
/>
</label>
<label className="al__toggle">
<input
type="checkbox"
checked={reducedMotion}
onChange={(e) => setReducedMotion(e.target.checked)}
data-testid="reduced-motion-toggle"
/>
Reduced motion
</label>
<fieldset className="al__renderer">
<legend>렌더러</legend>
<label className="al__toggle">
<input
type="radio"
name="renderer"
checked={rendererMode === "linocut"}
disabled={!rig}
onChange={() => setRendererMode("linocut")}
data-testid="renderer-linocut"
/>
linocut
</label>
<label className="al__toggle">
<input
type="radio"
name="renderer"
checked={rendererMode === "debug"}
onChange={() => setRendererMode("debug")}
data-testid="renderer-debug"
/>
debug
</label>
</fieldset>
<label>
피드백 모드
<select
value={feedbackMode}
onChange={(e) => setFeedbackMode(e.target.value as FeedbackMode)}
data-testid="feedback-mode"
>
<option value="immersive">immersive</option>
<option value="ambient">ambient</option>
<option value="coached">coached</option>
</select>
</label>
<button type="button" onClick={togglePause} data-testid="pause-toggle">
{paused ? "재개" : "일시정지"}
</button>
</section>
<section className="al__stage">
{rendererMode === "linocut" && rig ? (
<>
<div className="al__linocut-main">
<LinocutAvatar
rig={rig}
engine={engine}
running
nowMs={clock.now}
crop="bust"
backdropExpression={selectedExpression}
onLoadStateChange={setLinocutLoadState}
/>
</div>
<LinocutMotif
rig={rig}
group={backdropGroup}
budOpenness={feedbackMode === "immersive" ? null : openness}
feedbackMode={feedbackMode}
reducedMotion={reducedMotion}
size={96}
/>
</>
) : (
<DebugFace engine={engine} running nowMs={clock.now} />
)}
</section>
{rendererMode === "linocut" && rig ? (
<>
<p data-testid="linocut-load-state">로드 상태: {linocutLoadState}</p>
<section className="al__preview-row">
<div className="al__preview-cell al__preview-cell--bust-circle">
<LinocutAvatar rig={rig} engine={engine} running nowMs={clock.now} crop="bust" backdropExpression={selectedExpression} />
</div>
<div className="al__preview-cell al__preview-cell--face-96">
<LinocutAvatar rig={rig} engine={engine} running nowMs={clock.now} crop="face" backdropExpression={selectedExpression} />
</div>
<div className="al__preview-cell al__preview-cell--face-48">
<LinocutAvatar rig={rig} engine={engine} running nowMs={clock.now} crop="face" backdropExpression={selectedExpression} />
</div>
<div className="al__preview-cell al__preview-cell--portrait">
<LinocutAvatar rig={rig} engine={engine} running nowMs={clock.now} crop="portrait" backdropExpression={selectedExpression} />
</div>
</section>
</>
) : null}
<section className="al__panel">
<h2>표정 (28개)</h2>
<label className="al__intensity">
강도 {intensity.toFixed(2)}
<input
type="range"
min={0}
max={1}
step={0.01}
value={intensity}
onChange={(e) => setIntensity(Number(e.target.value))}
/>
</label>
<div className="al__grid">
{AVATAR_EXPRESSION_LIBRARY.map((expr) => (
<button
key={expr.id}
type="button"
className={expr.id === selectedExpression ? "al__chip al__chip--active" : "al__chip"}
data-testid={`expression-${expr.id}`}
onClick={() => setSelectedExpression(expr.id)}
>
{expr.label}
</button>
))}
</div>
</section>
<section className="al__panel">
<h2>반응 클립 (26개)</h2>
<label className="al__toggle">
<input type="checkbox" checked={mirrorClip} onChange={(e) => setMirrorClip(e.target.checked)} />
좌우 반전
</label>
<div className="al__grid">
{REACTION_CLIP_IDS.map((id) => (
<button
key={id}
type="button"
className="al__chip"
data-testid={`clip-${id}`}
onClick={() => handleClipClick(id)}
>
{REACTION_CLIPS[id].label}
</button>
))}
</div>
</section>
<section className="al__panel">
<h2>내담자 발화 · 공연 재생</h2>
<textarea
className="al__speech-input"
value={speechText}
onChange={(e) => setSpeechText(e.target.value)}
rows={3}
data-testid="speech-text"
/>
<div className="al__actions">
<button type="button" onClick={playSpeech} data-testid="play-speech">
공연 재생
</button>
<button type="button" onClick={playLeakTest} data-testid="play-leak-test">
누설 테스트
</button>
</div>
<div className="al__actions al__audio-file">
<input
ref={audioFileInputRef}
type="file"
accept="audio/*"
onChange={handleAudioFileChange}
data-testid="speech-audio-file"
/>
<button
type="button"
onClick={() => void playSpeechWithAudioFile()}
disabled={!audioFileName}
data-testid="play-speech-audio"
>
오디오 파일로 말하기
</button>
{audioFileName ? <span className="al__audio-file-name">{audioFileName}</span> : null}
</div>
<table className="al__meter al__speech-shape">
<thead>
<tr>
<th>비짐</th>
<th>open</th>
<th>wide</th>
<th>round</th>
<th>press</th>
</tr>
</thead>
<tbody>
<tr>
<td ref={(el) => { speechMeterRefs.current.viseme = el; }} data-testid="speech-shape-viseme">
X
</td>
<td ref={(el) => { speechMeterRefs.current.open = el; }} data-testid="speech-shape-open">
0.00
</td>
<td ref={(el) => { speechMeterRefs.current.wide = el; }} data-testid="speech-shape-wide">
0.00
</td>
<td ref={(el) => { speechMeterRefs.current.round = el; }} data-testid="speech-shape-round">
0.00
</td>
<td ref={(el) => { speechMeterRefs.current.press = el; }} data-testid="speech-shape-press">
0.00
</td>
</tr>
</tbody>
</table>
<p className="al__co-speech-status">
구: <span ref={(el) => { coSpeechMeterRefs.current.phraseKind = el; }} data-testid="co-speech-phrase-kind">-</span>
{" · "}
강세 박: <span ref={(el) => { coSpeechMeterRefs.current.stressed = el; }} data-testid="co-speech-stressed">아니오</span>
</p>
<div className="al__parse-result">
<h3>파싱된 cue</h3>
<ul data-testid="parsed-cues">
{parsedCues.map((cue, i) => (
<li key={i} data-clip={cue.clip} data-anchor={cue.anchor} data-source={cue.source}>
{cue.clip} / {cue.anchor} / at={cue.at.toFixed(2)} / {cue.source}
</li>
))}
</ul>
<h3>미대응 지문</h3>
<ul data-testid="unmatched-directions">
{unmatched.map((text, i) => (
<li key={i}>{text}</li>
))}
</ul>
</div>
</section>
<section className="al__panel">
<h2>채널 미터 ({CHANNEL_IDS.length}개)</h2>
<table className="al__meter">
<thead>
<tr>
<th>채널</th>
<th>값</th>
</tr>
</thead>
<tbody>
{CHANNEL_IDS.map((id) => (
<tr key={id} data-channel={id}>
<td>{id}</td>
<td
data-value
ref={(el) => {
meterRefs.current[id] = el;
}}
>
0.00
</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="al__panel">
<h2>엔진 스냅샷</h2>
<h3>활성 클립</h3>
<ul data-testid="active-clips">
{(snapshot?.activeClips ?? []).map((c, i) => (
<li key={i} data-clip={c.id}>
{c.id} · started={c.startedMs.toFixed(0)} · weight={c.weight.toFixed(2)} · mirror={String(c.mirror)}
</li>
))}
</ul>
<h3>예약된 cue</h3>
<ul data-testid="scheduled-cues">
{(snapshot?.scheduled ?? []).map((c, i) => (
<li key={i} data-clip={c.clip}>
{c.clip} · at={c.atMs.toFixed(0)} · {c.source}
</li>
))}
</ul>
<p data-testid="leak-active">누설 활성: {snapshot?.leakActive ? "예" : "아니오"}</p>
</section>
</div>
);
}