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:
1 parent
b8eee05d78
commit
3f11426964
297 files changed
+216627
-1926
No files matched your search
@@ -0,0 +1,93 @@
|
||||
# 自包含模拟包:把 `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% 非透明"的假阴性。
|
||||
Reference in new issue
Block a user