tools/serve.mjs 本地静态服务 + 测试驱动注入(模拟 WE 下发用户属性、冻结动画相位、藏 canvas 取参考帧) tools/cdp.mjs 最小 CDP 客户端(真实时间等待;--screenshot 的虚拟时间会在骨骼加载完成前就耗尽) tools/shot.mjs 截图/取状态,就绪判据可传入 tools/diff.mjs ffmpeg 差分:PSNR / SSIM / 平均亮度差 tools/capture.mjs 对拍矩阵:壁纸 × 分辨率 × (正常, 藏 canvas) 实测:xilian 1920x1080 spine 层贡献 mean|diff|=26.1/255,kv37 为 10.2/255, 同配置两次截图噪声底 0.68/255。
127 lines
4.3 KiB
JavaScript
127 lines
4.3 KiB
JavaScript
// 最小 CDP 客户端:用真实时间等待页面就绪,再截图 / 取状态。
|
|
// 为什么不用 msedge --screenshot:那个走的是"虚拟时间",而贴图解码与 WebGL 初始化吃的是真实 CPU 时间,
|
|
// 虚拟时间预算会在骨骼真正加载完之前就耗尽,截出来的是还没画内容的帧(这个坑已经踩过一次)。
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import { mkdir } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
|
|
const EDGE = [
|
|
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
].find((p) => existsSync(p));
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
export async function withPage(opts, fn) {
|
|
const { width = 1920, height = 1080, profile = "tools/.cache/edge-cdp", extraArgs = [] } = opts;
|
|
if (!EDGE) throw new Error("找不到 msedge.exe");
|
|
await mkdir(resolve(profile), { recursive: true });
|
|
|
|
const child = spawn(
|
|
EDGE,
|
|
[
|
|
"--headless=new",
|
|
"--remote-debugging-port=0",
|
|
`--user-data-dir=${resolve(profile)}`,
|
|
"--no-first-run",
|
|
"--no-default-browser-check",
|
|
"--disable-extensions",
|
|
"--hide-scrollbars",
|
|
"--enable-unsafe-swiftshader",
|
|
"--autoplay-policy=no-user-gesture-required",
|
|
"--mute-audio",
|
|
`--window-size=${width},${height}`,
|
|
...extraArgs,
|
|
"about:blank",
|
|
],
|
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
);
|
|
|
|
let stderr = "";
|
|
const wsUrl = await new Promise((res, rej) => {
|
|
const timer = setTimeout(() => rej(new Error("等待 DevTools 端点超时")), 20000);
|
|
child.stderr.on("data", (d) => {
|
|
stderr += d;
|
|
const m = stderr.match(/DevTools listening on (ws:\/\/\S+)/);
|
|
if (m) {
|
|
clearTimeout(timer);
|
|
res(m[1]);
|
|
}
|
|
});
|
|
child.on("close", () => {
|
|
clearTimeout(timer);
|
|
rej(new Error("Edge 提前退出:\n" + stderr.slice(-800)));
|
|
});
|
|
});
|
|
|
|
const port = new URL(wsUrl).port;
|
|
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
|
|
const page = list.find((t) => t.type === "page");
|
|
if (!page) throw new Error("没有 page target");
|
|
|
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
await new Promise((res, rej) => {
|
|
ws.onopen = res;
|
|
ws.onerror = (e) => rej(new Error("CDP WebSocket 连接失败: " + (e?.message ?? "")));
|
|
});
|
|
|
|
let nextId = 1;
|
|
const pending = new Map();
|
|
const consoleLines = [];
|
|
const exceptions = [];
|
|
ws.onmessage = (ev) => {
|
|
const msg = JSON.parse(ev.data);
|
|
if (msg.id && pending.has(msg.id)) {
|
|
const { res, rej } = pending.get(msg.id);
|
|
pending.delete(msg.id);
|
|
msg.error ? rej(new Error(`${msg.error.message} (${JSON.stringify(msg.error.data ?? "")})`)) : res(msg.result);
|
|
return;
|
|
}
|
|
if (msg.method === "Runtime.consoleAPICalled") {
|
|
const text = (msg.params.args ?? []).map((a) => a.value ?? a.description ?? a.type).join(" ");
|
|
consoleLines.push(`${msg.params.type}: ${text}`);
|
|
}
|
|
if (msg.method === "Runtime.exceptionThrown") {
|
|
const d = msg.params.exceptionDetails;
|
|
exceptions.push(d.exception?.description ?? d.text);
|
|
}
|
|
if (msg.method === "Log.entryAdded" && msg.params.entry.level === "error") {
|
|
consoleLines.push(`log-error: ${msg.params.entry.text}`);
|
|
}
|
|
};
|
|
|
|
const send = (method, params = {}) =>
|
|
new Promise((res, rej) => {
|
|
const id = nextId++;
|
|
pending.set(id, { res, rej });
|
|
ws.send(JSON.stringify({ id, method, params }));
|
|
});
|
|
|
|
const evaluate = async (expression) => {
|
|
const r = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
|
|
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? "evaluate 失败");
|
|
return r.result?.value;
|
|
};
|
|
|
|
try {
|
|
await send("Page.enable");
|
|
await send("Runtime.enable");
|
|
await send("Log.enable");
|
|
await send("Emulation.setDeviceMetricsOverride", {
|
|
width,
|
|
height,
|
|
deviceScaleFactor: 1,
|
|
mobile: false,
|
|
});
|
|
return await fn({ send, evaluate, consoleLines, exceptions, stderr: () => stderr });
|
|
} finally {
|
|
try { ws.close(); } catch {}
|
|
child.kill();
|
|
await sleep(120);
|
|
}
|
|
}
|
|
|
|
export { sleep };
|