目录
  1. 1. 一、引言:为什么音视频技术如此重要
  2. 2. 二、PCM 音频:数字声音的根基
    1. 2.1. 2.1 声音是如何变成数字的
    2. 2.2. 2.2 采样率:44.1kHz vs 48kHz
    3. 2.3. 2.3 位深度:16-bit vs 24-bit vs 32-bit
    4. 2.4. 2.4 声道布局
    5. 2.5. 2.5 WAV 文件格式
  3. 3. 三、YUV 色彩空间:视频的图像表示
    1. 3.1. 3.1 为什么视频使用 YUV 而不是 RGB
    2. 3.2. 3.2 YUV 的平面存储格式与半平面存储格式
    3. 3.3. 3.3 YUV ↔ RGB 转换
  4. 4. 四、H.264 编码管线:逐步骤深入
    1. 4.1. 4.1 H.264 编码器总体架构
    2. 4.2. 4.2 第一步:帧分割 —— 从帧到宏块
    3. 4.3. 4.3 第二步:预测(Prediction)
      1. 4.3.1. 4.3.1 帧内预测(Intra Prediction)—— 消除空间冗余
      2. 4.3.2. 4.3.2 帧间预测(Inter Prediction)—— 消除时间冗余
      3. 4.3.3. 4.3.3 预测模式决策
    4. 4.4. 4.4 第三步:变换与量化
      1. 4.4.1. 4.4.1 DCT 变换(整数 DCT)
      2. 4.4.2. 4.4.2 量化(Quantization)—— 有损压缩的核心
    5. 4.5. 4.5 第四步:熵编码
      1. 4.5.1. CAVLC(Context-Adaptive Variable Length Coding)
      2. 4.5.2. CABAC(Context-Adaptive Binary Arithmetic Coding)
    6. 4.6. 4.6 第五步:去块滤波(Deblocking Filter)
  5. 5. 五、I/P/B 帧与 GOP 结构
    1. 5.1. 5.1 三种帧类型
    2. 5.2. 5.2 GOP(Group of Pictures)
  6. 6. 六、容器格式
    1. 6.1. 6.1 MP4 —— 最通用的视频容器
    2. 6.2. 6.2 MKV / WebM —— 开放标准的容器
    3. 6.3. 6.3 FLV —— 直播时代的遗产
  7. 7. 七、流媒体协议
    1. 7.1. 7.1 RTMP —— 实时消息传输协议
    2. 7.2. 7.2 HLS —— HTTP Live Streaming
    3. 7.3. 7.3 DASH —— Dynamic Adaptive Streaming over HTTP
    4. 7.4. 7.4 WebRTC —— 实时通信的革命
  8. 8. 八、Android MediaCodec 实战
    1. 8.1. 8.1 MediaCodec 的生命周期
    2. 8.2. 8.2 同步模式(Synchronous Mode)
    3. 8.3. 8.3 异步模式(Asynchronous Mode)
    4. 8.4. 8.4 Surface 输入 —— 零拷贝编码
    5. 8.5. 8.5 编解码器选择
    6. 8.6. 8.6 常见问题与最佳实践
  9. 9. 九、总结与学习路径
【音视频、图像处理技术】音视频基础技术

一、引言:为什么音视频技术如此重要

如果说 HTTP 和 TCP/IP 是互联网的骨架,那么音视频技术就是互联网的血肉。2025 年,视频流占互联网总流量的 82% 以上。从抖音短视频到 Zoom 视频会议,从 Netflix 4K 流媒体到微信视频通话,音视频技术无处不在。

对于 Android 开发者而言,音视频技术更是绕不开的领域。无论是实现一个自定义相机预览,还是开发一套直播推流 SDK,或者优化视频播放的起播速度,都绕不开对底层音视频原理的深入理解。MediaCodec、MediaMuxer、OpenGL ES、FFmpeg 这些 API 和工具都只是表面——真正的内力来自于对 PCM、YUV、H.264、容器格式、流媒体协议的透彻理解。

本文将从最基础的音频采样和色彩空间讲起,深入到 H.264 编码管线,再到容器格式和流媒体协议,最后落地到 Android MediaCodec 的实战用法。这是一条从底层原理到工程实践的学习路径。


二、PCM 音频:数字声音的根基

2.1 声音是如何变成数字的

声音的本质是空气中传播的机械振动——声波。声波是连续的模拟信号,要让计算机处理它,必须经过脉冲编码调制(PCM, Pulse Code Modulation)过程,将模拟信号转换为数字信号。

PCM 的三步转换:

第 1 步:采样(Sampling)
以固定的时间间隔测量模拟信号的幅度。采样率(Sample Rate)决定了每秒采样的次数。根据奈奎斯特-香农采样定理,要无损地表示频率为 f 的信号,采样率必须至少为 2f。人耳能听到的最高频率约 20kHz,因此 CD 音质的采样率设定为 44.1kHz(略高于 2 × 20kHz)。

第 2 步:量化(Quantization)
将每个采样的连续幅度值映射到离散的数值等级。量化精度由位深度(Bit Depth)决定,位深度越高,量化噪声越低,动态范围越大。CD 使用 16 位量化。

第 3 步:编码(Encoding)
将量化后的数值表示为二进制数据。PCM 是最简单的编码方式,直接将量化值按顺序排列。有损压缩编码(如 MP3、AAC、Opus)则在此基础上进行心理声学建模和数据压缩。

模拟声波 (连续的电压信号)

▼ [采样器,每 1/44100 秒测量一次]

离散采样值: 0.12, 0.35, 0.67, 0.89, 0.95, 0.82, ...

▼ [量化器,映射到 16 位整数范围 -32768 ~ 32767]

量化值: 3932, 11469, 21955, 29164, 31130, 26870, ...

▼ [编码为二进制 PCM 流]

PCM 数据: 0x0F5C, 0x2CCD, 0x55C3, 0x71EC, 0x799A, 0x68F6, ...

2.2 采样率:44.1kHz vs 48kHz

44.1kHz —— 音频 CD 标准:

  • 由 Sony 和 Philips 在 1979 年联合制定(红皮书标准)
  • 选择 44.1kHz 的历史原因:早期数字音频使用录像带存储,44.1kHz 可以同时兼容 PAL(588 行/帧)和 NTSC(490 行/帧)制式的录像带
  • 今天仍是音乐制作和消费音频的主流采样率(MP3、AAC、流媒体音乐)

48kHz —— 专业视频制作标准:

  • 由电影和电视工业推动
  • 与视频帧率(24/25/30fps)的整数倍关系更好,便于音视频同步
  • 48kHz × 1024 采样/帧 ÷ 48000 = 与 24fps 视频每帧 2000 个采样,整数对齐
  • 广泛用于 DVD、蓝光、数字电视、YouTube

其他常见采样率:

  • 8kHz: 电话语音(VoIP、PSTN),足够覆盖 300-3400Hz 的语音频段
  • 16kHz: 宽带语音(VoLTE、WebRTC 默认),覆盖到 8kHz
  • 22.05kHz: 44.1kHz 的一半,低质量音乐
  • 32kHz: 专业广播和某些 VoIP 编解码器
  • 96kHz / 192kHz: 高解析度音频(Hi-Res Audio),主要用于录音棚制作和发烧友市场

Android 平台支持的采样率:
大多数 Android 设备的原生采样率是 48kHz。使用 AudioTrackAAudio 时,如果输入数据采样率与硬件不匹配,Android 的 AudioFlinger 会进行重采样(Resampling),这会引入额外的延迟和 CPU 开销。因此最优实践是始终使用 AudioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE) 获取设备原生采样率。

2.3 位深度:16-bit vs 24-bit vs 32-bit

位深度决定了每个采样值用多少比特表示,直接影响音频的动态范围和量化噪声。

16-bit(CD 标准):

  • 数值范围:-32768 ~ 32767(2^16 = 65536 个级别)
  • 理论动态范围:20 × log10(65536) ≈ 96.3 dB
  • 量化噪声的理论信噪比(SNR):约 96 dB
  • 适用场景:消费级音频播放、流媒体、游戏音效

24-bit(录音棚标准):

  • 数值范围:-8388608 ~ 8388607(2^24 = 16777216 个级别)
  • 理论动态范围:20 × log10(16777216) ≈ 144.5 dB
  • 远超人类听觉的动态范围(约 120 dB 从听阈到痛阈)
  • 适用场景:专业录音、混音、母带制作

32-bit(浮点):

  • IEEE 754 单精度浮点数:1 位符号 + 8 位指数 + 23 位尾数
  • 动态范围:约 1528 dB(因为浮点格式的动态范围不取决于位宽,而是指数量级范围)
  • 永远不会”数字削波”——超过 0 dBFS 的值仍能精确表示(值 > 1.0 或 < -1.0)
  • 适用场景:DAW(数字音频工作站)内部处理、音频插件链、FFmpeg 内部处理(flt 采样格式)

位深度的实际影响:

  • 更高的位深度**不意味着更好的”音质”**——一旦超过视频播放环境的噪声底噪(通常 40-60 dB SPL),额外的动态范围对听感没有贡献
  • 但更高的位深度对后期处理至关重要:在混音中多次调整增益、EQ、混响等操作会在每个步骤累积量化误差。使用 24-bit 或 32-bit 浮点作为工作格式,可以保持后期处理中的精度
  • 交付格式(MP3、AAC、Opus)不需要高位深:有损编解码器在编码过程中已经引入了远大于量化噪声的压缩失真

2.4 声道布局

单声道(Mono): 1 个声道。所有声音来自同一位置(或中心)。PCM 数据布局:[L]

立体声(Stereo): 2 个声道。最常见的消费音频格式,通过左右声道差异营造声场。PCM 数据布局(交错存储):[L0, R0, L1, R1, L2, R2, …]

5.1 环绕声: 6 个声道:

  • 前置左(FL)、前置右(FR)
  • 中置(FC)——主要用于对话
  • 低频效果(LFE / Subwoofer)——“.1” 表示只处理低频
  • 后置左(BL / SL)、后置右(BR / SR)

7.1 环绕声: 8 个声道:在 5.1 基础上增加侧环绕左(SL)和侧环绕右(SR)

Android 的声道掩码(Channel Mask):

// AudioFormat 中的声道常量
AudioFormat.CHANNEL_OUT_MONO // 单声道
AudioFormat.CHANNEL_OUT_STEREO // 立体声 (FL, FR)
AudioFormat.CHANNEL_OUT_5POINT1 // 5.1 (FL, FR, FC, LFE, BL, BR)
AudioFormat.CHANNEL_OUT_7POINT1 // 7.1 (FL, FR, FC, LFE, BL, BR, SL, SR)

声道布局 vs 声道数量:
声道数量(channel count)只是一个整数,声道布局(channel mask)描述了每个声道的空间位置。在 Android 的 AudioTrack 初始化时,如果只传声道数量而不传声道掩码,系统会用默认的布局映射。

2.5 WAV 文件格式

WAV(Waveform Audio File Format)是最简单的 PCM 音频容器。它基于 RIFF(Resource Interchange File Format)结构,由一个个”块”(chunk)级联而成。

WAV 文件的二进制结构:

┌─────────────────────────────────────────────────────────────────────┐
│ RIFF Chunk Descriptor │
├──────────────────────┬──────────────────────────────────────────────┤
│ ChunkID (4 bytes)│ "RIFF" │
│ ChunkSize (4 bytes)│ 36 + Subchunk2Size (文件总字节数 - 8) │
│ Format (4 bytes)│ "WAVE" │
├──────────────────────┴──────────────────────────────────────────────┤
│ Subchunk1: "fmt " (格式信息) │
├──────────────────────┬──────────────────────────────────────────────┤
│ Subchunk1ID (4 bytes)│ "fmt " (注意尾部空格) │
│ Subchunk1Size (4B) │ 16 (PCM) │
│ AudioFormat (2B) │ 1 = PCM (线性量化) │
│ NumChannels (2B) │ 1 = 单声道, 2 = 立体声 │
│ SampleRate (4B) │ 44100, 48000, ... │
│ ByteRate (4B) │ SampleRate × NumChannels × BitsPerSample/8 │
│ BlockAlign (2B) │ NumChannels × BitsPerSample/8 │
│ BitsPerSample (2B) │ 8, 16, 24, 32 │
├──────────────────────┴──────────────────────────────────────────────┤
│ Subchunk2: "data" (PCM 数据) │
├──────────────────────┬──────────────────────────────────────────────┤
│ Subchunk2ID (4 bytes)│ "data" │
│ Subchunk2Size (4B) │ NumSamples × NumChannels × BitsPerSample/8 │
│ Data (可变) │ PCM 采样数据,按声道交错排列 │
└──────────────────────┴──────────────────────────────────────────────┘

一个具体的 44.1kHz/16-bit/立体声 WAV 文件(前 44 字节头部):

偏移    内容                说明
0x00 52 49 46 46 "RIFF"
0x04 xx xx xx xx ChunkSize(文件大小 - 8)
0x08 57 41 56 45 "WAVE"
0x0C 66 6D 74 20 "fmt "(含尾部空格)
0x10 10 00 00 00 Subchunk1Size = 16
0x14 01 00 AudioFormat = 1 (PCM)
0x16 02 00 NumChannels = 2(立体声)
0x18 44 AC 00 00 SampleRate = 44100
0x1C 10 B1 02 00 ByteRate = 44100 × 2 × 2 = 176400
0x20 04 00 BlockAlign = 2 × 2 = 4
0x22 10 00 BitsPerSample = 16
0x24 64 61 74 61 "data"
0x28 xx xx xx xx Subchunk2Size(PCM 数据总字节数)
0x2C ... PCM 采样数据开始

WAV 的局限性:

  • 文件最大约 4GB(RIFF 的 ChunkSize 是 32 位无符号整数)
  • 不支持流式传输(必须在文件头部已知总数据大小)
  • 元数据支持非常有限(没有内建的标题、艺术家、专辑等字段)
  • 扩展格式 RF64 解决了 4GB 限制,但兼容性差

三、YUV 色彩空间:视频的图像表示

3.1 为什么视频使用 YUV 而不是 RGB

人眼对亮度(明暗)变化的敏感度远高于对颜色(色度)变化的敏感度。这一生物学事实是视频压缩的生理学基础。

YUV 的三个分量:

  • Y(Luma,亮度): 表示像素的明暗程度。黑白电视只显示这一分量
  • U(Cb,蓝色色度分量): 蓝色与亮度的差值
  • V(Cr,红色色度分量): 红色与亮度的差值

色度子采样(Chroma Subsampling):
利用人眼对色度不敏感的特性,视频编码使用色度子采样来减少数据量——保持每个像素的 Y 分量不变,但对 U 和 V 分量进行降采样。

常见的 YUV 格式标识(以 4:x:y 格式描述):

  • 4:4:4: 无子采样。每个像素有独立的 Y、U、V 值。质量最高,数据量最大(每 4 像素 12 个采样值)。用于专业后期制作和数字中间片。

  • 4:2:2: 水平方向每 2 个像素共用一对 UV。每 4 像素 8 个采样值。用于广播级制作(如广电录像机)和部分专业视频接口(SDI)。

  • 4:2:0: 水平方向和垂直方向都 2:1 子采样。每 2×2 的 4 个像素块共用一对 UV。数据量仅为 4:4:4 的 50%。这是消费级视频的绝对主流格式,H.264/H.265/VP9/AV1 几乎都使用 4:2:0。

4:2:0 色度子采样的像素布局 (每 2×2 = 4 个像素块):

Y 采样 (每个像素一个): U 采样 (4 个像素共用): V 采样 (4 个像素共用):
┌─────┬─────┐ ┌─────────────────┐ ┌─────────────────┐
│ Y00 │ Y01 │ │ │ │ │
├─────┼─────┤ │ U00 │ │ V00 │
│ Y10 │ Y11 │ │ │ │ │
└─────┴─────┘ └─────────────────┘ └─────────────────┘

4 个 Y 采样 + 1 个 U 采样 + 1 个 V 采样 = 6 个采样 (vs 4:4:4 需要 12 个采样)

3.2 YUV 的平面存储格式与半平面存储格式

YUV 数据在内存中的排列方式分为两大类:平面格式(Planar)半平面格式(Semi-Planar / Interleaved)

平面格式 —— I420(YUV420P):
三个分量分别存储为独立的平面(plane)。先存储所有像素的 Y,再存储所有像素的 U,最后存储所有像素的 V。

I420 内存布局 (4×4 像素为例):
Y plane: [Y00 Y01 Y02 Y03 Y10 Y11 Y12 Y13 Y20 Y21 Y22 Y23 Y30 Y31 Y32 Y33]
U plane: [U00 U01 U10 U11] ← 2×2 块,4:2:0 下采样
V plane: [V00 V01 V10 V11] ← 2×2 块,4:2:0 下采样

总字节数: 16 + 4 + 4 = 24 (每像素平均 1.5 字节,即 12 bit/pixel)

I420 是最常用的标准 YUV 格式,也是 Android MediaCodec 输出 COLOR_FormatYUV420Planar 的默认格式之一。

半平面格式 —— NV12:
Y 分量独占一个平面,U 和 V 分量交错存储在第二个平面中(UV 交错,即 UVUVUV…)。

NV12 内存布局 (4×4 像素为例):
Y plane: [Y00 Y01 Y02 Y03 Y10 Y11 Y12 Y13 Y20 Y21 Y22 Y23 Y30 Y31 Y32 Y33]
UV plane: [U00 V00 U01 V01 U10 V10 U11 V11] ← UV 交错

总字节数: 16 + 8 = 24 (每像素平均 1.5 字节)

NV12 是 Android 平台最广泛使用的 YUV 格式。Android Camera2 API 的 ImageFormat.NV21(见下文)和 MediaCodec 的 COLOR_FormatYUV420SemiPlanar 都使用类似的半平面布局。

半平面格式 —— NV21:
与 NV12 类似,但 U 和 V 的次序相反:V 在前,U 在后(即 VUVUVU…)。

NV21 内存布局 (4×4 像素为例):
Y plane: [Y00 Y01 Y02 Y03 Y10 Y11 Y12 Y13 Y20 Y21 Y22 Y23 Y30 Y31 Y32 Y33]
VU plane: [V00 U00 V01 U01 V10 U10 V11 U11] ← VU 交错 (注意次序与 NV12 不同)

总字节数: 16 + 8 = 24

NV21 是 Android 的默认相机预览格式Camera1 API 的 onPreviewFrame() 回调默认返回 NV21 数据。如果你需要将 Camera 帧送入 MediaCodec 编码器,通常需要在 NV21(相机输出)和 NV12(编码器输入)之间做转换——或者更简单地,使用 Surface 输入模式避免这个转换。

三大 YUV 格式对比:

特性 I420 (YUV420P) NV12 NV21
存储方式 三平面 双平面 (UV交错) 双平面 (VU交错)
Y 平面大小 W × H W × H W × H
UV 平面大小 W/2 × H/2 × 2 W × H/2 W × H/2
UV 序 U plane + V plane U+V 交错 V+U 交错
主流用途 编解码通用格式 Android MediaCodec 编码 Android Camera 预览

3.3 YUV ↔ RGB 转换

颜色空间转换是视频处理中最频繁的操作之一。以下是 BT.601 标准(SD 视频)的转换公式,也是 Android 平台最通用的转换。

RGB → YUV(BT.601,用于编码前的颜色空间转换):

Y  =  0.299 × R  + 0.587 × G  + 0.114 × B
Cb = -0.169 × R - 0.331 × G + 0.500 × B + 128
Cr = 0.500 × R - 0.419 × G - 0.081 × B + 128

注意 Y 的权重分配(0.299 / 0.587 / 0.114)反映了人眼对绿色最敏感、对红色次之、对蓝色最不敏感的特性。这就是为什么绿色通道携带了最多的亮度信息。

YUV → RGB(BT.601,用于显示时的转换):

R = Y + 1.402   × (Cr - 128)
G = Y - 0.34414 × (Cb - 128) - 0.71414 × (Cr - 128)
B = Y + 1.772 × (Cb - 128)

整型优化(避免浮点运算):
在移动设备和嵌入式平台上,浮点运算代价高昂。实际实现通常使用定点运算 + 移位来加速。例如,通过将系数乘以 2^n 并四舍五入为整数,转换后右移 n 位:

// 快速 YUV420 (NV21) → RGB888 转换 (定点优化)
// YUV 取值范围: Y ∈ [16, 235], UV ∈ [16, 240] (TV range / limited range)
// 对于 full range (JPEG range, YUV ∈ [0, 255]),需使用不同的公式

void nv21_to_rgb888(uint8_t *y_plane, uint8_t *vu_plane,
int width, int height, uint8_t *rgb_out) {
int frame_size = width * height;
for (int j = 0, yp = 0; j < height; j++) {
int uvp = frame_size + (j >> 1) * width;
int u = 0, v = 0;
for (int i = 0; i < width; i++, yp++) {
int y = (0xff & ((int) y_plane[yp])) - 16;
if (y < 0) y = 0;
if ((i & 1) == 0) {
v = (0xff & vu_plane[uvp++]) - 128; // NV21: V first
u = (0xff & vu_plane[uvp++]) - 128;
}
// 定点乘法: 系数 × 2^10 (1024)
int r = (1192 * y + 1634 * v) >> 10;
int g = (1192 * y - 833 * v - 400 * u) >> 10;
int b = (1192 * y + 2066 * u) >> 10;
// 饱和截断到 [0, 255]
rgb_out[yp * 3] = r < 0 ? 0 : (r > 255 ? 255 : r);
rgb_out[yp * 3 + 1] = g < 0 ? 0 : (g > 255 ? 255 : g);
rgb_out[yp * 3 + 2] = b < 0 ? 0 : (b > 255 ? 255 : b);
}
}
}

YUV 范围:TV Range vs Full Range(JPEG Range):

  • TV Range(Limited Range / BT.601/BT.709 标准): Y ∈ [16, 235], UV ∈ [16, 240]。这是广播电视的传统范围,为超调和下冲留了余量。
  • Full Range(JPEG Range): YUV ∈ [0, 255]。不需要映射偏移,数据更简单。

Android MediaCodec 默认使用 TV Range(除非编码器配置了特定 profile),解码后如果需要送入 OpenGL 纹理(期望 0-255 范围),需要注意范围映射。


四、H.264 编码管线:逐步骤深入

H.264(MPEG-4 AVC, Advanced Video Coding)至今仍是世界上最广泛使用的视频编解码标准。从 YouTube 视频到蓝光光盘,从安防监控到视频会议,H.264 无处不在。

H.264 编码器的工作不是”压缩像素”,而是消除冗余。视频中存在三类冗余:

  • 空间冗余(Spatial Redundancy): 同一帧内相邻像素通常相似 → 帧内预测
  • 时间冗余(Temporal Redundancy): 相邻帧之间通常变化很小 → 帧间预测
  • 统计冗余(Statistical Redundancy): 数据中的统计模式 → 熵编码

4.1 H.264 编码器总体架构

输入视频帧 (YUV)


┌──────────────────────┐
│ 1. 帧分割 │ 将一帧分割为宏块 (Macroblock, 16×16 像素)
│ 成宏块 │
└──────┬───────────────┘


┌──────────────────────┐
│ 2. 预测 (Prediction)│ 帧内预测 (空间) 或 帧间预测 (时间)
│ 生成预测帧 │ 输出: 预测块 + 残差
└──────┬───────────────┘


┌──────────────────────┐
│ 3. 变换与量化 │ DCT 变换 → 量化
│ 残差 → 系数 │ 这一步是"有损"的核心所在
└──────┬───────────────┘


┌──────────────────────┐
│ 4. 熵编码 │ CAVLC / CABAC
│ 系数 → 比特流 │ 无损压缩
└──────┬───────────────┘


┌──────────────────────┐
│ 5. 重建 (Decoder │ 逆量化 → 逆 DCT → 加回预测 → 去块滤波
│ Loop) │ 编码器内部维护一个解码器,确保一致
└──────────────────────┘


输出 NAL 单元 (Network Abstraction Layer units)

重建回路(Decoder Loop)为什么重要?
编码器的预测需要解码后的参考帧,而不是原始帧。如果编码器拿原始帧做预测而解码器拿重建帧做预测,误差会累积,最终导致漂移(Drift)——画质越来越差。因此每个 H.264 编码器内部都包含一个完整的解码器,确保预测基础一致。

4.2 第一步:帧分割 —— 从帧到宏块

H.264 将一帧图像分割为宏块(Macroblock, MB)作为基本处理单元:

  • 每个宏块包含一个 16×16 的亮度(Y)区域
  • 对应的两个 8×8 的色度区域(U 和 V,4:2:0 格式下)

对于 1080p(1920×1080)视频:

  • Y 层宏块数量:1920/16 × 1080/16 = 120 × 67.5 = 120 × 68 = 8160 个宏块
  • 每行最后一个宏块需要填充(padding)到 16 的整数倍

在 H.264 中,每个 16×16 宏块还可以进一步分割为更小的子宏块(Sub-macroblock Partition)用于运动补偿:

  • 16×16, 16×8, 8×16, 8×8
  • 8×8 还可以再分割为 8×4, 4×8, 4×4

更小的分割能更精确地匹配物体运动,但需要更多的运动矢量编码开销。

4.3 第二步:预测(Prediction)

预测是视频压缩的核心。H.264 提供了两大类预测模式:

4.3.1 帧内预测(Intra Prediction)—— 消除空间冗余

帧内预测利用同一帧内已编码的相邻像素来预测当前块,只传输预测模式编号和残差。

H.264 定义了 3 种亮度帧内预测类型(基于块大小):

  • Intra 4×4: 9 种预测模式。适合纹理细节丰富的区域
  • Intra 8×8: 9 种预测模式(High Profile 新增)。适合较平滑的渐变区域
  • Intra 16×16: 4 种预测模式。适合平坦区域

Intra 4×4 的 9 种预测模式:

当前 4×4 块依赖的参考像素 (已编码的相邻块):
Q A B C D E F G H
I [a b c d] ← 当前块 (待预测)
J [e f g h]
K [i j k l]
L [m n o p]

模式 0: Vertical (垂直)
a = e = i = m = A
b = f = j = n = B
c = g = k = o = C
d = h = l = p = D

模式 1: Horizontal (水平)
a = b = c = d = I
e = f = g = h = J
i = j = k = l = K
m = n = o = p = L

模式 2: DC (平均值)
all pixels = mean(A+B+C+D+I+J+K+L) / 8

模式 3: Diagonal Down-Left (对角线左下)
沿 45° 方向插值: a = (A + 2B + C + 2)/4

模式 4: Diagonal Down-Right (对角线右下)
沿 135° 方向插值

模式 5: Vertical-Right (垂直偏右)
模式 6: Horizontal-Down (水平偏下)
模式 7: Vertical-Left (垂直偏左)
模式 8: Horizontal-Up (水平偏上)

编码器会对所有 9 种模式计算率失真代价(Rate-Distortion Cost)RDcost = Distortion + λ × Rate,选择代价最小的模式。Distortion 是该模式预测产生的残差能量(SSD/SAD),Rate 是编码该模式所需的总比特数,λ 是拉格朗日乘子(取决于 QP)。

Intra 16×16 的 4 种预测模式:

  • Mode 0: Vertical
  • Mode 1: Horizontal
  • Mode 2: DC
  • Mode 3: Plane(平面):使用线性模型对平滑亮度渐变建模,适合天空、渐变背景等区域

色度帧内预测:
色度块(8×8,针对 4:2:0)也有自己的 4 种预测模式,类似于 Intra 16×16 的模式。

4.3.2 帧间预测(Inter Prediction)—— 消除时间冗余

帧间预测利用已编码的参考帧来预测当前块,只需传输运动矢量(指示参考块的位置)和残差

运动估计(Motion Estimation, ME):
编码器在参考帧的搜索窗口中寻找与当前块最匹配的区域。

当前帧 (时间 t)                      参考帧 (时间 t-1)
┌──────────────────────┐ ┌──────────────────────┐
│ │ │ │
│ ┌────┐ │ │ ┌────┐ │
│ │当前│ │ ──► │ │匹配│ │
│ │块 │ │ 运动 │ │块 │ │
│ └────┘ │ 矢量 │ └────┘ │
│ │ │ │
└──────────────────────┘ └──────────────────────┘

运动矢量 MV = (dx, dy) = 匹配块的位置 - 当前块的位置
残差 = 当前块 - 匹配块

运动估计算法:

  1. 全搜索(Full Search): 搜索窗口内每个可能的位置,绝对最优但计算量巨大
  2. 菱形搜索(Diamond Search): 从中心开始,按菱形模式向外搜索
  3. 六边形搜索(Hexagon Search): 类似菱形搜索但用六边形模式,收敛更快
  4. UMH(Uneven Multi-Hexagon): x264 的默认搜索算法,结合了多种搜索模式
  5. TESA(Transformed Exhaustive Search): x264 最慢但质量最高的算法

运动估计的匹配准则:

  • SAD(Sum of Absolute Differences): Σ|当前像素 - 参考像素|,硬件实现简单
  • SATD(Sum of Absolute Transformed Differences): 对残差先做 Hadamard 变换再求绝对和,码率估计更准确(x264 默认)
  • SSD(Sum of Squared Differences): 均方误差

子像素运动估计(Sub-pixel ME):
H.264 支持 1/4 像素精度(亮度)的运动估计。在搜索整像素最佳匹配后,编码器通过6 抽头 Wiener 滤波器插值出 1/2 像素位置,再通过双线性插值得到 1/4 像素位置,在这些子像素位置进一步搜索。这显著提高了运动补偿精度——特别是在缓慢运动的场景中,整像素精度可能导致”抖动”。

运动补偿(Motion Compensation, MC):
一旦确定运动矢量,编码器从参考帧中取出对应块,与当前块做差,得到残差。残差经过 DCT、量化和熵编码发送给解码器。解码器只做”运动补偿”(不需要”运动估计”,因为运动矢量已经在码流中)。

4.3.3 预测模式决策

编码器对每个宏块都要做复杂的模式决策:

  • SKIP 模式: 无残差,无运动矢量(直接沿用预测的运动矢量)。码率最小,用于静止帧或全局平移场景
  • Intra 模式: 帧内预测。用于场景切换、新出现的物体,或 I 帧中的宏块
  • Inter P 模式: 单个参考帧的帧间预测(前向预测)
  • Inter B 模式: 最多两个参考帧的帧间预测(前向+后向或双前向)

模式决策的遍历顺序(x264 的实现,从低复杂度到高复杂度):

  1. 首先检查 SKIP 模式(如代价足够低,直接选择)
  2. 测试 P 模式的各个分割
  3. 测试 Intra 模式(通常只在场景切换附近才会胜出)
  4. 对于 B 帧,还需要测试双向预测

4.4 第三步:变换与量化

4.4.1 DCT 变换(整数 DCT)

H.264 使用整数 DCT(Discrete Cosine Transform)代替传统的浮点 DCT。整数 DCT 的好处:

  • 无浮点运算,编解码结果在所有平台上 100% 一致(没有浮点舍入差异)
  • 硬件实现简单(只需加减和移位)
  • 与真正的 DCT 的近似误差极低,不影响压缩效率

4×4 整数 DCT 变换矩阵(H.264 标准定义):

正向变换:
┌ ┐ ┌ ┐ ┌ ┐T
│ 1 1 1 1 │ │ r00 r01 r02 r03│ │ 1 2 1 1 │
Y = C × R × C^T │ 2 1 -1 -2 │ │ r10 r11 r12 r13│ │ 1 1 -1 -2 │
C = ── │ 1 -1 -1 1 │ │ r20 r21 r22 r23│ × │ 1 -1 -1 2 │
└ ┘ └ ┘ └ ┘
│ 1 -2 2 -1 │ │ r30 r31 r32 r33│ │ 1 -2 2 -1 │
└ ┘ └ ┘ └ ┘

其中矩阵元素已经过缩放处理,实际的尺度因子被吸收到量化步长中

DCT 变换的核心效果:将残差数据的能量集中到左上角(低频系数)。对于大多数自然视频的残差,变换后大部分高频系数接近零,只需编码少数非零系数。

DCT 系数的空间分布:

4×4 DCT 系数矩阵:
┌──────────┬──────────┬───────────┬───────────┐
│ DC (0,0) │ AC (0,1) │ AC (0,2) │ AC (0,3) │ ← 能量集中区
│ 大值 │ 中值 │ 小值 │ 接近零 │
├──────────┼──────────┼───────────┼───────────┤
│ AC (1,0) │ AC (1,1) │ AC (1,2) │ AC (1,3) │
│ 中值 │ 小值 │ 接近零 │ 零 │
├──────────┼──────────┼───────────┼───────────┤
│ AC (2,0) │ AC (2,1) │ AC (2,2) │ AC (2,3) │
│ 小值 │ 接近零 │ 零 │ 零 │
├──────────┼──────────┼───────────┼───────────┤
│ AC (3,0) │ AC (3,1) │ AC (3,2) │ AC (3,3) │ ← 高频分量接近零
│ 接近零 │ 零 │ 零 │ 零 │
└──────────┴──────────┴───────────┴───────────┘

H.264 中还有额外的 Hadamard 变换:
对于 Intra 16×16 模式,亮度块的 16 个 DC 系数(每个 4×4 块的 DC 系数)先被组合成一个 4×4 DC 矩阵,再进行一次 Hadamard 变换。色度块的 DC 系数也是类似处理。这种分层变换进一步增强了能量集中。

4.4.2 量化(Quantization)—— 有损压缩的核心

量化是 H.264 编码中唯一的必然有损环节(预测中的子像素插值和去块滤波也是潜在的精度损失源)。它把连续的 DCT 系数映射为离散的整数级别:

量化公式:  Z_ij = round( Y_ij / Qstep )

其中:
Y_ij = 变换系数
Qstep = 量化步长 (由 QP 决定)
Z_ij = 量化后的系数

QP(Quantization Parameter)与 Qstep 的关系:

  • QP 取值范围:0 ~ 51
  • Qstep 随 QP 指数增长:QP 每增加 6,Qstep 翻倍
  • QP 增加 1,Qstep 增加约 12.5%,码率约降低 12.5%
QP 与 Qstep 对应关系:
QP: 0 6 12 18 24 30 36 42 48 51
Qstep:0.625 1.25 2.5 5 10 20 40 80 160 224

H.264 的量化矩阵(前 6 个 QP 值,4×4 块):

H.264 标准定义了量化矩阵 MF(Multiplication Factor),将除法和舍入合并为乘法+移位操作,以便于硬件实现。对于给定的 QP:

量化:  |Z_ij| = (|Y_ij| × MF[q][i][j] + f) >> qbits

其中:
MF[q][i][j] 根据系数位置有所不同 (低频系数量化更精细)
f = 2^qbits/3 (帧内) 或 2^qbits/6 (帧间) —— 决定了舍入偏向

量化引入了”死区(Dead Zone)”:
量化后,绝对值小于阈值的系数被归零。这个”死区”的大小由 f 参数控制。更大的死区清零更多系数,降低码率但损失更多细节。

量化对视觉质量的影响:

  • 低频系数量化误差 → 块效应(Blocking Artifacts),去块滤波可缓解
  • 高频系数被清零 → 细节丢失、模糊、ringing artifacts(振铃效应)
  • 色度分量过度量化 → 颜色失真和色块(Color Banding)

这就是为什么编码器通常对色度分量使用比亮度更高的 QP(”色度 QP 偏移”,通常 +6),因为人眼对色彩细节不敏感。

4.5 第四步:熵编码

熵编码(Entropy Coding)是无损压缩——它不损失任何信息,只是用更紧凑的方式表示量化后的系数。

H.264 提供了两种熵编码方案:

CAVLC(Context-Adaptive Variable Length Coding)

CAVLC 使用上下文自适应的可变长度编码表。它的核心思路:

  • 统计上,量化后的大多数系数都是零,非零系数通常集中在低频区域
  • 非零系数中,±1 出现的概率远高于其他值
  • 相邻块的非零系数数量高度相关

CAVLC 编码一个 4×4 块的全过程:

  1. 将 4×4 系数按 Zig-Zag 扫描为一维数组

    Zig-Zag 扫描顺序 (将 2D 系数矩阵 → 1D 序列):
    ┌────┬────┬────┬────┐
    │ 0 │ 1 │ 5 │ 6 │ 扫描: (0,0)→(0,1)→(1,0)→(2,0)→(1,1)→(0,2)→(0,3)→(1,2)→...
    ├────┼────┼────┼────┤
    │ 2 │ 4 │ 7 │ 12 │ 效果:低频系数在前,高频系数(多为零)集中在后面
    ├────┼────┼────┼────┤
    │ 3 │ 8 │ 11 │ 13 │
    ├────┼────┼────┼────┤
    │ 9 │ 10 │ 14 │ 15 │
    └────┴────┴────┴────┘
  2. 编码非零系数的个数(TotalCoeff)和 TrailingOnes(尾部连续的 ±1 个数,最多 3 个): 使用联合码表,根据相邻块预测当前块可能的 coeff 范围

  3. 编码每个 TrailingOne 的符号(+ 或 -): 每个 1 bit

  4. 编码剩余非零系数的值(Levels): 使用指数哥伦布编码或查找表

  5. 编码最后一个非零系数之前零的总数(TotalZeros): 使用上下文自适应的查找表

  6. 编码每个非零系数之前的零的个数(RunBefore): 从高频向低频方向,编码每个非零系数前面的连续零的个数

CABAC(Context-Adaptive Binary Arithmetic Coding)

CABAC 是 H.264 Main 和 High Profile 的默认熵编码方式,相比 CAVLC 有 9-14% 的码率节省。代价是计算复杂度显著升高。

CABAC 的三步框架:

第 1 步:二值化(Binarization)
将非二进制的语法元素(如运动矢量差值、系数 Level)映射为二进制串。常用方法:

  • 一元码(Unary): N 用 N 个 “1” + 一个 “0” 表示(如 3 → “1110”)
  • 截断一元码(Truncated Unary): 有限最大值版本
  • k 阶指数哥伦布码(Exp-Golomb): 适用于无上限的值
  • 定长码(Fixed Length): 如二进制表示的系数符号位

第 2 步:上下文建模(Context Modeling)
为每个比特(bin)动态选择概率模型。CABAC 定义了 460+ 个上下文模型,每个模型维护一个 6 位概率状态索引(共 64 个状态)和当前大概率符号(MPS, Most Probable Symbol)的取值。

上下文的选取取决于:

  • 当前语法元素的类型(coeff_flag, last_coeff, mvd 等)
  • 相邻块同类型语法元素的值(空间相关性)
  • 当前块在帧内的位置

第 3 步:二进制算术编码(Binary Arithmetic Coding)
对每个 bin,使用所选上下文模型的概率进行算术编码:

  • 编码器维护一个区间(Range, [0, 2^9-1])和一个偏移
  • 根据当前 bin 的概率将区间重新划分为两个子区间(MPS 区间 + LPS 区间)
  • 选择对应子区间,输出比特
  • 更新概率模型状态(如果编码的是 MPS,概率增大;如果是 LPS,概率减小并可能翻转 MPS)
  • 如果 Range 低于阈值(小于 256),执行重归一化:Range 倍增并输出一个比特

CABAC 是高度串行的——每个 bin 的编码依赖上一个 bin 更新后的状态。这让 CABAC 难以并行化,也是硬件解码器的设计瓶颈之一。H.265/HEVC 引入的波前并行处理(Wavefront Parallel Processing, WPP)Tiles以及在 H.266/VVC 中的进一步改进,都是为了绕开 CABAC 的串行瓶颈。

4.6 第五步:去块滤波(Deblocking Filter)

去块滤波(Deblocking Filter)是 H.264 编码环内的最后一步,也是解码端的必要环节。它对块边界附近的像素进行平滑处理,减少因分块编码产生的”块效应”(Blocking Artifacts)。

为什么会有块效应?

  • 相邻宏块使用不同的预测模式、参考帧、运动矢量
  • 量化对每个块的精度影响不同
  • 块边界处的像素可能来自完全不同的参考区域

去块滤波如何工作:

滤波在每个 4×4 块边界上进行(水平和垂直边界,先水平后垂直)。对于每条边界,滤波器根据以下因素决定滤波强度(Bs, Boundary Strength)

条件 Bs 值
边界是宏块边界,且至少一边是 Intra 预测 4(最强)
边界是宏块边界,且至少一边有编码残差 3
两边的运动矢量差 >= 1 个整像素 2
两边使用不同的参考帧,或运动矢量差 >= 1 个整像素 1
其他情况 0(不滤波)

BS = 4 的强滤波(针对平坦区域的宏块边界):
同时对边界两侧各 3 个像素(共 6 个像素)进行修改。

BS = 1/2/3 的弱滤波:
只可能修改边界两侧各 1 个像素(最多 2 个),且只在像素值差小于阈值时触发。

阈值控制(α 和 β):
去块滤波还使用两个与 QP 相关的阈值 α(块边缘阈值)和 β(块内阈值)。这些阈值与 QP 成正比:

  • QP 高 → α, β 大 → 更多滤波(因为量化噪声大,块效应更严重)
  • QP 低 → α, β 小 → 少滤波或不滤波(避免模糊保留的真实细节)
  • 可以通过 filterOffsetAfilterOffsetB 参数(在 Slice Header 中)偏移

去块滤波的贡献约占总解码时间的 1/3,但它对主观视觉质量的提升至关重要——同样的编码比特数下,有去块滤波的 H.264 视频显著优于没有该功能的视频格式(如 MPEG-4 Part 2)。


五、I/P/B 帧与 GOP 结构

5.1 三种帧类型

视频编码中的帧可分为三种基本类型,它们的根本区别在于预测参考的来源:

I 帧(Intra-coded Frame,帧内编码帧 / 关键帧):

  • 只使用帧内预测(不依赖其他帧)
  • 压缩率最低(约 7:1 到 20:1)——因为没有利用时间冗余
  • 随机访问点(Random Access Point)——解码可以从任何一个 I 帧开始
  • 错误恢复点——传输丢包或解码错误后,通过下一个 I 帧重新同步
  • I 帧通常以 IDR(Instantaneous Decoder Refresh) 形式出现,它告诉解码器:”忘记之前所有的参考帧,从这里开始重新构建”

P 帧(Predicted Frame,前向预测帧):

  • 可以引用之前的一个或多个帧(前向参考列表 List0)进行帧间预测
  • 也可以包含帧内编码的宏块(当帧间预测找不到好的匹配时)
  • 压缩率约为 I 帧的 3-5 倍
  • 解码 P 帧需要所有依赖的参考帧已经解码

B 帧(Bi-directional Predicted Frame,双向预测帧):

  • 可以引用之前的帧和之后的帧(双向参考,List0 + List1)
  • 拥有最高的压缩率(约为 I 帧的 10-20 倍)
  • 两种特殊的预测模式:
    • 双向预测(Bi-prediction): 前向预测块和后向预测块的加权平均
    • 直接模式(Direct Mode): 不传输运动矢量,从相邻块推算
  • B 帧不被其他帧引用——它可以被丢弃而不影响后续帧的解码(在码流稀疏化时非常有用)

B 帧的显示顺序 vs 编码顺序:
因为 B 帧引用了”未来”的帧,编码器必须调整顺序。例如:

显示顺序:  I₀  B₁  B₂  P₃  B₄  B₅  P₆
编码顺序: I₀ P₃ B₁ B₂ P₆ B₄ B₅
↑ ↑_ └── 未来帧必须在参考帧之前编码

这就是为什么视频容器中有一个 “PTS”(Presentation Time Stamp)和 “DTS”(Decode Time Stamp)的区别——DTS 决定解码时机,PTS 决定显示时机。

B 帧的参考列表:
H.264 允许 B 帧维护两个参考帧列表:

  • List0: 主要是过去帧(前向参考)
  • List1: 主要是未来帧(后向参考)

一个 B 帧的宏块可以使用:

  • 来自 List0 的单个参考
  • 来自 List1 的单个参考
  • 来自 List0 和 List1 的加权双向参考

5.2 GOP(Group of Pictures)

GOP 是一组连续的图像序列,从 IDR 帧开始,到下一个 IDR 帧之前结束。

GOP 结构的两个关键参数:

  • GOP 长度(GOP Size / Keyframe Interval): 两个 I 帧之间的帧数。例如 GOP=30 意味着每 30 帧就有一个 IDR 帧
  • B 帧数量(B-frame Count): 连续 P 帧之间的 B 帧数量

典型的 GOP 结构:

GOP Size = 30, B-frames = 2 (常见于 HLS 直播):
显示顺序: I₀ B₁ B₂ P₃ B₄ B₅ P₆ ... P₂₇ B₂₈ B₂₉ | I₃₀ (下一个 GOP)
编码顺序: I₀ P₃ B₁ B₂ P₆ B₄ B₅ ... P₂₇ B₂₅ B₂₆ P₃₀ B₂₈ B₂₉

GOP Size = 250, B-frames = 3 (常见于 VOD 高质量编码):
I₀ B₁ B₂ B₃ P₄ B₅ B₆ B₇ P₈ ... P₂₅₀ B₂₅₁ B₂₅₂ B₂₅₃ | I₂₅₄ (下一个 GOP)

GOP 设计的权衡:

参数 更小/更少 更大/更多
GOP 大小 更多关键帧 → 更大文件,但 seek 更快,错误恢复更快 更少关键帧 → 更高压缩率,但 seek 慢
B 帧数量 编码更快,延迟更低(实时场景) 更高压缩率,但延迟更高(VOD 场景)

分层 B 帧(Hierarchical B-frames):
现代编码器(H.264 High Profile, H.265, VP9, AV1)使用分层 B 帧结构进一步提高压缩效率:

显示顺序:  I₀  B₁  B₂  B₃  P₄  B₅  B₆  B₇  P₈
时间层级:
Level 0: I₀ ═══════════════ P₄ ═══════════════ P₈ ← 基础层 (如 7.5 fps)
Level 1: B₂ ═══════════════════ B₆ ← 第一增强层 (如 15 fps)
Level 2: B₁ B₃ B₅ B₇ ← 第二增强层 (如 30 fps)

分层 B 帧的优点:

  • 时间可伸缩性:丢弃高层级帧,可以降低帧率而不需要重新编码
  • 更高压缩率:每个层级的参考帧在时间上更近,预测更准确
  • 实现帧率自适应:网络拥塞时服务器可以丢弃高层时间层级的帧来降低码率,而客户端仍能播放(以较低帧率)

六、容器格式

编码后的视频流(H.264/H.265)和音频流(AAC/Opus)是裸流(raw elementary stream)——它们缺少时间戳、索引、元数据等关键信息。容器格式就是将这些裸流”打包”成可播放的文件或流。

6.1 MP4 —— 最通用的视频容器

MP4 基于 ISO Base Media File Format(ISOBMFF, ISO 14496-12),使用面向对象的”原子”(atom/box)结构。每个原子由 4 字节长度 + 4 字节类型 + 数据组成。

MP4 文件结构概述:

典型 MP4 文件(非 fragmented):
┌─────────────────────────────────────────────────────────────────┐
│ ftyp (File Type Box) │
│ - major_brand: "mp42" │
│ - compatible_brands: ["mp42", "isom", "avc1"] │
├─────────────────────────────────────────────────────────────────┤
│ moov (Movie Box) —— 元数据,包含所有轨道的描述信息 │
│ ├── mvhd (Movie Header) —— 时长、创建时间、时间标尺 │
│ ├── trak (Track: 视频) │
│ │ ├── tkhd (Track Header) —— 轨道ID、宽高、时长 │
│ │ └── mdia (Media) │
│ │ ├── mdhd (Media Header) —— 时间标尺 (如 90000) │
│ │ ├── hdlr (Handler) —— "vide" │
│ │ └── minf (Media Information) │
│ │ ├── vmhd (Video Media Header) │
│ │ ├── dinf (Data Information) │
│ │ └── stbl (Sample Table) │
│ │ ├── stsd (Sample Description) —— 编码器信息 │
│ │ │ └── avc1 (AVC/H.264) │
│ │ │ └── avcC (AVC Configuration) —— SPS+PPS │
│ │ ├── stts (Time-to-Sample) —— 帧持续时长 │
│ │ ├── stss (Sync Sample) —— 关键帧索引 │
│ │ ├── stsz (Sample Size) —— 每帧字节数 │
│ │ ├── stco/co64 (Chunk Offset) —— 数据在文件中的偏移│
│ │ └── stsc (Sample-to-Chunk) —— 帧到块映射 │
│ ├── trak (Track: 音频) │
│ │ └── ... (类似视频轨道) │
│ └── udta (User Data) —— 可选元数据 (标题、艺术家等) │
├─────────────────────────────────────────────────────────────────┤
│ mdat (Media Data Box) —— 实际的音视频编码数据 │
│ [H.264 NAL Unit] [AAC Frame] [H.264 NAL Unit] ... │
│ (所有轨道的编码数据交错存储) │
└─────────────────────────────────────────────────────────────────┘

关键原子详解:

ftyp(File Type Box):
标识文件的品牌(Brand)和兼容性(Compatible Brands)。例如:

  • mp42: 标准 MP4 文件
  • isom: 基于 ISOBMFF 的文件
  • avc1: 包含 AVC/H.264 视频
  • M4A: iTunes 音频 (.m4a)

moov(Movie Box):
MP4 的核心元数据容器。对于非 fragmented MP4(普通 mp4 文件),moov 必须包含所有帧的索引信息(stts, stsz, stco/stco64)。这意味着:

  • moov 通常写在 mdat 的后面(因为编码结束后才知道所有帧的大小和偏移)
  • 对于流式播放(HTTP Progressive Download),播放器需要先获取 moov 才能定位到具体的帧
  • 这就是为什么 ffmpeg 有一个 -movflags +faststart 参数——它将 moov 移动到文件开头,让播放器可以在文件完整下载前就开始播放

stbl(Sample Table Box):
stbl 下的子原子构成了帧索引表。理解这些原子之间的关系是理解 MP4 播放的关键:

时间 → 字节偏移的完整映射链:

stts (Time-to-Sample):
Sample 0: duration = 512 (以 mdhd timescale 为单位)
Sample 1: duration = 512
Sample 2: duration = 1024 ← 该帧持续 2 倍时间 (可能是隔帧的情况)
...

stsc (Sample-to-Chunk) + stco (Chunk Offset):
Chunk 0: 起始偏移 0x00001000, 包含 3 个 Samples
Chunk 1: 起始偏移 0x00003000, 包含 3 个 Samples
...

stsz (Sample Size):
Sample 0: 25341 bytes
Sample 1: 12450 bytes
Sample 2: 35012 bytes
...

要定位到第 N 帧:
1. stsc + stco → 第 N 帧在哪个 Chunk 中,该 Chunk 的起始偏移
2. stsz → 第 N 帧的大小
3. 该 Chunk 中第 N 帧之前的帧大小之和 → 第 N 帧在 Chunk 内的偏移
4. 文件偏移 = Chunk 起始偏移 + Chunk 内偏移
5. stts → 第 N 帧的 PTS

fMP4(Fragmented MP4):
传统的非 fragmented MP4 有一个致命缺陷:必须在编码开始时就知道所有帧的信息——这不适用于直播、录制、或分片传输。Fragmented MP4(fMP4)解决了这个问题。

fMP4 将媒体数据分割为多个fragment(片段),每个 fragment 可以独立解码和播放:

Fragmented MP4 结构:
┌──────────────────────────────────────────┐
│ ftyp │
├──────────────────────────────────────────┤
│ moov (稀疏的元数据,不含 stbl 完整索引) │
│ └── mvex (Movie Extends) │
│ └── trex (Track Extends) — 轨道默认值 │
├──────────────────────────────────────────┤
│ moof (Movie Fragment 1) │
│ ├── mfhd (Movie Fragment Header) │
│ └── traf (Track Fragment) │
│ ├── tfhd (Track Fragment Header) │
│ ├── tfdt (Track Fragment Decode Time)│
│ ├── trun (Track Run) — 该片段的帧信息│
│ └── ... │
├──────────────────────────────────────────┤
│ mdat (Fragment 1 的数据) │
├──────────────────────────────────────────┤
│ moof (Movie Fragment 2) │
├──────────────────────────────────────────┤
│ mdat (Fragment 2 的数据) │
├──────────────────────────────────────────┤
│ mfra (Movie Fragment Random Access) │ ← 可选,用于 seek
│ ├── tfra (Track Fragment Random Access)│
│ └── mfro (Movie Fragment Random Access Offset)│
└──────────────────────────────────────────┘

fMP4 是 DASH(Dynamic Adaptive Streaming)CMAF(Common Media Application Format)的核心容器格式,也是 HLS 从 TS 迁移到 fMP4 的目标格式。

6.2 MKV / WebM —— 开放标准的容器

MKV(Matroska Video):

  • 基于 EBML(Extensible Binary Meta Language),类似 XML 的二进制格式
  • 完全开源,无专利限制(专利风险已通过专利池覆盖)
  • 几乎可以封装任何编解码格式(H.264, H.265, VP9, AV1, AAC, Opus, FLAC, ASS 字幕等)
  • 支持章节、多音轨、多字幕轨、附件(嵌入字体)、菜单
  • 适合本地存储和归档,不适合流媒体(不支持 fragment 式随机访问)

WebM:
WebM 是 Matroska 的子集,专门为 Web 设计,由 Google 推动:

  • 视频:VP8 / VP9 / AV1
  • 音频:Vorbis / Opus
  • 不支持字幕、章节、多音轨等高级功能
  • 是 HTML5 <video> 标签的主要支持容器

EBML 结构:

EBML 元素:
┌──────────────────┬──────────────────────┬────────────────────┐
│ Element ID │ Element Data Size │ Element Data │
│ (变长编码) │ (变长编码) │ │
└──────────────────┴──────────────────────┴────────────────────┘

MKV 文件结构:
EBML Header (文档类型、版本)
└── Segment (整个文件内容)
├── SeekHead (索引: 各顶级元素的位置)
├── Info (时长、时间标尺、标题)
├── Tracks (轨道定义: 视频/音频/字幕)
├── Chapters (章节标记)
├── Attachments (嵌入的字体文件)
├── Tags (元数据标签)
├── Cluster (数据块 1)
│ ├── Timecode (该 Cluster 的起始时间戳)
│ ├── SimpleBlock (视频帧)
│ ├── SimpleBlock (音频帧)
│ └── ...
├── Cluster (数据块 2)
├── ...
└── Cues (关键帧索引,用于 seek)

6.3 FLV —— 直播时代的遗产

FLV(Flash Video)是 Adobe Flash Player 使用的视频容器,虽然 Flash 本身已于 2020 年退役,但 FLV 格式在直播推流领域仍然广泛使用,尤其是在中国国内的直播平台中。

FLV 文件结构:

FLV 文件:
┌────────────────────────────────────────────┐
│ FLV Header (9 bytes) │
│ - Signature: "FLV" (0x46 0x4C 0x56) │
│ - Version: 0x01 │
│ - TypeFlags: 0x05 (audio + video) │
│ - DataOffset: 0x00000009 (9 bytes) │
├────────────────────────────────────────────┤
│ PreviousTagSize0: 0x00000000 (4 bytes) │ ← 第一个 tag 之前总是 0
├────────────────────────────────────────────┤
│ FLV Tag (视频) │
│ - TagType: 0x09 (视频) │
│ - DataSize: 3 bytes │
│ - Timestamp: 3 bytes (ms) + 1 byte ext │
│ - StreamID: 3 bytes (总是 0x000000) │
│ - Video Data: │
│ - FrameType (4 bits): 1=keyframe, 2=inter frame│
│ - CodecID (4 bits): 7=AVC/H.264 │
│ - AVCPacketType: 0=AVC sequence header, 1=AVC NALU│
│ - CompositionTime: 3 bytes (PTS - DTS) │
│ - H.264 NALU data │
├────────────────────────────────────────────┤
│ PreviousTagSize: 4 bytes │ ← 前一个 tag 的总字节数
├────────────────────────────────────────────┤
│ FLV Tag (音频) │
│ - TagType: 0x08 (音频) │
│ - Audio Data: │
│ - SoundFormat (4 bits): 10=AAC │
│ - SoundRate (2 bits): 3=44kHz │
│ - SoundSize (1 bit): 1=16-bit │
│ - SoundType (1 bit): 1=stereo │
│ - AACPacketType: 0=sequence header, 1=AAC raw│
│ - AAC data │
├────────────────────────────────────────────┤
│ PreviousTagSize: ... │
├────────────────────────────────────────────┤
│ FLV Tag (视频) ... │
└────────────────────────────────────────────┘

FLV 的 tag 结构简单直接,每个 tag 有明确的时间戳。这使得 FLV 在直播场景中有两个优势:

  • 可以边录制边推流——每个 tag 自成一体,不需要全局索引
  • 解析器极其简单,适合低延迟场景

FLV 在 RTMP 中的应用:
在 RTMP 直播推流中,上行(推流)和下行(拉流)都使用 FLV 格式承载音视频数据。RTMP 的 VideoDataAudioData 消息体实际上就是 FLV tag 的数据部分(去掉 tag header)。


七、流媒体协议

7.1 RTMP —— 实时消息传输协议

RTMP(Real-Time Messaging Protocol)是 Adobe 开发的用于 Flash Player 和服务器之间传输音视频和数据的协议。虽然 Flash 已死,RTMP 在直播推流领域依然是主力。

RTMP 握手(Handshake):

RTMP 使用一个独特的三次握手来验证连接和交换随机数:

Client                          Server
│ │
│── C0+C1 ──────────────────► │
│ C0: 协议版本 (1 byte, 0x03) │
│ C1: 1536 bytes │
│ - time (4B): 当前时间戳 │
│ - zero (4B): 0x00000000 │
│ - random (1528B): 随机数据 │
│ │
│ ◄── S0+S1+S2 │
│ S0: 版本 │
│ S1: 服务器时间 + 随机数 │
│ S2: 回显 C1 的时间 + 随机数 │
│ │
│── C2 ──────────────────────► │
│ 回显 S1 的时间戳 + 随机数 │
│ │
│ 握手完成, 开始交换消息 │

这个握手过程看起来繁琐,但它实现了:

  • 协议版本协商
  • 确定连接的延迟(通过回显时间戳)
  • 确保双方都能正确处理随机数据(防御某些代理/缓存设备的错误行为)

RTMP 消息类型:

消息类型 ID 名称 用途
1 Set Chunk Size 调整分块大小(默认 128 字节)
2 Abort Message 取消一个消息的传输
3 Acknowledgement 流量控制:收到一定字节后回确认
4 User Control Message 流事件(Stream Begin, Stream EOF, Buffer Empty 等)
5 Window Ack Size 设置确认窗口大小
6 Set Peer Bandwidth 设置对端带宽限制
8 Audio Message 音频数据
9 Video Message 视频数据
15 AMF3 Data Message 元数据/数据消息(ActionScript 3 格式)
18 AMF0 Data Message 元数据/数据消息(ActionScript 1.0 格式)
20 Command Message (AMF0) RPC 命令:connect, createStream, publish, play 等
22 Aggregate Message 聚合消息(将多个子消息打包为一个,减少开销)

RTMP 分块(Chunking)机制:

RTMP 将大消息拆分为固定大小的块(chunk),在多路复用的连接上交错发送。默认块大小 128 字节——即消息超过 128 字节就会被拆分。块越小,音频帧可以更快地插入到视频帧之间,减少音频延迟;块越大,头部开销越小。

RTMP 消息分块示例 (一个 350 字节的视频消息, chunk size = 128):

┌──────────────┬──────────┐ ┌──────────────┬──────────┐ ┌──────────────┬──────────┐
│ Chunk Header │ 128 字节 │ │ Chunk Header │ 128 字节 │ │ Chunk Header │ 94 字节 │
│ (Type 0) │ 数据 │ │ (Type 3) │ 数据 │ │ (Type 3) │ 数据 │
└──────────────┴──────────┘ └──────────────┴──────────┘ └──────────────┴──────────┘

四种 Chunk Header 类型:

  • Type 0(12 字节): 完整头部。包含 Stream ID、Message Type、Timestamp 等全部信息
  • Type 1(8 字节): 省略了 Message Stream ID(与上一个相同),但包含 Timestamp Delta 和 Message Length
  • Type 2(4 字节): 只包含 Timestamp Delta。消息流 ID、消息类型、消息长度都与上一个相同
  • Type 3(1 字节): 最精简。Timestamps 也沿用之前的值(或使用 Timestamp Delta 扩展)

这个设计巧妙地利用了消息流的时序局部性——同一消息类型在同一条流中往往连续出现。

RTMP 的典型推/拉流交互流程:

推流 (Publishing):
Client → Server: connect("rtmp://server/app")
Server → Client: _result (连接成功)
Client → Server: releaseStream("streamKey")
Client → Server: FCPublish("streamKey")
Client → Server: createStream()
Server → Client: _result (stream ID = 1)
Client → Server: publish("streamKey")
Server → Client: onStatus("NetStream.Publish.Start")
Client → Server: @setDataFrame (onMetaData: 宽, 高, 帧率, 编码器等)
Client → Server: Video Data / Audio Data (连续的 FLV tag 数据)

拉流 (Playback):
Client → Server: connect("rtmp://server/app")
Server → Client: _result
Client → Server: createStream()
Server → Client: _result (stream ID = 1)
Client → Server: play("streamKey")
Server → Client: onStatus("NetStream.Play.Start")
Server → Client: @setDataFrame (onMetaData)
Server → Client: Video Data / Audio Data (连续的 FLV tag 数据)

7.2 HLS —— HTTP Live Streaming

HLS(HTTP Live Streaming)是 Apple 开发的基于 HTTP 的自适应流媒体协议。它是最广泛支持的流媒体协议——iOS 和 macOS 原生支持,Android 从 3.1 开始支持,所有主流浏览器通过 MSE(Media Source Extensions)支持。

HLS 的核心概念:

HLS 将视频切成一系列短小的媒体段(通常 2-10 秒),每个段是一个独立的文件(.ts 或 .mp4),然后通过播放列表(.m3u8)串联起来。

主播放列表(Master Playlist / Multivariant Playlist):

#EXTM3U
#EXT-X-VERSION:3

#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.4d001e,mp4a.40.2"
360p/prog_index.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=1400000,RESOLUTION=842x480,CODECS="avc1.4d001f,mp4a.40.2"
480p/prog_index.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720,CODECS="avc1.4d001f,mp4a.40.2"
720p/prog_index.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080,CODECS="avc1.4d0028,mp4a.40.2"
1080p/prog_index.m3u8

主播放列表列出了不同码率的变体(Variant)。播放器根据当前网络带宽自动选择并切换——这就是自适应码率(ABR, Adaptive Bitrate)

媒体播放列表(Media Playlist):

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:100
#EXTINF:6.000,
segment-100.ts
#EXTINF:6.000,
segment-101.ts
#EXTINF:5.500,
segment-102.ts
#EXTINF:6.000,
segment-103.ts
  • EXT-X-TARGETDURATION:最大分片时长(播放器据此计算缓冲策略)
  • EXT-X-MEDIA-SEQUENCE:第一个分片的序号(用于直播的滑动窗口)
  • EXTINF:每个分片的具体时长

直播 HLS 的滑动窗口:
在直播中,服务器不断添加新的分片并移除旧的分片,播放列表始终保持一个固定长度的窗口(通常是 3-5 个分片)。播放器定期重新请求播放列表来获取最新分片。

Live HLS 播放列表随时间的变化:

T=00:00: segment-100.ts, segment-101.ts, segment-102.ts
T=00:06: segment-101.ts, segment-102.ts, segment-103.ts
T=00:12: segment-102.ts, segment-103.ts, segment-104.ts
↑ 旧分片滑出 ↑ 新分片滑入

延迟来源(HLS 延迟通常 15-30 秒):

  1. 分片时长(如 6 秒)——一个分片必须先完全生成才能被客户端请求
  2. 分片缓冲——播放器通常缓冲 2-3 个分片以应对网络抖动
  3. 播放列表更新间隔——播放器每隔一定时间(通常是一个分片时长)请求更新
  4. 总延迟 ≈ 3 × 分片时长 ≈ 18 秒(基于 6 秒分片)

低延迟 HLS(LL-HLS / Apple Low-Latency HLS):
通过以下技术将 HLS 延迟降低到 2-5 秒:

  • 分片化 MP4(fMP4): 代替 .ts 文件
  • Partial Segments: 分片在完整生成前就可以部分交付
  • Blocking Playlist Reload: 服务器保持 HTTP 连接打开,直到有新分片再返回
  • Playlist Delta Updates: 只传输播放列表的变化部分
  • EXT-X-PRELOAD-HINT 预告下一个即将可用的分片,让播放器提前发起请求

7.3 DASH —— Dynamic Adaptive Streaming over HTTP

DASH(MPEG-DASH, ISO 23009-1)是国际标准化的自适���流媒体协议,与 HLS 类似但更灵活(也更复杂)。

DASH 的核心文件:MPD(Media Presentation Description)

<?xml version="1.0" encoding="utf-8"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011"
minBufferTime="PT1.5S"
type="static"
mediaPresentationDuration="PT0H9M56.46S"
profiles="urn:mpeg:dash:profile:isoff-live:2011">

<!-- 同一个内容的多个适应集 (AdaptationSet) -->
<Period>
<!-- 视频适应集 -->
<AdaptationSet mimeType="video/mp4"
codecs="avc1.4d001f"
frameRate="30"
segmentAlignment="true"
startWithSAP="1">

<!-- 每个 Representation 代表一个码率/分辨率级别 -->
<Representation id="1" bandwidth="800000"
width="640" height="360">
<SegmentTemplate timescale="30000"
media="video_360p_$Number$.m4s"
initialization="video_360p_init.mp4"
startNumber="1" />
</Representation>

<Representation id="2" bandwidth="2800000"
width="1280" height="720">
<SegmentTemplate timescale="30000"
media="video_720p_$Number$.m4s"
initialization="video_720p_init.mp4"
startNumber="1" />
</Representation>

<Representation id="3" bandwidth="5000000"
width="1920" height="1080">
<SegmentTemplate timescale="30000"
media="video_1080p_$Number$.m4s"
initialization="video_1080p_init.mp4"
startNumber="1" />
</Representation>
</AdaptationSet>

<!-- 音频适应集 -->
<AdaptationSet mimeType="audio/mp4"
codecs="mp4a.40.2"
audioSamplingRate="48000">
<Representation id="4" bandwidth="128000">
<SegmentTemplate timescale="48000"
media="audio_$Number$.m4s"
initialization="audio_init.mp4"
startNumber="1" />
</Representation>
</AdaptationSet>
</Period>
</MPD>

DASH 的分段(Segment)格式:

  • 初始化分片(Initialization Segment): 包含 ftyp + moov,只需加载一次
  • 媒体分片(Media Segment): 每个分片是一个独立的 fragmented MP4(包含 moof + mdat)

DASH vs HLS 对比:

维度 HLS DASH
播放列表格式 m3u8 (文本) MPD (XML)
分片格式 TS 或 fMP4 (CMAF) fMP4 或 CMAF
编解码限制 H.264/H.265 + AAC (规范限制) 无限制 (任意编解码)
DRM FairPlay (Apple 独占) Widevine/PlayReady/FairPlay 均可
延迟 传统 15-30s, LL-HLS 2-5s 传统 15-30s, LL-DASH 2-5s
平台支持 iOS/macOS 原生, Android 原生 Android 原生, Web (dash.js)

CMAF(Common Media Application Format):
CMAF 是一种标准化的分片 MP4 格式,同时被 HLS 和 DASH 支持。使用 CMAF 意味着同一组媒体分片可以同时用于 HLS 和 DASH,只需要两份不同的播放列表文件(.m3u8 和 .mpd)和一份初始化分片。这大大简化了媒体源站的基础设施。

7.4 WebRTC —— 实时通信的革命

WebRTC(Web Real-Time Communication)是一个开源项目,为浏览器和移动应用提供实时、点对点(P2P)的音视频和数据通信

WebRTC 的三大核心 API:

  • MediaStream / getUserMedia: 访问本地摄像头和麦克风
  • RTCPeerConnection: 建立点对点连接,传输音视频流
  • RTCDataChannel: 传输任意数据(低延迟,基于 SCTP)

WebRTC 的协议栈:

┌─────────────────────────────────────────────────┐
│ 应用层 (WebRTC API) │
├─────────────────────────────────────────────────┤
│ 信令 (SDP + ICE candidates, 通过 WebSocket │
│ 或自定义通道交换) │
├─────────────────────────────────────────────────┤
│ 媒体传输 │ 数据通道 │
│ SRTP (加密) │ SCTP over DTLS │
│ 音频/视频编解码 (Opus, │ (可靠/不可靠传输) │
│ H.264, VP8, VP9, AV1) │ │
├───────────────────────────┴───────────────────────┤
│ DTLS (密钥交换, 加密握手) │
├─────────────────────────────────────────────────┤
│ ICE / STUN / TURN (NAT 穿透) │
├─────────────────────────────────────────────────┤
│ UDP (主要) 或 TCP (备选) │
└─────────────────────────────────────────────────┘

WebRTC 连接建立全流程:

第一步:SDP(Session Description Protocol)交换(Offer/Answer 模型)

SDP 描述媒体能力:支持的编解码器、码率、分辨率、传输协议等。

呼叫方 (Offer) → 信令服务器 → 被叫方 (Answer):

呼叫方 SDP Offer:
v=0
o=- 461173043 2 IN IP4 127.0.0.1
s=-
t=0 0
m=audio 9 UDP/TLS/RTP/SAVPF 111 103
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=mid:audio
a=rtpmap:111 opus/48000/2 ← 支持 Opus 48kHz 立体声
a=fmtp:111 minptime=10;useinbandfec=1
a=rtpmap:103 ISAC/16000 ← 也支持 iSAC 16kHz
m=video 9 UDP/TLS/RTP/SAVPF 100 96
a=mid:video
a=rtpmap:100 VP8/90000 ← 支持 VP8 和 H.264
a=rtpmap:96 H264/90000
a=fmtp:96 profile-level-id=42e01f;packetization-mode=1

被叫方 SDP Answer:
v=0
o=- 672938074 2 IN IP4 127.0.0.1
...
m=audio 5000 UDP/TLS/RTP/SAVPF 111
a=rtpmap:111 opus/48000/2 ← 选择 Opus
m=video 5002 UDP/TLS/RTP/SAVPF 96
a=rtpmap:96 H264/90000 ← 选择 H.264

第二步:ICE(Interactive Connectivity Establishment)—— NAT 穿透

ICE 是 WebRTC 最精妙的部分。它尝试所有可能的方法在两个位于不同 NAT 后面的设备之间建立直接连接。

ICE 候选(Candidates)类型:

  1. Host Candidate(主机候选): 设备自身的 IP 地址

    • 192.168.1.10
    • 只在同一局域网内可用
  2. Server Reflexive Candidate(srflx / STUN 候选): 通过 STUN 服务器获取的公网 IP:Port

    • 过程:客户端向 STUN 服务器发送请求,STUN 服务器回复”你的公网地址是 203.0.113.5:45678”
    • 这是在 NAT 上自动创建的端口映射
  3. Relay Candidate(中继候选 / TURN 候选): 通过 TURN 服务器中继的所有流量

    • 当 P2P 直接连接失败(如对称 NAT)时使用
    • 服务器成本高(需要承载所有媒体的带宽)

ICE 的候选收集和连接检查:

1. 双方收集本地候选 (host candidates)
2. 双方请求 STUN 服务器获取 srflx candidates
3. 双方通过信令通道交换所有 candidates (包括 host, srflx, relay)
4. ICE 状态机开始连接检查 (Connectivity Checks):
- 对每对 (local candidate, remote candidate) 发送 STUN Binding Request
- 收到成功响应 → 这对候选可用
- 选择延迟最低的一对候选用作通信

第三步:DTLS-SRTP 密钥交换

连接建立后(ICE 成功后),WebRTC 使用 DTLS(Datagram Transport Layer Security)进行密钥交换。DTLS 本质上是在 UDP 上运行的 TLS,解决了”如何在不可靠的数据报传输上提供 TLS 的安全保证”的问题。

DTLS 握手完成后,从中派生出SRTP(Secure RTP)密钥,用于加密后续的媒体数据。这就是为什么叫 “DTLS-SRTP”——DTLS 负责密钥协商,SRTP 负责媒体加密。

SRTP 的关键特性:

  • 加密 RTP 载荷(使用 AES-CTR 或 AES-GCM)
  • 认证 RTP 数据包(使用 HMAC-SHA1)
  • 防重放攻击(通过序列号检查)
  • 开销极小(每包约 10 字节的认证标签)

WebRTC 的媒体传输特点:

  • UDP 优先: 实时通信不能等待 TCP 重传,丢几帧优于增加延迟
  • FEC(Forward Error Correction)和 NACK: 通过冗余数据或选择性重传来恢复丢包
  • 带宽估计(BWE, Bandwidth Estimation): 基于延迟梯度(delay-based, GCC 算法)和丢包率(loss-based)动态调整编码码率
  • RTCP(RTP Control Protocol): 定期发送 SR(Sender Report)和 RR(Receiver Report),传递丢包率、抖动、RTT 等统计信息

八、Android MediaCodec 实战

MediaCodec 是 Android 提供的底层音视频编解码 API。它直接使用设备的硬件编解码器(DSP, GPU, 或专用硬件加速模块),提供了远低于软件编解码的功耗和延迟。

8.1 MediaCodec 的生命周期

MediaCodec 状态机:
┌──────────┐
│Uninitialized│ ← createByCodecName / createEncoderByType / createDecoderByType
└────┬─────┘
│ configure()

┌──────────┐
┌──────│Configured│
│ └────┬─────┘
│ │ start() (同步模式在此调用 getInput/OutputBuffers)
│ ▼
│ ┌──────────┐
│ │ Running │◄────────────────────────────────┐
│ └────┬─────┘ │
│ │ queueInputBuffer + dequeueOutputBuffer │
│ │ (编解码循环在这里) │
│ │ │
│ ┌────▼─────┐ │
│ │End of │ ← 最后一个输入 buffer 带 │
│ │Stream │ BUFFER_FLAG_END_OF_STREAM │
│ └────┬─────┘ │
│ │ │
│ ┌────▼─────┐ ┌──────────┐ │
│ │Flushing │◄───│ flush() ├─────────────────┘
│ └────┬─────┘ └──────────┘
│ │
▼ ▼
┌────────────────────┐
│ Stopped / Released │
└────────────────────┘

8.2 同步模式(Synchronous Mode)

在同步模式下,编解码器使用一组固定的输入/输出缓冲区数组(从 API 21 之后已废弃 getInputBuffers()/getOutputBuffers(),改为在 dequeue 时获取 Buffer 索引):

视频解码示例(同步模式):

// 创建 H.264 解码器
MediaCodec codec = MediaCodec.createDecoderByType("video/avc");

// 配置: MediaFormat中传入 SPS+PPS (csd-0/csd-1)
MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);
format.setByteBuffer("csd-0", ByteBuffer.wrap(sps));
format.setByteBuffer("csd-1", ByteBuffer.wrap(pps));

codec.configure(format, surface, null, 0); // 输出到 Surface
codec.start();

// 获取输入输出缓冲区索引
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();

while (hasMoreData) {
// 1. 从 codec 获取一个空闲的输入缓冲区
int inputIndex = codec.dequeueInputBuffer(TIMEOUT_US);
if (inputIndex >= 0) {
ByteBuffer inputBuffer = codec.getInputBuffer(inputIndex);
// 从 MediaExtractor 或网络流中读取 H.264 数据
int sampleSize = extractor.readSampleData(inputBuffer, 0);
long pts = extractor.getSampleTime();
int flags = extractor.getSampleFlags();

codec.queueInputBuffer(inputIndex, 0, sampleSize, pts, flags);
extractor.advance();
}

// 2. 从 codec 获取一个已解码的输出帧
int outputIndex = codec.dequeueOutputBuffer(info, TIMEOUT_US);
if (outputIndex >= 0) {
// 如果输出到 Surface, 只需调用 releaseOutputBuffer 并指定 render=true
codec.releaseOutputBuffer(outputIndex, true);
// Surface 上会自动渲染该帧
} else if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
// 编解码器输出格式变化 (如分辨率变化)
MediaFormat newFormat = codec.getOutputFormat();
} else if (outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
// 没有可用的输出缓冲区, 稍后重试
}
}

// 发送 EOS (End of Stream)
int inputIndex = codec.dequeueInputBuffer(TIMEOUT_US);
codec.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);

// 等待解码完所有帧
while (true) {
int outputIndex = codec.dequeueOutputBuffer(info, TIMEOUT_US);
if (outputIndex >= 0) {
codec.releaseOutputBuffer(outputIndex, true);
} else if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
break;
}
}

codec.stop();
codec.release();

BufferInfo 详解:

public final class MediaCodec.BufferInfo {
public int offset; // 有效数据在 buffer 中的起始偏移
public int size; // 有效数据的字节数
public long presentationTimeUs; // 该帧的 PTS (微秒)
public int flags; // 标志位:
// BUFFER_FLAG_KEY_FRAME (1) — 关键帧
// BUFFER_FLAG_CODEC_CONFIG (2) — 编解码配置数据
// BUFFER_FLAG_END_OF_STREAM (4) — 流结束
// BUFFER_FLAG_PARTIAL_FRAME (8) — 部分帧
}

8.3 异步模式(Asynchronous Mode)

API 21 引入了异步模式,通过回调接收事件,避免了手动管理缓冲区队列。

MediaCodec codec = MediaCodec.createDecoderByType("video/avc");
MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);

codec.setCallback(new MediaCodec.Callback() {
@Override
public void onInputBufferAvailable(MediaCodec codec, int index) {
ByteBuffer inputBuffer = codec.getInputBuffer(index);
int sampleSize = extractor.readSampleData(inputBuffer, 0);
long pts = extractor.getSampleTime();
int flags = extractor.getSampleFlags();

if (sampleSize >= 0) {
codec.queueInputBuffer(index, 0, sampleSize, pts, flags);
extractor.advance();
} else {
// 输入数据已耗尽, 发送 EOS
codec.queueInputBuffer(index, 0, 0, 0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM);
}
}

@Override
public void onOutputBufferAvailable(MediaCodec codec, int index,
MediaCodec.BufferInfo info) {
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
// 解码完成
} else {
// 渲染到 Surface
codec.releaseOutputBuffer(index, true);
}
}

@Override
public void onError(MediaCodec codec, MediaCodec.CodecException e) {
// 处理错误
}

@Override
public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {
// 输出格式变化 (如分辨率变化)
}
});

codec.configure(format, surface, null, 0);
codec.start();

同步 vs 异步模式对比:

特性 同步模式 异步模式
API 级别 16+ 21+
线程模型 手动管理, 在调用线程上阻塞 回调在内部线程上执行
复杂度 需要处理超时和重试逻辑 事件驱动, 代码更简洁
性能 理论上略高 (无回调开销) 几乎相同
适用场景 已有自己的线程模型 新代码, 简单场景

8.4 Surface 输入 —— 零拷贝编码

从 API 18 开始,MediaCodec 支持将 Surface 作为编码器的输入。这是 Android 视频录制中最常用的模式——相机预览直接渲染到 Surface,编码器从 Surface 中取帧。

Surface 输入的工作流程:

// 创建 H.264 编码器
MediaCodec codec = MediaCodec.createEncoderByType("video/avc");
MediaFormat format = MediaFormat.createVideoFormat("video/avc",
width, height);
format.setInteger(MediaFormat.KEY_BIT_RATE, bitrate);
format.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); // 每秒一个 I 帧
format.setInteger(MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);

codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);

// 获取编码器的输入 Surface
Surface inputSurface = codec.createInputSurface();
codec.start();

// 场景 1: 将相机预览输出到编码器 Surface
// 使用 OpenGL ES 做中间处理 (如滤镜、水印):
// camera → SurfaceTexture → OpenGL → 编码器 inputSurface

// 场景 2: 通过 EGL/OpenGL 渲染帧到 Surface
EGL14.eglMakeCurrent(eglDisplay, inputSurface, inputSurface, eglContext);
// ... OpenGL 绘制 ...
EGL14.eglSwapBuffers(eglDisplay, inputSurface); // 提交帧给编码器
codec.signalEndOfInputStream(); // 通知不再有输入帧

// 场景 3: 使用 Canvas 绘制到 Surface (2D 图形)
Canvas canvas = inputSurface.lockCanvas(null);
// ... 绘制操作 ...
inputSurface.unlockCanvasAndPost(canvas);

// 获取编码输出
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
while (true) {
int outputIndex = codec.dequeueOutputBuffer(info, TIMEOUT_US);
if (outputIndex >= 0) {
ByteBuffer outputBuffer = codec.getOutputBuffer(outputIndex);
// 将编码后的 H.264 数据写入文件或推流
muxer.writeSampleData(trackIndex, outputBuffer, info);
codec.releaseOutputBuffer(outputIndex, false);
} else if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
// 初始化 MediaMuxer
trackIndex = muxer.addTrack(codec.getOutputFormat());
muxer.start();
}
}
codec.stop();
codec.release();

Surface 输入的底层原理(零拷贝路径):

传统的 Buffer 输入模式:

App 内存 (YUV Buffer)
→ Java/Kotlin 层 (ByteBuffer)
→ JNI 跨边界
→ Native 层 (ABuffer)
→ Binder IPC (到 mediaserver)
→ OMX/Codec2 HAL
→ 硬件编解码器

Surface 输入模式:

GPU 内存 (GraphicBuffer, 来自 Camera 或 OpenGL)
→ 同一块 GraphicBuffer 通过 BufferQueue 直接传递
→ OMX/Codec2 HAL 直接从 GraphicBuffer 读取
→ 硬件编解码器

零拷贝!Camera/GPU 的输出 buffer 就是编码器的输入 buffer

这就是 Surface 输入模式的核心优势:避免了从 GPU 到 CPU、再从 CPU 到编解码器的两次内存拷贝,对于 1080p@60fps 这样的大数据量场景,节省的带宽和时间非常可观。

8.5 编解码器选择

Android 设备上通常有多个 H.264 编解码器可供选择(硬件、软件、不同厂商实现)。

// 列出所有 H.264 编解码器
MediaCodecList list = new MediaCodecList(MediaCodecList.ALL_CODECS);
MediaCodecInfo[] codecInfos = list.getCodecInfos();

for (MediaCodecInfo info : codecInfos) {
if (!info.isEncoder()) continue;

String[] types = info.getSupportedTypes();
for (String type : types) {
if (type.equalsIgnoreCase("video/avc")) {
MediaCodecInfo.CodecCapabilities caps =
info.getCapabilitiesForType(type);

Log.d("Codec", "名称: " + info.getName());
Log.d("Codec", "类型: " + (info.isHardwareAccelerated() ? "硬件" : "软件"));

// 检查是否支持 Surface 输入
boolean useSurfaceInput = false;
for (int colorFormat : caps.colorFormats) {
if (colorFormat == MediaCodecInfo.CodecCapabilities
.COLOR_FormatSurface) {
useSurfaceInput = true;
}
}

// 支持的 Profile/Level
MediaCodecInfo.CodecProfileLevel[] profiles = caps.profileLevels;
for (MediaCodecInfo.CodecProfileLevel pl : profiles) {
Log.d("Codec", " Profile: " + pl.profile + ", Level: " + pl.level);
}
}
}
}

编解码器选择的经验法则:

  1. 硬件编解码器优先isHardwareAccelerated() == true):功耗低、延迟低、吞吐量高,但可能有厂商 bug。同一型号设备下,硬件解码器产生的输出格式(如 YUV 对齐方式)是稳定的。

  2. Google 软件编解码器作为备选c2.android.avc.encoder / c2.android.avc.decoder。行为一致、无厂商 bug、但功耗高、延迟高、4K 以上吃力。

  3. 永远不要硬编码解码器名称:使用 createEncoderByType("video/avc") / createDecoderByType("video/avc") 让系统选择默认编解码器。除非你有特定需求(如必须支持 Baseline Profile),才通过遍历选择特定编解码器。

  4. H.264 的 Profile / Level 选择:

    • Baseline Profile: 最简单,无 B 帧,无 CABAC。兼容性最好,压缩率最低。Android 相机的默认录制 profile。大部分硬件编码器只支持 Baseline。
    • Main Profile: 支持 B 帧和 CABAC。兼容性好,压缩率优于 Baseline。
    • High Profile: 支持 8×8 变换和自定义量化矩阵。最好的压缩率,但兼容性稍差(非常老旧的设备可能不支持)。YouTube 1080p+ 只用 High Profile。
    • 对于大多数 Android 应用,Baseline(兼容优先)或 Main(平衡)是安全的选择。
// 通过 ContentResolver 查询设备支持的编码器 (Android 5.0+)
// 这是更简单的获取可用视频编码器列表的方式
MediaCodecList codecList = new MediaCodecList(MediaCodecList.REGULAR_CODECS);

// 选择编码器的策略
String selectCodec(String mimeType, boolean isEncoder) {
// 优先选择硬件加速的编解码器
for (MediaCodecInfo info : codecList.getCodecInfos()) {
if (!info.isEncoder() == !isEncoder) continue;
for (String type : info.getSupportedTypes()) {
if (type.equalsIgnoreCase(mimeType)) {
if (info.isHardwareAccelerated()) {
return info.getName();
}
}
}
}
// 硬件解码器不可用, 回退到 createByType
return null;
}

8.6 常见问题与最佳实践

问题 1:编码器输出绿屏/花屏
最常见的调试方向是检查 YUV 格式。Android 相机输出通常是 NV21,而许多编码器的硬件实现期望 NV12 或 I420。使用 Surface 输入模式可以完全规避这一问题——OpenGL 纹理没有格式问题。

问题 2:解码器丢帧
确保 dequeueOutputBuffer 的超时时间不要太长(5000-10000 微秒为宜),并且及时调用 releaseOutputBuffer。如果渲染到 Surface 的 render=true,系统会在下一个 VSync 时将帧送达 SurfaceFlinger。

问题 3:Start-up 延迟
MediaCodec 的 start() 调用会有几十到上百毫秒的初始化延迟(硬件编解码器加载固件)。在实时通信场景中,可以预初始化一个”温”的编解码器池来消除这个延迟。

问题 4:Surface 输入下的时间戳控制
每个帧在 Surface 中输入时的时间戳由 eglPresentationTimeANDROID / setPresentationTime 控制。如果时间戳不连续或跳跃,编码器可能跳过帧或产生异常大的 GOP。

问题 5:Codec 2.0 vs OMX
Android 10 引入了 Codec 2.0 框架,正在逐步取代 OpenMAX(OMX)。Codec 2.0 使用更简单的 C API、更好的并发模型和更少的厂商定制空间。对于应用层开发者,MediaCodec 的 API 保持不变,但底层驱动逻辑有显著改善。


九、总结与学习路径

音视频技术是一个庞大而精密的领域,但它有着清晰的层次结构。以下是从基础到高级的学习建议:

第一层:理论基础(本文覆盖)

  • PCM 音频原理(采样、量化、WAV 格式)
  • YUV 色彩空间与色度子采样
  • H.264 编码管线(预测、变换、量化、熵编码、去块滤波)
  • 帧类型与 GOP 结构
  • 容器格式(MP4、MKV、FLV)
  • 流媒体协议(RTMP、HLS、DASH、WebRTC)

第二层:Android 媒体栈

  • MediaCodec(解码/编码)→ 本文覆盖
  • MediaExtractor + MediaMuxer(解封装/封装)
  • AudioTrack + AudioRecord(PCM 播放/录制)
  • Camera2 API + Surface(视频采集)
  • OpenGL ES 2.0+(渲染、滤镜、特效)

第三层:高级主题

  • FFmpeg(跨平台多媒体处理)
  • H.265/HEVC、VP9、AV1 编码技术演进
  • 视频超分辨率、插帧(AI/ML 辅助)
  • 实时通信优化(Jitter Buffer、FEC、Simulcast、SVC)
  • 视频质量评估(PSNR、SSIM、VMAF)

音视频开发的独特挑战在于:它跨越了数学、信号处理、计算机体系结构、操作系统、网络协议等多个领域。但它的回报也是丰厚的——你写下的每一条代码优化,都能让用户看到更清晰的画面、体验到更低延迟的通话、消耗更少的流量和电量。这正是系统级开发的魅力所在。

打赏
  • 微信
  • 支付宝

评论