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

以前WHIPについてのサポート記事を書いた.

h3poteto.hatenablog.com

これの続きで,ようやっとWHEPのサポートを開始した.

こちらは0.8.0から入っている.

github.com

WHEPとはこういうやつ

www.ietf.org

WHIPと同じくWebRTCのシグナリングをHTTPでなんとかしたい話の,受信側のプロトコルで,HTTPだけで映像を受信できるようなシグナリングをしたいという理解.

プロトコルとしてはHTTPエンドポイントを作るだけだが,WHIPとは方向が真逆

配信側は,クライアントからHTTPエンドポイントを叩いて,SDPを送りつけるところからスタートした.これはWebSocketを使うときと同じで,基本的にクライアントサイドで送りたい映像や音声を混ぜ込んだSDPを作れる.これをサーバに送りつけることで,配信の初期段階がスタートする.

しかし,受信の場合,メディアの情報はサーバ側にしかないわけで,WebSocketではSDP生成はサーバ側で行っていた.というか大抵のSFUサーバは,subscriberに対してSDPを送りつける形を取る. HTTPのエンドポイントを用意するだけだと,サーバ側からクライアント側に一方的に送りつけることができない.ので,WHEPではクライアントからメディア情報が入っていないSDPを送りつけ,サーバで不足しているメディアの情報を埋めてから送り返すというフローを取る.こうすることで,通常のHTTPサーバのままSDPのやり取りを開始することができる.

で,その先はWebSocketの場合とそこまで変わらないし,用意するエンドポイントもWHIPとそこまで大差ない.

  • POST /whep/{session_id}/{publisher_id} - 空のSDPを受け取り,publisher_id で指定されたpublisherが持っているtrack情報を埋めたSDPを返す
  • PATCH /whep/{session_id} - Trickle ICEのSDPを受け取る
  • DELETE /whep/{session_id} - WHEPのセッションを終わりにする

最初のPOSTエンドポイントだけ,publisher_id を受け取る.これは,どのメディアをsubscribeしたいかの指定をする場所がないので,エンドポイントのパスに入れている.

セッションの概念はWHIPと同じ.今回は,subscribe_transportを用意する必要があるので

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

struct Session {
    session_id: String,  // Or user_id.
    subscribe_transport: Arc<SubscribeTransport>
}


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

こういうメソッドを実装してやれば良い.

使い方

actix-webを使っている場合は,endpointを突っ込むだけ.これはWHIPと同じ.

#[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 = WhepEndpoint::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つのエンドポイントが自動的に生える.

WHEPはサーバ側のプロトコルを定義しているだけなので,クライアント側はこのプロトコルに従ってくれていれば,好きに作ってもらって問題ない.

ただ,一応サンプルは用意しているので,この辺を参考にしてもらうとだいたい何をすればいいか理解できると思う.

github.com

追加でのサポート予定

WHIPのところでも触れたが,現状actix-webへの差し込みしかサポートしていない.これを将来的に別のwebサーバに対しても差し込めるような関数を用意することは考えている.

また,WHEPのProtocol extensionsには

  • Server Sent Events extension
  • Video Layer Selection extension

の項目があり,これらはサポートしたいと思っている.RheomeshはSimulcastもSVCもサポートしているが,現状WHEPだとレイヤー選択をする口がないので.

また,これはWHIP・WHEP共通の話だが,

  • STUN/TURN server configuration
  • Authentication and authorization

あたりもサポートしたいと思っているよ.

Linuxデスクトップ向けのランチャーを作った

ずっとulauncherを使っていたんだけど,何度目かのこれ

github.com

で,毎回pythonバージョンが変わるたびに

$ yay -S ulauncher --answerclean All

をやり続け,いい加減嫌になった.そもそもランチャーが起動しないと色々アプリが起動できなくて困るわけで,こんなところでpython依存はもう嫌.ブチギレ.python嫌いだわ.

というわけでランチャーを自作した.こんなもん.desktopファイル読み込んで検索させるだけやろ. 面白そうだからRustで書く.

github.com

はい.

XでもWaylandでも動くよ

ちょうどWaylandの環境を少しずつ整えているところだったので,XもWaylandも両方対応するようにしたかった.とはいえGTK4でウィンドウを作る部分はXにもWaylandにも依存していない. 面倒なのはショートカットで,グローバルのショートカットを取ろうと思うとウィンドウシステムにかなり依存する.ランチャーだと,たいていグローバルのショートカットキーから起動したくなることが多いと思う.ここで,ウィンドウシステムごとの差異が生まれている.

Xの方はx11rbを使えば割と色々できたりするので,ここからグローバルなショートカットを登録できた.

Waylandの方は,Waylandがそんな機能を提供してくれないので諦めた. いや,GNOMEやKDEは知らんけど,swayとかhyprlandとか使ってる人にとって,グローバルのショートカットを定義するのはそんなに難しい話じゃないでしょ.ということはデーモン化したプロセスに対して,ウィンドウ表示のtoggleだけあればいいでしょ.

つまりsway configに

exec sleep 2 && rauncher

bindsym Control+space exec rauncher toggle

こう書ければいいでしょ.

なのでそう書いてくれれば問題ない.

設定とか

設定画面なんておしゃれなものを求める人はこんなアプリを使うはずがないのでそんなものは作っていない.tomlを書け.

で,初回rauncherを起動すると ~/.config/rauncher/config.toml が生成される.これがデフォルト設定だ.

[hotkey]
key = 102         # You can check the keycode with xev.
modifier = "ctrl" # "ctrl", "shift", or "alt"

[[custom_search]]
name = "Google"
exec = "https://www.google.com/search?q=%q"
icon_name = "web-browser"
default_search = true

ちなみに先程書いたが,hotkey の項目はXでのみ有効化される.Waylandの場合はこの項目に何を書いても無視される.

この状態で,i3とかswayとかhyprland等の設定に

exec sleep 2 && rauncher

こうやって書いてもらえれば,デーモンとして常駐してくれる.

今後

流石に設定項目が少なすぎる.もうちょっと色々設定できるようにしたいとは思っている.

あと, custom_search については,今のところ追加してもまったく意味がないのだが,好きな検索を追加できるようにしたいとは思っているよ.

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をもうちょっと改善しようと思っている.