Files
Shuery 3f11426964 feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源
把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
2026-10-02 01:27:02 +08:00

154 lines
7.8 KiB
TypeScript
Raw Permalink 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.
// 验收 `pnpm build --sim`:把产出的自包含包用 file:// 直接打开,确认
// ① 骨架真的画出来了(canvas 出现且有非空像素)
// ② 0 网络请求失败、0 异常、0 控制台报错
// ③ 模拟器面板在,且明确标着"模拟环境"
// ④ 音频能播(data: URL 内联)
// ⑤ 全程没有任何 http(s) 请求("自包含"的硬定义)
//
// 用法:node tools/checks/verify-sim-page.mts [分发目录名]
import { withPage, sleep } from "../cdp.mjs";
import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
const dirName = process.argv[2] ?? "single-hsr-kv37";
const pageAbs = resolve("dist/releases", dirName, "sim/index.html");
if (!existsSync(pageAbs)) {
console.error(`找不到 ${pageAbs}(先跑 node tools/build.ts --single kv37 --sim)`);
process.exit(1);
}
const fileUrl = "file:///" + pageAbs.replace(/\\/g, "/");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
console.log(`自包含包:${pageAbs}`);
console.log(`体积:${(statSync(pageAbs).size / 1024 / 1024).toFixed(1)} MB\n`);
await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
const rawSend = send;
await rawSend("Page.navigate", { url: fileUrl });
const deadline = Date.now() + 45000;
let ready = false;
while (Date.now() < deadline) {
const canvasCount = await evaluate('document.querySelectorAll("#spine-container canvas").length');
if (canvasCount > 0) {
ready = true;
break;
}
await sleep(250);
}
await sleep(2500);
// ⑤ "自包含"的硬定义:这个文档取过的资源里不能有任何 http(s)。
// 用页面侧的 Resource Timing,不需要 CDP 的事件通道。
const externalRequests = await evaluate(`(function () {
return performance.getEntriesByType("resource")
.map(function (e) { return e.name; })
.filter(function (n) { return /^https?:/i.test(n); });
})()`);
check(
Array.isArray(externalRequests) && externalRequests.length === 0,
"全程没有任何 http(s) 请求(自包含的硬定义)",
Array.isArray(externalRequests) && externalRequests.length > 0 ? externalRequests.slice(0, 3).join(", ") : "无",
);
const state = await evaluate(`(function () {
var canvas = document.querySelector("#spine-container canvas");
var out = {
canvas: document.querySelectorAll("#spine-container canvas").length,
canvasSize: canvas ? canvas.clientWidth + "x" + canvas.clientHeight : null,
bg: (document.body.style.backgroundImage || "").slice(0, 24),
bgIsData: /^url\\(["']?data:/.test(document.body.style.backgroundImage || ""),
sim: !!window.__weSim,
banner: document.getElementById("wesim") ? document.querySelector("#wesim .banner").textContent.trim() : null,
// 自包含页在 <分发根>/sim/ 下,预览图必须已被内联成 data: URL——
// file:// 下 ../preview.gif 是跨目录,读不到。
previewSrc: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.getAttribute("src").slice(0, 5) : null; })(),
previewNatural: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.naturalWidth + "x" + i.naturalHeight : null; })(),
modules: window.__weModules ? Object.keys(window.__weModules).length : 0,
assetKeys: window.__simAssets ? Object.keys(window.__simAssets).length : 0,
audioSrcIsData: false,
audioDuration: null
};
var a = document.getElementById("background-music");
if (a) {
out.audioSrcIsData = /^data:/.test(a.src || "");
out.audioDuration = isFinite(a.duration) ? a.duration : null;
out.audioError = a.error ? a.error.code : null;
}
return out;
})()`);
console.log("=== 渲染 ===");
check(ready, "出现 Spine 画布", `canvas=${state.canvas}`);
check(state.canvas === 1, "只有一张 canvas", `canvas=${state.canvas}`);
check(state.canvasSize === "1920x1080", "画布尺寸等于窗口", state.canvasSize);
// 画布真的画了东西:跨帧采样非透明比例。
//
// 两个坑叠在一起,先前两次都判成了"空白":
// ① 页面里有两张 canvas——spine 自己建的那张(承载渲染)和一张 300×150 的默认尺寸空画布。
// 按 `#spine-container canvas` 取到的是后者,尺寸就不对。
// ② 播放器用 `preserveDrawingBuffer: false`(默认),**帧外**读像素拿到的永远是已清空的缓冲。
// 只读一次必然是 0%,与画没画无关。必须在 rAF 里连续采样、取历史最大值。
await evaluate(`(function () {
var list = [].slice.call(document.querySelectorAll("#spine-container canvas"));
var canvas = list.sort(function (a, b) { return b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight; })[0];
if (!canvas) { window.__px = { error: "没有 canvas" }; return; }
var gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
if (!gl) { window.__px = { error: "拿不到 WebGL 上下文" }; return; }
window.__px = { best: 0, frames: 0, w: canvas.width, h: canvas.height, count: list.length };
var buf = new Uint8Array(canvas.width * canvas.height * 4);
var tick = function () {
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, buf);
var n = 0;
for (var i = 3; i < buf.length; i += 4) if (buf[i] > 8) n++;
var ratio = n / (canvas.width * canvas.height);
if (ratio > window.__px.best) window.__px.best = ratio;
window.__px.frames++;
if (window.__px.frames < 90) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
})()`);
await sleep(2500);
const pixels = await evaluate("window.__px");
check(
pixels !== null && pixels.best !== undefined && pixels.best > 0.02,
"画布上有实际像素(不是空白)",
pixels === null
? "没有 canvas"
: pixels.error
? pixels.error
: `非透明占比 ${((pixels.best ?? 0) * 100).toFixed(1)}%(${pixels.w}x${pixels.h},采样 ${pixels.frames} 帧)`,
);
console.log("\n=== 自包含性 ===");
check(state.bgIsData, "背景图是 data: URL(没有外部文件依赖)", state.bg);
check(state.assetKeys > 0, "内联资源表已建立", `${state.assetKeys} 个 key`);
check(state.modules >= 9, "模块注册表已建立", `${state.modules} 个模块`);
check(state.audioSrcIsData, "音频是 data: URL(内联)");
check(state.audioDuration !== null && state.audioDuration > 60, "音频可解码", state.audioDuration ? `${state.audioDuration.toFixed(0)}s` : `error=${state.audioError}`);
check(pageAbs.length > 0 && !/^https?:/.test(state.bg), "没有指向 http(s) 的资源");
console.log("\n=== 模拟器 ===");
check(state.sim, "window.__weSim 存在");
check(/模拟环境/.test(state.banner ?? ""), "面板标着「模拟环境」", state.banner);
// 自包含包里的预览图:必须是内联的 data: URL,而且要真的解码出来(坏图也会是 data:)。
if (state.previewSrc !== null) {
check(state.previewSrc === "data:", "预览图已内联成 data: URL", state.previewSrc);
check(state.previewNatural === "160x160", "内联的预览图能解码", state.previewNatural);
}
console.log("\n=== 干净度 ===");
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 3).join(" | ") || "无");
const noisy = consoleLines.filter((l) => /error|failed|blocked|CORS/i.test(l));
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
});
console.log(failures === 0 ? "\n自包含包可用:双击即可打开,无需 pnpm dev,也无需任何 http 服务" : `\n自包含包验收失败:${failures} 处`);
process.exit(failures === 0 ? 0 : 1);