我们用 Python 渲染器替掉了无头浏览器
Ridvay 导出的每一张海报、每一段视频,过去都是由浏览器渲染出来的。这不是什么比喻——是货真价实的 Chromium,在容器里启动,加载我们的编辑器,等字体就位,然后截一张图。
乍一听有点荒唐,可一旦你想明白别人为什么这么做,又会觉得理所当然。而等你真的去测一测,它就开始显得昂贵起来。
下面讲讲我们是怎么从那一步,走到一个 70 毫秒就能画出同一张海报的渲染器的,以及为了敢信任它,我们付出了什么。
为什么一开始用浏览器是合理的
我们的编辑器是一个 React 应用。一份设计就是一个 JSON 文档——页面、元素、位置、字体、颜色、动画步骤——编辑器把它变成 DOM 和 CSS。当你拖动一行标题时,你看到的其实是浏览器的排版引擎在干活。
所以当用户点下“导出”时,最稳妥的渲染器,就是那个天然与他眼前画面一致的东西:编辑器本身。我们用 ?render=1 参数以无头模式加载这个应用,把设计交给它,等字体和图片都稳定下来,再把画布截下来。
这种正确性是结构性的。不存在第二套实现会渐渐跑偏,因为压根就没有第二套实现。文字在编辑器里怎么换行,导出时就怎么换行——因为负责换行的是同一份代码。
代价是浏览器顺手带来的那一整套家当:进程启动、一棵 DOM、一套 CSS 层叠规则、一个能处理浮动、flexbox、网格和书写方向的排版引擎,再加一个合成器。而这一切,是为了画一份在绝大多数情况下只有六个矩形加几段文字的设计。
我们注意到的事
我们仔细看了看设计里到底装着什么。一张典型的海报,就是一个背景(纯色或线性渐变)、几块文字、两三个形状,外加一两张图片。全是绝对定位。没有浮动,没有 flexbox,没有嵌套布局。
画这种东西不需要浏览器。你需要的只是一个二维绘图库,以及一套老老实实的文字测量。
于是我们自己写了一个。它叫栅格化器,是一个小小的 Python 服务,摆在浏览器服务的前面——而不是取代它。
让它足够安全的那条规矩
这一节比任何性能数字都重要,所以要放在性能数字前面讲。
一个“通常是对的”快速渲染器,比根本没有快速渲染器更糟。如果栅格化器画出来的海报和浏览器画的略有出入,没有人会发现。两边都“渲染成功”了,用户也拿到了文件,只不过那是一份错的文件。
所以栅格化器在动笔之前,会先跑一遍严格的白名单——而且关键在于,它把关的是字段名,不只是元素类型。只要设计里带了一个栅格化器没实现的属性,这份设计就不归我们画。它会被原封不动地转交给 Chromium,调用方完全察觉不到差别。
这条规矩要防的正是这样一种失败。假设某个元素带着 "rotation": 45。一个按类型做白名单的渲染器会想“这是个形状,形状我会画”,然后把它画成没旋转的样子。浏览器则会把它转过来。两次渲染都成功了,其中一次悄无声息地是错的,而系统里任何地方都不会报错。
因此,只要遇到不认识的字段,就自动拒单,并把字段名写进日志。这些日志行本身就是一份需求清单:真实流量在要什么、而我们还不支持什么,一目了然。
一份设计如何变成 PNG
静态图这条路很短:
- 分类。 逐页、逐元素、逐行文字地对照白名单检查。任何未知字段、不支持的字体、不支持的文字系统或超出范围的取值,都会产生一条拒绝理由。只要出现任何一条理由,请求就转交给 Chromium。
- 画背景。 纯色填充,或者 CSS 风格的线性渐变。
- 把每个元素画到各自的图层上。 每个元素一张 RGBA 图层,按 z 序合成。分层这一点很要紧:它让元素的不透明度是与下方内容混合,而不是相乘叠加进去。
- 编码并上传。 默认 PNG,按需 JPEG,直接进存储。返回的响应和浏览器服务一样,是同样的
{imageUrl}结构。
需要格外小心的是文字。我们的格式里没有自动缩放——设计写了多大字号就渲染多大——所以栅格化器会加载浏览器加载的那份 TTF,用它来测量,并按 CSS pre-wrap 配 break-word 的规则换行。它还复刻了 CSS 的半行距,让字形在行框里居中,而不是贴着顶部。
有一个细节让我们实打实地耗了不少时间:可变字体必须按字体自己声明的顺序,把每一根轴的值都传全。Inter 的轴是 [opsz, wght]。只传一个值,等于只设了光学尺寸,字重根本没动,结果正文被少测了约 10%——这点差距足以让某一行文字在浏览器里被挤到第二行,在栅格化器里却不会。
那个占掉一次渲染 65% 时间的渐变
第一次做性能剖析,结果既让人难堪又让人开心:一半以上的渲染时间花在了渐变背景上——那是用一个 Python 循环,一个像素一个像素画出来的。
线性渐变按定义就是一维的。颜色只取决于一个投影坐标,所以真正值得计算的取值一共只有 256 个。把这条色带采样一次存成查找表,剩下的交给 NumPy 投影到整张画布上就行。
| 1080×1350 渐变 | 耗时 |
|---|---|
| 逐像素 Python 循环 | 1,237 ms |
| 256 项查找表 + NumPy | 10.2 ms |
单个函数提速 122 倍——正是这一改,让整条路线变得可行。
一份设计如何变成 MP4
到了视频这一块,架构才真正有意思起来,因为浏览器做动画是真的强,我们得非常小心地去对齐它。
我们的动画模型是刻意做小的:每个元素可以有入场、退场,以及一段贯穿整个场景的循环;每一页可以有一个通往下一页的转场。浏览器渲染视频的方式,就是老老实实跑一遍这套动画引擎,然后逐帧截图。
为了对齐,栅格化器把编辑器的动效代码逐行移植了过来——时间轴切分、预设定义、三次贝塞尔缓动求解器,连那个 24 次迭代的二分循环都一模一样,好让两套引擎算出的是同一个缓动值,而不只是接近的值。
性能上的诀窍是精灵图模型。一个朴素的逐帧渲染器,每一帧都会把所有元素重画一遍。但入场动画并不改变一个元素是什么——它改变的只是不透明度、位置、缩放和裁剪。浏览器其实也是这么干的:图层只排版和栅格化一次,之后每帧在合成器上套一个变换。
所以栅格化器把每个元素按原始尺寸画一次,把这张位图缓存下来,然后每一帧只对缓存的精灵图套上插值出来的变换。一条 150 帧的时间轴,耗时以秒计,而不是以分钟计。
帧数据随后以原始 RGB 的形式通过管道流进 ffmpeg——libx264,preset medium,CRF 20,yuv420p,+faststart。除了编码器自己的窗口之外什么都不缓冲,也从不写出任何中间 PNG 文件。
数字
先看绘制。一张 1080×1350 的海报,渐变背景、四个文字元素、两个形状,在本地一台 Apple M4 上实测:
| 环节 | 1×(1080×1350) | 2×(2160×2700,默认) |
|---|---|---|
| 绘制 | 26.8 ms | 70.2 ms |
| PNG 编码 | 68.8 ms | 205.2 ms |
| 输出体积 | 132 KB | 306 KB |
视频的帧生成,同一台机器:每帧 3.2 ms,约合每秒 315 帧,此外只需一次 24 ms 的精灵图缓存构建。
接下来是真正算数的对比——同一份设计提交给集群里的两个渲染器,端到端计时,包含上传:
| 片段 | 栅格化器 | Chromium | 提速 |
|---|---|---|---|
| 1080×1350,113 帧 | 6.0 s | 24.0 s | 4.0× |
| 720×900,77 帧 | 3.0 s | 18.0 s | 6.0× |
硬件相同,提交的设计相同,视频逐帧一致。
瓶颈换了地方
最让我们意外的是这个结果。在默认导出设置下——像素比 2、PNG——画出这张海报要 70 ms,压缩它却要 205 ms。现在 PNG 编码占了四分之三的工作量。
把同一张图换成质量 82 的 JPEG,编码耗时从 205 ms 降到 12.9 ms,文件也从 306 KB 变成 185 KB。端到端就是 83 ms 对 275 ms。
我们把优化力气全花在绘制上,结果绘制不再是问题了。下次当你笃定自己知道时间花在哪儿的时候,不妨想想这件事。
我们凭什么确定它真的没画错
一个号称与浏览器一致的渲染器,必须拿出证据,所以我们做了一套测试装置:把同一份设计同时提交给两个服务,再比对输出。
逐像素比对是没用的——两边的抗锯齿本就不同,那样每跑一次都会毫无理由地失败。装置比的是在两种不同栅格化实现下依然稳定的东西:边缘一圈背景色的平均值、每个元素框内被绘制像素的占比,以及整帧缩成 32×32 的灰度图。粗到让抗锯齿无关紧要,又精确到元素丢失、字体回退、区块位移都会立刻暴露。
目前的状态:静态图 13 个元素全部落在容差之内,视频 113 帧全部一致。最近一次跑的时候,有一个带动画的元素在栅格化器里测得的墨迹覆盖率是 0.238,在 Chromium 里是 0.240——相对差 1%。
我们给自己定的规矩是:没有一致性测试样例证明这个具体场景对得上,就不许往白名单里加新特性。没有这份证据就放宽白名单,这样的服务就会亲手毁掉自己刚挣来的信任。
哪些我们还是交给浏览器
不少,而且是有意为之。
从右往左书写的文字交给 Chromium,因为我们打包的 Pillow 没有带 Raqm,会把阿拉伯文按从左到右排出来,字母各自孤立、不连写。这不叫“稍有偏差”——那已经是另一段文字了,偏偏看上去还像是好好渲染出来的,这是最糟糕的一种失败。逐字母的动画预设也交出去,因为它们需要一套我们还没写的字符级引擎。页面之间的同元素形变转场同样如此,还有任何我们没有打包的字体。
在最近的生产流量里,快速路径处理了大约 83% 的渲染请求。剩下的绝大部分是我们自己的一致性测试样例在故意触发回退;真正的拒单不到 6%。我们追查了其中一例,原因是那份设计里写的是 durationMs,而我们的格式规定的字段是 duration。
这一例挺有意思,因为编辑器自己的加载器在 Chromium 落笔之前,就已经悄悄改写了这个字段:动画步骤里的未知字段会被丢掉,时长回落到默认值。所以浏览器渲染的根本不是一段 900 ms 的动画,而是 600 ms 的。拒绝这份设计从来就不是更安全的选择,只是更慢的选择。现在我们在分类之前会先跑一遍编辑器同样的归一化处理,这一类拒单也就消失了。
值得抄走的那一部分
如果这篇文章只留给你一样东西,那多半不是渐变查找表。
而是这个系统的形状:一条允许不完整的快速路径,摆在一条永远正确的慢速路径前面,外加一条铁律——凡是不认识的,一律放行到后面去。我们从来不需要让栅格化器什么都会画,只需要让它对自己不会画的东西保持诚实。
正因如此,它一开始才敢上线。快速路径里的 bug 只会让一次渲染变慢,不可能让它出错。
Ridvay 工程团队——数据来自一致性测试装置与一次本地性能剖析,2026 年 8 月。本文描述的渲染器,负责绘制在 Ridvay Studio 中做出的每一份设计,包括本博客的封面图和视频。