ブラウザでアニメーションスタンプ画像を深く読み込む

こんにちは、LINE Fukuokaのha1fです。この記事はLINE Advent Calendar 2017の8日目の記事です。

現在はiOSアプリの開発を担当していますが、入社前に内定者アルバイトとして社内ツールの作成を担当していました。その時の業務の1つ、アニメーションスタンプチェッカーの作成について書きます。

APNGとは

apng

APNG(Animated Portable Network Graphics)はアニメーション用の連番画像の形式で、LINEのアニメーションスタンプでも利用しています。

GIFと比べると、フルカラーを使えたり、アルファチャンネルを持てたり、圧縮率が高かったりという利点があります。

APNGはPNGと互換性があり、APNG非対応の環境でも、通常の静止画として表示されます。ツールを使って、連番のPNG画像などから作成できます。

アニメーションスタンプチェッカー

LINEのアニメーションスタンプには、いくつかの制約があります。

例えば、以下のような制約があります。

  • 幅320px×高さ270px以内
  • 幅か高さのどちらかが必ず270px以上
  • 再生時間は1スタンプあたり最大4秒
  • フレーム数は5~20フレーム
  • カラーモードはRGB
  • 画像容量は1ファイル300KB以下

詳細は以下のリンクから確認できます。

ガイドライン – LINE Creators Market

アニメーションスタンプチェッカーは、スタンプの制約判定の補助に使われているツールです(公開はしていません)。

checker

※スタンプ画像は非表示にしています。

画像の含まれたディレクトリをドラッグ&ドロップすると、画像が再帰的に読み込まれて、画像サイズや容量、カラーモードなどが判定され、必要に応じてエラーが表示されます。

今回、Mac/Windowsともに動作させる必要があるため、ブラウザベースのSPA(Single Page Application)として開発しました。

ブラウザでAPNGを再生する

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 });

しかし、ファイルの容量やカラーモードはこれだけでは取得できませんので、別の実装が必要になります。

File API

HTML5にはFile APIという仕様があり、ローカルにあるファイルをJavaScriptで読み込むことができます。

※apng-canvasではXMLHttpRequestを用いています。

これにより、アップロード前でも画像をプレビューしたり、ファイルを解析したりできます。今回はこのFile APIを用いて、画像データをより深く読み込んでいきます。

FileReader、File、FileList

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);
    });
}

これで、ファイルサイズを判定できます。

ArrayBufferとDataView

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からimgタグにセットする

ArrayBufferとして読み出したバイナリを通常どおりブラウザに表示するには、以下のようにしてDataURIに変換すると楽に表示できます。

img.attr({ src: `data:image/png;base64,${btoa(Array.from(new Uint8Array(this.arrayBuffer), e => String.fromCharCode(e)).join(''))}` });

PNGとAPNGの構造

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チャンク分のデータを読み込むメソッドとして定義したものです。

IHDRチャンクからカラータイプを判定する

チャンクの配列が取得できたら、それぞれのチャンクについて細かく解析を行います。

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への拡張

APNGは、通常のPNGで使われているチャンクと別に、’fcTL’チャンクや、’fdAT’チャンク、’acTL’といったチャンクを追加することで、PNGを拡張しています。’acTL’チャンクは1つだけ存在し、フレーム数やループ数などが含まれています。’fcTL’チャンクと’fdAT’チャンクは複数存在し、各フレームについての時間・画像データ・表示位置等の情報が含まれています。

詳細な仕様はMozillaの「Animated PNG graphics」で閲覧できます。

ここでは詳細は省略しますが、アニメーションスタンプチェッカーではこの仕様に基づいて解析しています。

連番画像を再生する

余談ですが、連番のPNG画像を再生する際に、キャンバスに描画するのも1つの手ですが、ツール内では、要素の位置を段階的に動かすことで簡易的に再生しています。

animation

※スタンプ画像は削除しています。

透過設定の不備などを発見しやすくするために、背景色を付けています。

まとめ

自分は通常業務でSwiftを書いているので、Macだけで動くツールを作るのが最も簡単です。クロスプラットフォームなツールを作成する手法としてはElectronもありますが、ブラウザでツールを作ると、Mac/Windowsで動くだけでなく、iOSやAndroidなどでも動作させることができます。

さらに、不具合が発生するなどしてツールを更新する必要がある時に、相手に新しいファイルをダウンロードしてもらう必要がなく、サーバー上のファイルを更新するだけで済みます。

HTML5になってからかなり機能も増えて、ブラウザツールは非常に面白い題材でした。

明日はHagaさんによる「UIにMetalが使えないかいろいろ試してみた話」です。お楽しみに!

Related Post