背景
我开发的APP,隐云图解制作 中有一个功能是拼接多个 gif 动图到一张动图上。
这段代码是使用 FFmpeg 来实现。
测试时发现直接水平拼接和直接横向拼接都没有问题,唯独方形拼接(类似九宫格)在开启按比例缩放后总是出现问题。
而这段代码的核心逻辑是先遍历输入文件列表,读取每个 gif 的尺寸信息并将其保存下来,同时按照特定拼接规则计算出缩放尺寸并保存。
然后将计算得到的尺寸生成 FFmpeg 命令。
排查过程
使用四张分辨率分别为:
640x368, 1080x1920, 640x368, 800x1280
的动图经过缩放计算后得到如下 MutableList:
[[640, 368], [640, 1137], [640, 368], [640, 1024]]
生成的 FFmpeg 命令为:
-y -i jointBg.png -i 2022-07-20-13-44-41-by_EL.gif -i 2022-07-20-13-44-18-by_EL.gif -i 2022-07-13-15-51-07-by_EL.gif -i 2022-07-14-14-28-57-by_EL.gif -filter_complex [0:v]pad=1280:2161[bg];[1:v]scale=640:1137[gif0];[2:v]scale=640:368[gif1];[3:v]scale=640:1024[gif2];[4:v]scale=640:368[gif3];[bg][gif0] overlay=0:0[over0];[over0][gif1] overlay=640:0[over1];[over1][gif2] overlay=0:1137[over2];[over2][gif3] overlay=640:368 2022-07-20-14-03-17-by_EL.gif
把上述命令中的其他参数移除,仅保留缩放和拼接命令,并适当的加点缩进:
# 缩放
[0:v]pad=1280:2161[bg];
[1:v]scale=640:1137[gif0];
[2:v]scale=640:368[gif1];
[3:v]scale=640:1024[gif2];
[4:v]scale=640:368[gif3];
# 拼接
[bg][gif0] overlay=0:0[over0];
[over0][gif1] overlay=640:0[over1];
[over1][gif2] overlay=0:1137[over2];
[over2][gif3] overlay=640:368
稍微解释一下上述命令:
上述缩放中的代码表示把输入的第 n 个文件按照 x:y 分辨率缩放后取别名为 xxx,如:
[0:v]pad=1280:2161[bg]
表示把输入的第 0 个文件当成画板,并扩展分辨率为 1280x2161,然后取别名为 bg 以供后续使用。
[1:v]scale=640:1137[gif0]
表示把第 1 个文件缩放为 640x1137 并取别名为 gif0.
而拼接中的代码也很好理解,如:
[bg][gif0] overlay=0:0[over0]
表示将别名为 gif0 的文件覆盖到 别名为 bg 的文件上,并且起点坐标为 0:0 ,最后将处理后的文件取别名为 over0 以供后续使用。
解释完,各位有没有发现问题?没有?哈哈,没有就对了,等我画个图就理解了。
按照预想情况,拼接后的动图应该是这样排列的:
(哈哈哈,因为这里只是用极端个例来说明这个现象,所以拼出来的是这么一个奇怪的形状)
但是实际情况确实这样的:
哈哈,发现问题了吧。
拼接的方向居然反了,如果只是反了到也还能看,关键是连同缩放尺寸一起反了,导致 16:9 的动图被强行拉伸成了 9:16,而原本 9:16 的动图又被强行压缩成了 16:9 ,那观感,简直不要太惨不忍睹。
那么造成这种情况的原因是什么呢?
先看代码:
val gifResolution = getJointGifResolution(context, jointMode, gifUris)
Log.i(TAG, "jointGif: gifResolution = $gifResolution")
val totalResolution = gifResolution[gifResolution.size - 2]
val minResolution = gifResolution[gifResolution.size - 1]
gifResolution.remove(totalResolution)
gifResolution.remove(minResolution)
val cmdBuilder = FFMpegArgumentsBuilder.Builder()
cmdBuilder.setOverride(true)
.setInput(jointBg.absolutePath) //输入背景
for (uri in gifUris) { //输入GIF
cmdBuilder.setInput(FileUtils.getMediaAbsolutePath(context, uri))
}
cmdBuilder.setArg("-filter_complex")
var cmdFilter = ""
//设置背景并扩展分辨率到 total
cmdFilter += "[0:v]pad=${totalResolution[0]}:${totalResolution[1]}[bg];"
//将输入文件缩放并取别名为 gifX (X为索引)
gifResolution.forEachIndexed { index, mutableList ->
cmdFilter += "[${index+1}:v]scale=${mutableList[0]}:${mutableList[1]}[gif$index];"
}
cmdFilter += "[bg][gif0] overlay=0:0[over0];"
//开始叠加动图
cmdFilter += getCmdFilterOverlaySquare(gifUris, gifResolution)
....
上述代码中,val gifResolution = getJointGifResolution(context, jointMode, gifUris)
表示计算输入文件的分辨率信息,计算完毕后打印日志输出的分辨率顺序还是正确的。
但是此处,为了方便返回数据,我会在原有 list 末尾再添加两条数据,并且在遍历前将这两条数据通过 remove
移除。
此时再去遍历这个 list 就出现了上述所说的顺序错乱。
所以合理猜测是由于调用了 remove 导致顺序被重新排列了?
我们写一段 demo 来尝试一下:
val list: MutableList<MutableList<Int>> = mutableListOf()
// 添加 ”正常数据“
list.add(mutableListOf(640, 368))
list.add(mutableListOf(640, 1137))
list.add(mutableListOf(640, 368))
list.add(mutableListOf(640, 1024))
// 添加 ”额外数据“
list.add(mutableListOf(1280, 2161))
list.add(mutableListOf(640, 368))
// 先输出处理前的 list
println("处理前:$list")
// 模拟处理数据
val value1 = list[list.size - 2]
val value2 = list[list.size - 1]
list.remove(value1)
list.remove(value2)
// 输出处理后数据
println("处理后:$list")
不出意外,输出结果为:
处理前:[[640, 368], [640, 1137], [640, 368], [640, 1024], [1280, 2161], [640, 368]]
处理后:[[640, 1137], [640, 368], [640, 1024], [640, 368]]
果然 list 被重新排序了。
那么问题来了,为什么呢?又该怎么解决呢?
错误原因及解决方法
既然已经知道了问题出自于 remove
方法,那么自然是从它下手。
先来看看 remove 的源码:
/**
* Removes a single instance of the specified element from this
* collection, if it is present.
*
* @return `true` if the element has been successfully removed; `false` if it was not present in the collection.
*/
public fun remove(element: E): Boolean
嗯,是个接口,问题不大,找到它的实现:
override fun remove(element: E): Boolean {
checkIsMutable()
val i = indexOf(element)
if (i >= 0) removeAt(i)
return i >= 0
}
代码很简单,核心就在第3-4行,使用 indexOf 查找到元素位置后调用 removeAt 删除。
哈哈,看出问题没有?
其实去我在刚写完背景时就突然发现了问题所在,但是想着写都写了,还是假装不知道继续写下去吧。
什么?还是没看出来问题所在?
那么我们来看看 indexOf 的源码……算了,源码都不用看了,直接看注释:
/**
* {@inheritDoc}
*
* @implSpec
* This implementation first gets a list iterator (with
* {@code listIterator()}). Then, it iterates over the list until the
* specified element is found or the end of the list is reached.
*
* @throws ClassCastException {@inheritDoc}
* @throws NullPointerException {@inheritDoc}
*/
看到了吗?这个方法返回的是“指定元素第一次出现的索引位置”
也就是说,调用 remove 后删除的是第一个该元素。
我们再来看看上述代码返回的删除前后的 list 变化:
处理前:[[640, 368], [640, 1137], [640, 368], [640, 1024], [1280, 2161], [640, 368]]
处理后:[[640, 1137], [640, 368], [640, 1024], [640, 368]]
发现了吗?并不是删除元素后 list 被重排了,只是好巧不巧的,我想删除最后一个 [640, 368]
但是,前面也有一个一样的 list 导致删除的是前面的第一个元素,而非我想要删除的最后一个元素。
那么问题来了,它凭什么就说这两个 [640, 368]
是同一个对象?
再看一段代码:
val a = listOf(100, 100)
val b = listOf(100, 100)
println(a==b)
// 输出
true
这又是为什么???
哈哈哈,这个问题留给各位自己想了。
总之,最后的解决方法也很简单,因为我需要删除的元素索引是固定的,所以只需要把代码中的:
gifResolution.remove(totalResolution)
gifResolution.remove(minResolution)
改为:
// 别看了,没写错,就是两个 size-1 ,为啥?你猜
gifResolution.removeAt(gifResolution.size - 1)
gifResolution.removeAt(gifResolution.size - 1)