LINE Engineering
Blog
ブラウザでアニメーションスタンプ画像を深く読み込む
LINE Fukuokaで、普段はiOSアプリを開発しています。
こんにちは、LINE Fukuokaのha1fです。この記事はLINE Advent Calendar 2017の8日目の記事です。
現在はiOSアプリの開発を担当していますが、入社前に内定者アルバイトとして社内ツールの作成を担当していました。その時の業務の1つ、アニメーションスタンプチェッカーの作成について書きます。
APNG(Animated Portable Network Graphics)はアニメーション用の連番画像の形式で、LINEのアニメーションスタンプでも利用しています。
GIFと比べると、フルカラーを使えたり、アルファチャンネルを持てたり、圧縮率が高かったりという利点があります。
APNGはPNGと互換性があり、APNG非対応の環境でも、通常の静止画として表示されます。ツールを使って、連番のPNG画像などから作成できます。
LINEのアニメーションスタンプには、いくつかの制約があります。
例えば、以下のような制約があります。
詳細は以下のリンクから確認できます。
アニメーションスタンプチェッカーは、スタンプの制約判定の補助に使われているツールです(公開はしていません)。
※スタンプ画像は非表示にしています。
画像の含まれたディレクトリをドラッグ&ドロップすると、画像が再帰的に読み込まれて、画像サイズや容量、カラーモードなどが判定され、必要に応じてエラーが表示されます。
今回、Mac/Windowsともに動作させる必要があるため、ブラウザベースのSPA(Single Page Application)として開発しました。
APNGファイルは、ブラウザが対応していれば、imgタグのsrcに設定するだけで自動的に再生されます。
以前はFirefoxしか対応していなかったのですが、2014年にSafariが対応しました。そして今年、2017年6月からは、Chromeも対応しています。
対応していないブラウザで再生する場合は、davidmz/apng-canvasなどのライブラリを使って描画できます。内部ではバイナリを読み込んで解析し、フレームごとにキャンバスに描画しています。
画像のサイズは、標準的な方法で取得できます。
var img = $("<img />", {
alt: foldername + "/" + filename
});
img.bind('load', function() => {
const dom = img.get(0);
const width = dom.naturalWidth;
const height = dom.naturalHeight;
}
img.attr({ src: e.target.result });
しかし、ファイルの容量やカラーモードはこれだけでは取得できませんので、別の実装が必要になります。
HTML5にはFile APIという仕様があり、ローカルにあるファイルをJavaScriptで読み込むことができます。
※apng-canvasではXMLHttpRequestを用いています。
これにより、アップロード前でも画像をプレビューしたり、ファイルを解析したりできます。今回はこのFile APIを用いて、画像データをより深く読み込んでいきます。
File APIは<input type="file">
という要素を使うだけで使うことができます。
さらに、<input type="file" webkitdirectory directory>
とすると、ディレクトリを選択させることができます(標準化はされていませんので、Chrome等一部のブラウザでのみ動作します)。
FileListオブジェクトがイベントハンドラに渡されるので、そこからforなどでFileオブジェクトを取り出し、FileReaderで解析します。
FileオブジェクトはBlobオブジェクトを継承していて、ファイル名やサイズ、MIMEタイプ等を取得できるので、処理の対象を絞ることもできます。
handleDirectorySelected(e) {
Array.from(e.target.files).forEach((file, index, array) => {
const fileSize = file.size;
// 必要に応じてnameやtypeなどで読み込み対象を絞る。
const reader = new FileReader();
reader.onload = (e) => { /* some process */ }
reader.readAsArrayBuffer(file);
});
}
これで、ファイルサイズを判定できます。
FileReaderの読み出し形式として、テキストやDataURIなども指定できますが、今回はバイナリ解析に便利なArrayBufferを使いました。
ArrayBufferは固定長のバイナリを表す型です。そのままでは操作できないので、DataViewを使って読み出します。
const fileReader = new FileReader();
fileReader.onload = function(e) {
const arrayBuffer = e.target.result;
const dataView = new DataView(arrayBuffer, 0);
// dataViewで処理する。
}
fileReader.readAsDataURL(file);
ここから、順番に読み出していきます。
ArrayBufferとして読み出したバイナリを通常どおりブラウザに表示するには、以下のようにしてDataURIに変換すると楽に表示できます。
img.attr({ src: `data:image/png;base64,${btoa(Array.from(new Uint8Array(this.arrayBuffer), e => String.fromCharCode(e)).join(''))}` });
PNGの仕様は、ISO/IEC 15948:2003などで定義されており、「Portable Network Graphics(PNG)Specification(Second Edition)」などから参照できます。
PNGのバイナリは、シグネチャ部の「89 50 4E 47 0D 0A 1A 0A」という8バイトのデータ列から始まり、その後にチャンクと呼ばれるデータのまとまりが複数続きます。
チャンク名 | サイズ(バイト) |
PNGシグネチャ | 8 |
IHDRチャンク | 25 |
・・・ | ・・・ |
IDATチャンク | 可変 |
・・・ | ・・・ |
IENDチャンク | 12 |
チャンクにはさまざまな種類があります。例えば'IHDR'チャンクには画像のサイズやカラーモードなどが含まれています。'IDAT'チャンクには画像データが含まれています。
それぞれのチャンクは以下のような構造になっています。
※チャンクサイズが0の場合はデータは省略されます。
内容 | サイズ(バイト) |
チャンクサイズ | 4 |
チャンクの種類 | 4 |
データ | 上で指定されたサイズ |
データのCRC | 4 |
これを順番に読み出していくと、チャンクの配列を取得できます。チャンクの種類は「Chunk layout」によるとISO 646で定義された文字のみなので、文字列に変換して保持しています。
getChunks() {
const chunks = [];
let chunk = { size: 0, type: '', crc: 0, dataOffset: 0x00, endOffset: 0x00 };
while (chunk.type !== 'IEND') {
chunk = this.readChunk(chunk.endOffset);
chunks.push(chunk);
}
return chunks;
}
ここでは省略していますが、readChunk()
メソッドは、指定した位置を起点にして、1チャンク分のデータを読み込むメソッドとして定義したものです。
チャンクの配列が取得できたら、それぞれのチャンクについて細かく解析を行います。
const ihdrChunk = chunks.find(function(chunk) {
return chunk.type === 'IHDR'
})
IHDRチャンクの構造は以下のとおりなので、合わせて読み込みます。
内容 | サイズ(バイト) |
幅 | 4 |
高さ | 4 |
ビット深度 | 1 |
カラータイプ | 1 |
圧縮手法 | 1 |
フィルター手法 | 1 |
インタレース手法 | 1 |
const offset = ihdrChunk.dataOffset;
return {
width: dataView.getUint32(offset),
height: dataView.getUint32(offset + 4),
bitDepth: dataView.getUint8(offset + 8),
colorType: dataView.getUint8(offset + 9),
compression: dataView.getUint8(offset + 10),
filter: dataView.getUint8(offset + 11),
interlace: dataView.getUint8(offset + 12),
crc: dataView.getUint32(offset + 13),
};
カラータイプの仕様は「Table 11.1」にあるとおりなので、これでようやく、RGBカラーかどうかを判定できるようになりました。
APNGは、通常のPNGで使われているチャンクと別に、'fcTL'チャンクや、'fdAT'チャンク、'acTL'といったチャンクを追加することで、PNGを拡張しています。'acTL'チャンクは1つだけ存在し、フレーム数やループ数などが含まれています。'fcTL'チャンクと'fdAT'チャンクは複数存在し、各フレームについての時間・画像データ・表示位置等の情報が含まれています。
詳細な仕様はMozillaの「Animated PNG graphics」で閲覧できます。
ここでは詳細は省略しますが、アニメーションスタンプチェッカーではこの仕様に基づいて解析しています。
余談ですが、連番のPNG画像を再生する際に、キャンバスに描画するのも1つの手ですが、ツール内では、要素の位置を段階的に動かすことで簡易的に再生しています。
※スタンプ画像は削除しています。
透過設定の不備などを発見しやすくするために、背景色を付けています。
自分は通常業務でSwiftを書いているので、Macだけで動くツールを作るのが最も簡単です。クロスプラットフォームなツールを作成する手法としてはElectronもありますが、ブラウザでツールを作ると、Mac/Windowsで動くだけでなく、iOSやAndroidなどでも動作させることができます。
さらに、不具合が発生するなどしてツールを更新する必要がある時に、相手に新しいファイルをダウンロードしてもらう必要がなく、サーバー上のファイルを更新するだけで済みます。
HTML5になってからかなり機能も増えて、ブラウザツールは非常に面白い題材でした。
明日はHagaさんによる「UIにMetalが使えないかいろいろ試してみた話」です。お楽しみに!
LINE Fukuokaで、普段はiOSアプリを開発しています。