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:
1 parent
d8c7e492f7
commit
399f99edd7
7 files changed
+499
No files matched your search
+181
@@ -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}/`);
|
||||
});
|
||||
Reference in new issue
Block a user