8. MediaMuxer实战:视频裁剪(Trimming)——截取视频的指定时间段

视频裁剪,说白了就是从一个完整的视频里,只取出你感兴趣的那一段。比如你录了一段10分钟的会议,但只需要第3分钟到第5分钟的关键发言。这就是Trimming要做的事。

我个人习惯把视频裁剪看作「拆了再拼」的过程。你想想看,一个视频文件就像一列火车,每一节车厢就是一帧画面。我们要做的,就是把第X节到第Y节车厢拆下来,再重新组装成一列新的火车。MediaMuxer在这里扮演的角色,就是那个负责重新组装的人。

8.1 裁剪的核心原理

视频裁剪不是简单的「切一刀」,它背后涉及两个关键操作:

  • 解封装(Demuxing):用MediaExtractor把原始视频拆成音频流和视频流
  • 重新封装(Muxing):用MediaMuxer把选中的那一段数据重新打包成新文件

我在项目中遇到过不少新手,以为裁剪就是改一下视频文件的头信息。其实不是的。裁剪的本质是「数据选择」——我们从原始文件中,只读取指定时间范围内的数据包,然后原封不动地写入新文件。

关键点:裁剪过程中,我们不对视频帧做任何解码和重新编码操作。这意味着画质不会损失,速度也最快。这就是所谓的「无损裁剪」(Lossless Trimming)。

视频裁剪核心流程 原始视频文件 MP4 / MOV 等 MediaExtractor 解封装:分离音视频轨道 seekTo(startTimeUs) 数据选择 读取 startTime ~ endTime 范围内的Sample数据 MediaMuxer 重新封装:写入新文件 writeSampleData() 裁剪后视频文件 仅包含指定时间段 整个过程不涉及编解码,属于无损操作

8.2 准备工作:设置裁剪参数

动手写代码之前,先把参数理清楚。你需要知道三件事:

  • 输入文件的路径
  • 裁剪的起始时间(单位:微秒)
  • 裁剪的结束时间(单位:微秒)

嗯,这里要注意。Android里时间单位经常让人头疼。MediaExtractor和MediaMuxer用的都是微秒(µs),不是毫秒。1秒 = 1,000,000微秒。我刚开始做的时候,就因为这个单位搞错过,裁剪出来的视频时长完全不对。

小技巧:写个工具方法,把「秒」转成「微秒」,避免每次手动乘1000000。

private long secondsToMicroseconds(float seconds) {
    return (long)(seconds * 1_000_000);
}

8.3 核心代码实现

下面这段代码,是我在实际项目中打磨过的版本。它处理了音频和视频两个轨道,并且做了边界检查。

public boolean trimVideo(String inputPath, String outputPath, 
                         long startUs, long endUs) {
    MediaExtractor extractor = null;
    MediaMuxer muxer = null;
    
    try {
        // 1. 初始化Extractor
        extractor = new MediaExtractor();
        extractor.setDataSource(inputPath);
        
        // 2. 创建Muxer
        muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
        
        int videoTrackIndex = -1;
        int audioTrackIndex = -1;
        
        // 3. 遍历所有轨道,分别添加
        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("video/") && videoTrackIndex == -1) {
                videoTrackIndex = muxer.addTrack(format);
            } else if (mime.startsWith("audio/") && audioTrackIndex == -1) {
                audioTrackIndex = muxer.addTrack(format);
            }
        }
        
        // 4. 启动Muxer
        muxer.start();
        
        // 5. 处理视频轨道
        if (videoTrackIndex != -1) {
            extractor.selectTrack(videoTrackIndex);
            extractor.seekTo(startUs, MediaExtractor.SeekMode.CLOSEST_SYNC);
            
            MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
            ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024); // 1MB缓冲区
            
            while (true) {
                info.offset = 0;
                info.size = extractor.readSampleData(buffer, 0);
                
                if (info.size < 0) break;
                
                info.presentationTimeUs = extractor.getSampleTime();
                
                // 超过结束时间就停止
                if (info.presentationTimeUs > endUs) break;
                
                info.flags = extractor.getSampleFlags();
                muxer.writeSampleData(videoTrackIndex, buffer, info);
                extractor.advance();
            }
            
            extractor.unselectTrack(videoTrackIndex);
        }
        
        // 6. 处理音频轨道(逻辑类似,略)
        // ... 
        
        muxer.stop();
        return true;
        
    } catch (IOException e) {
        e.printStackTrace();
        return false;
    } finally {
        if (extractor != null) extractor.release();
        if (muxer != null) muxer.release();
    }
}

8.4 关键细节:SeekMode的选择

代码里有个地方值得单独拿出来说——seekTo()的第二个参数。Android提供了三种SeekMode:

SeekMode 行为 适用场景
CLOSEST_SYNC 跳到最近的同步帧(关键帧) 裁剪时最常用,保证画面完整
CLOSEST 跳到离目标时间最近的帧 精确预览,但可能画面花屏
PREVIOUS_SYNC 跳到前一个同步帧 需要从关键帧开始解码时

我个人习惯在裁剪时用CLOSEST_SYNC。为什么?因为视频压缩的原理决定了,非关键帧必须依赖关键帧才能解码。如果你从非关键帧开始读取,写出来的视频开头几帧可能是花的。

注意:使用CLOSEST_SYNC意味着裁剪的起始时间可能比你指定的稍微早一点点(最多一个GOP的长度)。如果你需要精确到帧级别的裁剪,那就得走解码-重新编码的路线了。但那是另一回事,我们后面会讲到。

8.5 避坑指南

做视频裁剪,有几个坑我踩过,分享给你:

  • 时间戳回退问题:有些视频文件的时间戳不是严格递增的。如果你发现裁剪出来的视频播放卡顿,可以检查一下时间戳顺序。我遇到过一些从抖音下载的视频就有这个问题。
  • 音频轨道处理:音频的裁剪逻辑和视频一样,但要注意音频的结束时间。有时候视频结束了,音频还有一小段尾巴。最好统一用视频的结束时间作为基准。
  • 文件格式兼容性:MediaMuxer输出的MP4文件,在某些播放器上可能无法拖动进度条。这是因为缺少moov box的元数据。解决办法是输出完成后,用MediaMetadataRetriever重新写入元数据。

8.6 性能优化建议

如果你要裁剪的视频很大(比如4K、60fps),有几点可以优化:

  • 缓冲区大小:我一般用2MB的缓冲区,而不是1MB。大缓冲区可以减少I/O次数。
  • 异步处理:把裁剪操作放到后台线程,避免阻塞UI。用AsyncTask或者Coroutine都行。
  • 内存管理:裁剪完成后及时释放Extractor和Muxer。我见过有人忘记release,导致文件被占用无法删除。

好了,视频裁剪的核心内容就这些。说白了就是「定位-读取-写入」三步走。代码量不大,但细节不少。你动手写一遍,遇到问题再回来对照,会理解得更深。


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