これは何?
最近、cmaf(Common Media Application Format) を使った低遅延ライブについて、ちょこちょこ調べているんですが、手軽にオープンソースベースで試せる環境が見つけられなかったので、作ってみた ・・・ という POST です。
構成概要は、以下のような感じ
まずは動かしてみる
インストール
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 の低遅延ライブを視聴できます。
ライブラリとしては、 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/promises
の watch()
を用い 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/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 で確認できますので、興味のある方はチェックしてみてください。