🎬 第六章 HTTP-FLV流媒体协议
📄 一句话概括

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 秒实验

  1. 把帧数改成 100,看 tag 数怎么涨——约等于帧数加 2,多出来的是脚本 tag 与序列头。
  2. 把 off 从 13 改成 9,看解析结果——第一个 tag 的类型会读成 0,因为少了四字节长度。
  3. 打印每个 tag 的 PreviousTagSize,和上一段的总长比——能对上,这就是「可反向遍历」的实现基础。

常见误区

  1. 「FLV 头是 9 字节所以第一个 tag 从 9 开始」——是 13,中间还有 4 字节长度。
  2. 「时间戳是连续 4 字节」——拆成低 3 字节与高 1 字节两处。
  3. 「FLV 有索引」——没有,只有一条 tag 链。
自测三问(点开对答案) 1. FLV 的九字节头里有哪四项? —— **签名、版本、标志、头长度**;标志的 bit0 是视频、bit2 是音频。 2. 时间戳为什么特殊? —— **拆成低 3 字节与高 1 字节两处**,要拼起来才是 32 位。 3. FLV 为什么适合直播? —— **结构极简、tag 自带时间戳、随便切在 tag 边界都是合法文件。**

相关笔记