Edited at

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