MediaMuxer实战:视频拼接——把多个片段合成一个完整视频

视频拼接,说白了就是把几段视频首尾相连,合成一个完整的文件。你刷抖音看到的那些卡点视频、Vlog合集,背后基本都是这个操作。我在项目中做过不少拼接功能,踩过的坑比走过的路还多。今天咱们就把它彻底搞明白。

拼接的核心思路

MediaMuxer本身不提供拼接API,但我们可以换个思路:

  • 把每个视频片段分别解封装,拿到音视频轨道数据
  • 按顺序把数据写入同一个MediaMuxer
  • 注意时间戳要连续,不能断层

嗯,听起来简单,但实际做起来有几个关键点要处理好。

核心原则:拼接的本质是「时间戳重映射」。每个片段的起始时间必须接上上一个片段的结束时间。

准备工作:你需要什么

  • Android 4.3+(API 18+),MediaMuxer最低要求
  • 几个MP4文件,最好编码格式一致(H.264 + AAC)
  • MediaExtractor用来读取源文件
  • MediaMuxer用来写入目标文件

我的习惯:拼接前先检查所有片段的编码参数。如果一个是H.264,另一个是H.265,MediaMuxer会直接罢工。我曾经因为这个排查了半天,最后发现是素材格式不统一。

代码实现:一步一步来

先看一个完整的拼接函数。我会把关键步骤拆开讲。

private void concatenateVideos(String[] inputPaths, String outputPath) {
    MediaMuxer muxer = null;
    int videoTrackIndex = -1;
    int audioTrackIndex = -1;
    long baseTimeUs = 0;  // 基础时间戳偏移

    try {
        muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);

        for (int i = 0; i < inputPaths.length; i++) {
            MediaExtractor extractor = new MediaExtractor();
            extractor.setDataSource(inputPaths[i]);

            // 第一次循环:添加轨道
            if (i == 0) {
                for (int j = 0; j < extractor.getTrackCount(); j++) {
                    MediaFormat format = extractor.getTrackFormat(j);
                    String mime = format.getString(MediaFormat.KEY_MIME);
                    if (mime.startsWith("video/")) {
                        videoTrackIndex = muxer.addTrack(format);
                    } else if (mime.startsWith("audio/")) {
                        audioTrackIndex = muxer.addTrack(format);
                    }
                }
                muxer.start();
            }

            // 选择轨道并读取数据
            for (int j = 0; j < extractor.getTrackCount(); j++) {
                String mime = extractor.getTrackFormat(j).getString(MediaFormat.KEY_MIME);
                if (mime.startsWith("video/")) {
                    extractor.selectTrack(j);
                } else if (mime.startsWith("audio/")) {
                    extractor.selectTrack(j);
                }
            }

            // 读取并写入数据
            MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
            ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024); // 1MB缓冲区

            while (true) {
                int sampleSize = extractor.readSampleData(buffer, 0);
                if (sampleSize < 0) break;

                info.offset = 0;
                info.size = sampleSize;
                info.flags = extractor.getSampleFlags();
                info.presentationTimeUs = extractor.getSampleTime() + baseTimeUs;

                int trackIndex = -1;
                String currentMime = extractor.getTrackFormat(extractor.getSampleTrackIndex())
                        .getString(MediaFormat.KEY_MIME);
                if (currentMime.startsWith("video/")) {
                    trackIndex = videoTrackIndex;
                } else if (currentMime.startsWith("audio/")) {
                    trackIndex = audioTrackIndex;
                }

                if (trackIndex >= 0) {
                    muxer.writeSampleData(trackIndex, buffer, info);
                }

                extractor.advance();
            }

            // 更新基础时间戳
            long durationUs = getDuration(inputPaths[i]);
            baseTimeUs += durationUs;

            extractor.release();
        }

        muxer.stop();
        muxer.release();

    } catch (Exception e) {
        e.printStackTrace();
    }
}

关键点解析

1. 时间戳偏移

这是最容易出错的地方。每个片段的时间戳都是从0开始的,如果不做偏移,第二个片段的数据会覆盖第一个片段的时间点。我用的baseTimeUs就是干这个的——每处理完一个片段,就把它的时长累加上去。

我曾经踩过的坑:直接用System.nanoTime()当时间戳。结果拼接出来的视频播放时直接跳帧,因为时间戳不是从0开始的连续值。记住,时间戳必须是单调递增的,而且要从0开始。

2. 轨道添加时机

MediaMuxer有个限制:addTrack()必须在start()之前调用,而且一旦start了就不能再加轨道。所以第一个片段负责添加所有轨道,后面的片段只读数据不添加。

3. 缓冲区大小

我习惯用1MB的缓冲区。太小了频繁IO,太大了内存吃不消。如果是4K视频,建议调到2MB以上。

获取视频时长的小工具

private long getDuration(String path) {
    MediaMetadataRetriever retriever = new MediaMetadataRetriever();
    retriever.setDataSource(path);
    String duration = retriever.extractMetadata(
        MediaMetadataRetriever.METADATA_KEY_DURATION);
    retriever.release();
    return Long.parseLong(duration) * 1000; // 转成微秒
}

拼接流程图

视频拼接流程 多个MP4片段 MediaExtractor 逐片段解封装 时间戳重映射:每个片段 + baseTimeUs baseTimeUs = 前序片段时长累加 MediaMuxer 写入同一文件 注意事项 • 编码格式必须一致 • 轨道只添加一次 • 时间戳连续递增 • 缓冲区大小适中 • 记得释放资源 ⚠ 不要用系统时间

常见问题与避坑

问题 原因 解决方案
拼接后视频卡顿 时间戳不连续或跳变 检查baseTimeUs累加是否正确
只有画面没有声音 音频轨道未正确添加 确认音频轨道索引不为-1
拼接后文件损坏 MediaMuxer未正确stop 确保在finally中调用release
编码格式不兼容 片段使用了不同编码器 统一转码后再拼接

我的经验:如果遇到「MediaMuxer failed」这种笼统的错误,先检查输出路径是否有写入权限。Android 10以上还要处理Scoped Storage。我有个项目上线后突然崩溃,就是因为没适配分区存储。

性能优化建议

  • 使用异步处理:拼接操作很耗时,一定要放在子线程。我习惯用AsyncTask或者Coroutine。
  • 控制内存:不要一次性把所有片段加载到内存。逐片段处理,用完就释放。
  • 预检查:拼接前先检查所有片段的时长、编码格式、分辨率。不匹配的提前报错,别等到写了一半才崩溃。

总结

视频拼接用MediaMuxer实现,核心就三点:时间戳偏移、轨道只加一次、逐片段读写。你想想看,其实跟用胶带粘纸片是一个道理——对齐了才能粘得牢。

我在实际项目中还遇到过更复杂的情况,比如不同帧率的片段拼接、带旋转信息的视频处理。这些咱们后面章节再聊。先把基础的跑通,后面就好办了。

公众号:蓝海资料掘金营,微信deep3321