ffmpeg で low latency DASH server 作ってみた

## これは何？

最近、cmaf(Common Media Application Format) を使った低遅延ライブについて、ちょこちょこ調べているんですが、手軽にオープンソースベースで試せる環境が見つけられなかったので、作ってみた　・・・　という　POST　です。

構成概要は、以下のような感じ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/83466/72ced7a3-1b8d-c9d3-6017-fa17c9bbc242.png)


## まずは動かしてみる

### インストール

#### ffmpeg のインストール

解説は、後に回すことにして、まずは動かし方。まず、ffmpeg をインストールします。(たぶん）　v4.3.1 以降が必要です。

* Ubuntu20.04

```bash
$ sudo snap install ffmpeg
```

(aptだと、バージョンがちと古い）

* MacOS

```bash
$ brew install ffmpeg
```

でインストール完了

#### node のインストール

v14以降をインストールしてください（ES Module使ってるので）

#### サーバのインストール

サーバですが、 https://github.com/kokutele/ll-dash-server に公開しておきました。

```bash
$ git clone https://github.com/kokutele/ll-dash-server.git
```

### 動かしてみる

#### ffmpeg

以下の手順で、まず ffmpeg を動かします。

```bash
$ cd ll-dash-server/scripts
$ ./ll-rtmpsrc.sh
```

中身は

```bash
ffmpeg -f flv -listen 1 \
  -i rtmp://0.0.0.0:1935/live/app \
  -c:a aac \
  -c:v h264 -force_key_frames "expr:gte(t,n_forced*4)" -profile:v baseline \
  -map v:0 -b:0 1000k -s:0 640x360 \
  -map v:0 -b:0 500k -s:1 320x180 \
  -map a:0 \
  -ldash 1 -streaming 1 \
  -use_template 1 -use_timeline 0 \
  -adaptation_sets "id=0,streams=v id=1,streams=a" \
  -seg_duration 4 -frag_duration 1 -frag_type duration \
  -utc_timing_url "https://time.akamai.com/?iso" -window_size 15 \
  -extra_window_size 15 -remove_at_exit 1 \
  -f dash ${PWD}/../dash-data/1.mpd
```

rtmp でメディアストリームを受け付けて、　DASHのファイルを `ll-dash-server/dash-data/` 配下に生成する設定になっています。

#### OBS

OBSである必要はないですが、先の手順で挙げた rtmp 

```
rtmp://127.0.0.1:1935/live/app
```

に対し、メディアストリームを送信します（OBSをローカルで動かす場合を例示しました。アドレスは、環境に応じて変えてください。メディア絡みの設定は、デフォルトで大丈夫だと思う・・・たぶん）。うまくいくと、 `ll-dash-server/dash-data` 配下にファイルが生成されます。

#### サーバー起動

```bash
$ node index.js
```

これで、 5000 番ポートでサーバが起動するので、ブラウザで `http://localhost:5000` にアクセスすると、DASH の低遅延ライブを視聴できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/83466/c9830b33-3fe9-9705-a0d7-639b0d636dc4.png)

ライブラリとしては、 [dash.js](https://github.com/Dash-Industry-Forum/dash.js) を使っており、筆者の環境では、だいたい 6 秒ぐらいの遅延になりました。

## 解説

今回紹介したサーバーを作るに辺り、 こちらの海外ブログ [Low-latency dash streaming using open-source tools](https://bozhang-26963.medium.com/low-latency-dash-streaming-using-open-source-tools-f93142ece69d) を参考にしました。

低遅延 DASH では、 HTTP/1.1 Chunked Transfer を用い、ファイルが生成される途中の chunked データをハンドルする必要があります。先のブログでは、これのために ffmpeg のソースコードを変更する方式が紹介されているのですが、ソース変更はめんどい・・・ということで、素の ffmpeg で OK な方式のサーバーを作ってみました。

以下に、そのポイントだけ解説します。

### ffmpeg のファイル生成

ffmpeg では、ファイル生成の際　`chunk-stream0-00213.m4s.tmp` のように、拡張子 `.tmp` をつけてチャンク単位で書き込みを行い。完了すると `chunk-stream0-00213.m4s` とリネーム処理をして `.m4s` (フラグメント分割された mp4ファイル）を生成します。

なので、クライアントの `.m4s` リクエストに対して `.tmp` ファイルを参照。データが追加される都度 Chunked Transfer でデータ送信し、 `.m4s` にリネームされたタイミングでそのセッションを終了してあげればOKということになります。

以下に、そのポイントを紹介します。

#### Chunked Transfer

まず、 `.tmp` ファイルを Chunked Transfer する部分についての抜粋です（コードを読みやすくするため、エラー処理は省略しています）

```js
import { watch } from 'fs/promises'

let pos = 0

// set abort controller, since sometimes `rename` will not emitted
// when filename changed.
const ac = new AbortController()
const { signal } = ac

// start watcher for tmp file.
const watcher = watch( tmpFile, { persistent: true, recursive: false, signal } )

try {
  for await( const event of watcher ) {
    if( event.eventType === "change" ) {
      // in case when file changed, read added data then write it 
      // as chunked-transfer
      const { size } = await handler.stat()
      const len = size - pos
      const buff = Buffer.alloc( len )
      const { bytesRead } = await handler.read(buff, 0, len, pos )

      res.write( buff.slice( 0, bytesRead ) )
      pos += bytesRead
    }
  }
} catch(err) {
  if ( err.name === 'AbortError' ) {
    // do nothing
  }
}

// finish chunked-transfer
handler.close()
res.end()
```

まず、 `AbortController` インスタンスを生成し、 `.tmp` ファイルに対する watch ループを `ac.abort()` を呼ぶことで抜けれるようにしておきます。そして、 `fs/promises` の `watch()` を用い tmp ファイルの変更検知を行い、 `change` イベントの都度、追加されたデータを `res.write()` により送信することで Chunked Transfer を行っています。

また、後述する .tmp -> .m4s へのリネーム処理検知により `ac.abort()` が呼ばれると `catch(err)` にジャンプするため、 `res.end()` を呼ぶことで、Chunked Transfer のセッションを終了しています。

#### リネーム処理検知

リネーム処理を検知するために、サーバー起動時に、 dash ファイルが生成されるディレクトリに対し、以下のように `watch()` をかけています。

```js
import { watch as watchCallback } from 'fs'

watchCallback( this._dashDir, ( eventType, filename ) => {
  if( filename.match(/.+\.m4s$/) ) {
    this.emit( `${eventType}:${filename}` )
  }
})
```

ここで、ファイルが生成されると `rename` イベントが発火するため、都度 `rename:<m4sファイル名>` の形式でカスタムイベントを発火します。

そして、クライアントからリクエストを受信する都度、以下のように上記イベントに対するハンドラをしかけておくことで、該当ファイルに対する .tmp -> .m4s への変更を検知し、先に紹介した `ac.abort()` を呼んでいます。これにより、前述のとおり watch　ループから抜け出し、 Chunked Transfer のセッションが終了します。

```js
// When ${_filename}.tmp change to ${_filename}.m4s, event
// `rename:${_filename}` will be emitted. When we detect this
// event, we will stop file watcher using `ac.abort()`.
const _filename = req.params.filename

this.once(`rename:${_filename}`, () => {
  ac.abort()
})
```

#### `rename` イベント検知を外に出した理由

ここまで紹介したように、 `rename` イベントを検知することで Chunked Transfer セッションを抜け出しています。ここで、専用の watch コールバックをセットし、 `rename` イベントを処理しています。しかしながら、 `rename` イベントは `fs/promises` の `watch()` でも発火するため、わざわざ外出しの watcher をセットすることは余計に思えます。

これは、 `fs/promises` での `rename` イベント検知だと、そのイベントが発生しないことがあり、動作が不安定となったためです。これを回避するため、ここまで紹介したような方式を用いました。Node.js のマニュアルでは

> On most platforms, 'rename' is emitted whenever a filename appears or disappears in the directory.

とあり、ファイルの生成／削除のたびに `rename` イベントは発火します。このため、最初は `.tmp` ファイルに対する `rename` イベント監視を行い、**ファイル削除** を検知することで Chunked Transfer セッションを終了していました。しかし、前述のとおりこのイベントが発火しないことが度々あったため、外出しの watcher を定義し、 **ファイル生成** を検知するように変更しました。ちゃんと調べたわけではないので、あくまで憶測ですが、**削除の検知** はレースコンディションが発生しうるのかなぁ・・・と、**生成されたことの検知** であればそのような例外は発生しなさそう。
この憶測が正しいかどうかは不明ですが、いずれにしろ、イベントの漏れが起こらなくなり、正常なセッション終了を安定して行うことができるようになりました。

## むすび

本記事では、 ffmpeg を用いた低遅延 DASH サーバーについて解説しました。特に、Chunked Transfer の実現方法について、ポイントを解説しました。コードの全体は、　https://github.com/kokutele/ll-dash-server で確認できますので、興味のある方はチェックしてみてください。