凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

結婚式二次会用に Node.js x ブラウザでタイピング対決アプリを作ってみた

はじめに

先月、友人の結婚式の二次会でタイピング対決をしたいとの企画を、幹事の友人から受けました。面白かった要件としては、

  • 二人の顔を 2 台のカメラで映したい
  • タイピングしてる様子をリアルタイムで見たい

というものです。これをサーバは Node.js で、クライアントはブラウザで作成しました。エントリ書いてもいいよ、と許可を頂いたので、今後似たような依頼を受けた方のご参考になるように、エッセンス部分をご紹介します。

(追記:2013/11/20)
幹事さんも記事を公開されました:

やったこと

ホスト PC 側で新郎新婦それぞれの PC からのログインを待ち受けします。イイ感じに○で切り抜いてくれるような PNGイラレで作っておきました。

それぞれの PC からログインしてもらいます。

ログインしてカメラの許可ボタンが押されると画像が映ります。

ふたりともログインすると対決に移ります。先に打った方にポイントが入る方式で、ふたりとも打ち終わるとアニメーションして次の問題へ移ります。真ん中には当日来場した方の写真が映っており、メッセージをボードに記入してもらっています(ここではダミー画像)。そこに書いてあるメッセージを新郎・新婦が打ちこむ、というものでした。画像のパスやメッセージなどのデータは、幹事さんに指定フォーマットの json に書いてもらってます。変換中の様子(「おめでとう」であれば「おm」の「m」とか)も観客から見れるようになっています。

新郎新婦からはスクリーンにプロジェクタ投影されている文字は配置的に見れないので、手元で見れるようになっています。こちらは工数不足により最小限のデザインです。。

すべての問題を打ち終わると結果発表アニメーションが流れます。

新郎・新婦どちらが勝つか投票し、当てた人から抽選で景品がもらえるとの企画だったのですが、当日はイイ感じに一問差で新婦さんが勝ってくれたおかげで盛り上がって本当に良かったです。

構成

用意した構成はこんな感じになります。

クライアント側の PC で用意したことは、Chrome のインストールと、挿した WebCam を使用するように「設定 > コンテンツの設定 > メディア」から適切なカメラを設定くらいです。
その上で、各 PC の役割は以下の様な形になります。

ブラウザが途切れてしまっても途中から再開できるレジューム機能なども盛り込んでいますが、ここでは触れません。

解説

レイアウト

基本的にはサイズ決め打ちの画像を作成し、透明な div 要素をそれに合わせて absolute で配置してます。z-index を操作してカメラ画は後ろに置いてクリッピングされるようにしています。アニメーションは愛用している jQuery.Transit がすごい便利なのでこれでメソッドチェーンでもりもり書いています。

複数のカメラ画の転送

以下のサイトを参考にしています。

P2P 方式は面倒だったので、コスト高ですが楽なので WebSocket で行いました。基本的には、

  1. Stream API でカメラ画を取得して video タグに流し込む
  2. Canvas に video タグの画を描く
  3. toDataURLJPEG に圧縮 & Base64 エンコード
  4. Base64 エンコードされて文字列になった画像を WebSocket でサーバ側へ送信
  5. 受け手側で Base64 エンコードされた文字列を img タグの src に流し込む

これだけで可能です。自然言語で書くと長いですがコード的にはとてもシンプルです。

<!-- クライアント側 -->

<!DOCTYPE HTML>
<html lang="ja">
<head>
	<meta charset="UTF-8">
	<title>Video Chat Test</title>
	<script src="js/socket.io.js"></script>
	<script src="js/jquery.min.js"></script>
	<script>
		var socket = io.connect('http://localhost', { port: 12345 });

		$(function() {
			var canvas  = $('#webcam-image')[0];
			var video   = $('#webcam-movie')[0];
			var others  = $('#others-movie')[0];
			var context = canvas.getContext('2d');

			// ビデオ画をサーバに送る
			// --------------------------------------------------------------------------------
			var FPS = 30;
			navigator.webkitGetUserMedia(
				{ video: true },
				function(stream) {
					// video タグにカメラ画を流し込む
					video.src = window.URL.createObjectURL(stream);
					video.play();
					// 定期的に Canvas へ video タグの画像を書き出し、
					// それを toDataURL で Base64 エンコードして文字列にし、
					// その文字列をサーバに WebSocket で送る
					setInterval(function() {
						context.drawImage(video, 0, 0, 320, 240);
						var data = canvas.toDataURL('image/jpeg', 0.5);
						socket.emit('video', data);
					}, 1000 / FPS);
				},
				function(error) {
					console.error(error.code, 'connection failed');
				}
			);

			// サーバからのビデオ画を受ける
			// --------------------------------------------------------------------------------
			socket.on('video', function(data) {
				// サーバから送られてきた Base64 エンコードされた文字列を
				// そのまま img タグの src に流し込む
				if (typeof(data) === 'string') others.src = data;
			});

		});
	</script>
</head>
<body>
	<video  id="webcam-movie" width="320" height="240"></video>
	<canvas id="webcam-image" width="320" height="240"></canvas>
	<img    id="others-movie" width="320" height="240" />
</body>
</html>
// サーバ側

var io = require('socket.io').listen(12345);

io.sockets.on('connection', function (socket) {
	socket.on('video', function(data) {
		// ブロードキャストする
		io.sockets.emit('video', data);
	});
});

これで QVGA であれば遅延なく 30 fps で 2 つのストリームを転送出来ました。ただ、私の Mac Book Air (2012 Mid) では問題なかったのですが、本番で使用した友人の MBA(2011 モデル?) だと websocket のログ表示が重かったらしく遅延が発生したので、/dev/null へリダイレクトすることで解決しました。
Android でも可能なので、カメラ部を分離して Android x 2 をシステムに加えても良かったかもしれません。

(追記:2013/11/17)
友人のも 2012 Mid のようです。そんなに有意な差は出なそうですが、私の方は購入時にオプションで性能あげていたから平気だったのかもしれません。

入力情報のリアルタイムな取得

これはとても簡単でした。onChange に引っ掛けて取ってくると、入力後に Enter を押下したり別の場所をクリックしてアンフォーカスしないとイベントが発火されず、うまく行かないのですが、setInterval で input タグの中身を取ってくるとどうやら変換途中の文字列も取ってこれるようです。

<!-- クライアント側 -->

<!DOCTYPE html>
<html lang="ja">
<head>
	<meta charset="UTF-8">
	<title>Real-time input transfer test</title>
	<script src="js/socket.io.js"></script>
	<script src="js/jquery.min.js"></script>
	<script>
		$(function() {
			var socket = io.connect('http://localhost', { port: 12345 });
			$src  = $('#src');
			$dest = $('#dest');

			// input エリアを定期的に監視
			setInterval(function() {
				socket.emit('input', $src.val());
			}, 100);

			// そのまま流しこむ
			socket.on('input', function(data) {
				$dest.val(data);
			});
		});
	</script>
</head>
<body>
	<input id="src"  />
	<input id="dest" />
</body>
</html>
// サーバ側
var io = require('socket.io').listen(12345);

io.sockets.on('connection', function (socket) {
	socket.on('input', function(data) {
		io.sockets.emit('input', data);
	});
});

これらをイイ感じに組み合わせればアプリが完成、というわけです。

WebRTC で真面目にやりたい方向け

遠隔地でパケロスしても良いからイイ感じにやりたい、という要件に迫られたら、真面目に STUN サーバ経由でコミュニケーションすると良いと思います。

上記サイトを参考にすれば出来そうなので、必要に迫られたらやってみます。

コード

一緒に開発した友人の github のプライベートリポで作成してました。現在公開検討中です。

おわりに

はじめは、カメラ 2 個という要件を実現するために、Qt を使って、先日の記事でも書いた MFT 2013 用の OpenCV plugin と OSC plugin を利用しながら作成しようと思ったのですが、通信周りで難航したのと、私の Qt 力の足りなさ故に期間内の作成に目処が立たず、直前で断念して HTML で書き直すことにしました。しかしながら HTML にした結果、慣れていることもあり作成はそこそこ早く進行し、また友人に一部開発やテストを依頼することもでき、加えて新郎新婦が自身の PC に新たにアプリをインストールする必要もなくなり、更に本当に直前まで(二次会中も)修正が可能だったため、限られたスケジュールの中で何とか本番まで漕ぎ着けることが出来ました。こういった要件に応えるためには、利用するフレームワークの選定がとても重要だということを改めて痛感しました。一緒に開発をして下さった友人に感謝します。ありがとうございました。

素材