p2227

2019-12-12 17:11

js玩转APNG -- 逆转火狐

本文作者:IMWeb p2227 原文出处:IMWeb社区 未经同意,禁止转载

APNG是一种常见的网页动画,兼容性较好,交互性差,要想对其进行深入了解,则要了解其文件格式。本文以一个具体的问题为例,带你深入了解APNG的格式。

带着问题学习 -- 逆转火狐

先上问题:有一张火狐logo的图片,原图是顺时针旋转的,我们怎么来把它改为逆时针旋转呢?

原图

动画的基本原理

帧动画的基本原理是这样的,事先准备若干张静态图片(关键帧),每张图片之间有细微的差异,在快速顺序切换各个关键帧时,利用人眼视觉暂留的原理,给用户一个动画的错觉。 具体到火狐原图,其实他包含了25张关键帧,每一帧之间火狐旋转的角度有一点差别,然后每50ms播放一帧,这样就形成了动画 原图前8帧

鉴于以上原理,我们的整体思路其实还是比较简单的,把以上所有帧的播放顺序倒过来,就能把火狐逆转了。但在APNG里面实现,同时有新的问题

  1. 如何区别每一帧?
  2. 如何把播放顺序倒转? 所以我们下一步是要学习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等动画控制块形成的。 此处引用张现成的图片说明 一下 三个独立的 PNG 文件组成 APNG 的示意图

acTL是动画控制块,包括 帧数和播放次数

fcTL是帧控制块,包括帧的大小位置,序号,延时,清除方式,混合方式等信息 第一个fcTL块后面跟的是一个或多个 IDAT 块 第N个fcTL块后面跟的是一个或多个 fdAT 块 fdAT的内容构成上,比IDAT多了一个序号,这个序号是整个文件 fcTL和fdAT 两种块一起共享的 一个fcTL以及后面跟的所有内容块,组成了APNG的一个帧

acTL

acTL块的格式如下

|--4:长度0x08--|--4:acTL--|--4:帧数--|--4:循环数--|--4:CRC32--|

结合原图我们用十六进制查看器看一下内容, 原图的acTL

  • 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 第一帧的fcTL

  • 00 00 00 1a 表示本块内容的长度(0x1a,即26字节)对于 fcTL块来说是固定的
  • 66 63 54 4C 是 "fcTL" 四字母的ASCII码
  • 00 00 00 00 表示本帧的序号为0
  • 00 00 00 94 表示本帧的宽度为 0x94 === 148 像素,高度也类似
  • 后面的 8字节00表示当前帧的位置是无偏移的
  • 00 32 03 E8 表示当前帧的播放延时为 0x32 / 0x03E8 即 50 / 1000 === 50ms
  • 01 表示本帧的清除方式为 【清除为背景】
  • 00 表示本帧的混合方式为 【覆盖】

关于清除方式 ,混合方式,可以看一下这篇文章 https://developer.mozilla.org/zh-CN/docs/Mozilla/Tech/APNG 在本篇文章的例子中,我们比较关注的是 序号,和fcTL的整体意义。

后续的帧就不重复写了,各帧的fcTL chunk ,字段意义是一样的。在本例子火狐图片中,除了序号和crc,都是一样的。

转换思路

前面我们已经对APNG的格式有比较深入的了解,回到前面两个问题

  1. 如何区别每一帧?

一个fcTL以及后面跟的所有内容块,组成了APNG的一个帧

  1. 如何把播放顺序倒转?

除了把帧数据倒过来以外,我们还要注意 第一帧的数据块为 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图片,即:

img不显示

这时候我们要仔细检查相应模块的内容是否正确。

第二次遍历

如果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

  1. 把chunk标识改了
  2. 把序号去掉
/**
 * 输入标识名和内容,生成一个新的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;

整体代码思路如下:

整体代码思路

最终效果如下:

最终效果

相关资料

0条评论

    您需要 注册 一个IMWeb账号或者 才能进行评论。