一、视频转码到底是什么?
视频转码,说白了就是「把视频从一种格式变成另一种格式」。比如你手里有个 H.264 编码的 1080p 视频,想转成 H.265 编码的 720p 视频,这就是转码。
我刚开始做音视频开发时,总觉得转码不就是解码再编码吗?后来踩了坑才发现,这里面门道不少。你想想看,解码和编码之间怎么衔接?数据怎么传递?时间戳怎么处理?这些都是问题。
转码的核心流程其实很简单:
- 解封装:从 MP4 文件里读出音视频数据包
- 解码:把压缩的数据包解成原始帧(比如 YUV 或 PCM)
- 滤镜处理:缩放分辨率、改变帧率、旋转等
- 编码:把原始帧重新压缩成目标格式
- 封装:把编码后的数据包写进新的 MP4 文件
嗯,这里要注意,MediaMuxer 只负责最后一步——封装。前面的解码和滤镜,得靠 MediaCodec 和 MediaExtractor 来完成。
核心要点:MediaMuxer 本身不做转码,它只是把编码后的数据「打包」成 MP4 文件。真正的转码工作,是 MediaCodec 解码 + MediaCodec 编码的组合拳。
二、转码的整体架构
我习惯把转码流程画成一张图,这样思路更清晰。下面是我自己整理的架构图:
这张图我画了好几次才满意。你看,左边是输入,右边是输出,中间是编解码和滤镜。MediaMuxer 在右上角,负责把编码后的数据打包成 MP4。
三、MediaMuxer 在转码中的角色
MediaMuxer 在转码流程里,其实只做一件事:把编码后的音视频数据包,按时间顺序写入 MP4 文件。它不关心数据是怎么来的,也不关心数据是什么格式。
我记得第一次做转码时,以为 MediaMuxer 能自动处理一切。结果发现,它连时间戳都要我自己传。嗯,这里有个坑:
时间戳陷阱:MediaMuxer 要求传入的时间戳必须是微秒(microseconds)单位,而且必须是单调递增的。如果你传的时间戳乱跳,生成的 MP4 文件播放时会卡顿或者音画不同步。
MediaMuxer 的核心 API 其实就几个:
| 方法 | 作用 | 注意事项 |
|---|---|---|
addTrack() |
添加音视频轨道 | 必须在 start() 之前调用 |
start() |
开始封装 | 调用后不能再 addTrack() |
writeSampleData() |
写入一帧数据 | 时间戳必须递增 |
stop() |
停止封装 | 调用后释放资源 |
release() |
释放资源 | 必须调用,否则内存泄漏 |
四、实战:从 H.264 转码到 H.265
下面我写一个完整的转码示例。这个例子把 H.264 编码的 MP4 文件,转成 H.265 编码,同时把分辨率从 1080p 降到 720p。
public class VideoTranscoder {
private static final String TAG = "VideoTranscoder";
private static final int TIMEOUT_USEC = 10000;
private MediaExtractor extractor;
private MediaMuxer muxer;
private MediaCodec decoder;
private MediaCodec encoder;
public void transcode(String inputPath, String outputPath) {
try {
// 1. 初始化解封装器
extractor = new MediaExtractor();
extractor.setDataSource(inputPath);
// 2. 选择视频轨道
int videoTrackIndex = selectVideoTrack();
if (videoTrackIndex < 0) {
Log.e(TAG, "没有找到视频轨道");
return;
}
extractor.selectTrack(videoTrackIndex);
MediaFormat inputFormat = extractor.getTrackFormat(videoTrackIndex);
// 3. 初始化解码器
String mime = inputFormat.getString(MediaFormat.KEY_MIME);
decoder = MediaCodec.createDecoderByType(mime);
decoder.configure(inputFormat, null, null, 0);
decoder.start();
// 4. 初始化编码器(H.265,720p)
MediaFormat outputFormat = MediaFormat.createVideoFormat(
MediaFormat.MIMETYPE_VIDEO_HEVC, 1280, 720);
outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, 2_000_000);
outputFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
outputFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
outputFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_HEVC);
encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
encoder.start();
// 5. 初始化封装器
muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
int muxerTrackIndex = muxer.addTrack(outputFormat);
muxer.start();
// 6. 开始转码循环
boolean inputDone = false;
boolean outputDone = false;
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
while (!outputDone) {
// 6.1 喂数据给解码器
if (!inputDone) {
int inputIndex = decoder.dequeueInputBuffer(TIMEOUT_USEC);
if (inputIndex >= 0) {
ByteBuffer inputBuffer = decoder.getInputBuffer(inputIndex);
int sampleSize = extractor.readSampleData(inputBuffer, 0);
if (sampleSize < 0) {
decoder.queueInputBuffer(inputIndex, 0, 0, 0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM);
inputDone = true;
} else {
decoder.queueInputBuffer(inputIndex, 0, sampleSize,
extractor.getSampleTime(), 0);
extractor.advance();
}
}
}
// 6.2 从解码器取数据,喂给编码器
int outputIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_USEC);
if (outputIndex >= 0) {
// 这里可以做滤镜处理(缩放、旋转等)
// 为了简化,直接传给编码器
ByteBuffer outputBuffer = decoder.getOutputBuffer(outputIndex);
// 实际项目中,这里需要做格式转换和缩放
// 这里省略了 Surface 渲染的细节
decoder.releaseOutputBuffer(outputIndex, false);
}
// 6.3 从编码器取数据,写入 Muxer
int encoderOutputIndex = encoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_USEC);
if (encoderOutputIndex >= 0) {
ByteBuffer encodedData = encoder.getOutputBuffer(encoderOutputIndex);
if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
muxer.writeSampleData(muxerTrackIndex, encodedData, bufferInfo);
}
encoder.releaseOutputBuffer(encoderOutputIndex, false);
if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
outputDone = true;
}
}
}
Log.i(TAG, "转码完成");
} catch (Exception e) {
Log.e(TAG, "转码失败", e);
} finally {
releaseResources();
}
}
private int selectVideoTrack() {
for (int i = 0; i < extractor.getTrackCount(); i++) {
MediaFormat format = extractor.getTrackFormat(i);
String mime = format.getString(MediaFormat.KEY_MIME);
if (mime != null && mime.startsWith("video/")) {
return i;
}
}
return -1;
}
private void releaseResources() {
if (decoder != null) {
decoder.stop();
decoder.release();
decoder = null;
}
if (encoder != null) {
encoder.stop();
encoder.release();
encoder = null;
}
if (muxer != null) {
muxer.stop();
muxer.release();
muxer = null;
}
if (extractor != null) {
extractor.release();
extractor = null;
}
}
}
个人经验:上面的代码为了清晰,省略了 Surface 渲染的部分。实际项目中,我建议用 Surface 来做解码输出和编码输入,这样效率更高。具体做法是:解码器配置 Surface,编码器也配置 Surface,然后用 OpenGL 做中间处理。
五、转码中的关键问题
5.1 分辨率缩放
改变分辨率是转码最常见的需求。我建议用 Surface + OpenGL 来做缩放,而不是在 CPU 上做像素操作。为什么呢?因为 GPU 做缩放比 CPU 快得多,而且不占 CPU 资源。
我曾经试过用 Bitmap 做缩放,结果 1080p 的视频转码速度只有 0.5x,卡得不行。换成 Surface 后,速度直接飙到 3x。
5.2 时间戳处理
转码时,时间戳是最容易出问题的地方。我总结了几条经验:
- 保持原始时间戳:解码器输出的时间戳,直接传给编码器,不要自己算
- 注意单位:MediaCodec 的时间戳是微秒,MediaMuxer 也是微秒,保持一致
- 避免跳跃:如果编码器输出的时间戳不是单调递增的,MediaMuxer 会报错
我曾经踩过的坑:有一次转码后视频播放卡顿,查了半天发现是编码器输出的时间戳有重复。原因是编码器内部缓冲导致输出顺序和输入顺序不一致。解决办法是用一个队列来重新排序时间戳。
5.3 编码参数选择
编码参数直接影响输出质量和文件大小。我常用的参数组合:
| 场景 | 编码格式 | 码率 | 帧率 | 关键帧间隔 |
|---|---|---|---|---|
| 高清存储 | H.265 | 4-8 Mbps | 30 | 1 秒 |
| 网络传输 | H.264 | 1-2 Mbps | 24 | 2 秒 |
| 缩略图 | H.264 | 500 Kbps | 15 | 5 秒 |
六、性能优化建议
转码是个吃性能的活,我分享几个优化技巧:
- 用 Surface 模式:解码器输出到 Surface,编码器从 Surface 读取,避免内存拷贝
- 控制缓冲区大小:dequeueInputBuffer 的超时时间设 10ms 左右,别太长
- 异步处理:解码和编码用不同的线程,用队列传递数据
- 硬件加速:优先使用硬件编解码器,速度比软件快 5-10 倍
我的习惯:在转码开始前,先检查设备支持的编解码器列表。有些设备不支持 H.265 硬件编码,这时候就要降级到 H.264。用 MediaCodecList 可以查到所有可用的编解码器。
好了,这一章的内容就到这里。转码的核心思路就是「解码 → 处理 → 编码 → 封装」,MediaMuxer 只负责最后一步。下一章我们会深入 MediaMuxer 的 addTrack 和 writeSampleData 的细节,看看这两个方法到底做了什么。
公众号:蓝海资料掘金营,微信deep3321