17、MediaMuxer实战:画中画(PIP)——将一个小视频叠加到大视频上
画中画(Picture-in-Picture,简称PIP)这个功能,大家应该都不陌生。你看直播的时候,主播把摄像头画面缩成一个小窗口,放在游戏画面的角落——那就是PIP。在短视频编辑App里,这也是个高频需求。
今天我们就来聊聊,怎么用MediaMuxer配合MediaCodec,把一个小视频叠加到大视频上,生成一个真正的画中画视频文件。
PIP的核心思路
说白了,PIP不是简单的「两个视频拼在一起」。它需要把两个视频的画面,在时间轴上对齐,然后通过合成渲染,把一个小画面「贴」到大画面的某个位置。
我个人的理解是:PIP = 视频解码 + 画面合成 + 编码输出。三个步骤缺一不可。
核心流程:
- 用MediaExtractor分别读取主视频和小视频
- 用MediaCodec解码两个视频的画面
- 用OpenGL或Surface合成两个画面(小画面缩放+定位)
- 用MediaCodec编码合成后的画面
- 用MediaMuxer把编码后的数据写入MP4文件
嗯,这里要注意:音频的处理相对简单,直接把两个音频混音就行。但视频部分,必须逐帧合成。
准备工作:两个视频源
假设我们有两个视频文件:
- 主视频(big.mp4):1920x1080,时长30秒
- 小视频(small.mp4):640x480,时长10秒
我们的目标:把小视频放在主视频的右下角,大小缩放到原来的30%。
小技巧:小视频的时长如果比主视频短,播放完就消失。如果你想让它循环播放,需要在合成逻辑里做取模运算。我在项目里遇到过这个问题,当时产品要求小视频循环播放,我直接在帧循环里用 frameIndex % smallFrameCount 解决的。
第一步:初始化解码器和编码器
先初始化两个MediaCodec解码器,一个给主视频,一个给小视频。同时初始化一个编码器,用来输出合成后的画面。
// 主视频解码器
MediaExtractor bigExtractor = new MediaExtractor();
bigExtractor.setDataSource(bigPath);
MediaFormat bigFormat = bigExtractor.getTrackFormat(videoTrackIndex);
MediaCodec bigDecoder = MediaCodec.createDecoderByType(bigFormat.getString(MediaFormat.KEY_MIME));
bigDecoder.configure(bigFormat, bigSurface, null, 0);
bigDecoder.start();
// 小视频解码器
MediaExtractor smallExtractor = new MediaExtractor();
smallExtractor.setDataSource(smallPath);
MediaFormat smallFormat = smallExtractor.getTrackFormat(videoTrackIndex);
MediaCodec smallDecoder = MediaCodec.createDecoderByType(smallFormat.getString(MediaFormat.KEY_MIME));
smallDecoder.configure(smallFormat, smallSurface, null, 0);
smallDecoder.start();
// 编码器
MediaCodec encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
MediaFormat encodeFormat = MediaFormat.createVideoFormat(
MediaFormat.MIMETYPE_VIDEO_AVC, 1920, 1080);
encodeFormat.setInteger(MediaFormat.KEY_BIT_RATE, 8_000_000);
encodeFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
encodeFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
encoder.configure(encodeFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
encoder.start();
这里有个细节:解码器的输出Surface,要传给OpenGL做纹理输入。编码器的输入Surface,则是OpenGL渲染的目标。
第二步:OpenGL合成渲染
合成这一步,说白了就是画两个矩形。大矩形铺满全屏,小矩形缩放在角落。
我习惯用EGL + GLES20来做。核心的顶点着色器和片元着色器如下:
// 顶点着色器
private static final String VERTEX_SHADER =
"attribute vec4 aPosition;\n" +
"attribute vec2 aTexCoord;\n" +
"varying vec2 vTexCoord;\n" +
"void main() {\n" +
" gl_Position = aPosition;\n" +
" vTexCoord = aTexCoord;\n" +
"}";
// 片元着色器(主视频)
private static final String FRAGMENT_SHADER_BIG =
"#extension GL_OES_EGL_image_external : require\n" +
"precision mediump float;\n" +
"varying vec2 vTexCoord;\n" +
"uniform samplerExternalOES sTexture;\n" +
"void main() {\n" +
" gl_FragColor = texture2D(sTexture, vTexCoord);\n" +
"}";
小视频的片元着色器类似,但需要做缩放和偏移。比如放在右下角,缩放30%:
// 小视频的顶点坐标(右下角,缩放30%)
float[] smallVertex = {
0.7f, -0.7f, // 右下
1.0f, -0.7f, // 右上
0.7f, -1.0f, // 左下
1.0f, -1.0f // 左上
};
嗯,这里要注意:OpenGL的坐标范围是[-1, 1],左上角是(-1, 1),右下角是(1, -1)。所以右下角的小窗口,x范围是0.7到1.0,y范围是-1.0到-0.7。
曾经踩过的坑:我第一次做PIP时,直接把两个纹理叠加渲染,结果小视频的画面是反的。后来发现是纹理坐标的Y轴方向搞反了。Android的SurfaceTexture输出的纹理,Y轴是翻转的,需要在片元着色器里把y坐标取反:vTexCoord.y = 1.0 - vTexCoord.y;
第三步:帧同步与渲染循环
两个视频的帧率可能不一样。主视频30fps,小视频可能24fps。怎么对齐?
我的做法是:以主视频的时间戳为基准。每解码一帧主视频,就去检查小视频是否有对应时间戳的帧。如果有,就一起渲染;如果没有,就只渲染主视频。
while (!isEOS) {
// 喂数据给解码器
int bigInputIndex = bigDecoder.dequeueInputBuffer(10000);
if (bigInputIndex >= 0) {
// 读取主视频数据...
bigDecoder.queueInputBuffer(bigInputIndex, ...);
}
// 获取解码输出
MediaCodec.BufferInfo bigInfo = new MediaCodec.BufferInfo();
int bigOutputIndex = bigDecoder.dequeueOutputBuffer(bigInfo, 10000);
if (bigOutputIndex >= 0) {
bigDecoder.releaseOutputBuffer(bigOutputIndex, true);
// 检查小视频是否有对应帧
long pts = bigInfo.presentationTimeUs;
renderPIPFrame(pts);
}
}
renderPIPFrame里做的事情:
- 把小视频解码器里所有时间戳小于等于当前pts的帧都释放掉
- 用OpenGL绘制主视频纹理(全屏)
- 用OpenGL绘制小视频纹理(右下角缩放)
- 交换缓冲区,通知编码器
第四步:MediaMuxer写入
编码器输出的数据,通过MediaMuxer写入文件。这部分和普通的视频编码输出一样:
MediaMuxer muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
int videoTrackIndex = muxer.addTrack(encoderOutputFormat);
muxer.start();
MediaCodec.BufferInfo encoderInfo = new MediaCodec.BufferInfo();
while (!isEOS) {
int outputIndex = encoder.dequeueOutputBuffer(encoderInfo, 10000);
if (outputIndex >= 0) {
ByteBuffer outputData = encoder.getOutputBuffer(outputIndex);
if ((encoderInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
muxer.writeSampleData(videoTrackIndex, outputData, encoderInfo);
}
encoder.releaseOutputBuffer(outputIndex, false);
}
}
muxer.stop();
muxer.release();
个人建议:音频部分可以单独处理。用MediaExtractor提取两个视频的音频轨道,然后用AudioMixer混音,最后用MediaMuxer的另一个轨道写入。这样视频和音频分开处理,逻辑更清晰。
完整流程总结
| 步骤 | 组件 | 说明 |
|---|---|---|
| 1 | MediaExtractor x2 | 分别读取主视频和小视频 |
| 2 | MediaCodec x2(解码) | 解码两个视频的画面到Surface |
| 3 | OpenGL ES | 合成两个画面,小画面缩放+定位 |
| 4 | MediaCodec(编码) | 将合成画面编码为H.264 |
| 5 | MediaMuxer | 写入MP4文件 |
画中画这个功能,看起来复杂,拆开来看其实就是「解码 → 合成 → 编码」三个环节。你只要把每个环节的Surface和纹理处理好,剩下的就是时间戳对齐的问题。
我记得有一次,产品经理要求小视频可以拖动位置,用户想放左上角就放左上角。我直接在OpenGL的顶点坐标里加了个偏移参数,UI传什么坐标,我就把顶点偏移多少。嗯,这个扩展其实不难,关键是要把坐标映射关系搞清楚。
好了,这一章的内容就到这里。画中画的核心逻辑你已经掌握了,剩下的就是多写代码、多调试。遇到问题别慌,先检查时间戳,再检查纹理坐标——这两个地方出错的概率最大。
公众号:蓝海资料掘金营,微信deep3321