ヘッドレスブラウザをやめて Python レンダラーに置き換えた話
Ridvay が書き出すポスターも動画も、かつてはすべてウェブブラウザがレンダリングしていました。比喩ではありません。コンテナ上で実際に Chromium を起動し、私たちのエディタを読み込ませ、フォントの準備が整うのを待ち、そしてスクリーンショットを撮る。本当にそういう仕組みだったのです。
馬鹿げた話に聞こえるかもしれませんが、なぜそんなことをするのかを考えると、むしろ当然の選択に思えてきます。そして実際に計測してみると、今度はひどく高くつく選択に見えてきます。
ここでは、そこから同じポスターを 70 ミリ秒で描くレンダラーにたどり着くまでの道のりと、それを信頼できるものにするために何が必要だったかを紹介します。
そもそもブラウザが理にかなっていた理由
私たちのエディタは React アプリです。デザインは JSON ドキュメント、つまりページ、要素、座標、フォント、色、アニメーションのステップの集まりであり、エディタはそれを DOM と CSS に変換します。見出しをドラッグして動かしているとき、目の前で仕事をしているのはブラウザのレイアウトエンジンです。
だとすれば、ユーザーが書き出しを押したときにいちばん安全なレンダラーは、画面の見た目とすでに一致しているもの、すなわちエディタそのものです。?render=1 フラグを付けてアプリをヘッドレスで読み込み、デザインを渡し、フォントと画像が落ち着くのを待って、ステージをスクリーンショットする。
この方式は構造上、必ず正しくなります。ズレていく二つ目の実装が存在しない。そもそも二つ目の実装がないからです。テキストがエディタとまったく同じように折り返されるのは、折り返しているコードが同じだからです。
問題は、ブラウザが道連れに引き連れてくるものすべてです。プロセスの起動。DOM。CSS のカスケード。float も flexbox も grid も writing-mode も扱えるレイアウトエンジン。コンポジタ。そのすべてが、圧倒的多数のケースでは長方形が 6 つとテキストが少しあるだけのデザインのために動いています。
気づいたこと
そこで、実際のデザインに何が入っているのかを調べました。典型的なポスターは、背景(単色か線形グラデーション)、テキストブロックがいくつか、図形が 2 つ 3 つ、画像が 1 枚か 2 枚。座標はすべて絶対配置。float なし。flexbox なし。入れ子のレイアウトもなし。
これを描くのにブラウザは要りません。必要なのは 2D 描画ライブラリと、ごまかしのないテキスト計測だけです。
というわけで、自分たちで書きました。ラスタライザと呼んでいる小さな Python サービスで、ブラウザ側のサービスを置き換えるのではなく、その手前に置いてあります。
安全性を担保している唯一のルール
ここはどのベンチマークよりも重要なので、ベンチマークより先に書きます。
「たいていは正しい」高速レンダラーは、高速レンダラーが存在しないよりも悪いものです。ラスタライザがブラウザとほんの少し違う描き方をしても、誰も気づきません。どちらのレンダリングも「成功」します。ユーザーの手元にはファイルが届きます。ただ、それが間違ったファイルだというだけです。
そのためラスタライザは、何かを描く前に厳格なホワイトリスト検査を通します。決定的に重要なのは、要素の種類ではなくキー単位でホワイトリストしている点です。ラスタライザが実装していないプロパティを持つデザインは、私たちが描いてよいデザインではありません。そのまま 1 バイトも変えずに Chromium へ引き渡され、呼び出し側は違いに気づくことすらありません。
このルールが防ごうとしている失敗の形を挙げます。ある要素が "rotation": 45 を持っていたとしましょう。種類単位でホワイトリストするレンダラーは「これは図形だ、図形なら描ける」と判断し、回転させずに描いてしまいます。ブラウザのほうは回転させて描きます。レンダリングは 2 回とも成功し、片方は黙って間違っており、システムのどこにもエラーは出ません。
だからこそ、認識できないキーが 1 つでもあれば自動的に辞退し、そのキー名をログ行に残します。このログ行はそのまま機能追加のキューです。実際のトラフィックが求めていて、まだ対応できていないものが正確にわかるからです。
デザインが PNG になるまで
静止画の経路は短いものです。
- 判定する。 すべてのページ、要素、テキスト行をホワイトリストと照合します。未知のキー、非対応のフォント、非対応の文字体系、範囲外の値があれば、理由を表す文字列が返ります。理由が 1 つでもあれば、そのリクエストは Chromium にプロキシされます。
- 背景を描く。 単色の塗り、または CSS 相当の線形グラデーション。
- 要素ごとに専用のレイヤーへ描く。 要素 1 つにつき RGBA レイヤーを 1 枚用意し、z 順に合成します。この「要素ごと」が効いてきます。要素の不透明度が下地に乗算的に焼き付くのではなく、きちんと下地とブレンドされるからです。
- エンコードしてアップロードする。 既定は PNG、要求があれば JPEG で、そのままストレージへ。レスポンスはブラウザ側サービスが返すのと同じ
{imageUrl}の形です。
気を遣うべきはテキストです。私たちのフォーマットには自動フィットがなく、デザインに書かれたフォントサイズがそのまま描画されます。そこでラスタライザは、ブラウザが読み込むのと同じ TTF を読み込んで計測し、CSS の pre-wrap と break-word の組み合わせと同じ折り返しを行います。CSS のハーフレディングも再現しているので、グリフは行ボックスの上端に貼り付くのではなく、上下中央に収まります。
正しく動かすのに実際に時間を取られた細部がひとつあります。バリアブルフォントには、フォントが宣言している順番どおりにすべての軸を渡さなければなりません。Inter の軸は [opsz, wght] です。値を 1 つだけ渡すとオプティカルサイズだけが設定され、ウェイトには一切触れられません。その結果、本文テキストの計測が約 10% 小さく出てしまいます。ブラウザでは 2 行目に送られる 1 行が、ラスタライザでは送られない。それだけの差が生まれるには十分な誤差です。
レンダリング時間の 65% を占めていたグラデーション
最初のプロファイリングで、恥ずかしくもあり嬉しくもある事実が出てきました。レンダリング時間の半分以上が背景グラデーションで、しかも Python のループで 1 ピクセルずつ塗っていたのです。
線形グラデーションは定義上、一次元です。色は射影された 1 つの座標だけで決まるので、計算する価値のある値はどうやっても 256 通りしかありません。ランプを一度だけサンプリングしてルックアップテーブルに落とし、あとは NumPy にキャンバス全体へ射影させればいい。
| 1080×1350 のグラデーション | 所要時間 |
|---|---|
| ピクセル単位の Python ループ | 1,237 ms |
| 256 エントリの LUT + NumPy | 10.2 ms |
関数 1 つで 122 倍。このアプローチ全体を成立させた変更でした。
デザインが MP4 になるまで
アーキテクチャが面白くなるのは動画のほうです。ブラウザはアニメーションが本当に得意なので、それにどう合わせるかは慎重にならざるを得ませんでした。
私たちのアニメーションモデルは意図的に小さく作ってあります。各要素に任意の登場・退場・シーン全体にわたるループがあり、各ページには次のページへのトランジションがある。ブラウザ方式の動画レンダリングは、文字どおりそのアニメーションエンジンを走らせて 1 フレームずつスクリーンショットを撮ります。
これに一致させるため、ラスタライザはエディタのモーションコードを一行ずつ移植したものになっています。タイムラインの分割、プリセットの定義、そして三次ベジェのイージングソルバー。同じ 24 回の二分探索ループまで含めて同一です。おかげで両エンジンは「近い値」ではなく、まったく同じイージング後の値にたどり着きます。
性能面のカラクリはスプライトモデルです。素朴なフレームレンダラーは、毎フレームすべての要素を描き直します。しかし登場アニメーションは、要素が何であるかを変えるわけではありません。変わるのは不透明度、位置、拡大率、クリップだけです。これはブラウザがやっていることとまったく同じです。レイヤーのレイアウトとラスタライズは一度きりで、あとはコンポジタ側でフレームごとに transform を当てているだけなのですから。
そこでラスタライザも、各要素を素の大きさで一度だけ描いてビットマップをキャッシュし、フレームごとに補間した変換をそのスプライトへ適用します。150 フレームのタイムラインが、分単位ではなく秒単位で済むようになりました。
できたフレームは、パイプ経由で生の RGB として ffmpeg に流し込みます。libx264、preset medium、CRF 20、yuv420p、+faststart。バッファに載るのはエンコーダ自身のウィンドウだけで、中間の PNG ファイルは 1 枚も書き出しません。
数字を見る
まずは描画から。グラデーション背景、テキスト要素 4 つ、図形 2 つを含む 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 |
動画のフレーム生成は、同じマシンで 1 フレームあたり 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 かかります。いまや仕事の 4 分の 3 は PNG のエンコードなのです。
同じ画像を品質 82 の JPEG に切り替えると、エンコードは 205 ms ではなく 12.9 ms で済み、ファイルは 306 KB ではなく 185 KB になります。エンドツーエンドでは 275 ms に対して 83 ms です。
私たちは最適化の労力を描画に注ぎ、その結果、描画は問題ではなくなりました。「時間がどこに消えているかは分かっている」と確信したときに、思い出す価値のある話です。
本当に正しいと言い切れる根拠
同等だと主張するレンダラーには、それを証明する義務があります。そこで、同じデザインを両サービスに投げて出力を比較するハーネスを作りました。
レンダリング結果をピクセル単位で比較しても意味がありません。アンチエイリアスが違うので、理由もなく毎回失敗するだけです。代わりにこのハーネスは、異なる 2 つのラスタライザをまたいでも安定する指標を比べます。縁まわりの背景色の平均、各要素のボックス内で実際に塗られたピクセルの割合、そしてフレーム全体を 32×32 のグレースケールに縮約したもの。アンチエイリアスが影響しない程度に粗く、それでいて要素の欠落、フォントのフォールバック、ブロックのずれはすぐに表に出る程度には精密です。
現状は、静止画で 13 要素中 13 要素が許容範囲内、動画で 113 フレーム中 113 フレームが一致。直近の実行では、あるアニメーション要素のインク被覆率がラスタライザで 0.238、Chromium で 0.240 でした。相対差にして 1% です。
私たちが自らに課しているルールは、そのケースが一致することを証明する同等性フィクスチャなしに、ホワイトリストへ機能を追加しないというものです。証明のないままホワイトリストを緩めることこそ、この種のサービスが得たばかりの信頼を自ら壊す方法にほかなりません。
いまもブラウザに送っているもの
たくさんあります。しかも意図的にです。
右から左へ書く文字体系は Chromium 行きです。私たちが同梱している Pillow のビルドには Raqm が入っておらず、アラビア語を孤立形の非接続字形のまま左から右へ並べてしまうからです。これは「少しずれている」ではありません。ちゃんとレンダリングされたように見える、まったく別の文章です。考えうる最悪の失敗と言えます。1 文字ずつ動かすアニメーションプリセットも同じく回します。文字単位のエンジンが必要で、それはまだ作っていないからです。ページ間で要素を対応付けるモーフトランジションも、同梱していないフォントも同じ扱いです。
直近の本番トラフィックでは、レンダリング依頼のおよそ 83% を高速パスが処理していました。残りの大半は、私たち自身の同等性フィクスチャがわざとフォールバックを発生させていたぶんで、本物の辞退は 6% を下回っていました。その 1 件を追いかけてみると、原因は私たちのフォーマットで duration と定めているところに durationMs を持つデザインでした。
これが面白かったのは、Chromium が描き始めるより前に、エディタ自身のローダーがそのフィールドを黙って書き換えていたからです。アニメーションステップ内の未知のキーは捨てられ、duration は既定値に落ちます。つまりブラウザは 900 ms のアニメーションを描いていたのではなく、600 ms のアニメーションを描いていたわけです。このデザインを辞退することは、安全な選択などではまったくなく、単に遅い選択でした。いまは判定の前にエディタと同じ正規化を通しており、この種の辞退はなくなりました。
真似する価値があるところ
この記事から 1 つだけ持ち帰るとしたら、それはたぶんグラデーションのルックアップテーブルではありません。
持ち帰るべきはシステムの形です。常に正しい遅いパスの手前に、不完全であることを許された速いパスを置き、認識できないものは必ず後ろへ落とすという厳格なルールで結ぶ。ラスタライザにすべてを処理させる必要は一度もありませんでした。必要だったのは、処理できないものについて正直であることだけです。
だからこそ、そもそも出荷できたのです。速いパスのバグは、レンダリングを遅くします。間違ったものにすることは、できません。
Ridvay Engineering — 計測値は同等性ハーネスとローカルプロファイルより、2026 年 8 月。ここで紹介したレンダラーは、Ridvay Studio で作られたすべてのデザインを描いています。このブログのカバー画像や動画も含めて、です。