1秒未満の超低遅延配信が見れます
デモサイト、最新のChromeでのみ動きます。Big Buck Burmyをストリームで繰り返し配信しています。
ソースコード : サーバー側のソースはGoのみです。(aioquicがやっぱり不安定?) 環境構築周りのファイルはないです
既知の問題
- ビットレートは500kです (GPU付きインスタンスは高いから仕方ないよね)
- 同一のブラウザで複数開くと挙動がおかしくなるかもしれません
Warp is なんぞや?
Warpとはtwichが提唱するメディア配信プロトコルです。
現在はIETFのドラフトが提出されています。
こちらの方のブログが大変参考になります。
ざっくりと説明すると
- 細切れ(今回は500ms)動画や音声を1ストリーム1セグメントで配信する
- ストリームは並行して送受信てギルので、あるストリームが詰まってしまっても次のストリームを受信したらそこまでスキップされば良い。
- 音声を優先して受信する
というところかと思います。
なぜQUICなのかというと
- UDPではパケロスしたり破損したりするのでエラー処理が大変
- TCPだと上記の問題はないものの、正しい順番でパケットを受け取るまで後続のデータを受信しない
- かと言って複数のTCPコネクションを貼るにはハンドシェイクなどの負荷が高い
というところで、そこをうまくやってくれるのがQUICのストリームということです。
今までの低遅延配信との違い
現在のライブ配信では数秒程度のセグメントファイルをHTTP/1.1 または HTTP/2で受信していますが、それをHTTP/3にして、さらにWebTransportにします。
つくり
今回はEC2とGPU付きGKEに環境を構築し、それぞれMP4を読み込んで延々と配信するパターンとSRTで受信したものを配信するパターンを作りました。
GKEやGPUを使ったハードウェアエンコードについては本題とずれるので割愛します。
(まあ、環境構築はなかなか骨が折れますが、一度作って仕舞えば楽。あとはSSL証明書の管理だけなんとかならないものか。。)
パターン1 : ファイルからストリーミング
動画ファイルをffmpegで読み込み、それをHLSとして500msのセグメントファイルに書き出します。(ついでに現在時刻を動画に描画しておきます)
そしてファイルの書き出しを検知してサーバープログラムで読み取り、1セグメント1ストリームで配信していきます。
今回は0.5秒単位のセグメントファイルを書き出しているため、遅延は600ms - 1000ms程度になります。
動画的なこと
ffmpegのソフトウェアエンコードでのパラメータは以下のようになっています。
ハードウェアエンコードするとより負荷が少なくなりますし、多少遅延も少なくなります。
ffmpeg -re -stream_loop -1 -i ../movie/bbb_sunflower_1080p_30fps_normal.mp4 \
-vf "drawtext=fontfile=../movie/arial.ttf: text='%{localtime\:%X}_%{frame_num}': r=24: fontsize=60: fontcolor=white: x=(w-tw)/2: y=h-(2*lh): box=1: boxcolor=0x00000000@1" \
-ac 2 -acodec aac -vcodec libx264 -threads 2 -b:v 500k \
-r 30 -seg_duration 0.5 -g 15 -x264-params open-gop=0 -refs 0 -bf 0 \
-sc_threshold 0 -b_strategy 0 -strftime 1 -use_template 1 \
-window_size 5 -hls_playlist 1 -streaming 1 -remove_at_exit 1 -f dash manifest.mpd
パターン2 : SRTにて配信したものを中継する
パターン1では動画ファイルのストリーミング配信でしたが、こちらでは配信されたものをブロードキャストする方法を検討していきます。
配信者からSRTなどでサーバーに配信し、それをトランスコードせずにflagmented MP4に変換するだけにします。
遅延については5秒程度と予想より遅くなりました。
そろそろ1秒程度になりそうです!
1920 x 1080 h264 30fps 5Mbps + AAC
— alivelime (@purejapaneseonl) September 11, 2022
OBSからSRTで打ち上げてgstreamerで再エンコしてWar(WebTransport)でChrome複数タブで視聴するとこまで遅延は1秒ちょい
映像が詰まった時はスキップ、音声のみモードもつけてみた
2時間流してても音飛びやズレは少ないけど回線と時計の影響が大きい pic.twitter.com/kkVwFUop6B
動画的なこと
今回は手元のiMacからffmpegもしくはOBSにてSRTやRTMPにて配信してみました。
SRTのインストールについてはmacはbrewで、ubuntuなどはソースビルドする必要があります。
ffmpegは新しいバージョンであれば対応しているはずなので特にソースビルドは不要でした。
RTMPだとnginxでサーバーを立てないといけないですが、SRTだと mode=listener
で待ち受けモードにできるのでとても簡単です。
# サーバーにてSRTを待ち受ける
$ cd ../media && ffmpeg -re -i "srt://:4201?mode=listener&latency=120" \
-acodec copy -vcodec copy -threads 2 \
-strftime 1 -use_template 1 -seg_duration 0.5 \
-window_size 5 -hls_playlist 1 -streaming 1 -remove_at_exit 1 -f dash manifest.mpd
# Macにて配信する
$ brew install srt
# mp4を用意する
$ wget https://mirror.clarkson.edu/blender/demo/movies/BBB/bbb_sunflower_1080p_30fps_normal.mp4
# (arial.ttfをシステムディレクトリからコピーしてきてください。)
$ cd movie/ && ffmpeg -re -stream_loop -1 -i bbb_sunflower_1080p_30fps_normal.mp4 \
-vf "drawtext=fontfile=arial.ttf: \
text='%{localtime\:%X}_%{frame_num}': r=24: fontsize=60: fontcolor=white: \
x=(w-tw)/2: y=h-(2*lh): box=1: boxcolor=0x00000000@1" \
-ac 2 -acodec aac -vcodec h264_videotoolbox -threads 6 -b:v 500k \
-r 30 -seg_duration 0.5 -g 15 -x264-params open-gop=0 -refs 0 -bf 0 \
-sc_threshold 0 -b_strategy 0 \
-tune zerolatency \
-f flv \
"srt://{your host ip address}:4201?pkt_size=1316"
プログラム的なお話
WebTransportやWebCodecsそのものについては前回の記事などで解説していますので、今回はWarpの特徴的なところのみ記載してみます。
とは言っても、1セグメントを1ストリームで受信するというだけなので概念としてはわかりやすいかと思います。
ただし実装してみると動画と音声のタイミングを合わせるところやバッファリングをするのかしないのか、もしくは遅延に対してどう処理するか、と言ったことがたくさん出てきます。
次のコードはストリーム受信の処理です。init もしくは segment を示すJSONデータを受け取ったのちにメディアデータを受信し、MP4box.jsにてパースしています。
まずは新しいストリームを受信する処理です。
データは最初にそのストリームが何を示すのかJSONに入っていますのでまずはそれをパースします。
async accept() {
let reader = this.wt.incomingUnidirectionalStreams.getReader()
let sid = 0
this.timeVideo = performance.now()
this.timeAudio = performance.now()
try {
while (true) {
// console.log("wait stream")
const {value, done} = await reader.read()
if (done) {
console.log("Done accept unidirectional streams.")
return
}
const stream = value;
// ストリームから読み取り、ここは新しいストリームを優先させるため await しない
(async () => {
// console.log("accept stream")
let segmentType = ""
const reader = stream.getReader()
// read message
const { value, done } = await reader.read()
if (done) {
console.log("error: empty stream.")
return
}
... 受信したデータの処理、下記に続く
})()
}
} catch (err) {
console.log("accept incoming unidirectional stream. " + err)
}
}
ちなみにWebSocketでは送信したデータごとに相手の方も一つのデータとして処理できますが、WebTransportではQUIC層にてバッファリングされるため、どこからどこまでがJSONデータなのか長さを入れておく必要があります。
const msg_len = new DataView(value.buffer, 0).getInt8(0)
// console.log(msg_len)
console.log(new TextDecoder().decode(value.slice(1, 1 + msg_len)))
const message = JSON.parse(new TextDecoder().decode(value.slice(1, 1 + msg_len)))
... // JSONの内容で処理を分ける。長いので下記に抜粋する
さて、続いてメディアデータの処理です。
ポイントとしてはinit.mp4がないと処理ができないため、セグメントデータを受信しつつもinit.mp4を待つ必要があるところくらいでしょうか。
MP4Box.jsに放り込むとフレームごとにデータを取り出してくれます。
if (message.init !== undefined) {
segmentType = 'init'
this.id = message.init.id
// 最初のinitの読み込み完了を待つ
this.initMP4 = new Promise(async (resolve, reject) => {
... // init.mp4読み込むプロミス
});
console.log("received init.mp4")
return
} else if (message.segment !== undefined) {
if (this.kind === "video") {
// console.log("segment " + this.kind + " : " + (timeStart - this.timeVideo))
this.timeVideo = timeStart
} else {
// console.log("segment " + this.kind + " : " + (timeStart - this.timeAudio))
this.timeAudio = timeStart
}
let demuxer = new MP4Demuxer(sid++, await this.initMP4, this.kind, this.onframe, nbSamples);
demuxer.source.onframe = this.onframe.bind(this);
segmentType = "segment"
await demuxer.getConfig().then(async (config) => {
if (this.kind === 'video') {
self.postMessage({
type: "play",
width: config.codedWidth,
height: config.codedHeight,
})
}
this.decoder.configure(config);
});
demuxer.ondata(value.slice(1 + msg_len, value.byteLength), false)
demuxer.start()
while (true) {
const { value, done } = await reader.read()
if (done) {
console.log("stream done " + (sid-1) + " : " + (performance.now() - timeStart))
break
}
demuxer.ondata(value, true)
}
} else {
console.log("unknown type message. ")
console.log(message)
return
}
描画するところ
動画を再生する際にはフレームレートに合わせてタイマーにてフレームを処理する方法がありますが、今回はライブ配信用途なので遅延したフレームは捨てることにします。
遅延していないフレームは setTimeout()で再生すべき時間になるまで待機させています。
精度はミリ秒単位なので問題ないでしょうし、setTimeout()につまれるフレームは最大15なので多分問題ないとは思います。
追っかけ再生機能とか作るならちゃんとキューイングする必要はありそうです。
onframe(frame) {
if (!this.firstTimestamp) {
this.firstTimestamp = frame.timestamp - 1; // 0除算を避けるため1ずらしておく
}
const diff = Number(this.timeStart + ((frame.timestamp - this.firstTimestamp) / 1000) - performance.now())
// フレームが早ければ待つ。フレームレート内のタイミングであれば描画する、遅ければ読み飛ばす
if (diff > 0) {
if (this.kind === "video") {
// console.log(diff)
}
setTimeout(
() => {
this.frameWriter.write(frame);
frame.close();
},
diff
)
} else {
console.log("frame skipped. " + diff)
// 描画すべき時刻をすぎている場合は読み飛ばす
}
}
今後の課題とまとめ
超低遅延の配信といえばWebRTCでクラスタを組んだものなどありますが、なかなかスケールしづらいというのが課題でした。
WarpであればCFAMを使うため、DASHやHLSなどの既存のCDNと組み合わせる可能性もあり、QUICのロードバランスやマルチキャスト周りの仕様策定が待たれるところです。