Files
SpineWallpaper/tools/checks/verify-scene-player.mts
T
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

411 lines
21 KiB
TypeScript
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.
// 场景播放器的门:多骨架 + 贴图平面真的合成出来了,而且**先证明它会红**。
//
// 不碰 `wallpapers/`:夹具用抓取器 staged 的 kv45/scene_main(4 骨架 + 1 平面)自造,
// 预设写成与构建产物同形的 `preset.js`(同一个 asset()/相对路径解析规则)。
//
// 前置:`python -m tools.downloader fetch --page kv45`(产物在 tools/downloader/_out/hsr/kv45/scene_main)。
// 本脚本自己跑 `tsc -p tsconfig.runtime.json`,所以不依赖 pnpm build。
import { spawn, spawnSync } from "node:child_process";
import { createServer } from "node:http";
import { createReadStream, existsSync } from "node:fs";
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { dirname, extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { withPage, sleep } from "../cdp.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const FIXTURE = join(ROOT, "tools", ".cache", "scene-player-fixture");
const STAGED = join(ROOT, "tools", "downloader", "_out", "hsr", "kv45", "scene_main");
/** 透视样本:原神页,camera type 1 + 真实景深(kv45 是正交、z 全 0,抓不住投影算错)。 */
const STAGED_PERSPECTIVE = join(ROOT, "tools", "downloader", "_out", "ys", "nico-tea", "scene_main");
const SHOT = join(ROOT, "tools", ".cache", "scene-player-shot.png");
const PORT = Number(process.env.SCENE_PORT ?? 8197);
const W = 1280;
const H = 720;
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
interface SceneDebug {
parts: number;
loaded: number;
errors: string[];
framing: { x: number; y: number; width: number; height: number } | null;
content: { x: number; y: number; width: number; height: number } | null;
spines: string[];
images: string[];
solids: number;
rotatedSkipped: number;
}
interface StagedPart {
kind: string;
id: string;
order: number;
renderOrder?: number;
position: number[];
scale: number[];
geometrySize?: number[];
geometryCenter?: number[];
rotation?: number[];
animation?: string;
skin?: string;
timeScale?: number;
}
/** 把 staged 的 scene.json 翻成 preset 形状(与 tools/lib/generate.ts 吐出的字面量同形)。 */
async function buildPresetModule(
staged: string = STAGED,
id = "kv45",
game = "hsr",
cover: string | null = "w22_slg",
): Promise<string> {
const scene = JSON.parse(await readFile(join(staged, "scene.json"), "utf8")) as {
ui?: number[];
parts: StagedPart[];
camera?: { camera?: { type?: number; fov?: number }; position?: number[] };
};
const camera = scene.camera?.camera ?? {};
const cameraLine =
camera.type === undefined
? ""
: ` camera: ${JSON.stringify({ type: camera.type, fov: camera.fov, position: scene.camera?.position })},\n`;
const parts: string[] = [];
for (const part of scene.parts) {
// 与 promote.py 同语义:纯色平面不进预设(运行时画不了它,写进去只是死配置)。
if (part.kind === "solid") continue;
const fields: string[] = [
`kind: ${JSON.stringify(part.kind)}`,
`id: ${JSON.stringify(part.id)}`,
`order: ${JSON.stringify(part.order)}`,
`position: ${JSON.stringify(part.position)}`,
`scale: ${JSON.stringify(part.scale)}`,
];
if (part.renderOrder) fields.push(`renderOrder: ${JSON.stringify(part.renderOrder)}`);
if (part.kind === "spine") {
fields.push(`jsonUrl: asset("../assets/spines/${part.id}/${part.id}.json")`);
fields.push(`atlasUrl: asset("../assets/spines/${part.id}/${part.id}.atlas")`);
} else if (part.kind === "image") {
const dir = join(staged, "scene");
const files = existsSync(dir) ? await readdir(dir) : [];
const hit = files.find((f) => f.startsWith(`${part.id}.`));
if (!hit) continue;
fields.push(`image: asset("../assets/scene/${hit}")`);
}
if (part.geometrySize) {
fields.push(`width: ${JSON.stringify(part.geometrySize[0])}`);
fields.push(`height: ${JSON.stringify(part.geometrySize[1])}`);
}
if (part.geometryCenter) fields.push(`center: ${JSON.stringify(part.geometryCenter)}`);
if (part.rotation && part.rotation.some((v) => Math.abs(v) > 1e-9)) {
fields.push(`rotation: ${JSON.stringify(part.rotation)}`);
}
if (part.animation) fields.push(`animation: ${JSON.stringify(part.animation)}`);
if (part.skin) fields.push(`skin: ${JSON.stringify(part.skin)}`);
if (part.timeScale !== undefined) fields.push(`timeScale: ${JSON.stringify(part.timeScale)}`);
parts.push(` { ${fields.join(", ")} },`);
}
return [
`const base = new URL("./", import.meta.url);`,
`const asset = (path) => new URL(path, base).href;`,
`export default {`,
` id: ${JSON.stringify(id)},`,
` name: ${JSON.stringify(id)},`,
` game: ${JSON.stringify(game)},`,
// 不设背景图:背景比例链会改取景,门要的是**纯视锥**那一条路径。
` backgroundImage: ${cover ? `asset("../assets/scene/${cover}.png")` : `""`},`,
` sceneConfig: {`,
` ui: ${JSON.stringify(scene.ui ?? [2500, 1080])},`,
cameraLine + ` parts: [`,
...parts,
` ],`,
` },`,
` audioChoices: [],`,
` audioOptions: { source: "" },`,
`};`,
``,
].join("\n");
}
/** 用 ffmpeg 把 PNG 解成 RGBA 原始像素。 */
function rawPixels(png: string): Promise<Buffer> {
return new Promise((resolvePromise) => {
const p = spawn("ffmpeg", ["-v", "error", "-i", png, "-f", "rawvideo", "-pix_fmt", "rgba", "-"], {
stdio: ["ignore", "pipe", "ignore"],
});
const chunks: Buffer[] = [];
p.stdout.on("data", (d: Buffer) => chunks.push(d));
p.on("close", () => resolvePromise(Buffer.concat(chunks)));
p.on("error", () => resolvePromise(Buffer.alloc(0)));
});
}
/** 两张同尺寸截图里有多少比例的像素不同。 */
function diffRatio(a: Buffer, b: Buffer): number {
if (!a.length || a.length !== b.length) return -1;
let different = 0;
for (let i = 0; i < a.length; i += 4) {
if (a[i] !== b[i] || a[i + 1] !== b[i + 1] || a[i + 2] !== b[i + 2] || a[i + 3] !== b[i + 3]) different++;
}
return different / (a.length / 4);
}
const MIME: Record<string, string> = {
".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",
".png": "image/png",
".jpg": "image/jpeg",
".webp": "image/webp",
};
// ---------------------------------------------------------------------------------------
// 前置条件
// ---------------------------------------------------------------------------------------
console.log("场景播放器门:\n");
if (!existsSync(join(STAGED, "scene.json"))) {
console.log(` ✗ 缺少 staged 样本:${STAGED}\n 先跑:python -m tools.downloader fetch --page kv45`);
process.exit(1);
}
const build = spawnSync(process.execPath, ["node_modules/typescript/bin/tsc", "-p", "tsconfig.runtime.json"], {
cwd: ROOT,
encoding: "utf8",
});
check(build.status === 0, "运行时能编译(tsc -p tsconfig.runtime.json)", (build.stdout ?? "").trim().split("\n").pop() ?? "");
// ---------------------------------------------------------------------------------------
// 造夹具
// ---------------------------------------------------------------------------------------
await rm(FIXTURE, { recursive: true, force: true });
await mkdir(join(FIXTURE, "scripts"), { recursive: true });
await mkdir(join(FIXTURE, "styles"), { recursive: true });
await cp(STAGED, join(FIXTURE, "assets"), { recursive: true });
await cp(STAGED_PERSPECTIVE, join(FIXTURE, "assets-perspective"), { recursive: true });
for (const file of await readdir(join(ROOT, "build", "scripts"))) {
if (file.endsWith(".js")) await cp(join(ROOT, "build", "scripts", file), join(FIXTURE, "scripts", file));
}
await cp(join(ROOT, "src", "vendor", "spine-player.js"), join(FIXTURE, "scripts", "spine-player.js"));
for (const file of await readdir(join(ROOT, "src", "styles"))) {
await cp(join(ROOT, "src", "styles", file), join(FIXTURE, "styles", file));
}
const presetModule = await buildPresetModule();
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), presetModule, "utf8");
// 第二个夹具页:透视样本(把 assets 前缀改到 assets-perspective/)
const perspectiveModule = (await buildPresetModule(STAGED_PERSPECTIVE, "nico-tea", "ys", null)).replaceAll(
"../assets/",
"../assets-perspective/",
);
await writeFile(join(FIXTURE, "scripts", "scene-preset-perspective.js"), perspectiveModule, "utf8");
await writeFile(
join(FIXTURE, "scripts", "presets.js"),
[
`import preset from "./scene-preset.js";`,
`import perspective from "./scene-preset-perspective.js";`,
`export const defaultPresetId = "kv45";`,
`export default Object.fromEntries([["kv45", preset], ["nico-tea", perspective]]);`,
``,
].join("\n"),
"utf8",
);
await writeFile(
join(FIXTURE, "index.html"),
[
`<!DOCTYPE html>`,
`<html lang="zh"><head><meta charset="UTF-8" /><title>scene fixture</title>`,
`<link rel="icon" href="data:," />`,
`<link rel="stylesheet" href="./styles/spine-player.css" />`,
`<link rel="stylesheet" href="./styles/index.css" /></head>`,
`<body><div id="spine-container"></div><audio id="background-music"></audio>`,
`<script src="./scripts/spine-player.js"></script>`,
`<script type="module" src="./scripts/index.js"></script></body></html>`,
``,
].join("\n"),
"utf8",
);
check(existsSync(join(FIXTURE, "scripts", "scene-controller.js")), "夹具里带上了 scene-controller.js");
// ---------------------------------------------------------------------------------------
// 起静态服务
// ---------------------------------------------------------------------------------------
const server = createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
const rel = normalize(pathname).replace(/^([/\\])+/, "");
const target = resolve(join(FIXTURE, rel === "" ? "index.html" : rel));
if (!target.startsWith(FIXTURE)) {
res.writeHead(403).end();
return;
}
try {
const info = await stat(target);
if (info.isDirectory()) {
res.writeHead(404).end();
return;
}
res.writeHead(200, { "content-type": MIME[extname(target).toLowerCase()] ?? "application/octet-stream" });
createReadStream(target).pipe(res);
} catch {
res.writeHead(404).end();
}
});
await new Promise<void>((r) => server.listen(PORT, "127.0.0.1", () => r()));
const BASE = `http://127.0.0.1:${PORT}/`;
const readDebug = async (evaluate: (expr: string) => Promise<unknown>): Promise<SceneDebug | null> => {
const raw = await evaluate("JSON.stringify(window.__sceneDebug ?? null)");
return raw && raw !== "null" ? (JSON.parse(String(raw)) as SceneDebug) : null;
};
const waitFor = async (evaluate: (expr: string) => Promise<unknown>, predicate: (d: SceneDebug | null) => boolean, tries = 40): Promise<SceneDebug | null> => {
let last: SceneDebug | null = null;
for (let i = 0; i < tries; i++) {
last = await readDebug(evaluate);
if (predicate(last)) return last;
await sleep(250);
}
return last;
};
// ---------------------------------------------------------------------------------------
// 绿:多骨架 + 贴图平面真的合成出来
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: BASE });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= d.parts && d.parts > 0));
console.log(` · __sceneDebug = ${JSON.stringify(debug)}`);
check(Boolean(debug), "页面写出了 __sceneDebug");
check(debug?.parts === 5, "part 数 = 4 骨架 + 1 平面 = 5", debug?.parts);
check(debug?.loaded === 5, "5 件全部加载", debug?.loaded);
check((debug?.errors.length ?? 1) === 0, "0 加载错误", debug?.errors.join(" | "));
check(debug?.spines.length === 4, "4 具骨架都建了 AnimationState", debug?.spines.join(","));
check(debug?.images.length === 1, "1 块贴图平面", debug?.images.join(","));
check(Boolean(debug?.framing && debug.framing.width > 0 && debug.framing.height > 0), "取景矩形有效", JSON.stringify(debug?.framing));
// resize(Expand) 把画布像素尺寸设成 clientWidth * devicePixelRatio,所以按 dpr 比。
const canvasSize = String(
await evaluate(
`(() => { const c = document.querySelector("#spine-container canvas"); if (!c) return ""; const dpr = window.devicePixelRatio || 1; return [c.width, c.height, Math.round(c.clientWidth * dpr), Math.round(c.clientHeight * dpr)].join(","); })()`,
),
);
const [cw, ch, expectW, expectH] = canvasSize.split(",").map(Number);
check(cw === expectW && ch === expectH && cw! > 0, "画布像素尺寸 = CSS 尺寸 × dpr", canvasSize);
const glError = Number(await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (!c) return -1; const gl = c.getContext("webgl2") || c.getContext("webgl"); return gl ? gl.getError() : -1; })()`));
if (glError === -1) console.log(" · 拿不到 WebGL 上下文(类型不匹配),跳过 gl.getError 断言");
else check(glError === 0, "gl.getError() === 0", glError);
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 2).join(" | ") || "无");
// 画面非空:**隐藏画布前后截图必须不同**。
// 不能数"alpha>0 的像素"——页面自身有底色,空画布也全是不透明像素(实测 100%,是个假绿)。
const shotWith = (await send("Page.captureScreenshot", { format: "png" })) as { data: string };
await writeFile(SHOT, Buffer.from(shotWith.data, "base64"));
await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (c) c.style.visibility = "hidden"; })()`);
await sleep(300);
const shotWithout = (await send("Page.captureScreenshot", { format: "png" })) as { data: string };
const hidden = join(ROOT, "tools", ".cache", "scene-player-shot-hidden.png");
await writeFile(hidden, Buffer.from(shotWithout.data, "base64"));
const [withPixels, withoutPixels] = await Promise.all([rawPixels(SHOT), rawPixels(hidden)]);
const ratio = diffRatio(withPixels, withoutPixels);
check(ratio > 0.02, "画布确实贡献了像素(隐藏画布后画面变化 > 2%)", `${(ratio * 100).toFixed(1)}%(截图 ${SHOT})`);
await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (c) c.style.visibility = "visible"; })()`);
});
// ---------------------------------------------------------------------------------------
// 纯色平面:不能被当贴图画(原来会给 drawTexture 传 undefined 直接崩)
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
const withSolid = presetModule.replace(
` ],\n },`,
` { kind: "solid", id: "DEFAULT", order: 99, position: [0, 0, 0], scale: [1, 1, 1], width: 100, height: 100 },\n ],\n },`,
);
check(withSolid !== presetModule, "反例确实加进了一块纯色平面");
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), withSolid, "utf8");
await send("Page.navigate", { url: `${BASE}?solid=1` });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= 5));
check(debug?.solids === 1, "纯色平面被计数(solids = 1)", debug?.solids);
check(exceptions.length === 0, "纯色平面不崩(0 未捕获异常)", exceptions.slice(0, 1).join("") || "无");
});
// ---------------------------------------------------------------------------------------
// 透视场景:取景矩形必须等于**独立算出来**的视锥(这条能抓住"投影/取景算错")
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: `${BASE}?perspective=1` });
// 等运行时挂上监听器(它在 index.js 顶层注册,模块执行完才存在),再走 WE 的真实路径切预设。
let ready = false;
for (let i = 0; i < 40 && !ready; i++) {
ready = Boolean(await evaluate("typeof window.wallpaperPropertyListener === 'object' && window.wallpaperPropertyListener !== null"));
if (!ready) await sleep(250);
}
check(ready, "运行时挂上了 wallpaperPropertyListener");
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: "nico-tea" } })`);
const debug = await waitFor(evaluate, (d) => Boolean(d && d.parts > 20 && d.loaded > 20));
console.log(` · 透视 __sceneDebug = ${JSON.stringify(debug && { parts: debug.parts, loaded: debug.loaded, framing: debug.framing, rotatedSkipped: debug.rotatedSkipped })}`);
check(debug?.parts === 43, "透视样本 part 数 = 43", debug?.parts);
check((debug?.errors.length ?? 1) === 0, "透视样本 0 加载错误", debug?.errors.slice(0, 2).join(" | "));
check(debug?.rotatedSkipped === 2, "两片倾斜的 3D 面片被跳过并计数", debug?.rotatedSkipped);
check(exceptions.length === 0, "透视样本 0 未捕获异常", exceptions.slice(0, 1).join("") || "无");
// 独立算期望:s0 = 1/(tan(fov/2)·camZ);可见区 = ui 矩形 × s0;再按画布比例 contain。
const scene = JSON.parse(await readFile(join(STAGED_PERSPECTIVE, "scene.json"), "utf8")) as {
ui?: number[];
camera?: { camera?: { fov?: number }; position?: number[] };
};
const fov = scene.camera?.camera?.fov ?? 31.417;
const camZ = scene.camera?.position?.[2] ?? 1920;
const uiSize = scene.ui ?? [2500, 1080];
const uiW = uiSize[0] ?? 2500;
const uiH = uiSize[1] ?? 1080;
const s0 = 1 / (Math.tan((fov * Math.PI) / 360) * camZ);
const aspect = W / H;
// 复刻引擎 `resizeUI`:可见矩形 = UI 矩形按画布比收缩一个轴(b<1 收宽,否则收高)。
const b = aspect / (uiW / uiH);
const visW = b < 1 ? uiW * b : uiW;
const visH = b < 1 ? uiH : uiH / b;
const expectedW = visW * s0;
const expectedH = visH * s0;
const framing = debug?.framing;
const close = (a: number | undefined, b: number) => typeof a === "number" && Math.abs(a - b) / b < 1e-6;
check(close(framing?.width, expectedW), `取景宽 = 视锥宽(独立算 ${expectedW.toFixed(4)})`, framing?.width);
check(close(framing?.height, expectedH), `取景高 = 视锥高(独立算 ${expectedH.toFixed(4)})`, framing?.height);
check(close(framing?.x, -expectedW / 2) && close(framing?.y, -expectedH / 2), "取景以场景原点为中心", JSON.stringify(framing));
});
// ---------------------------------------------------------------------------------------
// 红:把一具骨架的 atlas 指错 → 必须报出来
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate }) => {
const broken = presetModule.replace(
`atlasUrl: asset("../assets/spines/01_beijing/01_beijing.atlas")`,
`atlasUrl: asset("../assets/spines/01_beijing/does-not-exist.atlas")`,
);
check(broken !== presetModule, "反例确实改动了 atlasUrl");
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), broken, "utf8");
await send("Page.navigate", { url: `${BASE}?red=1` });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.errors.length > 0), 20);
console.log(` · 反例 __sceneDebug = ${JSON.stringify(debug)}`);
check(Boolean(debug && debug.errors.length > 0), "atlas 指错 → 报出加载错误", debug?.errors.slice(0, 1).join(""));
check(Boolean(debug && debug.loaded < (debug?.parts ?? 0)), "atlas 指错 → 少加载一件", `${debug?.loaded}/${debug?.parts}`);
});
// ---------------------------------------------------------------------------------------
// 还原:必须重新变绿(证明红是那处改动引起的)
// ---------------------------------------------------------------------------------------
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), presetModule, "utf8");
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate }) => {
await send("Page.navigate", { url: `${BASE}?restore=1` });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= d.parts && d.parts > 0));
check(debug?.loaded === 5 && (debug?.errors.length ?? 1) === 0, "还原 atlasUrl → 重新全绿", `${debug?.loaded}/${debug?.parts}`);
});
server.close();
console.log(`\n夹具留在 ${FIXTURE}(可直接起服务器用眼睛看)`);
console.log(failures === 0 ? "\n场景播放器门:通过" : `\n场景播放器门:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);