12、MediaMuxer实战:视频倒放(Reverse Video)——实现视频的倒放效果
视频倒放,听起来挺酷对吧?把一个正常播放的视频,让它从结尾倒着播到开头。抖音上那些「时光倒流」的效果,其实就是这么做的。
但说实话,我第一次接到这个需求时,心里是有点打鼓的。MediaMuxer 能直接倒放吗?不能。那怎么办?
嗯,核心思路其实不复杂:把视频的每一帧提取出来,然后按相反的顺序重新编码、封装。说白了,就是「拆帧 → 反转顺序 → 重新合成」三步走。
核心原理:MediaMuxer 本身不支持倒放,但我们可以通过 MediaExtractor 提取帧数据,手动反转帧顺序,再用 MediaMuxer 重新合成。
12.1 整体流程设计
先画个图,让你对整个流程有个直观印象。我个人习惯在做任何复杂操作前,先把流程图画出来,这样写代码时心里有底。
你看,整个流程其实就三大块。但每一块都有坑,我一个个给你说。
12.2 第一步:提取视频帧
提取帧,用的是 MediaExtractor。这个老朋友我们在前面几章已经打过多次交道了。但这里有个关键点:我们要提取的是「所有帧」,而不是只提取关键帧。
为什么?因为倒放需要每一帧都反转,如果只提取关键帧,那画面会卡成 PPT。
// 提取所有帧到 List
private List<MediaCodec.BufferInfo> extractAllFrames(String videoPath) {
List<MediaCodec.BufferInfo> frameList = new ArrayList<>();
MediaExtractor extractor = new MediaExtractor();
try {
extractor.setDataSource(videoPath);
int videoTrackIndex = selectVideoTrack(extractor);
extractor.selectTrack(videoTrackIndex);
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024); // 1MB 缓冲区
while (true) {
int sampleSize = extractor.readSampleData(buffer, 0);
if (sampleSize < 0) break;
bufferInfo.set(0, sampleSize, extractor.getSampleTime(), extractor.getSampleFlags());
frameList.add(bufferInfo);
extractor.advance();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
extractor.release();
}
return frameList;
}
private int selectVideoTrack(MediaExtractor extractor) {
for (int i = 0; i < extractor.getTrackCount(); i++) {
MediaFormat format = extractor.getTrackFormat(i);
String mime = format.getString(MediaFormat.KEY_MIME);
if (mime != null && mime.startsWith("video/")) {
return i;
}
}
return -1;
}
我的经验:缓冲区大小别设太小。我曾经设了个 256KB,结果遇到 4K 视频直接崩了。建议至少 1MB,如果是处理高清视频,2MB 更稳妥。
12.3 第二步:反转帧顺序
这一步其实最简单。List 反转,一行代码搞定:
Collections.reverse(frameList);
但这里有个隐藏问题:时间戳。反转后,帧的顺序变了,但时间戳还是原来的。如果不重新计算时间戳,MediaMuxer 写出来的视频会乱套。
举个例子:原视频第 1 帧的时间戳是 0ms,第 30 帧是 1000ms。反转后,第 1 帧(原第 30 帧)的时间戳还是 1000ms,但它在输出文件里应该排在最前面,时间戳应该是 0ms。
所以,反转后必须重新计算时间戳:
private void recalculateTimestamps(List<MediaCodec.BufferInfo> frameList) {
long currentTime = 0;
long frameDuration = 0;
// 先算出一帧的时长(假设帧率固定)
if (frameList.size() > 1) {
frameDuration = frameList.get(1).presentationTimeUs - frameList.get(0).presentationTimeUs;
}
// 从后往前重新赋值
for (int i = 0; i < frameList.size(); i++) {
frameList.get(i).presentationTimeUs = currentTime;
currentTime += frameDuration;
}
}
注意:如果视频是可变帧率(VFR),上面的方法就不准了。我建议用更稳妥的方式:反转后,按顺序从 0 开始累加每帧的实际时长。不过大多数手机录的视频都是固定帧率,所以上面的代码够用。
12.4 第三步:用 MediaMuxer 重新封装
帧顺序反转好了,时间戳也重新算好了,接下来就是用 MediaMuxer 写出去。
private void remuxFrames(String outputPath,
List<MediaCodec.BufferInfo> frameList,
MediaFormat videoFormat) {
MediaMuxer muxer = null;
MediaExtractor extractor = new MediaExtractor();
try {
muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
int videoTrackIndex = muxer.addTrack(videoFormat);
muxer.start();
// 重新打开原文件,按反转后的顺序读取帧数据
extractor.setDataSource(inputPath);
int trackIndex = selectVideoTrack(extractor);
extractor.selectTrack(trackIndex);
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
for (int i = 0; i < frameList.size(); i++) {
MediaCodec.BufferInfo info = frameList.get(i);
// 定位到对应帧
extractor.seekTo(info.presentationTimeUs, MediaExtractor.SEEK_TO_CLOSEST_SYNC);
extractor.readSampleData(buffer, 0);
// 写入
muxer.writeSampleData(videoTrackIndex, buffer, info);
extractor.advance();
}
muxer.stop();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (muxer != null) muxer.release();
extractor.release();
}
}
关键点:这里有个容易踩的坑——seekTo 定位不一定精确。MediaExtractor 的 seek 是基于关键帧的,如果你要定位到非关键帧,它可能会跳到前一个关键帧。所以,我建议用另一种方式:先把所有帧的原始数据存到内存里,反转后直接写,不用 seek。
12.5 完整实现:内存缓存方案
既然 seek 不靠谱,那我们就用「全部读到内存」的方案。虽然吃内存,但胜在精确。
public class VideoReverser {
public void reverseVideo(String inputPath, String outputPath) {
MediaExtractor extractor = null;
MediaMuxer muxer = null;
try {
// 1. 提取所有帧数据到内存
extractor = new MediaExtractor();
extractor.setDataSource(inputPath);
int videoTrackIndex = selectVideoTrack(extractor);
extractor.selectTrack(videoTrackIndex);
MediaFormat videoFormat = extractor.getTrackFormat(videoTrackIndex);
List<FrameData> frames = new ArrayList<>();
ByteBuffer buffer = ByteBuffer.allocate(2 * 1024 * 1024);
while (true) {
int sampleSize = extractor.readSampleData(buffer, 0);
if (sampleSize < 0) break;
byte[] data = new byte[sampleSize];
buffer.position(0);
buffer.get(data, 0, sampleSize);
FrameData frame = new FrameData();
frame.data = data;
frame.bufferInfo = new MediaCodec.BufferInfo();
frame.bufferInfo.set(0, sampleSize,
extractor.getSampleTime(),
extractor.getSampleFlags());
frames.add(frame);
extractor.advance();
buffer.clear();
}
// 2. 反转帧顺序
Collections.reverse(frames);
// 3. 重新计算时间戳
long currentTime = 0;
long frameDuration = frames.size() > 1 ?
frames.get(1).bufferInfo.presentationTimeUs -
frames.get(0).bufferInfo.presentationTimeUs : 0;
for (FrameData frame : frames) {
frame.bufferInfo.presentationTimeUs = currentTime;
currentTime += frameDuration;
}
// 4. 写入输出文件
muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
int outputTrackIndex = muxer.addTrack(videoFormat);
muxer.start();
for (FrameData frame : frames) {
ByteBuffer writeBuffer = ByteBuffer.wrap(frame.data);
muxer.writeSampleData(outputTrackIndex, writeBuffer, frame.bufferInfo);
}
muxer.stop();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (extractor != null) extractor.release();
if (muxer != null) muxer.release();
}
}
private static class FrameData {
byte[] data;
MediaCodec.BufferInfo bufferInfo;
}
}
内存警告:一个 1080p、30 秒的视频,帧数据大概要占 200-300MB 内存。如果是 4K 视频,轻松上 1GB。所以这个方案只适合短视频。长视频怎么办?我后面会讲「分片处理」的方案。
12.6 音频倒放怎么办?
上面只处理了视频轨道。但实际项目中,用户要的是「完整倒放」,包括音频。
音频倒放比视频更麻烦。因为音频是连续的 PCM 数据,不能像视频那样按帧反转。正确的做法是:
- 用 MediaExtractor 提取音频轨道
- 用 MediaCodec 解码成 PCM 原始数据
- 把 PCM 数据按「块」反转(注意采样率、声道数)
- 重新编码成 AAC
- 用 MediaMuxer 把反转后的视频和音频合到一起
嗯,这个流程比较复杂,我打算在下一章专门讲「音频倒放」的细节。这里先给你提个醒:音频倒放不是简单的帧反转,需要做 PCM 级别的处理。
12.7 性能优化建议
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 内存溢出 | 所有帧数据加载到内存 | 分片处理,每 5 秒一个片段 |
| 处理速度慢 | 逐帧 seek 定位 | 用内存缓存方案,避免 seek |
| 音画不同步 | 音频未反转或时间戳错误 | 音频单独处理,最后合并 |
| 输出文件损坏 | MediaMuxer 未正确 stop | 用 try-finally 确保 release |
我曾经踩过的坑:有一次处理一个 5 分钟的视频,直接内存溢出。后来改成「分片读取 + 临时文件」的方式,每 3 秒一个片段,反转后再合并。虽然代码复杂了点,但内存占用从 1.2GB 降到了 150MB。
12.8 小结
视频倒放的核心就三点:提取帧 → 反转顺序 → 重新封装。听起来简单,但实际做起来,时间戳计算、内存管理、音频同步,每个环节都有坑。
我个人建议:先从「纯视频倒放」开始做,跑通了再加音频。别一上来就想搞大而全,容易把自己绕进去。
嗯,代码都给你了,去试试吧。有问题随时翻回来看。
公众号:蓝海资料掘金营,微信deep3321