本記事はDan Princeが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
この10年間にブラウザーベンダーがさまざまな新しいAPIを導入したことで、開発者はより豊富で柔軟な設計ができるようになりました。その1つがユーザーの音声やビデオデバイスにアクセスできるgetUserMedia APIです。しかし、ブラウザーの互換性の点ではまだ確立されているとは言えません。
そこで、Adam Wróbelはブラウザーの互換性を考慮したJpegCameraを設計しました。ユーザーのカメラが作動するブラウザー間で発生する異なる警告を考慮し、メディアへのアクセスがサポートされていない場合にフォールバックを提供するライブラリーです。
記事では、JpegカメラをHTMLのcanvas要素と合わせて使用して「Layout from Instagram(以降、Layout)」アプリのクローンを構築する方法を紹介します。
デモアプリのソースコードはGithubからダウンロードできます。
JpegCameraライブラリー
JpegCameraを使うと、アプリケーションの一部としてユーザーのカメラにアクセスできます。もしブラウザーがgetUserMedia()をサポートしていない場合は、潔くFlashのフォールバックにデグレードします。
まず始めに、プロジェクトに必要なスクリプトを書きます。
ライブラリーはSWF ObjectとCanvas to Blobライブラリーに依存し、両方ともこのプロジェクトのGithubページからダウンロードするzipファイルの一部に含まれています。しかし同じzipファイル内にはこれら3つのスクリプトと同じ機能を持つ複数のバージョンのスクリプトが含まれています。
そのことを念頭に置いて、次の3つの必要なスクリプトを読み込みます。
<script src="/jpeg_camera/swfobject.min.js" type="text/javascript"></script>
<script src="/jpeg_camera/canvas-to-blob.min.js" type="text/javascript"></script>
<script src="/jpeg_camera/jpeg_camera.min.js" type="text/javascript"></script>
または、代わりとなる以下のスクリプトだけでもOKです。
<script type="text/javascript" src="js/libs/jpeg_camera/jpeg_camera_with_dependencies.min.js"></script>
本番環境では、開発とは違って後者を書くことが多いようです。
ライブラリーを読み込めたら、グローバルでJpegCameraオブジェクトを使用して、カメラが利用できるかどうかを確認し、もしできない場合はフォールバックを選びます。
カメラへのアクセスが確認できれば、JpegCamera()コンストラクターでカメラの準備が整ったときのためにリスナーをセットアップします。
JpegCamera()コンストラクターは引数にCSSセレクターをとり、カメラストリームに使用するコンテナを指定します。
以下がこの手順のコードになります。
(function() {
if(!window.JpegCamera) {
alert('Camera access is not available in your browser');
} else {
JpegCamera('.camera')
.ready(function(resolution) {
// ...
}).error(function() {
alert('Camera access was denied');
});
}
})();
この方法でアプリケーションを設定してカメラの準備ができたら、そのまま起動するか、またはユーザーに違うブラウザー、あるいはアプリケーションでカメラがアクセスできるようにする必要があることを知らせます。
readyコールバック関数の内部では、第1引数としてデバイスのカメラ解像度を渡します。これは、構築中のアプリケーションがデバイスのカメラの品質(HDキャプチャーできるか否かなど)に依存している場合に役立ちます。
その間にerrorコールバックは引数として状況を説明するstringメッセージを受け取ります。エラー時にユーザーに説明を表示する必要があれば、ライブラリーが提供するメッセージを利用できます。
さらにJpegCamera APIは次のメソッドも提供しています。
- capture():写真を撮るメソッド。画像そのものをSnapshotオブジェクト(JpegCameraが画像に使うクラス)として返します
- show():写真を撮ったら、取得するSnapshotオブジェクトを使ってshow()を呼び出して、ページに画像を表示できます。カメラを初期化したときに指定した同じコンテナーの中に画像が表示されます
- showStream():スナップショットがコンテナーに表示された時点で、showStream()は画像を非表示にし、ストリームを表示します
- getCanvas():パラメーターとしてコールバック関数をとり、キャプチャーされた画像と合わせてcanvas要素を引数として受けます
JpegCameraでどのようなことができるのか、実際にアプリケーションで確認します。
アプリケーションを構築する
デモのアプリケーションはLayoutの模倣をしています。つまりユーザーは写真を撮り、写真を組み合わせて新しい画像を生成できます。デモバージョンでは、合成画像はクリックしてダウンロードできます。
アプリケーションの構造はモジュールのパターンに基づいています。このパターンにはいくつかのメリットがあります。
- 各アプリケーションコンポーネント間を明確に区分けできます
- ほかで厳格に求められるメソッドとプロパティだけにすることで、グローバルスコープが分かりやすくなります。言い換えると、プライベート属性が使えるようになります
self invoked関数に3つのパラメーターを渡していることに気づくはずです。
(window, document, jQuery)
これらの引数を受け取ります。
function(window, document, $)
windowとdocumentを渡す理由は、縮小のためです。これらを引数として渡すと、windowとdocumentは1文字に置き換えられます。もしこれらのグローバルオブジェクトを直接参照していたら、縮小化ツールはこれより短い名前で置き換えることはできません。
jQueryを使うと、main関数(たとえばPrototype)と同様に$を使うほかのライブラリーとの衝突を防ぎます。
LayoutsとCustomモジュールの一番上に、以下のような記述があります。
if(!window.LayoutApp) {
window.LayoutApp = {};
}
これには理由が2つあります。
- index.htmlのスクリプトが適切でなかった場合に、モジュールがエラーになるのを防ぎます
- モジュールをメインの一部にし、アプリケーションを起動したときだけ利用可能にすることで、グローバルスコープを分かりやすくします
アプリケーションのロジックは3つのモジュールに分かれます。
- Appモジュール
- Layoutモジュール
- Customモジュール
この3つのモジュールはライブラリーとともに、以下のようにindex.htmlに記述する必要があります。
<!-- index.html -->
<script type="text/javascript" src="js/libs/jquery-1.12.1.min.js"></script>
<script type="text/javascript" src="js/libs/jpeg_camera/jpeg_camera_with_dependencies.min.js"></script>
<script type="text/javascript" src="js/src/custom.js"></script>
<script type="text/javascript" src="js/src/layouts.js"></script>
<script type="text/javascript" src="js/src/app.js"></script>
またアプリケーションを起動するには、もう1つちょっとしたコードが必要です。
<!-- index.html -->
<script type="text/javascript">
(function() {
LayoutApp.init();
})();
</script>
モジュールを1つずつ説明していきます。
Appモジュール
Appモジュールはメインのアプリケーションロジックをホールドします。ユーザーとカメラとの対話を管理し、撮影した写真に基づくレイアウトを生成、ユーザーが生成した画像をダウンロードできるようにします。
すべてAppモジュールのinitメソッドから始まります。
// App module (app.js)
initCamera = function () {
if (!window.JpegCamera) {
alert('Camera access is not available in your browser');
} else {
camera = new JpegCamera('#camera')
.ready(function (resolution) {})
.error(function () {
alert('Camera access was denied');
});
}
},
bindEvents = function () {
$('#camera-wrapper').on('click', '#shoot', capture);
$('#layout-options').on('click', 'canvas', download);
};
init: function () {
initCamera();
bindEvents();
}
ìnit()が発生すると、ìnit()は以下のメソッドを呼び出してアプリケーションを開始します。
- initCamera()はカメラが利用可能なら起動し、利用できない場合は警告を表示します
- bindEvents()は必要なイベントリスナーをセットアップします
- 最初のメソッドはシャッター(Shoot)ボタンをクリックして写真を撮影します
- 2番目のメソッドは合成画像の1つがクリックされるとダウンロードします
capture = function () {
var snapshot = camera.capture();
images.push(snapshot);
snapshot.get_canvas(updateView);
},
ユーザーがシャッターをクリックするとcapture()が呼び出されます。capture()はスナップショットのクラスメソッドgetCanvas()を使って、コールバック関数updateView()として通します。
updateView = function (canvas) {
canvas.selected = true;
canvases.push(canvas);
if (!measuresSet) {
setCanvasMeasures(canvas);
measuresSet = true;
}
updateGallery(canvas);
updateLayouts(canvas);
},
そして、updateView()は新しいcanvasオブジェクトをキャッシュし(updateGallery()を参照)、魔法のようなメソッドupdateLayouts()を呼び出して新しい画像のレイアウトに更新します。
updateLayouts()は以下の3つのメソッドに依存しています。
- setImageMeasures():撮影した枚数を考慮に入れて画像に合った幅と高さを定義します
- setSourceCoordinates():画像の寸法を確認して、画像の中心に座標を返します
- setTargetCoordinates():描かれる画像のインデックスを考慮に入れて、対象のキャンバスに描かれる画像の位置の座標を返します
さらに、calculateCoeficient()はソースとターゲットのキャンバスの寸法を比較して、オリジナル画像と生成された画像のバランスを保つよう配慮します。
最後に、updateLayout()は上記の4つの関数のデータでcontext.drawImage()を使って新しいキャンバスに画像を描きます。描けるのは8つのパラメーターを使ったもので、ソースの座標、ソースの寸法、ターゲットの座標とターゲットの寸法を指定します。
Layoutsモジュール
Layoutsモジュールはヘルパー関数と基本的なレイアウトデータを提供します。
スコープを分かりやすい状態にしておきたいので、ほかのモジュールは絶対に必要なものだけを共有し、Layoutsモジュールは、Appモジュールがゲッターを経由して必要とする属性へのアクセスを許可します。
// Layouts module (layouts.js)
var CANVAS_MAX_MEASURE = 200,
LAYOUT_TYPES = {
HORIZONTAL: 'horizontal',
VERTICAL: 'vertical'
},
LAYOUTS = [
{
type: LAYOUT_TYPES.VERTICAL
},
{
type: LAYOUT_TYPES.HORIZONTAL
}
];
return {
getCanvasMaxWidth: function() {
return CANVAS_MAX_MEASURE;
},
getLayouts: function() {
return LAYOUTS.concat(Custom.getCustomLayouts());
},
isHorizontal: function(layout) {
return layout.type === LAYOUT_TYPES.HORIZONTAL;
},
isVertical: function(layout) {
return layout.type === LAYOUT_TYPES.VERTICAL;
},
isAvailable: function(layout, totalImages) {
return !layout.minImages || layout.minImages <= totalImages;
}
}
上のコードのように、どのモジュールもLayoutsモジュール内を変えられませんが、アプリケーションを動かすのに必要なものはすべて、すぐに利用できます。
アプリケーションでの各メソッドの働きは次の通りです。
- getCanvasMaxWidth():画像をきちんと表示するために、画像の幅をデフォルトで決め、CANVAS_MAX_MEASUREに割り当てました。この値はAppモジュールで合成画像の寸法を決める際に使います。Appモジュール内での実際の数式を確認します
// App module (app.js)
setCanvasMeasures = function (canvas) {
measures.height = canvas.height * MAX_MEASURE / canvas.width;
},
この方法で、JpegCameraから取得する画像の大きさに関係なく、合成画像を好きな寸法にできます。また撮影した写真の縦横比を維持して、伸び縮みを防ぎます。
- getLayouts():ユーザーが撮影した写真の合成写真を生成するレイアウトを返します。アプリケーションのデフォルトレイアウトとCustomモジュール(詳細は後述)に追加できるカスタムレイアウトの両方を一緒に返します
- isHorizontal() and isVertical():アプリケーションのデフォルトレイアウトは、LAYOUT_TYPESから値を取得するtype属性を設定して定義します。引数としてオブジェクトを受け取り、この定数に依存することで、2つのメソッドはlayout.type === LAYOUT_TYPES.HORIZONTALとlayout.type === LAYOUT_TYPES.VERTICALを評価します。これらの関数の戻り値に基づいて、Appモジュールは寸法、ソースの座標、合成画像のターゲットの座標を決めます
- isAvailable():ユーザーが撮影した画像の枚数と、レイアウトのminImages属性を考慮して、レイアウトをレンダリングするか否かを決めます。ユーザーの撮影した画像が多いか、ミニマムの設定枚数より多ければ、レイアウトをレンダリングします。ユーザーの撮影枚数が少ないか、レイアウトに定義されたminImages属性がなければcombined画像を生成します
Customモジュール
Customモジュールを使って、新しいレイアウトを追加できます。アプリケーションのメインメソッドsetImageMeasures()、 setSourceCoordinates()、setTargetCoordinates()の3つを新しいレイアウトで実装して追加します。
具体的には、CustomモジュールのCUSTOM_LAYOUTS配列に、上の3つのメソッドを実装して、新しいレイアウトオブジェクトを追加します。
// Custom module (custom.js)
var CUSTOM_LAYOUTS = [
/**
* Place your custom layouts as below
*/
// ,
// {
// setImageMeasures: function (layout, targetCanvas, imageIndex) {
// return {
// height: 0,
// width: 0
// }
// },
// setSourceCoordinates: function (canvas, layout, imageWidth, imageHeight, imageIndex) {
// return {
// x: 0,
// y: 0
// }
// },
// setTargetCoordinates: function (targetCanvas, layout, imageWidth, imageHeight, imageIndex) {
// return {
// x: 0,
// y: 0
// }
// }
// }
];
アプリケーションの各overriden関数は、描かれているレイアウトに必要な関数があるかを確認します。
App.setImageMeasures()の中は次のようになっています。
// App module (app.js)
setImageMeasures = function (layout, targetCanvas, imageIndex) {
if (isFunction(layout.setImageMeasures)) {
return layout.setImageMeasures(layout, targetCanvas, imageIndex);
} else {
if(Layouts.isVertical(layout)) {
return {
width: $(targetCanvas).width(),
height: $(targetCanvas).height() / images.length
};
} else if(Layouts.isHorizontal(layout)) {
return {
width: $(targetCanvas).width() / images.length,
height: $(targetCanvas).height()
};
}
return {
width: $(targetCanvas).width(),
height: $(targetCanvas).height()
};
}
}
カスタムレイアウトが画像の寸法を決める関数を実装しているか簡単に確認し、もし実装していればその関数を呼び出します。
呼び出しはisFunction()ヘルパーでできます。受け取った引数のタイプをチェックして実際に関数であるかを確認します。
// App module (app.js)
isFunction = function(f) {
return typeof f === 'function';
}
現在のモジュールがsetImageMeasures()を実装していなければ、アプリケーションは先に進み、レイアウトタイプ(HORIZONTALかHORIZONTAL)で寸法を設定します。
同じ流れでsetSourceCoordinates()とsetTargetCoordinates()も実装します。
新しいレイアウトで、撮影画像をトリミングするサイズ、どの座標をターゲットのキャンバスのどこに配置するか決められます。
覚えておいて欲しい重要な点は、カスタムレイアウトメソッドはオリジナルメソッドが返すのと同じ属性のオブジェクトを返さなければいけないことです。
分かりやすく説明すると、setImageMeasures()をカスタムで実装したら次の形式で返す必要があるということです。
{
height: 0, // height in pixels
width: 0 // width in pixels
}
カスタムレイアウトを作成する
カスタムレイアウトを作成します。こちらのファイルですべてのコードを確認できます。
Layoutsモジュールの項で説明したように、レイアウトは定義された属性minImagesを持てます。ここでは3に設定します。また1枚目の画像がターゲットキャンバスの60%、残りの40%を2枚目と3枚目で分けます。
{
minImages: 3,
imageData: [
{
widthPercent: 60,
heightPercent: 100,
targetX: 0,
targetY: 0
},
{
widthPercent: 20,
heightPercent: 100,
targetX: 120,
targetY: 0
},
{
widthPercent: 20,
heightPercent: 100,
targetX: 160,
targetY: 0
},
],
// ...
実行するには、targetCanvasの寸法を使って、3枚の画像に簡単なルールを適用します。
// Custom module (custom.js)
setImageMeasures: function (layout, targetCanvas, imageIndex) {
var imageData = this.imageData[imageIndex];
if( imageData) {
return {
width: imageData.widthPercent * $(targetCanvas).width() / 100,
height: imageData.heightPercent * $(targetCanvas).height() / 100
};
}
return {
height: 0,
width: 0
}
},
すべての関数は引数として現在処理している画像の数(imageIndex)を受け取るので、サイズやトリミングのソース座標、オリジナル画像の一部を各写真のターゲットキャンバスに置く座標を自由に決められます。
特定のimageIndexに関連するデータがない場合、両方の属性を0にセットしてオブジェクトを返します。この方法で、ユーザーがもしカスタムレイアウトで定義したよりも多くの写真を撮っても、合成画像の見栄をえ良くできます。
その他の2つの関数をオーバーライドします。
setSourceCoordinates()
画像の中心にすべての垂直コンテンツを一緒に入れたいので、xを50、yを0に設定してオブジェクトを返します。
setSourceCoordinates: function (canvas, layout, imageWidth, imageHeight, imageIndex) {
return {
x: 50,
y: 0
}
},
setTargetCoordinates()
キャンバスの寸法は分かっているので、ターゲットキャンバスに配置する位置を入力して定義します。
setTargetCoordinates: function (targetCanvas, layout, imageWidth, imageHeight, imageIndex) {
var imageData = this.imageData[imageIndex];
if (imageData) {
return {
x: imageData.targetX,
y: imageData.targetY
}
}
return {
x: 0,
y: 0
}
}
お気づきだと思いますが、この例にはまだたくさんの改善の余地がありますが、入門としてはちょうど良いはずです。
最後に
記事で説明してきた通り、JpegCameraはクロスブラウザーの互換性を心配することなく、アプリケーションで簡単にユーザーのカメラを使えます。
プロジェクトの一部にJpegCameraを取り入れるのは、ページに必要なスクリプトを追加するのと同じくらい簡単です。使い方もたった4つのAPIメソッドを理解するだけ。しかも、2〜300行より少し多いくらいのコードを書くだけで楽しいアプリケーションを追加できます!