Android音视频之FFmpeg踩坑之路

1,803 阅读10分钟

导读:

由于业务那边有个合成视频的需求,想做成把图片和视频混在一起带转场和bgm然后合成导出的功能,就去研究了一下音视频方面的技术,发现Android原生没有满足需求的技术,于是去学习FFmpeg的使用,总共用了大概两个星期的时间,中间遇到各种问题,好在最后都想到了解决方案,在这里记录下学习的过程,避免各位踩同样的坑

本文含以下内容:

1.FFmpeg常用命令

2.视频合成及转场的设计思路以及性能优化

3.自己在项目中遇到的比较大的问题及解决方案

4.后续优化方案

一.FFmpeg常用命令

FFmpeg官网:ffmpeg.org

里面有全部的命令参数说明,很详细

最简单的命令

ffmpeg -i input.mp4 output.avi

FFmpeg [全局选项] {[输入文件选项] -i 输入文件路径} ... {[输出文件选项] 输出文件地址} ...

*-i 输入

图片转视频

ffmpeg -y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -loop 1 -i pic.png/jpg -c:v libx264 -r 25 -t 1 out.jpg

*-f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 是添加静音音轨(后面拼接视频要用,如果只用单个视频可以不用音轨)

*-r 输出视频帧率

*-y 强制覆盖文件

*-t 输出视频时长,单位秒

生成黑色背景视频(带静音音轨)

ffmpeg -y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -r %d -i color=black -t %f -vf scale=1280:720 -vcodec mpeg4 %s

裁剪视频

ffmpeg -i input.mp4 -filter:v "crop=w:h:x:y" output.mp4

更改视频分辨率

ffmpeg -i input.mp4 -filter:v scale=1280:720 -c:a copy output.mp4

*-c:a copy 保留原音频的编码格式

高斯模糊

ffmpeg -y -i input.mp4 -filter_complex split[a][b];[a]scale=1280:720,boxblur=30:5[a];[b]scale=1280:720:force_original_aspect_ratio=decrease[b];[a][b]overlay=(W-w)/2[blur];[blur]pad=1280:720:(ow-iw)/2:(oh-ih)/2,setdar=16/9 -vcodec libx264 -r %d -preset superfast out.mp4

*split分割输入流为两个输入流a,b,a输入流模糊并铺满屏幕,b输入流保持视频原比例盖在a输入流中间位置

添加文本水印

ffmpeg -i input.mp4 -vf drawtext=fontfile=%s:fontcolor=white:fontsize=36:text='...':x=(w-tw)/2:y=(h-text_h)/2),drawtext=fontfile=%s:fontcolor=white:fontsize=36:text='...':x=(w-tw)/2:y=((h-text_h)/2)+(text_h-(th/4)) -y -vcodec mpeg4 output.mp4

*fontfile为字体文件,x=(w-tw)/2:y=(h-text_h)/2)表示文字在屏幕最中央,w为视频宽度,tw为文字宽度,text_h = th同理

添加bgm

ffmpeg -i video.mp4 -i bgm.mp3 -filter_complex [1:a]aloop=loop=-1[out];[out][0:a]amix -ss 0 -t %f -y %s

*[1:a] 表示第二个输入流的音轨,aloop=-1循环bgm,amix混合视频和bgm的音轨

常用命令网上有一大把,根据自己的需求去找一般都能找到,但重点还是理解ffmpeg的原理,要不出了问题还是没办法定位

比如concat拼接这个命令,官网给出的原理是这样,一开始看到[0:a][3:v]这些的时候也是一脸懵逼,后来理解了这些是视频,音频的输入

理解输入流

理解filter原理

二:视频合成及转场的设计思路以及性能优化

一开始的设计思路是,用fade这个渐变的命令来转场,但是效果不是特别好,第一个视频完全淡出后第二个视频才会进入,中间会有一段黑屏

,看着像在播ppt,大概是这样

于是试着做交叉淡入,大概是这样

代码如下(省略设置分辨率,视频比例,帧率,音频等):

ffmpeg -y -i a.mp4 -i b.mp4 -filter_complex [0:v]fade=t=out:st=%f:d=%f:alpha=0:color=black,setpts=PTS-STARTPTS[v0];[1:v]fade=t=in:st=0:d=%f:alpha=1,fade=t=out:st=%f:d=%f:alpha=0:color=black,setpts=PTS-STARTPTS+%f/TB[v1];[v0][v1]overlay[outv] -vcodec libx264 -map [outv] -f mp4 -r 25 -preset medium out.mp4

*st-开始渐变时间 d-持续时间 alpha-(If set to 1, fade only alpha channel, if one exists on the input. Default value is 0.官网这样解释,好像是多个输入流的时候设置为1)setpts=PTS-STARTPTS+%f/TB 第一帧开始的时间点

这样做一开始自己测试的时候好像没啥问题,但后面一接入实际操作,问题就很明显了,因为是overlay的操作,当30多个视频这样覆盖叠加的话,合成速度会变慢很多,而且性能差的手机直接报OOM错误,内存顶不住啊

于是想到了第三种方案,使用concat拼接视频而非叠加视频

先把两个视频切割成4个视频,第一个切割最后一秒,第二个切割第一秒,

然后切割出来的两个视频渐变+叠加生成一个新视频,最后把3个视频连接起来

/**
     * 多个视频拼接在一起concat(交叉淡入),只拼接和加bgm,不处理其他
     */
    public static String[] concatMultiVideoWithFade(List<MediaBean> list , String bgmFilePath, String fontFilePath, String targetPath) {

        final float FADE_DURATION = 0.2f;//0.2秒的fade
        final int FRAM = 25;//帧率
        final int bit = getFitBitRate(1280 * 720);

        StringBuilder bd =  new StringBuilder();
        bd.append("ffmpeg ");
        bd.append("-y ");
        float totaltime = 0 ;
        for(int i = 0 ; i < list.size() ; i++){ //; MediaBean vedio : list
            MediaBean vedio = list.get(i);

            //以下时间单位都是秒
            if (vedio.getType() != 1){
                //图片转视频,直接用视频时长
                bd.append(String.format("-i %s -r %d "  , codeString(vedio.getPath()) ,FRAM ));

                //加入视频总时长
                float addtime = (vedio.getTime()/1000f - FADE_DURATION);
                totaltime += addtime > 0 ? addtime : FADE_DURATION;

            }else {
                //原始视频,根据需求规则截取
                bd.append(String.format("-i %s -r %d " , codeString(vedio.getPath()) ,FRAM));
                float addtime = (vedio.getTime()/1000f - FADE_DURATION);
                totaltime += addtime > 0 ? addtime : FADE_DURATION;
            }

        }

        //最后一个不用减
        totaltime+=FADE_DURATION;

        //添加bgm
        if (!TextUtils.isEmpty(bgmFilePath)){
            bd.append(String.format("-i %s " , bgmFilePath));
        }
        bd.append("-filter_complex ");

        //plan A 加转场
        //渐变转场
        for(int i = 0 ; i < list.size() ; i++) {//; MediaBean vedio : list
            MediaBean vedio = list.get(i);
            float t = vedio.getTime()/1000f;
            //开始渐变的时间点
            float duration;
            if (t > FADE_DURATION ){
                duration = t - FADE_DURATION;
            }else {
                duration = t;
            }

            /*
            //拆分
            v0 -> [out0][fadeout0]
            v1 -> [fadein0][out1][fadeout1]
            v2 -> [fadein1][out2][fadeout2]
            v3 -> [fadein2][out3]
            //混合
            [fadeout0][fadeout0] fade-> [fade0]
            //拼接
            [out0][fade0][out1][fade1]..[fade n-1][outn]

             */
            if (i == 0){
                //第一个
                bd.append(String.format("[%d:v]split[out0][fadeout0];[out0]trim=end=%f[out0];[fadeout0]trim=start=%f,setpts=PTS-STARTPTS[fadeout0];", i , duration , duration ));
            }else if (i == list.size()-1 ){
                //最后一个
                bd.append(String.format("[%d:v]split[fadein%d][out%d];", i , i-1 , i ));
                bd.append(String.format("[fadein%d]trim=end=%f,fade=t=in:st=0:d=%f:alpha=1[fadein%d];" , i-1 , FADE_DURATION, FADE_DURATION , i-1 ));
                bd.append(String.format("[out%d]trim=start=%f,setpts=PTS-STARTPTS[out%d];" , i , FADE_DURATION , i ));

            }else {
                //中间
                bd.append(String.format("[%d:v]split[fadein%d][splite%d];", i , i-1 , i  ));
                bd.append(String.format("[splite%d]split[out%d][fadeout%d];", i , i , i ));
                bd.append(String.format("[fadein%d]trim=end=%f,fade=t=in:st=0:d=%f:alpha=1[fadein%d];" , i-1 , FADE_DURATION ,FADE_DURATION , i-1 ));
                bd.append(String.format("[out%d]trim=start=%f:end=%f,setpts=PTS-STARTPTS[out%d];" , i ,FADE_DURATION , duration , i ));
                bd.append(String.format("[fadeout%d]trim=start=%f,setpts=PTS-STARTPTS[fadeout%d];" , i , duration , i ));

            }
//            currentTime += duration;
        }

        // 0...n-1
        for(int i = 0 ; i < list.size()-1 ; i++) { //; MediaBean vedio : list
            bd.append(String.format("[fadeout%d][fadein%d]overlay[fade%d];" , i , i , i));

        }

        //0...n
        //拼接  [out0][fade0][out1][fade1]..[fade n-1][outn]
        //拼接视频
        for(int i = 0 ; i < list.size() ; i++) { //; MediaBean vedio : list

            if (i == list.size()-1){
                //最后一个
                bd.append(String.format("[out%d]" , i));
            }else {
                bd.append(String.format("[out%d][fade%d]" , i,  i ));
            }
        }

        bd.append(String.format("concat=n=%d:v=1:a=0[outv];" , (list.size()*2) - 1 ));

        float currentTime = 0;
        //覆盖音频
        for(int i = 0 ; i < list.size() ; i++) { //; MediaBean vedio : list
            MediaBean vedio = list.get(i);
            float t = vedio.getTime()/1000f;
            //开始消失时间,后面的视频进入时间
            float duration;
            if (t > FADE_DURATION ){
                duration = t - FADE_DURATION;
            }else {
                duration = t;
            }

//            adelay=1500|0|500
            if (i == 0){
                bd.append(String.format("[%d:a]afade=t=out:st=%f:d=%f,volume=10dB[a%d];" , i , duration , FADE_DURATION , i)); //,asetpts=PTS-STARTPTS
            }else {
                bd.append(String.format("[%d:a]adelay=%d|%d,afade=t=in:st=0:d=%f,afade=t=out:st=%f:d=%f,volume=10dB[a%d];" , i ,(int)(currentTime*1000) ,(int)(currentTime*1000) ,FADE_DURATION ,currentTime +duration ,FADE_DURATION ,i)); //,asetpts=PTS-STARTPTS+%f/TB
            }

//            bd.append(String.format("[%d:a]atrim=%f[a%d];" , i , duration - FADE_DURATION , i)); //,asetpts=PTS-STARTPTS

            currentTime += duration;
        }

        //拼接音频
        for(int i = 0 ; i < list.size() ; i++){
            bd.append(String.format("[a%d]",i));
        }
//        bd.append(String.format("concat=n=%d:v=0:a=1[outa]" , list.size()));

        bd.append(String.format("amix=inputs=%d:duration=longest[outa]",list.size() ));

        //添加bgm背景音
        if (!TextUtils.isEmpty(bgmFilePath)){
            bd.append(String.format(";[%d:a]aloop=loop=-1:size=2e+09,afade=t=out:st=%f:d=%f[bgm];[outa][bgm]amix=inputs=2:duration=first[outbgm]" , list.size() ,currentTime-0.8 , FADE_DURATION +0.8));
//            bd.append(String.format(" -vcodec libx264 -map [outv] -acodec aac -map [outbgm] -ar 22050 -ac 2 -ab 128k -r %d -preset medium -crf 18 %s" ,FRAM, targetPath));
            bd.append(String.format(" -vcodec libx264 -map [outv] -acodec aac -map [outbgm] -ar 22050 -ac 2 -ab 128k -r %d -pix_fmt yuv420p -preset fast %s" ,FRAM, targetPath));
        }else {
            //无bgm
            bd.append(String.format(" -vcodec libx264 -map [outv] -map [outa] -r %d -pix_fmt yuv420p -preset fast %s" ,FRAM, targetPath));
        }

        Log.d("ffmpeg--" , bd.toString());

        String str = bd.toString();
        String[] result = str.split(" ");
        for (int i = 0;i<result.length;i++){
            result[i] = decodeString(result[i]);
        }
        return result;
    }

这样最后试了比上一种性能稍微好一点...但还是会有oom的报错,

于是把问题聚焦在输入数据过多的方向上,随着输入数据的过多,FFmpeg命令可以达到几百行... 最多一次性输入38个视频或图片,并且中间还要加字幕,裁剪,高斯模糊等效果,不崩才怪....

那只能先牺牲合成速度来提高稳定性了,然后想到了两种方法来降低对性能的要求

1.每个效果分步骤合成 -> 图片转视频 -> 生成文字水印 -> 裁剪,高斯模糊 -> 拼接,转场 -> 加bgm

2.分组合成视频,因为最多有38个视频或图片,写了一个递归,拆分6个为一组,每6个合成为一个新视频,最终再把所有新视频拼接起来,加上bgm生成最终视频,这样38个数据源最终也只用拼接7个新视频

*这样合成会降低效率,但马上要发版,为了稳定性迫不得已,最终在小米5s上成功合成38个视频和图片

*后面研究了OpenGL ES来做转场效果及合成视频,android原生来做音频混合,ffmpeg就只处理裁剪分辨率,高斯模糊和添加字幕了

三.自己在项目中遇到的比较大的问题及解决方案

1.FFmpeg源码编译及导入open-GL插件

一开始调研的时候,发现有一个open-gl的转场库能够满足需求,但需要在ffmpeg的源码里面插入转场的代码,并且编译源码,在mac环境上编译是没有问题的,但在使用Android的NDK工具(让Android调用底层的C++代码)编译FFmpeg的时候,出现了各种问题,网上普遍也没有解决方案,因为涉及到架构和汇编方面的知识,不了解,遂放弃,自己做转场效果

2.图片转视频后拼接其他视频出错

这是由于一开始图片生成的视频,没有音轨,这应该是新手经常会犯的错误,导致最后合成bgm时找不到输入音轨报错

3.声音的转场效果

ffmpeg中的filter过滤器很强大,但是在视频和音频处理上有比较大的差异,比如转场时,视频用的是setpts=PTS-STARTPTS+%f/TB实现延时播放,而音频要用adelay来实现延时效果..

4.视频及音频的编码问题

视频拼接的时候要求各种格式都一样,比如分辨率,宽高比等,同时也要保证音频的编码,码率,比特率也要一样

5.添加文字水印必须要有字体文件路径,并且选用的第三方库没有开启这个功能...

一开始是导入了另一个库,后来发现字体文件太大而且两个库不好维护,最终用了图片水印替代

思路是先用Android原生生成一个TextView,然后利用缓存截图的原理,把Textview转成Bitmap然后再导出为图片,最后添加到视频中

6.输入源过多,会报OOM异常

分批处理,上面讲到了

7.源视频没有音轨

这个我真的没有想到....因为用的第三方库的FFmpeg没有报任何有用的错误,于是把命令和源文件全部拉到mac环境去执行,最后发现是其中一个视频没有音轨导致合成失败

这个问题其实不是什么问题,但坑爹的是用的那个库他没有报任何信息....其实应该自己编译FFmpeg源码然后用NDK导入到android中的,但尝试几次都失败了,所以才用了别人封好的库,这就导致都在花时间找问题出在哪,甚至一度以为是性能问题然后越跑越偏,最后才想到用mac的FFmpeg来跑这个命令,把源文件拉到mac,执行起来,然后报的错是[5:a]audio 有问题,一眼就看出来是这个视频的音轨编码出错或缺失...坑啊,浪费了我大半天

经验是像这种多平台的工具或框架,在其中一个平台出了问题没有头绪,可以换个平台试试,说不定能收获点什么有用的信息

四.后续优化

1.ffmpeg专门做做图片和视频的处理,视频拼接和转场用OpenGL

2.原视频或图片的宽高比接近目标的宽高比的话不做高斯模糊处理,提高性能

**3.图片处理完导出为图片,用 **-frames:v 1 取一帧储存为图片