23、视频裁剪与拼接:MediaCodec编解码循环、帧精确裁剪、无缝拼接、转场效果

各位同学,今天我们来啃一块硬骨头——视频裁剪与拼接。

说实话,这个主题在音视频开发里属于「看着简单,做起来全是坑」的类型。你想想看,裁剪要精确到帧,拼接要无缝,还要加转场效果……嗯,我当年第一次做这个功能时,差点被帧同步问题搞到怀疑人生。

23.1 核心思路:编解码循环

视频裁剪和拼接,本质上都是对已有视频进行重新编码。我们得把原始视频解码成原始帧,然后对这些帧做处理(裁剪、排序、加特效),最后再编码输出。

这个流程我习惯叫它「编解码循环」:

MediaExtractor → MediaCodec(解码) → 帧处理 → MediaCodec(编码) → MediaMuxer

说白了,就是一条流水线。原始视频进来,经过解码器变成一帧帧的图片,我们在中间插一手,改完了再交给编码器压回去。

关键点:解码和编码是异步的,需要处理好输入输出缓冲区的时序。我见过太多新手在这里死锁——解码器还没输出帧,编码器就在等输入,两边互相等着,程序就卡死了。

23.2 帧精确裁剪:别小看那几毫秒

视频裁剪最头疼的是什么?是「我想从第10秒剪到第20秒」,结果出来的视频多了两帧或者少了两帧。

为什么会这样?因为视频的GOP结构。关键帧(I帧)不是你想有就有的。如果你指定的裁剪时间点不是关键帧位置,解码器只能从上一个关键帧开始解,然后丢掉前面的帧。

我个人的做法是这样的:

  1. 先定位到目标时间点附近的关键帧——用MediaExtractor的seekTo方法,带上SEEK_TO_PREVIOUS_SYNC标记
  2. 逐帧解码,直到到达目标时间戳——每解出一帧,检查它的时间戳,小于目标时间的就丢掉
  3. 从目标时间戳开始,把帧送入编码器
// 伪代码示例
extractor.seekTo(cutStartUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC);
while (true) {
    int inputIndex = decoder.dequeueInputBuffer(TIMEOUT_US);
    if (inputIndex >= 0) {
        ByteBuffer inputBuffer = decoder.getInputBuffer(inputIndex);
        int sampleSize = extractor.readSampleData(inputBuffer, 0);
        if (sampleSize < 0) break;
        
        long presentationTimeUs = extractor.getSampleTime();
        decoder.queueInputBuffer(inputIndex, 0, sampleSize, 
                                 presentationTimeUs, 0);
        extractor.advance();
    }
    
    // 解码输出
    BufferInfo info = new BufferInfo();
    int outputIndex = decoder.dequeueOutputBuffer(info, TIMEOUT_US);
    if (outputIndex >= 0) {
        if (info.presentationTimeUs >= cutStartUs) {
            // 这才是我们要的帧,送入编码器
            encoder.queueInputFrame(info);
        }
        decoder.releaseOutputBuffer(outputIndex, false);
    }
}

注意:时间戳单位是微秒(μs),不是毫秒。我曾经犯过这个错,把毫秒当微秒传进去,结果裁剪出来的视频长了1000倍……排查了一下午才发现。

23.3 无缝拼接:帧率与时间戳的博弈

拼接两个视频,听起来就是把第一个的最后一帧和第二个的第一帧连起来。但实际操作中,你可能会遇到:

  • 两个视频的帧率不一样(一个30fps,一个24fps)
  • 分辨率不一样(一个1080p,一个720p)
  • 编码格式不一样(一个H.264,一个H.265)

怎么办?统一规格。我建议的做法是:

  1. 解码所有视频片段,统一到一个共同的Surface上渲染
  2. 编码器只认一个输入源——你拼接好的帧序列
  3. 时间戳要重新计算,不能直接用原始时间戳

举个例子,第一个视频时长10秒,第二个视频时长5秒。拼接后,第二个视频的第一帧时间戳应该是10秒(10000000μs),而不是它原始的0秒。

long accumulatedTimeUs = 0;
for (每个视频片段) {
    while (解码该片段的一帧) {
        // 重新计算时间戳
        info.presentationTimeUs = accumulatedTimeUs + frameIndex * frameDurationUs;
        encoder.queueInputFrame(info);
        frameIndex++;
    }
    accumulatedTimeUs += 片段时长Us;
    frameIndex = 0;
}

小技巧:如果两个视频分辨率不同,我习惯把编码器的输出分辨率设成较大的那个,然后小的视频居中显示,上下或左右补黑边。这样不会拉伸变形。

23.4 转场效果:OpenGL Shader 的魔法

转场效果,说白了就是在两个视频片段之间插入一段过渡动画。常见的转场有:

  • 淡入淡出(Crossfade)
  • 滑动(Slide)
  • 擦除(Wipe)
  • 缩放(Zoom)

这些效果怎么做?用OpenGL ES的片段着色器(Fragment Shader)。

我举个例子,最简单的淡入淡出:

// 片段着色器 - 淡入淡出
#version 300 es
precision mediump float;
uniform sampler2D uTextureA;  // 前一段视频的当前帧
uniform sampler2D uTextureB;  // 后一段视频的当前帧
uniform float uProgress;      // 0.0 ~ 1.0

in vec2 vTexCoord;
out vec4 fragColor;

void main() {
    vec4 colorA = texture(uTextureA, vTexCoord);
    vec4 colorB = texture(uTextureB, vTexCoord);
    fragColor = mix(colorA, colorB, uProgress);
}

你看,核心就是mix函数。uProgress从0渐变到1,画面就从A完全过渡到B了。

更复杂的转场,比如滑动:

// 滑动转场 - 从左到右
void main() {
    vec2 uv = vTexCoord;
    if (uv.x < uProgress) {
        fragColor = texture(uTextureB, uv + vec2(1.0 - uProgress, 0.0));
    } else {
        fragColor = texture(uTextureA, uv - vec2(uProgress, 0.0));
    }
}

实现流程:

  1. 在转场开始前,同时持有两个视频片段的解码帧
  2. 创建一个OpenGL纹理对,分别绑定两个帧
  3. 渲染时,根据当前进度(uProgress)计算混合结果
  4. 将渲染结果作为编码器的输入

23.5 整体架构图

下面这张图展示了整个编解码循环和转场处理的流程:

视频裁剪与拼接编解码循环架构 MediaExtractor 读取原始视频 MediaCodec(解码) 输出原始帧 帧处理模块 裁剪 / 时间戳重算 / 转场Shader OpenGL渲染 + 混合 MediaCodec(编码) 压缩为H.264/H.265 MediaMuxer 封装输出文件 关键说明: 1. 解码和编码运行在独立的线程中,通过帧队列通信 2. 帧处理模块接收原始帧,输出处理后的帧到编码器 3. 转场效果在帧处理模块中通过OpenGL Shader实现 4. 时间戳在帧处理模块中统一重新计算,保证连续性

23.6 避坑指南

最后,分享几个我踩过的坑:

  • 解码器输出格式不匹配——有些设备解码器输出的是YUV,有些是RGBA。我建议统一用Surface输入,让解码器直接渲染到纹理上,省去格式转换的麻烦。
  • 编码器输入帧率不稳定——如果解码速度跟不上编码速度,编码器会报错。解决办法是给编码器设置一个合理的帧率,然后用时间戳控制节奏。
  • 转场时的音频处理——转场期间,音频也要做淡入淡出。我习惯把音频单独抽出来处理,用AudioTrack混合后再交给Muxer。

重要提醒:MediaCodec在API 21以上支持异步模式(setCallback),我个人强烈推荐使用。同步模式下的循环容易写出死锁,异步模式用回调驱动,逻辑清晰很多。

好了,这一章的内容就到这里。裁剪和拼接是视频编辑的基础,掌握了编解码循环和帧处理,后面做更复杂的效果就水到渠成了。

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