OGP 画像の生成を satori (@vercel/og) から Playwright に変えた
tl;dr
OGP 画像を satori (@vercel/og
が内部で利用しているもので、JSX を SVG に変換するなんかすごいライブラリ) で生成していたが、運用上微妙だったので Playwright を使った生成に変えた
生成の所要時間は伸びてしまったが、挙動が安定しているため安心して運用できるようになった
モチベ
satori のテキスト周りのスペーシングの挙動がコロコロ変わりすぎて、安心して使い続けるのが難しかった
具体的には改行を含む右揃えテキストで最終行以外の末尾が変に空いてしまう事象などが発生した
特に困ることに、このような挙動変化が patch リリースでしれっと引き起こされ、リリースノートで特に言及されることもなくまた patch リリースで修正されたり、また別の挙動に変わったりする
実際前述の事象関連では v0.10.x 台だけ(つまり patch リリースのみ)ですでに2回挙動が変わっており、そのいずれもリリースノートで一切言及されていない
まだ v0.x.x なこともあり非難するつもりはないが、一方こんな状態では安心して運用するのはちょっと難しかった
そんなわけで、satori や @vercel/og
の台頭により、もはや古臭くもある Playwright による生成にわざわざ乗り換えることにした
実装
従来実装は以下のようなものだった
satori で SVG に変換し、さらに Resvg で SVG を PNG にしている
import { Resvg } from "@resvg/resvg-js";
import satori from "satori";
const generateOgpImage = async ({ slug }: { slug: string }) => {
// ...
const svg = await satori(
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
}}
>
<div>{title}</div>
<div>{author}</div>
</div>,
{ width: 1200, height: 630 },
);
const resvg = new Resvg(svg, {
fitTo: {
mode: "width",
value: 1200,
},
});
const png = resvg.render();
return png.asPng()
};
Playwright を用いた新実装は以下のようなもの
react-dom
の renderToStaticMarkup
で得られた静的な HTML 文字列を setContent
で直接ページに埋めている
import { chromium } from "playwright";
import { renderToStaticMarkup } from "react-dom/server";
const generateOgpImage = async ({ slug }: { slug: string }) => {
// ...
const markup = renderToStaticMarkup(
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
}}
>
<div>{title}</div>
<div>{author}</div>
</div>
);
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setViewportSize({
width: 1200,
height: 630,
});
await page.setContent(`<!doctype html>${markup}`, { waitUntil: "load" });
const image = await page.screenshot();
await browser.close();
return image;
};
画像の利用
従来は div
の backgroundImage
で背景画像を配置していたが、Playwright では waitUntil: "load"
でもこの画像のロードを待ってくれないようだったため、img
に変えた
- <div
- style={{
- backgroundImage: "url('https://example.com/ogp/background.jpg')",
- backgroundRepeat: "no-repeat",
- backgroundPosition: "center",
- backgroundSize: "100% 100%",
- width: "100%",
- height: "100%",
- }}
- >
+ <img
+ src="https://example.com/ogp/background.jpg"
+ width="100%"
+ height="100%"
+ style={{
+ objectFit: "fill",
+ position: "absolute",
+ top: "0",
+ left: "0",
+ }}
+ />
ローカルフォントファイルの使用
satori はフォントデータを読み込む機構[1]を持っているため、それを利用してローカルの(レポジトリ上に配置した)フォントファイルを描画に用いていた
const fontFile = await readFile(path.resolve("assets", "Custom-font.otf"));
const svg = await satori(
<div style={{ fontFamily: "Custom font" }}>{title}</div>,
{
width: 1200,
height: 630,
fonts: [{ name: "Custom font", data: fontFile }],
},
);
一方 Playwright はあくまでも Chromium でありシステムにインストールされたフォントや Web フォントを利用することが前提であるため、ローカルのフォントファイルを読み込む専用の機構などは存在しない
ローカルのフォントファイルを利用するためには CSS の @font-face
で src: url()
を利用してフォントデータを読み込むしかなく、ローカル HTTP サーバを立ててフォントファイルを配信するなどいろいろ試行錯誤したが、結局はフォントファイルを Base64 で読んで直接埋める方式に落ち着いた
ここ edge 上で動かす場合には懸念事項かもしれない (Vercel の 50MB 制限など)
const fontFile = await readFile(path.resolve("assets", "Custom-font.otf"), {
encoding: "base64",
});
const markup = renderToStaticMarkup(
<div>
<style
dangerouslySetInnerHTML={{
__html: `
@font-face {
font-family: 'Custom font';
src:
url(data:font/opentype;charset=utf-8;base64,${fontFile})
format('opentype');
}
`,
}}
/>
<div style={{ fontFamily: "Custom Font" }}>{title}</div>
</div>,
);
その他
satori は HTML と CSS っぽいものが書けるように見えるが、ブラウザ実装ではなくレイアウトエンジンには Yoga (React Native などで使用されている)を用いているため、そもそも CSS の挙動がブラウザとは異なる(使えるプロパティも CSS のサブセット)
そのため、従来同等のレイアウトを実現するためにスタイルに関しては少し調整が必要だった
結果
生成時間は0.4–0.5秒/枚程度から1秒/枚程度になった
個人的には生成枚数が多い大規模サイトでもない限り十分に許容できる
またレンダリング機構は Chromium であるため、テキストレイアウトの挙動はクセがなくなり、バージョン間での差異もかなり低減されると思われる
Discussion