🏞️

Cloudflare Workers で画像生成

2022/10/18に公開

どうも、 uzimaru です。
最近、Vercel が @vercel/og という package をリリースしました。
これは、Vercel Edge Functions で OGP 画像を生成するための package です。
Vercel はすでに vercel/og-image というリポジトリを公開しており、それを Fork して画像生成をしていた人も多いと思います。(自分もやってました)
こちらのリポジトリと違い @vercel/og は、ReactLike な Object や HTML を元に直接画像を生成するというアプローチを取っています。

そこで今回は、Edge 環境の一つである Cloudflare Workers で同じようなことをしてみたので記事にします。

@vercel/og の仕組み

まずは、 @vercel/og の仕組みから説明します。
こちらのライブラリは、satori というライブラリがベースになっています。
satori は、ReactLike な Object や HTML から SVG を生成するというライブラリになっています。
そう、SVG なので何らかの画像に変換する必要があります。
ドキュメント に書いている通り @resvg/resvg-js を使って png に変換しています。
流れを書くとこんな感じ

このフローを Edge 環境で行っているわけです

Cloudflare Workers で動かす

実際に動いているものが ↓ です

https://satori-workers.uzimaru.workers.dev

msg という SearchParams を渡すとその文字列が描画されます

https://satori-workers.uzimaru.workers.dev?msg=こんにちは

リポジトリは ↓ です
https://github.com/uzimaru0000/satori-workers

vercel/og は、Next.js 用に作成されている物っぽいのでそれ相当のものを実装して Cloudflare Workers に乗っけます。

Edge 環境で動かす

これらのライブラリは、Node で動くことを想定しているので Edge 環境ではそのままでは動きません。
そこで wasm を使います。それぞれ、wasm で動かすことを想定して作られているので Edge 環境でも動かせます!

satori を使えるようにする

satori は内部で yoga を使ってるので、これの wasm を使います。
satori を作った人が yoga-wasm-web というライブラリを作っているのでそれを利用します。
公式のリポジトリを見ると Runtime and WASM という項目があるのでそれを参考にします。

import satori, { init } from 'satori/wasm'
import initYoga from 'yoga-wasm-web'

const yoga = initYoga(await fetch('/yoga.wasm').then(res => res.arrayBuffer()))
init(yoga)

await satori(...)

ここで fetch で wasm を取ってきていますが、 Cloudflare Workers は外部から取ってきた wasm を実行出来ないようになっているようなので予め手元に持ってきます。
僕は unpkg 経由で yoga-wasm-web に入っている yoga.wasm を取ってきました。[1]

手元に wasm を取ってくる対応をするとこんな感じ

# ↓ を実行して手元に wasm を持ってくる
$ curl -L 'https://unpkg.com/yoga-wasm-web/dist/yoga.wasm' -o src/vender/yoga.wasm
import satori, { init } from 'satori/wasm'
import initYoga from 'yoga-wasm-web'
import yogaWasm from '../vender/yoga.wasm';

init(await initYoga(yogaWasm));

await satori(...)

resvg を使えるようにする

@resvg/resvg-js も wasm を使って実行することが出来ます。
リポジトリに example コード があるのでそちらを参考にしました。
また、@resvg/resvg-js には wasm 用の package である @resvg/resvg-wasm があるのでそちらを使います。

# ↓ を実行して手元に wasm を持ってくる
$ curl -L 'https://unpkg.com/@resvg/resvg-wasm/index_bg.wasm' -o src/vender/resvg.wasm
import { Resvg, initWasm } from '@resvg/resvg-wasm';
import resvgWasm from '../vender/resvg.wasm';

await initWasm(resvgWasm);
const svg = /* generate svg */
const resvg = new Resvg(svg, opts);
const pngData = resvg.render();
const pngBuffer = pngData.asPng();

wasm の初期化をまとめる

これらの wasm の初期化処理を扱いやすいようにまとめます。

const genModuleInit = () => {
  let isInit = false;
  return async () => {
    if (isInit) {
      return;
    }

    init(await initYoga(yogaWasm));
    await initWasm(resvgWasm);
    isInit = true;
  };
};
const moduleInit = genModuleInit();

isInit で管理しているのは複数回、初期化処理をすると @resvg/resvg-js がエラーを吐くためこのような形にしています。

画像生成のコア部分を作る

wasm の初期化は出来たので、あとはドキュメント通りにライブラリを使います。

export const generateImage = async (node: ReactNode) => {
  await moduleInit();
  // フォントの読み込み(後述)
  const notoSans = await loadGoogleFont({
    family: 'Noto Sans JP',
    weight: 100,
  });

  const svg = await satori(node, {
    width: 1200,
    height: 630,
    fonts: [
      {
        name: 'NotoSansJP',
        data: notoSans,
        weight: 100,
        style: 'thin',
      },
    ],
  });

  const resvg = new Resvg(svg);
  const pngData = resvg.render();
  const pngBuffer = pngData.asPng();

  return pngBuffer;
};

これで、 ReactNode を渡すと画像の ArrayBuffer が返ってくる関数ができました!
これを Cloudflare Workers の handler で呼び出すことで画像が生成されるエンドポイントを作成できます 🎉

ハマったポイント

容量が大きすぎてデプロイできない

Cloudflare Workers には、スクリプトのサイズが無料・有料変わらず 1MB という制限があります。
当初、wasm と同じ用にフォントも手元に落としてきて埋め込んでいたのですが、圧縮しても 1MB を超えてしまうサイズになっていました。
対応方法を探して、yoga-wasm-web を使っているリポジトリを見ていると Cloudflare Workers で OGP 作成をしようとしている例がすでにあったのでそちらを参考にしました。

https://github.com/kvnang/workers-og

見てみると、GoogleFonts の CSS が返ってくる URL を組み立てて CSS を取得、そこに埋め込まれているフォントの URL をぶっこ抜くというパワープレイをしてました。

https://github.com/kvnang/workers-og/blob/main/packages/workers-og/src/font.ts

「まぁそうかぁ」となったのでこちらのコードを使わせてもらいました。

まとめ

Next.js を使っていれば @vercel/og をそのまま使えるのですが、それ以外の環境でも動かしたいというケースが十分ありえると思うので参考になれば幸いです。
ちなみに、Next.js 以外で Vercel を使っている場合は Functions で satori@resvg/resvg-js を使えばいいっぽいです。

脚注
  1. これがいい方法なのか分かってないです。 ↩︎

Discussion