APNGの構造を理解する
目次
1. はじめに
私はピクセルアートが好きでよくwebサイトに使ったりしています。
web上で使用する場合、できるだけサイズが小さく扱いやすいファイル形式が良いという理由でPNG(Portable Network Graphics)を主に使用しています。
PNGには派生した形式のAPNG(Animated PNG)というものがあり、GIFのような動きのある画像として表示できます。
ピクセルアートを動かすのにも便利です。
ここではapngを表示できないようだったので下の画像はwebpです
使用画像:https://assetstore.unity.com/packages/2d/characters/pixel-adventure-1-155360#content
今まで何気なく利用していましたが、PNGとAPNGはどのような構造になっているのか気になったので調べてみることにしました。
2. PNGの構造を理解する
APNGを理解する前に、まずPNGの基本構造を押さえておく必要があります。PNGファイルは、シグネチャとチャンクから構成されています。
PNGのシグネチャ
PNGファイルは、以下の8バイトのシグネチャで始まります。
// 16進数
89 50 4E 47 0D 0A 1A 0A
これは固定値でPNGファイルであることを示しています。
Critical chunks
PNGファイルは、複数のチャンクで構成されています。主要なチャンクには以下があります。
PLTEチャンクはカラータイプが3の時は設置が必須、他は常に必須です。
- IHDR(Image Header):画像の基本情報(幅、高さ、ビット深度など)
- PLTE(Palette): カラーパレット定義
- IDAT(Image Data):実際の画像データ
- IEND(Image End):PNGデータの終端を示す
Ancillary chunks
任意で配置できるイメージへの付加情報を保持するためのチャンクです。
単にテキストを保持したり、タイムスタンプを保持したり様々なチャンクがあります。
後述するAPNGとして動作させるために必要なチャンクもこの補助チャンクです。
3. APNGの違い
APNGは、PNGの構造を拡張したものです。主な追加チャンクは以下の通りです:
- acTL(Animation Control):アニメーションの制御情報
- fcTL(Frame Control):各フレームの制御情報
- fdAT(Frame Data):2フレーム目以降の画像データ
これらのチャンクを適切に配置することで、複数の画像をアニメーションとして表示できます。
IDATの前にfcTLを配置するかは任意で、配置しなかった場合はアニメーションに含まれません。
因みにAPNGの表示に対応しない環境ではIDATの画像データが表示され、通常のPNGとして振る舞います。
4. PNGからAPNGへの変換プログラムの実装
いろいろ調べましたが実際に触ってみた方が理解が深まりそうなので、今回は複数のPNGファイルを一つのAPNGに変換するプログラムを作ってみたいと思います。
ブラウザで動くことを確認したかったのでSolidJS(TypeScript)で書いています。
4.1.バイナリの取得
まずは画像をバイナリデータに変換します。
fetchやinputで取得したPNGをUnit8Arrayでバイナリとして一時保存します。
以下SolidJSのinputで取得する例
const [files, setFiles] = createSignal<Uint8Array[]>([]);
const handleInput = async (event: Event) => {
const files = (event.target as HTMLInputElement).files;
if (!files) return;
const images = Array.from(files).map((file) => {
const reader = new FileReader();
reader.readAsArrayBuffer(file);
return new Promise<Uint8Array>((resolve) => {
reader.onload = () => {
resolve(new Uint8Array(reader.result as ArrayBuffer));
};
});
});
setFiles(await Promise.all(images));
};
return (
<input
type="file"
accept="image/png"
multiple
oninput={handleInput}
/>
)
ここで取得したバイナリを少し覗いてみましょう。
const hexString = Array.from(files()[0], (byte) =>
byte.toString(16).padStart(2, "0"),
).join(" ");
console.log(hexString);
先頭8バイトが89 50 4E 47 0D 0A 1A 0A
となっており、PNGのシグネチャと一致することが分かります。
4.2.チャンクを加工して組み立て
PNGシグネチャと一枚目のIDATチャンク以前のチャンクをコピーします。
const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
const chunks: Uint8Array[] = [PNG_SIGNATURE];
const firstImage = images[0];
const firstImageChunks = extractAllChunks(firstImage);
for (const chunk of firstImageChunks) {
if (chunk.type === "IDAT") {
break;
}
chunks.push(createChunk(chunk.type, chunk.data));
}
extractAllChunksは以下のように画像のチャンクを抜き出して配列で返しています。
const extractAllChunks = (pngData: Uint8Array): PNGChunk[] => {
const chunks: PNGChunk[] = [];
let offset = 8;
while (offset < pngData.length) {
const chunkLength = new DataView(pngData.buffer, offset).getUint32(0);
const chunkType = new TextDecoder().decode(
pngData.slice(offset + 4, offset + 8)
);
const chunkData = pngData.slice(offset + 8, offset + 8 + chunkLength);
chunks.push({ type: chunkType, data: chunkData });
offset += chunkLength + 12;
if (chunkType === "IEND") {
break;
}
}
return chunks;
};
acTLチャンクを作成して追加します。
今回は無限ループに設定しています。
chunks.push(createActlChunk(images.length));
const createActlChunk = (numFrames: number): Uint8Array => {
const data = new Uint8Array(8);
const view = new DataView(data.buffer);
view.setUint32(0, numFrames);
view.setUint32(4, 0); // Number of times to loop (0 = infinite)
return createChunk("acTL", data);
};
一枚目のIDATチャンクはそのまま、二枚目以降はfdATチャンクとしてシーケンス番号を付与しコピペします。
let sequenceNumber = 0;
for (let i = 0; i < images.length; i++) {
chunks.push(createFctlChunk(sequenceNumber++, width, height, delay));
const imageChunks = extractAllChunks(images[i]);
const idatChunks = imageChunks
.filter((chunk) => chunk.type === "IDAT")
.map((chunk) => chunk.data);
if (i === 0) {
for (const idatData of idatChunks) {
chunks.push(createChunk("IDAT", idatData));
}
} else {
chunks.push(...createFdatChunks(idatChunks, sequenceNumber));
sequenceNumber += idatChunks.length;
}
}
createFctlChunkでは一旦フレームの表示時間のみ指定できるようにしています。
const createFctlChunk = (
sequenceNumber: number,
width: number,
height: number,
delay: number
): Uint8Array => {
const data = new Uint8Array(26);
const view = new DataView(data.buffer);
view.setUint32(0, sequenceNumber);
view.setUint32(4, width);
view.setUint32(8, height);
view.setUint32(12, 0); // x offset
view.setUint32(16, 0); // y offset
view.setUint16(20, delay); // delay numerator
view.setUint16(22, 1000); // delay denominator
view.setUint8(24, 0); // dispose op
view.setUint8(25, 0); // blend op
return createChunk("fcTL", data);
};
createFdatChunksはIDATチャンクにシーケンス番号を追加しているだけです。
const createFdatChunks = (
idatChunks: Uint8Array[],
startSequenceNumber: number
): Uint8Array[] => {
return idatChunks.map((idatChunk, index) => {
const fdatData = new Uint8Array(idatChunk.length + 4);
new DataView(fdatData.buffer).setUint32(0, startSequenceNumber + index);
fdatData.set(idatChunk, 4);
return createChunk("fdAT", fdatData);
});
};
最後にIENDを追加して結合します。
chunks.push(createChunk("IEND", new Uint8Array(0)));
const newPngData = concatenateUint8Arrays(chunks);
因みに長くなりそうなので省きましたが、ちょこちょこ登場するcreateChunk関数が一番複雑になっており、チャンクの組み立てや各チャンクのタイプとデータからCRCを計算・付与を行っています。
4.3.表示
ここまで来ればあとは表示するだけです。
Unit8Array→Blob→urlと変換していき、imgに渡します。
const [url, setUrl] = createSignal("");
const blob = new Blob([newPngData], { type: "image/png" })
const generatedUrl = URL.createObjectURL(blob);
setUrl(generatedUrl);
return (
<Show when={url()}>
{(url) => (
<img src={url()} />
)}
</Show>
)
表示できました。
以下のURLから実際に触れます。
https://apng-generater.vercel.app
sample imagesボタンを押すと用意されたPNG画像(32x32px/12枚)を読み込んでAPNGに変換し、表示します。
お持ちの画像があればそれを変換することも可能です(多分)
サンプル画像が小さいのもあるとは思いますが、かなり高速に変換してくれて嬉しいです。
5. まとめ
PNGとAPNGの構造について少し理解できた気がします。
TypeScriptでバイナリを触ったことがなかったのですが、思ったよりも扱いやすくて驚きました。
また今回は単純にIDATをコピペしただけなので、最適化の余地は十分にあると考えています。
良い最適化が実装できそうならまた記事にするかもしれません。
6. 参考
https://www.w3.org/TR/png-3/#5DataRep
コメントを残す