아바타 v3 표현 엔진·리노컷 렌더러·립싱크와 Lab
- 표현 엔진: 채널 34개(mouthRound 추가), 표정 28종·강도 곡선·반응 클립 26종·지문 cue, 미세표정 누설 - 발화층: 한글 자모 비짐 9종, TTS 오디오 선분석 정렬, 60ms 앞당김·모음 간 비닫힘 - 발화 동반층: 억양·강세에 맞춘 고개 표류·끄덕임·질문 갸웃·들숨·눈썹 박·시선 회피·쉼 깜빡임 - P1 서연 리노컷 렌더러: 원화 픽셀 입술 띠 변형, 하관 띠 변형(턱·코 들썩), 볼 불룩, 작은 크기 선 보정, 모티프·배경 색면 - /dev/avatar-lab, check:avatar-presets·check:avatar-lipsync, avatar-lab E2E 19개
This commit is contained in:
parent
b7bd24f016
commit
85bd079d18
47 changed files with 8310 additions and 0 deletions
723
apps/web/src/pages/AvatarLab.tsx
Normal file
723
apps/web/src/pages/AvatarLab.tsx
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
/* =====================================================================
|
||||
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 { P1_LINOCUT_RIG } from "../components/avatar/v3/rigs/p1Rig";
|
||||
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 { buildPerformance, type Performance, type PerformanceCue } from "../components/avatar/engine/performance";
|
||||
import {
|
||||
buildSpeechTimeline,
|
||||
currentViseme,
|
||||
sampleSpeech,
|
||||
type PhraseKind,
|
||||
type SpeechShape,
|
||||
type SpeechTimeline,
|
||||
type VisemeId,
|
||||
} from "../components/avatar/engine/lipsync";
|
||||
import { buildCoSpeechPlan, isStressPulseActive, sampleCoSpeech, type CoSpeechPlan } from "../components/avatar/engine/coSpeech";
|
||||
import { computeEnvelope, type SpeechEnvelope } from "../components/avatar/engine/speechEnvelope";
|
||||
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 렌더러를 강제한다. */
|
||||
const LINOCUT_RIGS: Partial<Record<string, LinocutRig>> = { P1: P1_LINOCUT_RIG };
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 표시용: localMs 시점까지 시작한 가장 최근 구의 종류. 구 사이 휴지 중에는 그 직전 구를 보인다. */
|
||||
function currentPhraseKind(timeline: SpeechTimeline, localMs: number): PhraseKind | null {
|
||||
let kind: PhraseKind | null = null;
|
||||
for (const ph of timeline.phrases) {
|
||||
if (ph.startMs > localMs) break;
|
||||
kind = ph.kind;
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export default function AvatarLab() {
|
||||
const seed = useMemo(seedFromQuery, []);
|
||||
const clock = useRef<Clock>(createClock()).current;
|
||||
|
||||
const [personaCode, setPersonaCode] = useState("P1");
|
||||
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 speechRafRef = useRef<number>(0);
|
||||
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 () => {
|
||||
cancelAnimationFrame(speechRafRef.current);
|
||||
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 };
|
||||
}
|
||||
|
||||
/** timeline을 nowFn() 시계로 매 프레임 표본해 engine.setSpeechShape·setSpeechMotion에 흘려보낸다.
|
||||
coSpeechPlan이 있으면 발화 동반층(§5.5)도 같은 프레임에 표본한다. */
|
||||
function runSpeechTimeline(
|
||||
timeline: SpeechTimeline,
|
||||
speechStartMs: number,
|
||||
nowFn: () => number,
|
||||
articulation: number,
|
||||
coSpeechPlan: CoSpeechPlan | null,
|
||||
envelope?: SpeechEnvelope,
|
||||
): void {
|
||||
cancelAnimationFrame(speechRafRef.current);
|
||||
const endMs = speechStartMs + timeline.totalDurationMs;
|
||||
let prevLocalMs = -Infinity;
|
||||
const step = () => {
|
||||
const t = nowFn();
|
||||
if (t < speechStartMs) {
|
||||
speechRafRef.current = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
if (t >= endMs) {
|
||||
endSpeech(clock.now());
|
||||
return;
|
||||
}
|
||||
const localMs = t - speechStartMs;
|
||||
const shape = sampleSpeech(timeline, localMs, articulation);
|
||||
engine.setSpeechShape(shape);
|
||||
speechDisplayRef.current = { viseme: currentViseme(timeline, localMs), shape };
|
||||
|
||||
if (coSpeechPlan) {
|
||||
const sample = sampleCoSpeech(coSpeechPlan, localMs, prevLocalMs, envelope);
|
||||
engine.setSpeechMotion(sample.delta);
|
||||
if (sample.blinkNow) engine.requestSpeechBlink(clock.now());
|
||||
coSpeechDisplayRef.current = {
|
||||
phraseKind: currentPhraseKind(timeline, localMs),
|
||||
stressed: isStressPulseActive(coSpeechPlan, localMs),
|
||||
};
|
||||
}
|
||||
prevLocalMs = localMs;
|
||||
|
||||
speechRafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
speechRafRef.current = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function playSpeech(): void {
|
||||
const nowMs = clock.now();
|
||||
const { performance: perf, unmatched: um } = buildPerformance({
|
||||
text: speechText,
|
||||
expression: selectedExpression,
|
||||
intensity,
|
||||
openness,
|
||||
seed,
|
||||
});
|
||||
const speech = demeanorFor(personaCode).speech;
|
||||
const timeline = buildSpeechTimeline({ text: speechText, syllablesPerSec: speech.syllablesPerSec });
|
||||
const coSpeechPlan = buildCoSpeechPlan(timeline, speech, seed);
|
||||
const speechStartMs = nowMs + 300;
|
||||
setAvatarState("speaking");
|
||||
engine.setState("speaking", nowMs);
|
||||
engine.playPerformance(perf, { speechStartMs, speechDurationMs: timeline.totalDurationMs }, nowMs);
|
||||
setParsedCues(perf.cues);
|
||||
setUnmatched(um);
|
||||
runSpeechTimeline(timeline, speechStartMs, clock.now, speech.articulation, coSpeechPlan);
|
||||
}
|
||||
|
||||
function handleAudioFileChange(e: ChangeEvent<HTMLInputElement>): void {
|
||||
setAudioFileName(e.target.files?.[0]?.name ?? null);
|
||||
}
|
||||
|
||||
/** "오디오 파일로 말하기" — 로컬 오디오를 디코드해 포락선을 만들고, 오디오 시계로 표본한다. */
|
||||
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));
|
||||
const channels: Float32Array[] = [];
|
||||
for (let c = 0; c < audioBuffer.numberOfChannels; c++) channels.push(audioBuffer.getChannelData(c));
|
||||
const envelope = computeEnvelope(channels, audioBuffer.sampleRate);
|
||||
|
||||
const nowMs = clock.now();
|
||||
const { performance: perf, unmatched: um } = buildPerformance({
|
||||
text: speechText,
|
||||
expression: selectedExpression,
|
||||
intensity,
|
||||
openness,
|
||||
seed,
|
||||
});
|
||||
const speech = demeanorFor(personaCode).speech;
|
||||
const timeline = buildSpeechTimeline({ text: speechText, syllablesPerSec: speech.syllablesPerSec, envelope });
|
||||
const coSpeechPlan = buildCoSpeechPlan(timeline, speech, seed);
|
||||
|
||||
try {
|
||||
audioSourceRef.current?.stop();
|
||||
} catch {
|
||||
/* 이미 정지된 소스면 무시한다. */
|
||||
}
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(ctx.destination);
|
||||
audioSourceRef.current = source;
|
||||
|
||||
const speechStartMs = nowMs + 300;
|
||||
setAvatarState("speaking");
|
||||
engine.setState("speaking", nowMs);
|
||||
engine.playPerformance(perf, { speechStartMs, speechDurationMs: timeline.totalDurationMs }, nowMs);
|
||||
setParsedCues(perf.cues);
|
||||
setUnmatched(um);
|
||||
|
||||
const startAtCtx = ctx.currentTime + 0.3;
|
||||
source.start(startAtCtx);
|
||||
/* 오디오 시계 기준(초 → ms). Lab의 clock(performance.now() 기반)과는 별개 시계다 —
|
||||
재생 시작을 같은 300ms로 맞췄지만 독립 시계라 아주 긴 발화에서는 드리프트가 있을 수 있다. */
|
||||
const nowFn = () => (ctx.currentTime - startAtCtx) * 1000 + speechStartMs;
|
||||
runSpeechTimeline(timeline, speechStartMs, nowFn, speech.articulation, coSpeechPlan, envelope);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue