6、视频轨道提取实战:从MP4文件中提取H.264/H.265裸流
好,咱们今天来干点实在的。
前面几章我们把 MediaExtractor 的基本用法、轨道选择、数据读取都捋了一遍。说实话,那些都是热身。今天这个才是真刀真枪的实战——从 MP4 文件里把 H.264 或 H.265 的裸流提取出来。
什么叫裸流?就是去掉 MP4 的容器封装,只保留纯视频编码数据。H.264 裸流就是一堆 NAL Unit 组成的字节流,H.265 也一样。你想想看,很多播放器、编码器、流媒体服务器要的就是这个裸流,而不是 MP4 文件本身。
6.1 裸流提取的核心思路
说白了,MediaExtractor 给我们提供的是已经解封装好的数据。我们只需要做两件事:
- 找到视频轨道——判断它是 H.264 还是 H.265
- 读取数据并写入文件——注意要加上起始码(Start Code)
嗯,这里要注意。MediaExtractor 读取出来的数据,默认是不带起始码的。起始码是什么?H.264 用 0x00 0x00 0x00 0x01,H.265 也一样。每个 NAL Unit 前面都要有这个标记,解码器才能正确切分。
我在项目中遇到过一个问题:有个同事提取出来的裸流,用播放器打开直接花屏。查了半天,发现就是忘了加起始码。你想想看,解码器找不到 NAL 边界,不花屏才怪。
核心要点:
- MediaExtractor 读取的数据是 不含起始码 的
- 每个 NAL Unit 前面必须手动加上
0x00 0x00 0x00 0x01 - H.264 和 H.265 的起始码格式相同,但 NAL Unit 类型不同
6.2 判断视频编码类型
怎么知道这个 MP4 文件里是 H.264 还是 H.265?很简单,看 MediaFormat 的 MIME 类型。
MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource(filePath);
int videoTrackIndex = -1;
String mimeType = null;
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/")) {
videoTrackIndex = i;
mimeType = mime;
break;
}
}
if (videoTrackIndex == -1) {
// 没有视频轨道,直接返回
return;
}
boolean isH264 = "video/avc".equals(mimeType);
boolean isH265 = "video/hevc".equals(mimeType);
我个人习惯用 video/avc 和 video/hevc 来判断。avc 就是 H.264,hevc 就是 H.265。Android 的 MediaExtractor 返回的 MIME 类型是固定的,不会出现歧义。
6.3 提取裸流的核心代码
好,接下来是重头戏。我们直接上代码,边看边讲。
extractor.selectTrack(videoTrackIndex);
// 输出文件,比如 output.h264 或 output.h265
String outputPath = isH264 ? "output.h264" : "output.h265";
FileOutputStream fos = new FileOutputStream(outputPath);
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024); // 1MB 缓冲区
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
// 先写入 CSD 数据(SPS/PPS)
MediaFormat format = extractor.getTrackFormat(videoTrackIndex);
byte[] csd0 = format.getByteBuffer("csd-0").array();
byte[] csd1 = format.getByteBuffer("csd-1").array();
// 写入起始码 + SPS
fos.write(new byte[]{0x00, 0x00, 0x00, 0x01});
fos.write(csd0);
// 写入起始码 + PPS
fos.write(new byte[]{0x00, 0x00, 0x00, 0x01});
fos.write(csd1);
// 开始读取视频数据
while (true) {
int sampleSize = extractor.readSampleData(buffer, 0);
if (sampleSize < 0) {
break; // 读取完毕
}
// 写入起始码
fos.write(new byte[]{0x00, 0x00, 0x00, 0x01});
// 写入实际数据
byte[] data = new byte[sampleSize];
buffer.position(0);
buffer.get(data, 0, sampleSize);
fos.write(data);
// 前进到下一帧
extractor.advance();
}
fos.close();
extractor.release();
这段代码看起来简单,但有几个坑我得提醒你。
避坑指南:
- CSD 数据必须最先写入——SPS 和 PPS 是解码器的配置信息,没有它们,解码器不知道视频的分辨率、帧率等参数。我曾经见过有人把 CSD 写在文件中间,结果解码器直接崩溃。
- 每个 NAL Unit 前面都要加起始码——包括 CSD 数据。很多新手只给视频帧加,忘了给 SPS/PPS 加,结果文件不合法。
- 缓冲区大小要合理——1MB 对于 1080p 的视频基本够用,但如果是 4K 视频,建议用 4MB 或更大。
6.4 处理 H.265 的特殊情况
H.265 和 H.264 在提取流程上基本一样,但有一个区别:H.265 的 CSD 数据可能包含 VPS(Video Parameter Set)。VPS 是 H.265 新增的,放在 SPS 之前。
我记得第一次提取 H.265 裸流时,直接套用了 H.264 的代码,结果播放器提示「不支持该格式」。后来查文档才发现,H.265 的 CSD 数据顺序是:VPS → SPS → PPS,而 H.264 只有 SPS → PPS。
好在 Android 的 MediaFormat 已经帮我们处理好了。你只需要按顺序把 csd-0、csd-1、csd-2 都写进去就行。
// H.265 的 CSD 处理
for (int i = 0; ; i++) {
String key = "csd-" + i;
ByteBuffer csd = format.getByteBuffer(key);
if (csd == null) break;
fos.write(new byte[]{0x00, 0x00, 0x00, 0x01});
fos.write(csd.array());
}
这样写更通用,不管 H.264 还是 H.265,都能正确处理。
6.5 知识体系图
下面这张图帮你理清整个提取流程:
6.6 验证提取结果
代码写完了,怎么验证对不对?我一般用两个方法:
- 用命令行工具检查——比如
ffprobe output.h264,如果能正确识别出编码类型和分辨率,说明提取成功。 - 用播放器直接播放——VLC、MPV 都支持裸流播放。如果画面正常,没有花屏、绿屏,那就没问题。
小技巧:
如果你提取的是 H.264 裸流,可以用 ffplay output.h264 直接播放。H.265 也一样,ffplay output.h265。如果播放器提示「找不到解码器」,那多半是 CSD 数据没写对。
6.7 性能优化建议
最后聊点性能方面的。如果你要提取的视频很大,比如 4K 长视频,有几个优化点:
- 使用 BufferedOutputStream——直接 FileOutputStream 写入效率很低,包一层缓冲能快很多。
- 复用 ByteBuffer——不要在循环里反复创建 ByteBuffer,提前分配好,每次用完后 clear() 即可。
- 考虑异步写入——如果对实时性有要求,可以用生产者-消费者模式,读取线程和写入线程分开。
我个人习惯用 BufferedOutputStream 配合 4MB 的缓冲区,实测比直接写入快 3-5 倍。你想想看,每次写文件都要系统调用,攒一批再写,效率自然高。
好了,这一章的内容就到这。代码不多,但都是实打实的干货。你拿去跑一遍,应该就能从 MP4 里提取出干净的 H.264/H.265 裸流了。
公众号:蓝海资料掘金营,微信deep3321