WebSocketを使ってなんちゃってビデオチャットを作ってみた その3

はじめに

前々回前回からの続き。jQueryのWebカメラプラグインよりキャプチャ速度が早いFlashを作成することができたので、これを用いてなんちゃってビデオチャットを作り直しました。とりあえず、今回で完成ということに致します。

実際に動かしたときの様子を録画しましたが、何故か最初の数秒が変な感じになってしまいました(;・∀・)

上の段の映像は自身のWebカメラの映像を描画し、下の段の映像は相手のWebカメラの映像を描画しています。この動画では、ビデオチャットを行っているノートPC同士を向かいあわせに置き、PC同士の間の空間で手を動かしています。ところどころ描画が引っかかる時がありますが、これぐらいのフレームレートとタイムラグならビデオチャットとしてはギリギリ使い物になる気がします。

上の動画を録画したときの環境では、ノートPC*2台とWebSocketサーバは同一ネットワーク内に居た(ネットワークの距離が近い)のでこのような結果になりました。したがって、もっとネットワーク距離が離れている環境ではタイムラグが大きくなると思われます。

このビデオチャットプログラムは以下の通りです。

WebSocketサーバ側のプログラム

これは前々回のコードと変わっていません。

  • videoChatServer.js
var sys = require('sys')
var http = require("http")
var ws = require('./miksago-node-websocket-server/lib/ws/server');
var server = ws.createServer();
const NODE_MAX = 2;  // 接続上限数
var nodeCount = 0;   // 現在の接続数

// WebSocketコネクションが確立したときの処理
server.addListener("connection", function(conn){
  ++nodeCount;
  if (nodeCount > NODE_MAX) {
    conn.close();
    --nodeCount;
  } else {
      sys.log("connected: " + conn.id);

      // データを受信したときの処理
      conn.addListener("message", function (msg) {
          //sys.log(conn.id + ": length is " + msg.length);
          conn.broadcast(msg);
      });
  }
});

server.listen(8000);

Webサーバ側のプログラム

前回作成したswfファイルを以下のファイルと同じ階層に置いてください。

  • index.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <script type="text/javascript" src="videoChatClient.js"></script>
        <title>video chat</title>
    </head>
    <body>
        <embed src="MyJSCam.swf" name="MyJSCam" allowScriptAccess="always" width="0" height="0" />
        <hr style="clear:left;">
        <div style="float:left;"><canvas id="myCamera"></canvas></div>
        <div id="myCameraInfo">
            <span id="captureFPS" style="font-size: 20px;">captureFPS</span><br>
        </div>
        <hr style="clear:left;">
        <div style="float:left;"><canvas id="targetCamera"></canvas></div>
        <div id="targetCameraInfo">
            <span id="drawFPS" style="font-size: 20px;">drawFPS</span><br>
        </div>
        <hr style="clear:left;">
        <input name="mode" type="radio" value="" onclick="imageMode=this.value"/>FULL_COLOR
        <input name="mode" type="radio" value="GRAY_SCALE" onclick="imageMode=this.value"/>GRAY_SCALE
        <input name="mode" type="radio" value="RED" onclick="imageMode=this.value"/>RED
        <input name="mode" type="radio" value="GREEN" onclick="imageMode=this.value"/>GREEN
        <input name="mode" type="radio" value="BLUE" onclick="imageMode=this.value"/>BLUE
        <br>
        <input id="inputText" type="text" size="40">
        <input id="sendButton" type="submit" value="send">
        <hr>
        <div id="log"></div>
    </body>
</html>
  • videoChatClient.js
const DISP_WIDTH = 320
const DISP_HEIGHT = 240;
var webcamObj = null;
var myContext = null;
var myCameraImage = null;
var myCanvas = null;
var targetContext = null;
var targetCanvas = null;
var captureFPS = null;
var drawFPS = null;
var ws = null;
var imageMode = ""

function log (msg) {
    var logObj = $("#log");
    logObj.html(msg + "<br>" + logObj.html());
}

// Webカメラ画像をキャプチャしサーバに画像データを送信する関数
function captureLoop () {
    if (interval) clearInterval(interval);
    // Webカメラの画像データ(320x240のピクセルデータ)を取得する
    var captureImageData = webcamObj.capture();
    var pixel = null, sendData = null;
    var pos = 0, r = 0, g = 0, b = 0;

    for (var y=0; y<DISP_HEIGHT; ++y) {
        for (var x=0; x<DISP_WIDTH; ++x) {
            pixel = captureImageData[x][y];
            r = (pixel >> 16) & 0xFF;
            g = (pixel >> 8) & 0xFF;
            b = pixel & 0xFF;
            if (imageMode) {
                var gray = Math.round((r + g + b) / 3);
                switch (imageMode) {
                case "GRAY_SCALE":
                    r = g = b = gray;
                    break;
                case "RED":
                    r = (r > 50 && g*1.5 < r && b*1.5 < r) ? r : gray;
                    g = b = gray;
                    break;
                case "GREEN":
                    g = (g > 50 && r*1.5 < g && b*1.5 < g) ? g : gray;
                    r = b = gray;
                    break;
                case "BLUE":
                    b = (b > 50 && r*1.5 < b && g*1.5 < b) ? b : gray;
                    g = r = gray;
                    break;
                }
            }
            myCameraImage.data[pos + 0] = r;
            myCameraImage.data[pos + 1] = g;
            myCameraImage.data[pos + 2] = b;
            myCameraImage.data[pos + 3] = 0xFF;
            pos += 4;
        }
    }

    myContext.putImageData(myCameraImage, 0, 0);
    $("#captureFPS").html(Math.round(captureFPS()*100)/100 + "fps");
    sendData = {
        type: "img",
        data: myCanvas[0].toDataURL("image/jpeg")  // JPEGでBase64変換
    };
    ws.send(JSON.stringify(sendData));

    interval = setInterval(captureLoop, intervalTime);
}

function parseData (rawData) {
    var recieveData = JSON.parse(rawData);
    var func = null;
    switch (recieveData["type"]) {
    case "msg":
        func = writeMessage;
        break;
    case "img":
        func = drawImage;
        break;
    }
    func(recieveData["data"]);
}

function createFPS () {
    var oldTime = new Date();
    return function () {
        var newTime = new Date();
        var ps = 1000 / (newTime - oldTime);
        oldTime = newTime;

        return ps;
    }
}

function drawImage (data) {
    var img = new Image();
    img.src = data;
    img.onload = function () {
        targetContext.drawImage(img, 0, 0);
        $("#drawFPS").html(Math.round(drawFPS()*100)/100 + "fps");
    };
}

function writeMessage (msg) {
    log(msg);
}

function sendMessage (msg) {
    var sendData = {
        type: "msg",
        data: msg
    };
    if (ws.send(JSON.stringify(sendData))) {
        log("&lt;You&gt; " + msg);
        return true;
    }

    return false;
}

// init
$(function () {
    myCanvas = $("#myCamera");
    myCanvas.attr("width", DISP_WIDTH);
    myCanvas.attr("height", DISP_HEIGHT);
    myContext = myCanvas[0].getContext("2d");
    myContext.clearRect(0, 0, DISP_WIDTH, DISP_HEIGHT);
    myCameraImage = myContext.getImageData(0, 0, DISP_WIDTH, DISP_HEIGHT);
    targetCanvas = $("#targetCamera");
    targetCanvas.attr("width", DISP_WIDTH);
    targetCanvas.attr("height", DISP_HEIGHT);
    targetContext = targetCanvas[0].getContext("2d");
    targetContext.clearRect(0, 0, DISP_WIDTH, DISP_HEIGHT);

    ws = new WebSocket("ws://" + location.hostname + ":8000");
    ws.onopen = function () {
        log("connected.");
    };
    ws.onmessage = function (e) {
        parseData(e.data);
    };
    ws.onclose = function () {
        log("disconnected.");
        $("#dnSpeed").html("");
    }

    $("#sendButton").click(function () {
        var inputObj = $("#inputText");
        if (sendMessage(inputObj.val())) {
            inputObj.val("");
        }
    });

    captureFPS = createFPS();
    drawFPS = createFPS();
    webcamObj = document["MyJSCam"];
    interval = setInterval(captureLoop, 1000);
    intervalTime = 70;  // 通信環境に応じて適宜変化させるべき
})

前々回のコードを少し変えました。

Webカメラのキャプチャ画像をHTML5Canvasで描画したあと、その画像をBase64エンコードされた文字列に変換してWebSocketサーバに送信していますが、前々回のコードはPNGフォーマットだったものを今回はJPEGフォーマットに変更しました。Webカメラでキャプチャした画像のようなデータは、PNGよりもJPEGの方がデータサイズを小さくすることができます。

59    myContext.putImageData(myCameraImage, 0, 0);
60    $("#captureFPS").html(Math.round(captureFPS()*100)/100 + "fps");
61    sendData = {
62        type: "img",
63        data: myCanvas[0].toDataURL("image/jpeg")  // JPEGでBase64変換
64    };
65    ws.send(JSON.stringify(sendData));


また、captureLoop()関数をintervalTimeミリ秒の間隔で実行するようなコードに変更しました。私の環境では、70ミリ秒間隔でも特に問題がなかったので変化させていませんが、ちゃんとしたビデオチャットアプリを開発するのならば通信状況に応じて適宜変える必要があります。
# 今回は"なんちゃって"ビデオチャットなので(^_^;)