8. MediaMuxer实战:视频裁剪(Trimming)——截取视频的指定时间段
视频裁剪,说白了就是从一个完整的视频里,只取出你感兴趣的那一段。比如你录了一段10分钟的会议,但只需要第3分钟到第5分钟的关键发言。这就是Trimming要做的事。
我个人习惯把视频裁剪看作「拆了再拼」的过程。你想想看,一个视频文件就像一列火车,每一节车厢就是一帧画面。我们要做的,就是把第X节到第Y节车厢拆下来,再重新组装成一列新的火车。MediaMuxer在这里扮演的角色,就是那个负责重新组装的人。
8.1 裁剪的核心原理
视频裁剪不是简单的「切一刀」,它背后涉及两个关键操作:
- 解封装(Demuxing):用MediaExtractor把原始视频拆成音频流和视频流
- 重新封装(Muxing):用MediaMuxer把选中的那一段数据重新打包成新文件
我在项目中遇到过不少新手,以为裁剪就是改一下视频文件的头信息。其实不是的。裁剪的本质是「数据选择」——我们从原始文件中,只读取指定时间范围内的数据包,然后原封不动地写入新文件。
关键点:裁剪过程中,我们不对视频帧做任何解码和重新编码操作。这意味着画质不会损失,速度也最快。这就是所谓的「无损裁剪」(Lossless Trimming)。
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,导致文件被占用无法删除。
好了,视频裁剪的核心内容就这些。说白了就是「定位-读取-写入」三步走。代码量不大,但细节不少。你动手写一遍,遇到问题再回来对照,会理解得更深。