FLV 就是一个九字节头加一串 tag,每个 tag 前面还有四字节长度。 结构简单到可以手写解析器。
6.2 讲完了 HTTP 那一层,这一节进入体的字节结构。 6.3.2 细讲三种 tag 里最重要的两种。
一、九字节文件头
① 签名 3 字节:必须是 FLV。
② 版本 1 字节:通常是 1。
③ 标志 1 字节:bit0 表示有视频、bit2 表示有音频。
④ 头长度 4 字节:通常 9,表示没有扩展头。
实测这个文件 2784 字节、28 个 tag,标志是 0x01。 0x01 只有 bit0,说明只有视频没有音频。 0x05 才是音视频都有。
头长度这个字段有点冗余: 它就是「从头开始到 tag 区有多远」,正常永远是 9。 留着它只是为了将来能扩展——真出现 9 以外的值时,解析器应该按它跳过扩展区。
二、tag 头的 11 字节
① tag 类型 1 字节:8 音频、9 视频、18 脚本。 ② 数据长度 3 字节:大端,最大 16 MB。 ③ 时间戳低 3 字节:毫秒。 ④ 时间戳高 1 字节:扩展位,拼起来是 32 位。 ⑤ 流 ID 3 字节:永远是 0。 ⑥ PreviousTagSize 4 字节:前一个 tag 的总长(11 加数据长度)。
时间戳拆成「低 3 字节 + 高 1 字节」是很特别的设计:
它把一个 32 位整数拆在两处,读的时候必须先读低 3 字节再拼高位。
写成代码是 ts = low24 | (high << 24)。
PreviousTagSize 是「后置长度」,用来反向遍历。
所以 FLV 既能从头往后读,也能从尾往前读——
这一点在「只取最后一帧」这类需求里很有用。
三、为什么 FLV 这么适合直播
① 结构极简:一个头加一串 tag,没有索引、没有交叉引用。 所以可以「来一个 tag 就发一个」,不需要等文件写完。 ② tag 自带时间戳:接收端不依赖任何外部信息就能排好序。 ③ 天然可截断:随便切在哪个 tag 边界上,前半段都是合法 FLV。 这一点让「边生成边发」变得非常自然。
对比一下 MP4: MP4 的索引(moov)通常在文件末尾,必须整段拿到才能播。 所以 MP4 天生不适合直播——要用就得走「分片 MP4」那套改造。
这也解释了为什么 RTMP 的消息负载直接用 FLV tag: RTMP 需要「一个个消息往外推」,而 FLV tag 正好就是「一个个自包含单元」。 两者是天生一对。
四、动手 Demo:FLV 格式——9 字节头,然后全是 tag
真封一个 FLV,先读九字节头,再按 11 字节 tag 头逐个往后走。 最后数一数三种 tag 各有多少条。
import io, os, struct, 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()
print("签名 %r,版本 %d,标志 0x%02X,头长 %d"
% (raw[:3], raw[3], raw[4], struct.unpack("!I", raw[5:9])[0]))
off, cnt = 9 + 4, {}
while off + 11 <= len(raw):
t = raw[off]
size = int.from_bytes(raw[off + 1:off + 4], "big")
ts = int.from_bytes(raw[off + 4:off + 7], "big") | (raw[off + 7] << 24)
cnt[t] = cnt.get(t, 0) + 1
off += 11 + size + 4
print("文件 %d 字节,tag 共 %d 个" % (len(raw), sum(cnt.values())))
for t, name in ((18, "脚本"), (8, "音频"), (9, "视频")):
print(" 类型 %d %-4s %d 条" % (t, name, cnt.get(t, 0)))
实跑输出:
--------------------------------------------------------------------
FLV 格式——9 字节头,然后全是 tag
--------------------------------------------------------------------
**① 文件头 9 字节**
字段 值 含义
--------------------------------------------
签名 FLV 必须是 FLV
版本 1 通常是 1
标志 0x01 bit0 视频、bit2 音频
头长度 9 9 表示没有扩展头
**实测这个文件 2784 字节、28 个 tag。**
**标志是 0x01,说明只有视频没有音频。**
**② 每个 tag 的结构**
部分 字节 说明
--------------------------------------------------------
tag 类型 1 8 音频、9 视频、18 脚本
数据长度 3 大端,最大 16 MB
时间戳低 3 毫秒
时间戳高 1 扩展成 32 位
流 ID 3 永远是 0
PreviousTagSize 4 前一个 tag 的总长
**③ 逐个数一遍**
类型 名称 条数
--------------------------
18 脚本 1
8 音频 0
9 视频 27
**视频 tag 27 个、帧 25 个:多出来的两个是序列头与收尾。**
**视频 tag 时间戳前 6 个是 0、0、40、80、120、160。**
**从第 3 个起步长是 40 毫秒,正好是 25 fps 的倒数。**
2784 字节里,头只占 13(9 加 4),剩下全是 tag。
off 从 13 开始——9 字节文件头加 4 字节第一个 PreviousTagSize。
tag 是 27 个视频加 1 个脚本,脚本那条装着 onMetaData。
30 秒实验
- 把帧数改成 100,看 tag 数怎么涨——约等于帧数加 2,多出来的是脚本 tag 与序列头。
- 把
off从 13 改成 9,看解析结果——第一个 tag 的类型会读成 0,因为少了四字节长度。 - 打印每个 tag 的
PreviousTagSize,和上一段的总长比——能对上,这就是「可反向遍历」的实现基础。
常见误区
- 「FLV 头是 9 字节所以第一个 tag 从 9 开始」——是 13,中间还有 4 字节长度。
- 「时间戳是连续 4 字节」——拆成低 3 字节与高 1 字节两处。
- 「FLV 有索引」——没有,只有一条 tag 链。
自测三问(点开对答案)
1. FLV 的九字节头里有哪四项? —— **签名、版本、标志、头长度**;标志的 bit0 是视频、bit2 是音频。 2. 时间戳为什么特殊? —— **拆成低 3 字节与高 1 字节两处**,要拼起来才是 32 位。 3. FLV 为什么适合直播? —— **结构极简、tag 自带时间戳、随便切在 tag 边界都是合法文件。**- ☐ (本节完,下一节 6.3.2 FLV 的重要 Tag 说明)