6、视频轨道提取实战:从MP4文件中提取H.264/H.265裸流

好,咱们今天来干点实在的。

前面几章我们把 MediaExtractor 的基本用法、轨道选择、数据读取都捋了一遍。说实话,那些都是热身。今天这个才是真刀真枪的实战——从 MP4 文件里把 H.264 或 H.265 的裸流提取出来

什么叫裸流?就是去掉 MP4 的容器封装,只保留纯视频编码数据。H.264 裸流就是一堆 NAL Unit 组成的字节流,H.265 也一样。你想想看,很多播放器、编码器、流媒体服务器要的就是这个裸流,而不是 MP4 文件本身。

6.1 裸流提取的核心思路

说白了,MediaExtractor 给我们提供的是已经解封装好的数据。我们只需要做两件事:

  1. 找到视频轨道——判断它是 H.264 还是 H.265
  2. 读取数据并写入文件——注意要加上起始码(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/avcvideo/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-0csd-1csd-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 知识体系图

下面这张图帮你理清整个提取流程:

H.264/H.265 裸流提取流程 1. 打开文件 MediaExtractor setDataSource() 2. 选择视频轨道 遍历轨道 匹配 video/avc 或 video/hevc 3. 获取 CSD 数据 csd-0, csd-1, csd-2 SPS / PPS / VPS 4. 写入起始码 0x00 0x00 0x00 0x01 5. 循环读取数据 readSampleData() + advance() ⚠️ 关键注意事项 每个 NAL Unit 前都要加起始码 CSD 数据必须最先写入文件 H.265 可能有 VPS,需处理 csd-2

6.6 验证提取结果

代码写完了,怎么验证对不对?我一般用两个方法:

  1. 用命令行工具检查——比如 ffprobe output.h264,如果能正确识别出编码类型和分辨率,说明提取成功。
  2. 用播放器直接播放——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