このブログはNext.jsで作っていて、記事のOGP画像はHTMLから自動で生成しています。ライブラリにはPuppeteerを使い、Vercelから配信しています。この機能を実装する上で、どのライブラリを選ぶか悩んだり、いざVercelにデプロイしたらエラーになったりと、いくつかの問題がありました。
この経験をふまえて、この記事ではNext.jsで画像を自動生成するときの要件や選択肢、Puppeteerによる画像の生成方法を解説します。この記事を読むことで、コストをかけることなく、ユーザーに視覚的に分かりやすい形でコンテンツを届けられるようになります。
テクニカルライター。元エンジニア。共著で「現場で使えるRuby on Rails 5」を書きました。プログラミング教室を作るのが目標です。
画像を自動生成するときの要件
まず、この記事で解説する機能の要件を整理しておきます。要件としては、次の3つを満たすものにします。
- 任意のHTMLから画像を自動で生成する
- 画像を取得できるURLを公開する
- Next.jsで実装し、Vercelにデプロイする
この機能の主な利用例としてはOGP画像が考えられます。例えば、ブログのタイトルから画像を生成したりします。これははてなブログやZennが採用しています。
HTMLから画像を生成して、パブリックなURLを通して画像を取得できるようにします。このURLをOGPとして設定することで、コンテンツを公開すれば自動的にOGP画像が生成・設定されるようになります。
この機能を、今回はVercelにデプロイします。Vercelの制約として、サーバレス関数を50MB以下にする必要があります。使用するライブラリなど、実装方法によってはこの数字を超える可能性があるので、注意が必要になります。
Next.jsで画像を自動生成する方法の選択肢
この機能を実装する選択肢としては、大きく次の3つが考えられます。
- vercel/og-imageを利用する
- ライブラリを用いる。例えばPlaywrightやnode-html-to-image
- Puppeteerで実装する
vercel/og-imageは、Vercelが公開しているOGP画像生成ツールです。URLに文字列を指定すれば、画像を生成してくれます。ブログなどの簡単な用途なら、これでもいいです。
ただ、カスタマイズ性が低いのと、日本語を表示するにはforkしての対応が必要になります。運用コストを払うなら、自作した方がいいと思います。
Playwrightはマイクロソフト製で信頼感がありますが、本来の用途はテストであり守備範囲が広いです。node-html-to-imageは依存ライブラリの関係で、Vercelの制約である50MBを超えてしまいます。
PuppeteerはChromeの操作を自動化するライブラリで、これを使えば簡単な実装で画像を自動生成できます。Puppeteer自体はサイズが大きくVercelにデプロイできないですが、puppeteer-coreという本体のみがふくまれるライブラリを使えば問題ありません。
この記事ではPuppeteerを用いた実装について解説します。vercel/og-imageも、内部ではこのpuppeteer-coreを用いています。
動作環境
この記事で解説するコード例は、次に示す各環境で動作を確認しています。
ライブラリ | バージョン |
---|---|
next | 10.2.3 |
chrome-aws-lambda | 9.1.0 |
puppeteer-core | 9.1.1 |
Puppeteerによる画像の生成方法
ここでは例として、タイトルから画像を自動で生成する機能について解説します。例えば次のような画像が生成されます。
実装の基本的な流れとしては、次のようになります。
- URLからタイトルを取得する
- タイトルがないときは400を返す
- 画像のHTMLを定義する
- HTMLをバッファに入れる
- レスポンスとしてバッファを返す
タイトルは、Next.jsのDynamic Routesを使って取得します。まず、ライブラリをインストールします。
$ yarn add chrome-aws-lambda puppeteer-core
chrome-aws-lambdaはAWS Lambda用のChromiumバイナリです。Vercelはサーバレス関数をLambdaにデプロイする仕様のため、LambdaでChromiumを使うためにこれをインストールします。
Chromiumはオープンソースのブラウザで、PuppeteerはChromiumを制御するためのライブラリです。Puppeteerを通して、ChromiumでHTMLを描画・画像化する、ということになります。
コードは次のようになります。ファイル名は [title].tsx
です。
import { GetServerSideProps } from 'next' import chromium from 'chrome-aws-lambda' import puppeteer from 'puppeteer-core' const Image: React.FC = () => { return <></> } export const getServerSideProps: GetServerSideProps = async ({ res, params, }): Promise<any> => { const { title } = params if (!title) { res.statusCode = 400 res.end('Bad Request') return { props: {} } } const browser = await puppeteer.launch({ args: chromium.args, defaultViewport: { width: 1200, height: 675 }, executablePath: await chromium.executablePath, headless: chromium.headless, }) const html = `<html> <head> <style> body { width: 1200px; height: 675px; background-color: #f9fafb; } div { display: flex; justify-content: center; align-items: center; width: 60%; height: 100%; margin: auto; color: #374151; font-size: 3rem; font-weight: bold; line-height: 1.5; } </style> </head> <body> <div>${title}</div> </body> </html>` const page = await browser.newPage() await page.setContent(html) const buffer = await page.screenshot() res.setHeader('Content-Type', 'image/png') res.setHeader('Cache-Control', 'public, immutable, no-transform, s-maxage=31536000, max-age=31536000') res.end(buffer, 'binary') return { props: {} } } export default Image
Puppeteerのオプションについては公式ドキュメントをご覧ください。画像はPNGなどでも保存できますが、URLにアクセスしたときに画像を返すので、バイナリ形式にしています。
コード内で重要なのはキャッシュの設定です。キャッシュがないと、例えばTwitterでシェアしたときに画像が表示されません。これは、アクセスのたびに画像が生成されることによるタイムアウトが原因です。サーバーへの負荷もかかります。
キャッシュについてはMDN Web Docsをご覧ください。コード内のキャッシュの具体的な値はvercel/og-imageを参考にしました。
おわりに
ここではタイトルを例にしましたが、例えば画像にユーザー名を表示したり、背景画像を設定するようにすれば、より視覚的にユーザーを惹きつける画像にできると思います。