Files
SpineWallpaper/tools/serve.mjs
T
Shuery 399f99edd7 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。
2026-09-19 19:13:57 +08:00

182 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 零依赖静态服务器:把 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}/`);
});