65
66

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-05-17

はじめに

この記事は、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を利用した画面共有の方法を解説しました。画面共有機能を実装する方の参考になれば幸いです。

65
66
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
65
66

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?