5、MediaMuxer与MediaExtractor的协同工作:解封装后的数据如何重新封装

上一章我们把MediaMuxer的写入流程走了一遍。但有个问题一直悬着——喂给Muxer的原始数据从哪来?

你当然可以自己构造ByteBuffer,但真实场景下,我们更多是从一个已有的视频文件里把数据“拆”出来,再重新“装”进去。这就是MediaExtractor和MediaMuxer的协同工作。

说白了,一个负责拆,一个负责装。拆封装、重新封装,中间你可以做各种手脚——比如去掉某个音轨、替换背景音乐、调整视频分辨率。我最早做短视频剪辑SDK时,这个组合就是整个Pipeline的基石。

核心概念:MediaExtractor负责从源文件中读取并分离音视频数据,MediaMuxer负责将数据重新混合写入新文件。两者通过MediaCodec或直接传递ByteBuffer来交换数据。

5.1 协同工作的基本流程

先看一张整体流程图,帮你建立直观印象:

源文件.mp4 MediaExtractor 解封装 音频Track数据 ByteBuffer 视频Track数据 ByteBuffer MediaMuxer 重新封装 输出.mp4 图:MediaExtractor → 分离音视频数据 → MediaMuxer 重新封装

流程其实很清晰:

  1. 初始化Extractor:打开源文件,选择要读取的Track
  2. 初始化Muxer:根据Extractor的Track信息,添加对应Track
  3. 循环读取+写入:从Extractor读取Sample,直接写入Muxer
  4. 收尾:释放资源

嗯,这里要注意——Muxer的addTrack必须在start()之前调用,而addTrack需要的MediaFormat信息,正好可以从Extractor的getTrackFormat()拿到。这就是它们配合的第一个关键点。

5.2 核心代码实现

直接上代码。我习惯把复用逻辑封装成一个方法,这样在多个场景下都能调用:

private void remux(String srcPath, String dstPath) {
    MediaExtractor extractor = null;
    MediaMuxer muxer = null;
    
    try {
        // 1. 初始化Extractor
        extractor = new MediaExtractor();
        extractor.setDataSource(srcPath);
        
        // 2. 遍历Track,收集信息
        int audioTrackIndex = -1;
        int videoTrackIndex = -1;
        MediaFormat audioFormat = null;
        MediaFormat videoFormat = null;
        
        int trackCount = extractor.getTrackCount();
        for (int i = 0; i < trackCount; i++) {
            MediaFormat format = extractor.getTrackFormat(i);
            String mime = format.getString(MediaFormat.KEY_MIME);
            if (mime.startsWith("audio/")) {
                audioTrackIndex = i;
                audioFormat = format;
            } else if (mime.startsWith("video/")) {
                videoTrackIndex = i;
                videoFormat = format;
            }
        }
        
        // 3. 初始化Muxer
        muxer = new MediaMuxer(dstPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
        
        int muxerAudioTrack = -1;
        int muxerVideoTrack = -1;
        
        if (audioFormat != null) {
            muxerAudioTrack = muxer.addTrack(audioFormat);
        }
        if (videoFormat != null) {
            muxerVideoTrack = muxer.addTrack(videoFormat);
        }
        
        // 4. 启动Muxer
        muxer.start();
        
        // 5. 读取并写入音频数据
        if (audioTrackIndex != -1) {
            extractor.selectTrack(audioTrackIndex);
            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();
                
                muxer.writeSampleData(muxerAudioTrack, buffer, info);
                extractor.advance();
            }
            extractor.unselectTrack(audioTrackIndex);
        }
        
        // 6. 读取并写入视频数据(同上逻辑)
        // ... 省略,与音频类似
        
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        // 7. 释放资源
        if (muxer != null) {
            muxer.stop();
            muxer.release();
        }
        if (extractor != null) {
            extractor.release();
        }
    }
}

个人经验:缓冲区大小我一般分配1MB。太小会导致频繁的readSampleData调用,太大又浪费内存。1MB在大多数1080P视频上都能覆盖一个关键帧的大小,是个比较稳妥的选择。

5.3 关键细节与避坑指南

代码看起来简单,但实际跑起来坑不少。我一个个说。

5.3.1 Track的选择与取消

Extractor同一时间只能读取一个Track。你要先selectTrack,读完再unselectTrack。我曾经犯过一个错误——读完音频后忘了unselect,直接select视频,结果视频数据读出来全是乱的。

正确的做法是:

  • 读完一个Track后,立即unselectTrack
  • 再select下一个Track
  • 或者更稳妥:每次只处理一个Track,处理完再处理下一个

5.3.2 时间戳的连续性

Muxer要求写入的Sample时间戳必须是单调递增的。但Extractor读出来的数据,尤其是B帧存在时,时间戳可能不是严格递增的。怎么办?

我个人的做法是:在写入前做一个简单的排序,或者直接用MediaCodec解码后再编码,这样时间戳就规整了。如果只是纯复用(remux),不涉及编解码,那Extractor返回的时间戳本身就是递增的,可以放心用。

5.3.3 CSD信息的处理

视频Track的MediaFormat里,会包含csd-0、csd-1等关键信息(SPS/PPS)。这些信息在Extractor读取第一个Sample时就已经带上了。你不需要手动处理,Muxer在addTrack时会自动解析。

但有一点要注意——如果你在读取过程中跳过了某些Sample,可能导致CSD信息丢失。所以第一个Sample一定要完整读取。

我曾经踩过的坑:有一次做视频裁剪功能,我想跳过前几帧来达到“裁剪开头”的效果。结果跳过了包含SPS/PPS的第一个Sample,导致输出文件无法播放。后来我改成先读取第一个Sample写入Muxer,再根据时间戳决定是否写入后续Sample,问题就解决了。

5.4 实际应用场景

这个协同模式能做什么?我列几个常见场景:

场景 实现方式 注意事项
视频格式转换(如MKV转MP4) Extractor读取源文件,Muxer写入新格式 确保Muxer支持目标格式的OutputFormat
去除某个音轨 只select需要的Track,忽略不需要的 注意主音轨的选择,不要误删
替换背景音乐 Extractor只读视频Track,音频Track用其他来源 时间戳要对齐,否则音画不同步
视频拼接 多个Extractor依次读取,写入同一个Muxer 时间戳需要做偏移处理

你想想看,这些场景本质上都是“拆了再装”,只不过在中间环节做了不同的处理。理解了Extractor和Muxer的协同原理,这些功能实现起来就是排列组合的问题。

5.5 性能优化建议

最后聊点性能相关的。纯remux操作不涉及编解码,CPU消耗其实不高。但如果你处理的是4K视频,或者需要同时处理多个文件,还是要注意:

  • 复用ByteBuffer:不要每次循环都new一个,提前分配好,反复使用
  • 控制缓冲区大小:根据视频分辨率动态调整,别用固定值
  • 异步处理:如果UI线程需要显示进度,把remux操作放到子线程
  • 及时释放:Extractor和Muxer都持有文件句柄,不释放会导致文件占用

我记得有一次线上反馈说App处理完视频后,源文件删不掉。查了半天,发现是Extractor的release()没调用。从那以后,我所有资源释放都写在finally块里,养成习惯了。


好了,这一章就到这里。Extractor和Muxer的配合是音视频编辑的基础操作,掌握了它,后面讲裁剪、拼接、变速这些功能时,你就能更快上手。

核心要点回顾

  • Extractor负责解封装,Muxer负责重新封装
  • 通过getTrackFormat()传递MediaFormat信息
  • 注意Track的选择/取消、时间戳连续性、CSD信息
  • 纯remux性能开销小,但资源释放一定要做好