diff --git a/apps/web/e2e/avatar-lab.spec.ts b/apps/web/e2e/avatar-lab.spec.ts new file mode 100644 index 0000000..7aefafe --- /dev/null +++ b/apps/web/e2e/avatar-lab.spec.ts @@ -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"); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index ce0ed7e..aef1bfe 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/public/avatar/v3/p1/body.webp b/apps/web/public/avatar/v3/p1/body.webp new file mode 100644 index 0000000..5c49c59 Binary files /dev/null and b/apps/web/public/avatar/v3/p1/body.webp differ diff --git a/apps/web/public/avatar/v3/p1/face-detail.webp b/apps/web/public/avatar/v3/p1/face-detail.webp new file mode 100644 index 0000000..057708c Binary files /dev/null and b/apps/web/public/avatar/v3/p1/face-detail.webp differ diff --git a/apps/web/public/avatar/v3/p1/hair-front.webp b/apps/web/public/avatar/v3/p1/hair-front.webp new file mode 100644 index 0000000..b85a4ee Binary files /dev/null and b/apps/web/public/avatar/v3/p1/hair-front.webp differ diff --git a/apps/web/public/avatar/v3/p1/head.webp b/apps/web/public/avatar/v3/p1/head.webp new file mode 100644 index 0000000..346f41b Binary files /dev/null and b/apps/web/public/avatar/v3/p1/head.webp differ diff --git a/apps/web/public/avatar/v3/p1/jaw-detail.webp b/apps/web/public/avatar/v3/p1/jaw-detail.webp new file mode 100644 index 0000000..2da4468 Binary files /dev/null and b/apps/web/public/avatar/v3/p1/jaw-detail.webp differ diff --git a/apps/web/public/avatar/v3/p1/jaw-head.webp b/apps/web/public/avatar/v3/p1/jaw-head.webp new file mode 100644 index 0000000..0105f94 Binary files /dev/null and b/apps/web/public/avatar/v3/p1/jaw-head.webp differ diff --git a/apps/web/public/avatar/v3/p1/lip-lower.webp b/apps/web/public/avatar/v3/p1/lip-lower.webp new file mode 100644 index 0000000..a47ca0a Binary files /dev/null and b/apps/web/public/avatar/v3/p1/lip-lower.webp differ diff --git a/apps/web/public/avatar/v3/p1/lip-shadow.webp b/apps/web/public/avatar/v3/p1/lip-shadow.webp new file mode 100644 index 0000000..c8999a7 Binary files /dev/null and b/apps/web/public/avatar/v3/p1/lip-shadow.webp differ diff --git a/apps/web/public/avatar/v3/p1/lip-upper.webp b/apps/web/public/avatar/v3/p1/lip-upper.webp new file mode 100644 index 0000000..b1af9e0 Binary files /dev/null and b/apps/web/public/avatar/v3/p1/lip-upper.webp differ diff --git a/apps/web/public/avatar/v3/p1/motif/bud-closed.webp b/apps/web/public/avatar/v3/p1/motif/bud-closed.webp new file mode 100644 index 0000000..cb01ec2 Binary files /dev/null and b/apps/web/public/avatar/v3/p1/motif/bud-closed.webp differ diff --git a/apps/web/public/avatar/v3/p1/motif/bud-droop.webp b/apps/web/public/avatar/v3/p1/motif/bud-droop.webp new file mode 100644 index 0000000..a774453 Binary files /dev/null and b/apps/web/public/avatar/v3/p1/motif/bud-droop.webp differ diff --git a/apps/web/public/avatar/v3/p1/motif/bud-half.webp b/apps/web/public/avatar/v3/p1/motif/bud-half.webp new file mode 100644 index 0000000..2ba1e43 Binary files /dev/null and b/apps/web/public/avatar/v3/p1/motif/bud-half.webp differ diff --git a/apps/web/public/avatar/v3/p1/motif/bud-open.webp b/apps/web/public/avatar/v3/p1/motif/bud-open.webp new file mode 100644 index 0000000..f25d2e6 Binary files /dev/null and b/apps/web/public/avatar/v3/p1/motif/bud-open.webp differ diff --git a/apps/web/public/avatar/v3/p1/motif/weather-cognitive.webp b/apps/web/public/avatar/v3/p1/motif/weather-cognitive.webp new file mode 100644 index 0000000..cb97a4c Binary files /dev/null and b/apps/web/public/avatar/v3/p1/motif/weather-cognitive.webp differ diff --git a/apps/web/public/avatar/v3/p1/motif/weather-defensive.webp b/apps/web/public/avatar/v3/p1/motif/weather-defensive.webp new file mode 100644 index 0000000..8165437 Binary files /dev/null and b/apps/web/public/avatar/v3/p1/motif/weather-defensive.webp differ diff --git a/apps/web/public/avatar/v3/p1/motif/weather-energy.webp b/apps/web/public/avatar/v3/p1/motif/weather-energy.webp new file mode 100644 index 0000000..67edd2e Binary files /dev/null and b/apps/web/public/avatar/v3/p1/motif/weather-energy.webp differ diff --git a/apps/web/public/avatar/v3/p1/motif/weather-negative.webp b/apps/web/public/avatar/v3/p1/motif/weather-negative.webp new file mode 100644 index 0000000..04237ca Binary files /dev/null and b/apps/web/public/avatar/v3/p1/motif/weather-negative.webp differ diff --git a/apps/web/public/avatar/v3/p1/motif/weather-positive.webp b/apps/web/public/avatar/v3/p1/motif/weather-positive.webp new file mode 100644 index 0000000..202d3da Binary files /dev/null and b/apps/web/public/avatar/v3/p1/motif/weather-positive.webp differ diff --git a/apps/web/public/avatar/v3/p1/paper-grain.webp b/apps/web/public/avatar/v3/p1/paper-grain.webp new file mode 100644 index 0000000..19a1e7c Binary files /dev/null and b/apps/web/public/avatar/v3/p1/paper-grain.webp differ diff --git a/apps/web/scripts/check-avatar-lipsync.mjs b/apps/web/scripts/check-avatar-lipsync.mjs new file mode 100644 index 0000000..b8f2200 --- /dev/null +++ b/apps/web/scripts/check-avatar-lipsync.mjs @@ -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)", +); diff --git a/apps/web/scripts/check-avatar-presets.mjs b/apps/web/scripts/check-avatar-presets.mjs new file mode 100644 index 0000000..64ffefa --- /dev/null +++ b/apps/web/scripts/check-avatar-presets.mjs @@ -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}개, 지문 표본 검증 완료)`, +); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c9a8f8a..628d032 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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: 인증 없는 아바타 컴포지션 튜닝 페이지 (실서비스 아님) */} } /> + {/* dev: 인증 없는 아바타 v3 표현 엔진 Lab (실서비스 아님) */} + } /> + ; + +export type ChannelId = keyof typeof CHANNEL_SPECS; +export type ChannelVector = Record; +export type ChannelDelta = Partial>; + +export const CHANNEL_IDS = Object.keys(CHANNEL_SPECS) as ChannelId[]; + +/** 강도에 따라 켜지는 오버레이 채널과 최소 강도(결정문 §5.2). */ +export const OVERLAY_MIN_INTENSITY: Partial> = { + 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; + +export type SymmetricKey = keyof typeof SYMMETRIC_KEYS; +export type AuthoringKey = ChannelId | SymmetricKey; +export type AuthoringDelta = Partial>; + +/** 대칭 키를 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 = [ + ["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; +} diff --git a/apps/web/src/components/avatar/engine/clipCatalog.ts b/apps/web/src/components/avatar/engine/clipCatalog.ts new file mode 100644 index 0000000..baf337d --- /dev/null +++ b/apps/web/src/components/avatar/engine/clipCatalog.ts @@ -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>; +} + +export interface ReactionClip extends Omit { + id: ReactionClipId; + tracks: Partial>; +} + +/** 시작 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 = { + 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> = {}; + 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 = Object.fromEntries( + REACTION_CLIP_IDS.map((id) => { + const { tracks, ...rest } = CLIP_SPECS[id]; + return [id, { id, ...rest, tracks: expandTracks(tracks) }]; + }), +) as Record; diff --git a/apps/web/src/components/avatar/engine/clipPlayer.ts b/apps/web/src/components/avatar/engine/clipPlayer.ts new file mode 100644 index 0000000..43d6956 --- /dev/null +++ b/apps/web/src/components/avatar/engine/clipPlayer.ts @@ -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; +} diff --git a/apps/web/src/components/avatar/engine/coSpeech.ts b/apps/web/src/components/avatar/engine/coSpeech.ts new file mode 100644 index 0000000..4d28d8c --- /dev/null +++ b/apps/web/src/components/avatar/engine/coSpeech.ts @@ -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): 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); +} diff --git a/apps/web/src/components/avatar/engine/demeanorDefaults.ts b/apps/web/src/components/avatar/engine/demeanorDefaults.ts new file mode 100644 index 0000000..658309c --- /dev/null +++ b/apps/web/src/components/avatar/engine/demeanorDefaults.ts @@ -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; + /** 개방도가 이 값 미만일 때만 발동. 생략하면 제한 없음. */ + 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 & { 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 = { P1, P2, P3, P4, P5, P6, P7 }; + +export function demeanorFor(personaCode: string | null | undefined): Demeanor { + return (personaCode && DEMEANORS[personaCode.toUpperCase()]) || DEFAULT_DEMEANOR; +} diff --git a/apps/web/src/components/avatar/engine/engine.ts b/apps/web/src/components/avatar/engine/engine.ts new file mode 100644 index 0000000..33269c3 --- /dev/null +++ b/apps/web/src/components/avatar/engine/engine.ts @@ -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> = {}; + 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(); + 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 { + const out = new Set(); + 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, + }; +} diff --git a/apps/web/src/components/avatar/engine/expressionPresets.ts b/apps/web/src/components/avatar/engine/expressionPresets.ts new file mode 100644 index 0000000..f79583a --- /dev/null +++ b/apps/web/src/components/avatar/engine/expressionPresets.ts @@ -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 = { + 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 = Object.fromEntries( + Object.entries(PRESET_SPECS).map(([id, spec]) => [id, expandAuthoring(spec)]), +) as Record; + +/** 강도 → 적용 비율(결정문 §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; +} diff --git a/apps/web/src/components/avatar/engine/lipsync.ts b/apps/web/src/components/avatar/engine/lipsync.ts new file mode 100644 index 0000000..4c74ca1 --- /dev/null +++ b/apps/web/src/components/avatar/engine/lipsync.ts @@ -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> = { + 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; +} diff --git a/apps/web/src/components/avatar/engine/performance.ts b/apps/web/src/components/avatar/engine/performance.ts new file mode 100644 index 0000000..1156495 --- /dev/null +++ b/apps/web/src/components/avatar/engine/performance.ts @@ -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); +} diff --git a/apps/web/src/components/avatar/engine/rng.ts b/apps/web/src/components/avatar/engine/rng.ts new file mode 100644 index 0000000..b599330 --- /dev/null +++ b/apps/web/src/components/avatar/engine/rng.ts @@ -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(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(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; +} diff --git a/apps/web/src/components/avatar/engine/speechEnvelope.ts b/apps/web/src/components/avatar/engine/speechEnvelope.ts new file mode 100644 index 0000000..cc32ccb --- /dev/null +++ b/apps/web/src/components/avatar/engine/speechEnvelope.ts @@ -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, 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; +} diff --git a/apps/web/src/components/avatar/engine/stageDirectionLexicon.ts b/apps/web/src/components/avatar/engine/stageDirectionLexicon.ts new file mode 100644 index 0000000..b5a7aec --- /dev/null +++ b/apps/web/src/components/avatar/engine/stageDirectionLexicon.ts @@ -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" }, +]; diff --git a/apps/web/src/components/avatar/v3/DebugFace.tsx b/apps/web/src/components/avatar/v3/DebugFace.tsx new file mode 100644 index 0000000..7ad2955 --- /dev/null +++ b/apps/web/src/components/avatar/v3/DebugFace.tsx @@ -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; + +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({}); + const rafRef = useRef(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 ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/components/avatar/v3/LinocutAvatar.tsx b/apps/web/src/components/avatar/v3/LinocutAvatar.tsx new file mode 100644 index 0000000..8352635 --- /dev/null +++ b/apps/web/src/components/avatar/v3/LinocutAvatar.tsx @@ -0,0 +1,1089 @@ +/* ===================================================================== + LinocutAvatar — P1 리노컷 렌더러(결정문 §8.4). 단일 SVG, 레이어 이미지와 + 벡터 부위가 rig 좌표계(canvas)를 공유한다. rAF에서 engine.evaluate()를 + 불러 linocutGeometry의 순수 함수로 프레임을 계산하고 ref로 SVG 속성만 + 직접 쓴다(DebugFace와 같은 방식, 매 프레임 React state 갱신 없음). + 로드 상태만 React state로 관리한다(§8.4 로드 실패 규칙). + ===================================================================== */ + +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import type { AvatarEngine } from "../engine/engine"; +import { AVATAR_EXPRESSION_LIBRARY, type AvatarExpression } from "../persona"; +import type { BackdropGroup, LinocutRig, RigCrop } from "./linocutRig"; +import { + computeLinocutFrame, + faceOvalPointsAttr, + shrunkFaceOvalPointsAttr, + lipStripClipRect, + LIP_STRIP_COUNT, + jawWarpStripRects, + jawWarpStripClipPolygons, + jawWarpHeadHoleClipD, + jawWarpUpperBoundaryY, + type LinocutFrame, + type JawWarpLayer, +} from "./linocutGeometry"; +import "./linocut-avatar.css"; + +export interface LinocutAvatarProps { + rig: LinocutRig; + engine: AvatarEngine; + running: boolean; + nowMs?: () => number; + crop?: RigCrop; + backdropExpression?: AvatarExpression; + texture?: boolean; + label?: string; + className?: string; + onLoadStateChange?: (state: "loading" | "ready" | "error") => void; +} + +type Refs = Record; +type LoadState = "loading" | "ready" | "error"; + +function setRef(refs: Refs, name: string) { + return (el: SVGGraphicsElement | null) => { + refs[name] = el; + }; +} + +function groupFor(expression: AvatarExpression): BackdropGroup { + return AVATAR_EXPRESSION_LIBRARY.find((e) => e.id === expression)?.group ?? "cognitive"; +} + +/** 혀 그늘 색(§5.4 렌더러 규칙: "mouthInner를 밝게 섞어 정한 뒤 보고"). mouthInner를 + 흰색 쪽으로 35% 섞은 밝은 적갈색 — 2단계-B-1d-R6 선택, 보고 완료. */ +function lightenHex(hex: string, amount: number): string { + const n = parseInt(hex.replace("#", ""), 16); + const r = (n >> 16) & 0xff; + const g = (n >> 8) & 0xff; + const b = n & 0xff; + const mix = (c: number) => Math.round(c + (255 - c) * amount); + return `rgb(${mix(r)}, ${mix(g)}, ${mix(b)})`; +} + +function applyEye(refs: Refs, side: "left" | "right", eye: LinocutFrame["eyeLeft"]): void { + const p = `eye-${side}`; + const outline = refs[`${p}-outline`] as SVGPathElement | null; + if (outline) outline.setAttribute("d", eye.outlineD); + const clipD = refs[`${side}-clip-d`] as SVGPathElement | null; + if (clipD) clipD.setAttribute("d", eye.outlineD); + const upperLid = refs[`${p}-upper-lid`] as SVGPathElement | null; + if (upperLid) upperLid.setAttribute("d", eye.upperLidD); + const crease = refs[`${p}-crease`] as SVGPathElement | null; + if (crease) { + crease.setAttribute("d", eye.creaseD); + crease.setAttribute("opacity", String(eye.creaseOpacity)); + } + const lowerLid = refs[`${p}-lower-lid`] as SVGPathElement | null; + if (lowerLid) lowerLid.setAttribute("d", eye.lowerLidD); + const shadow = refs[`${p}-shadow`] as SVGPathElement | null; + if (shadow) shadow.setAttribute("d", eye.shadowD); + + const irisRing = refs[`${p}-iris-ring`] as SVGCircleElement | null; + if (irisRing) { + irisRing.setAttribute("cx", String(eye.irisCx)); + irisRing.setAttribute("cy", String(eye.irisCy)); + irisRing.setAttribute("r", String(Math.max(0, eye.irisRadius - eye.irisRingWidth / 2))); + irisRing.setAttribute("stroke-width", String(eye.irisRingWidth)); + } + const pupil = refs[`${p}-pupil`] as SVGCircleElement | null; + if (pupil) { + pupil.setAttribute("cx", String(eye.irisCx)); + pupil.setAttribute("cy", String(eye.irisCy)); + pupil.setAttribute("r", String(eye.pupilRadius)); + } + const highlight = refs[`${p}-highlight`] as SVGCircleElement | null; + if (highlight) { + highlight.setAttribute("cx", String(eye.highlightCx)); + highlight.setAttribute("cy", String(eye.highlightCy)); + highlight.setAttribute("r", String(eye.highlightRadius)); + } + const radial = refs[`${p}-radial`] as SVGPathElement | null; + if (radial) radial.setAttribute("d", eye.radialStrokesD); + + const tearGroup = refs[`tear-${side}`]; + if (tearGroup) tearGroup.setAttribute("opacity", String(eye.tearPoolOpacity)); + const tearPool = refs[`${p}-tear-pool`] as SVGPathElement | null; + if (tearPool) tearPool.setAttribute("d", eye.tearPoolD); + const tearDrop = refs[`${p}-tear-drop`] as SVGEllipseElement | null; + if (tearDrop) { + tearDrop.setAttribute("cx", String(eye.tearDropCx)); + tearDrop.setAttribute("cy", String(eye.tearDropCy)); + tearDrop.setAttribute("rx", String(eye.tearDropR)); + tearDrop.setAttribute("ry", String(eye.tearDropR * 1.3)); + } + const tearFlow = refs[`${p}-tear-flow`] as SVGPathElement | null; + if (tearFlow) { + tearFlow.setAttribute("d", eye.tearFlowD); + tearFlow.setAttribute("opacity", String(eye.tearFlowOpacity)); + } + const tearFlowDrop = refs[`${p}-tear-flow-drop`] as SVGEllipseElement | null; + if (tearFlowDrop) { + tearFlowDrop.setAttribute("cx", String(eye.tearFlowDropCx)); + tearFlowDrop.setAttribute("cy", String(eye.tearFlowDropCy)); + tearFlowDrop.setAttribute("rx", String(eye.tearFlowDropR)); + tearFlowDrop.setAttribute("ry", String(eye.tearFlowDropR * 1.2)); + tearFlowDrop.setAttribute("opacity", String(eye.tearFlowOpacity)); + } +} + +/** 하관 띠 변형(§8.4) 레이어 하나(head 또는 faceDetail) 적용. max|f|<0.3px(negligible)이면 + 그룹을 숨기고 개별 띠 속성은 건드리지 않는다("쉬는 동안 비용 0"). */ +function applyJawWarpLayer(refs: Refs, prefix: string, layer: JawWarpLayer | null): void { + const group = refs[`${prefix}-group`]; + if (!group) return; + if (!layer || layer.negligible) { + group.setAttribute("opacity", "0"); + return; + } + group.setAttribute("opacity", "1"); + layer.strips.forEach((strip, i) => { + const img = refs[`${prefix}-strip-${i}`] as SVGImageElement | null; + if (img) img.setAttribute("transform", `matrix(1 0 0 ${strip.scaleY} 0 ${strip.translateY})`); + }); +} + +/** R10(§8.4 개정, out4·out5 반려 대응): faceDetail은 대부분 투명한 덧칠 레이어라, + 하관 띠가 켜진 동안 원본 전체를 그대로 두면 변형된 사본과 겹쳐 턱선이 원래 + 자리와 내려간 자리에 두 겹(점선)으로 보인다. negligible(쉬는 동안)이면 원본 + 전체(face-detail-full)를 보이고, 아니면 변형 영역(y > y_n)을 뺀 사본 + (face-detail-upper)으로 바꾼다. */ +function applyFaceDetailSplit(refs: Refs, negligible: boolean): void { + const full = refs["face-detail-full"]; + if (full) full.setAttribute("opacity", negligible ? "1" : "0"); + const upper = refs["face-detail-upper"]; + if (upper) upper.setAttribute("opacity", negligible ? "0" : "1"); +} + +/** R13(§8.4 개정, out9 jaw-zoom 반려 대응): head-image도 faceDetail과 같은 이유로 + 원본 전체(head-image-full)/띠가 덮는 영역만 뚫은 사본(head-image-masked) 중 + negligible 여부로 하나만 보인다. */ +function applyHeadImageSplit(refs: Refs, negligible: boolean): void { + const full = refs["head-image-full"]; + if (full) full.setAttribute("opacity", negligible ? "1" : "0"); + const masked = refs["head-image-masked"]; + if (masked) masked.setAttribute("opacity", negligible ? "0" : "1"); +} + +function applyFrame(refs: Refs, frame: LinocutFrame): void { + const figure = refs.figure; + if (figure) figure.setAttribute("transform", frame.transforms.figure); + const body = refs.body; + if (body) body.setAttribute("transform", frame.transforms.body); + const head = refs.head; + if (head) head.setAttribute("transform", frame.transforms.head); + const face = refs.face; + if (face) face.setAttribute("transform", frame.transforms.face); + const hairFront = refs["hair-front"]; + if (hairFront) hairFront.setAttribute("transform", frame.transforms.hairFront); + + applyEye(refs, "left", frame.eyeLeft); + applyEye(refs, "right", frame.eyeRight); + + const browLeft = refs["brow-left"] as SVGPathElement | null; + if (browLeft) browLeft.setAttribute("d", frame.browLeft.bodyD); + const browLeftHair = refs["brow-left-hair"] as SVGPathElement | null; + if (browLeftHair) { + browLeftHair.setAttribute("d", frame.browLeft.hairStrokesD); + browLeftHair.setAttribute("stroke-width", String(frame.browLeft.hairStrokeWidth)); + } + const browRight = refs["brow-right"] as SVGPathElement | null; + if (browRight) browRight.setAttribute("d", frame.browRight.bodyD); + const browRightHair = refs["brow-right-hair"] as SVGPathElement | null; + if (browRightHair) { + browRightHair.setAttribute("d", frame.browRight.hairStrokesD); + browRightHair.setAttribute("stroke-width", String(frame.browRight.hairStrokeWidth)); + } + const browLeftShadow = refs["brow-left-shadow"] as SVGEllipseElement | null; + if (browLeftShadow) { + browLeftShadow.setAttribute("cx", String(frame.browLeft.shadowCx)); + browLeftShadow.setAttribute("cy", String(frame.browLeft.shadowCy)); + browLeftShadow.setAttribute("opacity", String(frame.browLeft.shadowOpacity)); + } + const browRightShadow = refs["brow-right-shadow"] as SVGEllipseElement | null; + if (browRightShadow) { + browRightShadow.setAttribute("cx", String(frame.browRight.shadowCx)); + browRightShadow.setAttribute("cy", String(frame.browRight.shadowCy)); + browRightShadow.setAttribute("opacity", String(frame.browRight.shadowOpacity)); + } + + const glabella = refs.glabella as SVGPathElement | null; + if (glabella) { + glabella.setAttribute("d", frame.glabella.paths.join(" ")); + glabella.setAttribute("opacity", String(frame.glabella.opacity)); + if (frame.glabella.strokeWidth !== undefined) glabella.setAttribute("stroke-width", String(frame.glabella.strokeWidth)); + } + const foreheadInner = refs["forehead-inner"] as SVGPathElement | null; + if (foreheadInner) { + foreheadInner.setAttribute("d", frame.foreheadInner.paths.join(" ")); + foreheadInner.setAttribute("opacity", String(frame.foreheadInner.opacity)); + } + const foreheadWhole = refs["forehead-whole"] as SVGPathElement | null; + if (foreheadWhole) { + foreheadWhole.setAttribute("d", frame.foreheadWhole.paths.join(" ")); + foreheadWhole.setAttribute("opacity", String(frame.foreheadWhole.opacity)); + } + const nasolabial = refs.nasolabial as SVGPathElement | null; + if (nasolabial) { + nasolabial.setAttribute("d", frame.nasolabial.paths.join(" ")); + nasolabial.setAttribute("opacity", String(frame.nasolabial.opacity)); + } + const chinCrease = refs["chin-crease"] as SVGPathElement | null; + if (chinCrease) { + chinCrease.setAttribute("d", frame.chinCrease.paths.join(" ")); + chinCrease.setAttribute("opacity", String(frame.chinCrease.opacity)); + if (frame.chinCrease.strokeWidth !== undefined) chinCrease.setAttribute("stroke-width", String(frame.chinCrease.strokeWidth)); + } + + applyJawWarpLayer(refs, "jaw-head", frame.jawWarp.head); + applyJawWarpLayer(refs, "jaw-face", frame.jawWarp.faceDetail); + applyHeadImageSplit(refs, frame.jawWarp.head?.negligible ?? true); + applyFaceDetailSplit(refs, frame.jawWarp.faceDetail?.negligible ?? true); + + /* R10(§8.4 개정): 얼굴 그룹 clip 다각형·창백 마스크 다각형도 하관 띠 변위를 + 따라간다(negligible이면 frame이 이미 원본과 같은 값을 낸다). */ + const faceOvalClip = refs["face-oval-clip"] as SVGPolygonElement | null; + if (faceOvalClip) faceOvalClip.setAttribute("points", frame.faceOvalWarpedPointsAttr); + const pallorMaskOval = refs["pallor-mask-oval"] as SVGPolygonElement | null; + if (pallorMaskOval) pallorMaskOval.setAttribute("points", frame.pallorMaskWarpedPointsAttr); + + const cheekPuffLeft = refs["cheek-puff-left"] as SVGEllipseElement | null; + const cheekPuffLeftHi = refs["cheek-puff-left-highlight"] as SVGEllipseElement | null; + if (cheekPuffLeft) { + cheekPuffLeft.setAttribute("cx", String(frame.mouth.cheekPuffLeft.cx)); + cheekPuffLeft.setAttribute("cy", String(frame.mouth.cheekPuffLeft.cy)); + cheekPuffLeft.setAttribute("opacity", String(frame.mouth.cheekPuffLeft.opacity)); + } + if (cheekPuffLeftHi) { + cheekPuffLeftHi.setAttribute("cx", String(frame.mouth.cheekPuffLeft.highlightCx)); + cheekPuffLeftHi.setAttribute("cy", String(frame.mouth.cheekPuffLeft.highlightCy)); + cheekPuffLeftHi.setAttribute("opacity", String(frame.mouth.cheekPuffLeft.opacity)); + } + const cheekPuffRight = refs["cheek-puff-right"] as SVGEllipseElement | null; + const cheekPuffRightHi = refs["cheek-puff-right-highlight"] as SVGEllipseElement | null; + if (cheekPuffRight) { + cheekPuffRight.setAttribute("cx", String(frame.mouth.cheekPuffRight.cx)); + cheekPuffRight.setAttribute("cy", String(frame.mouth.cheekPuffRight.cy)); + cheekPuffRight.setAttribute("opacity", String(frame.mouth.cheekPuffRight.opacity)); + } + if (cheekPuffRightHi) { + cheekPuffRightHi.setAttribute("cx", String(frame.mouth.cheekPuffRight.highlightCx)); + cheekPuffRightHi.setAttribute("cy", String(frame.mouth.cheekPuffRight.highlightCy)); + cheekPuffRightHi.setAttribute("opacity", String(frame.mouth.cheekPuffRight.opacity)); + } + + /* R8(2026-10-01): 입 모양은 lipTexture 스프라이트 알파가 정한다 — 띠는 세로 아핀 + 변환만 받고, upperD·lowerD로 만드는 벡터 clip은 없다(띠별 정적 사각형 clip만 + 남는다, JSX의 stripClipId). */ + /* 아랫입술 아래 그늘 조각(§8.4, rig.lipTexture.shadow): 턱 변위만 세로로 받는다 + (가로 배율 없음). shadow가 없는 리그는 ref가 null이라 그냥 건너뛴다. */ + const lipShadow = refs["lip-shadow"] as SVGImageElement | null; + if (lipShadow) lipShadow.setAttribute("transform", `translate(0 ${frame.lipShadowTranslateY})`); + + for (let i = 0; i < LIP_STRIP_COUNT; i++) { + const upperStrip = refs[`lip-upper-strip-${i}`] as SVGImageElement | null; + const um = frame.mouth.lipStripsUpper[i]; + if (upperStrip && um) upperStrip.setAttribute("transform", `matrix(${um.a} 0 0 ${um.d} ${um.e} ${um.f})`); + const lowerStrip = refs[`lip-lower-strip-${i}`] as SVGImageElement | null; + const lm = frame.mouth.lipStripsLower[i]; + if (lowerStrip && lm) lowerStrip.setAttribute("transform", `matrix(${lm.a} 0 0 ${lm.d} ${lm.e} ${lm.f})`); + } + const mouthLine = refs["mouth-line"] as SVGPathElement | null; + if (mouthLine) { + mouthLine.setAttribute("d", frame.mouth.lineD); + mouthLine.setAttribute("opacity", String(frame.mouth.lineOpacity)); + } + /* rig.lipTexture가 없는 리그 전용(R8 이전 벡터 입술 경로) — 엘리먼트가 DOM에 + 없으면(lipTexture 있는 리그) 아래 ref는 모두 null이라 그냥 건너뛴다. */ + const fallback = frame.mouth.vectorLipFallback; + if (fallback) { + const mouthUpper = refs["mouth-upper"] as SVGPathElement | null; + if (mouthUpper) mouthUpper.setAttribute("d", fallback.upperD); + const mouthLower = refs["mouth-lower"] as SVGPathElement | null; + if (mouthLower) mouthLower.setAttribute("d", fallback.lowerD); + const mouthCornerStrokes = refs["mouth-corner-strokes"] as SVGPathElement | null; + if (mouthCornerStrokes) mouthCornerStrokes.setAttribute("d", fallback.cornerStrokesD); + const mouthLineSecondary = refs["mouth-line-secondary"] as SVGPathElement | null; + if (mouthLineSecondary) { + mouthLineSecondary.setAttribute("d", fallback.lineSecondaryD); + mouthLineSecondary.setAttribute("opacity", String(fallback.lineSecondaryOpacity)); + } + const lowerShadowCrescent = refs["mouth-lower-shadow-crescent"] as SVGPathElement | null; + if (lowerShadowCrescent) lowerShadowCrescent.setAttribute("d", fallback.lowerShadowCrescentD); + } + const mouthInner = refs["mouth-inner"] as SVGPathElement | null; + if (mouthInner) { + mouthInner.setAttribute("d", frame.mouth.innerD); + mouthInner.setAttribute("opacity", String(frame.mouth.innerOpacity)); + } + const mouthInnerClip = refs["mouth-inner-clip-d"] as SVGPathElement | null; + if (mouthInnerClip) mouthInnerClip.setAttribute("d", frame.mouth.innerD); + const teeth = refs.teeth as SVGPathElement | null; + if (teeth) { + teeth.setAttribute("d", frame.mouth.teethD); + teeth.setAttribute("fill", frame.mouth.teethFillColor); + } + const teethShadow = refs["teeth-shadow"] as SVGPathElement | null; + if (teethShadow) teethShadow.setAttribute("d", frame.mouth.teethShadowD); + const tongueShadow = refs["tongue-shadow"] as SVGPathElement | null; + if (tongueShadow) { + tongueShadow.setAttribute("d", frame.mouth.tongueShadowD); + tongueShadow.setAttribute("opacity", String(frame.mouth.tongueShadowOpacity)); + } + + const cheekLeft = refs["cheek-left"]; + if (cheekLeft) cheekLeft.setAttribute("opacity", String(frame.cheekLeftOpacity)); + const cheekRight = refs["cheek-right"]; + if (cheekRight) cheekRight.setAttribute("opacity", String(frame.cheekRightOpacity)); + + const pallorColor = refs["pallor-color"]; + if (pallorColor) pallorColor.setAttribute("opacity", String(frame.pallorColorOpacity)); + const pallorWhite = refs["pallor-white"]; + if (pallorWhite) pallorWhite.setAttribute("opacity", String(frame.pallorWhiteOpacity)); + + const sweatGroup = refs.sweat; + if (sweatGroup) sweatGroup.setAttribute("opacity", String(frame.sweatOpacity)); + frame.sweatDrops.forEach((drop, i) => { + const el = refs[`sweat-${i}`] as SVGEllipseElement | null; + if (el) { + el.setAttribute("cx", String(drop.cx)); + el.setAttribute("cy", String(drop.cy)); + el.setAttribute("rx", String(drop.r)); + el.setAttribute("ry", String(drop.r * 1.3)); + } + const highlight = refs[`sweat-${i}-highlight`] as SVGEllipseElement | null; + if (highlight) { + highlight.setAttribute("cx", String(drop.cx - drop.r * 0.3)); + highlight.setAttribute("cy", String(drop.cy - drop.r * 0.35)); + highlight.setAttribute("rx", String(drop.r * 0.32)); + highlight.setAttribute("ry", String(drop.r * 0.4)); + } + }); +} + +export default function LinocutAvatar({ + rig, + engine, + running, + nowMs, + crop = "bust", + backdropExpression = "neutral", + texture = true, + label = "내담자 아바타", + className, + onLoadStateChange, +}: LinocutAvatarProps) { + const uid = useId(); + const refs = useRef({}); + const rafRef = useRef(0); + const backdropRef = useRef(null); + const svgRef = useRef(null); + /* 작은 크기 선 보정(§8.4 2단계-B-1d): 크기가 바뀔 때만(ResizeObserver, 프레임마다 + 아님) k = 렌더 폭 / viewBox 폭을 재고 s = clamp(0.3/k, 1, 2)를 기하 계산에 넘긴다. + rAF에서 매 프레임 읽지만 React state로 두면 리렌더가 생기므로 ref에 둔다. */ + const sizeScaleRef = useRef(1); + + const group = groupFor(backdropExpression); + + const expectedHrefs = useMemo(() => { + const hrefs = [rig.layers.body.href, rig.layers.head.href, rig.layers.hairFront.href]; + if (rig.layers.faceDetail) hrefs.push(rig.layers.faceDetail.href); + if (texture && rig.grain) hrefs.push(rig.grain.href); + if (rig.lipTexture) hrefs.push(rig.lipTexture.upper.href, rig.lipTexture.lower.href); + if (rig.lipTexture?.shadow) hrefs.push(rig.lipTexture.shadow.href); + if (rig.jaw) hrefs.push(rig.jaw.head.href, rig.jaw.detail.href); + return hrefs; + }, [rig, texture]); + + const [loadState, setLoadState] = useState("loading"); + + /* 이 환경에서 SVG 의 load·error 이벤트가 발동하지 않아(원인 미확인, + 2단계-B-1b 관찰) 같은 href를 HTMLImageElement로 미리 불러와 로드 상태를 + 집계한다. 화면 표시용 는 정상 로드·렌더되며, 이 프리로드는 로드 상태 + 추적 전용이다(§8.4 로드 실패 규칙은 그대로 지킨다). */ + useEffect(() => { + let cancelled = false; + const loaded = new Set(); + const errored = new Set(); + setLoadState("loading"); + onLoadStateChange?.("loading"); + + expectedHrefs.forEach((href) => { + const probe = new window.Image(); + probe.onload = () => { + if (cancelled) return; + loaded.add(href); + if (errored.size === 0 && loaded.size >= expectedHrefs.length) { + setLoadState("ready"); + onLoadStateChange?.("ready"); + } + }; + probe.onerror = () => { + if (cancelled) return; + errored.add(href); + setLoadState("error"); + onLoadStateChange?.("error"); + }; + probe.src = href; + }); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [expectedHrefs]); + + useEffect(() => { + if (backdropRef.current) backdropRef.current.style.fill = rig.backdrop[group]; + }, [group, rig]); + + useEffect(() => { + const el = svgRef.current; + if (!el) return; + const viewBoxWidth = rig.crops[crop][2]; + const update = (widthPx: number) => { + if (widthPx <= 0) return; + const k = widthPx / viewBoxWidth; + sizeScaleRef.current = Math.min(2, Math.max(1, 0.3 / k)); + }; + update(el.getBoundingClientRect().width); + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; + update(w); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, [rig, crop]); + + useEffect(() => { + if (!running) return; + const clock = nowMs ?? (() => performance.now()); + const loop = () => { + const v = engine.evaluate(clock()); + const frame = computeLinocutFrame(rig, v, sizeScaleRef.current); + applyFrame(refs.current, frame); + rafRef.current = requestAnimationFrame(loop); + }; + rafRef.current = requestAnimationFrame(loop); + return () => cancelAnimationFrame(rafRef.current); + }, [engine, running, nowMs, rig]); + + const viewBox = rig.crops[crop].join(" "); + const faceClipId = `${uid}-face-clip`; + const eyeLeftClipId = `${uid}-eye-left-clip`; + const eyeRightClipId = `${uid}-eye-right-clip`; + const mouthInnerClipId = `${uid}-mouth-inner-clip`; + const inkFilterId = `${uid}-ink-filter`; + const blushPatternId = `${uid}-blush-pattern`; + const blushMaskId = `${uid}-blush-mask`; + const grainPatternId = `${uid}-grain-pattern`; + const pallorBlurId = `${uid}-pallor-blur`; + const pallorMaskId = `${uid}-pallor-mask`; + const lipShadowPatternId = `${uid}-lip-shadow-pattern`; + const browShadowBlurId = `${uid}-brow-shadow-blur`; + const cheekPuffBlurId = `${uid}-cheek-puff-blur`; + /* R10(§8.4 개정, out4·out5 반려 대응): 원본 faceDetail을 "변형 영역(y > y_n)을 + 빼고" 그릴 때 쓰는 clip — y=0..y_n의 위쪽 절반만 남긴다(face-oval clip과 + 중첩해 교집합으로 쓴다, applyFaceDetailSplit). */ + const faceDetailUpperClipId = `${uid}-face-detail-upper-clip`; + /* R13(§8.4 개정, out9 jaw-zoom 반려 대응): 원본 head-image에서 띠가 덮는 영역만 + evenodd로 뚫어낸 clip(applyHeadImageSplit). */ + const headJawHoleClipId = `${uid}-head-jaw-hole-clip`; + + const cheekLeftPos = useMemo(() => { + const c = rig.landmarks.eyeLeft.iris.center; + return { cx: c[0], cy: c[1] + 85, rx: 55, ry: 32 }; + }, [rig]); + const cheekRightPos = useMemo(() => { + const c = rig.landmarks.eyeRight.iris.center; + return { cx: c[0], cy: c[1] + 85, rx: 55, ry: 32 }; + }, [rig]); + + const pal = rig.palette; + const tongueColor = useMemo(() => lightenHex(pal.mouthInner, 0.35), [pal.mouthInner]); + + /* 하관 띠 변형(§8.4): 정적 사각형(채널과 무관, lipStripClipRect와 같은 역할)은 + 마운트 시 한 번만 계산한다. 매 프레임 세로 변환만 rAF에서 ref로 쓴다. R10: 띠의 + 원본은 rig.jaw의 잘라 낸 조각이다 — 없는 리그는 기존처럼 전체 head·faceDetail을 + 쓴다(대체 경로). */ + const jawHeadImage = rig.jaw?.head ?? rig.layers.head; + const jawFaceDetailImage = rig.jaw?.detail ?? rig.layers.faceDetail; + const jawHeadRects = useMemo(() => jawWarpStripRects(rig, jawHeadImage), [rig, jawHeadImage]); + const jawFaceRects = useMemo( + () => (jawFaceDetailImage ? jawWarpStripRects(rig, jawFaceDetailImage) : null), + [rig, jawFaceDetailImage], + ); + /* R11(§8.4 개정, out6 jaw-zoom 반려 대응): 띠마다 clip = 띠 사각형 ∩ 확장 얼굴 + 윤곽(10px)을 띠의 로컬(변환 전) 좌표로 미리 계산한다. 이 다각형을 각 띠의 + (자신의 transform을 받는 요소) 자체의 clip으로 걸어야 경계가 내용과 + 함께 움직인다 — 바깥 그룹에 정적 clip을 걸면(out6) 화면에 고정된 경계가 + 내려간 턱선을 잘라 점선처럼 보였다. */ + const jawHeadStripClips = useMemo( + () => jawWarpStripClipPolygons(rig, jawHeadImage, jawHeadRects ?? []), + [rig, jawHeadImage, jawHeadRects], + ); + const jawFaceStripClips = useMemo( + () => (jawFaceDetailImage && jawFaceRects ? jawWarpStripClipPolygons(rig, jawFaceDetailImage, jawFaceRects) : null), + [rig, jawFaceDetailImage, jawFaceRects], + ); + const jawUpperBoundaryY = useMemo(() => jawWarpUpperBoundaryY(rig), [rig]); + /* R13(§8.4 개정, out9 jaw-zoom 반려 대응): head-image(원본, rig.layers.head)에서 + 띠가 덮는 영역만 구멍을 뚫는다. 구멍 범위는 jawHeadRects(띠 원본인 jawHeadImage + 기준, 캔버스 좌표 공유라 head-image에도 그대로 쓸 수 있다)를 넘겨 주지만, + jawWarpHeadHoleClipD가 위쪽 경계를 입 중심(mouthCenter.y)으로 다시 내려 + 잡는다(R14, out10 hair-edge 반려 대응) — 코~인중(f(y) 음수 구간)은 원본을 + 남겨 볼 가장자리 흰 틈을 막는다. */ + const headJawHoleD = useMemo(() => jawWarpHeadHoleClipD(rig, jawHeadRects ?? []), [rig, jawHeadRects]); + + return ( + + + {/* R10: points는 초기값만 정적이고, 하관 띠 변위 f(y)를 rAF에서 이 polygon에 + 직접 쓴다(applyFrame → face-oval-clip, negligible이면 원본과 같다). */} + + + + {/* R10(§8.4 개정): 원본 faceDetail을 "변형 영역을 빼고" 그릴 때 쓰는 위쪽 + 절반 clip(y=0..y_n). faceClipId와 중첩해 교집합으로 쓴다. */} + + + + {/* R13(§8.4 개정, out9 jaw-zoom 반려 대응): 원본 head-image에서 띠가 덮는 + 영역(확장 얼굴 윤곽 ∩ 띠 y-범위)만 구멍 낸 clip. 캔버스 전체 사각형과 + 구멍 다각형을 evenodd 한 path에 함께 넣어야 "사각형 − 구멍"이 된다 + (다각형 두 개를 따로 넣으면 합집합이 되어 구멍이 안 뚫린다). */} + {headJawHoleD ? ( + + + + ) : null} + + + + + + + + + + + + + + {/* 안쪽 올림 그늘(§8.4): 눈썹 머리 위 흐린 잉크 그늘 전용 가우시안 흐림. */} + + + + {/* 볼 불룩(§8.4 2026-10-01 추가): 입꼬리 옆 흐린 잉크 타원 전용 가우시안 흐림. */} + + + + + + + + + + + + + + + + + + + + + + {rig.grain && texture ? ( + + + + ) : null} + {/* 창백 마스크: 얼굴 윤곽을 4% 줄인 다각형을 가우시안 흐림(표준편차 16)해 경계가 + 칼같이 떨어지지 않게 한다(2단계-B-1b 1차 반려 대응). */} + + + + + {/* R10: points 초기값만 정적, 하관 띠 변위는 rAF에서 이 polygon에 직접 쓴다 + (applyFrame → pallor-mask-oval). */} + + + + + + + + + + + + + {/* R13(§8.4 개정, out9 jaw-zoom 반려 대응): 원본 head-image를 clip 없이 + 그대로 깔아 두면, 하관 띠가 켜진 동안 원본 턱선(제자리)과 띠가 옮겨 + 그린 턱선(내려간 자리)이 겹쳐 턱선이 두 겹으로 보인다(faceDetail이 + R10에서 이미 겪은 문제와 같다 — head만 그 조치가 빠져 있었다). head는 + 얼굴 윤곽 밖(귀·머리카락·목)도 담고 있어 faceDetail처럼 "y>y_n 전체"를 + 빼면 그 부분까지 잘려 나가므로, 띠가 실제로 덮는 영역(확장 얼굴 윤곽 ∩ + 띠 y-범위, jawWarpHeadHoleClipD)만 구멍을 뚫은 사본(head-image-masked)을 + 따로 둔다. applyHeadImageSplit이 head 띠의 negligible로 두 장 중 하나만 + 보인다(쉴 때는 -full, 띠가 켜지면 -masked + 아래 jaw-warp-head 띠). */} + {headJawHoleD ? ( + + + + ) : null} + {/* 하관 띠 변형(§8.4 2026-10-01 추가, R11 개정) — head 레이어. 각 띠의 + clip = 띠 사각형 ∩ 확장 윤곽(10px, 로컬/변환 전 좌표) — 확장 윤곽 밖 + (머리카락·귀·목)은 이 clip으로 그대로 가려져 움직이지 않는다. clip을 + 띠 자신(transform을 받는 )에 걸어야 경계가 내용과 함께 움직인다 + (R11: 바깥 그룹에 정적 clip을 걸면 화면에 고정된 경계가 내려간 턱선을 + 경계마다 잘라 점선처럼 보였다 — out6 반려 사유). 띠는 위 head-image-masked + 구멍 위에 겹쳐 그려 그 자리를 대체한다. 띠 원본은 jawHeadImage(rig.jaw.head + 조각, 없으면 전체 head)이고, 교집합이 없는 띠는 그리지 않는다. */} + {jawHeadRects ? ( + + {jawHeadStripClips.map((clipPoints, i) => { + if (!clipPoints) return null; + const stripClipId = `${uid}-jaw-head-strip-clip-${i}`; + return ( + + + + + + + ); + })} + + ) : null} + {/* R9: face의 clip을 3단(좁게→넓게→좁게)으로 나눈다. faceDetail 원본과 + 벡터 부위(눈·눈썹·입 등)는 그대로 얼굴 윤곽에 딱 맞게 clip하고, 그 + 사이(하관 띠 변형)만 띠별 확장 clip(R11, 10px)을 쓴다 — 원화 턱선 + 잉크가 좁은 윤곽 한가운데를 지나 입을 벌리면 턱선이 반만 내려가 + 점선처럼 끊기던 문제(§8.4 R9)를 고친다. transform은 face 자신이 + 그대로 받는다. */} + + {/* R10(§8.4 개정, out4·out5 반려 대응): faceDetail은 대부분 투명한 + 덧칠 레이어라, 아래 jaw-warp-face 띠가 켜진 동안 원본 전체를 같이 + 그리면 변형된 사본과 겹쳐 턱선이 두 겹(점선)으로 보인다. 원본 전체 + (face-detail-full)와 변형 영역(y>y_n)을 뺀 사본(face-detail-upper)을 + 모두 그려 두고, applyFaceDetailSplit이 negligible 여부로 opacity를 + 토글한다(쉴 때는 -full만 보임, 띠가 켜지면 -upper만 보임). */} + + {rig.layers.faceDetail ? ( + <> + + + + + + ) : null} + + {/* 하관 띠 변형(§8.4, R11 개정) — faceDetail 레이어. head와 같은 방식으로 + 각 띠의 clip = 띠 사각형 ∩ 확장 윤곽(10px, 로컬 좌표)을 띠 자신에 + 건다(R11: 바깥 그룹 정적 clip은 out6에서 점선 결함을 냈다). 띠 원본은 + jawFaceDetailImage(rig.jaw.detail 조각, 없으면 전체 faceDetail), + 교집합이 없는 띠는 그리지 않는다. */} + {rig.layers.faceDetail && jawFaceRects && jawFaceStripClips ? ( + + {jawFaceStripClips.map((clipPoints, i) => { + if (!clipPoints) return null; + const stripClipId = `${uid}-jaw-face-strip-clip-${i}`; + return ( + + + + + + + ); + })} + + ) : null} + + + + + + + + + + + + + + {/* 볼 불룩(§8.4 2026-10-01 추가): 입꼬리마다 바깥 흐린 잉크 타원 + 종이색 + 하이라이트. b = max(mouthPress, mouthRound) — ㅁ·ㅂ·ㅗ·ㅜ에서 볼록해진다. */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* R8(2026-10-01, §8.4 입 "질감" 재개정): lipTexture가 있으면 입 모양은 + 스프라이트 알파가 정한다(원칙: 중립은 원화와 픽셀 단위로 같고, 움직이면 + 원화 픽셀이 변형된다) — 벡터 단색 채움·그늘 초승달·입꼬리 쐐기를 그리지 + 않는다. 입 안(틈)·윗니·혀 그늘은 스프라이트 "아래"에 그려 벌어진 입에서 + 스프라이트의 투명한 부분(치아·구강) 사이로 비친다. lipTexture가 없는 + 리그는 예전 벡터 경로(vectorLipFallback)를 그대로 쓴다. */} + {!rig.lipTexture ? ( + + ) : null} + {!rig.lipTexture ? ( + + ) : null} + {!rig.lipTexture ? ( + + ) : null} + + + + {/* R9: 윗니 위 35% 잉크 그늘(불투명도 0.35 고정). */} + + + + {/* 아랫입술 아래 그늘 조각(§8.4 입 "질감" 개정, rig.lipTexture.shadow): + 원화 픽셀 그대로, 아랫입술 스프라이트 "아래"·입 안보다 "위" 순서로 + 그린다. 턱 변위 f(그늘 중심 y)만 세로로 받고 가로로는 줄지 않는다 + (out5 판정: 아랫입술 스프라이트에 붙여 두자 ㅗ에서 입 폭과 함께 + 압축되어 짙은 직사각형이 됐다 — 턱 피부 그늘은 입이 좁아져도 줄지 + 않는다). shadow가 없으면 그리지 않는다. */} + {rig.lipTexture?.shadow ? ( + + ) : null} + + {/* 입술 결 띠 변형(§8.4 2단계-B-1d-R7, R8 개정): 스프라이트를 세로 띠 + LIP_STRIP_COUNT(24)개로 나눠 띠마다 독립 matrix로 입 선 곡률을 + 따라간다. 띠 사각형 clip(스프라이트 폭 기준 등분, 정적)만 남고, + R8부터는 입술 모양으로 한 번 더 clip하지 않는다 — 스프라이트 자신의 + 알파가 입술 바깥 경계다. */} + {rig.lipTexture + ? Array.from({ length: LIP_STRIP_COUNT }, (_, i) => { + const rect = lipStripClipRect(rig.lipTexture!.upper, i); + const stripClipId = `${uid}-lip-upper-strip-clip-${i}`; + return ( + + + + + + + ); + }) + : null} + {rig.lipTexture + ? Array.from({ length: LIP_STRIP_COUNT }, (_, i) => { + const rect = lipStripClipRect(rig.lipTexture!.lower, i); + const stripClipId = `${uid}-lip-lower-strip-clip-${i}`; + return ( + + + + + + + ); + }) + : null} + + {/* 입 선(§8.4 R8): lipTexture가 있으면 작은 화면 보조일 뿐이라 opacity를 + 매 프레임 sizeScale로 조절한다(face 300px에서 0). 없으면 예전대로 + 0.95 고정(applyFrame이 매 프레임 같은 값을 다시 쓴다). */} + + {!rig.lipTexture ? ( + + ) : null} + {!rig.lipTexture ? ( + + ) : null} + + + + + + + + + + + + + + + + + + + + {rig.grain && texture ? ( + + ) : null} + + ); +} diff --git a/apps/web/src/components/avatar/v3/LinocutMotif.tsx b/apps/web/src/components/avatar/v3/LinocutMotif.tsx new file mode 100644 index 0000000..14b562b --- /dev/null +++ b/apps/web/src/components/avatar/v3/LinocutMotif.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/avatar/v3/debug-face.css b/apps/web/src/components/avatar/v3/debug-face.css new file mode 100644 index 0000000..a34b1a6 --- /dev/null +++ b/apps/web/src/components/avatar/v3/debug-face.css @@ -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; +} diff --git a/apps/web/src/components/avatar/v3/linocut-avatar.css b/apps/web/src/components/avatar/v3/linocut-avatar.css new file mode 100644 index 0000000..89a3e41 --- /dev/null +++ b/apps/web/src/components/avatar/v3/linocut-avatar.css @@ -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; +} diff --git a/apps/web/src/components/avatar/v3/linocut-motif.css b/apps/web/src/components/avatar/v3/linocut-motif.css new file mode 100644 index 0000000..2f93d97 --- /dev/null +++ b/apps/web/src/components/avatar/v3/linocut-motif.css @@ -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; + } +} diff --git a/apps/web/src/components/avatar/v3/linocutGeometry.ts b/apps/web/src/components/avatar/v3/linocutGeometry.ts new file mode 100644 index 0000000..4f0b037 --- /dev/null +++ b/apps/web/src/components/avatar/v3/linocutGeometry.ts @@ -0,0 +1,1622 @@ +/* ===================================================================== + 리노컷 렌더러 기하 — 결정문 §8.4 P1 리노컷 렌더러 명세의 정본 구현 + (2단계-B-1b 1차 반려 후 개정본). + React·DOM에 의존하지 않는 순수 함수만 둔다. 입력은 (rig, ChannelVector), + 출력은 각 부위의 path d·transform·불투명도다. 렌더러(LinocutAvatar.tsx)가 + rAF에서 이 함수를 호출해 SVG 속성을 직접 쓴다(DebugFace와 같은 방식). + 좌우 부호는 DebugFace와 같다: 오른쪽 채널의 가로(X) 성분만 부호를 반전한다. + ===================================================================== */ + +import type { ChannelVector } from "../engine/channels"; +import type { LinocutRig, RigBrow, RigEye, RigLayer, RigPoint } from "./linocutRig"; + +type Point = readonly [number, number]; + +/* ── 공용 수학 ─────────────────────────────────────────────────────── */ + +function clamp(v: number, lo = 0, hi = 1): number { + return v < lo ? lo : v > hi ? hi : v; +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function smoothstep(edge0: number, edge1: number, x: number): number { + const t = clamp((x - edge0) / (edge1 - edge0), 0, 1); + return t * t * (3 - 2 * t); +} + +/** O–I 직선 위 x 지점의 y(직선 보간). */ +function lineY(p0: Point, p1: Point, x: number): number { + if (p1[0] === p0[0]) return (p0[1] + p1[1]) / 2; + const t = (x - p0[0]) / (p1[0] - p0[0]); + return p0[1] + (p1[1] - p0[1]) * t; +} + +/** 세 점 P0·Mid·P2를 지나는 2차 베지어 제어점(t=0.5에서 Mid를 지나도록 역산). */ +function quadControlThrough(p0: Point, mid: Point, p2: Point): Point { + return [2 * mid[0] - 0.5 * (p0[0] + p2[0]), 2 * mid[1] - 0.5 * (p0[1] + p2[1])]; +} + +function quadPointAt(p0: Point, control: Point, p2: Point, t: number): Point { + const mt = 1 - t; + return [ + mt * mt * p0[0] + 2 * mt * t * control[0] + t * t * p2[0], + mt * mt * p0[1] + 2 * mt * t * control[1] + t * t * p2[1], + ]; +} + +function pathQ(p0: Point, control: Point, p2: Point): string { + return `M ${p0[0]} ${p0[1]} Q ${control[0]} ${control[1]} ${p2[0]} ${p2[1]}`; +} + +function sampleQuad(p0: Point, control: Point, p2: Point, tFrom: number, tTo: number, steps: number): Point[] { + const out: Point[] = []; + for (let i = 0; i < steps; i++) { + const t = lerp(tFrom, tTo, steps === 1 ? 0 : i / (steps - 1)); + out.push(quadPointAt(p0, control, p2, t)); + } + return out; +} + +/** 결정적 의사난수(0..1) — "시드 고정" 텍스처 획 배치용. Math.random 미사용. */ +function hash(n: number): number { + const x = Math.sin(n * 12.9898) * 43758.5453; + return x - Math.floor(x); +} + +function toPoint(p: RigPoint): Point { + return [p[0], p[1]]; +} + +/** 16진 색 두 개를 weightA:1-weightA로 섞는다(R9 윗니 색 등). */ +function mixHex(hexA: string, hexB: string, weightA: number): string { + const na = parseInt(hexA.replace("#", ""), 16); + const nb = parseInt(hexB.replace("#", ""), 16); + const ra = (na >> 16) & 0xff, ga = (na >> 8) & 0xff, ba = na & 0xff; + const rb = (nb >> 16) & 0xff, gb = (nb >> 8) & 0xff, bb = nb & 0xff; + const mix = (a: number, b: number) => Math.round(a * weightA + b * (1 - weightA)); + return `rgb(${mix(ra, rb)}, ${mix(ga, gb)}, ${mix(ba, bb)})`; +} + +/** p0→p1 진행 방향의 왼쪽 법선(부호는 방향에 종속, 리본 두께 방향에만 쓴다). */ +function normal(p0: Point, p1: Point): Point { + const dx = p1[0] - p0[0]; + const dy = p1[1] - p0[1]; + const len = Math.hypot(dx, dy) || 1; + return [-dy / len, dx / len]; +} + +/* ── Catmull-Rom(클램프) → 3차 베지어 ───────────────────────────────── + 눈썹·입술 바깥선 등 "곡선(꺾인 직선 금지)" 요구를 만족하는 공용 도구다. + 양끝은 이웃 점을 복제해(clamped) 자연스러운 끝 접선을 만든다. */ + +type CubicSeg = readonly [Point, Point, Point, Point]; + +function catmullRomSegments(points: Point[]): CubicSeg[] { + const n = points.length; + const get = (i: number): Point => points[Math.min(n - 1, Math.max(0, i))]; + const segs: CubicSeg[] = []; + for (let i = 0; i < n - 1; i++) { + const p0 = get(i - 1); + const p1 = get(i); + const p2 = get(i + 1); + const p3 = get(i + 2); + const c1: Point = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6]; + const c2: Point = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6]; + segs.push([p1, c1, c2, p2]); + } + return segs; +} + +function cubicPointAt(p0: Point, c1: Point, c2: Point, p3: Point, t: number): Point { + const mt = 1 - t; + const a = mt * mt * mt; + const b = 3 * mt * mt * t; + const c = 3 * mt * t * t; + const d = t * t * t; + return [ + a * p0[0] + b * c1[0] + c * c2[0] + d * p3[0], + a * p0[1] + b * c1[1] + c * c2[1] + d * p3[1], + ]; +} + +/** 선두 M 없이 이어붙일 수 있는 "C ..." 명령만. */ +function catmullRomCommands(points: Point[]): string { + return catmullRomSegments(points) + .map(([, c1, c2, p3]) => `C ${c1[0]} ${c1[1]} ${c2[0]} ${c2[1]} ${p3[0]} ${p3[1]}`) + .join(" "); +} + +function catmullRomPathD(points: Point[]): string { + return `M ${points[0][0]} ${points[0][1]} ${catmullRomCommands(points)}`; +} + +/** 전역 매개변수 t(0..1)를 함께 내는 표본. 리본 반두께 보간에 쓴다. */ +function sampleCatmullRomWithT(points: Point[], samplesPerSeg: number): Array<{ point: Point; t: number }> { + const segs = catmullRomSegments(points); + const segCount = segs.length; + const out: Array<{ point: Point; t: number }> = []; + segs.forEach(([p0, c1, c2, p3], segIndex) => { + const start = segIndex === 0 ? 0 : 1; + for (let i = start; i <= samplesPerSeg; i++) { + const localT = i / samplesPerSeg; + out.push({ point: cubicPointAt(p0, c1, c2, p3, localT), t: (segIndex + localT) / segCount }); + } + }); + return out; +} + +/** t에서의 구간 선형 보간값(정지점 배열 [t, value]). 리본 반두께·주름 두께에 쓴다. */ +function valueAtStops(t: number, stops: Array): number { + for (let i = 0; i < stops.length - 1; i++) { + const [t0, v0] = stops[i]; + const [t1, v1] = stops[i + 1]; + if (t <= t1 || i === stops.length - 2) { + const localT = t1 === t0 ? 0 : clamp((t - t0) / (t1 - t0)); + return lerp(v0, v1, localT); + } + } + return stops[stops.length - 1][1]; +} + +/** 곡선(3점 이상)을 표본한 뒤 정지점 기반 반두께를 붙인 리본용 데이터. */ +function curveRibbonData(anchors: Point[], stops: Array, samplesPerSeg: number) { + const withT = sampleCatmullRomWithT(anchors, samplesPerSeg); + return { samples: withT.map((s) => s.point), halfWidths: withT.map((s) => valueAtStops(s.t, stops)) }; +} + +/** 정점별 반두께로 폴리라인(또는 곡선 표본)을 채운 리본(다각형 path)으로 만든다. */ +function polylineRibbon(points: Point[], halfWidths: number[]): string { + const n = points.length; + const normals: Point[] = points.map((p, i) => { + const a = i > 0 ? normal(points[i - 1], p) : normal(p, points[i + 1]); + const b = i < n - 1 ? normal(p, points[i + 1]) : normal(points[i - 1], p); + return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; + }); + const top = points.map((p, i) => [p[0] + normals[i][0] * halfWidths[i], p[1] + normals[i][1] * halfWidths[i]] as Point); + const bot = points.map((p, i) => [p[0] - normals[i][0] * halfWidths[i], p[1] - normals[i][1] * halfWidths[i]] as Point); + const topStr = top.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`).join(" "); + const botStr = [...bot].reverse().map((p) => `L ${p[0]} ${p[1]}`).join(" "); + return `${topStr} ${botStr} Z`; +} + +/** 폴리라인 위 매개변수 t(0..1, 구간마다 등분)의 점·법선·보간값. */ +function alongPolyline(points: Point[], values: number[] | null, t: number): { point: Point; normal: Point; value: number } { + const segCount = points.length - 1; + const scaled = clamp(t, 0, 1) * segCount; + const segIndex = Math.min(segCount - 1, Math.floor(scaled)); + const localT = scaled - segIndex; + const p0 = points[segIndex]; + const p1 = points[segIndex + 1]; + const point: Point = [lerp(p0[0], p1[0], localT), lerp(p0[1], p1[1], localT)]; + const value = values ? lerp(values[segIndex], values[segIndex + 1], localT) : 0; + return { point, normal: normal(p0, p1), value }; +} + +/** 접선과, 화면 기준 위쪽을 향하도록 부호를 맞춘 법선(눈썹 털 획 방향용). */ +function tangentAndUpNormal(points: Point[], t: number): { tangent: Point; up: Point } { + const segCount = points.length - 1; + const scaled = clamp(t, 0, 1) * segCount; + const segIndex = Math.min(segCount - 1, Math.floor(scaled)); + const p0 = points[segIndex]; + const p1 = points[segIndex + 1]; + const dx = p1[0] - p0[0]; + const dy = p1[1] - p0[1]; + const len = Math.hypot(dx, dy) || 1; + const tangent: Point = [dx / len, dy / len]; + const raw: Point = [-tangent[1], tangent[0]]; + const up: Point = raw[1] > 0 ? [-raw[0], -raw[1]] : raw; + return { tangent, up }; +} + +/** 눈썹 몸체 위로 삐져나오는 털 획 n개(잉크, 뿌리는 리본 폭 ±60% 안에서 흩뿌림). + 작은 화면 보정(§8.4)으로 길이에 sizeScale을 곱한다. */ +function browHairStrokes(samples: Point[], halfWidths: number[], count: number, seedBase: number, sizeScale: number): string { + const segments: string[] = []; + for (let i = 0; i < count; i++) { + const t = (i + 0.5) / count; + const { point, value: halfW } = alongPolyline(samples, halfWidths, t); + const { tangent, up } = tangentAndUpNormal(samples, t); + const rootFrac = (hash(seedBase + i * 3) * 2 - 1) * 0.6; + const root: Point = [point[0] + up[0] * rootFrac * halfW, point[1] + up[1] * rootFrac * halfW]; + const angleDeg = t < 1 / 3 ? 35 : 15; + const angleRad = (angleDeg * Math.PI) / 180; + const dirX = up[0] * Math.cos(angleRad) + tangent[0] * Math.sin(angleRad); + const dirY = up[1] * Math.cos(angleRad) + tangent[1] * Math.sin(angleRad); + const len = (9 + hash(seedBase + i * 3 + 1) * 5) * sizeScale; + const tip: Point = [root[0] + dirX * len, root[1] + dirY * len]; + segments.push(`M ${root[0]} ${root[1]} L ${tip[0]} ${tip[1]}`); + } + return segments.join(" "); +} + +/** 타원 안에 짧게 굽은 획 n개(턱 오돌토돌함). 시드 고정, 위치·각도·굽음 모두 결정적. */ +function ellipseCurvedStrokes(center: Point, rx: number, ry: number, count: number, seedBase: number): string[] { + const out: string[] = []; + for (let i = 0; i < count; i++) { + const angle = hash(seedBase + i * 4) * Math.PI * 2; + const radiusFrac = Math.sqrt(hash(seedBase + i * 4 + 1)) * 0.7; + const cx = center[0] + Math.cos(angle) * rx * radiusFrac; + const cy = center[1] + Math.sin(angle) * ry * radiusFrac; + const strokeAngle = hash(seedBase + i * 4 + 2) * Math.PI * 2; + const len = 8 + hash(seedBase + i * 4 + 3) * 4; + const dx = (Math.cos(strokeAngle) * len) / 2; + const dy = (Math.sin(strokeAngle) * len) / 2; + const p0: Point = [cx - dx, cy - dy]; + const p2: Point = [cx + dx, cy + dy]; + const half = len / 2 || 1; + const bendNormal: Point = [-dy / half, dx / half]; + const bend = 2.5; + const mid: Point = [cx + bendNormal[0] * bend, cy + bendNormal[1] * bend]; + out.push(pathQ(p0, quadControlThrough(p0, mid, p2), p2)); + } + return out; +} + +/** 사인 물결(§8.4 이마 주름: 진폭 1.5px, 획당 1.5주기, 시드 고정 위상)을 얹은 표본점. + t는 anchors 곡선 전체 기준 0..1(토막으로 끊어도 재정규화하지 않는다 — 끊긴 자리를 + 지우기 전의 연속된 리본처럼 두께·물결이 이어지게 한다). */ +function wavyRibbonPoints( + anchors: Point[], + samplesPerSeg: number, + seed: number, + amplitude: number, + cycles: number, +): Array<{ point: Point; t: number }> { + const withT = sampleCatmullRomWithT(anchors, samplesPerSeg); + const phase = hash(seed) * Math.PI * 2; + const n = withT.length; + return withT.map((s, i) => { + const prevPoint = i > 0 ? withT[i - 1].point : s.point; + const nextPoint = i < n - 1 ? withT[i + 1].point : s.point; + const nrm = normal(prevPoint, nextPoint); + const off = Math.sin(s.t * Math.PI * 2 * cycles + phase) * amplitude; + return { point: [s.point[0] + nrm[0] * off, s.point[1] + nrm[1] * off] as Point, t: s.t }; + }); +} + +function ribbonFromWavyPoints(pts: Array<{ point: Point; t: number }>, stops: Array): string { + if (pts.length < 2) return ""; + return polylineRibbon(pts.map((p) => p.point), pts.map((p) => valueAtStops(p.t, stops))); +} + +/** 긴 호를 2~3토막으로 끊는다(틈 6~12%, 시드 고정). 원래 t(0..1) 구간을 그대로 쓴다. */ +function seededSegmentRanges(seed: number): Array<[number, number]> { + const segCount = 2 + Math.round(hash(seed)); + const gaps: number[] = []; + for (let i = 0; i < segCount - 1; i++) gaps.push(lerp(0.06, 0.12, hash(seed + i + 1))); + const gapTotal = gaps.reduce((sum, g) => sum + g, 0); + const segLen = (1 - gapTotal) / segCount; + const ranges: Array<[number, number]> = []; + let cursor = 0; + for (let i = 0; i < segCount; i++) { + ranges.push([cursor, cursor + segLen]); + cursor += segLen + (gaps[i] ?? 0); + } + return ranges; +} + +/* ── 그룹 변환(§8.4 변환 표, 2단계-B-1b 개정) ──────────────────────── */ + +export interface LinocutTransforms { + figure: string; + body: string; + head: string; + face: string; + hairFront: string; +} + +function pivotWrap(cx: number, cy: number, inner: string): string { + return `translate(${cx} ${cy}) ${inner} translate(${-cx} ${-cy})`; +} + +function computeTransforms(rig: LinocutRig, v: ChannelVector): LinocutTransforms { + const bodyPivot = toPoint(rig.pivots.body); + const neckPivot = toPoint(rig.pivots.neck); + const facePivot = toPoint(rig.pivots.face); + + const figureScale = 1 + v.bodyLean * 0.03; + const figure = [ + `translate(${v.bodyYaw * 8} ${-v.bodyLean * 6})`, + `rotate(${v.bodyRoll * 3} ${bodyPivot[0]} ${bodyPivot[1]})`, + pivotWrap(bodyPivot[0], bodyPivot[1], `scale(${figureScale} ${figureScale})`), + ].join(" "); + + const body = [ + `translate(0 ${-v.shoulderRaise * 10})`, + pivotWrap(bodyPivot[0], bodyPivot[1], `scale(1 ${1 + v.breath * 0.006})`), + ].join(" "); + + const pitchCoef = v.headPitch < 0 ? 20 : 14; + const head = [ + `translate(${v.headYaw * 14} ${-v.headPitch * pitchCoef - v.shoulderRaise * 4 - v.breath * 2.5})`, + `rotate(${v.headRoll * 7} ${neckPivot[0]} ${neckPivot[1]})`, + ].join(" "); + + const faceSx = 1 - Math.abs(v.headYaw) * 0.08; + const faceSy = 1 + v.headPitch * 0.03; + const face = [ + `translate(${v.headYaw * 6} ${-v.headPitch * 4})`, + pivotWrap(facePivot[0], facePivot[1], `scale(${faceSx} ${faceSy})`), + ].join(" "); + + const hairFront = `translate(${v.headYaw * 10} ${-v.headPitch * 3})`; + + return { figure, body, head, face, hairFront }; +} + +/* ── 눈 ────────────────────────────────────────────────────────────── */ + +export interface EyeGeometry { + outlineD: string; + upperLidD: string; + creaseD: string; + creaseOpacity: number; + lowerLidD: string; + shadowD: string; + irisCx: number; + irisCy: number; + irisRadius: number; + irisRingWidth: number; + pupilRadius: number; + highlightCx: number; + highlightCy: number; + highlightRadius: number; + radialStrokesD: string; + tearPoolD: string; + tearPoolOpacity: number; + tearDropCx: number; + tearDropCy: number; + tearDropR: number; + tearFlowD: string; + tearFlowOpacity: number; + tearFlowDropCx: number; + tearFlowDropCy: number; + tearFlowDropR: number; +} + +interface EyeChannels { + open: number; + smile: number; + lidTension: number; + gazeX: number; + gazeY: number; + pupilSize: number; + tear: number; +} + +const CRESCENT_SAMPLES = 14; +const CREASE_SAMPLES = 8; +const LOWER_LID_SAMPLES = 10; + +function buildEye(eye: RigEye, ch: EyeChannels, isScreenRight: boolean, sizeScale: number): EyeGeometry { + const O = toPoint(eye.outer); + const I = toPoint(eye.inner); + const U = toPoint(eye.upperLidTop); + const D = toPoint(eye.lowerLidBottom); + const C = toPoint(eye.iris.center); + const r = eye.iris.radius; + + /* 크게 뜬 눈(eyeOpen>1) 증폭: 그대로 두면 놀람이 잘 안 읽힌다(1차 반려 "표정 간 차이가 + 작다"와 연결). e는 eyeOpen<=1이면 그대로, 넘으면 1.8배 증폭(최대 1.5)한다. */ + const e = ch.open <= 1 ? Math.max(0, ch.open) : Math.min(1.5, 1 + (ch.open - 1) * 1.8); + + const openD = (1 - 0.75 * ch.smile) * (1 - 0.3 * ch.lidTension) * Math.min(1, 0.4 + 0.6 * clamp(ch.open, 0, 1)); + const openU = e * (1 - 0.25 * ch.lidTension) * (1 - 0.15 * ch.smile); + + const midD = lineY(O, I, D[0]); + const hD = D[1] - midD; + + const yD = midD + hD * openD; + /* yU는 "닫힘(openU=0)일 때 yD와 만나고, 열림(openU=1)일 때 랜드마크 U를 정확히 + 지난다"는 두 경계 조건을 만족해야 한다(중립 채널 눈꺼풀 곡선이 랜드마크를 + ≤0.5px로 지나야 한다는 요구, 2단계-B-1b 증거 요구 a). O-I 직선을 U·D 각각의 + x에서 따로 보간해 빼는 이전 방식은 U.x≠D.x일 때 오차가 생겨 이 조건을 만족하지 + 못했다. yD를 축으로 직접 선형보간하면 항상 정확하다. */ + const yU = yD + (U[1] - yD) * openU; + + const upperApex: Point = [U[0], yU]; + const lowerApex: Point = [D[0], yD]; + const upperControl = quadControlThrough(O, upperApex, I); + const lowerControl = quadControlThrough(O, lowerApex, I); + + const outlineD = `${pathQ(O, upperControl, I)} Q ${lowerControl[0]} ${lowerControl[1]} ${O[0]} ${O[1]} Z`; + + /* 윗눈꺼풀 선: s=0(바깥 O)→1(안쪽 I), t(s) = 1.5 + 4·(1 − smoothstep(0.45,1,s)). */ + const crescentTop = sampleQuad(O, upperControl, I, 0, 1, CRESCENT_SAMPLES); + const crescentBottom = crescentTop.map((p, i) => { + const s = i / (CRESCENT_SAMPLES - 1); + const t = sizeScale * (1.5 + 4 * (1 - smoothstep(0.45, 1, s))); + return [p[0], p[1] + t] as Point; + }); + const tailDir = (() => { + const dx = O[0] - upperControl[0]; + const dy = O[1] - upperControl[1]; + const len = Math.hypot(dx, dy) || 1; + return [dx / len, dy / len] as Point; + })(); + const tailTip: Point = [O[0] + tailDir[0] * 8, O[1] + tailDir[1] * 8]; + const upperLidD = [ + `M ${tailTip[0]} ${tailTip[1]}`, + `L ${O[0]} ${O[1]}`, + ...crescentTop.slice(1).map((p) => `L ${p[0]} ${p[1]}`), + ...[...crescentBottom].reverse().map((p) => `L ${p[0]} ${p[1]}`), + `L ${tailTip[0]} ${tailTip[1] + 1}`, + "Z", + ].join(" "); + + /* 쌍꺼풀 주름: s 0.15~0.85, 윗곡선 위로 9 + 5·max(0, openU−1)만큼, 불투명도 0.6 기준. */ + const creaseOffset = 9 + 5 * Math.max(0, openU - 1); + const creasePts = sampleQuad(O, upperControl, I, 0.15, 0.85, CREASE_SAMPLES).map((p) => [p[0], p[1] - creaseOffset] as Point); + const creaseD = creasePts.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`).join(" "); + const creaseOpacity = (openU < 0.35 ? clamp(openU / 0.35) : 1) * 0.6; + + /* 아랫눈꺼풀: s 0~0.8 가는 선. */ + const lowerPts = sampleQuad(O, lowerControl, I, 0, 0.8, LOWER_LID_SAMPLES); + const lowerLidD = lowerPts.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`).join(" "); + + /* 윗눈꺼풀 아래 3.5px 그늘 띠. */ + const shadowD = [ + ...crescentBottom.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`), + ...[...crescentBottom].reverse().map((p) => `L ${p[0]} ${p[1] + 3.5}`), + "Z", + ].join(" "); + + const irisCx = C[0] + ch.gazeX * 0.85 * r; + const irisCy = C[1] + ch.gazeY * 0.5 * r; + const pupilRadius = 0.42 * r * (1 + 0.3 * ch.pupilSize); + const highlightCx = irisCx - 0.35 * r; + const highlightCy = irisCy - 0.4 * r; + + const radialSegments: string[] = []; + const innerR = pupilRadius + 1; + const outerR = r * 0.85; + for (let k = 0; k < 8; k++) { + const angle = (k / 8) * Math.PI * 2; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + radialSegments.push( + `M ${irisCx + cos * innerR} ${irisCy + sin * innerR} L ${irisCx + cos * outerR} ${irisCy + sin * outerR}`, + ); + } + + const pool = clamp(ch.tear / 0.3); + const flow = clamp((ch.tear - 0.3) / 0.7); + const tearPoolPts = sampleQuad(O, lowerControl, I, 0.6, 1, 6); + const tearPoolD = tearPoolPts.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`).join(" "); + const tearDropCx = I[0]; + const tearDropCy = I[1] + 2; + const tearDropR = 2.5 * pool; + + const flowStart = quadPointAt(O, lowerControl, I, 0.65); + const flowLen = flow * 120 * (isScreenRight ? 0.7 : 1); + const flowEnd: Point = [flowStart[0] + 6, flowStart[1] + flowLen]; + const flowControl: Point = [flowStart[0] + 3, flowStart[1] + flowLen * 0.5]; + const flowHalfTop = 1.5; + const flowHalfBottom = 1; + const flowNormal = normal(flowStart, flowEnd); + const tearFlowD = + flowLen > 0.5 + ? [ + `M ${flowStart[0] + flowNormal[0] * flowHalfTop} ${flowStart[1] + flowNormal[1] * flowHalfTop}`, + `Q ${flowControl[0] + flowNormal[0] * flowHalfBottom} ${flowControl[1] + flowNormal[1] * flowHalfBottom} ${flowEnd[0] + flowNormal[0] * flowHalfBottom} ${flowEnd[1] + flowNormal[1] * flowHalfBottom}`, + `L ${flowEnd[0] - flowNormal[0] * flowHalfBottom} ${flowEnd[1] - flowNormal[1] * flowHalfBottom}`, + `Q ${flowControl[0] - flowNormal[0] * flowHalfBottom} ${flowControl[1] - flowNormal[1] * flowHalfBottom} ${flowStart[0] - flowNormal[0] * flowHalfTop} ${flowStart[1] - flowNormal[1] * flowHalfTop}`, + "Z", + ].join(" ") + : ""; + + return { + outlineD, + upperLidD, + creaseD, + creaseOpacity, + lowerLidD, + shadowD, + irisCx, + irisCy, + irisRadius: r, + irisRingWidth: 0.14 * r, + pupilRadius, + highlightCx, + highlightCy, + highlightRadius: 0.13 * r, + radialStrokesD: radialSegments.join(" "), + tearPoolD, + tearPoolOpacity: pool, + tearDropCx, + tearDropCy, + tearDropR, + tearFlowD, + tearFlowOpacity: flow, + tearFlowDropCx: flowEnd[0], + tearFlowDropCy: flowEnd[1], + tearFlowDropR: flowLen > 0.5 ? 2.2 : 0, + }; +} + +/* ── 눈썹(2단계-B-1b 개정: 곡선·리본·삐져나온 털 획) ──────────────── */ + +export interface BrowGeometry { + bodyD: string; + hairStrokesD: string; + hairStrokeWidth: number; + /** 채널 반영 후 실제 안쪽 끝·꼭짓점(이마 주름·그늘이 따라간다). */ + innerP: Point; + peakP: Point; + shadowCx: number; + shadowCy: number; + shadowOpacity: number; +} + +interface BrowChannels { + y: number; + angle: number; + x: number; + form: number; +} + +const BROW_STOPS: Array = [ + [0, 4.5], + [0.5, 3.2], + [1, 1.0], +]; + +function buildBrow(brow: RigBrow, ch: BrowChannels, side: "left" | "right", sizeScale: number): BrowGeometry { + const inner = toPoint(brow.inner); + const peak = toPoint(brow.peak); + const outer = toPoint(brow.outer); + + /* browAngle a는 부호에 따라 다른 계수를 쓴다(AU1 안쪽 올림은 바깥이 따라 오르지 않는다, + §8.4 2단계-B-1d 개정): a>0이면 안쪽 −60a·꼭짓점 −4a·바깥 +18a, a<0(AU4)이면 + 안쪽 −50a·꼭짓점 −12a·바깥 +14a. */ + const dyAll = -ch.y * 45; + const a = ch.angle; + const innerCoef = a > 0 ? -60 : -50; + const peakCoef = a > 0 ? -4 : -12; + const outerCoef = a > 0 ? 18 : 14; + const innerDy = dyAll + innerCoef * a; + const outerDy = dyAll + outerCoef * a; + const peakDy = dyAll + peakCoef * a - ch.form * 10; + const innerDx = side === "left" ? -ch.x * 16 : ch.x * 16; + + const innerP: Point = [inner[0] + innerDx, inner[1] + innerDy]; + const peakP: Point = [peak[0], peak[1] + peakDy]; + const outerP: Point = [outer[0], outer[1] + outerDy]; + + /* "세 점을 지나는 곡선(12점 이상 표본)", 꺾인 직선 금지(1차 반려 사유). 작은 화면 + 보정(§8.4 2단계-B-1d)으로 반두께·털 획 길이·굵기에 sizeScale을 곱한다. */ + const scaledStops = BROW_STOPS.map(([t, hw]) => [t, hw * sizeScale] as const); + const { samples, halfWidths } = curveRibbonData([innerP, peakP, outerP], scaledStops, 6); + const bodyD = polylineRibbon(samples, halfWidths); + const seedBase = side === "left" ? 0 : 100; + const hairStrokesD = browHairStrokes(samples, halfWidths, 14, seedBase, sizeScale); + const hairStrokeWidth = 1.3 * sizeScale; + + /* 안쪽 올림 그늘(§8.4): a>0.3이면 눈썹 머리 위에 흐린 잉크 그늘(가우시안 흐림은 + 렌더러의 SVG 필터가 맡는다, 여기서는 위치·불투명도만). */ + const shadowOpacity = 0.18 * clamp((a - 0.3) / 0.7); + const shadowCx = innerP[0]; + const shadowCy = innerP[1] - 18; + + return { bodyD, hairStrokesD, hairStrokeWidth, innerP, peakP, shadowCx, shadowCy, shadowOpacity }; +} + +/* ── 보조 주름 ─────────────────────────────────────────────────────── */ + +export interface WrinkleSet { + paths: string[]; + opacity: number; + /** 순수 stroke(리본이 아닌) 주름만 쓴다(미간·턱). 작은 화면 보정(sizeScale)이 반영된 값. */ + strokeWidth?: number; +} + +/* ── 입(2단계-B-1b 전면 교체: U/∩ 곡선·Catmull-Rom 바깥선) ──────────── */ + +export interface MouthGeometry { + lineD: string; + /** R8(2026-10-01, §8.4 입 "질감" 재개정, lipTexture 있는 리그 한정): 벡터 입 선은 + 이제 작은 화면 보조일 뿐이다 — 0.95·clamp((sizeScale−1)/0.5, 0, 1). face + 300px(sizeScale=1)에서는 0. lipTexture가 없는 리그는 기존대로 0.95 고정. */ + lineOpacity: number; + innerD: string; + innerOpacity: number; + teethD: string; + /** R9: 윗니 색(teeth·mouthInner 3:1 혼합, 새하얀 막대로 안 튀게). */ + teethFillColor: string; + /** R9: 윗니 위 35% 잉크 그늘(불투명도 0.35 고정, 렌더러가 적용). teethD가 비면 같이 빈다. */ + teethShadowD: string; + /** 혀 그늘(§5.4): open>0.45에서 나타나는 입 안 아래쪽 반달. */ + tongueShadowD: string; + tongueShadowOpacity: number; + /** rig.lipTexture가 없는 리그의 대체 경로 전용(R8: lipTexture 있으면 스프라이트 + 알파가 입 모양을 정하므로 이 벡터 채움·그늘·쐐기를 안 쓴다). */ + vectorLipFallback?: VectorLipFallback; + /** 입술 결 띠 변형(§8.4 입 "질감" 개정, 2단계-B-1d-R7). rig.lipTexture가 없으면 빈 배열. */ + lipStripsUpper: StripMatrix[]; + lipStripsLower: StripMatrix[]; + /** 채널 반영 후 실제 입꼬리 좌표(팔자 주름이 따라간다). */ + cornerLeft: Point; + cornerRight: Point; + /** 틈 높이 G(§5.4 R6b). §8.4 하관 띠 변형의 "현재 입 틈 높이"로 그대로 쓴다. */ + gapHeight: number; + /** 볼 불룩(§8.4, 2026-10-01 추가): 입꼬리마다 바깥 잉크 타원 + 종이색 하이라이트. */ + cheekPuffLeft: CheekPuff; + cheekPuffRight: CheekPuff; +} + +/** 볼 불룩 타원 한 쪽(§8.4): 흐린 잉크 타원(반지름 16×24, 가우시안 stdDev 6) + 종이색 + 하이라이트. 반지름은 렌더러 상수(고정 16×24)라 여기서는 중심·불투명도만 낸다. */ +export interface CheekPuff { + cx: number; + cy: number; + highlightCx: number; + highlightCy: number; + opacity: number; +} + +/** 입술 결 띠 하나의 아핀 변환(회전·기울임 없음: matrix(a,0,0,d,e,f)). */ +export interface StripMatrix { + a: number; + d: number; + e: number; + f: number; +} + +/** R8(2026-10-01, §8.4 입 "질감" 재개정): 16 → 24로 늘려 곡선 추종 오차를 줄인다 + (verify_r7_tracking.mjs 실측: joy 3.85→2.60px, grief 4.86→3.19px, U 6.90→4.55px). */ +export const LIP_STRIP_COUNT = 24; + +const MOUTH_LINE_STOPS: Array = [ + [0, 1.4], + [0.5, 2.6], + [1, 1.4], +]; + +/** 입술 결 띠 변형(§8.4 입 "질감" 개정, 2단계-B-1d-R7)의 중립 기준값. rig당 1회만 + 계산하면 되므로 캐시한다: W0·tU0·tL0(채널 반영 전 랜드마크 값)와 중립 Cu0(x). */ +interface NeutralMouthRef { + M0x: number; + W0: number; + tU0: number; + tL0: number; + cuAt0: (x: number) => number; +} +const neutralMouthRefCache = new WeakMap(); +function neutralMouthRef(rig: LinocutRig): NeutralMouthRef { + const cached = neutralMouthRefCache.get(rig); + if (cached) return cached; + const Lc0 = toPoint(rig.landmarks.mouthCornerLeft); + const Rc0 = toPoint(rig.landmarks.mouthCornerRight); + const T = toPoint(rig.landmarks.upperLipTop); + const B = toPoint(rig.landmarks.lowerLipBottom); + const M0 = toPoint(rig.landmarks.mouthCenter); + const control0 = quadControlThrough(Lc0, M0, Rc0); + const xToT0 = (x: number) => clamp((x - Lc0[0]) / (Rc0[0] - Lc0[0] || 1)); + const cuAt0 = (x: number) => quadPointAt(Lc0, control0, Rc0, xToT0(x))[1]; + const ref: NeutralMouthRef = { M0x: M0[0], W0: Rc0[0] - Lc0[0], tU0: M0[1] - T[1], tL0: B[1] - M0[1], cuAt0 }; + neutralMouthRefCache.set(rig, ref); + return ref; +} + +/** 입 모양의 채널 반영 핵심값(입술 결 띠 변형·바깥선 계산이 함께 쓴다). */ +interface MouthShape { + Lc: Point; + Rc: Point; + Mc: Point; + /** 닫힌 입(틈 없음) 기준 곡선의 2차 베지어 제어점 — 모서리 tilt(미소·찡그림)만 반영하고 + 벌림·오므림과는 무관하다. R6b 틈 타원(mouthGapCurves)이 이 위에 얹힌다. */ + seamControl: Point; + W: number; + tU: number; + tL: number; + /** 틈 반폭 a(§5.4 렌더러 입 모양, R6b: a = (W/2)(1−0.65r)). */ + gapHalfWidth: number; + /** 틈 높이 G(§5.4 렌더러 입 모양, R6b: G = g(1+0.8r)). §8.4 하관 띠 변형의 + "현재 입 틈 높이"가 바로 이 값이다. */ + gapHeight: number; +} + +function computeMouthShape(rig: LinocutRig, v: ChannelVector): MouthShape { + const Lc0 = toPoint(rig.landmarks.mouthCornerLeft); + const Rc0 = toPoint(rig.landmarks.mouthCornerRight); + const T = toPoint(rig.landmarks.upperLipTop); + const B = toPoint(rig.landmarks.lowerLipBottom); + const M0 = toPoint(rig.landmarks.mouthCenter); + + const w = 26 * v.mouthStretch + 12 * Math.max(0, v.mouthForm) - 10 * Math.max(0, -v.mouthForm); + const roundInset = 32 * v.mouthRound; + const Lc: Point = [Lc0[0] - w + roundInset, Lc0[1] - 36 * v.mouthForm - 22 * v.mouthAsym + 6 * v.chinRaise]; + const Rc: Point = [Rc0[0] + w - roundInset, Rc0[1] - 36 * v.mouthForm + 22 * v.mouthAsym + 6 * v.chinRaise]; + const Mc: Point = [M0[0], M0[1] - 6 * v.mouthForm - 4 * v.chinRaise]; + + /* 닫힌 입 기준 곡선: apex를 Mc에 정확히 두어(g 오프셋 없음) 미소·찡그림의 모서리↔중앙 + tilt만 담는다. mouthOpen=0이면 이것이 바로 예전 Cu=Cl이다. */ + const seamControl = quadControlThrough(Lc, Mc, Rc); + + const r = clamp(v.mouthRound); + const gapBase = v.mouthOpen * 44 * (1 - 0.3 * v.mouthStretch); + const gapHeight = gapBase * (1 + 0.8 * r); + const gapHalfWidth = ((Rc[0] - Lc[0]) / 2) * (1 - 0.65 * r); + + const roundThicken = 1 + 0.3 * r; + const tU = (M0[1] - T[1]) * (1 - 0.6 * v.mouthPress) * (1 - 0.25 * Math.max(0, v.mouthForm)) * roundThicken; + const tL = (B[1] - M0[1]) * (1 - 0.55 * v.mouthPress) * (1 - 0.2 * Math.max(0, v.mouthForm)) * (1 - 0.3 * v.chinRaise) * roundThicken; + const W = Rc[0] - Lc[0]; + + return { Lc, Rc, Mc, seamControl, W, tU, tL, gapHalfWidth, gapHeight }; +} + +/** 입 틈의 기준 곡선 Cu·Cl(§5.4 렌더러 입 모양 R6b 개정, 2단계-B-1d-R6b). 닫힌 입 + 베이스라인(seamControl) 위에 가운데 타원 틈(반폭 a, 높이 G)을 얹는다: Mc 중심으로 + 위 0.25G·아래 0.75G인 타원이며, gapFrac이 틈 경계(|x−Mc.x|=a)에서 정확히 0으로 + 수렴해 이음매 없이 베이스라인과 만난다 — "틈 밖의 입 선 구간은 닫힌 채 입 선만 + 그린다", "틈 밖에서는 Cl=Cu다"를 만족한다. r=0이면 a=W/2가 모서리(Lc·Rc)에 정확히 + 닿아 기존 렌즈(모서리~모서리)와 사실상 같아진다. */ +function mouthGapCurves(shape: MouthShape): { CuAt: (x: number) => number; ClAt: (x: number) => number } { + const xToT = (x: number) => clamp((x - shape.Lc[0]) / (shape.Rc[0] - shape.Lc[0] || 1)); + const seamAt = (x: number) => quadPointAt(shape.Lc, shape.seamControl, shape.Rc, xToT(x))[1]; + const gapFrac = (x: number) => { + if (shape.gapHalfWidth <= 0) return 0; + const nx = clamp((x - shape.Mc[0]) / shape.gapHalfWidth, -1, 1); + return Math.sqrt(Math.max(0, 1 - nx * nx)); + }; + return { + CuAt: (x: number) => seamAt(x) - 0.25 * shape.gapHeight * gapFrac(x), + ClAt: (x: number) => seamAt(x) + 0.75 * shape.gapHeight * gapFrac(x), + }; +} + +/** 검증용 공개 API(R6b·R7 수치 검증 스크립트 전용): 주어진 채널의 입 틈 기준 곡선 + Cu·Cl을 직접 낸다. 렌더러(buildMouth)는 이 함수를 안 거치고 내부에서 같은 계산을 + 한다 — 중복 계산이 아니라 외부 검증이 내부 비공개 함수(mouthGapCurves)에 닿을 + 길이 없어 만든 얇은 래퍼다. */ +export function mouthCurvesAt(rig: LinocutRig, v: ChannelVector): { CuAt: (x: number) => number; ClAt: (x: number) => number } { + return mouthGapCurves(computeMouthShape(rig, v)); +} + +/** xFrom..xTo를 count개로 균등 표본한 곡선 점(§5.4 R6b: CuAt/ClAt은 x를 받는 함수라 + quadPointAt/sampleQuad의 t 표본을 대체한다). */ +function sampleCurveX(xFrom: number, xTo: number, count: number, fn: (x: number) => number): Point[] { + const out: Point[] = []; + for (let i = 0; i < count; i++) { + const x = lerp(xFrom, xTo, count === 1 ? 0 : i / (count - 1)); + out.push([x, fn(x)]); + } + return out; +} + +/** 입술 결 띠 변형(§8.4 2단계-B-1d-R7, 순수 함수라 채널 벡터만으로 테스트할 수 있다). + 스프라이트를 가로 stripCount개 세로 띠로 나누고(스프라이트 폭 기준 등분, 이웃 띠와 + 1px 겹침), 띠 중심 x' = Mc.x + (x−M0.x)·W/W0, 윗입술 띠 y' = Cu(x')+(y−Cu0(x))·tU/tU0, + 아랫입술 띠 y' = Cl(x')+(y−Cu0(x))·tL/tL0(스펙대로 아래도 Cu0 기준 — 중립에서 Cu0=Cl0). */ +export function computeLipStripTransforms( + rig: LinocutRig, + v: ChannelVector, + stripCount: number = LIP_STRIP_COUNT, +): { upper: StripMatrix[]; lower: StripMatrix[] } | null { + if (!rig.lipTexture) return null; + const shape = computeMouthShape(rig, v); + const neutral = neutralMouthRef(rig); + const scaleX = shape.W / (neutral.W0 || 1); + const translateX = shape.Mc[0] - neutral.M0x * scaleX; + /* R7(§8.4 "입술 띠 변형의 기준 곡선(Cu·Cl)도 새 틈을 쓴다"): 띠 변형 기준도 R6b의 + 타원 틈 곡선을 그대로 쓴다. 틈 밖(mouthOpen=0 포함)에서는 Cl=Cu라 위·아래 입술 + 띠가 같은 닫힌 곡선을 따라간다. */ + const { CuAt, ClAt } = mouthGapCurves(shape); + + function stripsFor(sprite: RigLayer, scaleY: number, curveAt: (x: number) => number): StripMatrix[] { + const out: StripMatrix[] = []; + const stripW = sprite.w / stripCount; + for (let i = 0; i < stripCount; i++) { + const cx = sprite.x + (i + 0.5) * stripW; + const cxPrime = shape.Mc[0] + (cx - neutral.M0x) * scaleX; + const f = curveAt(cxPrime) - neutral.cuAt0(cx) * scaleY; + out.push({ a: scaleX, d: scaleY, e: translateX, f }); + } + return out; + } + + const scaleYUpper = shape.tU / (neutral.tU0 || 1); + const scaleYLower = shape.tL / (neutral.tL0 || 1); + return { + upper: stripsFor(rig.lipTexture.upper, scaleYUpper, CuAt), + lower: stripsFor(rig.lipTexture.lower, scaleYLower, ClAt), + }; +} + +/** 띠 사각형 clip의 정적 기하(스프라이트 폭 기준 등분, 이웃과 1px 겹침). 채널과 무관해 + 렌더러가 마운트 시 한 번만 계산해 에 쓴다. */ +export function lipStripClipRect(sprite: RigLayer, index: number, stripCount: number = LIP_STRIP_COUNT) { + const stripW = sprite.w / stripCount; + return { x: sprite.x + index * stripW - 0.5, y: sprite.y, w: stripW + 1, h: sprite.h }; +} + +/** rig.lipTexture가 없는 리그의 대체 경로(R8 이전 벡터 입술 그대로): 단색 입술 면 + (upperD·lowerD), 입꼬리 잉크 쐐기, 아랫입술 안쪽 보조선, 아랫입술 아래 그늘 + 초승달. R8은 lipTexture가 있는 리그에만 적용되므로(오케스트레이터 지시), 없는 + 리그는 이 함수로 기존 동작을 그대로 낸다. */ +export interface VectorLipFallback { + upperD: string; + lowerD: string; + cornerStrokesD: string; + lineSecondaryD: string; + lineSecondaryOpacity: number; + lowerShadowCrescentD: string; +} + +function cornerWedge(corner: Point, control: Point, length: number, halfWidth: number): string { + const dx = corner[0] - control[0]; + const dy = corner[1] - control[1]; + const len = Math.hypot(dx, dy) || 1; + const tip: Point = [corner[0] + (dx / len) * length, corner[1] + (dy / len) * length]; + return polylineRibbon([corner, tip], [halfWidth, 0]); +} + +function buildVectorLipFallback( + shape: MouthShape, + CuAt: (x: number) => number, + ClAt: (x: number) => number, + sizeScale: number, + chinRaise: number, +): VectorLipFallback { + const { Lc, Rc, Mc, seamControl, W, tU, tL, gapHeight } = shape; + + const upperValleyY = CuAt(Mc[0]) - tU + 4; + const lowerValleyY = ClAt(Mc[0]) + tL - 8 * chinRaise; + + const upperOuterAnchors: Point[] = [ + Lc, + [Lc[0] + 0.22 * W, CuAt(Lc[0] + 0.22 * W) - 0.55 * tU], + [Mc[0] - 0.19 * W, CuAt(Mc[0] - 0.19 * W) - tU], + [Mc[0], upperValleyY], + [Mc[0] + 0.19 * W, CuAt(Mc[0] + 0.19 * W) - tU], + [Rc[0] - 0.22 * W, CuAt(Rc[0] - 0.22 * W) - 0.55 * tU], + Rc, + ]; + const lowerOuterAnchors: Point[] = [ + Lc, + [Lc[0] + 0.25 * W, ClAt(Lc[0] + 0.25 * W) + 0.7 * tL], + [Mc[0], lowerValleyY], + [Rc[0] - 0.25 * W, ClAt(Rc[0] - 0.25 * W) + 0.7 * tL], + Rc, + ]; + + const upperOutlineD = catmullRomPathD(upperOuterAnchors); + const CLOSE_SAMPLES = 16; + const cuSamplesRcToLc = [...sampleCurveX(Lc[0], Rc[0], CLOSE_SAMPLES, CuAt)].reverse(); + const clSamplesLcToRc = sampleCurveX(Lc[0], Rc[0], CLOSE_SAMPLES, ClAt); + const upperD = `${upperOutlineD} ${catmullRomCommands(cuSamplesRcToLc)} Z`; + const lowerD = `M ${Lc[0]} ${Lc[1]} ${catmullRomCommands(clSamplesLcToRc)} ${catmullRomCommands([...lowerOuterAnchors].reverse())} Z`; + + const cornerStrokesD = [ + cornerWedge(Lc, seamControl, 10, 2.2 * sizeScale), + cornerWedge(Rc, seamControl, 10, 2.2 * sizeScale), + ].join(" "); + + const lineSecondaryD = catmullRomPathD(sampleCurveX(Lc[0] + 0.1 * W, Rc[0] - 0.1 * W, 8, ClAt)); + const lineSecondaryOpacity = gapHeight > 0.5 ? 0.6 : 0; + + const shadowBand = sampleCatmullRomWithT(lowerOuterAnchors, 8).filter((s) => s.t >= 0.15 && s.t <= 0.85); + const shadowTop = shadowBand.map((s) => [s.point[0], s.point[1] + 3] as Point); + const shadowBottom = shadowBand.map((s) => [s.point[0], s.point[1] + 9] as Point); + const lowerShadowCrescentD = [ + ...shadowTop.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`), + ...[...shadowBottom].reverse().map((p) => `L ${p[0]} ${p[1]}`), + "Z", + ].join(" "); + + return { upperD, lowerD, cornerStrokesD, lineSecondaryD, lineSecondaryOpacity, lowerShadowCrescentD }; +} + +function buildMouth(rig: LinocutRig, v: ChannelVector, sizeScale: number): MouthGeometry { + /* 찡그리면 입이 좁아진다(3차 판정: 비탄 입이 넓은 ∩자 만화 입이 되었다, §8.4 2단계-B-1d). + mouthRound(§5.4 발화층, 2B-1d-R6)는 모서리를 안쪽으로 추가 32r 옮긴다(ㅗ·ㅜ 오므림). + mouthStretch(넓힘)는 벌림 높이를 낮춘다(ㅣ·ㅡ처럼 옆으로 넓고 얕게). chinRaise(AU17)는 + 아랫입술을 밀어 올려 입을 삐죽 모은다. mouthRound는 입술을 도톰하게 한다(두께 ×(1+0.3r)). + 이 핵심값은 computeMouthShape로 뽑아 입술 결 띠 변형(computeLipStripTransforms)과 + 공유한다(중복 계산 방지, R7). */ + const shape = computeMouthShape(rig, v); + const { Lc, Rc, W, gapHeight } = shape; + const { CuAt, ClAt } = mouthGapCurves(shape); + + /* R8(2026-10-01, §8.4 입 "질감" 재개정): 입 모양은 이제 lipTexture 스프라이트 + 알파가 정한다(§8.4 원칙 "중립에서 렌더된 입은 원화와 픽셀 단위로 같고, 움직이면 + 원화 픽셀이 변형된다"). upperD·lowerD(벡터 입술 바깥선·면)는 더는 만들지 않는다 + — 이전에는 (a) 입술 단색 채움 (b) 입술 결 띠의 clip 도형으로 썼는데, 둘 다 이제 + 스프라이트 알파가 대신한다(clip은 띠별 사각형만 남는다, computeLipStripTransforms + 참고). 입 안(틈)·윗니·혀 그늘은 그대로 계산해 스프라이트 "아래"에 그린다(렌더러가 + 그리는 순서를 정한다). */ + + /* 벌어진 틈(§5.4 렌더러 입 모양 R6b): 오므린 입은 모서리부터 붙고 가운데만 둥글게 + 열린다(가운데 타원, mouthGapCurves). 틈 밖에서는 CuAt=ClAt(닫힘)이라 Lc~Rc 전체를 + 표본해도 틈 밖 구간은 저절로 높이 0인 얇은 선이 된다. */ + const GAP_SAMPLES = 16; + const innerUpper = sampleCurveX(Lc[0], Rc[0], GAP_SAMPLES, CuAt); + const innerLowerRev = [...sampleCurveX(Lc[0], Rc[0], GAP_SAMPLES, ClAt)].reverse(); + const innerD = `${catmullRomPathD([...innerUpper, ...innerLowerRev])} Z`; + const innerOpacity = gapHeight > 0.5 ? 1 : 0; + + /* 윗니(§5.4 렌더러 입 모양, R9 개정): open>0.08이고 (mouthStretch>0.2 또는 + open>0.3)이면 보인다. 높이는 min(6, 3+8·open)·(1−0.8·mouthRound)이고 틈 + 높이(G) 이하로 자른다(R9: 새하얀 막대로 튀고 ㅗ에서도 이가 도드라지던 문제 — + mouthRound가 높을수록 낮아져 오므린 입에서는 거의 안 보인다). 윗니 y는 새 + 틈(CuAt)을 따른다. */ + const teethVisible = v.mouthOpen > 0.08 && (v.mouthStretch > 0.2 || v.mouthOpen > 0.3); + const teethHeightRaw = teethVisible ? Math.min(6, 3 + 8 * v.mouthOpen) * (1 - 0.8 * clamp(v.mouthRound)) : 0; + const teethHeight = Math.min(teethHeightRaw, Math.max(0, gapHeight)); + const insetX = W * 0.12; + const teethTop = sampleCurveX(Lc[0] + insetX, Rc[0] - insetX, 6, CuAt); + const teethBottom = teethTop.map((p) => [p[0], p[1] + teethHeight] as Point); + const teethD = + teethHeight > 0 + ? [ + ...teethTop.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`), + ...[...teethBottom].reverse().map((p) => `L ${p[0]} ${p[1]}`), + "Z", + ].join(" ") + : ""; + /* R9: 윗니 색은 teeth·mouthInner를 3:1로 섞은 종이색(새하얀 막대로 안 튀게), + 위 35%에 잉크 0.35 그늘. */ + const teethFillColor = mixHex(rig.palette.teeth, rig.palette.mouthInner, 0.75); + const teethShadowD = + teethHeight > 0 + ? [ + ...teethTop.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`), + ...[...teethTop].reverse().map((p) => `L ${p[0]} ${p[1] + teethHeight * 0.35}`), + "Z", + ].join(" ") + : ""; + + /* R8: 벡터 입 선은 이제 작은 화면 보조일 뿐이다(§8.4 입 "질감" 재개정) — 큰 화면 + (face 300px, sizeScale=1)에서는 스프라이트가 결을 보여주므로 0, 작아질수록 + 0.95까지 올라간다. d(모양)는 여전히 Cu(R6b 새 틈 반영)를 따라 매 프레임 갱신한다 + (opacity가 0이어도 d는 계산해 둬야 화면이 커질 때 바로 보인다). */ + const lineSamples = sampleCurveX(Lc[0], Rc[0], 12, CuAt); + const lineHalfWidths = lineSamples.map((_, i) => valueAtStops(i / 11, MOUTH_LINE_STOPS) * sizeScale); + const accentBoost = clamp(Math.max(0, v.mouthPress - 0.3) * 1.5 + Math.max(0, -v.mouthForm - 0.3) * 1.2); + lineHalfWidths[0] += accentBoost * 0.8 * sizeScale; + lineHalfWidths[lineHalfWidths.length - 1] += accentBoost * 0.8 * sizeScale; + const lineD = polylineRibbon(lineSamples, lineHalfWidths); + /* R8은 lipTexture가 있는 리그에만 적용된다(오케스트레이터 지시). 없으면 기존대로 + 0.95 고정(벡터 선이 유일한 입 모양 전달자이므로 작은 화면 보조로 낮추면 안 된다). */ + const lineOpacity = rig.lipTexture ? 0.95 * clamp((sizeScale - 1) / 0.5, 0, 1) : 0.95; + + /* 혀 그늘(§5.4 렌더러 규칙): open>0.45이면 입 안 아래쪽에 밝은 적갈색 반달을 둔다. + Cl(아랫입술 안쪽 경계) 바로 위 얇은 띠(모서리 제외), 입 안 clip 안에서만 보인다. */ + const tongueOpacity = clamp((v.mouthOpen - 0.45) / 0.15); + const clBand = sampleCurveX(Lc[0] + 0.2 * W, Rc[0] - 0.2 * W, 8, ClAt); + const tongueTop = clBand.map((p) => [p[0], p[1] - 0.3 * gapHeight] as Point); + const tongueBottom = clBand.map((p) => [p[0], p[1] - 0.04 * gapHeight] as Point); + const tongueShadowD = + tongueOpacity > 0 + ? [ + ...tongueTop.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`), + ...[...tongueBottom].reverse().map((p) => `L ${p[0]} ${p[1]}`), + "Z", + ].join(" ") + : ""; + + /* 입술 결 띠 변형(§8.4 2단계-B-1d-R7). rig.lipTexture가 없으면 빈 배열(렌더러가 + 대체 경로로 단색만 쓴다). */ + const lipStrips = computeLipStripTransforms(rig, v); + + /* 볼 불룩(§8.4, 2026-10-01 추가): b = max(mouthPress, mouthRound) — ㅁ·ㅂ·ㅗ·ㅜ에서 + 입 옆 볼이 볼록해진다. 입꼬리마다 바깥 (±16,−4)에 흐린 잉크 타원, 그 위 (±4,−8)에 + 종이색 하이라이트. */ + const puffB = clamp(Math.max(v.mouthPress, v.mouthRound)); + const puffOpacity = 0.1 * puffB; + const cheekPuffLeft: CheekPuff = { + cx: Lc[0] - 16, + cy: Lc[1] - 4, + highlightCx: Lc[0] - 4, + highlightCy: Lc[1] - 8, + opacity: puffOpacity, + }; + const cheekPuffRight: CheekPuff = { + cx: Rc[0] + 16, + cy: Rc[1] - 4, + highlightCx: Rc[0] + 4, + highlightCy: Rc[1] - 8, + opacity: puffOpacity, + }; + + /* R8: lipTexture가 없는 리그만 예전 벡터 입술 채움·그늘·쐐기 경로를 쓴다. */ + const vectorLipFallback = rig.lipTexture ? undefined : buildVectorLipFallback(shape, CuAt, ClAt, sizeScale, v.chinRaise); + + return { + lineD, + lineOpacity, + innerD, + innerOpacity, + teethD, + teethFillColor, + teethShadowD, + tongueShadowD, + tongueShadowOpacity: tongueOpacity, + lipStripsUpper: lipStrips?.upper ?? [], + lipStripsLower: lipStrips?.lower ?? [], + cornerLeft: Lc, + cornerRight: Rc, + gapHeight, + cheekPuffLeft, + cheekPuffRight, + vectorLipFallback, + }; +} + +/* ── 하관 띠 변형(§8.4 2026-10-01 추가: "말하면 코도 들썩이고... 턱이 만든다") ───── + 입이 벌어지는 양의 대부분은 턱이 만든다. 리그 jaw(head·faceDetail에서 잘라 낸 + 조각)를 y_n부터 chinTip.y+30까지 가로 띠 JAW_STRIP_COUNT개로 나눠 세로 이동· + 배율만 준다(가로는 항등, 이웃 띠와 경계가 이어져 찢어지지 않는다). 렌더러가 각 + 띠를 faceOval과 교차한 영역에만 그린다("띠 clip = 띠 사각형 ∩ 얼굴 윤곽"). */ + +/** R10(§8.4 개정, out5 반려 대응): 20 → 12. 잘라 낸 작은 조각(rig.jaw)을 쓰면서 + 전체 이미지를 띠마다 다시 그리던 비용이 사라져 띠 수를 줄여도 경계가 덜 보인다. */ +export const JAW_STRIP_COUNT = 12; + +/** 띠 하나의 세로 아핀 변환(가로는 항등이라 렌더러는 matrix(1 0 0 scaleY 0 translateY)로 + 쓴다). 정적 사각형(경계 겹침 포함)은 jawWarpStripRects가 채널과 무관하게 따로 낸다 + (lipStripClipRect·computeLipStripTransforms의 정적/매 프레임 분리와 같은 패턴). */ +export interface JawWarpStrip { + scaleY: number; + translateY: number; +} + +export interface JawWarpLayer { + strips: JawWarpStrip[]; + /** max|f| < 0.3px(§8.4)이면 true — 렌더러가 띠를 숨기고 원본만 그린다(쉬는 동안 비용 0). */ + negligible: boolean; +} + +export interface JawWarpFrame { + head: JawWarpLayer | null; + faceDetail: JawWarpLayer | null; + /** 보고용: 턱 끝 변위 Jc(px, 아래가 +). */ + chinShiftPx: number; +} + +/** 띠 범위(y_n..chinTip.y+30)는 rig 랜드마크만으로 정해지고 채널과 무관하다. */ +function jawWarpRange(rig: LinocutRig): { yFrom: number; yTo: number } { + return { yFrom: rig.landmarks.noseTip[1] - 50, yTo: rig.landmarks.chinTip[1] + 30 }; +} + +/** 하관 띠 변형의 위쪽 경계 y_n(§8.4). 이 아래(y > y_n)의 얼굴 벡터 점·faceDetail만 + 변위장 f(y)를 받는다 — 렌더러가 faceDetail 원본을 "변형 영역을 빼고" 그릴 때 + 쓰는 상수 경계이기도 하다(R10). */ +export function jawWarpUpperBoundaryY(rig: LinocutRig): number { + return jawWarpRange(rig).yFrom; +} + +/** R10(§8.4 개정, out5 jaw-zoom 반려 대응): 띠 하나당 위아래로 이만큼 넓혀 이웃 + 띠와 겹친다(겹침 총량 JAW_STRIP_OVERLAP, lipStripClipRect와 같은 "1px 겹침" + 패턴). 직전 라운드의 흐린 마스크(feGaussianBlur stdDeviation 4, margin 12)는 + 경계 이음매를 고치지 못했고 비용만 컸다(가로세로 canvas 전체 크기의 흐림 필터를 + 띠마다 새로 태운다) — 원인은 이음매가 아니라 원본 faceDetail이 변형 영역에서 + 그대로 비쳐 보이는 것(아래 buildJawWarpLayer 주석)이었으므로, 흐림을 걷어내고 + 1px 겹침 딱딱한 clip으로 되돌린다. */ +const JAW_STRIP_OVERLAP = 1; + +/** 레이어(rig.jaw.head·rig.jaw.detail 조각, 또는 rig.jaw가 없는 리그의 대체 경로로 + 전체 head·faceDetail) 하나의 띠 JAW_STRIP_COUNT개의 정적 사각형(레이어 로컬 + 좌표, 위아래로 JAW_STRIP_OVERLAP만큼 넓혀 이웃 띠와 겹친다). 채널과 무관해 + 렌더러가 마운트 시 한 번만 계산해 에 쓴다(lipStripClipRect와 같은 + 역할). 레이어 범위 밖(원본 픽셀이 없는 범위)이면 null. */ +export function jawWarpStripRects(rig: LinocutRig, layer: RigLayer): Array<{ y: number; h: number }> | null { + const { yFrom, yTo } = jawWarpRange(rig); + const top = Math.max(yFrom, layer.y); + const bottom = Math.min(yTo, layer.y + layer.h); + if (bottom <= top) return null; + const sliceH = (bottom - top) / JAW_STRIP_COUNT; + const overlap = JAW_STRIP_OVERLAP; + const out: Array<{ y: number; h: number }> = []; + for (let i = 0; i < JAW_STRIP_COUNT; i++) { + const y0 = top + i * sliceH; + out.push({ y: y0 - overlap / 2, h: sliceH + overlap }); + } + return out; +} + +/** 세로 변위장 f(y)(§8.4): y≤y_n은 0, y_n→T.y는 smoothstep 0→−U, T.y→B.y는 + smoothstep −U→+Jl, B.y→C.y+20은 smoothstep Jl→Jc, 그 아래는 Jc. */ +function jawDisplacementField(yNose: number, tY: number, bY: number, cY: number, u: number, jl: number, jc: number) { + const bottomEdge = cY + 20; + return (y: number): number => { + if (y <= yNose) return 0; + if (y <= tY) return lerp(0, -u, smoothstep(yNose, tY, y)); + if (y <= bY) return lerp(-u, jl, smoothstep(tY, bY, y)); + if (y <= bottomEdge) return lerp(jl, jc, smoothstep(bY, bottomEdge, y)); + return jc; + }; +} + +/** 레이어 하나의 띠 JAW_STRIP_COUNT개의 매 프레임 세로 변환. 범위는 + jawWarpStripRects와 같은 top/bottom(레이어로 자른 y_n..chinTip.y+30)이다. */ +function buildJawWarpLayer(layer: RigLayer, f: (y: number) => number, yFrom: number, yTo: number): JawWarpLayer | null { + const top = Math.max(yFrom, layer.y); + const bottom = Math.min(yTo, layer.y + layer.h); + if (bottom <= top) return null; + const sliceH = (bottom - top) / JAW_STRIP_COUNT; + const strips: JawWarpStrip[] = []; + let maxAbsF = 0; + for (let i = 0; i < JAW_STRIP_COUNT; i++) { + const y0 = top + i * sliceH; + const y1 = y0 + sliceH; + const f0 = f(y0); + const f1 = f(y1); + maxAbsF = Math.max(maxAbsF, Math.abs(f0), Math.abs(f1)); + const scaleY = (y1 + f1 - (y0 + f0)) / (y1 - y0 || 1); + const translateY = y0 + f0 - y0 * scaleY; + strips.push({ scaleY, translateY }); + } + return { strips, negligible: maxAbsF < 0.3 }; +} + +/** §8.4 하관 띠 변형: 변위장 f와 head·faceDetail 띠 변환을 함께 낸다. gapHeight는 + buildMouth의 현재 틈 높이(§5.4 R6b 반영 값, "g는 현재 입 틈 높이")를 그대로 + 받는다(중복 계산 방지). R10: 띠의 원본은 rig.jaw의 잘라 낸 조각(head·detail)을 + 쓴다 — 전체 1005×1566 이미지를 띠마다 다시 그리던 out5 비용 문제(long task + 14/7.3s)의 원인이었다. rig.jaw가 없는 리그는 기존처럼 전체 head·faceDetail을 + 쓴다(대체 경로). faceDetail은 대부분 투명한 덧칠 레이어라 이 띠가 켜진 동안 + 원본 faceDetail을 그대로 함께 그리면 변형된 사본과 겹쳐 턱선이 원래 자리와 + 내려간 자리에 두 겹(점선)으로 보인다(out4·out5 반려 사유) — 이 값(negligible)을 + 렌더러가 읽어 원본 faceDetail을 "변형 영역(y > y_n, jawWarpUpperBoundaryY 아래)을 + 빼고" 그릴지 전체를 그릴지 고른다(렌더러 쪽 책임, 이 함수는 띠 변환만 낸다). */ +function computeJawWarp(rig: LinocutRig, v: ChannelVector, gapHeight: number): { frame: JawWarpFrame; f: (y: number) => number } { + const T = toPoint(rig.landmarks.upperLipTop); + const B = toPoint(rig.landmarks.lowerLipBottom); + const C = toPoint(rig.landmarks.chinTip); + const { yFrom: yNose, yTo } = jawWarpRange(rig); + const u = 0.7 * 0.25 * gapHeight + 1.5 * v.mouthStretch; + const jl = 0.8 * 0.75 * gapHeight; + const jc = 1.3 * jl; + const f = jawDisplacementField(yNose, T[1], B[1], C[1], u, jl, jc); + const headSource = rig.jaw?.head ?? rig.layers.head; + const faceDetailLayer = rig.layers.faceDetail; + const faceDetailSource = rig.jaw?.detail ?? faceDetailLayer; + const frame: JawWarpFrame = { + head: buildJawWarpLayer(headSource, f, yNose, yTo), + faceDetail: faceDetailLayer && faceDetailSource ? buildJawWarpLayer(faceDetailSource, f, yNose, yTo) : null, + chinShiftPx: jc, + }; + return { frame, f }; +} + +/* ── 프레임 조립 ───────────────────────────────────────────────────── */ + +export interface SweatDrop { + cx: number; + cy: number; + r: number; +} + +export interface LinocutFrame { + transforms: LinocutTransforms; + eyeLeft: EyeGeometry; + eyeRight: EyeGeometry; + browLeft: BrowGeometry; + browRight: BrowGeometry; + glabella: WrinkleSet; + /** 안쪽 올림 주름(AU1, 비탄·슬픔·불안) — 좌우 각 3획 무리. */ + foreheadInner: WrinkleSet; + /** 전체 올림 주름(AU1+2, 놀람·기대) — 두 꼭짓점을 가로지르는 긴 호 3획. */ + foreheadWhole: WrinkleSet; + mouth: MouthGeometry; + nasolabial: WrinkleSet; + chinCrease: WrinkleSet; + /** 하관 띠 변형(§8.4 2026-10-01 추가). */ + jawWarp: JawWarpFrame; + /** 얼굴 그룹 clip 다각형 points(R10: y>y_n 점에 하관 띠 변위 f(y) 반영, negligible이면 + 원본 얼굴 윤곽과 같은 값). */ + faceOvalWarpedPointsAttr: string; + /** 창백 마스크 다각형 points(위와 같은 이유로 f(y) 반영). */ + pallorMaskWarpedPointsAttr: string; + /** 아랫입술 아래 그늘 조각(rig.lipTexture.shadow)의 세로 이동값(§8.4: 턱 변위 + f(그늘 중심 y)만 받는다, 가로 배율 없음). shadow가 없는 리그는 0(렌더러가 + 애초에 그리지 않아 값이 쓰이지 않는다). */ + lipShadowTranslateY: number; + cheekLeftOpacity: number; + cheekRightOpacity: number; + pallorColorOpacity: number; + pallorWhiteOpacity: number; + sweatOpacity: number; + sweatDrops: [SweatDrop, SweatDrop]; +} + +const FOREHEAD_STOPS: Array = [ + [0, 0.4], + [0.5, 1.1], + [1, 0.4], +]; + +/** R10(§8.4 팔자 주름 개정, out4·out5 반려 대응): 반두께 1.1 → 0.3(이전 1.3 → 0.4는 + 코 옆 높은 곳에서 시작하는 균일한 가는 선이 입을 감싸는 괄호 아치로 떠 보였다). */ +const NASOLABIAL_STOPS: Array = [ + [0, 1.1], + [1, 0.3], +]; + +/** 안쪽 올림 주름 한쪽 3획(§8.4 2단계-B-1d): 현재 안쪽 끝·꼭짓점 사이(안쪽 65%·꼭짓점 + 35%) 위, 맨 아래가 안쪽 끝보다 22px 위·간격 11px·길이 58/48/38(아래가 김), 미간 + 쪽 끝이 4px 더 높다. */ +function browInnerClusterPaths(innerP: Point, peakP: Point, side: "left" | "right", sizeScale: number, seedBase: number): string[] { + const anchorX = innerP[0] * 0.65 + peakP[0] * 0.35; + const stops = FOREHEAD_STOPS.map(([t, hw]) => [t, hw * sizeScale] as const); + return [58, 48, 38].map((len, idx) => { + const baseY = innerP[1] - 22 - idx * 11; + const half = len / 2; + const leftY = side === "left" ? baseY : baseY - 4; + const rightY = side === "left" ? baseY - 4 : baseY; + const anchors: Point[] = [ + [anchorX - half, leftY], + [anchorX, baseY - 2], + [anchorX + half, rightY], + ]; + const wavy = wavyRibbonPoints(anchors, 6, seedBase + idx * 7, 1.5, 1.5); + return ribbonFromWavyPoints(wavy, stops); + }); +} + +/** 전체 올림 주름 한 획(§8.4 2단계-B-1d): 두 꼭짓점을 가로지르는 가운데 5px 솟은 호, + 2~3토막(틈 6~12%, 시드 고정). */ +function wholeRaiseArcPaths(peakLeft: Point, peakRight: Point, dy: number, sizeScale: number, seed: number): string[] { + const cx = (peakLeft[0] + peakRight[0]) / 2; + const halfW = Math.abs(peakRight[0] - peakLeft[0]) * 0.42; + const baseY = Math.min(peakLeft[1], peakRight[1]) - dy; + const anchors: Point[] = [ + [cx - halfW, baseY], + [cx, baseY - 5], + [cx + halfW, baseY], + ]; + const wavy = wavyRibbonPoints(anchors, 8, seed, 1.5, 1.5); + const stops = FOREHEAD_STOPS.map(([t, hw]) => [t, hw * sizeScale] as const); + return seededSegmentRanges(seed + 1).map(([tFrom, tTo]) => + ribbonFromWavyPoints( + wavy.filter((p) => p.t >= tFrom && p.t <= tTo), + stops, + ), + ); +} + +export function computeLinocutFrame(rig: LinocutRig, v: ChannelVector, sizeScale = 1): LinocutFrame { + const transforms = computeTransforms(rig, v); + + const eyeLeft = buildEye( + rig.landmarks.eyeLeft, + { open: v.eyeOpenL, smile: v.eyeSmileL, lidTension: v.lidTension, gazeX: v.gazeX, gazeY: v.gazeY, pupilSize: v.pupilSize, tear: v.tear }, + false, + sizeScale, + ); + const eyeRight = buildEye( + rig.landmarks.eyeRight, + { open: v.eyeOpenR, smile: v.eyeSmileR, lidTension: v.lidTension, gazeX: v.gazeX, gazeY: v.gazeY, pupilSize: v.pupilSize, tear: v.tear }, + true, + sizeScale, + ); + + const browLeft = buildBrow(rig.landmarks.browLeft, { y: v.browLY, angle: v.browLAngle, x: v.browLX, form: v.browForm }, "left", sizeScale); + const browRight = buildBrow(rig.landmarks.browRight, { y: v.browRY, angle: v.browRAngle, x: v.browRX, form: v.browForm }, "right", sizeScale); + + /* 미간 주름: 눈썹 안쪽 끝 중점 위, 짧은 수직 획 2개(변경 없음). */ + const browInnerLeft = toPoint(rig.landmarks.browLeft.inner); + const browInnerRight = toPoint(rig.landmarks.browRight.inner); + const glabellaCx = (browInnerLeft[0] + browInnerRight[0]) / 2; + const glabellaCy = (browInnerLeft[1] + browInnerRight[1]) / 2 - 6; + const glabellaOpacity = clamp( + Math.max(0, -v.browLX, -v.browRX) * 1.1 + Math.max(0, -v.browLAngle, -v.browRAngle) * 0.6, + 0, + 0.85, + ); + const glabella: WrinkleSet = { + paths: [ + `M ${glabellaCx - 5} ${glabellaCy - 7} L ${glabellaCx - 4} ${glabellaCy + 7}`, + `M ${glabellaCx + 5} ${glabellaCy - 7} L ${glabellaCx + 4} ${glabellaCy + 7}`, + ], + opacity: glabellaOpacity, + strokeWidth: 1.2 * sizeScale, + }; + + /* 이마 주름 두 종류(§8.4 2단계-B-1d 개정: 같은 높이 곧은 평행선 3줄이 바코드처럼 + 보인 3차 판정 대응). 안쪽 올림(AU1)은 좌우 각 3획 무리, 전체 올림(AU1+2)은 + 두 꼭짓점을 가로지르는 긴 호 3획을 2~3토막. 둘 다 현재(채널 반영 후) 눈썹 + 좌표를 따라가고, 모든 획에 사인 물결을 준다. */ + const browAngleAvg = (v.browLAngle + v.browRAngle) / 2; + const browYAvg = (v.browLY + v.browRY) / 2; + const foreheadInnerOpacity = clamp((browAngleAvg - 0.3) * 1.4, 0, 0.75); + const foreheadInner: WrinkleSet = { + paths: [ + ...browInnerClusterPaths(browLeft.innerP, browLeft.peakP, "left", sizeScale, 0), + ...browInnerClusterPaths(browRight.innerP, browRight.peakP, "right", sizeScale, 500), + ], + opacity: foreheadInnerOpacity, + }; + + const foreheadWholeOpacity = clamp((browYAvg - 0.35) * 1.6, 0, 0.7); + const foreheadWhole: WrinkleSet = { + paths: [30, 42, 54].flatMap((dy, idx) => wholeRaiseArcPaths(browLeft.peakP, browRight.peakP, dy, sizeScale, 700 + idx * 20)), + opacity: foreheadWholeOpacity, + }; + + const mouth = buildMouth(rig, v, sizeScale); + + /* 하관 띠 변형(§8.4 2026-10-01 추가): 입 틈 높이(mouth.gapHeight, §5.4 R6b 반영 + 값)로 변위장 f(y)를 정한다. 입을 제외한 얼굴 벡터 점 중 y>y_n인 점(팔자·턱 + 주름 등)에 f(y)를 더한다. */ + const jawWarp = computeJawWarp(rig, v, mouth.gapHeight); + const shiftY = (p: Point): Point => [p[0], p[1] + jawWarp.f(p[1])]; + + /* R10(§8.4 개정): 얼굴 그룹 clip 다각형·창백 마스크 다각형의 점에도 f(y)를 + 더한다 — 얼굴 윤곽이 턱과 함께 내려가야 위 shiftY로 이미 옮겨진 벡터 부위 + (팔자·턱 주름 등)와 faceDetail 띠가 원래(정지) 윤곽에서 잘리지 않는다. + head 띠의 negligible을 전체 대표값으로 쓴다(head는 항상 존재하는 필수 + 레이어라 buildJawWarpLayer가 null을 내는 경우가 사실상 없다). negligible이면 + 원본 점을 그대로 쓴다(§8.4 "negligible이면 원래 값을 쓴다"). */ + const jawNegligible = jawWarp.frame.head?.negligible ?? true; + const faceOvalBase = rig.faceOval.map(toPoint); + const faceOvalWarpedPointsAttr = formatPointsAttr(jawNegligible ? faceOvalBase : warpPointsY(faceOvalBase, jawWarp.f)); + const pallorMaskBase = shrinkPointsToward(rig.faceOval, 0.96); + const pallorMaskWarpedPointsAttr = formatPointsAttr(jawNegligible ? pallorMaskBase : warpPointsY(pallorMaskBase, jawWarp.f)); + + /* 아랫입술 아래 그늘 조각(§8.4 "질감" 개정, rig.lipTexture.shadow): 턱 변위 + f(그늘 중심 y)만큼만 세로로 옮긴다(가로 배율 없음 — out5 판정: 아랫입술 + 스프라이트에 붙여 두자 ㅗ에서 W/W0로 함께 압축되어 짙은 직사각형이 됐다. + 턱 피부 그늘은 입이 좁아져도 줄지 않는다). shadow가 없으면 값은 쓰이지 않는다. */ + const lipShadowTranslateY = rig.lipTexture?.shadow + ? jawWarp.f(rig.lipTexture.shadow.y + rig.lipTexture.shadow.h / 2) + : 0; + + /* 팔자 주름: 코 날개 옆 → 현재 입꼬리, 바깥으로 10px 부푼 호(리본, 반두께 1.3→0.4). + 수평 직선 금지(1차 반려 대응). 발화 반응(§8.4 2026-10-01): mouthStretch·mouthOpen도 + 불투명도에 더한다(ㅣ·ㅔ와 크게 벌린 입에서 깊어진다). */ + const noseTip = toPoint(rig.landmarks.noseTip); + /* R10(§8.4 팔자 주름 개정, out4·out5 반려 대응): 불투명도 상한 0.8 → 0.5로 낮춘다. */ + const nasolabialOpacity = clamp((v.mouthForm - 0.15) * 1.6 + 0.35 * v.mouthStretch + 0.2 * v.mouthOpen, 0, 0.5); + /* R10 개정: 시작 (noseTip.x∓40, noseTip.y+28), 끝 (corner.x에서 바깥으로 12, + corner.y+2), 바깥으로 6px 부푼 호. R9 좌표(시작 노즈팁 옆 38·8, 끝 입꼬리 밖 + 10·6, 부풂 8)는 여전히 joy·warm에서 입을 감싸는 괄호 아치로 떠 보였다(out4·out5 + 반려). 시작점을 코 옆 더 낮고 안쪽으로, 끝점을 입꼬리에 더 가깝게 당겨 짧고 + 낮은 주름으로 바꾼다. */ + const nasoLeftStart: Point = shiftY([noseTip[0] - 40, noseTip[1] + 28]); + const nasoLeftEnd: Point = shiftY([mouth.cornerLeft[0] - 12, mouth.cornerLeft[1] + 2]); + const nasoLeftMid: Point = [(nasoLeftStart[0] + nasoLeftEnd[0]) / 2 - 6, (nasoLeftStart[1] + nasoLeftEnd[1]) / 2]; + const nasoRightStart: Point = shiftY([noseTip[0] + 40, noseTip[1] + 28]); + const nasoRightEnd: Point = shiftY([mouth.cornerRight[0] + 12, mouth.cornerRight[1] + 2]); + const nasoRightMid: Point = [(nasoRightStart[0] + nasoRightEnd[0]) / 2 + 6, (nasoRightStart[1] + nasoRightEnd[1]) / 2]; + const nasolabialStops = NASOLABIAL_STOPS.map(([t, hw]) => [t, hw * sizeScale] as const); + const nasoLeft = curveRibbonData([nasoLeftStart, nasoLeftMid, nasoLeftEnd], nasolabialStops, 6); + const nasoRight = curveRibbonData([nasoRightStart, nasoRightMid, nasoRightEnd], nasolabialStops, 6); + const nasolabial: WrinkleSet = { + paths: [polylineRibbon(nasoLeft.samples, nasoLeft.halfWidths), polylineRibbon(nasoRight.samples, nasoRight.halfWidths)], + opacity: nasolabialOpacity, + }; + + /* 턱 주름: (B.x, B.y+26) 중심 타원(가로 0.3W, 세로 12) 안에 짧게 굽은 획 6개. 중심에도 + 하관 띠 변형 f(y)를 더한다(§8.4). */ + const lowerLipBottom = toPoint(rig.landmarks.lowerLipBottom); + const chinCreaseCenter = shiftY([lowerLipBottom[0], lowerLipBottom[1] + 26]); + const chinW = Math.abs(mouth.cornerRight[0] - mouth.cornerLeft[0]); + const chinCreaseOpacity = clamp(v.chinRaise * 0.8, 0, 0.8); + const chinCrease: WrinkleSet = { + paths: ellipseCurvedStrokes(chinCreaseCenter, chinW * 0.3, 12, 6, 400), + opacity: chinCreaseOpacity, + strokeWidth: 1.2 * sizeScale, + }; + + const cheekOpacity = clamp(v.cheek) * 0.8; + + /* 창백: 얼굴 윤곽을 4% 줄이고 흐린 마스크를 컴포넌트가 적용한다(§8.4 개정). + 여기서는 불투명도만 낸다. */ + const pallorClamped = clamp(v.pallor); + const pallorColorOpacity = pallorClamped * 0.45; + const pallorWhiteOpacity = pallorClamped * 0.12; + + const sweatOpacity = clamp(v.sweat * 1.3); + const dropY = v.sweat * 8; + const browOuterLeft = toPoint(rig.landmarks.browLeft.outer); + const browOuterRight = toPoint(rig.landmarks.browRight.outer); + const sweatDrops: [SweatDrop, SweatDrop] = [ + { cx: browOuterLeft[0] - 15, cy: browOuterLeft[1] - 25 + dropY, r: 4 }, + { cx: browOuterRight[0] + 15, cy: browOuterRight[1] - 25 + dropY, r: 4 }, + ]; + + return { + transforms, + eyeLeft, + eyeRight, + browLeft, + browRight, + glabella, + foreheadInner, + foreheadWhole, + mouth, + nasolabial, + chinCrease, + jawWarp: jawWarp.frame, + faceOvalWarpedPointsAttr, + pallorMaskWarpedPointsAttr, + lipShadowTranslateY, + cheekLeftOpacity: cheekOpacity, + cheekRightOpacity: cheekOpacity, + pallorColorOpacity, + pallorWhiteOpacity, + sweatOpacity, + sweatDrops, + }; +} + +/** 점 배열을 SVG points/polygon용 문자열로. */ +function formatPointsAttr(points: Point[]): string { + return points.map((p) => `${p[0]},${p[1]}`).join(" "); +} + +/** 다각형을 중심 기준 factor배로 줄인 점 배열(창백 마스크용). */ +function shrinkPointsToward(points: readonly RigPoint[], factor: number): Point[] { + const cx = points.reduce((s, p) => s + p[0], 0) / points.length; + const cy = points.reduce((s, p) => s + p[1], 0) / points.length; + return points.map((p) => [cx + (p[0] - cx) * factor, cy + (p[1] - cy) * factor] as Point); +} + +/** R10(§8.4 하관 띠 변형 개정): 점의 y에 변위장 f(y)를 더한다(y≤y_n인 점은 + f(y)=0이라 그대로다). 얼굴 그룹 clip 다각형·창백 마스크 다각형에 쓴다 — + 얼굴 윤곽이 턱과 함께 내려가야 이미 f(y)로 옮겨진 벡터 부위(팔자·턱 주름 등)와 + faceDetail 띠가 원래(정지) 윤곽에서 잘리지 않는다. */ +function warpPointsY(points: Point[], f: (y: number) => number): Point[] { + return points.map((p) => [p[0], p[1] + f(p[1])] as Point); +} + +/** 얼굴 윤곽 다각형을 SVG points/polygon용 문자열로. */ +export function faceOvalPointsAttr(rig: LinocutRig): string { + return formatPointsAttr(rig.faceOval.map(toPoint)); +} + +/** 창백 마스크용으로 얼굴 중심 기준 4% 줄인 다각형 points 문자열. */ +export function shrunkFaceOvalPointsAttr(rig: LinocutRig, factor = 0.96): string { + return formatPointsAttr(shrinkPointsToward(rig.faceOval, factor)); +} + +/** 얼굴 윤곽을 바깥으로 distance만큼 넓힌 다각형(꼭짓점 법선 오프셋, R9 하관 띠 + clip 확장). 각 꼭짓점에서 인접한 두 변의 법선 평균 방향으로 밀어낸다. 오목한 + 지점에서도 "바깥"이 확실하도록 중심에서 멀어지는 쪽으로 부호를 맞춘다(다각형 + 감김 방향에 기대지 않는다). 얼굴 윤곽처럼 매끈하고 대체로 볼록한 도형에서는 + 이 근사로 충분하다. */ +function offsetPolygonOutward(points: readonly RigPoint[], distance: number): Point[] { + const n = points.length; + const cx = points.reduce((s, p) => s + p[0], 0) / n; + const cy = points.reduce((s, p) => s + p[1], 0) / n; + return points.map((p, i) => { + const prev = points[(i - 1 + n) % n]; + const next = points[(i + 1) % n]; + const n1 = normal(toPoint(prev), toPoint(p)); + const n2 = normal(toPoint(p), toPoint(next)); + let nx = n1[0] + n2[0]; + let ny = n1[1] + n2[1]; + const len = Math.hypot(nx, ny) || 1; + nx /= len; + ny /= len; + const dot = nx * (p[0] - cx) + ny * (p[1] - cy); + if (dot < 0) { + nx = -nx; + ny = -ny; + } + return [p[0] + nx * distance, p[1] + ny * distance]; + }); +} + +/** R11(§8.4 개정, out6 jaw-zoom 반려 대응): 띠 하나의 clip 다각형 = 띠 사각형 + (레이어 로컬 좌표, jawWarpStripRects가 낸 y·h) ∩ 얼굴 윤곽을 바깥으로 + distance만큼 넓힌 다각형(R9 offsetPolygonOutward, rig 정적). 이 다각형을 각 + 띠의 (자신의 세로 이동·배율 transform을 받는 바로 그 요소)에 clip으로 + 걸면 교집합 경계가 띠 내용과 함께 움직인다 — R9처럼 그룹 전체에 정적 clip을 + 걸면(out6 반려 사유) 화면에 고정된 경계가 내려간 턱선을 경계마다 가로질러 잘라 + 점선처럼 보였다. 여백은 처음 18px에서 10px로 줄였다(넓으면 목 해칭까지 함께 + 내려가 목에 이음매가 생긴다 — 원화 턱선 잉크 두께 약 6px만 들어가면 된다). + Sutherland–Hodgman으로 확장 얼굴 다각형을 띠 사각형(축 정렬, 볼록)으로 잘라 + 교집합 하나만 낸다(볼록 clip 창이라 조각나지 않는다). 채널과 무관해 마운트 시 + 한 번만 계산한다(jawWarpStripRects와 같은 "정적/매 프레임 분리" 패턴). 교집합이 + 없는 자리는 null(렌더러가 그 띠를 그리지 않는다). */ +export function jawWarpStripClipPolygons( + rig: LinocutRig, + layer: RigLayer, + rects: Array<{ y: number; h: number }>, + distance = 10, +): Array { + const expanded = offsetPolygonOutward(rig.faceOval, distance); + return rects.map((rect) => { + const clipped = clipPolygonToRect(expanded, layer.x, rect.y, layer.x + layer.w, rect.y + rect.h); + return clipped.length < 3 ? null : formatPointsAttr(clipped); + }); +} + +/** 닫힌 다각형을 "M x,y L x,y ... Z" path 부분경로로. evenodd 구멍 뚫기(아래 + jawWarpHeadHoleClipD)의 외곽·구멍 부분경로 조립에 쓴다. */ +function pointsToClosedPathD(points: Point[]): string { + return points.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`).join(" ") + " Z"; +} + +/** R13(§8.4 개정, out9 jaw-zoom 반려 대응): 원본 head-image(미변형, 정적)는 지금까지 + clip 없이 항상 전체가 보였다 — 하관 띠(jaw-warp-head)가 켜진 동안에도 원본이 + 그대로 깔려 있어, 원본 턱선(제자리)과 띠가 옮겨 그린 턱선(내려간 자리)이 동시에 + 보여 턱선이 두 겹으로 갈라졌다(측정: DOM에서 head-image의 clip-path가 애초에 + null, 정적 전용/전체 전용 두 상태로 캡처하니 "제자리 끊긴 조각"과 "내려간 매끈한 + 곡선"이 각각 따로 나왔다). faceDetail(R10)이 이미 같은 문제를 "원본 전체 + (negligible)/변형 영역을 뺀 사본(그 외)" 두 장으로 나눠 풀었으니, head도 같은 + 패턴을 쓴다 — 다만 faceDetail과 달리 head-image는 얼굴 윤곽 밖(귀·머리카락·목)도 + 담고 있어 "y>y_n 전체를 뺀다"(faceDetail의 단순한 사각형 clip)를 그대로 쓰면 그 + 부분까지 잘려 나간다. 그래서 구멍은 "띠가 실제로 덮는 영역"(rects 합집합 y-범위 + ∩ 확장 얼굴 윤곽, jawWarpStripClipPolygons와 같은 확장 거리)만큼만 뚫는다 — + 귀·머리카락·목은 얼굴 윤곽 밖이라 구멍에 들지 않고 원본 그대로 남는다. evenodd + 한 path에 캔버스 전체 사각형(바깥 부분경로)과 구멍 다각형(안쪽 부분경로)을 함께 + 넣어 "사각형 − 구멍" 모양 하나를 낸다(다각형 두 개를 clipPath에 따로 넣으면 + 합집합이 되어 구멍이 뚫리지 않는다). rects가 비어 있으면(레이어가 하관 띠 범위 + 밖) null — 렌더러는 이때 구멍 없는 원본만 그린다. 채널과 무관해 마운트 시 한 번만 + 계산한다. + R14(§8.4 개정, out10 hair-edge 반려 대응): 구멍 위쪽 경계를 rects 최상단(y_n)이 + 아니라 입 중심(mouthCenter.y)으로 내린다. jawDisplacementField는 y_n부터 + upperLipTop까지 f(y)가 음수(콧날~인중이 위로 들린다) — 이 구간은 띠 내용이 + 아래(얼굴 윤곽이 좁아지는 자리)에서 끌어올려지므로, 띠의 확장 clip이 얼굴 윤곽 + 가장자리를 다 덮지 못하고 배경이 비치는 가는 틈이 남는다(볼 가장자리 흰 선). + 원본 head-image는 불투명 피부라 이 구간을 그대로 남겨 둬도 띠와 겹쳐 보이지 + 않는다(faceDetail처럼 잉크 선이 겹쳐 두 겹으로 보이는 레이어가 아니다) — 그래서 + 입 중심보다 위(코~인중)는 구멍을 뚫지 않고 원본을 남겨 틈을 메운다. 입 중심보다 + 아래(턱, f(y)가 양수로 전환하는 구간)만 여전히 구멍을 뚫어 두 겹 턱선을 막는다. */ +export function jawWarpHeadHoleClipD( + rig: LinocutRig, + rects: Array<{ y: number; h: number }>, + distance = 10, +): string | null { + if (rects.length === 0) return null; + const top = Math.max(Math.min(...rects.map((r) => r.y)), rig.landmarks.mouthCenter[1]); + const bottom = Math.max(...rects.map((r) => r.y + r.h)); + const expanded = offsetPolygonOutward(rig.faceOval, distance); + const hole = clipPolygonToRect(expanded, 0, top, rig.canvas.w, bottom); + if (hole.length < 3) return null; + const outer: Point[] = [ + [0, 0], + [rig.canvas.w, 0], + [rig.canvas.w, rig.canvas.h], + [0, rig.canvas.h], + ]; + return `${pointsToClosedPathD(outer)} ${pointsToClosedPathD(hole)}`; +} + +/** 볼록 다각형 clip 창(축 정렬 사각형)으로 다각형을 잘라 교집합 하나를 낸다 + (Sutherland–Hodgman). clip 창이 볼록이라 subject(얼굴 윤곽)가 오목해도 + 조각나지 않고 결과는 항상 단일 폐다각형이다(교집합이 없으면 빈 배열). */ +function clipPolygonToRect(poly: Point[], x0: number, y0: number, x1: number, y1: number): Point[] { + let out = poly; + out = clipHalfPlane(out, (p) => p[0] >= x0, (a, b) => intersectAtX(a, b, x0)); + out = clipHalfPlane(out, (p) => p[0] <= x1, (a, b) => intersectAtX(a, b, x1)); + out = clipHalfPlane(out, (p) => p[1] >= y0, (a, b) => intersectAtY(a, b, y0)); + out = clipHalfPlane(out, (p) => p[1] <= y1, (a, b) => intersectAtY(a, b, y1)); + return out; +} + +function clipHalfPlane(poly: Point[], inside: (p: Point) => boolean, intersect: (a: Point, b: Point) => Point): Point[] { + if (poly.length === 0) return poly; + const out: Point[] = []; + for (let i = 0; i < poly.length; i++) { + const curr = poly[i]; + const prev = poly[(i - 1 + poly.length) % poly.length]; + const currIn = inside(curr); + const prevIn = inside(prev); + if (currIn) { + if (!prevIn) out.push(intersect(prev, curr)); + out.push(curr); + } else if (prevIn) { + out.push(intersect(prev, curr)); + } + } + return out; +} + +function intersectAtX(a: Point, b: Point, x: number): Point { + const t = (x - a[0]) / (b[0] - a[0]); + return [x, a[1] + t * (b[1] - a[1])]; +} + +function intersectAtY(a: Point, b: Point, y: number): Point { + const t = (y - a[1]) / (b[1] - a[1]); + return [a[0] + t * (b[0] - a[0]), y]; +} diff --git a/apps/web/src/components/avatar/v3/linocutRig.ts b/apps/web/src/components/avatar/v3/linocutRig.ts new file mode 100644 index 0000000..885a144 --- /dev/null +++ b/apps/web/src/components/avatar/v3/linocutRig.ts @@ -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; + }; +} + +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; + /** 얼굴 윤곽 다각형(표정 부위 clip). */ + faceOval: readonly RigPoint[]; + landmarks: RigLandmarks; + palette: RigPalette; + backdrop: Record; + /** 없으면 모티프를 그리지 않는다. */ + motif?: RigMotif; +} diff --git a/apps/web/src/components/avatar/v3/rigs/p1Rig.ts b/apps/web/src/components/avatar/v3/rigs/p1Rig.ts new file mode 100644 index 0000000..b3e1277 --- /dev/null +++ b/apps/web/src/components/avatar/v3/rigs/p1Rig.ts @@ -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", + }, +}; diff --git a/apps/web/src/pages/AvatarLab.tsx b/apps/web/src/pages/AvatarLab.tsx new file mode 100644 index 0000000..758393e --- /dev/null +++ b/apps/web/src/pages/AvatarLab.tsx @@ -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> = { 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(createClock()).current; + + const [personaCode, setPersonaCode] = useState("P1"); + const [avatarState, setAvatarState] = useState("idle"); + const [openness, setOpenness] = useState(0.4); + const [reducedMotion, setReducedMotion] = useState(false); + const [paused, setPaused] = useState(false); + + const [selectedExpression, setSelectedExpression] = useState("neutral"); + const [intensity, setIntensity] = useState(0.7); + const [mirrorClip, setMirrorClip] = useState(false); + + const rig = LINOCUT_RIGS[personaCode]; + const [rendererMode, setRendererMode] = useState(rig ? "linocut" : "debug"); + const [feedbackMode, setFeedbackMode] = useState("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([]); + const [unmatched, setUnmatched] = useState([]); + const [snapshot, setSnapshot] = useState(null); + + const speechRafRef = useRef(0); + const meterRefs = useRef>({}); + + /* 발화층(립싱크) — 원시 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(null); + const audioSourceRef = useRef(null); + const audioFileInputRef = useRef(null); + const [audioFileName, setAudioFileName] = useState(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): void { + setAudioFileName(e.target.files?.[0]?.name ?? null); + } + + /** "오디오 파일로 말하기" — 로컬 오디오를 디코드해 포락선을 만들고, 오디오 시계로 표본한다. */ + async function playSpeechWithAudioFile(): Promise { + 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 ( +
+
+

아바타 v3 표현 엔진 Lab

+

채널·표정·반응 클립·지문 파서를 렌더러와 분리해 확인하는 개발 도구다(실 서비스 아님).

+
+ +
+ + + + + + + + +
+ 렌더러 + + +
+ + + + +
+ +
+ {rendererMode === "linocut" && rig ? ( + <> +
+ +
+ + + ) : ( + + )} +
+ + {rendererMode === "linocut" && rig ? ( + <> +

로드 상태: {linocutLoadState}

+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + ) : null} + +
+

표정 (28개)

+ +
+ {AVATAR_EXPRESSION_LIBRARY.map((expr) => ( + + ))} +
+
+ +
+

반응 클립 (26개)

+ +
+ {REACTION_CLIP_IDS.map((id) => ( + + ))} +
+
+ +
+

내담자 발화 · 공연 재생

+