js玩轉APNG — 逆轉火狐
- 2019 年 12 月 13 日
- 筆記
本文作者:IMWeb p2227 原文出處:IMWeb社區 未經同意,禁止轉載
APNG是一種常見的網頁動畫,兼容性較好,交互性差,要想對其進行深入了解,則要了解其文件格式。本文以一個具體的問題為例,帶你深入了解APNG的格式。
帶著問題學習 — 逆轉火狐
先上問題:有一張火狐logo的圖片,原圖是順時針旋轉的,我們怎麼來把它改為逆時針旋轉呢?

動畫的基本原理
幀動畫的基本原理是這樣的,事先準備若干張靜態圖片(關鍵幀),每張圖片之間有細微的差異,在快速順序切換各個關鍵幀時,利用人眼視覺暫留的原理,給用戶一個動畫的錯覺。 具體到火狐原圖,其實他包含了25張關鍵幀,每一幀之間火狐旋轉的角度有一點差別,然後每50ms播放一幀,這樣就形成了動畫

鑒於以上原理,我們的整體思路其實還是比較簡單的,把以上所有幀的播放順序倒過來,就能把火狐逆轉了。但在APNG裡面實現,同時有新的問題
- 如何區別每一幀?
- 如何把播放順序倒轉? 所以我們下一步是要學習APNG的文件格式
APNG 格式
PNG文件是一種二進位的點陣圖,由特定的文件頭+若干文件塊(chunk)組成 一個PNG文件的基本結構是這樣的
|-- PNG Signature --|-- IHDR --|-- IDAT --|-- IEND --|
PNG 簽名表示這是一個PNG文件 IHDR 是圖片的基本資訊,如寬高,色彩等 IDAT 是具體圖片影像數據塊,一個PNG文件有可能包含多個IDAT數據塊 IEND 表示一個PNG文件的結尾
PNG的文件塊(chunk)是特定格式的二進位數據塊,其基本格式如下
|--4:長度--|--4:標識符--|--N:內容,長度由前面參數決定--|--4:CRC32--|
一個基本的APNG文件是在PNG文件格式上增加acTL, fcTL等動畫控制塊形成的。 此處引用張現成的圖片說明 一下

acTL是動畫控制塊,包括 幀數和播放次數
fcTL是幀控制塊,包括幀的大小位置,序號,延時,清除方式,混合方式等資訊 第一個fcTL塊後面跟的是一個或多個 IDAT 塊 第N個fcTL塊後面跟的是一個或多個 fdAT 塊 fdAT的內容構成上,比IDAT多了一個序號,這個序號是整個文件 fcTL和fdAT 兩種塊一起共享的 一個fcTL以及後面跟的所有內容塊,組成了APNG的一個幀
acTL
acTL塊的格式如下
|--4:長度0x08--|--4:acTL--|--4:幀數--|--4:循環數--|--4:CRC32--|
結合原圖我們用十六進位查看器看一下內容,

00 00 00 08
表示本塊內容的長度(8位元組)對於 acTL塊來說是固定的61 63 54 4C
是 "acTL" 四字母的ASCII碼00 00 00 19
表示本圖片一共有0x19=== 25幀00 00 00 00
表示本圖片的播放次數為:無限循環播放
fcTL
fcTL塊的格式如下
(0) |--------------4:長度---------------|--------------4:fcTL---------------| (8) |--------------4:序列號-------------|--------------4:寬度----------------| (16)|--------------4:高度---------------|--------------4:X偏移--------------| (24)|--------------4:Y偏移-------------|----2:延時分子----|----2:延時分母----| (32)|-1:清除方式-|-1:混合方式-|-----------4:CRC32----------|
既然acTL告訴我們一共有25幀,那麼fcTL塊就會有25個,我們先看一下第一幀的fcTL

00 00 00 1a
表示本塊內容的長度(0x1a,即26位元組)對於 fcTL塊來說是固定的66 63 54 4C
是 "fcTL" 四字母的ASCII碼00 00 00 00
表示本幀的序號為000 00 00 94
表示本幀的寬度為 0x94 === 148 像素,高度也類似- 後面的 8位元組00表示當前幀的位置是無偏移的
00 32 03 E8
表示當前幀的播放延時為 0x32 / 0x03E8 即 50 / 1000 === 50ms01
表示本幀的清除方式為 【清除為背景】00
表示本幀的混合方式為 【覆蓋】
關於清除方式 ,混合方式,可以看一下這篇文章 https://developer.mozilla.org/zh-CN/docs/Mozilla/Tech/APNG 在本篇文章的例子中,我們比較關注的是 序號,和fcTL的整體意義。
後續的幀就不重複寫了,各幀的fcTL chunk ,欄位意義是一樣的。在本例子火狐圖片中,除了序號和crc,都是一樣的。
轉換思路
前面我們已經對APNG的格式有比較深入的了解,回到前面兩個問題
- 如何區別每一幀?
一個fcTL以及後面跟的所有內容塊,組成了APNG的一個幀
- 如何把播放順序倒轉?
除了把幀數據倒過來以外,我們還要注意 第一幀的數據塊為 IDAT ,不包含序號, 第N幀的數據塊為 fdAT ,包含4位元組的序號,其中序號是 fcTL和 fdAT 共享的 每一個塊要改,都要同時計算其CRC數據
程式碼與實施
工欲善其事,必先利其器
我們下面要進行程式碼操作了,這些都是二進位操作,不太可能一蹴而就的,所以我們需要一些調試的手段輔助處理。我們應該可以預料到,對APNG文件進行此操作,文件的大小、幀的個數、序列號個數是不會變的,所以在開發的過程中,我們可以把這一部分資訊輸出出來,方便自己調試,並且對照修改前後的兩個文件的資訊
// eachChunk是對 PNG 每個chunk進行遍歷的函數 eachChunk(bytes, (type, bytes, off, length) => { const dv = new DataView(bytes.buffer); textDOM.value += (type + 'n'); const obj = {}; switch (type) { case 'fdAT': obj.sequence_number = dv.getUint32(off + 8); obj.crc = dv.getUint32(off + 8 + length); break; case 'fcTL': obj.sequence_number = dv.getUint32(off + 8); obj.width = dv.getUint32(off + 8 + 4); obj.height = dv.getUint32(off + 8 + 8); obj.x_offset = dv.getUint32(off + 8 + 12); obj.y_offset = dv.getUint32(off + 8 + 16); obj.delay = (dv.getUint16(off + 8 + 20) / (dv.getUint16(off + 8 + 22) || 100))* 1000; obj.dispose_op = dv.getUint8(off + 8 + 24); obj.blend_op = dv.getUint8(off + 8 + 25); obj.crc = dv.getUint32(off + 8 + 26); break; default: break; } textDOM.value += (JSON.stringify(obj) + 'n');
效果如下:

我們可以看到這張圖片一共有109個序列號 (sequence number),如果逆轉操作前後序列號及其他資訊不對,可以快速定位到檢驗不通過的地方,快速進行修正。
第一次遍歷
由於我們只能按順序讀取文件內容,所以我們可能要遍歷兩次,第一次的時候主要是記錄每一幀的位置偏移,還有把一些非數據的幀(如IHDR)記錄下來 即形成以下的數據結構

第二次是針對該數據結構的遍歷, 先在「幀內容」裡面進行遍歷,拿出最後一幀, 然後在幀內進行遍歷
對非內容塊的讀寫,有時候會誤改了IHDR,acTL等模組,這一部分如果出錯,則會導致瀏覽器無法識別這是一張圖片,此時如果強行用img.src 進行設置,會展示為404圖片,即:

這時候我們要仔細檢查相應模組的內容是否正確。
第二次遍歷
如果chunk是 fcTL,則要重新開始序號,並且重新計算crc32,相關程式碼如下
dv.setUint32(off + 8, sn++); // sn是一個文件級別的計數器,dv是當前幀(1個fcTL+若干數據)組成的ArrayBuffer的dataView const fcTLCrc32 = CRC32.byte(chunk.subarray(off + 4, off + 8 + 26)); // 自己計算的crc32 dv.setUint32(off + 8 + 26, fcTLCrc32); // CRC32 dataArr.push(subBuffer(chunk, off, 8+length+4)); // subBuffer的功能是按指定下標拷貝一份新的ArrayBuffer
如果是 fdAT,
並且是第一幀,則要改為 IDAT
- 把chunk標識改了
- 把序號去掉
/** * 輸入標識名和內容,生成一個新的ArrayBuferr塊 * @param {string} type * @param {Uint8Array} dataBytes * @return {Uint8Array} */ var makeChunkBytes = function (type, dataBytes) { const crcLen = type.length + dataBytes.length; const bytes = new Uint8Array(crcLen + 8); const dv = new DataView(bytes.buffer); dv.setUint32(0, dataBytes.length); bytes.set(makeStringArray(type), 4); bytes.set(dataBytes, 8); var crc = CRC32.byte(bytes, 4, crcLen); dv.setUint32(crcLen + 4, crc); return bytes; }; const newData = makeChunkBytes('IDAT', chunk.subarray(off + 4 + 8, off + 8 + length)); // 4是sn,8是長度+chunk 標識 dataArr.push(newData);
如果不是第一幀,要改sn和crc32
dv.setUint32(off + 8, sn++); dataArr.push(subBuffer(chunk, off, 8+length+4));
如果chunk標識是 IDAT,則要改為fdAT,並增加sn
case 'IDAT': const newFdAT = new Uint8Array(length + 4); newFdAT.set([0,0,0,sn++]); newFdAT.set(subBuffer(chunk, off + 8, length), 4); dataArr.push(makeChunkBytes('fdAT', newFdAT)); break;
可以看到fcTL是APNG的播放控制內容,如果我們修改了一張APNG後,圖片的大小正常,但顯示為一片空白,或者只有一張靜態的圖片,那可以斷定是fcTL這一塊出現了問題,我們要仔細排查相應模組。
最後,把以上所有的數據裝進一個PNG的容器裡面,即前面是PNG 簽名,IHDR, acTL,後面是 IEND 塊,就能輸出一份PNG圖片了
const dataArr = [PNGSignature]; // ..... case 'IHDR': case 'acTL': dataArr.push(subBuffer(bytes, off, 12 + length)); // ...... dataArr.push(IEND_CHUNK); const blob = new Blob(dataArr,{ 'type': 'image/png' }); const url = URL.createObjectURL(blob); imgDOM.src = url;
整體程式碼思路如下:

最終效果如下:
