把项目从「手写 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 处引用自包含。
8.3 KiB
自包含模拟包:把 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% 非透明"的假阴性。