23、视频转码:分辨率调整、码率控制、格式转换实战

视频转码,说白了就是把视频从一种「形态」变成另一种「形态」。你可能会问:为什么不能直接播放原始视频?嗯,这个问题我当年也困惑过。直到我在一个项目中,遇到用户上传的4K视频在低端手机上卡成幻灯片……从那以后,我就彻底明白了转码的重要性。

这一章,我们聚焦三个核心操作:分辨率调整码率控制格式转换。这三板斧,是音视频开发者的基本功。

23.1 分辨率调整:不只是缩放那么简单

分辨率调整,听起来就是「把画面变小」。但实际做起来,坑不少。

我个人习惯用 MediaCodec 配合 MediaMuxer 来做。核心思路是:解码原视频 → 修改 Surface 的尺寸 → 重新编码。

先看一段核心代码:

// 设置输出分辨率
MediaFormat outputFormat = MediaFormat.createVideoFormat(MIME_TYPE, targetWidth, targetHeight);
outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate);
outputFormat.setInteger(MediaFormat.KEY_FRAME_RATE, frameRate);
outputFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL);

// 创建编码器
MediaCodec encoder = MediaCodec.createEncoderByType(MIME_TYPE);
encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);

这里要注意:宽高必须是16的倍数。为什么?因为大多数编码器(H.264/H.265)的宏块尺寸是16x16。如果你传一个奇数尺寸,编码器会直接报错,或者默默给你补黑边。

⚠️ 我曾经踩过的坑: 有一次用户上传了1080x1080的视频,我直接设成720x720,结果编码器崩溃了。查了半天才发现,720是16的倍数,但有些编码器要求宽高同时是2的幂次?不,其实是要求对齐到16。后来我加了个工具函数:
int alignTo16(int value) {
    return (value + 15) / 16 * 16;
}
从此再没出过问题。

23.2 码率控制:平衡画质与体积

码率控制,说白了就是「你愿意用多少比特来换一帧画面」。码率越高,画质越好,文件越大。反之亦然。

Android 的 MediaCodec 支持三种码率控制模式:

模式 常量名 特点 适用场景
恒定码率(CBR) BITRATE_MODE_CBR 码率始终不变,画面复杂时质量下降 直播、实时通信
可变码率(VBR) BITRATE_MODE_VBR 码率随画面复杂度变化,质量稳定 本地文件存储、点播
恒定质量(CQ) BITRATE_MODE_CQ 以质量为优先,码率不设上限 离线转码、存档

我个人建议:本地转码用 VBR。为什么?因为 VBR 能在画面简单时省码率,复杂时给足码率。我在项目中做过对比:同样一段视频,CBR 需要 4Mbps 才能达到的视觉效果,VBR 用 2.5Mbps 就能做到。省了将近 40% 的体积。

💡 小技巧: 设置码率时,可以参考这个经验公式:
// 对于 H.264,推荐码率(bps)≈ 分辨率像素数 × 0.07
int bitRate = (int)(targetWidth * targetHeight * 0.07f);
比如 720p(1280x720)≈ 921,600 像素,乘以 0.07 ≈ 64,512 bps?不对,这里单位是 bps,实际要再乘一个系数。更准确的做法是:
// 720p 25fps 的视频,推荐 1.5~2.5 Mbps
int bitRate = 2 * 1024 * 1024; // 2 Mbps

23.3 格式转换:容器与编码的「排列组合」

格式转换,很多人以为就是改个后缀名。其实不是。视频格式包含两层:容器格式(如 MP4、MKV、AVI)和编码格式(如 H.264、H.265、VP9)。

举个例子:一个 .mp4 文件,里面可以是 H.264 编码,也可以是 H.265 编码。你改后缀名,编码没变,播放器可能还是不认识。

真正的格式转换,需要做的是:

  1. 解封装:从容器中提取音视频流
  2. 解码:将压缩数据还原为原始帧
  3. 编码:用目标编码格式重新压缩
  4. 封装:写入目标容器

看一个完整的转码流程:

// 1. 创建 MediaExtractor 读取源文件
MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource(inputPath);

// 2. 选择视频轨道
int videoTrackIndex = selectTrack(extractor, "video/");
extractor.selectTrack(videoTrackIndex);

// 3. 获取源格式信息
MediaFormat inputFormat = extractor.getTrackFormat(videoTrackIndex);

// 4. 创建解码器
MediaCodec decoder = MediaCodec.createDecoderByType(mimeType);
decoder.configure(inputFormat, surface, null, 0);

// 5. 创建编码器(目标格式)
MediaCodec encoder = MediaCodec.createEncoderByType(targetMimeType);
encoder.configure(targetFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);

// 6. 创建 MediaMuxer 写入输出文件
MediaMuxer muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
int outputTrack = muxer.addTrack(encoder.getOutputFormat());
muxer.start();

这里有个关键点:解码器和编码器是异步工作的。你需要用两个线程分别处理输入和输出。我习惯用 HandlerThread 来管理:

HandlerThread decodeThread = new HandlerThread("DecodeThread");
decodeThread.start();
Handler decodeHandler = new Handler(decodeThread.getLooper());

HandlerThread encodeThread = new HandlerThread("EncodeThread");
encodeThread.start();
Handler encodeHandler = new Handler(encodeThread.getLooper());

🎯 实战经验: 我在做一个短视频 App 时,需要把用户上传的各种格式(MOV、AVI、FLV)统一转成 MP4(H.264 + AAC)。最头疼的是兼容性问题——有些视频的编码参数很奇葩,比如 23.976fps 的帧率、非标准的分辨率。我的解决方案是:

  • 先尝试直接转码,如果失败
  • 回退到「解码→缩放→编码」的通用流程
  • 如果还不行,就用 FFmpeg 做兜底

这套策略上线后,转码成功率从 92% 提升到了 99.7%。

23.4 实战:一个完整的转码工具

把上面这些知识点串起来,就是一个完整的转码工具。核心流程我用 SVG 画出来了:

视频转码核心流程 输入源文件 MediaExtractor 解封装 MediaCodec 解码 分辨率调整 / 码率控制 / 格式转换 MediaMuxer 封装输出 输入阶段 处理阶段 转码阶段 输出阶段

这个流程看起来简单,但实际编码时要注意几个细节:

  • 时间戳同步:解码后的帧要带着原始 PTS(显示时间戳)传给编码器,否则输出视频会音画不同步
  • 颜色空间转换:解码器输出的是 YUV 420SP,编码器可能要求 YUV 420P,需要做转换
  • 关键帧对齐:如果源视频的关键帧间隔是 2 秒,你转码时最好也保持这个间隔,否则 seek 时会不准
⚠️ 我曾经踩过的坑: 有一次转码后视频时长变短了。查了半天,发现是解码器丢掉了最后几帧——因为编码器在收到 EOS(结束标志)后,没有把缓冲区里的帧全部刷出来。解决方案是:在发送 EOS 后,再循环调用 dequeueOutputBuffer 直到返回 INFO_END_OF_STREAM

23.5 性能优化:让转码跑得更快

转码是个计算密集型任务。如果你不做优化,一个 10 分钟的 1080p 视频,可能得转 20 分钟。这谁受得了?

我常用的优化手段:

  1. 使用 Surface 输入:解码器直接输出到 Surface,编码器从 Surface 读取,省去内存拷贝
  2. 开启硬件加速:MediaCodec 默认使用硬件编解码,但有些设备需要显式指定
  3. 多线程并行:把视频切成多个 GOP(图像组),用多个线程并行转码,最后再拼接
  4. 跳过 B 帧:如果对压缩率要求不高,可以设置编码器只输出 I 帧和 P 帧,减少计算量
// 开启硬件加速的配置
MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);
format.setFeatureEnabled(MediaFormat.KEY_FEATURE_AVC_ENCODER, true);
// 或者指定编码器名称
MediaCodec encoder = MediaCodec.createByCodecName("OMX.qcom.video.encoder.avc");
💡 我的经验: 在大多数中高端手机上,硬件转码的速度可以达到 2x~4x 实时。也就是说,10 分钟的视频,3~5 分钟就能转完。但如果遇到低端机,或者编码格式不兼容(比如要转 H.265),硬件加速可能失效,这时候就要回退到软件编码。软件编码慢很多,但兼容性最好。

好了,这一章的内容就到这里。分辨率调整、码率控制、格式转换,这三件事看起来各自独立,但在实际项目中往往是同时进行的。你想想看,用户上传一个视频,你既要缩小分辨率,又要降低码率,还要转成 MP4 格式——这不就是三个操作一起上吗?

掌握好这些基础,后面遇到更复杂的转码需求(比如添加水印、裁剪片段、拼接视频),你也能游刃有余。