아바타 v3 표현 엔진·리노컷 렌더러·립싱크와 Lab
- 표현 엔진: 채널 34개(mouthRound 추가), 표정 28종·강도 곡선·반응 클립 26종·지문 cue, 미세표정 누설 - 발화층: 한글 자모 비짐 9종, TTS 오디오 선분석 정렬, 60ms 앞당김·모음 간 비닫힘 - 발화 동반층: 억양·강세에 맞춘 고개 표류·끄덕임·질문 갸웃·들숨·눈썹 박·시선 회피·쉼 깜빡임 - P1 서연 리노컷 렌더러: 원화 픽셀 입술 띠 변형, 하관 띠 변형(턱·코 들썩), 볼 불룩, 작은 크기 선 보정, 모티프·배경 색면 - /dev/avatar-lab, check:avatar-presets·check:avatar-lipsync, avatar-lab E2E 19개
316
apps/web/e2e/avatar-lab.spec.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/* =====================================================================
|
||||
avatar-lab.spec.ts — 아바타 v3 표현 엔진 dev Lab(/dev/avatar-lab) 검증 스펙.
|
||||
무인증 dev 페이지라 auth mock이 필요 없다(AvatarPreview 패턴과 동일).
|
||||
===================================================================== */
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
function channelCell(page: import("@playwright/test").Page, channel: string) {
|
||||
return page.locator(`tr[data-channel="${channel}"] td[data-value]`);
|
||||
}
|
||||
|
||||
test.describe("avatar v3 lab", () => {
|
||||
/* 이 페이지는 무인증 dev 라우트지만, AuthProvider가 마운트 시 항상 /auth/me를
|
||||
호출한다. 실 백엔드에 세션이 없으면 401이 그대로 콘솔 에러로 찍혀
|
||||
"콘솔 error 0" 판정을 이 페이지와 무관한 원인으로 깨뜨린다
|
||||
(avatar-expression-lab.spec.ts / admin-affect.spec.ts와 같은 mock 패턴). */
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
user_id: "avatar-lab-learner",
|
||||
email: "learner@hs.ac.kr",
|
||||
display_name: "Avatar Lab Learner",
|
||||
role: "learner",
|
||||
cohort_ids: [],
|
||||
onboarding_completed_at: Math.floor(Date.now() / 1000),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("콘솔 에러 없이 로드된다", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") errors.push(msg.text());
|
||||
});
|
||||
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
await expect(page.locator('[data-avatar-lab="true"]')).toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("표정 sad 강도 1 선택 후 채널이 뚜렷해진다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="expression-sad"]').click();
|
||||
const intensitySlider = page.locator(".al__intensity input[type='range']");
|
||||
await intensitySlider.focus();
|
||||
await intensitySlider.press("End");
|
||||
|
||||
const browLAngleCell = channelCell(page, "browLAngle");
|
||||
const mouthFormCell = channelCell(page, "mouthForm");
|
||||
|
||||
await expect.poll(async () => Number(await browLAngleCell.innerText()), { timeout: 3000 }).toBeGreaterThan(0.3);
|
||||
await expect.poll(async () => Number(await mouthFormCell.innerText()), { timeout: 3000 }).toBeLessThan(-0.2);
|
||||
});
|
||||
|
||||
test("nod_small 클릭 시 headPitch가 일시적으로 낮아진다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
const headPitchCell = channelCell(page, "headPitch");
|
||||
const before = Number(await headPitchCell.innerText());
|
||||
|
||||
await page.locator('[data-testid="clip-nod_small"]').click();
|
||||
|
||||
await expect
|
||||
.poll(async () => before - Number(await headPitchCell.innerText()), { timeout: 1500, intervals: [50] })
|
||||
.toBeGreaterThanOrEqual(0.05);
|
||||
});
|
||||
|
||||
test("괄호 지문이 cue로 파싱되고 미대응 지문이 표시된다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("(한숨) 그냥요. (작은 목소리로)");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
const firstCue = page.locator('[data-testid="parsed-cues"] li').first();
|
||||
await expect(firstCue).toHaveAttribute("data-clip", "sigh");
|
||||
await expect(firstCue).toHaveAttribute("data-anchor", "pre");
|
||||
await expect(firstCue).toHaveAttribute("data-source", "direction");
|
||||
|
||||
await expect(page.locator('[data-testid="unmatched-directions"] li')).toHaveText("작은 목소리로");
|
||||
});
|
||||
|
||||
test("누설 테스트 시 browLAngle이 일시적으로 커졌다가 다시 작아진다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
const browLAngleCell = channelCell(page, "browLAngle");
|
||||
const before = Number(await browLAngleCell.innerText());
|
||||
|
||||
await page.locator('[data-testid="play-leak-test"]').click();
|
||||
|
||||
await expect
|
||||
.poll(async () => Number(await browLAngleCell.innerText()) - before, { timeout: 1500, intervals: [50] })
|
||||
.toBeGreaterThanOrEqual(0.15);
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
const after = Number(await browLAngleCell.innerText());
|
||||
expect(after - before).toBeLessThan(0.15);
|
||||
});
|
||||
|
||||
test("reduced motion 토글 시 반응 클립이 비활성화된다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="reduced-motion-toggle"]').click();
|
||||
await page.waitForTimeout(150);
|
||||
await expect(page.locator('[data-testid="active-clips"] li')).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-testid="clip-nod_small"]').click();
|
||||
await page.waitForTimeout(200);
|
||||
await expect(page.locator('[data-testid="active-clips"] li')).toHaveCount(0);
|
||||
});
|
||||
|
||||
/* 발화층(립싱크) — 결정문 §5.4. 비짐별 입 모양이 채널에 실제로 합성되는지 채널 표로 확인한다. */
|
||||
|
||||
test("'우우우우' 발화 중 mouthRound이 커진다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("우우우우");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
const mouthRoundCell = channelCell(page, "mouthRound");
|
||||
await expect
|
||||
.poll(async () => Number(await mouthRoundCell.innerText()), { timeout: 3000, intervals: [50] })
|
||||
.toBeGreaterThan(0.4);
|
||||
});
|
||||
|
||||
test("'아아아아' 발화 중 mouthOpen이 커진다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("아아아아");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
const mouthOpenCell = channelCell(page, "mouthOpen");
|
||||
await expect
|
||||
.poll(async () => Number(await mouthOpenCell.innerText()), { timeout: 3000, intervals: [50] })
|
||||
.toBeGreaterThan(0.3);
|
||||
});
|
||||
|
||||
test("'음 음 음' 발화 중 mouthPress가 커진다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("음 음 음");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
const mouthPressCell = channelCell(page, "mouthPress");
|
||||
await expect
|
||||
.poll(async () => Number(await mouthPressCell.innerText()), { timeout: 3000, intervals: [50] })
|
||||
.toBeGreaterThan(0.2);
|
||||
});
|
||||
|
||||
/* 발화 동반층(§5.5) — 말할 때 머리·몸·눈썹·시선·깜빡임. */
|
||||
|
||||
test("'정말 그렇게 생각하세요?' 발화 중 headRoll 절댓값이 0.03을 넘는 순간이 있다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("정말 그렇게 생각하세요?");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
const headRollCell = channelCell(page, "headRoll");
|
||||
await expect
|
||||
.poll(async () => Math.abs(Number(await headRollCell.innerText())), { timeout: 4000, intervals: [50] })
|
||||
.toBeGreaterThan(0.03);
|
||||
});
|
||||
|
||||
test("평서문 발화 중 headPitch가 -0.05 아래로 내려가는 순간이 있다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("그냥요.");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
const headPitchCell = channelCell(page, "headPitch");
|
||||
await expect
|
||||
.poll(async () => Number(await headPitchCell.innerText()), { timeout: 4000, intervals: [50] })
|
||||
.toBeLessThan(-0.05);
|
||||
});
|
||||
|
||||
test("reduced motion에서는 발화 중 headYaw가 기본 자세에서 벗어나지 않는다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const headYawCell = channelCell(page, "headYaw");
|
||||
const baseline = Number(await headYawCell.innerText());
|
||||
|
||||
await page.locator('[data-testid="reduced-motion-toggle"]').click();
|
||||
await page.locator('[data-testid="speech-text"]').fill("정말 그렇게 생각하세요?");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
expect(Number(await headYawCell.innerText())).toBeCloseTo(baseline, 2);
|
||||
await page.waitForTimeout(800);
|
||||
expect(Number(await headYawCell.innerText())).toBeCloseTo(baseline, 2);
|
||||
});
|
||||
|
||||
/* 2단계-B — 리노컷 렌더러(§8.4). 기본 렌더러는 linocut(P1)이라 위 6개는 그대로
|
||||
통과한다(채널 미터·클립·지문 파서는 렌더러와 무관하게 동작한다). */
|
||||
|
||||
test("linocut 메인 루트가 ready가 되고 레이어 이미지 요청이 전부 200이다", async ({ page }) => {
|
||||
const imageStatuses: number[] = [];
|
||||
page.on("response", (res) => {
|
||||
if (/\/avatar\/v3\/p1\/.*\.webp$/.test(res.url())) imageStatuses.push(res.status());
|
||||
});
|
||||
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]');
|
||||
await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 });
|
||||
|
||||
expect(imageStatuses.length).toBeGreaterThanOrEqual(3);
|
||||
expect(imageStatuses.every((s) => s === 200)).toBe(true);
|
||||
});
|
||||
|
||||
test("joy 전환 후 mouth-line d가 바뀌고 grief에서 tear 부위가 보인다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]');
|
||||
await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 });
|
||||
|
||||
const mouthLine = mainSvg.locator('[data-part="mouth-line"]');
|
||||
const neutralD = await mouthLine.getAttribute("d");
|
||||
|
||||
await page.locator('[data-testid="expression-joy"]').click();
|
||||
const intensitySlider = page.locator(".al__intensity input[type='range']");
|
||||
await intensitySlider.focus();
|
||||
await intensitySlider.press("End");
|
||||
await expect.poll(async () => mouthLine.getAttribute("d"), { timeout: 2000 }).not.toBe(neutralD);
|
||||
|
||||
await page.locator('[data-testid="expression-grief"]').click();
|
||||
await expect
|
||||
.poll(async () => Number(await mainSvg.locator('[data-part="tear-left"]').getAttribute("opacity")), {
|
||||
timeout: 2000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("sad 선택 시 배경·모티프 그룹이 negative가 된다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]');
|
||||
await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 });
|
||||
|
||||
await page.locator('[data-testid="expression-sad"]').click();
|
||||
await expect(mainSvg).toHaveAttribute("data-backdrop-group", "negative");
|
||||
await expect(page.locator('[data-motif="bud-weather"]')).toHaveAttribute("data-motif-weather", "negative");
|
||||
});
|
||||
|
||||
test("피드백 모드에 따라 모티프 봉오리 개방도가 바뀐다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const motif = page.locator('[data-motif="bud-weather"]');
|
||||
const feedbackSelect = page.locator('[data-testid="feedback-mode"]');
|
||||
|
||||
await feedbackSelect.selectOption("immersive");
|
||||
await expect(motif).toHaveAttribute("data-motif-bud", "static");
|
||||
|
||||
await feedbackSelect.selectOption("coached");
|
||||
const opennessSlider = page.locator('[data-testid="openness-slider"]');
|
||||
await opennessSlider.focus();
|
||||
await opennessSlider.press("End");
|
||||
await expect(motif).toHaveAttribute("data-motif-bud", "1.00");
|
||||
});
|
||||
|
||||
test("공연을 연달아 재생하면 이전 공연의 미발동 cue가 취소된다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
await page.locator('[data-testid="pause-toggle"]').click();
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("(한숨) 첫 번째 문장이다");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
await page.waitForTimeout(150);
|
||||
const firstScheduled = await page.locator('[data-testid="scheduled-cues"] li').allTextContents();
|
||||
expect(firstScheduled.some((t) => t.includes("sigh"))).toBe(true);
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("(시선 피함) 두 번째는 다른 지문이다");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
await page.waitForTimeout(150);
|
||||
const secondScheduled = await page.locator('[data-testid="scheduled-cues"] li').allTextContents();
|
||||
|
||||
expect(secondScheduled.some((t) => t.includes("sigh"))).toBe(false);
|
||||
expect(secondScheduled.some((t) => t.includes("look_away_side"))).toBe(true);
|
||||
});
|
||||
|
||||
test("reduced motion에서도 linocut 루트는 ready를 유지하고 모티프 애니메이션이 멈춘다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]');
|
||||
await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 });
|
||||
|
||||
await page.locator('[data-testid="expression-sad"]').click();
|
||||
await expect(page.locator('[data-motif="bud-weather"]')).toHaveAttribute("data-motif-weather", "negative");
|
||||
|
||||
await page.locator('[data-testid="reduced-motion-toggle"]').click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
await expect(mainSvg).toHaveAttribute("data-load-state", "ready");
|
||||
const rainSprite = page.locator('[data-motif="bud-weather"] .linocut-motif__weather-negative').first();
|
||||
const animationName = await rainSprite.evaluate((el) => getComputedStyle(el).animationName);
|
||||
expect(animationName === "none" || animationName === "").toBe(true);
|
||||
});
|
||||
|
||||
test("sad + immersive는 봉오리가 숙이고, coached + 개방도 0.8 + sad는 곧게 선다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const motif = page.locator('[data-motif="bud-weather"]');
|
||||
const feedbackSelect = page.locator('[data-testid="feedback-mode"]');
|
||||
|
||||
await feedbackSelect.selectOption("immersive");
|
||||
await page.locator('[data-testid="expression-sad"]').click();
|
||||
await expect(motif).toHaveAttribute("data-motif-pose", "droop");
|
||||
|
||||
await feedbackSelect.selectOption("coached");
|
||||
const opennessSlider = page.locator('[data-testid="openness-slider"]');
|
||||
await opennessSlider.evaluate((el) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(el, "0.8");
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await expect(motif).toHaveAttribute("data-motif-pose", "upright");
|
||||
});
|
||||
});
|
||||
|
|
@ -17,6 +17,8 @@
|
|||
"check:dead-code": "knip --include files,dependencies,unlisted,unresolved,binaries --treat-config-hints-as-errors",
|
||||
"check:duplication": "jscpd src ../api/app --min-lines 8 --min-tokens 80 --ignore **/test_*.py,**/*_test.py,**/api.gen.ts,**/*.css,**/*.d.ts --threshold 0.05 --reporters console,threshold --no-tips",
|
||||
"generate:live2d-assets": "node scripts/generate-live2d-assets.mjs",
|
||||
"check:avatar-presets": "node scripts/check-avatar-presets.mjs",
|
||||
"check:avatar-lipsync": "node scripts/check-avatar-lipsync.mjs",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "npm run check:cosmetic-filter-safety && tsc -b",
|
||||
|
|
|
|||
BIN
apps/web/public/avatar/v3/p1/body.webp
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/web/public/avatar/v3/p1/face-detail.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/web/public/avatar/v3/p1/hair-front.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/web/public/avatar/v3/p1/head.webp
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
apps/web/public/avatar/v3/p1/jaw-detail.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/web/public/avatar/v3/p1/jaw-head.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/web/public/avatar/v3/p1/lip-lower.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/v3/p1/lip-shadow.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/v3/p1/lip-upper.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/bud-closed.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/bud-droop.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/bud-half.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/bud-open.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/weather-cognitive.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/weather-defensive.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/weather-energy.webp
Normal file
|
After Width: | Height: | Size: 7 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/weather-negative.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/weather-positive.webp
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
apps/web/public/avatar/v3/p1/paper-grain.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
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
|
|
@ -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}개, 지문 표본 검증 완료)`,
|
||||
);
|
||||
|
|
@ -50,6 +50,7 @@ const PendingApproval = lazy(() => import("./pages/PendingApproval"));
|
|||
const LearnerHome = lazy(() => import("./pages/LearnerHome"));
|
||||
const AvatarExpressionLab = lazy(() => import("./pages/AvatarExpressionLab"));
|
||||
const AvatarPreview = lazy(() => import("./pages/AvatarPreview"));
|
||||
const AvatarLab = lazy(() => import("./pages/AvatarLab"));
|
||||
const Session = lazy(() => import("./pages/Session"));
|
||||
const SessionReview = lazy(() => import("./pages/SessionReview"));
|
||||
const Professor = lazy(() => import("./pages/Professor"));
|
||||
|
|
@ -484,6 +485,9 @@ function AppRoutes() {
|
|||
{/* dev: 인증 없는 아바타 컴포지션 튜닝 페이지 (실서비스 아님) */}
|
||||
<Route path="/dev/avatar-preview" element={<AvatarPreview />} />
|
||||
|
||||
{/* dev: 인증 없는 아바타 v3 표현 엔진 Lab (실서비스 아님) */}
|
||||
<Route path="/dev/avatar-lab" element={<AvatarLab />} />
|
||||
|
||||
<Route
|
||||
path="/onboarding"
|
||||
element={
|
||||
|
|
|
|||
138
apps/web/src/components/avatar/engine/channels.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 채널 계약 — docs/decisions/avatar-expression-engine-v3.md §4 정본.
|
||||
신호 쪽은 채널을 모르고 cue만 만들고, 렌더러는 cue를 모르고 채널만 읽는다.
|
||||
채널을 추가·삭제할 때는 결정문 §4 표를 먼저 고친다.
|
||||
===================================================================== */
|
||||
|
||||
export interface ChannelSpec {
|
||||
min: number;
|
||||
max: number;
|
||||
/** 기본 자세 이전의 중립값. */
|
||||
neutral: number;
|
||||
/** 표정층 스프링 시간상수(ms). 0이면 평활 없이 즉시 적용. */
|
||||
tauMs: number;
|
||||
/** 대응 Live2D 파라미터 id. 표준에 없으면 custom 이름. */
|
||||
live2d: string;
|
||||
}
|
||||
|
||||
const HEAD = 350;
|
||||
const EYE = 120;
|
||||
const GAZE = 90;
|
||||
const MOUTH = 160;
|
||||
const OVERLAY = 600;
|
||||
|
||||
export const CHANNEL_SPECS = {
|
||||
headYaw: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamAngleX" },
|
||||
headPitch: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamAngleY" },
|
||||
headRoll: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamAngleZ" },
|
||||
bodyYaw: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamBodyAngleX" },
|
||||
bodyLean: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamBodyAngleY" },
|
||||
bodyRoll: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamBodyAngleZ" },
|
||||
shoulderRaise: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamShoulderY" },
|
||||
breath: { min: 0, max: 1, neutral: 0, tauMs: 0, live2d: "ParamBreath" },
|
||||
eyeOpenL: { min: 0, max: 1.3, neutral: 1, tauMs: EYE, live2d: "ParamEyeLOpen" },
|
||||
eyeOpenR: { min: 0, max: 1.3, neutral: 1, tauMs: EYE, live2d: "ParamEyeROpen" },
|
||||
eyeSmileL: { min: 0, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamEyeLSmile" },
|
||||
eyeSmileR: { min: 0, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamEyeRSmile" },
|
||||
lidTension: { min: 0, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamEyeTension" },
|
||||
gazeX: { min: -1, max: 1, neutral: 0, tauMs: GAZE, live2d: "ParamEyeBallX" },
|
||||
gazeY: { min: -1, max: 1, neutral: 0, tauMs: GAZE, live2d: "ParamEyeBallY" },
|
||||
pupilSize: { min: -1, max: 1, neutral: 0, tauMs: 300, live2d: "ParamEyeBallForm" },
|
||||
browLY: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowLY" },
|
||||
browRY: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowRY" },
|
||||
browLAngle: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowLAngle" },
|
||||
browRAngle: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowRAngle" },
|
||||
browLX: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowLX" },
|
||||
browRX: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowRX" },
|
||||
browForm: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowLForm" },
|
||||
mouthForm: { min: -1, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthForm" },
|
||||
mouthOpen: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthOpenY" },
|
||||
mouthPress: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthPress" },
|
||||
mouthAsym: { min: -1, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthAsym" },
|
||||
mouthStretch: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthStretch" },
|
||||
mouthRound: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthRound" },
|
||||
chinRaise: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamChinRaise" },
|
||||
cheek: { min: 0, max: 1, neutral: 0, tauMs: OVERLAY, live2d: "ParamCheek" },
|
||||
pallor: { min: 0, max: 1, neutral: 0, tauMs: OVERLAY, live2d: "ParamPallor" },
|
||||
tear: { min: 0, max: 1, neutral: 0, tauMs: OVERLAY, live2d: "ParamTear" },
|
||||
sweat: { min: 0, max: 1, neutral: 0, tauMs: OVERLAY, live2d: "ParamSweat" },
|
||||
} as const satisfies Record<string, ChannelSpec>;
|
||||
|
||||
export type ChannelId = keyof typeof CHANNEL_SPECS;
|
||||
export type ChannelVector = Record<ChannelId, number>;
|
||||
export type ChannelDelta = Partial<Record<ChannelId, number>>;
|
||||
|
||||
export const CHANNEL_IDS = Object.keys(CHANNEL_SPECS) as ChannelId[];
|
||||
|
||||
/** 강도에 따라 켜지는 오버레이 채널과 최소 강도(결정문 §5.2). */
|
||||
export const OVERLAY_MIN_INTENSITY: Partial<Record<ChannelId, number>> = {
|
||||
tear: 0.7,
|
||||
cheek: 0.5,
|
||||
pallor: 0.7,
|
||||
sweat: 0.7,
|
||||
};
|
||||
|
||||
/* ── 저작용 대칭 키: 좌우를 같은 값으로 펼친다 ─────────────────────── */
|
||||
const SYMMETRIC_KEYS = {
|
||||
eyeOpen: ["eyeOpenL", "eyeOpenR"],
|
||||
eyeSmile: ["eyeSmileL", "eyeSmileR"],
|
||||
browY: ["browLY", "browRY"],
|
||||
browAngle: ["browLAngle", "browRAngle"],
|
||||
browX: ["browLX", "browRX"],
|
||||
} as const satisfies Record<string, readonly [ChannelId, ChannelId]>;
|
||||
|
||||
export type SymmetricKey = keyof typeof SYMMETRIC_KEYS;
|
||||
export type AuthoringKey = ChannelId | SymmetricKey;
|
||||
export type AuthoringDelta = Partial<Record<AuthoringKey, number>>;
|
||||
|
||||
/** 대칭 키를 L/R 채널로 펼친다. 같은 채널을 직접 키와 대칭 키로 동시에 주면 직접 키가 이긴다. */
|
||||
export function expandAuthoring(delta: AuthoringDelta): ChannelDelta {
|
||||
const out: ChannelDelta = {};
|
||||
for (const [key, value] of Object.entries(delta) as Array<[AuthoringKey, number]>) {
|
||||
if (key in SYMMETRIC_KEYS) {
|
||||
for (const id of SYMMETRIC_KEYS[key as SymmetricKey]) {
|
||||
if (!(id in delta)) out[id] = value;
|
||||
}
|
||||
} else {
|
||||
out[key as ChannelId] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function neutralVector(): ChannelVector {
|
||||
const out = {} as ChannelVector;
|
||||
for (const id of CHANNEL_IDS) out[id] = CHANNEL_SPECS[id].neutral;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function clampChannel(id: ChannelId, value: number): number {
|
||||
const { min, max } = CHANNEL_SPECS[id];
|
||||
return value < min ? min : value > max ? max : value;
|
||||
}
|
||||
|
||||
/* ── 좌우 반전(미러) — 한쪽 방향성이 있는 클립을 반대로 재생할 때 ──── */
|
||||
const MIRROR_NEGATE: readonly ChannelId[] = ["headYaw", "headRoll", "bodyYaw", "bodyRoll", "gazeX", "mouthAsym"];
|
||||
const MIRROR_SWAP: ReadonlyArray<readonly [ChannelId, ChannelId]> = [
|
||||
["eyeOpenL", "eyeOpenR"],
|
||||
["eyeSmileL", "eyeSmileR"],
|
||||
["browLY", "browRY"],
|
||||
["browLAngle", "browRAngle"],
|
||||
["browLX", "browRX"],
|
||||
];
|
||||
|
||||
export function mirrorDelta(delta: ChannelDelta): ChannelDelta {
|
||||
const out: ChannelDelta = { ...delta };
|
||||
for (const id of MIRROR_NEGATE) {
|
||||
if (out[id] !== undefined) out[id] = -(out[id] as number);
|
||||
}
|
||||
for (const [l, r] of MIRROR_SWAP) {
|
||||
const lv = delta[l];
|
||||
const rv = delta[r];
|
||||
if (lv === undefined) delete out[r];
|
||||
else out[r] = lv;
|
||||
if (rv === undefined) delete out[l];
|
||||
else out[l] = rv;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
349
apps/web/src/components/avatar/engine/clipCatalog.ts
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 반응 클립 카탈로그 — docs/decisions/avatar-expression-engine-v3.md §6 정본 값.
|
||||
트랙 값은 표정층 결과에 더하는 delta다. 키프레임 [tMs, value, ease]에서 ease는
|
||||
직전 키프레임에서 이 키프레임으로 가는 구간의 곡선이며 "hold"는 계단(직전 값 유지 후 도달)이다.
|
||||
mirrorable 클립은 스케줄러가 시드 난수로 좌우 반전해 재생할 수 있다.
|
||||
키프레임 값 변경은 오케스트레이터 설계 결정이다.
|
||||
===================================================================== */
|
||||
|
||||
import { expandAuthoring, type AuthoringKey, type ChannelId } from "./channels";
|
||||
|
||||
export type ClipEase = "linear" | "in" | "out" | "inOut" | "hold";
|
||||
export type Keyframe = readonly [tMs: number, value: number, ease?: ClipEase];
|
||||
export type IdleSuppress = "blink" | "saccade" | "lipsync" | "sway";
|
||||
|
||||
export const REACTION_CLIP_IDS = [
|
||||
"nod_small",
|
||||
"nod_deep",
|
||||
"head_shake",
|
||||
"head_drop",
|
||||
"look_away_side",
|
||||
"look_down",
|
||||
"glance_up",
|
||||
"flinch",
|
||||
"freeze",
|
||||
"sigh",
|
||||
"deep_breath",
|
||||
"swallow",
|
||||
"lip_press",
|
||||
"lip_bite",
|
||||
"scoff",
|
||||
"eye_roll_small",
|
||||
"shrug",
|
||||
"lean_back",
|
||||
"lean_forward",
|
||||
"tear_up",
|
||||
"blink_flurry",
|
||||
"fidget_sway",
|
||||
"forced_smile",
|
||||
"nervous_laugh",
|
||||
"jaw_clench",
|
||||
"silence_hold",
|
||||
] as const;
|
||||
|
||||
export type ReactionClipId = (typeof REACTION_CLIP_IDS)[number];
|
||||
|
||||
interface ClipSpec {
|
||||
label: string;
|
||||
durationMs: number;
|
||||
fadeInMs: number;
|
||||
fadeOutMs: number;
|
||||
priority: 1 | 2 | 3;
|
||||
cooldownMs: number;
|
||||
mirrorable?: boolean;
|
||||
suppress?: readonly IdleSuppress[];
|
||||
tracks: Partial<Record<AuthoringKey, readonly Keyframe[]>>;
|
||||
}
|
||||
|
||||
export interface ReactionClip extends Omit<ClipSpec, "tracks"> {
|
||||
id: ReactionClipId;
|
||||
tracks: Partial<Record<ChannelId, readonly Keyframe[]>>;
|
||||
}
|
||||
|
||||
/** 시작 0 → peak 도달 → holdUntil까지 유지 → end에 0 복귀하는 사다리꼴 트랙. */
|
||||
function plateau(peak: number, riseMs: number, holdUntilMs: number, endMs: number, rise: ClipEase = "out"): readonly Keyframe[] {
|
||||
return [
|
||||
[0, 0],
|
||||
[riseMs, peak, rise],
|
||||
[holdUntilMs, peak, "hold"],
|
||||
[endMs, 0, "inOut"],
|
||||
];
|
||||
}
|
||||
|
||||
const CLIP_SPECS: Record<ReactionClipId, ClipSpec> = {
|
||||
nod_small: {
|
||||
label: "작게 끄덕",
|
||||
durationMs: 900, fadeInMs: 80, fadeOutMs: 150, priority: 1, cooldownMs: 1500,
|
||||
tracks: {
|
||||
headPitch: [[0, 0], [180, -0.15, "out"], [360, 0, "inOut"], [540, -0.12, "out"], [760, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
nod_deep: {
|
||||
label: "깊게 끄덕",
|
||||
durationMs: 1400, fadeInMs: 100, fadeOutMs: 200, priority: 2, cooldownMs: 3000,
|
||||
tracks: {
|
||||
headPitch: plateau(-0.35, 450, 800, 1300, "inOut"),
|
||||
eyeOpen: plateau(-0.2, 450, 800, 1300, "inOut"),
|
||||
},
|
||||
},
|
||||
head_shake: {
|
||||
label: "고개 젓기",
|
||||
durationMs: 1100, fadeInMs: 80, fadeOutMs: 150, priority: 2, cooldownMs: 2500,
|
||||
tracks: {
|
||||
headYaw: [[0, 0], [180, 0.2, "inOut"], [420, -0.2, "inOut"], [660, 0.14, "inOut"], [880, -0.06, "inOut"], [1100, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
head_drop: {
|
||||
label: "고개 떨굼",
|
||||
durationMs: 3200, fadeInMs: 150, fadeOutMs: 400, priority: 2, cooldownMs: 6000,
|
||||
suppress: ["saccade"],
|
||||
tracks: {
|
||||
headPitch: plateau(-0.45, 600, 2600, 3200),
|
||||
gazeY: plateau(-0.6, 400, 2700, 3200),
|
||||
shoulderRaise: plateau(-0.3, 700, 2600, 3200),
|
||||
eyeOpen: plateau(-0.2, 600, 2600, 3200),
|
||||
},
|
||||
},
|
||||
look_away_side: {
|
||||
label: "옆으로 시선 회피",
|
||||
durationMs: 2400, fadeInMs: 100, fadeOutMs: 300, priority: 1, cooldownMs: 4000, mirrorable: true,
|
||||
suppress: ["saccade"],
|
||||
tracks: {
|
||||
gazeX: plateau(0.7, 200, 2000, 2400),
|
||||
headYaw: plateau(0.15, 450, 2000, 2400),
|
||||
},
|
||||
},
|
||||
look_down: {
|
||||
label: "시선 내림",
|
||||
durationMs: 2200, fadeInMs: 100, fadeOutMs: 300, priority: 1, cooldownMs: 3000,
|
||||
suppress: ["saccade"],
|
||||
tracks: {
|
||||
gazeY: plateau(-0.7, 250, 1800, 2200),
|
||||
headPitch: plateau(-0.1, 400, 1800, 2200),
|
||||
eyeOpen: plateau(-0.25, 250, 1800, 2200),
|
||||
},
|
||||
},
|
||||
glance_up: {
|
||||
label: "힐끗 봄",
|
||||
durationMs: 1100, fadeInMs: 60, fadeOutMs: 200, priority: 1, cooldownMs: 3000,
|
||||
suppress: ["saccade"],
|
||||
tracks: {
|
||||
gazeY: plateau(0.35, 150, 650, 1100),
|
||||
eyeOpen: plateau(0.1, 150, 650, 1100),
|
||||
},
|
||||
},
|
||||
flinch: {
|
||||
label: "움찔",
|
||||
durationMs: 700, fadeInMs: 30, fadeOutMs: 250, priority: 3, cooldownMs: 2000,
|
||||
suppress: ["blink"],
|
||||
tracks: {
|
||||
headPitch: [[0, 0], [90, 0.15, "out"], [700, 0, "inOut"]],
|
||||
bodyLean: [[0, 0], [100, -0.25, "out"], [700, 0, "inOut"]],
|
||||
shoulderRaise: [[0, 0], [90, 0.5, "out"], [700, 0, "inOut"]],
|
||||
eyeOpen: [[0, 0], [60, -0.8, "out"], [160, 0.15, "out"], [700, 0, "inOut"]],
|
||||
browY: [[0, 0], [100, 0.3, "out"], [700, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
freeze: {
|
||||
label: "얼어붙음",
|
||||
durationMs: 1800, fadeInMs: 60, fadeOutMs: 400, priority: 3, cooldownMs: 4000,
|
||||
suppress: ["blink", "saccade", "sway"],
|
||||
tracks: {
|
||||
eyeOpen: plateau(0.25, 120, 1500, 1800),
|
||||
browY: plateau(0.35, 120, 1500, 1800),
|
||||
mouthOpen: plateau(0.15, 150, 1500, 1800),
|
||||
shoulderRaise: plateau(0.3, 150, 1500, 1800),
|
||||
},
|
||||
},
|
||||
sigh: {
|
||||
label: "한숨",
|
||||
durationMs: 2600, fadeInMs: 100, fadeOutMs: 300, priority: 2, cooldownMs: 8000,
|
||||
suppress: ["blink"],
|
||||
tracks: {
|
||||
shoulderRaise: [[0, 0], [800, 0.35, "inOut"], [1000, 0.35, "hold"], [2000, -0.3, "inOut"], [2600, 0, "inOut"]],
|
||||
headPitch: [[0, 0], [800, 0.08, "inOut"], [2000, -0.15, "inOut"], [2600, 0, "inOut"]],
|
||||
eyeOpen: [[0, 0], [900, 0, "hold"], [1500, -0.5, "inOut"], [2300, -0.1, "inOut"], [2600, 0, "inOut"]],
|
||||
mouthOpen: [[0, 0], [1000, 0, "hold"], [1300, 0.2, "out"], [1900, 0.05, "inOut"], [2200, 0, "inOut"]],
|
||||
bodyLean: [[0, 0], [800, 0.05, "inOut"], [2000, -0.1, "inOut"], [2600, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
deep_breath: {
|
||||
label: "심호흡",
|
||||
durationMs: 3600, fadeInMs: 150, fadeOutMs: 300, priority: 1, cooldownMs: 10000,
|
||||
tracks: {
|
||||
shoulderRaise: plateau(0.4, 1500, 1800, 3400, "inOut"),
|
||||
bodyLean: [[0, 0], [1500, 0.08, "inOut"], [3400, 0, "inOut"]],
|
||||
eyeOpen: [[0, 0], [1500, -0.3, "inOut"], [3000, -0.1, "inOut"], [3600, 0, "inOut"]],
|
||||
headPitch: [[0, 0], [1500, 0.1, "inOut"], [3400, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
swallow: {
|
||||
label: "침 삼킴",
|
||||
durationMs: 800, fadeInMs: 50, fadeOutMs: 150, priority: 1, cooldownMs: 3000,
|
||||
tracks: {
|
||||
chinRaise: [[0, 0], [200, 0.4, "out"], [450, 0, "inOut"]],
|
||||
mouthPress: [[0, 0], [150, 0.5, "out"], [500, 0.2, "inOut"], [800, 0, "inOut"]],
|
||||
headPitch: [[0, 0], [250, -0.05, "inOut"], [800, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
lip_press: {
|
||||
label: "입술 누름",
|
||||
durationMs: 1800, fadeInMs: 80, fadeOutMs: 250, priority: 1, cooldownMs: 3000,
|
||||
tracks: {
|
||||
mouthPress: plateau(0.7, 200, 1400, 1800),
|
||||
mouthForm: plateau(-0.15, 200, 1400, 1800),
|
||||
chinRaise: plateau(0.2, 200, 1400, 1800),
|
||||
},
|
||||
},
|
||||
lip_bite: {
|
||||
label: "입술 깨묾",
|
||||
durationMs: 1600, fadeInMs: 80, fadeOutMs: 250, priority: 1, cooldownMs: 4000, mirrorable: true,
|
||||
tracks: {
|
||||
mouthPress: plateau(0.6, 180, 1200, 1600),
|
||||
mouthAsym: plateau(0.35, 180, 1200, 1600),
|
||||
gazeY: plateau(-0.3, 250, 1200, 1600),
|
||||
},
|
||||
},
|
||||
scoff: {
|
||||
label: "피식·코웃음",
|
||||
durationMs: 1200, fadeInMs: 60, fadeOutMs: 250, priority: 2, cooldownMs: 4000, mirrorable: true,
|
||||
tracks: {
|
||||
mouthAsym: plateau(0.6, 150, 800, 1200),
|
||||
headYaw: plateau(0.1, 200, 800, 1200),
|
||||
gazeY: plateau(0.3, 200, 700, 1100),
|
||||
headPitch: [[0, 0], [120, 0.08, "out"], [600, 0, "inOut"]],
|
||||
mouthOpen: [[0, 0], [100, 0.08, "out"], [250, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
eye_roll_small: {
|
||||
label: "눈 굴림",
|
||||
durationMs: 1100, fadeInMs: 60, fadeOutMs: 200, priority: 2, cooldownMs: 6000, mirrorable: true,
|
||||
suppress: ["saccade"],
|
||||
tracks: {
|
||||
gazeY: [[0, 0], [250, 0.6, "out"], [550, 0.5, "inOut"], [900, 0.1, "inOut"], [1100, 0, "inOut"]],
|
||||
gazeX: [[0, 0], [250, 0.1, "out"], [550, 0.5, "inOut"], [900, 0.2, "inOut"], [1100, 0, "inOut"]],
|
||||
eyeOpen: [[0, 0], [250, -0.1, "out"], [1100, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
shrug: {
|
||||
label: "어깨 으쓱",
|
||||
durationMs: 1300, fadeInMs: 80, fadeOutMs: 200, priority: 1, cooldownMs: 3000, mirrorable: true,
|
||||
tracks: {
|
||||
shoulderRaise: plateau(0.6, 300, 700, 1300),
|
||||
browY: plateau(0.3, 300, 700, 1300),
|
||||
headRoll: [[0, 0], [300, 0.1, "out"], [1000, 0, "inOut"]],
|
||||
mouthForm: [[0, 0], [300, -0.2, "out"], [1000, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
lean_back: {
|
||||
label: "물러남",
|
||||
durationMs: 3000, fadeInMs: 150, fadeOutMs: 400, priority: 1, cooldownMs: 5000,
|
||||
tracks: {
|
||||
bodyLean: plateau(-0.4, 500, 2400, 3000),
|
||||
headPitch: plateau(0.08, 500, 2400, 3000),
|
||||
shoulderRaise: plateau(0.1, 500, 2400, 3000),
|
||||
},
|
||||
},
|
||||
lean_forward: {
|
||||
label: "다가옴",
|
||||
durationMs: 3000, fadeInMs: 150, fadeOutMs: 400, priority: 1, cooldownMs: 5000,
|
||||
tracks: {
|
||||
bodyLean: plateau(0.3, 600, 2400, 3000),
|
||||
eyeOpen: plateau(0.08, 600, 2400, 3000),
|
||||
browY: plateau(0.1, 600, 2400, 3000),
|
||||
},
|
||||
},
|
||||
tear_up: {
|
||||
label: "눈물 고임",
|
||||
durationMs: 4000, fadeInMs: 200, fadeOutMs: 800, priority: 2, cooldownMs: 12000,
|
||||
tracks: {
|
||||
tear: [[0, 0], [1500, 0.4, "inOut"], [3600, 0.4, "hold"], [4000, 0.25, "inOut"]],
|
||||
chinRaise: plateau(0.35, 800, 3000, 4000),
|
||||
browAngle: plateau(0.35, 800, 3000, 4000),
|
||||
eyeOpen: plateau(-0.15, 800, 3000, 4000),
|
||||
mouthPress: plateau(0.3, 800, 3000, 4000),
|
||||
},
|
||||
},
|
||||
blink_flurry: {
|
||||
label: "잦은 깜빡임",
|
||||
durationMs: 1200, fadeInMs: 0, fadeOutMs: 0, priority: 1, cooldownMs: 3000,
|
||||
suppress: ["blink"],
|
||||
tracks: {
|
||||
eyeOpen: [
|
||||
[0, 0], [60, -1, "in"], [130, 0, "out"],
|
||||
[330, -1, "in"], [400, 0, "out"],
|
||||
[620, -1, "in"], [690, 0, "out"],
|
||||
[900, -1, "in"], [970, 0, "out"],
|
||||
[1200, 0, "hold"],
|
||||
],
|
||||
},
|
||||
},
|
||||
fidget_sway: {
|
||||
label: "몸 흔들림",
|
||||
durationMs: 2400, fadeInMs: 150, fadeOutMs: 300, priority: 1, cooldownMs: 5000, mirrorable: true,
|
||||
suppress: ["sway"],
|
||||
tracks: {
|
||||
bodyRoll: [[0, 0], [400, 0.12, "inOut"], [900, -0.1, "inOut"], [1400, 0.08, "inOut"], [1900, -0.05, "inOut"], [2400, 0, "inOut"]],
|
||||
headRoll: [[0, 0], [450, -0.06, "inOut"], [950, 0.05, "inOut"], [1500, -0.03, "inOut"], [2400, 0, "inOut"]],
|
||||
gazeX: [[0, 0], [500, 0.3, "inOut"], [1100, -0.2, "inOut"], [1700, 0.1, "inOut"], [2400, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
forced_smile: {
|
||||
label: "억지 미소",
|
||||
durationMs: 2200, fadeInMs: 100, fadeOutMs: 300, priority: 2, cooldownMs: 3000,
|
||||
tracks: {
|
||||
mouthForm: plateau(0.4, 250, 1800, 2200),
|
||||
mouthPress: plateau(0.2, 250, 1800, 2200),
|
||||
browAngle: plateau(0.15, 250, 1800, 2200),
|
||||
},
|
||||
},
|
||||
nervous_laugh: {
|
||||
label: "어색한 웃음",
|
||||
durationMs: 1400, fadeInMs: 60, fadeOutMs: 250, priority: 2, cooldownMs: 5000, mirrorable: true,
|
||||
tracks: {
|
||||
mouthForm: [[0, 0], [150, 0.45, "out"], [900, 0.3, "inOut"], [1400, 0, "inOut"]],
|
||||
mouthOpen: [[0, 0], [150, 0.25, "out"], [300, 0.1, "inOut"], [450, 0.25, "out"], [600, 0.1, "inOut"], [900, 0, "inOut"]],
|
||||
eyeSmile: [[0, 0], [150, 0.3, "out"], [900, 0.1, "inOut"], [1400, 0, "inOut"]],
|
||||
gazeX: plateau(0.4, 200, 1100, 1400),
|
||||
headPitch: [[0, 0], [150, -0.08, "out"], [300, 0, "inOut"], [450, -0.08, "out"], [600, 0, "inOut"]],
|
||||
shoulderRaise: [[0, 0], [150, 0.15, "out"], [600, 0.15, "hold"], [1000, 0, "inOut"]],
|
||||
browAngle: plateau(0.2, 150, 900, 1400),
|
||||
},
|
||||
},
|
||||
jaw_clench: {
|
||||
label: "이 악묾",
|
||||
durationMs: 2000, fadeInMs: 80, fadeOutMs: 300, priority: 2, cooldownMs: 4000,
|
||||
tracks: {
|
||||
mouthPress: plateau(0.8, 200, 1600, 2000),
|
||||
browX: plateau(-0.4, 200, 1600, 2000),
|
||||
browY: plateau(-0.2, 200, 1600, 2000),
|
||||
lidTension: plateau(0.4, 200, 1600, 2000),
|
||||
headPitch: plateau(-0.05, 200, 1600, 2000),
|
||||
},
|
||||
},
|
||||
silence_hold: {
|
||||
label: "침묵 유지",
|
||||
durationMs: 4000, fadeInMs: 200, fadeOutMs: 400, priority: 2, cooldownMs: 6000,
|
||||
suppress: ["blink", "saccade", "sway", "lipsync"],
|
||||
tracks: {
|
||||
gazeY: plateau(-0.55, 500, 3500, 4000),
|
||||
headPitch: plateau(-0.12, 500, 3500, 4000),
|
||||
eyeOpen: [[0, 0], [500, -0.2, "out"], [1800, -0.2, "hold"], [2000, -0.9, "inOut"], [2250, -0.2, "inOut"], [3500, -0.2, "hold"], [4000, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function expandTracks(tracks: ClipSpec["tracks"]): ReactionClip["tracks"] {
|
||||
const out: Partial<Record<ChannelId, readonly Keyframe[]>> = {};
|
||||
for (const [key, frames] of Object.entries(tracks) as Array<[AuthoringKey, readonly Keyframe[]]>) {
|
||||
for (const id of Object.keys(expandAuthoring({ [key]: 1 })) as ChannelId[]) out[id] = frames;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const REACTION_CLIPS: Record<ReactionClipId, ReactionClip> = Object.fromEntries(
|
||||
REACTION_CLIP_IDS.map((id) => {
|
||||
const { tracks, ...rest } = CLIP_SPECS[id];
|
||||
return [id, { id, ...rest, tracks: expandTracks(tracks) }];
|
||||
}),
|
||||
) as Record<ReactionClipId, ReactionClip>;
|
||||
78
apps/web/src/components/avatar/engine/clipPlayer.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 반응 클립 재생기 — 결정문 §6.1 키프레임 보간·엔벨로프·합성.
|
||||
순수 함수뿐이다. 시간은 인자로만 받는다.
|
||||
===================================================================== */
|
||||
|
||||
import { mirrorDelta, type ChannelDelta, type ChannelId } from "./channels";
|
||||
import type { ClipEase, Keyframe, ReactionClip } from "./clipCatalog";
|
||||
|
||||
function ease(kind: ClipEase, t: number): number {
|
||||
switch (kind) {
|
||||
case "in":
|
||||
return t * t;
|
||||
case "out":
|
||||
return 1 - (1 - t) * (1 - t);
|
||||
case "inOut":
|
||||
return 3 * t * t - 2 * t * t * t;
|
||||
case "hold":
|
||||
return t >= 1 ? 1 : 0;
|
||||
case "linear":
|
||||
default:
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
function sampleTrack(frames: readonly Keyframe[], tMs: number): number {
|
||||
const first = frames[0];
|
||||
if (tMs <= first[0]) return first[1];
|
||||
const last = frames[frames.length - 1];
|
||||
if (tMs >= last[0]) return last[1];
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
const [t1, v1, arrivalEase] = frames[i];
|
||||
if (tMs > t1) continue;
|
||||
const [t0, v0] = frames[i - 1];
|
||||
const span = t1 - t0;
|
||||
const localT = span <= 0 ? 1 : (tMs - t0) / span;
|
||||
const eased = ease(arrivalEase ?? "linear", localT);
|
||||
return v0 + (v1 - v0) * eased;
|
||||
}
|
||||
return last[1];
|
||||
}
|
||||
|
||||
/** 클립의 tMs 시점 채널 delta(가산값). t가 구간 밖이면 빈 delta. */
|
||||
export function sampleClip(clip: ReactionClip, tMs: number): ChannelDelta {
|
||||
if (tMs < 0 || tMs > clip.durationMs) return {};
|
||||
const out: ChannelDelta = {};
|
||||
for (const id of Object.keys(clip.tracks) as ChannelId[]) {
|
||||
const frames = clip.tracks[id];
|
||||
if (!frames || frames.length === 0) continue;
|
||||
out[id] = sampleTrack(frames, tMs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** fadeIn→hold→fadeOut 엔벨로프(0..1). 구간 밖은 0. */
|
||||
export function envelope(clip: ReactionClip, tMs: number): number {
|
||||
if (tMs < 0 || tMs > clip.durationMs) return 0;
|
||||
const { fadeInMs, fadeOutMs, durationMs } = clip;
|
||||
if (fadeInMs > 0 && tMs < fadeInMs) return tMs / fadeInMs;
|
||||
const fadeOutStart = durationMs - fadeOutMs;
|
||||
if (fadeOutMs > 0 && tMs > fadeOutStart) return (durationMs - tMs) / fadeOutMs;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export interface ResolveClipOptions {
|
||||
weight?: number;
|
||||
mirror?: boolean;
|
||||
}
|
||||
|
||||
/** sampleClip × envelope × weight, mirror면 좌우 반전까지 적용한 최종 delta. */
|
||||
export function resolveClipDelta(clip: ReactionClip, tMs: number, opts: ResolveClipOptions = {}): ChannelDelta {
|
||||
const raw = sampleClip(clip, tMs);
|
||||
const scale = envelope(clip, tMs) * (opts.weight ?? 1);
|
||||
const out: ChannelDelta = {};
|
||||
for (const id of Object.keys(raw) as ChannelId[]) {
|
||||
out[id] = (raw[id] as number) * scale;
|
||||
}
|
||||
return opts.mirror ? mirrorDelta(out) : out;
|
||||
}
|
||||
452
apps/web/src/components/avatar/engine/coSpeech.ts
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 발화 동반층 — 결정문 §5.5 정본. 말할 때 머리·몸·눈썹·시선·깜빡임을
|
||||
만드는 순수 모듈이다. 발화 타임라인(§5.4, lipsync.ts)의 구·모음 목표·강세·휴지를
|
||||
입력으로 시드 난수 1회로 계획(CoSpeechPlan)을 만들고, 매 프레임 그 계획을
|
||||
표본해 채널 delta와 깜빡임 요청을 낸다. 렌더러·DOM과 무관하다.
|
||||
스프링(진입·이탈 시 매끄럽게 잇기)은 여기 두지 않는다 — engine.ts가 이 결과를
|
||||
목표값으로 스프링한다(발화층 lipsync 패턴과 동일, §5.4 "표본·평활" 주석 참고).
|
||||
===================================================================== */
|
||||
|
||||
import type { ChannelDelta } from "./channels";
|
||||
import { createRng } from "./rng";
|
||||
import { sampleEnvelope, type SpeechEnvelope } from "./speechEnvelope";
|
||||
import type { SpeechPauseWindow, SpeechPhraseInfo, SpeechTimeline } from "./lipsync";
|
||||
|
||||
/** demeanor.speech에서 발화 동반층이 쓰는 부분만 뽑은 것(순환 참조 방지 — demeanorDefaults.ts를 import하지 않는다). */
|
||||
export interface CoSpeechStyle {
|
||||
/** 모든 크기(깜빡임 제외)에 곱하는 배율(§5.5). */
|
||||
gesture: number;
|
||||
/** 구를 시작할 때 시선을 피할 확률. */
|
||||
gazeAvertProb: number;
|
||||
}
|
||||
|
||||
interface Breakpoint {
|
||||
t: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
interface StressPulseSpec {
|
||||
peakMs: number;
|
||||
riseMs: number;
|
||||
fallMs: number;
|
||||
headPitch: number;
|
||||
bodyLean: number;
|
||||
shoulderRaise: number;
|
||||
}
|
||||
|
||||
interface PhraseEndCosineSpec {
|
||||
kind: "cosine";
|
||||
peakMs: number;
|
||||
riseMs: number;
|
||||
fallMs: number;
|
||||
headPitch: number;
|
||||
headRoll: number;
|
||||
}
|
||||
|
||||
interface PhraseEndTrapezoidSpec {
|
||||
kind: "trapezoid";
|
||||
breakpoints: Breakpoint[];
|
||||
headPitch: number;
|
||||
headRoll: number;
|
||||
}
|
||||
|
||||
type PhraseEndSpec = PhraseEndCosineSpec | PhraseEndTrapezoidSpec;
|
||||
|
||||
interface BreathSpec {
|
||||
breakpoints: Breakpoint[];
|
||||
shoulderRaise: number;
|
||||
bodyLean: number;
|
||||
}
|
||||
|
||||
interface BrowSpec {
|
||||
breakpoints: Breakpoint[];
|
||||
browLY: number;
|
||||
browRY: number;
|
||||
}
|
||||
|
||||
interface GazeSpec {
|
||||
breakpoints: Breakpoint[];
|
||||
gazeX: number;
|
||||
gazeY: number;
|
||||
}
|
||||
|
||||
export interface CoSpeechPlan {
|
||||
totalDurationMs: number;
|
||||
/** 구 표류(§5.5) — 발화 전체를 잇는 연속 곡선. 스프레드 순서: [진입 전 0, 각 구 진입·유지, 마지막 구 뒤 700ms 복귀]. */
|
||||
driftYaw: Breakpoint[];
|
||||
driftRoll: Breakpoint[];
|
||||
driftPitch: Breakpoint[];
|
||||
/** 강세 박(§5.5) — 올림 코사인 펄스 목록. */
|
||||
stressPulses: StressPulseSpec[];
|
||||
/** 구 끝(§5.5) — 평서는 코사인 끄덕임, 질문은 사다리꼴 들림. 이어짐은 항목 없음. */
|
||||
phraseEndPulses: PhraseEndSpec[];
|
||||
/** 들숨(§5.5). */
|
||||
breathPulses: BreathSpec[];
|
||||
/** 눈썹 박(§5.5). */
|
||||
browPulses: BrowSpec[];
|
||||
/** 시선 회피(§5.5). */
|
||||
gazePulses: GazeSpec[];
|
||||
/** 깜빡임 요청 시각(발화 시작 기준 ms, 확률 필터링 완료, 오름차순). */
|
||||
blinkRequests: number[];
|
||||
}
|
||||
|
||||
export interface CoSpeechSample {
|
||||
delta: ChannelDelta;
|
||||
blinkNow?: boolean;
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
return v < 0 ? 0 : v > 1 ? 1 : v;
|
||||
}
|
||||
|
||||
function smoothstep(u: number): number {
|
||||
const c = clamp01(u);
|
||||
return c * c * (3 - 2 * c);
|
||||
}
|
||||
|
||||
/** 올림 코사인 b(t)(§5.5): 정점 전 riseMs 동안 0→1, 정점 뒤 fallMs 동안 1→0. */
|
||||
function raisedCosine(t: number, peakMs: number, riseMs: number, fallMs: number): number {
|
||||
if (t < peakMs) {
|
||||
if (riseMs <= 0) return 0;
|
||||
const u = (t - (peakMs - riseMs)) / riseMs;
|
||||
if (u <= 0) return 0;
|
||||
if (u >= 1) return 1;
|
||||
return 0.5 * (1 - Math.cos(Math.PI * u));
|
||||
}
|
||||
if (fallMs <= 0) return t === peakMs ? 1 : 0;
|
||||
const u = (t - peakMs) / fallMs;
|
||||
if (u >= 1) return 0;
|
||||
return 0.5 * (1 + Math.cos(Math.PI * u));
|
||||
}
|
||||
|
||||
/** breakpoints를 smoothstep으로 잇는 구간별 표본(구 표류·사다리꼴 펄스 공용). 구간 밖은 양끝 값으로 고정된다. */
|
||||
function sampleBreakpoints(bps: readonly Breakpoint[], t: number): number {
|
||||
if (bps.length === 0) return 0;
|
||||
if (t <= bps[0].t) return bps[0].v;
|
||||
const lastBp = bps[bps.length - 1];
|
||||
if (t >= lastBp.t) return lastBp.v;
|
||||
let i = 0;
|
||||
while (i < bps.length - 1 && bps[i + 1].t <= t) i++;
|
||||
const a = bps[i];
|
||||
const b = bps[i + 1];
|
||||
const span = b.t - a.t;
|
||||
const u = span > 0 ? (t - a.t) / span : 1;
|
||||
return a.v + (b.v - a.v) * smoothstep(u);
|
||||
}
|
||||
|
||||
/** 뒤 breakpoint의 t가 앞보다 앞서지 않게 고정한다(§5.5 "구마다 새로 뽑아" 곡선을 이어붙일 때,
|
||||
아주 짧은 구가 연달아 나와 램프 구간이 겹치는 드문 경우의 근사 — 작업 보고 참고). */
|
||||
function pushBp(arr: Breakpoint[], t: number, v: number): void {
|
||||
const last = arr[arr.length - 1];
|
||||
arr.push({ t: last ? Math.max(t, last.t) : t, v });
|
||||
}
|
||||
|
||||
interface PerPhraseDraw {
|
||||
yawSign: number;
|
||||
yawMag: number;
|
||||
rollSign: number;
|
||||
rollMag: number;
|
||||
pitchVal: number;
|
||||
browOn: boolean;
|
||||
gazeOn: boolean;
|
||||
gazeHoldMs: number;
|
||||
}
|
||||
|
||||
const DRIFT_RAMP_LEAD_MS = 100;
|
||||
const DRIFT_RAMP_MS = 450;
|
||||
const DRIFT_RELEASE_MS = 700;
|
||||
|
||||
function buildDrift(
|
||||
phrases: readonly SpeechPhraseInfo[],
|
||||
draws: readonly PerPhraseDraw[],
|
||||
g: number,
|
||||
): { yaw: Breakpoint[]; roll: Breakpoint[]; pitch: Breakpoint[] } {
|
||||
const yaw: Breakpoint[] = [];
|
||||
const roll: Breakpoint[] = [];
|
||||
const pitch: Breakpoint[] = [];
|
||||
let lastYaw = 0;
|
||||
let lastRoll = 0;
|
||||
let lastPitch = 0;
|
||||
let lastEndMs: number | null = null;
|
||||
|
||||
phrases.forEach((ph, i) => {
|
||||
if (ph.syllableTargetsMs.length === 0) return;
|
||||
const draw = draws[i];
|
||||
const rampStart = ph.startMs - DRIFT_RAMP_LEAD_MS;
|
||||
const rampEnd = rampStart + DRIFT_RAMP_MS;
|
||||
const targetYaw = draw.yawSign * draw.yawMag * g;
|
||||
const targetRoll = draw.rollSign * draw.rollMag * g;
|
||||
const targetPitch = draw.pitchVal * g;
|
||||
|
||||
pushBp(yaw, rampStart, lastYaw);
|
||||
pushBp(yaw, rampEnd, targetYaw);
|
||||
pushBp(roll, rampStart, lastRoll);
|
||||
pushBp(roll, rampEnd, targetRoll);
|
||||
pushBp(pitch, rampStart, lastPitch);
|
||||
pushBp(pitch, rampEnd, targetPitch);
|
||||
|
||||
lastYaw = targetYaw;
|
||||
lastRoll = targetRoll;
|
||||
lastPitch = targetPitch;
|
||||
lastEndMs = ph.endMs;
|
||||
});
|
||||
|
||||
if (lastEndMs !== null) {
|
||||
pushBp(yaw, lastEndMs, lastYaw);
|
||||
pushBp(yaw, lastEndMs + DRIFT_RELEASE_MS, 0);
|
||||
pushBp(roll, lastEndMs, lastRoll);
|
||||
pushBp(roll, lastEndMs + DRIFT_RELEASE_MS, 0);
|
||||
pushBp(pitch, lastEndMs, lastPitch);
|
||||
pushBp(pitch, lastEndMs + DRIFT_RELEASE_MS, 0);
|
||||
}
|
||||
|
||||
return { yaw, roll, pitch };
|
||||
}
|
||||
|
||||
function buildStressPulses(phrases: readonly SpeechPhraseInfo[], g: number): StressPulseSpec[] {
|
||||
const out: StressPulseSpec[] = [];
|
||||
for (const ph of phrases) {
|
||||
const mul = (ph.kind === "exclaim" ? 1.4 : 1) * g;
|
||||
for (const idx of ph.stressedIndices) {
|
||||
const vowelMs = ph.syllableTargetsMs[idx];
|
||||
if (vowelMs === undefined) continue;
|
||||
out.push({
|
||||
peakMs: vowelMs - 80,
|
||||
riseMs: 120,
|
||||
fallMs: 260,
|
||||
headPitch: -0.25 * mul,
|
||||
bodyLean: 0.12 * mul,
|
||||
shoulderRaise: 0.08 * mul,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildPhraseEndPulses(phrases: readonly SpeechPhraseInfo[], draws: readonly PerPhraseDraw[], g: number): PhraseEndSpec[] {
|
||||
const out: PhraseEndSpec[] = [];
|
||||
phrases.forEach((ph, i) => {
|
||||
const n = ph.syllableTargetsMs.length;
|
||||
if (n === 0) return;
|
||||
const lastMs = ph.syllableTargetsMs[n - 1];
|
||||
if (ph.kind === "statement") {
|
||||
out.push({ kind: "cosine", peakMs: lastMs, riseMs: 150, fallMs: 400, headPitch: -0.3 * g, headRoll: 0 });
|
||||
return;
|
||||
}
|
||||
if (ph.kind === "question") {
|
||||
/* "마지막 두 음절에 걸쳐" — 1음절 구는 음절 하나 분량(150ms)을 앞당겨 근사한다. */
|
||||
const riseStart = n >= 2 ? ph.syllableTargetsMs[n - 2] : lastMs - 150;
|
||||
const holdEnd = ph.endMs + 300;
|
||||
const releaseEnd = holdEnd + 400;
|
||||
out.push({
|
||||
kind: "trapezoid",
|
||||
breakpoints: [
|
||||
{ t: riseStart, v: 0 },
|
||||
{ t: lastMs, v: 1 },
|
||||
{ t: holdEnd, v: 1 },
|
||||
{ t: releaseEnd, v: 0 },
|
||||
],
|
||||
headPitch: 0.12 * g,
|
||||
headRoll: 0.15 * g * draws[i].rollSign,
|
||||
});
|
||||
}
|
||||
/* 이어짐(",")은 구 끝 동작이 없다(§5.5). */
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const BREATH_LEAD_MS = 250;
|
||||
const BREATH_PAUSE_MIN_MS = 200;
|
||||
const BREATH_EXHALE_FRACTION = 0.4;
|
||||
|
||||
function buildBreathPulses(phrases: readonly SpeechPhraseInfo[], g: number): BreathSpec[] {
|
||||
const out: BreathSpec[] = [];
|
||||
phrases.forEach((ph, i) => {
|
||||
if (ph.syllableTargetsMs.length === 0) return;
|
||||
const prev = phrases[i - 1];
|
||||
const gapMs = i === 0 ? Infinity : ph.startMs - (prev?.endMs ?? ph.startMs);
|
||||
if (i !== 0 && gapMs < BREATH_PAUSE_MIN_MS) return;
|
||||
const dur = Math.max(1, ph.endMs - ph.startMs);
|
||||
out.push({
|
||||
breakpoints: [
|
||||
{ t: ph.startMs - BREATH_LEAD_MS, v: 0 },
|
||||
{ t: ph.startMs, v: 1 },
|
||||
{ t: ph.startMs + dur * BREATH_EXHALE_FRACTION, v: 0 },
|
||||
],
|
||||
shoulderRaise: 0.25 * g,
|
||||
bodyLean: -0.05 * g,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildBrowPulses(phrases: readonly SpeechPhraseInfo[], draws: readonly PerPhraseDraw[], g: number): BrowSpec[] {
|
||||
const out: BrowSpec[] = [];
|
||||
phrases.forEach((ph, i) => {
|
||||
if (!draws[i].browOn) return;
|
||||
const firstStressIdx = ph.stressedIndices[0];
|
||||
const targetMs = firstStressIdx === undefined ? undefined : ph.syllableTargetsMs[firstStressIdx];
|
||||
if (targetMs === undefined) return;
|
||||
out.push({
|
||||
breakpoints: [
|
||||
{ t: targetMs - 100, v: 0 },
|
||||
{ t: targetMs, v: 1 },
|
||||
{ t: targetMs + 150, v: 1 },
|
||||
{ t: targetMs + 400, v: 0 },
|
||||
],
|
||||
browLY: 0.15 * g,
|
||||
browRY: 0.15 * g,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const GAZE_AVERT_TAIL_MS = 400;
|
||||
|
||||
function buildGazePulses(
|
||||
phrases: readonly SpeechPhraseInfo[],
|
||||
draws: readonly PerPhraseDraw[],
|
||||
g: number,
|
||||
totalDurationMs: number,
|
||||
): GazeSpec[] {
|
||||
const out: GazeSpec[] = [];
|
||||
phrases.forEach((ph, i) => {
|
||||
const draw = draws[i];
|
||||
if (!draw.gazeOn) return;
|
||||
if (ph.syllableTargetsMs.length === 0) return;
|
||||
if (ph.startMs >= totalDurationMs - GAZE_AVERT_TAIL_MS) return;
|
||||
out.push({
|
||||
breakpoints: [
|
||||
{ t: ph.startMs, v: 0 },
|
||||
{ t: ph.startMs + 150, v: 1 },
|
||||
{ t: ph.startMs + 150 + draw.gazeHoldMs, v: 1 },
|
||||
{ t: ph.startMs + 150 + draw.gazeHoldMs + 250, v: 0 },
|
||||
],
|
||||
gazeX: 0.25 * draw.yawSign * g,
|
||||
gazeY: -0.15 * g,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const BLINK_PAUSE_MIN_MS = 250;
|
||||
const BLINK_REQUEST_OFFSET_MS = 50;
|
||||
const BLINK_PROB = 0.6;
|
||||
|
||||
function buildBlinkRequests(pauses: readonly SpeechPauseWindow[], rng: ReturnType<typeof createRng>): number[] {
|
||||
const out: number[] = [];
|
||||
for (const pause of pauses) {
|
||||
if (pause.endMs - pause.startMs < BLINK_PAUSE_MIN_MS) continue;
|
||||
if (rng.next() < BLINK_PROB) out.push(pause.startMs + BLINK_REQUEST_OFFSET_MS);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발화 타임라인(§5.4)과 시드로 발화 동반 계획을 한 번 만든다(§5.5).
|
||||
* rng 소비 순서(같은 시드 재현성의 근거): 구마다 순서대로
|
||||
* [yaw 크기, roll 크기, pitch 값, (첫 구만) yaw 부호, roll 부호, 눈썹 박 확률, 시선 회피 확률,
|
||||
* (시선 회피 발동 시) 유지 시간] → 이후 휴지 구간마다 순서대로 [깜빡임 확률].
|
||||
*/
|
||||
export function buildCoSpeechPlan(timeline: SpeechTimeline, style: CoSpeechStyle, seed: number): CoSpeechPlan {
|
||||
const rng = createRng(seed);
|
||||
const g = style.gesture;
|
||||
const phrases = timeline.phrases;
|
||||
|
||||
let prevYawSign = 1;
|
||||
const draws: PerPhraseDraw[] = phrases.map((ph, i) => {
|
||||
const yawMag = rng.range(0.2, 0.4);
|
||||
const rollMag = rng.range(0.1, 0.2);
|
||||
const pitchVal = rng.range(-0.1, 0.05);
|
||||
const yawSign = i === 0 ? (rng.next() < 0.5 ? -1 : 1) : -prevYawSign;
|
||||
prevYawSign = yawSign;
|
||||
const rollSign = rng.next() < 0.5 ? -1 : 1;
|
||||
const browOn = ph.kind === "question" ? true : rng.next() < 0.5;
|
||||
const gazeOn = rng.next() < style.gazeAvertProb;
|
||||
const gazeHoldMs = gazeOn ? rng.range(500, 900) : 0;
|
||||
return { yawSign, yawMag, rollSign, rollMag, pitchVal, browOn, gazeOn, gazeHoldMs };
|
||||
});
|
||||
|
||||
const drift = buildDrift(phrases, draws, g);
|
||||
|
||||
return {
|
||||
totalDurationMs: timeline.totalDurationMs,
|
||||
driftYaw: drift.yaw,
|
||||
driftRoll: drift.roll,
|
||||
driftPitch: drift.pitch,
|
||||
stressPulses: buildStressPulses(phrases, g),
|
||||
phraseEndPulses: buildPhraseEndPulses(phrases, draws, g),
|
||||
breathPulses: buildBreathPulses(phrases, g),
|
||||
browPulses: buildBrowPulses(phrases, draws, g),
|
||||
gazePulses: buildGazePulses(phrases, draws, g, timeline.totalDurationMs),
|
||||
blinkRequests: buildBlinkRequests(timeline.pauses, rng),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획을 tMs(발화 시작 기준 ms) 시점으로 표본한다. prevTMs는 직전 프레임의 같은 기준
|
||||
* 시각이며, (prevTMs, tMs] 구간을 지난 깜빡임 요청이 있으면 blinkNow를 세운다(§5.5).
|
||||
* 음량 들썩임(§5.5)은 envelope가 있을 때만 shoulderRaise에 더한다.
|
||||
*/
|
||||
export function sampleCoSpeech(plan: CoSpeechPlan, tMs: number, prevTMs: number, envelope?: SpeechEnvelope): CoSpeechSample {
|
||||
let headPitch = sampleBreakpoints(plan.driftPitch, tMs);
|
||||
let headRoll = sampleBreakpoints(plan.driftRoll, tMs);
|
||||
const headYaw = sampleBreakpoints(plan.driftYaw, tMs);
|
||||
let bodyLean = 0;
|
||||
let shoulderRaise = 0;
|
||||
let browLY = 0;
|
||||
let browRY = 0;
|
||||
let gazeX = 0;
|
||||
let gazeY = 0;
|
||||
|
||||
for (const p of plan.stressPulses) {
|
||||
const b = raisedCosine(tMs, p.peakMs, p.riseMs, p.fallMs);
|
||||
if (b === 0) continue;
|
||||
headPitch += p.headPitch * b;
|
||||
bodyLean += p.bodyLean * b;
|
||||
shoulderRaise += p.shoulderRaise * b;
|
||||
}
|
||||
|
||||
for (const p of plan.phraseEndPulses) {
|
||||
const b = p.kind === "cosine" ? raisedCosine(tMs, p.peakMs, p.riseMs, p.fallMs) : sampleBreakpoints(p.breakpoints, tMs);
|
||||
if (b === 0) continue;
|
||||
headPitch += p.headPitch * b;
|
||||
headRoll += p.headRoll * b;
|
||||
}
|
||||
|
||||
for (const p of plan.breathPulses) {
|
||||
const b = sampleBreakpoints(p.breakpoints, tMs);
|
||||
if (b === 0) continue;
|
||||
shoulderRaise += p.shoulderRaise * b;
|
||||
bodyLean += p.bodyLean * b;
|
||||
}
|
||||
|
||||
if (envelope) {
|
||||
const p90 = envelope.p90 || 1e-6;
|
||||
const env = sampleEnvelope(envelope, tMs);
|
||||
shoulderRaise += 0.1 * Math.max(0, env / p90 - 0.5);
|
||||
}
|
||||
|
||||
for (const p of plan.browPulses) {
|
||||
const b = sampleBreakpoints(p.breakpoints, tMs);
|
||||
if (b === 0) continue;
|
||||
browLY += p.browLY * b;
|
||||
browRY += p.browRY * b;
|
||||
}
|
||||
|
||||
for (const p of plan.gazePulses) {
|
||||
const b = sampleBreakpoints(p.breakpoints, tMs);
|
||||
if (b === 0) continue;
|
||||
gazeX += p.gazeX * b;
|
||||
gazeY += p.gazeY * b;
|
||||
}
|
||||
|
||||
const delta: ChannelDelta = { headYaw, headPitch, headRoll, bodyLean, shoulderRaise, browLY, browRY, gazeX, gazeY };
|
||||
const blinkNow = plan.blinkRequests.some((e) => e > prevTMs && e <= tMs);
|
||||
return blinkNow ? { delta, blinkNow: true } : { delta };
|
||||
}
|
||||
|
||||
/** 표시용: tMs 시점에 강세 박이 활성인지(rise~fall 구간 안인지). Lab 채널 표 옆 표시에 쓴다. */
|
||||
export function isStressPulseActive(plan: CoSpeechPlan, tMs: number): boolean {
|
||||
return plan.stressPulses.some((p) => tMs >= p.peakMs - p.riseMs && tMs <= p.peakMs + p.fallMs);
|
||||
}
|
||||
198
apps/web/src/components/avatar/engine/demeanorDefaults.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 페르소나 성향(demeanor) 기본값 — 결정문 §7.2 ⑤ / §8.3.
|
||||
기본 자세·표현 증폭·깜빡임·시선·idle 클립 빈도를 캐릭터 카드 설정
|
||||
(data/personas/P*.json, apps/api/app/services/persona.py SEED_PERSONAS)에서 읽어 정했다.
|
||||
4단계에서 캐릭터 카드 avatar.demeanor 블록으로 옮긴다. 값 변경은 설계 결정이다.
|
||||
===================================================================== */
|
||||
|
||||
import type { AvatarState } from "../persona";
|
||||
import { expandAuthoring, type AuthoringDelta, type ChannelDelta } from "./channels";
|
||||
import type { ReactionClipId } from "./clipCatalog";
|
||||
|
||||
export interface IdleClipRule {
|
||||
clip: ReactionClipId;
|
||||
/** 다음 발동까지 간격 범위(ms). 시드 난수로 고른다. */
|
||||
everyMs: readonly [number, number];
|
||||
/** 이 상태에서만 발동. 생략하면 idle·listening 모두. speaking·thinking에는 idle 클립을 쓰지 않는다. */
|
||||
when?: Extract<AvatarState, "idle" | "listening">;
|
||||
/** 개방도가 이 값 미만일 때만 발동. 생략하면 제한 없음. */
|
||||
maxOpenness?: number;
|
||||
}
|
||||
|
||||
export interface Demeanor {
|
||||
basePose: ChannelDelta;
|
||||
/** 표정 delta 증폭. 억누르는 캐릭터는 1보다 작다. */
|
||||
expressivity: number;
|
||||
blinkIntervalMs: readonly [number, number];
|
||||
blinkDurationMs: number;
|
||||
saccadeIntervalMs: readonly [number, number];
|
||||
/** 시선 미세 이동 크기(gaze 채널 단위). */
|
||||
saccadeAmplitude: number;
|
||||
/** 호흡 외 미세 몸 흔들림 크기(0..1 배율). */
|
||||
swayAmplitude: number;
|
||||
idleClips: readonly IdleClipRule[];
|
||||
speech: SpeechStyle;
|
||||
}
|
||||
|
||||
/** 발화층(립싱크) 성향 — 결정문 §5.4. */
|
||||
export interface SpeechStyle {
|
||||
/** 조음 폭: 발화 입 모양(open·wide·round)에 곱한다. 웅얼거리는 캐릭터는 작다. */
|
||||
articulation: number;
|
||||
/** 오디오가 없을 때 텍스트만으로 타이밍을 만들 때의 발화 속도(음절/초). 한국어 대화체 평균 약 5.9. */
|
||||
syllablesPerSec: number;
|
||||
/** 발화 동반 움직임(고개 표류·강세 박·들숨·눈썹 박, 결정문 §5.5) 크기 배율. 위축된 캐릭터는 작다. */
|
||||
gesture: number;
|
||||
/** 구를 시작할 때 시선을 피할 확률(생각하며 말하기). */
|
||||
gazeAvertProb: number;
|
||||
}
|
||||
|
||||
function demeanor(spec: Omit<Demeanor, "basePose"> & { basePose: AuthoringDelta }): Demeanor {
|
||||
return { ...spec, basePose: expandAuthoring(spec.basePose) };
|
||||
}
|
||||
|
||||
/* P1 서연 — 고2, 우울·자살사고, 비자발 내방. N 0.85·E 0.25, "감정 억누르고 무덤덤한 척", 불면. */
|
||||
const P1 = demeanor({
|
||||
basePose: { headPitch: -0.12, shoulderRaise: -0.2, eyeOpen: -0.12, gazeY: -0.15, bodyLean: -0.05 },
|
||||
/* 절제는 강도 곡선(낮은 강도 억제)과 기본 자세가 맡는다. 0.75는 강도 1에서도 표정이 안 읽혀 0.9로 올렸다(2단계-B 소유자 관찰). */
|
||||
expressivity: 0.9,
|
||||
blinkIntervalMs: [2600, 5200],
|
||||
blinkDurationMs: 150,
|
||||
saccadeIntervalMs: [3000, 7000],
|
||||
saccadeAmplitude: 0.12,
|
||||
swayAmplitude: 0.3,
|
||||
idleClips: [
|
||||
{ clip: "sigh", everyMs: [25000, 45000], maxOpenness: 0.5 },
|
||||
{ clip: "look_down", everyMs: [9000, 16000], when: "listening", maxOpenness: 0.6 },
|
||||
{ clip: "look_away_side", everyMs: [12000, 20000], when: "listening", maxOpenness: 0.4 },
|
||||
{ clip: "lip_press", everyMs: [15000, 30000] },
|
||||
],
|
||||
/* 작게 웅얼거리고 느리다(무덤덤한 척·에너지 낮음). */
|
||||
speech: { articulation: 0.6, syllablesPerSec: 5.0, gesture: 0.55, gazeAvertProb: 0.7 },
|
||||
});
|
||||
|
||||
/* P2 민재 — 32세 직장인, 범불안·신체화, 논리적·장황. */
|
||||
const P2 = demeanor({
|
||||
basePose: { shoulderRaise: 0.2, lidTension: 0.15, browAngle: 0.1 },
|
||||
expressivity: 0.85,
|
||||
blinkIntervalMs: [1800, 3600],
|
||||
blinkDurationMs: 110,
|
||||
saccadeIntervalMs: [1800, 4200],
|
||||
saccadeAmplitude: 0.18,
|
||||
swayAmplitude: 0.4,
|
||||
idleClips: [
|
||||
{ clip: "swallow", everyMs: [10000, 18000] },
|
||||
{ clip: "blink_flurry", everyMs: [14000, 26000], maxOpenness: 0.6 },
|
||||
{ clip: "deep_breath", everyMs: [30000, 50000] },
|
||||
],
|
||||
/* 장황하고 빠르다. */
|
||||
speech: { articulation: 0.85, syllablesPerSec: 6.4, gesture: 0.9, gazeAvertProb: 0.5 },
|
||||
});
|
||||
|
||||
/* P3 지우 — 28세 미혼모, 역할 부담, 따뜻하지만 지친 톤. */
|
||||
const P3 = demeanor({
|
||||
basePose: { eyeOpen: -0.15, eyeSmile: 0.08, shoulderRaise: -0.15 },
|
||||
expressivity: 0.9,
|
||||
blinkIntervalMs: [2200, 4600],
|
||||
blinkDurationMs: 140,
|
||||
saccadeIntervalMs: [2600, 5600],
|
||||
saccadeAmplitude: 0.14,
|
||||
swayAmplitude: 0.35,
|
||||
idleClips: [
|
||||
{ clip: "deep_breath", everyMs: [25000, 40000] },
|
||||
{ clip: "sigh", everyMs: [30000, 50000], maxOpenness: 0.6 },
|
||||
{ clip: "look_down", everyMs: [12000, 20000], when: "listening" },
|
||||
],
|
||||
/* 따뜻하지만 지친 톤. */
|
||||
speech: { articulation: 0.8, syllablesPerSec: 5.6, gesture: 0.7, gazeAvertProb: 0.45 },
|
||||
});
|
||||
|
||||
/* P4 하늘 — 고2, 시험불안·완벽주의, 또박또박·자기검열. */
|
||||
const P4 = demeanor({
|
||||
basePose: { shoulderRaise: 0.15, lidTension: 0.15, eyeOpen: 0.05 },
|
||||
expressivity: 1.0,
|
||||
blinkIntervalMs: [1400, 3000],
|
||||
blinkDurationMs: 100,
|
||||
saccadeIntervalMs: [1500, 3500],
|
||||
saccadeAmplitude: 0.2,
|
||||
swayAmplitude: 0.5,
|
||||
idleClips: [
|
||||
{ clip: "blink_flurry", everyMs: [9000, 16000], maxOpenness: 0.6 },
|
||||
{ clip: "fidget_sway", everyMs: [12000, 22000] },
|
||||
{ clip: "swallow", everyMs: [14000, 24000] },
|
||||
{ clip: "lip_bite", everyMs: [18000, 30000], maxOpenness: 0.5 },
|
||||
],
|
||||
/* 또박또박. */
|
||||
speech: { articulation: 0.95, syllablesPerSec: 6.2, gesture: 0.65, gazeAvertProb: 0.55 },
|
||||
});
|
||||
|
||||
/* P5 도윤 — 중3, 또래관계 소외, 무던한 척·반존대. */
|
||||
const P5 = demeanor({
|
||||
basePose: { bodyLean: -0.1, gazeX: 0.12, headPitch: -0.05 },
|
||||
expressivity: 0.7,
|
||||
blinkIntervalMs: [2400, 4800],
|
||||
blinkDurationMs: 120,
|
||||
saccadeIntervalMs: [2500, 5500],
|
||||
saccadeAmplitude: 0.15,
|
||||
swayAmplitude: 0.35,
|
||||
idleClips: [
|
||||
{ clip: "look_away_side", everyMs: [8000, 14000], when: "listening", maxOpenness: 0.6 },
|
||||
{ clip: "shrug", everyMs: [20000, 35000] },
|
||||
{ clip: "look_down", everyMs: [15000, 25000] },
|
||||
],
|
||||
/* 무던한 척, 입을 크게 안 벌린다. */
|
||||
speech: { articulation: 0.65, syllablesPerSec: 5.8, gesture: 0.6, gazeAvertProb: 0.6 },
|
||||
});
|
||||
|
||||
/* P6 하린 — 고3, 진로갈등, 똑부러지지만 지친 톤. */
|
||||
const P6 = demeanor({
|
||||
basePose: { eyeOpen: -0.08 },
|
||||
expressivity: 0.9,
|
||||
blinkIntervalMs: [2000, 4200],
|
||||
blinkDurationMs: 120,
|
||||
saccadeIntervalMs: [2200, 5000],
|
||||
saccadeAmplitude: 0.14,
|
||||
swayAmplitude: 0.3,
|
||||
idleClips: [
|
||||
{ clip: "lip_press", everyMs: [12000, 22000] },
|
||||
{ clip: "deep_breath", everyMs: [25000, 40000] },
|
||||
{ clip: "look_away_side", everyMs: [16000, 28000], maxOpenness: 0.5 },
|
||||
],
|
||||
speech: { articulation: 0.85, syllablesPerSec: 6.0, gesture: 0.75, gazeAvertProb: 0.45 },
|
||||
});
|
||||
|
||||
/* P7 도현 — 고3, 입시 번아웃, 무덤덤·자조. */
|
||||
const P7 = demeanor({
|
||||
basePose: { eyeOpen: -0.25, headPitch: -0.18, shoulderRaise: -0.3, headRoll: 0.05 },
|
||||
expressivity: 0.65,
|
||||
blinkIntervalMs: [3000, 6000],
|
||||
blinkDurationMs: 190,
|
||||
saccadeIntervalMs: [3500, 8000],
|
||||
saccadeAmplitude: 0.1,
|
||||
swayAmplitude: 0.2,
|
||||
idleClips: [
|
||||
{ clip: "sigh", everyMs: [20000, 35000], maxOpenness: 0.6 },
|
||||
{ clip: "look_down", everyMs: [10000, 18000] },
|
||||
{ clip: "scoff", everyMs: [30000, 50000], maxOpenness: 0.4 },
|
||||
],
|
||||
/* 무덤덤·자조, 가장 작게 웅얼거린다. */
|
||||
speech: { articulation: 0.55, syllablesPerSec: 5.2, gesture: 0.4, gazeAvertProb: 0.65 },
|
||||
});
|
||||
|
||||
/* 성향 정의가 없는 페르소나(스튜디오 신규 등)의 기본값. */
|
||||
export const DEFAULT_DEMEANOR = demeanor({
|
||||
basePose: {},
|
||||
expressivity: 0.9,
|
||||
blinkIntervalMs: [2200, 4800],
|
||||
blinkDurationMs: 120,
|
||||
saccadeIntervalMs: [2500, 6000],
|
||||
saccadeAmplitude: 0.14,
|
||||
swayAmplitude: 0.35,
|
||||
idleClips: [{ clip: "look_down", everyMs: [15000, 25000], when: "listening" }],
|
||||
speech: { articulation: 0.8, syllablesPerSec: 5.9, gesture: 0.75, gazeAvertProb: 0.5 },
|
||||
});
|
||||
|
||||
const DEMEANORS: Record<string, Demeanor> = { P1, P2, P3, P4, P5, P6, P7 };
|
||||
|
||||
export function demeanorFor(personaCode: string | null | undefined): Demeanor {
|
||||
return (personaCode && DEMEANORS[personaCode.toUpperCase()]) || DEFAULT_DEMEANOR;
|
||||
}
|
||||
596
apps/web/src/components/avatar/engine/engine.ts
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 표현 엔진 — 결정문 §3 구조·§5 표정층·§6 반응층·§7 공연 스크립트.
|
||||
렌더러와 무관한 순수 상태머신이다. 시계(nowMs)와 난수(rng)는 모두 주입되며
|
||||
내부에서 Date/performance.now/Math.random을 직접 호출하지 않는다.
|
||||
렌더러는 evaluate(nowMs)가 돌려주는 채널 벡터만 읽는다.
|
||||
===================================================================== */
|
||||
|
||||
import {
|
||||
CHANNEL_IDS,
|
||||
CHANNEL_SPECS,
|
||||
OVERLAY_MIN_INTENSITY,
|
||||
clampChannel,
|
||||
neutralVector,
|
||||
type ChannelDelta,
|
||||
type ChannelId,
|
||||
type ChannelVector,
|
||||
} from "./channels";
|
||||
import { EXPRESSION_PRESETS, intensityGain } from "./expressionPresets";
|
||||
import { REACTION_CLIPS, type IdleSuppress, type ReactionClipId } from "./clipCatalog";
|
||||
import { resolveClipDelta } from "./clipPlayer";
|
||||
import type { Demeanor } from "./demeanorDefaults";
|
||||
import { createRng, type Rng } from "./rng";
|
||||
import { SILENT_SHAPE, type SpeechShape } from "./lipsync";
|
||||
import type { AvatarExpression, AvatarState } from "../persona";
|
||||
import type { Performance } from "./performance";
|
||||
|
||||
export interface AvatarEngineOptions {
|
||||
demeanor: Demeanor;
|
||||
seed: number;
|
||||
reducedMotion?: boolean;
|
||||
}
|
||||
|
||||
export interface TriggerClipOptions {
|
||||
weight?: number;
|
||||
mirror?: boolean;
|
||||
}
|
||||
|
||||
export interface PerformanceTimeline {
|
||||
speechStartMs: number;
|
||||
speechDurationMs: number;
|
||||
}
|
||||
|
||||
export interface DebugActiveClip {
|
||||
id: ReactionClipId;
|
||||
startedMs: number;
|
||||
weight: number;
|
||||
mirror: boolean;
|
||||
}
|
||||
|
||||
export interface DebugScheduledCue {
|
||||
clip: ReactionClipId;
|
||||
atMs: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface DebugSnapshot {
|
||||
activeClips: DebugActiveClip[];
|
||||
scheduled: DebugScheduledCue[];
|
||||
surface: { expression: AvatarExpression; intensity: number };
|
||||
leakActive: boolean;
|
||||
}
|
||||
|
||||
export interface AvatarEngine {
|
||||
setState(state: AvatarState, nowMs: number): void;
|
||||
setOpenness(openness: number): void;
|
||||
setSurface(expression: AvatarExpression, intensity: number): void;
|
||||
/** 발화층(§5.4) 목표 입 모양. null이면 목표가 X(닫힘)로 스프링 수렴한다. */
|
||||
setSpeechShape(shape: SpeechShape | null): void;
|
||||
/** 발화 동반층(§5.5) 목표 채널 delta(coSpeech.ts sampleCoSpeech 결과). speaking 중이면
|
||||
그대로 통과시킨다(강세 박 타이밍을 지키기 위해 스프링을 걸지 않는다). speaking이 아니거나
|
||||
null이면 그 값에서 τ150ms 스프링으로 0으로 되돌아간다. reduced motion이면 적용하지 않는다. */
|
||||
setSpeechMotion(delta: ChannelDelta | null): void;
|
||||
/** 발화 동반 깜빡임 요청(§5.5) — idle 깜빡임과 같은 경로로 지금 재생하고 idle 타이머를 다시 잡는다. */
|
||||
requestSpeechBlink(nowMs: number): void;
|
||||
triggerClip(id: ReactionClipId, nowMs: number, opts?: TriggerClipOptions): boolean;
|
||||
playPerformance(perf: Performance, timeline: PerformanceTimeline, nowMs: number): void;
|
||||
evaluate(nowMs: number): ChannelVector;
|
||||
debugSnapshot(): DebugSnapshot;
|
||||
}
|
||||
|
||||
interface ActiveClipEntry {
|
||||
clipId: ReactionClipId;
|
||||
startedMs: number;
|
||||
weight: number;
|
||||
mirror: boolean;
|
||||
priority: 1 | 2 | 3;
|
||||
evictAt?: number;
|
||||
}
|
||||
|
||||
interface ScheduledCue {
|
||||
clip: ReactionClipId;
|
||||
atMs: number;
|
||||
weight: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface LeakState {
|
||||
expression: AvatarExpression;
|
||||
intensity: number;
|
||||
startAbsMs: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
interface IdleClipState {
|
||||
nextAtMs: number;
|
||||
}
|
||||
|
||||
interface BlinkState {
|
||||
nextAt: number;
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
interface SaccadeState {
|
||||
nextAt: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
curX: number;
|
||||
curY: number;
|
||||
}
|
||||
|
||||
const EVICT_FADE_MS = 150;
|
||||
|
||||
/* ── 발화층 스프링(결정문 §5.4 "임계 감쇠 스프링 τ 45ms") ──────────────────
|
||||
표정층 입 스프링(160ms, channels.ts)과 별도로 여기서만 쓴다. sampleSpeech가
|
||||
돌려주는 목표값(SpeechShape)을 매 프레임 이 스프링으로 평활해 채널에 합성한다. */
|
||||
const SPEECH_SPRING_TAU_MS = 45;
|
||||
|
||||
/* ── 발화 동반층 스프링(결정문 §5.5 "발화가 끝나면 delta가 튀지 않게... 짧은 스프링으로
|
||||
되돌린다") ── coSpeech.ts의 순수 표본은 진입·이탈 스프링을 갖지 않으므로, speaking이 아니거나
|
||||
reduced motion이면 목표를 0으로 두고 여기서 짧게 수렴시킨다. */
|
||||
const CO_SPEECH_SPRING_TAU_MS = 150;
|
||||
const CO_SPEECH_CHANNELS: readonly ChannelId[] = [
|
||||
"headYaw",
|
||||
"headPitch",
|
||||
"headRoll",
|
||||
"bodyLean",
|
||||
"shoulderRaise",
|
||||
"browLY",
|
||||
"browRY",
|
||||
"gazeX",
|
||||
"gazeY",
|
||||
];
|
||||
|
||||
interface SpringState {
|
||||
value: number;
|
||||
velocity: number;
|
||||
}
|
||||
|
||||
function zeroSpring(): SpringState {
|
||||
return { value: 0, velocity: 0 };
|
||||
}
|
||||
|
||||
/** 임계 감쇠 2차 시스템의 정확해. omega = 1/tauMs. */
|
||||
function springTowards(state: SpringState, target: number, dt: number, tauMs: number): SpringState {
|
||||
if (tauMs <= 0 || dt <= 0) return { value: target, velocity: 0 };
|
||||
const omega = 1 / tauMs;
|
||||
const diff = state.value - target;
|
||||
const temp = (state.velocity + omega * diff) * dt;
|
||||
const decay = Math.exp(-omega * dt);
|
||||
return {
|
||||
value: target + (diff + temp) * decay,
|
||||
velocity: (state.velocity - omega * temp) * decay,
|
||||
};
|
||||
}
|
||||
|
||||
function filteredPresetDelta(expression: AvatarExpression, intensity: number, demeanor: Demeanor): ChannelDelta {
|
||||
const preset = EXPRESSION_PRESETS[expression];
|
||||
const gain = intensityGain(intensity) * demeanor.expressivity;
|
||||
const out: ChannelDelta = {};
|
||||
for (const id of Object.keys(preset) as ChannelId[]) {
|
||||
const threshold = OVERLAY_MIN_INTENSITY[id];
|
||||
if (threshold !== undefined && intensity < threshold) continue;
|
||||
out[id] = (preset[id] as number) * gain;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function blendDelta(a: ChannelDelta, b: ChannelDelta, w: number): ChannelDelta {
|
||||
const out: ChannelDelta = { ...a };
|
||||
for (const id of CHANNEL_IDS) {
|
||||
const av = a[id] ?? 0;
|
||||
const bv = b[id] ?? 0;
|
||||
const v = av * (1 - w) + bv * w;
|
||||
if (v !== 0 || av !== 0 || bv !== 0) out[id] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createAvatarEngine(options: AvatarEngineOptions): AvatarEngine {
|
||||
const { demeanor } = options;
|
||||
const reducedMotion = options.reducedMotion ?? false;
|
||||
const rng: Rng = createRng(options.seed);
|
||||
|
||||
let currentState: AvatarState = "idle";
|
||||
let openness = 0;
|
||||
let surfaceExpr: AvatarExpression = "neutral";
|
||||
let surfaceIntensity = 0;
|
||||
let pendingSpeechShape: SpeechShape | null = null;
|
||||
let speechSpring = { open: zeroSpring(), wide: zeroSpring(), round: zeroSpring(), press: zeroSpring() };
|
||||
|
||||
let pendingSpeechMotion: ChannelDelta | null = null;
|
||||
const coSpeechSpring: Partial<Record<ChannelId, SpringState>> = {};
|
||||
for (const id of CO_SPEECH_CHANNELS) coSpeechSpring[id] = zeroSpring();
|
||||
|
||||
let leak: LeakState | null = null;
|
||||
|
||||
const baseVector: ChannelVector = (() => {
|
||||
const out = neutralVector();
|
||||
for (const id of Object.keys(demeanor.basePose) as ChannelId[]) {
|
||||
out[id] += demeanor.basePose[id] as number;
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
let smoothed: ChannelVector | null = null;
|
||||
let lastEvalMs: number | null = null;
|
||||
|
||||
let activeClips: ActiveClipEntry[] = [];
|
||||
const cooldownMap = new Map<ReactionClipId, number>();
|
||||
let scheduledCues: ScheduledCue[] = [];
|
||||
|
||||
let idleClipStates: IdleClipState[] | null = null;
|
||||
let blinkState: BlinkState | null = null;
|
||||
let saccadeState: SaccadeState | null = null;
|
||||
|
||||
function leakWeight(nowMs: number): number {
|
||||
if (!leak) return 0;
|
||||
const t = nowMs - leak.startAbsMs;
|
||||
if (t < 0 || t > leak.durationMs) return 0;
|
||||
const fade = Math.min(60, leak.durationMs / 2);
|
||||
if (fade <= 0) return 1;
|
||||
if (t < fade) return t / fade;
|
||||
if (t > leak.durationMs - fade) return (leak.durationMs - t) / fade;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function activeSuppressions(nowMs: number): Set<IdleSuppress> {
|
||||
const out = new Set<IdleSuppress>();
|
||||
for (const entry of activeClips) {
|
||||
const clip = REACTION_CLIPS[entry.clipId];
|
||||
const t = nowMs - entry.startedMs;
|
||||
if (t < 0 || t > clip.durationMs) continue;
|
||||
for (const s of clip.suppress ?? []) out.add(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function triggerClip(id: ReactionClipId, nowMs: number, opts: TriggerClipOptions = {}): boolean {
|
||||
if (reducedMotion) return false;
|
||||
const clip = REACTION_CLIPS[id];
|
||||
const last = cooldownMap.get(id);
|
||||
if (last !== undefined && nowMs - last < clip.cooldownMs) return false;
|
||||
cooldownMap.set(id, nowMs);
|
||||
|
||||
let mirror = opts.mirror ?? false;
|
||||
if (clip.mirrorable && opts.mirror === undefined) {
|
||||
mirror = rng.next() < 0.5;
|
||||
}
|
||||
|
||||
const nonEvicting = activeClips.filter((e) => e.evictAt === undefined);
|
||||
if (nonEvicting.length >= 2) {
|
||||
let victim = nonEvicting[0];
|
||||
for (const e of nonEvicting) {
|
||||
const vClip = REACTION_CLIPS[victim.clipId];
|
||||
const eClip = REACTION_CLIPS[e.clipId];
|
||||
if (eClip.priority < vClip.priority || (eClip.priority === vClip.priority && e.startedMs < victim.startedMs)) {
|
||||
victim = e;
|
||||
}
|
||||
}
|
||||
victim.evictAt = nowMs;
|
||||
}
|
||||
|
||||
activeClips.push({
|
||||
clipId: id,
|
||||
startedMs: nowMs,
|
||||
weight: opts.weight ?? 1,
|
||||
mirror,
|
||||
priority: clip.priority,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function setState(state: AvatarState, nowMs: number): void {
|
||||
const prev = currentState;
|
||||
currentState = state;
|
||||
if (idleClipStates && (state === "idle" || state === "listening") && prev !== state) {
|
||||
demeanor.idleClips.forEach((rule, i) => {
|
||||
if (idleClipStates![i].nextAtMs < nowMs) {
|
||||
idleClipStates![i] = { nextAtMs: nowMs + rng.range(rule.everyMs[0], rule.everyMs[1]) };
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setOpenness(o: number): void {
|
||||
openness = o;
|
||||
}
|
||||
|
||||
function setSurface(expression: AvatarExpression, intensity: number): void {
|
||||
surfaceExpr = expression;
|
||||
surfaceIntensity = intensity;
|
||||
}
|
||||
|
||||
function setSpeechShape(shape: SpeechShape | null): void {
|
||||
pendingSpeechShape = shape;
|
||||
}
|
||||
|
||||
function setSpeechMotion(delta: ChannelDelta | null): void {
|
||||
pendingSpeechMotion = delta;
|
||||
}
|
||||
|
||||
function requestSpeechBlink(nowMs: number): void {
|
||||
if (reducedMotion) return;
|
||||
if (blinkState === null) blinkState = { nextAt: nowMs, startedAt: null };
|
||||
/* 지금 깜빡임을 재생한다. 자연 완료 시 applyBlink가 다음 idle 타이머를 다시 뽑으므로
|
||||
(§5.5 "idle 깜빡임 타이머를 다시 잡는다") 여기서 nextAt을 따로 계산하지 않는다. */
|
||||
blinkState.startedAt = nowMs;
|
||||
}
|
||||
|
||||
function playPerformance(perf: Performance, timeline: PerformanceTimeline, nowMs: number): void {
|
||||
scheduledCues = [];
|
||||
setSurface(perf.surface.expression, perf.surface.intensity);
|
||||
leak = perf.leak
|
||||
? {
|
||||
expression: perf.leak.expression,
|
||||
intensity: perf.leak.intensity,
|
||||
startAbsMs: timeline.speechStartMs + perf.leak.atMs,
|
||||
durationMs: perf.leak.durationMs,
|
||||
}
|
||||
: null;
|
||||
|
||||
for (const cue of perf.cues) {
|
||||
let atAbs: number;
|
||||
switch (cue.anchor) {
|
||||
case "pre":
|
||||
atAbs = timeline.speechStartMs + cue.at;
|
||||
break;
|
||||
case "post":
|
||||
atAbs = timeline.speechStartMs + timeline.speechDurationMs + cue.at;
|
||||
break;
|
||||
case "inline":
|
||||
atAbs = timeline.speechStartMs + timeline.speechDurationMs * cue.at;
|
||||
break;
|
||||
case "listen":
|
||||
default:
|
||||
atAbs = nowMs + cue.at;
|
||||
break;
|
||||
}
|
||||
scheduledCues.push({ clip: cue.clip, atMs: atAbs, weight: cue.weight ?? 1, source: cue.source });
|
||||
}
|
||||
}
|
||||
|
||||
function computeExpressionTarget(nowMs: number): ChannelVector {
|
||||
const surfaceDelta = filteredPresetDelta(surfaceExpr, surfaceIntensity, demeanor);
|
||||
const w = leakWeight(nowMs);
|
||||
const delta = w > 0 && leak ? blendDelta(surfaceDelta, filteredPresetDelta(leak.expression, leak.intensity, demeanor), w) : surfaceDelta;
|
||||
|
||||
const out: ChannelVector = { ...baseVector };
|
||||
for (const id of Object.keys(delta) as ChannelId[]) {
|
||||
out[id] += delta[id] as number;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function updateSmoothing(nowMs: number): void {
|
||||
const target = computeExpressionTarget(nowMs);
|
||||
if (smoothed === null || reducedMotion) {
|
||||
smoothed = target;
|
||||
return;
|
||||
}
|
||||
const dt = Math.max(0, nowMs - (lastEvalMs ?? nowMs));
|
||||
const next = {} as ChannelVector;
|
||||
for (const id of CHANNEL_IDS) {
|
||||
const tau = CHANNEL_SPECS[id].tauMs;
|
||||
if (tau <= 0) {
|
||||
next[id] = target[id];
|
||||
continue;
|
||||
}
|
||||
const alpha = 1 - Math.exp(-dt / tau);
|
||||
next[id] = smoothed[id] + (target[id] - smoothed[id]) * alpha;
|
||||
}
|
||||
smoothed = next;
|
||||
}
|
||||
|
||||
function processScheduledCues(nowMs: number): void {
|
||||
const remaining: ScheduledCue[] = [];
|
||||
for (const sc of scheduledCues) {
|
||||
if (nowMs >= sc.atMs) {
|
||||
triggerClip(sc.clip, nowMs, { weight: sc.weight });
|
||||
} else {
|
||||
remaining.push(sc);
|
||||
}
|
||||
}
|
||||
scheduledCues = remaining;
|
||||
}
|
||||
|
||||
function reactionLayerDelta(nowMs: number): ChannelDelta {
|
||||
const out: ChannelDelta = {};
|
||||
for (const entry of activeClips) {
|
||||
const clip = REACTION_CLIPS[entry.clipId];
|
||||
const t = nowMs - entry.startedMs;
|
||||
if (t < 0 || t > clip.durationMs) continue;
|
||||
let mult = 1;
|
||||
if (entry.evictAt !== undefined) {
|
||||
const et = nowMs - entry.evictAt;
|
||||
mult = et >= EVICT_FADE_MS ? 0 : 1 - et / EVICT_FADE_MS;
|
||||
}
|
||||
const delta = resolveClipDelta(clip, t, { weight: entry.weight * mult, mirror: entry.mirror });
|
||||
for (const id of Object.keys(delta) as ChannelId[]) {
|
||||
out[id] = (out[id] ?? 0) + (delta[id] as number);
|
||||
}
|
||||
}
|
||||
|
||||
activeClips = activeClips.filter((entry) => {
|
||||
const clip = REACTION_CLIPS[entry.clipId];
|
||||
const t = nowMs - entry.startedMs;
|
||||
if (entry.evictAt !== undefined && nowMs - entry.evictAt >= EVICT_FADE_MS) return false;
|
||||
if (t > clip.durationMs) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function processIdleClips(nowMs: number): void {
|
||||
if (currentState !== "idle" && currentState !== "listening") return;
|
||||
if (idleClipStates === null) {
|
||||
idleClipStates = demeanor.idleClips.map((rule) => ({
|
||||
nextAtMs: nowMs + rng.range(rule.everyMs[0], rule.everyMs[1]),
|
||||
}));
|
||||
}
|
||||
demeanor.idleClips.forEach((rule, i) => {
|
||||
const st = idleClipStates![i];
|
||||
if (nowMs < st.nextAtMs) return;
|
||||
const stateOk = rule.when === undefined || rule.when === currentState;
|
||||
const opennessOk = rule.maxOpenness === undefined || openness < rule.maxOpenness;
|
||||
if (stateOk && opennessOk) {
|
||||
triggerClip(rule.clip, nowMs, { weight: 1 });
|
||||
}
|
||||
idleClipStates![i] = { nextAtMs: nowMs + rng.range(rule.everyMs[0], rule.everyMs[1]) };
|
||||
});
|
||||
}
|
||||
|
||||
function applyBlink(out: ChannelVector, nowMs: number, suppressed: boolean): void {
|
||||
if (suppressed) return;
|
||||
if (blinkState === null) {
|
||||
blinkState = { nextAt: nowMs + rng.range(demeanor.blinkIntervalMs[0], demeanor.blinkIntervalMs[1]), startedAt: null };
|
||||
}
|
||||
if (blinkState.startedAt === null && nowMs >= blinkState.nextAt) {
|
||||
blinkState.startedAt = nowMs;
|
||||
}
|
||||
let mult = 1;
|
||||
if (blinkState.startedAt !== null) {
|
||||
const t = nowMs - blinkState.startedAt;
|
||||
if (t >= demeanor.blinkDurationMs) {
|
||||
blinkState.startedAt = null;
|
||||
blinkState.nextAt = nowMs + rng.range(demeanor.blinkIntervalMs[0], demeanor.blinkIntervalMs[1]);
|
||||
} else {
|
||||
const phase = 1 - Math.abs(t / demeanor.blinkDurationMs - 0.5) * 2;
|
||||
mult = 1 - phase * 0.95;
|
||||
if (mult < 0.05) mult = 0.05;
|
||||
}
|
||||
}
|
||||
out.eyeOpenL *= mult;
|
||||
out.eyeOpenR *= mult;
|
||||
}
|
||||
|
||||
function applySaccade(out: ChannelVector, nowMs: number, suppressed: boolean): void {
|
||||
if (suppressed) return;
|
||||
if (saccadeState === null) {
|
||||
saccadeState = { nextAt: nowMs, targetX: 0, targetY: 0, curX: 0, curY: 0 };
|
||||
}
|
||||
const intervalMul = currentState === "thinking" ? 0.5 : 1;
|
||||
if (nowMs >= saccadeState.nextAt) {
|
||||
const amp = demeanor.saccadeAmplitude * (currentState === "listening" ? 0.5 : 1);
|
||||
saccadeState.targetX = rng.range(-amp, amp);
|
||||
saccadeState.targetY = rng.range(-amp * 0.6, amp * 0.6);
|
||||
saccadeState.nextAt =
|
||||
nowMs + rng.range(demeanor.saccadeIntervalMs[0] * intervalMul, demeanor.saccadeIntervalMs[1] * intervalMul);
|
||||
}
|
||||
const dt = Math.max(0, nowMs - (lastEvalMs ?? nowMs));
|
||||
const alpha = 1 - Math.exp(-dt / 90);
|
||||
saccadeState.curX += (saccadeState.targetX - saccadeState.curX) * alpha;
|
||||
saccadeState.curY += (saccadeState.targetY - saccadeState.curY) * alpha;
|
||||
out.gazeX += saccadeState.curX;
|
||||
out.gazeY += saccadeState.curY;
|
||||
}
|
||||
|
||||
function applyBreath(out: ChannelVector, nowMs: number): void {
|
||||
const period =
|
||||
currentState === "listening" ? 4800 : currentState === "speaking" ? 3600 : currentState === "thinking" ? 5200 : 4200;
|
||||
const breath = 0.5 + 0.5 * Math.sin((2 * Math.PI * nowMs) / period);
|
||||
out.breath = breath;
|
||||
out.shoulderRaise += (breath - 0.5) * 0.06;
|
||||
}
|
||||
|
||||
function applySway(out: ChannelVector, nowMs: number, suppressed: boolean): void {
|
||||
if (suppressed) return;
|
||||
const roll = demeanor.swayAmplitude * 0.04 * (Math.sin(nowMs / 2300) + 0.6 * Math.sin(nowMs / 3700));
|
||||
out.bodyRoll += roll;
|
||||
out.headRoll += roll / 2;
|
||||
}
|
||||
|
||||
function applyIdleLayer(out: ChannelVector, nowMs: number): void {
|
||||
processIdleClips(nowMs);
|
||||
const suppress = activeSuppressions(nowMs);
|
||||
applyBlink(out, nowMs, suppress.has("blink"));
|
||||
applySaccade(out, nowMs, suppress.has("saccade"));
|
||||
applyBreath(out, nowMs);
|
||||
applySway(out, nowMs, suppress.has("sway"));
|
||||
}
|
||||
|
||||
function evaluate(nowMs: number): ChannelVector {
|
||||
processScheduledCues(nowMs);
|
||||
updateSmoothing(nowMs);
|
||||
|
||||
const out: ChannelVector = { ...(smoothed as ChannelVector) };
|
||||
const reaction = reducedMotion ? {} : reactionLayerDelta(nowMs);
|
||||
for (const id of Object.keys(reaction) as ChannelId[]) {
|
||||
out[id] += reaction[id] as number;
|
||||
}
|
||||
|
||||
if (!reducedMotion) applyIdleLayer(out, nowMs);
|
||||
|
||||
/* 발화층(결정문 §5.4 "엔진 합성"). 스프링은 speaking이 아닐 때도 X(닫힘)로
|
||||
계속 수렴시켜 상태 전환 시 값이 멈춰 남지 않게 한다. reduced motion이어도
|
||||
발화 입은 움직인다(기존 동작 유지 — idle·반응층만 끄는 대상이다). */
|
||||
const suppressLipsync = activeSuppressions(nowMs).has("lipsync");
|
||||
const speechTarget = currentState === "speaking" && pendingSpeechShape && !suppressLipsync ? pendingSpeechShape : SILENT_SHAPE;
|
||||
const speechDt = Math.max(0, nowMs - (lastEvalMs ?? nowMs));
|
||||
speechSpring = {
|
||||
open: springTowards(speechSpring.open, speechTarget.open, speechDt, SPEECH_SPRING_TAU_MS),
|
||||
wide: springTowards(speechSpring.wide, speechTarget.wide, speechDt, SPEECH_SPRING_TAU_MS),
|
||||
round: springTowards(speechSpring.round, speechTarget.round, speechDt, SPEECH_SPRING_TAU_MS),
|
||||
press: springTowards(speechSpring.press, speechTarget.press, speechDt, SPEECH_SPRING_TAU_MS),
|
||||
};
|
||||
if (currentState === "speaking") {
|
||||
const s = speechSpring;
|
||||
out.mouthOpen = s.open.value + 0.25 * out.mouthOpen;
|
||||
out.mouthStretch += s.wide.value;
|
||||
out.mouthRound = Math.max(out.mouthRound, s.round.value);
|
||||
out.mouthPress = Math.max(0.5 * out.mouthPress, s.press.value);
|
||||
out.mouthForm *= 1 - 0.5 * s.round.value;
|
||||
}
|
||||
|
||||
/* 발화 동반층(결정문 §5.5). reduced motion이면 아예 끈다(입·턱만 남는다). speaking이고
|
||||
pending delta가 있으면 계획 곡선을 그대로 통과시킨다 — coSpeech.ts의 강세 박(rise 120·
|
||||
fall 260)은 이미 매끄러운 올림 코사인이라, 여기서 또 τ150ms 스프링을 걸면 정점이 깎이고
|
||||
100ms 넘게 늦어져 "모음 목표 − 80ms 정점"이 무너진다(팀 리드 수정 요청, 2026-10-01).
|
||||
스프링 상태는 통과 중에도 값만 동기화해 두어(속도 0), speaking이 꺼지거나 delta가 null이
|
||||
되는 순간부터는 그 값에서 이어서 τ150ms로 0으로 되돌아간다(발화 종료 시 튀지 않게). */
|
||||
if (!reducedMotion) {
|
||||
const coSpeaking = currentState === "speaking";
|
||||
const coDt = Math.max(0, nowMs - (lastEvalMs ?? nowMs));
|
||||
for (const id of CO_SPEECH_CHANNELS) {
|
||||
if (coSpeaking && pendingSpeechMotion) {
|
||||
const v = pendingSpeechMotion[id] ?? 0;
|
||||
coSpeechSpring[id] = { value: v, velocity: 0 };
|
||||
out[id] += v;
|
||||
} else {
|
||||
const next = springTowards(coSpeechSpring[id] as SpringState, 0, coDt, CO_SPEECH_SPRING_TAU_MS);
|
||||
coSpeechSpring[id] = next;
|
||||
out[id] += next.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of CHANNEL_IDS) out[id] = clampChannel(id, out[id]);
|
||||
|
||||
lastEvalMs = nowMs;
|
||||
return out;
|
||||
}
|
||||
|
||||
function debugSnapshot(): DebugSnapshot {
|
||||
const leakActive = leak !== null && lastEvalMs !== null && lastEvalMs >= leak.startAbsMs && lastEvalMs <= leak.startAbsMs + leak.durationMs;
|
||||
return {
|
||||
activeClips: activeClips.map((e) => ({ id: e.clipId, startedMs: e.startedMs, weight: e.weight, mirror: e.mirror })),
|
||||
scheduled: scheduledCues.map((sc) => ({ clip: sc.clip, atMs: sc.atMs, source: sc.source })),
|
||||
surface: { expression: surfaceExpr, intensity: surfaceIntensity },
|
||||
leakActive,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
setState,
|
||||
setOpenness,
|
||||
setSurface,
|
||||
setSpeechShape,
|
||||
setSpeechMotion,
|
||||
requestSpeechBlink,
|
||||
triggerClip,
|
||||
playPerformance,
|
||||
evaluate,
|
||||
debugSnapshot,
|
||||
};
|
||||
}
|
||||
68
apps/web/src/components/avatar/engine/expressionPresets.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 표정 프리셋 — docs/decisions/avatar-expression-engine-v3.md §5 정본 값.
|
||||
각 값은 강도 1.0에서 페르소나 기본 자세 대비 delta다. FACS AU를 참고해
|
||||
눈썹(AU1/2/4)·눈꺼풀(AU5/6/7/43)·입(AU12/14/15/17/20/23/24/25/26)과
|
||||
머리·몸 자세를 함께 정한다. 오버레이(tear·cheek·pallor·sweat)는 강도 게이트를 따른다.
|
||||
값 변경은 오케스트레이터 설계 결정이다. 구분 가능성 불변량은
|
||||
scripts/check-avatar-presets.mjs 가 검증한다.
|
||||
===================================================================== */
|
||||
|
||||
import type { AvatarExpression } from "../persona";
|
||||
import { expandAuthoring, type AuthoringDelta, type ChannelDelta } from "./channels";
|
||||
|
||||
const PRESET_SPECS: Record<AvatarExpression, AuthoringDelta> = {
|
||||
neutral: {},
|
||||
|
||||
/* ── positive ─────────────────────────────────────────────────── */
|
||||
calm: { eyeSmile: 0.25, eyeOpen: -0.1, mouthForm: 0.2, shoulderRaise: -0.25, bodyLean: -0.05, browForm: 0.1 },
|
||||
warm: { eyeSmile: 0.45, mouthForm: 0.45, browY: 0.1, headRoll: 0.12, bodyLean: 0.15 },
|
||||
joy: { eyeSmile: 0.75, mouthForm: 0.85, mouthOpen: 0.35, browY: 0.2, cheek: 0.4, headPitch: 0.08, bodyLean: 0.1 },
|
||||
delight: { eyeOpen: 0.2, eyeSmile: 0.5, mouthForm: 0.75, mouthOpen: 0.5, browY: 0.45, bodyLean: 0.2, headPitch: 0.1, cheek: 0.3 },
|
||||
relief: { eyeOpen: -0.4, eyeSmile: 0.3, mouthForm: 0.35, browY: 0.15, browAngle: 0.2, shoulderRaise: -0.45, headPitch: -0.05 },
|
||||
hopeful: { eyeOpen: 0.08, browY: 0.3, browAngle: 0.25, mouthForm: 0.25, gazeY: 0.25, headPitch: 0.12, bodyLean: 0.1 },
|
||||
determined: { browY: -0.15, browX: -0.2, lidTension: 0.3, eyeOpen: 0.05, mouthPress: 0.35, mouthForm: 0.1, headPitch: -0.05, bodyLean: 0.2, shoulderRaise: -0.1 },
|
||||
|
||||
/* ── negative ─────────────────────────────────────────────────── */
|
||||
sad: { eyeOpen: -0.25, browAngle: 0.6, browY: 0.1, mouthForm: -0.45, gazeY: -0.35, headPitch: -0.2, shoulderRaise: -0.2, chinRaise: 0.2, tear: 0.5 },
|
||||
/* 비탄 눈썹은 AU1+AU4: 안쪽만 오르고 눈썹 전체는 눌려 모이며 바깥이 처진다. browY +0.2였을 때
|
||||
소유자가 "눈썹이 너무 위, 두 배는 더 내려와야"라고 봐서 안쪽 올림을 절반(약 57 → 27px)으로 줄였다. */
|
||||
grief: { eyeOpen: -0.45, browAngle: 0.8, browY: -0.4, browX: -0.25, browForm: 0.3, mouthForm: -0.7, mouthOpen: 0.2, chinRaise: 0.55, gazeY: -0.5, headPitch: -0.35, shoulderRaise: 0.2, tear: 0.9 },
|
||||
lonely: { eyeOpen: -0.2, browAngle: 0.4, mouthForm: -0.2, gazeX: 0.45, gazeY: -0.2, headYaw: 0.18, headPitch: -0.12, shoulderRaise: -0.3 },
|
||||
/* 불안은 렌더 구분도가 가장 낮았다(2단계-B-1d out4 face96 G의 0.44). 눈썹을 더 올려 모으고
|
||||
눈을 더 뜨고 입술을 누르며 턱을 긴장시키고 고개를 움츠린다(AU1+2+4·5·24·17). */
|
||||
anxious: { eyeOpen: 0.22, lidTension: 0.3, browAngle: 0.6, browX: -0.45, browY: 0.15, mouthPress: 0.45, mouthStretch: 0.25, chinRaise: 0.2, gazeX: -0.2, pupilSize: 0.25, headPitch: -0.06, shoulderRaise: 0.35, bodyLean: -0.1 },
|
||||
panic: { eyeOpen: 0.3, browAngle: 0.55, browY: 0.45, browX: -0.2, mouthStretch: 0.6, mouthOpen: 0.3, pupilSize: 0.5, shoulderRaise: 0.6, bodyLean: -0.25, pallor: 0.6, sweat: 0.5 },
|
||||
startled: { eyeOpen: 0.3, browY: 0.6, browAngle: 0.1, mouthOpen: 0.45, pupilSize: 0.3, headPitch: 0.12, bodyLean: -0.2, shoulderRaise: 0.4 },
|
||||
embarrassed: { eyeSmile: 0.2, mouthForm: 0.25, mouthAsym: 0.3, gazeX: 0.5, gazeY: -0.35, headPitch: -0.18, headRoll: -0.1, cheek: 0.7 },
|
||||
ashamed: { eyeOpen: -0.35, browAngle: 0.35, gazeY: -0.75, headPitch: -0.45, mouthPress: 0.35, mouthForm: -0.2, shoulderRaise: 0.3, bodyLean: -0.1, cheek: 0.45 },
|
||||
guilty: { eyeOpen: -0.25, browAngle: 0.5, browX: -0.15, gazeY: -0.55, gazeX: -0.25, headPitch: -0.25, mouthPress: 0.45, mouthForm: -0.25, chinRaise: 0.25 },
|
||||
overwhelmed: { eyeOpen: -0.3, lidTension: 0.25, browAngle: 0.7, browX: -0.3, mouthForm: -0.4, mouthOpen: 0.12, chinRaise: 0.35, headPitch: -0.3, shoulderRaise: 0.45, gazeY: -0.3, tear: 0.4 },
|
||||
|
||||
/* ── defensive ────────────────────────────────────────────────── */
|
||||
resistant: { eyeOpen: -0.12, lidTension: 0.3, browY: -0.2, browX: -0.25, mouthPress: 0.5, mouthAsym: 0.2, headYaw: -0.18, bodyLean: -0.3, headPitch: 0.08, gazeX: 0.3 },
|
||||
guarded: { eyeOpen: -0.08, lidTension: 0.4, browY: -0.1, mouthPress: 0.4, bodyLean: -0.2, shoulderRaise: 0.25, gazeX: 0.2, headPitch: -0.05 },
|
||||
skeptical: { browLY: 0.45, browRY: -0.25, lidTension: 0.35, mouthAsym: 0.45, mouthPress: 0.2, headRoll: -0.15, headPitch: 0.1, bodyLean: -0.1 },
|
||||
irritated: { browY: -0.35, browX: -0.45, browAngle: -0.3, lidTension: 0.4, mouthPress: 0.45, mouthForm: -0.15, gazeX: 0.25, headPitch: 0.05 },
|
||||
angry: { browY: -0.6, browX: -0.65, browAngle: -0.6, lidTension: 0.65, eyeOpen: 0.1, mouthPress: 0.6, mouthForm: -0.3, headPitch: -0.12, bodyLean: 0.15, cheek: 0.2 },
|
||||
rage: { browY: -0.8, browX: -0.8, browAngle: -0.8, lidTension: 0.5, eyeOpen: 0.25, mouthOpen: 0.45, mouthStretch: 0.35, mouthForm: -0.45, headPitch: -0.1, bodyLean: 0.3, shoulderRaise: 0.3, cheek: 0.55 },
|
||||
|
||||
/* ── cognitive ────────────────────────────────────────────────── */
|
||||
confused: { browLY: 0.4, browRY: -0.2, browX: -0.2, eyeOpen: -0.05, headRoll: 0.2, mouthAsym: 0.25, mouthPress: 0.15, gazeY: 0.15 },
|
||||
conflicted: { browX: -0.35, browAngle: 0.3, mouthPress: 0.4, mouthAsym: -0.2, gazeX: 0.3, gazeY: -0.2, headPitch: -0.08, lidTension: 0.2 },
|
||||
|
||||
/* ── energy ───────────────────────────────────────────────────── */
|
||||
tired: { eyeOpen: -0.45, browY: -0.1, browAngle: 0.1, mouthForm: -0.1, mouthOpen: 0.05, headPitch: -0.2, headRoll: 0.1, shoulderRaise: -0.45, gazeY: -0.2 },
|
||||
bored: { eyeOpen: -0.35, gazeX: 0.55, gazeY: 0.25, mouthForm: -0.15, mouthAsym: 0.15, headRoll: 0.18, bodyLean: -0.15, browY: -0.05 },
|
||||
};
|
||||
|
||||
export const EXPRESSION_PRESETS: Record<AvatarExpression, ChannelDelta> = Object.fromEntries(
|
||||
Object.entries(PRESET_SPECS).map(([id, spec]) => [id, expandAuthoring(spec)]),
|
||||
) as Record<AvatarExpression, ChannelDelta>;
|
||||
|
||||
/** 강도 → 적용 비율(결정문 §5.2). 0~0.35 절제, 0.35~0.7 중간, 0.7~1.0 뚜렷. */
|
||||
export function intensityGain(intensity: number): number {
|
||||
const x = intensity < 0 ? 0 : intensity > 1 ? 1 : intensity;
|
||||
if (x <= 0.35) return (x / 0.35) * 0.3;
|
||||
if (x <= 0.7) return 0.3 + ((x - 0.35) / 0.35) * 0.45;
|
||||
return 0.75 + ((x - 0.7) / 0.3) * 0.25;
|
||||
}
|
||||
681
apps/web/src/components/avatar/engine/lipsync.ts
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 발화층 — 한글 비짐 립싱크. 결정문 §5.4 발화층 정본.
|
||||
렌더러·엔진과 무관한 순수 모듈이다. 한글 분해는 유니코드 산술로 하고
|
||||
음운 규칙(g2p)은 쓰지 않는다(구분은 양순음 여부뿐, §5.4).
|
||||
흐름: buildSpeechTimeline(텍스트 [+포락선]) → SpeechTimeline(키 목록)
|
||||
→ 재생 중 매 프레임 sampleSpeech(timeline, t)로 표본.
|
||||
임계 감쇠 스프링(τ 45ms)은 여기 두지 않는다 — 엔진의 발화층이 프레임 dt를
|
||||
가지고 있으므로 engine.ts가 sampleSpeech 결과를 스프링으로 평활한다.
|
||||
===================================================================== */
|
||||
|
||||
import { detectVoicedSegments, sampleEnvelope, type SpeechEnvelope, type VoicedSegment } from "./speechEnvelope";
|
||||
|
||||
export type VisemeId = "X" | "M" | "A" | "EO" | "O" | "U" | "EU" | "I" | "E";
|
||||
|
||||
export interface SpeechShape {
|
||||
open: number;
|
||||
wide: number;
|
||||
round: number;
|
||||
press: number;
|
||||
}
|
||||
|
||||
export interface SpeechKey {
|
||||
/** 발화 시작 기준 절대 ms. */
|
||||
atMs: number;
|
||||
shape: SpeechShape;
|
||||
/** 디버그·검증용 비짐 이름. 렌더러는 shape만 쓴다. */
|
||||
viseme: VisemeId;
|
||||
}
|
||||
|
||||
export interface SpeechTimeline {
|
||||
keys: SpeechKey[];
|
||||
totalDurationMs: number;
|
||||
/** 오디오 선분석 경로에서만 채워진다(표본 시 진폭 조절·무음 판정에 쓴다). */
|
||||
envelope?: SpeechEnvelope;
|
||||
voicedSegments?: VoicedSegment[];
|
||||
/** 구 단위로 정렬한 창(진단·검증용, §5.4 "구 경계"). */
|
||||
phraseWindows?: VoicedSegment[];
|
||||
/** 발화 동반층(§5.5) 입력 — 구 목록(시작·끝·종류·음절별 모음 목표·강세 음절). */
|
||||
phrases: SpeechPhraseInfo[];
|
||||
/** 발화 동반층(§5.5) 입력 — 구 사이 휴지 구간. */
|
||||
pauses: SpeechPauseWindow[];
|
||||
}
|
||||
|
||||
/** 구 종류(§5.5): 마침표·구두점 없음 = 평서, `?` = 질문, `!` = 감탄, `,` = 이어짐. */
|
||||
export type PhraseKind = "statement" | "question" | "exclaim" | "continuation";
|
||||
|
||||
export interface SpeechPhraseInfo {
|
||||
index: number;
|
||||
/** 구 시작·끝(휴지 제외) 절대 ms. */
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
kind: PhraseKind;
|
||||
/** 이 구 안 음절(비한글 단위 포함)의 모음 목표 시각(§5.4 "모음 목표"), 순서대로. */
|
||||
syllableTargetsMs: number[];
|
||||
/** 강세 음절 인덱스(syllableTargetsMs 기준). 오름차순이며 첫 원소는 항상 0(구의 첫 음절, §5.5). */
|
||||
stressedIndices: number[];
|
||||
}
|
||||
|
||||
export interface SpeechPauseWindow {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
/* ── 비짐 9종 목표값(결정문 §5.4 표) ──────────────────────────────── */
|
||||
export const VISEME_SHAPE: Readonly<Record<VisemeId, SpeechShape>> = {
|
||||
X: { open: 0, wide: 0, round: 0, press: 0 },
|
||||
M: { open: 0, wide: 0, round: 0, press: 0.35 },
|
||||
A: { open: 0.75, wide: 0.1, round: 0, press: 0 },
|
||||
EO: { open: 0.55, wide: 0, round: 0.1, press: 0 },
|
||||
O: { open: 0.4, wide: 0, round: 0.75, press: 0 },
|
||||
U: { open: 0.2, wide: 0, round: 1.0, press: 0 },
|
||||
EU: { open: 0.15, wide: 0.4, round: 0, press: 0 },
|
||||
I: { open: 0.2, wide: 0.65, round: 0, press: 0 },
|
||||
E: { open: 0.45, wide: 0.45, round: 0, press: 0 },
|
||||
};
|
||||
|
||||
/** X(쉼) 목표 — 무음 판정·타임라인 끝맺음에 쓴다. */
|
||||
export const SILENT_SHAPE: SpeechShape = VISEME_SHAPE.X;
|
||||
|
||||
function cloneShape(s: SpeechShape): SpeechShape {
|
||||
return { ...s };
|
||||
}
|
||||
|
||||
function lerpShape(a: SpeechShape, b: SpeechShape, t: number): SpeechShape {
|
||||
return {
|
||||
open: a.open + (b.open - a.open) * t,
|
||||
wide: a.wide + (b.wide - a.wide) * t,
|
||||
round: a.round + (b.round - a.round) * t,
|
||||
press: a.press + (b.press - a.press) * t,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
return v < lo ? lo : v > hi ? hi : v;
|
||||
}
|
||||
|
||||
function smoothstep(u: number): number {
|
||||
const c = clamp(u, 0, 1);
|
||||
return c * c * (3 - 2 * c);
|
||||
}
|
||||
|
||||
/* ── 한글 분해(유니코드 산술) — 결정문 §5.4 "한글 분해는 유니코드 산술로 한다" ──── */
|
||||
const HANGUL_BASE = 0xac00;
|
||||
const HANGUL_LAST = 0xd7a3;
|
||||
const JUNGSEONG_COUNT = 21;
|
||||
const JONGSEONG_COUNT = 28;
|
||||
|
||||
function isHangulSyllable(code: number): boolean {
|
||||
return code >= HANGUL_BASE && code <= HANGUL_LAST;
|
||||
}
|
||||
|
||||
function decompose(code: number): { cho: number; jung: number; jong: number } {
|
||||
const sIndex = code - HANGUL_BASE;
|
||||
const jong = sIndex % JONGSEONG_COUNT;
|
||||
const jung = Math.floor(sIndex / JONGSEONG_COUNT) % JUNGSEONG_COUNT;
|
||||
const cho = Math.floor(sIndex / JONGSEONG_COUNT / JUNGSEONG_COUNT);
|
||||
return { cho, jung, jong };
|
||||
}
|
||||
|
||||
/* 초성 19종(유니코드 순서: ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ). */
|
||||
const BILABIAL_CHOSEONG = new Set([6, 7, 8, 17]); // ㅁㅂㅃㅍ
|
||||
const SIBILANT_CHOSEONG = new Set([9, 10, 12, 13, 14]); // ㅅㅆㅈㅉㅊ
|
||||
const SILENT_CHOSEONG = 11; // ㅇ
|
||||
|
||||
/* 종성 28종(0=받침 없음). 끝소리가 양순음(ㅁㅂㅍ)인 것 — 겹받침 ㄻ(ㅁ)·ㄿ(ㅍ)·ㅄ(ㅂ) 포함. */
|
||||
const BILABIAL_JONGSEONG = new Set([16, 17, 26, 10, 14, 18]);
|
||||
|
||||
/* 중성 21종: 단모음은 비짐 하나, 이중모음은 [활음, 뒤 모음] 쌍(결정문 §5.4). */
|
||||
type JungseongEntry = VisemeId | readonly [VisemeId, VisemeId];
|
||||
const JUNGSEONG_TABLE: readonly JungseongEntry[] = [
|
||||
"A", // 0 ㅏ
|
||||
"E", // 1 ㅐ
|
||||
["I", "A"], // 2 ㅑ
|
||||
["I", "E"], // 3 ㅒ
|
||||
"EO", // 4 ㅓ
|
||||
"E", // 5 ㅔ
|
||||
["I", "EO"], // 6 ㅕ
|
||||
["I", "E"], // 7 ㅖ
|
||||
"O", // 8 ㅗ
|
||||
["U", "A"], // 9 ㅘ
|
||||
["U", "E"], // 10 ㅙ
|
||||
["U", "E"], // 11 ㅚ(특칙: U→E)
|
||||
["I", "O"], // 12 ㅛ
|
||||
"U", // 13 ㅜ
|
||||
["U", "EO"], // 14 ㅝ
|
||||
["U", "E"], // 15 ㅞ
|
||||
["U", "I"], // 16 ㅟ(특칙: U→I)
|
||||
["I", "U"], // 17 ㅠ
|
||||
"EU", // 18 ㅡ
|
||||
["EU", "I"], // 19 ㅢ(특칙: EU→I)
|
||||
"I", // 20 ㅣ
|
||||
];
|
||||
|
||||
function jungseongVisemes(jung: number): { glide: VisemeId | null; target: VisemeId } {
|
||||
const entry = JUNGSEONG_TABLE[jung];
|
||||
return typeof entry === "string" ? { glide: null, target: entry } : { glide: entry[0], target: entry[1] };
|
||||
}
|
||||
|
||||
/* ── 음절 내부 키(비짐 프랙션) 생성 ────────────────────────────────── */
|
||||
interface LocalKey {
|
||||
frac: number;
|
||||
shape: SpeechShape;
|
||||
viseme: VisemeId;
|
||||
isTarget?: boolean;
|
||||
}
|
||||
|
||||
/* 양순음 초성 + 활음이 겹칠 때 활음 키를 두는 지점(폐쇄 해제 직후). 문서에 정확한 수치가 없어
|
||||
오케스트레이터 확인 전까지 쓰는 값 — "봐요" 표본(M→U→A)을 만족하는 최소치다. */
|
||||
const BILABIAL_GLIDE_FRAC = 0.15;
|
||||
|
||||
function buildSyllableKeys(cho: number, jung: number, jong: number): LocalKey[] {
|
||||
const { glide, target } = jungseongVisemes(jung);
|
||||
const hasGlide = glide !== null;
|
||||
const targetShape = cloneShape(VISEME_SHAPE[target]);
|
||||
const targetFrac = hasGlide ? 0.55 : 0.35;
|
||||
const keys: LocalKey[] = [];
|
||||
|
||||
if (BILABIAL_CHOSEONG.has(cho)) {
|
||||
keys.push({ frac: 0, shape: cloneShape(VISEME_SHAPE.M), viseme: "M" });
|
||||
if (hasGlide) {
|
||||
const g = glide as VisemeId;
|
||||
keys.push({ frac: BILABIAL_GLIDE_FRAC, shape: cloneShape(VISEME_SHAPE[g]), viseme: g });
|
||||
}
|
||||
} else if (cho === SILENT_CHOSEONG) {
|
||||
if (hasGlide) {
|
||||
const g = glide as VisemeId;
|
||||
keys.push({ frac: 0, shape: cloneShape(VISEME_SHAPE[g]), viseme: g });
|
||||
}
|
||||
/* 무성 초성 + 활음 없음: 시작 키 없이 선행 음절에서 바로 이어진다(동시조음). */
|
||||
} else {
|
||||
const baseId = hasGlide ? (glide as VisemeId) : target;
|
||||
const baseShape = VISEME_SHAPE[baseId];
|
||||
let shape: SpeechShape = { ...baseShape, open: baseShape.open * 0.5 };
|
||||
if (SIBILANT_CHOSEONG.has(cho)) {
|
||||
shape = { ...shape, open: Math.min(shape.open, 0.15), wide: shape.wide + 0.2 };
|
||||
}
|
||||
keys.push({ frac: 0, shape, viseme: baseId });
|
||||
}
|
||||
|
||||
keys.push({ frac: targetFrac, shape: targetShape, viseme: target, isTarget: true });
|
||||
|
||||
if (jong !== 0) {
|
||||
if (BILABIAL_JONGSEONG.has(jong)) {
|
||||
keys.push({ frac: 0.85, shape: cloneShape(VISEME_SHAPE.M), viseme: "M" });
|
||||
} else {
|
||||
keys.push({ frac: 0.85, shape: { ...targetShape, open: targetShape.open * 0.6 }, viseme: target });
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function otherUnitKeys(): LocalKey[] {
|
||||
/* 한글이 아닌 글자(숫자·영문)는 한 음절 EO로 친다(결정문 §5.4). */
|
||||
return [{ frac: 0.35, shape: cloneShape(VISEME_SHAPE.EO), viseme: "EO", isTarget: true }];
|
||||
}
|
||||
|
||||
/* ── 텍스트 → 구 단위 토큰화(휴지 구분, 결정문 §5.4 타이밍) ────────────── */
|
||||
type Unit =
|
||||
| { kind: "syllable"; cho: number; jung: number; jong: number; weight: number }
|
||||
| { kind: "other"; weight: number };
|
||||
|
||||
interface Phrase {
|
||||
units: Unit[];
|
||||
/** 이 구 다음에 오는 휴지(ms). 마지막 구가 구두점으로 끝나지 않으면 0. */
|
||||
restMs: number;
|
||||
kind: PhraseKind;
|
||||
}
|
||||
|
||||
/* voice.py _STAGE_DIRECTION_RE / performance.ts BRACKET_RE 와 같은 괄호 문자 집합. */
|
||||
const BRACKET_RE = /[(([【][^))\]】]*[))\]】]/g;
|
||||
|
||||
const PAUSE_COMMA_MS = 250;
|
||||
const PAUSE_SENTENCE_MS = 450;
|
||||
const PAUSE_ELLIPSIS_MS = 600;
|
||||
|
||||
function tokenizeToPhrases(
|
||||
text: string,
|
||||
): Array<{ units: Array<{ kind: "syllable" | "other"; cho?: number; jung?: number; jong?: number }>; restMs: number; kind: PhraseKind }> {
|
||||
const stripped = text.replace(BRACKET_RE, " ");
|
||||
const normalized = stripped.replace(/\.{2,}/g, "…");
|
||||
|
||||
const phrases: Array<{
|
||||
units: Array<{ kind: "syllable" | "other"; cho?: number; jung?: number; jong?: number }>;
|
||||
restMs: number;
|
||||
kind: PhraseKind;
|
||||
}> = [];
|
||||
let current: Array<{ kind: "syllable" | "other"; cho?: number; jung?: number; jong?: number }> = [];
|
||||
|
||||
const flush = (restMs: number, kind: PhraseKind) => {
|
||||
phrases.push({ units: current, restMs, kind });
|
||||
current = [];
|
||||
};
|
||||
|
||||
for (const ch of normalized) {
|
||||
if (/\s/.test(ch)) continue;
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
if (isHangulSyllable(code)) {
|
||||
const { cho, jung, jong } = decompose(code);
|
||||
current.push({ kind: "syllable", cho, jung, jong });
|
||||
continue;
|
||||
}
|
||||
if (ch === "…") {
|
||||
/* 말줄임표는 문장을 맺지 않는 망설임이라 이어짐(§5.5 구 끝 규칙에서 제외)으로 다룬다.
|
||||
§5.5 표는 이 경우를 명시하지 않아 오케스트레이터 확인 전까지 쓰는 값이다. */
|
||||
flush(PAUSE_ELLIPSIS_MS, "continuation");
|
||||
continue;
|
||||
}
|
||||
if (ch === "," || ch === ",") {
|
||||
flush(PAUSE_COMMA_MS, "continuation");
|
||||
continue;
|
||||
}
|
||||
if (ch === ".") {
|
||||
flush(PAUSE_SENTENCE_MS, "statement");
|
||||
continue;
|
||||
}
|
||||
if (ch === "?") {
|
||||
flush(PAUSE_SENTENCE_MS, "question");
|
||||
continue;
|
||||
}
|
||||
if (ch === "!") {
|
||||
flush(PAUSE_SENTENCE_MS, "exclaim");
|
||||
continue;
|
||||
}
|
||||
if (/[0-9A-Za-z]/.test(ch)) {
|
||||
current.push({ kind: "other" });
|
||||
continue;
|
||||
}
|
||||
/* 그 외 기호(물결표 등)는 발화 타이밍에 영향을 주지 않는다. */
|
||||
}
|
||||
if (current.length > 0) phrases.push({ units: current, restMs: 0, kind: "statement" });
|
||||
return phrases;
|
||||
}
|
||||
|
||||
function syllableWeight(jong: number, isPhraseFinal: boolean): number {
|
||||
let w = 1.0;
|
||||
if (jong !== 0) w += 0.25;
|
||||
if (isPhraseFinal) w += 0.5;
|
||||
return w;
|
||||
}
|
||||
|
||||
function otherWeight(isPhraseFinal: boolean): number {
|
||||
return isPhraseFinal ? 1.5 : 1.0;
|
||||
}
|
||||
|
||||
function withWeights(
|
||||
raw: Array<{
|
||||
units: Array<{ kind: "syllable" | "other"; cho?: number; jung?: number; jong?: number }>;
|
||||
restMs: number;
|
||||
kind: PhraseKind;
|
||||
}>,
|
||||
): Phrase[] {
|
||||
return raw.map(({ units, restMs, kind }) => {
|
||||
const withW: Unit[] = units.map((u, i) => {
|
||||
const isLast = i === units.length - 1;
|
||||
const phraseFinal = isLast && restMs > 0;
|
||||
if (u.kind === "syllable") {
|
||||
return { kind: "syllable", cho: u.cho as number, jung: u.jung as number, jong: u.jong as number, weight: syllableWeight(u.jong as number, phraseFinal) };
|
||||
}
|
||||
return { kind: "other", weight: otherWeight(phraseFinal) };
|
||||
});
|
||||
return { units: withW, restMs, kind };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 강세 음절 선택(§5.5): 구의 첫 음절 + 구 안에서 값이 가장 큰 음절(오디오는 포락선 피크,
|
||||
* 텍스트는 가중치). 구가 6음절 이상이면 이웃하지 않은 상위 2개를 추가로 고른다.
|
||||
* 반환은 오름차순 인덱스이며 첫 원소는 항상 0이다.
|
||||
*/
|
||||
function selectStressedIndices(weights: number[]): number[] {
|
||||
const n = weights.length;
|
||||
if (n === 0) return [];
|
||||
const selected = [0];
|
||||
if (n === 1) return selected;
|
||||
const extraCount = n >= 6 ? 2 : 1;
|
||||
const ranked = weights
|
||||
.map((w, i) => ({ i, w }))
|
||||
.filter((e) => e.i !== 0)
|
||||
.sort((a, b) => b.w - a.w || a.i - b.i);
|
||||
for (const { i } of ranked) {
|
||||
if (selected.length - 1 >= extraCount) break;
|
||||
if (selected.every((s) => Math.abs(s - i) >= 2)) selected.push(i);
|
||||
}
|
||||
selected.sort((a, b) => a - b);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function localKeysFor(unit: Unit): LocalKey[] {
|
||||
return unit.kind === "syllable" ? buildSyllableKeys(unit.cho, unit.jung, unit.jong) : otherUnitKeys();
|
||||
}
|
||||
|
||||
/* ── 레이아웃 → 최종 키 목록 ──────────────────────────────────────── */
|
||||
interface LaidUnit {
|
||||
startMs: number;
|
||||
durMs: number;
|
||||
localKeys: LocalKey[];
|
||||
}
|
||||
|
||||
const SHORT_SYLLABLE_MS = 90;
|
||||
const SHORT_SYLLABLE_PULL = 0.4;
|
||||
const MIN_HOLD_MS = 60;
|
||||
|
||||
/** 짧은 음절(<90ms) 목표를 앞뒤 음절 평균 쪽으로 40% 끌어당긴다(결정문 §5.4). */
|
||||
function applyShortSyllableAveraging(laidOut: LaidUnit[]): void {
|
||||
const targets = laidOut.map((lu) => lu.localKeys.find((k) => k.isTarget) ?? null);
|
||||
const originals = targets.map((k) => (k ? cloneShape(k.shape) : null));
|
||||
for (let i = 0; i < laidOut.length; i++) {
|
||||
if (laidOut[i].durMs >= SHORT_SYLLABLE_MS) continue;
|
||||
const key = targets[i];
|
||||
if (!key) continue;
|
||||
const prev = i > 0 ? originals[i - 1] : null;
|
||||
const next = i < laidOut.length - 1 ? originals[i + 1] : null;
|
||||
if (!prev && !next) continue;
|
||||
const avg = prev && next ? lerpShape(prev, next, 0.5) : ((prev ?? next) as SpeechShape);
|
||||
key.shape = lerpShape(originals[i] as SpeechShape, avg, SHORT_SYLLABLE_PULL);
|
||||
}
|
||||
}
|
||||
|
||||
/** 인접 키 간격이 60ms 미만이면 뒤 키를 밀어 최소 유지를 보장한다(결정문 §5.4). */
|
||||
function enforceMinHold(keys: SpeechKey[]): void {
|
||||
for (let i = 1; i < keys.length; i++) {
|
||||
if (keys[i].atMs - keys[i - 1].atMs < MIN_HOLD_MS) {
|
||||
keys[i].atMs = keys[i - 1].atMs + MIN_HOLD_MS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finalize(
|
||||
laidOut: LaidUnit[],
|
||||
pauseAtMs: number[],
|
||||
totalMs: number,
|
||||
envelope: SpeechEnvelope | undefined,
|
||||
voicedSegments: VoicedSegment[] | undefined,
|
||||
phraseWindows: VoicedSegment[] | undefined,
|
||||
phrases: SpeechPhraseInfo[],
|
||||
pauses: SpeechPauseWindow[],
|
||||
): SpeechTimeline {
|
||||
applyShortSyllableAveraging(laidOut);
|
||||
|
||||
const keys: SpeechKey[] = [];
|
||||
for (const lu of laidOut) {
|
||||
for (const lk of lu.localKeys) {
|
||||
keys.push({ atMs: lu.startMs + lk.frac * lu.durMs, shape: lk.shape, viseme: lk.viseme });
|
||||
}
|
||||
}
|
||||
for (const at of pauseAtMs) {
|
||||
keys.push({ atMs: at, shape: cloneShape(SILENT_SHAPE), viseme: "X" });
|
||||
}
|
||||
keys.sort((a, b) => a.atMs - b.atMs);
|
||||
|
||||
const last = keys[keys.length - 1];
|
||||
if (!last || last.viseme !== "X" || last.atMs < totalMs) {
|
||||
keys.push({ atMs: totalMs, shape: cloneShape(SILENT_SHAPE), viseme: "X" });
|
||||
}
|
||||
|
||||
enforceMinHold(keys);
|
||||
|
||||
const finalTotal = Math.max(totalMs, keys[keys.length - 1].atMs);
|
||||
return { keys, totalDurationMs: finalTotal, envelope, voicedSegments, phraseWindows, phrases, pauses };
|
||||
}
|
||||
|
||||
/* ── 텍스트만으로 타이밍(오디오 없음, 결정문 §5.4) ────────────────────── */
|
||||
function buildTextOnlyTimeline(phrases: Phrase[], syllablesPerSec: number): SpeechTimeline {
|
||||
const baseDurMs = 1000 / syllablesPerSec;
|
||||
const laidOut: LaidUnit[] = [];
|
||||
const pauseAtMs: number[] = [];
|
||||
const phraseInfos: SpeechPhraseInfo[] = [];
|
||||
const pauses: SpeechPauseWindow[] = [];
|
||||
let tMs = 0;
|
||||
|
||||
phrases.forEach((phrase, pIdx) => {
|
||||
const startMs = tMs;
|
||||
const syllableTargetsMs: number[] = [];
|
||||
const weights: number[] = [];
|
||||
for (const unit of phrase.units) {
|
||||
const durMs = unit.weight * baseDurMs;
|
||||
const localKeys = localKeysFor(unit);
|
||||
laidOut.push({ startMs: tMs, durMs, localKeys });
|
||||
const targetKey = localKeys.find((k) => k.isTarget);
|
||||
syllableTargetsMs.push(tMs + (targetKey?.frac ?? 0.35) * durMs);
|
||||
weights.push(unit.weight);
|
||||
tMs += durMs;
|
||||
}
|
||||
phraseInfos.push({
|
||||
index: pIdx,
|
||||
startMs,
|
||||
endMs: tMs,
|
||||
kind: phrase.kind,
|
||||
syllableTargetsMs,
|
||||
stressedIndices: selectStressedIndices(weights),
|
||||
});
|
||||
if (phrase.restMs > 0) {
|
||||
pauseAtMs.push(tMs);
|
||||
pauses.push({ startMs: tMs, endMs: tMs + phrase.restMs });
|
||||
tMs += phrase.restMs;
|
||||
}
|
||||
});
|
||||
|
||||
return finalize(laidOut, pauseAtMs, tMs, undefined, undefined, undefined, phraseInfos, pauses);
|
||||
}
|
||||
|
||||
/* ── 오디오 선분석 정렬(결정문 §5.4) ─────────────────────────────────── */
|
||||
const CORE_SNAP_RATIO = 0.35;
|
||||
|
||||
function findEnvelopePeakMs(envelope: SpeechEnvelope, fromMs: number, toMs: number): number | null {
|
||||
if (toMs <= fromMs) return null;
|
||||
const i0 = Math.max(0, Math.floor(fromMs / envelope.hopMs));
|
||||
const i1 = Math.min(envelope.rms.length - 1, Math.ceil(toMs / envelope.hopMs));
|
||||
let bestI = -1;
|
||||
let bestV = -Infinity;
|
||||
for (let i = i0; i <= i1; i++) {
|
||||
if (envelope.rms[i] > bestV) {
|
||||
bestV = envelope.rms[i];
|
||||
bestI = i;
|
||||
}
|
||||
}
|
||||
return bestI >= 0 ? bestI * envelope.hopMs : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구 수(N)와 유성 구간 수(M)를 맞춘다(결정문 §5.4).
|
||||
* M > N이면 가장 짧은 틈부터 합친다. M < N이면 가장 긴 구간을 분할한다(중간점 분할로 단순화 —
|
||||
* "구 가중치 비율로 나눈다"의 근사치. 여러 구가 한 구간에 몰리는 경우까지 정확히 풀려면
|
||||
* 별도의 배정 문제가 필요해 단순화했다. 자세한 사유는 작업 보고 참고).
|
||||
*/
|
||||
function reconcileSegments(segments: VoicedSegment[], phraseCount: number): VoicedSegment[] {
|
||||
if (phraseCount === 0) return [];
|
||||
let segs = segments.map((s) => ({ ...s }));
|
||||
if (segs.length === 0) {
|
||||
return Array.from({ length: phraseCount }, (_, i) => ({ startMs: i * 200, endMs: i * 200 + 150 }));
|
||||
}
|
||||
|
||||
while (segs.length > phraseCount) {
|
||||
let minGap = Infinity;
|
||||
let idx = 0;
|
||||
for (let i = 0; i < segs.length - 1; i++) {
|
||||
const gap = segs[i + 1].startMs - segs[i].endMs;
|
||||
if (gap < minGap) {
|
||||
minGap = gap;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
segs[idx] = { startMs: segs[idx].startMs, endMs: segs[idx + 1].endMs };
|
||||
segs.splice(idx + 1, 1);
|
||||
}
|
||||
|
||||
while (segs.length < phraseCount) {
|
||||
let maxDur = -1;
|
||||
let idx = 0;
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const dur = segs[i].endMs - segs[i].startMs;
|
||||
if (dur > maxDur) {
|
||||
maxDur = dur;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
const seg = segs[idx];
|
||||
const mid = seg.startMs + (seg.endMs - seg.startMs) / 2;
|
||||
segs.splice(idx, 1, { startMs: seg.startMs, endMs: mid }, { startMs: mid, endMs: seg.endMs });
|
||||
}
|
||||
|
||||
return segs;
|
||||
}
|
||||
|
||||
function buildAudioAlignedTimeline(phrases: Phrase[], envelope: SpeechEnvelope): SpeechTimeline {
|
||||
const voicedSegments = detectVoicedSegments(envelope);
|
||||
const phraseWeights = phrases.map((p) => p.units.reduce((s, u) => s + u.weight, 0));
|
||||
const windows = reconcileSegments(voicedSegments, phrases.length);
|
||||
|
||||
const laidOut: LaidUnit[] = [];
|
||||
const pauseAtMs: number[] = [];
|
||||
const phraseInfos: SpeechPhraseInfo[] = [];
|
||||
const pauses: SpeechPauseWindow[] = [];
|
||||
|
||||
for (let i = 0; i < phrases.length; i++) {
|
||||
const phrase = phrases[i];
|
||||
const win = windows[i];
|
||||
if (!win || phrase.units.length === 0) {
|
||||
phraseInfos.push({
|
||||
index: i,
|
||||
startMs: win?.startMs ?? 0,
|
||||
endMs: win?.endMs ?? 0,
|
||||
kind: phrase.kind,
|
||||
syllableTargetsMs: [],
|
||||
stressedIndices: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const totalWeight = phraseWeights[i] || 1;
|
||||
const winDur = Math.max(1, win.endMs - win.startMs);
|
||||
let acc = win.startMs;
|
||||
const syllableTargetsMs: number[] = [];
|
||||
const ampWeights: number[] = [];
|
||||
for (const unit of phrase.units) {
|
||||
const slotDur = winDur * (unit.weight / totalWeight);
|
||||
const slotStart = acc;
|
||||
acc += slotDur;
|
||||
const nominalCenter = slotStart + slotDur / 2;
|
||||
const searchLo = Math.max(win.startMs, nominalCenter - slotDur * CORE_SNAP_RATIO);
|
||||
const searchHi = Math.min(win.endMs, nominalCenter + slotDur * CORE_SNAP_RATIO);
|
||||
const peakMs = findEnvelopePeakMs(envelope, searchLo, searchHi);
|
||||
const startMs = peakMs === null ? slotStart : slotStart + (peakMs - nominalCenter);
|
||||
const localKeys = localKeysFor(unit);
|
||||
laidOut.push({ startMs, durMs: slotDur, localKeys });
|
||||
const targetKey = localKeys.find((k) => k.isTarget);
|
||||
const targetMs = startMs + (targetKey?.frac ?? 0.35) * slotDur;
|
||||
syllableTargetsMs.push(targetMs);
|
||||
ampWeights.push(sampleEnvelope(envelope, targetMs));
|
||||
}
|
||||
phraseInfos.push({
|
||||
index: i,
|
||||
startMs: win.startMs,
|
||||
endMs: win.endMs,
|
||||
kind: phrase.kind,
|
||||
syllableTargetsMs,
|
||||
stressedIndices: selectStressedIndices(ampWeights),
|
||||
});
|
||||
if (i < windows.length - 1) {
|
||||
pauseAtMs.push(win.endMs);
|
||||
pauses.push({ startMs: win.endMs, endMs: windows[i + 1].startMs });
|
||||
}
|
||||
}
|
||||
|
||||
const totalMs = windows.length > 0 ? windows[windows.length - 1].endMs : 0;
|
||||
return finalize(laidOut, pauseAtMs, totalMs, envelope, voicedSegments, windows, phraseInfos, pauses);
|
||||
}
|
||||
|
||||
export interface BuildSpeechTimelineParams {
|
||||
text: string;
|
||||
/** 오디오가 없을 때 쓰는 발화 속도(음절/초). demeanor.speech.syllablesPerSec. */
|
||||
syllablesPerSec: number;
|
||||
/** 있으면 오디오 선분석 정렬 경로를 쓴다. */
|
||||
envelope?: SpeechEnvelope;
|
||||
}
|
||||
|
||||
export function buildSpeechTimeline(params: BuildSpeechTimelineParams): SpeechTimeline {
|
||||
const phrases = withWeights(tokenizeToPhrases(params.text));
|
||||
if (params.envelope) return buildAudioAlignedTimeline(phrases, params.envelope);
|
||||
return buildTextOnlyTimeline(phrases, params.syllablesPerSec);
|
||||
}
|
||||
|
||||
/* ── 표본(재생 중 매 프레임, 결정문 §5.4 "표본·평활") ──────────────────── */
|
||||
const LOOKAHEAD_MS = 60;
|
||||
const SILENCE_CLOSE_MS = 100;
|
||||
const MAX_SPEAKING_OPEN = 0.6;
|
||||
|
||||
function isDeepSilence(segments: VoicedSegment[], tMs: number, minMs: number): boolean {
|
||||
let prevEnd = 0;
|
||||
for (const seg of segments) {
|
||||
if (tMs >= seg.startMs && tMs <= seg.endMs) return false;
|
||||
if (seg.startMs > tMs) break;
|
||||
prevEnd = seg.endMs;
|
||||
}
|
||||
return tMs - prevEnd > minMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* timeline을 tMs(재생 위치, 오디오 시계 또는 엔진 시계) 기준으로 표본한다.
|
||||
* 60ms 앞당김·smoothstep 보간·짧은 음절 평균화(타임라인 빌드 시 반영됨)·크기 조절식·
|
||||
* 무음 100ms 닫힘·articulation 곱·open 상한 0.6까지 여기서 끝낸다.
|
||||
* 임계 감쇠 스프링(τ 45ms)은 포함하지 않는다 — engine.ts가 이 결과를 목표값으로 스프링한다.
|
||||
*/
|
||||
export function sampleSpeech(timeline: SpeechTimeline, tMs: number, articulation = 1): SpeechShape {
|
||||
const keys = timeline.keys;
|
||||
if (keys.length === 0) return cloneShape(SILENT_SHAPE);
|
||||
|
||||
const tQuery = tMs + LOOKAHEAD_MS;
|
||||
let shape: SpeechShape;
|
||||
if (tQuery <= keys[0].atMs) {
|
||||
shape = cloneShape(keys[0].shape);
|
||||
} else if (tQuery >= keys[keys.length - 1].atMs) {
|
||||
shape = cloneShape(keys[keys.length - 1].shape);
|
||||
} else {
|
||||
let i = 0;
|
||||
while (i < keys.length - 1 && keys[i + 1].atMs <= tQuery) i++;
|
||||
const a = keys[i];
|
||||
const b = keys[i + 1];
|
||||
const span = b.atMs - a.atMs;
|
||||
const u = span > 0 ? (tQuery - a.atMs) / span : 1;
|
||||
shape = lerpShape(a.shape, b.shape, smoothstep(u));
|
||||
}
|
||||
|
||||
if (timeline.envelope) {
|
||||
const env = sampleEnvelope(timeline.envelope, tQuery);
|
||||
const p90 = timeline.envelope.p90 || 1e-6;
|
||||
const scale = clamp(0.45 + 0.75 * (env / p90), 0.45, 1.1);
|
||||
shape = { ...shape, open: shape.open * scale };
|
||||
if (timeline.voicedSegments && isDeepSilence(timeline.voicedSegments, tQuery, SILENCE_CLOSE_MS)) {
|
||||
shape = cloneShape(SILENT_SHAPE);
|
||||
}
|
||||
}
|
||||
|
||||
const art = Number.isFinite(articulation) ? articulation : 1;
|
||||
return {
|
||||
open: Math.min(shape.open * art, MAX_SPEAKING_OPEN),
|
||||
wide: shape.wide * art,
|
||||
round: shape.round * art,
|
||||
press: shape.press,
|
||||
};
|
||||
}
|
||||
|
||||
/** tMs 시점의 "현재 비짐" 이름(가장 최근에 지난 키). Lab 표시·디버그용. */
|
||||
export function currentViseme(timeline: SpeechTimeline, tMs: number): VisemeId {
|
||||
const keys = timeline.keys;
|
||||
if (keys.length === 0) return "X";
|
||||
const tQuery = tMs + LOOKAHEAD_MS;
|
||||
if (tQuery <= keys[0].atMs) return keys[0].viseme;
|
||||
let i = 0;
|
||||
while (i < keys.length - 1 && keys[i + 1].atMs <= tQuery) i++;
|
||||
return keys[i].viseme;
|
||||
}
|
||||
|
||||
/** 타임라인의 비짐 이름을 순서대로(연속 중복 제거) 뽑는다. 검증·디버그용. */
|
||||
export function visemeSequence(timeline: SpeechTimeline): VisemeId[] {
|
||||
const out: VisemeId[] = [];
|
||||
for (const k of timeline.keys) {
|
||||
if (out[out.length - 1] !== k.viseme) out.push(k.viseme);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
203
apps/web/src/components/avatar/engine/performance.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 신호 → 공연 스크립트 — 결정문 §7 정본 규칙.
|
||||
괄호 지문 파서(§7.3), 상태 유도 보조 cue(§7.4), 겉표정 강도(§7.5)를
|
||||
여기서 구현한다. 렌더러는 이 모듈을 몰라도 되고, 엔진은 Performance
|
||||
타입만 소비한다.
|
||||
===================================================================== */
|
||||
|
||||
import { AVATAR_EXPRESSION_LIBRARY, type AvatarExpression } from "../persona";
|
||||
import { STAGE_DIRECTION_MAX_LENGTH, STAGE_DIRECTION_RULES } from "./stageDirectionLexicon";
|
||||
import { createRng, hashString } from "./rng";
|
||||
import type { ReactionClipId } from "./clipCatalog";
|
||||
|
||||
export type PerformanceAnchor = "listen" | "pre" | "inline" | "post";
|
||||
export type PerformanceCueSource = "direction" | "state" | "jev";
|
||||
|
||||
export interface PerformanceCue {
|
||||
clip: ReactionClipId;
|
||||
anchor: PerformanceAnchor;
|
||||
/** anchor=inline이면 발화 텍스트 내 비율(0..1), 그 외에는 앵커 기준 지연 ms. */
|
||||
at: number;
|
||||
/** 클립 delta 배율(0..1). 생략하면 1. */
|
||||
weight?: number;
|
||||
source: PerformanceCueSource;
|
||||
}
|
||||
|
||||
export interface Performance {
|
||||
surface: { expression: AvatarExpression; intensity: number };
|
||||
leak?: { expression: AvatarExpression; intensity: number; atMs: number; durationMs: number };
|
||||
cues: PerformanceCue[];
|
||||
}
|
||||
|
||||
/* voice.py _STAGE_DIRECTION_RE 와 같은 괄호 문자 집합(반각·전각 소괄호·대괄호). */
|
||||
const BRACKET_RE = /[(([【][^))\]】]*[))\]】]/g;
|
||||
/* 복합 지문 분리 구분자. */
|
||||
const SPLIT_RE = /[,,·、]/;
|
||||
/* 발화 가능 글자에서 제외하는 구두점(공백은 별도로 제외). */
|
||||
const SPEAKABLE_PUNCTUATION = new Set(["…", ".", ",", "?", "!", "~"]);
|
||||
|
||||
function countSpeakable(text: string): number {
|
||||
let count = 0;
|
||||
for (const ch of text) {
|
||||
if (/\s/.test(ch)) continue;
|
||||
if (SPEAKABLE_PUNCTUATION.has(ch)) continue;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 괄호 지문을 cue로 파싱한다(결정문 §7.3).
|
||||
* 대상은 내담자 발화 원문 전체다. 발화 가능 텍스트에서 지문 조각은 제거된 채로 남는다.
|
||||
*/
|
||||
export function parseStageDirections(text: string): {
|
||||
cues: PerformanceCue[];
|
||||
unmatched: string[];
|
||||
speakableText: string;
|
||||
} {
|
||||
const rawCues: PerformanceCue[] = [];
|
||||
const unmatched: string[] = [];
|
||||
|
||||
for (const match of text.matchAll(BRACKET_RE)) {
|
||||
const full = match[0];
|
||||
const start = match.index ?? 0;
|
||||
const end = start + full.length;
|
||||
const inner = full.slice(1, -1).trim();
|
||||
if (inner.length > STAGE_DIRECTION_MAX_LENGTH) continue;
|
||||
|
||||
const beforeText = text.slice(0, start).replace(BRACKET_RE, "");
|
||||
const afterText = text.slice(end).replace(BRACKET_RE, "");
|
||||
const beforeCount = countSpeakable(beforeText);
|
||||
const afterCount = countSpeakable(afterText);
|
||||
|
||||
let anchor: PerformanceAnchor;
|
||||
let at: number;
|
||||
if (beforeCount === 0) {
|
||||
anchor = "pre";
|
||||
at = 0;
|
||||
} else if (afterCount === 0) {
|
||||
anchor = "post";
|
||||
at = 0;
|
||||
} else {
|
||||
anchor = "inline";
|
||||
at = beforeCount / (beforeCount + afterCount);
|
||||
}
|
||||
|
||||
const pieces = inner
|
||||
.split(SPLIT_RE)
|
||||
.map((piece) => piece.trim())
|
||||
.filter((piece) => piece.length > 0);
|
||||
|
||||
for (const piece of pieces) {
|
||||
const rule = STAGE_DIRECTION_RULES.find((candidate) => candidate.pattern.test(piece));
|
||||
if (rule) {
|
||||
rawCues.push({ clip: rule.clip, anchor, at, weight: rule.weight, source: "direction" });
|
||||
} else {
|
||||
unmatched.push(piece);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 같은 클립이 연달아 나오면 하나로 합친다. */
|
||||
const merged: PerformanceCue[] = [];
|
||||
for (const cue of rawCues) {
|
||||
const prev = merged[merged.length - 1];
|
||||
if (prev && prev.clip === cue.clip) continue;
|
||||
merged.push(cue);
|
||||
}
|
||||
|
||||
return {
|
||||
cues: merged.slice(0, 3),
|
||||
unmatched,
|
||||
speakableText: text.replace(BRACKET_RE, "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
interface AuxCandidate {
|
||||
clip: ReactionClipId;
|
||||
anchor: PerformanceAnchor;
|
||||
at: number;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
function auxCandidatesFor(group: string | undefined, intensity: number): AuxCandidate[] {
|
||||
switch (group) {
|
||||
case "negative":
|
||||
if (intensity < 0.5) return [];
|
||||
return [
|
||||
{ clip: "look_down", anchor: "pre", at: 200 },
|
||||
{ clip: "lip_press", anchor: "post", at: 0 },
|
||||
];
|
||||
case "defensive":
|
||||
return [
|
||||
{ clip: "look_away_side", anchor: "inline", at: 0.3 },
|
||||
{ clip: "lean_back", anchor: "pre", at: 0 },
|
||||
];
|
||||
case "positive":
|
||||
return [{ clip: "nod_small", anchor: "inline", at: 0.4 }];
|
||||
case "energy":
|
||||
return [{ clip: "look_down", anchor: "pre", at: 0, weight: 0.6 }];
|
||||
case "cognitive":
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 겉표정 강도(결정문 §7.5, legacy 경로). */
|
||||
export function surfaceIntensityFor(params: {
|
||||
expression: AvatarExpression;
|
||||
openness: number;
|
||||
safety: boolean;
|
||||
paused: boolean;
|
||||
}): number {
|
||||
const { expression, openness, safety, paused } = params;
|
||||
if (safety) return 0.9;
|
||||
if (paused) return 0.6;
|
||||
const group = AVATAR_EXPRESSION_LIBRARY.find((item) => item.id === expression)?.group;
|
||||
const o = openness < 0 ? 0 : openness > 1 ? 1 : openness;
|
||||
switch (group) {
|
||||
case "negative":
|
||||
case "defensive":
|
||||
return 0.35 + 0.5 * (1 - o);
|
||||
case "positive":
|
||||
return 0.3 + 0.6 * o;
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/** 지문 파싱 + 상태 유도 보조 cue(§7.4)까지 합쳐 공연 스크립트를 만든다. */
|
||||
export function buildPerformance(params: {
|
||||
text: string;
|
||||
expression: AvatarExpression;
|
||||
intensity: number;
|
||||
openness: number;
|
||||
seed: number;
|
||||
}): { performance: Performance; unmatched: string[]; speakableText: string } {
|
||||
const { text, expression, intensity, seed } = params;
|
||||
const { cues, unmatched, speakableText } = parseStageDirections(text);
|
||||
|
||||
let finalCues = cues;
|
||||
if (cues.length === 0) {
|
||||
const rng = createRng(hashString(text) ^ seed);
|
||||
if (rng.next() < 0.6) {
|
||||
const group = AVATAR_EXPRESSION_LIBRARY.find((item) => item.id === expression)?.group;
|
||||
const candidates = auxCandidatesFor(group, intensity);
|
||||
if (candidates.length > 0) {
|
||||
const picked = rng.pick(candidates);
|
||||
finalCues = [{ ...picked, source: "state" }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
performance: { surface: { expression, intensity }, cues: finalCues },
|
||||
unmatched,
|
||||
speakableText,
|
||||
};
|
||||
}
|
||||
|
||||
/** 발화 시간 추정: max(800, 발화 가능 글자 수 × 90)ms. */
|
||||
export function estimateSpeechDurationMs(speakableText: string): number {
|
||||
return Math.max(800, countSpeakable(speakableText) * 90);
|
||||
}
|
||||
47
apps/web/src/components/avatar/engine/rng.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 엔진 전용 시드 난수 — mulberry32.
|
||||
엔진은 순수 함수여야 하므로(결정문 §3) Math.random을 직접 쓰지 않고
|
||||
이 모듈이 주입하는 rng를 통해서만 난수를 소비한다.
|
||||
===================================================================== */
|
||||
|
||||
export interface Rng {
|
||||
/** [0,1) 균등 분포. */
|
||||
next(): number;
|
||||
/** [a,b) 균등 분포. */
|
||||
range(a: number, b: number): number;
|
||||
/** 배열에서 균등하게 하나 고른다. */
|
||||
pick<T>(arr: readonly T[]): T;
|
||||
}
|
||||
|
||||
export function createRng(seed: number): Rng {
|
||||
let state = seed >>> 0;
|
||||
|
||||
function next(): number {
|
||||
state = (state + 0x6d2b79f5) | 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
return {
|
||||
next,
|
||||
range(a: number, b: number): number {
|
||||
return a + (b - a) * next();
|
||||
},
|
||||
pick<T>(arr: readonly T[]): T {
|
||||
const idx = Math.min(arr.length - 1, Math.floor(next() * arr.length));
|
||||
return arr[idx];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** FNV-1a 32bit 문자열 해시. */
|
||||
export function hashString(text: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
106
apps/web/src/components/avatar/engine/speechEnvelope.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 발화 포락선 — 결정문 §5.4 발화층 "타이밍(오디오가 있으면 선분석)".
|
||||
AudioBuffer 등 DOM 타입에 의존하지 않는 순수 함수만 둔다(테스트 가능해야 한다).
|
||||
호출부(Lab·Session)가 AudioBuffer에서 채널 Float32Array를 꺼내 넘긴다.
|
||||
===================================================================== */
|
||||
|
||||
export interface SpeechEnvelope {
|
||||
/** RMS 표본 간격(ms). 결정문 §5.4는 10ms. */
|
||||
hopMs: number;
|
||||
/** hop마다 하나씩, 모노 합성 RMS. */
|
||||
rms: Float32Array;
|
||||
/** 진폭 조절식(§5.4 표본·평활)에 쓰는 90퍼센타일. */
|
||||
p90: number;
|
||||
/** 유성 구간 임계값(§5.4 타이밍)에 쓰는 95퍼센타일. */
|
||||
p95: number;
|
||||
}
|
||||
|
||||
export interface VoicedSegment {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
function percentile(sortedAscending: ArrayLike<number>, p: number): number {
|
||||
const n = sortedAscending.length;
|
||||
if (n === 0) return 0;
|
||||
const idx = Math.min(n - 1, Math.max(0, Math.ceil(p * n) - 1));
|
||||
return sortedAscending[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* 오디오 채널 데이터(모노 Float32Array 1개 또는 채널별 배열)에서 10ms 간격 RMS 포락선을 만든다.
|
||||
* 다채널이면 표본마다 채널 평균으로 모노 합성한다(결정문 §5.4 "AudioBuffer 모노 합성").
|
||||
*/
|
||||
export function computeEnvelope(
|
||||
channelData: Float32Array | Float32Array[],
|
||||
sampleRate: number,
|
||||
hopMs = 10,
|
||||
): SpeechEnvelope {
|
||||
const channels = Array.isArray(channelData) ? channelData : [channelData];
|
||||
const length = channels[0]?.length ?? 0;
|
||||
const hopSamples = Math.max(1, Math.round((hopMs / 1000) * sampleRate));
|
||||
const hopCount = Math.max(1, Math.ceil(length / hopSamples));
|
||||
const rms = new Float32Array(hopCount);
|
||||
|
||||
for (let h = 0; h < hopCount; h++) {
|
||||
const start = h * hopSamples;
|
||||
const end = Math.min(length, start + hopSamples);
|
||||
let sumSq = 0;
|
||||
let n = 0;
|
||||
for (let i = start; i < end; i++) {
|
||||
let mono = 0;
|
||||
for (const ch of channels) mono += ch[i] ?? 0;
|
||||
mono /= channels.length;
|
||||
sumSq += mono * mono;
|
||||
n++;
|
||||
}
|
||||
rms[h] = n > 0 ? Math.sqrt(sumSq / n) : 0;
|
||||
}
|
||||
|
||||
const sorted = Array.from(rms).sort((a, b) => a - b);
|
||||
return { hopMs, rms, p90: percentile(sorted, 0.9), p95: percentile(sorted, 0.95) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 유성 구간 검출(결정문 §5.4): 포락선 > max(0.02, 0.12·P95)인 구간.
|
||||
* 120ms 미만 틈은 메우고, 60ms 미만 구간은 버린다.
|
||||
*/
|
||||
export function detectVoicedSegments(envelope: SpeechEnvelope): VoicedSegment[] {
|
||||
const { hopMs, rms, p95 } = envelope;
|
||||
const threshold = Math.max(0.02, 0.12 * p95);
|
||||
|
||||
const raw: VoicedSegment[] = [];
|
||||
let curStart = -1;
|
||||
for (let i = 0; i < rms.length; i++) {
|
||||
const above = rms[i] > threshold;
|
||||
if (above && curStart < 0) curStart = i;
|
||||
if (!above && curStart >= 0) {
|
||||
raw.push({ startMs: curStart * hopMs, endMs: i * hopMs });
|
||||
curStart = -1;
|
||||
}
|
||||
}
|
||||
if (curStart >= 0) raw.push({ startMs: curStart * hopMs, endMs: rms.length * hopMs });
|
||||
|
||||
const filled: VoicedSegment[] = [];
|
||||
for (const seg of raw) {
|
||||
const prev = filled[filled.length - 1];
|
||||
if (prev && seg.startMs - prev.endMs < 120) {
|
||||
prev.endMs = seg.endMs;
|
||||
} else {
|
||||
filled.push({ ...seg });
|
||||
}
|
||||
}
|
||||
|
||||
return filled.filter((seg) => seg.endMs - seg.startMs >= 60);
|
||||
}
|
||||
|
||||
/** tMs 시점의 포락선 값(선형 보간). */
|
||||
export function sampleEnvelope(envelope: SpeechEnvelope, tMs: number): number {
|
||||
const { hopMs, rms } = envelope;
|
||||
if (rms.length === 0) return 0;
|
||||
const idx = tMs / hopMs;
|
||||
const i0 = Math.max(0, Math.min(rms.length - 1, Math.floor(idx)));
|
||||
const i1 = Math.min(rms.length - 1, i0 + 1);
|
||||
const frac = idx - i0;
|
||||
return rms[i0] + (rms[i1] - rms[i0]) * frac;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 괄호 지문 어휘집 — 결정문 §7.3 정본.
|
||||
근거: 실제 LLM 발화 표본(scratch/jev, P1 40턴)·golden 합성 대화(data/golden/P4~P7)·
|
||||
캐릭터 카드 verbal_tics. 괄호는 지문 외 용도(인용·메모)로도 쓰이므로
|
||||
어휘집에 맞는 괄호만 반응으로 바꾸고 나머지는 무시한다.
|
||||
규칙은 위에서부터 검사해 처음 맞는 것 하나만 쓴다(구체 → 일반 순서 유지).
|
||||
어휘 추가·순서 변경은 설계 결정이다.
|
||||
===================================================================== */
|
||||
|
||||
import type { ReactionClipId } from "./clipCatalog";
|
||||
|
||||
export interface StageDirectionRule {
|
||||
/** 괄호 안쪽 텍스트(앞뒤 공백 제거)에 대한 패턴. */
|
||||
pattern: RegExp;
|
||||
clip: ReactionClipId;
|
||||
/** 클립 delta 배율(0..1). 생략하면 1. */
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
/** 괄호 안쪽이 이 길이를 넘으면 지문이 아닌 것으로 보고 무시한다. */
|
||||
export const STAGE_DIRECTION_MAX_LENGTH = 16;
|
||||
|
||||
export const STAGE_DIRECTION_RULES: readonly StageDirectionRule[] = [
|
||||
/* 눈물·울음 */
|
||||
{ pattern: /눈물|울먹|글썽|훌쩍|목이\s*메/, clip: "tear_up" },
|
||||
|
||||
/* 한숨·호흡 */
|
||||
{ pattern: /(옅은|작은|짧은)\s*한숨/, clip: "sigh", weight: 0.6 },
|
||||
{ pattern: /한숨/, clip: "sigh" },
|
||||
{ pattern: /심호흡|숨을\s*(고르|크게|깊이)|크게\s*숨/, clip: "deep_breath" },
|
||||
{ pattern: /침을\s*삼|마른침|꿀꺽/, clip: "swallow" },
|
||||
|
||||
/* 침묵·멈춤·주저 */
|
||||
{ pattern: /침묵/, clip: "silence_hold" },
|
||||
{ pattern: /멈칫|굳어|얼어/, clip: "freeze", weight: 0.7 },
|
||||
{ pattern: /(잠시|잠깐)?\s*멈춤|말끝을?\s*흐리/, clip: "look_down", weight: 0.7 },
|
||||
{ pattern: /머뭇|망설/, clip: "lip_press", weight: 0.6 },
|
||||
|
||||
/* 웃음 */
|
||||
{ pattern: /피식|코웃음|쓴웃음|헛웃음|비웃/, clip: "scoff" },
|
||||
{ pattern: /억지\s*(로\s*)?웃|억지\s*미소/, clip: "forced_smile" },
|
||||
{ pattern: /(어색|긴장|멋쩍|머쓱)[한은]?\s*웃음|웃음/, clip: "nervous_laugh" },
|
||||
|
||||
/* 고개 */
|
||||
{ pattern: /(크게|깊이|깊게)\s*끄덕/, clip: "nod_deep" },
|
||||
{ pattern: /끄덕/, clip: "nod_small" },
|
||||
{ pattern: /고개를?\s*(젓|저어|흔들)|도리도리/, clip: "head_shake" },
|
||||
{ pattern: /고개를?\s*(숙|떨|푹)|고개\s*떨굼/, clip: "head_drop" },
|
||||
{ pattern: /고개를?\s*(살짝\s*)?돌/, clip: "look_away_side" },
|
||||
|
||||
/* 시선 */
|
||||
{ pattern: /시선을?\s*(피|돌)|눈을?\s*(피|돌)|딴\s*곳|창밖/, clip: "look_away_side" },
|
||||
{ pattern: /시선을?\s*(내리|아래)|바닥을?\s*(보|내려)|내려다/, clip: "look_down" },
|
||||
{ pattern: /힐끗|흘끗|쳐다|눈을?\s*맞/, clip: "glance_up" },
|
||||
{ pattern: /눈을?\s*굴리/, clip: "eye_roll_small" },
|
||||
{ pattern: /눈을?\s*(여러\s*번\s*)?깜빡/, clip: "blink_flurry" },
|
||||
|
||||
/* 몸 */
|
||||
{ pattern: /움찔|흠칫|놀라/, clip: "flinch" },
|
||||
{ pattern: /으쓱/, clip: "shrug" },
|
||||
{ pattern: /뒤로\s*기대|등을?\s*기대|몸을?\s*뒤로|팔짱/, clip: "lean_back" },
|
||||
{ pattern: /몸을?\s*앞으로|다가앉|앞으로\s*기울/, clip: "lean_forward" },
|
||||
{ pattern: /만지작|꼼지락|안절부절|다리를?\s*떨|손톱/, clip: "fidget_sway" },
|
||||
|
||||
/* 입 */
|
||||
{ pattern: /입술을?\s*깨물|입술\s*깨묾/, clip: "lip_bite" },
|
||||
{ pattern: /이를?\s*악물|주먹을?\s*쥐/, clip: "jaw_clench" },
|
||||
{ pattern: /입술을?\s*(꾹\s*)?다물|입을?\s*(꾹\s*)?다물/, clip: "lip_press" },
|
||||
];
|
||||
215
apps/web/src/components/avatar/v3/DebugFace.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/* =====================================================================
|
||||
DebugFace — 아바타 v3 엔진 채널 33개를 전부 눈으로 검증하는 선 기반 SVG 리그.
|
||||
아트 품질이 목적이 아니라 채널→시각 매핑 확인용이다(결정문 §9 1단계).
|
||||
매 프레임 React state를 갱신하지 않고 ref로 SVG 속성을 직접 쓴다.
|
||||
===================================================================== */
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { AvatarEngine } from "../engine/engine";
|
||||
import type { ChannelVector } from "../engine/channels";
|
||||
import "./debug-face.css";
|
||||
|
||||
export interface DebugFaceProps {
|
||||
engine: AvatarEngine;
|
||||
running: boolean;
|
||||
nowMs?: () => number;
|
||||
}
|
||||
|
||||
type Refs = Record<string, SVGGraphicsElement | null>;
|
||||
|
||||
const CX = 130;
|
||||
const HEAD_CY = 150;
|
||||
|
||||
function eyePathD(cx: number, cy: number, openness: number, smile: number, lidTension: number): string {
|
||||
const rx = 20;
|
||||
const ry = 14;
|
||||
const o = Math.max(0, Math.min(1.3, openness));
|
||||
const tensionAdj = 1 - lidTension * 0.4;
|
||||
const upperY = cy - ry * 1.05 * o * tensionAdj;
|
||||
const lowerY = cy + ry * 0.5 * o * (1 - Math.max(0, smile) * 0.7) * tensionAdj;
|
||||
return `M ${cx - rx} ${cy} Q ${cx} ${upperY} ${cx + rx} ${cy} Q ${cx} ${lowerY} ${cx - rx} ${cy} Z`;
|
||||
}
|
||||
|
||||
function browPathD(innerX: number, outerX: number, baseY: number, y: number, angle: number, x: number, form: number): string {
|
||||
const innerY = baseY - y * 18 - angle * 14;
|
||||
const outerY = baseY - y * 18 + angle * 8;
|
||||
const innerXAdj = innerX - x * 6;
|
||||
const midX = (innerXAdj + outerX) / 2;
|
||||
const midY = (innerY + outerY) / 2 - form * 10;
|
||||
return `M ${outerX} ${outerY} Q ${midX} ${midY} ${innerXAdj} ${innerY}`;
|
||||
}
|
||||
|
||||
function mouthPathD(v: ChannelVector): string {
|
||||
const cy = 192;
|
||||
/* mouthRound(§4 — 2026-09-30 립싱크 설계 채널): 오므린 입("ㅗ·ㅜ")을 입 폭을 줄이고
|
||||
세로로 둥글려 간단히 반영한다. 정밀한 리그 규칙(§8.4)은 LinocutAvatar가 맡는다. */
|
||||
const round = Math.max(0, Math.min(1, v.mouthRound));
|
||||
const halfWidth = Math.max(6, 26 + v.mouthStretch * 16 - round * 14);
|
||||
const leftX = CX - halfWidth;
|
||||
const rightX = CX + halfWidth;
|
||||
const cornerLift = v.mouthForm * 18;
|
||||
const asym = v.mouthAsym * 12;
|
||||
const leftCornerY = cy - cornerLift - asym;
|
||||
const rightCornerY = cy - cornerLift + asym;
|
||||
const restGap = Math.max(1.5, 5 - v.mouthPress * 3.2) + round * 6;
|
||||
const openGap = v.mouthOpen * 26;
|
||||
const upperCtrlY = cy - restGap - openGap * 0.5;
|
||||
const lowerCtrlY = cy + restGap + openGap;
|
||||
return `M ${leftX} ${leftCornerY} Q ${CX} ${upperCtrlY} ${rightX} ${rightCornerY} Q ${CX} ${lowerCtrlY} ${leftX} ${leftCornerY} Z`;
|
||||
}
|
||||
|
||||
function renderFrame(refs: Refs, v: ChannelVector): void {
|
||||
const body = refs.body;
|
||||
if (body) {
|
||||
const scaleY = 1 + v.bodyLean * 0.04;
|
||||
const translateX = v.bodyYaw * 6;
|
||||
body.setAttribute(
|
||||
"transform",
|
||||
`translate(${translateX} ${-v.bodyLean * 6}) rotate(${v.bodyRoll * 6} ${CX} 300) scale(1 ${scaleY})`,
|
||||
);
|
||||
}
|
||||
|
||||
const shoulder = refs.shoulder;
|
||||
if (shoulder) shoulder.setAttribute("transform", `translate(0 ${-v.shoulderRaise * 12})`);
|
||||
|
||||
const head = refs.head;
|
||||
if (head) {
|
||||
const translateX = v.headYaw * 14;
|
||||
const translateY = -v.headPitch * 14;
|
||||
head.setAttribute("transform", `translate(${translateX} ${translateY}) rotate(${v.headRoll * 10} ${CX} ${HEAD_CY})`);
|
||||
}
|
||||
|
||||
const face = refs.face;
|
||||
if (face) {
|
||||
const squish = 1 - Math.abs(v.headYaw) * 0.15;
|
||||
face.setAttribute("transform", `translate(${v.headYaw * 4} 0) scale(${squish} 1)`);
|
||||
}
|
||||
|
||||
const eyeLeft = refs["eye-left"] as SVGPathElement | null;
|
||||
if (eyeLeft) eyeLeft.setAttribute("d", eyePathD(96, 140, v.eyeOpenL, v.eyeSmileL, v.lidTension));
|
||||
const eyeRight = refs["eye-right"] as SVGPathElement | null;
|
||||
if (eyeRight) eyeRight.setAttribute("d", eyePathD(164, 140, v.eyeOpenR, v.eyeSmileR, v.lidTension));
|
||||
|
||||
const irisLeft = refs["iris-left"];
|
||||
if (irisLeft) irisLeft.setAttribute("transform", `translate(${v.gazeX * 8} ${v.gazeY * 6})`);
|
||||
const irisRight = refs["iris-right"];
|
||||
if (irisRight) irisRight.setAttribute("transform", `translate(${v.gazeX * 8} ${v.gazeY * 6})`);
|
||||
|
||||
const pupilLeft = refs["pupil-left"] as SVGCircleElement | null;
|
||||
if (pupilLeft) pupilLeft.setAttribute("r", String(Math.max(1, 3 * (1 + v.pupilSize * 0.5))));
|
||||
const pupilRight = refs["pupil-right"] as SVGCircleElement | null;
|
||||
if (pupilRight) pupilRight.setAttribute("r", String(Math.max(1, 3 * (1 + v.pupilSize * 0.5))));
|
||||
|
||||
const browLeft = refs["brow-left"] as SVGPathElement | null;
|
||||
if (browLeft) browLeft.setAttribute("d", browPathD(112, 72, 110, v.browLY, v.browLAngle, v.browLX, v.browForm));
|
||||
const browRight = refs["brow-right"] as SVGPathElement | null;
|
||||
if (browRight) browRight.setAttribute("d", browPathD(148, 188, 110, v.browRY, v.browRAngle, -v.browRX, v.browForm));
|
||||
|
||||
const mouth = refs.mouth as SVGPathElement | null;
|
||||
if (mouth) mouth.setAttribute("d", mouthPathD(v));
|
||||
|
||||
const chin = refs.chin;
|
||||
if (chin) chin.setAttribute("opacity", String(Math.max(0, Math.min(1, v.chinRaise))));
|
||||
|
||||
const cheekLeft = refs["cheek-left"];
|
||||
if (cheekLeft) cheekLeft.setAttribute("opacity", String(Math.max(0, Math.min(1, v.cheek)) * 0.85));
|
||||
const cheekRight = refs["cheek-right"];
|
||||
if (cheekRight) cheekRight.setAttribute("opacity", String(Math.max(0, Math.min(1, v.cheek)) * 0.85));
|
||||
|
||||
const pallor = refs.pallor;
|
||||
if (pallor) pallor.setAttribute("opacity", String(Math.max(0, Math.min(1, v.pallor)) * 0.55));
|
||||
|
||||
const tearLeft = refs["tear-left"];
|
||||
if (tearLeft) {
|
||||
const pool = Math.max(0, Math.min(1, v.tear / 0.3));
|
||||
const flow = Math.max(0, (v.tear - 0.3) / 0.7);
|
||||
tearLeft.setAttribute("opacity", String(pool));
|
||||
tearLeft.setAttribute("transform", `translate(0 0) scale(${1 + flow * 1.6})`);
|
||||
}
|
||||
const tearRight = refs["tear-right"];
|
||||
if (tearRight) {
|
||||
const pool = Math.max(0, Math.min(1, v.tear / 0.3));
|
||||
const flow = Math.max(0, (v.tear - 0.3) / 0.7);
|
||||
tearRight.setAttribute("opacity", String(pool));
|
||||
tearRight.setAttribute("transform", `translate(0 0) scale(${1 + flow * 1.6})`);
|
||||
}
|
||||
|
||||
const sweat = refs.sweat;
|
||||
if (sweat) sweat.setAttribute("opacity", String(Math.max(0, Math.min(1, v.sweat))));
|
||||
|
||||
const breath = refs.breath;
|
||||
if (breath) breath.setAttribute("transform", `scale(${0.85 + v.breath * 0.3})`);
|
||||
}
|
||||
|
||||
function setRef(refs: Refs, name: string) {
|
||||
return (el: SVGGraphicsElement | null) => {
|
||||
refs[name] = el;
|
||||
};
|
||||
}
|
||||
|
||||
export default function DebugFace({ engine, running, nowMs }: DebugFaceProps) {
|
||||
const refs = useRef<Refs>({});
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const clock = nowMs ?? (() => performance.now());
|
||||
const loop = () => {
|
||||
const v = engine.evaluate(clock());
|
||||
renderFrame(refs.current, v);
|
||||
rafRef.current = requestAnimationFrame(loop);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [engine, running, nowMs]);
|
||||
|
||||
return (
|
||||
<svg className="df" viewBox="0 0 260 340" role="img" aria-label="아바타 채널 디버그 리그" data-debug-face="true">
|
||||
<g data-part="body" ref={setRef(refs.current, "body")}>
|
||||
<rect x="70" y="230" width="120" height="90" rx="26" className="df__torso" data-part="body-torso" />
|
||||
<g data-part="shoulder" ref={setRef(refs.current, "shoulder")}>
|
||||
<rect x="52" y="222" width="156" height="26" rx="13" className="df__shoulder" />
|
||||
</g>
|
||||
|
||||
<g data-part="head" ref={setRef(refs.current, "head")}>
|
||||
<circle cx={CX} cy={HEAD_CY} r="72" className="df__skull" />
|
||||
|
||||
<g data-part="face" ref={setRef(refs.current, "face")}>
|
||||
<path ref={setRef(refs.current, "brow-left")} data-part="brow-left" className="df__brow" />
|
||||
<path ref={setRef(refs.current, "brow-right")} data-part="brow-right" className="df__brow" />
|
||||
|
||||
<g data-part="eye-group-left">
|
||||
<path ref={setRef(refs.current, "eye-left")} data-part="eye-left" className="df__eye" />
|
||||
<g ref={setRef(refs.current, "iris-left")}>
|
||||
<circle cx="96" cy="140" r="7" className="df__iris" />
|
||||
<circle ref={setRef(refs.current, "pupil-left")} cx="96" cy="140" r="3" className="df__pupil" />
|
||||
</g>
|
||||
<ellipse ref={setRef(refs.current, "tear-left")} data-part="tear-left" cx="96" cy="156" rx="3.5" ry="5" className="df__tear" />
|
||||
</g>
|
||||
|
||||
<g data-part="eye-group-right">
|
||||
<path ref={setRef(refs.current, "eye-right")} data-part="eye-right" className="df__eye" />
|
||||
<g ref={setRef(refs.current, "iris-right")}>
|
||||
<circle cx="164" cy="140" r="7" className="df__iris" />
|
||||
<circle ref={setRef(refs.current, "pupil-right")} cx="164" cy="140" r="3" className="df__pupil" />
|
||||
</g>
|
||||
<ellipse ref={setRef(refs.current, "tear-right")} data-part="tear-right" cx="164" cy="156" rx="3.5" ry="5" className="df__tear" />
|
||||
</g>
|
||||
|
||||
<circle ref={setRef(refs.current, "cheek-left")} data-part="cheek-left" cx="86" cy="172" r="14" className="df__cheek" />
|
||||
<circle ref={setRef(refs.current, "cheek-right")} data-part="cheek-right" cx="174" cy="172" r="14" className="df__cheek" />
|
||||
|
||||
<path ref={setRef(refs.current, "sweat")} data-part="sweat" d="M 182 108 q -4 8 0 14 q 4 -6 0 -14 Z" className="df__sweat" />
|
||||
|
||||
<path ref={setRef(refs.current, "mouth")} data-part="mouth" className="df__mouth" />
|
||||
<line ref={setRef(refs.current, "chin")} data-part="chin" x1="118" y1="216" x2="142" y2="216" className="df__chin" />
|
||||
|
||||
<ellipse ref={setRef(refs.current, "pallor")} data-part="pallor" cx={CX} cy={HEAD_CY} rx="70" ry="70" className="df__pallor" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<rect ref={setRef(refs.current, "breath")} data-part="breath" x="120" y="326" width="20" height="8" rx="4" className="df__breath" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
1089
apps/web/src/components/avatar/v3/LinocutAvatar.tsx
Normal file
100
apps/web/src/components/avatar/v3/LinocutMotif.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/* =====================================================================
|
||||
LinocutMotif — 결정문 §8.2 상징 모티프층·§8.4 배경 색면·모티프(2단계-B-1b
|
||||
판정으로 벡터→래스터 스프라이트 전환). 아바타 옆 작은 층으로 둔다.
|
||||
immersive는 개방도를 반영하지 않는다(budOpenness=null). 정적 SVG + CSS
|
||||
크로스페이드/keyframes로 구성해 React state를 프레임마다 갱신하지 않는다.
|
||||
===================================================================== */
|
||||
|
||||
import type { BackdropGroup, LinocutRig } from "./linocutRig";
|
||||
import "./linocut-motif.css";
|
||||
|
||||
export type MotifFeedbackMode = "immersive" | "ambient" | "coached";
|
||||
|
||||
export interface LinocutMotifProps {
|
||||
rig: LinocutRig;
|
||||
group: BackdropGroup;
|
||||
/** null이면 immersive 고정(0.25). ambient·coached는 실제 개방도 값. */
|
||||
budOpenness: number | null;
|
||||
/** 고개 숙임 조건이 모드마다 달라 필요하다. */
|
||||
feedbackMode: MotifFeedbackMode;
|
||||
reducedMotion: boolean;
|
||||
/** px 너비. 기본 96. */
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const WEATHER_GROUPS: readonly BackdropGroup[] = ["positive", "negative", "defensive", "cognitive", "energy"];
|
||||
|
||||
/** 개방도 o(0..1)를 닫힘·반쯤·활짝 가중치로 섞는다. */
|
||||
function budWeights(o: number): { closed: number; half: number; open: number } {
|
||||
if (o <= 0.5) return { closed: 1 - 2 * o, half: 2 * o, open: 0 };
|
||||
return { closed: 0, half: 2 - 2 * o, open: 2 * o - 1 };
|
||||
}
|
||||
|
||||
export default function LinocutMotif({
|
||||
rig,
|
||||
group,
|
||||
budOpenness,
|
||||
feedbackMode,
|
||||
reducedMotion,
|
||||
size = 96,
|
||||
className,
|
||||
}: LinocutMotifProps) {
|
||||
const motif = rig.motif;
|
||||
const openness = budOpenness ?? 0.25;
|
||||
const budAttr = budOpenness === null ? "static" : openness.toFixed(2);
|
||||
|
||||
/* immersive: negative면 숙인다. ambient·coached: negative이면서 개방도<0.35일 때만
|
||||
숙인다(우울한 P1은 negative가 잦아, 조건 없이 숙이면 개방도 신호가 가려진다). */
|
||||
const droop = feedbackMode === "immersive" ? group === "negative" : group === "negative" && openness < 0.35;
|
||||
const pose: "upright" | "droop" = droop ? "droop" : "upright";
|
||||
|
||||
if (!motif) return null;
|
||||
|
||||
const weights = budWeights(openness);
|
||||
const weatherCanvas = motif.weather.canvas;
|
||||
const budCanvas = motif.bud.canvas;
|
||||
const totalW = Math.max(weatherCanvas.w, budCanvas.w);
|
||||
const totalH = weatherCanvas.h + budCanvas.h;
|
||||
const budX = (totalW - budCanvas.w) / 2;
|
||||
const weatherX = (totalW - weatherCanvas.w) / 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
data-motif="bud-weather"
|
||||
data-motif-weather={group}
|
||||
data-motif-bud={budAttr}
|
||||
data-motif-pose={pose}
|
||||
data-reduced-motion={reducedMotion}
|
||||
className={className ? `linocut-motif ${className}` : "linocut-motif"}
|
||||
width={size}
|
||||
viewBox={`0 0 ${totalW} ${totalH}`}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g className="linocut-motif__weather">
|
||||
{WEATHER_GROUPS.map((g) => (
|
||||
<image
|
||||
key={g}
|
||||
href={motif.weather.sprites[g]}
|
||||
x={weatherX}
|
||||
y={0}
|
||||
width={weatherCanvas.w}
|
||||
height={weatherCanvas.h}
|
||||
className={`linocut-motif__weather-sprite linocut-motif__weather-${g}`}
|
||||
opacity={g === group ? 1 : 0}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
<g className="linocut-motif__bud-upright" transform={`translate(${budX} ${weatherCanvas.h})`}>
|
||||
<image href={motif.bud.closed} width={budCanvas.w} height={budCanvas.h} opacity={weights.closed} />
|
||||
<image href={motif.bud.half} width={budCanvas.w} height={budCanvas.h} opacity={weights.half} />
|
||||
<image href={motif.bud.open} width={budCanvas.w} height={budCanvas.h} opacity={weights.open} />
|
||||
</g>
|
||||
<g className="linocut-motif__bud-droop" transform={`translate(${budX} ${weatherCanvas.h})`}>
|
||||
<image href={motif.bud.droop} width={budCanvas.w} height={budCanvas.h} />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
83
apps/web/src/components/avatar/v3/debug-face.css
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/* =====================================================================
|
||||
DebugFace 스타일 — 아바타 v3 채널 디버그 리그(아트 품질 아님, 도식용).
|
||||
===================================================================== */
|
||||
|
||||
.df {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
aspect-ratio: 260 / 340;
|
||||
background: #f4efe6;
|
||||
border: 1px solid #d8cfbf;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.df__torso {
|
||||
fill: #7c8a92;
|
||||
}
|
||||
|
||||
.df__shoulder {
|
||||
fill: #6b7a82;
|
||||
}
|
||||
|
||||
.df__skull {
|
||||
fill: #f0ddc4;
|
||||
stroke: #cbb494;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.df__brow {
|
||||
fill: none;
|
||||
stroke: #4a3626;
|
||||
stroke-width: 4;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.df__eye {
|
||||
fill: #ffffff;
|
||||
stroke: #4a3626;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.df__iris {
|
||||
fill: #7a5a3c;
|
||||
}
|
||||
|
||||
.df__pupil {
|
||||
fill: #241a12;
|
||||
}
|
||||
|
||||
.df__mouth {
|
||||
fill: #a5473f;
|
||||
stroke: #6c2c26;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.df__chin {
|
||||
stroke: #cbb494;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.df__cheek {
|
||||
fill: #e78a8a;
|
||||
}
|
||||
|
||||
.df__sweat {
|
||||
fill: #bfe0f5;
|
||||
stroke: #8fbede;
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.df__tear {
|
||||
fill: #bfe0f5;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.df__pallor {
|
||||
fill: #d9d9df;
|
||||
}
|
||||
|
||||
.df__breath {
|
||||
fill: #9fb7a8;
|
||||
transform-origin: center;
|
||||
}
|
||||
83
apps/web/src/components/avatar/v3/linocut-avatar.css
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/* =====================================================================
|
||||
linocut-avatar.css — LinocutAvatar 렌더러 스타일. 결정문 §8.4.
|
||||
배경 전환 1.8s·figure 페이드 0.2s는 reduced motion에서 즉시 전환한다.
|
||||
===================================================================== */
|
||||
|
||||
.linocut-avatar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.linocut-avatar__backdrop {
|
||||
transition: fill 1.8s ease;
|
||||
}
|
||||
|
||||
.linocut-avatar__figure {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.linocut-avatar__figure[data-ready="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.linocut-avatar__backdrop {
|
||||
transition: none;
|
||||
}
|
||||
.linocut-avatar__figure {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 결·주름 등 순수 스트로크 요소 — 불투명도는 채널값에 따라 JS가 직접 쓴다. */
|
||||
.linocut-avatar__wrinkle,
|
||||
.linocut-avatar__crease,
|
||||
.linocut-avatar__lower-lid,
|
||||
.linocut-avatar__tear-pool,
|
||||
.linocut-avatar__iris-radial {
|
||||
fill: none;
|
||||
stroke-width: 1.2;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
/* 이마·팔자 주름 — 두께가 변하는 리본(채운 다각형). 불투명도는 JS가 직접 쓴다. */
|
||||
.linocut-avatar__wrinkle-ribbon {
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.linocut-avatar__tear-flow {
|
||||
stroke-width: 1;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* 눈썹 몸체 위로 삐져나온 털 획 — 정적 불투명도(채널 무관). */
|
||||
.linocut-avatar__brow-hair {
|
||||
fill: none;
|
||||
stroke-width: 1.3;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 아랫입술 아래 그늘 초승달 해칭(입체감). */
|
||||
.linocut-avatar__lip-shadow-crescent {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* 벌어졌을 때(g>0.5) 아랫입술 쪽에 보이는 가는 보조 선. */
|
||||
.linocut-avatar__mouth-line-secondary {
|
||||
fill: none;
|
||||
stroke-width: 1;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.linocut-avatar__pallor-color {
|
||||
mix-blend-mode: color;
|
||||
}
|
||||
|
||||
.linocut-avatar__grain {
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
94
apps/web/src/components/avatar/v3/linocut-motif.css
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/* =====================================================================
|
||||
linocut-motif.css — LinocutMotif 스타일(2단계-B-1b 판정으로 래스터
|
||||
스프라이트 전환). 날씨 1.5s 크로스페이드 + 약한 움직임(해 회전, 비구름·
|
||||
안개 가로 흔들림), 봉오리는 개방도 3장 크로스페이드 + 자세(곧음/숙임)
|
||||
순차 페이드. reduced motion(prop 또는 OS 설정)에서는 전부 정지한다.
|
||||
===================================================================== */
|
||||
|
||||
.linocut-motif {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.linocut-motif__weather-sprite {
|
||||
opacity: 0;
|
||||
transition: opacity 1.5s ease;
|
||||
}
|
||||
|
||||
/* 봉오리 개방도 3장(닫힘·반쯤·활짝) — 가중치 크로스페이드. */
|
||||
.linocut-motif__bud-upright image {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* 자세(곧음/숙임) 순차 페이드: 사라지는 쪽 0.6s, 나타나는 쪽 0.4s부터 0.9s까지. */
|
||||
.linocut-motif__bud-upright,
|
||||
.linocut-motif__bud-droop {
|
||||
opacity: 0;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
.linocut-motif[data-motif-pose="upright"] .linocut-motif__bud-upright,
|
||||
.linocut-motif[data-motif-pose="droop"] .linocut-motif__bud-droop {
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s ease 0.4s;
|
||||
}
|
||||
|
||||
@keyframes linocut-motif-sun-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.linocut-motif__weather-positive {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: linocut-motif-sun-spin 60s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes linocut-motif-weather-sway {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(-2.5px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(2.5px);
|
||||
}
|
||||
}
|
||||
|
||||
.linocut-motif__weather-negative {
|
||||
animation: linocut-motif-weather-sway 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.linocut-motif__weather-defensive {
|
||||
animation: linocut-motif-weather-sway 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* energy(초승달·별)·cognitive(옅은 안개결)는 정지. */
|
||||
|
||||
.linocut-motif[data-reduced-motion="true"] .linocut-motif__weather-sprite,
|
||||
.linocut-motif[data-reduced-motion="true"] .linocut-motif__bud-upright,
|
||||
.linocut-motif[data-reduced-motion="true"] .linocut-motif__bud-droop {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.linocut-motif[data-reduced-motion="true"] .linocut-motif__weather-positive,
|
||||
.linocut-motif[data-reduced-motion="true"] .linocut-motif__weather-negative,
|
||||
.linocut-motif[data-reduced-motion="true"] .linocut-motif__weather-defensive {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.linocut-motif__weather-sprite,
|
||||
.linocut-motif__bud-upright,
|
||||
.linocut-motif__bud-droop {
|
||||
transition: none;
|
||||
}
|
||||
.linocut-motif__weather-positive,
|
||||
.linocut-motif__weather-negative,
|
||||
.linocut-motif__weather-defensive {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
1622
apps/web/src/components/avatar/v3/linocutGeometry.ts
Normal file
138
apps/web/src/components/avatar/v3/linocutRig.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/* =====================================================================
|
||||
리노컷 리그 v3 데이터 계약 — 결정문 §8.2. 오케스트레이터 작성(워커 수정 금지).
|
||||
페르소나 리그 데이터(`rigs/*Rig.ts`)는 자산 파이프라인 스크립트가 생성하고,
|
||||
렌더러는 이 타입만 읽는다.
|
||||
좌표는 모두 캔버스 픽셀(원점 좌상단, y 아래로 증가)이다.
|
||||
좌우 표기는 화면 기준이다: Left = 작은 x. 엔진 채널의 L/R(eyeOpenL 등)도
|
||||
화면 기준이며 DebugFace와 같다.
|
||||
===================================================================== */
|
||||
|
||||
export type RigPoint = readonly [number, number];
|
||||
|
||||
/** [x, y, w, h] — 캔버스 좌표 사각형. */
|
||||
export type RigRect = readonly [number, number, number, number];
|
||||
|
||||
export interface RigLayer {
|
||||
/** public 기준 URL(예: `/avatar/v3/p1/head.webp`). */
|
||||
href: string;
|
||||
/** 이미지가 놓이는 캔버스 사각형. 파일 해상도와 무관하게 이 크기로 그린다. */
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface RigEye {
|
||||
inner: RigPoint;
|
||||
outer: RigPoint;
|
||||
upperLidTop: RigPoint;
|
||||
lowerLidBottom: RigPoint;
|
||||
iris: { center: RigPoint; radius: number };
|
||||
}
|
||||
|
||||
/** 눈썹 잉크 띠의 **중심선** 위 세 점(결정문 §8.4). 띠의 윗가장자리·아래 모서리를 찍으면
|
||||
수평에 가까운 원화 눈썹이 사선으로 그려져 중립이 화난 얼굴이 된다(2단계-B 3차 판정). */
|
||||
export interface RigBrow {
|
||||
inner: RigPoint;
|
||||
peak: RigPoint;
|
||||
outer: RigPoint;
|
||||
}
|
||||
|
||||
export interface RigLandmarks {
|
||||
eyeLeft: RigEye;
|
||||
eyeRight: RigEye;
|
||||
browLeft: RigBrow;
|
||||
browRight: RigBrow;
|
||||
noseTip: RigPoint;
|
||||
mouthCornerLeft: RigPoint;
|
||||
mouthCornerRight: RigPoint;
|
||||
/** 윗입술 윗선 중앙. */
|
||||
upperLipTop: RigPoint;
|
||||
/** 아랫입술 아랫선 중앙. */
|
||||
lowerLipBottom: RigPoint;
|
||||
/** 두 입술이 만나는 선의 중앙. */
|
||||
mouthCenter: RigPoint;
|
||||
chinTip: RigPoint;
|
||||
}
|
||||
|
||||
export type RigCrop = "portrait" | "bust" | "face";
|
||||
|
||||
/** 배경 색면·날씨 모티프 그룹 = 겉표정 그룹(`persona.ts` AVATAR_EXPRESSION_LIBRARY group). */
|
||||
export type BackdropGroup = "positive" | "negative" | "defensive" | "cognitive" | "energy";
|
||||
|
||||
export interface RigPalette {
|
||||
ink: string;
|
||||
sclera: string;
|
||||
iris: string;
|
||||
irisRing: string;
|
||||
lipUpper: string;
|
||||
lipLower: string;
|
||||
mouthLine: string;
|
||||
mouthInner: string;
|
||||
teeth: string;
|
||||
blush: string;
|
||||
tear: string;
|
||||
pallor: string;
|
||||
paper: string;
|
||||
motifPetal: string;
|
||||
motifLeaf: string;
|
||||
}
|
||||
|
||||
/** 상징 모티프 스프라이트(결정문 §8.4). 좌표는 각 스프라이트 캔버스 픽셀이다. */
|
||||
export interface RigMotif {
|
||||
bud: {
|
||||
/** 봉오리 스프라이트 공통 캔버스. 4장 모두 줄기 밑동이 base에 맞춰져 있다. */
|
||||
canvas: { w: number; h: number };
|
||||
base: RigPoint;
|
||||
closed: string;
|
||||
half: string;
|
||||
open: string;
|
||||
droop: string;
|
||||
};
|
||||
weather: {
|
||||
canvas: { w: number; h: number };
|
||||
sprites: Record<BackdropGroup, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LinocutRig {
|
||||
schemaVersion: "vignette.avatar.v3.rig.v1";
|
||||
persona: string;
|
||||
canvas: { w: number; h: number };
|
||||
layers: {
|
||||
body: RigLayer;
|
||||
head: RigLayer;
|
||||
hairFront: RigLayer;
|
||||
/** 눈두덩·다크서클·입가 음영과 점. 표정 부위 구멍은 피부로 메워져 있다. */
|
||||
faceDetail?: RigLayer;
|
||||
};
|
||||
/** 종이결 타일(곱하기 합성). size는 캔버스 단위 타일 한 변. */
|
||||
grain?: { href: string; size: number };
|
||||
/** 원화 입술 결 스프라이트(결정문 §8.4 입). 입 선·입꼬리 잉크를 입술 결로 메우고 가장자리를
|
||||
24px 연장했다. 렌더러가 현재 입술 면으로 clip하고 입술 기준점 이동·입 폭 비율만큼 옮기고 늘린다.
|
||||
없으면 입술을 palette 단색으로 채운다. */
|
||||
lipTexture?: {
|
||||
upper: RigLayer;
|
||||
lower: RigLayer;
|
||||
/** 아랫입술 아래 그늘(원화 픽셀). 입 모양을 따라 가로로 줄지 않고 턱 변위만 따른다. */
|
||||
shadow?: RigLayer;
|
||||
};
|
||||
/** 하관 띠 변형용으로 잘라 낸 head·faceDetail 조각(결정문 §8.4). 원본 레이어와 픽셀이 같다. */
|
||||
jaw?: { head: RigLayer; detail: RigLayer };
|
||||
pivots: {
|
||||
/** 고개 회전 중심(목). */
|
||||
neck: RigPoint;
|
||||
/** 몸 기울임·호흡 중심(하단 중앙). */
|
||||
body: RigPoint;
|
||||
/** 얼굴 부위 시차·압축 중심. */
|
||||
face: RigPoint;
|
||||
};
|
||||
crops: Record<RigCrop, RigRect>;
|
||||
/** 얼굴 윤곽 다각형(표정 부위 clip). */
|
||||
faceOval: readonly RigPoint[];
|
||||
landmarks: RigLandmarks;
|
||||
palette: RigPalette;
|
||||
backdrop: Record<BackdropGroup, string>;
|
||||
/** 없으면 모티프를 그리지 않는다. */
|
||||
motif?: RigMotif;
|
||||
}
|
||||
104
apps/web/src/components/avatar/v3/rigs/p1Rig.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/* 생성 파일 — docs/avatar-art/p1-linocut/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */
|
||||
import type { LinocutRig } from "../linocutRig";
|
||||
|
||||
export const P1_LINOCUT_RIG: LinocutRig = {
|
||||
schemaVersion: "vignette.avatar.v3.rig.v1",
|
||||
persona: "P1",
|
||||
canvas: { w: 1005, h: 1566 },
|
||||
layers: {
|
||||
body: { href: "/avatar/v3/p1/body.webp", x: 0, y: 807, w: 1005, h: 759 },
|
||||
head: { href: "/avatar/v3/p1/head.webp", x: 111, y: 72, w: 849, h: 1049 },
|
||||
hairFront: { href: "/avatar/v3/p1/hair-front.webp", x: 260, y: 391, w: 491, h: 516 },
|
||||
faceDetail: { href: "/avatar/v3/p1/face-detail.webp", x: 284, y: 444, w: 437, h: 478 },
|
||||
},
|
||||
grain: { href: "/avatar/v3/p1/paper-grain.webp", size: 256 },
|
||||
lipTexture: {
|
||||
upper: { href: "/avatar/v3/p1/lip-upper.webp", x: 404, y: 758, w: 182, h: 54 },
|
||||
lower: { href: "/avatar/v3/p1/lip-lower.webp", x: 404, y: 798, w: 182, h: 49 },
|
||||
shadow: { href: "/avatar/v3/p1/lip-shadow.webp", x: 414, y: 802, w: 162, h: 53 },
|
||||
},
|
||||
jaw: {
|
||||
head: { href: "/avatar/v3/p1/jaw-head.webp", x: 260, y: 653, w: 500, h: 307 },
|
||||
detail: { href: "/avatar/v3/p1/jaw-detail.webp", x: 260, y: 653, w: 500, h: 307 },
|
||||
},
|
||||
motif: {
|
||||
bud: {
|
||||
canvas: { w: 280, h: 420 },
|
||||
base: [134.2, 399],
|
||||
closed: "/avatar/v3/p1/motif/bud-closed.webp",
|
||||
half: "/avatar/v3/p1/motif/bud-half.webp",
|
||||
open: "/avatar/v3/p1/motif/bud-open.webp",
|
||||
droop: "/avatar/v3/p1/motif/bud-droop.webp",
|
||||
},
|
||||
weather: {
|
||||
canvas: { w: 320, h: 200 },
|
||||
sprites: {
|
||||
positive: "/avatar/v3/p1/motif/weather-positive.webp",
|
||||
negative: "/avatar/v3/p1/motif/weather-negative.webp",
|
||||
defensive: "/avatar/v3/p1/motif/weather-defensive.webp",
|
||||
cognitive: "/avatar/v3/p1/motif/weather-cognitive.webp",
|
||||
energy: "/avatar/v3/p1/motif/weather-energy.webp",
|
||||
},
|
||||
},
|
||||
},
|
||||
pivots: {
|
||||
neck: [500, 990],
|
||||
body: [502, 1566],
|
||||
face: [490, 660],
|
||||
},
|
||||
crops: {
|
||||
portrait: [0, 0, 1005, 1566],
|
||||
bust: [0, 40, 1005, 1005],
|
||||
face: [204.7, 360.6, 601.4, 601.4],
|
||||
},
|
||||
faceOval: [[481, 392.8], [551.8, 393.6], [612.4, 402.3], [669.7, 421.4], [707.5, 450.3], [731.2, 487.1], [743.8, 526.1], [748.5, 573.7], [745.6, 619.2], [739.1, 666.9], [727.3, 716.7], [709.7, 769.5], [687, 813.6], [661.3, 846.5], [629.6, 874.9], [603.3, 894.4], [574.9, 912.1], [542.1, 926], [500.6, 929.7], [461.5, 924.7], [432.5, 910.1], [408, 891.9], [384.7, 872.6], [356.2, 844.9], [333.7, 812.9], [312.5, 770.3], [294.2, 718.1], [281, 668.9], [273, 622.3], [265.1, 577.6], [262.2, 530.6], [267.6, 491.4], [282.1, 455.1], [310, 426.1], [357.1, 405.8], [412.2, 395.7]],
|
||||
landmarks: {
|
||||
eyeLeft: {
|
||||
inner: [430.9, 592.2],
|
||||
outer: [339.1, 578.8],
|
||||
upperLidTop: [376.3, 564.3],
|
||||
lowerLidBottom: [381.1, 598.2],
|
||||
iris: { center: [387.3, 579.1], radius: 21.4 },
|
||||
},
|
||||
eyeRight: {
|
||||
inner: [552.8, 590.9],
|
||||
outer: [648, 575.5],
|
||||
upperLidTop: [607.1, 562.1],
|
||||
lowerLidBottom: [603.9, 595],
|
||||
iris: { center: [601.9, 576.2], radius: 21.3 },
|
||||
},
|
||||
browLeft: { inner: [450.6, 525.1], peak: [360.1, 510.4], outer: [313.4, 513.3] },
|
||||
browRight: { inner: [518.2, 521.7], peak: [626.8, 508.2], outer: [682.8, 510.7] },
|
||||
noseTip: [486.6, 713],
|
||||
mouthCornerLeft: [422.1, 804.7],
|
||||
mouthCornerRight: [567.5, 805.8],
|
||||
upperLipTop: [489.8, 773.4],
|
||||
lowerLipBottom: [493, 831.5],
|
||||
mouthCenter: [491.6, 797.5],
|
||||
chinTip: [500.6, 919.5],
|
||||
},
|
||||
palette: {
|
||||
ink: "#1E1F1F",
|
||||
sclera: "#D8CEBD",
|
||||
iris: "#4F3B2C",
|
||||
irisRing: "#1E1F1F",
|
||||
lipUpper: "#97684B",
|
||||
lipLower: "#AB7857",
|
||||
mouthLine: "#292421",
|
||||
mouthInner: "#3B201B",
|
||||
teeth: "#E9E0CF",
|
||||
blush: "#C0624A",
|
||||
tear: "#EEE5D3",
|
||||
pallor: "#9AA3A6",
|
||||
paper: "#EEE5D3",
|
||||
motifPetal: "#D0A362",
|
||||
motifLeaf: "#53626C",
|
||||
},
|
||||
backdrop: {
|
||||
cognitive: "#ECE3D1",
|
||||
positive: "#F1DEC2",
|
||||
negative: "#DCE0E2",
|
||||
defensive: "#E6DAD3",
|
||||
energy: "#E2E0D0",
|
||||
},
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
197
apps/web/src/pages/avatar-lab.css
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
.al {
|
||||
padding: 24px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
color: #2a231b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.al__head h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.al__head p {
|
||||
margin: 0 0 20px;
|
||||
color: #6b5f50;
|
||||
}
|
||||
|
||||
.al__controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #ddd2bd;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.al__controls label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b5f50;
|
||||
}
|
||||
|
||||
.al__toggle {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
.al__stage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.al__renderer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid #ddd2bd;
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
color: #6b5f50;
|
||||
}
|
||||
|
||||
.al__linocut-main {
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.al__preview-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.al__preview-cell {
|
||||
flex: none;
|
||||
background: #f3ead9;
|
||||
}
|
||||
|
||||
.al__preview-cell--bust-circle {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.al__preview-cell--face-96 {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.al__preview-cell--face-48 {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.al__preview-cell--portrait {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.al__panel {
|
||||
margin-bottom: 28px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ddd2bd;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.al__panel h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.al__intensity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: #6b5f50;
|
||||
}
|
||||
|
||||
.al__grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.al__chip {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ccbfa4;
|
||||
border-radius: 999px;
|
||||
background: #fbf7ef;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.al__chip--active {
|
||||
background: #6b5e7d;
|
||||
color: #fff;
|
||||
border-color: #6b5e7d;
|
||||
}
|
||||
|
||||
.al__speech-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccbfa4;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.al__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.al__parse-result ul {
|
||||
margin: 4px 0 12px;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.al__meter {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.al__meter th,
|
||||
.al__meter td {
|
||||
border-bottom: 1px solid #eee2cd;
|
||||
padding: 3px 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.al__audio-file {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.al__audio-file-name {
|
||||
font-size: 12px;
|
||||
color: #6b5f50;
|
||||
}
|
||||
|
||||
.al__speech-shape {
|
||||
margin-bottom: 12px;
|
||||
max-width: 360px;
|
||||
}
|
||||