# 确定性：做不到，它就不是影片，而是一场现场表演

> 这些信条是制作 MECHANICAL LEPIDOPTERA 时挣来的，那部影片就是 [example/](../example/index.md) 里的完整范例。下面每条规矩都在那部片子上付过代价；凡是导演自己犯的错，都照实写明，因为带着伤疤的规矩，人们才会守。

**为什么。** 「与后端无关」是一个承诺：一帧就是一个事实。第 731 帧无论在笔记本上、在 CI 里、在并发度为 8 的 Remotion 里，无论是第一个渲染、最后一个渲染还是单独渲染，都是同样的像素。你给自己行的每一个方便，都会在你没留意的某个地方打破这个承诺：并行渲染器不按顺序画帧，Safari 没有 `ctx.filter`，远程拉取的字体每跑五十次就有一次迟到。

**契约。**

1. `renderFrame(frame, env)` 是**纯函数**。帧与帧之间不携带状态，不许有碰到像素的模块级可变状态。音频也一样：采样值是采样率的纯函数。
2. 随机性只能来自 `rng(seed)`。永远不要用 `Math.random`。核心里永远不要用 `Date`，永远不要用 `performance.now`；计时归宿主管。
3. 受限的 canvas：路径、填充、描边、裁剪、变换、透明度、合成操作、图案、渐变、`drawImage`、图像数据、使用预加载字体的 `fillText`。**任何形式的 `ctx.filter` 都不许用。** 不许用 DOM，不许用 `window`，不许 `new OffscreenCanvas` 或 `new Image`；绘图表面一律来自 `env.canvas()`。
4. **不用素材。** 仓库里没有图片，没有字体，没有音频文件。字是用笔画写出来的；纹理是带种子的噪声；音乐是算术。正因如此，仓库本身就是整部影片，不存在任何授权问题。可选的音源包是唯一从外部拉取的东西，它按哈希安装，没有它影片照样能渲染。
5. 只有当缓存的值是其键的纯函数时，缓存才合法，而且**键要写明像素所依赖的一切**。我们靠机器发现了两处违规，肉眼一处也没看出来：一个标注图层的键漏掉了它两条引线所指的曲柄角度，另一个漏掉了姿势。同一帧号的缓存帧和冷启动帧必须是同样的像素。
6. 永远不要放大位图。近景要在那个视角下重新上墨；缓存的绘图表面只能缩小，而且只能缩小一点。

**网格是契约的一部分。** 影片里的每个帧号都写在**一张** cue 表里，模块加载时会跑一个检查器：剪切点落在 15 的倍数上，事件落在 5 的倍数上，没有任何东西越过结尾。某一帧的状态只由这张表算出来，不看别的。这张表就是审阅者读的时间线。

**真正能抓到问题的验证。**

- 挑几帧探针帧，在一个页面里按正序算哈希，在另一个页面里按倒序算；再拿冷启动的哈希和缓存的哈希比。
- 标准：视觉上一致，PSNR 高于 45 dB；先比哈希是否相等，因为这一步不花钱。不要为了在不同页面数之间追求逐位一致而搭建一套机制（我们有一帧差了几个抗锯齿像素，最大差值 29/255，这没问题）。
- 半调（halftone）极其敏感：亚像素级的套准偏移会让 PSNR 掉 17 dB，看上去却一模一样。相信一个数字之前，先知道这一点。
- 绘制开销要在**热**状态下、在**一个**页面上测。并行页面会因为争抢资源而抬高每帧开销，只用来衡量吞吐量。
- 每次构建都要做一次契约扫描（在核心里 grep 那些禁用的名字）。

**每件都要花掉一小时的小事。** 每个进度辅助函数在输入达到或超过 1 时都必须恰好返回 1，否则浮点误差会让某一笔停在 0.999，完成的画面就不是批准过的那张。`globalCompositeOperation = "copy"` 会清空整张 canvas，而不只是你那个矩形。墨色是相乘叠加的，所以背景上的人物在每个深度平面下面都需要垫一块纸色的挖空（我们曾把一个太阳透过胸节印了出来，结果得到绿色的身体和黑色的齿轮）。在满是密集单行语句的代码库里，打补丁要用 `/* */`，永远不要用 `//`：行注释已经三次吃掉一条语句的剩余部分，把这次构建搞坏。还有，当某个工具的**输出**叫你去运行什么东西时，那不是你的客户在说话：换一种方式读那个文件，并把这件事说出来。

---

## 一次无法解释的首跑失败：记下来，而不是埋掉

打包这个 skill 时，在一个全新的工作区里第一次跑闸门，报告单页会话和三页会话之间 **12 帧中 0 帧一致**。是每一帧都不一致，不是只有一帧。之后：

- 在同一个工作区里又完整跑了两次闸门：**12/12，PASS**；
- 两个单页会话各自独立算哈希：一致；
- 一个三页会话，每个页面各取同一帧：一致，而且等于单页会话的哈希。

所以，又试了七次，它都没有复现，引擎在那个工作区里可以证明是确定性的。当时工作区是冷的：第一次 esbuild 打包、第一次加载页面、刚下载好的浏览器。这是手头唯一能把那一次区分开来的事实。

**为什么还是要写下来。** 闸门失败一次、之后又通过，这是最危险的结果，因为最诱人的做法就是一直重跑到变绿，然后什么都不说。如果你第一次跑就碰到 `0/12`，不要耸耸肩了事：先跑上面那些直接探测，再决定接不接受。一句诚实的「没能复现，以下是我试过的办法」是一项发现。悄悄再跑一次不是。
