Next.js Conf 2022で最も感動したライブラリ、vercel/satoriについて紹介させてください。

はじめまして。コミューンでサーバーサイドエンジニアとして働いています、あのちっくと申します。

突然ですが皆様は昨年 10 月に開催されたNext.js Conf 2022はご覧になられましたでしょうか。

Next.js Conf は Next.js の開発・メンテナンスを行っている Vercel 社が主催する、Next.js とその周辺技術に関するカンファレンスです。

コミューンでもメインプロダクト commmune の Web フロントフレームワークとして Next.js を採用しており、私個人としてもとても興味深くオンラインから視聴をしていました。

特に話題になったのは、React Server Components をサポートしたルーティング・レイアウトシステム"app directory"などの新機能を新たに追加したNext.js 13と、"Webpack の後継"を謳う Rust ベースの高速バンドルツールTurbopackなどでしょうか。

その中で私が最も興味を唆られたリリースは、HTML/CSS から SVG 画像を生成する機能、Vercel OG Image Generationでした。

今回は、この"Vercel OG Image Generation"を実現するために Vercel が新たに開発した技術、vercel/satori について紹介したいと思います。

ぜひ最後までお読みいただければ幸いです。 どうぞよろしくお願いします。

vercel/satori とは

vercel/satori(以下 satori)は、Vercel 社が Next.js Conf2022 と同時期にリリースした HTML/CSS から SVG 画像を生成する JavaScript ライブラリです。

github.com

これだけの説明だと Vercel OG Image Generation との差異が分かりづらいかと思いますが、 Vercel OG Image Generation はアプリケーションホスティングサービスVercelでのみ使うことが出来る機能で、この機能を使うと HTML/CSS から SVG 画像を生成する処理がVercel Edge Functionsと呼ばれる CDN 上で動作する高速・軽量なサーバレス環境へとデプロイされます。

対して satori は、Vercel や Next.js には依存せず、シンプルな JavaScript ライブラリとしてブラウザでも Node.js 環境でも動作します。 そのため、フレームワークを選ばず、様々な JavaScript プロジェクトで利用することが出来ます。

基本的な使い方

使い方はとても簡単で、以下の様にsatori関数に HTMLElement と画像サイズ、フォント情報などの情報を与えると、SVG 形式のテキストデータが返されます。

// sample.tsx
import satori from "satori";

const svg = await satori(
  <div
    style={{
      height: "100%",
      width: "100%",
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      justifyContent: "center",
      backgroundColor: "#000",
      fontSize: 32,
      fontWeight: 600,
    }}
  >
    <img
      style={{
        border: "4px solid #555",
        borderRadius: "50px",
        width: "100px",
        height: "100px",
      }}
      src="..."
    />
    <div style={{ color: "white", marginTop: 8 }}>@anoChick</div>
  </div>,
  {
    width: 240,
    height: 240,
    fonts: [
      {
        name: "NotoSansJapanese",
        data: ...,
        weight: 400,
        style: "normal",
      },
    ],
  }
);

得られた SVG データ

とても簡単ですね。

上記の例では JSX コンパイラが必要ですが、以下のように React 風のデータオブジェクトを与えることで、JSX コンパイラを必要とせず、通常の JavaScript として使うことも出来ます。

// sample.ts
import satori from "satori";
const svg = await satori(
  {
    type: "div",
    props: {
      children: [
        {
          type: "img",
          props: {
            src: "https://pbs.twimg.com/profile_images/1561331809090412544/CXzPrtzP_400x400.png",
            style: {
              border: "4px solid #555",
              borderRadius: "50px",
              width: "100px",
              height: "100px",
            },
          },
        },
        {
          type: "div",
          props: {
            children: "@anoChick",
            style: {
              color: "white",
              marginTop: 8,
            },
          },
        },
      ],
      style: {
        height: "100%",
        width: "100%",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        backgroundColor: "#000",
        fontSize: 32,
        fontWeight: 600,
      },
    },
  },
  {
    width: 240,
    height: 240,
    fonts: [
      {
        name: "NotoSansJapanese",
        data: fontBufferArray,
        weight: 400,
        style: "normal",
      },
    ],
  }
);

satori のモチベーションと優れていること

前節では satori の基本的な使い方について説明しました。

次に satori がなぜ作られたかというモチベーションの面と、優れている点についてお話したいと思います。

satori が利用されている Vercel の新機能"Vercel OG Image Generation"のリリースからもわかるように、satori の主な用途の一つとして「OG 画像の自動生成」が挙がります。

OG 画像とは、平たく言えば SNS 等で URL をシェアした際に表示される画像のことです。

nextjs.org このような感じですね。

これは、Open Graph Protocolという規格に従い、その Web ページの meta タグで定義された OG 画像が表示されているというものです。

satori のモチベーションについて説明する為に例として、「ブログなどのサービスで、投稿された記事の OG 画像の URL にリクエストがあった時、記事のタイトルと著者の名前・アイコン等をもとに OG 画像を自動生成して返す」というような機能を、satori を使わずに実装した場合について考えていきます。

実装例 1. ヘッドレスブラウザをつかってレンダリングする

もっとも手軽な実装方法は、Chromium などの Web ブラウザを起動し、ブラウザの機能を使って HTML+CSS レンダリングと画像化を行う方式です。

これは、Vercel 社が"Vercel OG Image Generation"をリリースする前から、vercel/og-imageというリポジトリで実装例を公開していました。 vercel/og-image は、HTML と CSS を基にレンダリングするという点は satori と同様ですが、「HTML/CSS レンダラーを使いたいが為にフル機能を搭載した Web ブラウザを起動する」というのは、オーバースペックだと言えるでしょう。

このオーバースペックであることが実際に解決すべき問題として取り上げられたのが 2021 年の 2 月頃。今から約 2 年前です。

github.com

Vercel でホスティングされるサービスは、基本的に実行環境としてServerless Functionsが使用されます。 Serverless Functions で用意された実行環境は実態として AWS Lambda を使用しており、AWS Lambda には 2021 年 2 月現在でデプロイ可能なファイルサイズ上限が 50MB までという制限が存在します。

日々アップデートされ機能が増えていく Chromium とその周辺ライブラリの合計が、当時ついにこの 50MB という制限を越えてしまい、最新版のデプロイが困難になってしまいました。

また、実行時のオーバーヘッドも無視できません。仮にデプロイが出来たとしても、Web ブラウザを起動した上で描画し、その後ページのスクリーンショットを撮るという方法で画像を生成する為、処理に必要なコンピューティングリソースは決して小さくは有りません。

まとめると、ヘッドレスブラウザを使ってレンダリングをする方法には以下のような課題があると言えます。

  • Web ブラウザに依存するためシステムのファイルサイズが大きくなる
  • Web ブラウザを起動、動作させる必要が有るため、比較的多くのコンピューティングリソースを必要とし、オーバーヘッドが生まれる。

実装例 2. 汎用グラフィックスライブラリを使用する

HTML/CSS を使わない別の手段として、任意のグラフィックスライブラリを使用する方法が考えられます。 グラフィックスライブラリを利用するのであれば、ブラウザを起動するほどの大きなオーバーヘッドはなく、動作速度も比較的高速になることが見込まれます。

候補となるライブラリは、ImageMagickcairoなどのネイティブライブラリからnode-canvassharpなどのネイティブライブラリに依存したアプリケーションライブラリ等、様々な選択肢が存在します。

ここで技術選定の判断材料の一つとして挙がるのが「セキュリティリスク」です。

例えばグラフィックスライブラリとして知名度が高い Imagemagick は、CVE Details の統計情報によると、2017 年の脆弱性は 357 件、そこから年々減少傾向にはありますが、昨年 2022 年でも 10 件超の脆弱性が確認されています。

Imagemagick : Vulnerability Statistics- CVE Detailsより抜粋

すべてのグラフィックスライブラリが同様に多くの脆弱性を持つというわけではありませんが、グラフィックスライブラリは処理が複雑で高い計算効率性を求められること、また様々な種類の画像形式へのサポートを求められることから、脆弱性が多くなりやすい傾向があるようで、技術選定には細心の注意を払う必要があると考えています。

また、実装難度に関する課題もあります。

以下は Node.js 環境上で HTML Canvas API とほぼ同じ API 仕様で画像処理を記述出来るライブラリnode-canvasを使った実装例です。

const { createCanvas, loadImage } from 'canvas'

const canvas = createCanvas(200, 200)
const context = canvas.getContext('2d')
context.moveTo(20, 20)
context.lineTo(100, 20)
context.fillStyle = '#999'
context.beginPath()
context.arc(100, 100, 75, 0, 2 * Math.PI)
context.fill()

context.fillStyle = 'orange'
context.fillRect(0, 0, 600, 315)
context.font = '32px Helvetica'
context.fillStyle = '#000'
context.fillText('OG画像生成テスト', 50, 170)
context.font = '28px Helvetica'
context.fillText('anoChick', 390, 284)

loadImage('image.png').then(image => {
  const x = 520
  const y = 240
  const w = 64
  const h = 64
  const r = 32
  context.beginPath()
  context.moveTo(x + r, y)
  context.lineTo(x + w - r, y)
  context.quadraticCurveTo(x + w, y, x + w, y + r)
  context.lineTo(x + w, y + h - r)
  context.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
  context.lineTo(x + r, y + h)
  context.quadraticCurveTo(x, y + h, x, y + h - r)
  context.lineTo(x, y + r)
  context.quadraticCurveTo(x, y, x + r, y)
  context.closePath()
  context.clip()
  context.drawImage(image, x, y, w, h)
})

API の仕様にもよりますが、期待する画の実現のために相当量のコードを手続き的に記述する必要があり、実装コストが比較的高くなってしまいます。 とくに、Web ブラウザには当たり前のように存在する「文章の折返し」などのレイアウトに関する仕組みは自前で実装するとなるとかなり複雑な処理が必要です。

まとめると、汎用グラフィックスライブラリを使用する方法には以下のような課題があると言えます。

  • 注意深く技術選定を行わないと不要なライブラリへの依存が増えてしまい、余計にセキュリティリスクが高まる

  • HTML/CSS と比べ、多くの Web エンジニアにとって慣れない API を利用して描画するため、作りたい画像の複雑さに応じて実装コストが高くなる

satori の優れている所

satori というライブラリの最大の魅力についてですが、ラスター系の画像処理を一切行わない点にあると私は考えます。

画像処理というとピクセルの羅列情報(ラスターデータ)を処理するようなものを想像しがちですが、satori の入力である HTML/CSS と出力である SVG はいずれも構造化表現が成されたテキストデータであるため、この性質を利用した satori の実装ではラスターデータの処理が排除されています

satori で行われている処理の内訳は、簡単に説明すると以下のようになります。

  • HTML の構造とプロパティSVG の構造とプロパティに変換する。
  • 要素同士の位置関係情報(x,y などの座標プロパティ等)については、flexbox ベースのレイアウトシステムYogaを使って算出する。

例えば、1 辺が 100px の黒い正方形を描画したい時、HTML/CSS だと以下のように記述出来ます。

<div style="background-color: black; width: 100px; height: 100px;" />

そしてこの HTML 要素に相当する React 要素を satori で変換すると以下のようになります。

<rect xmlns="http://www.w3.org/2000/svg" x="0" y="0" width="100" height="100" fill="black"/>

図にするとこのようなイメージで、プロパティの対応関係がわかりやすく存在し、変換処理に複雑な計算を要しないことが理解いただけるかと思います。

このように、極めて簡素な方法を用いて HTML/CSS データを SVG データに変換することを実現しており、「画像の自動生成」という本来やりたかった事を、ヘッドレスブラウザを使うよりも軽量に汎用グラフィックスライブラリを使うよりもシンプルに実現することに成功したのが vercel/satori であると私は考えます。

実用する場合の tips

最後に、satori を実際に業務で使ってみたいと思った方向けに、実用に際して留意すべき点についてお話したいと思います。 satori はとてもシンプルなライブラリであるため、様々な使い方ができ、今から紹介することは使い方の一例でしか無いという点について予めご了承ください。

JSX コンパイラに依存せずに使いたい場合

前述の通り、satori は"React 風のデータオブジェクト"を与えることで JSX コンパイラに依存せずに使うことが可能です。ですが、要素の構造が複雑になった場合、そのデータオブジェクトを手書きで構築するのは、記述量が多くなってしまいかなり手間がかかります。

そこで、HTML テキストをパースし、"React 風のデータオブジェクト"に変換するような仕組みを用意することで、HTML を記述する形で satori を利用することが出来ます。このような処理を行う実装は、satori-htmlとして、すでに公開している方がいらっしゃいます。

satori-html を使う場合は、以下のように記述することが出来ます。

import satori from "satori";
import { html } from "satori-html";

const markup = html`<div style="background-color: black; color: white;">@anoChick</div>`;
const svg = await satori(markup, {
  width: 200,
  height: 200,
  fonts: [...],
});

HTML テキストを渡すだけで使えるため、必要に応じてmustacheなどのテンプレートシステムを利用することも可能です。

フォントファイルのサイズを小さくする

satori は、SVG 化処理を行う際に、要素にテキストが含まれている場合はフォントデータを与える必要があります。

特にユーザーのブラウザ上で satori を使った SVG 化処理を行う場合はフォントデータをダウンロードするなどして用意する必要があります。

しかし、日本語フォントはひらがな、カタカナ、漢字を含むため、ファイルサイズが数 MB〜十数 MB になることも珍しくありません。 例として Google Fonts から提供されるNotoSansJP-Regular.otf のファイルサイズは 4.5MB あります。

こういった通常のフォントファイルを、ブラウザ上で satori が動作する機能を使うユーザーに随時ダウンロードしてもらうとなると、必要以上に多くの帯域幅の利用に繋がるだけでなく、ダウンロード待機時間が多くかかる為にユーザー体験を損ねる可能性があります。

これを解決する技術として、「フォントのサブセット化」というものがあります。 フォントのサブセット化は、あるフォントファイルのうち、必要な文字のフォントデータだけを抽出して新しいフォントファイルを生成する処理の事です。

生成されたフォントファイルは必要な文字のフォントデータしか含まれていないため、オリジナルのフォントファイルと比べてファイルサイズが小さくなります。

日本語は特に文字の種類が豊富であるため、このサブセット化処理の恩恵を強く受けます。100 文字以下の文章を使ってサブセット化処理をすると、フォントの種類にもよりますがだいたい数十 KB 程度に収まり、約 100 分の 1 のファイルサイズ削減になります。

実際にブラウザ上で必要な文字に応じたサブセット化されたフォントデータを得るためには、次のような方法が考えられます。

1. Google Fonts のサブセット化機能を活用する

Google Fontsで提供される Web フォントは、サブセット化機能を持っています。

フルセットの Web フォント(Noto Sans JP)
https://fonts.googleapis.com/css2?family=Noto+Sans+JP

サブセット化処理をした Web フォント
https://fonts.googleapis.com/css2?family=Noto+Sans+JP&text=commmune_ueno

しかしこの Web フォントは css 形式で提供されており、フォントデータそのものを入力として期待する satori では使うことが出来ません。 そのため、CSS ファイルを読み、フォントデータだけを取得する処理が必要です。

satori の GitHub リポジトリ内にこれを実装した例がありとても参考になりました。 satori/playground/pages/api/font.ts at main · vercel/satori · GitHub

Google Fonts 以外の Web フォントについても、同様の処理を加えることで satori でも利用出来る形式に変換出来るはずです。 ただしこれは正規の Web フォントの利用方法とは呼びづらく、Web フォントプロバイダーの利用規約や利用するフォントのライセンスに違反する可能性があるため、実際にシステムの一機能として実装する際には十分確認の上で実装・利用することををお勧めします。

2.自前でサブセット化処理を行う

もうひとつの方法は、動的にサブセット化処理を行った上でフォントファイルを返す API を自前で用意することです。 例として、Next.js の API Routes 機能を使って実装すると、以下のようになります。

// api/font.tsx
import type { NextApiRequest, NextApiResponse } from "next";
import subsetFont from "subset-font";

const FONT_URL = "https://.../font.ttf";
const CONTENT_TYPE = "font/ttf";
const FONT_FORMAT = "truetype";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // 使用する文字列を与える
  const text = `${req.query.text}`;

  // CDNからフォントデータをダウンロードする(キャッシュさせる)
  const fontBufferArray = await fetch(FONT_URL).then((res) =>
    res.arrayBuffer()
  );

  //フォントデータをサブセット化する
  const subsetBuffer = await subsetFont(Buffer.from(fontBufferArray), text, {
    targetFormat: FONT_FORMAT,
  });

  res.setHeader("content-type", CONTENT_TYPE);
  res.status(200).send(subsetBuffer);
}

このような方法でフォントファイルを CDN やアプリケーションサーバーのローカルディレクトリ等に配置した上で、リクエストに応じてサブセット化処理を行うことが出来ます。

これでサーバーからユーザー端末へのデータ転送量は大幅に削減することが出来ます。 ただし、基本的には API へのリクエストがあるたびにサブセット化処理が実行されるので、API の使われ方に応じて適宜キャッシュするような仕組みを導入すると良いでしょう。

Edge Functions を活用する

CDN 上でスクリプトを動作させる Edge Functions と呼ばれる仕組みは、デプロイ出来るファイルサイズに厳しい制限が設けられていることが多いです。 Cloudflare Workers や Vercel Edge Functions などには、ファイルサイズが 1MB までという制限があります。

satori はファイルサイズがとても軽量であるため、これらの Edge Functions でも余裕を持って使うことが出来るのも satori の強みです。

しかし、フォントデータも併せてデプロイするのは難しいでしょう。 特に日本語フォントはそれだけでファイルサイズ上限を超えてしまう事がほとんどです。

そのため、fetch api などを使ってフォントデータを取得して使う必要が出てきます。 この時、前節で紹介したサブセット化処理を活用する事で処理を高速かつ無駄なデータ転送なく行う事が可能になるでしょう。

サブセット化機能のある Web フォントを変換して使う処理はとても軽量なので特に問題なく Edge Functions に含める事が出来ます。

ですが、自前でサブセット化処理を行う方式については、1MB のファイルサイズに収まるサブセット化ライブラリは見つからなかったため、独自に実装する必要がありそうです。

もしフォントファイルのサブセット化処理を超軽量のファイルサイズで行えるライブラリがあれば、任意のフォントを使って Edge Functions で高速に動作する最高の SVG 生成 API が作れるようになるはずなので、そういったライブラリをご存知の方がいらっしゃいましたら是非ご一報いただけると幸いです。

まとめ

というわけで今回は、私が昨年で一番感動を覚えた Vercel 社製の SVG 画像生成ライブラリ vercel/satori についての概要と魅力、そして実用に際する tips 等についてお話させていただきました。

個人的に vercel/satori は画像生成界隈に大きな転換を与えたと思っており、satori が少しでも多くの人に認知され、使用されると良いなと考えています。

最後に、コミューンは「企業とユーザーが融け合う社会実現する」を Vision に掲げ、これに強く共感して一緒に働くエンジニアを募集しています。

ここまで読んでくださった方はきっとソフトウェア開発に強い興味関心がある方かと存じますので、すこしでもコミューンに興味をもってくださった場合は、まず下記ページからカジュアル面談をお申込いただき、そこで会社の話や興味のある技術の話等ができれば思います。

forms.gle

また、2023年2月3日に「\CEOが話す/ コミューン会社説明会」も開催予定ですので、そちらの方もぜひお気軽にご参加ください。

commmune.connpass.com

長くなりましたが、ここまでのご拝読ありがとうございました。