아바타 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
288
apps/web/scripts/check-avatar-lipsync.mjs
Normal file
288
apps/web/scripts/check-avatar-lipsync.mjs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-avatar-lipsync — 아바타 v3 발화층(립싱크) 데이터 정합성 검사.
|
||||
* docs/decisions/avatar-expression-engine-v3.md §5.4(발화층) "검증" 항목을 값·코드에 대해 검증한다.
|
||||
* engine 데이터 파일은 순수 TS라 esbuild로 node ESM으로 번들해(check-avatar-presets.mjs 패턴)
|
||||
* 동적 import로 값을 읽는다. 임시 산출물은 끝나면 지운다.
|
||||
*/
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const appRoot = process.cwd();
|
||||
const engineDir = path.join(appRoot, "src", "components", "avatar", "engine");
|
||||
|
||||
const ENTRY_FILES = ["lipsync.ts", "speechEnvelope.ts", "demeanorDefaults.ts", "coSpeech.ts", "engine.ts"];
|
||||
|
||||
const failures = [];
|
||||
function fail(message) {
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
async function loadModules() {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "vignette-avatar-lipsync-check-"));
|
||||
try {
|
||||
await writeFile(path.join(tempDir, "package.json"), JSON.stringify({ type: "module" }), "utf8");
|
||||
await build({
|
||||
entryPoints: ENTRY_FILES.map((f) => path.join(engineDir, f)),
|
||||
outdir: tempDir,
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
logLevel: "silent",
|
||||
});
|
||||
|
||||
const modules = {};
|
||||
for (const f of ENTRY_FILES) {
|
||||
const outFile = path.join(tempDir, f.replace(/\.ts$/, ".js"));
|
||||
const url = `${pathToFileURL(outFile).href}?t=${Date.now()}`;
|
||||
modules[f] = await import(url);
|
||||
}
|
||||
return modules;
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function nonSilentSequence(seq) {
|
||||
return seq.filter((v) => v !== "X");
|
||||
}
|
||||
|
||||
/* (a) 분해·매핑 표본(§5.4 검증: "음"은 끝이 M, "우리"는 U→I, "봐요"는 M→U→A→I→O). */
|
||||
function checkVisemeSamples(buildSpeechTimeline, visemeSequence) {
|
||||
const cases = [
|
||||
["음", ["EU", "M"]],
|
||||
["우리", ["U", "I"]],
|
||||
["봐요", ["M", "U", "A", "I", "O"]],
|
||||
];
|
||||
for (const [text, expected] of cases) {
|
||||
const timeline = buildSpeechTimeline({ text, syllablesPerSec: 5.0 });
|
||||
const seq = nonSilentSequence(visemeSequence(timeline));
|
||||
if (JSON.stringify(seq) !== JSON.stringify(expected)) {
|
||||
fail(`비짐 표본 "${text}": 기대 [${expected.join(",")}], 실제 [${seq.join(",")}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* (b) 최소 유지 60ms, M·X 밖에서는 open > 0(§5.4 검증). */
|
||||
function checkTimingInvariants(buildSpeechTimeline) {
|
||||
const timeline = buildSpeechTimeline({ text: "음… 그냥 요즘 잠을 잘 못 자요.", syllablesPerSec: 5.0 });
|
||||
for (let i = 1; i < timeline.keys.length; i++) {
|
||||
const gap = timeline.keys[i].atMs - timeline.keys[i - 1].atMs;
|
||||
if (gap < 59.99) fail(`최소 유지: 키 ${i - 1}→${i} 간격 ${gap.toFixed(2)}ms < 60ms`);
|
||||
}
|
||||
for (const key of timeline.keys) {
|
||||
if (key.viseme === "M" || key.viseme === "X") continue;
|
||||
if (!(key.shape.open > 0)) fail(`open>0 불변량: 비짐 ${key.viseme}의 open=${key.shape.open}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* (c) 텍스트 타이밍에서 syllablesPerSec와 발화 길이의 일치(받침·휴지 없는 표본으로 가중치를 1.0 고정). */
|
||||
function checkTextTimingRate(buildSpeechTimeline) {
|
||||
const text = "가나다라마바사";
|
||||
const syllablesPerSec = 5.0;
|
||||
const timeline = buildSpeechTimeline({ text, syllablesPerSec });
|
||||
const expectedMs = 7 * (1000 / syllablesPerSec);
|
||||
if (Math.abs(timeline.totalDurationMs - expectedMs) > 0.01) {
|
||||
fail(`텍스트 타이밍: 기대 ${expectedMs}ms, 실제 ${timeline.totalDurationMs}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
/* (d) 합성 포락선(구 3개·무음 2개) 정렬: 구 경계 오차 ≤ 30ms(§5.4 검증). */
|
||||
function checkAudioAlignment(buildSpeechTimeline) {
|
||||
const hopMs = 10;
|
||||
const totalHops = 150;
|
||||
const rms = new Float32Array(totalHops).fill(0.01);
|
||||
const loudSpans = [
|
||||
[0, 20],
|
||||
[50, 70],
|
||||
[100, 120],
|
||||
];
|
||||
for (const [a, b] of loudSpans) for (let i = a; i < b; i++) rms[i] = 1.0;
|
||||
const sorted = Array.from(rms).sort((a, b) => a - b);
|
||||
const pct = (p) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1))];
|
||||
const envelope = { hopMs, rms, p90: pct(0.9), p95: pct(0.95) };
|
||||
|
||||
const timeline = buildSpeechTimeline({ text: "가. 나. 다", syllablesPerSec: 5.0, envelope });
|
||||
const expected = loudSpans.map(([a, b]) => ({ startMs: a * hopMs, endMs: b * hopMs }));
|
||||
const windows = timeline.phraseWindows ?? [];
|
||||
if (windows.length !== expected.length) {
|
||||
fail(`구 경계: 기대 구 ${expected.length}개, 실제 ${windows.length}개`);
|
||||
return;
|
||||
}
|
||||
windows.forEach((w, i) => {
|
||||
const startErr = Math.abs(w.startMs - expected[i].startMs);
|
||||
const endErr = Math.abs(w.endMs - expected[i].endMs);
|
||||
if (startErr > 30) fail(`구 경계 ${i}: 시작 오차 ${startErr}ms > 30ms`);
|
||||
if (endErr > 30) fail(`구 경계 ${i}: 끝 오차 ${endErr}ms > 30ms`);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── §5.5 발화 동반층 검증 ─────────────────────────────────────────── */
|
||||
|
||||
/* (e) 같은 시드면 계획이 같다. */
|
||||
function checkPlanDeterminism(buildSpeechTimeline, buildCoSpeechPlan) {
|
||||
const timeline = buildSpeechTimeline({ text: "음… 그냥 요즘 잠을 잘 못 자요. 정말 그렇게 생각하세요?", syllablesPerSec: 5.0 });
|
||||
const style = { gesture: 0.55, gazeAvertProb: 0.7 };
|
||||
const a = buildCoSpeechPlan(timeline, style, 7);
|
||||
const b = buildCoSpeechPlan(timeline, style, 7);
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) fail("계획 결정성: 같은 시드인데 계획이 다르다");
|
||||
}
|
||||
|
||||
/* (f) 강세 박 정점 = 모음 목표 − 80ms(±1ms). */
|
||||
function checkStressPulsePeaks(buildSpeechTimeline, buildCoSpeechPlan) {
|
||||
const timeline = buildSpeechTimeline({ text: "음… 그냥 요즘 잠을 잘 못 자요. 정말 그렇게 생각하세요?", syllablesPerSec: 5.0 });
|
||||
const plan = buildCoSpeechPlan(timeline, { gesture: 0.55, gazeAvertProb: 0.7 }, 7);
|
||||
|
||||
const expectedPeaks = [];
|
||||
for (const ph of timeline.phrases) {
|
||||
for (const idx of ph.stressedIndices) {
|
||||
const vowelMs = ph.syllableTargetsMs[idx];
|
||||
if (vowelMs !== undefined) expectedPeaks.push(vowelMs - 80);
|
||||
}
|
||||
}
|
||||
const actualPeaks = plan.stressPulses.map((p) => p.peakMs);
|
||||
if (actualPeaks.length !== expectedPeaks.length) {
|
||||
fail(`강세 박 개수: 기대 ${expectedPeaks.length}, 실제 ${actualPeaks.length}`);
|
||||
return;
|
||||
}
|
||||
expectedPeaks.forEach((exp, i) => {
|
||||
const err = Math.abs(actualPeaks[i] - exp);
|
||||
if (err > 1) fail(`강세 박 ${i} 정점: 기대 ${exp}ms, 실제 ${actualPeaks[i]}ms(오차 ${err.toFixed(2)}ms)`);
|
||||
});
|
||||
}
|
||||
|
||||
/* (g) 질문 구에서 headRoll·headPitch가 들린다. */
|
||||
function checkQuestionLift(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech) {
|
||||
const timeline = buildSpeechTimeline({ text: "정말 그렇게 생각하세요?", syllablesPerSec: 5.0 });
|
||||
const plan = buildCoSpeechPlan(timeline, { gesture: 0.55, gazeAvertProb: 0.7 }, 7);
|
||||
const ph = timeline.phrases[0];
|
||||
const holdMs = ph.endMs + 150; /* 300ms 유지 구간 중간 */
|
||||
const sample = sampleCoSpeech(plan, holdMs, holdMs - 16, undefined);
|
||||
if (!(sample.delta.headPitch > 0)) fail(`질문 구 headPitch: ${holdMs}ms에 ${sample.delta.headPitch}(양수 기대)`);
|
||||
if (!(Math.abs(sample.delta.headRoll) > 0)) fail(`질문 구 headRoll: ${holdMs}ms에 ${sample.delta.headRoll}(0이 아닌 값 기대)`);
|
||||
}
|
||||
|
||||
/* (h) 발화 마지막 400ms에는 시선 회피가 없다. */
|
||||
function checkNoGazeAvertInTail(buildSpeechTimeline, buildCoSpeechPlan) {
|
||||
const timeline = buildSpeechTimeline({ text: "가나다라마바사아자차카, 나", syllablesPerSec: 5.0 });
|
||||
const lastPhrase = timeline.phrases[timeline.phrases.length - 1];
|
||||
if (!(lastPhrase.startMs >= timeline.totalDurationMs - 400)) {
|
||||
fail("시선 회피 표본 설계: 마지막 구가 발화 마지막 400ms 안에서 시작하지 않는다(표본 텍스트 재조정 필요)");
|
||||
return;
|
||||
}
|
||||
const plan = buildCoSpeechPlan(timeline, { gesture: 1, gazeAvertProb: 1 }, 3);
|
||||
if (plan.gazePulses.length !== timeline.phrases.length - 1) {
|
||||
fail(`시선 회피 발동 수: 기대 ${timeline.phrases.length - 1}(마지막 구 제외), 실제 ${plan.gazePulses.length}`);
|
||||
}
|
||||
for (const g of plan.gazePulses) {
|
||||
const startMs = g.breakpoints[0].t;
|
||||
if (startMs >= timeline.totalDurationMs - 400) fail(`시선 회피가 발화 마지막 400ms 안(${startMs}ms)에서 시작했다`);
|
||||
}
|
||||
}
|
||||
|
||||
/* (h-2) 평서 구 끝 끄덕임 headPitch < 0. */
|
||||
function checkStatementNodPitch(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech) {
|
||||
const timeline = buildSpeechTimeline({ text: "그냥요.", syllablesPerSec: 5.0 });
|
||||
const plan = buildCoSpeechPlan(timeline, { gesture: 0.55, gazeAvertProb: 0.7 }, 7);
|
||||
const ph = timeline.phrases[0];
|
||||
const lastTargetMs = ph.syllableTargetsMs[ph.syllableTargetsMs.length - 1];
|
||||
const sample = sampleCoSpeech(plan, lastTargetMs, lastTargetMs - 16, undefined);
|
||||
if (!(sample.delta.headPitch < 0)) fail(`평서 구 끝 끄덕임: ${lastTargetMs}ms에 headPitch=${sample.delta.headPitch}(음수 기대)`);
|
||||
}
|
||||
|
||||
/* (h-3) 엔진 통과: speaking 중 강세 박은 스프링 없이 그대로 통과해 정점 시각·크기를 지킨다
|
||||
(팀 리드 수정 요청, 2026-10-01). reduced motion 끔, 표정 neutral, 고정 시계 10ms 간격. */
|
||||
function checkEngineStressPulsePassthrough(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech, demeanorFor, createAvatarEngine) {
|
||||
const timeline = buildSpeechTimeline({ text: "그냥요.", syllablesPerSec: 5.0 });
|
||||
const style = { gesture: 0.55, gazeAvertProb: 0.7 };
|
||||
const plan = buildCoSpeechPlan(timeline, style, 7);
|
||||
const stress = plan.stressPulses[0];
|
||||
if (!stress) {
|
||||
fail("엔진 통과 검증: 강세 박이 없다");
|
||||
return;
|
||||
}
|
||||
|
||||
const demeanor = demeanorFor("P1");
|
||||
const engine = createAvatarEngine({ demeanor, seed: 1, reducedMotion: false });
|
||||
engine.setState("speaking", 0);
|
||||
engine.setSurface("neutral", 0);
|
||||
|
||||
const scanStart = Math.max(0, stress.peakMs - stress.riseMs - 50);
|
||||
const scanEnd = stress.peakMs + stress.fallMs + 50;
|
||||
const stepMs = 10;
|
||||
|
||||
const baseline = engine.evaluate(scanStart);
|
||||
const baseHeadPitch = baseline.headPitch;
|
||||
|
||||
let prevLocalMs = scanStart;
|
||||
let bestT = null;
|
||||
let bestDelta = 0;
|
||||
for (let t = scanStart; t <= scanEnd; t += stepMs) {
|
||||
const sample = sampleCoSpeech(plan, t, prevLocalMs);
|
||||
engine.setSpeechMotion(sample.delta);
|
||||
prevLocalMs = t;
|
||||
const v = engine.evaluate(t);
|
||||
const coHeadPitch = v.headPitch - baseHeadPitch;
|
||||
if (bestT === null || coHeadPitch < bestDelta) {
|
||||
bestDelta = coHeadPitch;
|
||||
bestT = t;
|
||||
}
|
||||
}
|
||||
|
||||
const timeErr = Math.abs(bestT - stress.peakMs);
|
||||
if (timeErr > 10) fail(`엔진 통과: 강세 박 정점 시각 오차 ${timeErr}ms > 10ms(계획 ${stress.peakMs}ms, 엔진 ${bestT}ms)`);
|
||||
const expectedMag = Math.abs(stress.headPitch);
|
||||
const actualMag = Math.abs(bestDelta);
|
||||
if (actualMag < expectedMag * 0.9) {
|
||||
fail(`엔진 통과: 강세 박 크기 ${actualMag.toFixed(4)} < 계획 값의 90%(${(expectedMag * 0.9).toFixed(4)})`);
|
||||
}
|
||||
}
|
||||
|
||||
/* (i) reduced motion에서는 엔진 delta가 0. */
|
||||
function checkReducedMotionZero(demeanorFor, createAvatarEngine) {
|
||||
const demeanor = demeanorFor("P1");
|
||||
const engine = createAvatarEngine({ demeanor, seed: 1, reducedMotion: true });
|
||||
engine.setState("speaking", 0);
|
||||
engine.setSpeechMotion({ headYaw: 0.9, headRoll: 0.9, browLY: 0.9, browRY: 0.9, gazeX: 0.9 });
|
||||
const v = engine.evaluate(50);
|
||||
/* P1 basePose는 이 채널들을 건드리지 않아 중립값이 정확히 0이다(demeanorDefaults.ts 기준). */
|
||||
for (const id of ["headYaw", "headRoll", "browLY", "browRY", "gazeX"]) {
|
||||
if (v[id] !== 0) fail(`reduced motion delta: ${id} 기대 0, 실제 ${v[id]}(setSpeechMotion이 반영되면 안 된다)`);
|
||||
}
|
||||
}
|
||||
|
||||
const modules = await loadModules();
|
||||
const { buildSpeechTimeline, visemeSequence } = modules["lipsync.ts"];
|
||||
const { demeanorFor } = modules["demeanorDefaults.ts"];
|
||||
const { buildCoSpeechPlan, sampleCoSpeech } = modules["coSpeech.ts"];
|
||||
const { createAvatarEngine } = modules["engine.ts"];
|
||||
|
||||
const p1Rate = demeanorFor("P1").speech.syllablesPerSec;
|
||||
if (p1Rate !== 5.0) fail(`P1 syllablesPerSec 기대 5.0, 실제 ${p1Rate}`);
|
||||
|
||||
checkVisemeSamples(buildSpeechTimeline, visemeSequence);
|
||||
checkTimingInvariants(buildSpeechTimeline);
|
||||
checkTextTimingRate(buildSpeechTimeline);
|
||||
checkAudioAlignment(buildSpeechTimeline);
|
||||
|
||||
checkPlanDeterminism(buildSpeechTimeline, buildCoSpeechPlan);
|
||||
checkStressPulsePeaks(buildSpeechTimeline, buildCoSpeechPlan);
|
||||
checkQuestionLift(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech);
|
||||
checkNoGazeAvertInTail(buildSpeechTimeline, buildCoSpeechPlan);
|
||||
checkStatementNodPitch(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech);
|
||||
checkEngineStressPulsePassthrough(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech, demeanorFor, createAvatarEngine);
|
||||
checkReducedMotionZero(demeanorFor, createAvatarEngine);
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const message of failures) console.error(`FAIL: ${message}`);
|
||||
console.error(`check-avatar-lipsync: 실패 ${failures.length}건`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"check-avatar-lipsync: 통과 (비짐 표본 3건, 최소 유지·open>0 불변량, 텍스트 발화 속도 일치, 구 경계 정렬 ≤30ms, " +
|
||||
"발화 동반층 결정성·강세 박 정점·질문 들림·시선 회피 꼬리 금지·평서 구 끝 끄덕임·엔진 통과·reduced motion 0)",
|
||||
);
|
||||
267
apps/web/scripts/check-avatar-presets.mjs
Normal file
267
apps/web/scripts/check-avatar-presets.mjs
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-avatar-presets — 아바타 v3 표현 엔진 데이터 정합성 검사.
|
||||
* docs/decisions/avatar-expression-engine-v3.md §5.1(구분 가능성 불변량)·
|
||||
* §6.1(클립 형식)·§7.3(지문 파서) 규칙을 값·코드에 대해 검증한다.
|
||||
*
|
||||
* engine 데이터 파일은 순수 TS라 esbuild로 각각 node ESM으로 번들해(임시 디렉터리,
|
||||
* generate-live2d-assets.mjs 패턴) 동적 import로 값을 읽는다. 임시 산출물은 끝나면 지운다.
|
||||
*/
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const appRoot = process.cwd();
|
||||
const engineDir = path.join(appRoot, "src", "components", "avatar", "engine");
|
||||
|
||||
const ENTRY_FILES = [
|
||||
"channels.ts",
|
||||
"expressionPresets.ts",
|
||||
"clipCatalog.ts",
|
||||
"demeanorDefaults.ts",
|
||||
"stageDirectionLexicon.ts",
|
||||
"performance.ts",
|
||||
];
|
||||
|
||||
/* 저작용 대칭 키 — channels.ts SYMMETRIC_KEYS 와 같은 쌍. 구분 가능성 불변량 계산에서
|
||||
좌우를 평균 1개 채널로 접는다(결정문 §5.1). */
|
||||
const SYMMETRIC_PAIRS = [
|
||||
["eyeOpenL", "eyeOpenR"],
|
||||
["eyeSmileL", "eyeSmileR"],
|
||||
["browLY", "browRY"],
|
||||
["browLAngle", "browRAngle"],
|
||||
["browLX", "browRX"],
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
|
||||
function fail(message) {
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
async function loadModules() {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "vignette-avatar-check-"));
|
||||
try {
|
||||
await writeFile(path.join(tempDir, "package.json"), JSON.stringify({ type: "module" }), "utf8");
|
||||
await build({
|
||||
entryPoints: ENTRY_FILES.map((f) => path.join(engineDir, f)),
|
||||
outdir: tempDir,
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
logLevel: "silent",
|
||||
});
|
||||
|
||||
const modules = {};
|
||||
for (const f of ENTRY_FILES) {
|
||||
const outFile = path.join(tempDir, f.replace(/\.ts$/, ".js"));
|
||||
const url = `${pathToFileURL(outFile).href}?t=${Date.now()}`;
|
||||
modules[f] = await import(url);
|
||||
}
|
||||
return modules;
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function effectiveChannelVector(delta, channelIds) {
|
||||
const used = new Set(SYMMETRIC_PAIRS.flat());
|
||||
const out = {};
|
||||
for (const [l, r] of SYMMETRIC_PAIRS) {
|
||||
const lv = delta[l] ?? 0;
|
||||
const rv = delta[r] ?? 0;
|
||||
out[`${l}/${r}`] = (lv + rv) / 2;
|
||||
}
|
||||
for (const id of channelIds) {
|
||||
if (used.has(id)) continue;
|
||||
out[id] = delta[id] ?? 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function l1Distance(a, b) {
|
||||
let sum = 0;
|
||||
let max = 0;
|
||||
for (const key of Object.keys(a)) {
|
||||
const d = Math.abs((a[key] ?? 0) - (b[key] ?? 0));
|
||||
sum += d;
|
||||
if (d > max) max = d;
|
||||
}
|
||||
return { sum, max };
|
||||
}
|
||||
|
||||
/* (a) 구분 가능성 불변량: 378쌍 L1 ≥ 0.6, 최대 단일 채널 차이 ≥ 0.25. */
|
||||
function checkDiscriminability(CHANNEL_IDS, EXPRESSION_PRESETS) {
|
||||
const ids = Object.keys(EXPRESSION_PRESETS);
|
||||
const vectors = new Map(ids.map((id) => [id, effectiveChannelVector(EXPRESSION_PRESETS[id], CHANNEL_IDS)]));
|
||||
let pairCount = 0;
|
||||
let minL1 = Infinity;
|
||||
let minL1Pair = "";
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
pairCount++;
|
||||
const { sum, max } = l1Distance(vectors.get(ids[i]), vectors.get(ids[j]));
|
||||
if (sum < minL1) {
|
||||
minL1 = sum;
|
||||
minL1Pair = `${ids[i]}/${ids[j]}`;
|
||||
}
|
||||
if (sum < 0.6) fail(`구분 가능성: ${ids[i]}/${ids[j]} L1=${sum.toFixed(3)} < 0.6`);
|
||||
if (max < 0.25) fail(`구분 가능성: ${ids[i]}/${ids[j]} 최대 단일 채널 차이=${max.toFixed(3)} < 0.25`);
|
||||
}
|
||||
}
|
||||
const expectedPairs = (ids.length * (ids.length - 1)) / 2;
|
||||
if (pairCount !== expectedPairs) fail(`구분 가능성: 쌍 개수 ${pairCount} != 기대 ${expectedPairs}`);
|
||||
return { pairCount, minL1, minL1Pair };
|
||||
}
|
||||
|
||||
/* (b) 모든 프리셋·클립·basePose 키가 CHANNEL_IDS에 속함. */
|
||||
function checkChannelKeys(CHANNEL_IDS, EXPRESSION_PRESETS, REACTION_CLIPS, DEFAULT_DEMEANOR, personaDemeanors) {
|
||||
const validIds = new Set(CHANNEL_IDS);
|
||||
for (const [exprId, delta] of Object.entries(EXPRESSION_PRESETS)) {
|
||||
for (const key of Object.keys(delta)) {
|
||||
if (!validIds.has(key)) fail(`채널 키: 프리셋 ${exprId}의 ${key}가 CHANNEL_IDS에 없음`);
|
||||
}
|
||||
}
|
||||
for (const [clipId, clip] of Object.entries(REACTION_CLIPS)) {
|
||||
for (const key of Object.keys(clip.tracks)) {
|
||||
if (!validIds.has(key)) fail(`채널 키: 클립 ${clipId}의 트랙 ${key}가 CHANNEL_IDS에 없음`);
|
||||
}
|
||||
}
|
||||
const demeanors = [["DEFAULT", DEFAULT_DEMEANOR], ...personaDemeanors];
|
||||
for (const [label, demeanor] of demeanors) {
|
||||
for (const key of Object.keys(demeanor.basePose)) {
|
||||
if (!validIds.has(key)) fail(`채널 키: demeanor ${label}의 basePose ${key}가 CHANNEL_IDS에 없음`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* (c)(d) 클립 키프레임 형식. */
|
||||
function checkClipKeyframes(REACTION_CLIPS) {
|
||||
for (const [clipId, clip] of Object.entries(REACTION_CLIPS)) {
|
||||
if (clip.fadeInMs + clip.fadeOutMs > clip.durationMs) {
|
||||
fail(`클립 ${clipId}: fadeInMs(${clip.fadeInMs})+fadeOutMs(${clip.fadeOutMs}) > durationMs(${clip.durationMs})`);
|
||||
}
|
||||
for (const [channelId, frames] of Object.entries(clip.tracks)) {
|
||||
if (!frames || frames.length === 0) continue;
|
||||
const [t0, v0] = frames[0];
|
||||
if (t0 !== 0) fail(`클립 ${clipId}.${channelId}: 첫 키프레임 시각이 0이 아님(${t0})`);
|
||||
if (v0 !== 0) fail(`클립 ${clipId}.${channelId}: 첫 키프레임 값이 0이 아님(${v0})`);
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
if (frames[i][0] <= frames[i - 1][0]) {
|
||||
fail(`클립 ${clipId}.${channelId}: 시각이 오름차순이 아님(${frames[i - 1][0]} -> ${frames[i][0]})`);
|
||||
}
|
||||
}
|
||||
const last = frames[frames.length - 1];
|
||||
if (last[0] > clip.durationMs) {
|
||||
fail(`클립 ${clipId}.${channelId}: 마지막 키프레임 시각(${last[0]}) > durationMs(${clip.durationMs})`);
|
||||
}
|
||||
if (last[1] !== 0 && clip.fadeOutMs < 300) {
|
||||
fail(`클립 ${clipId}.${channelId}: 마지막 값(${last[1]})이 0이 아닌데 fadeOutMs(${clip.fadeOutMs}) < 300`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* (e) demeanor idleClips·lexicon의 clip id가 REACTION_CLIP_IDS에 존재. */
|
||||
function checkClipReferences(REACTION_CLIP_IDS, DEFAULT_DEMEANOR, personaDemeanors, STAGE_DIRECTION_RULES) {
|
||||
const validIds = new Set(REACTION_CLIP_IDS);
|
||||
const demeanors = [["DEFAULT", DEFAULT_DEMEANOR], ...personaDemeanors];
|
||||
for (const [label, demeanor] of demeanors) {
|
||||
for (const rule of demeanor.idleClips) {
|
||||
if (!validIds.has(rule.clip)) fail(`demeanor ${label}의 idleClips 참조 ${rule.clip}가 REACTION_CLIP_IDS에 없음`);
|
||||
}
|
||||
}
|
||||
for (const rule of STAGE_DIRECTION_RULES) {
|
||||
if (!validIds.has(rule.clip)) fail(`stageDirectionLexicon 규칙의 ${rule.clip}가 REACTION_CLIP_IDS에 없음`);
|
||||
}
|
||||
}
|
||||
|
||||
/* (f) 표본 지문 대응표(결정문 §7.3, 앵커 보정은 오케스트레이터 2026-09-30 정정). */
|
||||
function checkStageDirectionSamples(parseStageDirections) {
|
||||
const singleClipCases = [
|
||||
["(한숨)", "sigh", undefined],
|
||||
["(옅은 한숨)", "sigh", 0.6],
|
||||
["(잠시 멈춤)", "look_down", undefined],
|
||||
["(잠깐 침묵)", "silence_hold", undefined],
|
||||
["(고개 살짝 돌림)", "look_away_side", undefined],
|
||||
["(어깨 으쓱)", "shrug", undefined],
|
||||
["(피식)", "scoff", undefined],
|
||||
["(어색한 웃음)", "nervous_laugh", undefined],
|
||||
["(긴장한 웃음)", "nervous_laugh", undefined],
|
||||
["(머뭇)", "lip_press", undefined],
|
||||
["(손톱 만지작)", "fidget_sway", undefined],
|
||||
["(시선 피함)", "look_away_side", undefined],
|
||||
["(쓴웃음)", "scoff", undefined],
|
||||
];
|
||||
|
||||
for (const [text, expectedClip, expectedWeight] of singleClipCases) {
|
||||
const { cues } = parseStageDirections(text);
|
||||
if (cues.length !== 1 || cues[0].clip !== expectedClip) {
|
||||
fail(`지문 "${text}": 기대 클립 ${expectedClip}, 실제 ${cues.map((c) => c.clip).join(",") || "(없음)"}`);
|
||||
continue;
|
||||
}
|
||||
if (expectedWeight !== undefined && cues[0].weight !== expectedWeight) {
|
||||
fail(`지문 "${text}": 기대 weight ${expectedWeight}, 실제 ${cues[0].weight}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { cues: multi } = parseStageDirections("(한숨, 침묵 10초)");
|
||||
const multiClips = multi.map((c) => c.clip).join(",");
|
||||
if (multiClips !== "sigh,silence_hold") {
|
||||
fail(`지문 "(한숨, 침묵 10초)": 기대 [sigh, silence_hold], 실제 [${multiClips}]`);
|
||||
}
|
||||
|
||||
const unmatchedCases = ["(작은 목소리로)", "('지침 vs 게으름' 재구성)"];
|
||||
for (const text of unmatchedCases) {
|
||||
const { cues, unmatched } = parseStageDirections(text);
|
||||
if (cues.length !== 0 || unmatched.length !== 1) {
|
||||
fail(`지문 "${text}": 미대응이어야 하는데 cues=${cues.length}, unmatched=${unmatched.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
const preCase = parseStageDirections("(한숨) 그냥요.");
|
||||
if (preCase.cues[0]?.anchor !== "pre") {
|
||||
fail(`앵커 "(한숨) 그냥요.": 기대 pre, 실제 ${preCase.cues[0]?.anchor}`);
|
||||
}
|
||||
|
||||
const inlineCase = parseStageDirections("몰라요. (한숨) 다 귀찮아요.");
|
||||
const inlineCue = inlineCase.cues[0];
|
||||
if (inlineCue?.anchor !== "inline") {
|
||||
fail(`앵커 "몰라요. (한숨) 다 귀찮아요.": 기대 inline, 실제 ${inlineCue?.anchor}`);
|
||||
} else if (inlineCue.at < 0.36 || inlineCue.at > 0.39) {
|
||||
fail(`앵커 "몰라요. (한숨) 다 귀찮아요.": at=${inlineCue.at.toFixed(4)}가 [0.36, 0.39] 밖`);
|
||||
}
|
||||
|
||||
const postCase = parseStageDirections("그냥요 (시선 피함)");
|
||||
if (postCase.cues[0]?.anchor !== "post") {
|
||||
fail(`앵커 "그냥요 (시선 피함)": 기대 post, 실제 ${postCase.cues[0]?.anchor}`);
|
||||
}
|
||||
}
|
||||
|
||||
const modules = await loadModules();
|
||||
const { CHANNEL_IDS } = modules["channels.ts"];
|
||||
const { EXPRESSION_PRESETS } = modules["expressionPresets.ts"];
|
||||
const { REACTION_CLIPS, REACTION_CLIP_IDS } = modules["clipCatalog.ts"];
|
||||
const { DEFAULT_DEMEANOR, demeanorFor } = modules["demeanorDefaults.ts"];
|
||||
const { STAGE_DIRECTION_RULES } = modules["stageDirectionLexicon.ts"];
|
||||
const { parseStageDirections } = modules["performance.ts"];
|
||||
|
||||
const personaDemeanors = ["P1", "P2", "P3", "P4", "P5", "P6", "P7"].map((code) => [code, demeanorFor(code)]);
|
||||
|
||||
const { pairCount, minL1, minL1Pair } = checkDiscriminability(CHANNEL_IDS, EXPRESSION_PRESETS);
|
||||
checkChannelKeys(CHANNEL_IDS, EXPRESSION_PRESETS, REACTION_CLIPS, DEFAULT_DEMEANOR, personaDemeanors);
|
||||
checkClipKeyframes(REACTION_CLIPS);
|
||||
checkClipReferences(REACTION_CLIP_IDS, DEFAULT_DEMEANOR, personaDemeanors, STAGE_DIRECTION_RULES);
|
||||
checkStageDirectionSamples(parseStageDirections);
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const message of failures) console.error(`FAIL: ${message}`);
|
||||
console.error(`check-avatar-presets: 실패 ${failures.length}건`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`check-avatar-presets: 통과 (프리셋 쌍 ${pairCount}개 전부 구분 가능, 최소 L1 ${minL1.toFixed(2)}[${minL1Pair}], 클립 ${REACTION_CLIP_IDS.length}개, 지문 표본 검증 완료)`,
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue