Canvas convert to Bitmap BLOB
Javascriptで大量のサムネイル画像を生成する必要があったのでそのお話です。
Javascriptで画像のサムネイルを作成する場合の定番的な手法として、
- Imageを作成
- CanvasにImageを書き込み
- Canvasで変形
- CanvasからData URLに変換
という流れがあります。
var img = new Image(); img.src = '/path/to/filename'; img.onload = function() { var canvas = document.createElement('canvas'); var ctx = canvas.getContext('2d'); var w = maxWidth/this.naturalWidth; var h = maxHeight/this.naturalHeight; var rate = w <= h ? w : h; rate = rate < 1 ? rate : 1; canvas.width = Math.round(this.naturalWidth*rate); canvas.height = Math.round(this.naturalHeight*rate); ctx.drawImage(this, 0, 0, this.naturalWidth, this.naturalHeight, 0, 0, canvas.width, canvas.height); if (this.src.match(/^data:image\/jpeg;/)) { this.src = canvas.toDataURL('image/jpeg', 1.0); } else { this.src = canvas.toDataURL(); } };
ただし、この方法は凄い重いです。
一つの画像のサムネイルを作るだけなら気になるほどの重さではないのですが、何枚もサムネイルを作るとメインスレッドが完全に固まってしまうほどの重さになります。
何が重いかというとcanvas.toDataURL
がこの処理の大部分をしめる原因になります。
(drawImageで変形すると重いのでsetTransform使う必要があるけど、その話は割愛)
他の方法としてW3Cの方をみるとcanvas.toBlob
というメソッドで非同期に生成できますが、実装されているブラウザがないので自力で別の方法を実装する必要があります。
そのため、凄い面倒臭いのですがCanvasの画像データから自力で画像のバイナリを作成します。
Get image data from Canvas
まずはCanvasから画像のピクセルデータを取得します。
var ctx = canvas.getContext('2d'); var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
取得したimageData
のimageData.data
からRGBAで4要素を取る1次元配列を取得することができます。
var red = imageData.data[0]; var green = imageData.data[1]; var blue = imageData.data[2]; var alpha = imageData.data[3];
Create Bitmap header
画像のピクセルデータの準備が出来たら、次にBitmap画像を作成していきます。 まずはBitmapのヘッダーを生成します。
function getLittleEndianHex(value) { var result = []; for (var bytes = 4; bytes > 0; bytes--) { result.push(String.fromCharCode(value & 255)); value >>= 8; } return result.join(''); } var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); var bodyByteSize = imageData.data.length; // create header string var header = 'BM' + getLittleEndianHex(bodyByteSize) '\x00\x00' + '\x00\x00' + '\x36\x00\x00\x00' + '\x28\x00\x00\x00' + getLittleEndianHex(canvas.width) + getLittleEndianHex(canvas.height) + '\x01\x00' + '\x20\x00' + '\x00\x00\x00\x00' + '\x00\x00\x00\x00' + '\x13\x0B\x00\x00' + '\x13\x0B\x00\x00' + '\x00\x00\x00\x00' + '\x00\x00\x00\x00';
※ 参考サイト
Write Bitmap data to ArrayBuffer
これで準備が出来たので、ArrayBufferにBitmapのヘッダと画像のピクセルデータを書き込んでいきます。
// create buffer var buf = new ArrayBuffer(byteSize + header.length + 4 - (header.length % 4); var data8 = new Uint8Array(buf); var data32 = new Uint32Array(buf); // write header for (var i = 0; i < header.length; i++) { data8[i] = header.charCodeAt(i); } // write image data for (var x = 0, z = Math.ceil(header.length % 4); x < width; x++) { for (var y = 0; y < height; y++) { var i = (width * y) + x; var j = (width * (height - y)) - (width - x) + z; data32[j] = (imageData.data[(i*4)+1] << 24) | (imageData.data[(i*4)+2] << 16) | (imageData.data[(i*4)+3] << 8) | (imageData.data[(i*4)+0]) || -1; } }
注意点としてUint32Array
を生成するときは4バイト単位の長さにすること。
BitmapとCanvasのピクセルデータでデータではy座標が逆になるという点に気をつけてください。
Convert ArrayBuffer to Blob and Blob URL
最後に作成したArrayBufferをBlobに変換し、そこからBlob URLに変換します。
var b = new Blob([buf, {type: 'image/bmp'}); var url = URL.createObjectURL(b); var img = new Image(); img.src = url; img.onload = function() { URLrevokeObjectURL(img.src); }
URL.createObjectURL
で作成できるBlob URLの数は決まっているため、Blob URLを作成したら必ずすぐにURLrevokeObjectURL
で解放してあげる必要があります。
きちんと計測した訳ではありませんが200〜400ぐらいでBlob URLの確保に失敗して読み込めないURLが出来始めます。
(URL.createObjectURL
自体は成功するので、読み込むまで失敗するかどうかわかりません)
Exceute on worker
これでCanvasからBlob URLへの変換ができましたが、canvas.toDataURL
よりもパフォーマンスが悪化しています。
そこで、Workerを使って別スレッドで処理を行います。
ちょうどimageDataがTransferableなオブジェクトのため、Workerに大きなデータを渡してもそこまで遅延せずにデータのやり取りを行えます。
// worker function function canvasToBlob() { self.addEventListener('message', function(event) { // create bitmap ... self.postMessage({buffer: buf}, [buf]); }); } // generate worker var workerFnString = '(' + canvasToBlob.toString() + ')()'; var workerblob = new Blob([workerFnString], {type: 'text/javascript'}); var workerUrl = URL.createObjectURL(workerblob); var worker = new Worker(workerUrl); // revoke url URL.revokeObjectURL(workerUrl); worker.addEventListener('message', function(e) { var b = new Blob([e.data.buffer], {type: 'image/bmp'}); var url = URL.createObjectURL(b); // apply object var img = new Image(); img.src = url; img.onload = function() { URL.revokeObjectURL(img.src); }); }); worker.postMessage({imageData: imageData}, [imageData.data.buffer]);
これでメインスレッドをブロックせずに実行できるようになりました。
※参考サイト
他にもリサイズ処理をWorkerでやったり、同時実行数を制限することでメインスレッドを固めるのを防ぐことができます。 将来的にはWorker上でCanvasを操作できるCanvasProxyや、CanvasからBlobを生成できるtoBlobがきたらもう少しパフォーマンスを良くできると思います。
しかし、まさかJSでBitmapの仕様を調べることになるとは思いませんでした。。。 基本的にブラウザアプリケーションではサーバーサイドでサムネイルを生成するのがベストな選択なので、どうしてもクライアントサイドで生成しないといけない理由がないときはサーバーサイドで生成するようにしてください。