🐯

Next.js14 + ヘッドレスブラウザでOG画像の自動生成APIを作る

2024/08/15に公開

こんにちは。音楽ディレクター・プロデューサーの村上といいます。
今回は、Next.jsサイトで各記事のOG画像を自動生成するシステムを独自実装で作ってみました。普通のAPIなので、Next.jsではない、どんなサイトでも使えます。

(Next.js/Vercelが提供する動的OGP自動生成ライブラリも存在しますが、いろいろ問題があって使えず。後述)

OG画像 is 何

OGPとも呼ばれます。これがサイトにメタデータとして埋め込まれていると、X(Twitter)やLineなどの各SNSやブログなどで、ユーザーがページをシェアしたときにサムネイル的なやつが表示されます。

下記が完成品です。CMSで記述した記事に含まれている画像を抽出し、記事のタイトルやサイトロゴなどが自動的に統合されて出力されています。
https://erisasaki.net/post/756671739541454848
https://erisasaki.net/post/684145030555910144

システムの全貌

けっこう複雑になってしまったので、いったい何を作ったのか、まずは図解します。メインサイトのプロジェクトと、API専用のプロジェクトを別で作っています。

ポイント

  • メインのサイトプロジェクトで、CMS側のデータ更新などによってリデプロイがトリガーされると、下記の素材をプリビルドスクリプトでAPI(Next.jsのAPIルート)に送信。
    [CMS(MicroCMS)からフェッチした記事内の画像データ、記事タイトル文字列、記事ID、サイトのメインバナー画像から抽出したサイト全体のドミナントカラー]
  • API側でデータを受け取ったら、クエリ内容に応じてハッシュ値を生成する。Vercelの提供するストレージサービスのBlobを使って、ハッシュ値をファイル名に持つキャッシュ済みの画像ファイルが無いか調べる
  • キャッシュがあればその画像を返す。なければOG画像の生成をトリガー
  • ヘッドレスブラウザ(Chromium)を起動し、テンプレートHTMLを読み込んでクエリ内容に置き換えてレンダリング。
  • ブラウザから出力されたスクリーンショットを返す。また、ハッシュ値をファイル名にした生成済みのOG画像をVercelのBlobに保存
  • メインサイト側はpublicフォルダに静的アセットとして書き込む。プリビルドスクリプトが完了後、通常のnext.jsのビルドプロセスに移行し、動的ルーティングで生成したOGPを各ページに埋め込む。

この実装の利点

なぜメインサイトのプロジェクトとAPIのプロジェクトを分けるのか?

→ ヘッドレスブラウザは依存関係が比較的シビアで、実行環境Linuxの特定のライブラリに依存していたり、Node.jsのバージョンを18に下げたりしなければならず、セキュリティ的にもメインサイトのプロジェクトと完全に分離して管理したい。
また、APIを切り離すことで今後Next.jsのnext/og機能の完成度が上がってきた場合などに容易に置き換えが可能で、他のNext.js以外のサイトでも流用できる。

なぜヘッドレスブラウザを使うのか?

→ OG画像の動的生成はcanvasを使ったりする方法がメジャーですが、一つ一つの要素の描画を手動で実装することになるので敷居が高い、後からデザインを変更するのも大変。
また、テキストの扱いがシビアで、カスタムフォントは使えるもののHTMLのように改行動作を自動的によしなにしてくれないのも問題(厳密にやろうとすると形態素解析などの実装が必要になる)。
ヘッドレスブラウザであれば、完全にいつものサイトデザインの感覚でレイアウトを行って画像を生成できる。

Chromium自体はご存じの通り巨大なブラウザアプリケーションなので、そのままではVercelなどのサーバレス環境に組み込んで使うことはできません。
そのため、ヘッドレレスChromiumでメジャーなライブラリに、Puppeteerというものがあるのですが、これのChromium本体を含んだフルバージョンではなく、Puppeteer-coreというラッパーAPIだけを使って、別の軽量なヘッドレスChromium実装と組み合わせて使います。今回は@sparticuz/chromiumというのを使いました。
https://pptr.dev/
https://github.com/Sparticuz/chromium

APIの実際のコード

なにはなくとも、佐々木恵梨さんの公式サイトで実際に稼働しているAPIのコードを載せます。
(こういうことができるのが個人開発かつ、案件ディレクター本人による自作自演の強み。)

下記のコードをまっさらなNext.jsプロジェクトにsrc/app/api/route.tsxとして保存し、
Vercelなどにデプロイするだけで、あなた専用のOG画像生成APIとしてすぐに使うことができます。

import { NextRequest, NextResponse } from 'next/server';
import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { put } from '@vercel/blob';
import { list } from '@vercel/blob';

//ハッシュ値の生成関数
function generateHash(input: string): string {
  return crypto.createHash('md5').update(input).digest('hex');
}

// 画像のアスペクト比の閾値。これを超えて横長の画像はクロップされる。それ以外はブラー画像で余白が埋められる
const ASPECT_RATIO_THRESHOLD = 16 / 9.1;

//APIリクエストの型定義
interface ApiRequest {
  id?: string;
  title?: string;
  imageBase64: string;
  imageWidth: number;
  imageHeight: number;
  dominantColor: string;
}

export async function POST(req: NextRequest) {

  let body: ApiRequest;

  try {
    body = await req.json();
  } catch (error) {
    console.error('リクエストボディの解析エラー:', error);
    return NextResponse.json({ error: 'リクエストボディのJSONが無効です' }, { status: 400 });
  }

  const { id, title, imageBase64, imageWidth, imageHeight, dominantColor } = body;

  if (!imageBase64 || !imageWidth || !imageHeight || !dominantColor) {
    console.log('リクエストボディが無効です:', body.id, body.title, body.imageWidth, body.imageHeight, body.dominantColor);
    return NextResponse.json({ error: '必要なフィールドが不足しています' }, { status: 400 });
  }

  const hashInput = `${imageBase64}_${id || 'default'}_${title || 'default'}_${dominantColor}`;
  const hash = generateHash(hashInput);

  // ブラウザインスタンス
  let browser = null;

  try {
    // キャッシュをチェック
    try {
      const { blobs } = await list();
      for (const blob of blobs) {
        if (blob.pathname.includes(hash)) {
          const cachedOgpResponse = await fetch(blob.downloadUrl);
          const cachedOgpArrayBuffer = await cachedOgpResponse.arrayBuffer();
          const base64String = Buffer.from(cachedOgpArrayBuffer).toString('base64');
          console.log('キャッシュされたOGPが見つかりました: ', id, title);
          return NextResponse.json({ screenshot: base64String });
        }
      }
    } catch (error) {
      // キャッシュが存在しない場合は無視
      console.log('OGPキャッシュが見つかりません。再生成します: ', id, title);
    }

    // ここからOGP生成処理
    browser = await puppeteer.launch({
      args: chromium.args,
      defaultViewport: chromium.defaultViewport,
      executablePath: await chromium.executablePath(),
      headless: 'new',
      ignoreHTTPSErrors: true,
    });

    const page = await browser.newPage();
    const logoImageBase64 = `data:image/svg+xml;base64,${await fs.readFile(
      path.join(process.cwd(), 'public', 'images', 'erisasaki-name-old.svg'),
      'base64'
    )}`;

    const imageAspectRatio = imageWidth / imageHeight;
    const isWiderThanThreshold = imageAspectRatio > ASPECT_RATIO_THRESHOLD;

    // フォントファイルの読み込み
    const mplus1pFont = await fs.readFile(path.join(process.cwd(), 'public', 'fonts', 'MPLUS1p-Medium.ttf'), 'base64');
    const jostFont = await fs.readFile(path.join(process.cwd(), 'public', 'fonts', 'Jost-Medium.ttf'), 'base64');
    const jostRegularFont = await fs.readFile(path.join(process.cwd(), 'public', 'fonts', 'Jost-Regular.ttf'), 'base64');

    await page.setViewport({
      width: 1200,
      height: 630,
      deviceScaleFactor: 1,
    });

    // HTMLコンテンツの設定
    await page.setContent(`
      <html>
        <head>
          <style>
            @font-face {
              font-family: 'MPLUS1p';
              src: url(data:font/truetype;charset=utf-8;base64,${mplus1pFont}) format('truetype');
            }
            @font-face {
              font-family: 'Jost';
              src: url(data:font/truetype;charset=utf-8;base64,${jostFont}) format('truetype');
            }
            @font-face {
              font-family: 'JostRegular';
              src: url(data:font/truetype;charset=utf-8;base64,${jostRegularFont}) format('truetype');
            }
            body {
              font-family: 'Jost', 'MPLUS1p', sans-serif;
              margin: 0;
              padding: 0;
            }
          </style>
        </head>
        <body>
          <div style='
            font-size: 48px;
            background-color: ${dominantColor};
            width: 100%;
            height: 100%;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            padding: 16px;
            position: relative;
            overflow: hidden;
          '>
            <div style='
              left: 32;
              top: 32;
              width: 1136px;
              height: 566px;
              position: fixed;
              border-radius: 32px;
              border-width: 1px;
              border-color: #ccc;
              overflow: hidden;
              z-index: 3;
              display: flex;
              align-items: center;
              justify-content: center;
            '>
              <img
                src='${imageBase64}'
                alt=''
                width='${imageWidth}'
                height='${imageHeight}'
                style='
                  position: absolute;
                  width: 100%;
                  height: 100%;
                  object-fit: cover;
                  filter: blur(15px) brightness(110%) saturate(75%);
                  transform: scale(1.1);
                  z-index: 1;
                '
              />
              <img
                src='${imageBase64}'
                alt=''
                width='${imageWidth}'
                height='${imageHeight}'
                style='
                  object-fit: ${isWiderThanThreshold ? 'cover' : 'contain'};
                  object-position: center;
                  width: 100%;
                  height: 100%;
                  position: absolute;
                  z-index: 2;
                  overflow: hidden;
                '
              />
            </div>
            <div style='
              position: fixed;
              top: 32px;
              left: 50%;
              transform: translateX(-50%);
              width: 1072px;
              height: auto;
              z-index: 3;
              display: flex;
              align-items: flex-start;
              justify-content: flex-start;
            '>
              <h1 style='
                position: absolute;
                width: 100%;
                color: rgba(255, 255, 255, 1);
                font-family: Jost, M PLUS 1p;
                font-size: 40px;
                font-weight: 400;
                line-height: 1.5;
                text-align: left;
                text-shadow: 0 0 75px rgba(255,255,255,1),
                              0 0 50px rgba(255,255,255,1),
                              0 0 30px rgba(255,255,255,1),
                              0 0 25px rgba(255,255,255,1),
                              0 0 20px rgba(255,255,255,1),
                              0 0 15px rgba(255,255,255,1),
                              0 0 5px rgba(255,255,255,1);
                display: ${title ? 'block' : 'none'};
                z-index: 4;
              '>
                ${title}
              </h1>
              <h1 style='
                position: absolute;
                color: #595659;
                font-family: Jost, M PLUS 1p;
                font-size: 40px;
                font-weight: 400;
                line-height: 1.5;
                text-align: left;
                text-shadow: 0 0 5px rgba(255,255,255,1);
                z-index: 5;
                display: ${title ? 'block' : 'none'};
              '>
                ${title}
              </h1>
            </div>
            <div style='
              position: fixed;
              height: 64px;
              bottom: 16px;
              left: 50%;
              transform: translateX(-50%);
              z-index: 5;
              display: flex;
              align-items: center;
              justify-content: flex-start;
              border-radius: 16px;
              border: 1px solid #c0bbbf;
              padding-left: 24px;
              padding-right: 24px;
              background-color: #fff;
              filter: drop-shadow(0 0 10px rgba(0,0,0,0.5));
            '>
              <img
                src='${logoImageBase64}'
                alt='佐々木恵梨'
                width='200px'
                height='35.5px'
                style='margin-right: 16px;'
              />
              <h2 style='
                color: #595659;
                font-family: JostRegular;
                font-size: 24px;
                font-weight: 400;
                line-height: 1.5;
                text-align: left;
              '>
                Official Website
              </h2>
            </div>
          </div>
        </body>
      </html>
    `);

    const screenshot = await page.screenshot({ encoding: 'base64' });

    const screenShortBinary = Buffer.from(screenshot, 'base64');

    // OGP画像をキャッシュ
    await put(`ogp_cache/${hash}.png`, screenShortBinary, {
      access: 'public',
      contentType: 'image/png'
    });

    console.log('OGP画像が生成されました:', id, title);

    return NextResponse.json({ screenshot });
  } catch (e) {
    console.error('OGP画像の生成エラー:', e);
    return NextResponse.json({ error: 'OGP画像の生成エラー' }, { status: 500 });
  } finally {
    if (browser) {
      await browser.close();
    }
  }
}

テンプレートHTMLをハードコーディングしてしまっていますが、当然外部から読み込めるようにした方がいいでしょう。あと、別にAPIの中でキャッシュ制御しなくて良いよというユースケースも多いと思うので、その辺は調整してください。(このコードは、Vercelでプロジェクトに紐づいたBlobストレージをセットアップ済みであることを前提としています。)
Vercelにデプロイした場合は、アドレスそのものを知らない限り他者は使えないですが、他の人に使われると困るとか、OG画像を見られたくないような(そんなことある?)場合はちゃんとAPIキーのセッティングをしましょう。

使い方

下記のようにAPIを呼び出します。HTMLのレンダリングに必要なパラメータなどは適宜調整してください。

      const response = await fetchWithTimeoutAndRetry('https://デプロイしたアドレス/api', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          id: id,
          title: post?.title,
          imageBase64: ogImageBase64,
          imageWidth: ogImageWidth,
          imageHeight: ogImageHeight,
          dominantColor: settings.dominantColor,
        }),
      }, 5, 60000, 5000);

      const data = await response.json();
      let ogpImageBase64 = data.screenshot;

      const screenShotFilePath = id
        ? path.join(ogpDir, `${id}_ogp.png`)
        : path.join(ogpDir, `default_ogp.png`);

      // Base64データをBufferに変換
      const imageBuffer = Buffer.from(ogpImageBase64, 'base64');

      // Sharpを使用してPNGとして保存(好きなようにしてください)
      await sharp(imageBuffer).png().toFile(screenShotFilePath);

注意点

今回使ったヘッドレスブラウザは、今のところ特定のバージョンの組み合わせではないとVercel上では動きませんでした。

  "dependencies": {
    "@sparticuz/chromium": "^119.0.2",
    "puppeteer-core": "^21.5.1"
  },
    "engines": {
    "node": "18"
  }

また、next.config.mjsに下記の設定を追加してください。

/** @type {import('next').NextConfig} */
const nextConfig = {
    experimental: {
    serverComponentsExternalPackages: ['puppeteer-core', '@sparticuz/chromium'],
  },
};

export default nextConfig;

Next.jsのnext/ogライブラリについて

さて、上記の実装によって、誰がいつ記事を更新してもレイアウトが整えられたOG画像が自動的に出力されるようになりました。キャッシュ機能もあるので、サイトのリビルド時間も問題なく、大満足です。

しかし、ここに至るまで紆余曲折がありまして、実はNext.jsには、パラメータを渡してOG画像を動的に自動生成するライブラリが組み込まれています。しかも、あらかじめ生成せずにページがシェアされた時に自動的に画像をレンダリングして返し、ある程度の制限があるとはいえReactのJSXでテンプレートを書けてしまうという、夢のような機能です。
https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image

夢のようなのですが、ただこれが罠でして、、、内部で使われているsatoriというエンジンがすごいお行儀が悪いようで、文字をレイアウトするだけならなんとかなりそうなのですが、画像を使ったりちょっと凝ったことをやろうとすると途端に崩壊します。
ドキュメントではimgタグになぜかUint8バイナリをsrcに渡すように書かれているのですが、そんなことは不可能だったり、ローカルでは動いていたのにVercelにデプロイするとファイルが見つからないとか、CSSの効きが一部怪しいとか、GitHubでもIssueまみれなので、正直、これはおすすめできません。

OGPはSSGしておく利点はあまりないので、これがちゃんと動くようになったらとても助かるんですけどね。今後に期待です。

とりあえず、OG画像生成めんどくさい!という人は試してみてください。

おしまい。

Discussion