아바타 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:
Yun Chan 2026-10-01 09:58:00 +09:00
parent b7bd24f016
commit 85bd079d18
47 changed files with 8310 additions and 0 deletions

View 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>
);
}