emahiro/b.log

日々の勉強の記録とか育児の記録とか。

Cloud Functions で ImageMagick 使って OGP 画像を生成・表示する

Overview

タイトルのとおりです。
2023年時点で Next を使って実装してある自分のポートフォリオサイトの Note ページに書かれた各ページのエントリのタイトルの OGP 画像を表示させます。エントリの本体は Notion にサーブしており、API 経由で Notion の Article 情報を取得してタイトルのOGP画像を生成する、という流れです。

今回は自前で OGP の画像を生成して Next.js 側で参照する方法を記載します。

自前で OGP を生成する

自前で OGP 画像を生成するときに考慮することは以下です。

  1. 何を使って画像を生成するか。
  2. 生成した画像の保存先

何を使って画像を生成するか

これが一番頭を悩ませました。

今回はカスタマイズしたOGP画像を生成する上で考慮したパターンは以下の3つです。

  1. 自前で実装せず Next.js で用意されている OGP の機能を利用して画像を生成する。
  2. Puppeteer を利用して生成した HTML をキャプチャした結果を OGP として利用する。
  3. ImageMagick を利用して画像を生成する。

Next.js で用意されてる OGP 向けの機能を利用する

公式のドキュメントで記載されてる以下の方法で実装する方法です。Generate images using code を使ってタイトル部分を動的に変更することが可能です。

この方法ではOGP画像でタイトルをエントリごとにカスタマイズするときにフォントが日本語である必要がありますが、公式ドキュメントで指定されている方法だと漢字混じりのタイトルで Unsupported OpenType が発生してしまい、ImageResponse を使った OGP の作成ができないことがネックになり ImageResponse での実装を見送りました。
※ ちなみにこの ImageResponse というのは過去バージョンの Next では vercel/og 呼ばれていた機能の現行バージョンの機能であって、ほぼほぼ vercel/og と同じことができます(便利機能をそのまま本体が取り込んだっぽい)

ref: @vercel/ogで記号文字を入れて"Unsupported OpenType signature"になって困ったけど解決したメモ

漢字対応してるフォントを ImageResponse で登録したときに OpenType のエラーが発生する問題については、自分の実装方法が間違っているかもしれないということもも否めませんが、そもそも Vercel 以外に乗っかる場合には Next.js を使うときにいくつか公式ドキュメントで紹介されている実装方法がそのまま使えないユースケースがありそうでした。結局のところ、Next を使う場合には Vercel に乗っかったほうが 100% 楽で、Static export などせずにランタイムごと Edge 環境にデプロイしてしまう、という技術選択が正しいという状況が現時点では生まれているんでしょうかね。

Puppeteer を利用してキャプチャする

Next.js で標準で用意されてる ImageResponse では日本語フォントの対応に難儀したので、FaaS で画像を生成する方針に切り替えます。

いくつか調べましたが、FaaS 上で画像を生成し、それを Strage に保存してクライアントから参照するありがちなパターンを採用します。

その上で何を使って FaaS 上で画像を生成するのかを考え、最初は puppeteer を使って FaaS 上でタイトルのみを表示させた HTML でページを組んでそれをキャプチャして画像として保存する、という方針を採用しました。
実装の詳細は以下のエントリが詳しいです。

yamahitsuji.medium.com

依存するライブラリも、実装方法もほぼここで記載されている内容そのままで実装し、動作させることもできたのですが、いくつかハマったところがあり、結論としてはこの方法は不採用にしました。

以下ハマったところです。

  • 日本語フォントが反映されず、真っ白なキャプチャを撮影してしまう。

  • medium で書かれた時期の puppeteer が古く、最新の puppeteer では chrome-aws-lambda のライブラリも互換がない。

    • これ自体は高速化するツールというだけなので chrome-aws-lambda を外しても動作はしました。
  • 動作が安定しない

    • 上記2つのトラブルを解消し、実際に puppeteer での HTML ページのキャプチャを動作させた所、動作するときとしないときがあり、挙動が安定しませんでした。原因ついては結局わかっていませんでしたが、puppeteer を動かすということは FaaS 上でブラウザを起動しキャプチャをするということになり、FaaS でやるには重たい処理にであることはわかります。実際 CloudFunctions のリソースの設定も結構モリモリの構成にしないと、そもそもブラウザが起動しないということもありました。

結局のところ最後の動作が安定しないことが決め手になって puppeteerの採用は諦めました。

ImageMagick を利用する

準備

Firebase Functions (CloudFunctions) が動作してるコンテナは現在デフォルトで ImageMagick のコマンドが使えます。
実行環境が nodejs である Firebase Functions 上で ImageMagick のコマンドが利用できるかどうかは以下のコマンドを使って確認できます。

await spawn("convert", ["-h"])

// or

await.spawn("which convert")

これで標準出力の結果でエラーが返却されない(両者ともに conver コマンドは存在することがわかる結果を出力する)ので Firebase Functions 上で convert コマンドを利用して画像を生成します。

ImageMagick での実装

実装自体は単純で convert コマンドを使用するときの CLI の中身を node の子プロセスの中で実行するだけです。具体的な実装については以下になります。

const createOgpImage = async (title: string): Promise<string> => {
  const outFile = path.join(os.tmpdir(), `ogp.png`);
  // create ogp image
  await spawnSync("convert", [
    "-font",
    "./font/NotoSansCJKjp-Medium.otf",
    "-pointsize",
    "38",
    "-fill",
    "black",
    "-gravity",
    "Center",
    "-annotate",
    "+0+0",
    title,
    "./ogp_src.png",
    outFile,
  ]);
  return outFile;
};

日本語フォントの設定

ここで ImageMagick を使うまでにハマっていたところとして日本語フォントの設定があります。これは package.json と同階層から指定した相対パスにフォントデータを入れて、convert コマンドを実行するときにその相対パスfont option で指定します。
実際のフォルダ構成は以下のようになります。

├ package.json
├ font
     └ $フォントデータ 

これで convert -font "./toFontPath で日本語フォントを指定して画像を生成します。

補足

ImageMagick を利用する前にもう少し軽量な画像編集ライブラリである sharp や nodejs 上で image magick を使うための npm package (公式ドキュメント にも記載されている)の gm を利用することを検討しましたが、sharp には画像作成の機能はなく、gm を使った場合は Functions codebase could not be analyzed successfully. というエラーが発生してデプロイすることができなくなってしまったので、ImageMagick のコマンドを CloudFunctions でそのまま使う方法を採用しました。

生成した画像の保存先

今回は Firebase を利用してるので保存先は Firebase Strage (Google Cloud Strage) に保存します。

生成タイミングは Next のアプリケーションをビルドするときに、Server Component 内で Notion の API にアクセスして記事情報を取得し、タイトル情報を取得後、CloudFunctions を HTTP 経由で Trigger して、CloudFunctions で生成した画像を保存します。図に書くと以下のようなピタゴラスイッチを作るイメージです。

ビルド時に CloudFunctions を Trigger する。

Next.js のアプリケーションのビルド時に Notion API にアクセスしてタイトル情報を取得し、OGP を生成したあとにブログエントリの各ページを SSG で生成して Firebase Hosting にデプロイする、という方法を採用しました。
今回 SSG を利用したのは、OGP の生成だけでなく、エントリのリストやエントリの中身を取得するのに都度、Notion の API を Call する場合、クライアント側にクレデンシャルを露出させないと行けない可能性があったので、ビルド時にエントリ本体を SSG で生成し、 OGP の生成・保存も Next のサーバーコンポーネントで行いました。
サーバーコンポーネントを利用したのは上記の API アクセスや CloudFunctions へのリクエスト情報をクライアント側に露出させるなく、クレデンシャル情報やアクセス先の URL をクライアントと同じリポジトリの中で管理することができるからです。
ビルドプロセスに組み込むためには以下の Next 側で提供されてる generate 用の関数を利用します。

ざっくり説明すると generateStaticParams は SSG で生成されるページの Index を生成し、generateMetadata でその生成された Index を受け取って metadata の生成を行います。metadata の生成時にエントリのタイトル情報が必要になるので、ここで Notion API に Index (Notion API で言うところの Article の PrimaryKey) を渡してエントリの情報を取得します。

サンプルコードは以下です。

// app/note/[id]/page.tsx

export async function generateStaticParams() {
  const resp = await FetchNotionArticles({ params: {} });
  return resp.results.map((result) => {
    return {
      id: result?.id,
    };
  });
}

export async function generateMetadata({
  params,
}: {
  params: {
    id: string;
  };
}): Promise<Metadata> {
  const data = await FetchNotionArticleDetail({
    params: { id: params.id, tags: [] },
  });
// 略
  return {
      title: data.title,
      openGraph: {
        // ref: [https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadata-fields] を参考に OG の必要な値を追加する。
      },
      images: [
          {
            url: `保存した画像の CloudStrage上の URL を入れる`,
            width: 1200,
            height: 630,
            type: "image/png",
          },
        ],
})

まとめ

Next.js の OGP の機構をそのまま使えると思ってましたが、日本語フォントとのかみ合わせがうまく行かない問題があって、ちょっと遠回りですが、GCPピタゴラスイッチを実装しました。
Next.js を使うなら Vercel に完全に乗っかったほうが楽だなーと思いましたが、元々 Firebase に乗っかってるプロダクトの上での実装だったので今回のように生の ImageMagick を使う方法を採用しました。

SeeAlso