Docker+ffmpegでWebアプリケーション上でBlobをエンコードする処理を実装する

## この記事は何
最近Next.js上でffmpegを用いたBlobのデータのエンコードを行う処理を実装しました。
実装の方法があまり調べても出てこなかったので記事としてまとめます。

## 実装の流れ
### Dockerの準備
まずはDockerで環境の準備を行います。
今回はNext.js上で実装をする想定なのでdocker-compose.ymlの設定はNext.jsの開発環境サーバーを立ち上げる前提にしています。
ポイントはDocker内で`ffmpeg`のインストールを行っているところです。
こちらでインストールした`ffmpeg`をこの後利用します。

```Dockerfile:Dockerfile
FROM node:[versionを指定]-alpine
WORKDIR /app

COPY . /app
RUN apk add --update --no-cache ffmpeg
RUN npm install
```

```docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    tty: true
    ports:
      - "3000:3000"
    command: npm run dev

    volumes:
      - type: bind
        source: .
        target: /app
      - type: volume
        source: node_modules
        target: /app/node_modules

volumes:
  node_modules:
```

### Node.js上でffmpegを利用するための準備
次に、ffmpegをNode.js上で利用するための準備を行います。
今回は[fluent\-ffmpeg/node\-fluent\-ffmpeg: A fluent API to FFMPEG \(http://www\.ffmpeg\.org\)](https://github.com/fluent-ffmpeg/node-fluent-ffmpeg) を用いて実装を行っていきます。

```sh:node-fluent-ffmpegのインストール
$ docker compose run --rm app npm install fluent-ffmpeg
$ docker compose run --rm app npm install -D @types/fluent-ffmpeg
```

### 処理の実装
最後に、受け取ったBlobデータのエンコード処理を実際に実装していきます。
まずはコードを紹介します。
今回は受け取った音声データを、mp3に変換して返す処理を実装します。


```transcodeToMp3.ts
import fs from "fs";
import { setTimeout } from "timers/promises";
import Ffmpeg from "fluent-ffmpeg";

export const transcodeToMp3 = async (args: { blob: Blob; name: string }) => {
  const inputFileName = `/tmp/${args.name}-input.${args.blob.type.split("/")[1]}`;
  const outputFileName = `/tmp/${args.name}-output.mp3`;

  fs.writeFileSync(inputFileName, Buffer.from(await args.blob.arrayBuffer()));

  let isCompleted = false;

  const deleteFiles = () => {
    if (fs.existsSync(inputFileName)) {
      fs.unlinkSync(inputFileName);
    }
    if (fs.existsSync(outputFileName)) {
      fs.unlinkSync(outputFileName);
    }
  }

  const command = Ffmpeg(inputFileName)
    .on("error", (err) => {
      deleteFiles();
    })
    .on("end", () => {
      isCompleted = true;
    })
    .save(outputFileName)

  for (let i = 0; i < 50; i++) {
    if (isCompleted) {
      break;
    }
    await setTimeout(100);
  }

  if (!isCompleted) {
    command.kill('SIGSTOP');
    deleteFiles();
    throw new Error("Failed to transcode");
  }

  const output = fs.readFileSync(outputFileName);

  deleteFiles();

  return new Blob([output], { type: "audio/mp3" });
};
```

今回の実装のポイントは以下のとおりです。

#### ポイント1: 一時ファイルを用いて変換処理を行う
ffmpegはBlobデータをそのまま変換させるようなことはできないため、
データを一度音声ファイルとして書き出してffmpegによる変換を行なっています。
一時ファイルが残り続けるのを避けるため、変換が完了した後に一時ファイルを削除する処理を実装しています。

https://github.com/fluent-ffmpeg/node-fluent-ffmpeg?tab=readme-ov-file#error-transcoding-error

のように、fluent-ffmpegではエラーをイベントとして受け取れるので、エラーが起こった際もファイルの削除を行うようにしています。
何かロガーなどがあればここでエラーメッセージを送信するのもありだと思います。

#### ポイント2: 変換処理の完了を`setTimeout`を用いてポーリングして確認する
fluent-ffmpegはffmpegの操作は行えるものの、処理の状態はPromiseなどで管理されているわけではないので、完了待ちをするのにピーキーな実装が必要になりました。

今回は`isCompleted`という変数を定義し、

https://github.com/fluent-ffmpeg/node-fluent-ffmpeg?tab=readme-ov-file#end-processing-finished

のように処理の完了のイベントハンドラを用いて`isCompleted`を`true`に変換するという処理を実装しました。

そしてfor文と`"timers/promises"`の`setTimeout`を用いて、
0.1秒ごとに`isCompleted`のステータスを確認し、trueになったタイミングで次の処理で移動するような実装を行いました。
なお、今回は最大でも5秒までしか処理を待たないようにしたかったため、for文で50回ポーリング処理を行い、それでも完了していない場合はffmpegを止めエラーを投げるようにしました。

### 処理を組み込む
あとはバックエンド処理として今回実装したメソッドを組み込めば実装は完了です。
Next.jsであればサーバーサイドの処理（App RouterであればServer ComponentsやServer Actions）に組み込めば動作します。