記事の概要
UnityでWebRTCの映像が出せたよーと無邪気に書いたところ、思ったより大きな反響を頂いたので急ぎ解説記事を書きました。
あんな内部動作の説明もほぼない記事をいっぱいLikeしていただいてすいません。
ゆれるごーふぁーSkyWay WebRTC GW使ってブラウザからUnityに動画流し込み成功した。
— るんばにゃん (@arukakan) 2018年7月10日
リファクタしたらライブラリ化しよう。 pic.twitter.com/rFORJ55Dlc
「ライブラリ化して公開する」と書きましたが、異常系の実装がめんどう興味を持って頂けたようなので取り急ぎ内部構造をお見せしたほうがいいかなと思い、慌てて中身の解説をします。
ライブラリ化は時間をかけてじっくりやることにしますので、試してみたい方はGitHubにリポジトリ全体を公開していますのでこちらをご覧ください。
以下リポジトリの中身の解説です。
はじめに
本プロジェクトは複数の外部プロジェクトを利用していますので、単体では動作しません。
SkyWayのWebRTC GWを利用していますので、単体では動作しません。
試して見る場合はこちらからダウンロードして合わせてご利用下さい。UnityプロジェクトはSampleSceneを開いて実行して下さい。Inspectorの中にAPI Keyを入力する欄があります。
解説
Unityへの描画
先日の記事でご紹介しましたように、mrayGStreamerUnityという非常に便利なUnityプロジェクトがあります。
こちらはgStreamerで扱う映像をテクスチャにして取り出せるものです。
gStreamerはさまざまな設定で映像を扱うことができますが、udpsrcプラグインを使うとudpで映像を受け取って描画処理を開始することができるので、まずはそのための設定をmrayGstreamerUnityに入れます。
といってもmrayGStreamerUnity自体がよくできているので、UnityのInspectorで設定できるようになっており、以下のスクリプトを設定するだけです。
udpsrc port=7000 caps="application/x-rtp,paylocad=(int)96" ! rtpjitterbuffer ! rtph264depay ! avdec_h264 output-corrupt=false ! videoconvert ! appsink name=videoSink
これは、「7000番ポートで受信したudpパケットをRTPとして解釈して、H.264形式で圧縮されている中身の映像データを展開して取り出す」という意味のgStreamerのスクリプトです。
従って7000番portにH.264形式の動画をRTPで送りつけるのが今回のゴールです。
映像データの転送のためのコーディング
WebRTCで飛んでくるMediaStreamはSRTPのため、このまま流しこんでも再生できません。
これをSkyWay WebRTC Gatewayを使って剥いてやります。
大まかな流れは公式のシーケンス図を見て頂けると分かると思いますが、これらの一つ一つは単なるREST API ACCESSなので順番にやっていきます。
今回Media Call, Media Answerのフローを追っていきますが、Unity側から動画を送りたいわけではないので、動画を送るための手順は省いています。
全ての処理はUnity/Assets/Scripts/SkyWayRestApi.csに書いてありそれを順々に解説します。
SkyWay WebRTC Gateway(以下Gatewayと記載)はREST APIで操作できるようになっています。
まずGatewayをSkyWayサーバへ接続させます。
API Referenceを読むとPOST /peersを叩けばよいことがわかります。
以下がその処理です。
SkyWayサーバとの接続
var peerParams = new PeerOptions();
peerParams.key = key;
peerParams.domain = domain;
peerParams.peer_id = peerId;
peerParams.turn = turn;
string peerParamsJson = JsonUtility.ToJson(peerParams);
byte[] peerParamsBytes = Encoding.UTF8.GetBytes(peerParamsJson);
//(1)SkyWayサーバとの接続開始するためのAPIを叩く
ObservableWWW.Post(entryPoint + "/peers", peerParamsBytes).Subscribe(x =>
{
//(2)この時点ではSkyWay WebRTC GWが「このPeer IDで処理を開始する」という応答でしかなく、
//SkyWayサーバで利用できるPeer IDとは限らない(重複で弾かれる等があり得るので)
var response = Json.Deserialize(x) as Dictionary<string, object>;
var parameters = (IDictionary) response["params"];
var peer_id = (string) parameters["peer_id"];
var token = (string) parameters["token"];
//SkyWayサーバとSkyWay WebRTC Gatewayが繋がって初めてPeer ID等が正式に決定するので、
//イベントを監視する(3)
var url = string.Format("{0}/peers/{1}/events?token={2}", entryPoint, peer_id, token);
ObservableWWW.Get(url).Repeat().Where(wx =>
{
//この時点ではOPENイベント以外はいらないので弾く
var res = Json.Deserialize(wx) as Dictionary<string, object>;
return res.ContainsKey("event") && (string) res["event"] == "OPEN";
}).First().Subscribe(sx => //ここでは最初の一回しか監視しない。着信等のイベントは後で別の場所で取ることにする
{
var response_j = Json.Deserialize(sx) as Dictionary<string, object>;
var parameters_s = (IDictionary) response_j["params"];
//正式決定したpeer_idとtokenを記録しておく(4)
_peerId = (string) parameters_s["peer_id"];
_peerToken = (string) parameters_s["token"];
//SkyWayサーバと繋がったときの処理を始める
_OnOpen();//(5)
}, ex =>
{
//ここが発火する場合は多分peer_idやtoken等が間違っている
//もしくはSkyWay WebRTC GWとSkyWayサーバの間で通信ができてない
Debug.LogError(ex);
});
}, ex =>
{
//(e-1)ここが発火する場合はSkyWay WebRTC GWと通信できてないのでは。
//そもそも起動してないとか
//他には、前回ちゃんとClose処理をしなかったため前のセッションが残っている場合が考えられる。
//その場合はWebRTC GWを再起動するか、別のPeer IDを利用する
//時間が経てば勝手に開放されるのでそこまで気にしなくてもよい(気にしなくてもいいとは言ってない)
Debug.LogError("error");
Debug.LogError(ex);
});
- まずPOST /peersに投げるためのJSONオブジェクトを作って投げます(1)
- するとGatewayがまず応答してくるので応答を読みます(2)。この中には、Gatewayの中にpeer objectを作ったので今後このtokenを使ってアクセスして下さいとい情報が含まれています。もしこの時点でエラーが出る場合(e-1)はGatewayが動いていなかったりゴミが残っている場合が多いので再起動するとよいです
- 最初のGatwayの応答はあくまでGatewayが処理を開始したと言うだけです。ここからSkyWayサーバにつなぎにいくので、その応答を待ちます。/peers/{peer_id}/eventsをGETすればイベントが落ちてくるのでそれを待ちます(3)
- 接続に成功した場合は、SkyWayサーバが払い出したPeer IDが帰ってきます。基本的にユーザが指定したPeer IDがベースになっていますが、既に別のユーザが使っている場合は使えなかったり、Peer IDを指定せずにSkyWayサーバに繋いだ場合はランダムのIDが発行されるので、ここでPeer IDを確認するのがよいでしょう(4)
- ここまででSkyWayサーバへの接続処理が終わったので次に移ります(5)
SkyWayサーバと繋がった後の処理
SkyWayサーバと繋がってしまえば、あとはJS版とやることはそう変わりません。
private void _OnOpen()
{
//UnityのGUI処理をするためにイベントを返してやる
OnOpen();
//イベントを監視する
//今回は着呼イベントしか監視していないが、他にもDataChannel側の着信処理等のイベントも来る
//これはプログラム起動中はずーっと監視しておくのが正しい。なのでRepeatする。(1)
var longPollUrl = string.Format("{0}/peers/{1}/events?token={2}", entryPoint, _peerId, _peerToken);
ObservableWWW.Get(longPollUrl).OnErrorRetry((Exception ex) => { }).Repeat().Where(wx =>
{
Debug.Log(wx);
var res = Json.Deserialize(wx) as Dictionary<string, object>;
Debug.Log(res.ContainsKey("event"));
Debug.Log(res["event"]);
return res.ContainsKey("event") && (string) res["event"] == "CALL";
}).First().Subscribe(sx =>//今回はCALLイベントしか見る気がないので一回だけ処理できればいいが、複数の相手と接続するときはFirstではまずい
{
//相手からCallがあったときに発火。応答処理を始める(2)
var response = Json.Deserialize(sx) as Dictionary<string, object>;
var callParameters = (IDictionary) response["call_params"];
_media_connection_id = (string) callParameters["media_connection_id"];
//応答処理をする
_Answer(_media_connection_id);
}, ex => { Debug.LogError(ex); });
}
- SkyWayサーバへ接続したことで、相手側からの着信があり得る状態になりました。なので着信イベントを監視する必要が有ります。/peers/{peer_id}/eventsを監視します(1)
- 相手側から着信が合った場合は"CALL"イベントが発火します(2)ので応答処理を初めます。応答の時点でGateway内にMediaConnectionオブジェクトが生成され、それを特定するためのmedia_connection_idが払い出されるので今後それを用いてMediaConnectionを操作します
####相手側からの着信への応答処理
private void _Answer(string media_connection_id)
{
var answerParams = _CreateAnswerParams();
string answerParamsString = JsonUtility.ToJson(answerParams);
Debug.Log(answerParamsString);
byte[] answerParamsBytes = Encoding.UTF8.GetBytes(answerParamsString);
var url = string.Format("{0}/media/connections/{1}/answer", entryPoint, media_connection_id);
//SkyWay WebRTC GWのMediaStream応答用APIを叩く(1)
ObservableWWW.Post(url, answerParamsBytes).SelectMany(x =>
{
//この時点でSkyWay WebRTC GWは接続処理を始めている(2)
//発信側でやることはもうないが、相手側が応答すると自動で動画が流れ始めるため、
//STREAMイベントを取って流れ始めたタイミングを確認しておくとボタン表示等を消すのに使える
//あと応答の場合はmedia_connection_idはもう知っているので別にJSONをParseする必要はない
var eventUrl = string.Format("{0}/media/connections/{1}/events", entryPoint, media_connection_id);
return ObservableWWW.Get(eventUrl);
}).Where(x =>
{
//STREAMイベント以外はいらないのでフィルタ
var res = Json.Deserialize((string) x) as Dictionary<string, object>;
return (string) res["event"] == "STREAM";
}).First().Subscribe(//今回の用途だと最初の一回だけ取れれば良い
x =>
{
//ビデオが正常に流れ始める(3)
//今回はmrayGStreamerUnityで受けるだけだが、ビデオを送り返したい場合はこのタイミングで
//SkyWay WebRTC GW宛にRTPパケットの送信を開始するとよい
OnStream();
Debug.Log("video has beed started redirecting to " + answerParams.redirect_params.video.ip_v4 + " " +
answerParams.redirect_params.video.port);
}, ex => { Debug.LogError(ex); });
}
- 応答は/media/connections/{media_connection_id}/answerを叩いて行います。先程記録したmedia_connection_idを用いてアクセスします(1)。この時渡すJSONで飛んできたMediaStreamを剥いたRTPをどこに飛ばすかを指定できるので、127.0.0.1の7000番に飛ばすようJSONを書きます。
- とりあえず応答処理を開始する旨をGatewayが返してくるので必要な情報を抜き、実際にMediaStreamが流れ始めるのを待ちます。Media系のイベントは/media/connections/{media_connection_id}/eventsを監視するとよいです。
- MediaStreamが開始されるタイミングは"STREAM"イベントでわかるのでこれを待ちます(3)。STREAMイベントは正確にはSRTPパケットではなくその前のSTUNパケット到着のタイミングで発火するので、両側で実際に映像を流さなくても発火します。映像を流し始めるタイミングが指定できる場合はこのタイミングまで待ってから流し込むのがよいでしょう。今回は映像を受信するだけなので特に何もしません。この時点でUnityのテクスチャに映像が表示されます。
着信したものに応答する場合はこれで終わりです。
####発信処理
次は自分から発信する処理です。もっと簡単です。
public void Call(string targetId)
{
var callParams = _CreateCallParams(targetId);
string callParamsString = JsonUtility.ToJson(callParams);
byte[] callParamsBytes = Encoding.UTF8.GetBytes(callParamsString);
//SkyWay WebRTC GWのMediaStream確立用APIを叩く(1)
ObservableWWW.Post(entryPoint + "/media/connections", callParamsBytes).SelectMany(x =>
{
var response = Json.Deserialize(x) as Dictionary<string, object>;
var parameters = (IDictionary) response["params"];
_media_connection_id = (string) parameters["media_connection_id"];
//この時点でSkyWay WebRTC GWは接続処理を始めている(2)
//発信側でやることはもうないが、相手側が応答すると自動で動画が流れ始めるため、
//STREAMイベントを取って流れ始めたタイミングを確認しておくとボタン表示等を消すのに使える
var url = string.Format("{0}/media/connections/{1}/events", entryPoint, _media_connection_id);
return ObservableWWW.Get(url);
}).Where(x =>
{
//STREAMイベント以外はいらないのでフィルタ
var res = Json.Deserialize((string) x) as Dictionary<string, object>;
return (string) res["event"] == "STREAM";
}).First().Subscribe(//今回の用途だと最初の一回だけ取れれば良い
x =>
{
//ビデオが正常に流れ始める(3)
//今回はmrayGStreamerUnityで受けるだけだが 、ビデオを送り返したい場合はこのタイミングで
//SkyWay WebRTC GW宛にRTPパケットの送信を開始するとよい
OnStream();
Debug.Log("video has beed started redirecting to " + callParams.redirect_params.video.ip_v4 + " " +
callParams.redirect_params.video.port);
}, ex => { Debug.LogError(ex); });
}
- 接続処理は/media/connectionsを叩くとよいです(1)。相手側から流れてきた映像の転送先はこの時に指定できるので、JSONに書き加えます。
- これまたGatewayが処理を初めてきた旨を伝えてくるので内容を確認したあと実際に流れ始めるのを待ちます(2)。先ほどと同じく/media/connections/{media_connection_id}/eventsを監視しますが、media_connection_idはこの時帰ってくるJSONに含まれています。
- STREAMイベントが発火したら成功です(3)
切断、解放処理は/media/connections/{media_connection_id}と/peers/{peer_id}にDELETEメソッドでアクセスすればよいです。ただ今回利用したObservableWWWにDELETEメソッドが無かったので実際にはやってないです…
Unityとの通信相手
WebRTCクライアントであれば基本的に何とでも通信できます。ブラウザ, iOS, Android, SkyWay WebRTC GWを利用したUnityやその他のプログラムと通信できます。
プロジェクトにはブラウザで実行するためのファイルも含んでいるので、
$python -m SimpleHTTPServer 9000
か何かして動かして下さい。但しブラウザによってはHTTPSではないとカメラ映像が取れないものがあるのでご注意下さい。あとSkyWayのAPI Keyを編集するのを忘れずに。
SkyWay WebRTC Gatewayを利用する場合は、公式リポジトリのRubyスクリプトで試すのが簡単です。media_calleeまたはmedia_callerを試すとよいでしょう。
Rubyスクリプトでポート開放した場合、以下のようにgStreamerでRTPパケットを流し込むとよいです。
#!/bin/sh
gst-launch-1.0 v4l2src device=/dev/video0 ! videoconvert ! video/x-raw,width=640,height=480,format=I420 ! videoconvert ! x264enc bitrate=8000 pass=quant quantizer=25 rc-lookahead=0 sliced-threads=true speed-preset=superfast sync-lookahead=0 tune=zerolatency ! rtph264pay ! udpsink port=50001 host=127.0.0.1 sync=false
video device idについては各自確認して適当な値を入れて下さい。
#今後の展望について
きれいにまとめてUnity Assets Storeに出せるといいなぁ…
でもこの記事を読んだ他の方が作ってくださるのも大歓迎なので、作ったらぜひ教えて下さい。
gStreamer自体はAndroidでも動くので、Android化もしたい。そうするとOculus GoでUnity & WebRTCできるようになるのでは。