vignette/apps/web/scripts/check-avatar-presets.mjs
Yun Chan 85bd079d18 아바타 v3 표현 엔진·리노컷 렌더러·립싱크와 Lab
- 표현 엔진: 채널 34개(mouthRound 추가), 표정 28종·강도 곡선·반응 클립 26종·지문 cue, 미세표정 누설
- 발화층: 한글 자모 비짐 9종, TTS 오디오 선분석 정렬, 60ms 앞당김·모음 간 비닫힘
- 발화 동반층: 억양·강세에 맞춘 고개 표류·끄덕임·질문 갸웃·들숨·눈썹 박·시선 회피·쉼 깜빡임
- P1 서연 리노컷 렌더러: 원화 픽셀 입술 띠 변형, 하관 띠 변형(턱·코 들썩), 볼 불룩, 작은 크기 선 보정, 모티프·배경 색면
- /dev/avatar-lab, check:avatar-presets·check:avatar-lipsync, avatar-lab E2E 19개
2026-10-01 09:58:00 +09:00

267 lines
11 KiB
JavaScript

#!/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}개, 지문 표본 검증 완료)`,
);