記事一覧に戻る
satoriでOGP画像を自動生成する際のCI最適化

satoriでOGP画像を自動生成する際のCI最適化

satori + @resvg/resvg-jsでブログ記事のOGP画像を自動生成する方法。CIでのフォント管理、woff2非対応の回避策、並列化によるビルド高速化まで実装例付きで解説。

ブログを構築する際、各記事にOGP画像(SNSシェア時に表示されるサムネイル)を用意するのは大切ですが、毎回手動で作成するのは面倒です。今回はsatoriを使ってタイトルからOGP画像を自動生成する仕組みを作り、CIで安定して動作させるために行った最適化について共有します。

技術スタック

  • satori: VercelがOSSとして公開している、React風のJSXからSVGを生成するライブラリ
  • @resvg/resvg-js: SVGをPNGに変換するRust製ライブラリのNode.jsバインディング
  • Astro: 静的サイトジェネレーター

最初のアプローチ:Google Fonts API

最初は公式ドキュメント通り、Google Fonts APIからフォントを取得する実装にしました。

const fontResponse = await fetch(
  'https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@700'
);
const css = await fontResponse.text();
const fontUrl = css.match(/src: url\((.+?)\)/)?.[1];
const fontData = await fetch(fontUrl).then(res => res.arrayBuffer());

ローカルでは問題なく動作しましたが、CI環境での実行を考えると課題が見えてきました。

CIでの問題点

  1. ネットワーク依存: 外部APIに依存するため、タイムアウトやレート制限のリスク
  2. 再現性: フォントのバージョンが変わると出力が変わる可能性
  3. ビルド時間: 毎回フォントをダウンロードする時間がかかる

フォントをリポジトリに含める決断

ネットワーク依存を排除するため、フォントファイルをリポジトリに含めることにしました。ここで問題になるのがファイルサイズです。

フォントサイズの比較

フォントサイズ
Noto Sans JP (フル)約16MB
Noto Sans JP (サブセット/woff2)534KB
Noto Sans JP (サブセット/OTF)2.1MB

woff2が圧倒的に軽いので最初はこれを選びましたが、実行時にエラーが発生。

Error: Unsupported OpenType signature wOF2

satoriはTrueType/OpenTypeフォント(.ttf/.otf)のみをサポートしており、woff2は使えませんでした。結果として2.1MBのOTFファイルを採用しました。

損益分岐点の計算

「2.1MBのフォントファイルを置くより、生成済み画像をコミットした方が軽いのでは?」という疑問が湧きました。

実際に生成した画像のサイズを確認:

api-generation.png      224KB
clean-architecture.png  209KB
multi-env-deploy.png    235KB
turborepo-monorepo.png  211KB

平均約215KB/画像として計算すると:

記事数画像合計サイズフォントサイズ
5記事1.0MB2.1MB
10記事2.1MB2.1MB
20記事4.2MB2.1MB
50記事10.5MB2.1MB

損益分岐点は約10記事。10記事を超えたあたりからフォントをリポジトリに含める方が有利になります。

また、画像をgit管理すると毎回の記事追加でバイナリが増えていく一方、フォントは1回追加すれば以降は増えません。長期的には圧倒的にフォント方式が有利です。

生成済み画像の扱い

画像はビルド時に生成するため、gitで管理する必要はありません。

# .gitignore
public/images/og/

package.jsonでビルド前に生成するように設定:

{
  "scripts": {
    "build": "pnpm generate:og && astro build",
    "generate:og": "tsx scripts/generate-og-images.ts"
  }
}

並列化によるパフォーマンス改善

4記事で約9秒かかっていた生成処理を、Promise.allで並列化しました。

変更前(逐次処理):

for (const task of tasks) {
  const png = await generateOgImage(task.title, fontData);
  fs.writeFileSync(task.outputPath, png);
}

変更後(並列処理):

await Promise.all(
  tasks.map(async (task) => {
    const png = await generateOgImage(task.title, fontData);
    fs.writeFileSync(task.outputPath, png);
  })
);

結果は9秒 → 7秒で約22%の改善。記事数が増えるほど効果が大きくなります。

スキップ処理の実装

効率化のため、以下の条件でスキップする仕組みを入れました:

  1. カスタムサムネイル指定時: frontmatterにthumbnailがある記事
  2. 生成済み画像がある時: 同名のPNGファイルが存在する記事
if (thumbnail) {
  console.log(`⏭️  ${file} - カスタムサムネイルあり、スキップ`);
  continue;
}

if (fs.existsSync(outputPath)) {
  console.log(`⏭️  ${file} - 既に生成済み、スキップ`);
  continue;
}

これにより、記事を1つ追加してもフルビルドではなく差分生成のみで済みます。

運用してみて

実際にこの構成で運用してみると、「ネットワーク依存を排除することの価値」を実感しています。

最初は「フォントファイルを2.1MBも持つのは…」と迷っていたけど、CI環境での安定性を考えると外部APIへの依存排除の方が圧倒的に大事でした。woff2が使えなくてOTFにせざるを得なかったのは想定外だったものの、損益分岐点を計算してみたら10記事程度で元が取れるとわかって納得。

並列化も地味に効いていて、記事が増えてもビルド時間が線形に伸びないのは嬉しいポイントです。これからブログを長く続けていく中で、この判断が正しかったかどうか、検証していきたいですね。