はじめに
この記事は、Multitrackを利用して、ビデオチャット+画面共有を行う方法を解説するものです。
MediaChannlel利用時の課題
御存知の通り、WebRTCのMediaChannlel機能を用いればビデオチャットが簡単に実装できます。しかしながら、複数のビデオをやり取りしたり、画面共有を合わせてやりたいなど、アプリケーションの要件次第で、実装は複雑に、そしてクライアント端末にかかる負荷(CPU)が高くなるという問題があります。
イメージでその様子をみてみます。
- 1:1でビデオチャット
- 1:1でビデオチャット+画面共有
- 4人フルメッシュでビデオチャット
- 4人フルメッシュでビデオチャット+画面共有(3本のMediaStreamが追加になる)
そもそも、フルメッシュはきついので、MCUとかSFU使おうよというのは、今回は無しで。
Multitrackを活用する
Multitrackを活用することで、接続にかかるオーバーヘッド等を軽減することが可能になり、端末への負荷が多少軽減されます。(通信帯域自体は変わりません)
このMultitrackですが、実は皆さん意識せずに使っています。先ほど示した図中にVideoTrackとAudioTrackという部分がありますが、まさにそれがMultitrackによって実現されているのです。
Multitrackを利用して同時に画面共有のストリームを送ると、こんな感じになります。
Multitrackの各ブラウザの実装状況や仕様などは、がねこさんのスライドが参考になります。
− WebRTC multitrack / multistream@mganeko
Trackを操作する
前提条件
- サンプルコードの実装にはWebRTCプラットフォーム SkyWay とSkyWay-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)
- 受信側(スクリーンシェアのTrackのみ)
id(label)でTrackを識別することはできますが、VideoTrackと区別がつきません。複雑なアプリケーションを開発する場合は、上手くハンドリングする必要があります。
MediaStreamはそのままにTrackを操作する
お気づきの方もいらっしゃると思いますが、上述したプログラムでは、スクリーンシェアを行うたびに結局MediaStreamを作成しています。つまり、Track操作を行うたびにMediaStreamを破棄して作りなおす必要が出てきます。ここからはもっとシンプルに、MediaStreamはそのままに、含まれるTrackの操作を行う方法を解説します。
処理の流れ
これを実現するには、offer/answerを都度実行してあげる必要があります。おおまかな処理の流れは以下のとおりです。
尚、この図の動作はChrome限定です。Firefoxではこのままでは動きません。(未検証)また、現在標準化の中でAPIの策定を行っている最中であり、Chromeについても将来的に変更される可能性が高いです。ご注意ください。
Peer.jsの実装例
この処理を実現するには、Peer.jsに手を入れる必要が出てきます。
まず、必要なメソッドを定義します。
MediaConnection.update()
MediaConnection.update(MediaStream);
- 引数のMediaStreamを用いてSDPの再交換要求を出す
- 処理フロー
- createOffer()
- setLocalDescription()
- socket.send(type:update)
MediaConnection.updateAnswer()
MediaConnection.updateAnswer();
- update要求に対して回答を出す
- 'update' イベントのCallbackで実行する
- 処理フロー
- setRemoteDescription()
- createAnswer()
- setLocalDescription()
次に、必要なイベントを定義します。
Peer.on('update')
peer.on('update',function(mediaConnection){....});
- リモートのpeerがUpdateを行った時に発生します。
- 処理フロー
- socket.receive(type:update)
- イベント発火
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でビデオチャット中
- 片方が追加で画面共有実施
一番下にレコードが追加れていれば成功です。
終わりに
今回は、Multitrackを利用した画面共有の方法を解説しました。画面共有機能を実装する方の参考になれば幸いです。