S3上のMP4ファイルを切り抜いて高速にレスポンスするWeb APIを作る

この記事は[レコチョク Advent Calendar 2022](https://qiita.com/advent-calendar/2022/recochoku)の13日目の記事となります。

# 前段
本記事では**ストレージサービス上のメディアファイルをオンデマンドで加工する方法**に焦点を当てます。
例えば、既存の動画から一部だけを切り抜いた所謂"切り抜き動画"をユーザーの入力に応じて生成する、といった機能の実装に役立ちます。

その中でも**Amazon S3上のMP4ファイルの冒頭n秒を切り抜き、それを高速に返す**具体的な方法を紹介します。
※ MP4ファイル(動画ファイル)とM4Aファイル(音楽ファイル)は内部構造が共通しているため、M4Aファイルにも全く同じ方法を適用することができます。

本記事の最後には、切り抜いたMP4ファイルを返すWeb APIを実装する方法を紹介します。

また、MP4ファイルの加工には、広く使われている[FFmpeg](https://ffmpeg.org/)を使用することとします。

# 課題
FFmpegを用いて「S3上のMP4ファイルの冒頭n秒を切り抜き、それをレスポンスとして返す」処理を逐次的に行うと、**レスポンス速度に問題が生じます**。

具体的には、下記のような方法となります。
1. S3から加工元のMP4ファイルをダウンロードする
2. **ダウンロード完了まで待機**
3. ダウンロードしたファイルをFFmpegによって加工する
4. **加工が終了するまで待機**
5. 加工したファイルをレスポンスへ流す

FFmpegは通常、加工元のファイルが存在している状態でなければ、処理を始めることはできません。
また、通常、加工が終了し、ファイルが生成されるまで別のプロセスが加工後のファイルに関与することができません(すなわち同期的に処理が行われる)。
このため、上記手順の2.と4.のように待機する時間が必要となります。

この方法をUnixコマンドを使って簡単に実践してみて、時間を測ってみます。
約868MBのMP4ファイルをダウンロードし、FFmpegで冒頭30秒を切り出すとします。
これを実現するには下記ようになり、このケースでは55秒くらいの時間がかかってしまいました。
冒頭30秒分のデータが欲しいだけなのに、全体のデータをダウンロードするまで待たなければならないためです。
```
$ time curl -s --output out.m4a https://ffmpeg-test.s3.ap-northeast-1.amazonaws.com/868p8MB.mp4

real	0m54.874s
user	0m13.745s
sys	    0m5.630s
```

したがって、このFFmpegによるボトルネックを解消する必要があります。

# 解決法(概略)
FFmpegの`pipe`オプションを用いて、パイプを使って加工元ファイルをFFmpegへ流し、逐一加工を行なってもらいます。
Unixコマンドで行なうと以下の通りとなります。
```
$ curl -s --output - {加工対象のファイルのURI} | ffmpeg -y -i pipe:0 -c copy -ss 0s -t {秒数}s -f mp4 -movflags frag_keyframe pipe:1
```

※ ただし、加工対象のファイルのmoov Boxの方がmdat Boxより先に配置されている必要があるという条件付きです(詳細は後述)。

# 解説
## 概要
ダウンロードされたデータを逐一標準出力に書き出します(`curl --output -`)。
FFmpeg側では標準入力から加工対象のデータを受け取るようにします(`ffmpeg -i pipe:0`)。
これにより、ダウンロードしながら加工を進行させることができます。
加工が終わった部分だけ逐一吐き出すように出力先を標準出力にします(`pipe:1`)。
これにより、加工が進行中の際に標準出力を読み取ることで、加工とレスポンスの同時進行が可能です。

以上のことから、**データのダウンロード、FFmpegによる加工、レスポンスを全て同時に進行することが可能**です。

コマンドの細かいオプションについては本質とはあまり関係ないので説明しません(調べれば出てくる内容かと思います)。

## 効果
実際にどのくらい時間が短縮されるか見てみます。
先ほどと同じく、868MBのMP4ファイルをダウンロードし、FFmpegで冒頭30秒を切り出してみます。
そうすると3秒で処理が完了します。

```
$ time curl -s --output - https://ffmpeg-test.s3.ap-northeast-1.amazonaws.com/868p8MB.m4a | ffmpeg -y -i pipe:0 -c copy -ss 0s -t 30s -f mp4 -movflags frag_keyframe pipe:1 > out.m4a

real	0m2.403s
user	0m0.703s
sys	    0m0.195s
```

## 条件
この方法を適用するためには条件が存在します。対象のMP4ファイル内部の構造を見た時に**moov Boxの方がmdat Boxよりも先に配置されている**ことです。

## Boxについて
### 概要
MP4における**Box**というのは、**MP4ファイルを構成する要素**のことを指します。
Boxには色々と種類がありますが、本記事において重要なのは、実際の音声・動画データを含む**mdat Box**、音声・動画データのメタデータを含む**moov Box**です。

**Boxは置換可能です**。したがってmoov Box → mdat Boxの順に並んでいることもあればmdat Box → moov Boxの順に並んでいることもあります。

moov Boxが先に来ていなければ本記事の方法を使うことができないのは、そうでなければ途中までダウンロードしたデータにメタデータが含まれておらず、mdat Boxのデータを読み取ることができないためと思われます。

### Boxの並び順の調べ方
moov Boxが先に来ているかどうか、といった並び順は、バイナリエディタで対象のファイルを開くと分かります。
(例: `$ cat {対象のファイル} | xxd | less`)

基本的にBOXは、
[BOXの大きさ(4バイト)] → [BOXの名前(4バイト)] → [データ] もしくは、
[BOXの大きさ(4バイト)] → [BOXの名前(4バイト)] → [他のBOX]の構造を持ちます。
(BOXは入れ子構造にすることが出来るため、BOXの中にBOXを配置することができます)

![box.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2960758/f30924d8-ff26-6a7f-a7b5-c61345d1d5a8.jpeg)

したがって、BOXの名前である"moov"(0x6d6f6f76)と"mdat"(0x6d646174)の位置が知ることができれば、
どちらのBOXが先に来ているか調べることが可能です。

moovが先に来ている場合
```
00000000: 0000 0020 6674 7970 4d34 4120 0000 0200  ... ftypM4A ....
00000010: 4d34 4120 6973 6f36 6973 6f6d 6973 6f32  M4A iso6isomiso2
00000020: 0000 02bb 6d6f 6f76 0000 006c 6d76 6864  ....moov...lmvhd ← moovが先に来ている
00000030: 0000 0000 0000 0000 0000 0000 0000 03e8  ................
00000040: 0000 0000 0001 0000 0100 0000 0000 0000  ................
00000050: 0000 0000 0001 0000 0000 0000 0000 0000  ................
```

mdatが先に来ている場合
```
00000000: 0000 001c 6674 7970 6973 6f6d 0000 0200  ....ftypisom....
00000010: 6973 6f6d 6973 6f32 6d70 3431 0000 0008  isomiso2mp41....
00000020: 6672 6565 3418 117f 6d64 6174 de02 004c  free4...mdat...L ← mdatが先に来ている
00000030: 6176 6335 392e 3138 2e31 3030 0042 2008  avc59.18.100.B .
00000040: c118 3821 1004 608c 1c21 1004 608c 1c21  ..8!..`..!..`..!
00000050: 1004 608c 1c21 1004 608c 1c21 1004 608c  ..`..!..`..!..`.
```

### moov Boxを先に持ってくる方法
FFmpegで実現できます。`faststart`オプションを付加します。
```
$ ffmpeg -y -i {加工前のファイルパス} -c copy -f mp4 +faststart {加工後のファイルパス}
```

こうすればmdat Boxが前にに来ているMP4ファイルにも本記事の方法を適用することができますが...
そもそもmoov Boxを前に持ってくるための本コマンドを実行するには、事前にデータ全体を取得していなければならないという事実には注意です。
したがって、対象のファイル全てのmoov Boxを前に持ってくる処理を事前にやっておく、といった処置が必要となります。

# 実際にS3にあるMP4ファイルを加工して返すWeb APIを実装する
上記のFFmpegのテクニックを用いて、S3に置いているファイルの冒頭30秒を切り抜き、それをレスポンスとして高速に返すWeb APIを実装します。

# 方法
## 概要
下記4つの処理を並列(非同期)に行います。
- S3から加工前のデータをダウンロード
- 加工前のデータをFFmpegへ流し込む
- 加工後のデータをFFmpegから出力する
- 出力された加工後のデータをレスポンスへ流す

このようにすることで、ダウンロードや加工を待たずにレスポンスを開始することが可能になります。
加えて、全てデータを`Stream`で扱うため、メモリを圧迫することもありません。

## 実装
下記のように実装します。
言語はJavaで、フレームワークはSpring Bootを使用します。
本筋とは関係がないため、例外処理は適当に実装しています。

`curl --output data.m4a -X GET "http://xxx/data/edit?key_name=123455678.m4a"`といったリクエストでデータを取得することができます。

```java
@RestController
public class DataController {
    /**
     * S3のオブジェクトを取得可能なInputStreamを取得する
     * (実装は掲載しない。)
     */
    @Autowired
    private S3Downloader s3Downloader;

    /**
     * データをダウンロードしながら加工し、レスポンスへ流す
     *
     * @param keyName  対象のオブジェクトのS3上のパス
     * @param response HttpServletResponse
     */
    @GetMapping(path = "data/edit")
    public void fetchData(
            @RequestParam("key_name") String keyName,
            HttpServletResponse response
    ) {
        // ヘッダーを設定する
        response.setHeader(HttpHeaders.CONTENT_TYPE, "audio/mp4");
        response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "aaa.m4a");

        // データをダウンロードしながら加工し、レスポンスへ流す
        edit(keyName, response);
    }

    /**
     * データをダウンロードしながら加工し、レスポンスへ流す
     *
     * @param keyName  対象のオブジェクトのS3上のパス
     * @param response HttpServletResponse
     */
    private void edit(String keyName, HttpServletResponse response) {
        List<String> command =
                List.of("ffmpeg",
                        "-i", "pipe:0", // 入力は標準入力から受け取る
                        "-c", "copy", // 再エンコードしない
                        "-ss", "0", // 開始時間
                        "-t", "30", // 長さ
                        "-f", "mp4", // 加工前のデータのフォーマット
                        "-movflags", "frag_keyframe+faststart", // フラグメント化する & moov boxをmdat boxより前に持ってくる
                        "pipe:1" // 出力先
                );

        // プロセス(FFmpeg)を開始
        Process process;
        try {
            process = new ProcessBuilder(command)
                    .start();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }

        try (
                // 加工前のデータのInputStream
                InputStream originalDataIs = s3Downloader.fetchDataStream(keyName);
                // プロセス(FFmpeg)の標準入力。ここに加工前のデータを入力する
                OutputStream ffmpegOs = process.getOutputStream();
                // プロセス(FFmpeg)の標準出力。ここから加工後のデータを取得する
                InputStream ffmpegIs = process.getInputStream();
                // レスポンス
                OutputStream responseOs = response.getOutputStream();
        ) {
            // 加工前のデータをプロセス(FFfmpeg)へ流し込む(非同期)
            AsyncTransferer transferer = new AsyncTransferer(originalDataIs, ffmpegOs);
            transferer.start();
            // プロセス(FFmpeg)から出力されたデータをレスポンスへ流し込む(非同期)
            AsyncTransferer transferer2 = new AsyncTransferer(ffmpegIs, responseOs);
            transferer2.start();
            // プロセスが終了するまで待つ
            process.waitFor();
            transferer.join();
            transferer2.join();

        } catch (Exception e) {
            throw new RuntimeException(e);
        }

        if (process.exitValue() != 0) {
            // FFmpegの処理が失敗した場合
            throw new RuntimeException();
        }
    }

    /**
     * 非同期にInputStreamからOutputStreamへデータを流す
     */
    private class AsyncTransferer extends Thread {
        InputStream inputStream;
        OutputStream outputStream;

        /**
         * コンストラクタ
         *
         * @param inputStream  InputStream
         * @param outputStream OutputStream
         */
        public AsyncTransferer(InputStream inputStream, OutputStream outputStream) {
            this.inputStream = inputStream;
            this.outputStream = outputStream;
        }

        /**
         * InputStreamからOutputStreamへデータを流す
         */
        @Override
        public void run() {
            try (
                    InputStream is = this.inputStream;
                    OutputStream os = this.outputStream;
            ) {
                is.transferTo(os);
            } catch (IOException e) {
                // InputStream.transferTo()の実行途中にFFmpegのプロセスが終了し、
                // 必然的にIOException: Broken pipeが発生する。このため、例外を握りつぶす。
                // このデータの受け流しやFFmpegのプロセス全体の成否についてはProcess.exitValue()で判定することとする。
            }
        }
    }
}
```

# 参考
[MP4のファイル構造を解説](https://qiita.com/satken2/items/d14b4113fe3fb5f5597b)
[第26回　携帯ゲーム機PSPの動画ファイル「MP4」とは何か](http://www.mpeg.co.jp/libraries/mpeg_labo/winPC_26.html)
[QuickTime File Format Specification](https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html#//apple_ref/doc/uid/TP40000939-CH204-SW1)
[ffmpeg Documentation](https://ffmpeg.org/ffmpeg.html)

---
明日の[レコチョク Advent Calendar](https://qiita.com/advent-calendar/2022/recochoku)は14日目 【Android】Canvas APIの使い方です。お楽しみに！

---
この記事はレコチョクのエンジニアブログの記事を転載したものとなります。

https://techblog.recochoku.jp/8742