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

366 lines
22 KiB
Markdown
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.
# SpineWallpaper
**把米哈游活动页里的 Spine 立绘与整页场景,做成一份可以直接上传 Wallpaper Engine 的网页壁纸。**
![Node](https://img.shields.io/badge/node-%E2%89%A524-339933?logo=nodedotjs&logoColor=white)
![pnpm](https://img.shields.io/badge/pnpm-11-F69220?logo=pnpm&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white)
![Wallpaper Engine](https://img.shields.io/badge/Wallpaper%20Engine-Web%20%E7%B1%BB%E5%9E%8B-1F1F1F)
![版本](https://img.shields.io/badge/version-3-7C4DFF)
![门禁](https://img.shields.io/badge/gates-pnpm%20check-2EA043)
![许可证](https://img.shields.io/badge/license-%E6%9C%AA%E5%A3%B0%E6%98%8E-lightgrey)
![预览](wallpapers/preview.gif)
## 📚 目录
- [✨ 项目简介](#-项目简介)
- [🧩 特性](#-特性)
- [🚀 快速开始](#-快速开始)
- [📦 三种产物](#-三种产物)
- [🧪 本地调试](#-本地调试)
- [🛠 命令与配置](#-命令与配置)
- [🗂 目录结构](#-目录结构)
- [🏗 架构与数据流](#-架构与数据流)
- [🔬 取景算法](#-取景算法)
- [✅ 质量门禁](#-质量门禁)
- [📥 资源抓取](#-资源抓取)
- [🚧 已知约束](#-已知约束)
- [🧠 决策记录](#-决策记录)
- [🤝 参与与反馈](#-参与与反馈)
- [📄 许可证](#-许可证)
- [🙏 致谢](#-致谢)
## ✨ 项目简介
这是一个 **Wallpaper Engine 网页壁纸(Web 类型)作品 + 围绕它的构建与验证管线**。源数据只有一份
(`wallpapers/`),其余全部由 `pnpm build` 生成:单档分发、游戏合集、全部合集,各自都能整体拷到别的机器直接上传。
当前收录 3 档壁纸、2 个游戏,共 6 个分发:
| 分发目录 | 内容 | 体积 |
| -------------------- | --------------------------------------------------------- | -------- |
| `single-hsr-xilian` | 昔涟立绘(单骨架) | 86.0 MB |
| `single-hsr-kv37` | 「成为昨日的明天」专题页(单骨架 + 显式像素视口) | 11.6 MB |
| `single-ys-nico-tea` | 魔女尼可的茶会(多骨架 + 场景合成) | 9.7 MB |
| `collection-hsr` | 上面前两档的切换合集 | 97.0 MB |
| `collection-ys` | 原神这一档 | 9.7 MB |
| `collection-all` | 全部壁纸合集(**已发布**的那一档,创意工坊 `3604974793`) | 106.3 MB |
> [!NOTE]
>
> `dist/` 是纯产物目录(全量约 390 MB),已 gitignore;真相永远在 `wallpapers/` 与 `src/`。
> 术语(作品 / 分发 / 合集 / 单档 / 预设 / 视口 / 对拍……)见 [CONTEXT.md](CONTEXT.md)。
## 🧩 特性
- 🎞 **保留作者原始观感**:Spine 骨架按原始世界单位渲染,不做矩形归一化;16:9 下与作者取景逐像素一致。
- 🖼 **跨比例构图同步**:立绘层的缩放跟着背景图的 `cover` 走,竖屏 / 带鱼屏不会出现「特效与插画脱节」。
- 🔀 **多预设切换**:合集分发用 WE 的 `preset` 下拉在壁纸之间切换,音源、背景、骨架整套跟随。
- 🎵 **音源可切换**:每档壁纸自带音源清单(无损 FLAC 优先),另有「随预设」与自定义 URL 两种模式。
- 🔊 **音量 / 作者信息 / 主题配色**:全部走 WE 原生属性表,样式与官方面板对齐。
- 🧪 **可在没有 WE 的机器上调试**:内置一层严格还原官方 API 面的 WE 模拟器,另可产出双击即看的自包含包。
- 🧱 **自包含产物**:每个分发目录都是完整的字节副本,不出现任何绝对链接与跨目录相对链接。
- 📐 **带门禁**:从源数据校验、产物自包含、逐像素对拍到面板还原度,共 24 个验收脚本(见 [质量门禁](#-质量门禁))。
## 🚀 快速开始
环境要求:
| 依赖 | 版本 | 用途 |
| ------- | ------------------------------ | --------------------------------------------------------- |
| Node.js | ≥ 24(开发用 26) | 构建、调试服、验收脚本 |
| pnpm | 11.x | 包管理与任务入口 |
| Python | 3.10+ 与 PyYAML | **只有**资源抓取(`tools/downloader/`)需要,不需要浏览器 |
| 浏览器 | Chromium 内核(Edge / Chrome) | 调试服、对拍与面板验收 |
```shell
# 1) 装依赖(只有 typescript 与 @types/node)
pnpm install
# 2) 全量编译六个分发到 dist/releases/
pnpm build
# 3) 起调试服(默认注入 WE 模拟器),浏览器打开 /release/<分发目录>/
node tools/dev.ts --port 5173
# 4) 跑一遍全部质量门禁(类型 + 构建 + 语法 + 路径 + 产物自包含 + 文案归属)
pnpm check
```
> [!TIP]
>
> 只想看某几档、不想等全量拷贝时,用 `pnpm build <壁纸id>` / `pnpm build collect hsr` /
> `pnpm build single`;产物目录默认会清掉本次未构建的旧分发,保留用 `--no-clean`。
## 📦 三种产物
「产物形态」是这个项目最容易混的地方,`pnpm build` 的三档开关分别对应三种用途:
| 用途 | 命令 | 产物 | 关键性质 |
| ------------------------------ | ----------------------- | ---------------------------------- | -------------------------------------------------- |
| 📤 上传创意工坊 | `pnpm build` | `dist/releases/<分发目录>/` | 干净,**没有**任何模拟器痕迹 |
| 🌐 静态托管(如 GitHub Pages) | `pnpm build --with-sim` | 同上,`index.html` 里多一段驱动 | 地址必须是**相对**路径(子路径下根绝对路径会 404) |
| 🖱 双击即看 / 离线分享 | `pnpm build sim` | 额外产出 `<分发根>/sim/index.html` | 资源全内联(单页 15 MB~129 MB),`file://` 可用 |
类型只有两种:**单档**与**合集**,区别在收档范围(`scope: "game" | "all"`)——
「全部合集」也是合集,不是第三种类型。
> [!WARNING]
>
> 自包含包会因为体积被 GitHub 的单文件上限挡住(合集约 128.7 MB > 100 MB),
> 所以静态托管走的是「分发目录 + 构建期注入」,而不是直接发布 `sim/index.html`。
## 🧪 本地调试
```mermaid
flowchart TD
Q{"这次要做什么?"} --> A["改 runtime / 模拟器源码"]
Q --> B["验证渲染是否逐像素一致"]
Q --> C["发给别人看 / 静态托管"]
Q --> D["上传创意工坊"]
Q --> E["离线分享,双击即看"]
A --> A1["node tools/dev.ts --port 5173<br/>热更新 + WE 模拟面板"]
B --> B1["node tools/serve.mjs 8190 --root dist/releases/collection-all<br/>+ tools/capture.mjs + verify-visual-equivalence"]
C --> C1["pnpm build --with-sim"]
D --> D1["pnpm build(默认产物,无注入痕迹)"]
E --> E1["pnpm build sim"]
```
模拟器只在需要时出现,**永不进发布产物**:调试服按路由注入、静态托管构建期注入、上传 WE 用默认产物零注入。
> [!IMPORTANT]
>
> 调试服监听 `src/` 与 `wallpapers/`,改 CSS 只换样式表、改 runtime 整页刷新。
> **跑 `test-inline-guard` 前必须先停掉调试服**——它会故意改坏 `src/vendor/` 来验证守卫,两者会抢构建。
## 🛠 命令与配置
### npm scripts
| 命令 | 作用 |
| ------------------ | --------------------------------------------------------------------- |
| `pnpm dev` | 调试服(SSE 热更新 + 按路由注入 WE 模拟器) |
| `pnpm build` | 全编:所有单档 + 每个游戏合集 + 全部合集 |
| `pnpm build:pages` | 静态托管用产物(`--with-sim`) |
| `pnpm typecheck` | `tsc` 全量类型检查(含验收脚本) |
| `pnpm check` | 门禁总入口:类型 → 构建 → 语法 → 路径 → 产物自包含 → 合集文案归属 |
| `pnpm serve` | 静态服务器(对拍用),`node tools/serve.mjs <端口> --root <分发目录>` |
| `pnpm capture` | 按 CDP 真实时间拍摄渲染图 |
### 构建开关
| 开关 | 作用 |
| ------------------ | ----------------------------------------------------- |
| `--with-sim` | 让分发自带模拟器驱动(静态托管预览用) |
| `--sim` | 额外产出双击可看的自包含包 |
| `--no-embed-audio` | `--sim` 时音频不内联(包更小,但 `file://` 下播不了) |
| `--strict` | 把 warning 升级为 failure |
| `--no-clean` | 保留本次未构建的旧分发目录 |
| `--dry-run` | 只打印计划,不落盘 |
### 源数据配置
- `wallpapers/<游戏id>/<壁纸id>/meta.json`:显示名、发布文案(BBCode)、预览图、音源清单。
- `wallpapers/<游戏id>/<壁纸id>/preset.template.json`:背景图、骨架 / 场景、动画名、取景。
- `wallpapers/<游戏id>/meta.json`:游戏显示名、共享音频、`title`(该游戏合集的标题,**必填**)。
- `wallpapers/meta.json`:全局文案与 `defaultPresetId`(决定 `preset` 下拉首项)。
- `wallpapers/sources.yml`:抓取目标页清单(**不是构建输入**)。
- `VERSION`:写进 `project.json.version`(WE 要求数字)。
完整字段表与目录规则见 [wallpapers/README.md](wallpapers/README.md) —— **动 `wallpapers/` 之前先读它**。
## 🗂 目录结构
```text
SpineWallpaper/
├── wallpapers/ ← 唯一真相来源(构建的全部输入)
│ ├── meta.json ← 全局文案(= collection-all 那一档)
│ ├── preview.gif
│ └── <游戏id>/
│ ├── meta.json ← 游戏显示名 / 共享音频 / 合集标题
│ └── <壁纸id>/
│ ├── meta.json ← 发布文案 + 音源清单
│ ├── preset.template.json ← 运行时配置(背景 / 骨架 / 取景)
│ ├── spines/<骨架名>/ ← 一具骨架一组:json + atlas + 贴图页
│ ├── scene/ ← 场景图与背景图
│ ├── audios/ ← 音源
│ └── preview.gif
├── src/
│ ├── runtime/ ← 运行时(preset / 背景 / 骨架 / 场景 / 音频 / 取景)
│ ├── simulator/ ← WE 模拟器(严格还原官方 API 面)
│ ├── styles/ templates/ ← 页面骨架与样式
│ ├── vendor/spine-player.js ← spine-ts 运行时副本
│ └── project.template.json ← project.json 的骨架
├── tools/
│ ├── build.ts dev.ts serve.mjs capture.mjs
│ ├── lib/{vault,generate,bundle,types,fs,drivers}.ts
│ ├── checks/ ← 验收套件(24 个脚本)
│ └── downloader/ ← 米哈游活动页 Spine 抓取器(Python)
├── docs/adr/0001–0008 ← 架构决策记录
├── CONTEXT.md ← 领域术语表
└── dist/, build/ ← 产物与编译中间物(gitignore)
```
## 🏗 架构与数据流
```mermaid
flowchart LR
subgraph SRC["源数据 · wallpapers/(唯一真相)"]
W["壁纸目录<br/>spines/ · scene/ · audios/<br/>meta.json · preset.template.json"]
end
subgraph FETCH["抓取器(可选)"]
DL["tools/downloader<br/>fetch → staging → promote"]
end
subgraph BUILD["pnpm build"]
V["lib/vault.ts<br/>读取 + 校验"]
G["lib/generate.ts<br/>合成 preset.js / project.json"]
B["lib/bundle.ts<br/>自包含包"]
end
subgraph OUT["dist/releases/"]
S["single-游戏-壁纸"]
C["collection-游戏 / collection-all"]
SIM["分发根/sim/index.html"]
end
WE["Wallpaper Engine<br/>浏览器 / 静态托管"]
DL -->|promote 后再进源数据| W
W --> V --> G
G --> S
G --> C
G --> B --> SIM
S --> WE
C --> WE
SIM -->|file:// 双击| WE
```
构建只做**拷贝**,不做链接:早先考虑过 NTFS 硬链接省磁盘,被否掉了——链接会让「产物自包含」这条
基本性质失效,拷到别的机器或只拷一个分发目录时,链接目标不在产物就残了。
## 🔬 取景算法
背景图走 CSS `background-size: cover`(铺满、按需裁切),而播放器默认按「含住作者视口」(contain)
映射立绘层——两者只在作者调参的那个比例(16:9)下一致。项目让立绘层跟着背景的缩放走:
$$
\operatorname{coverScale}(a,\ a_{\text{img}}) = \max\!\left(\frac{a}{a_{\text{img}}},\ 1\right)
$$
$$
V_h = V_0.h \times \frac{\operatorname{coverScale}(16/9,\ a_{\text{img}})}{\operatorname{coverScale}(a,\ a_{\text{img}})},
\qquad V_w = V_h \cdot a
$$
其中 $a$ 是当前画布宽高比,$a_{\text{img}}$ 是背景图自身的宽高比(运行时读 `naturalWidth / naturalHeight`,
不在预设里重复写容易写错的数字),$V_0$ 是作者视口换算到 16:9 后的可见矩形。中心不变,
等价于「立绘层相对背景插画永远 cover」。实现是纯函数,见
[viewport-fitter.ts](src/runtime/viewport-fitter.ts),决策见 [ADR 0003](docs/adr/0003-spine-follows-background-cover.md)。
> [!IMPORTANT]
>
> 应用方式是**原地改写** `config.viewport` 的字段后调 `player.setViewport(动画名)`,
> 绝不整体替换该对象——`setViewport()` 会无条件读 `config.viewport.animations[name]`,
> 换掉整个对象会在 `spine-player.js` 第 14973 行崩溃。维护时机挂在 `config.frame` 回调上。
## ✅ 质量门禁
`pnpm check` 是最小闭环(类型 + 构建 + 语法 + 路径 + 产物自包含 + 合集文案归属)。其余脚本前置条件不同,
**没有一条命令能全跑完**;完整清单与逐条调用方式见 [tools/checks/README.md](tools/checks/README.md)。
```shell
# ① 不需要服务器
pnpm check
pnpm build sim
node tools/checks/diff-project.mts # 产物 project.json vs 已发布基线
node tools/checks/check-collection-copy.mts # 游戏合集的文案必须是"它自己那个游戏"的
node tools/checks/verify-sim-page.mts <分发名> # 自包含包 file:// 可用(含"零 http 请求")
# ② 需要 tools/serve.mjs(对拍驱动 window.__test 只有它注入)
node tools/serve.mjs 8190 --root dist/releases/collection-all # 另开一个终端
node tools/checks/test-props.mjs test-resize.mjs test-acceptance.mjs
# ③ 需要 pnpm dev(模拟器面板)
node tools/dev.ts --port 5173 --no-build
node tools/checks/verify-panel.mts http://127.0.0.1:5173 # 面板还原度(12 组)
node tools/checks/check-single-bgm.mts http://127.0.0.1:5173 # 单档里切歌真的换音源
```
> [!CAUTION]
>
> **一个从不失败的检查等于没有检查。** 门禁类脚本必须先证明它会红(注入一个坏东西、确认报错、再还原)。
> 写 `.mjs` 时不要加类型注解——它是纯 JS,`tsc` 不管它,注解会在运行时抛 SyntaxError。
> 另外:不要与调试服同时跑会写 `dist/` 的构建,watcher 会和你抢。
## 📥 资源抓取
`tools/downloader/`(Python)从 `wallpapers/sources.yml` 列出的活动页抓 Spine 骨架、贴图与场景装配信息到
staging(`_out/`)。**每个有内容的场景各产出一个可直接搬走的壁纸目录**——整个目录挪进
`wallpapers/<游戏id>/` 就能用,也可以让 `promote` 代劳。它**不是构建输入**:
```shell
python -m tools.downloader fetch --page kv45 # 抓一页的全部场景(结尾自动 verify)
python -m tools.downloader fetch # 抓 sources.yml 里的全部页面
python -m tools.downloader fetch --interactive # 逐页问:这一页推荐哪个场景(不影响落盘范围)
python -m tools.downloader select --page nico-tea # 只做选择,写 selection.yml
python -m tools.downloader verify # 只自检产物
python -m tools.downloader fetch --offline # 只用缓存 + 已落盘产物重跑,不发任何请求
python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava … # 搬进 wallpapers/
node tools/checks/check-downloader.mts # 门:编译 + 夹具绿 + 四处破坏必红
```
场景装配信息写成 `scene.json` 侧车(在每个场景目录内),**构建期烘焙进 `preset.js`**,运行时不再读侧车。
详见 [tools/downloader/README.md](tools/downloader/README.md)。
## 🚧 已知约束
- **WE 运行时(本机 2.8.0.485 / CEF 146)**:官方只注入 `window.wallpaperPropertyListener`
(`applyUserProperties` / `applyGeneralProperties` / `setPaused` / `userDirectoryFiles*`),
`window.wallpaperRegisterListener` **不存在**——按它自检会在真实 WE 里误判。属性事件可能在 `window.load`
之前到达,注册必须在模块顶层。
- **音频编码**:CEF 的 FFmpeg 白名单含 flac / vorbis / opus / mp3 / pcm,**不含 aac**,所以 m4a 大概率不可用;
项目按 [ADR 0004](docs/adr/0004-lossless-audio.md) 优先无损 FLAC,不按体积取舍。
- **帧率**:WE 不限流 `requestAnimationFrame`,用户的 fps 上限经 `applyGeneralProperties` 下发,由壁纸自己实现。
- **源码语法**:Node 的 strip-only TS 模式不支持 `enum`、构造函数参数属性、`namespace`,源码里禁用这三种写法
(`pnpm check:syntax` 会拦);类型检查交给 `tsc`。
- **第三方体积**:`src/vendor/spine-player.js` 是 spine-ts 运行时的打包副本,修改前请先确认上游授权。
- **无托管 CI**:`package.json` 是 `private`,没有 `.github/workflows`;门禁全部在本地跑(见上节)。
## 🧠 决策记录
| ADR | 决策 |
| --------------------------------------------------------- | --------------------------------------------------------------------------------- |
| [0001](docs/adr/0001-resource-layout.md) | 资源按「游戏 / 壁纸」分层,目录用 id、显示名只进元数据 |
| [0002](docs/adr/0002-preset-as-es-module.md) | 预设用 ES module(`preset.js`)而不是 JSON,路径相对自身推导 |
| [0003](docs/adr/0003-spine-follows-background-cover.md) | 立绘层取景跟随背景图的 cover 缩放(16:9 为参考比例) |
| [0004](docs/adr/0004-lossless-audio.md) | 音频用无损,不按体积取舍 |
| [0005](docs/adr/0005-build-pipeline-and-dist-topology.md) | 构建管线与分发拓扑:id 化目录、不产生任何链接、合集层级 |
| [0006](docs/adr/0006-simulator-contract.md) | 模拟器契约:还原 WE 的 API 面、不自检、替身显式化 |
| [0007](docs/adr/0007-self-contained-sim-build.md) | 自包含调试包的产出方式与两个基准 |
| [0008](docs/adr/0008-asset-layout-per-skeleton.md) | 资源布局按「一具骨架一组」(`spines/` + `scene/`),取代 0001 / 0005 的按类型分层 |
## 🤝 参与与反馈
这是作者个人的壁纸作品仓库,但欢迎反馈问题与建议:
- 🐛 **问题与规格**按仓库约定写在 `.scratch/<feature>/` 下(见 [docs/agents/issue-tracker.md](docs/agents/issue-tracker.md)),
而不是只留在聊天里。
- 🧭 **改代码前**:先读 [CONTEXT.md](CONTEXT.md)(术语)与相关 ADR(决策),
再读 [AGENTS.md](AGENTS.md)(本仓库给 agent 的约定)。
- ✅ **提交前**:`pnpm check` 必须全绿;新增门禁要先证明它会红。
- 📦 **改 `wallpapers/` 前**:先读 [wallpapers/README.md](wallpapers/README.md)。
- 🎨 **改面板 / 模拟器前**:先看 [ADR 0006](docs/adr/0006-simulator-contract.md) 与 `tools/checks/verify-panel.mts`。
## 📄 许可证
仓库当前**没有声明开源许可证**(`package.json` 为 `private`),代码与素材默认保留所有权利;
若要复用,请先联系作者。第三方组件遵循其各自授权:
- Spine 运行时(`src/vendor/spine-player.js`):spine-ts,授权随上游
[Spine Runtimes License](https://esotericsoftware.com/spine-runtimes-license)。
- 壁纸中的立绘、场景与音频素材版权归米哈游 / 相关权利人所有,本仓库仅作个人壁纸作品使用。
## 🙏 致谢
- [Spine](https://esotericsoftware.com/) / spine-ts:骨架动画运行时。
- [Wallpaper Engine](https://www.wallpaperengine.io/):宿主与属性面板 API。
- 米哈游活动页:立绘与场景的原始来源(各档来源 URL 见 `wallpapers/sources.yml` 与各壁纸的 `preset.template.json`)。