diff --git a/apps/web/README.md b/apps/web/README.md index 747ff3d..2bb431a 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -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가 자동 적용. diff --git a/apps/web/e2e/avatar-expression.spec.ts b/apps/web/e2e/avatar-expression.spec.ts index 487c93a..1262eaa 100644 --- a/apps/web/e2e/avatar-expression.spec.ts +++ b/apps/web/e2e/avatar-expression.spec.ts @@ -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"); }); }); diff --git a/apps/web/e2e/avatar-session-speech.spec.ts b/apps/web/e2e/avatar-session-speech.spec.ts new file mode 100644 index 0000000..94a90cd --- /dev/null +++ b/apps/web/e2e/avatar-session-speech.spec.ts @@ -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 { + 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) { + 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 = {}) { + 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> { + const seen = new Set(); + 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); + }); +}); diff --git a/apps/web/e2e/session-layout.spec.ts b/apps/web/e2e/session-layout.spec.ts index d418fe9..dfc6587 100644 --- a/apps/web/e2e/session-layout.spec.ts +++ b/apps/web/e2e/session-layout.spec.ts @@ -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); diff --git a/apps/web/src/components/avatar/ClientAvatar.tsx b/apps/web/src/components/avatar/ClientAvatar.tsx index a2cd189..29d8d21 100644 --- a/apps/web/src/components/avatar/ClientAvatar.tsx +++ b/apps/web/src/components/avatar/ClientAvatar.tsx @@ -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 (
- {/* 호흡하는 광배 */} - + {legacyMounted ? ( + <> + {/* 호흡하는 광배 */} + - + + ) : null} + {rig && v3Active ? ( + + ) : null} {/* 페르소나 메타 + 상태 텍스트 */} diff --git a/apps/web/src/components/avatar/engine/speechDriver.ts b/apps/web/src/components/avatar/engine/speechDriver.ts new file mode 100644 index 0000000..ed1f06e --- /dev/null +++ b/apps/web/src/components/avatar/engine/speechDriver.ts @@ -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 }; +} diff --git a/apps/web/src/components/avatar/v3/ClientAvatarV3.tsx b/apps/web/src/components/avatar/v3/ClientAvatarV3.tsx new file mode 100644 index 0000000..f8c35e4 --- /dev/null +++ b/apps/web/src/components/avatar/v3/ClientAvatarV3.tsx @@ -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(null); + + /* 크롭(§8.5): 래퍼 박스의 실측 폭. 120px 이상이면 bust, 미만이면 face다. */ + const [crop, setCrop] = useState("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 | null>(null); + const activeSpeechIdRef = useRef(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 ( +
+ +
+ ); +} diff --git a/apps/web/src/components/avatar/v3/LinocutAvatar.tsx b/apps/web/src/components/avatar/v3/LinocutAvatar.tsx index 8352635..49cbcc2 100644 --- a/apps/web/src/components/avatar/v3/LinocutAvatar.tsx +++ b/apps/web/src/components/avatar/v3/LinocutAvatar.tsx @@ -23,6 +23,7 @@ import { type LinocutFrame, type JawWarpLayer, } from "./linocutGeometry"; +import { observeWidth } from "./observeWidth"; import "./linocut-avatar.css"; export interface LinocutAvatarProps { @@ -442,25 +443,23 @@ export default function LinocutAvatar({ const el = svgRef.current; if (!el) return; const viewBoxWidth = rig.crops[crop][2]; - const update = (widthPx: number) => { - if (widthPx <= 0) return; + return observeWidth(el, (widthPx) => { const k = widthPx / viewBoxWidth; sizeScaleRef.current = Math.min(2, Math.max(1, 0.3 / k)); - }; - update(el.getBoundingClientRect().width); - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; - update(w); - } }); - observer.observe(el); - return () => observer.disconnect(); }, [rig, crop]); + /* running=false(§8.5 세션 연결 — 시작 전 화면·썸네일)여도 정지한 한 프레임은 계산해 + 그린다. 그렇지 않으면 벡터 부위(눈·눈썹·입)가 비어 보인다. 마운트할 때, loadState가 + ready가 될 때, engine·rig·crop이 바뀔 때 다시 계산한다(crop·loadState를 deps에 + 넣는다). running=true면 그대로 rAF 루프를 돈다. */ useEffect(() => { - if (!running) return; const clock = nowMs ?? (() => performance.now()); + if (!running) { + const v = engine.evaluate(clock()); + applyFrame(refs.current, computeLinocutFrame(rig, v, sizeScaleRef.current)); + return; + } const loop = () => { const v = engine.evaluate(clock()); const frame = computeLinocutFrame(rig, v, sizeScaleRef.current); @@ -469,7 +468,7 @@ export default function LinocutAvatar({ }; rafRef.current = requestAnimationFrame(loop); return () => cancelAnimationFrame(rafRef.current); - }, [engine, running, nowMs, rig]); + }, [engine, running, nowMs, rig, crop, loadState]); const viewBox = rig.crops[crop].join(" "); const faceClipId = `${uid}-face-clip`; diff --git a/apps/web/src/components/avatar/v3/client-avatar-v3.css b/apps/web/src/components/avatar/v3/client-avatar-v3.css new file mode 100644 index 0000000..2afb9fd --- /dev/null +++ b/apps/web/src/components/avatar/v3/client-avatar-v3.css @@ -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; +} diff --git a/apps/web/src/components/avatar/v3/observeWidth.ts b/apps/web/src/components/avatar/v3/observeWidth.ts new file mode 100644 index 0000000..a767e5e --- /dev/null +++ b/apps/web/src/components/avatar/v3/observeWidth.ts @@ -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(); +} diff --git a/apps/web/src/components/avatar/v3/rigs/index.ts b/apps/web/src/components/avatar/v3/rigs/index.ts new file mode 100644 index 0000000..998afa5 --- /dev/null +++ b/apps/web/src/components/avatar/v3/rigs/index.ts @@ -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> = { 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; +} diff --git a/apps/web/src/components/avatar/v3/rigs/p1Rig.ts b/apps/web/src/components/avatar/v3/rigs/p1Rig.ts index b3e1277..e562ef0 100644 --- a/apps/web/src/components/avatar/v3/rigs/p1Rig.ts +++ b/apps/web/src/components/avatar/v3/rigs/p1Rig.ts @@ -1,4 +1,4 @@ -/* 생성 파일 — docs/avatar-art/p1-linocut/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */ +/* 생성 파일 — docs/avatar-art/linocut-pipeline/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */ import type { LinocutRig } from "../linocutRig"; export const P1_LINOCUT_RIG: LinocutRig = { diff --git a/apps/web/src/pages/AvatarLab.tsx b/apps/web/src/pages/AvatarLab.tsx index 758393e..1cac93c 100644 --- a/apps/web/src/pages/AvatarLab.tsx +++ b/apps/web/src/pages/AvatarLab.tsx @@ -14,18 +14,9 @@ import { CHANNEL_IDS } from "../components/avatar/engine/channels"; import { createAvatarEngine, type AvatarEngine, type DebugSnapshot } from "../components/avatar/engine/engine"; import { REACTION_CLIPS, REACTION_CLIP_IDS, type ReactionClipId } from "../components/avatar/engine/clipCatalog"; import { demeanorFor } from "../components/avatar/engine/demeanorDefaults"; -import { buildPerformance, type Performance, type PerformanceCue } from "../components/avatar/engine/performance"; -import { - buildSpeechTimeline, - currentViseme, - sampleSpeech, - type PhraseKind, - type SpeechShape, - type SpeechTimeline, - type VisemeId, -} from "../components/avatar/engine/lipsync"; -import { buildCoSpeechPlan, isStressPulseActive, sampleCoSpeech, type CoSpeechPlan } from "../components/avatar/engine/coSpeech"; -import { computeEnvelope, type SpeechEnvelope } from "../components/avatar/engine/speechEnvelope"; +import 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"; @@ -83,16 +74,6 @@ function createClock(): Clock { }; } -/** 표시용: localMs 시점까지 시작한 가장 최근 구의 종류. 구 사이 휴지 중에는 그 직전 구를 보인다. */ -function currentPhraseKind(timeline: SpeechTimeline, localMs: number): PhraseKind | null { - let kind: PhraseKind | null = null; - for (const ph of timeline.phrases) { - if (ph.startMs > localMs) break; - kind = ph.kind; - } - return kind; -} - function seedFromQuery(): number { const raw = new URLSearchParams(window.location.search).get("seed"); const parsed = raw === null ? NaN : Number(raw); @@ -132,7 +113,7 @@ export default function AvatarLab() { const [unmatched, setUnmatched] = useState([]); const [snapshot, setSnapshot] = useState(null); - const speechRafRef = useRef(0); + const speechHandleRef = useRef(null); const meterRefs = useRef>({}); /* 발화층(립싱크) — 원시 SpeechShape·비짐 표시용. 채널 미터와 달리 매 프레임 갱신하지 @@ -213,7 +194,7 @@ export default function AvatarLab() { useEffect(() => { return () => { - cancelAnimationFrame(speechRafRef.current); + speechHandleRef.current?.stop(); try { audioSourceRef.current?.stop(); } catch { @@ -247,76 +228,38 @@ export default function AvatarLab() { coSpeechDisplayRef.current = { phraseKind: null, stressed: false }; } - /** timeline을 nowFn() 시계로 매 프레임 표본해 engine.setSpeechShape·setSpeechMotion에 흘려보낸다. - coSpeechPlan이 있으면 발화 동반층(§5.5)도 같은 프레임에 표본한다. */ - function runSpeechTimeline( - timeline: SpeechTimeline, - speechStartMs: number, - nowFn: () => number, - articulation: number, - coSpeechPlan: CoSpeechPlan | null, - envelope?: SpeechEnvelope, - ): void { - cancelAnimationFrame(speechRafRef.current); - const endMs = speechStartMs + timeline.totalDurationMs; - let prevLocalMs = -Infinity; - const step = () => { - const t = nowFn(); - if (t < speechStartMs) { - speechRafRef.current = requestAnimationFrame(step); - return; - } - if (t >= endMs) { - endSpeech(clock.now()); - return; - } - const localMs = t - speechStartMs; - const shape = sampleSpeech(timeline, localMs, articulation); - engine.setSpeechShape(shape); - speechDisplayRef.current = { viseme: currentViseme(timeline, localMs), shape }; - - if (coSpeechPlan) { - const sample = sampleCoSpeech(coSpeechPlan, localMs, prevLocalMs, envelope); - engine.setSpeechMotion(sample.delta); - if (sample.blinkNow) engine.requestSpeechBlink(clock.now()); - coSpeechDisplayRef.current = { - phraseKind: currentPhraseKind(timeline, localMs), - stressed: isStressPulseActive(coSpeechPlan, localMs), - }; - } - prevLocalMs = localMs; - - speechRafRef.current = requestAnimationFrame(step); - }; - speechRafRef.current = requestAnimationFrame(step); - } - function playSpeech(): void { + speechHandleRef.current?.stop(); const nowMs = clock.now(); - const { performance: perf, unmatched: um } = buildPerformance({ + 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()), }); - const speech = demeanorFor(personaCode).speech; - const timeline = buildSpeechTimeline({ text: speechText, syllablesPerSec: speech.syllablesPerSec }); - const coSpeechPlan = buildCoSpeechPlan(timeline, speech, seed); - const speechStartMs = nowMs + 300; - setAvatarState("speaking"); - engine.setState("speaking", nowMs); - engine.playPerformance(perf, { speechStartMs, speechDurationMs: timeline.totalDurationMs }, nowMs); - setParsedCues(perf.cues); - setUnmatched(um); - runSpeechTimeline(timeline, speechStartMs, clock.now, speech.articulation, coSpeechPlan); + speechHandleRef.current = handle; + setParsedCues(handle.cues); + setUnmatched(handle.unmatched); } function handleAudioFileChange(e: ChangeEvent): void { setAudioFileName(e.target.files?.[0]?.name ?? null); } - /** "오디오 파일로 말하기" — 로컬 오디오를 디코드해 포락선을 만들고, 오디오 시계로 표본한다. */ + /** "오디오 파일로 말하기" — 로컬 오디오를 디코드해 speechDriver에 넘긴다(포락선 선분석· + 오디오 시계 표본은 startSpeech 안에서 한다). */ async function playSpeechWithAudioFile(): Promise { const file = audioFileInputRef.current?.files?.[0]; if (!file) return; @@ -330,21 +273,6 @@ export default function AvatarLab() { const arrayBuffer = await file.arrayBuffer(); const audioBuffer = await ctx.decodeAudioData(arrayBuffer.slice(0)); - const channels: Float32Array[] = []; - for (let c = 0; c < audioBuffer.numberOfChannels; c++) channels.push(audioBuffer.getChannelData(c)); - const envelope = computeEnvelope(channels, audioBuffer.sampleRate); - - const nowMs = clock.now(); - const { performance: perf, unmatched: um } = buildPerformance({ - text: speechText, - expression: selectedExpression, - intensity, - openness, - seed, - }); - const speech = demeanorFor(personaCode).speech; - const timeline = buildSpeechTimeline({ text: speechText, syllablesPerSec: speech.syllablesPerSec, envelope }); - const coSpeechPlan = buildCoSpeechPlan(timeline, speech, seed); try { audioSourceRef.current?.stop(); @@ -356,19 +284,33 @@ export default function AvatarLab() { source.connect(ctx.destination); audioSourceRef.current = source; - const speechStartMs = nowMs + 300; - setAvatarState("speaking"); - engine.setState("speaking", nowMs); - engine.playPerformance(perf, { speechStartMs, speechDurationMs: timeline.totalDurationMs }, nowMs); - setParsedCues(perf.cues); - setUnmatched(um); - const startAtCtx = ctx.currentTime + 0.3; source.start(startAtCtx); - /* 오디오 시계 기준(초 → ms). Lab의 clock(performance.now() 기반)과는 별개 시계다 — - 재생 시작을 같은 300ms로 맞췄지만 독립 시계라 아주 긴 발화에서는 드리프트가 있을 수 있다. */ - const nowFn = () => (ctx.currentTime - startAtCtx) * 1000 + speechStartMs; - runSpeechTimeline(timeline, speechStartMs, nowFn, speech.articulation, coSpeechPlan, envelope); + + 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 { diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index ad5139a..9a5b98e 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -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(null); const [resumedSessionLoaded, setResumedSessionLoaded] = useState(false); const [voiceAnalyser, setVoiceAnalyser] = useState(null); + // v3(리노컷) 아바타 발화 구동(결정문 §8.5). TTS 재생 시작 시점에 세팅하고, 정지·실패· + // 종료 경로에서 null로 되돌린다. + const [avatarSpeech, setAvatarSpeech] = useState(null); const [endDialogOpen, setEndDialogOpen] = useState(false); const [ending, setEnding] = useState(false); const endCancelRef = useRef(null); @@ -346,6 +350,9 @@ export default function Session() { const ttsPlaybackActiveRef = useRef(false); const ttsRequestAbortRef = useRef(null); const playTtsAudioRef = useRef<(() => Promise) | null>(null); + // v3 아바타 발화 텍스트(결정문 §8.5 2g): 텍스트 모드는 speakTextClientTurn 직전, + // 음성 모드는 reply 이벤트에서 담는다. 발화를 한 번 내보내면 비운다. + const pendingSpeechTextRef = useRef(null); const pendingVoiceLearnerIdRef = useRef(null); const pendingVoiceLearnerTextRef = useRef(""); const coachEvidenceCloseRef = useRef(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} /> diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index bd27506..c7c008d 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -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 { diff --git a/docs/avatar-art/linocut-cast-prompts/P2-front.txt b/docs/avatar-art/linocut-cast-prompts/P2-front.txt new file mode 100644 index 0000000..7da9e3c --- /dev/null +++ b/docs/avatar-art/linocut-cast-prompts/P2-front.txt @@ -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. \ No newline at end of file diff --git a/docs/avatar-art/linocut-cast-prompts/P3-front-v2.txt b/docs/avatar-art/linocut-cast-prompts/P3-front-v2.txt new file mode 100644 index 0000000..42bbd03 --- /dev/null +++ b/docs/avatar-art/linocut-cast-prompts/P3-front-v2.txt @@ -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. \ No newline at end of file diff --git a/docs/avatar-art/linocut-cast-prompts/P3-front.txt b/docs/avatar-art/linocut-cast-prompts/P3-front.txt new file mode 100644 index 0000000..89898f3 --- /dev/null +++ b/docs/avatar-art/linocut-cast-prompts/P3-front.txt @@ -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. \ No newline at end of file diff --git a/docs/avatar-art/linocut-cast-prompts/P4-front-v2.txt b/docs/avatar-art/linocut-cast-prompts/P4-front-v2.txt new file mode 100644 index 0000000..6f92de7 --- /dev/null +++ b/docs/avatar-art/linocut-cast-prompts/P4-front-v2.txt @@ -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. \ No newline at end of file diff --git a/docs/avatar-art/linocut-cast-prompts/P4-front.txt b/docs/avatar-art/linocut-cast-prompts/P4-front.txt new file mode 100644 index 0000000..5dd4341 --- /dev/null +++ b/docs/avatar-art/linocut-cast-prompts/P4-front.txt @@ -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. \ No newline at end of file diff --git a/docs/avatar-art/linocut-cast-prompts/P5-front-v2.txt b/docs/avatar-art/linocut-cast-prompts/P5-front-v2.txt new file mode 100644 index 0000000..72774db --- /dev/null +++ b/docs/avatar-art/linocut-cast-prompts/P5-front-v2.txt @@ -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. \ No newline at end of file diff --git a/docs/avatar-art/linocut-cast-prompts/P5-front.txt b/docs/avatar-art/linocut-cast-prompts/P5-front.txt new file mode 100644 index 0000000..dfeecdf --- /dev/null +++ b/docs/avatar-art/linocut-cast-prompts/P5-front.txt @@ -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. \ No newline at end of file diff --git a/docs/avatar-art/linocut-cast-prompts/P6-front-v2.txt b/docs/avatar-art/linocut-cast-prompts/P6-front-v2.txt new file mode 100644 index 0000000..86d221a --- /dev/null +++ b/docs/avatar-art/linocut-cast-prompts/P6-front-v2.txt @@ -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. \ No newline at end of file diff --git a/docs/avatar-art/linocut-cast-prompts/P6-front.txt b/docs/avatar-art/linocut-cast-prompts/P6-front.txt new file mode 100644 index 0000000..32d39d3 --- /dev/null +++ b/docs/avatar-art/linocut-cast-prompts/P6-front.txt @@ -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. \ No newline at end of file diff --git a/docs/avatar-art/linocut-cast-prompts/P7-front-v2.txt b/docs/avatar-art/linocut-cast-prompts/P7-front-v2.txt new file mode 100644 index 0000000..18a7064 --- /dev/null +++ b/docs/avatar-art/linocut-cast-prompts/P7-front-v2.txt @@ -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. \ No newline at end of file diff --git a/docs/avatar-art/linocut-cast-prompts/P7-front.txt b/docs/avatar-art/linocut-cast-prompts/P7-front.txt new file mode 100644 index 0000000..3c06f13 --- /dev/null +++ b/docs/avatar-art/linocut-cast-prompts/P7-front.txt @@ -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. \ No newline at end of file diff --git a/docs/avatar-art/linocut-cast-prompts/style-ref.png b/docs/avatar-art/linocut-cast-prompts/style-ref.png new file mode 100644 index 0000000..8bacbf1 Binary files /dev/null and b/docs/avatar-art/linocut-cast-prompts/style-ref.png differ diff --git a/docs/avatar-art/linocut-cast.md b/docs/avatar-art/linocut-cast.md new file mode 100644 index 0000000..9e52f72 --- /dev/null +++ b/docs/avatar-art/linocut-cast.md @@ -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`). diff --git a/docs/avatar-art/linocut-pipeline/README.md b/docs/avatar-art/linocut-pipeline/README.md new file mode 100644 index 0000000..531606a --- /dev/null +++ b/docs/avatar-art/linocut-pipeline/README.md @@ -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//`) + `rigs/` 생성 +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 +# 전체 실행 +/python.exe run_pipeline.py + +# 한 단계만 다시 실행 +/python.exe run_pipeline.py --only face_detail + +# 중간부터 끝까지 +/python.exe run_pipeline.py --from lip_texture + +# 단계 이름 목록 +/python.exe run_pipeline.py --list +``` + +각 단계 스크립트는 `python