Astro で satori を使う際、ローカル画像を読み込むのに苦労した

## この記事の概要

[Astro](https://astro.build/) と [satori](https://github.com/vercel/satori) を組み合わせて動的な OGP 画像を生成しようとしていました。

用意しておいた画像と組み合わせて OGP を生成しようとしたら苦戦したので、起きたことや解決策を備忘録として記事にします。

## 前提

### 環境

主要なものだけを記載します。

| 依存関係 | バージョン |
| --- | --- |
| astro | 4.9.2 |
| @astrojs/react | 3.4.0 |
| react | 18.3.1 |
| satori | 0.10.13 |
| sharp | 0.33.4 |

### やろうとしていたこと

内容自体は簡単で、次の通りです。

1. 背景に画像を敷く
    1. S3 などにアップされている画像ではなく、リポジトリ内に存在する画像
    1. background-image か、img 要素かなどは問わない
1. その上に記事タイトルを配置する
1. ビルド時に書き出して配信する

### 参考にしていた記事

https://blog.70-10.net/posts/satori-og-image/

1 番最初はこの記事を参考にして、文字を描画する段階までは進めました。

ただ、ローカル画像を使おうとしたら上手くいかなくなってしまいました。

## 解決策

最終的には次のようにして解消しました。

1. OGP に使う元となる画像を`public`ディレクトリに保存する
1. 画像を`base64`形式で読み込む
1. `satori`内の`img`の`src`に`base64`形式で読み込んだ画像を指定する

コードの全体像はこのようになっています。
（この記事に関係ある部分だけを抜き出しています）

```tsx
import { readFileSync } from "node:fs";
import satori from "satori";
import sharp from "sharp";

async function getFontData(text: string) {
  // フォントデータ取得のための処理
}

const imgBase64 = readFileSync(
  new URL("../../public/og-image-background.png", import.meta.url),
  { encoding: "base64" },
);
const imgDataUrl = `data:image/png;base64,${imgBase64}`;

export async function getOgImage(text: string) {
  const fontData = (await getFontData(text)) as ArrayBuffer;
  const svg = await satori(
    <div style={{ ... }}>
      <img style={{ ... }} src={imgDataUrl} />
      <div style={{ ... }}>{text}</div>
    </div>,
    {
      width: 1200,
      height: 630,
      fonts: [{ ... }],
    },
  );

  return await sharp(Buffer.from(svg)).png().toBuffer();
}
```

## ダメだったこと

### 通常の`import`で`img`の`src`を指定する

最初はこのように書いてみました。

```tsx
import imgSrc from "../assets/og-image-background.png";

...

export async function getOgImage(text: string) {
  const fontData = (await getFontData(text)) as ArrayBuffer;
  const svg = await satori(
    <div style={{ ... }}>
      <img style={{ ... }} src={imgSrc} />
      <div style={{ ... }}>{text}</div>
    </div>,
    // 中略
  );

  return await sharp(Buffer.from(svg)).png().toBuffer();
}
```

すると、次のようなエラーメッセージが出ました。

```sh
Unsupported image type: unknown
```

`.astro`以外のファイルで画像を指定するときは、書き方を変える必要がありました。[^import-other-assets]

[^import-other-assets]: https://docs.astro.build/en/guides/imports/#other-assets

```diff_tsx
  import imgSrc from "../assets/og-image-background.png";

  ...

  export async function getOgImage(text: string) {
    const fontData = (await getFontData(text)) as ArrayBuffer;
    const svg = await satori(
      <div style={{ ... }}>
-       <img style={{ ... }} src={imgSrc} />
+       <img style={{ ... }} src={imgSrc.src} />
        <div style={{ ... }}>{text}</div>
      </div>,
      // 中略
    );

    return await sharp(Buffer.from(svg)).png().toBuffer();
  }
```

しかしこれでもエラーが出ます。

```sh
Image source must be an absolute URL: ~~~
```

相対パス、絶対パスの両方を試しましたがダメでした。

楽に解決しようと外部にアップした画像の URL を指定することなのですが、色々と自分のやりたいこととの兼ね合いで、ローカル画像でも上手くいく方法を模索します。

### `base64`で画像を読み込む

satori のドキュメントに「生成されたSVGをPNGなどの他の画像フォーマットにレンダリングしたい場合は、props.srcとして直接base64エンコードされた画像データ(またはバッファ)を使用した方が、Satoriでの余分なI/Oが不要になります。」といった記載があるのを見つけました。[^satori-documentation-html-elements-images]

[^satori-documentation-html-elements-images]: https://github.com/vercel/satori?tab=readme-ov-file#images

余分とかでなくそもそも生成できないのですが、base64 でのアプローチに価値はありそうなので試してみました。

```diff_tsx
- import imgSrc from "../assets/og-image-background.png";

  ...

+ const imgBase64 = readFileSync(
+   new URL("../assets/og-image-background.png", import.meta.url),
+   { encoding: "base64" },
+ );
+ const imgDataUrl = `data:image/png;base64,${imgBase64}`;

  export async function getOgImage(text: string) {
    const fontData = (await getFontData(text)) as ArrayBuffer;
    const svg = await satori(
      <div style={{ ... }}>
-       <img style={{ ... }} src={imgSrc.src} />
+       <img style={{ ... }} src={imgDataUrl} />
        <div style={{ ... }}>{text}</div>
      </div>,
      // 中略
    );

    return await sharp(Buffer.from(svg)).png().toBuffer();
  }
```

これにより、開発環境での問題はクリアしました。
記事ごとにちゃんとテキストの違う画像が生成できました。

ただし、ビルドするとエラーが出ました。

```sh
ENOENT: no such file or directory, open '/PATH/TO/REPOSITORY/dist/assets/og-image-background.png'
```

`dist/`はビルド生成物が格納されるディレクトリです。

そして、Astro はデフォルトでは画像ファイルを`dist/_astro`に格納します。[^astro-configuration-build-options-assets]

[^astro-configuration-build-options-assets]: https://docs.astro.build/en/reference/configuration-reference/#buildassets

そのため`dist/assets`などというディレクトリは存在せず、エラーになってしまいました。

ここで`astro.config.mjs`の設定を変えてみました。
この設定により`dist/_astro`は`dist/assets`という名前になります。[^astro-configuration-build-options-assets][^dist-assetsDir-name]

[^dist-assetsDir-name]: 自動で生成されるディレクトリの名前を不用意に（しかも、何かしらと衝突しそうな`assets`なんて名前に）変えるなんてやめた方が良いとは思いますが、一旦試さずにはいられませんでした。

```diff_javascript
  export default defineConfig({
    ...
+   build: {
+     assets: "assets",
+   },
  });
```

ところが、これでもファイルは見つかりません。
画像が書き出される際、ファイル名にハッシュ値がついて`og-image-background.ハッシュ値.png`という名前になり、見つけられなかったのです。

### `public/`に画像を置く

実現したいのは、ビルド時にディレクトリ名やファイル名が変わらない状態です。

となると`src/`以下ではなく`public/`におけば良いのでは？と思い試してみました。

```diff_tsx
  ...

  const imgBase64 = readFileSync(
-   new URL("../assets/og-image-background.png", import.meta.url),
+   new URL("../../public/og-image-background.png", import.meta.url),
    { encoding: "base64" },
  );
  const imgDataUrl = `data:image/png;base64,${imgBase64}`;

  export async function getOgImage(text: string) {
    const fontData = (await getFontData(text)) as ArrayBuffer;
    const svg = await satori(
      <div style={{ ... }}>
        <img style={{ ... }} src={imgDataUrl} />
        <div style={{ ... }}>{text}</div>
      </div>,
      // 中略
    );

    return await sharp(Buffer.from(svg)).png().toBuffer();
  }
```

こちらで解消し、冒頭に載せたコードと同じになりました。

## まとめ

実現したいことの割に悩みましたが、無事解消して良かったです。

それなりにやりたい人が多そうな内容だと思っているのですが、X でも Stack Overflow でも個人ブログでも、実例を全然見つけられませんでした。

ややニッチですが、どなたかのお役に立てれば幸いです。