- register_edit(편집본 ECC 정렬·챔퍼 지표), normalize_base(P1 머리 크기 정규화, 위쪽 종이색 채움) - faceDetail region=face(얼굴 해칭을 원화 픽셀로 보존)·matchFacelessColor - brow_sprite 단계: 원화 눈썹 잉크 스프라이트(hairFront 아래 제외), 윗눈꺼풀 두께·홍채 실측 - 리그 계약 RigBrow.halfWidth/sprite·RigEye.lineScale, 렌더러 눈썹 16띠·바깥쪽 눈꺼풀 굵기 - 실행기 UTF-8 하위 실행, brow_centerline 패딩본 자체 생성, QA 시트 촬영 도구(깜빡임 회피·고해상도 대조) - P1은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
394 lines
19 KiB
JavaScript
394 lines
19 KiB
JavaScript
// 공통 리노컷 리그 — 캐스트 QA 캡처 도구(T7). scratch/linocut-review/capture_out4.mjs의
|
|
// 방식(apps/web의 playwright, auth/me 모킹, Lab 조작, 일시정지 뒤 스크린샷)을 그대로
|
|
// 따른다. 페르소나 코드 하나를 받아 AvatarLab(/dev/avatar-lab?persona=<CODE>)을 조작해
|
|
// 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 <CODE> <outDir> [--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 <CODE> <outDir> [--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 `<div style="display:grid;grid-template-columns:repeat(${cols},${cellPx}px);gap:6px;">${cells
|
|
.map((c) => `<div style="width:${cellPx}px;"><img src="${toDataUri(c.src)}" width="${cellPx}" style="display:block;border:1px solid #ccc;" /><div style="font:11px sans-serif;text-align:center;height:${labelH}px;overflow:hidden;">${c.label}</div></div>`)
|
|
.join("")}</div>`;
|
|
}
|
|
async function buildMontage(browser, filePath, htmlBody) {
|
|
// fullPage 스크린샷은 실제 콘텐츠 크기로 찍힌다(뷰포트보다 작아도 줄지 않고, 커도
|
|
// 안 잘린다 — 실측 확인됨). 뷰포트를 넉넉하게 어림잡으면 그 어림값만큼 빈 여백이
|
|
// 그대로 찍혀 긴 변 1000px 기준을 넘길 수 있어, 작게 고정해 항상 실제 콘텐츠
|
|
// 크기로 나오게 한다.
|
|
const page = await browser.newPage({ viewport: { width: 60, height: 60 } });
|
|
const html = `<!doctype html><html><body style="margin:0;padding:12px;background:#fff;font-family:sans-serif;">${htmlBody}</body></html>`;
|
|
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 = `<div style="display:flex;gap:20px;align-items:flex-start;">
|
|
<div style="width:${PANEL_W}px;"><img src="${toDataUri(neutralPortraitPath)}" width="${PANEL_W}" style="display:block;border:1px solid #ccc;" /><div style="font:12px sans-serif;text-align:center;margin-top:4px;">neutral 렌더(portrait)</div></div>
|
|
<div style="width:${PANEL_W}px;"><img src="${toDataUri(baseFrontPath)}" width="${PANEL_W}" style="display:block;border:1px solid #ccc;" /><div style="font:12px sans-serif;text-align:center;margin-top:4px;">base-front.png(원화)</div></div>
|
|
</div>`;
|
|
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); });
|