GIF编解码——编解码与合并

前言

项目中有一个GIF图二次编辑的需求,这一功能我主要借助Glide库来实现,这里通过系列文章分享下实现过程和GIF相关的学习经验。

GIF格式

编解码与合并

编解码器实现原理

效果

首先看下效果

原图1:

原图2:

调整图2的大小和位置后与图1叠加,最终效果:

这里合并主要借助Glide实现。

GIF的二次,核心思路就是将输入的GIF图进行解码,对解码后的图像数据进行修改后,再进行合并,将合并后的结果再通过编码生成新的GIF图。项目中图片加载使用了 Glide,Android本身是不支持渲染GIF图的,Glide可以帮助我们加载GIF图到ImageView——其原理就是先解码GIF图,然后逐帧渲染到ImageView上——同时也提供了开箱即用的编解码器,所以这里编解码都借助Glide来实现。

解码

这里我自己用一个 GifImage 类存储解析后的所有数据,一个 GifFrame 类存储单个帧中的数据:

1
2
3
4

data class GifFrame(val image: Bitmap, val delay: Int, val time: Long)

data class GifImage(val frames: List<GifFrame>, val time: Long)

这里我在 GifFrame 中多加了一个字段来表示这一帧在整个播放序列中处于哪一时刻,这个在后面合并时会用到。

然后是用Glide解码的简单写法:

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
30
31
32
33
34
35
36
37
38
39

/**
* @param filePath 输入GIF图源文件路径
* @param inSampleSize 优化bitmap解析时用到的采样率
*/
fun decode(context: Context, filePath: String, inSampleSize: Int = 2): GifImage? {
try {
val inputStream = FileInputStream(filePath)
val byteBuffer = ByteBuffer.wrap(inputStream.readBytes())
val glide = Glide.get(context)
//Glide提供了默认的Provider,Parser
val provider = GifBitmapProvider(glide.bitmapPool, glide.arrayPool)
val header = GifHeaderParser().setData(byteBuffer).parseHeader()//读取文件Header部分数据
val decoder = StandardGifDecoder(provider, header, byteBuffer, inSampleSize)//创建解码器
val frameCount = decoder.frameCount
decoder.advance()//获取下一帧
var time = 0L
var total = 0L
val frames = ArrayList<GifFrame>()
//已知帧的数量,循环读取每一帧的图片数据和间隔时间
for (i in 0 until frameCount) {
val bitmap = decoder.nextFrame//解码器id getNextFrame()方法 获取下一帧的图像数据
val delay = decoder.nextDelay

if (bitmap != null) {
frames.add(GifFrame(bitmap, delay, time))
total = time
//记录该帧在播放序列中的时刻
time += delay
}
decoder.advance()
}
inputStream.close()
return GifImage(frames, total)
} catch (e: Exception) {
e.printStackTrace()
return null
}
}

读取输入文件,创建一个解码器,先获取Header数据,然后遍历获取每一帧的数据,同时记录每一帧在播放序列中的时刻,这样一个简单的解码就完成了。

编码

编码就是解码的过程倒过来,Glide同样提供了解码器,在Glide仓库可以找到:gifencoder。把这三个类都拷贝到项目目录下即可使用:

然后一个编码的示例:

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

/**
* @param gifImage 构建的Gif图对象
* @param file 存储到文件
*/
fun encode(gifImage: GifImage, file: File) {
try {
val bos = ByteArrayOutputStream()
//创建一个编码器的实例,调start()方法开始写入
val encoder = AnimatedGifEncoder()
encoder.start(bos)
encoder.setRepeat(0)
for (frame in gifImage.frames) {
//逐帧写入
encoder.setDelay(frame.delay)
encoder.addFrame(frame.image)
}
encoder.finish()
//文件保存
val outStream = FileOutputStream(file)
outStream.write(bos.toByteArray())
outStream.close()
} catch (e: Exception) {
e.printStackTrace()
}
}

创建一个编码器,逐帧写入,编码器提供了很多public方法来设置一些属性,比如 setRepeat() 方法,用来设置GIF图是否可以循环播放/循环次数,如果不设置,默认生成的GIF图是不会自动循环播放的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

/**
* Sets the number of times the set of GIF frames should be played. Default is
* 1; 0 means play indefinitely. Must be invoked before the first image is
* added.
*
* @param iter
* int number of iterations.
*/
public void setRepeat(int iter) {
if (iter >= 0) {
repeat = iter;
}
}

以及 setDelay() 设置当前帧显示之后到下一帧显示之前的等待时间:

1
2
3
4
5
6
7
8
9
10
11

/**
* Sets the delay time between each frame, or changes it for subsequent frames
* (applies to last frame added).
*
* @param ms
* int delay time in milliseconds
*/
public void setDelay(int ms) {
delay = Math.round(ms / 10.0f);
}

合并

除了编码和解码之外,中间部分就是合并了。考虑最复杂的情况:输入的图片不止一张,其次输入的图片有的是GIF,有的是普通的静态图,且每张GIF图他的帧序列的时间间隔都是不一定不相等的,哪怕是同一张GIF其每两帧之间的间隔时间也不一定相同。因此合并的思路大体如下图:

输入的GIF图A B C,取所有帧的时刻做并集并去重得到总时间轴,合成后的每一帧都是由ABC的对应那一时刻的帧合并而成的静态图:

  • R1(帧) = A1 C1 合并
  • R2 = A1 B1 C1 合并
  • R3 = A2 B1 C1 合并
  • R4 = A1 B2 C1 合并
  • R5 = A2 B2 C2 合并

这样可以保证输出的结果播放速度/帧率保持不变,代码实现如下:

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
30
31
32
33
34
35
36
37
38

fun merge(width: Int, height: Int, images: List<GifImage>, duration: Long): GifImage {
val result = ArrayList<GifFrame>()
//保存每张Gif图的当前帧索引,初始化为0
val frameIndex = IntArray(images.size) { 0 }
//所有GIF图所有帧合并后按时间顺序排列而成的主时间轴
val timeline = images.flatMap { it.frames }.map { it.time }.distinct().sorted()

for (t in timeline.indices) {
val current = timeline[t]//当前时刻
val next = timeline.getOrNull(t + 1) ?: current
val base = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
base.density = images[0].frames[0].image.density
val canvas = Canvas(base)

for (i in images.indices) {

val index = frameIndex[i]
val image = images[i]
val frame = image.frames[index]

if (frame.time <= current) {
//当前帧刚好在当前时刻之前,则采用当前帧的图像并使索引+1
canvas.drawBitmap(frame.image, 0f, 0f, null)
frameIndex[i] = if (index == image.frames.size - 1) 0 else index + 1

} else if (frame.time > current) {
//当前帧的时间在当前时刻之后,则需要取当前帧之前的那一帧
val findIndex = max(index - 1, 0)
canvas.drawBitmap(image.frames[findIndex].image, 0f, 0f, null)
}
}

result.add(GifFrame(base, (next - current).toInt(), current))
}

return GifImage(result, duration)
}

图片合并这里就直接用了Canvas来合并多张Bitmap,参数 duration 为总的播放时长,整体逻辑其实很简单。另外在实际项目中,每张GIF图都是经过手势变换改变了大小、旋转角度并裁剪后的,这部分跟静态图片是类似的处理逻辑,对每一帧都做同样的处理,就不放代码了,这里只展示了最终结果合并的过程。