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


    前言

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

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

    大致效果如下:

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

    实现方法

    FFmpeg 简介

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

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

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

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

    1. -y 若指定的输出文件已存在则强制覆盖
    2. -i 设置输入文件,可以设置多个
    3. -filter_complex 设置复杂滤镜,我们这次要实现的拼接 gif 就是依靠这个参数完成

    在安卓中使用 FFmpeg

    我现在使用的库是 ffmpeg-kit 使用这个库可以直接集成 FFmpeg 到项目中,并且能够方便的执行 FFmpeg 命令。

    该库执行 FFmpeg 很简单,只需要:

    1. val session = FFmpegKit.executeWithArguments("your cmd text")
    2. if (ReturnCode.isSuccess(session.returnCode)) {
    3. Log.i(TAG, "Command execution completed successfully.")
    4. } else if (ReturnCode.isCancel(session.returnCode)) {
    5. Log.i(TAG, "Command execution cancelled by user.")
    6. } else {
    7. Log.e(TAG, String.format("Command execution fail with state %s and rc %s.%s", session.state, session.returnCode, session.failStackTrace))
    8. }
    9. 复制代码

    因为我需要自己管理线程,所以使用的是同步执行

    另外,我几乎试过当前 GitHub 上最近还在维护所有的 FFmpeg for Android 库,甚至还自己写过一个,但是都或多或少的有点问题,最终只有这个库能够适配我的需求。

    在此弱弱的吐槽一下某些“开源”库,只提供二进制包,不提供编译脚本,也不提供源代码,提供的二进制包缺少了某些依赖,我想自己动手编译都没法编译,一看 README ,好嘛,定制编译请联系作者付费获取,合着这开源开了个寂寞啊。

    拼接命令

    我们先来看一段完整的拼接命令,我会详细讲解各个参数的作用,最后再讲解如何动态生成需要的命令。

    完整命令:

    1. # 覆盖输出文件
    2. -y
    3. # 输入文件
    4. -i jointBg.png
    5. -i 1.gif
    6. -i 2.gif
    7. -i 3.gif
    8. -i 4.gif
    9. # 开始进行滤镜转换
    10. -filter_complex
    11. [0:v]pad=1280:2161[bg];
    12. [1:v]scale=640:1137[gif0];
    13. [2:v]scale=640:368[gif1];
    14. [3:v]scale=640:1024[gif2];
    15. [4:v]scale=640:368[gif3];
    16. [bg][gif0] overlay=0:0[over0];
    17. [over0][gif1] overlay=640:0[over1];
    18. [over1][gif2] overlay=0:1137[over2];
    19. [over2][gif3] overlay=640:368
    20. # 输出路径
    21. out.gif
    22. 复制代码

    为了方便查看,我使用换行分割了命令,使用时可不能加换行哦

    在这段代码中,我们使用 -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. 依次将缩放后的输入文件覆盖(叠加)到背景上

    动画演示:

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

    计算尺寸

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

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

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

    1. 横向拼接
    2. 纵向拼接
    3. 宫格拼接

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

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

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

    1. 完全以最小尺寸的图片为基准,将所有图片强制缩放到最小尺寸,这样可能会造成部分动图被拉伸失真。
    2. 以所有图片中的最小宽度为基准,等比例缩放其他图片,这样可以确保所有图片都不会失真,但是拼接出来的成品将不是一个完美的矩形,而是一个留有黑色背景的异形图片。

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

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

    1. val jointGifResolution: MutableList<MutableList<Int>> = ArrayList() // 所有动图的原始尺寸 list
    2. var minValue = Int.MAX_VALUE // 最小宽度(别问我为什么不命名成 minWidth ,问就是兼容性)
    3. var minValue2 = Int.MAX_VALUE // 最小高度
    4. for (uri in gifUris) {
    5. val gifDrawable = GifDrawable(context.contentResolver, uri)
    6. val height = gifDrawable.intrinsicHeight // 当前 gif 的原始高度
    7. val width = gifDrawable.intrinsicWidth // 当前 gif 的原始宽度
    8. jointGifResolution.add(mutableListOf(width, height)) // 将尺寸加入 list
    9. // 计算最小宽高
    10. if (minValue > width) {
    11. minValue = width
    12. }
    13. if (minValue2 > height) {
    14. minValue2 = height
    15. }
    16. }
    17. 复制代码

    其中,gifUris 即事先获取到的所有输入动图的 uri 列表。

    这里我们使用到了 GifDrawable 获取动图的尺寸,因为这不是本文的重点,所以不多加解释,读者只需知道这样可以拿到 gif 的原始尺寸即可。

    拿到所有动图的原始宽高和最小宽高后,下一步是计算需要的缩放值:

    1. var totalHeight = 0
    2. var totalWidth = 0
    3. var squareIndex = 0
    4. val squareTotalHeight: MutableList<Int> = arrayListOf()
    5. jointGifResolution.forEachIndexed { index, resolution ->
    6. val jointWidth = minValue // 无论使用缩放策略 1 还是 2,缩放宽度都是最小宽度
    7. val jointHeight = when (scaleMode) {
    8. // 如果使用缩放策略 2 则需要按比例计算出缩放高度
    9. GifTools.JointScaleModeWithRatio -> resolution[1] * minValue / resolution[0]
    10. // 如果使用缩放策略 1 则直接强制缩放到最小高度
    11. else -> minValue2
    12. }
    13. // 因为宫格拼接只能使用 2 的 n 次幂张图片,所以每行图片数量可以根据图片总数算出,不过太麻烦,所以这里我打了个表,直接从表里面拿
    14. // 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)
    15. var lineLength = GifTools.JointGifSquareLineLength[jointGifResolution.size]
    16. if (lineLength == null) {
    17. lineLength = sqrt(jointGifResolution.size.toDouble()).toInt()
    18. }
    19. if (scaleMode == GifTools.JointScaleModeWithRatio) { // 使用等比缩放策略
    20. if (index < lineLength) { // 所有图片宽度都是一样的,所以直接加一行的宽度得到的就是最大宽度
    21. totalWidth += jointWidth
    22. }
    23. try {
    24. // 这里是获取每一列的当前行高,并将其加起来,最终遍历完会得到当前列的高度
    25. val tempIndex = squareIndex % lineLength
    26. Log.e(TAG, "getJointGifResolution: temp index = $tempIndex")
    27. if (squareTotalHeight.size <= tempIndex) {
    28. squareTotalHeight.add(tempIndex, 0)
    29. }
    30. squareTotalHeight[tempIndex] = squareTotalHeight[tempIndex] + jointHeight
    31. } catch (e: java.lang.Exception) {
    32. Log.e(TAG, "getJointGifResolution: ", e)
    33. }
    34. // 将缩放尺寸更新至尺寸列表
    35. jointGifResolution[index] = mutableListOf(jointWidth, jointHeight)
    36. } else {
    37. // 如果不是按比例缩放,则直接将最小宽高存入总宽高
    38. if (index < lineLength) {
    39. totalHeight += min(jointHeight, jointWidth)
    40. totalWidth += min(jointHeight, jointWidth)
    41. }
    42. // 将缩放尺寸更新至尺寸列表
    43. jointGifResolution[index] = mutableListOf(min(jointHeight, jointWidth), min(jointHeight, jointWidth))
    44. }
    45. squareIndex++
    46. }
    47. 复制代码

    上面的代码我已经加了详细的注释,至此所有图片的缩放尺寸已计算出来。

    即,总尺寸为:

    1. if (scaleMode != GifTools.JointScaleModeWithRatio) {
    2. jointGifResolution.add(mutableListOf(totalWidth, totalHeight))
    3. }
    4. else {
    5. Log.e(TAG, "getJointGifResolution: $squareTotalHeight")
    6. jointGifResolution.add(mutableListOf(totalWidth, Collections.max(squareTotalHeight)))
    7. }
    8. 复制代码

    最小宽高为:

    1. jointGifResolution.add(mutableListOf(minValue, minValue2))
    2. 复制代码

    对了,你可能会奇怪,为什么我要把总尺寸和最小宽高存入缩放尺寸 list,哈哈,这是因为我懒,所以我对这个 list 的定义是:

    1. /**
    2. *
    3. * 遍历获取所有 gifUris 中的动图分辨率
    4. *
    5. * 并将经过处理后的所有长、宽之和存入 [size-2] ;
    6. *
    7. * 将最小的长宽存入 [size-1]
    8. * */
    9. 复制代码

    动态生成命令

    完成了尺寸的计算,下一步是按照输入文件和计算出来的尺寸动态的生成 FFmpeg 命令。

    不过在这之前,我们需要先创建一个 1x1 的图片,用来扩展成背景:

    1. private suspend fun createJointBgPic(context: Context): File? {
    2. val drawable = ColorDrawable(Color.parseColor("#FFFFFFFF"))
    3. val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
    4. val canvas = Canvas(bitmap)
    5. drawable.draw(canvas)
    6. return try {
    7. Tools.saveBitmap2File(bitmap, "jointBg", context.externalCacheDir)
    8. } catch (e: Exception) {
    9. log2text("Create cache bg fail!", "e", e)
    10. null
    11. }
    12. }
    13. 复制代码

    然后从尺寸列表中取出并删除追加在末尾的总尺寸和最小尺寸:

    1. // 别看了,没写错,就是两个 size-1 ,为啥?你猜
    2. val minResolution = gifResolution.removeAt(gifResolution.size - 1)
    3. val totalResolution = gifResolution.removeAt(gifResolution.size - 1)
    4. 复制代码

    然后,就是开始拼接命令,这里我为了方便使用,自己写了一个 FFmpeg 命令的 Builder:

    1. /**
    2. * @author equationl
    3. * */
    4. public class FFMpegArgumentsBuilder {
    5. private final String[] cmd;
    6. public static class Builder {
    7. private final ArrayList<String> cmd = new ArrayList<>();
    8. /**
    9. * Such as add [arg, value] to cmd[]
    10. * */
    11. public Builder setArgWithValue(String arg, String value) {
    12. this.cmd.add(arg);
    13. this.cmd.add(value);
    14. return this;
    15. }
    16. /**
    17. * Such as add arg to cmd[]
    18. * */
    19. public Builder setArg(String arg) {
    20. this.cmd.add(arg);
    21. return this;
    22. }
    23. /**
    24. * Such as "-ss time"
    25. * */
    26. public Builder setStartTime(String time) {
    27. this.cmd.add("-ss");
    28. this.cmd.add(time);
    29. return this;
    30. }
    31. /**
    32. * Such as "-to time"
    33. * */
    34. public Builder setEndTime(String time) {
    35. this.cmd.add("-to");
    36. this.cmd.add(time);
    37. return this;
    38. }
    39. /**
    40. * Such as "-i input"
    41. * */
    42. public Builder setInput(String input) {
    43. this.cmd.add("-i");
    44. this.cmd.add(input);
    45. return this;
    46. }
    47. /**
    48. * <p>Such as "-t time"</p>
    49. * <p>Note: call this before addInput() will limit input duration time; call before addOutput() will limit output duration time.</p>
    50. * */
    51. public Builder setDurationTime(String time) {
    52. this.cmd.add("-t");
    53. this.cmd.add(time);
    54. return this;
    55. }
    56. /**
    57. * <p>if isOverride is true, add "-y"; else add "-n"</p>
    58. * <p>if do not set this arg, FFMpeg may ask for if override existed output file</p>
    59. * */
    60. public Builder setOverride(Boolean isOverride) {
    61. if (isOverride) {
    62. this.cmd.add("-y");
    63. }
    64. else {
    65. this.cmd.add("-n");
    66. }
    67. return this;
    68. }
    69. /**
    70. * Add output file to cmd[].<b>You must call this at end.</b>
    71. * */
    72. public Builder setOutput(String output) {
    73. this.cmd.add(output);
    74. return this;
    75. }
    76. /**
    77. * <p>Set input/output file format</p>
    78. * <p>Such as "-f format"</p>
    79. * */
    80. public Builder setFormat(String format) {
    81. this.cmd.add("-f");
    82. this.cmd.add(format);
    83. return this;
    84. }
    85. /**
    86. * Set video filter
    87. * Such as "-vf filter"
    88. * */
    89. public Builder setVideoFilter(String filter) {
    90. this.cmd.add("-vf");
    91. this.cmd.add(filter);
    92. return this;
    93. }
    94. /**
    95. * Set frame rate, Such as "-r frameRate"
    96. * */
    97. public Builder setFrameRate(String frameRate) {
    98. this.cmd.add("-r");
    99. this.cmd.add(frameRate);
    100. return this;
    101. }
    102. /**
    103. * Set frame size, Such as "-s frameSize"
    104. * */
    105. public Builder setFrameSize(String frameSize) {
    106. this.cmd.add("-s");
    107. this.cmd.add(frameSize);
    108. return this;
    109. }
    110. public FFMpegArgumentsBuilder build() {
    111. return new FFMpegArgumentsBuilder(this, false);
    112. }
    113. /**
    114. * Build cmd
    115. *
    116. * @param isAddFFmpeg true: Add a ffmpeg flag in first
    117. * */
    118. public FFMpegArgumentsBuilder build(Boolean isAddFFmpeg) {
    119. return new FFMpegArgumentsBuilder(this, isAddFFmpeg);
    120. }
    121. }
    122. public String[] getCmd() {
    123. return this.cmd;
    124. }
    125. private FFMpegArgumentsBuilder(Builder b, Boolean isAddFFmpeg) {
    126. if (isAddFFmpeg) {
    127. b.cmd.add(0, "ffmpeg");
    128. }
    129. this.cmd = b.cmd.toArray(new String[0]);
    130. }
    131. }
    132. 复制代码

    开始生成命令文本:

    首先是输入文件等,

    1. val cmdBuilder = FFMpegArgumentsBuilder.Builder()
    2. cmdBuilder.setOverride(true) // -y
    3. .setInput(jointBg.absolutePath) // -i 输入背景
    4. for (uri in gifUris) { //输入GIF
    5. cmdBuilder.setInput(FileUtils.getMediaAbsolutePath(context, uri)) // -i
    6. }
    7. cmdBuilder.setArg("-filter_complex") //添加过滤器
    8. 复制代码

    然后是添加过滤器参数,

    1. //过滤器参数
    2. var cmdFilter = ""
    3. //设置背景并扩展分辨率到 total
    4. cmdFilter += "[0:v]pad=${totalResolution[0]}:${totalResolution[1]}[bg];"
    5. //将输入文件缩放并取别名为 gifX (X为索引)
    6. gifResolution.forEachIndexed { index, mutableList ->
    7. cmdFilter += "[${index+1}:v]scale=${mutableList[0]}:${mutableList[1]}[gif$index];"
    8. }
    9. cmdFilter += "[bg][gif0] overlay=0:0[over0];" //将第一个GIF叠加 bg 的 0:0 (即画面左下角)
    10. //开始叠加剩余动图
    11. cmdFilter += getCmdFilterOverlaySquare(gifUris, gifResolution)
    12. 复制代码

    其中,getCmdFilterOverlaySquare 用于计算 gif 的摆放坐标,并合成参数命令,实现如下:

    1. private fun getCmdFilterOverlaySquare(gifUris: ArrayList<Uri>, gifResolution: MutableList<MutableList<Int>>): String {
    2. // "[bg][gif0] overlay=0:0[over0];"
    3. var cmdFilter = ""
    4. var h: Int
    5. var w: Int
    6. var index = 0
    7. var lineLength = GifTools.JointGifSquareLineLength[gifUris.size]
    8. if (lineLength == null) {
    9. lineLength = sqrt(gifUris.size.toDouble()).toInt()
    10. }
    11. for (i in 0 until lineLength) {
    12. for (j in 0 until lineLength) {
    13. if ((i==lineLength-1 && j==lineLength-1) || (i==0 && j==0)) { //最后一张单独处理,第一张已处理
    14. continue
    15. }
    16. if (j==0) { //竖排第一个,w当然等于 0
    17. w = 0
    18. } else {
    19. w = 0
    20. for (k in 0 until j) {
    21. w += gifResolution[i*lineLength+k][0]
    22. }
    23. }
    24. if (i==0) { //横排第一个,h等于0
    25. h = 0
    26. } else {
    27. h = 0
    28. for (k in j until index step lineLength) {
    29. h += gifResolution[k][1]
    30. }
    31. }
    32. cmdFilter += "[over${index}][gif${index+1}] overlay=$w:$h[over${index + 1}];"
    33. index++
    34. }
    35. }
    36. w = 0
    37. for (i in 0 until lineLength-1) {
    38. w += gifResolution[i+lineLength*(lineLength-1)][0]
    39. }
    40. h = 0
    41. for (i in lineLength-1 until lineLength*lineLength-1 step lineLength) {
    42. h += gifResolution[i][1]
    43. }
    44. cmdFilter += "[over${index}][gif${index+1}] overlay=$w:$h"
    45. return cmdFilter
    46. }
    47. 复制代码

    上述代码不难理解,总之就是根据遍历到的 gif 索引,判断它应该所处的坐标,然后加入过滤器参数。

    最后,将过滤参数加入命令,加入输出文件路径,即可拿到最终命令文本 cmd

    1. cmdBuilder.setArg(cmdFilter)
    2. cmdBuilder.setOutput(resultPath)
    3. val cmd = cmdBuilder.build(false).cmd
    4. 复制代码

    最后,只要将这个命令文本仍给 FFmpeg 执行即可!

    总结

    虽然本文仅仅说的是如何拼接 Gif , 但是 FFmpeg 是十分强大的,我这个属于是抛砖引玉。

    相信各位有过这样一种需求,那就是做一个多人同屏的实时会议功能,如果在看本文之前你可能不知所措,但是看完本文你一定会觉得这是小菜一碟。

    因为 FFmpeg 原生支持串流,支持视频处理,你只要把我这里的输入文件改成串流,输出文件改成串流,再按照你的需求改一下坐标,那不就完成了吗?

     

  • 相关阅读:
    《古代汉语》王力笔记整理版
    嵌入式Linux应用开发-第十二章设备树的改造
    将Xml转为Map集合工具类
    小程序支付基本流程
    算法37|738,714,968
    Android Compose 十二:常用组件列表 上拉加载
    低代码在物品领用领域数字化转型的案例分析
    体育场馆预约小程序,体育馆预约小程序,体育馆预约系统小程序
    贪心算法小结
    sqlserver在设计表结构时,如何选择字段的数据类型
  • 原文地址:https://blog.csdn.net/sinat_17133389/article/details/126931995