LoginSignup
24
18

More than 1 year has passed since last update.

ffmpeg で low latency DASH server 作ってみた

Posted at

これは何?

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

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

image.png

まずは動かしてみる

インストール

ffmpeg のインストール

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

  • Ubuntu20.04
$ sudo snap install ffmpeg

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

  • MacOS
$ brew install ffmpeg

でインストール完了

node のインストール

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

サーバのインストール

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

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

動かしてみる

ffmpeg

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

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

中身は

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 配下にファイルが生成されます。

サーバー起動

$ node index.js

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

image.png

ライブラリとしては、 dash.js を使っており、筆者の環境では、だいたい 6 秒ぐらいの遅延になりました。

解説

今回紹介したサーバーを作るに辺り、 こちらの海外ブログ Low-latency dash streaming using open-source tools を参考にしました。

低遅延 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 する部分についての抜粋です(コードを読みやすくするため、エラー処理は省略しています)

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/promiseswatch() を用い tmp ファイルの変更検知を行い、 change イベントの都度、追加されたデータを res.write() により送信することで Chunked Transfer を行っています。

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

リネーム処理検知

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

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 のセッションが終了します。

// 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/promiseswatch() でも発火するため、わざわざ外出しの 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 で確認できますので、興味のある方はチェックしてみてください。

24
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
18