视频 tag 的第一个字节装了两层信息:帧类型与编码格式。 第二个字节只在 H.264 时才存在,表示是序列头还是画面。
6.3.1 讲了 tag 的外壳,这一节钻进两种主要 tag 的内部。 下一章转向流媒体开源库——看这些协议都有哪些现成实现。
一、视频 tag 的第一个字节
它拆成两半:高 4 位是帧类型、低 4 位是编码格式。 帧类型 1 是关键帧、2 是普通帧;编码 7 是 H.264。
实测第一个视频 tag 的这个字节是 0x17:
高 4 位 1 表示关键帧,低 4 位 7 表示 H.264。
所以 0x17 是「H.264 关键帧」的固定标志,
0x27 则是「H.264 普通帧」。
记住这两个值很实用:
在抓包里搜 17 开头的视频 tag,就是在找关键帧的位置。
这也是「首屏为什么慢」最直接的观测点。
二、第二个字节:AVCPacketType
只有编码格式是 7(H.264)时,第二个字节才有意义。 0 是 AVC 序列头,1 是真正的画面数据,2 是序列结束。
序列头里装的是什么? 是 SPS 与 PPS——解码器必须先拿到它们才能解后面的帧。 所以序列头永远排在第一个关键帧之前。
实测 27 个视频 tag 里: AVC 序列头 1 个、关键帧 3 个、普通帧 24 个。 关键帧比「GOP 25 该有 1 个」多, 多出来的是编码器在收尾时补的——它要保证最后一帧能独立解。
所以「帧数 × GOP」只能估关键帧数,真要数必须读帧类型位。 做首屏优化时如果按估算去调参数,很容易调不准。
三、音频 tag 与脚本 tag
音频 tag 的第一个字节拆成四段: 格式 4 位(10 是 AAC)、采样率 2 位、位深 1 位、声道 1 位。 AAC 时还要第二个字节:0 是音频序列头、1 是音频数据。
采样率的 2 位只有四个值:0 是 5.5 kHz、1 是 11 kHz、 2 是 22 kHz、3 是 44 kHz。 注意它是「采样率档位」不是「采样率数值」, 而且最高只到 44 kHz——48 kHz 要用扩展字段表示。
脚本 tag(类型 18)里的负载是 AMF0 编码的 onMetaData。
它写分辨率、帧率、码率、编码 ID 这些信息。
没有它,播放器可能按 0×0 建解码器,直接黑屏。
三种 tag 的职责可以这样记: 脚本 tag 说「这是什么规格」,序列头说「怎么解」, 画面与声音的 tag 说「这是第几毫秒的内容」。 缺任何一类,播放器都得猜。
四、动手 Demo:重要 Tag——视频 tag 的第一个字节是两层标志
读第一个视频 tag 的第一个字节,把高低 4 位分别打出来。 再统计关键帧、普通帧、序列头各有多少。
import io, os, tempfile
import av, numpy as np
tmp = os.path.join(tempfile.mkdtemp(), "a.flv")
c = av.open(tmp, "w", format="flv")
st = c.add_stream("libx264", rate=25)
st.width, st.height, st.pix_fmt = 320, 240, "yuv420p"
st.options = {"crf": "30", "preset": "veryfast", "g": "25"}
for i in range(25):
f = av.VideoFrame(320, 240, "yuv420p")
y = np.zeros((240, 320), np.uint8)
y[:, :] = np.linspace(16, 200, 320, dtype=np.uint8)[None, :]
f.planes[0].update(y.tobytes())
for pi in (1, 2):
f.planes[pi].update(bytes([128]) * f.planes[pi].buffer_size)
f.pts = i
for pk in st.encode(f):
c.mux(pk)
for pk in st.encode(None):
c.mux(pk)
c.close()
raw = io.open(tmp, "rb").read()
v, off = [], 13
while off + 11 <= len(raw):
t = raw[off]
size = int.from_bytes(raw[off + 1:off + 4], "big")
body = raw[off + 11:off + 11 + size]
if t == 9:
v.append(body)
off += 11 + size + 4
b0 = v[0][0]
print("第一个视频 tag 首字节 0x%02X" % b0)
print(" 帧类型 %d(1 是关键帧)" % (b0 >> 4))
print(" 编码 %d(7 是 H.264)" % (b0 & 0x0F))
print(" 第二字节 %d(0 序列头、1 画面)" % v[0][1])
key = [x for x in v if (x[0] >> 4) == 1]
seq = [x for x in v if x[1] == 0]
print("视频 tag %d 个:关键帧 %d、序列头 %d、普通帧 %d"
% (len(v), len(key), len(seq), len(v) - len(key)))
实跑输出:
--------------------------------------------------------------------
重要 Tag——视频 tag 的第一个字节是两层标志
--------------------------------------------------------------------
**① 视频 tag 的内部结构**
**第一个字节拆成两半:高 4 位是帧类型、低 4 位是编码格式。**
**帧类型 1 是关键帧、2 是普通帧;编码 7 是 H.264。**
字段 值 含义
--------------------------------------------------
帧类型 1 1 是关键帧
编码格式 7 7 是 H.264
第二字节 0 0 序列头、1 NALU、2 序列尾
**第二字节叫 AVCPacketType,只有编码是 7 时才存在。**
**0 是 AVC 序列头(装着 SPS/PPS),1 是真正的画面数据。**
**② 数一数关键帧与普通帧**
项 条数
--------------------------
视频 tag 总数 27
关键帧 3
普通帧 24
AVC 序列头 1
**27 个视频 tag 里有 3 个关键帧,比「GOP 25 该有 1 个」多。**
**多出来的是编码器在收尾时补的关键帧——要保证最后一帧可独立解。**
**所以帧数只能估,真要数得看每个 tag 的帧类型位。**
**③ 音频 tag 长什么样**
**第一个字节拆成四段:格式 4 位、采样率 2 位、位深 1 位、声道 1 位。**
**格式 10 是 AAC,采样率 3 表示 44 kHz,位深 0 是 8 位、1 是 16 位。**
**AAC 时还要第二个字节:0 是音频序列头、1 是音频数据。**
**这个文件里音频 tag 是 0 条——不是漏了,是编码时只编了视频。**
**真实推流一定是音视频双路,两者的 tag 按时间戳交错发送。**
首字节 0x17 = 关键帧加 H.264,0x27 就是普通帧,这是最常见的两个值。 第二字节为 0 的是序列头,它必须排在第一个关键帧之前。 关键帧 3 个、序列头 1 个,两者不重叠,因为序列头本身也标成关键帧。
30 秒实验
- 把
g改成 10,看关键帧数——涨到 3 倍左右,但每个都更小。 - 打印最后一个视频 tag 的首字节,看它是不是关键帧——是,这就是「收尾补关键帧」的直接证据。
- 把脚本 tag 的体读出来打印,看 onMetaData——能看到分辨率、帧率这些字段,说明元数据确实在 tag 里。
常见误区
- 「首字节就是编码格式」——高 4 位是帧类型,低 4 位才是编码。
- 「第二字节总是存在」——只有 H.264 才有。
- 「关键帧数 = 帧数/GOP」——只能估,收尾会多补。
自测三问(点开对答案)
1. 0x17 与 0x27 分别是什么意思? —— **0x17 是 H.264 关键帧,0x27 是 H.264 普通帧。** 2. AVCPacketType 的三个值? —— **0 序列头、1 画面数据、2 序列结束。** 3. 脚本 tag 里装的是什么? —— **AMF0 编码的 onMetaData**,写分辨率、帧率、码率这些元数据。- ☐ (本章完,下一章 7.1 流媒体开源库简介)