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

8.3 KiB
Raw Blame History

自包含模拟包:把 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% 非透明"的假阴性。