23、视频裁剪与拼接:MediaCodec编解码循环、帧精确裁剪、无缝拼接、转场效果
各位同学,今天我们来啃一块硬骨头——视频裁剪与拼接。
说实话,这个主题在音视频开发里属于「看着简单,做起来全是坑」的类型。你想想看,裁剪要精确到帧,拼接要无缝,还要加转场效果……嗯,我当年第一次做这个功能时,差点被帧同步问题搞到怀疑人生。
23.1 核心思路:编解码循环
视频裁剪和拼接,本质上都是对已有视频进行重新编码。我们得把原始视频解码成原始帧,然后对这些帧做处理(裁剪、排序、加特效),最后再编码输出。
这个流程我习惯叫它「编解码循环」:
MediaExtractor → MediaCodec(解码) → 帧处理 → MediaCodec(编码) → MediaMuxer
说白了,就是一条流水线。原始视频进来,经过解码器变成一帧帧的图片,我们在中间插一手,改完了再交给编码器压回去。
关键点:解码和编码是异步的,需要处理好输入输出缓冲区的时序。我见过太多新手在这里死锁——解码器还没输出帧,编码器就在等输入,两边互相等着,程序就卡死了。
23.2 帧精确裁剪:别小看那几毫秒
视频裁剪最头疼的是什么?是「我想从第10秒剪到第20秒」,结果出来的视频多了两帧或者少了两帧。
为什么会这样?因为视频的GOP结构。关键帧(I帧)不是你想有就有的。如果你指定的裁剪时间点不是关键帧位置,解码器只能从上一个关键帧开始解,然后丢掉前面的帧。
我个人的做法是这样的:
- 先定位到目标时间点附近的关键帧——用MediaExtractor的seekTo方法,带上SEEK_TO_PREVIOUS_SYNC标记
- 逐帧解码,直到到达目标时间戳——每解出一帧,检查它的时间戳,小于目标时间的就丢掉
- 从目标时间戳开始,把帧送入编码器
// 伪代码示例
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)
怎么办?统一规格。我建议的做法是:
- 解码所有视频片段,统一到一个共同的Surface上渲染
- 编码器只认一个输入源——你拼接好的帧序列
- 时间戳要重新计算,不能直接用原始时间戳
举个例子,第一个视频时长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));
}
}
实现流程:
- 在转场开始前,同时持有两个视频片段的解码帧
- 创建一个OpenGL纹理对,分别绑定两个帧
- 渲染时,根据当前进度(uProgress)计算混合结果
- 将渲染结果作为编码器的输入
23.5 整体架构图
下面这张图展示了整个编解码循环和转场处理的流程:
23.6 避坑指南
最后,分享几个我踩过的坑:
- 解码器输出格式不匹配——有些设备解码器输出的是YUV,有些是RGBA。我建议统一用Surface输入,让解码器直接渲染到纹理上,省去格式转换的麻烦。
- 编码器输入帧率不稳定——如果解码速度跟不上编码速度,编码器会报错。解决办法是给编码器设置一个合理的帧率,然后用时间戳控制节奏。
- 转场时的音频处理——转场期间,音频也要做淡入淡出。我习惯把音频单独抽出来处理,用AudioTrack混合后再交给Muxer。
重要提醒:MediaCodec在API 21以上支持异步模式(setCallback),我个人强烈推荐使用。同步模式下的循环容易写出死锁,异步模式用回调驱动,逻辑清晰很多。
好了,这一章的内容就到这里。裁剪和拼接是视频编辑的基础,掌握了编解码循环和帧处理,后面做更复杂的效果就水到渠成了。