前言
在我的项目 隐云图解制作 中,有一个功能是按照一定规则将多张 gif 拼接成一张 gif。
当然,这里说的拼接是类似于拼图一样的拼接,而不是简单粗暴的把多个 gif 合成一个 gif 并按顺序播放。
大致效果如下:
注意:上面的动图只展示了预览效果,没有展示实际合成效果,但是合成效果和预览效果是一摸一样的,有兴趣的话,我可以再开一篇文章讲解怎么实现这个预览效果
实现方法
FFmpeg 简介
在开始之前先简单介绍一下什么是 FFmpeg,不过我相信只要是稍微接触过一点音视频的开发者都知道 FFmpeg。
FFmpeg 是一个开放源代码的自由软件,可以执行音频和视频多种格式的录影、转换、串流功能,包含了 libavcodec ——这是一个用于多个项目中音频和视频的解码器库,以及 libavformat ——一个音频与视频格式转换库。
简单来说,只要是和音视频相关的操作,几乎都可以使用 FFmpeg 来实现。
当然,FFmpeg 是一个纯命令行工具,所以我在这里简单介绍几个本文需要用到的参数:
- -y 若指定的输出文件已存在则强制覆盖
- -i 设置输入文件,可以设置多个
- -filter_complex 设置复杂滤镜,我们这次要实现的拼接 gif 就是依靠这个参数完成
在安卓中使用 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 原生支持串流,支持视频处理,你只要把我这里的输入文件改成串流,输出文件改成串流,再按照你的需求改一下坐标,那不就完成了吗?