【Next.js 13】OGP画像を動的生成してFirestore + CloudStorageに保存するまで。
Next.js 13でWEBサイトを作成している時に、OGP画像を生成してFirestore + CloudStorageに保存する処理を調べたので、今回はそのメモをします。
※今回は「ひとまず動く」という内容なので、不要な部分などあるかもしれません。
目次
1)今回使うパッケージをインストール
$ npm install firebase
$ npm install firebase-admin
$ npm install @napi-rs/canvas
今回Vercelにデプロイしたので「canvas」の代わりに「@napi-rs/canvas」を使いました。後述の「今回発生したエラー」で詳しい理由を紹介しています。
2)Firebaseでプロジェクトを作成する
「Firebase」で新しくプロジェクトを作成します。
Firestore Databaseを作成する
Storageを作成する
秘密鍵を取得する
「設定」→「サービスアカウント」→「新しい秘密鍵を生成」をしてJsonファイルを取得します。
このJsonファイルのデータは後ほどセットアップの環境変数で使用します。
3)Next.jsでFirebaseのセットアップをする
import { initializeApp, getApps } from "firebase/app";
// 必要な機能をインポート
import { getAnalytics } from "firebase/analytics";
import { getFirestore } from "firebase/firestore";
import { getStorage } from "firebase/storage";
import { getAuth } from "firebase/auth";
import { getFunctions } from "firebase/functions";
const firebaseConfig = {
// TODO:認証情報を設置
apiKey: process.env.FIREBASE_API_KEY,
authDomain: process.env.FIREBASE_AUTH_DOMAIN,
projectId: process.env.FIREBASE_PROJECT_ID,
storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.FIREBASE_APP_ID,
measurementId: process.env.FIREBASE_MEASUREMENT_ID,
};
if (!getApps()?.length) {
// Firebaseアプリの初期化
initializeApp(firebaseConfig);
}
// 他ファイルで使うために機能をエクスポート
export const analytics = getAnalytics();
export const db = getFirestore();
export const storage = getStorage();
export const auth = getAuth();
export const funcions = getFunctions();
import * as admin from "firebase-admin";
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID!,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL!,
privateKey: process.env.FIREBASE_PRIVATE_KEY!.replace(/\\n/g, "\n"),
}),
storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
});
}
const db = admin.firestore();
const storage = admin.storage();
export { db, storage };
FIREBASE_API_KEY=xxx
FIREBASE_AUTH_DOMAIN=xxx
FIREBASE_PROJECT_ID=xxx
FIREBASE_STORAGE_BUCKET=xxx
FIREBASE_MESSAGING_SENDER_ID=xxx
FIREBASE_APP_ID=xxx
FIREBASE_MEASUREMENT_ID=xxx
FIREBASE_CLIENT_EMAIL=xxx
FIREBASE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nxxx
4)画像URLを取得&生成するAPIを作成する
API作成(/api/ogp)
import { NextApiRequest, NextApiResponse } from "next";
import { db, storage } from "@/lib/firebase/firebaseAdmin";
import { createOGPImage } from "@/utils/ogpGenerator";
export default async function GenerateOGP(req: NextApiRequest, res: NextApiResponse) {
// 1) Cloud Storage: 画像を保存する「ストレージ」
// 2) Firestore: Cloud Storageに保存された画像のパスをまとめた「データベース」
// ============================================
// リクエスト元から「URL」と「診断タイトル」を取得
// ============================================
const pageUrl = req.query.url as string; // URLを取得
const pageId = encodeURIComponent(pageUrl); // URLをエンコード(Firestoreに保存するID用)
const text = req.query.text as string // OGPに描画するテキストを取得
// ============================================
// FirestoreからOGP画像のURLを取得
// ============================================
const doc = await db.collection("ogps").doc(pageId).get();
// ============================================
// FirestoreにあればURL取得、なければ画像生成してからURL取得
// ============================================
if (doc.exists) { // ===== OGP画像が既にある場合 =====
res.status(200).json({ imageUrl: doc.data()?.imageUrl }); // 200: 画像URLを返す
} else { // ===== OGP画像がない場合 =====
// OGP画像を生成
const ogpImage = await createOneOGPImage(text); // 画像を生成する
const fileName = `${pageId}.png`; // ファイル名を設定
// Cloud Storageに画像を保存
const file = storage.bucket().file(fileName); // Cloud Storageにファイルオブジェクトを作成(画像保存先の確保)
await file.save(ogpImage, { contentType: "image/png" }); // Cloud Storageのファイルオブジェクトに生成画像を保存
// OGP画像のURLを取得
const imageUrl = await file.getSignedUrl({ // getSignedUrlメソッド: 読み取り専用のURLを生成
action: "read",
expires: "03-09-2491",
});
await db.collection("ogps").doc(pageId).set({imageUrl: imageUrl[0]}); // FirestoreにOGP画像のURLを保存
res.status(200).json({ imageUrl: imageUrl[0] }); // 200: 画像URLを返す
}
};
このコードでは、Firestoreに既に生成画像があればその画像URLを取得し、なければ新しく画像を生成する処理をして画像URLを取得します。
canvasで画像生成処理
import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas";
export async function createOGPImage(text: string): Promise<Buffer> {
const baseImagePath = "public/images/ogps/ogpBase.png"; // OGP画像のベースURL
const baseImage = await loadImage(baseImagePath); // ベース画像を読み込み
const canvas = createCanvas(baseImage.width, baseImage.height); // キャンバスを作成(ベース画像と同じサイズ)
GlobalFonts.registerFromPath('public/fonts/NotoSansJP-Bold.ttf', 'Noto') // 日本語フォントを指定
const ctx = canvas.getContext("2d"); // 2Dキャンバスを指定
ctx.drawImage(baseImage, 0, 0, baseImage.width, baseImage.height); // ベース画像をキャンバスに描画
// フォントと色を設定
ctx.font = "40px 'Noto'";
ctx.fillStyle = "black";
// テキストの描画
ctx.fillText(text, 50, 50)
return canvas.toBuffer("image/png");
}
このコードでは、実際にどんな画像を生成するかを記載しています。具体的には下記のような内容を指定しています。
- ベースになる画像を指定(背景画像)
- 日本語フォント(今回の使用フォント:Noto Sans Japanese – Google Fonts)
- 描画位置
5)ページにOGPを設定する
// メタデータを生成する関数
export async function generateMetadata() {
const url = `https://${YOUR_SITE_DOMAIN}`;
const ogpUrl = `https://${YOUR_SITE_DOMAIN}/api/ogp?text=${'描画するテキスト'}`;
const metadata = {
title: 'YOUR_TITLE',
description: 'YOUR_DESCRIPTION',
openGraph: {
title: 'YOUR_TITLE',
description: 'YOUR_DESCRIPTION',
url: url,
images: [
{ url: ogpUrl, width: 1200, height: 630, alt: 'YOUR_TITLE'}
],
locale: 'ja_JP',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'YOUR_TITLE',
description: 'YOUR_DESCRIPTION',
images: [ogpUrl]
},
}
return metadata;
}
export default function Home() {
return (...)
}
おまけ:今回起きたエラー
「canvas」がインストールできない
「canvas」を使う場合には依存関係のあるライブラリをインストールする必要があります。Dockerを使用する場合にはDockerfileに下記を追記してライブラリをインストールします。
# 依存関係のあるライブラリのインストール
RUN apt-get update && apt-get install -y \
build-essential \
libcairo2-dev \
libpango1.0-dev \
libjpeg-dev \
libgif-dev \
librsvg2-dev
「canvas」が容量制限によってVercelで使えない
Error: The Serverless Function “api/ogp” is 54.9mb which exceeds the maximum size limit of 50mb
「canvas」ではVercelへのデプロイ時にエラーが起きました。「@napi-rs/canvas」を使用することで容量を減らして同じ機能のままデプロイすることができました。
https://xxx/api/ogpが上手く機能しない
最初は/app
ディレクトリ下に/api/ogp
を作成していました。ただしNext.js 13では/api
は、/app
下ではなく/pages
下に置かなければいけないようでした。
新しくトップディレクトリに/pages
ディレクトリを作成して、/pages/api/ogp.ts
を作成することで、https://xxx/api/ogpで処理を進められるようになりました。
おまけ:OGP画像の描画デザインを変える
- テキストの高さ・幅の中央揃え
- 指定テキスト描画範囲を超えたら改行
import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas";
export async function createOGPImage(text: string): Promise<Buffer> {
// console.log(text)
const baseImagePath = "public/images/ogps/ogpBase.png"; // OGP画像のベースURL
const baseImage = await loadImage(baseImagePath); // ベース画像を読み込み
const canvas = createCanvas(baseImage.width, baseImage.height); // キャンバスを作成(ベース画像と同じサイズ)
GlobalFonts.registerFromPath('public/fonts/NotoSansJP-Bold.ttf', 'Noto') // 日本語フォントを指定
const ctx = canvas.getContext("2d"); // 2Dキャンバスを指定
ctx.drawImage(baseImage, 0, 0, baseImage.width, baseImage.height); // ベース画像をキャンバスに描画
// フォントと色を設定
ctx.font = "40px 'Noto'";
ctx.fillStyle = "black";
const drawWidth = 950; // 描画範囲の幅
const drawHeight = 380; // 描画範囲の高さ
const drawStartX = (canvas.width - drawWidth) / 2; // 描画範囲の開始X座標
const drawStartY = (canvas.height - drawHeight) / 2; // 描画範囲の開始Y座標
const textWidth = ctx.measureText(text).width; // テキストの描画幅を計算
const textHeight = 40; // フォントサイズからテキストの高さを決定(実際の高さはフォントにより異なる可能性があります)
// const textX = drawStartX + (drawWidth - textWidth) / 2; // 描画範囲中央揃えにするためのX座標を計算
// const textY = drawStartY + (drawHeight + textHeight) / 2; // 描画範囲中央揃えにするためのY座標を計算
// ctx.fillText(text, textX, textY); // テキストを描画
const lineHeight = 50; // 行の高さ
const words = text.split(''); // 単語単位に分割する
let lines = [''];
let lineIndex = 0;
// 一文字ずつループ処理
for (let n = 0; n < words.length; n++) {
// console.log(words[n])
const testLine = lines[lineIndex] + words[n]; // 現在の行に文字を追加して仮テキストを作成
const metrics = ctx.measureText(testLine); // 仮テキストの幅を計測
const testWidth = metrics.width;
// 仮テキストの幅が描画範囲を超えていたら、新しい行を作成
if (testWidth > drawWidth && n > 0) {
lineIndex++;
lines[lineIndex] = ""; // 新しい行を空文字列で初期化
}
lines[lineIndex] += words[n]; // 現在の行に文字を追加
// 「。」と「!」の場合は改行する
if ((words[n] === "。" || words[n] === "!") && n !== words.length - 1) {
lineIndex++;
lines[lineIndex] = ""; // 新しい行を空文字列で初期化
}
}
const totalTextHeight = lineIndex * lineHeight; // テキストの高さ合計を計算
const textStartY = drawStartY + (drawHeight - totalTextHeight) / 2; // テキストの開始Y座標を計算
// 一行ずつ描画
for (let i = 0; i <= lineIndex; i++) {
const lineWidth = ctx.measureText(lines[i]).width;
ctx.fillText(lines[i], drawStartX + (drawWidth - lineWidth) / 2, textStartY + i * lineHeight + lineHeight/2);
}
return canvas.toBuffer("image/png");
}