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