// 把差分掩码(立绘层 = 正常帧 − 藏 canvas 参考帧)压成 ASCII, // 这样在没有图像输入能力的情况下也能"看"清构图。 // 用法: // node tools/ascii.mjs [cols] 差分两幅,打印强度图 + 实体占比图 // node tools/ascii.mjs [cols] 单幅,打印亮度图 // 行数按 2.2:1 的字符高宽比自动换算,保证画面不被压扁。 import { spawn } from "node:child_process"; const flags = process.argv.slice(2).filter((a) => a.startsWith("--")); const [fileA, fileB, colsArg] = process.argv.slice(2).filter((a) => !a.startsWith("--")); const solidOnly = flags.includes("--solid"); if (!fileA) { console.error("usage: node tools/ascii.mjs [ref.png] [cols]"); process.exit(2); } function probe(file) { return new Promise((resolve) => { const p = spawn("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file]); let out = ""; p.stdout.on("data", (d) => (out += d)); p.on("close", () => { const [w, h] = out.trim().split(",").map(Number); resolve({ w, h }); }); }); } function gray(file) { return new Promise((resolve, reject) => { const p = spawn("ffmpeg", ["-v", "error", "-i", file, "-f", "rawvideo", "-pix_fmt", "gray", "-"], { stdio: ["ignore", "pipe", "pipe"] }); const chunks = []; let err = ""; p.stdout.on("data", (d) => chunks.push(d)); p.stderr.on("data", (d) => (err += d)); p.on("close", (code) => (code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(err || `exit ${code}`)))); }); } const RAMP = " .:-=+*#%@"; const { w: W, h: H } = await probe(fileA); const bufA = await gray(fileA); if (bufA.length < W * H) { console.error(`像素不足:${bufA.length} < ${W * H}`); process.exit(1); } // 终端字符高约为宽的 2.2 倍,据此把列数换算成行数,保持画面比例 const cols = Math.max(20, Math.min(Number(colsArg) || 110, Math.floor((46 * 2.2 * W) / H))); const rows = Math.max(6, Math.round((cols * H) / W / 2.2)); const cw = W / cols; const chh = H / rows; function grid(fn) { const out = []; for (let r = 0; r < rows; r++) { let line = ""; for (let c = 0; c < cols; c++) { const x0 = Math.floor(c * cw); const x1 = Math.min(W, Math.ceil((c + 1) * cw)); const y0 = Math.floor(r * chh); const y1 = Math.min(H, Math.ceil((r + 1) * chh)); line += RAMP[Math.max(0, Math.min(9, fn(x0, x1, y0, y1)))]; } out.push(line); } return out; } function print(title, lines) { console.log(title); for (const l of lines) console.log("|" + l + "|"); console.log(""); } console.log(`画布 ${W}x${H} → ${cols}列 x ${rows}行,单元 ${cw.toFixed(1)}x${chh.toFixed(1)}px;ramp "${RAMP}"`); if (!fileB) { print(`亮度图 ${fileA}`, grid((x0, x1, y0, y1) => { let s = 0; let n = 0; for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { s += bufA[y * W + x]; n++; } return Math.round((s / n / 255) * 9); })); } else { const bufB = await gray(fileB); if (bufB.length < W * H) { console.error(`参考帧像素不足:${bufB.length} < ${W * H}`); process.exit(1); } const d = new Uint8Array(W * H); for (let i = 0; i < W * H; i++) d[i] = Math.abs(bufA[i] - bufB[i]); if (!solidOnly) print(`立绘层 · 平均强度 (${fileA} − ${fileB})`, grid((x0, x1, y0, y1) => { let s = 0; let n = 0; for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { s += d[y * W + x]; n++; } return Math.round((s / n / 255) * 9); })); print(`立绘层 · 实体占比 (>96)`, grid((x0, x1, y0, y1) => { let k = 0; let n = 0; for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { if (d[y * W + x] > 96) k++; n++; } return Math.round((k / n) * 9); })); }