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 处引用自包含。
This commit is contained in:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

+410
View File
@@ -0,0 +1,410 @@
// 场景播放器的门:多骨架 + 贴图平面真的合成出来了,而且**先证明它会红**。
//
// 不碰 `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);