使用 MediaExtractor + MediaMuxer 多个视频合成MP4

在上一篇文章OpenGLES + MediaCodec 短视频分段录制实现与无丢帧录制优化中说到了MediaCodec录制视频的一些优化思路。经过上一步,我们就实现了短视频的分段录制功能以及录制视频帧率的优化。在得到多段视频之后,我们如何将这些细小的视频文件合成一个视频文件呢? 这是有好多种方法,比如通过MP4Parser、FFmpeg等进行合成。这里介绍如何使用Android原生的MediaExtractor + MediaMuxer进行对多段MP4视频文件进行合成。

1、MediaExtractor 提取媒体信息
MediaExtractor 主要的作用是从数据源中提取媒体数据,并将媒体数据传递给解复用器(通常是解码器)
使用MediaExtractor 一般是这样的,不断循环读取VideoPath/AudioPath,将媒体信息从文件中读取出来, 筛选出不同的轨道trackIndex并通过getTrackFormat(trackIndex)方法获取对应的轨道信息:

        // MediaExtractor拿到多媒体信息,用于MediaMuxer创建文件
        while (videoIterator.hasNext()) {
            String videoPath = (String) videoIterator.next();
            MediaExtractor extractor = new MediaExtractor();
            try {
                extractor.setDataSource(videoPath);
            } catch (Exception ex) {
                ex.printStackTrace();
            }

            int trackIndex;
            if (!hasVideoFormat) {
                trackIndex = selectTrack(extractor, "video/");
                if(trackIndex < 0) {
                    Log.e(TAG, "No video track found in " + videoPath);
                } else {
                    extractor.selectTrack(trackIndex);
                    mVideoFormat = extractor.getTrackFormat(trackIndex);
                    hasVideoFormat = true;
                }
            }

            if (!hasAudioFormat) {
                trackIndex = selectTrack(extractor, "audio/");
                if(trackIndex < 0) {
                    Log.e(TAG, "No audio track found in " + videoPath);
                } else {
                    extractor.selectTrack(trackIndex);
                    mAudioFormat = extractor.getTrackFormat(trackIndex);
                    hasAudioFormat = true;
                }
            }

            extractor.release();

            if (hasVideoFormat && hasAudioFormat) {
                break;
            }
        }

/**
     *  选择轨道
     * @param extractor     MediaExtractor
     * @param mimePrefix    音轨还是视轨
     * @return
     */
    private int selectTrack(MediaExtractor extractor, String mimePrefix) {
        // 获取轨道总数
        int numTracks = extractor.getTrackCount();
        // 遍历查找包含mimePrefix的轨道
        for(int i = 0; i < numTracks; ++i) {
            MediaFormat format = extractor.getTrackFormat(i);
            String mime = format.getString("mime");
            if (mime.startsWith(mimePrefix)) {
                return i;
            }
        }

        return -1;
    }


2、创建MediaMuxer并添加相应的轨道
通过MediaExtractor 拿到媒体的轨道trackIndex 和 轨道信息trackFormat之后,我们就可以创建一个MediaMuxer复用器并添加相应的轨道(音频轨道、视频轨道等):

        try {
            mMuxer = new MediaMuxer(mDestPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
        } catch (IOException e) {
            e.printStackTrace();
        }

        if (hasVideoFormat) {
            mOutVideoTrackIndex = mMuxer.addTrack(mVideoFormat);
        }

        if (hasAudioFormat) {
            mOutAudioTrackIndex = mMuxer.addTrack(mAudioFormat);
        }
        mMuxer.start();

3、合并视频
根据前面得到的媒体信息、媒体轨道等信息就可以进行合并了。合并的流程大体如下所示:
① 选择视频的轨道(音频轨道、视频轨道等),判断是否存在音视频轨道,并获取对应轨道的index。
② 判断视频的视频轨道和视频轨道是否存在,如果都不存在,则该视频文件很可能损坏了,合并失败,并退出。
③ 如果存在音频轨道、视频轨道,进入合并阶段
④ 通过MediaExtractor的readSampleData()方法从视频中读取音频轨道、视频轨道的数据,并判断读取出来的数据帧是否大于0,如果大于0,则用getSampleTime()方法读取数据帧的pts,并通过getSampleFlags()读取flag标志,然后创建一个BufferInfo类用来保存这些帧数据。
⑤ 将MediaExtractor提取的数据,通过MediaMuxer 的writeSampleData()方法将媒体数据写入到新的文件
⑥ 遍历下一个视频文件,并计算从下一个视频文件提取出来的媒体数据的PTS,用于下一次的视频合成
⑦ 释放MediaMuxer、MediaExtractor

至此,我们就把多段MP4视频合成功能的流程介绍完了,完整的合并代码如下所示:

@TargetApi(18)
public class VideoCombiner {

    private static final String TAG = "VideoCombiner";
    private static final boolean VERBOSE = true;

    // 最大缓冲区(1024 x 1024 = 1048576、1920 x 1080 = 2073600)
    // 由于没有录制1080P的视频,因此用1024的Buffer来缓存
    private static final int MAX_BUFF_SIZE = 1048576;

    private List<String> mVideoList;
    private String mDestPath;

    private MediaMuxer mMuxer;
    private ByteBuffer mReadBuf;
    private int mOutAudioTrackIndex;
    private int mOutVideoTrackIndex;
    private MediaFormat mAudioFormat;
    private MediaFormat mVideoFormat;

    private VideoCombineListener mCombineListener;

    public interface VideoCombineListener {

        /**
         * 合并开始
         */
        void onCombineStart();

        /**
         * 合并过程
         * @param current 当前合并的视频
         * @param sum   合并视频总数
         */
        void onCombineProcessing(int current, int sum);

        /**
         * 合并结束
         * @param success   是否合并成功
         */
        void onCombineFinished(boolean success);
    }



    public VideoCombiner(List<String> videoList, String destPath, VideoCombineListener listener) {
        mVideoList = videoList;
        mDestPath = destPath;
        mCombineListener = listener;
        mReadBuf = ByteBuffer.allocate(MAX_BUFF_SIZE);
    }

    /**
     * 合并视频
     * @return
     */
    @SuppressLint("WrongConstant")
    public void combineVideo() {
        boolean hasAudioFormat = false;
        boolean hasVideoFormat = false;
        Iterator videoIterator = mVideoList.iterator();

        // 开始合并
        if (mCombineListener != null) {
            mCombineListener.onCombineStart();
        }

        // MediaExtractor拿到多媒体信息,用于MediaMuxer创建文件
        while (videoIterator.hasNext()) {
            String videoPath = (String) videoIterator.next();
            MediaExtractor extractor = new MediaExtractor();
            try {
                extractor.setDataSource(videoPath);
            } catch (Exception ex) {
                ex.printStackTrace();
            }

            int trackIndex;
            if (!hasVideoFormat) {
                trackIndex = selectTrack(extractor, "video/");
                if(trackIndex < 0) {
                    Log.e(TAG, "No video track found in " + videoPath);
                } else {
                    extractor.selectTrack(trackIndex);
                    mVideoFormat = extractor.getTrackFormat(trackIndex);
                    hasVideoFormat = true;
                }
            }

            if (!hasAudioFormat) {
                trackIndex = selectTrack(extractor, "audio/");
                if(trackIndex < 0) {
                    Log.e(TAG, "No audio track found in " + videoPath);
                } else {
                    extractor.selectTrack(trackIndex);
                    mAudioFormat = extractor.getTrackFormat(trackIndex);
                    hasAudioFormat = true;
                }
            }

            extractor.release();

            if (hasVideoFormat && hasAudioFormat) {
                break;
            }
        }

        try {
            mMuxer = new MediaMuxer(mDestPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
        } catch (IOException e) {
            e.printStackTrace();
        }

        if (hasVideoFormat) {
            mOutVideoTrackIndex = mMuxer.addTrack(mVideoFormat);
        }

        if (hasAudioFormat) {
            mOutAudioTrackIndex = mMuxer.addTrack(mAudioFormat);
        }
        mMuxer.start();

        // MediaExtractor遍历读取帧,MediaMuxer写入帧,并记录帧信息
        long ptsOffset = 0L;
        Iterator trackIndex = mVideoList.iterator();
        int currentVideo = 0;
        boolean combineResult = true;
        while (trackIndex.hasNext()) {
            // 监听当前合并第几个视频
            currentVideo++;
            if (mCombineListener != null) {
                mCombineListener.onCombineProcessing(currentVideo, mVideoList.size());
            }

            String videoPath = (String) trackIndex.next();
            boolean hasVideo = true;
            boolean hasAudio = true;

            // 选择视频轨道
            MediaExtractor videoExtractor = new MediaExtractor();
            try {
                videoExtractor.setDataSource(videoPath);
            } catch (Exception e) {
                e.printStackTrace();
            }
            int inVideoTrackIndex = selectTrack(videoExtractor, "video/");
            if(inVideoTrackIndex < 0) {
                hasVideo = false;
            }
            videoExtractor.selectTrack(inVideoTrackIndex);

            // 选择音频轨道
            MediaExtractor audioExtractor = new MediaExtractor();
            try {
                audioExtractor.setDataSource(videoPath);
            } catch (Exception e) {
                e.printStackTrace();
            }
            int inAudioTrackIndex = selectTrack(audioExtractor, "audio/");
            if (inAudioTrackIndex < 0) {
                hasAudio = false;
            }
            audioExtractor.selectTrack(inAudioTrackIndex);

            // 如果存在视频轨道和音频轨道都不存在,则合并失败,文件出错
            if (!hasVideo && !hasAudio) {

                combineResult = false;

                videoExtractor.release();
                audioExtractor.release();

                break;
            }

            boolean bMediaDone = false;
            long presentationTimeUs = 0L;
            long audioPts = 0L;
            long videoPts = 0L;

            while (!bMediaDone) {
                // 判断是否存在音视频
                if(!hasVideo && !hasAudio) {
                    break;
                }

                int outTrackIndex;
                MediaExtractor extractor;
                int currentTrackIndex;
                if ((!hasVideo || audioPts - videoPts <= 50000L) && hasAudio) {
                    currentTrackIndex = inAudioTrackIndex;
                    outTrackIndex = mOutAudioTrackIndex;
                    extractor = audioExtractor;
                } else {
                    currentTrackIndex = inVideoTrackIndex;
                    outTrackIndex = mOutVideoTrackIndex;
                    extractor = videoExtractor;
                }

                if (VERBOSE) {
                    Log.d(TAG, "currentTrackIndex: " + currentTrackIndex
                            + ", outTrackIndex: " + outTrackIndex);
                }

                mReadBuf.rewind();
                // 读取数据帧
                int frameSize = extractor.readSampleData(mReadBuf, 0);
                if (frameSize < 0) {
                    if (currentTrackIndex == inVideoTrackIndex) {
                        hasVideo = false;
                    } else if (currentTrackIndex == inAudioTrackIndex) {
                        hasAudio = false;
                    }
                } else {
                    if (extractor.getSampleTrackIndex() != currentTrackIndex) {
                        Log.e(TAG, "got sample from track "
                                + extractor.getSampleTrackIndex()
                                + ", expected " + currentTrackIndex);
                    }

                    // 读取帧的pts
                    presentationTimeUs = extractor.getSampleTime();
                    if (currentTrackIndex == inVideoTrackIndex) {
                        videoPts = presentationTimeUs;
                    } else {
                        audioPts = presentationTimeUs;
                    }

                    // 帧信息
                    BufferInfo info = new BufferInfo();
                    info.offset = 0;
                    info.size = frameSize;
                    info.presentationTimeUs = ptsOffset + presentationTimeUs;

                    if ((extractor.getSampleFlags() & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) {
                        info.flags = MediaCodec.BUFFER_FLAG_KEY_FRAME;
                    }
                    mReadBuf.rewind();
                    if (VERBOSE) {
                        Log.d(TAG, String.format("write sample track %d, size %d, pts %d flag %d",
                                Integer.valueOf(outTrackIndex),
                                Integer.valueOf(info.size),
                                Long.valueOf(info.presentationTimeUs),
                                Integer.valueOf(info.flags))
                        );
                    }
                    // 将读取到的数据写入文件
                    mMuxer.writeSampleData(outTrackIndex, mReadBuf, info);
                    extractor.advance();
                }
            }

            // 当前文件最后一帧的PTS,用作下一个视频的pts
            ptsOffset += videoPts > audioPts ? videoPts : audioPts;
            // 当前文件最后一帧和下一帧的间隔差40ms,默认录制25fps的视频,帧间隔时间就是40ms
            // 但由于使用MediaCodec录制完之后,后面又写入了一个OES的帧,导致前面解析的时候会有时间差
            // 这里设置10ms效果比40ms的要好些。
            ptsOffset += 10000L;

            if (VERBOSE) {
                Log.d(TAG, "finish one file, ptsOffset " + ptsOffset);
            }

            // 释放资源
            videoExtractor.release();
            audioExtractor.release();
        }

        // 释放复用器
        if (mMuxer != null) {
            try {
                mMuxer.stop();
                mMuxer.release();
            } catch (Exception e) {
                Log.e(TAG, "Muxer close error. No data was written");
            } finally {
                mMuxer = null;
            }
        }

        if (VERBOSE) {
            Log.d(TAG, "video combine finished");
        }

        // 合并结束
        if (mCombineListener != null) {
            mCombineListener.onCombineFinished(combineResult);
        }
    }

    /**
     *  选择轨道
     * @param extractor     MediaExtractor
     * @param mimePrefix    音轨还是视轨
     * @return
     */
    private int selectTrack(MediaExtractor extractor, String mimePrefix) {
        // 获取轨道总数
        int numTracks = extractor.getTrackCount();
        // 遍历查找包含mimePrefix的轨道
        for(int i = 0; i < numTracks; ++i) {
            MediaFormat format = extractor.getTrackFormat(i);
            String mime = format.getString("mime");
            if (mime.startsWith(mimePrefix)) {
                return i;
            }
        }

        return -1;
    }
}

4、如何使用
为了方便使用,我们可以创建一个Manager来管理合并视频功能,这样我们就可以在Activity、Fragment里面直接通过调用VideoCombineManager.getInstance().startVideoCombiner()方法,传递需要合并的路径列表、输出路径、合并状态监听接口参数,使用起来非常方便:

public final class VideoCombineManager {

    private static final String TAG = "VideoCombineManage";

    private static VideoCombineManager mInstance;


    public static VideoCombineManager getInstance() {
        if (mInstance == null) {
            mInstance = new VideoCombineManager();
        }
        return mInstance;
    }

    /**
     * 初始化媒体合并器
     * @param videoPath
     * @param destPath
     */
    public void startVideoCombiner(final List<String> videoPath, final String destPath,
                                   final VideoCombiner.VideoCombineListener listener) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                VideoCombiner videoCombiner = new VideoCombiner(videoPath, destPath, listener);
                videoCombiner.combineVideo();
            }
        }).start();
    }
}

5、MediaExtractor + MediaMuxer 合并视频的优缺点
在录制视频合成方面,MediaExtractor + MediaMuxer 比较适合应用内简单的视频合成场景,比如在相机里面录制了多段视频,然后跳转至预览页面直接合成视频这样的场景。如果要做短视频SDK,估计有点力不从心,比如短视频SDK里面,需要添加字幕、添加mkv等功能,MediaExtractor + MediaMuxer组合是做不到的。需要做短视频SDK的童鞋,还是老老实实用FFmpeg这样成熟的框架吧。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,719评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,337评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 142,887评论 0 324
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,488评论 1 266
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,313评论 4 357
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,284评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,672评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,346评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,644评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,700评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,457评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,316评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,706评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,261评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,648评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,859评论 2 335