12、MediaExtractor与MediaMuxer结合:重新封装为新的MP4文件

好,咱们今天聊点实战的。

前面几章我们把MediaExtractor和MediaMuxer拆开讲了一遍。一个负责读,一个负责写。那问题来了——能不能让它们俩配合,把视频重新封装一下?

说白了,就是从一个MP4里把音视频轨道读出来,再写进一个新的MP4文件。中间不做编解码,只做“搬运”。这种操作在业内叫重新封装(Remux)

我在项目里遇到过好几次这种需求。比如用户录了个视频,但原始文件里音频是AAC、视频是H.264,我想把它转成另一种封装格式,或者单纯想去掉某个轨道。这时候MediaExtractor + MediaMuxer就是最佳拍档。

核心要点:重新封装不涉及编解码,性能开销极低。你不需要关心视频具体是什么编码格式,只要轨道类型匹配,MediaMuxer就能直接写入。

12.1 整体流程

先看一张流程图,帮你快速建立整体认知:

MediaExtractor + MediaMuxer 重新封装流程 输入MP4文件 MediaExtractor 选择音视频轨道 MediaMuxer 输出MP4文件 循环读取 关键点: • 不涉及编解码 • 只搬运数据 • 性能开销极低

流程其实很简单:

  1. 用MediaExtractor打开源文件
  2. 遍历所有轨道,找到你想要的音视频轨道
  3. 把轨道信息传给MediaMuxer,让它添加对应的轨道
  4. 循环从Extractor读取数据,写入Muxer
  5. 释放资源

嗯,这里要注意:MediaMuxer在添加完所有轨道后,必须调用start()方法,然后才能开始写数据。顺序搞反了会直接崩溃。

12.2 核心代码实现

直接上代码。我个人习惯把重新封装写成一个工具方法,方便复用:

public class Remuxer {
    private static final String TAG = "Remuxer";
    private static final int BUFFER_SIZE = 1024 * 1024; // 1MB缓冲区

    public static boolean remux(String srcPath, String dstPath) {
        MediaExtractor extractor = null;
        MediaMuxer muxer = null;
        boolean success = false;

        try {
            // 1. 初始化Extractor
            extractor = new MediaExtractor();
            extractor.setDataSource(srcPath);

            // 2. 初始化Muxer
            muxer = new MediaMuxer(dstPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);

            // 3. 遍历轨道,添加到Muxer
            int videoTrackIndex = -1;
            int audioTrackIndex = -1;

            for (int i = 0; i < extractor.getTrackCount(); i++) {
                MediaFormat format = extractor.getTrackFormat(i);
                String mime = format.getString(MediaFormat.KEY_MIME);

                if (mime.startsWith("video/") && videoTrackIndex == -1) {
                    videoTrackIndex = muxer.addTrack(format);
                    extractor.selectTrack(i);
                } else if (mime.startsWith("audio/") && audioTrackIndex == -1) {
                    audioTrackIndex = muxer.addTrack(format);
                    extractor.selectTrack(i);
                }
            }

            // 4. 开始封装
            muxer.start();

            // 5. 读取并写入数据
            ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE);
            MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();

            // 先处理视频轨道
            if (videoTrackIndex != -1) {
                extractor.unselectTrack(videoTrackIndex);
                extractor.selectTrack(videoTrackIndex);
                while (true) {
                    bufferInfo.set(0, 0, 0, 0);
                    int sampleSize = extractor.readSampleData(buffer, 0);
                    if (sampleSize < 0) break;

                    bufferInfo.size = sampleSize;
                    bufferInfo.presentationTimeUs = extractor.getSampleTime();
                    bufferInfo.flags = extractor.getSampleFlags();

                    muxer.writeSampleData(videoTrackIndex, buffer, bufferInfo);
                    extractor.advance();
                }
            }

            // 再处理音频轨道
            if (audioTrackIndex != -1) {
                extractor.unselectTrack(audioTrackIndex);
                extractor.selectTrack(audioTrackIndex);
                while (true) {
                    bufferInfo.set(0, 0, 0, 0);
                    int sampleSize = extractor.readSampleData(buffer, 0);
                    if (sampleSize < 0) break;

                    bufferInfo.size = sampleSize;
                    bufferInfo.presentationTimeUs = extractor.getSampleTime();
                    bufferInfo.flags = extractor.getSampleFlags();

                    muxer.writeSampleData(audioTrackIndex, buffer, bufferInfo);
                    extractor.advance();
                }
            }

            success = true;
        } catch (IOException e) {
            Log.e(TAG, "remux failed", e);
        } finally {
            // 6. 释放资源
            if (muxer != null) {
                try {
                    muxer.stop();
                    muxer.release();
                } catch (Exception e) {
                    Log.e(TAG, "release muxer error", e);
                }
            }
            if (extractor != null) {
                extractor.release();
            }
        }
        return success;
    }
}

我的经验:缓冲区大小建议设为1MB左右。太小了会导致频繁的I/O操作,太大了又浪费内存。1MB是个比较平衡的值。另外,记得在finally块里释放资源,否则文件句柄泄漏了很难排查。

12.3 关键细节

代码看起来简单,但有几个坑我得提醒你:

  • 轨道选择顺序:MediaExtractor同一时间只能选择一个轨道。所以我在代码里先处理视频,再处理音频。每次切换轨道前先unselectTrack,再selectTrack
  • 时间戳连续性:重新封装时,时间戳必须保持原样。不要自作聪明去修改presentationTimeUs,否则播放器会乱跳。
  • 关键帧问题:如果你只提取部分片段,记得从关键帧开始。否则新文件开头几秒可能花屏。

我曾经踩过的坑:有一次我忘了调用muxer.start(),结果写数据时直接抛了IllegalStateException。查了半天才发现是顺序问题。还有一次,我在循环里重复创建了ByteBuffer,导致内存暴涨。记住:一个缓冲区复用即可

12.4 进阶:只提取视频或音频

有时候我们只需要视频轨道,或者只需要音频轨道。比如用户想从视频里提取背景音乐。那代码就更简单了:

// 只提取音频
public static boolean extractAudio(String srcPath, String dstPath) {
    MediaExtractor extractor = new MediaExtractor();
    extractor.setDataSource(srcPath);

    int audioTrackIndex = -1;
    for (int i = 0; i < extractor.getTrackCount(); i++) {
        MediaFormat format = extractor.getTrackFormat(i);
        if (format.getString(MediaFormat.KEY_MIME).startsWith("audio/")) {
            audioTrackIndex = i;
            break;
        }
    }

    if (audioTrackIndex == -1) return false;

    extractor.selectTrack(audioTrackIndex);
    MediaFormat audioFormat = extractor.getTrackFormat(audioTrackIndex);

    MediaMuxer muxer = new MediaMuxer(dstPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
    int muxerTrackIndex = muxer.addTrack(audioFormat);
    muxer.start();

    // ... 读取写入逻辑同上
}

你想想看,这个场景是不是很常见?抖音上那些“提取视频中的音乐”功能,底层就是这么干的。

12.5 性能对比

重新封装和重新编码的性能差距有多大?我拿一个1080P、30秒的视频做过测试:

操作方式 耗时 CPU占用 文件大小
重新封装(Remux) 约0.5秒 5%以下 与原文件几乎一致
重新编码(Transcode) 约15秒 80%以上 可能变大或变小

看到了吧?重新封装几乎是瞬间完成。所以如果你的需求只是改变封装格式、提取轨道、或者拼接视频片段,优先考虑Remux

12.6 总结

MediaExtractor和MediaMuxer的组合,是Android音视频开发里最实用的“搬运工”。

我个人建议你把今天这个remux()方法保存下来,以后做视频编辑、格式转换、轨道提取时直接拿来用。嗯,代码量不大,但能解决很多实际问题。

最后提醒一句:别忘了在AndroidManifest里加上文件读写权限。我刚开始学的时候,调试了半天发现是权限没开,尴尬得很。

核心收获:

  • MediaExtractor负责读取,MediaMuxer负责写入
  • 重新封装不涉及编解码,性能极高
  • 注意轨道选择顺序和资源释放
  • 缓冲区复用,避免频繁创建对象

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