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

+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}/`);
});