🎨

【Node.js】つらみを解消しながら動的なOGP画像を生成する

に公開

はじめに

こんにちは!株式会社スーパーハムスターでCTOをしております、もひこ(@andmohiko)です。

みなさんは、OGP画像を動的に生成するとき、どのような方法を使っていますか?

例えば簡単なものであれば、Next.jsで開発している場合は@vercel/ogを利用することが最初の選択肢に挙がってくるかもしれません。しかし、デザインやレイアウトが複雑なものになるとCanvasを検討することもあるのではないでしょうか。

一方で、Canvasと向き合のはつらい経験だったという声も耳にするため、なるべくCanvasを使いたくないという気持ちもあると思います。

この記事では、Node.jsとCanvasを使って、できるだけつらみを解消しながら、動的なOGP画像を生成する方法について解説します。

この記事で作れるようになるもの

この記事では、次のようなOGP画像を生成することをゴールとします。

このOGP画像の生成では、以下の処理を行っています。

  • OGP画像のベースとなる背景画像を設定
  • 左下にフリーテキストを挿入
  • 右下にサービスのロゴを挿入
  • テキストとロゴを見やすくするために背景にグラデーションを追加

これらの処理を少しでもつらさを軽減しながら実装していきます。

今回は、2024年末にリリースした「彼氏がかわいすぎる.com」で実際に作成したOGP画像を例に解説していきます。このアプリの詳細については、クソアプリアドベントカレンダーの記事で紹介していますので、ぜひそちらもご覧ください。
https://zenn.dev/andmohiko/articles/d916e356438cfe

node-canvasとは

Canvasとは

<canvas>は、HTML5で導入された描画領域(要素)のことです。JavaScriptを使って、この領域にグラフィックスや画像を描画したり、アニメーションを作成したりすることができます。

ブラウザ上で動的に図形を描いたり、ゲームやデータビジュアライゼーションなどに利用されることが多いです。

<canvas id="myCanvas" width="500" height="500"></canvas>
<script>
  const canvas = document.getElementById('myCanvas');
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = 'blue';
  ctx.fillRect(50, 50, 200, 100);  // 青い四角を描画
</script>

node-canvasとは

node-canvasは、Node.js環境でCanvasを扱えるようにするライブラリです。通常、HTMLのCanvasはブラウザ内で使われますが、node-canvasを使えばサーバーサイドでも画像やグラフィックの描画が可能になります。

ブラウザを介さずに動的な画像生成ができるため、OGP画像の自動生成などに利用されます。

Canvasとnode-canvasの違い

項目 Canvas node-canvas
実行環境 ブラウザ サーバー(Node.js)
主な用途 グラフィックス描画、ゲーム制作 OGP画像生成、PDF生成
依存ライブラリ なし Cairo
出力形式 ウェブページ上で描画 画像(PNG, JPEG)やBase64

node-canvasと向き合うつらさ

node-canvasを扱う際に、筆者は次の3点が特につらいと感じます。

  • CSSと違い、実装しながらその場で成果物を確認しづらい
  • node-canvasの依存解決がつらい
  • そもそもやりたいことってどうやってcanvasで記述するんだっけ(?。?)

Canvasの実装はposition absolute芸と似ていますが、position absoluteとの違いは配置した要素の座標を確認できないことです。

成果物の確認しづらさ

通常のWeb開発であれば、CSSでスタイルを変更すると即座にブラウザで反映され、リアルタイムに見た目を確認できます。しかし、Canvasでは成果物が画像になるため、ブラウザのインスペクタのようなもので見ながら調整するということができません。node-canvasの場合はコードを書いてから実行し、生成された画像を確認するという流れになります。

例えば「ちょっと位置を1px動かしたい」「色を少しだけ濃くしたい」といった細かい調整でも再度スクリプトを実行しないと結果が見えません。これが繰り返されると、かなりの時間がかかり、調整だけで疲弊してしまいます。

node-canvasの依存解決のつらさ

node-canvasは単体では動作せず、Cairoという描画エンジンへの依存があります。このCairoのインストールが曲者で、環境によってはライブラリのバージョン違いやパスの設定ミスなどでビルドエラーが頻発します。

Canvasの書き方に慣れない

ブラウザのCanvas APIに慣れている人でも、「テキストを中央に配置」「画像の上に半透明のレイヤーを敷く」といった処理をnode-canvasで実装しようとすると、「あれ?どうやるんだっけ」と手が止まることがあります。Canvas自体が低レベルAPIであり、CSSのように

text-align: center;

で終わらないのがつらいポイントです。

下記のような書き方でテキストを描画できますが、細かな位置調整や複数行テキストの対応は自分で計算しないといけません。一行の処理が想定以上に長くなり、コードがどんどん煩雑になります。

ctx.font = '48px serif';
ctx.textAlign = 'center';
ctx.fillText('Hello', canvas.width / 2, canvas.height / 2);  

やりたいことが増えるほど、Canvasのコードが複雑になり、「これ、普通にHTML+CSSでやったほうが早くない?」という気持ちになります。

Canvasの書き方を都度調べながら作業を進め、実際に生成された画像を見ながら要素の座標を調整していく作業を繰り返していくことになります。

つらみを乗り越える解決策

さきほどの3点を解決するため、

  • Canvas上にグリッド線を引くことでおおまかな座標を見やすくする
  • FigmaとGPTを活用する
  • node-canvasを使わない

という作戦でいきます。

グリッド線を引く

Canvas上に数pxごとに罫線を引くことでおおまかな座標を見やすくします。罫線の間隔はお好みで調節してください。筆者は100pxごとに引くことにしました。

FigmaとGPT

まずは作りたいイメージをFigmaに起こします。Figma上で作成したものをCSSとしてエクスポートし、GPTに投げてCanvasの記法に変換します。

例えば次のようなCSSを書き、GPTに投げるとこのようなCanvasのコードが吐き出せます。

/* テキストの下に敷くグラデーションの背景のCSS */
position: absolute;
width: 900px;
height: 193px;
left: 0px;
top: 317px;
background: linear-gradient(180deg, rgba(34, 34, 34, 0) 0%, #222222 100%);
// グラデーションのレイヤーを引くCanvasのコード
const gradientHeight = 193 // 高さを指定
const gradientWidth = canvasWidth // 幅を指定
const gradientTopPosition = canvasHeight - gradientHeight // キャンバスの下部に配置

// グラデーションを作成
const gradient = ctx.createLinearGradient(
  0,
  gradientTopPosition,
  0,
  canvasHeight,
)
gradient.addColorStop(0, 'rgba(34, 34, 34, 0)') // 開始色
gradient.addColorStop(1, '#222222') // 終了色

// 塗りつぶしスタイルをグラデーションに設定
ctx.fillStyle = gradient

// グラデーションを描画
ctx.fillRect(
  (canvasWidth - gradientWidth) / 2,
  gradientTopPosition,
  gradientWidth,
  gradientHeight,
)

node-canvas互換のライブラリを使う

node-canvasの環境構築は詰まりやすいポイントだと思います。そこで、Web Canvas API互換なAPIを実装した @napi-rs/canvas というライブラリを使用します。このパッケージはネイティブモジュールに依存しない実装になっています。すでにnode-canvasで実装していたところもimportを書き換えるだけで使えます。

- import { createCanvas } from 'node-canvas';
+ import { createCanvas } from '@napi-rs/canvas';

また、画像の合成やリサイズには sharp を使用します。

具体的な実装

それでは具体的な実装に入っていきます。今回使用したライブラリのバージョンは下記の通りです。

  • TypeScript: 5.6.3
  • @napi-rs/canvas: 0.1.65
  • sharp: 0.33.5

環境構築

必要なライブラリをインストールします。package.jsonはこうなりました。具体的な手順は省略します。

..., // 省略
"dependencies": {
  "@napi-rs/canvas": "^0.1.65", // 今回の主役
  "axios": "^1.7.9",
  "cors": "^2.8.5",
  "express": "^4.21.2",
  "express-promise-router": "^4.1.1",
  "express-validator": "^7.2.0",
  "firebase-admin": "^12.1.0",
  "firebase-functions": "^5.0.0",
  "sharp": "^0.33.5", // 今回の主役
  "uuid": "^11.0.3"
},

Figmaにデザインを作成する

最終的にこのようなデザインを作成しました。

CSSとしてエクスポート

右クリックでここからエクスポートします。

エクスポートすると下のようなものがクリップボードに保存されます。さきほどのコードの再掲です。

/* Rectangle 1 */

position: absolute;
width: 900px;
height: 193px;
left: 0px;
top: 317px;

background: linear-gradient(180deg, rgba(34, 34, 34, 0) 0%, #222222 100%);

GPTに投げる

こちらのCSSをCanvasに書き換えるようにChatGPTに依頼すると次のようなコードを書いてくれます。こちらもさきほどのコードの再掲です。

// グラデーションのレイヤーを引くCanvasのコード

const gradientHeight = 193 // 高さを指定
const gradientWidth = canvasWidth // 幅を指定
const gradientTopPosition = canvasHeight - gradientHeight // キャンバスの下部に配置
// グラデーションを作成
const gradient = ctx.createLinearGradient(
  0,
  gradientTopPosition,
  0,
  canvasHeight,
)
gradient.addColorStop(0, 'rgba(34, 34, 34, 0)') // 開始色
gradient.addColorStop(1, '#222222') // 終了色

// 塗りつぶしスタイルをグラデーションに設定
ctx.fillStyle = gradient

// グラデーションを描画
ctx.fillRect(
  (canvasWidth - gradientWidth) / 2,
  gradientTopPosition,
  gradientWidth,
  gradientHeight,
)

Canvasで実装する

GPTが吐き出したものを組み合わせながら実装していきます。

export const generateOgpImage = async (
  imagePath: string,
  name: string,
  uploadPath: string,
): Promise<string> => {
  const thumbnailBuffer = await fetchImageBuffer(imagePath)
  const logoBuffer = await fetchImageBuffer(logoUrl)

  const resizedThumbnail = await sharp(thumbnailBuffer)
    .resize(canvasWidth, canvasHeight)
    .toBuffer()
  const resizedLogo = await sharp(logoBuffer).resize(240, 140).toBuffer()

  const canvas = createCanvas(canvasWidth, canvasHeight)
  const ctx = canvas.getContext('2d')

  // グラデーションのレイヤーを引く
  const gradientHeight = 193 // 高さを指定
  const gradientWidth = canvasWidth // 幅を指定
  const gradientTopPosition = canvasHeight - gradientHeight // キャンバスの下部に配置
  // グラデーションを作成
  const gradient = ctx.createLinearGradient(
    0,
    gradientTopPosition,
    0,
    canvasHeight,
  )
  gradient.addColorStop(0, 'rgba(34, 34, 34, 0)') // 開始色
  gradient.addColorStop(1, '#222222') // 終了色

  // 塗りつぶしスタイルをグラデーションに設定
  ctx.fillStyle = gradient

  // グラデーションを描画
  ctx.fillRect(
    (canvasWidth - gradientWidth) / 2,
    gradientTopPosition,
    gradientWidth,
    gradientHeight,
  )

  // 100pxごとに縦と横の罫線を描画
  ctx.beginPath()
  // blue stroke
  ctx.strokeStyle = '#ff00ff'
  ctx.lineWidth = 1
  // 縦線
  for (let x = 0; x <= canvasWidth; x += 100) {
    ctx.moveTo(x, 0)
    ctx.lineTo(x, canvasHeight)
  }
  // 横線
  for (let y = 0; y <= canvasHeight; y += 100) {
    ctx.moveTo(0, y)
    ctx.lineTo(canvasWidth, y)
  }
  ctx.stroke()

  // canvas上に描画した罫線をバッファとして取得
  const gridBuffer = canvas.toBuffer('image/png')

  // 彼氏名を描画
  drawCanvasText(
    ctx,
    60,
    'Hiragino Maru Gothic ProN',
    '#ffffff',
    name,
    22,
    568,
    'left',
    'middle',
    'bold',
  )

  const textImageBuffer = canvas.toBuffer('image/png')

  const outputBuffer = await sharp(resizedThumbnail)
    .composite([
      { input: gridBuffer, top: 0, left: 0 },
      { input: textImageBuffer, top: 0, left: 0 },
      { input: resizedLogo, top: 458, left: 928 }, // ここは罫線を見ながら位置を調整する
    ])
    .toBuffer()

  const fileUrl = await saveBufferToStorageOperation(
    {
      fileName: `${uuidV4()}.png`,
      type: 'image/png',
      data: outputBuffer,
    },
    uploadPath,
  )
  return fileUrl
}

コードの全体像はこちらから見ることができます。

https://github.com/andmohiko/kareshi-kawaisugiru/blob/develop/functions/src/useCases/generateOgpImage.ts

罫線を見ながら調整していく

このAPIにリクエストを送るとこのような画像が生成されました。

あとは罫線を見ながら各要素の配置を微調整していけば完成です🎉

さいごに

今回の最終的なコードはこちらのリポジトリから見ることができます。

https://github.com/andmohiko/kareshi-kawaisugiru

これらの手法を組み合わせることで、見栄えが良くカスタマイズ性の高いOGP画像を効率的に作成できるようになります。

ぜひ、みなさんのプロジェクトにも取り入れて、OGP画像の生成に活かしてください。

Discussion