18、MediaMuxer实战:视频合成(Composition)——将多个视频画面拼接成一个
各位同学,欢迎来到第18章。今天咱们聊点真正硬核的东西——视频合成。
说白了,就是把两段或多段视频,拼成一个画面。比如你拍了个左边视角,我拍了个右边视角,最后合成一个画中画,或者左右分屏。这在短视频编辑、监控拼接、多机位直播回放里太常见了。
我当年第一次做这个功能时,以为直接用MediaMuxer把两个视频的轨道写进去就行。结果呢?画面是有了,但两个视频各播各的,完全没对齐。后来我才明白——MediaMuxer只管封装,不管画面怎么拼。真正的合成,得靠MediaCodec做解码+渲染+编码。
嗯,今天咱们就把这个流程彻底讲透。
视频合成的核心思路
先理清逻辑。你要把两个视频拼成一个,本质上需要三步:
- 解码:把两个视频的每一帧,从压缩格式(H.264/H.265)解出来,变成原始图像数据(YUV或RGBA)。
- 合成:把两帧原始图像,按你想要的布局(左右、上下、画中画)拼成一帧新图像。
- 编码:把合成后的新帧,重新压缩成视频流,再用MediaMuxer写进MP4文件。
你看,MediaMuxer只参与了最后一步。前两步才是真正的技术活。
关键点:MediaMuxer不负责画面合成,它只负责把编码后的数据打包成MP4。合成逻辑必须你自己实现。
整体流程图
我画了一张图,帮你把整个流程串起来。建议你盯着看30秒,再往下读代码。
这张图从左往右看。两个视频先各自解码,然后送到合成器里拼成一帧,再编码、封装。每一步都不能少。
解码环节:别小看同步问题
解码本身不难,MediaCodec的标准用法大家都熟。但合成场景下有个坑——两个视频的帧率可能不一样。
比如视频A是30fps,视频B是24fps。你解码出来的帧,时间戳对不上。合成时如果直接拿最新的一帧去拼,画面会卡顿或跳帧。
我个人的习惯是:以帧率较高的视频为基准。每解码出一帧A,就去查B的当前帧。如果B的时间戳落后了,就重复使用B的上一帧;如果B超前了,就丢掉B的某些帧。说白了,就是手动做一次帧对齐。
小技巧:用TreeMap
合成环节:OpenGL还是CPU像素操作?
这里有两个选择:
- CPU像素操作:把YUV数据读到ByteBuffer里,手动拷贝像素。简单,但慢。适合低分辨率或测试用。
- OpenGL ES:把两帧数据上传到纹理,用Shader做合成。复杂,但快。适合1080p以上。
我建议你直接上OpenGL。为什么?因为MediaCodec解码出来的数据,很多设备直接输出的是Surface纹理,你拿到的就是OpenGL纹理ID。用Shader做合成,连CPU拷贝都省了。
举个例子,左右分屏的Fragment Shader长这样:
// 左右分屏 Shader
#version 300 es
precision mediump float;
uniform sampler2D uTextureA;
uniform sampler2D uTextureB;
in vec2 vTexCoord;
out vec4 fragColor;
void main() {
vec2 coord = vTexCoord;
if (coord.x < 0.5) {
// 左半部分显示视频A
coord.x = coord.x * 2.0;
fragColor = texture(uTextureA, coord);
} else {
// 右半部分显示视频B
coord.x = (coord.x - 0.5) * 2.0;
fragColor = texture(uTextureB, coord);
}
}
你看,核心逻辑就几行。把纹理坐标映射到两个视频的各自区域,然后采样输出。画中画、上下分屏、九宫格,都是改改坐标映射的事。
注意:OpenGL环境需要EGL支持。如果你是在普通Android应用里做,记得用GLSurfaceView或者自己创建EGLContext。我曾经在Service里做后台合成,忘了初始化EGL,结果SurfaceTexture一直报错,排查了半天。
编码与封装:MediaMuxer收尾
合成后的帧,通过OpenGL的FBO(帧缓冲对象)读取出来,或者用ImageReader转成ByteBuffer。然后喂给MediaCodec编码器。
编码器的配置要注意:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| MIME | video/avc | H.264,兼容性最好 |
| BITRATE | 视频宽度×高度×0.1 | 比如1920×1080,约2Mbps |
| FRAME_RATE | 取两个视频中较高的帧率 | 避免卡顿 |
| I_FRAME_INTERVAL | 1 | 每秒一个关键帧,方便剪辑 |
编码完成后,把MediaCodec输出的BufferInfo和ByteBuffer,依次传给MediaMuxer的writeSampleData。注意时间戳要连续递增,不能回退。
嗯,这里有个细节——编码器的输入时间戳必须从0开始。如果你合成时用了两个视频的原始时间戳,编码器会认为时间戳跳变,导致输出花屏。我踩过这个坑,后来统一在合成阶段重新生成时间戳,按帧率递增。
完整流程的伪代码
最后,给你一个伪代码框架,把上面所有环节串起来:
// 1. 初始化解码器
MediaCodec decoderA = MediaCodec.createDecoderByType("video/avc");
MediaCodec decoderB = MediaCodec.createDecoderByType("video/avc");
// 配置解码器,绑定Surface(用于OpenGL输入)
// 2. 初始化编码器
MediaCodec encoder = MediaCodec.createEncoderByType("video/avc");
// 配置编码器,输出到MediaMuxer
// 3. 初始化MediaMuxer
MediaMuxer muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
int videoTrackIndex = muxer.addTrack(encoder.getOutputFormat());
muxer.start();
// 4. 循环解码+合成+编码
while (有帧数据) {
// 解码A和B各一帧
int inputIndexA = decoderA.dequeueInputBuffer(10000);
// ... 填充数据
decoderA.queueInputBuffer(inputIndexA, ...);
int inputIndexB = decoderB.dequeueInputBuffer(10000);
// ... 填充数据
decoderB.queueInputBuffer(inputIndexB, ...);
// 从解码器输出Surface获取纹理
// 用OpenGL Shader合成左右分屏
// 从FBO读取合成后的帧
// 编码合成帧
int outputIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000);
if (outputIndex >= 0) {
ByteBuffer encodedData = encoder.getOutputBuffer(outputIndex);
muxer.writeSampleData(videoTrackIndex, encodedData, bufferInfo);
encoder.releaseOutputBuffer(outputIndex, false);
}
}
// 5. 清理
decoderA.stop(); decoderA.release();
decoderB.stop(); decoderB.release();
encoder.stop(); encoder.release();
muxer.stop(); muxer.release();
这段代码虽然简略,但骨架是完整的。你把它填上具体的OpenGL渲染逻辑,就是一个可用的视频合成器。
总结一下:视频合成的本质是解码→合成→编码。MediaMuxer只负责最后一步封装。真正的难点在帧同步和OpenGL渲染。别怕,多调试几次,你也能搞定。
好了,这一章的内容就到这里。代码示例你可以直接拿去跑,遇到问题欢迎交流。下一章咱们聊点更刺激的——视频变速播放的底层实现。
公众号:蓝海资料掘金营,微信deep3321