RheomeshでScalable Video Coding(SVC)をサポートする

h3poteto.hatenablog.com

ここで作ったOSSにScalable Video Coding(SVC)のサポートを入れた.

0.7.1から使えるようになっている.

github.com

SVCとは

https://zenn.dev/yohhoy/articles/webrtc-svcext-av1

この辺が参考になる.やりたいこととしては,受信側がPCのスペックや回線スピードに合わせて適切な解像度/FPSで映像を受信するためのものだ.もともとRheomeshではSimulcastをサポートしており,SVCはサポートしていなかった.

Simulcastの場合,本当に複数本の映像を送信側が送る必要がある.高解像度・中解像度・低解像度の映像をそれぞれ送信しつつ,受信側で適切なものを選んで受信させるという方式だ.この場合の問題点は,送信側が3本映像を送らなきゃいけないとういことだ.同じ映像なのに,解像度ごとに2本3本と送る必要が有り,これは結構無駄な気がする.

SVCはこれに対して,送る映像は1本で良い.その中で,空間スケーラビリティ・時間スケーラビリティに分割されたパケットが送られてきて,SFUサーバ側でフィルタリングして受信側に渡してやる.例えば受信側が十分はスペック・回線スピードであれば,すべてのデータを渡すことで,高解像度・高FPSの映像を受信させる.逆に受信側が低スペックの場合であれば,SFUサーバ側で低解像度・低FPSのパケットのみをフィルタリングして送ってやることで,実際に低スペックに合わせた映像を受信することができる.

www.w3.org

使い方

配信側

配信側はサーバでの設定は一切不要だ,クライアント側の設定だけで完結する.

https://h3poteto.github.io/rheomesh//pages/02_getting_started/#publish

この例でpublishする際に,

import { SVCEncodings } from "rheomesh";

const publisher = await publishTransport.current!.publish(track, {
  encodings: SVCEncodings(),
  preferredCodec: "AV1",
});

とすることでSVCが有効なpublisherとなる.ちなみにSVCはAV1もしくはVP9でサポートされている. SVCEncodings は現状 L2T3_KEY になっているが,別にここは好きに変更してもらっても構わない.型は Array<SVCRTCRtpEncodingParameters> なので,

encodings: [{ scalabilityMode: "L2T3_KEY" }]

みたいな形になっていれば問題ない.

受信側

受信側は,受信クライアントから「どのSID, TIDのパケットを受信したいか」を受け取る必要がある.

exampleでは,このようなWebSocketのメッセージを受信する.

https://github.com/h3poteto/rheomesh/blob/a9a63ef08a995ea7f6b430cc743d3fc7a8d9b748/sfu/examples/media_server.rs#L381-L396

そして,

if let Err(err) = subscriber.set_preferred_layer(sid, tid).await {
    tracing::error!("Failed to set preferred layer: {}", err);
}

こんなメソッドを呼び出してやればよい.これで,このsubscriberは指定されたSID, TIDのパケットのみを受け取るようになる.

今後の予定とか

今のところ受信側が手動で,というか意図的にSIDやTIDを指定して映像を切り替える必要がある.これはこれで必要なシーンはあると思う.例えば特定の映像だけデカく表示したいとか,その他の映像はアイコン程度の大きさで良い場合とかね.

ただ,一般的なミーティングにおけるユースケースだと,いちいちクライアントがどのSID/TIDを指定すべきかの判断材料があまりない.なので,できれば受信側から受け取れるRTCPに含まれる情報から,帯域等のフィードバックを得られれば,それによりSFUサーバ側で自動判定してやるロジックを入れたいとは思っている.そうすれば,送信側でSVC指定するだけで,受信側は自動選択になるはずだ.

RheomeshでWebRTC-HTTP Ingestion Protocolをサポートする

h3poteto.hatenablog.com

ここで作っていたOSSでWebRTC-HTTP Ingestion Protocol(WHIP)のサポートを開始した.こちらは0.7.0から入っている.

github.com

WHIPというのはこういうやつ

www.ietf.org

WebRTC SFUだと,配信を開始するまでにシグナリングが必要で,SDPのやりとりを複数回双方向にやらなきゃいけなくて大変だよね,というのがある.しかも双方向なのでたいていWebSocketとか使わないといけないので,それもまた面倒なことではある.ので,HTTPだけでさくっと映像を送りつけるためにWHIPというのは存在するのだと概ね理解している.

で,プロトコルが決まっているのでだいたいそのとおりに実装するだけ……なのだがちょいちょい裏話を書いておく.

やることはWHIP用のエンドポイントを生やすだけ

WHIPが要求しているのはWebサーバでPOST/PATCH/DELETEのエンドポイントを生やすだけなのだが.その前に一つ.RheomeshはWebRTC SFU用のライブラリであり,これ自体がWebサーバを内包するようなライブラリではない.ExampleではもちろんWebサーバを使ったサンプルを乗せているが,あくまでもそれはユーザが好きなWebサーバを使ってシグナリングを実装する中で,SFUに関わるところだけRheomeshを呼ぶという前提にしてある(これはmediasoupなんかでも同じ).

なので,WHIPをやるにあたり,勝手にWebサーバを立てるという案は採用できない.あくまでエンドポイントを生やすためのメソッドを用意するだけで,Webサーバ自体はやっぱりユーザが建ててね,という建付けは変更していない.なので今回の方針としては,actixでWebサーバを立てるときに突っ込める設定を提供するところまでとしている.

追加されるエンドポイントは,

  • POST /whip/{session_id} - メディアのSDPを受け取りanswerのSDPを返す
  • PATCH /whip/{session_id} - Trickle ICEのSDPを受け取る
  • DELETE /whip/{session_id} - WHIPのセッションを終わりにする

の3つだけ.session_idは,文字列であればなんでも良い.ここでいうセッションは,接続を管理するidでしかないので,同じユーザの同じ接続がちゃんと同じsession_idになっていれば問題ない. 例えば,一人のユーザは絶対に一つのミーティングルームにしか入らない(一人で複数のミーティングに同時接続できない)という制約があるのであれば,別にuser_idでも構わない.

基本的にはセッションごとにTransportを作って管理するので,セッションとTransportの対応付けをやってもらう必要がある.これは,WHIPであってもTransportの作成タイミングはPOSTよりも前になるために,ユーザ側で管理してもらう必要がある.

struct SessionStore {
    sessions: Arc<Mutex<HashMap<String, Session>>>
}

struct Session {
    session_id: String,  // Or user_id.
    publish_transport: Arc<PublishTransport>
}

#[async_trait]
impl PublishTransportProvider for SessionStore {
    async fn get_publish_transport(
        &self,
        session_id: &str,
    ) -> Result<Arc<PublishTransport>, actix_web::Error> {
        let sessions = self.sessions.lock().await;
        if let Some(session) = sessions.get(session_id) {
            let p = session.publish_transport.clone();
            Ok(p)
        } else {
            Err(actix_web::error::ErrorNotFound("Session not found"))
        }
    }
}

こんな感じで,PublishTransportProviderを実装してもらう必要がある.

使い方

actix-webを使っている場合は,endpointを突っ込むだけだ.

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let store = SessionStore {
        sessions: Arc::new(Mutex::new(HashMap::new())),
    };
    let store_data = Data::new(store.clone());

    let endpoint = WhipEndpoint::new(store);

    HttpServer::new(move || {
        App::new()
            .app_data(store_data.clone())
            .configure(|cfg| {
                endpoint.clone().configure(cfg);
            })
    })
    .bind("0.0.0.0:4000")?
    .run()
    .await
}

これで前述の3つのエンドポイントが自動的に生える.

WHIPはサーバ側のシグナリングプロトコルの話だけであり,クライアントがどのように動くべきかを特に規定していないのだが,一応動作確認のためにもサンプルを作ってある.

https://github.com/h3poteto/rheomesh/blob/master/client/example/whip/src/pages/room.tsx

こんな感じでSDPを作って送りつければ良いと思う.

SDPはRheomeshのクライアントでも,RTCPeerConnectionからでも簡単に手に入るとは思うが,TrickleICE用の,candidateだけを含んだSDPを生成するのがちょっと面倒だった.

actix-web以外のサーバは?

これは将来的にサポートしようとは思っている.ただ,どうしてもエンドポイントの設定が入る都合上,あんまり汎用的にはできないと思っていて,一つずつかなぁ.今流行りなのはaxumとかかね.どれからいけるかわからんけど,エンドポイントの設定を流し込めるようなインターフェースがあれば,サポートしていけるとは思う.

WHEPは?

今回,WHIPはクライアントからサーバにメディアを送りつけるだけのシグナリングだった.SFUサーバである以上受信側のことも考えてほしくて,それはWebRTC -HTTP Egress Protocol(WHEP)というのがだ,こちらはまだサポートしていない.そのうちやるつもりではあるけど,今回はWHIPだけ先に実装したというだけ.

Rheomeshで録画機能をサポートする

h3poteto.hatenablog.com

ここで作っていたOSSに録画機能を入れた.

github.com

それと,ドキュメントサイトを作っておいたので,こちらに詳しい機能説明を載せてある.

h3poteto.github.io

録画機能と言ってもRTPパケットを転送するだけ

関数一つで録画ファイルまで生成してしまうのも悪くはないんだけれど,サーバとして稼働することを考えるとあんまり自由度がない. むしろもっとシンプルにして,エンコード等は外でやってもらうほうがいいかと思い,単にRTPパケットを転送するだけにした. ちなみにffmpegもGStreamerも,送られてきた生RTPパケットから動画ファイルを作り出せるので,録画自体はそれらにお任せする設計にしておく.

ただ,一部SDPはほしいかと思ったので,SDPを生成できるようにしておく.特にffmpegあたりはSDPを元に録画をするので,これがないと話にならない.

docs.rs

generate_sdp で,SDPの文字列が得られる.あとは,これを指定してffmpegを起動しておいて,

$ ffplay -protocol_whitelist file,rtp,udp -analyzeduration 10000000 -probesize 50000000 -f sdp -i stream.sdp

で,受信を待機させる. この状態で

docs.rs

start_recording すれば,生RTP転送が始まるので,ffplayで映像が確認できる.

録画したものをファイルに保存しておきたい場合は,

$ ffmpeg -protocol_whitelist file,rtp,udp -i stream.sdp -c copy output.mkv

こういうコマンドにしておくと output.mkv に録画されることになる.

GStreamerであれば,コマンドライン引数でこういうのを指定するので,SDPを読んだうえで

$ gst-launch-1.0 udpsrc port=30001 ! \
  application/x-rtp,payload=103,encoding-name=H264 ! \
  rtph264depay ! \
  h264parse ! \
  avdec_h264 ! \
  videoconvert ! \
  autovideosink

こんな感じのコマンドで待機しておく.使ってるエンコーディングによって多少パラメータは変わるが,そのへんは公式のドキュメントで,

gstreamer.freedesktop.org

gstreamer.freedesktop.org

このへんを調べてもらって,SDPにかかれているエンコーディングと合致したものを指定してもらいたい.

Exampleもあるよ

h3poteto.github.io

cameraのexampleに録画機能のexampleも付属させておいた.

これは画面上にSDPを出力するので,それをコピーしてffmpegなりGStreamerで受信してもらえれば良い.

次はrelayをもうちょっと改善しようと思っている.

車輪の再発明がしたくなってWebRTC SFUライブラリを作った

会社で仕事をしていると,どうしても生産性がどうのとか保守性がどうのとかいう思考に囚われてしまう. 仕事をする限り,できるだけ息の長い技術を選択したいし,メンテナンス性が高いものを使いたいと思ってしまう. その結果だいたいどこでも「車輪の再発明をするな」とか言われてしまうんだけど,別に仕事はそれでいい.好きに効率を追い求めて利益を追求したらいい.

でもそれ以外の時間は好きなことをしているので,別に効率とか生産性とかどうでもいいんだよ. むしろ趣味というのは非効率である方が面白いんだよ.利益とか追い求めてないしどうでもいいんだよ.

こういう気持ちになったときはOSSにコミットするというのも一つ手なのだが,いや,ちょっと生産的すぎる.世界に貢献しすぎている. もっと俺の欲望に忠実に無駄なものを作りたくなったので,車輪を再発明してみることにした.


WebRTCというのがある.こいつは元来P2Pでデータをやり取りして,主に映像とか音声をブラウザ間でやり取りする技術だ. そこにSFUというのがある.これは,本来P2Pであったデータのやり取りをサーバ経由で行うものだ. なんでこいうことが必要になるのかはとりあえずここではどうでもいいとして,SFUだ.SFUサーバが作りたい.

RustにはWebRTCのスタックを実装したものがあって

github.com

これで一通りのWebRTCはできる.そして一応このリポジトリにもSFUのexampleがある.だが,examplesを動かしてみても,実用的なWebRTC SFUサーバをこいつから作るには結構な量のコードを書く必要がある.さらに,WebRTCではシグナリングも必要になる.メジャーなのはWebSocketだと思うが,別にここはgRPCでもMQTTでもなんでもいいのだが,とりあえずシグナリングサーバも一緒に作る必要がある. ここの自由度を上げたくなったのでSFUサーバを作る部分をライブラリに切り出して,シグナリングサーバから独立させたパッケージを作りたくなった.

すでにmediasoupがある?今そんな話はしてないんだよ

WebRTC SFUライブラリ

で,作った.Rustで.

github.com

SFUはSelective Forwarding Unitの略称なのだが,全く名前の通りでSFUサーバ自体もWebRTCの一つのピアに過ぎない.ただ単に,一つのピアが中心に居座ってすべてを中継しているだけで,基本的な通信の仕組みはP2PのWebRTCと何ら変わらない.だから別に特別なことをしているわけではないんだが,いかんせん正しく要求されたクライアントに中継してやるロジックが結構多い.これはRustという言語の特性もあるのかもしれんが.

このライブラリは,SFUサーバをRustで作るためのライブラリだ.ただしWebRTCはだいたいブラウザから映像を送信して,ブラウザで見ると思うのでブラウザ側のクライアントライブラリも提供している.これで,シグナリングサーバをRustで作って,クライアントアプリケーションをJavascriptで作れるようになっている.

  • 一般的な音声,映像の送受信
  • DataChannel
  • Simulcast

あたりは普通にサポートできた.

SVCというのも最近はあるのだが,

Scalable Video Coding (SVC) Extension for WebRTC

これについては特許の問題等が不安なので,サポートはしていない.ただしtemporal layerのフィルタリングは実装しているので,「spatial layerの指定はできないけど,temporal layerの指定だけはできる」状態になっている.

ちなみにwebrtc-rsは,VP8やVP9についてはライブラリ側でRTPパケットからtemporal_idのパースをしてくれる.しかしAV1に関してはそういった関数を提供していなかったので,ヘッダーのDependencyDescriptorのパースを自分でやる必要があった.spatial layerの指定を実装しないのに,ここまでやる必要あったか?とも思うのだが,DependencyDescriptorのドキュメントは割と丁寧で,実装するのは結構楽しかったのでヨシ.

relayというかpipeというかfowardingというか

mediasoupみたいなライブラリだとpipeと読んでいる.どう呼ぶのか固定の名前がない気がするんだけど…….SFUサーバはそのままでは基本的にクラスタリングや負荷分散ができない.先に書いた通り,単に一つのピアであるので,こいつが複数に分裂するというのは想定されていない.だから,パフォーマンスはサーバのスペックである程度限界が決まってしまうし,耐障害性のために複数台用意しておくのも難しくなる.

こういう問題を回避するために,pipeとかrelayとかforwardingとか,そういう名前で表現される機能が必要になる.つまり,サーバAがクライアントaから受け取ったRTPパケットは,本来サーバAに接続したクライアント達しか視聴できない.しかしこれを,サーバBに接続したクライアントbが受信できれば,ある意味負荷分散になるのではないだろうか.クライアントが,サーバAに接続しようがサーバBに接続しようが,同じようにクライアントaが送信している映像が見えているのであれば,サーバとしては負荷分散ができている.この思想に基づいてRTPパケットを他サーバに転送できる仕組みも実装してある.

ここではrelayと名前をつけたけど,これはまだそこまで完成度が高くない. とりあえず思いついたものを作ってみたので,UDPを使ってサーバAのRTPパケットをサーバBに転送している.ただしここの通信はWebRTCスタックを利用していない.単にRTPパケットをUDPで送りつけているだけだ.もちろん,送信元や送信先を識別できるようなロジックにしてはいるのだが,DTLSになっていないし受信側もUDPサーバを一つ用意しているだけだ.つまり様々な宛先のRTPパケットが飛んでくる.この辺,パフォーマンスは未知数だ.

おそらくだが,このサーバ間のrelayもWebRTCにしてしまったほうが安定するような気がしている.ただ,いくつか懸念点がある. まずはサーバ間のためのシグナリングが必要になるという点だ.これはクライアントとのシグナリングとは別なので,シグナリングをライブラリ内に内包するのか?という疑問に最後まで答えが出なかった.使うならgRPCかなぁとは思っているのだが.

次にポートだ.WebRTCはUDP通信するときにポートを一つ消費する.そして基本的にはこのポートは使い回されない.つまりサーバ側がクライアントからRTPパケットを受け取るとき,サーバはポートを一つ消費している.このサーバに別のクライアントが接続してきたとき,またポートを一つ消費する.Linuxのポートは65535までしか存在しない上に,若い番号のポートは別の用途に使われるのでUDP通信では使えない.そのため,ポート的にも収容できるユーザに上限がある.さらにサーバ間のRTPパケットのやり取りでポートを消費していくと,これは結構な数のポートが消費されることになる.仮にサーバが受け取ったひとつの映像を10台にrelayするとしたら,そのサーバは受信するために1ポート,relayで送信するために10ポート使うことになる.そこにさらにクライアントが受信するためのポートも追加で消費する.これは結構大きい.

とはいえRTCPどうするか問題が残る

RFC 3550 - RTP: A Transport Protocol for Real-Time Applications

RTCPは受信側が送信側に対してフィードバックを送信するプロトコルで,だいたいのWebRTCにおいて受信側から送信側に送られてくる.ハンドリングするかどうかは好きにしたらいいが,とりあえず送られてくる.ただ,上記のようにrelayサーバ間はRTPパケットを送りつけているだけなので,逆方向の通信となるRTCPはハンドリングできていない.サーバAについては,クライアントaがサーバAにつながっているのでそこに送りつけてやればいいのだが,サーバBに関しては,サーバB -> サーバAという方向の通信を今のアーキテクチャで想定していないのでRTCPを送る方法がない. これを送ろうと思うと,結局双方向通信になってしまうので,UDPで双方向通信するくらいならWebRTC通信にしてしまったほうが楽なのだと思っている.

というわけでここは将来的に変えるかもしれない.

で,使い方とか

どうしてもシグナリングを含まないライブラリなので,シグナリングでwebsocketとかを絡めると使い方が複雑になる.ましてやクライアント側とのやりとりもあるのでね. なのでexampleを用意してあるので,だいたいこのフローでWebSocketサーバを作ると動くようになる.たぶんgRPCとか他のシグナリング方法でも普通に動く.

https://github.com/h3poteto/rheomesh/blob/master/sfu/examples/media_server.rs

対応するクライアント側のexampleがこれ.

https://github.com/h3poteto/rheomesh/blob/master/client/example/multiple/src/pages/room.tsx

まぁ一応対応するフローも全部READMEに書いてあるけど,結構むずいよね.

パフォーマンスとか

一切計測していないので未知数.ローカルで動かしている限りはパフォーマンス問題に出くわさないので,絶望的に悪くて使い物にならんという程ではない.ただ,どこまで行けるのかを確認するにも結構な数のクライアントを用意する必要があり,まだそこまでテストしていないだけ.これがk6だけでいけるかはまだちょっとやってないのでわからん.

また,先程書いたがrelayに関してはUDPのサーバがどの程度のパフォーマンスを出せるかわからない.こちらもサーバの台数を増やして,relayの数を増やしたときにどうなるのかをテストする必要がある.

ただ改善していきたいとは思うので,なんか見つけるたびに改善するとは思う.

そもそもなんでRustで書こうと思ったのか?

特に深い理由はないけど,書いたら面白そうなのがRustだったのと,こういう大量のパケット送りつけてそれを中継して誰かに送りつける,みたいな処理はRustで書いて十分な速さが出るだろうというパフォーマンスに対する信頼もあった. Goでも良かったかもしれないが,Goは結構書き飽きていた. Elixirという選択肢もあったが,これはもしかしたらそのうちやるかもしれん.ただ,同時並列処理に対する信頼感はあるものの,1プロセスですら大量のパケット処理が必要になるものに対してElixir(というかErlang)がどこまで速く処理できるかはちょっと疑問だ.若干この辺のパフォーマンスに対する信頼がない(落ちないという信頼はあるが).

あとがき

よくよく考えたら自作したけどOSSライセンスにしてしまったし公開しているし,これもまたOSSになってしまった…….

webrtc-rsはWebRTCのスタックを一通り実装しているが,元ネタはPionというGoのWebRTCスタックだ.

github.com

これをRustで再実装したものがwebrtc-rsになっている. 今回,SFUの部分だけを実装しているが,「いや,本当の再発明はWebRTCのスタック全部作るべきでは?」というのも思っていたので,もしかしたら俺は最終的にrheomeshのWebRTCスタック部分も再発明するのかもしれない…….本当にメリットなんて一切ないだろうけど,それはそれで面白いのだから良い.

WhalebirdとFedistarでPixelfedのサポートを開始した

Pixelfedは長らくOAuthのcode認証に問題があり,redirect_urlにurn:ietf:wg:oauth:2.0:oobを指定した場合,エラーで認証できなくなっていた. これがようやく解消され,

github.com

その後ちょっと問題はあったものの

github.com

現状のpixelfed.socialでは解消されている. これでようやくcode認証ができるようになって,oauth tokenが取れるようになった.

というわけで,megalodn側のPixelfedサポートを開始し,同時にWhalebirdとFedistarでもPixelfedが使えるようになっている.

制約とか

まぁ認証方法は揃えられたのでそこは問題なし.問題があるとすると,そもそもPixelfedはWebSocketによるストリーミングを提供していないので,そこに関連する機能が軒並み使えない.例えば通知とかね.タイムラインの自動更新とかね.これはWeb版でも存在していなかったので,実装されてないんじゃないかなぁ.ドキュメントは空っぽだった.

beta-preview.pixelfed.io

もしかしたらそのうち実装されるのかもしれない.

あと,代表的なものとしてリストが存在しない.そのためリスト関連の機能は全滅している.また,もともと写真を共有するSNSをコンセプトにしている都合上,添付ファイルなしの文字だけの投稿を受け付けていない.そのためこちらについてはバリデーションを追加する対応が必要だったりする.

まぁでもそんなところか.