13、MediaMuxer实战:视频变速(Speed Change)——实现视频的快放或慢放

视频变速,说白了就是让视频播放速度变快或变慢。你刷短视频时看到的「2倍速播放」、「0.5倍慢动作」,底层原理就是今天我们要聊的东西。

我个人习惯把视频变速分成两类:一类是「播放器层面的变速」,只改变播放速率,不生成新文件;另一类是「编辑层面的变速」,要重新编码生成一个变速后的视频文件。咱们今天讲的是后者——用 MediaMuxer 配合 MediaCodec,真正把变速后的视频写出去。

变速的核心原理

视频由一帧一帧的画面组成,帧与帧之间有固定的时间间隔。比如 30fps 的视频,每帧间隔约 33ms。变速的本质,就是改变这个时间间隔的「消费速度」。

举个例子:

  • 2倍速快放:原本 10 秒的视频,只取其中 5 秒的帧,播放速度翻倍。
  • 0.5倍速慢放:原本 10 秒的视频,把每一帧重复一次,变成 20 秒。

嗯,这里要注意——慢放时如果只是简单重复帧,画面会显得卡顿。真正专业的做法是「帧插值」,但那是另一门学问了。咱们今天先搞定基础的帧重复方案。

核心公式:变速后的时长 = 原始时长 / 速度因子

速度因子 > 1 为快放,速度因子 < 1 为慢放。

整体流程设计

用 MediaMuxer 做视频变速,流程其实不复杂。我画了一张图帮你理清思路:

视频变速核心流程 MediaExtractor 读取原始视频 MediaCodec 解码 得到原始帧数据 帧选择/重复 根据速度因子处理 MediaCodec 编码 重新压缩帧数据 MediaMuxer 合成输出文件 时间轴方向 速度因子决定策略 快放:丢弃部分帧 慢放:重复已有帧

你看,整个流程就是「解封装 → 解码 → 帧处理 → 编码 → 封装」。其中「帧处理」这一步,就是变速的核心所在。

代码实现:快放与慢放

咱们直接上代码。我先把变速的核心逻辑封装成一个方法:

/**
 * 视频变速处理
 * @param inputPath  原始视频路径
 * @param outputPath 输出视频路径
 * @param speed      速度因子(2.0=2倍速,0.5=0.5倍速)
 */
public void changeVideoSpeed(String inputPath, String outputPath, float speed) {
    // 1. 初始化 MediaExtractor
    MediaExtractor extractor = new MediaExtractor();
    extractor.setDataSource(inputPath);
    
    // 2. 找到视频轨道
    int videoTrackIndex = -1;
    MediaFormat format = null;
    for (int i = 0; i < extractor.getTrackCount(); i++) {
        MediaFormat trackFormat = extractor.getTrackFormat(i);
        String mime = trackFormat.getString(MediaFormat.KEY_MIME);
        if (mime.startsWith("video/")) {
            videoTrackIndex = i;
            format = trackFormat;
            break;
        }
    }
    extractor.selectTrack(videoTrackIndex);
    
    // 3. 配置解码器
    MediaCodec decoder = MediaCodec.createDecoderByType(
        format.getString(MediaFormat.KEY_MIME));
    decoder.configure(format, null, null, 0);
    decoder.start();
    
    // 4. 配置编码器(输出格式与输入保持一致)
    MediaCodec encoder = MediaCodec.createEncoderByType(
        format.getString(MediaFormat.KEY_MIME));
    MediaFormat outputFormat = MediaFormat.createVideoFormat(
        format.getString(MediaFormat.KEY_MIME),
        format.getInteger(MediaFormat.KEY_WIDTH),
        format.getInteger(MediaFormat.KEY_HEIGHT));
    outputFormat.setInteger(MediaFormat.KEY_BIT_RATE,
        format.getInteger(MediaFormat.KEY_BIT_RATE));
    outputFormat.setInteger(MediaFormat.KEY_FRAME_RATE,
        (int)(format.getInteger(MediaFormat.KEY_FRAME_RATE) * speed));
    outputFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
    encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
    encoder.start();
    
    // 5. 初始化 MediaMuxer
    MediaMuxer muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
    int muxerTrackIndex = muxer.addTrack(outputFormat);
    muxer.start();
    
    // 6. 帧处理循环(核心变速逻辑)
    MediaCodec.BufferInfo decodeInfo = new MediaCodec.BufferInfo();
    MediaCodec.BufferInfo encodeInfo = new MediaCodec.BufferInfo();
    boolean inputDone = false;
    boolean outputDone = false;
    long frameCount = 0;
    
    // 用于慢放的帧缓存队列
    Queue<Integer> frameQueue = new LinkedList<>();
    
    while (!outputDone) {
        // ... 解码、帧处理、编码的完整循环
        // 这里省略了标准的 MediaCodec 循环代码
        // 重点看下面的帧选择逻辑
    }
    
    // 7. 释放资源
    decoder.stop();
    decoder.release();
    encoder.stop();
    encoder.release();
    muxer.stop();
    muxer.release();
    extractor.release();
}

代码框架搭好了,咱们重点看帧处理那块。我单独拎出来讲:

帧选择策略:快放时怎么丢帧

快放 2 倍速,意味着每 2 帧里只取 1 帧。我习惯用一个计数器来控制:

// 快放帧选择逻辑
long inputFrameIndex = 0;
while (解码器还有数据) {
    if (inputFrameIndex % (int)speed == 0) {
        // 这一帧保留,送入编码器
        encoder.queueInputBuffer(...);
    }
    // 否则直接丢弃
    inputFrameIndex++;
}

举个例子,speed=2.0 时,保留第 0、2、4、6...帧,丢弃第 1、3、5、7...帧。这样视频时长就缩短了一半。

小技巧:快放时帧率也要跟着调整。比如原始 30fps,2 倍速后输出帧率设为 15fps,这样播放器才能正确播放。否则你虽然丢了帧,但播放器还是按 30fps 播,画面会「跳着走」。

帧选择策略:慢放时怎么补帧

慢放 0.5 倍速,每帧要重复一次。我最初做这个功能时,直接简单重复帧,结果画面有明显的「卡顿感」。后来发现,关键是要保持时间戳的连续性。

// 慢放帧重复逻辑
long originalPts = 解码得到的原始时间戳;
long repeatedPts = originalPts;  // 第一份
// 送入编码器
encoder.queueInputBuffer(data, 0, size, repeatedPts, flags);

// 第二份,时间戳增加一帧的间隔
long frameDuration = 1000000 / originalFrameRate;  // 微秒
repeatedPts = originalPts + frameDuration;
encoder.queueInputBuffer(data, 0, size, repeatedPts, flags);

为什么要手动加时间戳?因为编码器不会自动帮你算。你重复送同一帧数据,如果不改时间戳,两帧的时间戳一样,播放器会认为它们是同一时刻的帧,直接跳过第二帧。

我曾经踩过的坑:慢放时如果只是简单复制帧数据而不调整 PTS,最终视频时长不变,等于没做慢放。后来排查了半天才发现是时间戳没递增。记住:每一帧送入编码器时,PTS 必须严格递增。

完整的速度因子对照表

速度因子 效果 帧处理策略 输出帧率
0.25 4倍慢放 每帧重复 4 次 原始帧率 / 4
0.5 2倍慢放 每帧重复 2 次 原始帧率 / 2
1.0 正常速度 不处理 原始帧率
1.5 1.5倍快放 每 3 帧取 2 帧 原始帧率 * 1.5
2.0 2倍快放 每 2 帧取 1 帧 原始帧率 * 2
4.0 4倍快放 每 4 帧取 1 帧 原始帧率 * 4

你想想看,如果速度因子是 1.5,怎么丢帧?我一般用「累积误差法」:维护一个累积值,每帧加上速度因子,累积值超过 1 就保留这一帧,然后减 1。这样能保证帧分布均匀,不会出现连续丢两帧的情况。

音频怎么办?

嗯,这里要坦白说——视频变速如果不同步处理音频,最终出来的视频会「音画不同步」。音频变速比视频复杂得多,因为音频不能简单丢帧或重复帧,否则声音会变调。

我建议的做法是:

  • 快放:音频用 Sonic 或 SoundTouch 库做变速不变调处理
  • 慢放:同样用音频处理库,保持音调不变

不过音频变速涉及音频重采样、时域压扩等概念,咱们这个章节先聚焦视频部分。音频的处理,我会在后面的章节单独讲。

重要提醒:如果你只是做「播放器变速」,完全不需要重新编码。直接用 MediaPlayer 的 setPlaybackParams 方法就行。只有当你需要「导出变速后的视频文件」时,才需要用 MediaMuxer 走完整流程。

性能优化建议

我在项目中遇到过一个问题:4K 视频做 4 倍快放时,解码速度跟不上编码速度,导致内存暴涨。后来怎么解决的?

  1. 控制解码帧数:不要一次性解码所有帧,用生产者-消费者模式,解码一帧处理一帧。
  2. 使用 Surface 输入:如果只是简单变速,可以用 MediaCodec 的 Surface 模式,减少内存拷贝。
  3. 异步处理:解码和编码跑在不同的线程,用 BufferQueue 做缓冲。

说白了,视频变速这个功能,代码量不大,但坑不少。尤其是时间戳的处理,稍不注意就会出问题。我建议你写完代码后,用 MediaInfo 工具检查输出视频的帧率和时长,确保符合预期。

好了,这一章的内容就到这里。变速的核心逻辑你已经掌握了,剩下的就是动手实践。记住:快放丢帧,慢放补帧,时间戳一定要递增。


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