确定性:做不到,它就不是影片,而是一场现场表演
这些信条是制作 MECHANICAL LEPIDOPTERA 时挣来的,那部影片就是 example/ 里的完整范例。下面每条规矩都在那部片子上付过代价;凡是导演自己犯的错,都照实写明,因为带着伤疤的规矩,人们才会守。
为什么。 「与后端无关」是一个承诺:一帧就是一个事实。第 731 帧无论在笔记本上、在 CI 里、在并发度为 8 的 Remotion 里,无论是第一个渲染、最后一个渲染还是单独渲染,都是同样的像素。你给自己行的每一个方便,都会在你没留意的某个地方打破这个承诺:并行渲染器不按顺序画帧,Safari 没有 ctx.filter,远程拉取的字体每跑五十次就有一次迟到。
契约。
renderFrame(frame, env)是纯函数。帧与帧之间不携带状态,不许有碰到像素的模块级可变状态。音频也一样:采样值是采样率的纯函数。- 随机性只能来自
rng(seed)。永远不要用Math.random。核心里永远不要用Date,永远不要用performance.now;计时归宿主管。 - 受限的 canvas:路径、填充、描边、裁剪、变换、透明度、合成操作、图案、渐变、
drawImage、图像数据、使用预加载字体的fillText。任何形式的ctx.filter都不许用。 不许用 DOM,不许用window,不许new OffscreenCanvas或new Image;绘图表面一律来自env.canvas()。 - 不用素材。 仓库里没有图片,没有字体,没有音频文件。字是用笔画写出来的;纹理是带种子的噪声;音乐是算术。正因如此,仓库本身就是整部影片,不存在任何授权问题。可选的音源包是唯一从外部拉取的东西,它按哈希安装,没有它影片照样能渲染。
- 只有当缓存的值是其键的纯函数时,缓存才合法,而且键要写明像素所依赖的一切。我们靠机器发现了两处违规,肉眼一处也没看出来:一个标注图层的键漏掉了它两条引线所指的曲柄角度,另一个漏掉了姿势。同一帧号的缓存帧和冷启动帧必须是同样的像素。
- 永远不要放大位图。近景要在那个视角下重新上墨;缓存的绘图表面只能缩小,而且只能缩小一点。
网格是契约的一部分。 影片里的每个帧号都写在一张 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,不要耸耸肩了事:先跑上面那些直接探测,再决定接不接受。一句诚实的「没能复现,以下是我试过的办法」是一项发现。悄悄再跑一次不是。