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

2,339 阅读10分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第12天,点击查看活动详情

前言

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

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

大致效果如下:

preview1.gif

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

实现方法

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. 依次将缩放后的输入文件覆盖(叠加)到背景上

动画演示:

demo1.gif

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

计算尺寸

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

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

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

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

screenshot1.jpg

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

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

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

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