Gif的进化版 —— Apng的解析以及编辑

想必大家都用都过图,知道表情包一般都是gif图,本文将带你了解它的船新升级版本 —— Apng。希望通过此文,大家能对Apng以及如何通过js操作二进制数据有所了解。

起因

某天 UI 提了一个需求想把项目中的动图的延迟做调整,项目中使用的 apng 格式图片,由于调整后间隔是固定的,我想的是直接修改 Apng 数据就好了,不想额外引入 apng-js,而且它不支持导出为图片,还需要把现有的图片改成组件。虽然现在也能找到一些支持修改 Apng 的工具,但用起来不是很顺手(不太符合这个需求),要么就是没有相应的程序包,所以打算自己整一个。

简介

说起png,大家都有所耳闻,png的全称是Portable Network Graphics(便携式网络图形),是一种支持无损数据压缩的光栅图形文件格式,被开发为一种改进的gif(有说法称pngpng not gif的简写)。

我们对png的第一印象一般都是支持透明度,对gif的印象一般都是动图(各种表情包~),那为什么说pnggif的改进型呢?

这里有一段故事:gif的全称是 Graphics Interchange Format( 图形交换格式 ),最初是为了填补跨平台图像格式的空白,可以理解为静态图像和视频之间的空白。gif在当时来说已经满足了需求,问题在于gif压缩使用了LZW算法(串表压缩算法),而这个算法的商用是需要授权的,在这个阴影笼罩下,业界很快就推出了不含LZW算法的图片,png 由此诞生。

在我们的认识中,gif的主要特点是支持动态图像的,基于这一点,png又怎么能够替代gif呢?这个要基于当时的实际情况考虑,当时动态的gif的应用是比较少的,并且基于其他各种原因,png被确定应是一种单图像格式规范,多图像的支持由后续的扩展实现,称为MNG全称是Multiple-image Network GraphicsMNG于 2001 年推出,但可惜的是没有被各大浏览器支持,最后销声匿迹。

2004 年,Mozilla内部诞生了一种叫做Apng的动图格式,它本身由一帧帧png组成,结合优异的压缩算法,能在与gif图相当大小的情况下实现更好的现实效果,在不支持Apng的浏览器里也能降级为静态的png图片,下面是gifApng的效果对比。

500

Apng在近几年已经得到各大浏览器的支持,在需要显示动态图且对图片质量有要求的场景,我们可以尝试使用Apng,由于png本身的特性,显示效果会比gif更好。

can-use

Apng 格式

Apng作为png的一种扩展,主要的整个数据结果与png一致,只是多定义了几种数据块类型,Apng的结构如下图所示,它实际上是由多张png图片组成的,通过acTL模块控制动画效果,fcTL模块控制帧间的过度效果。图像的数据块结构保持与png一致,除了第一帧之外的数据帧命名为fdAT,其他的数据块与png一致。

img

每个数据块的组成如下图,除了文件头,其他块都可以按照这个规则去解析

image-20220408172631310

接下我们简单了解一下几个主要数据块。

PNG signature

这一段文件头是固定的,称为魔数magic number,是png专用的文件头。

IHDR

文件头数据块IHDR(header chunk):它包含有png文件中存储的图像数据的基本信息,并要作为第一个数据块出现在png数据流中,而且一个png数据流中只能有一个文件头数据块。它储存了以下一些数据

  • width 长度

  • height 高度

  • bit depth 图像深度

  • color type 颜色类型

  • compression method 压缩方法

  • filter method 滤波器方法

  • interlace method 非隔行扫描方法

其中需要关注的是widthheightpng的宽高在这里定义。

color type代表图像的颜色类型,能区分是灰阶,索引色还是真彩色等等,当值为 3 时,代表是索引色,此时需要提供一个PLTE数据块。扩展一下,png又可以细分为png-8png-24png-32,其中png-8使用的就是索引色,具体可以参考下图:

img

acTL

动画控制数据块animation control chunk,用于控制动画的效果,提供了两个数据

  • num_frames 总共有多少帧
  • num_play 动画播放多少次,0 为循环播放

fcTL

帧控制数据块frame control chunk,用于控制如何帧间混合效果,是直接覆盖还是部分覆盖,偏移量和延时等信息,提供的数据有:

  • sequence_number 帧序号

  • width 宽度

  • height 高度

  • x_offset 此帧数据 x 轴偏移量

  • y_offset 此帧数据 y 轴偏移量

  • delay_num 间隔分子

  • delay_den 间隔分母

  • dispose_op 在显示该帧之前,需要对前面缓冲输出区域做何种处理。

  • blend_op 帧渲染类型

这里可以发现Apng优化体积的一种手段,每一帧的数据提供的画面不一定是完整,需要结合上一帧来渲染,如果整个画面只有部分变化,实际上当前帧只提供了变化部分的图像数据,通过这样的方式可以有效地节省文件体积。

dispose_op规定了在显示下一帧之前要做的处理,取值对应的处理方式:

  • 0: 不做任何处理,保持原样
  • 1: 把当前缓冲区的数据都处理为透明
  • 2: 渲染下一帧之前把缓冲区恢复成之前的状况

blend_op规定了当前帧的内容的处理方式,取值与对应的处理方式:

  • 0: 将当前帧的内容替换到当前缓冲区上
  • 1: 将当前帧的内容混合到当前缓冲区中

fdAT

帧数据块frame data chunk,基本结构与IDAT一致,只是数据部分前面多出4个字节表明帧序号。

JS 中的二进制处理

经过上面的介绍,我们了解了Apngpng之上新加的几个数据块的结构,在此基础之上,我们可以通过解析Apng的内容并修改对应位置的数据来调整Apng的表现,并预览修改后的效果。

我们用javascript来解析apng文件,首先就需要了解一下js中处理二进制数据的几个方法:

ArrayBuffer

代表一个二进制数据,表示通用的、固定长度的原始二进制数据缓冲区,不可编辑。可以理解为这个对象存了一系列的01数据。

DataView

数据视图,是一个可以从 二进制ArrayBuffer 对象中读写多种数值类型的底层接口,使用它时,不用考虑不同平台的字节序问题。通俗的理解就是ArrayBuffer的编辑要委托给DataView来处理。

Uint8Array

表示一个 8 位无符号整型数组,创建时内容被初始化为0。创建完后,可以以对象的方式或使用数组下标索引的方式引用数组中的元素。

实现编辑

我们这次处理大概就用上以上几个对象来处理,首先我们根据magic number判断一下这个文件是不是png,然后就可以开始遍历这个数据对象,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const travelsChunk = (
buffer: ArrayBuffer,
cb: (chunkBuffer: Uint8Array, chunkOffset: number, fileBuffer: ArrayBuffer) => void
) => {
let byteOffset = 8; // png文件头
const dataView = new DataView(buffer);
do {
const chunkLength = dataView.getUint32(byteOffset);
const chunkData = new Uint8Array(buffer.slice(byteOffset, byteOffset + 8 + chunkLength + 4));

cb(chunkData, byteOffset, buffer);
byteOffset += 4 + 4 + chunkLength + 4;
} while (byteOffset < buffer.byteLength);
};

这里做的处理主要就是根据块的结构,把整个二进制对象分割成不同的数据块做处理,遍历直到结束。

通过解析每个数据块的第 4 到 8 的字节,我们可以获得数据块的名称,然后根据每个数据块不同的名称,我们把需要支持编辑的数据块做处理,其它的做保留,例如一个数据块的基类被定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Chunk<T = object> {
// 二进制数据
public data: Uint8Array;

// 数据块的名称
public get name() {
return readBinary(this.data.subarray(4, 8));
}

constructor(data: Uint8Array) {
this.data = data;
}

// 修改配置并创建新的实例
public newByConfig(newConfig: Partial<T>) {
const newInstance = this.createNewInstance();
Object.keys(newConfig).forEach((name) => (newInstance[name] = newConfig[name]));
newInstance.syncData();
newInstance.data = createBinary(newInstance.data.subarray(4, this.data.byteLength - 4));
return newInstance as unknown as this;
}

// 不同的更新data的操作
public syncData() {}

public createNewInstance() {
return new Chunk(this.data);
}
}

后续我们修改内容就通过统一的暴露的方法就可以了,剩下的就是用户界面的工作以及预览。

实现预览

解析了Apng文件之后,我们就可以根据fcTL的配置去渲染每一帧的图片,根据acTL的配置决定要播放多少次。其中主要的处理就是fcTL中的dispose_op以及blend_op中定义的处理,我们使用canvas去渲染的话,这一部分还是比较好处理的,我们只需要根据相应的数据保留或者清除对应区域的图像就行,以下为处理的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 上一帧定义了要把数据区清空
if (prevFrame && prevFrame.fcTLChunk.disposeOp == 1) {
this.ctx.clearRect(
prevFrame.fcTLChunk.xOffset,
prevFrame.fcTLChunk.yOffset,
prevFrame.fcTLChunk.width,
prevFrame.fcTLChunk.height
);
// 上一帧定义了要把数据区恢复成之前的状态
} else if (prevFrame && this.prevFrameData && prevFrame.fcTLChunk.disposeOp == 2) {
this.ctx.putImageData(this.prevFrameData, prevFrame.fcTLChunk.xOffset, prevFrame.fcTLChunk.yOffset);
}

// 保存渲染当前帧之前的状态
if (frame.fcTLChunk.disposeOp == 2) {
this.prevFrameData = this.ctx.getImageData(fctl.xOffset, fctl.yOffset, fctl.width, fctl.height);
}

// 渲染当前帧之前把数据区清空
if (frame.fcTLChunk.blendOp == 0) {
this.ctx.clearRect(fctl.xOffset, fctl.yOffset, fctl.width, fctl.height);
}

this.ctx.drawImage(this.$images[this.index], fctl.xOffset, fctl.yOffset);

然后我们只需要根据acTL中定义的播放次数进行播放,就了预览。

至此我们就实现了编辑与预览的功能,例如下面这个例子,我们可以把一张只播放一次的apng图片修改成循环播放:

change-time

也可以把每一帧的间隔调小一点然后,让小象跑得更快(注意的一点是大部分浏览器都把最小间隔设定在11ms,如果小于这个值,间隔会自动提升):

12.5ms

以上的功能大家都可以在这里在线 demo中体验到。

打包应用

实现了这些功能之后,作为一个编辑器工具,它是完全可以离线使用,我们可以把它打包成一个App。作为一个小工具,不希望有太大的体积,除了electron之外,我们还有很多其他的选择:tauriNeutralinoJSChromelyelectrinogo-astilectronwails等等,它们各有千秋。从star数出发,我选择使用tauri来打包应用。

tauri前端直接使用系统的webview,与系统交互部分使用rust编写,虽然兼容性肯定是不如electron,但用来做一个小工具绰绰有余。使用起来也比较方便,只需要安装一下rust以及配置下环境就可以了,使用系统层面的API也比较方便,直接看下API 文档,然后直接调用就可以了,最后就可以根据不同的环境打包成不同的安装包。

tauri直接使用系统webview的方式也导致了他的兼容性堪忧,在mac中就不可避免的要去面对safari这位重量级大哥。比如我这个小工具中,tauri表现出来的对文件的支持就比较糟糕,在macinput[type=file]无法唤起文件选择框(issues),在linuxinput是没有问题了,但文件的drop事件没有触发(issues)。在mac还会有在使用 canvas 时不时会崩溃等问题,感觉这条路实现的跨端的应用还是比较坎坷的……😂

总结

png作为gif的替代品,虽然现在的热度并不高,但它凭借优秀的表现,相信它会在日后成为主流的选择。通过这篇文章我们也可看到现在web生态的蓬勃发展,现在你可以相对方便地去操作文件,调用系统API,开发一款自己的应用,能做的事情越来越多,希望后续也能继续保持这个势头发展下 🤯!

参考资料