test: 零依赖对拍测试台(可在不改 dist/ 的前提下复现 WE 行为)

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。
This commit is contained in:
Shuery committed 2026-09-19 19:13:57 +08:00
1 parent d8c7e492f7
commit 399f99edd7
7 files changed
+499

No files matched your search

+1
View File
@@ -0,0 +1 @@
* -text
+6
View File
@@ -0,0 +1,6 @@
# 对拍与开发产物的输出目录(可随时重新生成,不入库)
tools/shots/
tools/.cache/
# 杂项
*.log
+70
View File
@@ -0,0 +1,70 @@
// 对拍矩阵:每套壁纸 × 每种分辨率 各出两张 PNG —— 正常渲染 + 藏起 canvas 的"只有背景"参考帧,
// 然后给出"spine 层占了多少像素"的数值。
// 用法:node tools/capture.mjs <tag> [--presets xilian,kv37] [--sizes 3440x1440,1920x1080,1080x1920]
// [--base http://127.0.0.1:8181] [--propsAt load|dom] [--no-ref]
import { spawn } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
const argv = process.argv.slice(2);
const tag = argv[0];
if (!tag || tag.startsWith("--")) {
console.error("usage: node tools/capture.mjs <tag> [--presets a,b] [--sizes WxH,...] [--base url] [--propsAt dom] [--no-ref]");
process.exit(2);
}
const flag = (name, dflt) => {
const i = argv.indexOf(`--${name}`);
return i >= 0 ? argv[i + 1] : dflt;
};
const presets = flag("presets", "xilian,kv37").split(",").filter(Boolean);
const sizes = flag("sizes", "3440x1440,1920x1080,1080x1920").split(",").filter(Boolean);
const base = flag("base", "http://127.0.0.1:8181");
const propsAt = flag("propsAt", "load");
const wantRef = !argv.includes("--no-ref");
const wait = flag("wait", "25000");
const outDir = resolve("tools/shots", tag);
await mkdir(outDir, { recursive: true });
function run(cmd, args) {
return new Promise((resolve) => {
const p = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
let out = "";
p.stdout.on("data", (d) => (out += d));
p.stderr.on("data", (d) => (out += d));
p.on("close", (code) => resolve({ code, out }));
});
}
const url = (extra) => `${base}/index.html?${extra}`;
const props = (preset) => `__props=${encodeURIComponent(JSON.stringify({ preset }))}`;
const rows = [];
for (const preset of presets) {
for (const size of sizes) {
const [w, h] = size.split("x");
const main = resolve(outDir, `${preset}-${size}.png`);
const q = `${props(preset)}&__freeze=1${propsAt === "dom" ? "&__propsAt=dom" : ""}`;
const a = await run(process.execPath, ["tools/shot.mjs", main, url(q), w, h, wait]);
if (a.code !== 0) {
console.log(`FAIL ${preset} ${size}: ${a.out.trim().split("\n").pop()}`);
continue;
}
let refLine = "";
if (wantRef) {
const ref = resolve(outDir, `${preset}-${size}.ref.png`);
const b = await run(process.execPath, ["tools/shot.mjs", ref, url(q + "&__hide=canvas"), w, h, wait]);
if (b.code === 0) {
const d = await run(process.execPath, ["tools/diff.mjs", ref, main]);
refLine = d.out.trim();
} else {
refLine = "ref FAILED";
}
}
rows.push(`${preset.padEnd(7)} ${size.padEnd(11)} spine 层贡献: ${refLine}`);
console.log(rows[rows.length - 1]);
}
}
console.log(`\n输出目录: tools/shots/${tag}/`);
+126
View File
@@ -0,0 +1,126 @@
// 最小 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 };
+49
View File
@@ -0,0 +1,49 @@
// 两张 PNG 的数值对拍:PSNR / SSIM / 平均亮度差(0-255)。
// 用法:node tools/diff.mjs <a.png> <b.png> [diff-out.png]
// 判读:平均差 < 1 视为肉眼无差异;1-3 为细微;> 8 通常意味着构图/内容变了。
import { spawn } from "node:child_process";
const [a, b, outPng] = process.argv.slice(2);
if (!a || !b) {
console.error("usage: node tools/diff.mjs <a.png> <b.png> [diff-out.png]");
process.exit(2);
}
function ff(args) {
return new Promise((resolve) => {
const p = spawn("ffmpeg", ["-hide_banner", "-loglevel", "info", ...args], { stdio: ["ignore", "pipe", "pipe"] });
let err = "";
p.stderr.on("data", (d) => (err += d));
p.stdout.on("data", () => {});
p.on("close", () => resolve(err));
});
}
const psnr = await ff(["-i", a, "-i", b, "-lavfi", "psnr", "-f", "null", "-"]);
const ssim = await ff(["-i", a, "-i", b, "-lavfi", "ssim", "-f", "null", "-"]);
const yavg = await ff([
"-i", a, "-i", b,
"-lavfi", "blend=all_mode=difference,signalstats,metadata=print:key=lavfi.signalstats.YAVG",
"-f", "null", "-",
]);
const num = (text, re) => {
const m = text.match(re);
return m ? Number(m[1]) : NaN;
};
const yavgs = [...yavg.matchAll(/YAVG=([\d.]+)/g)].map((m) => Number(m[1]));
const report = {
psnr_avg: /average:inf/.test(psnr) ? Infinity : num(psnr, /average:([\d.]+)/),
ssim_all: num(ssim, /All:([\d.]+)/),
mean_abs_diff: yavgs.length ? yavgs[yavgs.length - 1] : NaN,
};
console.log(`psnr=${report.psnr_avg} dB ssim=${report.ssim_all} mean|diff|=${report.mean_abs_diff}/255`);
if (outPng) {
await ff(["-y", "-i", a, "-i", b, "-lavfi", "blend=all_mode=difference", "-frames:v", "1", outPng]);
console.log(`diff image -> ${outPng}`);
}
+181
View File
@@ -0,0 +1,181 @@
// 零依赖静态服务器:把 dist/ 以正确的 MIME 暴露出来,供无头 Edge 与人工本地预览使用。
// 用法:node tools/serve.mjs [port] [--root <dir>]
//
// 它同时承担"测试台"的角色,且完全不改动 dist/:
// ?nojs=1 剥掉所有 <script>,得到"只有 CSS 背景、没有 canvas"的对照组
// ?__props={...} 模拟 Wallpaper Engine 下发用户属性,如 {"preset":"kv37","audio_volume":0.2}
// ?__propsAt=load 默认在 window.load 之后下发(正常路径)
// =dom 则在 DOMContentLoaded 时下发,复现"WE 在 load 之前就推属性"的时序
// ?__freeze=1 骨骼加载完成后暂停播放并把动画相位归零,使截图逐像素可复现
// ?__hide=canvas 渲染正常但把 canvas 藏起来,得到"只有背景图"的同款参考帧
//
// 驱动脚本会把状态写进 document.title,配合 shot.mjs --dom 即可断言。
import { createServer } from "node:http";
import { createReadStream } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { extname, join, normalize, resolve, sep } from "node:path";
const args = process.argv.slice(2);
const portArg = args.find((a) => /^\d+$/.test(a));
const rootFlag = args.indexOf("--root");
const root = resolve(rootFlag >= 0 ? args[rootFlag + 1] : "dist");
const port = Number(portArg ?? 8181);
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml",
".flac": "audio/flac",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".opus": "audio/ogg",
".wav": "audio/wav",
".webm": "video/webm",
};
// 注入到 <script type="module" src="scripts/index.js"> 之前:
// 这是唯一能"在 spine-player.js 已加载、而 index.js 尚未执行"的插入点。
const DRIVER = (q) => `<script>
(function () {
var q = new URLSearchParams(location.search);
var props = {};
try { props = JSON.parse(q.get("__props") || "{}"); } catch (e) { props = {}; }
var freeze = q.get("__freeze") === "1";
var propsAt = q.get("__propsAt") || "load";
var state = { patched: false, presets: Object.keys(props), propsAt: null, successAt: null, frames: null, error: null };
window.__test = state;
if (!window.spine || !window.spine.SpinePlayer) {
state.error = "spine.SpinePlayer missing";
} else {
// 注意:spine-player.js 用 __export 把导出定义成 getter-only,
// 直接给 spine.SpinePlayer 赋值在非严格模式下会静默失败,所以只能补 prototype。
var proto = window.spine.SpinePlayer.prototype;
var origValidate = proto.validateConfig;
proto.validateConfig = function (config) {
var prevSuccess = config.success;
var prevError = config.error;
config.success = function (p) {
window.__player = p;
state.successAt = Math.round(performance.now());
if (freeze) freezePlayer(p);
if (prevSuccess) prevSuccess(p);
};
config.error = function (p, msg) { state.error = String(msg); if (prevError) prevError(p, msg); };
state.patched = true;
return origValidate.call(this, config);
};
var origInitialize = proto.initialize;
proto.initialize = function () {
window.__player = this;
return origInitialize.apply(this, arguments);
};
var origDraw = proto.drawFrame;
proto.drawFrame = function () {
state.drawCalls = (state.drawCalls || 0) + 1;
return origDraw.apply(this, arguments);
};
}
function freezePlayer(p) {
try {
p.pause();
var cur = p.animationState && p.animationState.getCurrent(0);
if (cur) cur.trackTime = 0;
if (p.skeleton) p.skeleton.updateWorldTransform(2);
state.info = {
animations: p.skeleton ? p.skeleton.data.animations.map(function (a) { return a.name; }) : [],
skin: p.skeleton ? p.skeleton.data.skins.map(function (s) { return s.name; }) : [],
playerError: p.error ? String(p.error) : null,
};
if (q.get("__hide") === "canvas" && p.canvas) p.canvas.style.visibility = "hidden";
} catch (e) { state.error = "freeze: " + e; }
}
function pushProps() {
if (!Object.keys(props).length) return;
var listener = window.wallpaperPropertyListener;
if (!listener || !listener.applyUserProperties) { state.error = "wallpaperPropertyListener missing"; return; }
var payload = {};
Object.keys(props).forEach(function (k) { payload[k] = { value: props[k] }; });
listener.applyUserProperties(payload);
state.propsAt = Math.round(performance.now());
}
if (propsAt === "dom") {
document.addEventListener("DOMContentLoaded", pushProps);
} else {
window.addEventListener("load", function () { setTimeout(pushProps, 0); });
}
var ticks = 0;
var timer = setInterval(function () {
ticks++;
if (window.__player) state.frames = window.__player.time ? Math.round(window.__player.time.frames) : state.frames;
document.title = "TESTSTATE " + JSON.stringify(state);
if (ticks > 40) clearInterval(timer);
}, 250);
})();
</script>`;
async function html(body, query) {
let out = body;
if (query.get("nojs") === "1") out = out.replace(/<script\b[^>]*><\/script>/g, "");
else if (query.has("__props") || query.has("__freeze")) {
out = out.replace(/(<script\b[^>]*type="module"[^>]*>)/, DRIVER(query) + "$1");
}
return out;
}
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://127.0.0.1");
let rel = decodeURIComponent(url.pathname);
if (rel.endsWith("/")) rel += "index.html";
const abs = normalize(join(root, rel));
if (abs !== root && !abs.startsWith(root + sep)) {
res.writeHead(403).end("forbidden");
return;
}
const info = await stat(abs).catch(() => null);
if (!info || !info.isFile()) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end(`404 ${rel}`);
console.log(`404 ${rel}`);
return;
}
if (extname(abs).toLowerCase() === ".html") {
const body = await html(await readFile(abs, "utf8"), url.searchParams);
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"content-length": Buffer.byteLength(body),
"cache-control": "no-store",
});
res.end(body);
return;
}
res.writeHead(200, {
"content-type": MIME[extname(abs).toLowerCase()] ?? "application/octet-stream",
"content-length": info.size,
"cache-control": "no-store",
});
createReadStream(abs).pipe(res);
} catch (err) {
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end(String(err));
}
});
server.listen(port, "127.0.0.1", () => {
console.log(`serving ${root}`);
console.log(` http://127.0.0.1:${port}/`);
});
+66
View File
@@ -0,0 +1,66 @@
// 用 CDP 截图 / 取状态(真实时间等待,见 cdp.mjs 顶部注释)。
// 用法:node tools/shot.mjs <out.png|-> <url> <W> <H> [waitMs] [options]
// --wait "<js 表达式>" 就绪判据,默认为"骨骼已加载或已报错"
// --eval "<js>" 截图前执行的表达式(结果会打印出来)
// --settle <ms> 就绪后额外等待,确保至少画过一帧(默认 500)
// --dom 不截图,打印 document.title 里的测试状态
// --extra "<edge 旗标>" 追加 Edge 旗标
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { withPage, sleep } from "./cdp.mjs";
const argv = process.argv.slice(2);
const [out, url, w, h, waitMs = "30000"] = argv;
if (!out || !url || !w || !h) {
console.error('usage: node tools/shot.mjs <out.png|-> <url> <W> <H> [waitMs] [--wait js] [--eval js] [--settle ms] [--dom]');
process.exit(2);
}
const opt = (name, dflt) => {
const i = argv.indexOf(`--${name}`);
return i >= 0 ? argv[i + 1] : dflt;
};
const DEFAULT_WAIT = '(window.__test ? !!(window.__test.successAt || window.__test.error) : document.readyState === "complete")';
const waitFor = opt("wait", DEFAULT_WAIT);
const settle = Number(opt("settle", "500"));
const domMode = argv.includes("--dom");
const extraArgs = [];
if (opt("extra")) extraArgs.push(opt("extra"));
const result = await withPage({ width: Number(w), height: Number(h), extraArgs }, async ({ send, evaluate, consoleLines, exceptions }) => {
await send("Page.navigate", { url });
const deadline = Date.now() + Number(waitMs);
let ready = false;
while (Date.now() < deadline) {
if (await evaluate(waitFor)) { ready = true; break; }
await sleep(150);
}
const state = await evaluate('(function(){ try { return JSON.parse((document.title||"").replace(/^TESTSTATE /,"")) } catch(e){ return null } })()');
await sleep(settle);
if (domMode) {
const evalOut = opt("eval") ? await evaluate(opt("eval")) : undefined;
return { ready, state, evalOut, consoleLines, exceptions };
}
if (opt("eval")) await evaluate(opt("eval"));
const shot = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
await writeFile(resolve(out), Buffer.from(shot.data, "base64"));
return { ready, state, consoleLines, exceptions };
});
const tag = result.ready ? "ready" : "TIMEOUT";
if (domMode) {
console.log(`state=${tag}`);
if (result.evalOut !== undefined) console.log(`eval=${JSON.stringify(result.evalOut)}`);
console.log(JSON.stringify(result.state, null, 2));
} else {
console.log(`${out} ${tag}`);
if (result.state) console.log(` state: ${JSON.stringify(result.state)}`);
}
const noisy = result.consoleLines.filter((l) => /error|warn|uncaught|fail/i.test(l));
if (noisy.length) console.log(" console: " + noisy.slice(0, 6).join(" | "));
if (result.exceptions.length) console.log(" exceptions: " + result.exceptions.slice(0, 4).join(" | "));
if (!result.ready) process.exit(1);