Compare commits
5 commits
b7bd24f016
...
613bcb603e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
613bcb603e | ||
|
|
ecb36d123f | ||
|
|
f11e76ff18 | ||
|
|
00169533d8 | ||
|
|
85bd079d18 |
4
.gitignore
vendored
|
|
@ -67,3 +67,7 @@ docs/design-verification/
|
|||
# 공개 엔진 실행 로그의 로컬 백업
|
||||
/apps/api/engine.public.*.log.*.bak
|
||||
infra/.tmp-*.b64
|
||||
|
||||
# 아바타 v3 아트 파이프라인: 내려받는 MediaPipe 모델과 다시 만들 수 있는 진단 PNG
|
||||
docs/avatar-art/*/scripts/_models/
|
||||
docs/avatar-art/*/preview/**/*.png
|
||||
|
|
|
|||
|
|
@ -96,6 +96,9 @@ src/
|
|||
- UI 프리미티브는 `components/ui` 배럴에서 가져온다. props/타입이 안정 계약이다.
|
||||
- `ClientAvatar` props(`persona`/`state`/`affect`/`analyser`)는 확정 인터페이스.
|
||||
avatar 에이전트는 이 파일 내부 SVG/모션만 고도화하고 시그니처는 유지.
|
||||
리노컷 리그가 있는 페르소나(`components/avatar/v3/rigs`, 지금은 P1)는 내부에서 v3로 그리며,
|
||||
선택 prop `openness`·`surfaceIntensity`·`speech`(TTS 발화 구동)를 더 받는다(결정문
|
||||
`docs/decisions/avatar-expression-engine-v3.md` §8.5). 빌드 플래그 `VITE_AVATAR_V3=0`이면 모두 기존 SVG.
|
||||
- 세션 데이터는 `lib/api.ts`의 `sessionApi`(start/turn/end/stream) 사용.
|
||||
SSE 토큰 수신은 `openSessionStream(sessionId, { onToken, onDone, ... })`.
|
||||
- 페이지는 `default export`. `AppShell`로 감싸면 톱바/네비/역할 accent가 자동 적용.
|
||||
|
|
|
|||
|
|
@ -337,15 +337,16 @@ test.describe("persona avatar expression rig", () => {
|
|||
await expect(page.locator(".sx-stage__now")).toContainText("온화함");
|
||||
});
|
||||
|
||||
test("uses the original SVG parameter rig for P1 Seoyeon", async ({ page }) => {
|
||||
test("uses the v3 linocut avatar for P1 Seoyeon", async ({ page }) => {
|
||||
await page.goto("/learn/session/P1");
|
||||
|
||||
const avatar = page.locator('.vg-avatar[data-persona-code="P1"]').first();
|
||||
await expect(avatar).toBeVisible();
|
||||
await expect(avatar).toHaveAttribute("data-render-mode", "svg");
|
||||
await expect(avatar).toHaveAttribute("data-affect", "sad");
|
||||
await expect(avatar.locator(".vg-raster")).toHaveCount(0);
|
||||
await expect(avatar.locator(".vg-avatar__svg")).toBeVisible();
|
||||
await expect(avatar.locator('[data-avatar-neck="true"]')).toBeVisible();
|
||||
|
||||
const v3 = avatar.locator('[data-avatar-renderer="linocut"]');
|
||||
await expect(v3).toHaveAttribute("data-load-state", "ready", { timeout: 15_000 });
|
||||
await expect(v3.locator(".linocut-avatar")).toBeVisible();
|
||||
await expect(v3).toHaveAttribute("data-viseme", "X");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
316
apps/web/e2e/avatar-lab.spec.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/* =====================================================================
|
||||
avatar-lab.spec.ts — 아바타 v3 표현 엔진 dev Lab(/dev/avatar-lab) 검증 스펙.
|
||||
무인증 dev 페이지라 auth mock이 필요 없다(AvatarPreview 패턴과 동일).
|
||||
===================================================================== */
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
function channelCell(page: import("@playwright/test").Page, channel: string) {
|
||||
return page.locator(`tr[data-channel="${channel}"] td[data-value]`);
|
||||
}
|
||||
|
||||
test.describe("avatar v3 lab", () => {
|
||||
/* 이 페이지는 무인증 dev 라우트지만, AuthProvider가 마운트 시 항상 /auth/me를
|
||||
호출한다. 실 백엔드에 세션이 없으면 401이 그대로 콘솔 에러로 찍혀
|
||||
"콘솔 error 0" 판정을 이 페이지와 무관한 원인으로 깨뜨린다
|
||||
(avatar-expression-lab.spec.ts / admin-affect.spec.ts와 같은 mock 패턴). */
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
user_id: "avatar-lab-learner",
|
||||
email: "learner@hs.ac.kr",
|
||||
display_name: "Avatar Lab Learner",
|
||||
role: "learner",
|
||||
cohort_ids: [],
|
||||
onboarding_completed_at: Math.floor(Date.now() / 1000),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("콘솔 에러 없이 로드된다", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") errors.push(msg.text());
|
||||
});
|
||||
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
await expect(page.locator('[data-avatar-lab="true"]')).toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("표정 sad 강도 1 선택 후 채널이 뚜렷해진다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="expression-sad"]').click();
|
||||
const intensitySlider = page.locator(".al__intensity input[type='range']");
|
||||
await intensitySlider.focus();
|
||||
await intensitySlider.press("End");
|
||||
|
||||
const browLAngleCell = channelCell(page, "browLAngle");
|
||||
const mouthFormCell = channelCell(page, "mouthForm");
|
||||
|
||||
await expect.poll(async () => Number(await browLAngleCell.innerText()), { timeout: 3000 }).toBeGreaterThan(0.3);
|
||||
await expect.poll(async () => Number(await mouthFormCell.innerText()), { timeout: 3000 }).toBeLessThan(-0.2);
|
||||
});
|
||||
|
||||
test("nod_small 클릭 시 headPitch가 일시적으로 낮아진다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
const headPitchCell = channelCell(page, "headPitch");
|
||||
const before = Number(await headPitchCell.innerText());
|
||||
|
||||
await page.locator('[data-testid="clip-nod_small"]').click();
|
||||
|
||||
await expect
|
||||
.poll(async () => before - Number(await headPitchCell.innerText()), { timeout: 1500, intervals: [50] })
|
||||
.toBeGreaterThanOrEqual(0.05);
|
||||
});
|
||||
|
||||
test("괄호 지문이 cue로 파싱되고 미대응 지문이 표시된다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("(한숨) 그냥요. (작은 목소리로)");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
const firstCue = page.locator('[data-testid="parsed-cues"] li').first();
|
||||
await expect(firstCue).toHaveAttribute("data-clip", "sigh");
|
||||
await expect(firstCue).toHaveAttribute("data-anchor", "pre");
|
||||
await expect(firstCue).toHaveAttribute("data-source", "direction");
|
||||
|
||||
await expect(page.locator('[data-testid="unmatched-directions"] li')).toHaveText("작은 목소리로");
|
||||
});
|
||||
|
||||
test("누설 테스트 시 browLAngle이 일시적으로 커졌다가 다시 작아진다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
const browLAngleCell = channelCell(page, "browLAngle");
|
||||
const before = Number(await browLAngleCell.innerText());
|
||||
|
||||
await page.locator('[data-testid="play-leak-test"]').click();
|
||||
|
||||
await expect
|
||||
.poll(async () => Number(await browLAngleCell.innerText()) - before, { timeout: 1500, intervals: [50] })
|
||||
.toBeGreaterThanOrEqual(0.15);
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
const after = Number(await browLAngleCell.innerText());
|
||||
expect(after - before).toBeLessThan(0.15);
|
||||
});
|
||||
|
||||
test("reduced motion 토글 시 반응 클립이 비활성화된다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="reduced-motion-toggle"]').click();
|
||||
await page.waitForTimeout(150);
|
||||
await expect(page.locator('[data-testid="active-clips"] li')).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-testid="clip-nod_small"]').click();
|
||||
await page.waitForTimeout(200);
|
||||
await expect(page.locator('[data-testid="active-clips"] li')).toHaveCount(0);
|
||||
});
|
||||
|
||||
/* 발화층(립싱크) — 결정문 §5.4. 비짐별 입 모양이 채널에 실제로 합성되는지 채널 표로 확인한다. */
|
||||
|
||||
test("'우우우우' 발화 중 mouthRound이 커진다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("우우우우");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
const mouthRoundCell = channelCell(page, "mouthRound");
|
||||
await expect
|
||||
.poll(async () => Number(await mouthRoundCell.innerText()), { timeout: 3000, intervals: [50] })
|
||||
.toBeGreaterThan(0.4);
|
||||
});
|
||||
|
||||
test("'아아아아' 발화 중 mouthOpen이 커진다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("아아아아");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
const mouthOpenCell = channelCell(page, "mouthOpen");
|
||||
await expect
|
||||
.poll(async () => Number(await mouthOpenCell.innerText()), { timeout: 3000, intervals: [50] })
|
||||
.toBeGreaterThan(0.3);
|
||||
});
|
||||
|
||||
test("'음 음 음' 발화 중 mouthPress가 커진다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("음 음 음");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
const mouthPressCell = channelCell(page, "mouthPress");
|
||||
await expect
|
||||
.poll(async () => Number(await mouthPressCell.innerText()), { timeout: 3000, intervals: [50] })
|
||||
.toBeGreaterThan(0.2);
|
||||
});
|
||||
|
||||
/* 발화 동반층(§5.5) — 말할 때 머리·몸·눈썹·시선·깜빡임. */
|
||||
|
||||
test("'정말 그렇게 생각하세요?' 발화 중 headRoll 절댓값이 0.03을 넘는 순간이 있다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("정말 그렇게 생각하세요?");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
const headRollCell = channelCell(page, "headRoll");
|
||||
await expect
|
||||
.poll(async () => Math.abs(Number(await headRollCell.innerText())), { timeout: 4000, intervals: [50] })
|
||||
.toBeGreaterThan(0.03);
|
||||
});
|
||||
|
||||
test("평서문 발화 중 headPitch가 -0.05 아래로 내려가는 순간이 있다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("그냥요.");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
const headPitchCell = channelCell(page, "headPitch");
|
||||
await expect
|
||||
.poll(async () => Number(await headPitchCell.innerText()), { timeout: 4000, intervals: [50] })
|
||||
.toBeLessThan(-0.05);
|
||||
});
|
||||
|
||||
test("reduced motion에서는 발화 중 headYaw가 기본 자세에서 벗어나지 않는다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const headYawCell = channelCell(page, "headYaw");
|
||||
const baseline = Number(await headYawCell.innerText());
|
||||
|
||||
await page.locator('[data-testid="reduced-motion-toggle"]').click();
|
||||
await page.locator('[data-testid="speech-text"]').fill("정말 그렇게 생각하세요?");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
expect(Number(await headYawCell.innerText())).toBeCloseTo(baseline, 2);
|
||||
await page.waitForTimeout(800);
|
||||
expect(Number(await headYawCell.innerText())).toBeCloseTo(baseline, 2);
|
||||
});
|
||||
|
||||
/* 2단계-B — 리노컷 렌더러(§8.4). 기본 렌더러는 linocut(P1)이라 위 6개는 그대로
|
||||
통과한다(채널 미터·클립·지문 파서는 렌더러와 무관하게 동작한다). */
|
||||
|
||||
test("linocut 메인 루트가 ready가 되고 레이어 이미지 요청이 전부 200이다", async ({ page }) => {
|
||||
const imageStatuses: number[] = [];
|
||||
page.on("response", (res) => {
|
||||
if (/\/avatar\/v3\/p1\/.*\.webp$/.test(res.url())) imageStatuses.push(res.status());
|
||||
});
|
||||
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]');
|
||||
await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 });
|
||||
|
||||
expect(imageStatuses.length).toBeGreaterThanOrEqual(3);
|
||||
expect(imageStatuses.every((s) => s === 200)).toBe(true);
|
||||
});
|
||||
|
||||
test("joy 전환 후 mouth-line d가 바뀌고 grief에서 tear 부위가 보인다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]');
|
||||
await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 });
|
||||
|
||||
const mouthLine = mainSvg.locator('[data-part="mouth-line"]');
|
||||
const neutralD = await mouthLine.getAttribute("d");
|
||||
|
||||
await page.locator('[data-testid="expression-joy"]').click();
|
||||
const intensitySlider = page.locator(".al__intensity input[type='range']");
|
||||
await intensitySlider.focus();
|
||||
await intensitySlider.press("End");
|
||||
await expect.poll(async () => mouthLine.getAttribute("d"), { timeout: 2000 }).not.toBe(neutralD);
|
||||
|
||||
await page.locator('[data-testid="expression-grief"]').click();
|
||||
await expect
|
||||
.poll(async () => Number(await mainSvg.locator('[data-part="tear-left"]').getAttribute("opacity")), {
|
||||
timeout: 2000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("sad 선택 시 배경·모티프 그룹이 negative가 된다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]');
|
||||
await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 });
|
||||
|
||||
await page.locator('[data-testid="expression-sad"]').click();
|
||||
await expect(mainSvg).toHaveAttribute("data-backdrop-group", "negative");
|
||||
await expect(page.locator('[data-motif="bud-weather"]')).toHaveAttribute("data-motif-weather", "negative");
|
||||
});
|
||||
|
||||
test("피드백 모드에 따라 모티프 봉오리 개방도가 바뀐다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const motif = page.locator('[data-motif="bud-weather"]');
|
||||
const feedbackSelect = page.locator('[data-testid="feedback-mode"]');
|
||||
|
||||
await feedbackSelect.selectOption("immersive");
|
||||
await expect(motif).toHaveAttribute("data-motif-bud", "static");
|
||||
|
||||
await feedbackSelect.selectOption("coached");
|
||||
const opennessSlider = page.locator('[data-testid="openness-slider"]');
|
||||
await opennessSlider.focus();
|
||||
await opennessSlider.press("End");
|
||||
await expect(motif).toHaveAttribute("data-motif-bud", "1.00");
|
||||
});
|
||||
|
||||
test("공연을 연달아 재생하면 이전 공연의 미발동 cue가 취소된다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
await page.locator('[data-testid="pause-toggle"]').click();
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("(한숨) 첫 번째 문장이다");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
await page.waitForTimeout(150);
|
||||
const firstScheduled = await page.locator('[data-testid="scheduled-cues"] li').allTextContents();
|
||||
expect(firstScheduled.some((t) => t.includes("sigh"))).toBe(true);
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill("(시선 피함) 두 번째는 다른 지문이다");
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
await page.waitForTimeout(150);
|
||||
const secondScheduled = await page.locator('[data-testid="scheduled-cues"] li').allTextContents();
|
||||
|
||||
expect(secondScheduled.some((t) => t.includes("sigh"))).toBe(false);
|
||||
expect(secondScheduled.some((t) => t.includes("look_away_side"))).toBe(true);
|
||||
});
|
||||
|
||||
test("reduced motion에서도 linocut 루트는 ready를 유지하고 모티프 애니메이션이 멈춘다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]');
|
||||
await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 });
|
||||
|
||||
await page.locator('[data-testid="expression-sad"]').click();
|
||||
await expect(page.locator('[data-motif="bud-weather"]')).toHaveAttribute("data-motif-weather", "negative");
|
||||
|
||||
await page.locator('[data-testid="reduced-motion-toggle"]').click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
await expect(mainSvg).toHaveAttribute("data-load-state", "ready");
|
||||
const rainSprite = page.locator('[data-motif="bud-weather"] .linocut-motif__weather-negative').first();
|
||||
const animationName = await rainSprite.evaluate((el) => getComputedStyle(el).animationName);
|
||||
expect(animationName === "none" || animationName === "").toBe(true);
|
||||
});
|
||||
|
||||
test("sad + immersive는 봉오리가 숙이고, coached + 개방도 0.8 + sad는 곧게 선다", async ({ page }) => {
|
||||
await page.goto("/dev/avatar-lab?seed=1");
|
||||
const motif = page.locator('[data-motif="bud-weather"]');
|
||||
const feedbackSelect = page.locator('[data-testid="feedback-mode"]');
|
||||
|
||||
await feedbackSelect.selectOption("immersive");
|
||||
await page.locator('[data-testid="expression-sad"]').click();
|
||||
await expect(motif).toHaveAttribute("data-motif-pose", "droop");
|
||||
|
||||
await feedbackSelect.selectOption("coached");
|
||||
const opennessSlider = page.locator('[data-testid="openness-slider"]');
|
||||
await opennessSlider.evaluate((el) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(el, "0.8");
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await expect(motif).toHaveAttribute("data-motif-pose", "upright");
|
||||
});
|
||||
});
|
||||
300
apps/web/e2e/avatar-session-speech.spec.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
/* =====================================================================
|
||||
avatar-session-speech.spec.ts — P1 세션 TTS 재생 ↔ v3(리노컷) 아바타
|
||||
발화 연결 E2E(결정문 §8.5 "세션 연결" 테스트 계약).
|
||||
|
||||
목적: TTS 응답을 테스트 안에서 만든 WAV(유성 구간이 있는 톤버스트
|
||||
여러 개)로 모킹해, 재생 중 v3 래퍼의 data-speech-source="audio"·
|
||||
data-viseme 순환을 확인하고, 재생이 끝나면 발화 속성이 정리되는지
|
||||
본다. TTS 실패(500)면 data-speech-source="text"로 텍스트 타이밍
|
||||
발화로 떨어지는지도 본다.
|
||||
|
||||
근거: apps/web/src/pages/Session.tsx playTtsAudio/speakTextClientTurn,
|
||||
apps/web/src/components/avatar/v3/ClientAvatarV3.tsx.
|
||||
주의: 모든 API는 route fixture로 모킹한다(실제 AI 엔진·TTS 제공자 없음).
|
||||
헤드리스 자동재생 정책 때문에 이 파일만 autoplay-policy를 느슨하게
|
||||
연다(세션 범위, 다른 스펙에 영향 없음).
|
||||
===================================================================== */
|
||||
|
||||
import { expect, test, type Locator, type Page, type Route } from "@playwright/test";
|
||||
|
||||
test.use({ launchOptions: { args: ["--autoplay-policy=no-user-gesture-required"] } });
|
||||
|
||||
const SESSION_ID = "66666666-6666-4666-8666-666666666666";
|
||||
const LEARNER_TEXT = "요즘 많이 힘들었어요. 어떤 마음이 가장 크게 남아 있나요?";
|
||||
const CLIENT_REPLY = "그냥요. 잠을 잘 못 자요. 아무 것도 하고 싶지 않아요.";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
function sseTurnBody(tokens: string[], done: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
for (const token of tokens) lines.push("event: token", `data: ${token}`, "");
|
||||
lines.push("event: done", `data: ${JSON.stringify(done)}`, "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function fulfillTurn(route: Route, tokens: string[], done: Record<string, unknown>) {
|
||||
await route.fulfill({ status: 200, contentType: "text/event-stream", body: sseTurnBody(tokens, done) });
|
||||
}
|
||||
|
||||
function writeAsciiString(view: DataView, offset: number, text: string): void {
|
||||
for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
|
||||
}
|
||||
|
||||
/**
|
||||
* 유성 구간이 있는 톤버스트 여러 개로 테스트용 WAV를 만든다(16비트 PCM 모노).
|
||||
* decodeAudioData가 바로 디코드할 수 있다. 무음 구간(0)과 사인파 구간을 번갈아
|
||||
* 넣어 speechEnvelope.ts의 유성 구간 검출(포락선 > max(0.02, 0.12·P95))이
|
||||
* 서로 다른 구간 여러 개를 집어내게 한다.
|
||||
*/
|
||||
function buildToneBurstWav(): Buffer {
|
||||
const sampleRate = 16000;
|
||||
const amplitude = 0.7;
|
||||
const segments: Array<{ freq: number; durMs: number }> = [
|
||||
{ freq: 0, durMs: 150 },
|
||||
{ freq: 220, durMs: 320 },
|
||||
{ freq: 0, durMs: 180 },
|
||||
{ freq: 420, durMs: 280 },
|
||||
{ freq: 0, durMs: 180 },
|
||||
{ freq: 320, durMs: 300 },
|
||||
{ freq: 0, durMs: 220 },
|
||||
];
|
||||
|
||||
const samples: number[] = [];
|
||||
for (const seg of segments) {
|
||||
const count = Math.round((seg.durMs / 1000) * sampleRate);
|
||||
for (let i = 0; i < count; i++) {
|
||||
samples.push(seg.freq > 0 ? amplitude * Math.sin((2 * Math.PI * seg.freq * i) / sampleRate) : 0);
|
||||
}
|
||||
}
|
||||
|
||||
const dataLength = samples.length * 2;
|
||||
const buffer = Buffer.alloc(44 + dataLength);
|
||||
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||
|
||||
writeAsciiString(view, 0, "RIFF");
|
||||
view.setUint32(4, 36 + dataLength, true);
|
||||
writeAsciiString(view, 8, "WAVE");
|
||||
writeAsciiString(view, 12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true); // PCM
|
||||
view.setUint16(22, 1, true); // mono
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true); // byte rate(mono·16비트)
|
||||
view.setUint16(32, 2, true); // block align
|
||||
view.setUint16(34, 16, true); // bits per sample
|
||||
writeAsciiString(view, 36, "data");
|
||||
view.setUint32(40, dataLength, true);
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
||||
view.setInt16(44 + i * 2, Math.round(clamped * 32767), true);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function routeSessionScreen(page: Page) {
|
||||
const startedAt = new Date(Date.now() - 60_000);
|
||||
|
||||
// catch-all을 먼저 등록한다 — Playwright는 나중에 등록한 route가 이긴다.
|
||||
await page.route("**/api/**", (route) => route.fulfill(jsonRoute({ detail: "not part of this fixture" }, 404)));
|
||||
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
user_id: "00000000-0000-0000-0000-0avatarspeech",
|
||||
email: "avatar-speech.learner@hs.ac.kr",
|
||||
display_name: "학습자",
|
||||
role: "learner",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: Math.floor(Date.now() / 1000),
|
||||
onboarding_completed_at: Math.floor(Date.now() / 1000),
|
||||
nickname: "학습자",
|
||||
self_introduction: "아바타 v3 발화 연결 E2E 검증용 학습자입니다.",
|
||||
avatar_url: "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route("**/api/users/me/prepost-measures**", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
pilot_id: "phase3-pilot-draft",
|
||||
instrument_version: "pilot-prepost-scaffold-2026-06-28",
|
||||
measures: [],
|
||||
complete_pre_count: 0,
|
||||
complete_post_count: 0,
|
||||
updated_at: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route("**/api/personas", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute([
|
||||
{
|
||||
code: "P1",
|
||||
display_name: "서연(가명) · 고2 · 우울/자살사고",
|
||||
difficulty: "hard",
|
||||
theory_target: ["humanistic", "cbt"],
|
||||
demographics: { age_band: "16-18", sex: "female", grade: "고2", status: "재학" },
|
||||
presenting_summary: "우울감과 자살사고 위험",
|
||||
voice_preset: null,
|
||||
source: "database",
|
||||
degraded: false,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route("**/api/voice/health", (route) =>
|
||||
route.fulfill(jsonRoute({ available: false, reason: "e2e fixture" })),
|
||||
);
|
||||
|
||||
await page.route("**/api/sessions", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute(
|
||||
{
|
||||
session_id: SESSION_ID,
|
||||
case_id: "avatar-speech-case-001",
|
||||
session_no: 1,
|
||||
stage: "라포",
|
||||
effective_openness: 0.3,
|
||||
recall_summary: null,
|
||||
degraded: false,
|
||||
},
|
||||
201,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}`, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
session_id: SESSION_ID,
|
||||
case_id: "avatar-speech-case-001",
|
||||
persona_code: "P1",
|
||||
persona_name: "서연",
|
||||
session_no: 1,
|
||||
status: "active",
|
||||
stage: "라포",
|
||||
theory_mode: "humanistic",
|
||||
effective_openness: 0.3,
|
||||
started_at: startedAt.toISOString(),
|
||||
ended_at: null,
|
||||
review_ready: false,
|
||||
turns: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// 회기 전 자기점검(pre)은 이미 원장에 잠긴 상태로 제공한다 — 이 스펙의 대상이 아니다.
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/alliance-pulses`, async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill(jsonRoute({ pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "awaiting_agents" }, 202));
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute({
|
||||
items: [
|
||||
{
|
||||
pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
checkpoint: "pre",
|
||||
status: "ready",
|
||||
learner_locked_at: startedAt.toISOString(),
|
||||
revealed_at: startedAt.toISOString(),
|
||||
error_code: null,
|
||||
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
|
||||
measurements: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/live-coach`, (route) =>
|
||||
route.fulfill(jsonRoute({ source: "database", quota: { remaining: 3, max: 3 }, credit_events: [], events: [] })),
|
||||
);
|
||||
}
|
||||
|
||||
function doneEvent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
session_id: SESSION_ID,
|
||||
stage: "라포",
|
||||
effective_openness: 0.42,
|
||||
turn_seq: 1,
|
||||
safety_flagged: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendLearnerTurn(page: Page) {
|
||||
const input = page.getByLabel("학습자 발화 입력");
|
||||
await input.fill(LEARNER_TEXT);
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
}
|
||||
|
||||
/** 재생 중 data-viseme가 거친 서로 다른 값의 집합(관찰 상한 5초, 50ms 간격 표본). */
|
||||
async function collectDistinctVisemes(locator: Locator, minDistinct: number): Promise<Set<string>> {
|
||||
const seen = new Set<string>();
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
const viseme = await locator.getAttribute("data-viseme");
|
||||
if (viseme) seen.add(viseme);
|
||||
if (seen.size >= minDistinct) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
test.describe("P1 세션 — v3 아바타 TTS 발화 연결", () => {
|
||||
test("TTS 오디오 재생 중 립싱크가 돌고, 끝나면 발화 속성이 정리된다", async ({ page }) => {
|
||||
await routeSessionScreen(page);
|
||||
await page.route("**/api/voice/speech", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "audio/wav", body: buildToneBurstWav() }),
|
||||
);
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) => fulfillTurn(route, [CLIENT_REPLY], doneEvent()));
|
||||
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await expect(page.locator(".sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await sendLearnerTurn(page);
|
||||
|
||||
const v3 = page.locator(".sx-page--active .vg-avatar [data-avatar-renderer='linocut']");
|
||||
await expect(v3).toHaveAttribute("data-speech-source", "audio", { timeout: 15_000 });
|
||||
|
||||
const visemes = await collectDistinctVisemes(v3, 2);
|
||||
expect(visemes.size, `관찰한 비짐: ${[...visemes].join(",")}`).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await expect(v3).not.toHaveAttribute("data-speech-source", "audio", { timeout: 10_000 });
|
||||
await expect(v3).toHaveAttribute("data-viseme", "X", { timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("TTS 실패(500)면 텍스트 타이밍 발화로 떨어진다", async ({ page }) => {
|
||||
await routeSessionScreen(page);
|
||||
await page.route("**/api/voice/speech", (route) => route.fulfill(jsonRoute({ detail: "tts failure fixture" }, 500)));
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) => fulfillTurn(route, [CLIENT_REPLY], doneEvent()));
|
||||
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await expect(page.locator(".sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await sendLearnerTurn(page);
|
||||
|
||||
const v3 = page.locator(".sx-page--active .vg-avatar [data-avatar-renderer='linocut']");
|
||||
await expect(v3).toHaveAttribute("data-speech-source", "text", { timeout: 15_000 });
|
||||
|
||||
const visemes = await collectDistinctVisemes(v3, 2);
|
||||
expect(visemes.size, `관찰한 비짐: ${[...visemes].join(",")}`).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -666,6 +666,62 @@ test.describe("learner session full-screen layout", () => {
|
|||
}
|
||||
});
|
||||
|
||||
// P1 서연은 v3(리노컷) 아바타로 그린다(결정문 §8.5). 위 "dense viewport" 스윕을 P1로도
|
||||
// 돌려 v3 래퍼(절대 위치·원형 클립)가 기존 SVG와 같은 레이아웃 게이트를 통과하는지 본다.
|
||||
test("keeps critical session controls visible across dense viewport sizes for the v3 avatar (P1)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await signInAsLearner(page);
|
||||
|
||||
const viewports = [
|
||||
{ width: 1366, height: 768 },
|
||||
{ width: 1180, height: 768 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 881, height: 768 },
|
||||
{ width: 820, height: 1180 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 320, height: 568 },
|
||||
];
|
||||
|
||||
await page.setViewportSize(viewports[0]);
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await completeAlliancePreCheckpoint(page);
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
for (const viewport of viewports) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
await expectNoDocumentOverflow(page);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectSessionControlsInsideViewport(page);
|
||||
await expectNoVisibleSessionPanelOverlap(page);
|
||||
await expectMainControlsUnclipped(page);
|
||||
await expectSessionPageHeightToMatchViewport(page);
|
||||
await expectActiveSessionUsableLayout(page);
|
||||
|
||||
/* 320×568 저높이 폰은 session.css가 아바타 오브(.sx-orb-wrap)를 숨긴다(기존 SVG도 같다).
|
||||
보일 때는 v3 래퍼가 스테이지 원 자리를 정확히 차지해야 한다. */
|
||||
const orbShown = !(viewport.width === 320 && viewport.height === 568);
|
||||
const stage = page.locator(".sx-page--active .vg-avatar__stage").first();
|
||||
if (orbShown) {
|
||||
await expect(stage).toBeVisible();
|
||||
const v3 = stage.locator('[data-avatar-renderer="linocut"]');
|
||||
await expect(v3).toHaveAttribute("data-load-state", "ready", { timeout: 15_000 });
|
||||
const [stageBox, v3Box] = await Promise.all([stage.boundingBox(), v3.boundingBox()]);
|
||||
expect(stageBox, "아바타 스테이지 좌표").not.toBeNull();
|
||||
expect(v3Box, "v3 래퍼 좌표").not.toBeNull();
|
||||
for (const key of ["x", "y", "width", "height"] as const) {
|
||||
expect(Math.abs(stageBox![key] - v3Box![key]), `v3 래퍼 ${key}`).toBeLessThanOrEqual(1);
|
||||
}
|
||||
} else {
|
||||
await expect(page.locator(".sx-page--active .sx-orb-wrap")).toBeHidden();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("does not leave an unsaved local transcript when a text turn is rejected", async ({ page }) => {
|
||||
await signInAsLearner(page);
|
||||
const persona = await fetchAvailablePersona(page, 1);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@
|
|||
"check:dead-code": "knip --include files,dependencies,unlisted,unresolved,binaries --treat-config-hints-as-errors",
|
||||
"check:duplication": "jscpd src ../api/app --min-lines 8 --min-tokens 80 --ignore **/test_*.py,**/*_test.py,**/api.gen.ts,**/*.css,**/*.d.ts --threshold 0.05 --reporters console,threshold --no-tips",
|
||||
"generate:live2d-assets": "node scripts/generate-live2d-assets.mjs",
|
||||
"check:avatar-presets": "node scripts/check-avatar-presets.mjs",
|
||||
"check:avatar-lipsync": "node scripts/check-avatar-lipsync.mjs",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "npm run check:cosmetic-filter-safety && tsc -b",
|
||||
|
|
|
|||
BIN
apps/web/public/avatar/v3/p1/body.webp
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/web/public/avatar/v3/p1/face-detail.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/web/public/avatar/v3/p1/hair-front.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/web/public/avatar/v3/p1/head.webp
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
apps/web/public/avatar/v3/p1/jaw-detail.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/web/public/avatar/v3/p1/jaw-head.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/web/public/avatar/v3/p1/lip-lower.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/v3/p1/lip-shadow.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/v3/p1/lip-upper.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/bud-closed.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/bud-droop.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/bud-half.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/bud-open.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/weather-cognitive.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/weather-defensive.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/weather-energy.webp
Normal file
|
After Width: | Height: | Size: 7 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/weather-negative.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/web/public/avatar/v3/p1/motif/weather-positive.webp
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
apps/web/public/avatar/v3/p1/paper-grain.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
288
apps/web/scripts/check-avatar-lipsync.mjs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-avatar-lipsync — 아바타 v3 발화층(립싱크) 데이터 정합성 검사.
|
||||
* docs/decisions/avatar-expression-engine-v3.md §5.4(발화층) "검증" 항목을 값·코드에 대해 검증한다.
|
||||
* engine 데이터 파일은 순수 TS라 esbuild로 node ESM으로 번들해(check-avatar-presets.mjs 패턴)
|
||||
* 동적 import로 값을 읽는다. 임시 산출물은 끝나면 지운다.
|
||||
*/
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const appRoot = process.cwd();
|
||||
const engineDir = path.join(appRoot, "src", "components", "avatar", "engine");
|
||||
|
||||
const ENTRY_FILES = ["lipsync.ts", "speechEnvelope.ts", "demeanorDefaults.ts", "coSpeech.ts", "engine.ts"];
|
||||
|
||||
const failures = [];
|
||||
function fail(message) {
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
async function loadModules() {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "vignette-avatar-lipsync-check-"));
|
||||
try {
|
||||
await writeFile(path.join(tempDir, "package.json"), JSON.stringify({ type: "module" }), "utf8");
|
||||
await build({
|
||||
entryPoints: ENTRY_FILES.map((f) => path.join(engineDir, f)),
|
||||
outdir: tempDir,
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
logLevel: "silent",
|
||||
});
|
||||
|
||||
const modules = {};
|
||||
for (const f of ENTRY_FILES) {
|
||||
const outFile = path.join(tempDir, f.replace(/\.ts$/, ".js"));
|
||||
const url = `${pathToFileURL(outFile).href}?t=${Date.now()}`;
|
||||
modules[f] = await import(url);
|
||||
}
|
||||
return modules;
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function nonSilentSequence(seq) {
|
||||
return seq.filter((v) => v !== "X");
|
||||
}
|
||||
|
||||
/* (a) 분해·매핑 표본(§5.4 검증: "음"은 끝이 M, "우리"는 U→I, "봐요"는 M→U→A→I→O). */
|
||||
function checkVisemeSamples(buildSpeechTimeline, visemeSequence) {
|
||||
const cases = [
|
||||
["음", ["EU", "M"]],
|
||||
["우리", ["U", "I"]],
|
||||
["봐요", ["M", "U", "A", "I", "O"]],
|
||||
];
|
||||
for (const [text, expected] of cases) {
|
||||
const timeline = buildSpeechTimeline({ text, syllablesPerSec: 5.0 });
|
||||
const seq = nonSilentSequence(visemeSequence(timeline));
|
||||
if (JSON.stringify(seq) !== JSON.stringify(expected)) {
|
||||
fail(`비짐 표본 "${text}": 기대 [${expected.join(",")}], 실제 [${seq.join(",")}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* (b) 최소 유지 60ms, M·X 밖에서는 open > 0(§5.4 검증). */
|
||||
function checkTimingInvariants(buildSpeechTimeline) {
|
||||
const timeline = buildSpeechTimeline({ text: "음… 그냥 요즘 잠을 잘 못 자요.", syllablesPerSec: 5.0 });
|
||||
for (let i = 1; i < timeline.keys.length; i++) {
|
||||
const gap = timeline.keys[i].atMs - timeline.keys[i - 1].atMs;
|
||||
if (gap < 59.99) fail(`최소 유지: 키 ${i - 1}→${i} 간격 ${gap.toFixed(2)}ms < 60ms`);
|
||||
}
|
||||
for (const key of timeline.keys) {
|
||||
if (key.viseme === "M" || key.viseme === "X") continue;
|
||||
if (!(key.shape.open > 0)) fail(`open>0 불변량: 비짐 ${key.viseme}의 open=${key.shape.open}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* (c) 텍스트 타이밍에서 syllablesPerSec와 발화 길이의 일치(받침·휴지 없는 표본으로 가중치를 1.0 고정). */
|
||||
function checkTextTimingRate(buildSpeechTimeline) {
|
||||
const text = "가나다라마바사";
|
||||
const syllablesPerSec = 5.0;
|
||||
const timeline = buildSpeechTimeline({ text, syllablesPerSec });
|
||||
const expectedMs = 7 * (1000 / syllablesPerSec);
|
||||
if (Math.abs(timeline.totalDurationMs - expectedMs) > 0.01) {
|
||||
fail(`텍스트 타이밍: 기대 ${expectedMs}ms, 실제 ${timeline.totalDurationMs}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
/* (d) 합성 포락선(구 3개·무음 2개) 정렬: 구 경계 오차 ≤ 30ms(§5.4 검증). */
|
||||
function checkAudioAlignment(buildSpeechTimeline) {
|
||||
const hopMs = 10;
|
||||
const totalHops = 150;
|
||||
const rms = new Float32Array(totalHops).fill(0.01);
|
||||
const loudSpans = [
|
||||
[0, 20],
|
||||
[50, 70],
|
||||
[100, 120],
|
||||
];
|
||||
for (const [a, b] of loudSpans) for (let i = a; i < b; i++) rms[i] = 1.0;
|
||||
const sorted = Array.from(rms).sort((a, b) => a - b);
|
||||
const pct = (p) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1))];
|
||||
const envelope = { hopMs, rms, p90: pct(0.9), p95: pct(0.95) };
|
||||
|
||||
const timeline = buildSpeechTimeline({ text: "가. 나. 다", syllablesPerSec: 5.0, envelope });
|
||||
const expected = loudSpans.map(([a, b]) => ({ startMs: a * hopMs, endMs: b * hopMs }));
|
||||
const windows = timeline.phraseWindows ?? [];
|
||||
if (windows.length !== expected.length) {
|
||||
fail(`구 경계: 기대 구 ${expected.length}개, 실제 ${windows.length}개`);
|
||||
return;
|
||||
}
|
||||
windows.forEach((w, i) => {
|
||||
const startErr = Math.abs(w.startMs - expected[i].startMs);
|
||||
const endErr = Math.abs(w.endMs - expected[i].endMs);
|
||||
if (startErr > 30) fail(`구 경계 ${i}: 시작 오차 ${startErr}ms > 30ms`);
|
||||
if (endErr > 30) fail(`구 경계 ${i}: 끝 오차 ${endErr}ms > 30ms`);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── §5.5 발화 동반층 검증 ─────────────────────────────────────────── */
|
||||
|
||||
/* (e) 같은 시드면 계획이 같다. */
|
||||
function checkPlanDeterminism(buildSpeechTimeline, buildCoSpeechPlan) {
|
||||
const timeline = buildSpeechTimeline({ text: "음… 그냥 요즘 잠을 잘 못 자요. 정말 그렇게 생각하세요?", syllablesPerSec: 5.0 });
|
||||
const style = { gesture: 0.55, gazeAvertProb: 0.7 };
|
||||
const a = buildCoSpeechPlan(timeline, style, 7);
|
||||
const b = buildCoSpeechPlan(timeline, style, 7);
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) fail("계획 결정성: 같은 시드인데 계획이 다르다");
|
||||
}
|
||||
|
||||
/* (f) 강세 박 정점 = 모음 목표 − 80ms(±1ms). */
|
||||
function checkStressPulsePeaks(buildSpeechTimeline, buildCoSpeechPlan) {
|
||||
const timeline = buildSpeechTimeline({ text: "음… 그냥 요즘 잠을 잘 못 자요. 정말 그렇게 생각하세요?", syllablesPerSec: 5.0 });
|
||||
const plan = buildCoSpeechPlan(timeline, { gesture: 0.55, gazeAvertProb: 0.7 }, 7);
|
||||
|
||||
const expectedPeaks = [];
|
||||
for (const ph of timeline.phrases) {
|
||||
for (const idx of ph.stressedIndices) {
|
||||
const vowelMs = ph.syllableTargetsMs[idx];
|
||||
if (vowelMs !== undefined) expectedPeaks.push(vowelMs - 80);
|
||||
}
|
||||
}
|
||||
const actualPeaks = plan.stressPulses.map((p) => p.peakMs);
|
||||
if (actualPeaks.length !== expectedPeaks.length) {
|
||||
fail(`강세 박 개수: 기대 ${expectedPeaks.length}, 실제 ${actualPeaks.length}`);
|
||||
return;
|
||||
}
|
||||
expectedPeaks.forEach((exp, i) => {
|
||||
const err = Math.abs(actualPeaks[i] - exp);
|
||||
if (err > 1) fail(`강세 박 ${i} 정점: 기대 ${exp}ms, 실제 ${actualPeaks[i]}ms(오차 ${err.toFixed(2)}ms)`);
|
||||
});
|
||||
}
|
||||
|
||||
/* (g) 질문 구에서 headRoll·headPitch가 들린다. */
|
||||
function checkQuestionLift(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech) {
|
||||
const timeline = buildSpeechTimeline({ text: "정말 그렇게 생각하세요?", syllablesPerSec: 5.0 });
|
||||
const plan = buildCoSpeechPlan(timeline, { gesture: 0.55, gazeAvertProb: 0.7 }, 7);
|
||||
const ph = timeline.phrases[0];
|
||||
const holdMs = ph.endMs + 150; /* 300ms 유지 구간 중간 */
|
||||
const sample = sampleCoSpeech(plan, holdMs, holdMs - 16, undefined);
|
||||
if (!(sample.delta.headPitch > 0)) fail(`질문 구 headPitch: ${holdMs}ms에 ${sample.delta.headPitch}(양수 기대)`);
|
||||
if (!(Math.abs(sample.delta.headRoll) > 0)) fail(`질문 구 headRoll: ${holdMs}ms에 ${sample.delta.headRoll}(0이 아닌 값 기대)`);
|
||||
}
|
||||
|
||||
/* (h) 발화 마지막 400ms에는 시선 회피가 없다. */
|
||||
function checkNoGazeAvertInTail(buildSpeechTimeline, buildCoSpeechPlan) {
|
||||
const timeline = buildSpeechTimeline({ text: "가나다라마바사아자차카, 나", syllablesPerSec: 5.0 });
|
||||
const lastPhrase = timeline.phrases[timeline.phrases.length - 1];
|
||||
if (!(lastPhrase.startMs >= timeline.totalDurationMs - 400)) {
|
||||
fail("시선 회피 표본 설계: 마지막 구가 발화 마지막 400ms 안에서 시작하지 않는다(표본 텍스트 재조정 필요)");
|
||||
return;
|
||||
}
|
||||
const plan = buildCoSpeechPlan(timeline, { gesture: 1, gazeAvertProb: 1 }, 3);
|
||||
if (plan.gazePulses.length !== timeline.phrases.length - 1) {
|
||||
fail(`시선 회피 발동 수: 기대 ${timeline.phrases.length - 1}(마지막 구 제외), 실제 ${plan.gazePulses.length}`);
|
||||
}
|
||||
for (const g of plan.gazePulses) {
|
||||
const startMs = g.breakpoints[0].t;
|
||||
if (startMs >= timeline.totalDurationMs - 400) fail(`시선 회피가 발화 마지막 400ms 안(${startMs}ms)에서 시작했다`);
|
||||
}
|
||||
}
|
||||
|
||||
/* (h-2) 평서 구 끝 끄덕임 headPitch < 0. */
|
||||
function checkStatementNodPitch(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech) {
|
||||
const timeline = buildSpeechTimeline({ text: "그냥요.", syllablesPerSec: 5.0 });
|
||||
const plan = buildCoSpeechPlan(timeline, { gesture: 0.55, gazeAvertProb: 0.7 }, 7);
|
||||
const ph = timeline.phrases[0];
|
||||
const lastTargetMs = ph.syllableTargetsMs[ph.syllableTargetsMs.length - 1];
|
||||
const sample = sampleCoSpeech(plan, lastTargetMs, lastTargetMs - 16, undefined);
|
||||
if (!(sample.delta.headPitch < 0)) fail(`평서 구 끝 끄덕임: ${lastTargetMs}ms에 headPitch=${sample.delta.headPitch}(음수 기대)`);
|
||||
}
|
||||
|
||||
/* (h-3) 엔진 통과: speaking 중 강세 박은 스프링 없이 그대로 통과해 정점 시각·크기를 지킨다
|
||||
(팀 리드 수정 요청, 2026-10-01). reduced motion 끔, 표정 neutral, 고정 시계 10ms 간격. */
|
||||
function checkEngineStressPulsePassthrough(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech, demeanorFor, createAvatarEngine) {
|
||||
const timeline = buildSpeechTimeline({ text: "그냥요.", syllablesPerSec: 5.0 });
|
||||
const style = { gesture: 0.55, gazeAvertProb: 0.7 };
|
||||
const plan = buildCoSpeechPlan(timeline, style, 7);
|
||||
const stress = plan.stressPulses[0];
|
||||
if (!stress) {
|
||||
fail("엔진 통과 검증: 강세 박이 없다");
|
||||
return;
|
||||
}
|
||||
|
||||
const demeanor = demeanorFor("P1");
|
||||
const engine = createAvatarEngine({ demeanor, seed: 1, reducedMotion: false });
|
||||
engine.setState("speaking", 0);
|
||||
engine.setSurface("neutral", 0);
|
||||
|
||||
const scanStart = Math.max(0, stress.peakMs - stress.riseMs - 50);
|
||||
const scanEnd = stress.peakMs + stress.fallMs + 50;
|
||||
const stepMs = 10;
|
||||
|
||||
const baseline = engine.evaluate(scanStart);
|
||||
const baseHeadPitch = baseline.headPitch;
|
||||
|
||||
let prevLocalMs = scanStart;
|
||||
let bestT = null;
|
||||
let bestDelta = 0;
|
||||
for (let t = scanStart; t <= scanEnd; t += stepMs) {
|
||||
const sample = sampleCoSpeech(plan, t, prevLocalMs);
|
||||
engine.setSpeechMotion(sample.delta);
|
||||
prevLocalMs = t;
|
||||
const v = engine.evaluate(t);
|
||||
const coHeadPitch = v.headPitch - baseHeadPitch;
|
||||
if (bestT === null || coHeadPitch < bestDelta) {
|
||||
bestDelta = coHeadPitch;
|
||||
bestT = t;
|
||||
}
|
||||
}
|
||||
|
||||
const timeErr = Math.abs(bestT - stress.peakMs);
|
||||
if (timeErr > 10) fail(`엔진 통과: 강세 박 정점 시각 오차 ${timeErr}ms > 10ms(계획 ${stress.peakMs}ms, 엔진 ${bestT}ms)`);
|
||||
const expectedMag = Math.abs(stress.headPitch);
|
||||
const actualMag = Math.abs(bestDelta);
|
||||
if (actualMag < expectedMag * 0.9) {
|
||||
fail(`엔진 통과: 강세 박 크기 ${actualMag.toFixed(4)} < 계획 값의 90%(${(expectedMag * 0.9).toFixed(4)})`);
|
||||
}
|
||||
}
|
||||
|
||||
/* (i) reduced motion에서는 엔진 delta가 0. */
|
||||
function checkReducedMotionZero(demeanorFor, createAvatarEngine) {
|
||||
const demeanor = demeanorFor("P1");
|
||||
const engine = createAvatarEngine({ demeanor, seed: 1, reducedMotion: true });
|
||||
engine.setState("speaking", 0);
|
||||
engine.setSpeechMotion({ headYaw: 0.9, headRoll: 0.9, browLY: 0.9, browRY: 0.9, gazeX: 0.9 });
|
||||
const v = engine.evaluate(50);
|
||||
/* P1 basePose는 이 채널들을 건드리지 않아 중립값이 정확히 0이다(demeanorDefaults.ts 기준). */
|
||||
for (const id of ["headYaw", "headRoll", "browLY", "browRY", "gazeX"]) {
|
||||
if (v[id] !== 0) fail(`reduced motion delta: ${id} 기대 0, 실제 ${v[id]}(setSpeechMotion이 반영되면 안 된다)`);
|
||||
}
|
||||
}
|
||||
|
||||
const modules = await loadModules();
|
||||
const { buildSpeechTimeline, visemeSequence } = modules["lipsync.ts"];
|
||||
const { demeanorFor } = modules["demeanorDefaults.ts"];
|
||||
const { buildCoSpeechPlan, sampleCoSpeech } = modules["coSpeech.ts"];
|
||||
const { createAvatarEngine } = modules["engine.ts"];
|
||||
|
||||
const p1Rate = demeanorFor("P1").speech.syllablesPerSec;
|
||||
if (p1Rate !== 5.0) fail(`P1 syllablesPerSec 기대 5.0, 실제 ${p1Rate}`);
|
||||
|
||||
checkVisemeSamples(buildSpeechTimeline, visemeSequence);
|
||||
checkTimingInvariants(buildSpeechTimeline);
|
||||
checkTextTimingRate(buildSpeechTimeline);
|
||||
checkAudioAlignment(buildSpeechTimeline);
|
||||
|
||||
checkPlanDeterminism(buildSpeechTimeline, buildCoSpeechPlan);
|
||||
checkStressPulsePeaks(buildSpeechTimeline, buildCoSpeechPlan);
|
||||
checkQuestionLift(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech);
|
||||
checkNoGazeAvertInTail(buildSpeechTimeline, buildCoSpeechPlan);
|
||||
checkStatementNodPitch(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech);
|
||||
checkEngineStressPulsePassthrough(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech, demeanorFor, createAvatarEngine);
|
||||
checkReducedMotionZero(demeanorFor, createAvatarEngine);
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const message of failures) console.error(`FAIL: ${message}`);
|
||||
console.error(`check-avatar-lipsync: 실패 ${failures.length}건`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"check-avatar-lipsync: 통과 (비짐 표본 3건, 최소 유지·open>0 불변량, 텍스트 발화 속도 일치, 구 경계 정렬 ≤30ms, " +
|
||||
"발화 동반층 결정성·강세 박 정점·질문 들림·시선 회피 꼬리 금지·평서 구 끝 끄덕임·엔진 통과·reduced motion 0)",
|
||||
);
|
||||
267
apps/web/scripts/check-avatar-presets.mjs
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-avatar-presets — 아바타 v3 표현 엔진 데이터 정합성 검사.
|
||||
* docs/decisions/avatar-expression-engine-v3.md §5.1(구분 가능성 불변량)·
|
||||
* §6.1(클립 형식)·§7.3(지문 파서) 규칙을 값·코드에 대해 검증한다.
|
||||
*
|
||||
* engine 데이터 파일은 순수 TS라 esbuild로 각각 node ESM으로 번들해(임시 디렉터리,
|
||||
* generate-live2d-assets.mjs 패턴) 동적 import로 값을 읽는다. 임시 산출물은 끝나면 지운다.
|
||||
*/
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const appRoot = process.cwd();
|
||||
const engineDir = path.join(appRoot, "src", "components", "avatar", "engine");
|
||||
|
||||
const ENTRY_FILES = [
|
||||
"channels.ts",
|
||||
"expressionPresets.ts",
|
||||
"clipCatalog.ts",
|
||||
"demeanorDefaults.ts",
|
||||
"stageDirectionLexicon.ts",
|
||||
"performance.ts",
|
||||
];
|
||||
|
||||
/* 저작용 대칭 키 — channels.ts SYMMETRIC_KEYS 와 같은 쌍. 구분 가능성 불변량 계산에서
|
||||
좌우를 평균 1개 채널로 접는다(결정문 §5.1). */
|
||||
const SYMMETRIC_PAIRS = [
|
||||
["eyeOpenL", "eyeOpenR"],
|
||||
["eyeSmileL", "eyeSmileR"],
|
||||
["browLY", "browRY"],
|
||||
["browLAngle", "browRAngle"],
|
||||
["browLX", "browRX"],
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
|
||||
function fail(message) {
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
async function loadModules() {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "vignette-avatar-check-"));
|
||||
try {
|
||||
await writeFile(path.join(tempDir, "package.json"), JSON.stringify({ type: "module" }), "utf8");
|
||||
await build({
|
||||
entryPoints: ENTRY_FILES.map((f) => path.join(engineDir, f)),
|
||||
outdir: tempDir,
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
logLevel: "silent",
|
||||
});
|
||||
|
||||
const modules = {};
|
||||
for (const f of ENTRY_FILES) {
|
||||
const outFile = path.join(tempDir, f.replace(/\.ts$/, ".js"));
|
||||
const url = `${pathToFileURL(outFile).href}?t=${Date.now()}`;
|
||||
modules[f] = await import(url);
|
||||
}
|
||||
return modules;
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function effectiveChannelVector(delta, channelIds) {
|
||||
const used = new Set(SYMMETRIC_PAIRS.flat());
|
||||
const out = {};
|
||||
for (const [l, r] of SYMMETRIC_PAIRS) {
|
||||
const lv = delta[l] ?? 0;
|
||||
const rv = delta[r] ?? 0;
|
||||
out[`${l}/${r}`] = (lv + rv) / 2;
|
||||
}
|
||||
for (const id of channelIds) {
|
||||
if (used.has(id)) continue;
|
||||
out[id] = delta[id] ?? 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function l1Distance(a, b) {
|
||||
let sum = 0;
|
||||
let max = 0;
|
||||
for (const key of Object.keys(a)) {
|
||||
const d = Math.abs((a[key] ?? 0) - (b[key] ?? 0));
|
||||
sum += d;
|
||||
if (d > max) max = d;
|
||||
}
|
||||
return { sum, max };
|
||||
}
|
||||
|
||||
/* (a) 구분 가능성 불변량: 378쌍 L1 ≥ 0.6, 최대 단일 채널 차이 ≥ 0.25. */
|
||||
function checkDiscriminability(CHANNEL_IDS, EXPRESSION_PRESETS) {
|
||||
const ids = Object.keys(EXPRESSION_PRESETS);
|
||||
const vectors = new Map(ids.map((id) => [id, effectiveChannelVector(EXPRESSION_PRESETS[id], CHANNEL_IDS)]));
|
||||
let pairCount = 0;
|
||||
let minL1 = Infinity;
|
||||
let minL1Pair = "";
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
pairCount++;
|
||||
const { sum, max } = l1Distance(vectors.get(ids[i]), vectors.get(ids[j]));
|
||||
if (sum < minL1) {
|
||||
minL1 = sum;
|
||||
minL1Pair = `${ids[i]}/${ids[j]}`;
|
||||
}
|
||||
if (sum < 0.6) fail(`구분 가능성: ${ids[i]}/${ids[j]} L1=${sum.toFixed(3)} < 0.6`);
|
||||
if (max < 0.25) fail(`구분 가능성: ${ids[i]}/${ids[j]} 최대 단일 채널 차이=${max.toFixed(3)} < 0.25`);
|
||||
}
|
||||
}
|
||||
const expectedPairs = (ids.length * (ids.length - 1)) / 2;
|
||||
if (pairCount !== expectedPairs) fail(`구분 가능성: 쌍 개수 ${pairCount} != 기대 ${expectedPairs}`);
|
||||
return { pairCount, minL1, minL1Pair };
|
||||
}
|
||||
|
||||
/* (b) 모든 프리셋·클립·basePose 키가 CHANNEL_IDS에 속함. */
|
||||
function checkChannelKeys(CHANNEL_IDS, EXPRESSION_PRESETS, REACTION_CLIPS, DEFAULT_DEMEANOR, personaDemeanors) {
|
||||
const validIds = new Set(CHANNEL_IDS);
|
||||
for (const [exprId, delta] of Object.entries(EXPRESSION_PRESETS)) {
|
||||
for (const key of Object.keys(delta)) {
|
||||
if (!validIds.has(key)) fail(`채널 키: 프리셋 ${exprId}의 ${key}가 CHANNEL_IDS에 없음`);
|
||||
}
|
||||
}
|
||||
for (const [clipId, clip] of Object.entries(REACTION_CLIPS)) {
|
||||
for (const key of Object.keys(clip.tracks)) {
|
||||
if (!validIds.has(key)) fail(`채널 키: 클립 ${clipId}의 트랙 ${key}가 CHANNEL_IDS에 없음`);
|
||||
}
|
||||
}
|
||||
const demeanors = [["DEFAULT", DEFAULT_DEMEANOR], ...personaDemeanors];
|
||||
for (const [label, demeanor] of demeanors) {
|
||||
for (const key of Object.keys(demeanor.basePose)) {
|
||||
if (!validIds.has(key)) fail(`채널 키: demeanor ${label}의 basePose ${key}가 CHANNEL_IDS에 없음`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* (c)(d) 클립 키프레임 형식. */
|
||||
function checkClipKeyframes(REACTION_CLIPS) {
|
||||
for (const [clipId, clip] of Object.entries(REACTION_CLIPS)) {
|
||||
if (clip.fadeInMs + clip.fadeOutMs > clip.durationMs) {
|
||||
fail(`클립 ${clipId}: fadeInMs(${clip.fadeInMs})+fadeOutMs(${clip.fadeOutMs}) > durationMs(${clip.durationMs})`);
|
||||
}
|
||||
for (const [channelId, frames] of Object.entries(clip.tracks)) {
|
||||
if (!frames || frames.length === 0) continue;
|
||||
const [t0, v0] = frames[0];
|
||||
if (t0 !== 0) fail(`클립 ${clipId}.${channelId}: 첫 키프레임 시각이 0이 아님(${t0})`);
|
||||
if (v0 !== 0) fail(`클립 ${clipId}.${channelId}: 첫 키프레임 값이 0이 아님(${v0})`);
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
if (frames[i][0] <= frames[i - 1][0]) {
|
||||
fail(`클립 ${clipId}.${channelId}: 시각이 오름차순이 아님(${frames[i - 1][0]} -> ${frames[i][0]})`);
|
||||
}
|
||||
}
|
||||
const last = frames[frames.length - 1];
|
||||
if (last[0] > clip.durationMs) {
|
||||
fail(`클립 ${clipId}.${channelId}: 마지막 키프레임 시각(${last[0]}) > durationMs(${clip.durationMs})`);
|
||||
}
|
||||
if (last[1] !== 0 && clip.fadeOutMs < 300) {
|
||||
fail(`클립 ${clipId}.${channelId}: 마지막 값(${last[1]})이 0이 아닌데 fadeOutMs(${clip.fadeOutMs}) < 300`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* (e) demeanor idleClips·lexicon의 clip id가 REACTION_CLIP_IDS에 존재. */
|
||||
function checkClipReferences(REACTION_CLIP_IDS, DEFAULT_DEMEANOR, personaDemeanors, STAGE_DIRECTION_RULES) {
|
||||
const validIds = new Set(REACTION_CLIP_IDS);
|
||||
const demeanors = [["DEFAULT", DEFAULT_DEMEANOR], ...personaDemeanors];
|
||||
for (const [label, demeanor] of demeanors) {
|
||||
for (const rule of demeanor.idleClips) {
|
||||
if (!validIds.has(rule.clip)) fail(`demeanor ${label}의 idleClips 참조 ${rule.clip}가 REACTION_CLIP_IDS에 없음`);
|
||||
}
|
||||
}
|
||||
for (const rule of STAGE_DIRECTION_RULES) {
|
||||
if (!validIds.has(rule.clip)) fail(`stageDirectionLexicon 규칙의 ${rule.clip}가 REACTION_CLIP_IDS에 없음`);
|
||||
}
|
||||
}
|
||||
|
||||
/* (f) 표본 지문 대응표(결정문 §7.3, 앵커 보정은 오케스트레이터 2026-09-30 정정). */
|
||||
function checkStageDirectionSamples(parseStageDirections) {
|
||||
const singleClipCases = [
|
||||
["(한숨)", "sigh", undefined],
|
||||
["(옅은 한숨)", "sigh", 0.6],
|
||||
["(잠시 멈춤)", "look_down", undefined],
|
||||
["(잠깐 침묵)", "silence_hold", undefined],
|
||||
["(고개 살짝 돌림)", "look_away_side", undefined],
|
||||
["(어깨 으쓱)", "shrug", undefined],
|
||||
["(피식)", "scoff", undefined],
|
||||
["(어색한 웃음)", "nervous_laugh", undefined],
|
||||
["(긴장한 웃음)", "nervous_laugh", undefined],
|
||||
["(머뭇)", "lip_press", undefined],
|
||||
["(손톱 만지작)", "fidget_sway", undefined],
|
||||
["(시선 피함)", "look_away_side", undefined],
|
||||
["(쓴웃음)", "scoff", undefined],
|
||||
];
|
||||
|
||||
for (const [text, expectedClip, expectedWeight] of singleClipCases) {
|
||||
const { cues } = parseStageDirections(text);
|
||||
if (cues.length !== 1 || cues[0].clip !== expectedClip) {
|
||||
fail(`지문 "${text}": 기대 클립 ${expectedClip}, 실제 ${cues.map((c) => c.clip).join(",") || "(없음)"}`);
|
||||
continue;
|
||||
}
|
||||
if (expectedWeight !== undefined && cues[0].weight !== expectedWeight) {
|
||||
fail(`지문 "${text}": 기대 weight ${expectedWeight}, 실제 ${cues[0].weight}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { cues: multi } = parseStageDirections("(한숨, 침묵 10초)");
|
||||
const multiClips = multi.map((c) => c.clip).join(",");
|
||||
if (multiClips !== "sigh,silence_hold") {
|
||||
fail(`지문 "(한숨, 침묵 10초)": 기대 [sigh, silence_hold], 실제 [${multiClips}]`);
|
||||
}
|
||||
|
||||
const unmatchedCases = ["(작은 목소리로)", "('지침 vs 게으름' 재구성)"];
|
||||
for (const text of unmatchedCases) {
|
||||
const { cues, unmatched } = parseStageDirections(text);
|
||||
if (cues.length !== 0 || unmatched.length !== 1) {
|
||||
fail(`지문 "${text}": 미대응이어야 하는데 cues=${cues.length}, unmatched=${unmatched.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
const preCase = parseStageDirections("(한숨) 그냥요.");
|
||||
if (preCase.cues[0]?.anchor !== "pre") {
|
||||
fail(`앵커 "(한숨) 그냥요.": 기대 pre, 실제 ${preCase.cues[0]?.anchor}`);
|
||||
}
|
||||
|
||||
const inlineCase = parseStageDirections("몰라요. (한숨) 다 귀찮아요.");
|
||||
const inlineCue = inlineCase.cues[0];
|
||||
if (inlineCue?.anchor !== "inline") {
|
||||
fail(`앵커 "몰라요. (한숨) 다 귀찮아요.": 기대 inline, 실제 ${inlineCue?.anchor}`);
|
||||
} else if (inlineCue.at < 0.36 || inlineCue.at > 0.39) {
|
||||
fail(`앵커 "몰라요. (한숨) 다 귀찮아요.": at=${inlineCue.at.toFixed(4)}가 [0.36, 0.39] 밖`);
|
||||
}
|
||||
|
||||
const postCase = parseStageDirections("그냥요 (시선 피함)");
|
||||
if (postCase.cues[0]?.anchor !== "post") {
|
||||
fail(`앵커 "그냥요 (시선 피함)": 기대 post, 실제 ${postCase.cues[0]?.anchor}`);
|
||||
}
|
||||
}
|
||||
|
||||
const modules = await loadModules();
|
||||
const { CHANNEL_IDS } = modules["channels.ts"];
|
||||
const { EXPRESSION_PRESETS } = modules["expressionPresets.ts"];
|
||||
const { REACTION_CLIPS, REACTION_CLIP_IDS } = modules["clipCatalog.ts"];
|
||||
const { DEFAULT_DEMEANOR, demeanorFor } = modules["demeanorDefaults.ts"];
|
||||
const { STAGE_DIRECTION_RULES } = modules["stageDirectionLexicon.ts"];
|
||||
const { parseStageDirections } = modules["performance.ts"];
|
||||
|
||||
const personaDemeanors = ["P1", "P2", "P3", "P4", "P5", "P6", "P7"].map((code) => [code, demeanorFor(code)]);
|
||||
|
||||
const { pairCount, minL1, minL1Pair } = checkDiscriminability(CHANNEL_IDS, EXPRESSION_PRESETS);
|
||||
checkChannelKeys(CHANNEL_IDS, EXPRESSION_PRESETS, REACTION_CLIPS, DEFAULT_DEMEANOR, personaDemeanors);
|
||||
checkClipKeyframes(REACTION_CLIPS);
|
||||
checkClipReferences(REACTION_CLIP_IDS, DEFAULT_DEMEANOR, personaDemeanors, STAGE_DIRECTION_RULES);
|
||||
checkStageDirectionSamples(parseStageDirections);
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const message of failures) console.error(`FAIL: ${message}`);
|
||||
console.error(`check-avatar-presets: 실패 ${failures.length}건`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`check-avatar-presets: 통과 (프리셋 쌍 ${pairCount}개 전부 구분 가능, 최소 L1 ${minL1.toFixed(2)}[${minL1Pair}], 클립 ${REACTION_CLIP_IDS.length}개, 지문 표본 검증 완료)`,
|
||||
);
|
||||
|
|
@ -50,6 +50,7 @@ const PendingApproval = lazy(() => import("./pages/PendingApproval"));
|
|||
const LearnerHome = lazy(() => import("./pages/LearnerHome"));
|
||||
const AvatarExpressionLab = lazy(() => import("./pages/AvatarExpressionLab"));
|
||||
const AvatarPreview = lazy(() => import("./pages/AvatarPreview"));
|
||||
const AvatarLab = lazy(() => import("./pages/AvatarLab"));
|
||||
const Session = lazy(() => import("./pages/Session"));
|
||||
const SessionReview = lazy(() => import("./pages/SessionReview"));
|
||||
const Professor = lazy(() => import("./pages/Professor"));
|
||||
|
|
@ -484,6 +485,9 @@ function AppRoutes() {
|
|||
{/* dev: 인증 없는 아바타 컴포지션 튜닝 페이지 (실서비스 아님) */}
|
||||
<Route path="/dev/avatar-preview" element={<AvatarPreview />} />
|
||||
|
||||
{/* dev: 인증 없는 아바타 v3 표현 엔진 Lab (실서비스 아님) */}
|
||||
<Route path="/dev/avatar-lab" element={<AvatarLab />} />
|
||||
|
||||
<Route
|
||||
path="/onboarding"
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ import { Brows } from "./Brows";
|
|||
import { Mouth } from "./Mouth";
|
||||
import { live2dModel3Path, live2dModelForPersonaCode, live2dMotionForExpression } from "./live2dModel";
|
||||
import { useExpressionTransition } from "./useExpressionTransition";
|
||||
import ClientAvatarV3, { type AvatarSpeech } from "./v3/ClientAvatarV3";
|
||||
import { linocutRigFor } from "./v3/rigs";
|
||||
import "./client-avatar.css";
|
||||
|
||||
/* ── 공개 타입 재노출 (기존 import 경로 호환) ──────────────────────────
|
||||
|
|
@ -48,6 +50,7 @@ export {
|
|||
type AvatarPersona,
|
||||
} from "./persona";
|
||||
export type { Live2DExpressionMotion, Live2DPersonaModel } from "./live2dModel";
|
||||
export type { AvatarSpeech } from "./v3/ClientAvatarV3";
|
||||
|
||||
export interface ClientAvatarProps {
|
||||
persona: AvatarPersona;
|
||||
|
|
@ -65,6 +68,15 @@ export interface ClientAvatarProps {
|
|||
* null/미지정이면 speaking 동안 차분한 의사 발화 모션.
|
||||
*/
|
||||
speakingProgress?: number | null;
|
||||
/**
|
||||
* 개방도 0~1(세션 effective_openness). v3(리노컷) 리그가 있는 페르소나에서만 쓴다.
|
||||
* 미지정이면 rapport로 대신한다(결정문 §8.5).
|
||||
*/
|
||||
openness?: number;
|
||||
/** 겉표정 강도 0~1(performance.ts surfaceIntensityFor). 미지정이면 0.5. v3 전용. */
|
||||
surfaceIntensity?: number;
|
||||
/** TTS 발화 구동(결정문 §8.5). v3 리그가 있는 페르소나에서만 립싱크·발화 동반층을 돈다. */
|
||||
speech?: AvatarSpeech | null;
|
||||
/** px 지름 (기본 220 — §5.2 아바타 220px) */
|
||||
size?: number;
|
||||
className?: string;
|
||||
|
|
@ -215,6 +227,9 @@ export function ClientAvatar({
|
|||
analyser = null,
|
||||
rapport = 0,
|
||||
speakingProgress = null,
|
||||
openness,
|
||||
surfaceIntensity,
|
||||
speech = null,
|
||||
size = 220,
|
||||
className,
|
||||
animated = true,
|
||||
|
|
@ -222,7 +237,29 @@ export function ClientAvatar({
|
|||
showMeta = true,
|
||||
}: ClientAvatarProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const motionEnabled = animated && !reduced;
|
||||
|
||||
/* v3(리노컷) 분기(결정문 §8.5) — persona.code로 판정(지금은 P1만). 리그가 있으면
|
||||
그림 영역을 ClientAvatarV3로 그린다. 로드가 끝날 때까지 기존 SVG를 그대로 보이고,
|
||||
ready가 되면 300ms 불투명도 전환으로 v3를 위에 올린 뒤 기존 SVG를 언마운트해
|
||||
rAF를 멈춘다. error면 v3를 언마운트하고 기존 SVG로 남는다(§8.4 로드 실패 규칙). */
|
||||
const rig = useMemo(() => linocutRigFor(persona.code), [persona.code]);
|
||||
const [v3LoadState, setV3LoadState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [legacyMounted, setLegacyMounted] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setV3LoadState("loading");
|
||||
setLegacyMounted(true);
|
||||
}, [rig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (v3LoadState !== "ready") return;
|
||||
const transitionMs = reduced ? 0 : 300;
|
||||
const timer = window.setTimeout(() => setLegacyMounted(false), transitionMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [v3LoadState, reduced]);
|
||||
|
||||
const v3Active = rig !== null && v3LoadState !== "error";
|
||||
const motionEnabled = animated && !reduced && legacyMounted;
|
||||
|
||||
// 외형 안전값
|
||||
const skin = persona.skinTone;
|
||||
|
|
@ -275,6 +312,10 @@ export function ClientAvatar({
|
|||
const shoulderRotate = params.shoulderTurn * 0.4;
|
||||
const mouthOpen = Math.max(mouth, params.mouthOpen);
|
||||
|
||||
// v3 전용 보정값(결정문 §8.5): 없으면 openness는 rapport로, 강도는 0.5로 둔다.
|
||||
const v3Openness = openness ?? rapport ?? 0.5;
|
||||
const v3SurfaceIntensity = surfaceIntensity ?? 0.5;
|
||||
|
||||
return (
|
||||
<figure
|
||||
className={"vg-avatar" + (className ? " " + className : "")}
|
||||
|
|
@ -307,66 +348,84 @@ export function ClientAvatar({
|
|||
) : null}
|
||||
|
||||
<div className="vg-avatar__stage" style={{ height: size }}>
|
||||
{/* 호흡하는 광배 */}
|
||||
<AuraLayer
|
||||
hue={params.auraHue}
|
||||
opacity={params.auraOpacity}
|
||||
saturation={age.auraSaturation}
|
||||
state={state}
|
||||
mouth={mouth}
|
||||
reduced={reduced}
|
||||
/>
|
||||
{legacyMounted ? (
|
||||
<>
|
||||
{/* 호흡하는 광배 */}
|
||||
<AuraLayer
|
||||
hue={params.auraHue}
|
||||
opacity={params.auraOpacity}
|
||||
saturation={age.auraSaturation}
|
||||
state={state}
|
||||
mouth={mouth}
|
||||
reduced={reduced}
|
||||
/>
|
||||
|
||||
<svg
|
||||
className="vg-avatar__svg"
|
||||
viewBox="0 0 200 200"
|
||||
width={size}
|
||||
height={size}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* 흉상 그룹: 어깨 호흡(translateY) + 저항 시 미세 회전 */}
|
||||
<g transform={`translate(0 ${-breath}) rotate(${shoulderRotate} 100 150)`}>
|
||||
{/* 어깨/상반신 실루엣 */}
|
||||
<BodySilhouette color={outfitColor} shoulderTurn={params.shoulderTurn} />
|
||||
<svg
|
||||
className="vg-avatar__svg"
|
||||
viewBox="0 0 200 200"
|
||||
width={size}
|
||||
height={size}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* 흉상 그룹: 어깨 호흡(translateY) + 저항 시 미세 회전 */}
|
||||
<g transform={`translate(0 ${-breath}) rotate(${shoulderRotate} 100 150)`}>
|
||||
{/* 어깨/상반신 실루엣 */}
|
||||
<BodySilhouette color={outfitColor} shoulderTurn={params.shoulderTurn} />
|
||||
|
||||
<g transform={`rotate(${params.headTilt} 100 101)`}>
|
||||
<HairBack style={hairStyle} color={hairColor} jawWidth={age.jawWidth} />
|
||||
<NeckBridge skin={skin} />
|
||||
<g transform={`rotate(${params.headTilt} 100 101)`}>
|
||||
<HairBack style={hairStyle} color={hairColor} jawWidth={age.jawWidth} />
|
||||
<NeckBridge skin={skin} />
|
||||
|
||||
{/* 머리 (양식화 — 코·주름·모공 없음) */}
|
||||
<ellipse cx="100" cy="92" rx={42 * age.jawWidth} ry="46" fill={skin} />
|
||||
{/* 머리 (양식화 — 코·주름·모공 없음) */}
|
||||
<ellipse cx="100" cy="92" rx={42 * age.jawWidth} ry="46" fill={skin} />
|
||||
|
||||
<HairFront style={hairStyle} color={hairColor} />
|
||||
<HairFront style={hairStyle} color={hairColor} />
|
||||
|
||||
{/* 얼굴 그룹: 시선 회피/saccade (translate) */}
|
||||
<g transform={`translate(${gazeX} ${gazeY})`}>
|
||||
<Brows
|
||||
browTilt={params.browTilt}
|
||||
browLift={params.browLift}
|
||||
browPinch={params.browPinch}
|
||||
color={hairColor}
|
||||
/>
|
||||
<Eyes
|
||||
eyeSize={age.eyeSize}
|
||||
blink={blink}
|
||||
eyeOpen={params.eyeOpen}
|
||||
eyelidDrop={params.eyelidDrop}
|
||||
pupilScale={params.pupilScale}
|
||||
irisColor={irisColor}
|
||||
/>
|
||||
<Mouth
|
||||
open={mouthOpen}
|
||||
curve={params.mouthCurve}
|
||||
width={params.mouthWidth}
|
||||
tension={params.mouthTension}
|
||||
color="#9B5B52"
|
||||
/>
|
||||
{/* 얼굴 그룹: 시선 회피/saccade (translate) */}
|
||||
<g transform={`translate(${gazeX} ${gazeY})`}>
|
||||
<Brows
|
||||
browTilt={params.browTilt}
|
||||
browLift={params.browLift}
|
||||
browPinch={params.browPinch}
|
||||
color={hairColor}
|
||||
/>
|
||||
<Eyes
|
||||
eyeSize={age.eyeSize}
|
||||
blink={blink}
|
||||
eyeOpen={params.eyeOpen}
|
||||
eyelidDrop={params.eyelidDrop}
|
||||
pupilScale={params.pupilScale}
|
||||
irisColor={irisColor}
|
||||
/>
|
||||
<Mouth
|
||||
open={mouthOpen}
|
||||
curve={params.mouthCurve}
|
||||
width={params.mouthWidth}
|
||||
tension={params.mouthTension}
|
||||
color="#9B5B52"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{rig && v3Active ? (
|
||||
<ClientAvatarV3
|
||||
rig={rig}
|
||||
code={persona.code}
|
||||
state={state}
|
||||
affect={affect}
|
||||
openness={v3Openness}
|
||||
surfaceIntensity={v3SurfaceIntensity}
|
||||
speech={speech}
|
||||
running={animated}
|
||||
reducedMotion={reduced}
|
||||
onLoadStateChange={setV3LoadState}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 페르소나 메타 + 상태 텍스트 */}
|
||||
|
|
|
|||
138
apps/web/src/components/avatar/engine/channels.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 채널 계약 — docs/decisions/avatar-expression-engine-v3.md §4 정본.
|
||||
신호 쪽은 채널을 모르고 cue만 만들고, 렌더러는 cue를 모르고 채널만 읽는다.
|
||||
채널을 추가·삭제할 때는 결정문 §4 표를 먼저 고친다.
|
||||
===================================================================== */
|
||||
|
||||
export interface ChannelSpec {
|
||||
min: number;
|
||||
max: number;
|
||||
/** 기본 자세 이전의 중립값. */
|
||||
neutral: number;
|
||||
/** 표정층 스프링 시간상수(ms). 0이면 평활 없이 즉시 적용. */
|
||||
tauMs: number;
|
||||
/** 대응 Live2D 파라미터 id. 표준에 없으면 custom 이름. */
|
||||
live2d: string;
|
||||
}
|
||||
|
||||
const HEAD = 350;
|
||||
const EYE = 120;
|
||||
const GAZE = 90;
|
||||
const MOUTH = 160;
|
||||
const OVERLAY = 600;
|
||||
|
||||
export const CHANNEL_SPECS = {
|
||||
headYaw: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamAngleX" },
|
||||
headPitch: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamAngleY" },
|
||||
headRoll: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamAngleZ" },
|
||||
bodyYaw: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamBodyAngleX" },
|
||||
bodyLean: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamBodyAngleY" },
|
||||
bodyRoll: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamBodyAngleZ" },
|
||||
shoulderRaise: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamShoulderY" },
|
||||
breath: { min: 0, max: 1, neutral: 0, tauMs: 0, live2d: "ParamBreath" },
|
||||
eyeOpenL: { min: 0, max: 1.3, neutral: 1, tauMs: EYE, live2d: "ParamEyeLOpen" },
|
||||
eyeOpenR: { min: 0, max: 1.3, neutral: 1, tauMs: EYE, live2d: "ParamEyeROpen" },
|
||||
eyeSmileL: { min: 0, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamEyeLSmile" },
|
||||
eyeSmileR: { min: 0, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamEyeRSmile" },
|
||||
lidTension: { min: 0, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamEyeTension" },
|
||||
gazeX: { min: -1, max: 1, neutral: 0, tauMs: GAZE, live2d: "ParamEyeBallX" },
|
||||
gazeY: { min: -1, max: 1, neutral: 0, tauMs: GAZE, live2d: "ParamEyeBallY" },
|
||||
pupilSize: { min: -1, max: 1, neutral: 0, tauMs: 300, live2d: "ParamEyeBallForm" },
|
||||
browLY: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowLY" },
|
||||
browRY: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowRY" },
|
||||
browLAngle: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowLAngle" },
|
||||
browRAngle: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowRAngle" },
|
||||
browLX: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowLX" },
|
||||
browRX: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowRX" },
|
||||
browForm: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowLForm" },
|
||||
mouthForm: { min: -1, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthForm" },
|
||||
mouthOpen: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthOpenY" },
|
||||
mouthPress: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthPress" },
|
||||
mouthAsym: { min: -1, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthAsym" },
|
||||
mouthStretch: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthStretch" },
|
||||
mouthRound: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthRound" },
|
||||
chinRaise: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamChinRaise" },
|
||||
cheek: { min: 0, max: 1, neutral: 0, tauMs: OVERLAY, live2d: "ParamCheek" },
|
||||
pallor: { min: 0, max: 1, neutral: 0, tauMs: OVERLAY, live2d: "ParamPallor" },
|
||||
tear: { min: 0, max: 1, neutral: 0, tauMs: OVERLAY, live2d: "ParamTear" },
|
||||
sweat: { min: 0, max: 1, neutral: 0, tauMs: OVERLAY, live2d: "ParamSweat" },
|
||||
} as const satisfies Record<string, ChannelSpec>;
|
||||
|
||||
export type ChannelId = keyof typeof CHANNEL_SPECS;
|
||||
export type ChannelVector = Record<ChannelId, number>;
|
||||
export type ChannelDelta = Partial<Record<ChannelId, number>>;
|
||||
|
||||
export const CHANNEL_IDS = Object.keys(CHANNEL_SPECS) as ChannelId[];
|
||||
|
||||
/** 강도에 따라 켜지는 오버레이 채널과 최소 강도(결정문 §5.2). */
|
||||
export const OVERLAY_MIN_INTENSITY: Partial<Record<ChannelId, number>> = {
|
||||
tear: 0.7,
|
||||
cheek: 0.5,
|
||||
pallor: 0.7,
|
||||
sweat: 0.7,
|
||||
};
|
||||
|
||||
/* ── 저작용 대칭 키: 좌우를 같은 값으로 펼친다 ─────────────────────── */
|
||||
const SYMMETRIC_KEYS = {
|
||||
eyeOpen: ["eyeOpenL", "eyeOpenR"],
|
||||
eyeSmile: ["eyeSmileL", "eyeSmileR"],
|
||||
browY: ["browLY", "browRY"],
|
||||
browAngle: ["browLAngle", "browRAngle"],
|
||||
browX: ["browLX", "browRX"],
|
||||
} as const satisfies Record<string, readonly [ChannelId, ChannelId]>;
|
||||
|
||||
export type SymmetricKey = keyof typeof SYMMETRIC_KEYS;
|
||||
export type AuthoringKey = ChannelId | SymmetricKey;
|
||||
export type AuthoringDelta = Partial<Record<AuthoringKey, number>>;
|
||||
|
||||
/** 대칭 키를 L/R 채널로 펼친다. 같은 채널을 직접 키와 대칭 키로 동시에 주면 직접 키가 이긴다. */
|
||||
export function expandAuthoring(delta: AuthoringDelta): ChannelDelta {
|
||||
const out: ChannelDelta = {};
|
||||
for (const [key, value] of Object.entries(delta) as Array<[AuthoringKey, number]>) {
|
||||
if (key in SYMMETRIC_KEYS) {
|
||||
for (const id of SYMMETRIC_KEYS[key as SymmetricKey]) {
|
||||
if (!(id in delta)) out[id] = value;
|
||||
}
|
||||
} else {
|
||||
out[key as ChannelId] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function neutralVector(): ChannelVector {
|
||||
const out = {} as ChannelVector;
|
||||
for (const id of CHANNEL_IDS) out[id] = CHANNEL_SPECS[id].neutral;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function clampChannel(id: ChannelId, value: number): number {
|
||||
const { min, max } = CHANNEL_SPECS[id];
|
||||
return value < min ? min : value > max ? max : value;
|
||||
}
|
||||
|
||||
/* ── 좌우 반전(미러) — 한쪽 방향성이 있는 클립을 반대로 재생할 때 ──── */
|
||||
const MIRROR_NEGATE: readonly ChannelId[] = ["headYaw", "headRoll", "bodyYaw", "bodyRoll", "gazeX", "mouthAsym"];
|
||||
const MIRROR_SWAP: ReadonlyArray<readonly [ChannelId, ChannelId]> = [
|
||||
["eyeOpenL", "eyeOpenR"],
|
||||
["eyeSmileL", "eyeSmileR"],
|
||||
["browLY", "browRY"],
|
||||
["browLAngle", "browRAngle"],
|
||||
["browLX", "browRX"],
|
||||
];
|
||||
|
||||
export function mirrorDelta(delta: ChannelDelta): ChannelDelta {
|
||||
const out: ChannelDelta = { ...delta };
|
||||
for (const id of MIRROR_NEGATE) {
|
||||
if (out[id] !== undefined) out[id] = -(out[id] as number);
|
||||
}
|
||||
for (const [l, r] of MIRROR_SWAP) {
|
||||
const lv = delta[l];
|
||||
const rv = delta[r];
|
||||
if (lv === undefined) delete out[r];
|
||||
else out[r] = lv;
|
||||
if (rv === undefined) delete out[l];
|
||||
else out[l] = rv;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
349
apps/web/src/components/avatar/engine/clipCatalog.ts
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 반응 클립 카탈로그 — docs/decisions/avatar-expression-engine-v3.md §6 정본 값.
|
||||
트랙 값은 표정층 결과에 더하는 delta다. 키프레임 [tMs, value, ease]에서 ease는
|
||||
직전 키프레임에서 이 키프레임으로 가는 구간의 곡선이며 "hold"는 계단(직전 값 유지 후 도달)이다.
|
||||
mirrorable 클립은 스케줄러가 시드 난수로 좌우 반전해 재생할 수 있다.
|
||||
키프레임 값 변경은 오케스트레이터 설계 결정이다.
|
||||
===================================================================== */
|
||||
|
||||
import { expandAuthoring, type AuthoringKey, type ChannelId } from "./channels";
|
||||
|
||||
export type ClipEase = "linear" | "in" | "out" | "inOut" | "hold";
|
||||
export type Keyframe = readonly [tMs: number, value: number, ease?: ClipEase];
|
||||
export type IdleSuppress = "blink" | "saccade" | "lipsync" | "sway";
|
||||
|
||||
export const REACTION_CLIP_IDS = [
|
||||
"nod_small",
|
||||
"nod_deep",
|
||||
"head_shake",
|
||||
"head_drop",
|
||||
"look_away_side",
|
||||
"look_down",
|
||||
"glance_up",
|
||||
"flinch",
|
||||
"freeze",
|
||||
"sigh",
|
||||
"deep_breath",
|
||||
"swallow",
|
||||
"lip_press",
|
||||
"lip_bite",
|
||||
"scoff",
|
||||
"eye_roll_small",
|
||||
"shrug",
|
||||
"lean_back",
|
||||
"lean_forward",
|
||||
"tear_up",
|
||||
"blink_flurry",
|
||||
"fidget_sway",
|
||||
"forced_smile",
|
||||
"nervous_laugh",
|
||||
"jaw_clench",
|
||||
"silence_hold",
|
||||
] as const;
|
||||
|
||||
export type ReactionClipId = (typeof REACTION_CLIP_IDS)[number];
|
||||
|
||||
interface ClipSpec {
|
||||
label: string;
|
||||
durationMs: number;
|
||||
fadeInMs: number;
|
||||
fadeOutMs: number;
|
||||
priority: 1 | 2 | 3;
|
||||
cooldownMs: number;
|
||||
mirrorable?: boolean;
|
||||
suppress?: readonly IdleSuppress[];
|
||||
tracks: Partial<Record<AuthoringKey, readonly Keyframe[]>>;
|
||||
}
|
||||
|
||||
export interface ReactionClip extends Omit<ClipSpec, "tracks"> {
|
||||
id: ReactionClipId;
|
||||
tracks: Partial<Record<ChannelId, readonly Keyframe[]>>;
|
||||
}
|
||||
|
||||
/** 시작 0 → peak 도달 → holdUntil까지 유지 → end에 0 복귀하는 사다리꼴 트랙. */
|
||||
function plateau(peak: number, riseMs: number, holdUntilMs: number, endMs: number, rise: ClipEase = "out"): readonly Keyframe[] {
|
||||
return [
|
||||
[0, 0],
|
||||
[riseMs, peak, rise],
|
||||
[holdUntilMs, peak, "hold"],
|
||||
[endMs, 0, "inOut"],
|
||||
];
|
||||
}
|
||||
|
||||
const CLIP_SPECS: Record<ReactionClipId, ClipSpec> = {
|
||||
nod_small: {
|
||||
label: "작게 끄덕",
|
||||
durationMs: 900, fadeInMs: 80, fadeOutMs: 150, priority: 1, cooldownMs: 1500,
|
||||
tracks: {
|
||||
headPitch: [[0, 0], [180, -0.15, "out"], [360, 0, "inOut"], [540, -0.12, "out"], [760, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
nod_deep: {
|
||||
label: "깊게 끄덕",
|
||||
durationMs: 1400, fadeInMs: 100, fadeOutMs: 200, priority: 2, cooldownMs: 3000,
|
||||
tracks: {
|
||||
headPitch: plateau(-0.35, 450, 800, 1300, "inOut"),
|
||||
eyeOpen: plateau(-0.2, 450, 800, 1300, "inOut"),
|
||||
},
|
||||
},
|
||||
head_shake: {
|
||||
label: "고개 젓기",
|
||||
durationMs: 1100, fadeInMs: 80, fadeOutMs: 150, priority: 2, cooldownMs: 2500,
|
||||
tracks: {
|
||||
headYaw: [[0, 0], [180, 0.2, "inOut"], [420, -0.2, "inOut"], [660, 0.14, "inOut"], [880, -0.06, "inOut"], [1100, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
head_drop: {
|
||||
label: "고개 떨굼",
|
||||
durationMs: 3200, fadeInMs: 150, fadeOutMs: 400, priority: 2, cooldownMs: 6000,
|
||||
suppress: ["saccade"],
|
||||
tracks: {
|
||||
headPitch: plateau(-0.45, 600, 2600, 3200),
|
||||
gazeY: plateau(-0.6, 400, 2700, 3200),
|
||||
shoulderRaise: plateau(-0.3, 700, 2600, 3200),
|
||||
eyeOpen: plateau(-0.2, 600, 2600, 3200),
|
||||
},
|
||||
},
|
||||
look_away_side: {
|
||||
label: "옆으로 시선 회피",
|
||||
durationMs: 2400, fadeInMs: 100, fadeOutMs: 300, priority: 1, cooldownMs: 4000, mirrorable: true,
|
||||
suppress: ["saccade"],
|
||||
tracks: {
|
||||
gazeX: plateau(0.7, 200, 2000, 2400),
|
||||
headYaw: plateau(0.15, 450, 2000, 2400),
|
||||
},
|
||||
},
|
||||
look_down: {
|
||||
label: "시선 내림",
|
||||
durationMs: 2200, fadeInMs: 100, fadeOutMs: 300, priority: 1, cooldownMs: 3000,
|
||||
suppress: ["saccade"],
|
||||
tracks: {
|
||||
gazeY: plateau(-0.7, 250, 1800, 2200),
|
||||
headPitch: plateau(-0.1, 400, 1800, 2200),
|
||||
eyeOpen: plateau(-0.25, 250, 1800, 2200),
|
||||
},
|
||||
},
|
||||
glance_up: {
|
||||
label: "힐끗 봄",
|
||||
durationMs: 1100, fadeInMs: 60, fadeOutMs: 200, priority: 1, cooldownMs: 3000,
|
||||
suppress: ["saccade"],
|
||||
tracks: {
|
||||
gazeY: plateau(0.35, 150, 650, 1100),
|
||||
eyeOpen: plateau(0.1, 150, 650, 1100),
|
||||
},
|
||||
},
|
||||
flinch: {
|
||||
label: "움찔",
|
||||
durationMs: 700, fadeInMs: 30, fadeOutMs: 250, priority: 3, cooldownMs: 2000,
|
||||
suppress: ["blink"],
|
||||
tracks: {
|
||||
headPitch: [[0, 0], [90, 0.15, "out"], [700, 0, "inOut"]],
|
||||
bodyLean: [[0, 0], [100, -0.25, "out"], [700, 0, "inOut"]],
|
||||
shoulderRaise: [[0, 0], [90, 0.5, "out"], [700, 0, "inOut"]],
|
||||
eyeOpen: [[0, 0], [60, -0.8, "out"], [160, 0.15, "out"], [700, 0, "inOut"]],
|
||||
browY: [[0, 0], [100, 0.3, "out"], [700, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
freeze: {
|
||||
label: "얼어붙음",
|
||||
durationMs: 1800, fadeInMs: 60, fadeOutMs: 400, priority: 3, cooldownMs: 4000,
|
||||
suppress: ["blink", "saccade", "sway"],
|
||||
tracks: {
|
||||
eyeOpen: plateau(0.25, 120, 1500, 1800),
|
||||
browY: plateau(0.35, 120, 1500, 1800),
|
||||
mouthOpen: plateau(0.15, 150, 1500, 1800),
|
||||
shoulderRaise: plateau(0.3, 150, 1500, 1800),
|
||||
},
|
||||
},
|
||||
sigh: {
|
||||
label: "한숨",
|
||||
durationMs: 2600, fadeInMs: 100, fadeOutMs: 300, priority: 2, cooldownMs: 8000,
|
||||
suppress: ["blink"],
|
||||
tracks: {
|
||||
shoulderRaise: [[0, 0], [800, 0.35, "inOut"], [1000, 0.35, "hold"], [2000, -0.3, "inOut"], [2600, 0, "inOut"]],
|
||||
headPitch: [[0, 0], [800, 0.08, "inOut"], [2000, -0.15, "inOut"], [2600, 0, "inOut"]],
|
||||
eyeOpen: [[0, 0], [900, 0, "hold"], [1500, -0.5, "inOut"], [2300, -0.1, "inOut"], [2600, 0, "inOut"]],
|
||||
mouthOpen: [[0, 0], [1000, 0, "hold"], [1300, 0.2, "out"], [1900, 0.05, "inOut"], [2200, 0, "inOut"]],
|
||||
bodyLean: [[0, 0], [800, 0.05, "inOut"], [2000, -0.1, "inOut"], [2600, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
deep_breath: {
|
||||
label: "심호흡",
|
||||
durationMs: 3600, fadeInMs: 150, fadeOutMs: 300, priority: 1, cooldownMs: 10000,
|
||||
tracks: {
|
||||
shoulderRaise: plateau(0.4, 1500, 1800, 3400, "inOut"),
|
||||
bodyLean: [[0, 0], [1500, 0.08, "inOut"], [3400, 0, "inOut"]],
|
||||
eyeOpen: [[0, 0], [1500, -0.3, "inOut"], [3000, -0.1, "inOut"], [3600, 0, "inOut"]],
|
||||
headPitch: [[0, 0], [1500, 0.1, "inOut"], [3400, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
swallow: {
|
||||
label: "침 삼킴",
|
||||
durationMs: 800, fadeInMs: 50, fadeOutMs: 150, priority: 1, cooldownMs: 3000,
|
||||
tracks: {
|
||||
chinRaise: [[0, 0], [200, 0.4, "out"], [450, 0, "inOut"]],
|
||||
mouthPress: [[0, 0], [150, 0.5, "out"], [500, 0.2, "inOut"], [800, 0, "inOut"]],
|
||||
headPitch: [[0, 0], [250, -0.05, "inOut"], [800, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
lip_press: {
|
||||
label: "입술 누름",
|
||||
durationMs: 1800, fadeInMs: 80, fadeOutMs: 250, priority: 1, cooldownMs: 3000,
|
||||
tracks: {
|
||||
mouthPress: plateau(0.7, 200, 1400, 1800),
|
||||
mouthForm: plateau(-0.15, 200, 1400, 1800),
|
||||
chinRaise: plateau(0.2, 200, 1400, 1800),
|
||||
},
|
||||
},
|
||||
lip_bite: {
|
||||
label: "입술 깨묾",
|
||||
durationMs: 1600, fadeInMs: 80, fadeOutMs: 250, priority: 1, cooldownMs: 4000, mirrorable: true,
|
||||
tracks: {
|
||||
mouthPress: plateau(0.6, 180, 1200, 1600),
|
||||
mouthAsym: plateau(0.35, 180, 1200, 1600),
|
||||
gazeY: plateau(-0.3, 250, 1200, 1600),
|
||||
},
|
||||
},
|
||||
scoff: {
|
||||
label: "피식·코웃음",
|
||||
durationMs: 1200, fadeInMs: 60, fadeOutMs: 250, priority: 2, cooldownMs: 4000, mirrorable: true,
|
||||
tracks: {
|
||||
mouthAsym: plateau(0.6, 150, 800, 1200),
|
||||
headYaw: plateau(0.1, 200, 800, 1200),
|
||||
gazeY: plateau(0.3, 200, 700, 1100),
|
||||
headPitch: [[0, 0], [120, 0.08, "out"], [600, 0, "inOut"]],
|
||||
mouthOpen: [[0, 0], [100, 0.08, "out"], [250, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
eye_roll_small: {
|
||||
label: "눈 굴림",
|
||||
durationMs: 1100, fadeInMs: 60, fadeOutMs: 200, priority: 2, cooldownMs: 6000, mirrorable: true,
|
||||
suppress: ["saccade"],
|
||||
tracks: {
|
||||
gazeY: [[0, 0], [250, 0.6, "out"], [550, 0.5, "inOut"], [900, 0.1, "inOut"], [1100, 0, "inOut"]],
|
||||
gazeX: [[0, 0], [250, 0.1, "out"], [550, 0.5, "inOut"], [900, 0.2, "inOut"], [1100, 0, "inOut"]],
|
||||
eyeOpen: [[0, 0], [250, -0.1, "out"], [1100, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
shrug: {
|
||||
label: "어깨 으쓱",
|
||||
durationMs: 1300, fadeInMs: 80, fadeOutMs: 200, priority: 1, cooldownMs: 3000, mirrorable: true,
|
||||
tracks: {
|
||||
shoulderRaise: plateau(0.6, 300, 700, 1300),
|
||||
browY: plateau(0.3, 300, 700, 1300),
|
||||
headRoll: [[0, 0], [300, 0.1, "out"], [1000, 0, "inOut"]],
|
||||
mouthForm: [[0, 0], [300, -0.2, "out"], [1000, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
lean_back: {
|
||||
label: "물러남",
|
||||
durationMs: 3000, fadeInMs: 150, fadeOutMs: 400, priority: 1, cooldownMs: 5000,
|
||||
tracks: {
|
||||
bodyLean: plateau(-0.4, 500, 2400, 3000),
|
||||
headPitch: plateau(0.08, 500, 2400, 3000),
|
||||
shoulderRaise: plateau(0.1, 500, 2400, 3000),
|
||||
},
|
||||
},
|
||||
lean_forward: {
|
||||
label: "다가옴",
|
||||
durationMs: 3000, fadeInMs: 150, fadeOutMs: 400, priority: 1, cooldownMs: 5000,
|
||||
tracks: {
|
||||
bodyLean: plateau(0.3, 600, 2400, 3000),
|
||||
eyeOpen: plateau(0.08, 600, 2400, 3000),
|
||||
browY: plateau(0.1, 600, 2400, 3000),
|
||||
},
|
||||
},
|
||||
tear_up: {
|
||||
label: "눈물 고임",
|
||||
durationMs: 4000, fadeInMs: 200, fadeOutMs: 800, priority: 2, cooldownMs: 12000,
|
||||
tracks: {
|
||||
tear: [[0, 0], [1500, 0.4, "inOut"], [3600, 0.4, "hold"], [4000, 0.25, "inOut"]],
|
||||
chinRaise: plateau(0.35, 800, 3000, 4000),
|
||||
browAngle: plateau(0.35, 800, 3000, 4000),
|
||||
eyeOpen: plateau(-0.15, 800, 3000, 4000),
|
||||
mouthPress: plateau(0.3, 800, 3000, 4000),
|
||||
},
|
||||
},
|
||||
blink_flurry: {
|
||||
label: "잦은 깜빡임",
|
||||
durationMs: 1200, fadeInMs: 0, fadeOutMs: 0, priority: 1, cooldownMs: 3000,
|
||||
suppress: ["blink"],
|
||||
tracks: {
|
||||
eyeOpen: [
|
||||
[0, 0], [60, -1, "in"], [130, 0, "out"],
|
||||
[330, -1, "in"], [400, 0, "out"],
|
||||
[620, -1, "in"], [690, 0, "out"],
|
||||
[900, -1, "in"], [970, 0, "out"],
|
||||
[1200, 0, "hold"],
|
||||
],
|
||||
},
|
||||
},
|
||||
fidget_sway: {
|
||||
label: "몸 흔들림",
|
||||
durationMs: 2400, fadeInMs: 150, fadeOutMs: 300, priority: 1, cooldownMs: 5000, mirrorable: true,
|
||||
suppress: ["sway"],
|
||||
tracks: {
|
||||
bodyRoll: [[0, 0], [400, 0.12, "inOut"], [900, -0.1, "inOut"], [1400, 0.08, "inOut"], [1900, -0.05, "inOut"], [2400, 0, "inOut"]],
|
||||
headRoll: [[0, 0], [450, -0.06, "inOut"], [950, 0.05, "inOut"], [1500, -0.03, "inOut"], [2400, 0, "inOut"]],
|
||||
gazeX: [[0, 0], [500, 0.3, "inOut"], [1100, -0.2, "inOut"], [1700, 0.1, "inOut"], [2400, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
forced_smile: {
|
||||
label: "억지 미소",
|
||||
durationMs: 2200, fadeInMs: 100, fadeOutMs: 300, priority: 2, cooldownMs: 3000,
|
||||
tracks: {
|
||||
mouthForm: plateau(0.4, 250, 1800, 2200),
|
||||
mouthPress: plateau(0.2, 250, 1800, 2200),
|
||||
browAngle: plateau(0.15, 250, 1800, 2200),
|
||||
},
|
||||
},
|
||||
nervous_laugh: {
|
||||
label: "어색한 웃음",
|
||||
durationMs: 1400, fadeInMs: 60, fadeOutMs: 250, priority: 2, cooldownMs: 5000, mirrorable: true,
|
||||
tracks: {
|
||||
mouthForm: [[0, 0], [150, 0.45, "out"], [900, 0.3, "inOut"], [1400, 0, "inOut"]],
|
||||
mouthOpen: [[0, 0], [150, 0.25, "out"], [300, 0.1, "inOut"], [450, 0.25, "out"], [600, 0.1, "inOut"], [900, 0, "inOut"]],
|
||||
eyeSmile: [[0, 0], [150, 0.3, "out"], [900, 0.1, "inOut"], [1400, 0, "inOut"]],
|
||||
gazeX: plateau(0.4, 200, 1100, 1400),
|
||||
headPitch: [[0, 0], [150, -0.08, "out"], [300, 0, "inOut"], [450, -0.08, "out"], [600, 0, "inOut"]],
|
||||
shoulderRaise: [[0, 0], [150, 0.15, "out"], [600, 0.15, "hold"], [1000, 0, "inOut"]],
|
||||
browAngle: plateau(0.2, 150, 900, 1400),
|
||||
},
|
||||
},
|
||||
jaw_clench: {
|
||||
label: "이 악묾",
|
||||
durationMs: 2000, fadeInMs: 80, fadeOutMs: 300, priority: 2, cooldownMs: 4000,
|
||||
tracks: {
|
||||
mouthPress: plateau(0.8, 200, 1600, 2000),
|
||||
browX: plateau(-0.4, 200, 1600, 2000),
|
||||
browY: plateau(-0.2, 200, 1600, 2000),
|
||||
lidTension: plateau(0.4, 200, 1600, 2000),
|
||||
headPitch: plateau(-0.05, 200, 1600, 2000),
|
||||
},
|
||||
},
|
||||
silence_hold: {
|
||||
label: "침묵 유지",
|
||||
durationMs: 4000, fadeInMs: 200, fadeOutMs: 400, priority: 2, cooldownMs: 6000,
|
||||
suppress: ["blink", "saccade", "sway", "lipsync"],
|
||||
tracks: {
|
||||
gazeY: plateau(-0.55, 500, 3500, 4000),
|
||||
headPitch: plateau(-0.12, 500, 3500, 4000),
|
||||
eyeOpen: [[0, 0], [500, -0.2, "out"], [1800, -0.2, "hold"], [2000, -0.9, "inOut"], [2250, -0.2, "inOut"], [3500, -0.2, "hold"], [4000, 0, "inOut"]],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function expandTracks(tracks: ClipSpec["tracks"]): ReactionClip["tracks"] {
|
||||
const out: Partial<Record<ChannelId, readonly Keyframe[]>> = {};
|
||||
for (const [key, frames] of Object.entries(tracks) as Array<[AuthoringKey, readonly Keyframe[]]>) {
|
||||
for (const id of Object.keys(expandAuthoring({ [key]: 1 })) as ChannelId[]) out[id] = frames;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const REACTION_CLIPS: Record<ReactionClipId, ReactionClip> = Object.fromEntries(
|
||||
REACTION_CLIP_IDS.map((id) => {
|
||||
const { tracks, ...rest } = CLIP_SPECS[id];
|
||||
return [id, { id, ...rest, tracks: expandTracks(tracks) }];
|
||||
}),
|
||||
) as Record<ReactionClipId, ReactionClip>;
|
||||
78
apps/web/src/components/avatar/engine/clipPlayer.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 반응 클립 재생기 — 결정문 §6.1 키프레임 보간·엔벨로프·합성.
|
||||
순수 함수뿐이다. 시간은 인자로만 받는다.
|
||||
===================================================================== */
|
||||
|
||||
import { mirrorDelta, type ChannelDelta, type ChannelId } from "./channels";
|
||||
import type { ClipEase, Keyframe, ReactionClip } from "./clipCatalog";
|
||||
|
||||
function ease(kind: ClipEase, t: number): number {
|
||||
switch (kind) {
|
||||
case "in":
|
||||
return t * t;
|
||||
case "out":
|
||||
return 1 - (1 - t) * (1 - t);
|
||||
case "inOut":
|
||||
return 3 * t * t - 2 * t * t * t;
|
||||
case "hold":
|
||||
return t >= 1 ? 1 : 0;
|
||||
case "linear":
|
||||
default:
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
function sampleTrack(frames: readonly Keyframe[], tMs: number): number {
|
||||
const first = frames[0];
|
||||
if (tMs <= first[0]) return first[1];
|
||||
const last = frames[frames.length - 1];
|
||||
if (tMs >= last[0]) return last[1];
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
const [t1, v1, arrivalEase] = frames[i];
|
||||
if (tMs > t1) continue;
|
||||
const [t0, v0] = frames[i - 1];
|
||||
const span = t1 - t0;
|
||||
const localT = span <= 0 ? 1 : (tMs - t0) / span;
|
||||
const eased = ease(arrivalEase ?? "linear", localT);
|
||||
return v0 + (v1 - v0) * eased;
|
||||
}
|
||||
return last[1];
|
||||
}
|
||||
|
||||
/** 클립의 tMs 시점 채널 delta(가산값). t가 구간 밖이면 빈 delta. */
|
||||
export function sampleClip(clip: ReactionClip, tMs: number): ChannelDelta {
|
||||
if (tMs < 0 || tMs > clip.durationMs) return {};
|
||||
const out: ChannelDelta = {};
|
||||
for (const id of Object.keys(clip.tracks) as ChannelId[]) {
|
||||
const frames = clip.tracks[id];
|
||||
if (!frames || frames.length === 0) continue;
|
||||
out[id] = sampleTrack(frames, tMs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** fadeIn→hold→fadeOut 엔벨로프(0..1). 구간 밖은 0. */
|
||||
export function envelope(clip: ReactionClip, tMs: number): number {
|
||||
if (tMs < 0 || tMs > clip.durationMs) return 0;
|
||||
const { fadeInMs, fadeOutMs, durationMs } = clip;
|
||||
if (fadeInMs > 0 && tMs < fadeInMs) return tMs / fadeInMs;
|
||||
const fadeOutStart = durationMs - fadeOutMs;
|
||||
if (fadeOutMs > 0 && tMs > fadeOutStart) return (durationMs - tMs) / fadeOutMs;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export interface ResolveClipOptions {
|
||||
weight?: number;
|
||||
mirror?: boolean;
|
||||
}
|
||||
|
||||
/** sampleClip × envelope × weight, mirror면 좌우 반전까지 적용한 최종 delta. */
|
||||
export function resolveClipDelta(clip: ReactionClip, tMs: number, opts: ResolveClipOptions = {}): ChannelDelta {
|
||||
const raw = sampleClip(clip, tMs);
|
||||
const scale = envelope(clip, tMs) * (opts.weight ?? 1);
|
||||
const out: ChannelDelta = {};
|
||||
for (const id of Object.keys(raw) as ChannelId[]) {
|
||||
out[id] = (raw[id] as number) * scale;
|
||||
}
|
||||
return opts.mirror ? mirrorDelta(out) : out;
|
||||
}
|
||||
452
apps/web/src/components/avatar/engine/coSpeech.ts
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 발화 동반층 — 결정문 §5.5 정본. 말할 때 머리·몸·눈썹·시선·깜빡임을
|
||||
만드는 순수 모듈이다. 발화 타임라인(§5.4, lipsync.ts)의 구·모음 목표·강세·휴지를
|
||||
입력으로 시드 난수 1회로 계획(CoSpeechPlan)을 만들고, 매 프레임 그 계획을
|
||||
표본해 채널 delta와 깜빡임 요청을 낸다. 렌더러·DOM과 무관하다.
|
||||
스프링(진입·이탈 시 매끄럽게 잇기)은 여기 두지 않는다 — engine.ts가 이 결과를
|
||||
목표값으로 스프링한다(발화층 lipsync 패턴과 동일, §5.4 "표본·평활" 주석 참고).
|
||||
===================================================================== */
|
||||
|
||||
import type { ChannelDelta } from "./channels";
|
||||
import { createRng } from "./rng";
|
||||
import { sampleEnvelope, type SpeechEnvelope } from "./speechEnvelope";
|
||||
import type { SpeechPauseWindow, SpeechPhraseInfo, SpeechTimeline } from "./lipsync";
|
||||
|
||||
/** demeanor.speech에서 발화 동반층이 쓰는 부분만 뽑은 것(순환 참조 방지 — demeanorDefaults.ts를 import하지 않는다). */
|
||||
export interface CoSpeechStyle {
|
||||
/** 모든 크기(깜빡임 제외)에 곱하는 배율(§5.5). */
|
||||
gesture: number;
|
||||
/** 구를 시작할 때 시선을 피할 확률. */
|
||||
gazeAvertProb: number;
|
||||
}
|
||||
|
||||
interface Breakpoint {
|
||||
t: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
interface StressPulseSpec {
|
||||
peakMs: number;
|
||||
riseMs: number;
|
||||
fallMs: number;
|
||||
headPitch: number;
|
||||
bodyLean: number;
|
||||
shoulderRaise: number;
|
||||
}
|
||||
|
||||
interface PhraseEndCosineSpec {
|
||||
kind: "cosine";
|
||||
peakMs: number;
|
||||
riseMs: number;
|
||||
fallMs: number;
|
||||
headPitch: number;
|
||||
headRoll: number;
|
||||
}
|
||||
|
||||
interface PhraseEndTrapezoidSpec {
|
||||
kind: "trapezoid";
|
||||
breakpoints: Breakpoint[];
|
||||
headPitch: number;
|
||||
headRoll: number;
|
||||
}
|
||||
|
||||
type PhraseEndSpec = PhraseEndCosineSpec | PhraseEndTrapezoidSpec;
|
||||
|
||||
interface BreathSpec {
|
||||
breakpoints: Breakpoint[];
|
||||
shoulderRaise: number;
|
||||
bodyLean: number;
|
||||
}
|
||||
|
||||
interface BrowSpec {
|
||||
breakpoints: Breakpoint[];
|
||||
browLY: number;
|
||||
browRY: number;
|
||||
}
|
||||
|
||||
interface GazeSpec {
|
||||
breakpoints: Breakpoint[];
|
||||
gazeX: number;
|
||||
gazeY: number;
|
||||
}
|
||||
|
||||
export interface CoSpeechPlan {
|
||||
totalDurationMs: number;
|
||||
/** 구 표류(§5.5) — 발화 전체를 잇는 연속 곡선. 스프레드 순서: [진입 전 0, 각 구 진입·유지, 마지막 구 뒤 700ms 복귀]. */
|
||||
driftYaw: Breakpoint[];
|
||||
driftRoll: Breakpoint[];
|
||||
driftPitch: Breakpoint[];
|
||||
/** 강세 박(§5.5) — 올림 코사인 펄스 목록. */
|
||||
stressPulses: StressPulseSpec[];
|
||||
/** 구 끝(§5.5) — 평서는 코사인 끄덕임, 질문은 사다리꼴 들림. 이어짐은 항목 없음. */
|
||||
phraseEndPulses: PhraseEndSpec[];
|
||||
/** 들숨(§5.5). */
|
||||
breathPulses: BreathSpec[];
|
||||
/** 눈썹 박(§5.5). */
|
||||
browPulses: BrowSpec[];
|
||||
/** 시선 회피(§5.5). */
|
||||
gazePulses: GazeSpec[];
|
||||
/** 깜빡임 요청 시각(발화 시작 기준 ms, 확률 필터링 완료, 오름차순). */
|
||||
blinkRequests: number[];
|
||||
}
|
||||
|
||||
export interface CoSpeechSample {
|
||||
delta: ChannelDelta;
|
||||
blinkNow?: boolean;
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
return v < 0 ? 0 : v > 1 ? 1 : v;
|
||||
}
|
||||
|
||||
function smoothstep(u: number): number {
|
||||
const c = clamp01(u);
|
||||
return c * c * (3 - 2 * c);
|
||||
}
|
||||
|
||||
/** 올림 코사인 b(t)(§5.5): 정점 전 riseMs 동안 0→1, 정점 뒤 fallMs 동안 1→0. */
|
||||
function raisedCosine(t: number, peakMs: number, riseMs: number, fallMs: number): number {
|
||||
if (t < peakMs) {
|
||||
if (riseMs <= 0) return 0;
|
||||
const u = (t - (peakMs - riseMs)) / riseMs;
|
||||
if (u <= 0) return 0;
|
||||
if (u >= 1) return 1;
|
||||
return 0.5 * (1 - Math.cos(Math.PI * u));
|
||||
}
|
||||
if (fallMs <= 0) return t === peakMs ? 1 : 0;
|
||||
const u = (t - peakMs) / fallMs;
|
||||
if (u >= 1) return 0;
|
||||
return 0.5 * (1 + Math.cos(Math.PI * u));
|
||||
}
|
||||
|
||||
/** breakpoints를 smoothstep으로 잇는 구간별 표본(구 표류·사다리꼴 펄스 공용). 구간 밖은 양끝 값으로 고정된다. */
|
||||
function sampleBreakpoints(bps: readonly Breakpoint[], t: number): number {
|
||||
if (bps.length === 0) return 0;
|
||||
if (t <= bps[0].t) return bps[0].v;
|
||||
const lastBp = bps[bps.length - 1];
|
||||
if (t >= lastBp.t) return lastBp.v;
|
||||
let i = 0;
|
||||
while (i < bps.length - 1 && bps[i + 1].t <= t) i++;
|
||||
const a = bps[i];
|
||||
const b = bps[i + 1];
|
||||
const span = b.t - a.t;
|
||||
const u = span > 0 ? (t - a.t) / span : 1;
|
||||
return a.v + (b.v - a.v) * smoothstep(u);
|
||||
}
|
||||
|
||||
/** 뒤 breakpoint의 t가 앞보다 앞서지 않게 고정한다(§5.5 "구마다 새로 뽑아" 곡선을 이어붙일 때,
|
||||
아주 짧은 구가 연달아 나와 램프 구간이 겹치는 드문 경우의 근사 — 작업 보고 참고). */
|
||||
function pushBp(arr: Breakpoint[], t: number, v: number): void {
|
||||
const last = arr[arr.length - 1];
|
||||
arr.push({ t: last ? Math.max(t, last.t) : t, v });
|
||||
}
|
||||
|
||||
interface PerPhraseDraw {
|
||||
yawSign: number;
|
||||
yawMag: number;
|
||||
rollSign: number;
|
||||
rollMag: number;
|
||||
pitchVal: number;
|
||||
browOn: boolean;
|
||||
gazeOn: boolean;
|
||||
gazeHoldMs: number;
|
||||
}
|
||||
|
||||
const DRIFT_RAMP_LEAD_MS = 100;
|
||||
const DRIFT_RAMP_MS = 450;
|
||||
const DRIFT_RELEASE_MS = 700;
|
||||
|
||||
function buildDrift(
|
||||
phrases: readonly SpeechPhraseInfo[],
|
||||
draws: readonly PerPhraseDraw[],
|
||||
g: number,
|
||||
): { yaw: Breakpoint[]; roll: Breakpoint[]; pitch: Breakpoint[] } {
|
||||
const yaw: Breakpoint[] = [];
|
||||
const roll: Breakpoint[] = [];
|
||||
const pitch: Breakpoint[] = [];
|
||||
let lastYaw = 0;
|
||||
let lastRoll = 0;
|
||||
let lastPitch = 0;
|
||||
let lastEndMs: number | null = null;
|
||||
|
||||
phrases.forEach((ph, i) => {
|
||||
if (ph.syllableTargetsMs.length === 0) return;
|
||||
const draw = draws[i];
|
||||
const rampStart = ph.startMs - DRIFT_RAMP_LEAD_MS;
|
||||
const rampEnd = rampStart + DRIFT_RAMP_MS;
|
||||
const targetYaw = draw.yawSign * draw.yawMag * g;
|
||||
const targetRoll = draw.rollSign * draw.rollMag * g;
|
||||
const targetPitch = draw.pitchVal * g;
|
||||
|
||||
pushBp(yaw, rampStart, lastYaw);
|
||||
pushBp(yaw, rampEnd, targetYaw);
|
||||
pushBp(roll, rampStart, lastRoll);
|
||||
pushBp(roll, rampEnd, targetRoll);
|
||||
pushBp(pitch, rampStart, lastPitch);
|
||||
pushBp(pitch, rampEnd, targetPitch);
|
||||
|
||||
lastYaw = targetYaw;
|
||||
lastRoll = targetRoll;
|
||||
lastPitch = targetPitch;
|
||||
lastEndMs = ph.endMs;
|
||||
});
|
||||
|
||||
if (lastEndMs !== null) {
|
||||
pushBp(yaw, lastEndMs, lastYaw);
|
||||
pushBp(yaw, lastEndMs + DRIFT_RELEASE_MS, 0);
|
||||
pushBp(roll, lastEndMs, lastRoll);
|
||||
pushBp(roll, lastEndMs + DRIFT_RELEASE_MS, 0);
|
||||
pushBp(pitch, lastEndMs, lastPitch);
|
||||
pushBp(pitch, lastEndMs + DRIFT_RELEASE_MS, 0);
|
||||
}
|
||||
|
||||
return { yaw, roll, pitch };
|
||||
}
|
||||
|
||||
function buildStressPulses(phrases: readonly SpeechPhraseInfo[], g: number): StressPulseSpec[] {
|
||||
const out: StressPulseSpec[] = [];
|
||||
for (const ph of phrases) {
|
||||
const mul = (ph.kind === "exclaim" ? 1.4 : 1) * g;
|
||||
for (const idx of ph.stressedIndices) {
|
||||
const vowelMs = ph.syllableTargetsMs[idx];
|
||||
if (vowelMs === undefined) continue;
|
||||
out.push({
|
||||
peakMs: vowelMs - 80,
|
||||
riseMs: 120,
|
||||
fallMs: 260,
|
||||
headPitch: -0.25 * mul,
|
||||
bodyLean: 0.12 * mul,
|
||||
shoulderRaise: 0.08 * mul,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildPhraseEndPulses(phrases: readonly SpeechPhraseInfo[], draws: readonly PerPhraseDraw[], g: number): PhraseEndSpec[] {
|
||||
const out: PhraseEndSpec[] = [];
|
||||
phrases.forEach((ph, i) => {
|
||||
const n = ph.syllableTargetsMs.length;
|
||||
if (n === 0) return;
|
||||
const lastMs = ph.syllableTargetsMs[n - 1];
|
||||
if (ph.kind === "statement") {
|
||||
out.push({ kind: "cosine", peakMs: lastMs, riseMs: 150, fallMs: 400, headPitch: -0.3 * g, headRoll: 0 });
|
||||
return;
|
||||
}
|
||||
if (ph.kind === "question") {
|
||||
/* "마지막 두 음절에 걸쳐" — 1음절 구는 음절 하나 분량(150ms)을 앞당겨 근사한다. */
|
||||
const riseStart = n >= 2 ? ph.syllableTargetsMs[n - 2] : lastMs - 150;
|
||||
const holdEnd = ph.endMs + 300;
|
||||
const releaseEnd = holdEnd + 400;
|
||||
out.push({
|
||||
kind: "trapezoid",
|
||||
breakpoints: [
|
||||
{ t: riseStart, v: 0 },
|
||||
{ t: lastMs, v: 1 },
|
||||
{ t: holdEnd, v: 1 },
|
||||
{ t: releaseEnd, v: 0 },
|
||||
],
|
||||
headPitch: 0.12 * g,
|
||||
headRoll: 0.15 * g * draws[i].rollSign,
|
||||
});
|
||||
}
|
||||
/* 이어짐(",")은 구 끝 동작이 없다(§5.5). */
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const BREATH_LEAD_MS = 250;
|
||||
const BREATH_PAUSE_MIN_MS = 200;
|
||||
const BREATH_EXHALE_FRACTION = 0.4;
|
||||
|
||||
function buildBreathPulses(phrases: readonly SpeechPhraseInfo[], g: number): BreathSpec[] {
|
||||
const out: BreathSpec[] = [];
|
||||
phrases.forEach((ph, i) => {
|
||||
if (ph.syllableTargetsMs.length === 0) return;
|
||||
const prev = phrases[i - 1];
|
||||
const gapMs = i === 0 ? Infinity : ph.startMs - (prev?.endMs ?? ph.startMs);
|
||||
if (i !== 0 && gapMs < BREATH_PAUSE_MIN_MS) return;
|
||||
const dur = Math.max(1, ph.endMs - ph.startMs);
|
||||
out.push({
|
||||
breakpoints: [
|
||||
{ t: ph.startMs - BREATH_LEAD_MS, v: 0 },
|
||||
{ t: ph.startMs, v: 1 },
|
||||
{ t: ph.startMs + dur * BREATH_EXHALE_FRACTION, v: 0 },
|
||||
],
|
||||
shoulderRaise: 0.25 * g,
|
||||
bodyLean: -0.05 * g,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildBrowPulses(phrases: readonly SpeechPhraseInfo[], draws: readonly PerPhraseDraw[], g: number): BrowSpec[] {
|
||||
const out: BrowSpec[] = [];
|
||||
phrases.forEach((ph, i) => {
|
||||
if (!draws[i].browOn) return;
|
||||
const firstStressIdx = ph.stressedIndices[0];
|
||||
const targetMs = firstStressIdx === undefined ? undefined : ph.syllableTargetsMs[firstStressIdx];
|
||||
if (targetMs === undefined) return;
|
||||
out.push({
|
||||
breakpoints: [
|
||||
{ t: targetMs - 100, v: 0 },
|
||||
{ t: targetMs, v: 1 },
|
||||
{ t: targetMs + 150, v: 1 },
|
||||
{ t: targetMs + 400, v: 0 },
|
||||
],
|
||||
browLY: 0.15 * g,
|
||||
browRY: 0.15 * g,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const GAZE_AVERT_TAIL_MS = 400;
|
||||
|
||||
function buildGazePulses(
|
||||
phrases: readonly SpeechPhraseInfo[],
|
||||
draws: readonly PerPhraseDraw[],
|
||||
g: number,
|
||||
totalDurationMs: number,
|
||||
): GazeSpec[] {
|
||||
const out: GazeSpec[] = [];
|
||||
phrases.forEach((ph, i) => {
|
||||
const draw = draws[i];
|
||||
if (!draw.gazeOn) return;
|
||||
if (ph.syllableTargetsMs.length === 0) return;
|
||||
if (ph.startMs >= totalDurationMs - GAZE_AVERT_TAIL_MS) return;
|
||||
out.push({
|
||||
breakpoints: [
|
||||
{ t: ph.startMs, v: 0 },
|
||||
{ t: ph.startMs + 150, v: 1 },
|
||||
{ t: ph.startMs + 150 + draw.gazeHoldMs, v: 1 },
|
||||
{ t: ph.startMs + 150 + draw.gazeHoldMs + 250, v: 0 },
|
||||
],
|
||||
gazeX: 0.25 * draw.yawSign * g,
|
||||
gazeY: -0.15 * g,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const BLINK_PAUSE_MIN_MS = 250;
|
||||
const BLINK_REQUEST_OFFSET_MS = 50;
|
||||
const BLINK_PROB = 0.6;
|
||||
|
||||
function buildBlinkRequests(pauses: readonly SpeechPauseWindow[], rng: ReturnType<typeof createRng>): number[] {
|
||||
const out: number[] = [];
|
||||
for (const pause of pauses) {
|
||||
if (pause.endMs - pause.startMs < BLINK_PAUSE_MIN_MS) continue;
|
||||
if (rng.next() < BLINK_PROB) out.push(pause.startMs + BLINK_REQUEST_OFFSET_MS);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발화 타임라인(§5.4)과 시드로 발화 동반 계획을 한 번 만든다(§5.5).
|
||||
* rng 소비 순서(같은 시드 재현성의 근거): 구마다 순서대로
|
||||
* [yaw 크기, roll 크기, pitch 값, (첫 구만) yaw 부호, roll 부호, 눈썹 박 확률, 시선 회피 확률,
|
||||
* (시선 회피 발동 시) 유지 시간] → 이후 휴지 구간마다 순서대로 [깜빡임 확률].
|
||||
*/
|
||||
export function buildCoSpeechPlan(timeline: SpeechTimeline, style: CoSpeechStyle, seed: number): CoSpeechPlan {
|
||||
const rng = createRng(seed);
|
||||
const g = style.gesture;
|
||||
const phrases = timeline.phrases;
|
||||
|
||||
let prevYawSign = 1;
|
||||
const draws: PerPhraseDraw[] = phrases.map((ph, i) => {
|
||||
const yawMag = rng.range(0.2, 0.4);
|
||||
const rollMag = rng.range(0.1, 0.2);
|
||||
const pitchVal = rng.range(-0.1, 0.05);
|
||||
const yawSign = i === 0 ? (rng.next() < 0.5 ? -1 : 1) : -prevYawSign;
|
||||
prevYawSign = yawSign;
|
||||
const rollSign = rng.next() < 0.5 ? -1 : 1;
|
||||
const browOn = ph.kind === "question" ? true : rng.next() < 0.5;
|
||||
const gazeOn = rng.next() < style.gazeAvertProb;
|
||||
const gazeHoldMs = gazeOn ? rng.range(500, 900) : 0;
|
||||
return { yawSign, yawMag, rollSign, rollMag, pitchVal, browOn, gazeOn, gazeHoldMs };
|
||||
});
|
||||
|
||||
const drift = buildDrift(phrases, draws, g);
|
||||
|
||||
return {
|
||||
totalDurationMs: timeline.totalDurationMs,
|
||||
driftYaw: drift.yaw,
|
||||
driftRoll: drift.roll,
|
||||
driftPitch: drift.pitch,
|
||||
stressPulses: buildStressPulses(phrases, g),
|
||||
phraseEndPulses: buildPhraseEndPulses(phrases, draws, g),
|
||||
breathPulses: buildBreathPulses(phrases, g),
|
||||
browPulses: buildBrowPulses(phrases, draws, g),
|
||||
gazePulses: buildGazePulses(phrases, draws, g, timeline.totalDurationMs),
|
||||
blinkRequests: buildBlinkRequests(timeline.pauses, rng),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획을 tMs(발화 시작 기준 ms) 시점으로 표본한다. prevTMs는 직전 프레임의 같은 기준
|
||||
* 시각이며, (prevTMs, tMs] 구간을 지난 깜빡임 요청이 있으면 blinkNow를 세운다(§5.5).
|
||||
* 음량 들썩임(§5.5)은 envelope가 있을 때만 shoulderRaise에 더한다.
|
||||
*/
|
||||
export function sampleCoSpeech(plan: CoSpeechPlan, tMs: number, prevTMs: number, envelope?: SpeechEnvelope): CoSpeechSample {
|
||||
let headPitch = sampleBreakpoints(plan.driftPitch, tMs);
|
||||
let headRoll = sampleBreakpoints(plan.driftRoll, tMs);
|
||||
const headYaw = sampleBreakpoints(plan.driftYaw, tMs);
|
||||
let bodyLean = 0;
|
||||
let shoulderRaise = 0;
|
||||
let browLY = 0;
|
||||
let browRY = 0;
|
||||
let gazeX = 0;
|
||||
let gazeY = 0;
|
||||
|
||||
for (const p of plan.stressPulses) {
|
||||
const b = raisedCosine(tMs, p.peakMs, p.riseMs, p.fallMs);
|
||||
if (b === 0) continue;
|
||||
headPitch += p.headPitch * b;
|
||||
bodyLean += p.bodyLean * b;
|
||||
shoulderRaise += p.shoulderRaise * b;
|
||||
}
|
||||
|
||||
for (const p of plan.phraseEndPulses) {
|
||||
const b = p.kind === "cosine" ? raisedCosine(tMs, p.peakMs, p.riseMs, p.fallMs) : sampleBreakpoints(p.breakpoints, tMs);
|
||||
if (b === 0) continue;
|
||||
headPitch += p.headPitch * b;
|
||||
headRoll += p.headRoll * b;
|
||||
}
|
||||
|
||||
for (const p of plan.breathPulses) {
|
||||
const b = sampleBreakpoints(p.breakpoints, tMs);
|
||||
if (b === 0) continue;
|
||||
shoulderRaise += p.shoulderRaise * b;
|
||||
bodyLean += p.bodyLean * b;
|
||||
}
|
||||
|
||||
if (envelope) {
|
||||
const p90 = envelope.p90 || 1e-6;
|
||||
const env = sampleEnvelope(envelope, tMs);
|
||||
shoulderRaise += 0.1 * Math.max(0, env / p90 - 0.5);
|
||||
}
|
||||
|
||||
for (const p of plan.browPulses) {
|
||||
const b = sampleBreakpoints(p.breakpoints, tMs);
|
||||
if (b === 0) continue;
|
||||
browLY += p.browLY * b;
|
||||
browRY += p.browRY * b;
|
||||
}
|
||||
|
||||
for (const p of plan.gazePulses) {
|
||||
const b = sampleBreakpoints(p.breakpoints, tMs);
|
||||
if (b === 0) continue;
|
||||
gazeX += p.gazeX * b;
|
||||
gazeY += p.gazeY * b;
|
||||
}
|
||||
|
||||
const delta: ChannelDelta = { headYaw, headPitch, headRoll, bodyLean, shoulderRaise, browLY, browRY, gazeX, gazeY };
|
||||
const blinkNow = plan.blinkRequests.some((e) => e > prevTMs && e <= tMs);
|
||||
return blinkNow ? { delta, blinkNow: true } : { delta };
|
||||
}
|
||||
|
||||
/** 표시용: tMs 시점에 강세 박이 활성인지(rise~fall 구간 안인지). Lab 채널 표 옆 표시에 쓴다. */
|
||||
export function isStressPulseActive(plan: CoSpeechPlan, tMs: number): boolean {
|
||||
return plan.stressPulses.some((p) => tMs >= p.peakMs - p.riseMs && tMs <= p.peakMs + p.fallMs);
|
||||
}
|
||||
198
apps/web/src/components/avatar/engine/demeanorDefaults.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 페르소나 성향(demeanor) 기본값 — 결정문 §7.2 ⑤ / §8.3.
|
||||
기본 자세·표현 증폭·깜빡임·시선·idle 클립 빈도를 캐릭터 카드 설정
|
||||
(data/personas/P*.json, apps/api/app/services/persona.py SEED_PERSONAS)에서 읽어 정했다.
|
||||
4단계에서 캐릭터 카드 avatar.demeanor 블록으로 옮긴다. 값 변경은 설계 결정이다.
|
||||
===================================================================== */
|
||||
|
||||
import type { AvatarState } from "../persona";
|
||||
import { expandAuthoring, type AuthoringDelta, type ChannelDelta } from "./channels";
|
||||
import type { ReactionClipId } from "./clipCatalog";
|
||||
|
||||
export interface IdleClipRule {
|
||||
clip: ReactionClipId;
|
||||
/** 다음 발동까지 간격 범위(ms). 시드 난수로 고른다. */
|
||||
everyMs: readonly [number, number];
|
||||
/** 이 상태에서만 발동. 생략하면 idle·listening 모두. speaking·thinking에는 idle 클립을 쓰지 않는다. */
|
||||
when?: Extract<AvatarState, "idle" | "listening">;
|
||||
/** 개방도가 이 값 미만일 때만 발동. 생략하면 제한 없음. */
|
||||
maxOpenness?: number;
|
||||
}
|
||||
|
||||
export interface Demeanor {
|
||||
basePose: ChannelDelta;
|
||||
/** 표정 delta 증폭. 억누르는 캐릭터는 1보다 작다. */
|
||||
expressivity: number;
|
||||
blinkIntervalMs: readonly [number, number];
|
||||
blinkDurationMs: number;
|
||||
saccadeIntervalMs: readonly [number, number];
|
||||
/** 시선 미세 이동 크기(gaze 채널 단위). */
|
||||
saccadeAmplitude: number;
|
||||
/** 호흡 외 미세 몸 흔들림 크기(0..1 배율). */
|
||||
swayAmplitude: number;
|
||||
idleClips: readonly IdleClipRule[];
|
||||
speech: SpeechStyle;
|
||||
}
|
||||
|
||||
/** 발화층(립싱크) 성향 — 결정문 §5.4. */
|
||||
export interface SpeechStyle {
|
||||
/** 조음 폭: 발화 입 모양(open·wide·round)에 곱한다. 웅얼거리는 캐릭터는 작다. */
|
||||
articulation: number;
|
||||
/** 오디오가 없을 때 텍스트만으로 타이밍을 만들 때의 발화 속도(음절/초). 한국어 대화체 평균 약 5.9. */
|
||||
syllablesPerSec: number;
|
||||
/** 발화 동반 움직임(고개 표류·강세 박·들숨·눈썹 박, 결정문 §5.5) 크기 배율. 위축된 캐릭터는 작다. */
|
||||
gesture: number;
|
||||
/** 구를 시작할 때 시선을 피할 확률(생각하며 말하기). */
|
||||
gazeAvertProb: number;
|
||||
}
|
||||
|
||||
function demeanor(spec: Omit<Demeanor, "basePose"> & { basePose: AuthoringDelta }): Demeanor {
|
||||
return { ...spec, basePose: expandAuthoring(spec.basePose) };
|
||||
}
|
||||
|
||||
/* P1 서연 — 고2, 우울·자살사고, 비자발 내방. N 0.85·E 0.25, "감정 억누르고 무덤덤한 척", 불면. */
|
||||
const P1 = demeanor({
|
||||
basePose: { headPitch: -0.12, shoulderRaise: -0.2, eyeOpen: -0.12, gazeY: -0.15, bodyLean: -0.05 },
|
||||
/* 절제는 강도 곡선(낮은 강도 억제)과 기본 자세가 맡는다. 0.75는 강도 1에서도 표정이 안 읽혀 0.9로 올렸다(2단계-B 소유자 관찰). */
|
||||
expressivity: 0.9,
|
||||
blinkIntervalMs: [2600, 5200],
|
||||
blinkDurationMs: 150,
|
||||
saccadeIntervalMs: [3000, 7000],
|
||||
saccadeAmplitude: 0.12,
|
||||
swayAmplitude: 0.3,
|
||||
idleClips: [
|
||||
{ clip: "sigh", everyMs: [25000, 45000], maxOpenness: 0.5 },
|
||||
{ clip: "look_down", everyMs: [9000, 16000], when: "listening", maxOpenness: 0.6 },
|
||||
{ clip: "look_away_side", everyMs: [12000, 20000], when: "listening", maxOpenness: 0.4 },
|
||||
{ clip: "lip_press", everyMs: [15000, 30000] },
|
||||
],
|
||||
/* 작게 웅얼거리고 느리다(무덤덤한 척·에너지 낮음). */
|
||||
speech: { articulation: 0.6, syllablesPerSec: 5.0, gesture: 0.55, gazeAvertProb: 0.7 },
|
||||
});
|
||||
|
||||
/* P2 민재 — 32세 직장인, 범불안·신체화, 논리적·장황. */
|
||||
const P2 = demeanor({
|
||||
basePose: { shoulderRaise: 0.2, lidTension: 0.15, browAngle: 0.1 },
|
||||
expressivity: 0.85,
|
||||
blinkIntervalMs: [1800, 3600],
|
||||
blinkDurationMs: 110,
|
||||
saccadeIntervalMs: [1800, 4200],
|
||||
saccadeAmplitude: 0.18,
|
||||
swayAmplitude: 0.4,
|
||||
idleClips: [
|
||||
{ clip: "swallow", everyMs: [10000, 18000] },
|
||||
{ clip: "blink_flurry", everyMs: [14000, 26000], maxOpenness: 0.6 },
|
||||
{ clip: "deep_breath", everyMs: [30000, 50000] },
|
||||
],
|
||||
/* 장황하고 빠르다. */
|
||||
speech: { articulation: 0.85, syllablesPerSec: 6.4, gesture: 0.9, gazeAvertProb: 0.5 },
|
||||
});
|
||||
|
||||
/* P3 지우 — 28세 미혼모, 역할 부담, 따뜻하지만 지친 톤. */
|
||||
const P3 = demeanor({
|
||||
basePose: { eyeOpen: -0.15, eyeSmile: 0.08, shoulderRaise: -0.15 },
|
||||
expressivity: 0.9,
|
||||
blinkIntervalMs: [2200, 4600],
|
||||
blinkDurationMs: 140,
|
||||
saccadeIntervalMs: [2600, 5600],
|
||||
saccadeAmplitude: 0.14,
|
||||
swayAmplitude: 0.35,
|
||||
idleClips: [
|
||||
{ clip: "deep_breath", everyMs: [25000, 40000] },
|
||||
{ clip: "sigh", everyMs: [30000, 50000], maxOpenness: 0.6 },
|
||||
{ clip: "look_down", everyMs: [12000, 20000], when: "listening" },
|
||||
],
|
||||
/* 따뜻하지만 지친 톤. */
|
||||
speech: { articulation: 0.8, syllablesPerSec: 5.6, gesture: 0.7, gazeAvertProb: 0.45 },
|
||||
});
|
||||
|
||||
/* P4 하늘 — 고2, 시험불안·완벽주의, 또박또박·자기검열. */
|
||||
const P4 = demeanor({
|
||||
basePose: { shoulderRaise: 0.15, lidTension: 0.15, eyeOpen: 0.05 },
|
||||
expressivity: 1.0,
|
||||
blinkIntervalMs: [1400, 3000],
|
||||
blinkDurationMs: 100,
|
||||
saccadeIntervalMs: [1500, 3500],
|
||||
saccadeAmplitude: 0.2,
|
||||
swayAmplitude: 0.5,
|
||||
idleClips: [
|
||||
{ clip: "blink_flurry", everyMs: [9000, 16000], maxOpenness: 0.6 },
|
||||
{ clip: "fidget_sway", everyMs: [12000, 22000] },
|
||||
{ clip: "swallow", everyMs: [14000, 24000] },
|
||||
{ clip: "lip_bite", everyMs: [18000, 30000], maxOpenness: 0.5 },
|
||||
],
|
||||
/* 또박또박. */
|
||||
speech: { articulation: 0.95, syllablesPerSec: 6.2, gesture: 0.65, gazeAvertProb: 0.55 },
|
||||
});
|
||||
|
||||
/* P5 도윤 — 중3, 또래관계 소외, 무던한 척·반존대. */
|
||||
const P5 = demeanor({
|
||||
basePose: { bodyLean: -0.1, gazeX: 0.12, headPitch: -0.05 },
|
||||
expressivity: 0.7,
|
||||
blinkIntervalMs: [2400, 4800],
|
||||
blinkDurationMs: 120,
|
||||
saccadeIntervalMs: [2500, 5500],
|
||||
saccadeAmplitude: 0.15,
|
||||
swayAmplitude: 0.35,
|
||||
idleClips: [
|
||||
{ clip: "look_away_side", everyMs: [8000, 14000], when: "listening", maxOpenness: 0.6 },
|
||||
{ clip: "shrug", everyMs: [20000, 35000] },
|
||||
{ clip: "look_down", everyMs: [15000, 25000] },
|
||||
],
|
||||
/* 무던한 척, 입을 크게 안 벌린다. */
|
||||
speech: { articulation: 0.65, syllablesPerSec: 5.8, gesture: 0.6, gazeAvertProb: 0.6 },
|
||||
});
|
||||
|
||||
/* P6 하린 — 고3, 진로갈등, 똑부러지지만 지친 톤. */
|
||||
const P6 = demeanor({
|
||||
basePose: { eyeOpen: -0.08 },
|
||||
expressivity: 0.9,
|
||||
blinkIntervalMs: [2000, 4200],
|
||||
blinkDurationMs: 120,
|
||||
saccadeIntervalMs: [2200, 5000],
|
||||
saccadeAmplitude: 0.14,
|
||||
swayAmplitude: 0.3,
|
||||
idleClips: [
|
||||
{ clip: "lip_press", everyMs: [12000, 22000] },
|
||||
{ clip: "deep_breath", everyMs: [25000, 40000] },
|
||||
{ clip: "look_away_side", everyMs: [16000, 28000], maxOpenness: 0.5 },
|
||||
],
|
||||
speech: { articulation: 0.85, syllablesPerSec: 6.0, gesture: 0.75, gazeAvertProb: 0.45 },
|
||||
});
|
||||
|
||||
/* P7 도현 — 고3, 입시 번아웃, 무덤덤·자조. */
|
||||
const P7 = demeanor({
|
||||
basePose: { eyeOpen: -0.25, headPitch: -0.18, shoulderRaise: -0.3, headRoll: 0.05 },
|
||||
expressivity: 0.65,
|
||||
blinkIntervalMs: [3000, 6000],
|
||||
blinkDurationMs: 190,
|
||||
saccadeIntervalMs: [3500, 8000],
|
||||
saccadeAmplitude: 0.1,
|
||||
swayAmplitude: 0.2,
|
||||
idleClips: [
|
||||
{ clip: "sigh", everyMs: [20000, 35000], maxOpenness: 0.6 },
|
||||
{ clip: "look_down", everyMs: [10000, 18000] },
|
||||
{ clip: "scoff", everyMs: [30000, 50000], maxOpenness: 0.4 },
|
||||
],
|
||||
/* 무덤덤·자조, 가장 작게 웅얼거린다. */
|
||||
speech: { articulation: 0.55, syllablesPerSec: 5.2, gesture: 0.4, gazeAvertProb: 0.65 },
|
||||
});
|
||||
|
||||
/* 성향 정의가 없는 페르소나(스튜디오 신규 등)의 기본값. */
|
||||
export const DEFAULT_DEMEANOR = demeanor({
|
||||
basePose: {},
|
||||
expressivity: 0.9,
|
||||
blinkIntervalMs: [2200, 4800],
|
||||
blinkDurationMs: 120,
|
||||
saccadeIntervalMs: [2500, 6000],
|
||||
saccadeAmplitude: 0.14,
|
||||
swayAmplitude: 0.35,
|
||||
idleClips: [{ clip: "look_down", everyMs: [15000, 25000], when: "listening" }],
|
||||
speech: { articulation: 0.8, syllablesPerSec: 5.9, gesture: 0.75, gazeAvertProb: 0.5 },
|
||||
});
|
||||
|
||||
const DEMEANORS: Record<string, Demeanor> = { P1, P2, P3, P4, P5, P6, P7 };
|
||||
|
||||
export function demeanorFor(personaCode: string | null | undefined): Demeanor {
|
||||
return (personaCode && DEMEANORS[personaCode.toUpperCase()]) || DEFAULT_DEMEANOR;
|
||||
}
|
||||
596
apps/web/src/components/avatar/engine/engine.ts
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 표현 엔진 — 결정문 §3 구조·§5 표정층·§6 반응층·§7 공연 스크립트.
|
||||
렌더러와 무관한 순수 상태머신이다. 시계(nowMs)와 난수(rng)는 모두 주입되며
|
||||
내부에서 Date/performance.now/Math.random을 직접 호출하지 않는다.
|
||||
렌더러는 evaluate(nowMs)가 돌려주는 채널 벡터만 읽는다.
|
||||
===================================================================== */
|
||||
|
||||
import {
|
||||
CHANNEL_IDS,
|
||||
CHANNEL_SPECS,
|
||||
OVERLAY_MIN_INTENSITY,
|
||||
clampChannel,
|
||||
neutralVector,
|
||||
type ChannelDelta,
|
||||
type ChannelId,
|
||||
type ChannelVector,
|
||||
} from "./channels";
|
||||
import { EXPRESSION_PRESETS, intensityGain } from "./expressionPresets";
|
||||
import { REACTION_CLIPS, type IdleSuppress, type ReactionClipId } from "./clipCatalog";
|
||||
import { resolveClipDelta } from "./clipPlayer";
|
||||
import type { Demeanor } from "./demeanorDefaults";
|
||||
import { createRng, type Rng } from "./rng";
|
||||
import { SILENT_SHAPE, type SpeechShape } from "./lipsync";
|
||||
import type { AvatarExpression, AvatarState } from "../persona";
|
||||
import type { Performance } from "./performance";
|
||||
|
||||
export interface AvatarEngineOptions {
|
||||
demeanor: Demeanor;
|
||||
seed: number;
|
||||
reducedMotion?: boolean;
|
||||
}
|
||||
|
||||
export interface TriggerClipOptions {
|
||||
weight?: number;
|
||||
mirror?: boolean;
|
||||
}
|
||||
|
||||
export interface PerformanceTimeline {
|
||||
speechStartMs: number;
|
||||
speechDurationMs: number;
|
||||
}
|
||||
|
||||
export interface DebugActiveClip {
|
||||
id: ReactionClipId;
|
||||
startedMs: number;
|
||||
weight: number;
|
||||
mirror: boolean;
|
||||
}
|
||||
|
||||
export interface DebugScheduledCue {
|
||||
clip: ReactionClipId;
|
||||
atMs: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface DebugSnapshot {
|
||||
activeClips: DebugActiveClip[];
|
||||
scheduled: DebugScheduledCue[];
|
||||
surface: { expression: AvatarExpression; intensity: number };
|
||||
leakActive: boolean;
|
||||
}
|
||||
|
||||
export interface AvatarEngine {
|
||||
setState(state: AvatarState, nowMs: number): void;
|
||||
setOpenness(openness: number): void;
|
||||
setSurface(expression: AvatarExpression, intensity: number): void;
|
||||
/** 발화층(§5.4) 목표 입 모양. null이면 목표가 X(닫힘)로 스프링 수렴한다. */
|
||||
setSpeechShape(shape: SpeechShape | null): void;
|
||||
/** 발화 동반층(§5.5) 목표 채널 delta(coSpeech.ts sampleCoSpeech 결과). speaking 중이면
|
||||
그대로 통과시킨다(강세 박 타이밍을 지키기 위해 스프링을 걸지 않는다). speaking이 아니거나
|
||||
null이면 그 값에서 τ150ms 스프링으로 0으로 되돌아간다. reduced motion이면 적용하지 않는다. */
|
||||
setSpeechMotion(delta: ChannelDelta | null): void;
|
||||
/** 발화 동반 깜빡임 요청(§5.5) — idle 깜빡임과 같은 경로로 지금 재생하고 idle 타이머를 다시 잡는다. */
|
||||
requestSpeechBlink(nowMs: number): void;
|
||||
triggerClip(id: ReactionClipId, nowMs: number, opts?: TriggerClipOptions): boolean;
|
||||
playPerformance(perf: Performance, timeline: PerformanceTimeline, nowMs: number): void;
|
||||
evaluate(nowMs: number): ChannelVector;
|
||||
debugSnapshot(): DebugSnapshot;
|
||||
}
|
||||
|
||||
interface ActiveClipEntry {
|
||||
clipId: ReactionClipId;
|
||||
startedMs: number;
|
||||
weight: number;
|
||||
mirror: boolean;
|
||||
priority: 1 | 2 | 3;
|
||||
evictAt?: number;
|
||||
}
|
||||
|
||||
interface ScheduledCue {
|
||||
clip: ReactionClipId;
|
||||
atMs: number;
|
||||
weight: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface LeakState {
|
||||
expression: AvatarExpression;
|
||||
intensity: number;
|
||||
startAbsMs: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
interface IdleClipState {
|
||||
nextAtMs: number;
|
||||
}
|
||||
|
||||
interface BlinkState {
|
||||
nextAt: number;
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
interface SaccadeState {
|
||||
nextAt: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
curX: number;
|
||||
curY: number;
|
||||
}
|
||||
|
||||
const EVICT_FADE_MS = 150;
|
||||
|
||||
/* ── 발화층 스프링(결정문 §5.4 "임계 감쇠 스프링 τ 45ms") ──────────────────
|
||||
표정층 입 스프링(160ms, channels.ts)과 별도로 여기서만 쓴다. sampleSpeech가
|
||||
돌려주는 목표값(SpeechShape)을 매 프레임 이 스프링으로 평활해 채널에 합성한다. */
|
||||
const SPEECH_SPRING_TAU_MS = 45;
|
||||
|
||||
/* ── 발화 동반층 스프링(결정문 §5.5 "발화가 끝나면 delta가 튀지 않게... 짧은 스프링으로
|
||||
되돌린다") ── coSpeech.ts의 순수 표본은 진입·이탈 스프링을 갖지 않으므로, speaking이 아니거나
|
||||
reduced motion이면 목표를 0으로 두고 여기서 짧게 수렴시킨다. */
|
||||
const CO_SPEECH_SPRING_TAU_MS = 150;
|
||||
const CO_SPEECH_CHANNELS: readonly ChannelId[] = [
|
||||
"headYaw",
|
||||
"headPitch",
|
||||
"headRoll",
|
||||
"bodyLean",
|
||||
"shoulderRaise",
|
||||
"browLY",
|
||||
"browRY",
|
||||
"gazeX",
|
||||
"gazeY",
|
||||
];
|
||||
|
||||
interface SpringState {
|
||||
value: number;
|
||||
velocity: number;
|
||||
}
|
||||
|
||||
function zeroSpring(): SpringState {
|
||||
return { value: 0, velocity: 0 };
|
||||
}
|
||||
|
||||
/** 임계 감쇠 2차 시스템의 정확해. omega = 1/tauMs. */
|
||||
function springTowards(state: SpringState, target: number, dt: number, tauMs: number): SpringState {
|
||||
if (tauMs <= 0 || dt <= 0) return { value: target, velocity: 0 };
|
||||
const omega = 1 / tauMs;
|
||||
const diff = state.value - target;
|
||||
const temp = (state.velocity + omega * diff) * dt;
|
||||
const decay = Math.exp(-omega * dt);
|
||||
return {
|
||||
value: target + (diff + temp) * decay,
|
||||
velocity: (state.velocity - omega * temp) * decay,
|
||||
};
|
||||
}
|
||||
|
||||
function filteredPresetDelta(expression: AvatarExpression, intensity: number, demeanor: Demeanor): ChannelDelta {
|
||||
const preset = EXPRESSION_PRESETS[expression];
|
||||
const gain = intensityGain(intensity) * demeanor.expressivity;
|
||||
const out: ChannelDelta = {};
|
||||
for (const id of Object.keys(preset) as ChannelId[]) {
|
||||
const threshold = OVERLAY_MIN_INTENSITY[id];
|
||||
if (threshold !== undefined && intensity < threshold) continue;
|
||||
out[id] = (preset[id] as number) * gain;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function blendDelta(a: ChannelDelta, b: ChannelDelta, w: number): ChannelDelta {
|
||||
const out: ChannelDelta = { ...a };
|
||||
for (const id of CHANNEL_IDS) {
|
||||
const av = a[id] ?? 0;
|
||||
const bv = b[id] ?? 0;
|
||||
const v = av * (1 - w) + bv * w;
|
||||
if (v !== 0 || av !== 0 || bv !== 0) out[id] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createAvatarEngine(options: AvatarEngineOptions): AvatarEngine {
|
||||
const { demeanor } = options;
|
||||
const reducedMotion = options.reducedMotion ?? false;
|
||||
const rng: Rng = createRng(options.seed);
|
||||
|
||||
let currentState: AvatarState = "idle";
|
||||
let openness = 0;
|
||||
let surfaceExpr: AvatarExpression = "neutral";
|
||||
let surfaceIntensity = 0;
|
||||
let pendingSpeechShape: SpeechShape | null = null;
|
||||
let speechSpring = { open: zeroSpring(), wide: zeroSpring(), round: zeroSpring(), press: zeroSpring() };
|
||||
|
||||
let pendingSpeechMotion: ChannelDelta | null = null;
|
||||
const coSpeechSpring: Partial<Record<ChannelId, SpringState>> = {};
|
||||
for (const id of CO_SPEECH_CHANNELS) coSpeechSpring[id] = zeroSpring();
|
||||
|
||||
let leak: LeakState | null = null;
|
||||
|
||||
const baseVector: ChannelVector = (() => {
|
||||
const out = neutralVector();
|
||||
for (const id of Object.keys(demeanor.basePose) as ChannelId[]) {
|
||||
out[id] += demeanor.basePose[id] as number;
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
let smoothed: ChannelVector | null = null;
|
||||
let lastEvalMs: number | null = null;
|
||||
|
||||
let activeClips: ActiveClipEntry[] = [];
|
||||
const cooldownMap = new Map<ReactionClipId, number>();
|
||||
let scheduledCues: ScheduledCue[] = [];
|
||||
|
||||
let idleClipStates: IdleClipState[] | null = null;
|
||||
let blinkState: BlinkState | null = null;
|
||||
let saccadeState: SaccadeState | null = null;
|
||||
|
||||
function leakWeight(nowMs: number): number {
|
||||
if (!leak) return 0;
|
||||
const t = nowMs - leak.startAbsMs;
|
||||
if (t < 0 || t > leak.durationMs) return 0;
|
||||
const fade = Math.min(60, leak.durationMs / 2);
|
||||
if (fade <= 0) return 1;
|
||||
if (t < fade) return t / fade;
|
||||
if (t > leak.durationMs - fade) return (leak.durationMs - t) / fade;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function activeSuppressions(nowMs: number): Set<IdleSuppress> {
|
||||
const out = new Set<IdleSuppress>();
|
||||
for (const entry of activeClips) {
|
||||
const clip = REACTION_CLIPS[entry.clipId];
|
||||
const t = nowMs - entry.startedMs;
|
||||
if (t < 0 || t > clip.durationMs) continue;
|
||||
for (const s of clip.suppress ?? []) out.add(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function triggerClip(id: ReactionClipId, nowMs: number, opts: TriggerClipOptions = {}): boolean {
|
||||
if (reducedMotion) return false;
|
||||
const clip = REACTION_CLIPS[id];
|
||||
const last = cooldownMap.get(id);
|
||||
if (last !== undefined && nowMs - last < clip.cooldownMs) return false;
|
||||
cooldownMap.set(id, nowMs);
|
||||
|
||||
let mirror = opts.mirror ?? false;
|
||||
if (clip.mirrorable && opts.mirror === undefined) {
|
||||
mirror = rng.next() < 0.5;
|
||||
}
|
||||
|
||||
const nonEvicting = activeClips.filter((e) => e.evictAt === undefined);
|
||||
if (nonEvicting.length >= 2) {
|
||||
let victim = nonEvicting[0];
|
||||
for (const e of nonEvicting) {
|
||||
const vClip = REACTION_CLIPS[victim.clipId];
|
||||
const eClip = REACTION_CLIPS[e.clipId];
|
||||
if (eClip.priority < vClip.priority || (eClip.priority === vClip.priority && e.startedMs < victim.startedMs)) {
|
||||
victim = e;
|
||||
}
|
||||
}
|
||||
victim.evictAt = nowMs;
|
||||
}
|
||||
|
||||
activeClips.push({
|
||||
clipId: id,
|
||||
startedMs: nowMs,
|
||||
weight: opts.weight ?? 1,
|
||||
mirror,
|
||||
priority: clip.priority,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function setState(state: AvatarState, nowMs: number): void {
|
||||
const prev = currentState;
|
||||
currentState = state;
|
||||
if (idleClipStates && (state === "idle" || state === "listening") && prev !== state) {
|
||||
demeanor.idleClips.forEach((rule, i) => {
|
||||
if (idleClipStates![i].nextAtMs < nowMs) {
|
||||
idleClipStates![i] = { nextAtMs: nowMs + rng.range(rule.everyMs[0], rule.everyMs[1]) };
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setOpenness(o: number): void {
|
||||
openness = o;
|
||||
}
|
||||
|
||||
function setSurface(expression: AvatarExpression, intensity: number): void {
|
||||
surfaceExpr = expression;
|
||||
surfaceIntensity = intensity;
|
||||
}
|
||||
|
||||
function setSpeechShape(shape: SpeechShape | null): void {
|
||||
pendingSpeechShape = shape;
|
||||
}
|
||||
|
||||
function setSpeechMotion(delta: ChannelDelta | null): void {
|
||||
pendingSpeechMotion = delta;
|
||||
}
|
||||
|
||||
function requestSpeechBlink(nowMs: number): void {
|
||||
if (reducedMotion) return;
|
||||
if (blinkState === null) blinkState = { nextAt: nowMs, startedAt: null };
|
||||
/* 지금 깜빡임을 재생한다. 자연 완료 시 applyBlink가 다음 idle 타이머를 다시 뽑으므로
|
||||
(§5.5 "idle 깜빡임 타이머를 다시 잡는다") 여기서 nextAt을 따로 계산하지 않는다. */
|
||||
blinkState.startedAt = nowMs;
|
||||
}
|
||||
|
||||
function playPerformance(perf: Performance, timeline: PerformanceTimeline, nowMs: number): void {
|
||||
scheduledCues = [];
|
||||
setSurface(perf.surface.expression, perf.surface.intensity);
|
||||
leak = perf.leak
|
||||
? {
|
||||
expression: perf.leak.expression,
|
||||
intensity: perf.leak.intensity,
|
||||
startAbsMs: timeline.speechStartMs + perf.leak.atMs,
|
||||
durationMs: perf.leak.durationMs,
|
||||
}
|
||||
: null;
|
||||
|
||||
for (const cue of perf.cues) {
|
||||
let atAbs: number;
|
||||
switch (cue.anchor) {
|
||||
case "pre":
|
||||
atAbs = timeline.speechStartMs + cue.at;
|
||||
break;
|
||||
case "post":
|
||||
atAbs = timeline.speechStartMs + timeline.speechDurationMs + cue.at;
|
||||
break;
|
||||
case "inline":
|
||||
atAbs = timeline.speechStartMs + timeline.speechDurationMs * cue.at;
|
||||
break;
|
||||
case "listen":
|
||||
default:
|
||||
atAbs = nowMs + cue.at;
|
||||
break;
|
||||
}
|
||||
scheduledCues.push({ clip: cue.clip, atMs: atAbs, weight: cue.weight ?? 1, source: cue.source });
|
||||
}
|
||||
}
|
||||
|
||||
function computeExpressionTarget(nowMs: number): ChannelVector {
|
||||
const surfaceDelta = filteredPresetDelta(surfaceExpr, surfaceIntensity, demeanor);
|
||||
const w = leakWeight(nowMs);
|
||||
const delta = w > 0 && leak ? blendDelta(surfaceDelta, filteredPresetDelta(leak.expression, leak.intensity, demeanor), w) : surfaceDelta;
|
||||
|
||||
const out: ChannelVector = { ...baseVector };
|
||||
for (const id of Object.keys(delta) as ChannelId[]) {
|
||||
out[id] += delta[id] as number;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function updateSmoothing(nowMs: number): void {
|
||||
const target = computeExpressionTarget(nowMs);
|
||||
if (smoothed === null || reducedMotion) {
|
||||
smoothed = target;
|
||||
return;
|
||||
}
|
||||
const dt = Math.max(0, nowMs - (lastEvalMs ?? nowMs));
|
||||
const next = {} as ChannelVector;
|
||||
for (const id of CHANNEL_IDS) {
|
||||
const tau = CHANNEL_SPECS[id].tauMs;
|
||||
if (tau <= 0) {
|
||||
next[id] = target[id];
|
||||
continue;
|
||||
}
|
||||
const alpha = 1 - Math.exp(-dt / tau);
|
||||
next[id] = smoothed[id] + (target[id] - smoothed[id]) * alpha;
|
||||
}
|
||||
smoothed = next;
|
||||
}
|
||||
|
||||
function processScheduledCues(nowMs: number): void {
|
||||
const remaining: ScheduledCue[] = [];
|
||||
for (const sc of scheduledCues) {
|
||||
if (nowMs >= sc.atMs) {
|
||||
triggerClip(sc.clip, nowMs, { weight: sc.weight });
|
||||
} else {
|
||||
remaining.push(sc);
|
||||
}
|
||||
}
|
||||
scheduledCues = remaining;
|
||||
}
|
||||
|
||||
function reactionLayerDelta(nowMs: number): ChannelDelta {
|
||||
const out: ChannelDelta = {};
|
||||
for (const entry of activeClips) {
|
||||
const clip = REACTION_CLIPS[entry.clipId];
|
||||
const t = nowMs - entry.startedMs;
|
||||
if (t < 0 || t > clip.durationMs) continue;
|
||||
let mult = 1;
|
||||
if (entry.evictAt !== undefined) {
|
||||
const et = nowMs - entry.evictAt;
|
||||
mult = et >= EVICT_FADE_MS ? 0 : 1 - et / EVICT_FADE_MS;
|
||||
}
|
||||
const delta = resolveClipDelta(clip, t, { weight: entry.weight * mult, mirror: entry.mirror });
|
||||
for (const id of Object.keys(delta) as ChannelId[]) {
|
||||
out[id] = (out[id] ?? 0) + (delta[id] as number);
|
||||
}
|
||||
}
|
||||
|
||||
activeClips = activeClips.filter((entry) => {
|
||||
const clip = REACTION_CLIPS[entry.clipId];
|
||||
const t = nowMs - entry.startedMs;
|
||||
if (entry.evictAt !== undefined && nowMs - entry.evictAt >= EVICT_FADE_MS) return false;
|
||||
if (t > clip.durationMs) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function processIdleClips(nowMs: number): void {
|
||||
if (currentState !== "idle" && currentState !== "listening") return;
|
||||
if (idleClipStates === null) {
|
||||
idleClipStates = demeanor.idleClips.map((rule) => ({
|
||||
nextAtMs: nowMs + rng.range(rule.everyMs[0], rule.everyMs[1]),
|
||||
}));
|
||||
}
|
||||
demeanor.idleClips.forEach((rule, i) => {
|
||||
const st = idleClipStates![i];
|
||||
if (nowMs < st.nextAtMs) return;
|
||||
const stateOk = rule.when === undefined || rule.when === currentState;
|
||||
const opennessOk = rule.maxOpenness === undefined || openness < rule.maxOpenness;
|
||||
if (stateOk && opennessOk) {
|
||||
triggerClip(rule.clip, nowMs, { weight: 1 });
|
||||
}
|
||||
idleClipStates![i] = { nextAtMs: nowMs + rng.range(rule.everyMs[0], rule.everyMs[1]) };
|
||||
});
|
||||
}
|
||||
|
||||
function applyBlink(out: ChannelVector, nowMs: number, suppressed: boolean): void {
|
||||
if (suppressed) return;
|
||||
if (blinkState === null) {
|
||||
blinkState = { nextAt: nowMs + rng.range(demeanor.blinkIntervalMs[0], demeanor.blinkIntervalMs[1]), startedAt: null };
|
||||
}
|
||||
if (blinkState.startedAt === null && nowMs >= blinkState.nextAt) {
|
||||
blinkState.startedAt = nowMs;
|
||||
}
|
||||
let mult = 1;
|
||||
if (blinkState.startedAt !== null) {
|
||||
const t = nowMs - blinkState.startedAt;
|
||||
if (t >= demeanor.blinkDurationMs) {
|
||||
blinkState.startedAt = null;
|
||||
blinkState.nextAt = nowMs + rng.range(demeanor.blinkIntervalMs[0], demeanor.blinkIntervalMs[1]);
|
||||
} else {
|
||||
const phase = 1 - Math.abs(t / demeanor.blinkDurationMs - 0.5) * 2;
|
||||
mult = 1 - phase * 0.95;
|
||||
if (mult < 0.05) mult = 0.05;
|
||||
}
|
||||
}
|
||||
out.eyeOpenL *= mult;
|
||||
out.eyeOpenR *= mult;
|
||||
}
|
||||
|
||||
function applySaccade(out: ChannelVector, nowMs: number, suppressed: boolean): void {
|
||||
if (suppressed) return;
|
||||
if (saccadeState === null) {
|
||||
saccadeState = { nextAt: nowMs, targetX: 0, targetY: 0, curX: 0, curY: 0 };
|
||||
}
|
||||
const intervalMul = currentState === "thinking" ? 0.5 : 1;
|
||||
if (nowMs >= saccadeState.nextAt) {
|
||||
const amp = demeanor.saccadeAmplitude * (currentState === "listening" ? 0.5 : 1);
|
||||
saccadeState.targetX = rng.range(-amp, amp);
|
||||
saccadeState.targetY = rng.range(-amp * 0.6, amp * 0.6);
|
||||
saccadeState.nextAt =
|
||||
nowMs + rng.range(demeanor.saccadeIntervalMs[0] * intervalMul, demeanor.saccadeIntervalMs[1] * intervalMul);
|
||||
}
|
||||
const dt = Math.max(0, nowMs - (lastEvalMs ?? nowMs));
|
||||
const alpha = 1 - Math.exp(-dt / 90);
|
||||
saccadeState.curX += (saccadeState.targetX - saccadeState.curX) * alpha;
|
||||
saccadeState.curY += (saccadeState.targetY - saccadeState.curY) * alpha;
|
||||
out.gazeX += saccadeState.curX;
|
||||
out.gazeY += saccadeState.curY;
|
||||
}
|
||||
|
||||
function applyBreath(out: ChannelVector, nowMs: number): void {
|
||||
const period =
|
||||
currentState === "listening" ? 4800 : currentState === "speaking" ? 3600 : currentState === "thinking" ? 5200 : 4200;
|
||||
const breath = 0.5 + 0.5 * Math.sin((2 * Math.PI * nowMs) / period);
|
||||
out.breath = breath;
|
||||
out.shoulderRaise += (breath - 0.5) * 0.06;
|
||||
}
|
||||
|
||||
function applySway(out: ChannelVector, nowMs: number, suppressed: boolean): void {
|
||||
if (suppressed) return;
|
||||
const roll = demeanor.swayAmplitude * 0.04 * (Math.sin(nowMs / 2300) + 0.6 * Math.sin(nowMs / 3700));
|
||||
out.bodyRoll += roll;
|
||||
out.headRoll += roll / 2;
|
||||
}
|
||||
|
||||
function applyIdleLayer(out: ChannelVector, nowMs: number): void {
|
||||
processIdleClips(nowMs);
|
||||
const suppress = activeSuppressions(nowMs);
|
||||
applyBlink(out, nowMs, suppress.has("blink"));
|
||||
applySaccade(out, nowMs, suppress.has("saccade"));
|
||||
applyBreath(out, nowMs);
|
||||
applySway(out, nowMs, suppress.has("sway"));
|
||||
}
|
||||
|
||||
function evaluate(nowMs: number): ChannelVector {
|
||||
processScheduledCues(nowMs);
|
||||
updateSmoothing(nowMs);
|
||||
|
||||
const out: ChannelVector = { ...(smoothed as ChannelVector) };
|
||||
const reaction = reducedMotion ? {} : reactionLayerDelta(nowMs);
|
||||
for (const id of Object.keys(reaction) as ChannelId[]) {
|
||||
out[id] += reaction[id] as number;
|
||||
}
|
||||
|
||||
if (!reducedMotion) applyIdleLayer(out, nowMs);
|
||||
|
||||
/* 발화층(결정문 §5.4 "엔진 합성"). 스프링은 speaking이 아닐 때도 X(닫힘)로
|
||||
계속 수렴시켜 상태 전환 시 값이 멈춰 남지 않게 한다. reduced motion이어도
|
||||
발화 입은 움직인다(기존 동작 유지 — idle·반응층만 끄는 대상이다). */
|
||||
const suppressLipsync = activeSuppressions(nowMs).has("lipsync");
|
||||
const speechTarget = currentState === "speaking" && pendingSpeechShape && !suppressLipsync ? pendingSpeechShape : SILENT_SHAPE;
|
||||
const speechDt = Math.max(0, nowMs - (lastEvalMs ?? nowMs));
|
||||
speechSpring = {
|
||||
open: springTowards(speechSpring.open, speechTarget.open, speechDt, SPEECH_SPRING_TAU_MS),
|
||||
wide: springTowards(speechSpring.wide, speechTarget.wide, speechDt, SPEECH_SPRING_TAU_MS),
|
||||
round: springTowards(speechSpring.round, speechTarget.round, speechDt, SPEECH_SPRING_TAU_MS),
|
||||
press: springTowards(speechSpring.press, speechTarget.press, speechDt, SPEECH_SPRING_TAU_MS),
|
||||
};
|
||||
if (currentState === "speaking") {
|
||||
const s = speechSpring;
|
||||
out.mouthOpen = s.open.value + 0.25 * out.mouthOpen;
|
||||
out.mouthStretch += s.wide.value;
|
||||
out.mouthRound = Math.max(out.mouthRound, s.round.value);
|
||||
out.mouthPress = Math.max(0.5 * out.mouthPress, s.press.value);
|
||||
out.mouthForm *= 1 - 0.5 * s.round.value;
|
||||
}
|
||||
|
||||
/* 발화 동반층(결정문 §5.5). reduced motion이면 아예 끈다(입·턱만 남는다). speaking이고
|
||||
pending delta가 있으면 계획 곡선을 그대로 통과시킨다 — coSpeech.ts의 강세 박(rise 120·
|
||||
fall 260)은 이미 매끄러운 올림 코사인이라, 여기서 또 τ150ms 스프링을 걸면 정점이 깎이고
|
||||
100ms 넘게 늦어져 "모음 목표 − 80ms 정점"이 무너진다(팀 리드 수정 요청, 2026-10-01).
|
||||
스프링 상태는 통과 중에도 값만 동기화해 두어(속도 0), speaking이 꺼지거나 delta가 null이
|
||||
되는 순간부터는 그 값에서 이어서 τ150ms로 0으로 되돌아간다(발화 종료 시 튀지 않게). */
|
||||
if (!reducedMotion) {
|
||||
const coSpeaking = currentState === "speaking";
|
||||
const coDt = Math.max(0, nowMs - (lastEvalMs ?? nowMs));
|
||||
for (const id of CO_SPEECH_CHANNELS) {
|
||||
if (coSpeaking && pendingSpeechMotion) {
|
||||
const v = pendingSpeechMotion[id] ?? 0;
|
||||
coSpeechSpring[id] = { value: v, velocity: 0 };
|
||||
out[id] += v;
|
||||
} else {
|
||||
const next = springTowards(coSpeechSpring[id] as SpringState, 0, coDt, CO_SPEECH_SPRING_TAU_MS);
|
||||
coSpeechSpring[id] = next;
|
||||
out[id] += next.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of CHANNEL_IDS) out[id] = clampChannel(id, out[id]);
|
||||
|
||||
lastEvalMs = nowMs;
|
||||
return out;
|
||||
}
|
||||
|
||||
function debugSnapshot(): DebugSnapshot {
|
||||
const leakActive = leak !== null && lastEvalMs !== null && lastEvalMs >= leak.startAbsMs && lastEvalMs <= leak.startAbsMs + leak.durationMs;
|
||||
return {
|
||||
activeClips: activeClips.map((e) => ({ id: e.clipId, startedMs: e.startedMs, weight: e.weight, mirror: e.mirror })),
|
||||
scheduled: scheduledCues.map((sc) => ({ clip: sc.clip, atMs: sc.atMs, source: sc.source })),
|
||||
surface: { expression: surfaceExpr, intensity: surfaceIntensity },
|
||||
leakActive,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
setState,
|
||||
setOpenness,
|
||||
setSurface,
|
||||
setSpeechShape,
|
||||
setSpeechMotion,
|
||||
requestSpeechBlink,
|
||||
triggerClip,
|
||||
playPerformance,
|
||||
evaluate,
|
||||
debugSnapshot,
|
||||
};
|
||||
}
|
||||
68
apps/web/src/components/avatar/engine/expressionPresets.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 표정 프리셋 — docs/decisions/avatar-expression-engine-v3.md §5 정본 값.
|
||||
각 값은 강도 1.0에서 페르소나 기본 자세 대비 delta다. FACS AU를 참고해
|
||||
눈썹(AU1/2/4)·눈꺼풀(AU5/6/7/43)·입(AU12/14/15/17/20/23/24/25/26)과
|
||||
머리·몸 자세를 함께 정한다. 오버레이(tear·cheek·pallor·sweat)는 강도 게이트를 따른다.
|
||||
값 변경은 오케스트레이터 설계 결정이다. 구분 가능성 불변량은
|
||||
scripts/check-avatar-presets.mjs 가 검증한다.
|
||||
===================================================================== */
|
||||
|
||||
import type { AvatarExpression } from "../persona";
|
||||
import { expandAuthoring, type AuthoringDelta, type ChannelDelta } from "./channels";
|
||||
|
||||
const PRESET_SPECS: Record<AvatarExpression, AuthoringDelta> = {
|
||||
neutral: {},
|
||||
|
||||
/* ── positive ─────────────────────────────────────────────────── */
|
||||
calm: { eyeSmile: 0.25, eyeOpen: -0.1, mouthForm: 0.2, shoulderRaise: -0.25, bodyLean: -0.05, browForm: 0.1 },
|
||||
warm: { eyeSmile: 0.45, mouthForm: 0.45, browY: 0.1, headRoll: 0.12, bodyLean: 0.15 },
|
||||
joy: { eyeSmile: 0.75, mouthForm: 0.85, mouthOpen: 0.35, browY: 0.2, cheek: 0.4, headPitch: 0.08, bodyLean: 0.1 },
|
||||
delight: { eyeOpen: 0.2, eyeSmile: 0.5, mouthForm: 0.75, mouthOpen: 0.5, browY: 0.45, bodyLean: 0.2, headPitch: 0.1, cheek: 0.3 },
|
||||
relief: { eyeOpen: -0.4, eyeSmile: 0.3, mouthForm: 0.35, browY: 0.15, browAngle: 0.2, shoulderRaise: -0.45, headPitch: -0.05 },
|
||||
hopeful: { eyeOpen: 0.08, browY: 0.3, browAngle: 0.25, mouthForm: 0.25, gazeY: 0.25, headPitch: 0.12, bodyLean: 0.1 },
|
||||
determined: { browY: -0.15, browX: -0.2, lidTension: 0.3, eyeOpen: 0.05, mouthPress: 0.35, mouthForm: 0.1, headPitch: -0.05, bodyLean: 0.2, shoulderRaise: -0.1 },
|
||||
|
||||
/* ── negative ─────────────────────────────────────────────────── */
|
||||
sad: { eyeOpen: -0.25, browAngle: 0.6, browY: 0.1, mouthForm: -0.45, gazeY: -0.35, headPitch: -0.2, shoulderRaise: -0.2, chinRaise: 0.2, tear: 0.5 },
|
||||
/* 비탄 눈썹은 AU1+AU4: 안쪽만 오르고 눈썹 전체는 눌려 모이며 바깥이 처진다. browY +0.2였을 때
|
||||
소유자가 "눈썹이 너무 위, 두 배는 더 내려와야"라고 봐서 안쪽 올림을 절반(약 57 → 27px)으로 줄였다. */
|
||||
grief: { eyeOpen: -0.45, browAngle: 0.8, browY: -0.4, browX: -0.25, browForm: 0.3, mouthForm: -0.7, mouthOpen: 0.2, chinRaise: 0.55, gazeY: -0.5, headPitch: -0.35, shoulderRaise: 0.2, tear: 0.9 },
|
||||
lonely: { eyeOpen: -0.2, browAngle: 0.4, mouthForm: -0.2, gazeX: 0.45, gazeY: -0.2, headYaw: 0.18, headPitch: -0.12, shoulderRaise: -0.3 },
|
||||
/* 불안은 렌더 구분도가 가장 낮았다(2단계-B-1d out4 face96 G의 0.44). 눈썹을 더 올려 모으고
|
||||
눈을 더 뜨고 입술을 누르며 턱을 긴장시키고 고개를 움츠린다(AU1+2+4·5·24·17). */
|
||||
anxious: { eyeOpen: 0.22, lidTension: 0.3, browAngle: 0.6, browX: -0.45, browY: 0.15, mouthPress: 0.45, mouthStretch: 0.25, chinRaise: 0.2, gazeX: -0.2, pupilSize: 0.25, headPitch: -0.06, shoulderRaise: 0.35, bodyLean: -0.1 },
|
||||
panic: { eyeOpen: 0.3, browAngle: 0.55, browY: 0.45, browX: -0.2, mouthStretch: 0.6, mouthOpen: 0.3, pupilSize: 0.5, shoulderRaise: 0.6, bodyLean: -0.25, pallor: 0.6, sweat: 0.5 },
|
||||
startled: { eyeOpen: 0.3, browY: 0.6, browAngle: 0.1, mouthOpen: 0.45, pupilSize: 0.3, headPitch: 0.12, bodyLean: -0.2, shoulderRaise: 0.4 },
|
||||
embarrassed: { eyeSmile: 0.2, mouthForm: 0.25, mouthAsym: 0.3, gazeX: 0.5, gazeY: -0.35, headPitch: -0.18, headRoll: -0.1, cheek: 0.7 },
|
||||
ashamed: { eyeOpen: -0.35, browAngle: 0.35, gazeY: -0.75, headPitch: -0.45, mouthPress: 0.35, mouthForm: -0.2, shoulderRaise: 0.3, bodyLean: -0.1, cheek: 0.45 },
|
||||
guilty: { eyeOpen: -0.25, browAngle: 0.5, browX: -0.15, gazeY: -0.55, gazeX: -0.25, headPitch: -0.25, mouthPress: 0.45, mouthForm: -0.25, chinRaise: 0.25 },
|
||||
overwhelmed: { eyeOpen: -0.3, lidTension: 0.25, browAngle: 0.7, browX: -0.3, mouthForm: -0.4, mouthOpen: 0.12, chinRaise: 0.35, headPitch: -0.3, shoulderRaise: 0.45, gazeY: -0.3, tear: 0.4 },
|
||||
|
||||
/* ── defensive ────────────────────────────────────────────────── */
|
||||
resistant: { eyeOpen: -0.12, lidTension: 0.3, browY: -0.2, browX: -0.25, mouthPress: 0.5, mouthAsym: 0.2, headYaw: -0.18, bodyLean: -0.3, headPitch: 0.08, gazeX: 0.3 },
|
||||
guarded: { eyeOpen: -0.08, lidTension: 0.4, browY: -0.1, mouthPress: 0.4, bodyLean: -0.2, shoulderRaise: 0.25, gazeX: 0.2, headPitch: -0.05 },
|
||||
skeptical: { browLY: 0.45, browRY: -0.25, lidTension: 0.35, mouthAsym: 0.45, mouthPress: 0.2, headRoll: -0.15, headPitch: 0.1, bodyLean: -0.1 },
|
||||
irritated: { browY: -0.35, browX: -0.45, browAngle: -0.3, lidTension: 0.4, mouthPress: 0.45, mouthForm: -0.15, gazeX: 0.25, headPitch: 0.05 },
|
||||
angry: { browY: -0.6, browX: -0.65, browAngle: -0.6, lidTension: 0.65, eyeOpen: 0.1, mouthPress: 0.6, mouthForm: -0.3, headPitch: -0.12, bodyLean: 0.15, cheek: 0.2 },
|
||||
rage: { browY: -0.8, browX: -0.8, browAngle: -0.8, lidTension: 0.5, eyeOpen: 0.25, mouthOpen: 0.45, mouthStretch: 0.35, mouthForm: -0.45, headPitch: -0.1, bodyLean: 0.3, shoulderRaise: 0.3, cheek: 0.55 },
|
||||
|
||||
/* ── cognitive ────────────────────────────────────────────────── */
|
||||
confused: { browLY: 0.4, browRY: -0.2, browX: -0.2, eyeOpen: -0.05, headRoll: 0.2, mouthAsym: 0.25, mouthPress: 0.15, gazeY: 0.15 },
|
||||
conflicted: { browX: -0.35, browAngle: 0.3, mouthPress: 0.4, mouthAsym: -0.2, gazeX: 0.3, gazeY: -0.2, headPitch: -0.08, lidTension: 0.2 },
|
||||
|
||||
/* ── energy ───────────────────────────────────────────────────── */
|
||||
tired: { eyeOpen: -0.45, browY: -0.1, browAngle: 0.1, mouthForm: -0.1, mouthOpen: 0.05, headPitch: -0.2, headRoll: 0.1, shoulderRaise: -0.45, gazeY: -0.2 },
|
||||
bored: { eyeOpen: -0.35, gazeX: 0.55, gazeY: 0.25, mouthForm: -0.15, mouthAsym: 0.15, headRoll: 0.18, bodyLean: -0.15, browY: -0.05 },
|
||||
};
|
||||
|
||||
export const EXPRESSION_PRESETS: Record<AvatarExpression, ChannelDelta> = Object.fromEntries(
|
||||
Object.entries(PRESET_SPECS).map(([id, spec]) => [id, expandAuthoring(spec)]),
|
||||
) as Record<AvatarExpression, ChannelDelta>;
|
||||
|
||||
/** 강도 → 적용 비율(결정문 §5.2). 0~0.35 절제, 0.35~0.7 중간, 0.7~1.0 뚜렷. */
|
||||
export function intensityGain(intensity: number): number {
|
||||
const x = intensity < 0 ? 0 : intensity > 1 ? 1 : intensity;
|
||||
if (x <= 0.35) return (x / 0.35) * 0.3;
|
||||
if (x <= 0.7) return 0.3 + ((x - 0.35) / 0.35) * 0.45;
|
||||
return 0.75 + ((x - 0.7) / 0.3) * 0.25;
|
||||
}
|
||||
681
apps/web/src/components/avatar/engine/lipsync.ts
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 발화층 — 한글 비짐 립싱크. 결정문 §5.4 발화층 정본.
|
||||
렌더러·엔진과 무관한 순수 모듈이다. 한글 분해는 유니코드 산술로 하고
|
||||
음운 규칙(g2p)은 쓰지 않는다(구분은 양순음 여부뿐, §5.4).
|
||||
흐름: buildSpeechTimeline(텍스트 [+포락선]) → SpeechTimeline(키 목록)
|
||||
→ 재생 중 매 프레임 sampleSpeech(timeline, t)로 표본.
|
||||
임계 감쇠 스프링(τ 45ms)은 여기 두지 않는다 — 엔진의 발화층이 프레임 dt를
|
||||
가지고 있으므로 engine.ts가 sampleSpeech 결과를 스프링으로 평활한다.
|
||||
===================================================================== */
|
||||
|
||||
import { detectVoicedSegments, sampleEnvelope, type SpeechEnvelope, type VoicedSegment } from "./speechEnvelope";
|
||||
|
||||
export type VisemeId = "X" | "M" | "A" | "EO" | "O" | "U" | "EU" | "I" | "E";
|
||||
|
||||
export interface SpeechShape {
|
||||
open: number;
|
||||
wide: number;
|
||||
round: number;
|
||||
press: number;
|
||||
}
|
||||
|
||||
export interface SpeechKey {
|
||||
/** 발화 시작 기준 절대 ms. */
|
||||
atMs: number;
|
||||
shape: SpeechShape;
|
||||
/** 디버그·검증용 비짐 이름. 렌더러는 shape만 쓴다. */
|
||||
viseme: VisemeId;
|
||||
}
|
||||
|
||||
export interface SpeechTimeline {
|
||||
keys: SpeechKey[];
|
||||
totalDurationMs: number;
|
||||
/** 오디오 선분석 경로에서만 채워진다(표본 시 진폭 조절·무음 판정에 쓴다). */
|
||||
envelope?: SpeechEnvelope;
|
||||
voicedSegments?: VoicedSegment[];
|
||||
/** 구 단위로 정렬한 창(진단·검증용, §5.4 "구 경계"). */
|
||||
phraseWindows?: VoicedSegment[];
|
||||
/** 발화 동반층(§5.5) 입력 — 구 목록(시작·끝·종류·음절별 모음 목표·강세 음절). */
|
||||
phrases: SpeechPhraseInfo[];
|
||||
/** 발화 동반층(§5.5) 입력 — 구 사이 휴지 구간. */
|
||||
pauses: SpeechPauseWindow[];
|
||||
}
|
||||
|
||||
/** 구 종류(§5.5): 마침표·구두점 없음 = 평서, `?` = 질문, `!` = 감탄, `,` = 이어짐. */
|
||||
export type PhraseKind = "statement" | "question" | "exclaim" | "continuation";
|
||||
|
||||
export interface SpeechPhraseInfo {
|
||||
index: number;
|
||||
/** 구 시작·끝(휴지 제외) 절대 ms. */
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
kind: PhraseKind;
|
||||
/** 이 구 안 음절(비한글 단위 포함)의 모음 목표 시각(§5.4 "모음 목표"), 순서대로. */
|
||||
syllableTargetsMs: number[];
|
||||
/** 강세 음절 인덱스(syllableTargetsMs 기준). 오름차순이며 첫 원소는 항상 0(구의 첫 음절, §5.5). */
|
||||
stressedIndices: number[];
|
||||
}
|
||||
|
||||
export interface SpeechPauseWindow {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
/* ── 비짐 9종 목표값(결정문 §5.4 표) ──────────────────────────────── */
|
||||
export const VISEME_SHAPE: Readonly<Record<VisemeId, SpeechShape>> = {
|
||||
X: { open: 0, wide: 0, round: 0, press: 0 },
|
||||
M: { open: 0, wide: 0, round: 0, press: 0.35 },
|
||||
A: { open: 0.75, wide: 0.1, round: 0, press: 0 },
|
||||
EO: { open: 0.55, wide: 0, round: 0.1, press: 0 },
|
||||
O: { open: 0.4, wide: 0, round: 0.75, press: 0 },
|
||||
U: { open: 0.2, wide: 0, round: 1.0, press: 0 },
|
||||
EU: { open: 0.15, wide: 0.4, round: 0, press: 0 },
|
||||
I: { open: 0.2, wide: 0.65, round: 0, press: 0 },
|
||||
E: { open: 0.45, wide: 0.45, round: 0, press: 0 },
|
||||
};
|
||||
|
||||
/** X(쉼) 목표 — 무음 판정·타임라인 끝맺음에 쓴다. */
|
||||
export const SILENT_SHAPE: SpeechShape = VISEME_SHAPE.X;
|
||||
|
||||
function cloneShape(s: SpeechShape): SpeechShape {
|
||||
return { ...s };
|
||||
}
|
||||
|
||||
function lerpShape(a: SpeechShape, b: SpeechShape, t: number): SpeechShape {
|
||||
return {
|
||||
open: a.open + (b.open - a.open) * t,
|
||||
wide: a.wide + (b.wide - a.wide) * t,
|
||||
round: a.round + (b.round - a.round) * t,
|
||||
press: a.press + (b.press - a.press) * t,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
return v < lo ? lo : v > hi ? hi : v;
|
||||
}
|
||||
|
||||
function smoothstep(u: number): number {
|
||||
const c = clamp(u, 0, 1);
|
||||
return c * c * (3 - 2 * c);
|
||||
}
|
||||
|
||||
/* ── 한글 분해(유니코드 산술) — 결정문 §5.4 "한글 분해는 유니코드 산술로 한다" ──── */
|
||||
const HANGUL_BASE = 0xac00;
|
||||
const HANGUL_LAST = 0xd7a3;
|
||||
const JUNGSEONG_COUNT = 21;
|
||||
const JONGSEONG_COUNT = 28;
|
||||
|
||||
function isHangulSyllable(code: number): boolean {
|
||||
return code >= HANGUL_BASE && code <= HANGUL_LAST;
|
||||
}
|
||||
|
||||
function decompose(code: number): { cho: number; jung: number; jong: number } {
|
||||
const sIndex = code - HANGUL_BASE;
|
||||
const jong = sIndex % JONGSEONG_COUNT;
|
||||
const jung = Math.floor(sIndex / JONGSEONG_COUNT) % JUNGSEONG_COUNT;
|
||||
const cho = Math.floor(sIndex / JONGSEONG_COUNT / JUNGSEONG_COUNT);
|
||||
return { cho, jung, jong };
|
||||
}
|
||||
|
||||
/* 초성 19종(유니코드 순서: ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ). */
|
||||
const BILABIAL_CHOSEONG = new Set([6, 7, 8, 17]); // ㅁㅂㅃㅍ
|
||||
const SIBILANT_CHOSEONG = new Set([9, 10, 12, 13, 14]); // ㅅㅆㅈㅉㅊ
|
||||
const SILENT_CHOSEONG = 11; // ㅇ
|
||||
|
||||
/* 종성 28종(0=받침 없음). 끝소리가 양순음(ㅁㅂㅍ)인 것 — 겹받침 ㄻ(ㅁ)·ㄿ(ㅍ)·ㅄ(ㅂ) 포함. */
|
||||
const BILABIAL_JONGSEONG = new Set([16, 17, 26, 10, 14, 18]);
|
||||
|
||||
/* 중성 21종: 단모음은 비짐 하나, 이중모음은 [활음, 뒤 모음] 쌍(결정문 §5.4). */
|
||||
type JungseongEntry = VisemeId | readonly [VisemeId, VisemeId];
|
||||
const JUNGSEONG_TABLE: readonly JungseongEntry[] = [
|
||||
"A", // 0 ㅏ
|
||||
"E", // 1 ㅐ
|
||||
["I", "A"], // 2 ㅑ
|
||||
["I", "E"], // 3 ㅒ
|
||||
"EO", // 4 ㅓ
|
||||
"E", // 5 ㅔ
|
||||
["I", "EO"], // 6 ㅕ
|
||||
["I", "E"], // 7 ㅖ
|
||||
"O", // 8 ㅗ
|
||||
["U", "A"], // 9 ㅘ
|
||||
["U", "E"], // 10 ㅙ
|
||||
["U", "E"], // 11 ㅚ(특칙: U→E)
|
||||
["I", "O"], // 12 ㅛ
|
||||
"U", // 13 ㅜ
|
||||
["U", "EO"], // 14 ㅝ
|
||||
["U", "E"], // 15 ㅞ
|
||||
["U", "I"], // 16 ㅟ(특칙: U→I)
|
||||
["I", "U"], // 17 ㅠ
|
||||
"EU", // 18 ㅡ
|
||||
["EU", "I"], // 19 ㅢ(특칙: EU→I)
|
||||
"I", // 20 ㅣ
|
||||
];
|
||||
|
||||
function jungseongVisemes(jung: number): { glide: VisemeId | null; target: VisemeId } {
|
||||
const entry = JUNGSEONG_TABLE[jung];
|
||||
return typeof entry === "string" ? { glide: null, target: entry } : { glide: entry[0], target: entry[1] };
|
||||
}
|
||||
|
||||
/* ── 음절 내부 키(비짐 프랙션) 생성 ────────────────────────────────── */
|
||||
interface LocalKey {
|
||||
frac: number;
|
||||
shape: SpeechShape;
|
||||
viseme: VisemeId;
|
||||
isTarget?: boolean;
|
||||
}
|
||||
|
||||
/* 양순음 초성 + 활음이 겹칠 때 활음 키를 두는 지점(폐쇄 해제 직후). 문서에 정확한 수치가 없어
|
||||
오케스트레이터 확인 전까지 쓰는 값 — "봐요" 표본(M→U→A)을 만족하는 최소치다. */
|
||||
const BILABIAL_GLIDE_FRAC = 0.15;
|
||||
|
||||
function buildSyllableKeys(cho: number, jung: number, jong: number): LocalKey[] {
|
||||
const { glide, target } = jungseongVisemes(jung);
|
||||
const hasGlide = glide !== null;
|
||||
const targetShape = cloneShape(VISEME_SHAPE[target]);
|
||||
const targetFrac = hasGlide ? 0.55 : 0.35;
|
||||
const keys: LocalKey[] = [];
|
||||
|
||||
if (BILABIAL_CHOSEONG.has(cho)) {
|
||||
keys.push({ frac: 0, shape: cloneShape(VISEME_SHAPE.M), viseme: "M" });
|
||||
if (hasGlide) {
|
||||
const g = glide as VisemeId;
|
||||
keys.push({ frac: BILABIAL_GLIDE_FRAC, shape: cloneShape(VISEME_SHAPE[g]), viseme: g });
|
||||
}
|
||||
} else if (cho === SILENT_CHOSEONG) {
|
||||
if (hasGlide) {
|
||||
const g = glide as VisemeId;
|
||||
keys.push({ frac: 0, shape: cloneShape(VISEME_SHAPE[g]), viseme: g });
|
||||
}
|
||||
/* 무성 초성 + 활음 없음: 시작 키 없이 선행 음절에서 바로 이어진다(동시조음). */
|
||||
} else {
|
||||
const baseId = hasGlide ? (glide as VisemeId) : target;
|
||||
const baseShape = VISEME_SHAPE[baseId];
|
||||
let shape: SpeechShape = { ...baseShape, open: baseShape.open * 0.5 };
|
||||
if (SIBILANT_CHOSEONG.has(cho)) {
|
||||
shape = { ...shape, open: Math.min(shape.open, 0.15), wide: shape.wide + 0.2 };
|
||||
}
|
||||
keys.push({ frac: 0, shape, viseme: baseId });
|
||||
}
|
||||
|
||||
keys.push({ frac: targetFrac, shape: targetShape, viseme: target, isTarget: true });
|
||||
|
||||
if (jong !== 0) {
|
||||
if (BILABIAL_JONGSEONG.has(jong)) {
|
||||
keys.push({ frac: 0.85, shape: cloneShape(VISEME_SHAPE.M), viseme: "M" });
|
||||
} else {
|
||||
keys.push({ frac: 0.85, shape: { ...targetShape, open: targetShape.open * 0.6 }, viseme: target });
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function otherUnitKeys(): LocalKey[] {
|
||||
/* 한글이 아닌 글자(숫자·영문)는 한 음절 EO로 친다(결정문 §5.4). */
|
||||
return [{ frac: 0.35, shape: cloneShape(VISEME_SHAPE.EO), viseme: "EO", isTarget: true }];
|
||||
}
|
||||
|
||||
/* ── 텍스트 → 구 단위 토큰화(휴지 구분, 결정문 §5.4 타이밍) ────────────── */
|
||||
type Unit =
|
||||
| { kind: "syllable"; cho: number; jung: number; jong: number; weight: number }
|
||||
| { kind: "other"; weight: number };
|
||||
|
||||
interface Phrase {
|
||||
units: Unit[];
|
||||
/** 이 구 다음에 오는 휴지(ms). 마지막 구가 구두점으로 끝나지 않으면 0. */
|
||||
restMs: number;
|
||||
kind: PhraseKind;
|
||||
}
|
||||
|
||||
/* voice.py _STAGE_DIRECTION_RE / performance.ts BRACKET_RE 와 같은 괄호 문자 집합. */
|
||||
const BRACKET_RE = /[(([【][^))\]】]*[))\]】]/g;
|
||||
|
||||
const PAUSE_COMMA_MS = 250;
|
||||
const PAUSE_SENTENCE_MS = 450;
|
||||
const PAUSE_ELLIPSIS_MS = 600;
|
||||
|
||||
function tokenizeToPhrases(
|
||||
text: string,
|
||||
): Array<{ units: Array<{ kind: "syllable" | "other"; cho?: number; jung?: number; jong?: number }>; restMs: number; kind: PhraseKind }> {
|
||||
const stripped = text.replace(BRACKET_RE, " ");
|
||||
const normalized = stripped.replace(/\.{2,}/g, "…");
|
||||
|
||||
const phrases: Array<{
|
||||
units: Array<{ kind: "syllable" | "other"; cho?: number; jung?: number; jong?: number }>;
|
||||
restMs: number;
|
||||
kind: PhraseKind;
|
||||
}> = [];
|
||||
let current: Array<{ kind: "syllable" | "other"; cho?: number; jung?: number; jong?: number }> = [];
|
||||
|
||||
const flush = (restMs: number, kind: PhraseKind) => {
|
||||
phrases.push({ units: current, restMs, kind });
|
||||
current = [];
|
||||
};
|
||||
|
||||
for (const ch of normalized) {
|
||||
if (/\s/.test(ch)) continue;
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
if (isHangulSyllable(code)) {
|
||||
const { cho, jung, jong } = decompose(code);
|
||||
current.push({ kind: "syllable", cho, jung, jong });
|
||||
continue;
|
||||
}
|
||||
if (ch === "…") {
|
||||
/* 말줄임표는 문장을 맺지 않는 망설임이라 이어짐(§5.5 구 끝 규칙에서 제외)으로 다룬다.
|
||||
§5.5 표는 이 경우를 명시하지 않아 오케스트레이터 확인 전까지 쓰는 값이다. */
|
||||
flush(PAUSE_ELLIPSIS_MS, "continuation");
|
||||
continue;
|
||||
}
|
||||
if (ch === "," || ch === ",") {
|
||||
flush(PAUSE_COMMA_MS, "continuation");
|
||||
continue;
|
||||
}
|
||||
if (ch === ".") {
|
||||
flush(PAUSE_SENTENCE_MS, "statement");
|
||||
continue;
|
||||
}
|
||||
if (ch === "?") {
|
||||
flush(PAUSE_SENTENCE_MS, "question");
|
||||
continue;
|
||||
}
|
||||
if (ch === "!") {
|
||||
flush(PAUSE_SENTENCE_MS, "exclaim");
|
||||
continue;
|
||||
}
|
||||
if (/[0-9A-Za-z]/.test(ch)) {
|
||||
current.push({ kind: "other" });
|
||||
continue;
|
||||
}
|
||||
/* 그 외 기호(물결표 등)는 발화 타이밍에 영향을 주지 않는다. */
|
||||
}
|
||||
if (current.length > 0) phrases.push({ units: current, restMs: 0, kind: "statement" });
|
||||
return phrases;
|
||||
}
|
||||
|
||||
function syllableWeight(jong: number, isPhraseFinal: boolean): number {
|
||||
let w = 1.0;
|
||||
if (jong !== 0) w += 0.25;
|
||||
if (isPhraseFinal) w += 0.5;
|
||||
return w;
|
||||
}
|
||||
|
||||
function otherWeight(isPhraseFinal: boolean): number {
|
||||
return isPhraseFinal ? 1.5 : 1.0;
|
||||
}
|
||||
|
||||
function withWeights(
|
||||
raw: Array<{
|
||||
units: Array<{ kind: "syllable" | "other"; cho?: number; jung?: number; jong?: number }>;
|
||||
restMs: number;
|
||||
kind: PhraseKind;
|
||||
}>,
|
||||
): Phrase[] {
|
||||
return raw.map(({ units, restMs, kind }) => {
|
||||
const withW: Unit[] = units.map((u, i) => {
|
||||
const isLast = i === units.length - 1;
|
||||
const phraseFinal = isLast && restMs > 0;
|
||||
if (u.kind === "syllable") {
|
||||
return { kind: "syllable", cho: u.cho as number, jung: u.jung as number, jong: u.jong as number, weight: syllableWeight(u.jong as number, phraseFinal) };
|
||||
}
|
||||
return { kind: "other", weight: otherWeight(phraseFinal) };
|
||||
});
|
||||
return { units: withW, restMs, kind };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 강세 음절 선택(§5.5): 구의 첫 음절 + 구 안에서 값이 가장 큰 음절(오디오는 포락선 피크,
|
||||
* 텍스트는 가중치). 구가 6음절 이상이면 이웃하지 않은 상위 2개를 추가로 고른다.
|
||||
* 반환은 오름차순 인덱스이며 첫 원소는 항상 0이다.
|
||||
*/
|
||||
function selectStressedIndices(weights: number[]): number[] {
|
||||
const n = weights.length;
|
||||
if (n === 0) return [];
|
||||
const selected = [0];
|
||||
if (n === 1) return selected;
|
||||
const extraCount = n >= 6 ? 2 : 1;
|
||||
const ranked = weights
|
||||
.map((w, i) => ({ i, w }))
|
||||
.filter((e) => e.i !== 0)
|
||||
.sort((a, b) => b.w - a.w || a.i - b.i);
|
||||
for (const { i } of ranked) {
|
||||
if (selected.length - 1 >= extraCount) break;
|
||||
if (selected.every((s) => Math.abs(s - i) >= 2)) selected.push(i);
|
||||
}
|
||||
selected.sort((a, b) => a - b);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function localKeysFor(unit: Unit): LocalKey[] {
|
||||
return unit.kind === "syllable" ? buildSyllableKeys(unit.cho, unit.jung, unit.jong) : otherUnitKeys();
|
||||
}
|
||||
|
||||
/* ── 레이아웃 → 최종 키 목록 ──────────────────────────────────────── */
|
||||
interface LaidUnit {
|
||||
startMs: number;
|
||||
durMs: number;
|
||||
localKeys: LocalKey[];
|
||||
}
|
||||
|
||||
const SHORT_SYLLABLE_MS = 90;
|
||||
const SHORT_SYLLABLE_PULL = 0.4;
|
||||
const MIN_HOLD_MS = 60;
|
||||
|
||||
/** 짧은 음절(<90ms) 목표를 앞뒤 음절 평균 쪽으로 40% 끌어당긴다(결정문 §5.4). */
|
||||
function applyShortSyllableAveraging(laidOut: LaidUnit[]): void {
|
||||
const targets = laidOut.map((lu) => lu.localKeys.find((k) => k.isTarget) ?? null);
|
||||
const originals = targets.map((k) => (k ? cloneShape(k.shape) : null));
|
||||
for (let i = 0; i < laidOut.length; i++) {
|
||||
if (laidOut[i].durMs >= SHORT_SYLLABLE_MS) continue;
|
||||
const key = targets[i];
|
||||
if (!key) continue;
|
||||
const prev = i > 0 ? originals[i - 1] : null;
|
||||
const next = i < laidOut.length - 1 ? originals[i + 1] : null;
|
||||
if (!prev && !next) continue;
|
||||
const avg = prev && next ? lerpShape(prev, next, 0.5) : ((prev ?? next) as SpeechShape);
|
||||
key.shape = lerpShape(originals[i] as SpeechShape, avg, SHORT_SYLLABLE_PULL);
|
||||
}
|
||||
}
|
||||
|
||||
/** 인접 키 간격이 60ms 미만이면 뒤 키를 밀어 최소 유지를 보장한다(결정문 §5.4). */
|
||||
function enforceMinHold(keys: SpeechKey[]): void {
|
||||
for (let i = 1; i < keys.length; i++) {
|
||||
if (keys[i].atMs - keys[i - 1].atMs < MIN_HOLD_MS) {
|
||||
keys[i].atMs = keys[i - 1].atMs + MIN_HOLD_MS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finalize(
|
||||
laidOut: LaidUnit[],
|
||||
pauseAtMs: number[],
|
||||
totalMs: number,
|
||||
envelope: SpeechEnvelope | undefined,
|
||||
voicedSegments: VoicedSegment[] | undefined,
|
||||
phraseWindows: VoicedSegment[] | undefined,
|
||||
phrases: SpeechPhraseInfo[],
|
||||
pauses: SpeechPauseWindow[],
|
||||
): SpeechTimeline {
|
||||
applyShortSyllableAveraging(laidOut);
|
||||
|
||||
const keys: SpeechKey[] = [];
|
||||
for (const lu of laidOut) {
|
||||
for (const lk of lu.localKeys) {
|
||||
keys.push({ atMs: lu.startMs + lk.frac * lu.durMs, shape: lk.shape, viseme: lk.viseme });
|
||||
}
|
||||
}
|
||||
for (const at of pauseAtMs) {
|
||||
keys.push({ atMs: at, shape: cloneShape(SILENT_SHAPE), viseme: "X" });
|
||||
}
|
||||
keys.sort((a, b) => a.atMs - b.atMs);
|
||||
|
||||
const last = keys[keys.length - 1];
|
||||
if (!last || last.viseme !== "X" || last.atMs < totalMs) {
|
||||
keys.push({ atMs: totalMs, shape: cloneShape(SILENT_SHAPE), viseme: "X" });
|
||||
}
|
||||
|
||||
enforceMinHold(keys);
|
||||
|
||||
const finalTotal = Math.max(totalMs, keys[keys.length - 1].atMs);
|
||||
return { keys, totalDurationMs: finalTotal, envelope, voicedSegments, phraseWindows, phrases, pauses };
|
||||
}
|
||||
|
||||
/* ── 텍스트만으로 타이밍(오디오 없음, 결정문 §5.4) ────────────────────── */
|
||||
function buildTextOnlyTimeline(phrases: Phrase[], syllablesPerSec: number): SpeechTimeline {
|
||||
const baseDurMs = 1000 / syllablesPerSec;
|
||||
const laidOut: LaidUnit[] = [];
|
||||
const pauseAtMs: number[] = [];
|
||||
const phraseInfos: SpeechPhraseInfo[] = [];
|
||||
const pauses: SpeechPauseWindow[] = [];
|
||||
let tMs = 0;
|
||||
|
||||
phrases.forEach((phrase, pIdx) => {
|
||||
const startMs = tMs;
|
||||
const syllableTargetsMs: number[] = [];
|
||||
const weights: number[] = [];
|
||||
for (const unit of phrase.units) {
|
||||
const durMs = unit.weight * baseDurMs;
|
||||
const localKeys = localKeysFor(unit);
|
||||
laidOut.push({ startMs: tMs, durMs, localKeys });
|
||||
const targetKey = localKeys.find((k) => k.isTarget);
|
||||
syllableTargetsMs.push(tMs + (targetKey?.frac ?? 0.35) * durMs);
|
||||
weights.push(unit.weight);
|
||||
tMs += durMs;
|
||||
}
|
||||
phraseInfos.push({
|
||||
index: pIdx,
|
||||
startMs,
|
||||
endMs: tMs,
|
||||
kind: phrase.kind,
|
||||
syllableTargetsMs,
|
||||
stressedIndices: selectStressedIndices(weights),
|
||||
});
|
||||
if (phrase.restMs > 0) {
|
||||
pauseAtMs.push(tMs);
|
||||
pauses.push({ startMs: tMs, endMs: tMs + phrase.restMs });
|
||||
tMs += phrase.restMs;
|
||||
}
|
||||
});
|
||||
|
||||
return finalize(laidOut, pauseAtMs, tMs, undefined, undefined, undefined, phraseInfos, pauses);
|
||||
}
|
||||
|
||||
/* ── 오디오 선분석 정렬(결정문 §5.4) ─────────────────────────────────── */
|
||||
const CORE_SNAP_RATIO = 0.35;
|
||||
|
||||
function findEnvelopePeakMs(envelope: SpeechEnvelope, fromMs: number, toMs: number): number | null {
|
||||
if (toMs <= fromMs) return null;
|
||||
const i0 = Math.max(0, Math.floor(fromMs / envelope.hopMs));
|
||||
const i1 = Math.min(envelope.rms.length - 1, Math.ceil(toMs / envelope.hopMs));
|
||||
let bestI = -1;
|
||||
let bestV = -Infinity;
|
||||
for (let i = i0; i <= i1; i++) {
|
||||
if (envelope.rms[i] > bestV) {
|
||||
bestV = envelope.rms[i];
|
||||
bestI = i;
|
||||
}
|
||||
}
|
||||
return bestI >= 0 ? bestI * envelope.hopMs : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구 수(N)와 유성 구간 수(M)를 맞춘다(결정문 §5.4).
|
||||
* M > N이면 가장 짧은 틈부터 합친다. M < N이면 가장 긴 구간을 분할한다(중간점 분할로 단순화 —
|
||||
* "구 가중치 비율로 나눈다"의 근사치. 여러 구가 한 구간에 몰리는 경우까지 정확히 풀려면
|
||||
* 별도의 배정 문제가 필요해 단순화했다. 자세한 사유는 작업 보고 참고).
|
||||
*/
|
||||
function reconcileSegments(segments: VoicedSegment[], phraseCount: number): VoicedSegment[] {
|
||||
if (phraseCount === 0) return [];
|
||||
let segs = segments.map((s) => ({ ...s }));
|
||||
if (segs.length === 0) {
|
||||
return Array.from({ length: phraseCount }, (_, i) => ({ startMs: i * 200, endMs: i * 200 + 150 }));
|
||||
}
|
||||
|
||||
while (segs.length > phraseCount) {
|
||||
let minGap = Infinity;
|
||||
let idx = 0;
|
||||
for (let i = 0; i < segs.length - 1; i++) {
|
||||
const gap = segs[i + 1].startMs - segs[i].endMs;
|
||||
if (gap < minGap) {
|
||||
minGap = gap;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
segs[idx] = { startMs: segs[idx].startMs, endMs: segs[idx + 1].endMs };
|
||||
segs.splice(idx + 1, 1);
|
||||
}
|
||||
|
||||
while (segs.length < phraseCount) {
|
||||
let maxDur = -1;
|
||||
let idx = 0;
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const dur = segs[i].endMs - segs[i].startMs;
|
||||
if (dur > maxDur) {
|
||||
maxDur = dur;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
const seg = segs[idx];
|
||||
const mid = seg.startMs + (seg.endMs - seg.startMs) / 2;
|
||||
segs.splice(idx, 1, { startMs: seg.startMs, endMs: mid }, { startMs: mid, endMs: seg.endMs });
|
||||
}
|
||||
|
||||
return segs;
|
||||
}
|
||||
|
||||
function buildAudioAlignedTimeline(phrases: Phrase[], envelope: SpeechEnvelope): SpeechTimeline {
|
||||
const voicedSegments = detectVoicedSegments(envelope);
|
||||
const phraseWeights = phrases.map((p) => p.units.reduce((s, u) => s + u.weight, 0));
|
||||
const windows = reconcileSegments(voicedSegments, phrases.length);
|
||||
|
||||
const laidOut: LaidUnit[] = [];
|
||||
const pauseAtMs: number[] = [];
|
||||
const phraseInfos: SpeechPhraseInfo[] = [];
|
||||
const pauses: SpeechPauseWindow[] = [];
|
||||
|
||||
for (let i = 0; i < phrases.length; i++) {
|
||||
const phrase = phrases[i];
|
||||
const win = windows[i];
|
||||
if (!win || phrase.units.length === 0) {
|
||||
phraseInfos.push({
|
||||
index: i,
|
||||
startMs: win?.startMs ?? 0,
|
||||
endMs: win?.endMs ?? 0,
|
||||
kind: phrase.kind,
|
||||
syllableTargetsMs: [],
|
||||
stressedIndices: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const totalWeight = phraseWeights[i] || 1;
|
||||
const winDur = Math.max(1, win.endMs - win.startMs);
|
||||
let acc = win.startMs;
|
||||
const syllableTargetsMs: number[] = [];
|
||||
const ampWeights: number[] = [];
|
||||
for (const unit of phrase.units) {
|
||||
const slotDur = winDur * (unit.weight / totalWeight);
|
||||
const slotStart = acc;
|
||||
acc += slotDur;
|
||||
const nominalCenter = slotStart + slotDur / 2;
|
||||
const searchLo = Math.max(win.startMs, nominalCenter - slotDur * CORE_SNAP_RATIO);
|
||||
const searchHi = Math.min(win.endMs, nominalCenter + slotDur * CORE_SNAP_RATIO);
|
||||
const peakMs = findEnvelopePeakMs(envelope, searchLo, searchHi);
|
||||
const startMs = peakMs === null ? slotStart : slotStart + (peakMs - nominalCenter);
|
||||
const localKeys = localKeysFor(unit);
|
||||
laidOut.push({ startMs, durMs: slotDur, localKeys });
|
||||
const targetKey = localKeys.find((k) => k.isTarget);
|
||||
const targetMs = startMs + (targetKey?.frac ?? 0.35) * slotDur;
|
||||
syllableTargetsMs.push(targetMs);
|
||||
ampWeights.push(sampleEnvelope(envelope, targetMs));
|
||||
}
|
||||
phraseInfos.push({
|
||||
index: i,
|
||||
startMs: win.startMs,
|
||||
endMs: win.endMs,
|
||||
kind: phrase.kind,
|
||||
syllableTargetsMs,
|
||||
stressedIndices: selectStressedIndices(ampWeights),
|
||||
});
|
||||
if (i < windows.length - 1) {
|
||||
pauseAtMs.push(win.endMs);
|
||||
pauses.push({ startMs: win.endMs, endMs: windows[i + 1].startMs });
|
||||
}
|
||||
}
|
||||
|
||||
const totalMs = windows.length > 0 ? windows[windows.length - 1].endMs : 0;
|
||||
return finalize(laidOut, pauseAtMs, totalMs, envelope, voicedSegments, windows, phraseInfos, pauses);
|
||||
}
|
||||
|
||||
export interface BuildSpeechTimelineParams {
|
||||
text: string;
|
||||
/** 오디오가 없을 때 쓰는 발화 속도(음절/초). demeanor.speech.syllablesPerSec. */
|
||||
syllablesPerSec: number;
|
||||
/** 있으면 오디오 선분석 정렬 경로를 쓴다. */
|
||||
envelope?: SpeechEnvelope;
|
||||
}
|
||||
|
||||
export function buildSpeechTimeline(params: BuildSpeechTimelineParams): SpeechTimeline {
|
||||
const phrases = withWeights(tokenizeToPhrases(params.text));
|
||||
if (params.envelope) return buildAudioAlignedTimeline(phrases, params.envelope);
|
||||
return buildTextOnlyTimeline(phrases, params.syllablesPerSec);
|
||||
}
|
||||
|
||||
/* ── 표본(재생 중 매 프레임, 결정문 §5.4 "표본·평활") ──────────────────── */
|
||||
const LOOKAHEAD_MS = 60;
|
||||
const SILENCE_CLOSE_MS = 100;
|
||||
const MAX_SPEAKING_OPEN = 0.6;
|
||||
|
||||
function isDeepSilence(segments: VoicedSegment[], tMs: number, minMs: number): boolean {
|
||||
let prevEnd = 0;
|
||||
for (const seg of segments) {
|
||||
if (tMs >= seg.startMs && tMs <= seg.endMs) return false;
|
||||
if (seg.startMs > tMs) break;
|
||||
prevEnd = seg.endMs;
|
||||
}
|
||||
return tMs - prevEnd > minMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* timeline을 tMs(재생 위치, 오디오 시계 또는 엔진 시계) 기준으로 표본한다.
|
||||
* 60ms 앞당김·smoothstep 보간·짧은 음절 평균화(타임라인 빌드 시 반영됨)·크기 조절식·
|
||||
* 무음 100ms 닫힘·articulation 곱·open 상한 0.6까지 여기서 끝낸다.
|
||||
* 임계 감쇠 스프링(τ 45ms)은 포함하지 않는다 — engine.ts가 이 결과를 목표값으로 스프링한다.
|
||||
*/
|
||||
export function sampleSpeech(timeline: SpeechTimeline, tMs: number, articulation = 1): SpeechShape {
|
||||
const keys = timeline.keys;
|
||||
if (keys.length === 0) return cloneShape(SILENT_SHAPE);
|
||||
|
||||
const tQuery = tMs + LOOKAHEAD_MS;
|
||||
let shape: SpeechShape;
|
||||
if (tQuery <= keys[0].atMs) {
|
||||
shape = cloneShape(keys[0].shape);
|
||||
} else if (tQuery >= keys[keys.length - 1].atMs) {
|
||||
shape = cloneShape(keys[keys.length - 1].shape);
|
||||
} else {
|
||||
let i = 0;
|
||||
while (i < keys.length - 1 && keys[i + 1].atMs <= tQuery) i++;
|
||||
const a = keys[i];
|
||||
const b = keys[i + 1];
|
||||
const span = b.atMs - a.atMs;
|
||||
const u = span > 0 ? (tQuery - a.atMs) / span : 1;
|
||||
shape = lerpShape(a.shape, b.shape, smoothstep(u));
|
||||
}
|
||||
|
||||
if (timeline.envelope) {
|
||||
const env = sampleEnvelope(timeline.envelope, tQuery);
|
||||
const p90 = timeline.envelope.p90 || 1e-6;
|
||||
const scale = clamp(0.45 + 0.75 * (env / p90), 0.45, 1.1);
|
||||
shape = { ...shape, open: shape.open * scale };
|
||||
if (timeline.voicedSegments && isDeepSilence(timeline.voicedSegments, tQuery, SILENCE_CLOSE_MS)) {
|
||||
shape = cloneShape(SILENT_SHAPE);
|
||||
}
|
||||
}
|
||||
|
||||
const art = Number.isFinite(articulation) ? articulation : 1;
|
||||
return {
|
||||
open: Math.min(shape.open * art, MAX_SPEAKING_OPEN),
|
||||
wide: shape.wide * art,
|
||||
round: shape.round * art,
|
||||
press: shape.press,
|
||||
};
|
||||
}
|
||||
|
||||
/** tMs 시점의 "현재 비짐" 이름(가장 최근에 지난 키). Lab 표시·디버그용. */
|
||||
export function currentViseme(timeline: SpeechTimeline, tMs: number): VisemeId {
|
||||
const keys = timeline.keys;
|
||||
if (keys.length === 0) return "X";
|
||||
const tQuery = tMs + LOOKAHEAD_MS;
|
||||
if (tQuery <= keys[0].atMs) return keys[0].viseme;
|
||||
let i = 0;
|
||||
while (i < keys.length - 1 && keys[i + 1].atMs <= tQuery) i++;
|
||||
return keys[i].viseme;
|
||||
}
|
||||
|
||||
/** 타임라인의 비짐 이름을 순서대로(연속 중복 제거) 뽑는다. 검증·디버그용. */
|
||||
export function visemeSequence(timeline: SpeechTimeline): VisemeId[] {
|
||||
const out: VisemeId[] = [];
|
||||
for (const k of timeline.keys) {
|
||||
if (out[out.length - 1] !== k.viseme) out.push(k.viseme);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
203
apps/web/src/components/avatar/engine/performance.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 신호 → 공연 스크립트 — 결정문 §7 정본 규칙.
|
||||
괄호 지문 파서(§7.3), 상태 유도 보조 cue(§7.4), 겉표정 강도(§7.5)를
|
||||
여기서 구현한다. 렌더러는 이 모듈을 몰라도 되고, 엔진은 Performance
|
||||
타입만 소비한다.
|
||||
===================================================================== */
|
||||
|
||||
import { AVATAR_EXPRESSION_LIBRARY, type AvatarExpression } from "../persona";
|
||||
import { STAGE_DIRECTION_MAX_LENGTH, STAGE_DIRECTION_RULES } from "./stageDirectionLexicon";
|
||||
import { createRng, hashString } from "./rng";
|
||||
import type { ReactionClipId } from "./clipCatalog";
|
||||
|
||||
export type PerformanceAnchor = "listen" | "pre" | "inline" | "post";
|
||||
export type PerformanceCueSource = "direction" | "state" | "jev";
|
||||
|
||||
export interface PerformanceCue {
|
||||
clip: ReactionClipId;
|
||||
anchor: PerformanceAnchor;
|
||||
/** anchor=inline이면 발화 텍스트 내 비율(0..1), 그 외에는 앵커 기준 지연 ms. */
|
||||
at: number;
|
||||
/** 클립 delta 배율(0..1). 생략하면 1. */
|
||||
weight?: number;
|
||||
source: PerformanceCueSource;
|
||||
}
|
||||
|
||||
export interface Performance {
|
||||
surface: { expression: AvatarExpression; intensity: number };
|
||||
leak?: { expression: AvatarExpression; intensity: number; atMs: number; durationMs: number };
|
||||
cues: PerformanceCue[];
|
||||
}
|
||||
|
||||
/* voice.py _STAGE_DIRECTION_RE 와 같은 괄호 문자 집합(반각·전각 소괄호·대괄호). */
|
||||
const BRACKET_RE = /[(([【][^))\]】]*[))\]】]/g;
|
||||
/* 복합 지문 분리 구분자. */
|
||||
const SPLIT_RE = /[,,·、]/;
|
||||
/* 발화 가능 글자에서 제외하는 구두점(공백은 별도로 제외). */
|
||||
const SPEAKABLE_PUNCTUATION = new Set(["…", ".", ",", "?", "!", "~"]);
|
||||
|
||||
function countSpeakable(text: string): number {
|
||||
let count = 0;
|
||||
for (const ch of text) {
|
||||
if (/\s/.test(ch)) continue;
|
||||
if (SPEAKABLE_PUNCTUATION.has(ch)) continue;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 괄호 지문을 cue로 파싱한다(결정문 §7.3).
|
||||
* 대상은 내담자 발화 원문 전체다. 발화 가능 텍스트에서 지문 조각은 제거된 채로 남는다.
|
||||
*/
|
||||
export function parseStageDirections(text: string): {
|
||||
cues: PerformanceCue[];
|
||||
unmatched: string[];
|
||||
speakableText: string;
|
||||
} {
|
||||
const rawCues: PerformanceCue[] = [];
|
||||
const unmatched: string[] = [];
|
||||
|
||||
for (const match of text.matchAll(BRACKET_RE)) {
|
||||
const full = match[0];
|
||||
const start = match.index ?? 0;
|
||||
const end = start + full.length;
|
||||
const inner = full.slice(1, -1).trim();
|
||||
if (inner.length > STAGE_DIRECTION_MAX_LENGTH) continue;
|
||||
|
||||
const beforeText = text.slice(0, start).replace(BRACKET_RE, "");
|
||||
const afterText = text.slice(end).replace(BRACKET_RE, "");
|
||||
const beforeCount = countSpeakable(beforeText);
|
||||
const afterCount = countSpeakable(afterText);
|
||||
|
||||
let anchor: PerformanceAnchor;
|
||||
let at: number;
|
||||
if (beforeCount === 0) {
|
||||
anchor = "pre";
|
||||
at = 0;
|
||||
} else if (afterCount === 0) {
|
||||
anchor = "post";
|
||||
at = 0;
|
||||
} else {
|
||||
anchor = "inline";
|
||||
at = beforeCount / (beforeCount + afterCount);
|
||||
}
|
||||
|
||||
const pieces = inner
|
||||
.split(SPLIT_RE)
|
||||
.map((piece) => piece.trim())
|
||||
.filter((piece) => piece.length > 0);
|
||||
|
||||
for (const piece of pieces) {
|
||||
const rule = STAGE_DIRECTION_RULES.find((candidate) => candidate.pattern.test(piece));
|
||||
if (rule) {
|
||||
rawCues.push({ clip: rule.clip, anchor, at, weight: rule.weight, source: "direction" });
|
||||
} else {
|
||||
unmatched.push(piece);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 같은 클립이 연달아 나오면 하나로 합친다. */
|
||||
const merged: PerformanceCue[] = [];
|
||||
for (const cue of rawCues) {
|
||||
const prev = merged[merged.length - 1];
|
||||
if (prev && prev.clip === cue.clip) continue;
|
||||
merged.push(cue);
|
||||
}
|
||||
|
||||
return {
|
||||
cues: merged.slice(0, 3),
|
||||
unmatched,
|
||||
speakableText: text.replace(BRACKET_RE, "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
interface AuxCandidate {
|
||||
clip: ReactionClipId;
|
||||
anchor: PerformanceAnchor;
|
||||
at: number;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
function auxCandidatesFor(group: string | undefined, intensity: number): AuxCandidate[] {
|
||||
switch (group) {
|
||||
case "negative":
|
||||
if (intensity < 0.5) return [];
|
||||
return [
|
||||
{ clip: "look_down", anchor: "pre", at: 200 },
|
||||
{ clip: "lip_press", anchor: "post", at: 0 },
|
||||
];
|
||||
case "defensive":
|
||||
return [
|
||||
{ clip: "look_away_side", anchor: "inline", at: 0.3 },
|
||||
{ clip: "lean_back", anchor: "pre", at: 0 },
|
||||
];
|
||||
case "positive":
|
||||
return [{ clip: "nod_small", anchor: "inline", at: 0.4 }];
|
||||
case "energy":
|
||||
return [{ clip: "look_down", anchor: "pre", at: 0, weight: 0.6 }];
|
||||
case "cognitive":
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 겉표정 강도(결정문 §7.5, legacy 경로). */
|
||||
export function surfaceIntensityFor(params: {
|
||||
expression: AvatarExpression;
|
||||
openness: number;
|
||||
safety: boolean;
|
||||
paused: boolean;
|
||||
}): number {
|
||||
const { expression, openness, safety, paused } = params;
|
||||
if (safety) return 0.9;
|
||||
if (paused) return 0.6;
|
||||
const group = AVATAR_EXPRESSION_LIBRARY.find((item) => item.id === expression)?.group;
|
||||
const o = openness < 0 ? 0 : openness > 1 ? 1 : openness;
|
||||
switch (group) {
|
||||
case "negative":
|
||||
case "defensive":
|
||||
return 0.35 + 0.5 * (1 - o);
|
||||
case "positive":
|
||||
return 0.3 + 0.6 * o;
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/** 지문 파싱 + 상태 유도 보조 cue(§7.4)까지 합쳐 공연 스크립트를 만든다. */
|
||||
export function buildPerformance(params: {
|
||||
text: string;
|
||||
expression: AvatarExpression;
|
||||
intensity: number;
|
||||
openness: number;
|
||||
seed: number;
|
||||
}): { performance: Performance; unmatched: string[]; speakableText: string } {
|
||||
const { text, expression, intensity, seed } = params;
|
||||
const { cues, unmatched, speakableText } = parseStageDirections(text);
|
||||
|
||||
let finalCues = cues;
|
||||
if (cues.length === 0) {
|
||||
const rng = createRng(hashString(text) ^ seed);
|
||||
if (rng.next() < 0.6) {
|
||||
const group = AVATAR_EXPRESSION_LIBRARY.find((item) => item.id === expression)?.group;
|
||||
const candidates = auxCandidatesFor(group, intensity);
|
||||
if (candidates.length > 0) {
|
||||
const picked = rng.pick(candidates);
|
||||
finalCues = [{ ...picked, source: "state" }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
performance: { surface: { expression, intensity }, cues: finalCues },
|
||||
unmatched,
|
||||
speakableText,
|
||||
};
|
||||
}
|
||||
|
||||
/** 발화 시간 추정: max(800, 발화 가능 글자 수 × 90)ms. */
|
||||
export function estimateSpeechDurationMs(speakableText: string): number {
|
||||
return Math.max(800, countSpeakable(speakableText) * 90);
|
||||
}
|
||||
47
apps/web/src/components/avatar/engine/rng.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 엔진 전용 시드 난수 — mulberry32.
|
||||
엔진은 순수 함수여야 하므로(결정문 §3) Math.random을 직접 쓰지 않고
|
||||
이 모듈이 주입하는 rng를 통해서만 난수를 소비한다.
|
||||
===================================================================== */
|
||||
|
||||
export interface Rng {
|
||||
/** [0,1) 균등 분포. */
|
||||
next(): number;
|
||||
/** [a,b) 균등 분포. */
|
||||
range(a: number, b: number): number;
|
||||
/** 배열에서 균등하게 하나 고른다. */
|
||||
pick<T>(arr: readonly T[]): T;
|
||||
}
|
||||
|
||||
export function createRng(seed: number): Rng {
|
||||
let state = seed >>> 0;
|
||||
|
||||
function next(): number {
|
||||
state = (state + 0x6d2b79f5) | 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
return {
|
||||
next,
|
||||
range(a: number, b: number): number {
|
||||
return a + (b - a) * next();
|
||||
},
|
||||
pick<T>(arr: readonly T[]): T {
|
||||
const idx = Math.min(arr.length - 1, Math.floor(next() * arr.length));
|
||||
return arr[idx];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** FNV-1a 32bit 문자열 해시. */
|
||||
export function hashString(text: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
153
apps/web/src/components/avatar/engine/speechDriver.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 발화 구동 공용 모듈 — 결정문 §8.5 "세션 연결". AvatarLab의
|
||||
runSpeechTimeline / playSpeech / playSpeechWithAudioFile / endSpeech가
|
||||
하던 엔진 구동 부분(buildPerformance → buildSpeechTimeline[+포락선]
|
||||
→ buildCoSpeechPlan → playPerformance → 매 프레임 표본)을 여기로 옮겨
|
||||
Lab과 v3 래퍼(ClientAvatarV3)가 함께 쓴다. 화면 표시(ref 갱신)는 onFrame
|
||||
콜백으로 호출자에 남긴다. 발화 종료 시 상태 복귀(Lab은 listening, 래퍼는
|
||||
prop state)도 호출자가 onEnd에서 정한다 — 이 모듈은 setSpeechShape(null)·
|
||||
setSpeechMotion(null)과 rAF 해제까지만 한다.
|
||||
===================================================================== */
|
||||
|
||||
import type { AvatarExpression } from "../persona";
|
||||
import type { AvatarEngine } from "./engine";
|
||||
import type { SpeechStyle } from "./demeanorDefaults";
|
||||
import { buildPerformance, type PerformanceCue } from "./performance";
|
||||
import {
|
||||
buildSpeechTimeline,
|
||||
currentViseme,
|
||||
sampleSpeech,
|
||||
type PhraseKind,
|
||||
type SpeechShape,
|
||||
type SpeechTimeline,
|
||||
type VisemeId,
|
||||
} from "./lipsync";
|
||||
import { buildCoSpeechPlan, isStressPulseActive, sampleCoSpeech } from "./coSpeech";
|
||||
import { computeEnvelope, type SpeechEnvelope } from "./speechEnvelope";
|
||||
|
||||
export interface SpeechDriverAudio {
|
||||
buffer: AudioBuffer;
|
||||
context: BaseAudioContext;
|
||||
/** source.start(when)의 when(초, context 시계). */
|
||||
startAt: number;
|
||||
}
|
||||
|
||||
export interface SpeechFrameSample {
|
||||
viseme: VisemeId;
|
||||
shape: SpeechShape;
|
||||
phraseKind: PhraseKind | null;
|
||||
stressed: boolean;
|
||||
}
|
||||
|
||||
export interface StartSpeechParams {
|
||||
engine: AvatarEngine;
|
||||
/** 괄호 지문을 포함한 발화 원문. */
|
||||
text: string;
|
||||
expression: AvatarExpression;
|
||||
intensity: number;
|
||||
openness: number;
|
||||
seed: number;
|
||||
speech: SpeechStyle;
|
||||
/** 엔진 시계(engine.evaluate에 넘기는 시계와 같은 시계). */
|
||||
now: () => number;
|
||||
/** 있으면 오디오 선분석 정렬 경로. 없으면 텍스트 타이밍(speech.syllablesPerSec). */
|
||||
audio?: SpeechDriverAudio;
|
||||
/** 매 프레임 표본 결과(화면 표시용). React state 갱신은 호출자 책임(ref로 받는다). */
|
||||
onFrame?: (sample: SpeechFrameSample) => void;
|
||||
/** 타임라인이 끝까지 재생됐을 때(stop() 호출로 끝난 경우에는 부르지 않는다). */
|
||||
onEnd?: () => void;
|
||||
}
|
||||
|
||||
export interface SpeechHandle {
|
||||
stop(): void;
|
||||
/** 표시·디버그용 — buildPerformance가 파싱한 cue와 미대응 지문. */
|
||||
cues: PerformanceCue[];
|
||||
unmatched: string[];
|
||||
}
|
||||
|
||||
/** tMs 시점까지 시작한 가장 최근 구의 종류. 구 사이 휴지 중에는 그 직전 구를 보인다. */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발화 하나를 시작한다. 오디오가 있으면 포락선을 선분석해 오디오 시계로 표본하고,
|
||||
* 없으면 텍스트 타이밍으로 엔진 시계에서 300ms 뒤 시작한다(결정문 §5.4·§8.5).
|
||||
* 지문은 playPerformance로 함께 재생한다. 반환된 stop()은 호출자가 발화를 바꾸거나
|
||||
* 끝낼 때 부른다(speechShape·speechMotion을 null로 되돌리고 rAF를 해제, 상태 복귀는 않는다).
|
||||
*/
|
||||
export function startSpeech(params: StartSpeechParams): SpeechHandle {
|
||||
const { engine, text, expression, intensity, openness, seed, speech, now, audio, onFrame, onEnd } = params;
|
||||
const nowMs = now();
|
||||
const { performance: perf, unmatched } = buildPerformance({ text, expression, intensity, openness, seed });
|
||||
|
||||
let envelope: SpeechEnvelope | undefined;
|
||||
let timeline: SpeechTimeline;
|
||||
let speechStartMs: number;
|
||||
let sampleClock: () => number;
|
||||
|
||||
if (audio) {
|
||||
const channels: Float32Array[] = [];
|
||||
for (let c = 0; c < audio.buffer.numberOfChannels; c++) channels.push(audio.buffer.getChannelData(c));
|
||||
envelope = computeEnvelope(channels, audio.buffer.sampleRate);
|
||||
timeline = buildSpeechTimeline({ text, syllablesPerSec: speech.syllablesPerSec, envelope });
|
||||
speechStartMs = nowMs + (audio.startAt - audio.context.currentTime) * 1000;
|
||||
/* 오디오 시계 기준(초 → ms, §8.5 "매 프레임 표본 시각은 (context.currentTime − startAt)·1000"). */
|
||||
sampleClock = () => (audio.context.currentTime - audio.startAt) * 1000 + speechStartMs;
|
||||
} else {
|
||||
timeline = buildSpeechTimeline({ text, syllablesPerSec: speech.syllablesPerSec });
|
||||
speechStartMs = nowMs + 300;
|
||||
sampleClock = now;
|
||||
}
|
||||
|
||||
const coSpeechPlan = buildCoSpeechPlan(timeline, speech, seed);
|
||||
engine.playPerformance(perf, { speechStartMs, speechDurationMs: timeline.totalDurationMs }, nowMs);
|
||||
|
||||
let rafId = 0;
|
||||
let prevLocalMs = -Infinity;
|
||||
const endMs = speechStartMs + timeline.totalDurationMs;
|
||||
|
||||
function stop(): void {
|
||||
cancelAnimationFrame(rafId);
|
||||
engine.setSpeechShape(null);
|
||||
engine.setSpeechMotion(null);
|
||||
}
|
||||
|
||||
const step = () => {
|
||||
const t = sampleClock();
|
||||
if (t < speechStartMs) {
|
||||
rafId = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
if (t >= endMs) {
|
||||
stop();
|
||||
onEnd?.();
|
||||
return;
|
||||
}
|
||||
const localMs = t - speechStartMs;
|
||||
const shape = sampleSpeech(timeline, localMs, speech.articulation);
|
||||
engine.setSpeechShape(shape);
|
||||
|
||||
const sample = sampleCoSpeech(coSpeechPlan, localMs, prevLocalMs, envelope);
|
||||
engine.setSpeechMotion(sample.delta);
|
||||
if (sample.blinkNow) engine.requestSpeechBlink(now());
|
||||
|
||||
onFrame?.({
|
||||
viseme: currentViseme(timeline, localMs),
|
||||
shape,
|
||||
phraseKind: currentPhraseKind(timeline, localMs),
|
||||
stressed: isStressPulseActive(coSpeechPlan, localMs),
|
||||
});
|
||||
|
||||
prevLocalMs = localMs;
|
||||
rafId = requestAnimationFrame(step);
|
||||
};
|
||||
rafId = requestAnimationFrame(step);
|
||||
|
||||
return { stop, cues: perf.cues, unmatched };
|
||||
}
|
||||
106
apps/web/src/components/avatar/engine/speechEnvelope.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 발화 포락선 — 결정문 §5.4 발화층 "타이밍(오디오가 있으면 선분석)".
|
||||
AudioBuffer 등 DOM 타입에 의존하지 않는 순수 함수만 둔다(테스트 가능해야 한다).
|
||||
호출부(Lab·Session)가 AudioBuffer에서 채널 Float32Array를 꺼내 넘긴다.
|
||||
===================================================================== */
|
||||
|
||||
export interface SpeechEnvelope {
|
||||
/** RMS 표본 간격(ms). 결정문 §5.4는 10ms. */
|
||||
hopMs: number;
|
||||
/** hop마다 하나씩, 모노 합성 RMS. */
|
||||
rms: Float32Array;
|
||||
/** 진폭 조절식(§5.4 표본·평활)에 쓰는 90퍼센타일. */
|
||||
p90: number;
|
||||
/** 유성 구간 임계값(§5.4 타이밍)에 쓰는 95퍼센타일. */
|
||||
p95: number;
|
||||
}
|
||||
|
||||
export interface VoicedSegment {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
function percentile(sortedAscending: ArrayLike<number>, p: number): number {
|
||||
const n = sortedAscending.length;
|
||||
if (n === 0) return 0;
|
||||
const idx = Math.min(n - 1, Math.max(0, Math.ceil(p * n) - 1));
|
||||
return sortedAscending[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* 오디오 채널 데이터(모노 Float32Array 1개 또는 채널별 배열)에서 10ms 간격 RMS 포락선을 만든다.
|
||||
* 다채널이면 표본마다 채널 평균으로 모노 합성한다(결정문 §5.4 "AudioBuffer 모노 합성").
|
||||
*/
|
||||
export function computeEnvelope(
|
||||
channelData: Float32Array | Float32Array[],
|
||||
sampleRate: number,
|
||||
hopMs = 10,
|
||||
): SpeechEnvelope {
|
||||
const channels = Array.isArray(channelData) ? channelData : [channelData];
|
||||
const length = channels[0]?.length ?? 0;
|
||||
const hopSamples = Math.max(1, Math.round((hopMs / 1000) * sampleRate));
|
||||
const hopCount = Math.max(1, Math.ceil(length / hopSamples));
|
||||
const rms = new Float32Array(hopCount);
|
||||
|
||||
for (let h = 0; h < hopCount; h++) {
|
||||
const start = h * hopSamples;
|
||||
const end = Math.min(length, start + hopSamples);
|
||||
let sumSq = 0;
|
||||
let n = 0;
|
||||
for (let i = start; i < end; i++) {
|
||||
let mono = 0;
|
||||
for (const ch of channels) mono += ch[i] ?? 0;
|
||||
mono /= channels.length;
|
||||
sumSq += mono * mono;
|
||||
n++;
|
||||
}
|
||||
rms[h] = n > 0 ? Math.sqrt(sumSq / n) : 0;
|
||||
}
|
||||
|
||||
const sorted = Array.from(rms).sort((a, b) => a - b);
|
||||
return { hopMs, rms, p90: percentile(sorted, 0.9), p95: percentile(sorted, 0.95) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 유성 구간 검출(결정문 §5.4): 포락선 > max(0.02, 0.12·P95)인 구간.
|
||||
* 120ms 미만 틈은 메우고, 60ms 미만 구간은 버린다.
|
||||
*/
|
||||
export function detectVoicedSegments(envelope: SpeechEnvelope): VoicedSegment[] {
|
||||
const { hopMs, rms, p95 } = envelope;
|
||||
const threshold = Math.max(0.02, 0.12 * p95);
|
||||
|
||||
const raw: VoicedSegment[] = [];
|
||||
let curStart = -1;
|
||||
for (let i = 0; i < rms.length; i++) {
|
||||
const above = rms[i] > threshold;
|
||||
if (above && curStart < 0) curStart = i;
|
||||
if (!above && curStart >= 0) {
|
||||
raw.push({ startMs: curStart * hopMs, endMs: i * hopMs });
|
||||
curStart = -1;
|
||||
}
|
||||
}
|
||||
if (curStart >= 0) raw.push({ startMs: curStart * hopMs, endMs: rms.length * hopMs });
|
||||
|
||||
const filled: VoicedSegment[] = [];
|
||||
for (const seg of raw) {
|
||||
const prev = filled[filled.length - 1];
|
||||
if (prev && seg.startMs - prev.endMs < 120) {
|
||||
prev.endMs = seg.endMs;
|
||||
} else {
|
||||
filled.push({ ...seg });
|
||||
}
|
||||
}
|
||||
|
||||
return filled.filter((seg) => seg.endMs - seg.startMs >= 60);
|
||||
}
|
||||
|
||||
/** tMs 시점의 포락선 값(선형 보간). */
|
||||
export function sampleEnvelope(envelope: SpeechEnvelope, tMs: number): number {
|
||||
const { hopMs, rms } = envelope;
|
||||
if (rms.length === 0) return 0;
|
||||
const idx = tMs / hopMs;
|
||||
const i0 = Math.max(0, Math.min(rms.length - 1, Math.floor(idx)));
|
||||
const i1 = Math.min(rms.length - 1, i0 + 1);
|
||||
const frac = idx - i0;
|
||||
return rms[i0] + (rms[i1] - rms[i0]) * frac;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 괄호 지문 어휘집 — 결정문 §7.3 정본.
|
||||
근거: 실제 LLM 발화 표본(scratch/jev, P1 40턴)·golden 합성 대화(data/golden/P4~P7)·
|
||||
캐릭터 카드 verbal_tics. 괄호는 지문 외 용도(인용·메모)로도 쓰이므로
|
||||
어휘집에 맞는 괄호만 반응으로 바꾸고 나머지는 무시한다.
|
||||
규칙은 위에서부터 검사해 처음 맞는 것 하나만 쓴다(구체 → 일반 순서 유지).
|
||||
어휘 추가·순서 변경은 설계 결정이다.
|
||||
===================================================================== */
|
||||
|
||||
import type { ReactionClipId } from "./clipCatalog";
|
||||
|
||||
export interface StageDirectionRule {
|
||||
/** 괄호 안쪽 텍스트(앞뒤 공백 제거)에 대한 패턴. */
|
||||
pattern: RegExp;
|
||||
clip: ReactionClipId;
|
||||
/** 클립 delta 배율(0..1). 생략하면 1. */
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
/** 괄호 안쪽이 이 길이를 넘으면 지문이 아닌 것으로 보고 무시한다. */
|
||||
export const STAGE_DIRECTION_MAX_LENGTH = 16;
|
||||
|
||||
export const STAGE_DIRECTION_RULES: readonly StageDirectionRule[] = [
|
||||
/* 눈물·울음 */
|
||||
{ pattern: /눈물|울먹|글썽|훌쩍|목이\s*메/, clip: "tear_up" },
|
||||
|
||||
/* 한숨·호흡 */
|
||||
{ pattern: /(옅은|작은|짧은)\s*한숨/, clip: "sigh", weight: 0.6 },
|
||||
{ pattern: /한숨/, clip: "sigh" },
|
||||
{ pattern: /심호흡|숨을\s*(고르|크게|깊이)|크게\s*숨/, clip: "deep_breath" },
|
||||
{ pattern: /침을\s*삼|마른침|꿀꺽/, clip: "swallow" },
|
||||
|
||||
/* 침묵·멈춤·주저 */
|
||||
{ pattern: /침묵/, clip: "silence_hold" },
|
||||
{ pattern: /멈칫|굳어|얼어/, clip: "freeze", weight: 0.7 },
|
||||
{ pattern: /(잠시|잠깐)?\s*멈춤|말끝을?\s*흐리/, clip: "look_down", weight: 0.7 },
|
||||
{ pattern: /머뭇|망설/, clip: "lip_press", weight: 0.6 },
|
||||
|
||||
/* 웃음 */
|
||||
{ pattern: /피식|코웃음|쓴웃음|헛웃음|비웃/, clip: "scoff" },
|
||||
{ pattern: /억지\s*(로\s*)?웃|억지\s*미소/, clip: "forced_smile" },
|
||||
{ pattern: /(어색|긴장|멋쩍|머쓱)[한은]?\s*웃음|웃음/, clip: "nervous_laugh" },
|
||||
|
||||
/* 고개 */
|
||||
{ pattern: /(크게|깊이|깊게)\s*끄덕/, clip: "nod_deep" },
|
||||
{ pattern: /끄덕/, clip: "nod_small" },
|
||||
{ pattern: /고개를?\s*(젓|저어|흔들)|도리도리/, clip: "head_shake" },
|
||||
{ pattern: /고개를?\s*(숙|떨|푹)|고개\s*떨굼/, clip: "head_drop" },
|
||||
{ pattern: /고개를?\s*(살짝\s*)?돌/, clip: "look_away_side" },
|
||||
|
||||
/* 시선 */
|
||||
{ pattern: /시선을?\s*(피|돌)|눈을?\s*(피|돌)|딴\s*곳|창밖/, clip: "look_away_side" },
|
||||
{ pattern: /시선을?\s*(내리|아래)|바닥을?\s*(보|내려)|내려다/, clip: "look_down" },
|
||||
{ pattern: /힐끗|흘끗|쳐다|눈을?\s*맞/, clip: "glance_up" },
|
||||
{ pattern: /눈을?\s*굴리/, clip: "eye_roll_small" },
|
||||
{ pattern: /눈을?\s*(여러\s*번\s*)?깜빡/, clip: "blink_flurry" },
|
||||
|
||||
/* 몸 */
|
||||
{ pattern: /움찔|흠칫|놀라/, clip: "flinch" },
|
||||
{ pattern: /으쓱/, clip: "shrug" },
|
||||
{ pattern: /뒤로\s*기대|등을?\s*기대|몸을?\s*뒤로|팔짱/, clip: "lean_back" },
|
||||
{ pattern: /몸을?\s*앞으로|다가앉|앞으로\s*기울/, clip: "lean_forward" },
|
||||
{ pattern: /만지작|꼼지락|안절부절|다리를?\s*떨|손톱/, clip: "fidget_sway" },
|
||||
|
||||
/* 입 */
|
||||
{ pattern: /입술을?\s*깨물|입술\s*깨묾/, clip: "lip_bite" },
|
||||
{ pattern: /이를?\s*악물|주먹을?\s*쥐/, clip: "jaw_clench" },
|
||||
{ pattern: /입술을?\s*(꾹\s*)?다물|입을?\s*(꾹\s*)?다물/, clip: "lip_press" },
|
||||
];
|
||||
210
apps/web/src/components/avatar/v3/ClientAvatarV3.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/* =====================================================================
|
||||
ClientAvatarV3 — 리노컷 리그가 있는 페르소나를 위한 v3 그림 영역 래퍼.
|
||||
결정문 §8.5 "세션 연결". ClientAvatar가 그림 영역만 이 컴포넌트로 바꿔
|
||||
그린다(루트 data-*·캡션·메타는 ClientAvatar가 그대로 유지한다).
|
||||
|
||||
엔진은 이 컴포넌트가 소유한다(persona.code로 demeanor·시드를 만든다).
|
||||
시계는 모듈 수준 상수 함수(engineNowMs)를 LinocutAvatar에 안정 참조로
|
||||
넘긴다 — 인라인 화살표를 넘기면 리렌더마다 rAF가 다시 시작된다.
|
||||
크롭(bust/face)은 이 래퍼의 실측 폭(ResizeObserver)으로 정한다 —
|
||||
session.css의 !important 규칙이 실제 렌더 크기를 덮어쓰기 때문에 size
|
||||
prop은 쓰지 않는다(§8.5).
|
||||
===================================================================== */
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AvatarExpression, AvatarState } from "../persona";
|
||||
import { createAvatarEngine } from "../engine/engine";
|
||||
import { demeanorFor } from "../engine/demeanorDefaults";
|
||||
import { hashString } from "../engine/rng";
|
||||
import { startSpeech, type SpeechDriverAudio } from "../engine/speechDriver";
|
||||
import type { VisemeId } from "../engine/lipsync";
|
||||
import LinocutAvatar from "./LinocutAvatar";
|
||||
import { observeWidth } from "./observeWidth";
|
||||
import type { LinocutRig, RigCrop } from "./linocutRig";
|
||||
import "./client-avatar-v3.css";
|
||||
|
||||
/** ClientAvatar의 speech? prop 타입 — ClientAvatar 모듈에서 재노출한다. */
|
||||
export interface AvatarSpeech {
|
||||
id: string;
|
||||
/** 괄호 지문을 포함한 답변 원문. */
|
||||
text: string;
|
||||
/** 있으면 오디오 선분석 경로. startAt은 source.start(when)의 when(초, context 시계). */
|
||||
audio?: SpeechDriverAudio;
|
||||
}
|
||||
|
||||
export interface ClientAvatarV3Props {
|
||||
rig: LinocutRig;
|
||||
code: string | null | undefined;
|
||||
state: AvatarState;
|
||||
affect: AvatarExpression;
|
||||
/** 호출부가 openness ?? rapport ?? 0.5 로 보정해 넘긴다. */
|
||||
openness: number;
|
||||
/** 호출부가 surfaceIntensity ?? 0.5 로 보정해 넘긴다. */
|
||||
surfaceIntensity: number;
|
||||
speech?: AvatarSpeech | null;
|
||||
/** false면 rAF를 멈추고 정지한 한 프레임만 그린다(시작 전 화면·썸네일). */
|
||||
running: boolean;
|
||||
reducedMotion: boolean;
|
||||
className?: string;
|
||||
onLoadStateChange?: (state: "loading" | "ready" | "error") => void;
|
||||
}
|
||||
|
||||
const CROP_BUST_MIN_WIDTH = 120;
|
||||
|
||||
/** 엔진 시계는 performance.now()다. 안정 참조로 LinocutAvatar의 nowMs에 넘긴다
|
||||
(인라인 화살표를 넘기면 리렌더마다 rAF가 다시 시작된다, §8.5). */
|
||||
function engineNowMs(): number {
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
export default function ClientAvatarV3({
|
||||
rig,
|
||||
code,
|
||||
state,
|
||||
affect,
|
||||
openness,
|
||||
surfaceIntensity,
|
||||
speech = null,
|
||||
running,
|
||||
reducedMotion,
|
||||
className,
|
||||
onLoadStateChange,
|
||||
}: ClientAvatarV3Props) {
|
||||
const demeanor = useMemo(() => demeanorFor(code), [code]);
|
||||
const seed = useMemo(() => hashString(code ?? ""), [code]);
|
||||
const engine = useMemo(
|
||||
() => createAvatarEngine({ demeanor, seed, reducedMotion }),
|
||||
[demeanor, seed, reducedMotion],
|
||||
);
|
||||
|
||||
const stateRef = useRef(state);
|
||||
const speakingRef = useRef(false);
|
||||
|
||||
/* 엔진이 바뀌면(페르소나·reduced motion 전환) 지금 값으로 한 번에 다시 맞춘다
|
||||
(AvatarLab.tsx의 같은 패턴). */
|
||||
useEffect(() => {
|
||||
engine.setState(speakingRef.current ? "speaking" : state, engineNowMs());
|
||||
engine.setOpenness(openness);
|
||||
engine.setSurface(affect, surfaceIntensity);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [engine]);
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
if (!speakingRef.current) engine.setState(state, engineNowMs());
|
||||
}, [engine, state]);
|
||||
|
||||
useEffect(() => {
|
||||
engine.setOpenness(openness);
|
||||
}, [engine, openness]);
|
||||
|
||||
useEffect(() => {
|
||||
engine.setSurface(affect, surfaceIntensity);
|
||||
}, [engine, affect, surfaceIntensity]);
|
||||
|
||||
/* 래퍼 박스 루트 — 크롭 판정(ResizeObserver)과 발화 data-* 속성 쓰기가 함께 쓴다. */
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
/* 크롭(§8.5): 래퍼 박스의 실측 폭. 120px 이상이면 bust, 미만이면 face다. */
|
||||
const [crop, setCrop] = useState<RigCrop>("face");
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
return observeWidth(el, (widthPx) => setCrop(widthPx >= CROP_BUST_MIN_WIDTH ? "bust" : "face"));
|
||||
}, []);
|
||||
|
||||
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
|
||||
function handleLoadStateChange(next: "loading" | "ready" | "error"): void {
|
||||
setLoadState(next);
|
||||
onLoadStateChange?.(next);
|
||||
}
|
||||
|
||||
/* 발화(§8.5·§5.4·§5.5): id가 바뀌면 이전 발화를 멈추고 새 발화를 시작한다. 발화가
|
||||
진행 중이면 엔진 상태를 "speaking"으로 유지하고, 끝나면 그때의 prop state로
|
||||
되돌린다. 프레임마다 바뀌는 data-viseme은 React state가 아니라 ref로 쓴다. */
|
||||
const speechHandleRef = useRef<ReturnType<typeof startSpeech> | null>(null);
|
||||
const activeSpeechIdRef = useRef<string | null>(null);
|
||||
|
||||
function writeSpeechAttrs(source: "audio" | "text" | null, viseme: VisemeId): void {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
if (source) {
|
||||
el.setAttribute("data-speech-source", source);
|
||||
el.setAttribute("data-viseme", viseme);
|
||||
} else {
|
||||
el.removeAttribute("data-speech-source");
|
||||
el.setAttribute("data-viseme", "X");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!speech) {
|
||||
if (activeSpeechIdRef.current !== null) {
|
||||
speechHandleRef.current?.stop();
|
||||
speechHandleRef.current = null;
|
||||
activeSpeechIdRef.current = null;
|
||||
speakingRef.current = false;
|
||||
engine.setState(stateRef.current, engineNowMs());
|
||||
writeSpeechAttrs(null, "X");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (speech.id === activeSpeechIdRef.current) return;
|
||||
|
||||
speechHandleRef.current?.stop();
|
||||
activeSpeechIdRef.current = speech.id;
|
||||
speakingRef.current = true;
|
||||
engine.setState("speaking", engineNowMs());
|
||||
const source: "audio" | "text" = speech.audio ? "audio" : "text";
|
||||
writeSpeechAttrs(source, "X");
|
||||
|
||||
speechHandleRef.current = startSpeech({
|
||||
engine,
|
||||
text: speech.text,
|
||||
expression: affect,
|
||||
intensity: surfaceIntensity,
|
||||
openness,
|
||||
seed,
|
||||
speech: demeanor.speech,
|
||||
now: engineNowMs,
|
||||
audio: speech.audio,
|
||||
onFrame: ({ viseme }) => writeSpeechAttrs(source, viseme),
|
||||
onEnd: () => {
|
||||
speakingRef.current = false;
|
||||
speechHandleRef.current = null;
|
||||
activeSpeechIdRef.current = null;
|
||||
engine.setState(stateRef.current, engineNowMs());
|
||||
writeSpeechAttrs(null, "X");
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [speech]);
|
||||
|
||||
/* 언마운트 시 발화 rAF를 해제한다(페르소나 전환 등으로 이 래퍼 자체가 사라질 때). */
|
||||
useEffect(() => {
|
||||
return () => speechHandleRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
const transitionMs = reducedMotion ? 0 : 300;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={"vg-avatar-v3" + (className ? " " + className : "")}
|
||||
data-avatar-renderer="linocut"
|
||||
data-load-state={loadState}
|
||||
data-viseme="X"
|
||||
style={{ opacity: loadState === "ready" ? 1 : 0, transitionDuration: `${transitionMs}ms` }}
|
||||
>
|
||||
<LinocutAvatar
|
||||
rig={rig}
|
||||
engine={engine}
|
||||
running={running}
|
||||
nowMs={engineNowMs}
|
||||
crop={crop}
|
||||
backdropExpression={affect}
|
||||
onLoadStateChange={handleLoadStateChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
apps/web/src/components/avatar/v3/DebugFace.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/* =====================================================================
|
||||
DebugFace — 아바타 v3 엔진 채널 33개를 전부 눈으로 검증하는 선 기반 SVG 리그.
|
||||
아트 품질이 목적이 아니라 채널→시각 매핑 확인용이다(결정문 §9 1단계).
|
||||
매 프레임 React state를 갱신하지 않고 ref로 SVG 속성을 직접 쓴다.
|
||||
===================================================================== */
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { AvatarEngine } from "../engine/engine";
|
||||
import type { ChannelVector } from "../engine/channels";
|
||||
import "./debug-face.css";
|
||||
|
||||
export interface DebugFaceProps {
|
||||
engine: AvatarEngine;
|
||||
running: boolean;
|
||||
nowMs?: () => number;
|
||||
}
|
||||
|
||||
type Refs = Record<string, SVGGraphicsElement | null>;
|
||||
|
||||
const CX = 130;
|
||||
const HEAD_CY = 150;
|
||||
|
||||
function eyePathD(cx: number, cy: number, openness: number, smile: number, lidTension: number): string {
|
||||
const rx = 20;
|
||||
const ry = 14;
|
||||
const o = Math.max(0, Math.min(1.3, openness));
|
||||
const tensionAdj = 1 - lidTension * 0.4;
|
||||
const upperY = cy - ry * 1.05 * o * tensionAdj;
|
||||
const lowerY = cy + ry * 0.5 * o * (1 - Math.max(0, smile) * 0.7) * tensionAdj;
|
||||
return `M ${cx - rx} ${cy} Q ${cx} ${upperY} ${cx + rx} ${cy} Q ${cx} ${lowerY} ${cx - rx} ${cy} Z`;
|
||||
}
|
||||
|
||||
function browPathD(innerX: number, outerX: number, baseY: number, y: number, angle: number, x: number, form: number): string {
|
||||
const innerY = baseY - y * 18 - angle * 14;
|
||||
const outerY = baseY - y * 18 + angle * 8;
|
||||
const innerXAdj = innerX - x * 6;
|
||||
const midX = (innerXAdj + outerX) / 2;
|
||||
const midY = (innerY + outerY) / 2 - form * 10;
|
||||
return `M ${outerX} ${outerY} Q ${midX} ${midY} ${innerXAdj} ${innerY}`;
|
||||
}
|
||||
|
||||
function mouthPathD(v: ChannelVector): string {
|
||||
const cy = 192;
|
||||
/* mouthRound(§4 — 2026-09-30 립싱크 설계 채널): 오므린 입("ㅗ·ㅜ")을 입 폭을 줄이고
|
||||
세로로 둥글려 간단히 반영한다. 정밀한 리그 규칙(§8.4)은 LinocutAvatar가 맡는다. */
|
||||
const round = Math.max(0, Math.min(1, v.mouthRound));
|
||||
const halfWidth = Math.max(6, 26 + v.mouthStretch * 16 - round * 14);
|
||||
const leftX = CX - halfWidth;
|
||||
const rightX = CX + halfWidth;
|
||||
const cornerLift = v.mouthForm * 18;
|
||||
const asym = v.mouthAsym * 12;
|
||||
const leftCornerY = cy - cornerLift - asym;
|
||||
const rightCornerY = cy - cornerLift + asym;
|
||||
const restGap = Math.max(1.5, 5 - v.mouthPress * 3.2) + round * 6;
|
||||
const openGap = v.mouthOpen * 26;
|
||||
const upperCtrlY = cy - restGap - openGap * 0.5;
|
||||
const lowerCtrlY = cy + restGap + openGap;
|
||||
return `M ${leftX} ${leftCornerY} Q ${CX} ${upperCtrlY} ${rightX} ${rightCornerY} Q ${CX} ${lowerCtrlY} ${leftX} ${leftCornerY} Z`;
|
||||
}
|
||||
|
||||
function renderFrame(refs: Refs, v: ChannelVector): void {
|
||||
const body = refs.body;
|
||||
if (body) {
|
||||
const scaleY = 1 + v.bodyLean * 0.04;
|
||||
const translateX = v.bodyYaw * 6;
|
||||
body.setAttribute(
|
||||
"transform",
|
||||
`translate(${translateX} ${-v.bodyLean * 6}) rotate(${v.bodyRoll * 6} ${CX} 300) scale(1 ${scaleY})`,
|
||||
);
|
||||
}
|
||||
|
||||
const shoulder = refs.shoulder;
|
||||
if (shoulder) shoulder.setAttribute("transform", `translate(0 ${-v.shoulderRaise * 12})`);
|
||||
|
||||
const head = refs.head;
|
||||
if (head) {
|
||||
const translateX = v.headYaw * 14;
|
||||
const translateY = -v.headPitch * 14;
|
||||
head.setAttribute("transform", `translate(${translateX} ${translateY}) rotate(${v.headRoll * 10} ${CX} ${HEAD_CY})`);
|
||||
}
|
||||
|
||||
const face = refs.face;
|
||||
if (face) {
|
||||
const squish = 1 - Math.abs(v.headYaw) * 0.15;
|
||||
face.setAttribute("transform", `translate(${v.headYaw * 4} 0) scale(${squish} 1)`);
|
||||
}
|
||||
|
||||
const eyeLeft = refs["eye-left"] as SVGPathElement | null;
|
||||
if (eyeLeft) eyeLeft.setAttribute("d", eyePathD(96, 140, v.eyeOpenL, v.eyeSmileL, v.lidTension));
|
||||
const eyeRight = refs["eye-right"] as SVGPathElement | null;
|
||||
if (eyeRight) eyeRight.setAttribute("d", eyePathD(164, 140, v.eyeOpenR, v.eyeSmileR, v.lidTension));
|
||||
|
||||
const irisLeft = refs["iris-left"];
|
||||
if (irisLeft) irisLeft.setAttribute("transform", `translate(${v.gazeX * 8} ${v.gazeY * 6})`);
|
||||
const irisRight = refs["iris-right"];
|
||||
if (irisRight) irisRight.setAttribute("transform", `translate(${v.gazeX * 8} ${v.gazeY * 6})`);
|
||||
|
||||
const pupilLeft = refs["pupil-left"] as SVGCircleElement | null;
|
||||
if (pupilLeft) pupilLeft.setAttribute("r", String(Math.max(1, 3 * (1 + v.pupilSize * 0.5))));
|
||||
const pupilRight = refs["pupil-right"] as SVGCircleElement | null;
|
||||
if (pupilRight) pupilRight.setAttribute("r", String(Math.max(1, 3 * (1 + v.pupilSize * 0.5))));
|
||||
|
||||
const browLeft = refs["brow-left"] as SVGPathElement | null;
|
||||
if (browLeft) browLeft.setAttribute("d", browPathD(112, 72, 110, v.browLY, v.browLAngle, v.browLX, v.browForm));
|
||||
const browRight = refs["brow-right"] as SVGPathElement | null;
|
||||
if (browRight) browRight.setAttribute("d", browPathD(148, 188, 110, v.browRY, v.browRAngle, -v.browRX, v.browForm));
|
||||
|
||||
const mouth = refs.mouth as SVGPathElement | null;
|
||||
if (mouth) mouth.setAttribute("d", mouthPathD(v));
|
||||
|
||||
const chin = refs.chin;
|
||||
if (chin) chin.setAttribute("opacity", String(Math.max(0, Math.min(1, v.chinRaise))));
|
||||
|
||||
const cheekLeft = refs["cheek-left"];
|
||||
if (cheekLeft) cheekLeft.setAttribute("opacity", String(Math.max(0, Math.min(1, v.cheek)) * 0.85));
|
||||
const cheekRight = refs["cheek-right"];
|
||||
if (cheekRight) cheekRight.setAttribute("opacity", String(Math.max(0, Math.min(1, v.cheek)) * 0.85));
|
||||
|
||||
const pallor = refs.pallor;
|
||||
if (pallor) pallor.setAttribute("opacity", String(Math.max(0, Math.min(1, v.pallor)) * 0.55));
|
||||
|
||||
const tearLeft = refs["tear-left"];
|
||||
if (tearLeft) {
|
||||
const pool = Math.max(0, Math.min(1, v.tear / 0.3));
|
||||
const flow = Math.max(0, (v.tear - 0.3) / 0.7);
|
||||
tearLeft.setAttribute("opacity", String(pool));
|
||||
tearLeft.setAttribute("transform", `translate(0 0) scale(${1 + flow * 1.6})`);
|
||||
}
|
||||
const tearRight = refs["tear-right"];
|
||||
if (tearRight) {
|
||||
const pool = Math.max(0, Math.min(1, v.tear / 0.3));
|
||||
const flow = Math.max(0, (v.tear - 0.3) / 0.7);
|
||||
tearRight.setAttribute("opacity", String(pool));
|
||||
tearRight.setAttribute("transform", `translate(0 0) scale(${1 + flow * 1.6})`);
|
||||
}
|
||||
|
||||
const sweat = refs.sweat;
|
||||
if (sweat) sweat.setAttribute("opacity", String(Math.max(0, Math.min(1, v.sweat))));
|
||||
|
||||
const breath = refs.breath;
|
||||
if (breath) breath.setAttribute("transform", `scale(${0.85 + v.breath * 0.3})`);
|
||||
}
|
||||
|
||||
function setRef(refs: Refs, name: string) {
|
||||
return (el: SVGGraphicsElement | null) => {
|
||||
refs[name] = el;
|
||||
};
|
||||
}
|
||||
|
||||
export default function DebugFace({ engine, running, nowMs }: DebugFaceProps) {
|
||||
const refs = useRef<Refs>({});
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const clock = nowMs ?? (() => performance.now());
|
||||
const loop = () => {
|
||||
const v = engine.evaluate(clock());
|
||||
renderFrame(refs.current, v);
|
||||
rafRef.current = requestAnimationFrame(loop);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [engine, running, nowMs]);
|
||||
|
||||
return (
|
||||
<svg className="df" viewBox="0 0 260 340" role="img" aria-label="아바타 채널 디버그 리그" data-debug-face="true">
|
||||
<g data-part="body" ref={setRef(refs.current, "body")}>
|
||||
<rect x="70" y="230" width="120" height="90" rx="26" className="df__torso" data-part="body-torso" />
|
||||
<g data-part="shoulder" ref={setRef(refs.current, "shoulder")}>
|
||||
<rect x="52" y="222" width="156" height="26" rx="13" className="df__shoulder" />
|
||||
</g>
|
||||
|
||||
<g data-part="head" ref={setRef(refs.current, "head")}>
|
||||
<circle cx={CX} cy={HEAD_CY} r="72" className="df__skull" />
|
||||
|
||||
<g data-part="face" ref={setRef(refs.current, "face")}>
|
||||
<path ref={setRef(refs.current, "brow-left")} data-part="brow-left" className="df__brow" />
|
||||
<path ref={setRef(refs.current, "brow-right")} data-part="brow-right" className="df__brow" />
|
||||
|
||||
<g data-part="eye-group-left">
|
||||
<path ref={setRef(refs.current, "eye-left")} data-part="eye-left" className="df__eye" />
|
||||
<g ref={setRef(refs.current, "iris-left")}>
|
||||
<circle cx="96" cy="140" r="7" className="df__iris" />
|
||||
<circle ref={setRef(refs.current, "pupil-left")} cx="96" cy="140" r="3" className="df__pupil" />
|
||||
</g>
|
||||
<ellipse ref={setRef(refs.current, "tear-left")} data-part="tear-left" cx="96" cy="156" rx="3.5" ry="5" className="df__tear" />
|
||||
</g>
|
||||
|
||||
<g data-part="eye-group-right">
|
||||
<path ref={setRef(refs.current, "eye-right")} data-part="eye-right" className="df__eye" />
|
||||
<g ref={setRef(refs.current, "iris-right")}>
|
||||
<circle cx="164" cy="140" r="7" className="df__iris" />
|
||||
<circle ref={setRef(refs.current, "pupil-right")} cx="164" cy="140" r="3" className="df__pupil" />
|
||||
</g>
|
||||
<ellipse ref={setRef(refs.current, "tear-right")} data-part="tear-right" cx="164" cy="156" rx="3.5" ry="5" className="df__tear" />
|
||||
</g>
|
||||
|
||||
<circle ref={setRef(refs.current, "cheek-left")} data-part="cheek-left" cx="86" cy="172" r="14" className="df__cheek" />
|
||||
<circle ref={setRef(refs.current, "cheek-right")} data-part="cheek-right" cx="174" cy="172" r="14" className="df__cheek" />
|
||||
|
||||
<path ref={setRef(refs.current, "sweat")} data-part="sweat" d="M 182 108 q -4 8 0 14 q 4 -6 0 -14 Z" className="df__sweat" />
|
||||
|
||||
<path ref={setRef(refs.current, "mouth")} data-part="mouth" className="df__mouth" />
|
||||
<line ref={setRef(refs.current, "chin")} data-part="chin" x1="118" y1="216" x2="142" y2="216" className="df__chin" />
|
||||
|
||||
<ellipse ref={setRef(refs.current, "pallor")} data-part="pallor" cx={CX} cy={HEAD_CY} rx="70" ry="70" className="df__pallor" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<rect ref={setRef(refs.current, "breath")} data-part="breath" x="120" y="326" width="20" height="8" rx="4" className="df__breath" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
1088
apps/web/src/components/avatar/v3/LinocutAvatar.tsx
Normal file
100
apps/web/src/components/avatar/v3/LinocutMotif.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/* =====================================================================
|
||||
LinocutMotif — 결정문 §8.2 상징 모티프층·§8.4 배경 색면·모티프(2단계-B-1b
|
||||
판정으로 벡터→래스터 스프라이트 전환). 아바타 옆 작은 층으로 둔다.
|
||||
immersive는 개방도를 반영하지 않는다(budOpenness=null). 정적 SVG + CSS
|
||||
크로스페이드/keyframes로 구성해 React state를 프레임마다 갱신하지 않는다.
|
||||
===================================================================== */
|
||||
|
||||
import type { BackdropGroup, LinocutRig } from "./linocutRig";
|
||||
import "./linocut-motif.css";
|
||||
|
||||
export type MotifFeedbackMode = "immersive" | "ambient" | "coached";
|
||||
|
||||
export interface LinocutMotifProps {
|
||||
rig: LinocutRig;
|
||||
group: BackdropGroup;
|
||||
/** null이면 immersive 고정(0.25). ambient·coached는 실제 개방도 값. */
|
||||
budOpenness: number | null;
|
||||
/** 고개 숙임 조건이 모드마다 달라 필요하다. */
|
||||
feedbackMode: MotifFeedbackMode;
|
||||
reducedMotion: boolean;
|
||||
/** px 너비. 기본 96. */
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const WEATHER_GROUPS: readonly BackdropGroup[] = ["positive", "negative", "defensive", "cognitive", "energy"];
|
||||
|
||||
/** 개방도 o(0..1)를 닫힘·반쯤·활짝 가중치로 섞는다. */
|
||||
function budWeights(o: number): { closed: number; half: number; open: number } {
|
||||
if (o <= 0.5) return { closed: 1 - 2 * o, half: 2 * o, open: 0 };
|
||||
return { closed: 0, half: 2 - 2 * o, open: 2 * o - 1 };
|
||||
}
|
||||
|
||||
export default function LinocutMotif({
|
||||
rig,
|
||||
group,
|
||||
budOpenness,
|
||||
feedbackMode,
|
||||
reducedMotion,
|
||||
size = 96,
|
||||
className,
|
||||
}: LinocutMotifProps) {
|
||||
const motif = rig.motif;
|
||||
const openness = budOpenness ?? 0.25;
|
||||
const budAttr = budOpenness === null ? "static" : openness.toFixed(2);
|
||||
|
||||
/* immersive: negative면 숙인다. ambient·coached: negative이면서 개방도<0.35일 때만
|
||||
숙인다(우울한 P1은 negative가 잦아, 조건 없이 숙이면 개방도 신호가 가려진다). */
|
||||
const droop = feedbackMode === "immersive" ? group === "negative" : group === "negative" && openness < 0.35;
|
||||
const pose: "upright" | "droop" = droop ? "droop" : "upright";
|
||||
|
||||
if (!motif) return null;
|
||||
|
||||
const weights = budWeights(openness);
|
||||
const weatherCanvas = motif.weather.canvas;
|
||||
const budCanvas = motif.bud.canvas;
|
||||
const totalW = Math.max(weatherCanvas.w, budCanvas.w);
|
||||
const totalH = weatherCanvas.h + budCanvas.h;
|
||||
const budX = (totalW - budCanvas.w) / 2;
|
||||
const weatherX = (totalW - weatherCanvas.w) / 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
data-motif="bud-weather"
|
||||
data-motif-weather={group}
|
||||
data-motif-bud={budAttr}
|
||||
data-motif-pose={pose}
|
||||
data-reduced-motion={reducedMotion}
|
||||
className={className ? `linocut-motif ${className}` : "linocut-motif"}
|
||||
width={size}
|
||||
viewBox={`0 0 ${totalW} ${totalH}`}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g className="linocut-motif__weather">
|
||||
{WEATHER_GROUPS.map((g) => (
|
||||
<image
|
||||
key={g}
|
||||
href={motif.weather.sprites[g]}
|
||||
x={weatherX}
|
||||
y={0}
|
||||
width={weatherCanvas.w}
|
||||
height={weatherCanvas.h}
|
||||
className={`linocut-motif__weather-sprite linocut-motif__weather-${g}`}
|
||||
opacity={g === group ? 1 : 0}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
<g className="linocut-motif__bud-upright" transform={`translate(${budX} ${weatherCanvas.h})`}>
|
||||
<image href={motif.bud.closed} width={budCanvas.w} height={budCanvas.h} opacity={weights.closed} />
|
||||
<image href={motif.bud.half} width={budCanvas.w} height={budCanvas.h} opacity={weights.half} />
|
||||
<image href={motif.bud.open} width={budCanvas.w} height={budCanvas.h} opacity={weights.open} />
|
||||
</g>
|
||||
<g className="linocut-motif__bud-droop" transform={`translate(${budX} ${weatherCanvas.h})`}>
|
||||
<image href={motif.bud.droop} width={budCanvas.w} height={budCanvas.h} />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
apps/web/src/components/avatar/v3/client-avatar-v3.css
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/* =====================================================================
|
||||
client-avatar-v3.css — ClientAvatarV3 래퍼 스타일. 결정문 §8.5 "화면".
|
||||
|
||||
원형 오브 안(.vg-avatar__stage, position:relative)에 꽉 차게 올라가
|
||||
기존 SVG와 정확히 같은 자리를 차지한다(inset:0) — session.css의
|
||||
!important 크기 규칙이 스테이지 쪽에 이미 걸려 있어 이 규칙만으로
|
||||
모든 브레이크포인트에서 기존 SVG와 같은 크기가 된다. 원형이 아닌 곳에
|
||||
이 컴포넌트를 쓰면 border-radius는 그대로 두되 바깥에서 inset을
|
||||
덮어써야 한다(지금은 호출부가 모두 원형 오브다).
|
||||
===================================================================== */
|
||||
|
||||
.vg-avatar-v3 {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
transition-property: opacity;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
83
apps/web/src/components/avatar/v3/debug-face.css
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/* =====================================================================
|
||||
DebugFace 스타일 — 아바타 v3 채널 디버그 리그(아트 품질 아님, 도식용).
|
||||
===================================================================== */
|
||||
|
||||
.df {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
aspect-ratio: 260 / 340;
|
||||
background: #f4efe6;
|
||||
border: 1px solid #d8cfbf;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.df__torso {
|
||||
fill: #7c8a92;
|
||||
}
|
||||
|
||||
.df__shoulder {
|
||||
fill: #6b7a82;
|
||||
}
|
||||
|
||||
.df__skull {
|
||||
fill: #f0ddc4;
|
||||
stroke: #cbb494;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.df__brow {
|
||||
fill: none;
|
||||
stroke: #4a3626;
|
||||
stroke-width: 4;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.df__eye {
|
||||
fill: #ffffff;
|
||||
stroke: #4a3626;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.df__iris {
|
||||
fill: #7a5a3c;
|
||||
}
|
||||
|
||||
.df__pupil {
|
||||
fill: #241a12;
|
||||
}
|
||||
|
||||
.df__mouth {
|
||||
fill: #a5473f;
|
||||
stroke: #6c2c26;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.df__chin {
|
||||
stroke: #cbb494;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.df__cheek {
|
||||
fill: #e78a8a;
|
||||
}
|
||||
|
||||
.df__sweat {
|
||||
fill: #bfe0f5;
|
||||
stroke: #8fbede;
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.df__tear {
|
||||
fill: #bfe0f5;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.df__pallor {
|
||||
fill: #d9d9df;
|
||||
}
|
||||
|
||||
.df__breath {
|
||||
fill: #9fb7a8;
|
||||
transform-origin: center;
|
||||
}
|
||||
83
apps/web/src/components/avatar/v3/linocut-avatar.css
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/* =====================================================================
|
||||
linocut-avatar.css — LinocutAvatar 렌더러 스타일. 결정문 §8.4.
|
||||
배경 전환 1.8s·figure 페이드 0.2s는 reduced motion에서 즉시 전환한다.
|
||||
===================================================================== */
|
||||
|
||||
.linocut-avatar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.linocut-avatar__backdrop {
|
||||
transition: fill 1.8s ease;
|
||||
}
|
||||
|
||||
.linocut-avatar__figure {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.linocut-avatar__figure[data-ready="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.linocut-avatar__backdrop {
|
||||
transition: none;
|
||||
}
|
||||
.linocut-avatar__figure {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 결·주름 등 순수 스트로크 요소 — 불투명도는 채널값에 따라 JS가 직접 쓴다. */
|
||||
.linocut-avatar__wrinkle,
|
||||
.linocut-avatar__crease,
|
||||
.linocut-avatar__lower-lid,
|
||||
.linocut-avatar__tear-pool,
|
||||
.linocut-avatar__iris-radial {
|
||||
fill: none;
|
||||
stroke-width: 1.2;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
/* 이마·팔자 주름 — 두께가 변하는 리본(채운 다각형). 불투명도는 JS가 직접 쓴다. */
|
||||
.linocut-avatar__wrinkle-ribbon {
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.linocut-avatar__tear-flow {
|
||||
stroke-width: 1;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* 눈썹 몸체 위로 삐져나온 털 획 — 정적 불투명도(채널 무관). */
|
||||
.linocut-avatar__brow-hair {
|
||||
fill: none;
|
||||
stroke-width: 1.3;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 아랫입술 아래 그늘 초승달 해칭(입체감). */
|
||||
.linocut-avatar__lip-shadow-crescent {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* 벌어졌을 때(g>0.5) 아랫입술 쪽에 보이는 가는 보조 선. */
|
||||
.linocut-avatar__mouth-line-secondary {
|
||||
fill: none;
|
||||
stroke-width: 1;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.linocut-avatar__pallor-color {
|
||||
mix-blend-mode: color;
|
||||
}
|
||||
|
||||
.linocut-avatar__grain {
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
94
apps/web/src/components/avatar/v3/linocut-motif.css
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/* =====================================================================
|
||||
linocut-motif.css — LinocutMotif 스타일(2단계-B-1b 판정으로 래스터
|
||||
스프라이트 전환). 날씨 1.5s 크로스페이드 + 약한 움직임(해 회전, 비구름·
|
||||
안개 가로 흔들림), 봉오리는 개방도 3장 크로스페이드 + 자세(곧음/숙임)
|
||||
순차 페이드. reduced motion(prop 또는 OS 설정)에서는 전부 정지한다.
|
||||
===================================================================== */
|
||||
|
||||
.linocut-motif {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.linocut-motif__weather-sprite {
|
||||
opacity: 0;
|
||||
transition: opacity 1.5s ease;
|
||||
}
|
||||
|
||||
/* 봉오리 개방도 3장(닫힘·반쯤·활짝) — 가중치 크로스페이드. */
|
||||
.linocut-motif__bud-upright image {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* 자세(곧음/숙임) 순차 페이드: 사라지는 쪽 0.6s, 나타나는 쪽 0.4s부터 0.9s까지. */
|
||||
.linocut-motif__bud-upright,
|
||||
.linocut-motif__bud-droop {
|
||||
opacity: 0;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
.linocut-motif[data-motif-pose="upright"] .linocut-motif__bud-upright,
|
||||
.linocut-motif[data-motif-pose="droop"] .linocut-motif__bud-droop {
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s ease 0.4s;
|
||||
}
|
||||
|
||||
@keyframes linocut-motif-sun-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.linocut-motif__weather-positive {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: linocut-motif-sun-spin 60s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes linocut-motif-weather-sway {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(-2.5px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(2.5px);
|
||||
}
|
||||
}
|
||||
|
||||
.linocut-motif__weather-negative {
|
||||
animation: linocut-motif-weather-sway 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.linocut-motif__weather-defensive {
|
||||
animation: linocut-motif-weather-sway 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* energy(초승달·별)·cognitive(옅은 안개결)는 정지. */
|
||||
|
||||
.linocut-motif[data-reduced-motion="true"] .linocut-motif__weather-sprite,
|
||||
.linocut-motif[data-reduced-motion="true"] .linocut-motif__bud-upright,
|
||||
.linocut-motif[data-reduced-motion="true"] .linocut-motif__bud-droop {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.linocut-motif[data-reduced-motion="true"] .linocut-motif__weather-positive,
|
||||
.linocut-motif[data-reduced-motion="true"] .linocut-motif__weather-negative,
|
||||
.linocut-motif[data-reduced-motion="true"] .linocut-motif__weather-defensive {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.linocut-motif__weather-sprite,
|
||||
.linocut-motif__bud-upright,
|
||||
.linocut-motif__bud-droop {
|
||||
transition: none;
|
||||
}
|
||||
.linocut-motif__weather-positive,
|
||||
.linocut-motif__weather-negative,
|
||||
.linocut-motif__weather-defensive {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
1622
apps/web/src/components/avatar/v3/linocutGeometry.ts
Normal file
138
apps/web/src/components/avatar/v3/linocutRig.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/* =====================================================================
|
||||
리노컷 리그 v3 데이터 계약 — 결정문 §8.2. 오케스트레이터 작성(워커 수정 금지).
|
||||
페르소나 리그 데이터(`rigs/*Rig.ts`)는 자산 파이프라인 스크립트가 생성하고,
|
||||
렌더러는 이 타입만 읽는다.
|
||||
좌표는 모두 캔버스 픽셀(원점 좌상단, y 아래로 증가)이다.
|
||||
좌우 표기는 화면 기준이다: Left = 작은 x. 엔진 채널의 L/R(eyeOpenL 등)도
|
||||
화면 기준이며 DebugFace와 같다.
|
||||
===================================================================== */
|
||||
|
||||
export type RigPoint = readonly [number, number];
|
||||
|
||||
/** [x, y, w, h] — 캔버스 좌표 사각형. */
|
||||
export type RigRect = readonly [number, number, number, number];
|
||||
|
||||
export interface RigLayer {
|
||||
/** public 기준 URL(예: `/avatar/v3/p1/head.webp`). */
|
||||
href: string;
|
||||
/** 이미지가 놓이는 캔버스 사각형. 파일 해상도와 무관하게 이 크기로 그린다. */
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface RigEye {
|
||||
inner: RigPoint;
|
||||
outer: RigPoint;
|
||||
upperLidTop: RigPoint;
|
||||
lowerLidBottom: RigPoint;
|
||||
iris: { center: RigPoint; radius: number };
|
||||
}
|
||||
|
||||
/** 눈썹 잉크 띠의 **중심선** 위 세 점(결정문 §8.4). 띠의 윗가장자리·아래 모서리를 찍으면
|
||||
수평에 가까운 원화 눈썹이 사선으로 그려져 중립이 화난 얼굴이 된다(2단계-B 3차 판정). */
|
||||
export interface RigBrow {
|
||||
inner: RigPoint;
|
||||
peak: RigPoint;
|
||||
outer: RigPoint;
|
||||
}
|
||||
|
||||
export interface RigLandmarks {
|
||||
eyeLeft: RigEye;
|
||||
eyeRight: RigEye;
|
||||
browLeft: RigBrow;
|
||||
browRight: RigBrow;
|
||||
noseTip: RigPoint;
|
||||
mouthCornerLeft: RigPoint;
|
||||
mouthCornerRight: RigPoint;
|
||||
/** 윗입술 윗선 중앙. */
|
||||
upperLipTop: RigPoint;
|
||||
/** 아랫입술 아랫선 중앙. */
|
||||
lowerLipBottom: RigPoint;
|
||||
/** 두 입술이 만나는 선의 중앙. */
|
||||
mouthCenter: RigPoint;
|
||||
chinTip: RigPoint;
|
||||
}
|
||||
|
||||
export type RigCrop = "portrait" | "bust" | "face";
|
||||
|
||||
/** 배경 색면·날씨 모티프 그룹 = 겉표정 그룹(`persona.ts` AVATAR_EXPRESSION_LIBRARY group). */
|
||||
export type BackdropGroup = "positive" | "negative" | "defensive" | "cognitive" | "energy";
|
||||
|
||||
export interface RigPalette {
|
||||
ink: string;
|
||||
sclera: string;
|
||||
iris: string;
|
||||
irisRing: string;
|
||||
lipUpper: string;
|
||||
lipLower: string;
|
||||
mouthLine: string;
|
||||
mouthInner: string;
|
||||
teeth: string;
|
||||
blush: string;
|
||||
tear: string;
|
||||
pallor: string;
|
||||
paper: string;
|
||||
motifPetal: string;
|
||||
motifLeaf: string;
|
||||
}
|
||||
|
||||
/** 상징 모티프 스프라이트(결정문 §8.4). 좌표는 각 스프라이트 캔버스 픽셀이다. */
|
||||
export interface RigMotif {
|
||||
bud: {
|
||||
/** 봉오리 스프라이트 공통 캔버스. 4장 모두 줄기 밑동이 base에 맞춰져 있다. */
|
||||
canvas: { w: number; h: number };
|
||||
base: RigPoint;
|
||||
closed: string;
|
||||
half: string;
|
||||
open: string;
|
||||
droop: string;
|
||||
};
|
||||
weather: {
|
||||
canvas: { w: number; h: number };
|
||||
sprites: Record<BackdropGroup, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LinocutRig {
|
||||
schemaVersion: "vignette.avatar.v3.rig.v1";
|
||||
persona: string;
|
||||
canvas: { w: number; h: number };
|
||||
layers: {
|
||||
body: RigLayer;
|
||||
head: RigLayer;
|
||||
hairFront: RigLayer;
|
||||
/** 눈두덩·다크서클·입가 음영과 점. 표정 부위 구멍은 피부로 메워져 있다. */
|
||||
faceDetail?: RigLayer;
|
||||
};
|
||||
/** 종이결 타일(곱하기 합성). size는 캔버스 단위 타일 한 변. */
|
||||
grain?: { href: string; size: number };
|
||||
/** 원화 입술 결 스프라이트(결정문 §8.4 입). 입 선·입꼬리 잉크를 입술 결로 메우고 가장자리를
|
||||
24px 연장했다. 렌더러가 현재 입술 면으로 clip하고 입술 기준점 이동·입 폭 비율만큼 옮기고 늘린다.
|
||||
없으면 입술을 palette 단색으로 채운다. */
|
||||
lipTexture?: {
|
||||
upper: RigLayer;
|
||||
lower: RigLayer;
|
||||
/** 아랫입술 아래 그늘(원화 픽셀). 입 모양을 따라 가로로 줄지 않고 턱 변위만 따른다. */
|
||||
shadow?: RigLayer;
|
||||
};
|
||||
/** 하관 띠 변형용으로 잘라 낸 head·faceDetail 조각(결정문 §8.4). 원본 레이어와 픽셀이 같다. */
|
||||
jaw?: { head: RigLayer; detail: RigLayer };
|
||||
pivots: {
|
||||
/** 고개 회전 중심(목). */
|
||||
neck: RigPoint;
|
||||
/** 몸 기울임·호흡 중심(하단 중앙). */
|
||||
body: RigPoint;
|
||||
/** 얼굴 부위 시차·압축 중심. */
|
||||
face: RigPoint;
|
||||
};
|
||||
crops: Record<RigCrop, RigRect>;
|
||||
/** 얼굴 윤곽 다각형(표정 부위 clip). */
|
||||
faceOval: readonly RigPoint[];
|
||||
landmarks: RigLandmarks;
|
||||
palette: RigPalette;
|
||||
backdrop: Record<BackdropGroup, string>;
|
||||
/** 없으면 모티프를 그리지 않는다. */
|
||||
motif?: RigMotif;
|
||||
}
|
||||
15
apps/web/src/components/avatar/v3/observeWidth.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/** 요소의 렌더 폭(px)을 지금 한 번, 이후 크기가 바뀔 때마다 onWidth로 알린다(프레임마다 아님).
|
||||
폭이 0 이하인 측정(아직 배치 전)은 건너뛴다. 반환 함수로 관찰을 끝낸다. */
|
||||
export function observeWidth(el: Element, onWidth: (widthPx: number) => void): () => void {
|
||||
const report = (widthPx: number) => {
|
||||
if (widthPx > 0) onWidth(widthPx);
|
||||
};
|
||||
report(el.getBoundingClientRect().width);
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
report(entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
16
apps/web/src/components/avatar/v3/rigs/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/* =====================================================================
|
||||
아바타 v3 리그 레지스트리 — 결정문 §8.5 "분기". 페르소나 코드 → 리노컷
|
||||
리그. 지금은 P1만 있고 나머지는 null(기존 SVG로 남는다). 빌드 플래그
|
||||
VITE_AVATAR_V3=0 이면 항상 null(기본은 켜짐).
|
||||
===================================================================== */
|
||||
|
||||
import type { LinocutRig } from "../linocutRig";
|
||||
import { P1_LINOCUT_RIG } from "./p1Rig";
|
||||
|
||||
const LINOCUT_RIGS: Partial<Record<string, LinocutRig>> = { P1: P1_LINOCUT_RIG };
|
||||
|
||||
export function linocutRigFor(code: string | null | undefined): LinocutRig | null {
|
||||
if (import.meta.env.VITE_AVATAR_V3 === "0") return null;
|
||||
if (!code) return null;
|
||||
return LINOCUT_RIGS[code.toUpperCase()] ?? null;
|
||||
}
|
||||
104
apps/web/src/components/avatar/v3/rigs/p1Rig.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/* 생성 파일 — docs/avatar-art/linocut-pipeline/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",
|
||||
},
|
||||
};
|
||||
665
apps/web/src/pages/AvatarLab.tsx
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
/* =====================================================================
|
||||
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 type { Performance, PerformanceCue } from "../components/avatar/engine/performance";
|
||||
import type { PhraseKind, SpeechShape, VisemeId } from "../components/avatar/engine/lipsync";
|
||||
import { startSpeech, type SpeechHandle } from "../components/avatar/engine/speechDriver";
|
||||
import { AVATAR_EXPRESSION_LIBRARY, type AvatarExpression, type AvatarState } from "../components/avatar/persona";
|
||||
import "./avatar-lab.css";
|
||||
|
||||
const PERSONA_OPTIONS: Array<{ code: string; label: string }> = [
|
||||
{ code: "P1", label: "P1 서연" },
|
||||
{ code: "P2", label: "P2 민재" },
|
||||
{ code: "P3", label: "P3 지우" },
|
||||
{ code: "P4", label: "P4 하늘" },
|
||||
{ code: "P5", label: "P5 도윤" },
|
||||
{ code: "P6", label: "P6 하린" },
|
||||
{ code: "P7", label: "P7 도현" },
|
||||
];
|
||||
|
||||
/** 리노컷 리그가 있는 페르소나만. 없는 페르소나는 debug 렌더러를 강제한다. */
|
||||
const LINOCUT_RIGS: Partial<Record<string, LinocutRig>> = { P1: P1_LINOCUT_RIG };
|
||||
|
||||
type RendererMode = "linocut" | "debug";
|
||||
type FeedbackMode = "immersive" | "ambient" | "coached";
|
||||
|
||||
const STATE_OPTIONS: Array<{ id: AvatarState; label: string }> = [
|
||||
{ id: "idle", label: "대기" },
|
||||
{ id: "listening", label: "경청" },
|
||||
{ id: "thinking", label: "생각" },
|
||||
{ id: "speaking", label: "발화" },
|
||||
];
|
||||
|
||||
interface Clock {
|
||||
now(): number;
|
||||
pause(): void;
|
||||
resume(): void;
|
||||
isPaused(): boolean;
|
||||
}
|
||||
|
||||
function createClock(): Clock {
|
||||
let offset = 0;
|
||||
let paused = false;
|
||||
let frozen = 0;
|
||||
return {
|
||||
now(): number {
|
||||
return paused ? frozen : performance.now() - offset;
|
||||
},
|
||||
pause(): void {
|
||||
if (paused) return;
|
||||
frozen = performance.now() - offset;
|
||||
paused = true;
|
||||
},
|
||||
resume(): void {
|
||||
if (!paused) return;
|
||||
offset = performance.now() - frozen;
|
||||
paused = false;
|
||||
},
|
||||
isPaused(): boolean {
|
||||
return paused;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function seedFromQuery(): number {
|
||||
const raw = new URLSearchParams(window.location.search).get("seed");
|
||||
const parsed = raw === null ? NaN : Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : 1;
|
||||
}
|
||||
|
||||
export default function AvatarLab() {
|
||||
const seed = useMemo(seedFromQuery, []);
|
||||
const clock = useRef<Clock>(createClock()).current;
|
||||
|
||||
const [personaCode, setPersonaCode] = useState("P1");
|
||||
const [avatarState, setAvatarState] = useState<AvatarState>("idle");
|
||||
const [openness, setOpenness] = useState(0.4);
|
||||
const [reducedMotion, setReducedMotion] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const [selectedExpression, setSelectedExpression] = useState<AvatarExpression>("neutral");
|
||||
const [intensity, setIntensity] = useState(0.7);
|
||||
const [mirrorClip, setMirrorClip] = useState(false);
|
||||
|
||||
const rig = LINOCUT_RIGS[personaCode];
|
||||
const [rendererMode, setRendererMode] = useState<RendererMode>(rig ? "linocut" : "debug");
|
||||
const [feedbackMode, setFeedbackMode] = useState<FeedbackMode>("coached");
|
||||
const [linocutLoadState, setLinocutLoadState] = useState<"loading" | "ready" | "error">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
if (!rig) setRendererMode("debug");
|
||||
}, [rig]);
|
||||
|
||||
const backdropGroup = useMemo(
|
||||
() => AVATAR_EXPRESSION_LIBRARY.find((e) => e.id === selectedExpression)?.group ?? "cognitive",
|
||||
[selectedExpression],
|
||||
);
|
||||
|
||||
const [speechText, setSpeechText] = useState("(한숨) 그냥요. 별로 할 말 없어요.");
|
||||
const [parsedCues, setParsedCues] = useState<PerformanceCue[]>([]);
|
||||
const [unmatched, setUnmatched] = useState<string[]>([]);
|
||||
const [snapshot, setSnapshot] = useState<DebugSnapshot | null>(null);
|
||||
|
||||
const speechHandleRef = useRef<SpeechHandle | null>(null);
|
||||
const meterRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
||||
|
||||
/* 발화층(립싱크) — 원시 SpeechShape·비짐 표시용. 채널 미터와 달리 매 프레임 갱신하지
|
||||
않고(React state 금지, 결정문 §3) 아래 100ms interval에서 ref로 직접 쓴다. */
|
||||
const speechDisplayRef = useRef<{ viseme: VisemeId; shape: SpeechShape }>({
|
||||
viseme: "X",
|
||||
shape: { open: 0, wide: 0, round: 0, press: 0 },
|
||||
});
|
||||
const speechMeterRefs = useRef<{
|
||||
viseme: HTMLElement | null;
|
||||
open: HTMLElement | null;
|
||||
wide: HTMLElement | null;
|
||||
round: HTMLElement | null;
|
||||
press: HTMLElement | null;
|
||||
}>({ viseme: null, open: null, wide: null, round: null, press: null });
|
||||
|
||||
/* 발화 동반층(§5.5) 표시용 — 현재 구 종류·강세 박 여부. speechDisplayRef와 같은 이유로
|
||||
React state가 아니라 ref로 갱신하고 100ms interval에서 DOM에 직접 쓴다. */
|
||||
const coSpeechDisplayRef = useRef<{ phraseKind: PhraseKind | null; stressed: boolean }>({ phraseKind: null, stressed: false });
|
||||
const coSpeechMeterRefs = useRef<{ phraseKind: HTMLElement | null; stressed: HTMLElement | null }>({
|
||||
phraseKind: null,
|
||||
stressed: null,
|
||||
});
|
||||
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||
const audioFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [audioFileName, setAudioFileName] = useState<string | null>(null);
|
||||
|
||||
const engine: AvatarEngine = useMemo(
|
||||
() => createAvatarEngine({ demeanor: demeanorFor(personaCode), seed, reducedMotion }),
|
||||
[personaCode, seed, reducedMotion],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
engine.setState(avatarState, clock.now());
|
||||
engine.setOpenness(openness);
|
||||
engine.setSurface(selectedExpression, intensity);
|
||||
}, [engine]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
engine.setState(avatarState, clock.now());
|
||||
}, [engine, avatarState]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
engine.setOpenness(openness);
|
||||
}, [engine, openness]);
|
||||
|
||||
useEffect(() => {
|
||||
engine.setSurface(selectedExpression, intensity);
|
||||
}, [engine, selectedExpression, intensity]);
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshot(engine.debugSnapshot());
|
||||
const interval = window.setInterval(() => {
|
||||
const v = engine.evaluate(clock.now());
|
||||
for (const id of CHANNEL_IDS) {
|
||||
const cell = meterRefs.current[id];
|
||||
if (cell) cell.textContent = v[id].toFixed(2);
|
||||
}
|
||||
setSnapshot(engine.debugSnapshot());
|
||||
|
||||
const sd = speechDisplayRef.current;
|
||||
const sm = speechMeterRefs.current;
|
||||
if (sm.viseme) sm.viseme.textContent = sd.viseme;
|
||||
if (sm.open) sm.open.textContent = sd.shape.open.toFixed(2);
|
||||
if (sm.wide) sm.wide.textContent = sd.shape.wide.toFixed(2);
|
||||
if (sm.round) sm.round.textContent = sd.shape.round.toFixed(2);
|
||||
if (sm.press) sm.press.textContent = sd.shape.press.toFixed(2);
|
||||
|
||||
const cd = coSpeechDisplayRef.current;
|
||||
const cm = coSpeechMeterRefs.current;
|
||||
if (cm.phraseKind) cm.phraseKind.textContent = cd.phraseKind ?? "-";
|
||||
if (cm.stressed) cm.stressed.textContent = cd.stressed ? "예" : "아니오";
|
||||
}, 100);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [engine]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
speechHandleRef.current?.stop();
|
||||
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 };
|
||||
}
|
||||
|
||||
function playSpeech(): void {
|
||||
speechHandleRef.current?.stop();
|
||||
const nowMs = clock.now();
|
||||
const speech = demeanorFor(personaCode).speech;
|
||||
setAvatarState("speaking");
|
||||
engine.setState("speaking", nowMs);
|
||||
const handle = startSpeech({
|
||||
engine,
|
||||
text: speechText,
|
||||
expression: selectedExpression,
|
||||
intensity,
|
||||
openness,
|
||||
seed,
|
||||
speech,
|
||||
now: clock.now,
|
||||
onFrame: ({ viseme, shape, phraseKind, stressed }) => {
|
||||
speechDisplayRef.current = { viseme, shape };
|
||||
coSpeechDisplayRef.current = { phraseKind, stressed };
|
||||
},
|
||||
onEnd: () => endSpeech(clock.now()),
|
||||
});
|
||||
speechHandleRef.current = handle;
|
||||
setParsedCues(handle.cues);
|
||||
setUnmatched(handle.unmatched);
|
||||
}
|
||||
|
||||
function handleAudioFileChange(e: ChangeEvent<HTMLInputElement>): void {
|
||||
setAudioFileName(e.target.files?.[0]?.name ?? null);
|
||||
}
|
||||
|
||||
/** "오디오 파일로 말하기" — 로컬 오디오를 디코드해 speechDriver에 넘긴다(포락선 선분석·
|
||||
오디오 시계 표본은 startSpeech 안에서 한다). */
|
||||
async function playSpeechWithAudioFile(): Promise<void> {
|
||||
const file = audioFileInputRef.current?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const AudioContextCtor =
|
||||
window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextCtor) return;
|
||||
const ctx = audioCtxRef.current ?? new AudioContextCtor();
|
||||
audioCtxRef.current = ctx;
|
||||
if (ctx.state === "suspended") await ctx.resume();
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer.slice(0));
|
||||
|
||||
try {
|
||||
audioSourceRef.current?.stop();
|
||||
} catch {
|
||||
/* 이미 정지된 소스면 무시한다. */
|
||||
}
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(ctx.destination);
|
||||
audioSourceRef.current = source;
|
||||
|
||||
const startAtCtx = ctx.currentTime + 0.3;
|
||||
source.start(startAtCtx);
|
||||
|
||||
speechHandleRef.current?.stop();
|
||||
const nowMs = clock.now();
|
||||
const speech = demeanorFor(personaCode).speech;
|
||||
setAvatarState("speaking");
|
||||
engine.setState("speaking", nowMs);
|
||||
const handle = startSpeech({
|
||||
engine,
|
||||
text: speechText,
|
||||
expression: selectedExpression,
|
||||
intensity,
|
||||
openness,
|
||||
seed,
|
||||
speech,
|
||||
now: clock.now,
|
||||
audio: { buffer: audioBuffer, context: ctx, startAt: startAtCtx },
|
||||
onFrame: ({ viseme, shape, phraseKind, stressed }) => {
|
||||
speechDisplayRef.current = { viseme, shape };
|
||||
coSpeechDisplayRef.current = { phraseKind, stressed };
|
||||
},
|
||||
onEnd: () => endSpeech(clock.now()),
|
||||
});
|
||||
speechHandleRef.current = handle;
|
||||
setParsedCues(handle.cues);
|
||||
setUnmatched(handle.unmatched);
|
||||
}
|
||||
|
||||
function playLeakTest(): void {
|
||||
const nowMs = clock.now();
|
||||
const perf: Performance = {
|
||||
surface: { expression: "neutral", intensity: 0.3 },
|
||||
leak: { expression: "sad", intensity: 0.8, atMs: 500, durationMs: 350 },
|
||||
cues: [],
|
||||
};
|
||||
setAvatarState("listening");
|
||||
engine.setState("listening", nowMs);
|
||||
engine.playPerformance(perf, { speechStartMs: nowMs, speechDurationMs: 1200 }, nowMs);
|
||||
setParsedCues([]);
|
||||
setUnmatched([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="al" data-avatar-lab="true">
|
||||
<header className="al__head">
|
||||
<h1>아바타 v3 표현 엔진 Lab</h1>
|
||||
<p>채널·표정·반응 클립·지문 파서를 렌더러와 분리해 확인하는 개발 도구다(실 서비스 아님).</p>
|
||||
</header>
|
||||
|
||||
<section className="al__controls">
|
||||
<label>
|
||||
페르소나
|
||||
<select value={personaCode} onChange={(e) => setPersonaCode(e.target.value)}>
|
||||
{PERSONA_OPTIONS.map((p) => (
|
||||
<option key={p.code} value={p.code}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
상태
|
||||
<select value={avatarState} onChange={(e) => setAvatarState(e.target.value as AvatarState)}>
|
||||
{STATE_OPTIONS.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
개방도 {openness.toFixed(2)}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={openness}
|
||||
onChange={(e) => setOpenness(Number(e.target.value))}
|
||||
data-testid="openness-slider"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="al__toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reducedMotion}
|
||||
onChange={(e) => setReducedMotion(e.target.checked)}
|
||||
data-testid="reduced-motion-toggle"
|
||||
/>
|
||||
Reduced motion
|
||||
</label>
|
||||
|
||||
<fieldset className="al__renderer">
|
||||
<legend>렌더러</legend>
|
||||
<label className="al__toggle">
|
||||
<input
|
||||
type="radio"
|
||||
name="renderer"
|
||||
checked={rendererMode === "linocut"}
|
||||
disabled={!rig}
|
||||
onChange={() => setRendererMode("linocut")}
|
||||
data-testid="renderer-linocut"
|
||||
/>
|
||||
linocut
|
||||
</label>
|
||||
<label className="al__toggle">
|
||||
<input
|
||||
type="radio"
|
||||
name="renderer"
|
||||
checked={rendererMode === "debug"}
|
||||
onChange={() => setRendererMode("debug")}
|
||||
data-testid="renderer-debug"
|
||||
/>
|
||||
debug
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label>
|
||||
피드백 모드
|
||||
<select
|
||||
value={feedbackMode}
|
||||
onChange={(e) => setFeedbackMode(e.target.value as FeedbackMode)}
|
||||
data-testid="feedback-mode"
|
||||
>
|
||||
<option value="immersive">immersive</option>
|
||||
<option value="ambient">ambient</option>
|
||||
<option value="coached">coached</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="button" onClick={togglePause} data-testid="pause-toggle">
|
||||
{paused ? "재개" : "일시정지"}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="al__stage">
|
||||
{rendererMode === "linocut" && rig ? (
|
||||
<>
|
||||
<div className="al__linocut-main">
|
||||
<LinocutAvatar
|
||||
rig={rig}
|
||||
engine={engine}
|
||||
running
|
||||
nowMs={clock.now}
|
||||
crop="bust"
|
||||
backdropExpression={selectedExpression}
|
||||
onLoadStateChange={setLinocutLoadState}
|
||||
/>
|
||||
</div>
|
||||
<LinocutMotif
|
||||
rig={rig}
|
||||
group={backdropGroup}
|
||||
budOpenness={feedbackMode === "immersive" ? null : openness}
|
||||
feedbackMode={feedbackMode}
|
||||
reducedMotion={reducedMotion}
|
||||
size={96}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<DebugFace engine={engine} running nowMs={clock.now} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{rendererMode === "linocut" && rig ? (
|
||||
<>
|
||||
<p data-testid="linocut-load-state">로드 상태: {linocutLoadState}</p>
|
||||
|
||||
<section className="al__preview-row">
|
||||
<div className="al__preview-cell al__preview-cell--bust-circle">
|
||||
<LinocutAvatar rig={rig} engine={engine} running nowMs={clock.now} crop="bust" backdropExpression={selectedExpression} />
|
||||
</div>
|
||||
<div className="al__preview-cell al__preview-cell--face-96">
|
||||
<LinocutAvatar rig={rig} engine={engine} running nowMs={clock.now} crop="face" backdropExpression={selectedExpression} />
|
||||
</div>
|
||||
<div className="al__preview-cell al__preview-cell--face-48">
|
||||
<LinocutAvatar rig={rig} engine={engine} running nowMs={clock.now} crop="face" backdropExpression={selectedExpression} />
|
||||
</div>
|
||||
<div className="al__preview-cell al__preview-cell--portrait">
|
||||
<LinocutAvatar rig={rig} engine={engine} running nowMs={clock.now} crop="portrait" backdropExpression={selectedExpression} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<section className="al__panel">
|
||||
<h2>표정 (28개)</h2>
|
||||
<label className="al__intensity">
|
||||
강도 {intensity.toFixed(2)}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={intensity}
|
||||
onChange={(e) => setIntensity(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<div className="al__grid">
|
||||
{AVATAR_EXPRESSION_LIBRARY.map((expr) => (
|
||||
<button
|
||||
key={expr.id}
|
||||
type="button"
|
||||
className={expr.id === selectedExpression ? "al__chip al__chip--active" : "al__chip"}
|
||||
data-testid={`expression-${expr.id}`}
|
||||
onClick={() => setSelectedExpression(expr.id)}
|
||||
>
|
||||
{expr.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="al__panel">
|
||||
<h2>반응 클립 (26개)</h2>
|
||||
<label className="al__toggle">
|
||||
<input type="checkbox" checked={mirrorClip} onChange={(e) => setMirrorClip(e.target.checked)} />
|
||||
좌우 반전
|
||||
</label>
|
||||
<div className="al__grid">
|
||||
{REACTION_CLIP_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className="al__chip"
|
||||
data-testid={`clip-${id}`}
|
||||
onClick={() => handleClipClick(id)}
|
||||
>
|
||||
{REACTION_CLIPS[id].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="al__panel">
|
||||
<h2>내담자 발화 · 공연 재생</h2>
|
||||
<textarea
|
||||
className="al__speech-input"
|
||||
value={speechText}
|
||||
onChange={(e) => setSpeechText(e.target.value)}
|
||||
rows={3}
|
||||
data-testid="speech-text"
|
||||
/>
|
||||
<div className="al__actions">
|
||||
<button type="button" onClick={playSpeech} data-testid="play-speech">
|
||||
공연 재생
|
||||
</button>
|
||||
<button type="button" onClick={playLeakTest} data-testid="play-leak-test">
|
||||
누설 테스트
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="al__actions al__audio-file">
|
||||
<input
|
||||
ref={audioFileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleAudioFileChange}
|
||||
data-testid="speech-audio-file"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void playSpeechWithAudioFile()}
|
||||
disabled={!audioFileName}
|
||||
data-testid="play-speech-audio"
|
||||
>
|
||||
오디오 파일로 말하기
|
||||
</button>
|
||||
{audioFileName ? <span className="al__audio-file-name">{audioFileName}</span> : null}
|
||||
</div>
|
||||
|
||||
<table className="al__meter al__speech-shape">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>비짐</th>
|
||||
<th>open</th>
|
||||
<th>wide</th>
|
||||
<th>round</th>
|
||||
<th>press</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td ref={(el) => { speechMeterRefs.current.viseme = el; }} data-testid="speech-shape-viseme">
|
||||
X
|
||||
</td>
|
||||
<td ref={(el) => { speechMeterRefs.current.open = el; }} data-testid="speech-shape-open">
|
||||
0.00
|
||||
</td>
|
||||
<td ref={(el) => { speechMeterRefs.current.wide = el; }} data-testid="speech-shape-wide">
|
||||
0.00
|
||||
</td>
|
||||
<td ref={(el) => { speechMeterRefs.current.round = el; }} data-testid="speech-shape-round">
|
||||
0.00
|
||||
</td>
|
||||
<td ref={(el) => { speechMeterRefs.current.press = el; }} data-testid="speech-shape-press">
|
||||
0.00
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p className="al__co-speech-status">
|
||||
구: <span ref={(el) => { coSpeechMeterRefs.current.phraseKind = el; }} data-testid="co-speech-phrase-kind">-</span>
|
||||
{" · "}
|
||||
강세 박: <span ref={(el) => { coSpeechMeterRefs.current.stressed = el; }} data-testid="co-speech-stressed">아니오</span>
|
||||
</p>
|
||||
|
||||
<div className="al__parse-result">
|
||||
<h3>파싱된 cue</h3>
|
||||
<ul data-testid="parsed-cues">
|
||||
{parsedCues.map((cue, i) => (
|
||||
<li key={i} data-clip={cue.clip} data-anchor={cue.anchor} data-source={cue.source}>
|
||||
{cue.clip} / {cue.anchor} / at={cue.at.toFixed(2)} / {cue.source}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h3>미대응 지문</h3>
|
||||
<ul data-testid="unmatched-directions">
|
||||
{unmatched.map((text, i) => (
|
||||
<li key={i}>{text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="al__panel">
|
||||
<h2>채널 미터 ({CHANNEL_IDS.length}개)</h2>
|
||||
<table className="al__meter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>채널</th>
|
||||
<th>값</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CHANNEL_IDS.map((id) => (
|
||||
<tr key={id} data-channel={id}>
|
||||
<td>{id}</td>
|
||||
<td
|
||||
data-value
|
||||
ref={(el) => {
|
||||
meterRefs.current[id] = el;
|
||||
}}
|
||||
>
|
||||
0.00
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section className="al__panel">
|
||||
<h2>엔진 스냅샷</h2>
|
||||
<h3>활성 클립</h3>
|
||||
<ul data-testid="active-clips">
|
||||
{(snapshot?.activeClips ?? []).map((c, i) => (
|
||||
<li key={i} data-clip={c.id}>
|
||||
{c.id} · started={c.startedMs.toFixed(0)} · weight={c.weight.toFixed(2)} · mirror={String(c.mirror)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h3>예약된 cue</h3>
|
||||
<ul data-testid="scheduled-cues">
|
||||
{(snapshot?.scheduled ?? []).map((c, i) => (
|
||||
<li key={i} data-clip={c.clip}>
|
||||
{c.clip} · at={c.atMs.toFixed(0)} · {c.source}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p data-testid="leak-active">누설 활성: {snapshot?.leakActive ? "예" : "아니오"}</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,7 +23,8 @@ import {
|
|||
ClientAvatar,
|
||||
expressionLabelFor,
|
||||
} from "../components/avatar/ClientAvatar";
|
||||
import type { AvatarState, AvatarAffect } from "../components/avatar/ClientAvatar";
|
||||
import type { AvatarAffect, AvatarSpeech, AvatarState } from "../components/avatar/ClientAvatar";
|
||||
import { surfaceIntensityFor } from "../components/avatar/engine/performance";
|
||||
import { Kicker, Button, Icon, surfaceClassName } from "../components/ui";
|
||||
import { InnerReactionCard } from "../components/inner-reaction/InnerReactionCard";
|
||||
import {
|
||||
|
|
@ -268,6 +269,9 @@ export default function Session() {
|
|||
const [voiceConsentError, setVoiceConsentError] = useState<string | null>(null);
|
||||
const [resumedSessionLoaded, setResumedSessionLoaded] = useState(false);
|
||||
const [voiceAnalyser, setVoiceAnalyser] = useState<AnalyserNode | null>(null);
|
||||
// v3(리노컷) 아바타 발화 구동(결정문 §8.5). TTS 재생 시작 시점에 세팅하고, 정지·실패·
|
||||
// 종료 경로에서 null로 되돌린다.
|
||||
const [avatarSpeech, setAvatarSpeech] = useState<AvatarSpeech | null>(null);
|
||||
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
||||
const [ending, setEnding] = useState(false);
|
||||
const endCancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -346,6 +350,9 @@ export default function Session() {
|
|||
const ttsPlaybackActiveRef = useRef(false);
|
||||
const ttsRequestAbortRef = useRef<AbortController | null>(null);
|
||||
const playTtsAudioRef = useRef<(() => Promise<void>) | null>(null);
|
||||
// v3 아바타 발화 텍스트(결정문 §8.5 2g): 텍스트 모드는 speakTextClientTurn 직전,
|
||||
// 음성 모드는 reply 이벤트에서 담는다. 발화를 한 번 내보내면 비운다.
|
||||
const pendingSpeechTextRef = useRef<string | null>(null);
|
||||
const pendingVoiceLearnerIdRef = useRef<number | null>(null);
|
||||
const pendingVoiceLearnerTextRef = useRef<string>("");
|
||||
const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -419,6 +426,7 @@ export default function Session() {
|
|||
ttsPlaybackCleanupRef.current = null;
|
||||
if (cleanup) cleanup();
|
||||
setVoiceAnalyser(null);
|
||||
setAvatarSpeech(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1284,6 +1292,15 @@ export default function Session() {
|
|||
}
|
||||
}, [acceptConsent, consentChecked, pushSignal]);
|
||||
|
||||
// TTS를 받지 못했거나 재생하지 못한 경로(결정문 §8.5 2g): 발화 텍스트 ref에 남은
|
||||
// 텍스트가 있으면 텍스트 타이밍 발화로 v3 아바타를 말하게 한다. 내보내면 ref를 비운다.
|
||||
const speakPendingTextFallback = useCallback(() => {
|
||||
const text = pendingSpeechTextRef.current;
|
||||
pendingSpeechTextRef.current = null;
|
||||
if (!text) return;
|
||||
setAvatarSpeech({ id: randomUuid(), text });
|
||||
}, []);
|
||||
|
||||
const speakTextClientTurn = useCallback(
|
||||
async (sessionId: string, turnSeq: number) => {
|
||||
const requestId = ttsPlaybackRequestRef.current + 1;
|
||||
|
|
@ -1311,13 +1328,14 @@ export default function Session() {
|
|||
setVoiceStatus("degraded");
|
||||
setVoiceDetail("내담자 음성을 재생하지 못했습니다. 자막 응답은 화면에 남겼습니다.");
|
||||
pushSignal("warn", "AI 음성 재생 실패");
|
||||
speakPendingTextFallback();
|
||||
} finally {
|
||||
if (ttsRequestAbortRef.current === abortController) {
|
||||
ttsRequestAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[pushSignal],
|
||||
[pushSignal, speakPendingTextFallback],
|
||||
);
|
||||
|
||||
const appendServerClientReply = useCallback(
|
||||
|
|
@ -1477,6 +1495,7 @@ export default function Session() {
|
|||
setAvatarState("listening");
|
||||
if (!conversationStopped && !qualityRetryable) {
|
||||
if (clientReply && typeof done.turn_seq === "number") {
|
||||
pendingSpeechTextRef.current = clientReply;
|
||||
void speakTextClientTurn(liveSessionId, done.turn_seq);
|
||||
}
|
||||
void requestLiveCoach({
|
||||
|
|
@ -1613,12 +1632,15 @@ export default function Session() {
|
|||
ttsPlaybackActiveRef.current = false;
|
||||
ttsPlaybackCleanupRef.current = null;
|
||||
setVoiceAnalyser(null);
|
||||
setAvatarSpeech(null);
|
||||
setAvatarState("listening");
|
||||
setVoiceStatus("idle");
|
||||
setVoiceDetail("응답이 끝났습니다. 마이크를 다시 켜 발화하세요.");
|
||||
closeVoiceSocket();
|
||||
};
|
||||
|
||||
// TTS를 받았지만 재생하지 못한 경로(결정문 §8.5 2g): 발화 텍스트가 있으면
|
||||
// 텍스트 타이밍 발화로 v3 아바타를 대신 말하게 한다.
|
||||
const failPlayback = (detail: string, status: VoiceStatus = "error") => {
|
||||
ttsPlaybackActiveRef.current = false;
|
||||
ttsPlaybackCleanupRef.current = null;
|
||||
|
|
@ -1627,6 +1649,7 @@ export default function Session() {
|
|||
setVoiceStatus(status);
|
||||
setVoiceDetail(detail);
|
||||
closeVoiceSocket();
|
||||
speakPendingTextFallback();
|
||||
};
|
||||
|
||||
const ctx = ensureVoiceAudioContext();
|
||||
|
|
@ -1677,7 +1700,13 @@ export default function Session() {
|
|||
setAvatarState("speaking");
|
||||
setVoiceStatus("speaking");
|
||||
setVoiceDetail(`${clientName} 음성을 재생 중입니다.`);
|
||||
source.start();
|
||||
const startAt = ctx.currentTime + 0.08;
|
||||
source.start(startAt);
|
||||
const speechText = pendingSpeechTextRef.current;
|
||||
pendingSpeechTextRef.current = null;
|
||||
if (speechText) {
|
||||
setAvatarSpeech({ id: randomUuid(), text: speechText, audio: { buffer: decoded, context: ctx, startAt } });
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
setVoiceAnalyser(null);
|
||||
|
|
@ -1711,11 +1740,14 @@ export default function Session() {
|
|||
|
||||
try {
|
||||
await audio.play();
|
||||
const speechText = pendingSpeechTextRef.current;
|
||||
pendingSpeechTextRef.current = null;
|
||||
if (speechText) setAvatarSpeech({ id: randomUuid(), text: speechText });
|
||||
} catch {
|
||||
cleanupElement();
|
||||
failPlayback("브라우저가 자동 재생을 막았습니다. 자막 응답은 화면에 남겼습니다.", "degraded");
|
||||
}
|
||||
}, [clientName, closeVoiceSocket, ensureVoiceAudioContext, stopTtsPlayback]);
|
||||
}, [clientName, closeVoiceSocket, ensureVoiceAudioContext, speakPendingTextFallback, stopTtsPlayback]);
|
||||
|
||||
useEffect(() => {
|
||||
playTtsAudioRef.current = playTtsAudio;
|
||||
|
|
@ -1947,6 +1979,8 @@ export default function Session() {
|
|||
|
||||
if (payload.type === "reply") {
|
||||
replyReceived = true;
|
||||
// 음성 모드 발화 텍스트(결정문 §8.5 2g): reply 이벤트의 payload.text를 담는다.
|
||||
pendingSpeechTextRef.current = payload.text ?? null;
|
||||
setClientReplyPending(false);
|
||||
if (payload.stage) setStage(payload.stage);
|
||||
if (typeof payload.effective_openness === "number") {
|
||||
|
|
@ -3562,6 +3596,14 @@ export default function Session() {
|
|||
affect={avatarAffect}
|
||||
analyser={voiceAnalyser}
|
||||
rapport={meters.rapport}
|
||||
openness={openness}
|
||||
surfaceIntensity={surfaceIntensityFor({
|
||||
expression: avatarAffect,
|
||||
openness,
|
||||
safety: Boolean(safety),
|
||||
paused,
|
||||
})}
|
||||
speech={avatarSpeech}
|
||||
size={220}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
197
apps/web/src/pages/avatar-lab.css
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
.al {
|
||||
padding: 24px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
color: #2a231b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.al__head h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.al__head p {
|
||||
margin: 0 0 20px;
|
||||
color: #6b5f50;
|
||||
}
|
||||
|
||||
.al__controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #ddd2bd;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.al__controls label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b5f50;
|
||||
}
|
||||
|
||||
.al__toggle {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
.al__stage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.al__renderer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid #ddd2bd;
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
color: #6b5f50;
|
||||
}
|
||||
|
||||
.al__linocut-main {
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.al__preview-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.al__preview-cell {
|
||||
flex: none;
|
||||
background: #f3ead9;
|
||||
}
|
||||
|
||||
.al__preview-cell--bust-circle {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.al__preview-cell--face-96 {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.al__preview-cell--face-48 {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.al__preview-cell--portrait {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.al__panel {
|
||||
margin-bottom: 28px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ddd2bd;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.al__panel h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.al__intensity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: #6b5f50;
|
||||
}
|
||||
|
||||
.al__grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.al__chip {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ccbfa4;
|
||||
border-radius: 999px;
|
||||
background: #fbf7ef;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.al__chip--active {
|
||||
background: #6b5e7d;
|
||||
color: #fff;
|
||||
border-color: #6b5e7d;
|
||||
}
|
||||
|
||||
.al__speech-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccbfa4;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.al__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.al__parse-result ul {
|
||||
margin: 4px 0 12px;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.al__meter {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.al__meter th,
|
||||
.al__meter td {
|
||||
border-bottom: 1px solid #eee2cd;
|
||||
padding: 3px 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.al__audio-file {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.al__audio-file-name {
|
||||
font-size: 12px;
|
||||
color: #6b5f50;
|
||||
}
|
||||
|
||||
.al__speech-shape {
|
||||
margin-bottom: 12px;
|
||||
max-width: 360px;
|
||||
}
|
||||
2
apps/web/src/vite-env.d.ts
vendored
|
|
@ -3,6 +3,8 @@
|
|||
interface ImportMetaEnv {
|
||||
/** API 베이스 URL. 기본 "/api" (vite proxy / nginx 가 백엔드로 라우팅). */
|
||||
readonly VITE_API_BASE?: string;
|
||||
/** "0"이면 아바타 v3(리노컷)를 끄고 항상 기존 SVG를 쓴다. 기본(미설정)은 켜짐. */
|
||||
readonly VITE_AVATAR_V3?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@
|
|||
| [`decisions/local-voice-stack.md`](./decisions/local-voice-stack.md) | 노트북 로컬 음성 스택 결정 — faster-whisper STT + MeloTTS TTS(둘 다 MIT), 설치 함정·cuDNN 제약, G7 게이트 provider 계약 |
|
||||
| [`decisions/jev-client-affect.md`](./decisions/jev-client-affect.md) | Jev 감정 상태 판단 도입 결정 — 기존 내담자 생성 모델과 안전·단계 소유권을 유지하고, 한국어·다중 턴·지연 실증 뒤에만 승격 |
|
||||
| [`decisions/outcome-alliance-measurement-ledger.md`](./decisions/outcome-alliance-measurement-ledger.md) | Outcome & Alliance append-only 측정 원장, source/perspective 경계, 전진 복구·롤백 결정 |
|
||||
| [`decisions/avatar-expression-engine-v3.md`](./decisions/avatar-expression-engine-v3.md) | 가상 내담자 아바타 v3 — 채널·표정·반응 클립·지문 cue 계약, 리노컷 혼합 리그(덩어리 이미지 레이어 + 표정 벡터), 단계·판정 기록 |
|
||||
|
||||
## 🧪 Phase 3 파일럿 (forward — 아직 미실행)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
| ADMIN-OPENROUTER-001 | 새 사용자 OAuth finish 성공을 관측한다. | API/web 복구·실제 PostgreSQL rollback·배포/보존 census·공개 관리자 UI를 수용했다. OpenRouter UI 상태는 미연결이고 health의 Jev `live_verified=false`는 live 검증이 아니며, 새 OAuth finish 성공 관측 전까지 진행 중이다. [운영 영수증](./ops/evidence/admin-openrouter-recovery-2026-09-22.json)을 따른다. |
|
||||
| JEV-001 | 충분한 한국어 독립 평가와 전체 지연 반복 비교를 마친다. | 운영 API, 합성·인증 브라우저 3턴 runtime과 DB 감정 영속화는 수용했지만 품질 승격은 보류한다. API legacy baseline 1191 passed·1 skipped·0 failed, gateway 82 passed는 구현 회귀 증거이며 품질·성능 우위 증거가 아니다. 위 품질 게이트를 닫은 뒤 전체 사용자 흐름 완료 여부를 판단하며, 최신 probe·수용/반려 근거는 [Jev 결정문](./decisions/jev-client-affect.md)을 따른다. |
|
||||
| JEV-002 | v2 평가·표현 프로토콜을 운영에 올리고 실제 대사 영향을 관측한다. | 로컬 구현·실 Jev 16/16·실 DB RLS 15항목·실 스택 레이아웃을 수용했다. 운영은 migration 24를 먼저 적용한 뒤 API·web을 배포한다(스키마 없이 배포하면 readiness가 기동을 막는다). 로컬 `claude_cli` 생성 엔진 시간 초과로 v2 지시가 실제 대사에 미치는 영향은 미관측이다. 보정 관찰(`a_understood` 문구, `a_sore_spot` 과다 선택)은 전문가 검토 대상이다. [v2 프로토콜](./decisions/jev-client-affect-v2.md) |
|
||||
| AVATAR-V3 | 가상 내담자 아바타 v3(표현 엔진·반응 모션·리노컷 리그)를 P1부터 세션에 연결한다. | 1단계 엔진·`/dev/avatar-lab` 수용(check:avatar-presets 통과·E2E 6 passed), 2단계-A P1 리노컷 레이어 수용. 2단계-B-1b 렌더러 기능 계약·B-1c 모티프 수용. 진행: 2단계-B-1d 표정 판독성 개정(입술 결 래스터, 눈썹 중심선 랜드마크, 이마 주름, 작은 크기 선 보정)과 발화층 립싱크(한글 비짐 + 오디오 선분석, 결정문 §5.4 — 구현·검증됨), 발화 동반층 §5.5·하관 띠 변형(턱·코·볼)·원화 픽셀 입술 구현, 2단계-B-1d 수용(소유자 시각 확인 대기). 남음: 2단계-B-2 세션 연결·소유자 시각 확인, 3단계 API `avatar_cue`·지문 어휘 가이드, 4단계 7명 외형 SSOT. [결정문](./decisions/avatar-expression-engine-v3.md) |
|
||||
| USER-LATENCY (JEV-001) | 실제 경로에서 text display·voice onset·완료 준비를 분리 측정한다. | 안전 검사를 유지한 최적화 뒤 반복 비교로 사용자 체감 지연을 평가한다. 현재 CUA 관측상한과 소수 audit은 속도 개선 증거가 아니다. |
|
||||
| G6-PRODUCER-CONFLICT | 기존 submission id와 content hash를 읽기 전용으로 대조한다. | `SupervisionResearchConflictError`의 동일 id 재실행 idempotency 충돌을 해소하고 해당 테스트와 실제 cycle 성공을 확인한다. Jev·세션 경로와 분리된 기존 운영 충돌이며, 이번 배포 회귀로 단정하지 않는다. |
|
||||
| VNET-001 | `vnet.18ka.net` 공개 전환의 외부 설정을 마친다. | DNS, Cloudflare zone 권한, Google redirect URI가 모두 준비된 뒤 live 검증. |
|
||||
|
|
|
|||
BIN
docs/avatar-art/art-direction-v3/p1/d1-paper-cutout.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
docs/avatar-art/art-direction-v3/p1/d2-ink-editorial.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
docs/avatar-art/art-direction-v3/p1/d3-modern-folk.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
docs/avatar-art/art-direction-v3/p1/d4-cinematic-flat.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/avatar-art/art-direction-v3/p1/r2-a-paper-portrait.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
docs/avatar-art/art-direction-v3/p1/r2-b-linocut.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
docs/avatar-art/art-direction-v3/p1/r2-c-gouache-editorial.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
docs/avatar-art/art-direction-v3/p1/r2-d-modernist-poster.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
9
docs/avatar-art/art-direction-v3/prompts/_common.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
Character: Seoyeon, a 17-year-old Korean girl in her second year of high school. She is depressed and exhausted, was brought to counseling by her mother against her will, and is thinking about dropping out of school. She hides her feelings behind a flat, guarded face and believes she is a burden. Visual traits: shoulder-length, slightly unkempt dark brown hair with a few loose strands; faint sleepless shadows under the eyes; an oversized grey-navy school cardigan with the sleeves pulled over her hands; slightly slumped shoulders; a plain black hair tie on one wrist. Dignified and deeply human, not cute, not anime, not glamorous, not a fashion model. Absolutely no self-harm imagery and no marks on the skin.
|
||||
|
||||
Layout: one wide horizontal style frame showing the SAME character three times side by side with identical head-and-shoulders bust framing, facing the viewer at a slight three-quarter angle, on a plain flat background.
|
||||
- Left: guarded neutral (flat mouth, eyes slightly lowered, avoiding eye contact).
|
||||
- Middle: quiet sadness she tries to hold back (inner brows raised, lips pressed together, one tear on the cheek).
|
||||
- Right: a small, reluctant half-smile of relief (eyes softening, lifting her gaze toward the viewer).
|
||||
Next to each portrait, a small symbolic motif that mirrors her inner state: a closed flower bud under a light grey mist cloud (left); the same bud drooping in soft rain (middle); the bud slightly opening under a thin ray of warm light (right).
|
||||
|
||||
Construction: the rendering must be translatable into layered 2D vector shapes for a web animation rig. Use clean closed shapes and a clear separation between hair, face, eyes, brows, mouth and clothing. No photorealism, no heavy painterly blending. No text, no letters, no watermark.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Art direction: layered paper cut-out collage with gouache flat colors. Every form is a cut paper layer with a subtle paper-fiber grain and a very soft shadow between layers. Muted palette of dusty blue, warm grey and pale ochre skin tones, with a single muted coral accent. Calm, tactile and handmade; symbolic like a contemporary picture book.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Art direction: contemporary editorial illustration. Expressive, confident ink line drawing for the face and features with slightly varying line weight, combined with flat color blocks printed slightly off-register like a risograph print. Only three spot colors: deep indigo, soft teal-grey and warm peach, on off-white paper. Generous negative space and emotionally precise facial lines.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Art direction: modern folk, Scandinavian picture-book style. Simplified geometric forms built from arcs and circles, gently stylized proportions (slightly elongated neck, small features), a subtle textile-like pattern on the cardigan, and a warm muted palette of slate blue, sage, sand and brick red. Poetic, quiet, symbolic and human.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Art direction: cinematic flat vector illustration with two-tone cel shading and a soft colored rim light, like a portrait from an emotional narrative indie game. Restrained natural skin tones and a cool blue-grey ambient light, with warm light appearing only in the symbolic motif. Clean, modern, grounded and empathetic.
|
||||
11
docs/avatar-art/art-direction-v3/prompts/r2/_common.txt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
Character: Seoyeon, a 17-year-old Korean girl in her second year of high school. She is depressed and exhausted, was brought to counseling by her mother against her will, sleeps badly, and is thinking about dropping out of school. She hides her feelings behind a flat, guarded face and believes she is a burden.
|
||||
|
||||
She must look like one specific real person, not a generic pretty girl. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), subtle monolid-leaning eyelids, a slightly uneven face, a small mole under her left eye, a real nose and lips, faint sleepless shadows under the eyes, a few blemishes. Shoulder-length dark brown hair, slightly unkempt, loosely tucked behind one ear with a few stray strands. She wears an oversized faded charcoal hoodie over a white t-shirt, with the hood down. Slightly slumped shoulders. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. Absolutely no self-harm imagery and no marks on the skin.
|
||||
|
||||
Layout: one wide horizontal style frame showing the SAME character three times side by side, each in identical framing: head, neck and shoulders only, hands and arms completely out of frame, facing the viewer at a slight three-quarter angle, on a plain background.
|
||||
- Left: guarded neutral (flat mouth, eyes slightly lowered, avoiding eye contact).
|
||||
- Middle: quiet sadness she tries to hold back (inner brows raised, lips pressed together, chin tense, one tear on the cheek).
|
||||
- Right: a small, reluctant half-smile of relief (eyes softening, lifting her gaze toward the viewer).
|
||||
Next to each portrait, a small symbolic motif that mirrors her inner state: a closed flower bud under a low grey mist cloud (left); the same bud bent in soft rain (middle); the bud beginning to open under a thin ray of warm light (right).
|
||||
|
||||
Construction: the rendering must be translatable into layered 2D vector shapes for a web animation rig. Clean closed shapes and a clear separation between hair, face, eyes, brows, mouth and clothing. No text, no letters, no watermark.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Art direction: layered paper cut-out portrait. Every form is a cut sheet of matte paper with visible fiber grain, torn or knife-cut edges and a very soft shadow between layers. The face is built from a few carefully cut skin-tone planes with drawn pencil details for the eyes, brows and mouth. Muted palette of dusty blue, warm grey, pale ochre and umber, with one muted coral accent used only in the symbolic motif. Tactile, quiet and humane, like a contemporary literary picture book for adults.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Art direction: linocut / woodblock relief print. Bold carved black-indigo lines with visible gouge marks, flat areas of two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Strong, expressive, symbolic and serious, like a contemporary printmaker's portrait series about inner life. The facial expression is carried by a few decisive carved lines.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Art direction: contemporary gouache editorial portrait, like an illustration for a thoughtful magazine essay about adolescence. Flat opaque gouache shapes with visible dry-brush texture, restrained natural skin tones, a limited palette of charcoal, faded blue, sage and warm skin, and one soft coral accent in the symbolic motif. Simple but precise drawing of the eyes, brows and mouth; calm composition with generous negative space.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Art direction: modernist symbolic poster portrait, inspired by mid-century European poster art. The figure is simplified into a few bold geometric color planes (hair as one dark shape, face as two tonal planes, hoodie as large blocks), with a subtle silkscreen grain. The symbolic motif is integrated boldly into the composition rather than placed as a small icon. Limited palette: deep navy, dusty rose, ochre and off-white. Graphic, poetic and memorable, while the face still clearly shows a real, specific, vulnerable person.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P2-front.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean man in his early thirties, an anxious office worker. A lean, slightly gaunt angular face with tension in the jaw and a faint vertical crease between his eyebrows; tired but alert eyes with light shadows under them. Short, neatly cut black hair with a side part, a little flattened as if he keeps running his hand through it, and one small cowlick sticking up at the crown. Clean-shaven with a faint stubble shadow. He wears a pale blue office shirt with the top button undone and no tie, under a dark slate-grey V-neck knit sweater, and a plain company ID lanyard cord around his neck with the badge tucked inside the sweater so only the cord shows. His shoulders are stiff and slightly raised.
|
||||
|
||||
He must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin.
|
||||
|
||||
Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P3-front-v2.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean woman in her late twenties, a single mother of a two-year-old, warm but exhausted. Facial structure: a round, full-cheeked face with a soft short chin; gently down-turned eyes with clear double eyelids; low, softly arched eyebrows; a small rounded nose; a full lower lip. She clearly looks like an adult in her late twenties, not a teenager, with faint lines beside her nose and tired skin; heavy lower eyelids and shadows under the eyes, slightly dry lips, no makeup. Hair: dark brown, pulled back from her face into a low, messy bun that is clearly visible at the back of her head, with loose strands falling around her ears and jaw (not over her eyes or eyebrows). Clothing: a slightly pilled ochre-mustard knit sweater, a little stretched at one shoulder, over a cream t-shirt, with a small faded milk stain near the shoulder. Her shoulders are slightly dropped.
|
||||
|
||||
She must look like one specific real person with the distinct facial structure described above, not a generic attractive person, and must not resemble anyone else. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No logos, brand stripes or text on clothing. No marks or wounds on the skin.
|
||||
|
||||
Print style, matching the attached texture sample exactly: a linocut relief print with bold carved black-indigo lines and visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration; the skin is a flat warm ochre-beige like the neck in the sample. The attached image is only a texture and color sample of the print; it shows no face and gives no facial features. Framing: a portrait of head, neck and shoulders only, centered, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. The top of the hair is about 8% below the top edge, the chin is at about 59% of the image height, and the face is about 48% of the image width. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P3-front.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean woman in her late twenties, a single mother of a two-year-old, warm but exhausted. A kind, soft face, visibly tired: heavy lower eyelids, faint shadows under the eyes, slightly dry lips, no makeup. Dark brown hair pulled back into a low, messy bun, with loose strands falling around her face and ears (not over her eyes or eyebrows). She wears a slightly pilled ochre-mustard knit sweater, a little stretched at one shoulder, over a cream t-shirt, with a small faded milk stain near the shoulder of the sweater. Her shoulders are slightly dropped.
|
||||
|
||||
She must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin.
|
||||
|
||||
Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P4-front-v2.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean high-school girl of seventeen, a diligent top student and class president, tense and perfectionistic. Facial structure: a narrow heart-shaped face with a small pointed chin and high cheekbones; long almond-shaped eyes with thin double eyelids, slightly upturned at the outer corners; thin, straight, neatly groomed eyebrows; a narrow straight nose; a small mouth with thin lips pressed tight. Slightly too-alert eyes with faint shadows of sleeplessness. Hair: long straight black hair pulled smoothly straight back from her face and tied in a low ponytail at the nape (the ponytail mostly hidden behind her neck), forehead completely uncovered with no bangs and no hair over the forehead, only a few short baby hairs at her temples. Clothing: a navy crewneck sweater over a white collared shirt with the collar arranged neatly, and a yellow pencil tucked behind her right ear. Very upright posture with stiff, slightly raised shoulders.
|
||||
|
||||
She must look like one specific real person with the distinct facial structure described above, not a generic attractive person, and must not resemble anyone else. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No logos, brand stripes or text on clothing. No marks or wounds on the skin.
|
||||
|
||||
Print style, matching the attached texture sample exactly: a linocut relief print with bold carved black-indigo lines and visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration; the skin is a flat warm ochre-beige like the neck in the sample. The attached image is only a texture and color sample of the print; it shows no face and gives no facial features. Framing: a portrait of head, neck and shoulders only, centered, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. The top of the hair is about 8% below the top edge, the chin is at about 59% of the image height, and the face is about 48% of the image width. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P4-front.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean high-school girl of seventeen, a diligent top student and class president, tense and perfectionistic. A neat, carefully groomed look; slightly too-alert eyes with faint shadows of sleeplessness under them and a tense mouth. Long straight black hair tied in a low ponytail at the nape of her neck, a few short baby hairs escaping at her temples, no bangs so her forehead and eyebrows are fully visible. She wears a navy crewneck sweater over a white collared shirt with the collar arranged neatly, and a pencil tucked behind her right ear. Very upright posture with stiff, slightly raised shoulders.
|
||||
|
||||
She must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin.
|
||||
|
||||
Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P5-front-v2.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean middle-school boy of fifteen, slim and still childlike, guarded and watchful. Facial structure: a boyish face with a soft rounded jaw and some baby fat in the cheeks; a short, broad button nose; small narrow monolid eyes; thick, low, straight eyebrows; a small mouth with a fuller lower lip; ears that stick out slightly. A wary, observant look, and two or three small adolescent pimples on his forehead and chin. Hair: short dark brown hair, untidy and unstyled, soft and a little flat, with one cowlick. Clothing: an oversized plain black zip-up track jacket with no stripes and no logos, zipped halfway over a grey t-shirt, with a pair of white wired earphones hanging loose around his neck. His shoulders are slightly hunched.
|
||||
|
||||
He must look like one specific real person with the distinct facial structure described above, not a generic attractive person, and must not resemble anyone else. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No logos, brand stripes or text on clothing. No marks or wounds on the skin.
|
||||
|
||||
Print style, matching the attached texture sample exactly: a linocut relief print with bold carved black-indigo lines and visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration; the skin is a flat warm ochre-beige like the neck in the sample. The attached image is only a texture and color sample of the print; it shows no face and gives no facial features. Framing: a portrait of head, neck and shoulders only, centered, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. The top of the hair is about 8% below the top edge, the chin is at about 59% of the image height, and the face is about 48% of the image width. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P5-front.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean middle-school boy of fifteen, slim and still a little childlike, guarded and watchful. Short dark brown hair, untidy and unstyled, with a cowlick. Straight, narrow eyes with low upper eyelids and no eyelash emphasis; a wary, observant look. Two or three small adolescent pimples on his forehead and chin. He wears an oversized black zip-up track jacket zipped halfway over a grey t-shirt, with a pair of wired earphones hanging loose around his neck. His shoulders are slightly hunched.
|
||||
|
||||
He must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin.
|
||||
|
||||
Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P6-front-v2.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean high-school senior girl of eighteen, intelligent and articulate but worn out, torn between what her parents want and what she loves. Facial structure: a long oval face with a defined jaw; deep-set eyes with natural double eyelids under a slightly heavy brow ridge; thick, straight, dark eyebrows that are her most noticeable feature; a long straight nose with a high bridge; a wide mouth with a thin upper lip. Thoughtful eyes with shadows of sleepless nights, slightly chapped lips. Hair: medium-length dark brown hair with a soft natural wave; the top half is gathered back and held with a large plain dark hair claw clip that is clearly visible at the crown of her head, and the lower half falls in waves to her shoulders, a few strands loose beside her face (not over her eyes or eyebrows). Clothing: an oversized off-white cable-knit cardigan over a grey-and-white striped shirt, with a faint smudge of charcoal drawing dust on the edge of the cardigan collar. Her shoulders are drawn slightly inward.
|
||||
|
||||
She must look like one specific real person with the distinct facial structure described above, not a generic attractive person, and must not resemble anyone else. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No logos, brand stripes or text on clothing. No marks or wounds on the skin.
|
||||
|
||||
Print style, matching the attached texture sample exactly: a linocut relief print with bold carved black-indigo lines and visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration; the skin is a flat warm ochre-beige like the neck in the sample. The attached image is only a texture and color sample of the print; it shows no face and gives no facial features. Framing: a portrait of head, neck and shoulders only, centered, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. The top of the hair is about 8% below the top edge, the chin is at about 59% of the image height, and the face is about 48% of the image width. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P6-front.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean high-school senior girl of eighteen, intelligent and articulate but worn out, torn between what her parents want and what she loves. A thoughtful face with shadows of sleepless nights under her eyes and slightly chapped lips. Medium-length dark brown hair with a soft natural wave, the top half loosely held back with a plain hair claw clip (no decoration), a few strands falling loose beside her face (not over her eyes or eyebrows). She wears an oversized off-white cable-knit cardigan over a grey-and-white striped shirt, with a faint smudge of charcoal drawing dust on the edge of the cardigan collar. Her shoulders are drawn slightly inward.
|
||||
|
||||
She must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin.
|
||||
|
||||
Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P7-front-v2.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean high-school senior boy of eighteen, tall and lanky, visibly burnt out. Facial structure: a long, angular face with a strong jaw and sharp cheekbones; long narrow eyes with heavy, drooping monolid upper eyelids; thin straight eyebrows set low; a long nose with a prominent bridge; thin lips; a visible Adam's apple. He looks older and more gaunt than a middle-school boy. Dark circles under his eyes, a pale tired complexion, and a faint shadow of an unshaven upper lip. Hair: short black hair flattened on one side and sticking up at the back as if he just got out of bed, with an uneven side part. Clothing: a rumpled charcoal-grey crewneck sweatshirt with the wrinkled white collar of a school shirt showing at the neck. Slumped shoulders. Not a delinquent: an ordinary, exhausted student.
|
||||
|
||||
He must look like one specific real person with the distinct facial structure described above, not a generic attractive person, and must not resemble anyone else. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No logos, brand stripes or text on clothing. No marks or wounds on the skin.
|
||||
|
||||
Print style, matching the attached texture sample exactly: a linocut relief print with bold carved black-indigo lines and visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration; the skin is a flat warm ochre-beige like the neck in the sample. The attached image is only a texture and color sample of the print; it shows no face and gives no facial features. Framing: a portrait of head, neck and shoulders only, centered, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. The top of the hair is about 8% below the top edge, the chin is at about 59% of the image height, and the face is about 48% of the image width. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
5
docs/avatar-art/linocut-cast-prompts/P7-front.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
A Korean high-school senior boy of eighteen, tall and lanky, visibly burnt out. Short black hair flattened on one side and sticking up at the back as if he just got out of bed, with an uneven side part. Heavy drooping upper eyelids, dark circles under his eyes, a pale, slightly sallow complexion, and a faint shadow of an unshaven upper lip. He wears a rumpled charcoal-grey crewneck sweatshirt with the wrinkled white collar of a school shirt showing at the neck. Slumped shoulders. Not a delinquent: an ordinary, exhausted student.
|
||||
|
||||
He must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin.
|
||||
|
||||
Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text.
|
||||
BIN
docs/avatar-art/linocut-cast-prompts/style-ref.png
Normal file
|
After Width: | Height: | Size: 930 KiB |
74
docs/avatar-art/linocut-cast.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# 리노컷 아바타 캐스트 설계 (P1~P7)
|
||||
|
||||
아바타 v3 리노컷 리그([결정문](../decisions/avatar-expression-engine-v3.md) §8)의 7명 외형·상징 설계다. 오케스트레이터가 캐릭터 카드(`apps/api/app/services/persona.py` SEED_PERSONAS, `data/personas/P4~P7.json`)와 성향(`apps/web/src/components/avatar/engine/demeanorDefaults.ts`)에서 끌어냈다. 4단계에서 캐릭터 카드 `avatar.look` 블록(§8.3)으로 옮긴다.
|
||||
|
||||
## 공통 원칙
|
||||
|
||||
- **한 판화가의 연작**처럼 화풍을 통일한다. 검은 남색 조각선, 크림 종이, 잉크 두 색(슬레이트 블루 `#53626C`, 황토 `#D0A362`)이다. 캐릭터는 얼굴·체형·헤어·의상과 **사람다운 디테일 하나**로 구분한다. 7월 래스터가 반려된 이유 중 하나가 "같은 얼굴에 머리만 다름"이었다.
|
||||
- 실제 한국인 한 사람처럼 그린다. 애니·웹툰·아이돌풍, 큰 눈, 속눈썹 강조, 미화를 금지한다. 교복을 캐릭터 기호로 쓰지 않는다(소유자 반려 사유: 애니풍 교복).
|
||||
- **리그 제약**
|
||||
- 정면, 입 다묾, 정면 응시, 어깨 수평, 머리·목·어깨만 그리고 손은 프레임 밖이다.
|
||||
- 눈·눈썹·입이 머리카락·손·소품에 가리지 않는다. 안경은 쓰지 않는다(벡터 눈 위에 테가 와야 하는데 head 레이어에 있으면 눈 아래로 깔린다).
|
||||
- 피부에 상처·자국을 그리지 않는다(자해 연상 금지).
|
||||
- 구도·크기·종이는 P1 정면 원화(`p1-linocut/base/base-front.png`)를 화풍·구도 참조로 쓴다. 같은 머리 크기·위치여야 같은 렌더러 변환 수치가 통한다.
|
||||
- 상징 모티프는 P1 봉오리와 같은 문법이다. 한 대상의 네 상태 — 닫힘·반쯤·활짝(개방도 0 → 1)과 처짐(부정 겉표정) — 를 같은 크기·같은 밑동으로 그린다. 날씨 5종은 P1 스프라이트를 모두가 함께 쓴다.
|
||||
|
||||
## 혼동 쌍 구분
|
||||
|
||||
| 쌍 | 공통점 | 구분 |
|
||||
|---|---|---|
|
||||
| P4 하늘 / P6 하린 | 상위권 여고생, 부모 기대 | P4: 낮게 묶은 긴 생머리·남색 스웨터·귀에 꽂은 연필·꼿꼿함 / P6: 반쯤 올려 집게핀으로 고정한 웨이브 머리·오프화이트 케이블 카디건·줄무늬 셔츠·목깃의 목탄 가루·움츠림 |
|
||||
| P5 도윤 / P7 도현 | 무덤덤한 남학생, 반존대 | P5: 15세 앳된 얼굴·검은 트랙 재킷·목에 건 유선 이어폰·경계하는 눈 / P7: 18세 마르고 긴 체형·눌린 뒷머리·회색 맨투맨 위로 구겨진 흰 셔츠 깃·무거운 눈꺼풀 |
|
||||
| P3 지우 / P6 하린 | 니트 상의 | P3: 황토색 니트 스웨터·낮은 올림머리 / P6: 오프화이트 카디건 |
|
||||
|
||||
## 캐릭터별
|
||||
|
||||
### P1 서연 (기존)
|
||||
- 고2 여학생, 우울·자퇴 고민. 어깨 길이의 흐트러진 흑갈색 머리를 한쪽 귀 뒤로 넘김, 왼눈 밑 점, 잠 못 잔 그늘, 바랜 차콜 후드에 흰 티, 처진 어깨
|
||||
- 상징: 꽃봉오리(닫힘·반쯤·활짝·고개 숙임)
|
||||
|
||||
### P2 민재
|
||||
- 32세 남성 직장인, 범불안·신체화, 완벽주의·점검, 장황하고 빠른 말, 어깨가 올라가 있다.
|
||||
- 외형: 마르고 각진 얼굴, 턱의 긴장, 미간의 옅은 세로 주름, 지쳤지만 경계하는 눈과 옅은 눈 밑 그늘. 짧게 정돈한 검은 옆가르마 머리는 손으로 자꾸 쓸어 눌렸고 정수리에 작은 뻗침이 있다. 면도한 턱에 옅은 수염 그늘.
|
||||
- 의상: 맨 위 단추를 푼 연청색 셔츠(넥타이 없음) 위 진회색 브이넥 니트, 니트 안으로 넣은 **사원증 목줄**(끈만 보임). 어깨가 굳어 살짝 올라감.
|
||||
- 상징: **회중시계** — 닫힘: 뚜껑이 닫히고 줄이 촘촘히 감김 / 반쯤: 뚜껑이 조금 열려 문자판이 보임 / 활짝: 뚜껑이 활짝 열려 차분한 문자판 / 처짐: 닫힌 시계가 엉킨 줄 끝에 매달려 늘어짐. 통제·점검·시간 압박.
|
||||
|
||||
### P3 지우
|
||||
- 28세 여성 미혼모(2세 아이), 소진·외로움, 따뜻하지만 지친 톤, 혼자 짊어진다.
|
||||
- 외형: 부드럽고 따뜻한 인상, 눈에 띄게 지친 얼굴 — 무거운 아래 눈꺼풀, 눈 밑 그늘, 마른 입술, 화장기 없음. 흑갈색 머리를 낮게 대충 틀어 올리고 잔머리가 얼굴과 귀 옆으로 흘러내린다.
|
||||
- 의상: 보풀 핀 **황토색 니트 스웨터**(어깨 쪽이 조금 늘어남) 안에 크림색 티셔츠, **어깨 근처의 옅은 우유 얼룩**. 어깨가 조금 처짐.
|
||||
- 상징: **촛불** — 닫힘: 아주 작고 낮은 불꽃 / 반쯤: 조금 자란 불꽃 / 활짝: 안정된 따뜻한 불꽃과 둥근 빛무리 / 처짐: 꺼질 듯 흔들리는 불꽃과 가는 연기, 흘러내린 촛농. 남을 위해 태우는 사람.
|
||||
|
||||
### P4 하늘
|
||||
- 고2 여학생, 시험불안·완벽주의, 반장, 또박또박·자기검열, 어깨를 올리고 눈을 약간 크게 뜬다.
|
||||
- 외형: 단정하게 꾸민 얼굴, 지나치게 깨어 있는 눈과 옅은 수면 부족 그늘, 긴장한 입. 긴 검은 생머리를 목덜미에서 낮게 묶었고, 관자놀이에 짧은 잔머리 몇 가닥이 삐져나온다. 앞머리 없음(눈썹이 다 보임).
|
||||
- 의상: 흰 깃 셔츠 위 남색 크루넥 스웨터, 깃을 반듯하게 정리, **오른쪽 귀에 꽂은 연필**. 아주 꼿꼿한 자세에 어깨가 굳음.
|
||||
- 상징: **연** — 닫힘: 연이 접혀 땅에 놓이고 실이 얼레에 꽉 감김 / 반쯤: 연이 조금 떠오르고 실이 팽팽함 / 활짝: 느슨하게 굽은 실과 꼬리를 달고 높이 남 / 처짐: 앙상한 가지에 걸려 늘어지고 꼬리가 엉킴.
|
||||
|
||||
### P5 도윤
|
||||
- 중3 남학생, 또래 무리에서 빠짐(단톡방), 관찰력·그림 취미, 무던한 척 눈치 봄, 시선을 옆으로, 입을 크게 안 벌린다.
|
||||
- 외형: 마르고 아직 앳된 15세 얼굴. 손대지 않은 흑갈색 짧은 머리, 뻗친 가마. 낮은 윗눈꺼풀의 가늘고 곧은 눈(속눈썹 강조 없음), 경계하며 살피는 눈빛. 이마와 턱에 작은 여드름 두어 개.
|
||||
- 의상: 반쯤 잠근 **큰 검은 집업 트랙 재킷** 안에 회색 티셔츠, **목에 느슨하게 건 유선 이어폰**. 어깨를 살짝 웅크리고 고개를 조금 숙임.
|
||||
- 상징: **참새** — 닫힘: 깃털을 부풀리고 머리를 깃에 묻음 / 반쯤: 고개를 들고 옆을 봄 / 활짝: 부리를 벌려 지저귀며 한쪽 날개를 조금 듦 / 처짐: 머리를 숙이고 깃털이 헝클어진 채 웅크림. 모두 짧은 맨 가지 위.
|
||||
|
||||
### P6 하린
|
||||
- 고3 여학생, 의대와 미술 사이의 진로 갈등, 몰래 미술 실기, 개방성 최고, 똑부러지지만 지친 톤, 손톱을 만지작거린다.
|
||||
- 외형: 총명하고 말을 잘할 것 같지만 지친 얼굴, 생각이 많은 눈과 밤샘의 그늘, 조금 튼 입술. 자연스러운 웨이브의 중단발 흑갈색 머리를 윗부분만 장식 없는 집게핀으로 느슨하게 올렸고 몇 가닥이 흘러내린다.
|
||||
- 의상: 큰 **오프화이트 케이블 카디건** 안에 회색·흰색 줄무늬 셔츠, **카디건 깃 가장자리에 옅게 묻은 목탄 가루**. 어깨를 조금 안으로 모음.
|
||||
- 상징: **고치와 나비** — 닫힘: 가지에 매달린 고치 / 반쯤: 고치가 갈라지고 구겨진 날개가 나옴 / 활짝: 황토·슬레이트 날개를 편 나비 / 처짐: 힘없이 늘어져 말라 가는 고치.
|
||||
|
||||
### P7 도현
|
||||
- 고3 남학생, 입시 번아웃·무쾌감, 폰과 잠으로 도피, 무덤덤·자조, 눈을 가장 많이 내리고 고개를 숙이며 가장 작게 웅얼거린다.
|
||||
- 외형: 키가 크고 마른 체형, 눈에 띄게 지친 얼굴. 한쪽이 눌리고 뒷머리가 뻗친 짧은 검은 머리(방금 일어난 듯), 고르지 않은 옆가르마. 무겁게 처진 윗눈꺼풀, 짙은 눈 밑 그늘, 창백하고 누르스름한 안색, 깎지 않은 옅은 콧수염 그늘. 불량학생이 아니라 평범한 지친 학생.
|
||||
- 의상: 구겨진 **차콜 회색 맨투맨** 목 위로 구겨진 흰 셔츠 깃. 어깨가 처짐.
|
||||
- 상징: **달팽이** — 닫힘: 껍데기 속에 완전히 숨음 / 반쯤: 머리를 조금 내밂 / 활짝: 더듬이를 세우고 앞으로 미끄러지며 옅은 자국을 남김 / 처짐: 숨은 채 껍데기가 옆으로 기울어짐.
|
||||
|
||||
## 생성 프롬프트 (정면 원화 후보)
|
||||
|
||||
codex `image_gen`, 1024×1536 high, 참조 이미지 = `docs/avatar-art/p1-linocut/base/base-front.png`. 프롬프트 = 머리말 + [인물] + [공통 제약] + [구도·화풍].
|
||||
|
||||
- 머리말(호출 지시): "Call the built-in image_gen tool immediately, using the attached image only as the style and framing reference. Do not read any files, do not use any skill, do not ask questions. Invoke the image_gen tool once with size 1024x1536 and high quality, using exactly this prompt, then save the resulting image into the current directory as <파일명>.png:"
|
||||
- [공통 제약]: "He/She must look like one specific real person, not a generic attractive person. Natural, realistic Korean facial features: eyes of natural human size (not enlarged), a slightly uneven face, a real nose and lips, natural skin texture. Eyes, eyebrows and mouth are fully visible: no hair, hands or objects over them, and no glasses. Dignified and deeply human. Strictly NOT anime, NOT manga, NOT webtoon, NOT K-pop idol styling, no big shiny eyes, no eyelash emphasis, no glamour. No marks or wounds on the skin."
|
||||
- [구도·화풍]: "Draw a completely different person from the one in the reference image. Keep exactly the same linocut relief-print style as the reference: bold carved black-indigo lines with visible gouge marks, flat areas of only two additional ink colors (muted slate blue and warm ochre) printed on cream paper with slight ink texture and imperfect registration. Keep exactly the same framing, scale and position as the reference: head, neck and shoulders only, the head the same size and at the same height in the frame, facing the viewer straight on (frontal, symmetrical), neutral closed mouth, eyes open looking straight at the viewer, shoulders level, hands out of frame. Plain flat cream background with nothing else: no flowers, no clouds, no motifs, no text."
|
||||
|
||||
[인물] 문단(영문)은 생성 패킷에 있다(`linocut-cast-prompts/P*.txt`).
|
||||
149
docs/avatar-art/linocut-pipeline/README.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# 공통 리노컷 아바타 파이프라인
|
||||
|
||||
아바타 v3 리노컷 리그(결정문 [`avatar-expression-engine-v3.md`](../../decisions/avatar-expression-engine-v3.md)
|
||||
§8.2 리그 원칙)의 자산 파이프라인이다. P1~P7 등 모든 페르소나가 이 공통 스크립트를 공유하고,
|
||||
페르소나별 차이는 각 페르소나 폴더(예: `../p1-linocut/`)의 `persona.json`에서만 읽는다.
|
||||
|
||||
결과물(레이어 픽셀, 알파, 게시된 WebP, 리그 TS)은 페르소나별 폴더 구조나 상수 위치가
|
||||
바뀌었다는 이유로 달라지지 않는다 — 알고리즘은 건드리지 않았다(P1 회귀 검증: 아래 참고).
|
||||
|
||||
## 단계와 의존 순서
|
||||
|
||||
`run_pipeline.py`가 아래 순서로 각 단계를 독립 프로세스로 실행한다(괄호는 산출물):
|
||||
|
||||
1. `landmarks.py` — base-front.png 랜드마크 검출 (`manifest.landmarks`)
|
||||
2. `brow_centerline.py` — `manifest.landmarks.eyebrowLeft/Right`를 잉크 띠 중심선으로 보정
|
||||
3. `segmentation.py` — `layers/{body,head,hairFront}.png`(v1) + manifest 기준 섹션
|
||||
4. `layers_v2.py` — `layers/v2/{body,head,hairFront}.png`(턱 밑 띠·잔머리 halo 보정)
|
||||
5. `face_detail.py` — `layers/v2/face-detail.png`
|
||||
6. `paper_grain.py` — `layers/v2/paper-grain.png`(다른 단계와 독립, 순서 유연)
|
||||
7. `lip_texture.py` — `layers/v2/lip-{upper,lower,shadow}.png`
|
||||
8. `jaw_pieces.py` — `layers/v2/jaw-{head,detail}.png`
|
||||
9. `export_rig.py` — WebP 게시(`apps/web/public/avatar/v3/<publicSlug>/`) + `rigs/<rigFileName>` 생성
|
||||
10. `final_previews.py` — 게시된 WebP로 모션·눈/입 확대 미리보기
|
||||
|
||||
이 순서는 전달받은 작업 설명의 번호(주제별 묶음)와 다르다 — 특히 `final_previews`는
|
||||
`export_rig`가 쓴 `export-rig-report.json`을 읽으므로 반드시 `export_rig` *다음*이어야
|
||||
한다. `face_detail`·`jaw_pieces`는 `manifest.landmarks`(1~2단계가 채움)와
|
||||
`layers/v2/{head,hairFront,body}.png`(3~4단계)가 먼저 있어야 한다.
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
# 전체 실행
|
||||
<venv>/python.exe run_pipeline.py <persona-dir>
|
||||
|
||||
# 한 단계만 다시 실행
|
||||
<venv>/python.exe run_pipeline.py <persona-dir> --only face_detail
|
||||
|
||||
# 중간부터 끝까지
|
||||
<venv>/python.exe run_pipeline.py <persona-dir> --from lip_texture
|
||||
|
||||
# 단계 이름 목록
|
||||
<venv>/python.exe run_pipeline.py --list
|
||||
```
|
||||
|
||||
각 단계 스크립트는 `python <script>.py <persona-dir>`로 단독 실행도 된다(디버깅용).
|
||||
|
||||
**주의**: `landmarks.py`만 혼자 다시 돌리면 `manifest.landmarks`를 통째로 새로 써서
|
||||
`brow_centerline.py`가 보정한 눈썹 중심선이 사라진다. `--only landmarks`를 쓴 뒤에는
|
||||
`--only brow_centerline`도 반드시 같이 돌려야 한다(자동으로 뒤따라 돌지 않는다).
|
||||
|
||||
## persona.json 스키마
|
||||
|
||||
페르소나 폴더(예: `../p1-linocut/persona.json`)에 둔다. 랜드마크·분할로 계산 가능한
|
||||
값은 각 단계 스크립트가 직접 계산하므로 여기 없다 — 원화마다 달라지고 유도할 수 없는
|
||||
값(참조 이미지를 보고 사람이 고른 점·상자)만 이 파일에 둔다.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"code": "P1", // 필수. manifest.persona, rig.persona
|
||||
"publicSlug": "p1", // 생략 시 code.lower(). apps/web/public/avatar/v3/<publicSlug>/
|
||||
"rigFileName": "p1Rig.ts", // 생략 시 "<publicSlug>Rig.ts"
|
||||
"rigExportName": "P1_LINOCUT_RIG", // 생략 시 "<CODE>_LINOCUT_RIG"
|
||||
|
||||
// 모티프 팔레트(motifPetal/motifLeaf) 표본을 뽑을 스타일 참조 이미지(persona.json 기준 상대경로).
|
||||
// export_rig.py의 팔레트 계산에서만 쓴다 — 모티프 스프라이트 자체는 이번 파이프라인
|
||||
// 범위 밖이다(오케스트레이터가 따로 설계).
|
||||
"styleFrame": "../art-direction-v3/p1/r2-b-linocut.png",
|
||||
|
||||
"paletteSamples": {
|
||||
// 머리카락 어두운 덩어리 표본(ink 팔레트색). base-front.png 픽셀 기준 상자.
|
||||
"ink": { "box": [280, 100, 720, 350], "lumThreshold": 55 },
|
||||
// 눈 흰자/홍채/홍채테 고정 설계값(결정문 §8.2 "고정값" 원칙) — 생략하면 공통 기본값 사용.
|
||||
"eyeOverride": { "sclera": "#D8CEBD", "iris": "#4F3B2C", "irisRing": "#1E1F1F" },
|
||||
// styleFrame에서 꽃잎(ochre)·잎/구름(blue) 색을 뽑을 상자들. kind는 "ochre" 또는 "blue".
|
||||
"motifPetalBoxes": [{ "label": "sun", "kind": "ochre", "box": [1230, 10, 1536, 210] }],
|
||||
"motifLeafBoxes": [{ "label": "cloudLeft", "kind": "blue", "box": [20, 20, 380, 190] }]
|
||||
},
|
||||
|
||||
"faceDetail": {
|
||||
// 점(기미) 등 랜드마크로 안 나오는 얼굴 반점. 없으면 빈 배열(점 없는 캐릭터도 된다).
|
||||
"moles": [{ "center": [661.3, 627.9], "radius": 20.0 }]
|
||||
},
|
||||
|
||||
// 렌더러 회전/스케일 중심점(결정문 §8.4). 원화를 보고 목·몸통·얼굴 중심을 정한다.
|
||||
"pivots": { "neck": [500, 990], "body": [502, 1566], "face": [490, 660] },
|
||||
|
||||
// bust 크롭은 정사각형(변 = 캔버스 폭)이고 위쪽 오프셋만 여기서 정한다.
|
||||
"crops": { "bustYOffset": 40 }
|
||||
}
|
||||
```
|
||||
|
||||
`eyeOverride`·`paletteFixed`(mouthInner/teeth/blush/tear/pallor/paper)·`backdrop`(겉표정
|
||||
그룹별 배경색)은 모든 페르소나가 공유하는 기본값이 있다(`persona_config.py`의
|
||||
`DEFAULT_*`) — 캐릭터마다 다르게 할 필요가 있을 때만 persona.json에 적어 덮어쓴다.
|
||||
|
||||
`styleFrame`·`paletteSamples.ink`·`paletteSamples.motifPetalBoxes`/`motifLeafBoxes`·
|
||||
`pivots`는 **필수**다(`export_rig.py`가 해당 값을 쓰는 시점에 없으면 어떤 필드를
|
||||
채워야 하는지 알려주며 멈춘다).
|
||||
|
||||
### `faceDetail.browLandmarksOverride` — P1 전용 호환 장치, 새 페르소나는 쓰지 않는다
|
||||
|
||||
```jsonc
|
||||
"faceDetail": {
|
||||
"browLandmarksOverride": {
|
||||
"browLeft": { "inner": [..], "peak": [..], "outer": [..] },
|
||||
"browRight": { "inner": [..], "peak": [..], "outer": [..] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
있으면 `face_detail.py`가 눈썹 제외 영역·눈 영역 y0 계산에 `manifest.landmarks`의
|
||||
현재(중심선 보정) 눈썹 좌표 대신 이 값을 쓴다. 다른 랜드마크(눈·입·코·턱)는 그대로
|
||||
현재 값을 쓴다 — 눈썹만 바꾼다.
|
||||
|
||||
P1의 기존 `face-detail.png`·`jaw-detail.png`·해당 webp·`p1Rig.ts`는 눈썹 중심선 보정
|
||||
(`manifest.browCenterline`) **이전** 좌표(`browCenterline.oldPoints`)로 빌드된 뒤
|
||||
"다시 빌드하지 않는다"는 오케스트레이터 지시로 고정됐다(소유자도 그 결과를 검수했다).
|
||||
그래서 P1 persona.json에는 `browCenterline.oldPoints`와 같은 값을 넣어 재실행 결과가
|
||||
그 고정본과 바이트 단위로 같아지게 한다. **새 페르소나는 이 필드를 넣지 않는다** —
|
||||
처음부터 중심선 보정 좌표로 빌드되므로 과거 좌표를 따로 고정할 이유가 없다.
|
||||
|
||||
## 모델 파일(저장소에 없음)
|
||||
|
||||
`scripts/_models/`에 MediaPipe 모델을 받아 둔다(모든 페르소나가 공유, `.gitignore`의
|
||||
`docs/avatar-art/*/scripts/_models/` 패턴에 그대로 맞는다).
|
||||
|
||||
- `face_landmarker.task`: https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task
|
||||
- `selfie_multiclass_256x256.tflite`: https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_multiclass_256x256/float32/latest/selfie_multiclass_256x256.tflite
|
||||
|
||||
python 환경은 numpy·Pillow·scipy·opencv·mediapipe가 필요하다.
|
||||
|
||||
## 새 페르소나를 추가할 때 사람이 해야 하는 일
|
||||
|
||||
이 파이프라인은 자동으로 원화를 만들지 않는다. 사람(또는 다른 워커)이 먼저 준비해야
|
||||
하는 것:
|
||||
|
||||
1. 원화 2장: `<persona-dir>/base/base-front.png`(정면 기본형)과
|
||||
`base-faceless.png`(같은 그림에서 눈·눈썹·입만 지운 것). 결정문 §8.2 생성 규칙을 따른다.
|
||||
2. `<persona-dir>/raw/body.png`: body 레이어가 head_mask로 가려지는 목 상단 영역을
|
||||
메울 재생성 참조 편집본(크로마키 초록 배경, base-front와 같은 정렬).
|
||||
3. `<persona-dir>/persona.json`: 위 스키마대로 작성한다. 특히 `styleFrame`·
|
||||
`paletteSamples`·`pivots`는 원화를 눈으로 보고 정해야 한다(자동 유도 불가).
|
||||
4. (선택) `<persona-dir>/motif/`: 모티프 스프라이트는 이 라운드 범위 밖이다 — 오케스트레이터가
|
||||
따로 설계한다. 없으면 `export_rig.py`가 모티프 없는 리그를 만든다(rig.motif 생략).
|
||||
5. `scripts/_models/`에 모델 파일이 없으면 받아 둔다(위 링크, 한 번만).
|
||||
|
||||
그 다음 `run_pipeline.py <persona-dir>`를 실행하고, 출력된 검사 수치(halo%, 평균절대차
|
||||
등 — 각 단계 스크립트가 콘솔에 찍고 manifest.json에도 남긴다)를 기준치와 비교해 판정한다.
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"""단독 프로세스로 FaceLandmarker를 실행해 478 랜드마크를 JSON으로 출력한다.
|
||||
(같은 프로세스에서 ImageSegmenter와 함께 쓰면 세그폴트가 재현되어 분리했다.)
|
||||
|
||||
사용: python _run_face_landmarks.py <이미지경로> <출력json경로>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import mediapipe as mp
|
||||
import numpy as np
|
||||
from mediapipe.tasks import python as mp_python
|
||||
from mediapipe.tasks.python import vision
|
||||
from PIL import Image
|
||||
|
||||
MODEL_FACE = Path(__file__).resolve().parent / "_models" / "face_landmarker.task"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
image_path = Path(sys.argv[1])
|
||||
out_path = Path(sys.argv[2])
|
||||
|
||||
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_FACE))
|
||||
options = vision.FaceLandmarkerOptions(
|
||||
base_options=base_options, running_mode=vision.RunningMode.IMAGE, num_faces=1
|
||||
)
|
||||
im = Image.open(image_path).convert("RGB")
|
||||
w, h = im.size
|
||||
arr = np.array(im)
|
||||
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
|
||||
with vision.FaceLandmarker.create_from_options(options) as landmarker:
|
||||
result = landmarker.detect(mp_image)
|
||||
|
||||
if not result.face_landmarks:
|
||||
out_path.write_text(json.dumps({"ok": False}), encoding="utf-8")
|
||||
print("FACE_LANDMARKS_FAILED")
|
||||
return 1
|
||||
|
||||
lm = result.face_landmarks[0]
|
||||
pts = [[p.x * w, p.y * h] for p in lm]
|
||||
out_path.write_text(json.dumps({"ok": True, "width": w, "height": h, "points": pts}), encoding="utf-8")
|
||||
print(f"FACE_LANDMARKS_OK n={len(pts)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"""단독 프로세스로 ImageSegmenter(selfie_multiclass_256x256)를 실행해
|
||||
category_mask를 .npy로 저장한다. (세그폴트 회피를 위해 FaceLandmarker와 분리.)
|
||||
|
||||
사용: python _run_segmentation.py <이미지경로> <출력npy경로>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import mediapipe as mp
|
||||
import numpy as np
|
||||
from mediapipe.tasks import python as mp_python
|
||||
from mediapipe.tasks.python import vision
|
||||
from PIL import Image
|
||||
|
||||
MODEL_SEG = Path(__file__).resolve().parent / "_models" / "selfie_multiclass_256x256.tflite"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
image_path = Path(sys.argv[1])
|
||||
out_path = Path(sys.argv[2])
|
||||
|
||||
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_SEG))
|
||||
options = vision.ImageSegmenterOptions(
|
||||
base_options=base_options, output_confidence_masks=False, output_category_mask=True
|
||||
)
|
||||
im = Image.open(image_path).convert("RGB")
|
||||
arr = np.array(im)
|
||||
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
|
||||
with vision.ImageSegmenter.create_from_options(options) as seg:
|
||||
result = seg.segment(mp_image)
|
||||
|
||||
if result.category_mask is None:
|
||||
print("SEGMENTATION_FAILED")
|
||||
return 1
|
||||
|
||||
category_mask = result.category_mask.numpy_view()[:, :, 0].copy()
|
||||
np.save(out_path, category_mask)
|
||||
print(f"SEGMENTATION_OK shape={category_mask.shape}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
374
docs/avatar-art/linocut-pipeline/scripts/brow_centerline.py
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
"""공통 리노컷 리그 — 눈썹 랜드마크를 잉크 띠 중심선으로 정정.
|
||||
|
||||
1차(반려)는 front-F 밝기 차이만으로 마스크를 만들어 눈두덩 그늘 해칭까지
|
||||
눈썹으로 잡았고, 열별 지역 평활(Savitzky-Golay)이 평평한 눈썹에서 물결치며
|
||||
peak를 안쪽 끝 열에 찍었다(오케스트레이터 판정, 2단계-B-1d).
|
||||
|
||||
이번 버전:
|
||||
- 마스크 = (front 휘도가 눈썹 bbox 안 Otsu 임계값 이하, 짙은 잉크) AND
|
||||
(front가 F보다 25 이상 어두움, 머리카락·눈꺼풀 크레이스 등 F에도 있는
|
||||
구조물 제외). 두 조건의 교집합이라 중간 톤 그늘 해칭은 Otsu 임계값보다
|
||||
밝아 빠진다.
|
||||
- 눈썹은 조각도 해칭 때문에 잉크가 여러 조각으로 끊겨 있다(단일 최대
|
||||
연결성분만 쓰면 절반 가까이 누락됨을 확인). 그래서 형태학적 closing으로
|
||||
같은 눈썹에 속한 조각을 하나로 묶은 뒤, 그 묶음 영역 안의 "원본"(닫기
|
||||
전) 마스크 픽셀만 최종 띠로 쓴다(닫기는 성분을 찾는 데만 쓰고 픽셀을
|
||||
부풀리지 않는다). 그 안에서 면적 30px 미만 잔점은 버린다.
|
||||
- 중심선은 전역 2차 다항식 하나를 열별 가중 무게중심에 적합한다(지역
|
||||
평활 대신 — 평평한 눈썹에서 물결을 만들지 않는다). 잔차 2배 표준편차를
|
||||
넘는 이상치 열을 한 번 제거하고 재적합한다.
|
||||
- peak는 적합 곡선의 꼭짓점(2차 다항식은 극값이 하나뿐이다)이 바깥 끝
|
||||
기준 25~60% 구간에 있으면 그 점, 아니면(구간 밖 = 그 구간에서 단조,
|
||||
또는 구간 안 높이 차 < 3px = 거의 평평) 바깥 끝에서 35% 지점의 곡선
|
||||
위 점을 쓴다.
|
||||
|
||||
전제: landmarks.py가 이미 manifest.landmarks를 채워 두어야 한다.
|
||||
|
||||
실행: <venv>/python.exe brow_centerline.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from scipy import ndimage
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
# bbox: old(mediapipe) 세 점 bbox에 이 여백을 더한 영역 안에서 잉크를 찾는다.
|
||||
BBOX_X_PAD = 25.0
|
||||
BBOX_Y_UP = 42.0
|
||||
BBOX_Y_DOWN = 14.0 # 30이면 눈꺼풀 크레이스(다크서클 경계) 잉크가 섞인다(탐색 확인).
|
||||
|
||||
DIFF_THRESH = 25.0 # front가 F보다 이만큼 어두우면 "front에만 있는 것"으로 본다
|
||||
CLOSING_KERNEL = (7, 11) # (세로, 가로) — 눈썹 해칭 조각을 같은 띠로 묶기 위한 형태학적 닫기
|
||||
MIN_COMPONENT_AREA = 30.0 # 닫기로 묶은 최대 성분 안에서, 이보다 작은 잔점은 버린다
|
||||
OUTLIER_STD_MULT = 2.0 # 잔차가 표준편차의 이 배수를 넘으면 이상치 열로 제거
|
||||
EDGE_INSET_FRAC = 0.03 # inner·outer = 마스크 열 범위 양끝에서 이 비율만큼 안쪽
|
||||
|
||||
PEAK_WINDOW = (0.25, 0.60) # 바깥 끝 기준 이 구간 안에 꼭짓점이 있으면 그 점을 쓴다
|
||||
PEAK_FALLBACK_FRAC = 0.35
|
||||
PEAK_FLAT_HEIGHT_PX = 3.0 # 이 구간 안 높이 차가 이보다 작으면 "거의 평평"으로 본다
|
||||
PEAK_FAIL_EDGE_FRAC = 0.20 # 최종 peak가 양끝 이 비율 안이면 실패
|
||||
|
||||
CHECK_A_MIN_FRAC = 0.80
|
||||
|
||||
EVIDENCE_MAX_SIDE = 1000
|
||||
EVIDENCE_ZOOM = 3
|
||||
|
||||
|
||||
def region_bbox_from_points(inner, peak, outer) -> tuple[int, int, int, int]:
|
||||
xs = [inner[0], peak[0], outer[0]]
|
||||
ys = [inner[1], peak[1], outer[1]]
|
||||
x0 = int(round(min(xs) - BBOX_X_PAD))
|
||||
x1 = int(round(max(xs) + BBOX_X_PAD))
|
||||
y0 = int(round(min(ys) - BBOX_Y_UP))
|
||||
y1 = int(round(max(ys) + BBOX_Y_DOWN))
|
||||
return x0, y0, x1, y1
|
||||
|
||||
|
||||
def build_ink_band(front_lum: np.ndarray, diff: np.ndarray, x0: int, y0: int, x1: int, y1: int) -> tuple[np.ndarray, float]:
|
||||
"""bbox 안에서 (Otsu 잉크) AND (front가 F보다 어두움) 마스크를 만들고,
|
||||
형태학적 닫기로 같은 눈썹 조각을 묶은 최대 성분 안의 원본 픽셀만
|
||||
남긴 뒤 작은 잔점을 버린다. 반환: (band bool 배열(bbox 로컬 좌표), otsu 임계값)."""
|
||||
region_lum = front_lum[y0:y1, x0:x1]
|
||||
u8 = np.clip(np.round(region_lum), 0, 255).astype(np.uint8)
|
||||
otsu_thresh, _ = cv2.threshold(u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||
ink_mask = region_lum <= otsu_thresh
|
||||
diff_region = diff[y0:y1, x0:x1]
|
||||
diff_mask = diff_region >= DIFF_THRESH
|
||||
combined = ink_mask & diff_mask
|
||||
|
||||
closed = ndimage.binary_closing(combined, structure=np.ones(CLOSING_KERNEL))
|
||||
labeled, n = ndimage.label(closed)
|
||||
if n == 0:
|
||||
raise SystemExit("[중단] 눈썹 bbox 안에 잉크 마스크가 전혀 없다.")
|
||||
sizes = ndimage.sum(closed, labeled, range(1, n + 1))
|
||||
top_label = int(np.argmax(sizes)) + 1
|
||||
band = combined & (labeled == top_label)
|
||||
|
||||
lbl2, n2 = ndimage.label(band)
|
||||
if n2 > 0:
|
||||
sizes2 = ndimage.sum(band, lbl2, range(1, n2 + 1))
|
||||
keep_labels = [i + 1 for i, s in enumerate(sizes2) if s >= MIN_COMPONENT_AREA]
|
||||
band = band & np.isin(lbl2, keep_labels)
|
||||
|
||||
return band, float(otsu_thresh)
|
||||
|
||||
|
||||
def column_weighted_centroid(band: np.ndarray, diff_region: np.ndarray, x0: int, y0: int) -> dict:
|
||||
colmask = band.any(axis=0)
|
||||
cols = np.where(colmask)[0]
|
||||
col_min, col_max = int(cols.min()), int(cols.max())
|
||||
xs_local = np.arange(col_min, col_max + 1)
|
||||
centroid_y = np.full(len(xs_local), np.nan)
|
||||
col_ylo = np.full(len(xs_local), np.nan)
|
||||
col_yhi = np.full(len(xs_local), np.nan)
|
||||
for i, cx in enumerate(xs_local):
|
||||
colpix = band[:, cx]
|
||||
if not colpix.any():
|
||||
continue
|
||||
yy = np.where(colpix)[0]
|
||||
w = diff_region[yy, cx]
|
||||
centroid_y[i] = np.average(yy, weights=w) + y0
|
||||
col_ylo[i] = yy.min() + y0
|
||||
col_yhi[i] = yy.max() + y0
|
||||
return {
|
||||
"col_min": col_min + x0, "col_max": col_max + x0,
|
||||
"xs_local": xs_local, "centroid_y": centroid_y,
|
||||
"col_ylo": col_ylo, "col_yhi": col_yhi, "x0": x0,
|
||||
}
|
||||
|
||||
|
||||
def fit_centerline(xs_abs: np.ndarray, ys: np.ndarray) -> tuple[np.poly1d, int]:
|
||||
coef = np.polyfit(xs_abs, ys, 2)
|
||||
fit = np.poly1d(coef)
|
||||
resid = ys - fit(xs_abs)
|
||||
std = resid.std()
|
||||
outlier = np.abs(resid) > OUTLIER_STD_MULT * std if std > 0 else np.zeros_like(resid, dtype=bool)
|
||||
n_outliers = int(outlier.sum())
|
||||
if n_outliers > 0 and (~outlier).sum() >= 3:
|
||||
coef2 = np.polyfit(xs_abs[~outlier], ys[~outlier], 2)
|
||||
fit = np.poly1d(coef2)
|
||||
return fit, n_outliers
|
||||
|
||||
|
||||
def find_peak(fit: np.poly1d, col_min: int, col_max: int, outer_edge_x: float, width: float) -> tuple[tuple[float, float], str]:
|
||||
a, b, _c = fit.coeffs
|
||||
xs_sample = np.linspace(col_min, col_max, 400)
|
||||
t_sample = np.abs(xs_sample - outer_edge_x) / width
|
||||
in_window = (t_sample >= PEAK_WINDOW[0]) & (t_sample <= PEAK_WINDOW[1])
|
||||
|
||||
use_fallback = True
|
||||
vertex_pt: tuple[float, float] | None = None
|
||||
if abs(a) > 1e-6:
|
||||
xv = -b / (2 * a)
|
||||
if col_min <= xv <= col_max:
|
||||
tv = abs(xv - outer_edge_x) / width
|
||||
if PEAK_WINDOW[0] <= tv <= PEAK_WINDOW[1]:
|
||||
window_ys = fit(xs_sample[in_window])
|
||||
height_diff = float(window_ys.max() - window_ys.min()) if window_ys.size else 0.0
|
||||
if height_diff >= PEAK_FLAT_HEIGHT_PX:
|
||||
vertex_pt = (float(xv), float(fit(xv)))
|
||||
use_fallback = False
|
||||
|
||||
if use_fallback:
|
||||
# 바깥 끝에서 안쪽으로 35% 지점
|
||||
if outer_edge_x <= col_min + 1e-6:
|
||||
fx = col_min + PEAK_FALLBACK_FRAC * width
|
||||
else:
|
||||
fx = col_max - PEAK_FALLBACK_FRAC * width
|
||||
peak_pt = (float(fx), float(fit(fx)))
|
||||
method = "fallback35"
|
||||
else:
|
||||
peak_pt = vertex_pt # type: ignore[assignment]
|
||||
method = "vertex"
|
||||
|
||||
return peak_pt, method
|
||||
|
||||
|
||||
def process_brow(name: str, front_lum: np.ndarray, diff: np.ndarray, old_pts: dict) -> dict:
|
||||
inner_old, peak_old, outer_old = old_pts["inner"], old_pts["peak"], old_pts["outer"]
|
||||
x0, y0, x1, y1 = region_bbox_from_points(inner_old, peak_old, outer_old)
|
||||
band, otsu_thresh = build_ink_band(front_lum, diff, x0, y0, x1, y1)
|
||||
diff_region = diff[y0:y1, x0:x1]
|
||||
cw = column_weighted_centroid(band, diff_region, x0, y0)
|
||||
|
||||
valid = ~np.isnan(cw["centroid_y"])
|
||||
xs_abs = cw["xs_local"][valid] + x0
|
||||
ys_valid = cw["centroid_y"][valid]
|
||||
fit, n_outliers = fit_centerline(xs_abs, ys_valid)
|
||||
|
||||
col_min, col_max = cw["col_min"], cw["col_max"]
|
||||
width = float(col_max - col_min)
|
||||
inset_px = width * EDGE_INSET_FRAC
|
||||
|
||||
left_is_outer = outer_old[0] < inner_old[0]
|
||||
if left_is_outer:
|
||||
outer_edge_x = float(col_min)
|
||||
inner_edge_x = float(col_max)
|
||||
outer_x = col_min + inset_px
|
||||
inner_x = col_max - inset_px
|
||||
else:
|
||||
outer_edge_x = float(col_max)
|
||||
inner_edge_x = float(col_min)
|
||||
outer_x = col_max - inset_px
|
||||
inner_x = col_min + inset_px
|
||||
|
||||
inner_pt = (round(float(inner_x), 2), round(float(fit(inner_x)), 2))
|
||||
outer_pt = (round(float(outer_x), 2), round(float(fit(outer_x)), 2))
|
||||
peak_pt_raw, peak_method = find_peak(fit, col_min, col_max, outer_edge_x, width)
|
||||
peak_pt = (round(peak_pt_raw[0], 2), round(peak_pt_raw[1], 2))
|
||||
peak_frac = abs(peak_pt_raw[0] - outer_edge_x) / width
|
||||
peak_fail = peak_frac < PEAK_FAIL_EDGE_FRAC or peak_frac > (1.0 - PEAK_FAIL_EDGE_FRAC)
|
||||
|
||||
# 검사(a): 열의 80% 이상에서 적합 곡선 y가 그 열 마스크의 위-아래 끝 사이
|
||||
n_ok = 0
|
||||
n_total = int(valid.sum())
|
||||
for i in range(len(cw["xs_local"])):
|
||||
if not valid[i]:
|
||||
continue
|
||||
cx_abs = cw["xs_local"][i] + x0
|
||||
fy = float(fit(cx_abs))
|
||||
if cw["col_ylo"][i] <= fy <= cw["col_yhi"][i]:
|
||||
n_ok += 1
|
||||
frac_ok = n_ok / n_total if n_total else 0.0
|
||||
|
||||
return {
|
||||
"name": name, "bbox": [x0, y0, x1, y1], "otsuThresh": otsu_thresh,
|
||||
"colRange": [col_min, col_max], "width": width,
|
||||
"nColumns": n_total, "nOutliersRemoved": n_outliers,
|
||||
"fitCoeffs": [round(float(c), 8) for c in fit.coeffs],
|
||||
"inner": inner_pt, "peak": peak_pt, "outer": outer_pt,
|
||||
"peakMethod": peak_method, "peakFracFromOuterEdge": round(float(peak_frac), 4),
|
||||
"peakFail": bool(peak_fail),
|
||||
"checkA_fracColumnsFitWithinMask": round(frac_ok, 4),
|
||||
"checkA_pass": frac_ok >= CHECK_A_MIN_FRAC,
|
||||
"band": band, "bandX0": x0, "bandY0": y0,
|
||||
"fit": fit, "colMin": col_min, "colMax": col_max,
|
||||
}
|
||||
|
||||
|
||||
def draw_evidence_panel(front_img: Image.Image, old_pts: dict, result: dict) -> Image.Image:
|
||||
x0, y0, x1, y1 = result["bbox"]
|
||||
pad = 15
|
||||
box = (max(0, x0 - pad), max(0, y0 - pad), x1 + pad, y1 + pad)
|
||||
crop = front_img.crop(box).convert("RGB")
|
||||
crop = crop.resize((crop.width * EVIDENCE_ZOOM, crop.height * EVIDENCE_ZOOM), Image.LANCZOS)
|
||||
d = ImageDraw.Draw(crop)
|
||||
ox, oy = box[0], box[1]
|
||||
|
||||
def to_panel(px, py):
|
||||
return ((px - ox) * EVIDENCE_ZOOM, (py - oy) * EVIDENCE_ZOOM)
|
||||
|
||||
# 마스크 윤곽선(노랑)
|
||||
band_u8 = (result["band"].astype(np.uint8)) * 255
|
||||
contours, _ = cv2.findContours(band_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
bx0, by0 = result["bandX0"], result["bandY0"]
|
||||
for cnt in contours:
|
||||
pts = [to_panel(p[0][0] + bx0, p[0][1] + by0) for p in cnt]
|
||||
if len(pts) >= 2:
|
||||
d.line(pts + [pts[0]], fill=(230, 200, 0), width=2)
|
||||
|
||||
# 적합 중심선(청록)
|
||||
fit = result["fit"]
|
||||
xs_line = np.linspace(result["colMin"], result["colMax"], 200)
|
||||
ys_line = fit(xs_line)
|
||||
line_pts = [to_panel(x, y) for x, y in zip(xs_line, ys_line)]
|
||||
d.line(line_pts, fill=(0, 200, 200), width=2)
|
||||
|
||||
for key in ("inner", "peak", "outer"):
|
||||
ox_pt, oy_pt = old_pts[key]
|
||||
px, py = to_panel(ox_pt, oy_pt)
|
||||
d.ellipse([px - 5, py - 5, px + 5, py + 5], outline=(230, 30, 30), width=2)
|
||||
nx_pt, ny_pt = result[key]
|
||||
px2, py2 = to_panel(nx_pt, ny_pt)
|
||||
d.ellipse([px2 - 5, py2 - 5, px2 + 5, py2 + 5], fill=(0, 220, 220))
|
||||
|
||||
return crop
|
||||
|
||||
|
||||
def main(persona_dir: Path) -> int:
|
||||
cfg = load_persona_config(persona_dir)
|
||||
base_dir = cfg.base_dir
|
||||
preview_v2_dir = cfg.preview_v2_dir
|
||||
manifest_path = cfg.manifest_path
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
lm = manifest["landmarks"]
|
||||
|
||||
front = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
|
||||
faceless = np.array(Image.open(base_dir / "base-faceless-padded.png").convert("RGB")).astype(np.float64)
|
||||
front_lum = front.mean(axis=2)
|
||||
faceless_lum = faceless.mean(axis=2)
|
||||
diff = np.clip(faceless_lum - front_lum, 0.0, None)
|
||||
|
||||
# 재실행 멱등성: 이 스크립트가 landmarks.eyebrowLeft/Right를 덮어쓰므로,
|
||||
# 진짜 mediapipe 원본은 browCenterline.oldPoints에 한 번 고정해 두고
|
||||
# 재실행 시 거기서 읽는다(자기가 쓴 결과를 다시 원본으로 오인하지 않는다).
|
||||
prev_old_points = manifest.get("browCenterline", {}).get("oldPoints")
|
||||
if prev_old_points is not None:
|
||||
browL_old = prev_old_points["browLeft"]
|
||||
browR_old = prev_old_points["browRight"]
|
||||
else:
|
||||
browL_old = lm["eyebrowLeft"]
|
||||
browR_old = lm["eyebrowRight"]
|
||||
|
||||
resultL = process_brow("browLeft", front_lum, diff, browL_old)
|
||||
resultR = process_brow("browRight", front_lum, diff, browR_old)
|
||||
|
||||
for result, old_pts in ((resultL, browL_old), (resultR, browR_old)):
|
||||
print(f"=== {result['name']} ===")
|
||||
print(f" bbox={result['bbox']} otsuThresh={result['otsuThresh']} colRange={result['colRange']}")
|
||||
print(f" fit={result['fitCoeffs']} outliers제거={result['nOutliersRemoved']}/{result['nColumns']}")
|
||||
print(f" old inner={old_pts['inner']} peak={old_pts['peak']} outer={old_pts['outer']}")
|
||||
print(f" new inner={result['inner']} peak={result['peak']}({result['peakMethod']}) outer={result['outer']}")
|
||||
print(f" 검사(a) 열 포함 비율 = {result['checkA_fracColumnsFitWithinMask']} (기준>=0.80) {'OK' if result['checkA_pass'] else '[실패]'}")
|
||||
print(f" peak 위치비율(바깥끝기준) = {result['peakFracFromOuterEdge']} {'[실패:양끝20% 안]' if result['peakFail'] else 'OK'}")
|
||||
|
||||
dropL = resultL["inner"][1] - resultL["peak"][1]
|
||||
dropR = resultR["inner"][1] - resultR["peak"][1]
|
||||
print(f"검사(b) browLeft inner.y-peak.y = {dropL:.2f} (예상 0~20)")
|
||||
print(f"검사(b) browRight inner.y-peak.y = {dropR:.2f} (예상 0~20)")
|
||||
|
||||
inner_y_symmetry = abs(resultL["inner"][1] - resultR["inner"][1])
|
||||
peak_y_symmetry = abs(resultL["peak"][1] - resultR["peak"][1])
|
||||
print(f"검사(d) 좌우 inner.y 차 = {inner_y_symmetry:.2f}")
|
||||
print(f"검사(d) 좌우 peak.y 차 = {peak_y_symmetry:.2f}")
|
||||
|
||||
print(f"검사(c) browLeft peak 위치비율 = {resultL['peakFracFromOuterEdge']} ({resultL['peakMethod']})")
|
||||
print(f"검사(c) browRight peak 위치비율 = {resultR['peakFracFromOuterEdge']} ({resultR['peakMethod']})")
|
||||
|
||||
front_img = Image.open(base_dir / "base-front.png").convert("RGB")
|
||||
panelL = draw_evidence_panel(front_img, browL_old, resultL)
|
||||
panelR = draw_evidence_panel(front_img, browR_old, resultR)
|
||||
gap = 20
|
||||
combined = Image.new("RGB", (panelL.width + panelR.width + gap, max(panelL.height, panelR.height)), (255, 255, 255))
|
||||
combined.paste(panelL, (0, 0))
|
||||
combined.paste(panelR, (panelL.width + gap, 0))
|
||||
scale = min(1.0, EVIDENCE_MAX_SIDE / max(combined.size))
|
||||
if scale < 1.0:
|
||||
combined = combined.resize((round(combined.width * scale), round(combined.height * scale)), Image.LANCZOS)
|
||||
preview_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
evidence_path = preview_v2_dir / "brow-centerline.jpg"
|
||||
combined.convert("RGB").save(evidence_path, "JPEG", quality=90)
|
||||
print(f"저장: {evidence_path}")
|
||||
|
||||
lm["eyebrowLeft"] = {"inner": list(resultL["inner"]), "peak": list(resultL["peak"]), "outer": list(resultL["outer"])}
|
||||
lm["eyebrowRight"] = {"inner": list(resultR["inner"]), "peak": list(resultR["peak"]), "outer": list(resultR["outer"])}
|
||||
|
||||
def strip_for_json(r: dict) -> dict:
|
||||
return {k: v for k, v in r.items() if k not in ("band", "fit")}
|
||||
|
||||
manifest["browCenterline"] = {
|
||||
"method": "Otsu(front lum in bbox) AND diff(F-front)>=25, morphological closing to merge hatching, "
|
||||
"single global degree-2 polyfit on column-weighted centroid with one-pass outlier removal",
|
||||
"diffThresh": DIFF_THRESH,
|
||||
"bboxPad": {"x": BBOX_X_PAD, "yUp": BBOX_Y_UP, "yDown": BBOX_Y_DOWN},
|
||||
"closingKernel": list(CLOSING_KERNEL),
|
||||
"minComponentAreaPx": MIN_COMPONENT_AREA,
|
||||
"outlierStdMult": OUTLIER_STD_MULT,
|
||||
"edgeInsetFrac": EDGE_INSET_FRAC,
|
||||
"peakWindow": list(PEAK_WINDOW),
|
||||
"peakFallbackFrac": PEAK_FALLBACK_FRAC,
|
||||
"peakFlatHeightPx": PEAK_FLAT_HEIGHT_PX,
|
||||
"peakFailEdgeFrac": PEAK_FAIL_EDGE_FRAC,
|
||||
"oldPoints": {"browLeft": browL_old, "browRight": browR_old},
|
||||
"newPoints": {"browLeft": strip_for_json(resultL), "browRight": strip_for_json(resultR)},
|
||||
"checkB_innerMinusPeakY": {"browLeft": round(dropL, 2), "browRight": round(dropR, 2)},
|
||||
"checkD_symmetry": {"innerYDiff": round(inner_y_symmetry, 2), "peakYDiff": round(peak_y_symmetry, 2)},
|
||||
"evidenceImage": "preview/v2/brow-centerline.jpg",
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
1106
docs/avatar-art/linocut-pipeline/scripts/export_rig.py
Normal file
758
docs/avatar-art/linocut-pipeline/scripts/face_detail.py
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
"""공통 리노컷 리그 — faceDetail 레이어 생성(결정문 §8.2).
|
||||
|
||||
base-front.png(눈·눈썹·입·점이 있는 원본)에서 이목구비 잉크선 전체를 덮는
|
||||
"제외 영역"을 랜드마크 기준으로 넉넉히 잡고, 그 안은 base-faceless-padded(F)
|
||||
픽셀을 그대로 쓴다(비율 보간·블러 없음). 제외 영역 밖은 base-front를 그대로
|
||||
쓴다(다크서클 해칭·입가 음영 등 살려야 할 그늘은 그대로 남는다). 제외 영역
|
||||
경계는 10px 페더로 base-front↔F를 섞는다.
|
||||
|
||||
F 자체에 남아 있는 잔여 잉크 중 "선 모양"(형태학적 opening으로 얇은 성분만
|
||||
추출)만 복제 도장으로 지운다 — 넓고 부드러운 그늘(브로우뼈 음영 등)은 F의
|
||||
진짜 결이므로 건드리지 않는다.
|
||||
|
||||
머리카락(분할 category=1, 2px 팽창)이 덮는 자리는 faceDetail 알파를 0으로
|
||||
둔다 — head 레이어의 같은 가닥과 겹쳐 보이는 것을 막는다.
|
||||
|
||||
점(mole)처럼 원화에만 있고 랜드마크로 유도할 수 없는 반점은 persona.json의
|
||||
faceDetail.moles(원점+반경 목록)에서 읽는다. 목록이 비면 점 영역을 그리지
|
||||
않는다(모든 캐릭터가 점이 있는 것은 아니다).
|
||||
|
||||
전제: layers_v2.py가 이미 layers/v2/{body,head,hairFront}.png를 만들어 두어야
|
||||
한다(정지 합성 검사에 쓴다).
|
||||
|
||||
실행: <venv>/python.exe face_detail.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from scipy.ndimage import (
|
||||
binary_closing, binary_dilation, binary_erosion, binary_fill_holes, binary_opening,
|
||||
distance_transform_edt, gaussian_filter, label,
|
||||
)
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from segmentation import build_padded_faceless, to_u8, alpha_bbox, run_segmentation, composite_over, CREAM_BG # noqa: E402
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
# --- 영역(region) 상수: faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영 포함) --
|
||||
REGION_FEATHER_PX = 12.0
|
||||
|
||||
# --- 제외 영역(구멍) 상수: 이목구비 잉크선을 덮는 좁은 범위 ------------------
|
||||
EYE_INK_MARGIN = 32.0
|
||||
EYE_INK_THRESH = 150.0
|
||||
EYE_CREASE_UP_PX = 18 # 쌍꺼풀 주름 커버(위로)
|
||||
EYE_OUTER_EXT_PX = 12 # 바깥 꼬리 커버
|
||||
EYE_FINAL_DILATE_PX = 6 # 위/바깥/눈 틈에 적용(아래쪽은 별도)
|
||||
|
||||
# 아랫눈꺼풀은 선 자체만 좁게 덮는다 — 다크서클 해칭(그 아래 살려야 할 그늘)을
|
||||
# 먹지 않기 위해 위/바깥과는 다른(더 좁은) 팽창·페더를 쓴다.
|
||||
EYE_LOWER_LINE_ABOVE_PX = 2.0
|
||||
EYE_LOWER_LINE_BELOW_PX = 5.0
|
||||
EYE_LOWER_DILATE_PX = 2
|
||||
EYE_LOWER_FEATHER_PX = 4.0
|
||||
|
||||
EYE_INK_LOWER_CAP_PX = 7.0 # eye_dark_hole 연결요소가 다크서클 해칭까지 붙어 나오는 것을 자른다
|
||||
|
||||
DARK_CIRCLE_BAND_Y0 = 10.0 # 아랫눈꺼풀 아래 10~35px
|
||||
DARK_CIRCLE_BAND_Y1 = 35.0
|
||||
DARK_CIRCLE_BAND_HALF_W = 35.0 # 홍채 중심 x ±35px
|
||||
DARK_CIRCLE_MATCH_TOLERANCE = 6.0
|
||||
|
||||
BROW_INK_MARGIN = 20.0
|
||||
BROW_INK_THRESH = 150.0
|
||||
BROW_STROKE_HALF_WIDTH = 14 # 랜드마크 곡선 ±14px 띠
|
||||
BROW_FINAL_DILATE_PX = 10
|
||||
|
||||
MOUTH_INK_MARGIN = 15.0
|
||||
MOUTH_INK_THRESH = 150.0
|
||||
MOUTH_CORNER_EXT_PX = 12
|
||||
MOUTH_SHADOW_EXT_PX = 24 # 아랫입술 아래 그늘선 커버
|
||||
MOUTH_FINAL_DILATE_PX = 8
|
||||
|
||||
EXCLUSION_BOUNDARY_FEATHER_PX = 10.0
|
||||
HAIR_DILATE_PX = 2
|
||||
|
||||
# --- 잔여 잉크(선 모양만) 복제 도장 상수 -------------------------------------
|
||||
CLONE_TARGET_THRESH = 65.0
|
||||
AVOID_THRESH = 110.0
|
||||
LINE_OPENING_ITER = 3
|
||||
CLONE_STAMP_FEATHER_PX = 4.0
|
||||
CLONE_STAMP_CANDIDATES = [
|
||||
(0, -40), (0, -60), (-40, 0), (40, 0), (0, 40), (0, 60),
|
||||
(-60, 0), (60, 0), (0, -80), (0, 80), (-40, -40), (40, -40), (-40, 40), (40, 40),
|
||||
(0, -100), (0, -120), (0, -150), (0, -180), (0, 100), (0, 120),
|
||||
(-80, 0), (80, 0), (-100, 0), (100, 0), (-120, -60), (120, -60), (-60, -100), (60, -100),
|
||||
]
|
||||
|
||||
# --- 검사 상수 ---------------------------------------------------------------
|
||||
RING_PX = 12
|
||||
HF_SIGMA = 2.0
|
||||
LINE_SIGMA = 3.0
|
||||
CHECKD_THRESH = 0.7
|
||||
CHECKE_THRESH = 1.5
|
||||
GENERIC_PATCH_SIZE = 48
|
||||
GENERIC_PATCH_MARGIN = 40
|
||||
|
||||
|
||||
def ellipse_alpha(cx: float, cy: float, rx: float, ry: float, w: int, h: int, feather: float) -> np.ndarray:
|
||||
yy, xx = np.mgrid[0:h, 0:w].astype(np.float64)
|
||||
d = ((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2
|
||||
alpha = (d <= 1.0).astype(np.float64) * 255.0
|
||||
alpha = gaussian_filter(alpha, sigma=feather / 2.0)
|
||||
return np.clip(alpha, 0, 255)
|
||||
|
||||
|
||||
def eyebrow_mask(inner, peak, outer, w: int, h: int, width: int) -> np.ndarray:
|
||||
img = Image.new("L", (w, h), 0)
|
||||
d = ImageDraw.Draw(img)
|
||||
d.line([tuple(outer), tuple(peak), tuple(inner)], fill=255, width=width, joint="curve")
|
||||
d.ellipse([outer[0] - width / 2, outer[1] - width / 2, outer[0] + width / 2, outer[1] + width / 2], fill=255)
|
||||
d.ellipse([inner[0] - width / 2, inner[1] - width / 2, inner[0] + width / 2, inner[1] + width / 2], fill=255)
|
||||
return np.array(img) > 127
|
||||
|
||||
|
||||
def lens_polygon(inner, outer, upper, lower, pad: float, n: int = 24) -> list[tuple[float, float]]:
|
||||
def quad_bezier(p0, p1, p2, n):
|
||||
ts = np.linspace(0, 1, n)
|
||||
pts = []
|
||||
for t in ts:
|
||||
x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t ** 2 * p2[0]
|
||||
y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t ** 2 * p2[1]
|
||||
pts.append((x, y))
|
||||
return pts
|
||||
|
||||
upper_ctrl = (upper[0], upper[1] - pad)
|
||||
lower_ctrl = (inner[0], lower[1] + pad)
|
||||
upper_curve = quad_bezier(outer, upper_ctrl, inner, n)
|
||||
lower_curve = quad_bezier(inner, lower_ctrl, outer, n)
|
||||
return upper_curve + lower_curve
|
||||
|
||||
|
||||
def nearest_component_label(labeled: np.ndarray, py: float, px: float) -> int:
|
||||
"""(py,px) 위치의 라벨을 쓰되, 그 지점이 어떤 연결요소에도 안 속하면(랜드마크가
|
||||
잉크 픽셀에서 살짝 벗어난 경우) 가장 가까운 연결요소의 라벨을 대신 쓴다."""
|
||||
h, w = labeled.shape
|
||||
iy, ix = int(round(py)), int(round(px))
|
||||
iy = min(max(iy, 0), h - 1)
|
||||
ix = min(max(ix, 0), w - 1)
|
||||
seed_label = int(labeled[iy, ix])
|
||||
if seed_label != 0:
|
||||
return seed_label
|
||||
if not (labeled != 0).any():
|
||||
return 0
|
||||
_, (near_y, near_x) = distance_transform_edt(labeled == 0, return_indices=True)
|
||||
return int(labeled[near_y[iy, ix], near_x[iy, ix]])
|
||||
|
||||
|
||||
def eye_dark_hole(
|
||||
eye: dict, lum: np.ndarray, w: int, h: int, margin: float = EYE_INK_MARGIN,
|
||||
thresh: float = EYE_INK_THRESH, close_iter: int = 3
|
||||
) -> np.ndarray:
|
||||
"""눈 틈(공막·홍채·동공·아이라이너 잉크) 전체를 명도 임계값 + 연결요소로 찾는다."""
|
||||
lens = lens_polygon(eye["innerCorner"], eye["outerCorner"], eye["upperLidTop"], eye["lowerLidBottom"], pad=6.0)
|
||||
xs = [p[0] for p in lens]
|
||||
ys = [p[1] for p in lens]
|
||||
x0, x1 = min(xs) - margin, max(xs) + margin
|
||||
y0, y1 = min(ys) - margin, max(ys) + margin
|
||||
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
|
||||
yy, xx = np.mgrid[0:h, 0:w]
|
||||
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
|
||||
dark = search_ellipse & (lum < thresh)
|
||||
dark = binary_closing(dark, iterations=close_iter)
|
||||
labeled, _ = label(dark)
|
||||
icx, icy = eye["iris"]["center"]
|
||||
seed_label = nearest_component_label(labeled, icy, icx)
|
||||
if seed_label == 0:
|
||||
return np.zeros((h, w), dtype=bool)
|
||||
comp = labeled == seed_label
|
||||
return binary_fill_holes(comp)
|
||||
|
||||
|
||||
def lower_lid_line_mask(
|
||||
inner, outer, lower, w: int, h: int, above: float = EYE_LOWER_LINE_ABOVE_PX, below: float = EYE_LOWER_LINE_BELOW_PX
|
||||
) -> np.ndarray:
|
||||
"""아랫눈꺼풀 선(랜드마크 곡선) 자체만 위로 above px, 아래로 below px 두께로
|
||||
감싸는 좁은 띠를 만든다 — 다크서클 해칭을 먹지 않기 위해 이 아래는 손대지 않는다."""
|
||||
n = 40
|
||||
ts = np.linspace(0, 1, n)
|
||||
curve = [
|
||||
((1 - t) ** 2 * outer[0] + 2 * (1 - t) * t * lower[0] + t ** 2 * inner[0],
|
||||
(1 - t) ** 2 * outer[1] + 2 * (1 - t) * t * lower[1] + t ** 2 * inner[1])
|
||||
for t in ts
|
||||
]
|
||||
upper_edge = [(x, y - above) for x, y in curve]
|
||||
lower_edge = [(x, y + below) for x, y in curve]
|
||||
poly = upper_edge + lower_edge[::-1]
|
||||
img = Image.new("L", (w, h), 0)
|
||||
ImageDraw.Draw(img).polygon(poly, fill=255)
|
||||
return np.array(img) > 127
|
||||
|
||||
|
||||
def dark_circle_band_mask(eye: dict, w: int, h: int) -> np.ndarray:
|
||||
"""아랫눈꺼풀 아래 10~35px, 홍채 중심 x ±35px 띠(다크서클 해칭 검사용)."""
|
||||
icx = eye["iris"]["center"][0]
|
||||
lower_y = eye["lowerLidBottom"][1]
|
||||
y0, y1 = lower_y + DARK_CIRCLE_BAND_Y0, lower_y + DARK_CIRCLE_BAND_Y1
|
||||
x0, x1 = icx - DARK_CIRCLE_BAND_HALF_W, icx + DARK_CIRCLE_BAND_HALF_W
|
||||
m = np.zeros((h, w), dtype=bool)
|
||||
yy0, yy1 = max(0, int(round(y0))), min(h, int(round(y1)))
|
||||
xx0, xx1 = max(0, int(round(x0))), min(w, int(round(x1)))
|
||||
m[yy0:yy1, xx0:xx1] = True
|
||||
return m
|
||||
|
||||
|
||||
def brow_dark_hole(
|
||||
brow: dict, lum: np.ndarray, w: int, h: int, margin: float = BROW_INK_MARGIN,
|
||||
thresh: float = BROW_INK_THRESH, close_iter: int = 3
|
||||
) -> np.ndarray:
|
||||
"""눈썹 잉크(굵은 털 뭉치) 전체를 명도 임계값 + 연결요소로 찾는다(eye_dark_hole과 같은 방식)."""
|
||||
inner, peak, outer = brow["inner"], brow["peak"], brow["outer"]
|
||||
xs = [inner[0], peak[0], outer[0]]
|
||||
ys = [inner[1], peak[1], outer[1]]
|
||||
x0, x1 = min(xs) - margin, max(xs) + margin
|
||||
y0, y1 = min(ys) - margin - 10, max(ys) + margin + 15
|
||||
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
|
||||
yy, xx = np.mgrid[0:h, 0:w]
|
||||
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
|
||||
dark = search_ellipse & (lum < thresh)
|
||||
dark = binary_closing(dark, iterations=close_iter)
|
||||
labeled, _ = label(dark)
|
||||
pcx, pcy = peak
|
||||
seed_label = nearest_component_label(labeled, pcy, pcx)
|
||||
if seed_label == 0:
|
||||
return np.zeros((h, w), dtype=bool)
|
||||
comp = labeled == seed_label
|
||||
return binary_fill_holes(comp)
|
||||
|
||||
|
||||
def mouth_dark_hole(
|
||||
mcL, mcR, upLip, loLip, lum: np.ndarray, w: int, h: int, margin: float = MOUTH_INK_MARGIN,
|
||||
thresh: float = MOUTH_INK_THRESH, close_iter: int = 3
|
||||
) -> np.ndarray:
|
||||
"""입술 잉크(윗/아랫입술 선·안쪽 그늘) 전체를 명도 임계값 + 연결요소로 찾는다."""
|
||||
x0, x1 = min(mcL[0], mcR[0]) - margin, max(mcL[0], mcR[0]) + margin
|
||||
y0, y1 = upLip[1] - margin, loLip[1] + margin
|
||||
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
|
||||
yy, xx = np.mgrid[0:h, 0:w]
|
||||
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
|
||||
dark = search_ellipse & (lum < thresh)
|
||||
dark = binary_closing(dark, iterations=close_iter)
|
||||
labeled, _ = label(dark)
|
||||
ccx, ccy = (mcL[0] + mcR[0]) / 2, (upLip[1] + loLip[1]) / 2
|
||||
seed_label = nearest_component_label(labeled, ccy, ccx)
|
||||
if seed_label == 0:
|
||||
return np.zeros((h, w), dtype=bool)
|
||||
comp = labeled == seed_label
|
||||
return binary_fill_holes(comp)
|
||||
|
||||
|
||||
def shift_mask(mask: np.ndarray, dy: int, dx: int) -> np.ndarray:
|
||||
"""mask를 (dy,dx)만큼 평행이동한다(래핑 없음, 밖으로 밀려난 부분은 버림)."""
|
||||
h, w = mask.shape
|
||||
out = np.zeros_like(mask)
|
||||
src_y0, src_y1 = max(0, -dy), h - max(0, dy)
|
||||
dst_y0, dst_y1 = max(0, dy), h - max(0, -dy)
|
||||
src_x0, src_x1 = max(0, -dx), w - max(0, dx)
|
||||
dst_x0, dst_x1 = max(0, dx), w - max(0, -dx)
|
||||
if src_y1 <= src_y0 or src_x1 <= src_x0:
|
||||
return out
|
||||
out[dst_y0:dst_y1, dst_x0:dst_x1] = mask[src_y0:src_y1, src_x0:src_x1]
|
||||
return out
|
||||
|
||||
|
||||
def grow_directional(mask: np.ndarray, dy: int = 0, dx: int = 0) -> np.ndarray:
|
||||
"""mask를 (dy,dx) 방향으로 1px씩 단계적으로 밀어 그 방향으로 최대
|
||||
|dy| 또는 |dx| px까지 덮는다(원래 mask가 있던 자리에서 그 방향으로 "그림자를
|
||||
드리운" 모양) — 눈 주름·아랫눈꺼풀·입가 그늘처럼 특정 방향으로만 구멍을
|
||||
넓힐 때 쓴다."""
|
||||
steps = max(abs(dy), abs(dx), 1)
|
||||
out = mask.copy()
|
||||
for i in range(1, steps + 1):
|
||||
fy = round(dy * i / steps)
|
||||
fx = round(dx * i / steps)
|
||||
out |= shift_mask(mask, fy, fx)
|
||||
return out
|
||||
|
||||
|
||||
def line_only_mask(dark_mask: np.ndarray, iterations: int = LINE_OPENING_ITER) -> np.ndarray:
|
||||
"""넓고 부드러운 그늘(오프닝으로 살아남는 덩어리)을 빼고, 얇은 선 성분만 남긴다."""
|
||||
opened = binary_opening(dark_mask, iterations=iterations)
|
||||
return dark_mask & ~opened
|
||||
|
||||
|
||||
def clone_stamp_fill(f_arr: np.ndarray, bad_mask: np.ndarray, avoid_mask: np.ndarray) -> tuple[np.ndarray, list[dict]]:
|
||||
"""bad_mask(F에 남은 선 모양 잔여 잉크)의 연결요소마다 avoid_mask(제외 영역 ∪
|
||||
머리카락 ∪ F 잔여 잉크)를 피하는 이웃 패치를 후보 오프셋에서 찾아 그대로
|
||||
옮겨 붙인다 — 복제 도장. 블러 없이 원본 해칭 텍스처를 재배치만 한다."""
|
||||
h, w = bad_mask.shape
|
||||
labeled, n = label(bad_mask)
|
||||
out = f_arr.copy()
|
||||
good = ~avoid_mask
|
||||
report: list[dict] = []
|
||||
for comp_id in range(1, n + 1):
|
||||
comp = labeled == comp_id
|
||||
ys, xs = np.where(comp)
|
||||
y0, y1, x0, x1 = int(ys.min()), int(ys.max()) + 1, int(xs.min()), int(xs.max()) + 1
|
||||
comp_sub = comp[y0:y1, x0:x1]
|
||||
# shift_mask(f_arr, dy, dx)는 출력 위치 y의 값을 입력 위치 y-dy에서
|
||||
# 가져온다(아래로 dy만큼 미는 것) — 그래서 comp가 실제로 퍼오는 소스
|
||||
# 좌표는 y0-dy..y1-dy, x0-dx..x1-dx이다. 여기를 검증해야 한다(이전에는
|
||||
# y0+dy로 반대 방향을 검증하는 부호 버그가 있었다).
|
||||
chosen = None
|
||||
best_std = -1.0
|
||||
for dx, dy in CLONE_STAMP_CANDIDATES:
|
||||
sy0, sy1, sx0, sx1 = y0 - dy, y1 - dy, x0 - dx, x1 - dx
|
||||
if sy0 < 0 or sx0 < 0 or sy1 > h or sx1 > w:
|
||||
continue
|
||||
if not np.all(good[sy0:sy1, sx0:sx1][comp_sub]):
|
||||
continue
|
||||
src_patch = f_arr[sy0:sy1, sx0:sx1][comp_sub]
|
||||
s = float(src_patch.std())
|
||||
if s > best_std:
|
||||
best_std = s
|
||||
chosen = (dx, dy)
|
||||
|
||||
# comp 안쪽은 무조건 완전 교체(blend=1)한다. 컴포넌트별로 가우시안이나
|
||||
# 거리변환 페더를 주면, 서로 몇 px 안 떨어진 작은 컴포넌트(대다수가
|
||||
# 1~수십 px)들의 페더 자락이 이웃 컴포넌트의 comp 영역까지 침범해 이미
|
||||
# 교체된 픽셀을 나중 컴포넌트의 도장 값으로 다시 섞어버려 어둡게 되돌리는
|
||||
# 문제가 있었다 — comp 경계는 하드컷으로 두고, 바깥쪽 전환은 뒤에서
|
||||
# bad_texture 전체 기준으로 한 번에 처리한다.
|
||||
blend = comp.astype(np.float64)
|
||||
|
||||
if chosen is None:
|
||||
_, (iy, ix) = distance_transform_edt(~good, return_indices=True)
|
||||
shifted = f_arr[iy, ix, :]
|
||||
method = "nearest-good-pixel"
|
||||
offset = None
|
||||
else:
|
||||
dx, dy = chosen
|
||||
shifted = np.stack([shift_mask(f_arr[..., c], dy, dx) for c in range(f_arr.shape[2])], axis=2)
|
||||
method = "clone-stamp"
|
||||
offset = [dx, dy]
|
||||
|
||||
out = out * (1 - blend[..., None]) + shifted * blend[..., None]
|
||||
report.append({"componentId": comp_id, "pixels": int(comp.sum()), "offset": offset, "method": method})
|
||||
return out, report
|
||||
|
||||
|
||||
def high_freq_energy(lum: np.ndarray, mask: np.ndarray, sigma: float = HF_SIGMA) -> float:
|
||||
if not mask.any():
|
||||
return 0.0
|
||||
hf = lum - gaussian_filter(lum, sigma=sigma)
|
||||
return float(hf[mask].std())
|
||||
|
||||
|
||||
def line_energy(lum: np.ndarray, mask: np.ndarray, sigma: float = LINE_SIGMA) -> float:
|
||||
"""원본이 자신의 블러보다 어두운 정도(가는 잉크선 성분)의 평균 크기."""
|
||||
if not mask.any():
|
||||
return 0.0
|
||||
blurred = gaussian_filter(lum, sigma=sigma)
|
||||
neg = np.clip(blurred - lum, 0, None)
|
||||
return float(neg[mask].mean())
|
||||
|
||||
|
||||
def find_clean_patch(
|
||||
avoid_dilated: np.ndarray, roi: tuple[int, int, int, int], size: int = GENERIC_PATCH_SIZE
|
||||
) -> tuple[int, int]:
|
||||
"""roi=(x0,y0,x1,y1) 범위 안에서 avoid_dilated가 전부 False인 size x size
|
||||
창의 좌상단 좌표를 찾는다. 완전히 깨끗한 창이 없으면 가장 깨끗한 것을 쓴다."""
|
||||
x0, y0, x1, y1 = roi
|
||||
h, w = avoid_dilated.shape
|
||||
x0, y0 = max(0, x0), max(0, y0)
|
||||
x1, y1 = min(w, x1), min(h, y1)
|
||||
eligible = ~avoid_dilated
|
||||
mask_f = eligible.astype(np.float64)
|
||||
csum = np.pad(np.cumsum(np.cumsum(mask_f, axis=0), axis=1), ((1, 0), (1, 0)))
|
||||
sums = csum[size:, size:] - csum[:-size, size:] - csum[size:, :-size] + csum[:-size, :-size]
|
||||
sy0, sy1 = max(0, y0), min(sums.shape[0] - 1, y1 - size)
|
||||
sx0, sx1 = max(0, x0), min(sums.shape[1] - 1, x1 - size)
|
||||
if sy1 < sy0 or sx1 < sx0:
|
||||
sy0, sy1, sx0, sx1 = 0, sums.shape[0] - 1, 0, sums.shape[1] - 1
|
||||
sub = sums[sy0:sy1 + 1, sx0:sx1 + 1]
|
||||
idx = np.unravel_index(np.argmax(sub), sub.shape)
|
||||
return int(idx[1] + sx0), int(idx[0] + sy0)
|
||||
|
||||
|
||||
def main(persona_dir: Path) -> int:
|
||||
cfg = load_persona_config(persona_dir)
|
||||
base_dir = cfg.base_dir
|
||||
layers_v2_dir = cfg.layers_v2_dir
|
||||
preview_v2_dir = cfg.preview_v2_dir
|
||||
manifest_path = cfg.manifest_path
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
lm = manifest["landmarks"]
|
||||
|
||||
front = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
|
||||
f_img = build_padded_faceless(base_dir)
|
||||
f_arr = np.array(f_img).astype(np.float64)
|
||||
h, w, _ = f_arr.shape
|
||||
lum_front = front.mean(axis=2)
|
||||
lum_f_raw = f_arr.mean(axis=2)
|
||||
|
||||
eyeL, eyeR = lm["eyeLeft"], lm["eyeRight"]
|
||||
if cfg.brow_landmarks_override is not None:
|
||||
# P1 전용 호환 장치: 눈썹 제외 영역·eyeRegion y0은 눈썹 중심선 보정 이전
|
||||
# 좌표로 고정 빌드된 채 "다시 빌드하지 않는다"는 지시로 승인됐다(persona.json
|
||||
# 참고). 다른 모든 랜드마크(눈·입·코·턱)는 그대로 현재 값을 쓴다.
|
||||
browL, browR = cfg.brow_landmarks_override["browLeft"], cfg.brow_landmarks_override["browRight"]
|
||||
brow_source = "persona.json faceDetail.browLandmarksOverride"
|
||||
else:
|
||||
browL, browR = lm["eyebrowLeft"], lm["eyebrowRight"]
|
||||
brow_source = "manifest.landmarks.eyebrowLeft/Right"
|
||||
mcL, mcR = lm["mouthCornerLeft"], lm["mouthCornerRight"]
|
||||
upLip, loLip = lm["upperLipTopCenter"], lm["lowerLipBottomCenter"]
|
||||
noseTip, chinTip = lm["noseTip"], lm["chinTip"]
|
||||
print(f"눈썹 좌표 출처: {brow_source}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 영역(region): faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영·점)
|
||||
# ------------------------------------------------------------------
|
||||
def eye_region_bbox(eye, brow, outward_sign: float):
|
||||
outer, inner = eye["outerCorner"], eye["innerCorner"]
|
||||
x_outer = outer[0] + outward_sign * 30.0
|
||||
x_inner = inner[0] - outward_sign * 25.0
|
||||
x_min, x_max = sorted([x_outer, x_inner])
|
||||
y_min = brow["peak"][1] - 10.0
|
||||
y_max = eye["lowerLidBottom"][1] + 70.0
|
||||
return x_min, y_min, x_max, y_max
|
||||
|
||||
bx0, by0, bx1, by1 = eye_region_bbox(eyeL, browL, outward_sign=-1.0)
|
||||
rx0, ry0, rx1, ry1 = eye_region_bbox(eyeR, browR, outward_sign=+1.0)
|
||||
eyeL_cx, eyeL_cy = (bx0 + bx1) / 2, (by0 + by1) / 2
|
||||
eyeL_rx, eyeL_ry = (bx1 - bx0) / 2, (by1 - by0) / 2
|
||||
eyeR_cx, eyeR_cy = (rx0 + rx1) / 2, (ry0 + ry1) / 2
|
||||
eyeR_rx, eyeR_ry = (rx1 - rx0) / 2, (ry1 - ry0) / 2
|
||||
|
||||
mouth_x0 = min(mcL[0], mcR[0]) - 45.0
|
||||
mouth_x1 = max(mcL[0], mcR[0]) + 45.0
|
||||
mouth_y0 = noseTip[1] + 25.0
|
||||
mouth_y1 = chinTip[1] - 15.0
|
||||
mouth_cx, mouth_cy = (mouth_x0 + mouth_x1) / 2, (mouth_y0 + mouth_y1) / 2
|
||||
mouth_rx, mouth_ry = (mouth_x1 - mouth_x0) / 2, (mouth_y1 - mouth_y0) / 2
|
||||
|
||||
print(f"eyeLeft 영역 bbox=({bx0:.1f},{by0:.1f},{bx1:.1f},{by1:.1f})")
|
||||
print(f"eyeRight 영역 bbox=({rx0:.1f},{ry0:.1f},{rx1:.1f},{ry1:.1f})")
|
||||
print(f"mouth 영역 bbox=({mouth_x0:.1f},{mouth_y0:.1f},{mouth_x1:.1f},{mouth_y1:.1f})")
|
||||
|
||||
a_eyeL = ellipse_alpha(eyeL_cx, eyeL_cy, eyeL_rx, eyeL_ry, w, h, REGION_FEATHER_PX)
|
||||
a_eyeR = ellipse_alpha(eyeR_cx, eyeR_cy, eyeR_rx, eyeR_ry, w, h, REGION_FEATHER_PX)
|
||||
a_mouth = ellipse_alpha(mouth_cx, mouth_cy, mouth_rx, mouth_ry, w, h, REGION_FEATHER_PX)
|
||||
a_mole = np.zeros((h, w), dtype=np.float64)
|
||||
for mole in cfg.moles:
|
||||
mcx, mcy = mole["center"]
|
||||
r = float(mole["radius"])
|
||||
a_mole = np.maximum(a_mole, ellipse_alpha(mcx, mcy, r, r, w, h, REGION_FEATHER_PX))
|
||||
region_alpha = np.maximum(np.maximum(a_eyeL, a_eyeR), np.maximum(a_mouth, a_mole))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 제외 영역(구멍): 이목구비 잉크선 전체 + 지정 방향 여유 + 최종 팽창
|
||||
# ------------------------------------------------------------------
|
||||
print("=== 제외 영역 계산 ===")
|
||||
eyeL_ink_raw = eye_dark_hole(eyeL, lum_front, w, h)
|
||||
eyeR_ink_raw = eye_dark_hole(eyeR, lum_front, w, h)
|
||||
# eye_dark_hole은 명도<150 연결요소를 찾는데, 다크서클 해칭이 눈 잉크와
|
||||
# 이어져 있어(binary_closing으로 다리까지 놓여) 같은 컴포넌트로 잡혀
|
||||
# 아랫눈꺼풀선 아래 30px까지 "눈 잉크"로 나온다 — 랜드마크선+7px 아래는
|
||||
# 잘라내 다크서클 해칭이 눈 제외 영역에 섞이지 않게 한다.
|
||||
yy_full, _ = np.mgrid[0:h, 0:w]
|
||||
eyeL_cap = yy_full <= (eyeL["lowerLidBottom"][1] + EYE_INK_LOWER_CAP_PX)
|
||||
eyeR_cap = yy_full <= (eyeR["lowerLidBottom"][1] + EYE_INK_LOWER_CAP_PX)
|
||||
eyeL_ink = eyeL_ink_raw & eyeL_cap
|
||||
eyeR_ink = eyeR_ink_raw & eyeR_cap
|
||||
print(f"눈 잉크 다크서클 절단: L {int(eyeL_ink_raw.sum())}->{int(eyeL_ink.sum())}px, R {int(eyeR_ink_raw.sum())}->{int(eyeR_ink.sum())}px")
|
||||
# 위(쌍꺼풀 주름)·바깥(꼬리)은 기존처럼 잉크에서 방향성 확장 + 6px 팽창.
|
||||
# 아래쪽은 잉크에서 확장하지 않고, 랜드마크 곡선 자체의 좁은 띠(-2~+5px)만
|
||||
# 별도로 2px 팽창한다 — 다크서클 해칭을 먹지 않기 위함(오케스트레이터 지시).
|
||||
eyeL_grown = eyeL_ink | grow_directional(eyeL_ink, dy=-EYE_CREASE_UP_PX) | grow_directional(eyeL_ink, dx=-EYE_OUTER_EXT_PX)
|
||||
eyeR_grown = eyeR_ink | grow_directional(eyeR_ink, dy=-EYE_CREASE_UP_PX) | grow_directional(eyeR_ink, dx=EYE_OUTER_EXT_PX)
|
||||
# 6px 팽창은 사방으로 동시에 퍼지므로, 이미 절단한 아래쪽 경계를 다시
|
||||
# 밀어 내린다 — 팽창 뒤에도 같은 절단선으로 다시 한 번 잘라 위/바깥
|
||||
# 팽창(원하는 효과)만 남기고 아래쪽 재침범은 막는다.
|
||||
eyeL_core_excl = binary_dilation(eyeL_grown, iterations=EYE_FINAL_DILATE_PX) & eyeL_cap
|
||||
eyeR_core_excl = binary_dilation(eyeR_grown, iterations=EYE_FINAL_DILATE_PX) & eyeR_cap
|
||||
|
||||
eyeL_lower_line = lower_lid_line_mask(eyeL["innerCorner"], eyeL["outerCorner"], eyeL["lowerLidBottom"], w, h)
|
||||
eyeR_lower_line = lower_lid_line_mask(eyeR["innerCorner"], eyeR["outerCorner"], eyeR["lowerLidBottom"], w, h)
|
||||
eyeL_lower_excl = binary_dilation(eyeL_lower_line, iterations=EYE_LOWER_DILATE_PX)
|
||||
eyeR_lower_excl = binary_dilation(eyeR_lower_line, iterations=EYE_LOWER_DILATE_PX)
|
||||
|
||||
eyeL_excl = eyeL_core_excl | eyeL_lower_excl
|
||||
eyeR_excl = eyeR_core_excl | eyeR_lower_excl
|
||||
# 페더 프로파일을 나눌 때는 "아래쪽 좁은 띠 전용 마스크"가 아니라 "제외
|
||||
# 영역 중 아랫눈꺼풀선 근방(위로 3px)에 걸리는 부분 전체"를 기준으로
|
||||
# 삼는다 — core_excl의 6px 팽창분도 같은 y대에 있으면 좁은 4px 페더를
|
||||
# 받아야 다크서클 쪽으로 넓은 10px 페더가 새는 것을 막는다.
|
||||
eyeL_lower_zone = eyeL_excl & (yy_full > eyeL["lowerLidBottom"][1] - 8.0)
|
||||
eyeR_lower_zone = eyeR_excl & (yy_full > eyeR["lowerLidBottom"][1] - 8.0)
|
||||
eye_lower_bands = eyeL_lower_zone | eyeR_lower_zone
|
||||
|
||||
browL_ink = brow_dark_hole(browL, lum_front, w, h)
|
||||
browR_ink = brow_dark_hole(browR, lum_front, w, h)
|
||||
browL_band = eyebrow_mask(browL["inner"], browL["peak"], browL["outer"], w, h, 2 * BROW_STROKE_HALF_WIDTH)
|
||||
browR_band = eyebrow_mask(browR["inner"], browR["peak"], browR["outer"], w, h, 2 * BROW_STROKE_HALF_WIDTH)
|
||||
browL_excl = binary_dilation(browL_ink | browL_band, iterations=BROW_FINAL_DILATE_PX)
|
||||
browR_excl = binary_dilation(browR_ink | browR_band, iterations=BROW_FINAL_DILATE_PX)
|
||||
|
||||
mouth_ink = mouth_dark_hole(mcL, mcR, upLip, loLip, lum_front, w, h)
|
||||
mouth_grown = (
|
||||
mouth_ink
|
||||
| grow_directional(mouth_ink, dx=-MOUTH_CORNER_EXT_PX)
|
||||
| grow_directional(mouth_ink, dx=MOUTH_CORNER_EXT_PX)
|
||||
| grow_directional(mouth_ink, dy=MOUTH_SHADOW_EXT_PX)
|
||||
)
|
||||
mouth_excl = binary_dilation(mouth_grown, iterations=MOUTH_FINAL_DILATE_PX)
|
||||
|
||||
exclusion_mask = eyeL_excl | eyeR_excl | browL_excl | browR_excl | mouth_excl
|
||||
named_excl = {"eyeLeft": eyeL_excl, "eyeRight": eyeR_excl, "browLeft": browL_excl, "browRight": browR_excl, "mouth": mouth_excl}
|
||||
print(f"제외 영역 픽셀: eye={int((eyeL_excl|eyeR_excl).sum())} brow={int((browL_excl|browR_excl).sum())} mouth={int(mouth_excl.sum())} union={int(exclusion_mask.sum())}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 머리카락 마스크(분할, 2px 팽창) — faceDetail 알파를 0으로 만든다
|
||||
# ------------------------------------------------------------------
|
||||
category_mask = run_segmentation(base_dir / "base-faceless-padded.png")
|
||||
hair_mask = binary_dilation(category_mask == 1, iterations=HAIR_DILATE_PX)
|
||||
print(f"머리카락 마스크(2px 팽창) 픽셀: {int(hair_mask.sum())}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 텍스처원(질감, 블러 금지): F가 선 모양 잔여 잉크인 자리만 복제 도장
|
||||
# ------------------------------------------------------------------
|
||||
dark_in_excl = exclusion_mask & ~hair_mask & (lum_f_raw < CLONE_TARGET_THRESH)
|
||||
bad_texture = line_only_mask(dark_in_excl)
|
||||
avoid_mask = exclusion_mask | hair_mask | (lum_f_raw < AVOID_THRESH)
|
||||
print(f"텍스처 복제 대상(제외 영역 안 F 선 모양 잔여 잉크): {int(bad_texture.sum())}px (넓은 그늘 {int(dark_in_excl.sum()) - int(bad_texture.sum())}px는 보존)")
|
||||
f_clean, stamp_report = clone_stamp_fill(f_arr, bad_texture, avoid_mask)
|
||||
for r in stamp_report:
|
||||
print(f" 복제 도장: comp={r['componentId']} px={r['pixels']} offset={r['offset']} method={r['method']}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 합성: 제외 영역 밖은 front 그대로, 안은 F(f_clean) 그대로. 비율 보간
|
||||
# 없음. 경계는 10px 페더로 섞는다 — 단 눈 아래쪽 좁은 띠(eye_lower_bands)는
|
||||
# 다크서클 해칭을 먹지 않도록 4px 페더만 쓴다(오케스트레이터 지시). 두
|
||||
# 페더 영역을 나눠 계산한 뒤 max로 합친다. 복제 도장으로 갈아 끼운 자리
|
||||
# (bad_texture)는 경계 페더가 원본 front(잉크 그 자체)를 다시 섞어 넣지
|
||||
# 못하게 blend=1로 고정한다 — 안 그러면 제외 영역 가장자리 근처의 잔여
|
||||
# 잉크는 도장으로 지워도 다시 어두워진다.
|
||||
# ------------------------------------------------------------------
|
||||
blend_main = np.clip(
|
||||
gaussian_filter((exclusion_mask & ~eye_lower_bands).astype(np.float64), sigma=EXCLUSION_BOUNDARY_FEATHER_PX / 2.0), 0.0, 1.0
|
||||
)
|
||||
blend_lower = np.clip(gaussian_filter(eye_lower_bands.astype(np.float64), sigma=EYE_LOWER_FEATHER_PX / 2.0), 0.0, 1.0)
|
||||
blend = np.maximum(blend_main, blend_lower)
|
||||
blend = np.maximum(blend, bad_texture.astype(np.float64))
|
||||
face_detail_rgb = front * (1 - blend[..., None]) + f_clean * blend[..., None]
|
||||
|
||||
# region_alpha(눈·입 타원)는 기존 "눈두덩~다크서클" 범위용으로 만든 것이라,
|
||||
# 새로 넓힌 제외 영역(특히 눈썹은 자체 타원이 없다)을 다 못 덮을 수 있다 —
|
||||
# 못 덮으면 그 자리는 faceDetail이 아니라 head 레이어의 원본(미처리) 잉크가
|
||||
# 그대로 비쳐 보인다. 제외 영역은 RGB와 같은 10px 페더 프로필로 알파도
|
||||
# 최소 보장한다.
|
||||
face_detail_alpha = np.maximum(region_alpha, blend * 255.0)
|
||||
face_detail_alpha[hair_mask] = 0.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 저장
|
||||
# ------------------------------------------------------------------
|
||||
layers_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = layers_v2_dir / "face-detail.png"
|
||||
Image.fromarray(np.dstack([to_u8(face_detail_rgb), to_u8(face_detail_alpha)]), "RGBA").save(out_path)
|
||||
bbox = alpha_bbox(to_u8(face_detail_alpha))
|
||||
print(f"저장: {out_path} bbox={bbox}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 정지 합성(body+head+faceDetail+hairFront, 종이 위)
|
||||
# ------------------------------------------------------------------
|
||||
def load_rgba(p: Path) -> tuple[np.ndarray, np.ndarray]:
|
||||
arr = np.array(Image.open(p).convert("RGBA")).astype(np.float64)
|
||||
return arr[..., :3], arr[..., 3]
|
||||
|
||||
body_rgb, body_a = load_rgba(layers_v2_dir / "body.png")
|
||||
head_rgb, head_a = load_rgba(layers_v2_dir / "head.png")
|
||||
hf_rgb, hf_a = load_rgba(layers_v2_dir / "hairFront.png")
|
||||
|
||||
canvas = np.zeros((h, w, 4), dtype=np.float64)
|
||||
canvas[..., 0] = CREAM_BG[0]; canvas[..., 1] = CREAM_BG[1]; canvas[..., 2] = CREAM_BG[2]; canvas[..., 3] = 255.0
|
||||
canvas = composite_over(canvas, to_u8(body_rgb), to_u8(body_a))
|
||||
canvas = composite_over(canvas, to_u8(head_rgb), to_u8(head_a))
|
||||
canvas = composite_over(canvas, to_u8(face_detail_rgb), to_u8(face_detail_alpha))
|
||||
canvas = composite_over(canvas, to_u8(hf_rgb), to_u8(hf_a))
|
||||
static_composite = to_u8(canvas)[..., :3].astype(np.float64)
|
||||
lum_composite = static_composite.mean(axis=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 검사 (a): 제외 영역 밖, faceDetail alpha>0.5에서 base-front 대비 <=3
|
||||
# ------------------------------------------------------------------
|
||||
check_mask = (face_detail_alpha > 127) & ~exclusion_mask
|
||||
diff = np.abs(static_composite - front).mean(axis=2)
|
||||
mean_abs_a = float(diff[check_mask].mean()) if check_mask.any() else None
|
||||
print(f"검사(a) 제외영역 밖 & faceDetail>0.5 평균절대차 = {mean_abs_a:.3f} (기준 <=3)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 검사 (b): 제외 영역 경계 안팎 3px 띠 평균 명도 차 <=6
|
||||
# ------------------------------------------------------------------
|
||||
excl_inner_band = exclusion_mask & ~binary_erosion(exclusion_mask, iterations=3)
|
||||
excl_outer_band = binary_dilation(exclusion_mask, iterations=3) & ~exclusion_mask
|
||||
inner_mean = float(lum_composite[excl_inner_band].mean()) if excl_inner_band.any() else None
|
||||
outer_mean = float(lum_composite[excl_outer_band].mean()) if excl_outer_band.any() else None
|
||||
seam_diff = abs(inner_mean - outer_mean) if inner_mean is not None and outer_mean is not None else None
|
||||
print(f"검사(b) 제외영역 경계 안({inner_mean:.2f})/밖({outer_mean:.2f}) 명도차 = {seam_diff:.3f} (기준 <=6)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 검사 (c): 제외 영역 안, 머리카락이 아닌 픽셀 중 명도<70 비율 <=0.3%
|
||||
# ------------------------------------------------------------------
|
||||
check_region_c = exclusion_mask & ~hair_mask
|
||||
violations = check_region_c & (lum_composite < 70)
|
||||
n_violations = int(violations.sum())
|
||||
hole_dark_pct = float(violations.sum()) / float(check_region_c.sum()) * 100.0 if check_region_c.any() else 0.0
|
||||
print(f"검사(c) 제외영역 안(머리카락 제외) 명도<70 비율 = {hole_dark_pct:.4f}% (기준 <=0.3%, 위반 {n_violations}px)")
|
||||
|
||||
if hole_dark_pct > 0.3 and n_violations > 0:
|
||||
overlay = front.copy()
|
||||
overlay[violations] = np.array([230.0, 30.0, 30.0])
|
||||
ys, xs = np.where(violations)
|
||||
pad = 30
|
||||
cx0, cy0 = max(0, int(xs.min()) - pad), max(0, int(ys.min()) - pad)
|
||||
cx1, cy1 = min(w, int(xs.max()) + pad), min(h, int(ys.max()) + pad)
|
||||
diag_path = preview_v2_dir / "checkC-violations.png"
|
||||
Image.fromarray(to_u8(overlay)).crop((cx0, cy0, cx1, cy1)).save(diag_path)
|
||||
print(f" [checkC 미달] 위반 위치 진단 이미지 저장: {diag_path}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 일반 피부 표본(뺨 2곳 + 이마 1곳, F 기준, 이목구비·머리카락에서 40px+ 이격)
|
||||
# ------------------------------------------------------------------
|
||||
avoid_for_patch = binary_dilation(exclusion_mask | hair_mask, iterations=GENERIC_PATCH_MARGIN)
|
||||
browL_peak, browR_peak = browL["peak"], browR["peak"]
|
||||
eyeL_lower, eyeR_lower = eyeL["lowerLidBottom"], eyeR["lowerLidBottom"]
|
||||
|
||||
forehead_roi = (
|
||||
int(min(browL_peak[0], browR_peak[0]) - 20), int(min(browL_peak[1], browR_peak[1]) - 100),
|
||||
int(max(browL_peak[0], browR_peak[0]) + 20), int(min(browL_peak[1], browR_peak[1]) - 20),
|
||||
)
|
||||
cheekL_roi = (
|
||||
int(eyeL["outerCorner"][0] - 100), int(eyeL_lower[1] + 10),
|
||||
int(noseTip[0] - 20), int(mcL[1] - 5),
|
||||
)
|
||||
cheekR_roi = (
|
||||
int(noseTip[0] + 20), int(eyeR_lower[1] + 10),
|
||||
int(eyeR["outerCorner"][0] + 100), int(mcR[1] - 5),
|
||||
)
|
||||
patch_boxes: dict[str, tuple[int, int, int, int]] = {}
|
||||
for name, roi in (("forehead", forehead_roi), ("cheekLeft", cheekL_roi), ("cheekRight", cheekR_roi)):
|
||||
x0, y0 = find_clean_patch(avoid_for_patch, roi, GENERIC_PATCH_SIZE)
|
||||
patch_boxes[name] = (x0, y0, x0 + GENERIC_PATCH_SIZE, y0 + GENERIC_PATCH_SIZE)
|
||||
clean_frac = float((~avoid_for_patch[y0:y0 + GENERIC_PATCH_SIZE, x0:x0 + GENERIC_PATCH_SIZE]).mean()) * 100.0
|
||||
print(f"일반 피부 표본 {name}: box={patch_boxes[name]} 깨끗한 비율={clean_frac:.1f}%")
|
||||
|
||||
lum_f_clean = f_clean.mean(axis=2)
|
||||
patch_masks = {}
|
||||
for name, (x0, y0, x1, y1) in patch_boxes.items():
|
||||
m = np.zeros((h, w), dtype=bool)
|
||||
m[y0:y1, x0:x1] = True
|
||||
patch_masks[name] = m
|
||||
|
||||
baseline_hf = float(np.mean([high_freq_energy(lum_f_clean, m) for m in patch_masks.values()]))
|
||||
baseline_line = float(np.mean([line_energy(lum_front, m) for m in patch_masks.values()]))
|
||||
print(f"일반 피부 표본 기준: 고주파 에너지={baseline_hf:.3f}, 선 에너지={baseline_line:.3f}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 검사 (d): 질감 보존 — 구멍 안 고주파 에너지(F 기준) / 일반 피부 표본 평균 >= 0.7
|
||||
# ------------------------------------------------------------------
|
||||
checkD: dict[str, dict] = {}
|
||||
all_pass_d = True
|
||||
for name, side_excl in named_excl.items():
|
||||
side_visible = side_excl & ~hair_mask
|
||||
e_inside = high_freq_energy(lum_f_clean, side_visible)
|
||||
ratio_d = e_inside / baseline_hf if baseline_hf > 1e-6 else 0.0
|
||||
passed = ratio_d >= CHECKD_THRESH
|
||||
all_pass_d = all_pass_d and passed
|
||||
checkD[name] = {"insideEnergy": e_inside, "baselineEnergy": baseline_hf, "ratio": ratio_d, "pass": passed}
|
||||
print(f"검사(d) {name}: 안={e_inside:.3f} 표본기준={baseline_hf:.3f} 비율={ratio_d:.3f} (기준 >={CHECKD_THRESH}) {'OK' if passed else '[미달]'}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 검사 (e): 유령 윤곽 — 제외 영역 밖 0~12px 띠(머리카락 제외)의 선
|
||||
# 에너지(front 기준) / 일반 피부 표본 평균 <= 1.5. 머리카락 가닥은 이목구비
|
||||
# 잉크선이 아니므로 checkC와 같은 기준으로 뺀다.
|
||||
# ------------------------------------------------------------------
|
||||
checkE: dict[str, dict] = {}
|
||||
all_pass_e = True
|
||||
for name, side_excl in named_excl.items():
|
||||
band = binary_dilation(side_excl, iterations=RING_PX) & ~exclusion_mask & ~hair_mask
|
||||
e_band = line_energy(lum_front, band)
|
||||
ratio_e = e_band / baseline_line if baseline_line > 1e-6 else 0.0
|
||||
passed = ratio_e <= CHECKE_THRESH
|
||||
all_pass_e = all_pass_e and passed
|
||||
checkE[name] = {"bandLineEnergy": e_band, "baselineLineEnergy": baseline_line, "ratio": ratio_e, "pass": passed}
|
||||
print(f"검사(e) {name}: 경계띠선에너지={e_band:.3f} 표본기준={baseline_line:.3f} 비율={ratio_e:.3f} (기준 <={CHECKE_THRESH}) {'OK' if passed else '[미달-유령윤곽]'}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 검사 (다크서클 복원): 아랫눈꺼풀 아래 10~35px, 홍채 중심 x ±35 띠의 합성
|
||||
# 평균 명도가 base-front와 ±6 이내여야 한다(오케스트레이터 지시 — 다크서클
|
||||
# 해칭이 지워지면 안 된다).
|
||||
# ------------------------------------------------------------------
|
||||
checkDarkCircle: dict[str, dict] = {}
|
||||
all_pass_dc = True
|
||||
for name, eye in (("eyeLeft", eyeL), ("eyeRight", eyeR)):
|
||||
band = dark_circle_band_mask(eye, w, h)
|
||||
composite_mean = float(lum_composite[band].mean())
|
||||
front_mean = float(lum_front[band].mean())
|
||||
diff2 = abs(composite_mean - front_mean)
|
||||
passed = diff2 <= DARK_CIRCLE_MATCH_TOLERANCE
|
||||
all_pass_dc = all_pass_dc and passed
|
||||
checkDarkCircle[name] = {"compositeMean": composite_mean, "frontMean": front_mean, "diff": diff2, "pass": passed}
|
||||
print(f"검사(다크서클) {name}: 합성={composite_mean:.1f} base-front={front_mean:.1f} 차={diff2:.2f} (기준 <={DARK_CIRCLE_MATCH_TOLERANCE}) {'OK' if passed else '[미달]'}")
|
||||
|
||||
manifest.setdefault("layersV2", {})
|
||||
manifest["layersV2"]["faceDetail"] = {
|
||||
"browLandmarksSource": brow_source,
|
||||
"regions": {
|
||||
"eyeLeft": [round(bx0, 1), round(by0, 1), round(bx1, 1), round(by1, 1)],
|
||||
"eyeRight": [round(rx0, 1), round(ry0, 1), round(rx1, 1), round(ry1, 1)],
|
||||
"mouth": [round(mouth_x0, 1), round(mouth_y0, 1), round(mouth_x1, 1), round(mouth_y1, 1)],
|
||||
"moles": [{"center": [round(m["center"][0], 1), round(m["center"][1], 1)], "radius": m["radius"]} for m in cfg.moles],
|
||||
},
|
||||
"exclusionPixels": {
|
||||
"eyeLeft": int(eyeL_excl.sum()), "eyeRight": int(eyeR_excl.sum()),
|
||||
"browLeft": int(browL_excl.sum()), "browRight": int(browR_excl.sum()),
|
||||
"mouth": int(mouth_excl.sum()), "union": int(exclusion_mask.sum()),
|
||||
},
|
||||
"hairMaskPixels": int(hair_mask.sum()),
|
||||
"bbox": bbox,
|
||||
"textureCloneStamp": {
|
||||
"cloneTargetThreshold": CLONE_TARGET_THRESH,
|
||||
"avoidThreshold": AVOID_THRESH,
|
||||
"lineOpeningIterations": LINE_OPENING_ITER,
|
||||
"featherPx": CLONE_STAMP_FEATHER_PX,
|
||||
"badTexturePixels": int(bad_texture.sum()),
|
||||
"preservedShadowPixels": int(dark_in_excl.sum()) - int(bad_texture.sum()),
|
||||
"components": stamp_report,
|
||||
},
|
||||
"genericPatches": {name: list(box) for name, box in patch_boxes.items()},
|
||||
"genericPatchBaseline": {"highFreqEnergy": baseline_hf, "lineEnergy": baseline_line},
|
||||
"checkA_meanAbsDiff": mean_abs_a,
|
||||
"checkB_seamLumDiff": seam_diff,
|
||||
"checkC_holeDarkResiduePct": hole_dark_pct,
|
||||
"checkC_violationPixels": n_violations,
|
||||
"checkD_texturePreservation": checkD,
|
||||
"checkD_allPass": all_pass_d,
|
||||
"checkE_ghostOutline": checkE,
|
||||
"checkE_allPass": all_pass_e,
|
||||
"checkDarkCircle": checkDarkCircle,
|
||||
"checkDarkCircle_allPass": all_pass_dc,
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
344
docs/avatar-art/linocut-pipeline/scripts/final_previews.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
"""공통 리노컷 리그 — 최종 게시 미리보기.
|
||||
|
||||
게시된 WebP(apps/web/public/avatar/v3/<publicSlug>/*.webp)를 다시 읽어 합성한다
|
||||
(원본 PNG가 아니라 실제로 배포되는 파일을 검증하기 위함). export_rig.py가 남긴
|
||||
preview/v2/export-rig-report.json의 rig 정보(레이어 x,y,w,h, pivots, palette)를
|
||||
좌표 소스로 쓴다. 벡터 부위(눈·눈썹·입 등)는 없다 — faceDetail과 grain까지만
|
||||
포함한 정적 합성이다.
|
||||
|
||||
생성물: motion-{cream,cool,dark}.png, face-detail.png, closed-eyes.png, holes-texture.jpg, ghost-check.jpg
|
||||
|
||||
전제: export_rig.py가 먼저 게시를 끝내야 한다(이 스크립트가 export-rig-report.json과
|
||||
게시된 webp를 읽는다) — 파이프라인에서 가장 마지막에 돌린다.
|
||||
|
||||
실행: <venv>/python.exe final_previews.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from segmentation import composite_over, rotate_rgba, to_u8, translate_rgba, build_padded_faceless # noqa: E402
|
||||
import face_detail as bfd # noqa: E402
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
GRAIN_OPACITY = 0.35
|
||||
|
||||
BG_CREAM = (0xEE, 0xE5, 0xD3)
|
||||
BG_COOL = (0xDC, 0xE0, 0xE2)
|
||||
BG_DARK = (0x3A, 0x3A, 0x3A)
|
||||
|
||||
FRAMES = [
|
||||
("rotate-4deg", {"rotate": -4.0, "tx": 0.0, "ty": 0.0}),
|
||||
("rotate+4deg", {"rotate": 4.0, "tx": 0.0, "ty": 0.0}),
|
||||
("up14px", {"rotate": 0.0, "tx": 0.0, "ty": -14.0}),
|
||||
("down10px", {"rotate": 0.0, "tx": 0.0, "ty": 10.0}),
|
||||
("right12px", {"rotate": 0.0, "tx": 12.0, "ty": 0.0}),
|
||||
]
|
||||
|
||||
|
||||
def load_layer(public_dir: Path, href_stem: str, x: float, y: float, w: float, h: float, canvas_w: int, canvas_h: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""게시된 webp를 rig 사각형(x,y,w,h)에 맞춰 리사이즈하고 캔버스 크기로 패딩한다."""
|
||||
im = Image.open(public_dir / f"{href_stem}.webp").convert("RGBA")
|
||||
tw, th = round(w), round(h)
|
||||
if im.size != (tw, th):
|
||||
im = im.resize((tw, th), Image.LANCZOS)
|
||||
canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
|
||||
canvas.paste(im, (round(x), round(y)))
|
||||
arr = np.array(canvas).astype(np.float64)
|
||||
return arr[..., :3], arr[..., 3]
|
||||
|
||||
|
||||
def polygon_alpha_mask(points: list[tuple[float, float]], w: int, h: int) -> np.ndarray:
|
||||
img = Image.new("L", (w, h), 0)
|
||||
ImageDraw.Draw(img).polygon(points, fill=255)
|
||||
from scipy.ndimage import gaussian_filter
|
||||
return np.clip(gaussian_filter(np.array(img, dtype=np.float64), sigma=2.0), 0, 255)
|
||||
|
||||
|
||||
def tile_grain(public_dir: Path, canvas_w: int, canvas_h: int, size: int) -> np.ndarray:
|
||||
grain_im = Image.open(public_dir / "paper-grain.webp").convert("RGB")
|
||||
if grain_im.size != (size, size):
|
||||
grain_im = grain_im.resize((size, size), Image.LANCZOS)
|
||||
grain = np.array(grain_im).astype(np.float64)
|
||||
ny = -(-canvas_h // size)
|
||||
nx = -(-canvas_w // size)
|
||||
tiled = np.tile(grain, (ny, nx, 1))[:canvas_h, :canvas_w, :]
|
||||
return tiled
|
||||
|
||||
|
||||
def apply_grain_multiply(rgb: np.ndarray, grain: np.ndarray, opacity: float) -> np.ndarray:
|
||||
factor = grain / 255.0
|
||||
multiplied = rgb * factor
|
||||
return np.clip(rgb * (1 - opacity) + multiplied * opacity, 0, 255)
|
||||
|
||||
|
||||
def composite_static(body, head, face_detail, hair_front, bg: tuple[int, int, int], canvas_w: int, canvas_h: int) -> np.ndarray:
|
||||
canvas = np.zeros((canvas_h, canvas_w, 4), dtype=np.float64)
|
||||
canvas[..., 0], canvas[..., 1], canvas[..., 2] = bg
|
||||
canvas[..., 3] = 255.0
|
||||
canvas = composite_over(canvas, to_u8(body[0]), to_u8(body[1]))
|
||||
canvas = composite_over(canvas, to_u8(head[0]), to_u8(head[1]))
|
||||
canvas = composite_over(canvas, to_u8(face_detail[0]), to_u8(face_detail[1]))
|
||||
canvas = composite_over(canvas, to_u8(hair_front[0]), to_u8(hair_front[1]))
|
||||
return to_u8(canvas)[..., :3].astype(np.float64)
|
||||
|
||||
|
||||
def main(persona_dir: Path) -> int:
|
||||
cfg = load_persona_config(persona_dir)
|
||||
base_dir = cfg.base_dir
|
||||
layers_v2_dir = cfg.layers_v2_dir
|
||||
preview_v2_dir = cfg.preview_v2_dir
|
||||
manifest_path = cfg.manifest_path
|
||||
public_dir = cfg.public_dir
|
||||
|
||||
report_path = preview_v2_dir / "export-rig-report.json"
|
||||
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
rig = report["rig"]
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
cw, ch = rig["canvas"]["w"], rig["canvas"]["h"]
|
||||
neck_pivot = tuple(rig["pivots"]["neck"])
|
||||
|
||||
def layer_of(key: str) -> tuple[np.ndarray, np.ndarray]:
|
||||
l = rig["layers"][key]
|
||||
stem = Path(l["href"]).stem
|
||||
return load_layer(public_dir, stem, l["x"], l["y"], l["w"], l["h"], cw, ch)
|
||||
|
||||
body = layer_of("body")
|
||||
head = layer_of("head")
|
||||
hair_front = layer_of("hairFront")
|
||||
face_detail_raw = layer_of("faceDetail")
|
||||
|
||||
face_oval = [tuple(p) for p in rig["faceOval"]]
|
||||
clip = polygon_alpha_mask(face_oval, cw, ch) / 255.0
|
||||
face_detail = (face_detail_raw[0], face_detail_raw[1] * clip)
|
||||
|
||||
grain_tile = tile_grain(public_dir, cw, ch, rig["grain"]["size"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# motion-{cream,cool,dark}.png
|
||||
# ------------------------------------------------------------------
|
||||
for bg_name, bg in (("cream", BG_CREAM), ("cool", BG_COOL), ("dark", BG_DARK)):
|
||||
frame_ims = []
|
||||
for name, t in FRAMES:
|
||||
h_rgb, h_a = to_u8(head[0]), to_u8(head[1])
|
||||
fd_rgb, fd_a = to_u8(face_detail[0]), to_u8(face_detail[1])
|
||||
hf_rgb, hf_a = to_u8(hair_front[0]), to_u8(hair_front[1])
|
||||
if t["rotate"] != 0.0:
|
||||
h_rgb, h_a = rotate_rgba(h_rgb, h_a, t["rotate"], neck_pivot)
|
||||
fd_rgb, fd_a = rotate_rgba(fd_rgb, fd_a, t["rotate"], neck_pivot)
|
||||
hf_rgb, hf_a = rotate_rgba(hf_rgb, hf_a, t["rotate"], neck_pivot)
|
||||
if t["tx"] != 0.0 or t["ty"] != 0.0:
|
||||
h_rgb, h_a = translate_rgba(h_rgb, h_a, t["tx"], t["ty"])
|
||||
fd_rgb, fd_a = translate_rgba(fd_rgb, fd_a, t["tx"], t["ty"])
|
||||
hf_rgb, hf_a = translate_rgba(hf_rgb, hf_a, t["tx"] * 1.4, t["ty"] * 1.4)
|
||||
frame_rgb = composite_static(
|
||||
(to_u8(body[0]), to_u8(body[1])), (h_rgb, h_a), (fd_rgb, fd_a), (hf_rgb, hf_a), bg, cw, ch
|
||||
)
|
||||
frame_rgb = apply_grain_multiply(frame_rgb, grain_tile, GRAIN_OPACITY)
|
||||
im = Image.fromarray(to_u8(frame_rgb), "RGB")
|
||||
d = ImageDraw.Draw(im)
|
||||
label_color = (255, 60, 60) if bg_name != "dark" else (255, 200, 140)
|
||||
d.text((20, 20), name, fill=label_color)
|
||||
frame_ims.append(im)
|
||||
|
||||
# 세로로 긴 캔버스라 옆으로 5장 나열하면 매우 넓어지므로 절반 크기로 축소해 나열
|
||||
scale = 0.45
|
||||
sw, sh = round(cw * scale), round(ch * scale)
|
||||
gap = 10
|
||||
strip = Image.new("RGB", (sw * len(frame_ims) + gap * (len(frame_ims) - 1), sh), bg)
|
||||
x = 0
|
||||
for im in frame_ims:
|
||||
strip.paste(im.resize((sw, sh), Image.LANCZOS), (x, 0))
|
||||
x += sw + gap
|
||||
out_path = preview_v2_dir / f"motion-{bg_name}.png"
|
||||
strip.save(out_path)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 제외 영역 재구성(face_detail.py와 동일한 함수·상수) — 미리보기가
|
||||
# 실제 게시물이 쓴 것과 같은 제외 영역 윤곽을 보여주게 한다.
|
||||
# ------------------------------------------------------------------
|
||||
lm = manifest["landmarks"]
|
||||
front = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
|
||||
eyeL, eyeR = lm["eyeLeft"], lm["eyeRight"]
|
||||
browL, browR = lm["eyebrowLeft"], lm["eyebrowRight"]
|
||||
mcL, mcR = lm["mouthCornerLeft"], lm["mouthCornerRight"]
|
||||
upLip, loLip = lm["upperLipTopCenter"], lm["lowerLipBottomCenter"]
|
||||
lum_front = front.mean(axis=2)
|
||||
import scipy.ndimage as ndi
|
||||
|
||||
yy_full, _ = np.mgrid[0:ch, 0:cw]
|
||||
eyeL_cap = yy_full <= (eyeL["lowerLidBottom"][1] + bfd.EYE_INK_LOWER_CAP_PX)
|
||||
eyeR_cap = yy_full <= (eyeR["lowerLidBottom"][1] + bfd.EYE_INK_LOWER_CAP_PX)
|
||||
eyeL_ink = bfd.eye_dark_hole(eyeL, lum_front, cw, ch) & eyeL_cap
|
||||
eyeR_ink = bfd.eye_dark_hole(eyeR, lum_front, cw, ch) & eyeR_cap
|
||||
eyeL_grown = eyeL_ink | bfd.grow_directional(eyeL_ink, dy=-bfd.EYE_CREASE_UP_PX) | bfd.grow_directional(eyeL_ink, dx=-bfd.EYE_OUTER_EXT_PX)
|
||||
eyeR_grown = eyeR_ink | bfd.grow_directional(eyeR_ink, dy=-bfd.EYE_CREASE_UP_PX) | bfd.grow_directional(eyeR_ink, dx=bfd.EYE_OUTER_EXT_PX)
|
||||
eyeL_core_excl = ndi.binary_dilation(eyeL_grown, iterations=bfd.EYE_FINAL_DILATE_PX) & eyeL_cap
|
||||
eyeR_core_excl = ndi.binary_dilation(eyeR_grown, iterations=bfd.EYE_FINAL_DILATE_PX) & eyeR_cap
|
||||
eyeL_lower_excl = ndi.binary_dilation(
|
||||
bfd.lower_lid_line_mask(eyeL["innerCorner"], eyeL["outerCorner"], eyeL["lowerLidBottom"], cw, ch), iterations=bfd.EYE_LOWER_DILATE_PX
|
||||
)
|
||||
eyeR_lower_excl = ndi.binary_dilation(
|
||||
bfd.lower_lid_line_mask(eyeR["innerCorner"], eyeR["outerCorner"], eyeR["lowerLidBottom"], cw, ch), iterations=bfd.EYE_LOWER_DILATE_PX
|
||||
)
|
||||
eyeL_excl = eyeL_core_excl | eyeL_lower_excl
|
||||
eyeR_excl = eyeR_core_excl | eyeR_lower_excl
|
||||
|
||||
browL_ink = bfd.brow_dark_hole(browL, lum_front, cw, ch)
|
||||
browR_ink = bfd.brow_dark_hole(browR, lum_front, cw, ch)
|
||||
browL_band = bfd.eyebrow_mask(browL["inner"], browL["peak"], browL["outer"], cw, ch, 2 * bfd.BROW_STROKE_HALF_WIDTH)
|
||||
browR_band = bfd.eyebrow_mask(browR["inner"], browR["peak"], browR["outer"], cw, ch, 2 * bfd.BROW_STROKE_HALF_WIDTH)
|
||||
browL_excl = ndi.binary_dilation(browL_ink | browL_band, iterations=bfd.BROW_FINAL_DILATE_PX)
|
||||
browR_excl = ndi.binary_dilation(browR_ink | browR_band, iterations=bfd.BROW_FINAL_DILATE_PX)
|
||||
|
||||
mouth_ink = bfd.mouth_dark_hole(mcL, mcR, upLip, loLip, lum_front, cw, ch)
|
||||
mouth_grown = (
|
||||
mouth_ink
|
||||
| bfd.grow_directional(mouth_ink, dx=-bfd.MOUTH_CORNER_EXT_PX)
|
||||
| bfd.grow_directional(mouth_ink, dx=bfd.MOUTH_CORNER_EXT_PX)
|
||||
| bfd.grow_directional(mouth_ink, dy=bfd.MOUTH_SHADOW_EXT_PX)
|
||||
)
|
||||
mouth_excl = ndi.binary_dilation(mouth_grown, iterations=bfd.MOUTH_FINAL_DILATE_PX)
|
||||
|
||||
named_holes = {"eyeLeft": eyeL_excl, "eyeRight": eyeR_excl, "browLeft": browL_excl, "browRight": browR_excl, "mouth": mouth_excl}
|
||||
hole_mask = eyeL_excl | eyeR_excl | browL_excl | browR_excl | mouth_excl
|
||||
|
||||
def mask_outline(mask: np.ndarray) -> np.ndarray:
|
||||
return mask & ~ndi.binary_erosion(mask, iterations=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# holes-texture.jpg: 구멍별로 [F 원본, 메운 결과(face_detail_rgb),
|
||||
# base-front, 제외 영역 윤곽 겹침]을 2배 확대해 나란히 놓는다.
|
||||
# ------------------------------------------------------------------
|
||||
f_arr = np.array(build_padded_faceless(base_dir)).astype(np.float64)
|
||||
# 게시된 webp는 알파 bbox로 잘려 있어(bbox 밖은 빈 캔버스) 구멍이 bbox 경계에
|
||||
# 걸치면 미리보기가 검게 잘린 것처럼 보인다 — 원본 PNG(전체 캔버스, RGB가
|
||||
# 어디서나 정의됨)를 직접 읽어 이 문제를 피한다.
|
||||
fd_rgb_full = np.array(Image.open(layers_v2_dir / "face-detail.png").convert("RGBA")).astype(np.float64)[..., :3]
|
||||
|
||||
outline_overlay = front.copy()
|
||||
outline_overlay[mask_outline(hole_mask)] = np.array([40.0, 200.0, 60.0])
|
||||
|
||||
rows = []
|
||||
zoom = 2
|
||||
hole_pad = 16
|
||||
for name, m in named_holes.items():
|
||||
ys, xs = np.where(m)
|
||||
bx0, by0, bx1, by1 = int(xs.min()) - hole_pad, int(ys.min()) - hole_pad, int(xs.max()) + 1 + hole_pad, int(ys.max()) + 1 + hole_pad
|
||||
box = (max(0, bx0), max(0, by0), min(cw, bx1), min(ch, by1))
|
||||
f_crop = Image.fromarray(to_u8(f_arr), "RGB").crop(box)
|
||||
fill_crop = Image.fromarray(to_u8(fd_rgb_full), "RGB").crop(box)
|
||||
front_crop = Image.fromarray(to_u8(front), "RGB").crop(box)
|
||||
outline_crop = Image.fromarray(to_u8(outline_overlay), "RGB").crop(box)
|
||||
pw2, ph2 = f_crop.size
|
||||
f_crop = f_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
|
||||
fill_crop = fill_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
|
||||
front_crop = front_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
|
||||
outline_crop = outline_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
|
||||
row = Image.new("RGB", (pw2 * zoom * 4 + 30, ph2 * zoom + 20), (255, 255, 255))
|
||||
d = ImageDraw.Draw(row)
|
||||
for i, (label, im) in enumerate([("F 원본", f_crop), ("메운 결과", fill_crop), ("base-front", front_crop), ("제외영역 윤곽", outline_crop)]):
|
||||
row.paste(im, (i * (pw2 * zoom + 10), 20))
|
||||
d.text((i * (pw2 * zoom + 10), 2), f"{name}: {label}", fill=(0, 0, 0))
|
||||
rows.append(row)
|
||||
max_w = max(r.width for r in rows)
|
||||
total_h = sum(r.height for r in rows) + 10 * (len(rows) - 1)
|
||||
holes_tex = Image.new("RGB", (max_w, total_h), (255, 255, 255))
|
||||
y = 0
|
||||
for r in rows:
|
||||
holes_tex.paste(r, (0, y))
|
||||
y += r.height + 10
|
||||
holes_tex_path = preview_v2_dir / "holes-texture.jpg"
|
||||
holes_tex.convert("RGB").save(holes_tex_path, "JPEG", quality=90)
|
||||
print(f"저장: {holes_tex_path}")
|
||||
|
||||
fd_bbox = manifest["layersV2"]["faceDetail"]["bbox"]
|
||||
fx0, fy0, fx1, fy1 = fd_bbox
|
||||
pad = 20
|
||||
fx0, fy0 = max(0, fx0 - pad), max(0, fy0 - pad)
|
||||
fx1, fy1 = min(cw, fx1 + pad), min(ch, fy1 + pad)
|
||||
|
||||
# (1) faceDetail 단독(크림 배경 위)
|
||||
fd_on_cream = np.zeros((ch, cw, 3), dtype=np.float64)
|
||||
fd_on_cream[...] = BG_CREAM
|
||||
fd_on_cream = composite_over(
|
||||
np.dstack([fd_on_cream, np.full((ch, cw), 255.0)]), to_u8(face_detail[0]), to_u8(face_detail[1])
|
||||
)[..., :3]
|
||||
|
||||
# (2) 구멍 표시(빨강 오버레이)
|
||||
hole_overlay = front.copy()
|
||||
hole_overlay[hole_mask] = hole_overlay[hole_mask] * 0.4 + np.array([230.0, 40.0, 40.0]) * 0.6
|
||||
|
||||
# (3) 정지 합성
|
||||
static_full = composite_static(body, head, face_detail, hair_front, BG_CREAM, cw, ch)
|
||||
static_full = apply_grain_multiply(static_full, grain_tile, GRAIN_OPACITY)
|
||||
|
||||
# (4) base-front 비교는 front 그대로
|
||||
|
||||
panels = [
|
||||
("faceDetail 단독", Image.fromarray(to_u8(fd_on_cream), "RGB")),
|
||||
("구멍 표시", Image.fromarray(to_u8(hole_overlay), "RGB")),
|
||||
("정지 합성", Image.fromarray(to_u8(static_full), "RGB")),
|
||||
("base-front", Image.fromarray(to_u8(front), "RGB")),
|
||||
]
|
||||
crop_box = (int(fx0), int(fy0), int(fx1), int(fy1))
|
||||
cropped = [im.crop(crop_box) for _, im in panels]
|
||||
pw, ph = cropped[0].size
|
||||
strip = Image.new("RGB", (pw * 4 + 30, ph + 24), (255, 255, 255))
|
||||
x = 0
|
||||
for (label, _), im in zip(panels, cropped):
|
||||
strip.paste(im, (x, 24))
|
||||
d = ImageDraw.Draw(strip)
|
||||
d.text((x, 4), label, fill=(0, 0, 0))
|
||||
x += pw + 10
|
||||
out_path = preview_v2_dir / "face-detail.png"
|
||||
strip.save(out_path)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# closed-eyes.png: 벡터 없이 구멍만 보이는 정지 합성의 눈·입 확대
|
||||
# (머리 영역 중심 기준 상대 좌표 — 결과물 회귀 대상이 아닌 미리보기 전용)
|
||||
# ------------------------------------------------------------------
|
||||
eyeL_out, eyeR_out = eyeL["outerCorner"], eyeR["outerCorner"]
|
||||
eye_cx = (eyeL_out[0] + eyeR_out[0]) / 2.0
|
||||
eye_box = (int(eye_cx - 210), int(browL["peak"][1] - 40), int(eye_cx + 210), int(eyeL["lowerLidBottom"][1] + 100))
|
||||
mouth_cx = (mcL[0] + mcR[0]) / 2.0
|
||||
mouth_box = (int(mouth_cx - 135), int(upLip[1] - 43), int(mouth_cx + 135), int(loLip[1] + 78))
|
||||
eye_crop = Image.fromarray(to_u8(static_full), "RGB").crop(eye_box)
|
||||
mouth_crop = Image.fromarray(to_u8(static_full), "RGB").crop(mouth_box)
|
||||
zoom = 2
|
||||
eye_crop = eye_crop.resize((eye_crop.width * zoom, eye_crop.height * zoom), Image.LANCZOS)
|
||||
mouth_w = eye_crop.width
|
||||
mouth_h = round(mouth_crop.height * (mouth_w / mouth_crop.width))
|
||||
mouth_crop = mouth_crop.resize((mouth_w, mouth_h), Image.LANCZOS)
|
||||
out_im = Image.new("RGB", (mouth_w, eye_crop.height + mouth_h + 10), (255, 255, 255))
|
||||
out_im.paste(eye_crop, (0, 0))
|
||||
out_im.paste(mouth_crop, (0, eye_crop.height + 10))
|
||||
out_path = preview_v2_dir / "closed-eyes.png"
|
||||
out_im.save(out_path)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ghost-check.jpg: faceDetail만 올린 얼굴(벡터 없음)의 눈·눈썹·입을 한
|
||||
# 프레임으로 2배 확대 — 옛 잉크선(유령 윤곽)이 남았는지 보는 용도.
|
||||
# ------------------------------------------------------------------
|
||||
ghost_box = (int(eye_cx - 240), int(browL["peak"][1] - 70), int(eye_cx + 260), int(loLip[1] + 110))
|
||||
ghost_crop = Image.fromarray(to_u8(static_full), "RGB").crop(ghost_box)
|
||||
ghost_crop = ghost_crop.resize((ghost_crop.width * 2, ghost_crop.height * 2), Image.LANCZOS)
|
||||
out_path = preview_v2_dir / "ghost-check.jpg"
|
||||
ghost_crop.convert("RGB").save(out_path, "JPEG", quality=92)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(Path(sys.argv[1])))
|
||||