こんにちは、渕上です。
前回調査したWebRTCを使って、ビデオチャットのシンプルなサンプルを作ってみたいと思います。

1. カメラの検出を行う

Navigator.MediaDevices.getUserMedia()

2つのメソッド

現在WebブラウザからカメラにアクセスするためのAPIとしては、下記の2つが用意されています。

Navigator.MediaDevice.getUserMedia()はNavigator.getUserMedia()に比べて新しく、
現時点ではFirefox38以降でのみ動作するメソッドです。

  • Promiseオブジェクトを返すメソッドであり、コールバックをネストしていくよりも見通しのいい記述ができること
  • カメラからキャプチャする映像のFPSやスケールを簡単に調整することができること

がメリットのようです。

今回は折角ですので、この新APIを試してみたいと思います。
(そのため、Firefox38以降以外では動作しません)

サンプルコード

下記は、Navigator.MediaDevices.getUserMedia()を利用したカメラ検出のサンプルコードです。
Firefoxで実行するとカメラ、マイクへのアクセス許可を求めるダイアログが表示され、
許可するとブラウザ上にキャプチャされた映像が流れるかと思います。

<html>
  <head>
    <script>

    var initialize = function(){

      // サポート外ブラウザの場合何もしない
      if (!navigator.mediaDevices) {
        alert('サポート外のブラウザ');
        return;
      }

      // キャプチャする映像、音声の設定値
      // フレームレートの最大値を15fpsにしてみた
      var constraints = {
        audio: true,
        video: {
          frameRate : { max: 15 }
        }
      }

      // Navigator.MediaDevices.getUserMedia() の実行
      var promise = navigator.mediaDevices.getUserMedia(constraints);
      promise.then(successCallback, rejectCallback);
    }

    // カメラに正常接続したときのコールバック
    // MediaStreamオブジェクトをURL化し、videoタグに流す
    var successCallback = function(localMediaStream){
      var video = document.querySelector('video');
      video.src = window.URL.createObjectURL(localMediaStream);
      video.play();
    }

    var rejectCallback = function(e){
      alert('カメラへのアクセスを拒否しました');
      console.log(e);
    }

    window.addEventListener('DOMContentLoaded', initialize, false);
    </script>
  </head>
  <body>
    <video></video>
  </body>
</html>

2. 公開STUNサーバを使ってWebRTC接続を行う

今回は折角ですので、Googleの公開STUNサーバを使って実装した、
簡単なビデオチャットのサンプルをご紹介したいと思います。

尚、今回のサンプルでは、下記を全く考慮していません。
悪しからず。

  • 複数人接続
  • エラー処理関係
  • 切断時処理関係

サーバ側サンプル

サーバ側は、node.js + socket.ioを利用して作成しました。
行う処理はいたってシンプルで、’signaling’イベントで受け取ったデータを、
そのままクライアントに返すだけです。

この時、socket.broadcast.emitでデータを返すことによって、
送信者以外にsocket接続をしているクライアントすべてにデータを返しています

サーバ側コード

var express    = require('express');
var app        = express();
var http       = require('http').Server(app);

app.set('view engine', 'jade');
app.set('views', __dirname + '/views');
app.use(express.static(__dirname + '/public'));

var io = require('socket.io')(http);

io.on('connection', function(socket){
  console.log('[connected]', socket.id);

  socket.broadcast.emit('entry-newbie', {});

  socket.on('signaling', function(data){
    socket.broadcast.emit('signaling', data);
  });

});

app.get('/', function (req, res){
  res.render('index');
});

http.listen(3000);

クライアント側サンプル

クライアント側は、サーバ側に比べて処理が多いためやや複雑ですが、
100行程度で収まっています。

エラー処理などを考慮していないとはいえ、
こんなにシンプルにブラウザ同士の双方向通信が実現できるのは驚きでした。

クライアント側コード

// ベンダプリフィックスつきメソッドの統一
window.RTCPeerConnection     = ( window.webkitPeerConnection00 || window.webkitRTCPeerConnection || window.mozRTCPeerConnection);
window.RTCSessionDescription = ( window.mozRTCSessionDescription || window.RTCSessionDescription);
window.RTCIceCandidate       = ( window.mozRTCIceCandidate || window.RTCIceCandidate);
// ice の設定 今回はGoogleの公開STUNサーバのみを対象とする
var iceConfig = { "iceServers" : [ { "url" : "stun:stun.l.google.com:19302" } ] }
var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':false, 'OfferToReceiveVideo':true }};

var pc;
var socket;

var initialize = function(){
  //socket = io('http://192.168.33.10:3000');
  socket = io('http://192.168.0.188:3000');

  socket.on('signaling', function(data){
    if (data.type == 'offer') {
      recieveOffer(data);
    }
    else if (data.type == 'answer') {
      recieveAnswer(data);
    }
    else if (data.candidate) {
      pc.addIceCandidate(new RTCIceCandidate(data));
    }
  });

  setUpNewConnection();
};

var successCallback = function(localMediaStream){
  pc.addStream(localMediaStream);
  var video = document.getElementById('localVideo');
  video.src = window.URL.createObjectURL(localMediaStream);
  video.play();

  if (!pc.remoteDescription) {
    pc.createOffer(function(offer){
      pc.setLocalDescription(offer);
      socket.emit('signaling', offer); 
    },
    function(error){
      console.log('createOffer has error.', error);
    });
  }
  else {
    pc.createAnswer(function(answer){
      pc.setLocalDescription(answer);
      socket.emit('signaling', answer);
    },
    function(error){
      console.log('createAnswer has error.', error);
    });
  }
}

var rejectCallback = function(e){
  alert('カメラへのアクセスを拒否しました');
  console.log(e);
}

var setUpNewConnection = function(callback) {
  var constraints = {
    audio: true,
    video: {
      width: 640,
      height: 480,
      frameRate : { max: 30 }
    }
  }

  var promise = navigator.mediaDevices.getUserMedia(constraints);
  promise.then(successCallback, rejectCallback);

  pc = new RTCPeerConnection(iceConfig);

  pc.onicecandidate = function(event) {
    if (event.candidate) {
     socket.emit('signaling', event.candidate);
    }
  }

  pc.onaddstream = function(event){
    console.log(event);
    if (event.stream) {
      var video = document.getElementById('remoteVideo');
      video.src = window.URL.createObjectURL(event.stream);
      video.play();
    }
  }
}

var recieveOffer = function(offer) {
  console.log('recieve offer', offer);

  setUpNewConnection();
  pc.setRemoteDescription(new RTCSessionDescription(offer));
}

var recieveAnswer = function(answer) {
  pc.setRemoteDescription(new RTCSessionDescription(answer));
}

window.addEventListener('DOMContentLoaded', initialize, false);

実際の処理の流れ

最後になりましたが、クライアント側、
サーバ側の実際の処理の流れをご紹介したいと思います。

サーバ側が2つのクライアントからアクセスを受ける前提で、
先にアクセスするのをクライアントA、あとからアクセスするのをクライアントBとして解説します。

クライアントAがサーバにアクセスする

クライアントAがサーバにアクセスして、クライアント側コードをDLすると、
RTCPeerConnectionクラスのインスタンスを生成すると同時に、
カメラへのアクセス許可を求めます。

カメラのアクセスを許可すると、
RTCPeerConnection.createOfferメソッドが実行されます。

createOfferの成功時コールバックでは、
RTCPeerConnection.setLocalDescriptionメソッドでofferの内容を自身にセットするとともに、
サーバ側にWebSocketでofferの中身をそのまま送信します。
offerには、自身の接続情報が含まれまています。

サーバ側はクライアントA以外のソケットに、受け取ったofferを返しますが、
この時点では他に誰も接続していないので、何も起こりません。

2. クライアントBがサーバにアクセスする

クライアントBがサーバにアクセスすると、上記1と同じ処理が実行されます。

この時点ではクライアントABともにWebSocket通信を確立しているため、
クライアントBがサーバにofferをWebSocketで送信し、
サーバがクライアントAにofferを返すことになります。

3. クライアントAがofferを受け取る

サーバからクライアントBのofferを受け取ったクライアントAは、
RTCPeerConnection.setRemoteDescriptionで、クライアントBのofferをセットします。

また、PCに接続したカメラからキャプチャした映像と音声を
RTCPeerConnection.setStreamメソッドでセットします。

その後、RTCPeerConnection.createAnswerメソッドで、サーバ側に自身の接続情報(answer)を
WebSocket通信で送信します。

サーバ側は、クライアントBにクライアントAのanswerを返します。

4. クライアントBがanswerを受け取る

クライアントBはクライアントAのanswerを
RTCPeerConnection.setRemoteDescriptionメソッドで相手の接続情報としてセットして、
WebRTC通信が開始になります。