ES 到 TS 只有四步:拿裸流、打 PES、写 PMT、切 188 包。 每一步都在补一层接收端需要的信息。
5.4.1 把四层的职责拆开了,这一节把它们串成一条流水线。 5.4.3 做个小结,把 PS 与 TS 的分工钉死。
一、流程是四步
① 拿到 ES:编码器输出的 H.264 裸流,按 NAL 分隔。 这一步的产物没有任何时间信息。 ② 打成 PES:每个 NAL(或一组 NAL)加起始码与时间戳。 时间戳是这一层的核心产出。 ③ 写 PMT 声明:告诉接收端「视频走哪个 PID、编码是什么」。 没有这张表,接收端拿到包也不知道往哪个解码器送。 ④ 切成 188 包:加包头、按 PID 分发、必要时加适配字段。
四步里只有第 ③ 步容易漏。 因为前三步都是「加工数据」,第 ③ 步是「写说明」—— 而少了说明,数据再多也解不出来。
二、真走一遍前三步
实测用 125 字节的 NAL 走一遍: NAL 原始 125 字节,打成 PES 后总长 140 字节(载荷 125 + 头 15)。
中间还有一个容易忘的动作:H.264 在 TS 里要转成 Annex-B 格式。
也就是 NAL 前面要加 00 00 00 01 四字节起始码。
如果拿到的 NAL 已经带了起始码,就不用再加,原样送进 PES 载荷。
这条规则经常被搞错: AVCC 格式(MP4 里用的)是「2 字节长度 + NAL」,没有起始码; Annex-B 格式(TS 与 RTP 里用的)是「起始码 + NAL」。 两者转换时必须重写,不能直接拷。
三、切成 188 字节包
PES 共 140 字节,切成 1 个 188 字节包。 最后一包填充 44 字节,用的是适配字段。
注意「最后一包要填充」这一条: TS 是定长 188,所以末尾不足一包的必须用适配字段补齐。 填充字节不参与解码,但会占带宽。 这就是为什么长时间看 TS 码流的实际码率总比「编码码率」高一点点。
第 ④ 步的真实实现还要更细: 每个包的包头要带「载荷单元起始标志」(PUSI),只有 PES 的第一包置 1。 接收端靠它判断「新的 PES 从这里开始」。
这解释了 5.5.1 要实测的一个数字: 为什么 PUSI 为 1 的包数几乎等于帧数,而不是包数。 因为一个帧可能被切成几十个包,但只有第一个带 PUSI。
四、动手 Demo:编码流程——ES 怎么一步步变成 TS 包
先把四步摆出来,再真拿一个 125 字节的 NAL 走前三步。 最后算切包与填充:140 字节的 PES 要填 44 字节才凑满 188。
import struct
nal = b"\x00\x00\x00\x01" + bytes(range(1, 122)) # 125 字节 Annex-B
print("NAL 原始 %d 字节" % len(nal))
print("已带 4 字节起始码:%s" % (nal[:4] == b"\x00\x00\x00\x01"))
head = b"\x00\x00\x01" + b"\xE0"
head += struct.pack("!H", 0x8000 | (3 + 9 + 5 + len(nal)))
head += b"\x84\x80\x05" + b"\x21\x00\x01\x00\x01"
pes = head + nal
print("PES 总长 %d(载荷 %d + 头 %d)" % (len(pes), len(nal), len(pes) - len(nal)))
# 切 188:4 字节包头 + 188-4 字节剩余
need = 188 - 4
pad = need - len(pes) % need if len(pes) % need else 0
n = (len(pes) + pad) // need
print("切成 %d 个 188 字节包,最后一包填充 %d 字节" % (n, pad))
# 第一个包的包头:同步字节 + PUSI + PID
b1 = 0x47
b2 = 0x40 | ((256 >> 8) & 0x1F) # PUSI=1,PID 高 5 位
b3 = 256 & 0xFF
b4 = 0x30 | 0 # 适配字段 3:既有适配又有载荷
print("首包包头 %02X %02X %02X %02X" % (b1, b2, b3, b4))
实跑输出:
--------------------------------------------------------------------
编码流程——ES 怎么一步步变成 TS 包
--------------------------------------------------------------------
**① 流程是四步**
**① 拿到 ES**:**编码器输出的 H.264 裸流,按 NAL 分隔。**
**② 打成 PES**:**每个 NAL(或一组 NAL)加起始码与时间戳。**
**③ 写 PMT 声明**:**告诉接收端「视频走哪个 PID、编码是什么」。**
**④ 切成 188 包**:**加包头、按 PID 分发、必要时加适配字段。**
**② 真走一遍前三步**
NAL 原始 125 字节
H.264 在 TS 里要转成 Annex-B(用 4 字节起始码)——已经是了,所以原样。
PES 总长 140(载荷 125 + 头 15)
**③ 切成 188 字节包**
PES 共 140 字节,切成 1 个 188 字节包
最后一包填充 44 字节(用适配字段填)
**注意「最后一包要填充」这一条:**
**TS 是定长 188,所以末尾不足一包的必须用适配字段(adaptation field)补齐。**
**填充字节不参与解码,但会占带宽。**
**这就是为什么长时间看 TS 码流的实际码率总比「编码码率」高一点点。**
**其实第③步的真实实现还要更细:**
**每个包的包头要带「载荷单元起始标志」(PUSI),只有 PES 的第一包置 1。**
**接收端靠它判断「新的 PES 从这里开始」。**
PES 头 15 字节、载荷 125 字节,合计 140——正好只占一个 188 包的四分之三。
填充 44 字节,184 - 140 = 44,算得上一字节不差。
包头第 2 字节是 0x40 | PID高位,这就是 PUSI 置 1 的写法。
30 秒实验
- 把 NAL 改成 300 字节,看要几个包、填多少——2 个包,填充变成 68 字节。
- 把
b4改成0x10,看适配字段控制变成几——变成 1,表示「纯载荷、没有适配字段」,这时遇到填充就会出错。 - 把 PID 改成 4097,再打印包头——高位进 b2 的低 5 位,能看出 13 位 PID 是怎么拼的。
常见误区
- 「Annex-B 与 AVCC 可以直接互换」——不行,一个带起始码一个带长度。
- 「填充字节也算有效载荷」——不算,接收端会跳过。
- 「每个 TS 包都带 PUSI」——只有 PES 的首包带。
自测三问(点开对答案)
1. ES 到 TS 的四步是什么? —— **拿 ES、打 PES、写 PMT、切 188 包**;第 ③ 步最容易漏。 2. 为什么最后一包要用适配字段填充? —— **TS 定长 188**,不足一包的必须补齐,填充会占带宽但不参与解码。 3. PUSI 的作用是什么? —— **标记「PES 从这里开始」**,只有首包置 1。- ☐ (本节完,下一节 5.4.3 PS/TS 码流小结)