前言

  公司业务中有一些场景需要用到服务端音视频剪辑技术,最开始为了快速上线使用的是某公有云的商用解决方案,但由于费用太高所以我们团队经过一个星期的冲刺,给出了一个FFmpeg+Serverless的解决方案,更好地满足了业务方的视频剪辑需求。经过统计,自研方案成功地将剪辑失败率降到了万分之一左右,并且将费用成本降到了商用解决方案的零头,是一个非常大的突破。现在我们计划把该平台做得更加通用化,让更多的业务可以无缝接入,通过任务编排来实现更多定制化需求。

  题外话,为什么我们会选择Serverless来实现该业务呢,因为我们的业务高峰期特别明显,时效性要求也高,在使用某公有云解决方案期间经常触发系统QPS限制,经过多次沟通也只能临时调整,而且对方技术半夜还打电话来问我是否可以限制QPS,作为使用方肯定是不愿意的,所以除了成本外性能也是我们下决心自研系统的原因之一。Serverless在使用过程中遇到的问题我也会在后续时间里面记录下来。

  本编博客主要是记录在整个过程中涉及到的FFmpeg常见命令,以及一些坑,分享给大家使用,若有谬误请批评指正。

基本命令

音视频剪切

String.format("%s -i %s -vcodec libx264  -ss %s -to %s %s -y", ffmpegCommandPath, sourceFilePath, startTime, endTime, targetFilePath)

ffmpegCommandPath    表示FFmpeg执行命令的路径

sourceFilePath              表示源文件路径

startTime                     表示剪切的起始点,格式为 "00:00:00", 例如 "00:00:15" 表示剪切从第15秒开始

endTime                      表示剪切的终止点,格式为 "00:00:00", 例如 "00:00:20" 表示剪切截止到第20秒

targetFilePath               表示剪切后的输出文件

-y                               表示输出文件若存在则覆盖  

音频/视频简单拼接

String.format("%s -f concat -safe 0 -i %s -c copy %s", ffmpegCommandPath, concatListFilePath, destinationFilePath)

ffmpegCommandPath    表示FFmpeg执行命令的路径

concatListFilePath         表示拼接的配置文件,内容格式为

destinationFilePath        表示拼接后的输出文件

使用限制:该方式不涉及到文件的解码、编码,所以速度极快,但如果待处理文件的编码格式不同则请勿使用,否则输出的文件可能无法正常播放(或者只能播放一部分)。如果编码格式不同,请参考下文中的音频拼接/视频拼接方式,会更加可靠,当会更加消耗资源。

音频拼接

由于涉及到到参数拼接,所以直接上代码(Java方式)。

/**
 * 音频文件拼接
 * @param files                     音频文件资源路径数组
 * @param destinationFilePath       处理后输出文件路径
 */
public static void audioConcat(String[] files, String destinationFilePath) {
    // command list
    List<String> commandList = new ArrayList<>();
    commandList.add("ffmpeg");

    // input_options
    for (String file : files) {
        commandList.add("-i");
        commandList.add(file);
    }

    // filter_complex
    StringBuilder filterComplexOptions = new StringBuilder();
    for (int i = 0; i < files.length; i++) {
        filterComplexOptions.append(String.format("[%s:0]", i));
    }
    filterComplexOptions.append(String.format("concat=n=%s:v=0:a=1[out]", files.length));
    commandList.add("-filter_complex");
    commandList.add(filterComplexOptions.toString());
    commandList.add("-map");
    commandList.add("[out]");
    commandList.add(destinationFilePath);
    Runtime.getRuntime().exec(commandList.toArray(new String[0]));

    // next process
}

视频拼接

由于涉及到到参数拼接,所以直接上代码(Java方式)。

/**
 * 视频拼接
 * @param files                  音频文件资源路径数组
 * @param destinationFilePath    处理后输出文件路径
 * @param outputWidth            输出视频的宽度
 * @param outputHeight           输出视频的高度
 */
public static void videoConcat(String[] files, String destinationFilePath, Integer outputWidth, Integer outputHeight) {
    // command list
    List<String> commandList = buildFfmpegCommand();
    commandList.add("ffmpeg");

    // input_options
    for (String file : files) {
        commandList.add("-i");
        commandList.add(file);
    }

    // filter_complex
    StringBuilder filterComplexOptions = new StringBuilder();
    StringBuilder streamsOptions = new StringBuilder();
    for (int i = 0; i < files.length; i++) {
        filterComplexOptions.append(String.format("[%s:v]scale=w=%s:h=%s,setsar=1/1[v%s];", i, outputWidth, outputHeight, i));
        streamsOptions.append(String.format("[v%s][%s:a]", i, i));
    }
    streamsOptions.append(String.format("concat=n=%s:v=1:a=1 [vv] [aa]", files.length));
    commandList.add("-filter_complex");
    commandList.add(String.format("%s%s", filterComplexOptions.toString(), streamsOptions.toString()));
    Collections.addAll(commandList, "-map", "[vv]", "-map", "[aa]", "-c:v", "libx264", "-x264-params",
        "profile=main:level=3.1", "-crf", "18", "-y", "-vsync", "vfr");
    commandList.add(destinationFilePath);
    Runtime.getRuntime().exec(commandList.toArray(new String[0]));

    // next process
}

踩坑经验: 我们在拼接过程中遇到了视频拼接出错的情况,但数量比较少,通过FFprobe命令分析,发现这种情况出现在其中某个视频无音轨的情况,找了很多解决方案,最后采用的方式是为这个视频配一个音轨,相当于先把素材标准化处理,为视频注入音轨的方式见下文 "无音轨视频配音"。

音视频混合

String.format("%s -i %s -i %s -filter_complex amix -map 0:v -map 0:a -map 1:a -shortest -y %s", ffmpegCommandPath, videoFilePath, audioFilePath, targetFilePath)

ffmpegCommandPath    表示FFmpeg执行命令的路径

videoFilePath                表示视频文件路径

audioFilePath                表示音频文件路径

targetFilePath               表示输出文件路径

无音轨视频配音

String.format("%s -i %s -f lavfi -i aevalsrc=0 -shortest -y %s", ffmpegCommandPath, videoFilePath, targetFilePath)

ffmpegCommandPath    表示FFmpeg执行命令的路径

videoFilePath                表示视频文件路径

targetFilePath               表示输出文件路径

踩坑经验

Runtime.getRuntime().exec() 的问题

上述涉及到Java的部分都是采用的 Runtime.getRuntime().exec(String[] cmdarray) 而不是 Runtime.getRuntime().exec(String command),因为后者一旦遇到双引号就会带来问题,当时就想使用字符串的方式来执行(这样的话一旦程序中遇到问题就可以很方便地复制到Shell中复现),但命令中一旦存在双引号就无法解决,困扰了很久,读到了 "getruntime() exec() with double quotes in command" 这篇文章后决定就用数组形式吧。

执行结果 Process.waitFor() 的问题

Runtime.getRuntime().exec() 的执行结果需要通过 waitFor() 方式来获取子进程退出码,以此来判断是否执行成功,但倘若 FFmpeg 没有关闭调试信息,则可能会导致该函数一直卡在这。当时程序中有少部分任务会卡死,我还以为是Serverless平台的问题,但后面通过 "Java process.waitFor() 卡死问题" 这篇文章找到了解决方案,因此可以通过关闭调试信息的方式来规避,即"ffmpeg -loglevel quiet"。

06-25 20:43