はじめに
「低遅延なライブ配信」 + 「OBSみたくwebページ合成」を自前でやってみたいな〜と思い調べた中で、学んだもの・作ったもの・躓いたものを忘れないようにメモしてみる。
前提
この記事では、要件は以下を考えてみる。
- 映像配信:配信者は、映像・音声を受信者に配信できること
- 映像合成:外部のWebページを映像に合成(OBS的な体験)する機能
- 特に合成素材は、拡張しやすく&提供しやすいwebページを想定
アーキテクチャ特性は以下を考えてみる。
- 低遅延配信:配信者から受信者に、低遅延で高速にメディアを送信したい
- 環境の可搬性:環境を持ち出しやすくするため、containerで管理
- クライアント負荷低減:クライアントはOBSが使えなくてもいいように、映像合成処理をサーバに任せたい。また、クライアント側で映像を合成する仕組みはアプリを用意する必要もあり手間なので、映像を送信する仕組みだけに集中させてみる。
方針案
映像合成の例として、最近のライブ配信サービスでは、配信者のカメラ映像にリアルタイムなデータ(コメント、webページ、投げ銭エフェクトなど)を合成するものが思い浮かぶ。実現する方法としては次のパターンを考えてみた。
1. クライアント(配信者)サイド合成
- 概要
OBSやスマホアプリ、webページ内で合成するパターン - 作り
配信者のアプリで映像・音声をキャプチャ。そのまま、webページを合成して映像としてサーバに送信する。 - 懸念
配信者側のアプリに映像の合成の仕組みが必要で手間。よくあるのはOBSを配信者側で用意する方法。& 配信端末のCPU/GPU負荷が高く、バッテリー消費が激しい
2. クライアント(受信者)サイド合成
- 概要
webやスマホアプリ内で合成するパターン - 作り
受信者側のアプリで受信した映像・音声にwebページを合成し表示する。 - 懸念
受信者側のアプリに映像の合成の仕組みが必要で手間。描画用のアプリやwebページを用意する必要があり、Youtubeなどの媒体への配信には使えない。& 受信端末のCPU/GPU負荷が高くなりがち、バッテリー消費が激しくなりがち。
3. サーバーサイド合成
- 概要
サーバー側で映像を受け取り、加工して再配信する。 - 作り
配信者は、WebRTCで映像送信するだけ。サーバで受信から映像合成、受信者への際配送までおこなう。 - 懸念
実装が手間。インフラコストと遅延が増大しやすい。
クライアントサイドはOBSなど使って実現している例はいっぱいあるので、「クライアントの負荷をゼロにしつつ、Web技術(HTML/CSS)で自由自在なレイアウトを実現する」 ために、サーバーサイド合成のアプローチを試してみた。
システムアーキテクチャ
① SFUコンテナ (Go + Pion)
- 役割: 映像のルーティングを担当させる(=SFU)。配信者からWebRTCで映像を受け取り、それを合成コンテナへ転送。また、プロトコルレベルでの制御(RTCPパケットのハンドリング)も担う
- 採用技術: Go, Pion
② 合成コンテナ / Mixer (Node.js + Puppeteer + Xvfb)
- 役割: 映像の「レンダリングエンジン」兼「配信エンコーダー」。SFUからWebRTCで映像を受信してブラウザ上に描画し、HTML/CSSでオーバーレイを合成。その画面をキャプチャしてRTMPサーバに送信
- 採用技術: Node.js, Puppeteer (Chrome), Xvfb, FFmpeg
③ Nginxコンテナ (Media Server)
- 役割: 配信のエンドポイント。合成コンテナから送られてきたRTMPストリームを受け取り、HLS (m3u8/ts) に変換して視聴者に配信
- 採用技術: Nginx + RTMP Module
- 備考: ブラウザでは直接RTMPの再生ができないため、直接RTMP形式で返すのではなく、HTTPサポートしている動画ストリーム形式としてHLSに変換させた。本番では、HLSでは遅延が発生するので別の方法で視聴者に届ける方法も検討したい(今後。。。)
あとはここに書いてないけど、ClientとSFUサーバの間もHTTPS/WSSで通信させるために、nginxを噛ませている。
SFUサーバ実装
SFUでは、メディアの中身をいじらない。
RTPパケットに対するエンコード/デコードの負荷を負うことなく、低遅延でメディアを合成サーバーへ送り届けることを目指す。
動的トラック・ルーティング
配信者から TrackRemote(受信トラック)が届くと、SFUはそれに対応する TrackLocal(送信トラック)をメモリ上に作成する。そして、Goroutineを立ち上げ、パケットをひたすらコピーし続けさせる。
// SFUのパケット転送ロジック(一部抜粋)
func (s *SFUServer) addTrack(t *webrtc.TrackRemote) *webrtc.TrackLocalStaticRTP {
// 1. 合成ノード送出用のローカルトラックを作成
localTrack, _ := webrtc.NewTrackLocalStaticRTP(
remoteTrack.Codec().RTPCodecCapability,
remoteTrack.ID(),
remoteTrack.StreamID(),
)
// 2. パケット転送ループ
go func() {
buf := make([]byte, 1500) // MTUサイズ
for {
n, _, err := remoteTrack.Read(buf)
if err != nil { return }
// 合成ノードへWrite
localTrack.Write(buf[:n])
}
}()
return localTrack
}
合成サーバ実装
合成サーバは、Node.js (Puppeteer) で制御される Chrome が鍵。webページを合成する場合、ページの描画をまかせるならブラウザが楽だったのでサーバサイドに責務を任せた。
WebRTC受信とレイヤー構造
ブラウザ内では、SFUとWebRTC接続し、受信した映像を タグで再生させる。
さらに、その上に
FFmpeg キャプチャをできるだけ低遅延で
Xvfbに描画された画面は、同コンテナ内の FFmpeg が x11grab でキャプチャし、RTMPストリームとして送出する。
デフォルト設定のFFmpegは、フレームレート解析のために数秒バッファリングしてしまうため、遅延対策として以下のパラメータを使用
ffmpeg \
-probesize 32M \ # 解析バッファサイズを大きく確保
-analyzeduration 0 \ # 解析時間をゼロにして即座に開始
-f x11grab \ # X11画面キャプチャ
-i :99.0 \ # Xvfbのディスプレイ番号
-tune zerolatency \ # 遅延排除設定
-preset veryfast \
-f flv "${RTMP_URL}" # RTMPサーバに送信
躓いたポイント
1. 100秒待たないと映像が出ない問題
動作確認中、システム自体はエラーなく動いているのに、「WebRTC接続確立から映像が出るまで1分以上(時には100秒近く)かかる」 という現象が発生。 忘れた頃にパッと映像が出る挙動に悩まされた。
原因:PLI (キーフレーム要求) が届かない
WebRTCの映像圧縮(H.264/VP8)は「差分圧縮」とのこと。
最初の完全な画像(I-Frame / Keyframe)を受け取らないと、その後の差分データ(P-Frame)はすべてゴミとなり、画面は黒いままになってしまう。
今回は、この完全な画像を要求する処理がうまく届かなかったことが原因だった。
参考: IETF RFC 4585
参考: IETF RFC 6184
実装でまずかったポイント
通常、受信側(この場合は合成サーバのChrome)は「I-Frameがない」と気づくと、送信側に PLI (Picture Loss Indication) パケットを送り、「I-Frameを送ってくれ!」と要求する。
今回の構成では以下のすれ違いが起きていた。
- Chrome (合成サーバ側) がPLIを要求
- SFUが受け取ったら、RTPストリームをSFU上で再生成して配信者に送信。しかし、SFU内でトラックIDの管理やルーティング処理を行う過程で、「Chromeが要求しているトラックID」と「配信者が送信しているトラックID」のマッピングが解決できない状態になった
- つまり、配信者に「I-Frame送って」という要求が届かず、Chromeは配信者(のブラウザ)が偶然I-Frameを送るまで、ひたすら待ちぼうけになっていた
なぜIDのマッピングが解決できなくなったか?
-
SFUでRTPヘッダー情報の作り直しが発火
最初に実装したコードでは、配信者からのパケットを TrackRemote で受け取り、それを TrackLocalStaticRTP という新しいオブジェクトにaddTrack()を介して書き込んで合成サーバへ送っていた。
この実装では、映像データ(H.264/VP8のペイロード)そのものはコピーするが、RTPヘッダー情報は作り直されてしまう。
特に、ヘッダの中で重要なのが SSRC (Synchronization Source) 。
SSRCの動きとしては、以下のようになり、合成サーバからすると、送られてきた映像は 「SSRC 0xBBBBのストリーム」 となる。- 配信者 → SFU:
SSRC 0xAAAA(例) で送信する - SFU → 合成サーバ: NewTrackLocalStaticRTP が生成される際、Pionが新しいランダムな
SSRC 0xBBBBを割り当てて送信する
- 配信者 → SFU:
-
PLI要求の宛先が不明になる
合成サーバ(の中で動いているChrome)が「キーフレームがない」と判断したとき、Chromeは RTCPパケット(PLI)を SFU に送信する。 このPLIパケットの中身には、「ターゲットのSSRC(Media SSRC)」 が記載。
SSRCの動きとしては、以下のようになり、配信者側(ブラウザ)では「SSRC 0xBBBBのキーフレームを要求されたが、私が送っているのはSSRC 0xAAAAだから無視しよう」となる。- 合成サーバ →
SSRC 0xBBBBのキーフレームを要求(PLI送信) - SFU → PLIを受信
- SFU → 合成サーバの要求PLIをそのまま配信者に転送
- 配信者 → 送信に使ったSSRCと異なるSSRCのフレーム要求が届く=廃棄される
- 合成サーバ →
-
コード上の誤解
コード上ではremoteTrack.ID()をコピーしてlocalTrackを作っていた。だから、IDが同じだと思っていた。しかし、RTPパケットのヘッダーにある 32bit整数値のSSRC は、WebRTCスタック(Pion)がセッションごとに動的に生成するものであり、このコードではコピーされなかった。
// 修正後のコード(一部抜粋)
peerConnection, err := s.WebrtcAPI.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
},
})
...
peerConnection.OnTrack(func(t *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
// 受信したRawトラックをHubに追加し、Mixerへの転送のためのシグナリングを促す
s.addTrack(t)
})
解決策
SFU側で映像トラックを検知した瞬間、SFU自身が配信者に対してキーフレームを要求する実装に変更。その際、配信社のSSRC情報をそのまま使ってフレームの再要求を行う。
// 修正のコード(一部抜粋)
peerConnection, err := s.WebrtcAPI.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
},
})
...
peerConnection.OnTrack(func(t *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
// 受信したRawトラックをHubに追加し、Mixerへの転送のためのシグナリングを促す
s.addTrack(t)
...
// ⭐️⭐️⭐️t.SSRC() は「配信者のSSRC (例 0xAAAA)」が入っている⭐️⭐️⭐️
peerConnection.WriteRTCP([]rtcp.Packet{
&rtcp.PictureLossIndication{MediaSSRC: uint32(t.SSRC())},
})
})
2. Iframeの内容がブラウザ機能でキャプチャできない (CORSとTainted Canvas)
当初、合成処理も配信エンコードもすべてブラウザ(Chrome)内で完結させる予定だった。 具体的には、以下のような実装を想定。
-
<video>(カメラ映像) と<iframe>(外部Webサイト) を重ねて表示 -
canvas.drawImage()でそれらを1枚のCanvasに書き込む -
canvas.captureStream()でストリーム化し、MediaRecorder API等でWebRTC送出する
しかし、このアプローチは 「外部Webサイト(Iframe)が配信映像に映らない」 という致命的な問題で破綻。
原因
-
Origin制約
ブラウザには Same-Origin Policyというセキュリティ制約がある。 -
Iframeの描画不可
canvas.drawImage()は画像やビデオを描画できるが、DOM要素である<iframe>そのものをCanvasに「焼き付ける」ことはできない -
キャンバス汚染
SVGのforeignObjectなどで外部リソースを描画しても、異なるドメイン(Cross-Origin)のコンテンツが含まれたCanvasは "Tainted"(汚染)状態となり、captureStream() や toDataURL() を実行した瞬間にセキュリティエラーとなる。 -
getDisplayMediaの壁
画面共有API (getDisplayMedia) も考えたが、**「ユーザーがポップアップで許可ボタンを押す」**操作が必要になり、無人のサーバー環境では手間だった。
解決策
ブラウザ内部でのキャプチャを諦め、「OS(ウィンドウシステム)の見た目をそのまま録画する」 アプローチで対応してみた
-
Xvfb (X virtual framebuffer)
物理モニターのないサーバー上で、仮想的なディスプレイ環境を作成した。ここにChromeを--kiosk(全画面)モードで表示。ブラウザがレンダリングしている見た目(Videoの上にIframeが乗っている状態)をそのまま使う -
FFmpeg (x11grab)採用
Xvfbの画面(:99.0など)を入力ソースとしてキャプチャした。
# containerのentrypoint.sh にて、ブラウザのセキュリティ制約の外側(OS層)から見た目をぶっこ抜く
ffmpeg -f x11grab -i :99.0 ... (RTMP配信)
まとめ
手探りなのでベストプラクティスかわからなかったけど、面倒な処理と気をつけないといけないポイントも多いな、という印象。
まだ受信して動画確認する部分は簡単に確認するためにHLSで表示させているだけだが、ここもWebRTCで低遅延にしてみたい所存...
