くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

unjs/nitro+satori+sharpで動的OGP画像を自動生成する

Nuxtだと、Nuxt SEO Kitnuxt-og-imageを使えば、
vueコンポーネントとかHMLTをOG画像にできるけど、 unjs/nitroでもできないかなと思い、
いろいろ調べてみたときの備忘録(*´ω`*)

nuxt-og-imageの中をのぞいてみたら、
satoriとsharpを使ってたので、それを参考に実装してみた

satori+sharpを使った画像生成

この3つのライブラリを使うと、HTMLをPNGに変換できるっぽい

satoriはVercel社が出しているライブラリで、JSX形式で利用するっぽい。
なので、HTMLといってもReactNodeを受け取る形になっている。

jsx形式なら、satoriとsharpでこんな感じに画像を生成できる。

// api.jsx
import satori from 'satori'

// satoriでReactNodeをSVGに変換
const svg = await satori(
  <div style={{ color: 'black' }}>hello, world</div>,
  { width: 600, height: 400, fonts: [] },
);

// sharpでSVGからPNGに変換
const png = await sharp(Buffer.from(svg)).png().toBuffer();

そのままだと使えないので、natemoo-re/satori-htmlを使う。
HTMLな文字列をVNodeに変換し、satriのインプットにできるように変換してくれるライブラリ。

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

 // satori-htmlで文字列をVNodeに変換
const vnode = html(`<div style="color: black;">hello, world</div>`);

// satoriでVNodeをSVGに変換
const svg = await satori(
  vnode,
  { width: 600, height: 400, fonts: [] },
);

// sharpでSVGからPNGに変換
const png = await sharp(Buffer.from(svg)).png().toBuffer();

これでHTML文字列からPNGを生成できちゃう。すごい(*´ω`*)

satoriもすべてのCSSを扱えるわけではないため、
サポート状況や制限事項などはドキュメントを参照する

satoriで画像を扱う

画像については、base64形式に変換して、埋め込めばOK。
URLであれば、src="https://...."とかでもいける。

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

// 画像ファイルの読み込み
const imageBuffer = await fs.readFileSync("<image_path>");
// 画像ファイルをbase64形式にエンコード
const imageBase64 = Buffer.from(imageBuffer).toString("base64");
const imageData = `data:image/png;base64,${imageBase64}`;

const vnode = html(`
  <div>
    <img src="${imageData}" width="1200" height="630"/>
  </div>
`);

satoriでfontを扱う

フォントを変更したい場合は、satoriのオプションに、
フォントファイルのデータを渡せばOK

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

// fontデータの読み取り
const fontData = await fs.readFileSync("./NotoSansJP-Bold.ttf");

 // satori-htmlで文字列をVNodeに変換
const vnode = html(`<div style="color: black;">hello, world</div>`);

// satoriでVNodeをSVGに変換
const svg = await satori(
  vnode,
  { width: 1200, height: 630, 
    // フォントの設定
    fonts: [
      { name: 'Noto Sans JP',
        data: fontData,
        style: 'normal',
      },
    ],
    // 埋め込みフォントの有効化(デフォルトはfalse)
    embedFont: true,
  },
);

nitroで画像生成できるようにする

ディレクトリ構成はこんな感じ。

server/
  assets/
    NotoSansJP-Bold.ttf.b64
    og_template.png.b64
  routes/
    index.ts
  nitro.config.ts
  package.json

実際にOGP画像を生成する
routes/index.tsはこんな感じ。

// routes/index.ts
import satori from "satori";
import { html } from "satori-html";
import sharp from "sharp";

export default defineEventHandler(async (event) => {
  const { text } = getQuery(event);
  
  // fontデータの読み取り
  const fontDataB64 = await useStorage().getItem<String>("assets/server/NotoSansJP-Bold.ttf.b64");
  const fontData = Buffer.from(fontDataB64, "base64");

  // 背景画像の読み込み
  const imageBase64 = await useStorage().getItem<String>("assets/server/og_template.png.b64");
  // 画像ファイルをbase64形式にエンコード
  const imageData = `data:image/png;base64,${imageBase64}`;

   // satori-htmlで文字列をVNodeに変換
  const vnode = html(`
    <div style="width: 1200px; height: 630px; position: relative; display: flex; justify-content: center; align-items: center;">
      <img style="position: absolute; z-index: -1;" src="${imageData}"></img>
      <div style="font-size: 68px; text-align: center;">${ text || "Hello World"}</div>
    </div>
`);

  // satoriでVNodeをSVGに変換
  const svg = await satori(
    vnode,
    { width: 1200, height: 630, 
      // フォントの設定
      fonts: [
        { name: 'Noto Sans JP', data: fontData, style: 'normal', },
      ],
      // 埋め込みフォントの有効化(デフォルトはfalse)
      embedFont: true,
    },
  );
  
  // sharpでSVGからPNGに変換
  const png = await sharp(Buffer.from(svg)).png().toBuffer();
  
  // ヘッダーの設定
  setHeader(event, "Content-Type", `image/png`);
  setHeader(event, "Cache-Control", "public, max-age=604800");
  return ogImage;
});

unstorageの扱いがちょっとムズイ

基本は今までのを組み合わせた形で、
fsのかわりにunjs/unstorageをつかう。

unstorageをちゃんと理解できていない部分もあるけど、

const fontData = await useStorage().getItemRaw<ArrayBuffer>("assets/server/NotoSansJP-Bold.ttf");

のように、getItemRaw()を使うと、
npm run devのときは問題ないが、npm run buildしたあとに、うまく動かなかった。。

nitroはbuild時に.output/にすべてを配置するようになっているため、
assetsなどのファイルも.output/配下にまとめられるが、 output/server/chunks/raw/og_template.mjsを見てみると、

// .output/server/chunks/raw/og_template.mjs
// ROLLUP_NO_REPLACE 
 const og_template = "�PNG\r\n\u001a\n\u0000\u0000\u0000\rIHD

という感じの文字列になっていて、うまくArrayBufferとして読み取ることができなかった。。

なので、あらかじめ、Base64形式にエンコードした.b64ファイルを用意しておき、
それを利用するようにしている。

Base64化は、コマンドで実行した。

$ base64 -i og_template.png -o og_template.png.b64

getItems()の指定がちょっとムズイ

また、getItems()で指定するassets/server/もすこしわかりずらく、
Issueがあがっているほど。

assets/ディレクトリ配下のファイルにserver側の処理でアクセスする場合は、
getItems(assets/server/<filename>)という感じになる。

このガイドのあたりに書いてあるとおり、
nitro.config.tsにアセットディレクトリを追加できるが、

// nitro.config.ts
export default defineNitroConfig({
  serverAssets: [{
    baseName: 'templates',
    dir: './templates' // Relative to `srcDir` (`server/` for nuxt)
  }]
})

その場合は、こんな感じになるよう。

// routes/success.ts
export default defineEventHandler(async (event) => {
  return await useStorage().getItem(`assets/templates/success.html`)
  // or
  return await useStorage('assets:templates').getItem(`success.html`)
})

以上!! いろいろハマったけど、nitroでもOGPを生成できるように(*´ω`*)
こう見てみると、いろいろよしなにやってくれるnuxt-og-imageは偉大だ。。(*´ω`*)

参考にしたサイトさま