Files
SpineWallpaper/docs/adr/0007-self-contained-sim-build.md
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

94 lines
8.3 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.
# 自包含模拟包:把 `file://` 下的不可能变成可能
## 背景
`pnpm dev` 起本地服务、在浏览器里调壁纸,这条路径很好用,但它有一个硬前提:**有一个 HTTP 服务在跑**。要给别人看一眼效果、要在没装依赖的机器上确认"这档壁纸到底长什么样"、要把某一版效果截图存档,都得先把服务起起来。于是有了"自包含包"这个需求:一个 `sim/index.html`,双击就能开,不依赖 `pnpm dev`、不依赖任何服务器、也不依赖网络。
一开始以为这只是"把资源内联进 HTML"。实际做下来,`file://` 这个环境把四件平时根本不会注意到的事变成了拦路虎,每一件都值得记下来。
## 决策
### 1. 产物位置:`<分发根>/sim/index.html`,不往 `sim/` 里复制任何资源
自包含包是**分发目录的一部分**,不是一份独立产物:
```
dist/releases/single-hsr-kv37/
index.html ← 发布用(ES module,需要 http)
project.json
preset.js
images/ effects/ audios/ ← 布局见 ADR 0008:现在是 spines/<骨架名>/ scene/ audios/
sim/index.html ← 自包含(经典脚本 + 全内联,双击可开)
```
好处是它天然跟着分发走:拷走整个分发目录,发布用的那份和调试用的那份一起走。代价是页面不在分发根下,所以**资源根**和**模块根**要分开算——这是本 ADR 第 4 条,也是这次最费时间的一个坑。
资源一律不复制进 `sim/`。复制会让"分发目录里多出一份重复的 2 MB 贴图",且一旦漏拷一个就是白屏;全部内联则"要么整页可用,要么构建期就报错",没有中间状态。
### 2. 一切内联:模块、资源、音频都变成页面里的字节
`file://` 下浏览器把页面当成 `origin: null`,于是:
| 做法 | `file://` 下 | 结论 |
| --- | --- | --- |
| `<script type="module">` | 拒绝(CORS) | 不能用 |
| `import()` / `fetch()` / `XHR` | 拒绝(CORS) | 不能用 |
| 同目录相对 `<img>` / `<audio>` | 可以 | 可用但不必要 |
| 跨目录 file 资源 | 拒绝 | 不能用 |
| `data:` URL | 处处可用 | **选它** |
| 内联 `<script>` | 可用 | **选它** |
所以自包含包 = 一份 HTML,里面依次是:spine-player(内联)→ 属性下发 → 模块注册表 → 模拟器模块 → 驱动脚本 → 壁纸运行时。所有 ES module 被合成**经典脚本**,用一张 `__weModules` 注册表 + `__require(id)` 还原 `import` 语义;所有资源(图片/骨架 JSON/atlas/贴图页/音频)转成 `data:` URL,装在一张以 URL 为键的表里。
`import` 改写是**保声明**的:`export const X = 1` 会变成 `const X = 1` 加末尾一行 `exports.X = X`。早期版本直接改成 `exports.X = 1`,把模块内的局部绑定删掉了——`REFERENCE_ASPECT is not defined`,而且只在第一帧渲染时才炸(默认参数到那时才求值),构建、加载、尺寸检查全部正常。
### 3. 音频内联是可选项(`--no-embed-audio`)
`xilian-src.flac` 是 37.7 MB,base64 后 50.3 MB。默认内联(这样双击就有声音),但提供开关给"只看画面、不在乎体积"的场合。关闭时构建会明确列出哪些音频没内联、以及后果("双击打开时选不到它们"),而不是安静地少一块功能。
### 4. 两个根必须分开算(**本次最大的坑**)
页面的位置和模块的位置**不是同一个基准**:
- **页面根**:自包含页永远在 `<分发根>/sim/index.html`,资源相对**分发根**寻址,所以相对页面是 `"../"`——**与合集深度无关**。
- **模块根**:`preset.js` 在 `<分发根>/<壁纸id>/preset.js`(合集)或 `<分发根>/preset.js`(单档),发布版用 `new URL("./", import.meta.url)` 相对**自己所在目录**推导,所以相对页面是 `"../<壁纸id>/"`。
这两个值曾经都被写成"页面根 × 分发深度",于是:
- `collection-all`(深度 2)的资源根变成 `"../../"`,跑到 `dist/releases/` 去了;
- 合集分发的 `preset.js` 把 `./audios/kv37/x.mp3` 解析到 `<releases>/audios/...`(少一层),音频退回 `file://` 读,报 `ERR_FILE_NOT_FOUND`。
两个错误的现象都长成"表和模块看起来都对,只有某个资源不对",非常容易误判成那个资源自己的内联逻辑有问题。**单档分发两种错误都不会出现**(深度 0、没有 `<壁纸id>/` 这一段),所以只测单档会一路绿灯。
对应地,资源表的建键基准是**分发根**(`assetRelIn`,合集里带 `<壁纸id>/`),而不是 `preset.js` 的相对路径(`assetUrlIn`)。用后者建键会丢掉 `<壁纸id>/` 这一段。
### 5. 表要同时按"相对键"和"绝对键"注册
运行时拿到的 URL 有两种形态:`preset.js` 求值出来的**绝对** URL(`file:///…/images/kv37.webp`),和 spine-player 内部按 `pathPrefix + 相对路径` 拼出来的**相对**串。两者都要能查中,所以每份资源都注册 `key`、`./key`、`/key`、裸名,以及每个键 `new URL(k, base).href` 的绝对形式。
spine-player 的 `rawDataURIs` 尤其要注意:`loadTexture(path)` 用 `path` 查表、但把回调注册在**原来的** `path` 上,而 `loadTextureAtlas` 传下来的 `path` 是 `atlasUrl 的父目录 + 页名`。因此 `config.rawDataURIs` 要合并 `byRelative` 与 `byAbsolute` **两族**键,且 `jsonUrl`/`atlasUrl`/`binaryUrl` 三个值也要各自以绝对键登记。少了任何一族,spine 就安静地退回 XHR,在 `file://` 下被 CORS 拦掉。
这些键一旦对不上,症状是白屏加一条 `net::ERR_FILE_NOT_FOUND`,**看不出是哪个键**。所以 `WrappedSpinePlayer` 里有一道**生产断言**:三个键任何一个不在合并后的表里就立刻抛错,并打印缺的那个键名。先前的三轮错误猜测都是因为探针只打印了"表有多少个键",而不是"运行时到底查了哪个键"。
### 6. 门禁:`check:dist` 检查自包含页的外部依赖
`check:dist` 对每个 `sim/index.html` 做三项断言:没有 `<script type="module">`、标签上没有 `http(s)://`/`file:///`/根绝对路径。
扫描前必须**剥掉内联脚本的内容**——spine-player 是整份内联的,它内部带着一段编辑器示例模板,里面有 `<script src="https://…">` 这样的字符串,直接对整页扫标签会把它们当依赖,四个分发全报假阳性。
剥除时**只删标签之间的内容,保留开标签本身**。第一版整体替换掉了开标签,于是所有 `<script src=…>` 都不再被检查,门禁看着在跑、实际只能查到 `<link>`。是"往页面里注入一个外链看它拦不拦得住"这个测试把这个漏洞暴露出来的——**一个从不失败的检查等于没有检查**,所以 `tools/checks/test-sim-gate.mts` 会注入四种外部依赖(外链 script、`file:///`、根绝对路径、`type=module`)确认每种都被拦下。
## 未采纳的方案
- **用 Service Worker 拦截 `file://` 请求**:`file://` 下不能注册 Service Worker。
- **让用户起一个 `file://` 代理或用 `--allow-file-access-from-files`**:那就又回到"需要额外操作"了,自包含的意义没了。
- **把资源复制进 `sim/` 而不内联**:跨目录 `file://` 资源读不到,同目录的也要靠相对路径,且会把分发体积翻倍。
- **只支持单档壁纸的自包含包**:合集分发的调试需求同样真实(要试预设切换、共享音频),而且正是它暴露了第 4 条那两个基准错误。
- **在页面上做环境自检来决定走哪条路**:与 ADR 0006 第 1 条同因——判据会恒为假。自包含与否由**构建产物形态**决定,不由运行时探测决定。
## 影响
- `pnpm build sim` 产出自包含包;`pnpm build sim <壁纸id>` / `sim collect` 收窄范围。
- 构建时间从 0.8 s 增至约 5 s(base64 编码 + 语法自检);单页 15 MB(kv37)到 129 MB(内联 flac 的合集)。
- 验收靠 `tools/checks/verify-sim-page.mts`:用 CDP 以 `file://` 打开,断言画布有实际像素、0 异常、0 控制台报错、无 `http(s)` 请求。**像素采样必须在 `requestAnimationFrame` 内跨帧取最大值**——`preserveDrawingBuffer: false` 下在 rAF 外读 `gl.readPixels` 永远读到清空后的缓冲,会得到"0% 非透明"的假阴性。