聊聊APNG

吐槽君 分类:javascript

聊聊APNG

背景

apng逐渐成为大部分业务实现复杂动效、动画的方案。这种方案有下面几个优点:

  1. 相比于gif,画质更好,尤其对于带透明度的图片。具体比较请自行google
  2. 本身其实是一个png文件,在不支持apng的设备上时,能降级显示一个png静图(后面会讲到)
  3. 可以直接作为img标签插入到网页中去,无需逻辑控制动画,开发成本低
  4. 直接由设计师产出,设计还原度100%

我们的智能辅播业务也有这样的使用场景。如下图

聊聊APNG

图片可能会被降级点击查看:gw.alicdn.com/imgextra/i1…

上面这张图在设计师通过软件制作出来时,是一个无限循环的apng文件。所以不加处理直接展示在设备上时将会循环播放。而下面这幅图在设计出来就是一个播放1次的动画(如果没看到动作可以直接复制图片链接在浏览器打开。

聊聊APNG

图片可能会被降级,点击查看:gw.alicdn.com/imgextra/i1…

一个良好的网页应该遵循基本的规范,比如W3C无障碍规范中明确的:

不要设计会导致癫痫发作或身体反应的内容。

网页不包含任何闪光超过3次/秒的内容,或闪光低于一般闪光和红色闪光阈值。.

除非动画对于功能或传达的信息至关重要,否则可以禁用由交互触发的交互式动画。

所以页面上的动画不应该一直重复播放(一方面会夺了用户的焦点,另一方面令人烦躁)。在智能辅播的业务中,我们规定了动画只在获取到小助理新的对话内容的时候才播放一次。

在weex环境下,我们的设计师直接产出一个不循环播放的apng文件,前端只需要加载即可。在h5环境下,其实我们能直接控制apng的播放。

apng-canvas

apng-canvas 是一个用于在浏览器环境下控制apng文件播放行为的库。它接受一个apng的buffer数据,并从中提取出每一帧的数据,再逐帧拼装成png格式数据以绘制在canvas上。同时也暴露了一些方法来控制动画的播放次数、暂停等行为。具体使用不在本文阐述,有兴趣可戳链接试用。

(A)PNG 规范

我详细学习了下apng-canvas的解码思路,又看了下PNG和APNG的规范文档,大概有了个概念。(A)PNG文件数据流其实是一个个数据块(chunks)和文件签名构成。这类文件的签名用8位字节数组表示是(占了8个字节)

export const PNG_SIGNATURE_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
// 对应十进制是:
export const _PNG_SIGNATURE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
 

apng的规范

  • apng规范文档戳此处
  • PNG规范戳此处

相比于PNG,APNG多了下面这些类型块

块类型 必须 含义 位置与要求
acTL 动画控制块 紧随IHDR块之后
fcTL 帧控制块 1. 第一个fcTL紧随acTL后
2. 之后所有的fcTL都位于每一帧的开头
fdAT 帧数据块 紧随fcTL之后,且至少有一个

构成一个apng的核心块如下图(引用源:segmentfault.com/a/119000002…

聊聊APNG

聊聊APNG

这些块在apng文件流中的顺序如下:

聊聊APNG

当时尝试合成apng时,踩坑了很长时间的几个点:

  1. 必须要有IDAT块,这个块通常取自第一帧png的IDAT块,这个块的作用就是在一些不支持apng的环境中作为降级的png使用

  2. fcTL和fdAT块共享顺序号(sequence),这个号从0开始,即第一个IDAT前的fcTL的sequence为0

  3. IDAT可能存在多个,需要依次序放入数据流中

  4. 必须要注意图片的尺寸是否设置正确,图片尺寸设置不正确时解析出来的序列帧有问题,同时apng会自动降级为第一个IDAT表示的静态图,如下:(第一个是apng在浏览器中的实际效果,后面三个是解析该apng得到的png的渲染效果)

    聊聊APNG

由png合成apng

Apng-canvas 提供了解析、并在canvas中播放apng的能力,我们可以循着作者的思路反向生成一个apng。核心代码如下,完整代码请戳:apng-handler

interface Params {
  /* png buffers */
  buffers: ArrayBuffer[];
  /* 播放次数:0表示无限循环 */
  playNum?: number;
  /* 我们在此先假设所有帧的尺寸都相同 */
  width: number;
  height: number;
}

/**
 * assemble png buffers to apng buffer
 * 根据png序列生产apng数据
 */
export function apngAssembler(params: Params) {
  const { buffers = [], playNum = 0, width, height } = params;
  const bb: BlobPart[] = [];

  /* 1.头8个字节放入PNG签名 */
  bb.push(PNG_SIGNATURE_BYTES);

  // 使用第一帧的 IHDR, IEND, IDAT数据块. 注意 IDAT块可能有多个
  let IDATParts: Uint8Array[] = [];
  let IHDR: Uint8Array;
  let IEND: Uint8Array;
  parseChunks(new Uint8Array(buffers[0]), ({ type, bytes, off, length }) => {
    if (type === "IHDR") {
      /* 8: 4字节的长度信息 + 4字节的type字符串信息 */
      IHDR = bytes.subarray(off + 8, off + 8 + length);
    }
    if (type === "IDAT") {
      IDATParts.push(bytes.subarray(off + 8, off + 8 + length));
    }
    if (type === "IEND") {
      IEND = bytes.subarray(off + 8, off + 8 + length);
    }
    return true;
  });

  /* 2. PNG签名后放入头部信息IHDR块 */
  bb.push(makeChunkBytes("IHDR", IHDR));

  /* 3. 头部信息之后放入acTL块 */
  bb.push(createAcTL(buffers.length, playNum));

  /* 4. 放入第一个fcTL控制块 第一个seq是0 */
  bb.push(createFcTL({ seq: 0, width, height }));

  /* 5. 放入 IDAT 块 */
  for (let IDAT of IDATParts) {
    bb.push(makeChunkBytes("IDAT", IDAT));
  }

  /* 6. 从第二帧开始循环存入帧数据fcTL和fdAT */
  // 注意现在seq已经是1了
  let seq = 1;
  for (let i = 1; i < buffers.length; i++) {
    /* 6.1 放入fcTL */
    bb.push(createFcTL({ seq, width, height }));
    // 注意fcTL和fdAT共享seq
    seq += 1;

    // 拿到当前帧buffer的IDAT块列表
    let iDatParts: Uint8Array[] = [];
    parseChunks(new Uint8Array(buffers[i]), ({ type, bytes, off, length }) => {
      if (type === "IDAT") {
        iDatParts.push(bytes.subarray(off + 8, off + 8 + length));
      }
      return true;
    });

    /* 6.2 使用这个IDAT块,生成fdAT */
    for (let j = 0; j < iDatParts.length; j++) {
      bb.push(createFdAT(seq, iDatParts[j]));
      seq++;
    }
  }

  /* 7. 放入最后一部分IEND块 */
  bb.push(makeChunkBytes("IEND", IEND));

  // 返回一个Blob对象
  return new Blob(bb, { type: "image/apng" });
}
 

这里最关键的就是fcTLacTL,它们在控制着整个apng的播放行为,比如fcTL用到的控制帧渲染的两个参数:

/**
 * @see https://wiki.mozilla.org/APNG_Specification#.60fcTL.60:_The_Frame_Control_Chunk
 * 渲染下一帧前如何处理当前帧
 */
export enum DisposeOP {
  /* 在渲染下一帧之前不会对此帧进行任何处理;输出缓冲区的内容保持不变。 */
  NONE,
  /* 在渲染下一帧之前,将输出缓冲区的帧区域清除为完全透明的黑色。 */
  TRANSPARENT,
  /* 在渲染下一帧之前,将输出缓冲区的帧区域恢复为先前的内容。 */
  PREVIOUS,
}

/**
 * @see https://wiki.mozilla.org/APNG_Specification#.60fcTL.60:_The_Frame_Control_Chunk
 * 当前帧渲染时的混合模式
 */
export enum BlendOP {
  /* 该帧的所有颜色分量(包括alpha)都将覆盖该帧的输出缓冲区的当前内容 */
  SOURCE,
  /* 直接覆盖 */
  OVER,
}
 

结尾

Apng-canvas是一个很棒的库,但是平时都在写业务逻辑代码,很少涉及到字节数组、位运算相关的内容,再加上这个库作者几乎没有什么注释,所以理解这个库里的一些方法还是要花些时间的。

举个例子:8位字节数组转十进制的位运算版本如下

export const bytes2Decimal = function (bytes: Uint8Array, off: number, bLen = 4) {
  let x = 0;
  // Force the most-significant byte to unsigned.
  x += (bytes[0 + off] << 24) >>> 0;
  for (let i = 1; i < bLen; i++) x += bytes[i + off] << ((3 - i) * 8);
  return x;
};
 

写成我们常用的更易理解的方法:

export const _bytes2Decimal = (bytes: Uint8Array, off: number, bLen = 4) => {
  let x = "";
  for (let i = off; i < off + bLen; i++) {
    // 每一位都转换为2进制并补至8位
    x += ("00000000" + bytes[i].toString(2)).slice(-8);
  }
  // 再把字符串转为10进制数字返回
  return parseInt(x, 2);
};
 

我把这个库外加png合成apng的核心方法放在了一个新的仓库里。使用ts重写了一下,改了一些方法名称、也改变了部分代码结构,更方便阅读理解。仓库地址:apng-handler。希望能收获一些浏览器环境下压缩apng的pr。

附一张使用代码合成apng的效果图(delay0.1s,dispose采用TRANSPARENT(1)模式:下一帧渲染前清除画布):

聊聊APNG

附录

  1. APNG 规范

    最重要的资料,详细解释了每个apng相比于png增加的一些规范。

  2. W3C PNG 规范

    W3C的文档,想要深入了解必须阅读学习的。但是过于专业,我也没有都看完,主要还是看一些概念性的东西。我想如果以后需要去了解压缩的实现的话一定还要再看看的。

  3. APNG 维基百科

    主要就是那张解释图,很多文章都会引用的,我加在README里了

  4. Web 端 APNG 播放实现原理

    国内网易云前端团队对于apng-canvas的解释,里面的一张图非常不错

  5. ezgif.com

    生成apng的在线工具

  6. APNG Assembler

    生成、解析apng的一款软件

  7. Join up PNG images to an APNG animated image

    回答了一个Node环境下的encode方法

  8. UPNG.js

    我试用了一次但是失败了,可能是用法有问题,另外这个代码也不是很好懂,没有细看了。

回复

我来回复
  • 暂无回复内容