WebRTC
SkyWay

WebRTCで快適な画面共有を実現する

More than 1 year has passed since last update.

はじめに

この記事は、Multitrackを利用して、ビデオチャット+画面共有を行う方法を解説するものです。

MediaChannlel利用時の課題

御存知の通り、WebRTCのMediaChannlel機能を用いればビデオチャットが簡単に実装できます。しかしながら、複数のビデオをやり取りしたり、画面共有を合わせてやりたいなど、アプリケーションの要件次第で、実装は複雑に、そしてクライアント端末にかかる負荷(CPU)が高くなるという問題があります。

イメージでその様子をみてみます。

  • 1:1でビデオチャット

スライド1.jpg

  • 1:1でビデオチャット+画面共有

スライド2.jpg

  • 4人フルメッシュでビデオチャット

スライド3.jpg

  • 4人フルメッシュでビデオチャット+画面共有(3本のMediaStreamが追加になる)

スライド4.jpg

そもそも、フルメッシュはきついので、MCUとかSFU使おうよというのは、今回は無しで。

Multitrackを活用する

Multitrackを活用することで、接続にかかるオーバーヘッド等を軽減することが可能になり、端末への負荷が多少軽減されます。(通信帯域自体は変わりません)
このMultitrackですが、実は皆さん意識せずに使っています。先ほど示した図中にVideoTrackとAudioTrackという部分がありますが、まさにそれがMultitrackによって実現されているのです。

Multitrackを利用して同時に画面共有のストリームを送ると、こんな感じになります。

スライド5.jpg

Multitrackの各ブラウザの実装状況や仕様などは、がねこさんのスライドが参考になります。
− WebRTC multitrack / multistream@mganeko

Trackを操作する

前提条件

  • サンプルコードの実装にはWebRTCプラットフォーム SkyWaySkyWay-ScreenShare を利用します
  • 両プロダクトの詳しい使い方については割愛します
  • 動作検証に利用するブラウザは 2016/5/17現在のChrome M50(Stable版)とします

ブラウザネイティブAPIの確認

Trackを操作するために必要なAPIは以下のとおりです。

stream.getTracks()
stream.getVideoTracks()
stream.getAudioTracks()
stream.addTrack()
stream.removeTrack()

MediaStreamを作成するためには以下のAPIを利用します。(まだベンダープレフィックスが付いてます)

new webkitMediaStream()

送り側(例)

        var localStream;
        $('#start-screen').click(function () {
            if(screen.isEnabledExtension()){
                screen.startScreenShare({
                    // 省略
                },function (stream){
                        // ビデオチャット用のMediaStreamにVideoTrackがいくつ含まれているかをチェック
                        if(localStream.getVideoTracks().length == 2){
                            // 既に2つのトラックが含まれる場合は、2つめのトラック(ScreenShare)を削除
                            localStream.removeTrack(localStream.getVideoTracks()[1]);
                        }
                        // 取得したScreenShareのトラックからVideoトラックを取り出す
                        var _SCTrack = stream.getVideoTracks()[0];
                        // ビデオチャット用のMediaStreamにトラックを追加する
                        localStream.addTrack(_SCTrack);
                        // 例:追加したMediaStreamを相手に送信
                        var call = peer.call(_peerid, localStream);
                        step3(call);
                    }
                },function(error){
                    // 省略
                },function(){
                    // 省略
                });
            }else{
                    // 省略
            }
        });

受け側(例)

        call.on('stream', function (stream) {
            // 受け取ったMediaStreamのトラック数を調べる
            var _tracklengs = stream.getVideoTracks().length;
            if( _tracklengs == 2){
                // トラック数が2つの場合、Video要素に渡すための空のMediaStream objectを作成する
                var _peerVideo = new webkitMediaStream();
                var _peerScreen = new webkitMediaStream();
                // 作成した2つの空のMediaStream Objectに、それぞれビデオとスクリーンシェア用のTrackを追加する
                _peerVideo.addTrack(stream.getVideoTracks()[0]);
                _peerScreen.addTrack(stream.getVideoTracks()[1]);
                // それぞれのMediaStreamをVideo要素にアタッチする
                attachMediaStream($('#their-video')[0],_peerVideo);
                attachMediaStream($('#their-screen')[0],_peerScreen);
            }else {
                attachMediaStream($('#their-video')[0],stream);
            }
        });

これだけで、ビデオチャットの映像、音声、画面共有のTrackを1本のMediaStreamで扱うことが可能になります。
注意点としては、ビデオチャットの映像のTrackと画面共有のTrackについては、相手側で識別できません。(同じVideoStreamTrackになる)

  • 送信元(一番下がスクリーンシェアのTrack)

スクリーンショット 2016-05-17 15.21.09.png

  • 受信側(スクリーンシェアのTrackのみ)

スクリーンショット 2016-05-17 15.21.21.png

id(label)でTrackを識別することはできますが、VideoTrackと区別がつきません。複雑なアプリケーションを開発する場合は、上手くハンドリングする必要があります。

MediaStreamはそのままにTrackを操作する

お気づきの方もいらっしゃると思いますが、上述したプログラムでは、スクリーンシェアを行うたびに結局MediaStreamを作成しています。つまり、Track操作を行うたびにMediaStreamを破棄して作りなおす必要が出てきます。ここからはもっとシンプルに、MediaStreamはそのままに、含まれるTrackの操作を行う方法を解説します。

処理の流れ

これを実現するには、offer/answerを都度実行してあげる必要があります。おおまかな処理の流れは以下のとおりです。

qiita_image_2.jpg

尚、この図の動作はChrome限定です。Firefoxではこのままでは動きません。(未検証)また、現在標準化の中でAPIの策定を行っている最中であり、Chromeについても将来的に変更される可能性が高いです。ご注意ください。

Peer.jsの実装例

この処理を実現するには、Peer.jsに手を入れる必要が出てきます。
まず、必要なメソッドを定義します。

MediaConnection.update()

MediaConnection.update(MediaStream);
  • 引数のMediaStreamを用いてSDPの再交換要求を出す
  • 処理フロー
    1. createOffer()
    2. setLocalDescription()
    3. socket.send(type:update)

MediaConnection.updateAnswer()

MediaConnection.updateAnswer();
  • update要求に対して回答を出す
  • 'update' イベントのCallbackで実行する
  • 処理フロー
    1. setRemoteDescription()
    2. createAnswer()
    3. setLocalDescription()

次に、必要なイベントを定義します。

Peer.on('update')

peer.on('update',function(mediaConnection){....});
  • リモートのpeerがUpdateを行った時に発生します。
  • 処理フロー
    1. socket.receive(type:update)
    2. イベント発火

diffはこんな感じです。

diff --git a/lib/mediaconnection.js b/lib/mediaconnection.js
index xxxxxxx..xxxxxxx xxxxxxx
--- a/lib/mediaconnection.js
+++ b/lib/mediaconnection.js
@@ -94,4 +94,22 @@ MediaConnection.prototype.close = function() {
   this.emit('close')
 };

+/**
+ * Supported Multitrack operation
+ */
+
+MediaConnection.prototype.update = function(stream) {
+
+  util.log("Starting re-exchange of SDP");
+  Negotiator.reExchange(this);
+
+};
+
+MediaConnection.prototype.updateAnswer = function() {
+
+  util.log("re-exchange of SDP from answer step");
+  Negotiator.handleSDP('UPDATE', this, this.options._payload.sdp);
+
+};
+
 module.exports = MediaConnection;
diff --git a/lib/negotiator.js b/lib/negotiator.js
index xxxxxxx..xxxxxxx xxxxxxx
--- a/lib/negotiator.js
+++ b/lib/negotiator.js
@@ -232,7 +232,14 @@ Negotiator.cleanup = function(connection) {
 Negotiator._makeOffer = function(connection) {
   var pc = connection.pc;

-  if(!!pc.remoteDescription && !!pc.remoteDescription.type) return;
+  /** Supported Multi Stream **/
+  var offertype = 'OFFER';
+
+  /** Supported Multi Stream **/
+  //if(!!pc.remoteDescription && !!pc.remoteDescription.type) return;
+  if(!!pc.remoteDescription && !!pc.remoteDescription.type) {
+    offertype = 'UPDATE';
+  }

   pc.createOffer(function(offer) {
     util.log('Created offer.');
@@ -244,7 +251,7 @@ Negotiator._makeOffer = function(connection) {
     pc.setLocalDescription(offer, function() {
       util.log('Set localDescription: offer', 'for:', connection.peer);
       connection.provider.socket.send({
-        type: 'OFFER',
+        type: offertype,
         payload: {
           sdp: {
             type: offer.type,
@@ -317,8 +324,11 @@ Negotiator.handleSDP = function(type, connection, receivedSdp) {
   pc.setRemoteDescription(sdp, function() {
     util.log('Set remoteDescription:', type, 'for:', connection.peer);

+    /** Supported Multi Stream **/
     if (type === 'OFFER') {
       Negotiator._makeAnswer(connection);
+    }else if(type === 'UPDATE'){
+      Negotiator._makeUpdateAnswer(connection);
     }
   }, function(err) {
     connection.provider.emitError('webrtc', err);
@@ -344,4 +354,54 @@ Negotiator.handleCandidate = function(connection, ice) {
   util.log('Added ICE candidate for:', connection.peer);
 }

+/**
+ * Supported Multitrack operation
+ */
+
+/** Created Offer for re-exchange of SDP. */
+Negotiator.reExchange = function(connection){
+  util.log('Created Offer for re-exchange of SDP.');
+  if (!util.supports.onnegotiationneeded) {
+    setTimeout(function(){
+      Negotiator._makeOffer(connection);
+    }, 0);
+  }
+
+}
+/** Created answer for re-exchange of SDP */
+Negotiator._makeUpdateAnswer = function(connection) {
+  var pc = connection.pc;
+
+  pc.createAnswer(function(answer) {
+    util.log('Created answer for re-exchange of SDP.');
+
+    if (!util.supports.sctp && connection.type === 'data' && connection.reliable) {
+      answer.sdp = Reliable.higherBandwidthSDP(answer.sdp);
+    }
+
+    pc.setLocalDescription(answer, function() {
+      util.log('Set localDescription: answer', 'for:', connection.peer);
+      connection.provider.socket.send({
+        type: 'ANSWER',
+        payload: {
+          sdp: {
+            type: answer.type,
+            sdp: answer.sdp
+          },
+          type: connection.type,
+          connectionId: connection.id,
+          browser: util.browser
+        },
+        dst: connection.peer
+      });
+    }, function(err) {
+      connection.provider.emitError('webrtc', err);
+      util.log('Failed to setLocalDescription, ', err);
+    });
+  }, function(err) {
+    connection.provider.emitError('webrtc', err);
+    util.log('Failed to create answer, ', err);
+  });
+}
+
 module.exports = Negotiator;
diff --git a/lib/peer.js b/lib/peer.js
index xxxxxxx..xxxxxxx xxxxxxx
--- a/lib/peer.js
+++ b/lib/peer.js
@@ -306,6 +306,27 @@ Peer.prototype._handleMessage = function(message) {
         }
       }
       break;
+    case 'UPDATE': // Supported Multitrack operation
+      var connectionId = payload.connectionId;
+      connection = this.getConnection(peer, connectionId);
+
+      if (!!connection) {
+        util.warn('Connection is not completed: ', connectionId);
+        //connection.handleMessage(message);
+      } else {
+        // Create a new connection.
+        if (payload.type === 'media') {
+          util.log("MediaConnection created in UPDATE");
+          this.emit('update', connection);
+        } else if (payload.type === 'data') {
+          util.warn('This type does not correspond UPDATE:', payload.type);
+          return;
+        } else {
+          util.warn('Received malformed connection type:', payload.type);
+          return;
+        }
+      }
+      break;
     default:
       if (!payload) {
         util.warn('You received a malformed message from ' + peer + ' of type ' + type);

送り側(例)

        var localStream;
        var existingCall; //MediaConnection object
        $('#start-screen').click(function () {
            if(screen.isEnabledExtension()){
                screen.startScreenShare({
                    // 省略
                },function (stream){
                    if(existingCall != null){
                        if(localStream.getVideoTracks().length == 2){
                            localStream.removeTrack(localStream.getVideoTracks()[1]);
                        }
                        var _SCTrack = stream.getVideoTracks()[0];
                        localStream.addTrack(_SCTrack);
                        // offer/answerを開始
                        existingCall.update();
                    }
                },function(error){
                    // 省略
                },function(){
                    // 省略
                });
            }else{
                    // 省略
            }
        });

受け側1(例)

        peer.on('update', function(call) {
          call.updateAnswer();
        });

受け側2(例)

        var remoteStream;
        call.on('stream', function (stream) {

            var remoteStream = stream;

            // 初回接続時にビデオチャット用の映像を取り出す
            var _peerVideo = new webkitMediaStream();
            _peerVideo.addTrack(stream.getVideoTracks()[0]);
            attachMediaStream($('#their-video')[0],_peerVideo);

            // 初回接続時にビデオチャット用の映像を取り出す
            var _peerScreen = new webkitMediaStream();
            var _tracklength = stream.getVideoTracks().length;
            if( _tracklength == 2){
                _peerScreen.addTrack(stream.getVideoTracks()[1]);
                attachMediaStream($('#their-screen')[0],_peerScreen);
            }

            // Trackが追加されると発火する
            stream.onaddtrack = function(event) {
                /** event.trackでtrackを拾わなくてもremoteStreamには自動で追加されている **/
                var __peerVideo = new webkitMediaStream();
                __peerVideo.addTrack(event.track);
                attachMediaStream($('#their-screen')[0],__peerVideo);
            };

            // Trackが削除されると発火する
            stream.onremovetrack = function() {
                /** Trackは勝手に反映されるので特にやることはない **/
                attachMediaStream($('#their-screen')[0],null);
            };

        });

デモ

  • WebRTC Meetup Tokyo #10 で実施

受け手側の挙動

  • 1:1でビデオチャット中

before.png

  • 片方が追加で画面共有実施

after.png

一番下にレコードが追加れていれば成功です。

終わりに

今回は、Multitrackを利用した画面共有の方法を解説しました。画面共有機能を実装する方の参考になれば幸いです。