Engineering

Astroで動的OGP画像を生成する — SatoriとResvg WASMによるビルド時レンダリング

  • Astro
  • OGP
  • Satori
  • Resvg
  • Cloudflare Workers
  • TypeScript

Labee Dev Toolboxでは、すべてのページのOGP画像をビルド時に自動生成しています。Satoriでテンプレートからtitleやdescriptionを埋め込んだSVGを作り、@resvg/resvg-wasmでPNGに変換するパイプラインです。Cloudflare Workers上で動くAstroプロジェクトでは @vercel/og が使えないため、SatoriとResvgを直接組み合わせる構成を選びました。カテゴリごとにカラーテーマを切り替え、日本語フォントとしてNoto Sans JPをバンドルすることで、外部サービスに依存しない画像生成を実現しています。

labee.devLabee Dev Toolbox - 世界から見た、あなたのドメインの本当の姿。外部視点で DNS / SSL / IP / Mail を 1 秒でチェック。Gmail/Outlook 義務化対応の SPF/DKIM/DMARC 確認に。

OGP画像を自動生成したい理由

SNSでURLがシェアされたとき、OGP画像の有無はクリック率に影響します。タイトルとdescriptionだけのプレーンなカードと、ブランドカラーで装飾された画像付きのカードでは、視認性が明らかに異なります。

Labee Dev Toolboxにはブログ記事、用語集、ガイドの3種類のコンテンツがあり、それぞれ数十ページ規模で増え続けます。ページごとに手動で画像を用意するのは現実的ではありません。コンテンツのfrontmatterからタイトルとdescriptionを取り出し、テンプレートに流し込んで画像を自動生成する仕組みが必要でした。

@vercel/ogを使わなかった理由

OGP画像の動的生成でよく選ばれるのは @vercel/og です。このライブラリはSatoriとResvg WASMをラップしたもので、Vercel Edge FunctionsやNode.js環境で手軽に使えます。しかし、Labee Dev ToolboxはCloudflare Workers上で動いています。

@vercel/og はVercel Edge Functionsを前提に設計されており、WASMバイナリの初期化やフォントの読み込みがVercelのインフラに依存する部分があります。Cloudflare Workersのランタイムでは、Node.jsのファイルシステムAPI(fs.readFileSync など)が制限される場面があり、@vercel/og をそのまま動かすにはワークアラウンドが必要です。

であれば、@vercel/og の内部で使われているSatoriと@resvg/resvg-wasmを直接組み合わせる方がシンプルです。依存ライブラリの挙動を直接制御でき、Cloudflare Workers環境の制約に合わせた初期化処理を書けます。

ビルド時生成を選んだ理由

OGP画像の生成タイミングには、リクエスト時(ランタイム)とビルド時(静的生成)の2つの選択肢があります。

ランタイム生成は、リクエストのたびにSVGの生成とPNGへの変換を行います。SatoriによるSVG生成はJSXライクなテンプレートの評価とレイアウト計算を伴い、ResvgによるPNG変換はWASMの実行を伴います。フォントファイルの読み込みも含めると、1枚あたりの処理時間は無視できません。Cloudflare WorkersにはCPU時間の制限があり、OGP画像のようなCPU集約型の処理をリクエストごとに走らせると制限に抵触するリスクがあります。CDNキャッシュである程度は軽減できますが、キャッシュミス時のレイテンシやCPU時間は依然として課題になります。

ビルド時生成であれば、デプロイ前にすべてのOGP画像を生成し終えます。ランタイムではPNGファイルを静的に配信するだけなので、CPU時間の制約を気にする必要がありません。コンテンツサイトではページの内容がデプロイ単位で確定するため、OGP画像もデプロイ単位で確定させるのが自然です。Astroの getStaticPaths()export const prerender = true を組み合わせることで、コンテンツごとにOGP画像を静的生成できます。

全体の構成

OGP画像の生成は5つのモジュールに分割しています。

src/lib/og/
  index.ts        # 公開APIのre-export
  types.ts        # OgDescriptor, OgCategoryTheme の型定義
  descriptor.ts   # Content Collectionからディスクリプタ一覧を生成
  template.ts     # JSXライクなテンプレート定義
  fonts.ts        # Noto Sans JPフォントの読み込み
  render.ts       # Satori + Resvg WASMによるレンダリング

ページ側のエンドポイントは src/pages/og/[...slug].png.ts です。getStaticPaths() で全ディスクリプタを列挙し、各ディスクリプタに対して /og/{slug}.png を生成します。

ディスクリプタの生成

ディスクリプタは、OGP画像に描画する情報をまとめたオブジェクトです。

interface OgDescriptor {
  slug: string
  title: string
  description: string
  category: OgCategory
  badge: string
}

getAllOgDescriptors() がAstroのContent Collectionからブログ記事、用語集、ガイドの各コンテンツを取得し、ディスクリプタの配列を返します。カテゴリに応じてバッジのテキストを設定し、ブログ記事には「技術ノート」、用語集には「用語解説」、ガイドには「ガイド」または「チェックリスト」を表示します。

// blog
descriptors.push({
  slug: `jp/posts/${post.id}`,
  title: post.data.title,
  description: post.data.description,
  category: 'blog',
  badge: '技術ノート',
})

本番ビルドでは data.drafttrue のコンテンツを除外し、開発環境ではドラフトも含めてOGP画像を生成します。

テンプレートの設計

Satoriは内部的にYoga Layoutを使っており、Flexboxベースのレイアウトを受け取ります。テンプレートはJSXではなく、{ type, props } 形式のプレーンオブジェクトとして定義しています。Satoriの satori() 関数はReactNodeを受け取る型シグネチャですが、ランタイムではプレーンオブジェクトでも動作します。Reactへの依存を避けるためにこの方式を採用しました。

type SatoriElement = {
  type: string
  props: {
    style?: Record<string, unknown>
    children?: (SatoriElement | string)[] | SatoriElement | string
  }
}

画像のレイアウトは、左端のアクセントライン、上部のカテゴリバッジ、中央のタイトルとdescription、下部のサイト名で構成されます。1200x630pxの画像全体を1つのFlexboxコンテナとして扱い、justifyContent: 'space-between' で各要素を配置しています。

カテゴリ別のカラーテーマ

コンテンツのカテゴリに応じて、アクセントラインとバッジの配色を切り替えます。

const CATEGORY_THEMES: Record<string, OgCategoryTheme> = {
  blog: {
    accentFrom: '#0d9488',
    accentTo: '#5eead4',
    badgeColor: '#0d9488',
    badgeBg: '#f0fdfa',
    badgeBorder: '#99f6e4',
  },
  glossary: {
    accentFrom: '#7c3aed',
    accentTo: '#a78bfa',
    badgeColor: '#6d28d9',
    badgeBg: '#f5f3ff',
    badgeBorder: '#c4b5fd',
  },
  guide: {
    accentFrom: '#c2410c',
    accentTo: '#fb923c',
    badgeColor: '#c2410c',
    badgeBg: '#fff7ed',
    badgeBorder: '#fdba74',
  },
}

ブログはteal、用語集はpurple、ガイドはorangeです。SNSのタイムラインでカードを見たとき、色でコンテンツの種類が判別できます。左端のアクセントラインはグラデーションで表現し、バッジは配色に合わせた背景色とボーダーでラベルを表示します。

カテゴリが定義済みのテーマに該当しない場合は、blueのデフォルトテーマにフォールバックします。新しいカテゴリを追加したときにOGP画像の生成が壊れないための安全策です。

日本語フォントの扱い

OGP画像で日本語テキストを描画するには、フォントファイルをSatoriに渡す必要があります。Satoriはシステムフォントにアクセスできないため、フォントデータをバイナリとして明示的に読み込みます。

Noto Sans JPを @fontsource/noto-sans-jp パッケージ経由で使用しています。Google Fontsからビルド時にダウンロードするのではなく、npmパッケージとしてnode_modulesに含めることで、ネットワークアクセスなしにフォントを読み込めます。

// fonts.ts(簡略化)
const FONT_DIR = path.join(
  process.cwd(),
  'node_modules/@fontsource/noto-sans-jp/files'
)

let cachedFonts: Font[] | null = null

export function loadFonts() {
  if (cachedFonts) return cachedFonts

  cachedFonts = [
    {
      name: 'Noto Sans JP',
      data: fs.readFileSync(
        path.join(FONT_DIR, 'noto-sans-jp-japanese-400-normal.woff')
      ),
      weight: 400,
      style: 'normal' as const,
    },
    // latin-400, japanese-700, latin-700 も同様に読み込む
  ]

  return cachedFonts
}

日本語サブセットとLatinサブセットの両方を読み込んでいます。Noto Sans JPはCJK文字を含むため1ファイルあたりのサイズが大きく、ランタイムで毎回読み込むとパフォーマンスに影響します。フォントデータはモジュールレベルでキャッシュし、複数のOGP画像を生成する際に再読み込みが発生しないようにしています。

ウェイトは400(Regular)と700(Bold)の2種類を用意しています。タイトルをBoldで、descriptionをRegularで描画し、視覚的な階層を作ります。

レンダリングパイプライン

レンダリングは3つのステップで構成されます。

export async function renderOgImage(
  descriptor: OgDescriptor
): Promise<Uint8Array> {
  await ensureWasmInitialized()
  const fonts = loadFonts()
  const template = buildTemplate(descriptor)

  const svg = await satori(template as unknown as ReactNode, {
    width: 1200,
    height: 630,
    fonts,
  })

  const resvg = new Resvg(svg)
  const pngData = resvg.render()
  return pngData.asPng()
}

最初にResvg WASMの初期化を行います。WASMバイナリは node_modules/@resvg/resvg-wasm/index_bg.wasm から読み込み、initWasm() に渡します。この初期化は一度だけ実行すればよいため、フラグで管理しています。

次にSatoriがテンプレートとフォントデータを受け取り、SVG文字列を生成します。Satoriの内部ではYoga Layoutエンジンがレイアウト計算を行い、Flexboxの配置がSVGの座標系にマッピングされます。

最後にResvgがSVG文字列をパースし、PNGバイナリに変換します。ResvgはRust製のSVGレンダリングライブラリで、WASMにコンパイルされたバージョンをNode.js環境で使っています。ブラウザーのレンダリングエンジンに依存しないため、ヘッドレスブラウザーのセットアップは不要です。

ページエンドポイント

Astroのファイルベースルーティングを使い、/og/[...slug].png にOGP画像を配信します。

// src/pages/og/[...slug].png.ts
export const prerender = true

export const getStaticPaths: GetStaticPaths = async () => {
  const descriptors = await getAllOgDescriptors()
  return descriptors.map((d) => ({
    params: { slug: d.slug },
    props: { descriptor: d },
  }))
}

export const GET: APIRoute = async ({ props }) => {
  const { descriptor } = props

  try {
    const png = await renderOgImage(descriptor)
    return new Response(png as unknown as BodyInit, {
      headers: {
        'Content-Type': 'image/png',
        'Cache-Control': 'public, max-age=2592000',
      },
    })
  } catch {
    return new Response(
      'OG image generation is only available at build time',
      { status: 501 }
    )
  }
}

export const prerender = true がこの設計の要です。Labee Dev Toolboxは output: 'server' でSSRを有効にしていますが、OGP画像のエンドポイントだけをプリレンダリング対象として指定しています。ビルド時に getStaticPaths() が全ディスクリプタを列挙し、各スラッグに対して GET ハンドラーが実行され、PNG画像がファイルとして出力されます。デプロイ後はCDNから静的ファイルとして配信されるため、Workerの処理は不要です。

Cache-Control: public, max-age=259200030日間のキャッシュを指定しています。OGP画像はコンテンツの更新時にのみ変化するため、長めのキャッシュ期間を設定しています。

dev環境での制限

dev環境では、catch ブロックで501ステータスを返します。Astroの開発サーバーはworkerdサンドボックス内で動作しますが、fs.readFileSync によるnode_modulesへのアクセスがサンドボックスの制限で失敗する場合があります。WASMバイナリの読み込みやフォントファイルの読み込みが該当します。

OGP画像はSNSシェア時のプレビュー用であり、ローカル開発中に実際の画像を確認する必要性は低いため、開発体験への影響はありません。画像の見た目を確認したい場合は、pnpm build && pnpm preview でビルド後のプレビューサーバーを使います。

HTMLへのメタタグ埋め込み

生成したOGP画像をHTMLの <meta> タグで参照します。BaseLayoutで ogImage プロパティを受け取り、og:imagetwitter:image を出力します。

---
const { ogImage, ogImageAlt = ogTitle } = Astro.props
---

{ogImage && (
  <>
    <meta property="og:image" content={ogImage} />
    <meta property="og:image:width" content="1200" />
    <meta property="og:image:height" content="630" />
    <meta property="og:image:type" content="image/png" />
    <meta property="og:image:alt" content={ogImageAlt} />
  </>
)}

og:image:widthog:image:height を明示することで、SNSのクローラーが画像を取得する前にアスペクト比を確定できます。og:image:typeimage/png を指定することで、画像フォーマットの推測に依存しない配信になります。

今後の展望

現在の構成はLabee Dev Toolbox専用ですが、labee.jpにも同じ仕組みを導入する予定です。サイトごとにカラーテーマやレイアウトは異なりますが、Satori + Resvg WASMのパイプラインとAstroの getStaticPaths() による静的生成の設計はそのまま流用できます。テンプレートとディスクリプタの定義をサイトごとに差し替えるだけで対応できる構成を目指しています。