18、MediaMuxer实战:视频合成(Composition)——将多个视频画面拼接成一个

各位同学,欢迎来到第18章。今天咱们聊点真正硬核的东西——视频合成。

说白了,就是把两段或多段视频,拼成一个画面。比如你拍了个左边视角,我拍了个右边视角,最后合成一个画中画,或者左右分屏。这在短视频编辑、监控拼接、多机位直播回放里太常见了。

我当年第一次做这个功能时,以为直接用MediaMuxer把两个视频的轨道写进去就行。结果呢?画面是有了,但两个视频各播各的,完全没对齐。后来我才明白——MediaMuxer只管封装,不管画面怎么拼。真正的合成,得靠MediaCodec做解码+渲染+编码。

嗯,今天咱们就把这个流程彻底讲透。

视频合成的核心思路

先理清逻辑。你要把两个视频拼成一个,本质上需要三步:

  1. 解码:把两个视频的每一帧,从压缩格式(H.264/H.265)解出来,变成原始图像数据(YUV或RGBA)。
  2. 合成:把两帧原始图像,按你想要的布局(左右、上下、画中画)拼成一帧新图像。
  3. 编码:把合成后的新帧,重新压缩成视频流,再用MediaMuxer写进MP4文件。

你看,MediaMuxer只参与了最后一步。前两步才是真正的技术活。

关键点:MediaMuxer不负责画面合成,它只负责把编码后的数据打包成MP4。合成逻辑必须你自己实现。

整体流程图

我画了一张图,帮你把整个流程串起来。建议你盯着看30秒,再往下读代码。

输入视频 A 输入视频 B MediaCodec 解码 MediaCodec 解码 OpenGL / 像素操作 画面合成 MediaCodec 编码 MediaMuxer 封装 输出 MP4

这张图从左往右看。两个视频先各自解码,然后送到合成器里拼成一帧,再编码、封装。每一步都不能少。

解码环节:别小看同步问题

解码本身不难,MediaCodec的标准用法大家都熟。但合成场景下有个坑——两个视频的帧率可能不一样

比如视频A是30fps,视频B是24fps。你解码出来的帧,时间戳对不上。合成时如果直接拿最新的一帧去拼,画面会卡顿或跳帧。

我个人的习惯是:以帧率较高的视频为基准。每解码出一帧A,就去查B的当前帧。如果B的时间戳落后了,就重复使用B的上一帧;如果B超前了,就丢掉B的某些帧。说白了,就是手动做一次帧对齐。

小技巧:用TreeMap来缓存两个视频的解码帧,按时间戳排序。合成时从Map里取时间最接近的两帧。我在项目里一直这么用,效果很稳。

合成环节: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