コンテンツにスキップ

Top

WebRTCを使って動画配信(同一LAN内) その1

WebRTCを使って、PC(A)のカメラ画像をPC(B)から閲覧できるようにする。
PC(A)はWindows PCとかじゃなく、Androidとかのスマートフォンでも問題ない。

WebRTCのざっくり説明(同一LAN編)

WebRTCって何?と思うかもしれないがHTMLの規格の一つでブラウザを使ってP2Pで動画配信とかができるAPIだと思えばいい。
(正確にはブラウザじゃなくてもモバイルアプリとかのAPIでできるとかいろいろあるけどそういうのどうでもいい)

いままででもSkypeなどで普通に行われている技術だが、それらは企業によって隠蔽されてきた規格だった。
それがRFCなどで規定されてオープンに使用できるようになったので、簡単に例えばSkypeもどきなビデオ通話も個人が簡単に(かはスキル次第だが)できるようになった。

ブラウザはどれが対応しているの?と思うかもしれないが、WebRTCに参加しているのはApple、Google、Microsoftなど主要なブラウザ作ってる企業なのでほぼすべての主要なブラウザは対応しているといっていい。

そして、同一LAN内のPC同士での動画配信に関して、WebRTCで知っておかなければいけない用語はシグナリングサーバーのみである。

PC(A)とPC(B)がP2Pでやり取りするためにはお互いのIPアドレスなりポート番号なりのネットワーク情報が必要となる。
さらにPC(A)が配信する動画の形式(mp4なのかv8なのか)や音声の形式などがわからないと通信できない。

これらの情報を収集したり提供したりするのがシグナリングサーバである。

なのでシグナリングサーバがなければWebRTCでP2P通信ができないといっても過言ではないと言いたいところだが、ちまちま手でデータを書いてもできるっちゃできるので言い過ぎではある。
(IPにしろなんにしろ固定にしてその値を直書きすりゃいいわけで)

ただまぁふつうはシグナリングサーバーを使うよ!ということ。

ちなみにこのシグナリングサーバが扱う情報のプロトコルはSession Description Protocol(SDP)というが難しいのであんま考えないでいい。

さきほど同一LAN内で、と制限を付けたのは、もしも同一LAN内の配信でなければNAT越えが必要となるためSTUNサーバというものが必要となったり、また、FireWallの問題が生じるため、TURNサーバが必要となったりするからだが今回は関係ない。

といったことからこの記事ではSDPの詳細もSTUNもTURNも知らなくてよい。

事前準備。 SSL証明書の作成

WebRTCで動画配信をするには、SSL証明書が必要となる。

昔はなくてもよかったようだけど、今はSSL証明書がないとエラーが出てつながらない。
抜け道とかもあるようだがブラウザを最新にしたらできなくなった、などあるので、抜け道探しをするよりはさっさとオレオレ証明書を作ったほうが無難なので証明書を作る。

ちなみに本ページは同一LAN上での動画配信なのでオレオレ証明書で問題ないからオレオレ証明書を作る。

以下の手順でオレオレ証明書を作成する。
IPアドレスで作らないといけないので(ドメインあるなら別だが)san.txtというのを作る。ここでは自身のIPアドレスが192.168.10.2とする。
san.txt

subjectAltName = IP:192.168.10.2
basicConstraints=CA:TRUE

んで、コマンドプロンプトで(多分Linuxもおんなじ)

> openssl genrsa -out server.key
> openssl req -new -key server.key -out server.csr -subj "/CN=192.168.10.2"
> openssl x509 -in server.csr -out server.crt -req -signkey server.key -days 365 -extfile san.txt
Certificate request self-signature ok
subject=CN = 192.168.10.2

でオレオレ証明書完成でござる。

IPアドレスは普通の家庭環境ではDHCPなのでいつか変わると思うが、IP変わっても特に問題なくつながった。なんでだろね?

Node.jsでシグナリングサーバーを立てる

シグナリングサーバを簡単に作るにはNode.jsを使うのが良い。(性能的にどうかとかは知らん)

まずは必要となるパッケージをインストール。

npm init
npm install express
npm install cors
npm install socket.io

今回、ファイル名はsignaling_server.jsとする。
signaling_server.js

const PORT = 8443;

const express = require('express');
const cors    = require('cors');    // Cross-Origin Resource Sharing(CORS)
const fs      = require('fs');      // 証明書を読み込むのに使う
const https   = require('https');

const app = express();
app.use(express.static('public'));
app.use(cors());

// keyとかcertとかの文字列は決められたものなので変更しないこ
// 昔はssl_server_keyとかssl_server_crtだったので古いWebサイトを参照してコピペするとアクセスできないので注意!
const options = {
  key  : fs.readFileSync('./server.key'),
  cert : fs.readFileSync('./server.crt'),
};
const server = https.createServer(options, app);
server.listen(PORT);

const io = require('socket.io')(server);

// connection はあらかじめ決められたお作法文字
// サーバとクライアントの接続が確立したとき、サーバー側では connection イベントが発生する
// (クライアント側は connect と、文字列が違うので要注意!)
io.on('connection', function(socket) { 
    // 動画をブロードキャストする
    socket.on('message', function(message) {
        message.from = socket.id; // 誰から、というのを設定する
        socket.broadcast.emit('message', message); // ブロードキャスト
    });
});

んで、起動。

node signaling_server.js

あとは、PC(A)とPC(B)がWebRTCのAPIを使って(具体的にはJavaScriptのAPIを使うのでwebページを作ってシグナリングサーバをWebサーバにして接続する)動画を配信、受信すればよいだけ。

ファイヤーウォール の 8443 に穴を開ける

先ほど作ったシグナリングサーバはポート8443で立ち上げている。
ので、そのポート番号に穴をあけていないと当然だが別PCからアクセスできないのでファイヤーウォールに穴を開ける。

Windows PCならファイヤーウォールの状態の確認→詳細設定→受信の規則、で穴をあけることができるので開けておく。
Linuxの場合はufwを使う。細かいことはググればよろし。

配信側と購読側のページを作成する

送信側をpublish.html、受信側をsubscribe.htmlとする。

publish.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>WebRTC Sample Publisher 1</title>
  </head>

  <body onload='onLoad()'>
    <div>
      <video id='video' autoplay muted playsinline></video>
    </div>
    <div>
      <button type='button' onclick='startVideo()'>Start</button>
      <button type='button' onclick='stopVideo()' >Stop</button>
    </div>
  </body>

  <script src="/socket.io/socket.io.js"></script>
  <script>
    var socket = null;
    var peer   = null;
    var stream = null;

    async function onLoad() {
      startVideo(); // まず動画読み込んどく

      var signaling_server_url = window.location.host; // Webサーバとシグナリングサーバが同じなので(違ったら別途指定が必要)
      socket = io.connect(signaling_server_url, {secure: true});

      socket.on("message", async function(message) {
        switch (message.type) {
          case "call me": // なんでcall meなのかは知らん。どっかのRFCで規定されとんのか?
            if (stream) {
              peer = new RTCPeerConnection();

              // setLocalDescription実行後、call backされる
              peer.onicecandidate = function(evt) {
                if (evt.candidate) {
                  let candidate = {type: "candidate", ice: evt.candidate };
                  socket.emit("message", candidate);
                }
              }

              // 動画をトラックに追加
              stream.getTracks().forEach(function(track){
                peer.addTrack(track, stream);
              });

              let offer = await peer.createOffer();
              await peer.setLocalDescription(offer); // これを実行すると内部でICE情報ができるので、上部のonicecandidateがコールバックされる

              // 自身のSDP情報を送信
              let sdp = {type: peer.localDescription.type, sdp: peer.localDescription.sdp, sendto: message.from};
              socket.emit("message", sdp);
            }
            break;
          case "answer":
            let sessionDescription = new RTCSessionDescription(message);
            await peer.setRemoteDescription(sessionDescription);
            break;
          case "candidate":
            let candidate = new RTCIceCandidate(message.ice);
            peer.addIceCandidate(candidate);
            break;
        }
      });
    }

    async function startVideo() {
      try {
        const option = { video: true, audio: false }; // フロントカメラ
        //const option = { video: {facingMode:{ideal:"environment"}}, audio: false }; // バックカメラ
        stream = await navigator.mediaDevices.getUserMedia(option);
        element = document.getElementById("video");
        element.srcObject = stream;
        await element.play();
      } catch (error) {
        console.log(error);
      }
    }

    function stopVideo() {
      let tracks = stream.getTracks();
      for (let track of tracks) {
        track.stop();
      }
      stream = null;
    }

  </script>
</html>

subscribe.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>WebRTC Sample Subscriber 1</title>
  </head>

  <body onload='onLoad()'>
    <div>
      <video id="video"></video>
    </div>
    <div>
      <button type="button" onclick="connect()" class="temi-btn">Connect</button>
    </div>
  </body>

  <script src="/socket.io/socket.io.js"></script>
  <script>
    var socket = null;
    var peer   = null;

    async function onLoad() {
      let signaling_server_url = window.location.host;  // Webサーバとシグナリングサーバが同じなので(違ったら別途指定が必要)
      socket = io.connect(signaling_server_url, {secure: true});

      // 各種メッセージの処理
      socket.on("message", async function(message) {
        switch (message.type) {
          case "offer":
            peer = new RTCPeerConnection();

            peer.onicecandidate = function(evt) {
              if (evt.candidate) {
                let candidate = {type: "candidate", ice: evt.candidate };
                socket.emit("message", candidate);
              }
            }

            peer.ontrack = async function(evt) {
              let element = document.getElementById("video");
              element.srcObject = evt.streams[0]; // なぜ 0 固定なのか不明
              await element.play(); // 再生
            }

            let offer = new RTCSessionDescription(message);
            await peer.setRemoteDescription(offer);

            let answer = await peer.createAnswer();
            await peer.setLocalDescription(answer);

            let sdp = {type: peer.localDescription.type, sdp: peer.localDescription.sdp, sendto: message.from};
            socket.emit("message", sdp);
            break;

          case "candidate":
            let candidate = new RTCIceCandidate(message.ice);
            peer.addIceCandidate(candidate);
            break;
        }
      });
    }

    // なぜ"call me"なのか不明。もちろん別の文字列でも良いわけだがみんな call me にしている。どっかのRFCで定義されているんだろうか?
    function connect() {
      socket.emit("message", {type: "call me"}); 
    }
  </script>
</html>

先ほど作ったsignaling_server.jsは app.use(express.static('public')); と書いているとおり、publicフォルダ配下がルートになっている。
なので、publicフォルダを作り、そこにpublish.htmlとsubscribe.htmlを置く。
(もちろんだが、シグナリングサーバーをWebサーバーと兼任する必要などない。兼任もできるからやっただけで)

そしてPC(A)、PC(B)のそれぞれのブラウザからアクセスすればよい。

今回、シグナリングサーバ(Webサーバ)のIPアドレスが192.168.10.2だとしたら、 https://192.168.10.2:8443/publish.html と https://192.168.10.2:8443/subscribe.html になる。

アクセス後、オレオレ証明書なので警告は出るが、無視すればアクセスできる。

ここでもしそもそも接続できなかったとしたらファイヤーウォール設定に問題がある可能性があるので上記で記載した8443をちゃんと開けているか再確認したほうが良い。

書きながら思ったが、調査したことないからわからんが、ChromiumやWebKitを直接使えばWebサーバなくてもWebRTCできるんかな?

大まかな流れ

シーケンス図めんどいので文章で。

①配信側も購読側もシグナリングサーバにコネクトしておく
②購読側のPC(B)からシグナリングサーバに"call me"という文字列を送る
(なぜ"call me"なのかは知らない。RFCかなんかで規定されてるのかもしれないが、配信側も購読側も自分で実装している場合、別にhogeでもかまわないが"call me"にしている)
③シグナリングサーバから配信側サーバに"call me"が通知されるのでSDPやらICEやらの情報をやり取りする
④購読側もSDPやらICEやらの情報をやり取りして、P2PできるようになるのでP2Pが出来たらvideoタグにストリームを流して再生!

という流れ。

Chromeブラウザは厳しい

とりあえずオレオレ証明書でWebRTCが使えるわけだが、Chromeブラウザは証明書をブラウザ側にインポートしてあげないと接続できなかった。
(設定→プライバシーとセキュリティ→セキュリティ→証明書の管理、で信頼されたルート証明機関に.crtをインポートする)

証明書作り直したりするたびやるの面倒くさいのでFireFoxやMicrosoft Edgeを使って回避しているが(これらのブラウザでは問題はない)、時間の問題というかこれらのブラウザもChrome同様厳しくなっていくのでは?と思うので結局いつかはちゃんとしないといけないのだろうと思った。

購読側を複数立ち上げないこと

subscribe.htmlを複数立ち上げていたらそれぞれがアクセスしに行ってエラーになった。
最初、何のエラーかわからなかったんで困った。
気を付けること!

roomという概念

ここまでやりましたが、これだと1台のシグナリングサーバーで1ペアの動画配信しかできません。

複数のペアで動画配信したい場合は、ポートを変えてシグナリングサーバーを複数立てないといけない、ということでしょうか?

いいえ、そんなことはしないでも複数のペアを作ることができます。

それがroomというSocket.IOが提供するライブラリを使った方法です。

続く。