Files
SpineWallpaper/tools/serve.mjs
T
Shuery 09ff322fe8 refactor: 资源按 游戏/壁纸 分层,预设拆进各自壁纸目录
- assets/<游戏>/<壁纸>/{effects,images,audios}/,每档壁纸自包含
- 每档壁纸自带 preset.js:用 import.meta.url 推导自身资源路径,目录可整体搬走;
  目录名用显示名(含「」),代码键用稳定 id(xilian/kv37,与 WE 属性值绑定)
- preset-store.js -> presets.js:静态 import 两份预设并建 id 索引
- 新增 CONTEXT.md 术语表与 ADR-0001/0002
- 新增 check-paths / compare / heatmap 三个验证工具;serve.mjs 改为每帧强制动画相位归零

验证:check-paths 11/11 路径存在(含此前零引用的 昔涟.flac);浏览器 0 个真实 404;
与基线 worktree 同冻结设置对拍,9 对跨版本比较中 4 对逐像素完全相同(差异 0),
其余 ≤0.19/255,远低于基线自身的双模态抖动(2.27/255)。
2026-09-19 19:26:46 +08:00

195 lines
7.8 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;
// 冻结只做一次是不够的:相位可能在成功回调前后被推进过一帧。这里每帧都强制归零,
// 保证"同一份代码的任何两次截图"是同一相位,对拍才有意义。
if (freeze) {
try {
var cur = this.animationState && this.animationState.getCurrent(0);
if (cur) cur.trackTime = 0;
if (this.skeleton) this.skeleton.updateWorldTransform(2);
this.viewportTransitionStart = performance.now() - 1e6;
} catch (e) { state.error = "freeze-frame: " + e; }
}
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);
// 视口过渡(transitionTime 默认 0.25s)是时间相关的插值,会把"冻结"变得不彻底:
// 把它推到足够久以前,transitionAlpha >= 1,直接使用 currentViewport。
p.viewportTransitionStart = performance.now() - 1e6;
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}/`);
});