// 공통 리노컷 리그 — 캐스트 QA 캡처 도구(T7). scratch/linocut-review/capture_out4.mjs의 // 방식(apps/web의 playwright, auth/me 모킹, Lab 조작, 일시정지 뒤 스크린샷)을 그대로 // 따른다. 페르소나 코드 하나를 받아 AvatarLab(/dev/avatar-lab?persona=)을 조작해 // 5장을 만든다: sheet-expr-bust.jpg, sheet-expr-face.jpg, sheet-viseme.jpg, // neutral-vs-art.jpg, face-art-vs-render.jpg. // // 비짐 9종은 Lab에 고정 기능이 없어(엔진 채널을 직접 주입할 UI가 없다) 발화 재생 중 // 프레임을 쓴다: 비짐 글자를 담는 텍스트를 재생하면서 매 프레임을 처음부터 다시 // 재생해 정확한 오프셋만큼 기다리는 방식(capture_speech_strip*.mjs 패턴)으로 (t, // 비짐) 타임라인을 훑고, 각 목표 비짐이 가장 오래 유지된 구간의 중앙 지점을 그 // 비짐의 대표 프레임으로 쓴다. // // 사용: node capture_cast_sheet.mjs [--base http://localhost:5173] import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO = path.join(__dirname, "../../../../"); const require = createRequire(path.join(REPO, "apps/web/package.json")); const { chromium } = require("playwright"); const esbuild = require("esbuild"); const Module = require("node:module"); const argv = process.argv.slice(2); const CODE = argv[0]; const outDirArg = argv[1]; if (!CODE || !outDirArg) { console.error("사용: node capture_cast_sheet.mjs [--base http://localhost:5173]"); process.exit(1); } const baseIdx = argv.indexOf("--base"); const BASE_URL = baseIdx !== -1 ? argv[baseIdx + 1] : "http://localhost:5173"; const OUT = path.resolve(outDirArg); const FRAMES = path.join(OUT, "frames"); mkdirSync(OUT, { recursive: true }); mkdirSync(FRAMES, { recursive: true }); const SLUG = CODE.toLowerCase(); const CODE_UPPER = CODE.toUpperCase(); /* ── 16표정(사용자 지정) ── */ const EXPR16 = [ "neutral", "calm", "warm", "joy", "sad", "grief", "lonely", "anxious", "panic", "startled", "ashamed", "angry", "skeptical", "confused", "tired", "bored", ]; /* ── 비짐 9종(결정문 §5.4) — 목표 open/wide/round로 가장 또렷이 드러나는 한글 음절 1개씩. 밥/맘은 초성·종성이 모두 양순음(M)이라 M 비짐 구간이 더 길게 유지된다. ── */ const VISEME_WORDS = ["밥", "너", "소", "두", "그", "미", "네"]; const VISEME_TEXT = VISEME_WORDS.join(" "); const VISEME_TARGETS = ["M", "A", "EO", "O", "U", "EU", "I", "E"]; // X는 재생 전 초기 상태에서 따로 캡처 const VISEME_SCAN_END_MS = 2800; const VISEME_SCAN_STEP_MS = 50; const VISEME_CELL_TARGET_W = 300; // 최종 비짐 크롭 1장의 캔버스 단위 목표 너비(px) /* ── 깜빡임/유휴 시선 흔들림 회피(T4a) — neutral-vs-art가 깜빡임 중에 찍힌 사례가 있었다(P2). reducedMotion=true면 유휴층(breath·blink·시선 흔들림)이 꺼져 채널이 표정 목표값에 그대로 스냅되지만(엔진 §idle layer), 안전망으로 700ms 간격 3연사 중 eye-left outline bbox 높이가 가장 큰 장(=눈이 가장 크게 뜬 상태)을 고른다. ── */ const EYE_STABILITY_SHOTS = 3; const EYE_STABILITY_INTERVAL_MS = 700; const FACE_COMPARE_RENDER_PX = 760; // T4b: 렌더 얼굴 크롭 캡처 해상도 const FACE_COMPARE_ART_PX = 500; // T4b: 원화 얼굴 크롭 캡처 해상도 const FACE_COMPARE_MAX_LONG_SIDE = 1000; function toDataUri(filePath) { return `data:image/png;base64,${readFileSync(filePath).toString("base64")}`; } function loadTs(file) { const code = esbuild.transformSync(readFileSync(file, "utf8"), { loader: "ts", format: "cjs" }).code; const m = new Module(file); m.filename = file; m.paths = Module._nodeModulePaths(path.dirname(file)); m._compile(code, file); return m.exports; } async function mockAuth(page) { await page.route("**/api/auth/me", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ user_id: "cast-sheet", email: "cast-sheet@hs.ac.kr", display_name: "CastSheet", role: "learner", cohort_ids: [], onboarding_completed_at: Math.floor(Date.now() / 1000), }), }), ); } async function waitReady(page) { await page.waitForFunction(() => { const svg = document.querySelector('.al__linocut-main svg[data-render-mode="linocut-v3"]'); return svg && svg.getAttribute("data-load-state") === "ready"; }, { timeout: 10000 }); } async function setReducedMotion(page, on) { const el = page.locator('[data-testid="reduced-motion-toggle"]'); if ((await el.isChecked()) !== on) await el.click(); } async function setExpression(page, id) { await page.locator(`[data-testid="expression-${id}"]`).click(); } async function setIntensity(page, value) { await page.locator(".al__intensity input[type=range]").evaluate((el, v) => { const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set; setter.call(el, String(v)); el.dispatchEvent(new Event("input", { bubbles: true })); }, value); } async function resizeAndScreenshot(locator, wPx, hPx, filePath) { const prev = await locator.evaluate((el) => ({ w: el.style.width, h: el.style.height })); await locator.evaluate((el, size) => { el.style.width = `${size.w}px`; el.style.height = `${size.h}px`; }, { w: wPx, h: hPx }); await locator.waitFor(); await locator.screenshot({ path: filePath }); await locator.evaluate((el, p) => { el.style.width = p.w; el.style.height = p.h; }, prev); } function labelRow(cells, cellPx, cols, labelH = 22) { return `
${cells .map((c) => `
${c.label}
`) .join("")}
`; } async function buildMontage(browser, filePath, htmlBody) { // fullPage 스크린샷은 실제 콘텐츠 크기로 찍힌다(뷰포트보다 작아도 줄지 않고, 커도 // 안 잘린다 — 실측 확인됨). 뷰포트를 넉넉하게 어림잡으면 그 어림값만큼 빈 여백이 // 그대로 찍혀 긴 변 1000px 기준을 넘길 수 있어, 작게 고정해 항상 실제 콘텐츠 // 크기로 나오게 한다. const page = await browser.newPage({ viewport: { width: 60, height: 60 } }); const html = `${htmlBody}`; await page.setContent(html); await page.waitForTimeout(300); await page.screenshot({ path: filePath, type: "jpeg", quality: 88, fullPage: true }); await page.close(); } async function cropScale(page, srcPath, rect, scale, destPath) { const uri = toDataUri(srcPath); const outUri = await page.evaluate(async ({ src, rect, scale }) => { const img = await new Promise((resolve, reject) => { const im = new Image(); im.onload = () => resolve(im); im.onerror = reject; im.src = src; }); const c = document.createElement("canvas"); c.width = Math.max(1, Math.round(rect.w * scale)); c.height = Math.max(1, Math.round(rect.h * scale)); const ctx = c.getContext("2d"); ctx.imageSmoothingEnabled = false; ctx.drawImage(img, rect.x, rect.y, rect.w, rect.h, 0, 0, c.width, c.height); return c.toDataURL("image/png"); }, { src: uri, rect, scale }); writeFileSync(destPath, Buffer.from(outUri.split(",")[1], "base64")); } async function scaleDownToMaxLongSide(browser, filePath, maxSide) { const page = await browser.newPage({ viewport: { width: 60, height: 60 } }); const uri = toDataUri(filePath); const outUri = await page.evaluate(async ({ src, maxSide }) => { const img = await new Promise((resolve, reject) => { const im = new Image(); im.onload = () => resolve(im); im.onerror = reject; im.src = src; }); const scale = Math.min(1, maxSide / Math.max(img.width, img.height)); const c = document.createElement("canvas"); c.width = Math.max(1, Math.round(img.width * scale)); c.height = Math.max(1, Math.round(img.height * scale)); const ctx = c.getContext("2d"); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; ctx.drawImage(img, 0, 0, c.width, c.height); return c.toDataURL("image/jpeg", 0.9); }, { src: uri, maxSide }); writeFileSync(filePath, Buffer.from(outUri.split(",")[1], "base64")); await page.close(); } /** 깜빡임(또는 유휴 시선 흔들림) 중 캡처되는 것을 피한다(T4a). 같은 locator를 EYE_STABILITY_INTERVAL_MS 간격으로 EYE_STABILITY_SHOTS번 찍어, eye-left outline (흰자 바탕) bbox 높이가 가장 큰 장을 destPath에 남긴다. */ async function screenshotEyeStable(page, locator, wPx, hPx, destPath) { const candidates = []; for (let i = 0; i < EYE_STABILITY_SHOTS; i++) { if (i > 0) await page.waitForTimeout(EYE_STABILITY_INTERVAL_MS); const tmpPath = path.join(FRAMES, `${path.basename(destPath, path.extname(destPath))}-stab${i}.png`); await resizeAndScreenshot(locator, wPx, hPx, tmpPath); const h = await page.evaluate(() => { const outline = document.querySelector('[data-part="eye-left"]')?.children?.[0]; return outline ? outline.getBBox().height : 0; }); candidates.push({ h, tmpPath }); } const best = candidates.reduce((a, b) => (b.h > a.h ? b : a)); copyFileSync(best.tmpPath, destPath); return destPath; } /** 같은 비짐이 유지된 가장 긴 구간에서 중앙 표본을 고른다(전환 중 프레임을 피한다). 표본 자체(스크린샷 포함)를 반환한다 — 나중에 같은 t로 재생을 다시 틀어 캡처하면 클릭~엔진 시계 리셋 사이의 수십ms급 비결정성 때문에 스캔 때와 다른 비짐이 잡힐 수 있다(실측 확인됨). 그래서 스캔 패스에서 이미 찍어 둔 스크린샷을 그대로 쓴다. */ function pickRepresentativeSample(samples, viseme) { let bestLen = 0; let bestMid = null; let i = 0; while (i < samples.length) { if (samples[i].viseme !== viseme) { i++; continue; } let j = i; while (j < samples.length && samples[j].viseme === viseme) j++; const runLen = j - i; if (runLen > bestLen) { bestLen = runLen; bestMid = samples[Math.floor((i + j - 1) / 2)]; } i = j; } return bestMid; } async function main() { const rigPath = path.join(REPO, `apps/web/src/components/avatar/v3/rigs/${SLUG}Rig.ts`); const rigExportName = `${CODE_UPPER}_LINOCUT_RIG`; const rigModule = loadTs(rigPath); const rig = rigModule[rigExportName]; if (!rig) throw new Error(`${rigPath}에 ${rigExportName} export가 없다.`); const baseFrontPath = path.join(REPO, `docs/avatar-art/${SLUG}-linocut/base/base-front.png`); const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1400, height: 1100 } }); await mockAuth(page); await page.goto(`${BASE_URL}/dev/avatar-lab?seed=1&persona=${CODE_UPPER}`); await page.waitForSelector('[data-avatar-lab="true"]'); await waitReady(page); const bustCircle = page.locator(".al__preview-cell--bust-circle"); const face96 = page.locator(".al__preview-cell--face-96"); const portrait = page.locator(".al__preview-cell--portrait"); const visemeCell = page.locator('[data-testid="speech-shape-viseme"]'); /* ── 1,2) sheet-expr-bust.jpg / sheet-expr-face.jpg: 16표정, 강도1, 4x4 격자 ── */ await setReducedMotion(page, true); const CELL_PX = 215; const exprShots = []; for (const id of EXPR16) { await setExpression(page, id); await setIntensity(page, 1); await page.waitForTimeout(500); const bustPath = path.join(FRAMES, `expr-bust-${id}.png`); const facePath = path.join(FRAMES, `expr-face-${id}.png`); await resizeAndScreenshot(bustCircle, CELL_PX, CELL_PX, bustPath); await resizeAndScreenshot(face96, CELL_PX, CELL_PX, facePath); exprShots.push({ id, bustPath, facePath }); console.log(`[expr] ${id}@1 캡처 완료`); } { const cells = exprShots.map((s) => ({ src: s.bustPath, label: s.id })); const rows = []; for (let i = 0; i < cells.length; i += 4) rows.push(labelRow(cells.slice(i, i + 4), CELL_PX, 4)); await buildMontage(browser, path.join(OUT, "sheet-expr-bust.jpg"), rows.join("")); console.log("sheet-expr-bust.jpg 완료"); } { const cells = exprShots.map((s) => ({ src: s.facePath, label: s.id })); const rows = []; for (let i = 0; i < cells.length; i += 4) rows.push(labelRow(cells.slice(i, i + 4), CELL_PX, 4)); await buildMontage(browser, path.join(OUT, "sheet-expr-face.jpg"), rows.join("")); console.log("sheet-expr-face.jpg 완료"); } /* ── 3) sheet-viseme.jpg: 비짐 9종, 입 주변 확대, Lab에 고정 기능이 없어 발화 재생 중 프레임을 쓴다(패킷 비목표 — 엔진 값 변경 금지라 임시 harness도 만들지 않는다). ── */ const [fcx, fcy, fcw] = rig.crops.face; const RENDER_PX = 1203; const faceScale = RENDER_PX / fcw; const L = rig.landmarks; const mxs = [L.mouthCornerLeft[0], L.mouthCornerRight[0]]; const mys = [L.upperLipTop[1], L.lowerLipBottom[1]]; const mw = mxs[1] - mxs[0]; const mh = mys[1] - mys[0]; const mouthRect = { x: mxs[0] - mw * 0.25, y: mys[0] - mh * 0.6, w: mw * 1.5, h: mh * 2.2 }; const mouthRenderRect = { x: (mouthRect.x - fcx) * faceScale, y: (mouthRect.y - fcy) * faceScale, w: mouthRect.w * faceScale, h: mouthRect.h * faceScale }; const cropStageScale = (VISEME_CELL_TARGET_W / mouthRect.w) / faceScale; async function captureMouthZoom(destPath) { const bigPath = path.join(FRAMES, "viseme-big-tmp.png"); await resizeAndScreenshot(face96, RENDER_PX, RENDER_PX, bigPath); await cropScale(page, bigPath, mouthRenderRect, cropStageScale, destPath); } await setReducedMotion(page, false); await setExpression(page, "neutral"); await setIntensity(page, 0.3); await page.waitForTimeout(300); const visemeShots = []; // X(쉼) — 아직 재생을 누르기 전 초기 휴지 상태. { const shotPath = path.join(FRAMES, "viseme-X.png"); await captureMouthZoom(shotPath); visemeShots.push({ id: "X", src: shotPath }); console.log("[viseme] X(재생 전 초기 상태) 캡처 완료"); } await page.locator('[data-testid="speech-text"]').fill(VISEME_TEXT); // 한 패스로 (t, 비짐, 스크린샷)을 같이 모은다 — 읻고 나중에 같은 t로 재생을 다시 // 틀어 캡처하면(2패스) 클릭~엔진 시계 리셋 사이 비결정성 때문에 스캔 때와 다른 // 비짐이 잡히는 것을 실측으로 확인했다(capture_speech_strip*.mjs와 같은 1패스 방식). const samples = []; for (let t = 0; t <= VISEME_SCAN_END_MS; t += VISEME_SCAN_STEP_MS) { await page.locator('[data-testid="play-speech"]').click(); await page.waitForTimeout(t); const viseme = await visemeCell.innerText().catch(() => "?"); const shotPath = path.join(FRAMES, `viseme-scan-t${String(t).padStart(5, "0")}.png`); await captureMouthZoom(shotPath); samples.push({ t, viseme, shotPath }); } writeFileSync( path.join(OUT, "viseme-timeline.json"), JSON.stringify(samples.map(({ t, viseme, shotPath }) => ({ t, viseme, shotPath })), null, 1), ); console.log(`[viseme] 타임라인 ${samples.length}개 표본 수집(스크린샷 포함) 완료`); for (const target of VISEME_TARGETS) { const best = pickRepresentativeSample(samples, target); if (best === null) { console.log(`[viseme] ${target} — 타임라인에서 찾지 못함(건너뜀)`); continue; } visemeShots.push({ id: target, src: best.shotPath, t: best.t }); console.log(`[viseme] ${target} t=${best.t}ms 표본 채택`); } { const cells = visemeShots.map((s) => ({ src: s.src, label: s.id })); const rows = []; const cellPx = VISEME_CELL_TARGET_W; for (let i = 0; i < cells.length; i += 3) rows.push(labelRow(cells.slice(i, i + 3), cellPx, 3)); await buildMontage(browser, path.join(OUT, "sheet-viseme.jpg"), rows.join("")); console.log("sheet-viseme.jpg 완료"); } /* ── 4) neutral-vs-art.jpg: neutral 렌더(portrait)와 base-front.png를 나란히(T4a: reducedMotion을 다시 켜 유휴층을 끄고, 700ms 3연사로 깜빡임/시선 흔들림을 피한다 — viseme 구간에서 reducedMotion이 꺼진 채로 남아 neutral-vs-art가 유휴 애니메이션 중에 찍힌 사례가 있었다, P2). ── */ await setReducedMotion(page, true); await setExpression(page, "neutral"); await setIntensity(page, 1); await page.waitForTimeout(400); const PANEL_W = 420; const PANEL_H = Math.round((PANEL_W * rig.canvas.h) / rig.canvas.w); const neutralPortraitPath = path.join(FRAMES, "neutral-portrait.png"); await screenshotEyeStable(page, portrait, PANEL_W, PANEL_H, neutralPortraitPath); { const html = `
neutral 렌더(portrait)
base-front.png(원화)
`; await buildMontage(browser, path.join(OUT, "neutral-vs-art.jpg"), html); console.log("neutral-vs-art.jpg 완료"); } /* ── 5) face-art-vs-render.jpg: 고해상도 얼굴 대조(T4b) — bust 얼굴 크롭(rig.crops.face)을 렌더 760px·원화 500px로 키워 neutral·grief·joy·angry와 나란히 둔다(2줄 3칸, 최종 긴 변 <=1000px로 다시 줄인다). ── */ { const fch = rig.crops.face[3]; const artFaceCropPath = path.join(FRAMES, "face-art-crop.png"); await cropScale(page, baseFrontPath, { x: fcx, y: fcy, w: fcw, h: fch }, FACE_COMPARE_ART_PX / fcw, artFaceCropPath); const FACE_COMPARE_EXPR = ["neutral", "grief", "joy", "angry"]; const faceCompareCells = [{ src: artFaceCropPath, label: "원화(base-front)" }]; for (const id of FACE_COMPARE_EXPR) { await setExpression(page, id); await setIntensity(page, 1); await page.waitForTimeout(400); const shotPath = path.join(FRAMES, `face-art-vs-render-${id}.png`); await screenshotEyeStable(page, face96, FACE_COMPARE_RENDER_PX, FACE_COMPARE_RENDER_PX, shotPath); faceCompareCells.push({ src: shotPath, label: `렌더: ${id}` }); } const rows = []; for (let i = 0; i < faceCompareCells.length; i += 3) { rows.push(labelRow(faceCompareCells.slice(i, i + 3), FACE_COMPARE_RENDER_PX, 3)); } const outPath = path.join(OUT, "face-art-vs-render.jpg"); await buildMontage(browser, outPath, rows.join("")); await scaleDownToMaxLongSide(browser, outPath, FACE_COMPARE_MAX_LONG_SIDE); console.log("face-art-vs-render.jpg 완료"); } await browser.close(); console.log(`\n모두 완료. 산출물: ${OUT}`); } main().catch((e) => { console.error(e); process.exit(1); });