在我的项目 隐云图解制作 中,有一个功能是按照一定规则将多张 gif 拼接成一张 gif。
当然,这里说的拼接是类似于拼图一样的拼接,而不是简单粗暴的把多个 gif 合成一个 gif 并按顺序播放。
大致效果如下:
注意:上面的动图只展示了预览效果,没有展示实际合成效果,但是合成效果和预览效果是一摸一样的,有兴趣的话,我可以再开一篇文章讲解怎么实现这个预览效果
在开始之前先简单介绍一下什么是 FFmpeg,不过我相信只要是稍微接触过一点音视频的开发者都知道 FFmpeg。
FFmpeg 是一个开放源代码的自由软件,可以执行音频和视频多种格式的录影、转换、串流功能,包含了 libavcodec ——这是一个用于多个项目中音频和视频的解码器库,以及 libavformat ——一个音频与视频格式转换库。
简单来说,只要是和音视频相关的操作,几乎都可以使用 FFmpeg 来实现。
当然,FFmpeg 是一个纯命令行工具,所以我在这里简单介绍几个本文需要用到的参数:
我现在使用的库是 ffmpeg-kit 使用这个库可以直接集成 FFmpeg 到项目中,并且能够方便的执行 FFmpeg 命令。
该库执行 FFmpeg 很简单,只需要:
- val session = FFmpegKit.executeWithArguments("your cmd text")
- if (ReturnCode.isSuccess(session.returnCode)) {
- Log.i(TAG, "Command execution completed successfully.")
- } else if (ReturnCode.isCancel(session.returnCode)) {
- Log.i(TAG, "Command execution cancelled by user.")
- } else {
- Log.e(TAG, String.format("Command execution fail with state %s and rc %s.%s", session.state, session.returnCode, session.failStackTrace))
- }
- 复制代码
因为我需要自己管理线程,所以使用的是同步执行
另外,我几乎试过当前 GitHub 上最近还在维护所有的 FFmpeg for Android 库,甚至还自己写过一个,但是都或多或少的有点问题,最终只有这个库能够适配我的需求。
在此弱弱的吐槽一下某些“开源”库,只提供二进制包,不提供编译脚本,也不提供源代码,提供的二进制包缺少了某些依赖,我想自己动手编译都没法编译,一看 README ,好嘛,定制编译请联系作者付费获取,合着这开源开了个寂寞啊。
我们先来看一段完整的拼接命令,我会详细讲解各个参数的作用,最后再讲解如何动态生成需要的命令。
完整命令:
- # 覆盖输出文件
- -y
-
- # 输入文件
- -i jointBg.png
- -i 1.gif
- -i 2.gif
- -i 3.gif
- -i 4.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
-
- # 输出路径
- out.gif
- 复制代码
为了方便查看,我使用换行分割了命令,使用时可不能加换行哦
在这段代码中,我们使用 -y
参数指定如果输出文件已存在则覆盖。
接下来使用 -i
参数输入了 5 个文件,其中 jointBg.png
是我生成的一个 1x1 像素的图片,用于后面扩展成背景画布,其他的 gif 文件就是要拼接的源文件。
然后使用 -filter_complex
表示要做一个复杂滤镜,后面跟着的都是这个复杂滤镜的参数:
[0:v]pad=1280:2161[bg];
表示将输入的第一个文件作为视频打开,并将其当成画板,同时缩放分辨率为 1280x2161 (后面会讲这些分辨率是怎么来的),最后取名为 bg
。
[1:v]scale=640:1137[gif0];
表示将输入的第二个文件作为视频打开,并缩放分辨率至 640x1137 , 最后取别名为 gif0
。
下面的三行语句作用相同。
然后就是开始拼接:
[bg][gif0] overlay=0:0[over0];
表示将 gif0
覆盖到 bg
上,并且覆盖的起点坐标为 0x0 ,最后将该其取名为 over0
。
下面的三行代码作用相同。
简单理解一下这个过程:
动画演示:
仅作演示便于理解,实际拼接时一般都是放大 bg , 缩小 gif,并且 gif 将完全覆盖住 bg
上一节中的命令涉及到很多缩放过程,那么这个缩放的尺寸是如何得到的呢?
这一节我们将讲解如何计算尺寸。
首先,我们需要知道的是,当前这个功能,一共有三种拼接模式:
本文主要讲解的是宫格拼接,宫格拼接的样式即文章开头的预览效果那种。
既然是宫格拼接,那么绕不开的就是如果拼接的动图尺寸不一致,怎么确保拼接出来的动图美观?
这里我们有两种策略,由用户自行选择:
确定了我们使用的两种缩放策略,下面就是开始计算成品的总尺寸和每张输入图片的需要缩放尺寸。
不过在此之前,我们需要遍历所有输入图片,拿到所有图片的原始尺寸和所有图片中的最小尺寸:
- val jointGifResolution: MutableList<MutableList<Int>> = ArrayList() // 所有动图的原始尺寸 list
- var minValue = Int.MAX_VALUE // 最小宽度(别问我为什么不命名成 minWidth ,问就是兼容性)
- var minValue2 = Int.MAX_VALUE // 最小高度
-
- for (uri in gifUris) {
- val gifDrawable = GifDrawable(context.contentResolver, uri)
- val height = gifDrawable.intrinsicHeight // 当前 gif 的原始高度
- val width = gifDrawable.intrinsicWidth // 当前 gif 的原始宽度
- jointGifResolution.add(mutableListOf(width, height)) // 将尺寸加入 list
-
- // 计算最小宽高
- if (minValue > width) {
- minValue = width
- }
- if (minValue2 > height) {
- minValue2 = height
- }
- }
- 复制代码
其中,gifUris
即事先获取到的所有输入动图的 uri 列表。
这里我们使用到了 GifDrawable
获取动图的尺寸,因为这不是本文的重点,所以不多加解释,读者只需知道这样可以拿到 gif 的原始尺寸即可。
拿到所有动图的原始宽高和最小宽高后,下一步是计算需要的缩放值:
- var totalHeight = 0
- var totalWidth = 0
-
- var squareIndex = 0
- val squareTotalHeight: MutableList<Int> = arrayListOf()
-
- jointGifResolution.forEachIndexed { index, resolution ->
- val jointWidth = minValue // 无论使用缩放策略 1 还是 2,缩放宽度都是最小宽度
- val jointHeight = when (scaleMode) {
- // 如果使用缩放策略 2 则需要按比例计算出缩放高度
- GifTools.JointScaleModeWithRatio -> resolution[1] * minValue / resolution[0]
- // 如果使用缩放策略 1 则直接强制缩放到最小高度
- else -> minValue2
- }
- // 因为宫格拼接只能使用 2 的 n 次幂张图片,所以每行图片数量可以根据图片总数算出,不过太麻烦,所以这里我打了个表,直接从表里面拿
- // val JointGifSquareLineLength = hashMapOf(4 to 2, 9 to 3, 16 to 4, 25 to 5, 36 to 6, 49 to 7, 64 to 8, 81 to 9, 100 to 10)
- var lineLength = GifTools.JointGifSquareLineLength[jointGifResolution.size]
- if (lineLength == null) {
- lineLength = sqrt(jointGifResolution.size.toDouble()).toInt()
- }
-
- if (scaleMode == GifTools.JointScaleModeWithRatio) { // 使用等比缩放策略
-
- if (index < lineLength) { // 所有图片宽度都是一样的,所以直接加一行的宽度得到的就是最大宽度
- totalWidth += jointWidth
- }
- try {
- // 这里是获取每一列的当前行高,并将其加起来,最终遍历完会得到当前列的高度
- val tempIndex = squareIndex % lineLength
- Log.e(TAG, "getJointGifResolution: temp index = $tempIndex")
- if (squareTotalHeight.size <= tempIndex) {
- squareTotalHeight.add(tempIndex, 0)
- }
- squareTotalHeight[tempIndex] = squareTotalHeight[tempIndex] + jointHeight
- } catch (e: java.lang.Exception) {
- Log.e(TAG, "getJointGifResolution: ", e)
- }
-
- // 将缩放尺寸更新至尺寸列表
- jointGifResolution[index] = mutableListOf(jointWidth, jointHeight)
- } else {
- // 如果不是按比例缩放,则直接将最小宽高存入总宽高
- if (index < lineLength) {
- totalHeight += min(jointHeight, jointWidth)
- totalWidth += min(jointHeight, jointWidth)
- }
-
- // 将缩放尺寸更新至尺寸列表
- jointGifResolution[index] = mutableListOf(min(jointHeight, jointWidth), min(jointHeight, jointWidth))
- }
- squareIndex++
- }
- 复制代码
上面的代码我已经加了详细的注释,至此所有图片的缩放尺寸已计算出来。
即,总尺寸为:
- if (scaleMode != GifTools.JointScaleModeWithRatio) {
- jointGifResolution.add(mutableListOf(totalWidth, totalHeight))
- }
- else {
- Log.e(TAG, "getJointGifResolution: $squareTotalHeight")
- jointGifResolution.add(mutableListOf(totalWidth, Collections.max(squareTotalHeight)))
- }
- 复制代码
最小宽高为:
- jointGifResolution.add(mutableListOf(minValue, minValue2))
- 复制代码
对了,你可能会奇怪,为什么我要把总尺寸和最小宽高存入缩放尺寸 list,哈哈,这是因为我懒,所以我对这个 list 的定义是:
- /**
- *
- * 遍历获取所有 gifUris 中的动图分辨率
- *
- * 并将经过处理后的所有长、宽之和存入 [size-2] ;
- *
- * 将最小的长宽存入 [size-1]
- * */
- 复制代码
完成了尺寸的计算,下一步是按照输入文件和计算出来的尺寸动态的生成 FFmpeg 命令。
不过在这之前,我们需要先创建一个 1x1 的图片,用来扩展成背景:
- private suspend fun createJointBgPic(context: Context): File? {
- val drawable = ColorDrawable(Color.parseColor("#FFFFFFFF"))
- val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
- val canvas = Canvas(bitmap)
- drawable.draw(canvas)
- return try {
- Tools.saveBitmap2File(bitmap, "jointBg", context.externalCacheDir)
- } catch (e: Exception) {
- log2text("Create cache bg fail!", "e", e)
- null
- }
- }
- 复制代码
然后从尺寸列表中取出并删除追加在末尾的总尺寸和最小尺寸:
- // 别看了,没写错,就是两个 size-1 ,为啥?你猜
- val minResolution = gifResolution.removeAt(gifResolution.size - 1)
- val totalResolution = gifResolution.removeAt(gifResolution.size - 1)
- 复制代码
然后,就是开始拼接命令,这里我为了方便使用,自己写了一个 FFmpeg 命令的 Builder:
- /**
- * @author equationl
- * */
- public class FFMpegArgumentsBuilder {
- private final String[] cmd;
-
- public static class Builder {
- private final ArrayList<String> cmd = new ArrayList<>();
-
- /**
- * Such as add [arg, value] to cmd[]
- * */
- public Builder setArgWithValue(String arg, String value) {
- this.cmd.add(arg);
- this.cmd.add(value);
- return this;
- }
-
- /**
- * Such as add arg to cmd[]
- * */
- public Builder setArg(String arg) {
- this.cmd.add(arg);
- return this;
- }
-
- /**
- * Such as "-ss time"
- * */
- public Builder setStartTime(String time) {
- this.cmd.add("-ss");
- this.cmd.add(time);
- return this;
- }
-
- /**
- * Such as "-to time"
- * */
- public Builder setEndTime(String time) {
- this.cmd.add("-to");
- this.cmd.add(time);
- return this;
- }
-
- /**
- * Such as "-i input"
- * */
- public Builder setInput(String input) {
- this.cmd.add("-i");
- this.cmd.add(input);
- return this;
- }
-
- /**
- * <p>Such as "-t time"</p>
- * <p>Note: call this before addInput() will limit input duration time; call before addOutput() will limit output duration time.</p>
- * */
- public Builder setDurationTime(String time) {
- this.cmd.add("-t");
- this.cmd.add(time);
- return this;
- }
-
- /**
- * <p>if isOverride is true, add "-y"; else add "-n"</p>
- * <p>if do not set this arg, FFMpeg may ask for if override existed output file</p>
- * */
- public Builder setOverride(Boolean isOverride) {
- if (isOverride) {
- this.cmd.add("-y");
- }
- else {
- this.cmd.add("-n");
- }
- return this;
- }
-
- /**
- * Add output file to cmd[].<b>You must call this at end.</b>
- * */
- public Builder setOutput(String output) {
- this.cmd.add(output);
- return this;
- }
-
- /**
- * <p>Set input/output file format</p>
- * <p>Such as "-f format"</p>
- * */
- public Builder setFormat(String format) {
- this.cmd.add("-f");
- this.cmd.add(format);
- return this;
- }
-
- /**
- * Set video filter
- * Such as "-vf filter"
- * */
- public Builder setVideoFilter(String filter) {
- this.cmd.add("-vf");
- this.cmd.add(filter);
- return this;
- }
-
- /**
- * Set frame rate, Such as "-r frameRate"
- * */
- public Builder setFrameRate(String frameRate) {
- this.cmd.add("-r");
- this.cmd.add(frameRate);
- return this;
- }
-
- /**
- * Set frame size, Such as "-s frameSize"
- * */
- public Builder setFrameSize(String frameSize) {
- this.cmd.add("-s");
- this.cmd.add(frameSize);
- return this;
- }
-
- public FFMpegArgumentsBuilder build() {
- return new FFMpegArgumentsBuilder(this, false);
- }
-
- /**
- * Build cmd
- *
- * @param isAddFFmpeg true: Add a ffmpeg flag in first
- * */
- public FFMpegArgumentsBuilder build(Boolean isAddFFmpeg) {
- return new FFMpegArgumentsBuilder(this, isAddFFmpeg);
- }
- }
-
- public String[] getCmd() {
- return this.cmd;
- }
-
- private FFMpegArgumentsBuilder(Builder b, Boolean isAddFFmpeg) {
- if (isAddFFmpeg) {
- b.cmd.add(0, "ffmpeg");
- }
- this.cmd = b.cmd.toArray(new String[0]);
- }
-
- }
- 复制代码
开始生成命令文本:
首先是输入文件等,
- val cmdBuilder = FFMpegArgumentsBuilder.Builder()
- cmdBuilder.setOverride(true) // -y
- .setInput(jointBg.absolutePath) // -i 输入背景
-
- for (uri in gifUris) { //输入GIF
- cmdBuilder.setInput(FileUtils.getMediaAbsolutePath(context, uri)) // -i
- }
-
- 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];" //将第一个GIF叠加 bg 的 0:0 (即画面左下角)
-
- //开始叠加剩余动图
- cmdFilter += getCmdFilterOverlaySquare(gifUris, gifResolution)
- 复制代码
其中,getCmdFilterOverlaySquare
用于计算 gif 的摆放坐标,并合成参数命令,实现如下:
- private fun getCmdFilterOverlaySquare(gifUris: ArrayList<Uri>, gifResolution: MutableList<MutableList<Int>>): String {
- // "[bg][gif0] overlay=0:0[over0];"
- var cmdFilter = ""
- var h: Int
- var w: Int
- var index = 0
- var lineLength = GifTools.JointGifSquareLineLength[gifUris.size]
- if (lineLength == null) {
- lineLength = sqrt(gifUris.size.toDouble()).toInt()
- }
-
- for (i in 0 until lineLength) {
- for (j in 0 until lineLength) {
- if ((i==lineLength-1 && j==lineLength-1) || (i==0 && j==0)) { //最后一张单独处理,第一张已处理
- continue
- }
- if (j==0) { //竖排第一个,w当然等于 0
- w = 0
- } else {
- w = 0
- for (k in 0 until j) {
- w += gifResolution[i*lineLength+k][0]
- }
- }
- if (i==0) { //横排第一个,h等于0
- h = 0
- } else {
- h = 0
- for (k in j until index step lineLength) {
- h += gifResolution[k][1]
- }
- }
-
- cmdFilter += "[over${index}][gif${index+1}] overlay=$w:$h[over${index + 1}];"
- index++
- }
- }
-
- w = 0
- for (i in 0 until lineLength-1) {
- w += gifResolution[i+lineLength*(lineLength-1)][0]
- }
-
- h = 0
- for (i in lineLength-1 until lineLength*lineLength-1 step lineLength) {
- h += gifResolution[i][1]
- }
-
- cmdFilter += "[over${index}][gif${index+1}] overlay=$w:$h"
-
- return cmdFilter
- }
- 复制代码
上述代码不难理解,总之就是根据遍历到的 gif 索引,判断它应该所处的坐标,然后加入过滤器参数。
最后,将过滤参数加入命令,加入输出文件路径,即可拿到最终命令文本 cmd
:
- cmdBuilder.setArg(cmdFilter)
- cmdBuilder.setOutput(resultPath)
-
- val cmd = cmdBuilder.build(false).cmd
- 复制代码
最后,只要将这个命令文本仍给 FFmpeg 执行即可!
虽然本文仅仅说的是如何拼接 Gif , 但是 FFmpeg 是十分强大的,我这个属于是抛砖引玉。
相信各位有过这样一种需求,那就是做一个多人同屏的实时会议功能,如果在看本文之前你可能不知所措,但是看完本文你一定会觉得这是小菜一碟。
因为 FFmpeg 原生支持串流,支持视频处理,你只要把我这里的输入文件改成串流,输出文件改成串流,再按照你的需求改一下坐标,那不就完成了吗?