🖼️

Satoriによる快適&ハイパフォーマンスな画像生成!

2023/04/26に公開

こんにちは!テラーノベルでiOS/Android/Webとフロントエンド周りを担当している @kazutoyoです!

先日テラーノベルの新しい機能として、作品のカバー画像を生成する機能をリリースしました! 🚀
https://teller.jp/s/33vd25jp04vhj-8319830982

【新機能】タイトルを入れると自動でカバー画像が作られるようになったよ🎉

こちらの機能はVercelが公開しているvercel/satoriを利用し、動的に画像を生成しています。

Satoriは、HTML/CSSを使用してSVGを出力することができるライブラリです。
Vercelは以前、PuppeteerのようなHeadless Browserを使ってOG Imageを生成するvercel/og-imageを開発していましたが、パフォーマンスやその他の問題点があったため、今回Satoriを開発しました。
https://vercel.com/blog/introducing-vercel-og-image-generation-fast-dynamic-social-card-images

テラーノベルでは、以前からカバー画像生成機能の提供を検討し始めました。
当初のプロトタイプでは、opentype.jsを利用してフォントをSVGに変換し、SVGでレイアウトして画像を生成していました。
しかし、複雑な表示の構成が難しく、レイアウトもやりづらいと感じていました。
また、og-imageのようにHTML+CSSで構成されたページからPuppeteerを使ってスクリーンショットを取得する方法も検討しましたが、パフォーマンス面で問題があると考えていました。

Satoriはこれらの問題点を解決できると判断し、今回利用することにしました。

Satoriで出来ること

Satoriで行えることを確認するには、Vercelが公開しているPlaygroundを試してみてください。
https://og-playground.vercel.app/

左側のペインにはHTMLとCSSで構成されたレイアウトが表示され、右側のペインでプレビューが可能です。Tailwindを使ったCSS、EmojiのProviderの種類選択、多言語対応などの例が紹介されています。

JSX Support

SatoriはJSXをサポートしているため、Reactに慣れている方にとっては普段のWebページを作成するのと同じようにレイアウトをすることができます。
(またJSXトランスパイラを有効にしないときは、React-elements-like objectによるレイアウトも可能です。)

<img> のサポート

<img> 要素も対応しています。
しかし src で指定されているURLはSatoriのレンダリング時に動的に取得されるため、静的な画像であればBase64でインラインで埋め込んでしまう方が良いでしょう。

https://github.com/vercel/satori#images

CSSの対応

Satoriは内部的にReact Nativeなどで利用されているFlexbox実装のYoga LayoutのWASM版を利用しています。
CSSのサブセットとなるため、すべてのCSSの機能をサポートしているわけではありません。

詳細なAPIの対応状況はこちらをご確認ください。
https://github.com/vercel/satori#css

フォント

Satoriは内部的にOpenType.jsを利用しており、OpenType.jsがサポートしているTTF/OTF/WOFFを利用できます。
satoriの fonts オプションでフォントデータを設定し、 fontFamily で指定したフォントを利用することが出来ます。

await satori(
  <div style={{ fontFamily: 'Inter' }}>Hello</div>,
  {
    width: 600,
    height: 400,
    fonts: [
      {
        name: 'Inter',
        data: inter,
        weight: 400,
        style: 'normal',
      },
      {
        name: 'Inter',
        data: interBold,
        weight: 700,
        style: 'normal',
      },
    ],
  }
)

https://github.com/vercel/satori#fonts

Emojiの対応

SatoriでEmojiに対応する方法として graphemeImages で対応する方法と loadAdditionalAsset で対応する方法のがあります。

graphemeImages

こちらはシンプルな方法で、対応する文字のときに指定した画像のURLを返すといった対応です。

await satori(
  <div>Next.js is 🤯!</div>,
  {
    ...,
    graphemeImages: {
      '🤯': 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/1f92f.svg',
    },
  }
)

ただし、絵文字に対して1つ1つにこちらのように対応させるのは難しいため、次の loadAdditionalAsset の方法で対応したほうが良いでしょう。

loadAdditionalAsset

loadAdditionalAsset では、文字ごとにその文字の言語を判定し動的なアセットを読み込む処理が可能です。
Satoriのドキュメントにある以下の例では、 👋 の文字は code="emoji" と判定され、そのときに data:image/svg+xml;base64,... といったSVGの画像データを返します。

await satori(
  <div>👋 你好</div>,
  {
    // `code` will be the detected language code, `emoji` if it's an Emoji, or `unknown` if not able to tell.
    // `segment` will be the content to render.
    loadAdditionalAsset: async (code: string, segment: string) => {
      if (code === 'emoji') {
        // if segment is an emoji
        return `data:image/svg+xml;base64,...`
      }

      // if segment is normal text
      return loadFontFromSystem(code)
    }
  }
)

上記の例では実際の処理が省略されているため、実際にEmojiを表示するにはSatoriのリポジトリにあるPlaygroundが参考になります。
コードとしては長いので抜粋しますが index.tsx loadDynamicAsset でemojiのときに loadEmoji 関数を呼び出します。
https://github.com/vercel/satori/blob/main/playground/pages/index.tsx#L98-L105

utils/twemoji.tsloadEmoji 関数では文字のコードをTwemojiやFluent EmojiなどjsdelivrにホストされているSVG画像のURLを読み込んで返します。
https://github.com/vercel/satori/blob/main/playground/utils/twemoji.ts#L34-L69

テラーノベルではUnicode 15.0のEmojiまで対応しているNoto Color EmojiのSVGファイルをアプリケーション内に配置し、そちらを読み込むようにしています。

顔文字の対応 😃

顔文字は特殊な文字を使っているケースがあり、 fonts オプションで指定しているような一般的なフォントでは対応していないケースがあります。
例えば次のような顔文字の目の部分など、Noto Sans CJKでも対応できません。

このようなときEmojiの対応と同様ですが、 loadDynamicAsset で対応している言語のフォントを動的に読み込みます。

こちらもPlaygroundのコードが参考になります。
主な処理は以下の通りで、文字の言語がSatori内で対応しているもの[1]に関してはGoogle Fontsから動的にフォントを読み込みフォントデータを返します。
https://github.com/vercel/satori/blob/main/playground/pages/index.tsx#L107-L163
https://github.com/vercel/satori/blob/main/playground/pages/api/font.ts

これにより、フォントのフォールバックが行われて特殊な文字にも対応することが出来ます。

OpenType.jsのフォントがパースできないとき

loadAdditionalAssetでGoogle Fontからフォントを動的に読み込んだとき、一部のフォントがOpenType.jsでパースが出来ない文字が発生しました。

こちらはフォントをGoogle Font APIで読み込んだが、何らかのエラー(自分の環境では400などが発生していました)が起きており、PlaygroundのコードではそのエラーページのArrayBufferとして返しているため、OpenType.jsがFontDataではない不正なデータとしてエラーとなっていました。

コードを以下のように修正します。
https://github.com/vercel/satori/blob/main/playground/pages/api/font.ts#L108-L110

  const res = await fetch(resource[1])
  
+ if (!res.ok) return null;

  return res.arrayBuffer()

このように修正することでTofu表示になってしまいましたが、レンダリングエラーで生成ができないことは回避することが出来ました。

まとめ

以上、Satoriの機能と実際の使用感についてのお話でした。
絵文字や顔文字の対応は少々手間がかかりましたが、レイアウトのしやすさやパフォーマンスの向上など、画像生成でSatoriを用いたことは良かったと感じています。

テラーノベルのカバー画像生成機能はユーザーから高い評価を受けており、今後はさらにリッチな画像生成が可能になるよう改善を進めていきたいと思っています!

脚注
  1. 現在サポートされているのは以下に指定されているcode
    https://github.com/vercel/satori/blob/main/src/language.ts#L25-L46 ↩︎

テラーノベル テックブログ

Discussion