きっかけは、"カメラ配信でも遅延を1秒未満にしたい!"
さて、前回はWebTransportというものを使い、サーバーからMP4の動画を0.5秒程度の遅延で配信するというのをやりました。
これはこれで所謂オンデマンドストリーミング配信なので良いのですが、
実際にはカメラなどでライブ配信し、その遅延も1秒未満にしたい、というところにありました。
構成図を書くとこんな感じですが、遅延は5秒でした。これでは超低遅延とは申せません。
ffmpegではパラメータ調整に限界がある
ffmpegはとても良くできていて公式マニュアルにこれでもかと情報が載っていますし、
ニコラボさんをはじめ、日本語の情報もたくさんあります。ググれば大体何とかなるのではないでしょうか?
さて今回調べていたところ、打ち上げ時に -f mpegts
とすると5秒だったのに対し -f flv
とすると3秒程度になることがわかりました。
とはいえ SRTで配信するのでOBS Studioなどでは mpegts
で配信されることになります。
そのためどうもmuxするところのバッファを短くすれば良さそうです。早速オプションを調べてみますが。。なさそうです。
あとはソースコードを読んでいくしかないのですが、それだったら最初からプログラムすることを前提として開発されているGStreamerを使った方が良いのでは思いました。
そこでGStreamerの出番
前置きが少し長くなりましたが、GStreamerとはプログラムすることを前提としたメディアフレームワークです。
ffmpegもPythonでOpenCVと組み合わせて使えたりしますが、主な用途はコマンドラインツールのようにたくさんのオプションをつけて実行する使われ方が多いと思います。
対してGStreamerは予め決められた規則に従ってプラグインを組み合わせ、一つのパイプラインとしてプログラミングさせるように作れているようです。
名前にストリームと入っている通り、Unixのコマンドラインのようにパイプでデータを渡して処理する設計思想です。
GoogleのMeetで仮想背景を処理するのに使われているMediaPipeや、メディアサーバーのKurentoなど、影響を受けたものも多いようです。
これを使えばバッファや遅延の調整はできそうですし、各要素がプラグインとなっているので必要な箇所だけ修正するか作るかすれば良さそうです。
日本語の情報が少なく、学習コストが高い
公式マニュアルには色々と概念からチュートリアルまで一通り書いてあるのですが、イマイチよくわかりません。
チュートリアルをやって、マニュアルを見て、またチュートリアルをやって、と何度か繰り返しているとわかってくるのかもしれません。
ffmpegはたくさん情報があるのにGStreamerになると途端に情報が少なくなります。
日本語の情報
私が参考にしたサイトはこの辺りでしょうか
特に上記の方のGStreamerでテキストを処理する話については仕組みが理解できそうなのでRustでもやってみたいと思います。
海外の情報
公式マニュアルとチュートリアルはもちろん、GStreamer + Rust でメディアサーバーを作るというドンピシャなライブコーディングがありました。
(↓RTMP Switcherを作る話ですが全部で数十時間くらいあります😱)
環境構築
環境構築についてはとても簡単で、様々なディストリービューションのパッケージマネージャーにて管理されているようです。
むしろソースコードコンパイルの情報が少ないのが不思議ですが単にその必要が少ないだけなのかもしれません。
Rustでの開発用にはDockerfileを書きましたが、コンテナで動かすとGUIでの確認ができなかったりするので一旦はローカルマシンにインストールして触ってみるのが良いと思います。
$ brew install gstreamer \
gst-libav \
gst-plugins-bad \
gst-plugins-base \
gst-plugins-good \
gst-plugins-ugly
# みんな大好きBigBuckBummyをダウンロードする
$ wget https://mirror.clarkson.edu/blender/demo/movies/BBB/bbb_sunflower_1080p_30fps_normal.mp4
プログラミングの前に gst-launch-1.0 を理解する
GStreamerの本来の目的はプログラムを組むことにあるわけですが、そもそも概念を理解していないといけません。
ffmpegと同じようにコマンドラインツールとして使える gst-launch-1.0
というバイナリが標準で入っています。
ただ、公式としてはこれはデバッグ用ツールとのことです。
gst-launch-1.0の情報はググれば日本語でもわりと出てきはしますが、
videoの処理だけでaudioの処理が入ってないとか、とかく応用が効かないことが多いのです。
そんなわけでまず gst-launch-1.0
のコマンドがさっと頭に思い浮かぶようにならないとプログラムを書くどころではなさそうです。
参考までに、次の例はSRTで受信した動画・音声をDASHにするというだけのコマンドですが、これを書くだけで2日かかったような気がします。
gst-launch-1.0 -v srtserversrc uri="srt://:4201?mode=listener" ! tsdemux name=demux \
demux. ! queue ! h264parse ! dash.video_0 \
demux. ! queue ! audio/mpeg ! aacparse ! dash.audio_0 \
dashsink name=dash mpd-root-path=/media muxer=ts dynamic=true target-duration=1
# なお、target-duration=1 (guint) は秒数を指定するのでdashsinkのソースを書き換えないと遅延を1秒未満にできない
h264parse
とか audio/mpeg
などこれらを指定しないと動きません。(一応、自動判別するためのプラグインも用意はされています)
GStramer基本概念
ここでは概念を一つずつ洗い出していきたいと思います。
パイプライン(pipeline)
GStreamerの実行単位をパイプラインと言います。
usage:
gst-launch-1.0 [option] PIPELINE_DESCRIPTION
パイプラインには状態(ステート)があります
プレイヤーを作る場合などは理解が必要と思われますが、ストリーミングでは流しっぱなしなのであまり意識する必要はありません。
動いているかそうでないかくらいです。真面目にエラー処理やる場合は必要かもしれません。
マニュアル
$ gst-launch-1.0 fakesrc ! fakesink
パイプラインを一時停止 (PAUSED) にしています...
Pipeline is PREROLLING ...
Pipeline is PREROLLED ...
パイプラインを再生中 (PLAYING) にしています...
Redistribute latency...
New clock: GstSystemClock
^Chandling interrupt. <- Ctrc + C で終了
割り込み: パイプラインを停止しています...
Execution ended after 0:00:04.069181000
Setting pipeline to NULL ...
Freeing pipeline ...
エレメント(element)
パイプラインは複数のエレメントからできています。
gst-launch-1.0 ではエレメントを !
で繋いて記載します。
$ gst-launch-1.0 element1 ! element2 ! ... ! elementN
Elementにはいくつか種類があり、srcで始まりsinkで終わります。
# 最小限の何もしないパイプライン
$ gst-launch-1.0 fakesrc ! fakesink
# テスト映像を表示する
$ gst-launch-1.0 videotestsrc ! autovideosink
# カメラの映像を表示する
$ gst-launch-1.0 avfvideosrc ! autovideosin
# テスト音声を再生する? (↓これは動かないんです 正しいコマンドは次の項で)
$ gst-launch-1.0 audiotestsrc ! autoaudiosink
テスト映像についてはわかりやすくて良いですね。色々パラメータがあるので試してみると面白いです
テスト音声については早速GStreamerらしいところが出てきました。一見動きそうですが動きません。
Elementのsrcとsinkの間はfilterで繋ぎます
# テスト映像に現在時刻を描画する
$ gst-launch-1.0 videotestsrc ! clockoverlay auto-resize=false time-format="%Y-%m-%d %H:%M:%S" ! autovideosink
# 1280 x 720 にする
$ gst-launch-1.0 avfvideosrc ! video/x-raw,width=1280,height=720 ! autovideosink
# テスト音声を再生する(音が鳴ります audioconvertについては後述)
$ gst-launch-1.0 audiotestsrc ! audioconvert ! autoaudiosink
# マイクの音声を再生する
$ gst-launch-1.0 autoaudiosrc ! audioconvert ! audioresample ! autoaudiosink
現在時刻表示については典型的なフィルタですね。
音声についてはフォーマット(caps
とかcapability
とかと呼ばれるもの)を合わせるためのものです。これを押さえておかないとパイプラインが組めません。詳しくは後述
(少し寄り道)GStreamerはオブジェクト指向です
上記の clockoverlay
で auto-resize=false
というプロパティを設定しました。
ところがマニュアルをみてみるとプロパティは time-format
しかありません。
なぜでしょう? フォントサイズやら表示位置やら色々なプロパティがあっても良いはずです。
答えはHierarchy
のセクションにあります。
GObject
╰──GInitiallyUnowned
╰──GstObject
╰──GstElement
╰──GstBaseTextOverlay
╰──clockoverlay
これはGstBaseTextOverlayを継承しているため、それが持つパラメータはそのまま使えるのでした。
このオブジェクト思考であるというところは、今後プラグインを自分で作るにあたって重要になってきます。
さらにfilterには mux / demux / tee / queue / capsfilter などがあります!
demuxはコンテナからデータを分離します
通常はファイルや受信したデータを映像と音声に分けて処理をします。
それぞれ別のスレッドになるため queue
を挟む必要があります。(入れないとパイプラインが動きません)
# BigBuckBummyを再生する
$ gst-launch-1.0 filesrc location="./bbb_sunflower_1080p_30fps_normal.mp4" ! qtdemux name=demux \
demux. ! queue ! avdec_h264 ! autovideosink \
demux. ! queue ! mpegaudioparse ! avdec_mp3 ! audioconvert ! audioresample ! autoaudiosink
# SRTで受信したデータをDASHで書き出す
$ gst-launch-1.0 -v srtserversrc uri="srt://:4201?mode=listener" ! tsdemux name=demux \
demux. ! queue ! h264parse ! dash.video_0 \
demux. ! queue ! audio/mpeg ! aacparse ! dash.audio_0 \
dashsink name=dash mpd-root-path=/media muxer=ts dynamic=true target-duration=1
ちなみにRTMPなら flvdemux
を使うことでしょう。
(ちゃんと音声付きで再生される)
さて、エンコード・デコードが絡んでくると *convert
だの *parse
だの色々出てきます。
ひとまずfilterを先に一通り書いときます。
muxはデータをコンテナ化します
demuxの反対でデータをコンテナ化します。映像・音声を組み合わせことも多いですが、単純に映像だけ・音声だけを個別にコンテナ化してファイル書き出すことも可能です。
# カメラとマイクを一つのMP4ファイルに録画する
$ gst-launch-1.0 -e autovideosrc ! videoconvert ! x264enc tune=zerolatency speed-preset=ultrafast bitrate=500 ! h264parse ! queue ! mux. \
autoaudiosrc ! audioconvert ! audioresample ! faac bitrate=192000 ! queue ! mux. \
qtmux name=mux ! filesink location="./cam_mic.mp4"
カメラの映像だけを保存するコマンド例は結構見かけるのですが、マイクも合わせて保存する例がありそうでないんです。
しかも -e
オプションをつけないとファイルが破損します。
# -e オプションは終了時にストリームの終端であることをプラグインに通知し、その結果ファイルのヘッダーがきちんと書き込まれます。
-e, --eos-on-shutdown Force EOS on sources before shutting the pipeline down
teeは映像や音声スレッドを複製します
すみません。あまり使ったことないのと、こちらの記事がよく纏まっています。
capsfilterはcapabilitiesを設定します
はい、なんのこちゃよくわからないので次で詳しく説明します。
audio/mpeg
とか video/x-raw,width=1280,height=720``h264parse
とかそういうやつです。
今回の山場、PadとCapabilitiesはどこにどういうデータが流れるか指定します
まず、映像や音声など一つのストリームが流れる出入り口を Pad
と言います。
エレメントは複数のPadを持つことがあり、入口がsink
出口がsrc
となります。
(sinkは凹んだ形、データの流れを受け止めるところ。srcは源、データの流れが湧き出すところ。とイメージすると覚えやすいかもしれません)
なぜ、GStreamerのコマンドを適当に書くと動かないのでしょうか。
それは各エレメントがPad
とCapabilities
と流れるデータが一致するように求めるためです。
基本的なエレメントはpadを一つ持ちます
例えば、filesrc
やfilesink
、ダミーデータを流すだけの fakesrc
やfakesink
にはどんなデータが流れても良いわけです。
データが破損してようがランダム文字列であろうが、ただ読み書きさえすれば良いわけです。
ただし、複数のストリームを扱うことは想定していないのでPadは一つしか持ちません。
$ gst-inspect-1.0 filesink
...
Pad Templates:
SINK template: 'sink'
Availability: Always
Capabilities:
ANY
padはsink padが一つあるだけです。
映像と音声の二つの入力を渡されても書き込み順番はわからないのでそういう処理はmuxに任せることになります。
流れるデータは何でも良いので Capabilities: ANY
となっています。
filterエレメントの多くもやはり受け取ったデータを加工して次に渡すだけなので sink pad
一つとsrc pad
一つを持ちます。
mux / demux は複数のpadを持ちます
demuxは一つのコンテナから複数の映像や音声を分離するため、複数のsrc pad
を持ちます。
反対にmuxは複数の映像や音声から一つのコンテナ化されたデータを出力するので複数のsink pad
を持ちます。
capabilitiesとは受け入れ可能なフォーマットのこと
先ほど例にあげた filesrc
やfilesink
についてはどんなデータがでも良いわけですが、
clockoverlay
や再生するためのautovideosink
などは予めpadを流れるデータがどういうフォーマットか知っていなければなりません。
また、mux
やdemux
は形式さえ分かっていればよく、詳細なフォーマットまでは知る必要はありません。
それらの情報はプラグインに書いてあります。
例えば、qtmuxであればMP4に準じたデータである必要がありますが、分離したデータについては何が入っていても良いというわけです。
$ gst-inspect-1.0 qtdemux
SINK template: 'sink'
Availability: Always
Capabilities:
video/quicktime
video/mj2
audio/x-m4a
application/x-3gp
# srcについてはどんなデータでも良い
SRC template: 'video_%u'
Availability: Sometimes
Capabilities:
ANY
...
反対にclocloverlay
ではデコードされたデータである程度のフォーマットである必要があります。
$ gst-inspect-1.0 clockoverlay
...
SINK template: 'video_sink'
Availability: Always
Capabilities:
video/x-raw
format: { (string)ABGR64_LE, (string)BGRA64_LE, (string)AYUV64, (string)ARGB64_LE, (string)ARGB64, (string)RGBA64_LE, (string)ABGR64_BE, (string)BGRA64_BE, (string)ARGB64_BE, (string)RGBA64_BE, (string)GBRA_12LE, (string)GBRA_12BE, (string)Y412_LE, (string)Y412_BE, (string)A444_10LE, (string)GBRA_10LE, (string)A444_10BE, (string)GBRA_10BE, (string)A422_10LE, (string)A422_10BE, (string)A420_10LE, (string)A420_10BE, (string)RGB10A2_LE, (string)BGR10A2_LE, (string)Y410, (string)GBRA, (string)ABGR, (string)VUYA, (string)BGRA, (string)AYUV, (string)ARGB, (string)RGBA, (string)A420, (string)AV12, (string)Y444_16LE, (string)Y444_16BE, (string)v216, (string)P016_LE, (string)P016_BE, (string)Y444_12LE, (string)GBR_12LE, (string)Y444_12BE, (string)GBR_12BE, (string)I422_12LE, (string)I422_12BE, (string)Y212_LE, (string)Y212_BE, (string)I420_12LE, (string)I420_12BE, (string)P012_LE, (string)P012_BE, (string)Y444_10LE, (string)GBR_10LE, (string)Y444_10BE, (string)GBR_10BE, (string)r210, (string)I422_10LE, (string)I422_10BE, (string)NV16_10LE32, (string)Y210, (string)v210, (string)UYVP, (string)I420_10LE, (string)I420_10BE, (string)P010_10LE, (string)NV12_10LE32, (string)NV12_10LE40, (string)P010_10BE, (string)Y444, (string)RGBP, (string)GBR, (string)BGRP, (string)NV24, (string)xBGR, (string)BGRx, (string)xRGB, (string)RGBx, (string)BGR, (string)IYU2, (string)v308, (string)RGB, (string)Y42B, (string)NV61, (string)NV16, (string)VYUY, (string)UYVY, (string)YVYU, (string)YUY2, (string)I420, (string)YV12, (string)NV21, (string)NV12, (string)NV12_64Z32, (string)NV12_4L4, (string)NV12_32L32, (string)Y41B, (string)IYU1, (string)YVU9, (string)YUV9, (string)RGB16, (string)BGR16, (string)RGB15, (string)BGR15, (string)RGB8P, (string)GRAY16_LE, (string)GRAY16_BE, (string)GRAY10_LE32, (string)GRAY8 }
width: [ 1, 2147483647 ]
height: [ 1, 2147483647 ]
framerate: [ 0/1, 2147483647/1 ]
...
convert resample parse はフォーマット変換エレメント
時折出てきた videoconvert
やaudioresample
などは細かいフォーマット変換のためのエレメントです。
各エレメントの間で、同じ生データだけれども画像のフォーマットやサンプリングレートが異なるときに変換してくれるものです。
まとめ
このほかにもbinやらバッファやら色々あるのですが、ひとまずcaps
がわかればパイプラインは書けるかなと思います。
本当はRustのプラグイン書いたところまでまとめようと思いましたがまだまだ時間がかかりそうです。
それでは次回はRustでシンプルなスケルトンからチュートリアルをなぞる感じでまとめていこうと思います。