前言
作为破除人类交流困境的神器,没有什么场景是一张表情包不能表达的。想像一下,当你正同时打开 N 个 VSCode 疯狂打码的时候,DING~ 的一声脆响,产品经理发来一条消息:昨天提的那几个 bug 修复好了吗?
基于「能发图就不打字」的原则,是时候祭出收藏夹中了大杀器了↓
简单的动图创造了进退自如的交流空间,这些动图就是我们通常使用的 GIF 图。
业务背景
然而,在风控场景下,黑灰产利用 GIF 图片多帧的特性,把非法图片注入其中,再通过手动修改文件扩展名的方式,伪装成普通图片,无疑给风险防控增加了难度。一闪而过的非法主图,让运营小二防不胜防;图片最终定格在看似没什么问题的尾帧上,让运营小二难以捕捉到有效的关键信息。
GIF 图片虽然有“动”的性质,但在 Web 中被一视同仁地做为图片处理,没有提供任何特殊待遇 API,所以无法控制 GIF 图片的播放、暂停、结束监听等事件。那么有没有办法能让一闪而过且定格在尾帧的 GIF 图“动”起来呢?接下来我们深入探究,剖析一下 GIF 图里都有神马。
GIF 格式介绍
GIF 是一种位图。位图的大致原理是:图片由许多的像素组成,每一个像素都被指定了一种颜色,这些像素综合起来就构成了图片。8 位的「位」即颜色深度,颜色深度由一个图像的位深决定,简单来说就是最多支持多少种颜色(举个栗子,位深为 1 的像素有两个值:黑和白。位深越大,图像可包含的颜色越多,颜色表现越准确,8 位 GIF 图最多包含 256 种颜色)
GIF87a 版本是 1987 年推出的,一个文件存储一个图像,严格不支持透明像素;GIF87a 采用 LZW 压缩算法,它能够在保持图像质量的前提下将图像尺寸压缩百分之二十到二十五。
GIF89a 版本是 1989 年推出的很有特色的版本,该版本允许一个文件存储多个图像,可实现动画功能,允许某些像素透明。这个版本中,为 GIF 文档扩充了图形控制区块、备注、说明、应用程序编程接口 4 个区块,并提供了对透明色和多帧动画的支持,如果将这些图像连续播放出来,就能够组成最简单的动画。所以常被用来存储“动态图片”,通常时间短,体积小,内容简单,成像相对清晰。在现在我们所说的 GIF 一版都是 89a 的格式。
GIF 文件结构拆解
想要知道图片是如何“动”起来的,首先了解它是如何存储的。我们引用网络上的一张图,来看看 GIF 格式的图像文件结构:
图片来源:What's In A GIF
GIF 格式的文件按块存储,整体上分为三部分:
- 文件头(Header)
- GIF 数据流(GIF Data Stream)
- 文件结尾(Trailer)
其中,数据流中的文本扩展块、应用扩展块和注释扩展块我们跳过不看,让图片“动”起来的秘诀就存在于 图形控制扩展(Graphic Control Extension) 中。下面让我们用一个栗子来一探究竟吧。
样例准备
样例图片
打开可见图片会有瞬间闪烁效果。
十六进制转换器
文件头
识别一张图是不是 GIF 并不只看图片扩展格式或者图片是否会动,GIF 文件的前 6 个字节内容是 GIF 的署名和版本号,通过控制台打印我们可以得到:
对照 ASCII 编码我们可以得到 47 49 46 38 39 61 对应 GIF 89a
简单!继续往下看↓
GIF 数据流
图形控制扩展
我们通过观察不难发现,图片会有瞬间闪烁的效果,对比文章开头表情包图,为什么有些 GIF 图可以一直循环播放,有些却是瞬间闪烁然后定格在第二帧呢?
在 89a 版本,GIF 添加了图形控制扩展块,放在图像标识符(Image Descriptor)的前面,用来控制紧跟在它后面的第一个图象的显示,图形控制扩展块的结构如下图所示:
由上图可见,整个扩展块结构如下:
扩展块标识符 | 1 字节、固定值 0x21 |
扩展块标识 | 1 字节、固定值 0xF9 |
扩展块子块长度 | 1 字节 |
保留位 | 3 位 |
处置方法 | 3 位 |
用户输入标志 | 1 位 |
透明颜色标志 | 1 位 |
延迟时间 | 2 字节 |
透明颜色索引 | 1 字节 |
扩展块尾 | 1 字节、固定值 0x00 |
找到它了!罪魁祸首就是延迟时间!延迟时间标记了需要暂停这个延迟时间后再继续往下处理数据流,这里可以理解为动图中每一帧的停留时间,其单位为 1/100 秒。
分析到这里,有种茅塞顿开的感觉,回到代码中,我们通过控制台可以看到原图解析出来的数据是这样的:
延迟时间:00 00,十六进制转换十进制为:0
我们通过手动设置延迟时间,就可以让一闪而过的图片“动”起来:
手动修改后的延迟时间:32 00,十六进制转换十进制为:800
核心代码如下:
let p = 0; // 当前 Buffer 处理对应的下标
while (notEndOfFile && p < contentBuffer.length) {
...
switch (contentBuffer[p++]) {
case 0xf9: // Graphics Control Extension
if (contentBuffer[p++] !== 0x4 || contentBuffer[p+4] !== 0)
throw new Error("Invalid graphics extension block.");
p++; // graphicPackedFiled
if (delay) {
const delayArr = numberToByteArr(delay);
contentBuffer[p] = delayArr[delayArr.length - 1];
contentBuffer[p+1] = delayArr[delayArr.length - 2] || 0;
}
p = p + 4; // 略过 delay 2 字节, transparentIndex 1 字节,结束符号 1字节
break;
}
}
文件结尾
当所有子图像数据解析完毕,就会遇到文件尾,这一部分只有一个值为 0 的字节,标识一个 GIF 文件结束。文件尾固定为 0x3B
写在最后
在上一篇解决图片跨域的文章中笔者有介绍,借助团队 Serverless 能力搭建图片跨域转发服务器,本次的 GIF 文件解析方案是在原有的 BFF 层基础能力之上搭建的。
Octopus 图片转发服务详细信息
请求地址:https://xxx.fc.alibaba-inc.com/gifTransformer
请求方法:GET
参数:
url: 必传,需要解析的图片地址
loop: 非必传,GIF 图循环次数
delay: 非必传,GIF 图每一帧播放时间(ms)
返回结果:解析后的 GIF 图
GIF 图解析最终落地风险排查业务,解决了业务一直头痛的黑灰产非法主图判定难的问题,有兴趣的同学不妨上手尝试一下。