注册

在安卓项目中使用 FFmpeg 实现 GIF 拼接(可扩展为实现视频会议多人同屏效果)

前言

在我的项目 隐云图解制作 中,有一个功能是按照一定规则将多张 gif 拼接成一张 gif。

当然,这里说的拼接是类似于拼图一样的拼接,而不是简单粗暴的把多个 gif 合成一个 gif 并按顺序播放。

大致效果如下:

533bc3e3d6b54fdcaac7068111b1c5d1~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?

注意:上面的动图只展示了预览效果,没有展示实际合成效果,但是合成效果和预览效果是一摸一样的,有兴趣的话,我可以再开一篇文章讲解怎么实现这个预览效果

实现方法

FFmpeg 简介

在开始之前先简单介绍一下什么是 FFmpeg,不过我相信只要是稍微接触过一点音视频的开发者都知道 FFmpeg。

FFmpeg 是一个开放源代码的自由软件,可以执行音频和视频多种格式的录影、转换、串流功能,包含了 libavcodec ——这是一个用于多个项目中音频和视频的解码器库,以及 libavformat ——一个音频与视频格式转换库。

简单来说,只要是和音视频相关的操作,几乎都可以使用 FFmpeg 来实现。

当然,FFmpeg 是一个纯命令行工具,所以我在这里简单介绍几个本文需要用到的参数:

  1. -y 若指定的输出文件已存在则强制覆盖

  2. -i 设置输入文件,可以设置多个

  3. -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

下面的三行代码作用相同。

简单理解一下这个过程:

  1. 创建一个图片,并缩放尺寸至事先计算出来的最终拼接成品的尺寸作为背景

  2. 依次将输入的文件缩放至事先计算好的尺寸

  3. 依次将缩放后的输入文件覆盖(叠加)到背景上

动画演示:

dd8509c8b5b645ddb9fd038714f2eb2e~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?

仅作演示便于理解,实际拼接时一般都是放大 bg , 缩小 gif,并且 gif 将完全覆盖住 bg

计算尺寸

上一节中的命令涉及到很多缩放过程,那么这个缩放的尺寸是如何得到的呢?

这一节我们将讲解如何计算尺寸。

首先,我们需要知道的是,当前这个功能,一共有三种拼接模式:

  1. 横向拼接

  2. 纵向拼接

  3. 宫格拼接

964d0463c7b54b2d98f926b7f8c41644~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?

本文主要讲解的是宫格拼接,宫格拼接的样式即文章开头的预览效果那种。

既然是宫格拼接,那么绕不开的就是如果拼接的动图尺寸不一致,怎么确保拼接出来的动图美观?

这里我们有两种策略,由用户自行选择:

  1. 完全以最小尺寸的图片为基准,将所有图片强制缩放到最小尺寸,这样可能会造成部分动图被拉伸失真。

  2. 以所有图片中的最小宽度为基准,等比例缩放其他图片,这样可以确保所有图片都不会失真,但是拼接出来的成品将不是一个完美的矩形,而是一个留有黑色背景的异形图片。

确定了我们使用的两种缩放策略,下面就是开始计算成品的总尺寸和每张输入图片的需要缩放尺寸。

不过在此之前,我们需要遍历所有输入图片,拿到所有图片的原始尺寸和所有图片中的最小尺寸:

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 原生支持串流,支持视频处理,你只要把我这里的输入文件改成串流,输出文件改成串流,再按照你的需求改一下坐标,那不就完成了吗?

作者:equationl
来源:juejin.cn/post/7136325945937362952

0 个评论

要回复文章请先登录注册