아바타 v3 표현 엔진·리노컷 렌더러·립싱크와 Lab

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

View 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");
});
});