리노컷 공통 파이프라인 캐스트 확장 — 정렬·정규화·얼굴 해칭·원화 눈썹·눈 실측
- 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은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
This commit is contained in:
parent
9a22d1647b
commit
98577cf4ae
18 changed files with 2524 additions and 85 deletions
394
docs/avatar-art/linocut-pipeline/qa/capture_cast_sheet.mjs
Normal file
394
docs/avatar-art/linocut-pipeline/qa/capture_cast_sheet.mjs
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
// 공통 리노컷 리그 — 캐스트 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); });
|
||||
Loading…
Add table
Add a link
Reference in a new issue