12、MediaMuxer实战:视频倒放(Reverse Video)——实现视频的倒放效果

视频倒放,听起来挺酷对吧?把一个正常播放的视频,让它从结尾倒着播到开头。抖音上那些「时光倒流」的效果,其实就是这么做的。

但说实话,我第一次接到这个需求时,心里是有点打鼓的。MediaMuxer 能直接倒放吗?不能。那怎么办?

嗯,核心思路其实不复杂:把视频的每一帧提取出来,然后按相反的顺序重新编码、封装。说白了,就是「拆帧 → 反转顺序 → 重新合成」三步走。

核心原理:MediaMuxer 本身不支持倒放,但我们可以通过 MediaExtractor 提取帧数据,手动反转帧顺序,再用 MediaMuxer 重新合成。

12.1 整体流程设计

先画个图,让你对整个流程有个直观印象。我个人习惯在做任何复杂操作前,先把流程图画出来,这样写代码时心里有底。

视频倒放核心流程 ① 提取帧数据 MediaExtractor ② 反转帧顺序 List 反转操作 ③ 重新编码封装 MediaMuxer 详细拆解: 1. 用 MediaExtractor 读取视频文件,逐帧提取到 List<MediaCodec.BufferInfo> 2. 提取完成后,用 Collections.reverse() 反转帧顺序 3. 创建 MediaMuxer,按反转后的顺序逐帧写入 4. 注意:音频也需要同步反转,否则音画不同步 5. 输出倒放后的 MP4 文件 关键点:帧顺序反转 + 时间戳重新计算

你看,整个流程其实就三大块。但每一块都有坑,我一个个给你说。

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 数据,不能像视频那样按帧反转。正确的做法是:

  1. 用 MediaExtractor 提取音频轨道
  2. 用 MediaCodec 解码成 PCM 原始数据
  3. 把 PCM 数据按「块」反转(注意采样率、声道数)
  4. 重新编码成 AAC
  5. 用 MediaMuxer 把反转后的视频和音频合到一起

嗯,这个流程比较复杂,我打算在下一章专门讲「音频倒放」的细节。这里先给你提个醒:音频倒放不是简单的帧反转,需要做 PCM 级别的处理

12.7 性能优化建议

问题 原因 解决方案
内存溢出 所有帧数据加载到内存 分片处理,每 5 秒一个片段
处理速度慢 逐帧 seek 定位 用内存缓存方案,避免 seek
音画不同步 音频未反转或时间戳错误 音频单独处理,最后合并
输出文件损坏 MediaMuxer 未正确 stop 用 try-finally 确保 release

我曾经踩过的坑:有一次处理一个 5 分钟的视频,直接内存溢出。后来改成「分片读取 + 临时文件」的方式,每 3 秒一个片段,反转后再合并。虽然代码复杂了点,但内存占用从 1.2GB 降到了 150MB。

12.8 小结

视频倒放的核心就三点:提取帧 → 反转顺序 → 重新封装。听起来简单,但实际做起来,时间戳计算、内存管理、音频同步,每个环节都有坑。

我个人建议:先从「纯视频倒放」开始做,跑通了再加音频。别一上来就想搞大而全,容易把自己绕进去。

嗯,代码都给你了,去试试吧。有问题随时翻回来看。


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