推流 = 编码 + 封装成 flv 字节 + 把这串字节送出去。 本机 rtmp 协议没编进来,所以最后一段换成手工分块,其余全真跑。
13.2 看懂了服务器那半边,这一节看推流这半边。 这是全书唯一一次把「码流字节」亲手送到另一端。
一、先查这条路在不在
容器与协议是两个不同的名字,别混。 名字 在表里 是什么
rtmp 不在 协议,没编进来 flv 在 容器 rtsp 在 协议加容器 mpegts 在 容器
flv 在表里、rtmp 不在。
前者管「怎么写这个文件」,后者管「怎么送到对面」。
PyAV 这套库编进来时没带 rtmp 协议。
所以从这里开始要分两件事讲:
「flv 字节怎么生成」能真跑,「字节怎么送进 RTMP」只能讲规则。
而这两件事之间恰好有个能真跑的替代方案:用普通 TCP 送。
二、硬推 rtmp 会怎样
这一条要亲手试一次,因为它的表现很误导人。 项 值
打开 19350 端口 没有服务在听 走到了哪一步 写第一包也过了 报错 UndefinedError|Errno 138 全程耗时 五秒半
这就是那个经典陷阱:「能打开」不等于「能工作」。
av.open 只建一个容器对象,第一包写进去也不报错,真正的连接要等收尾冲空包时才发生。
而且它先重试了几秒才放弃——上面那个五秒半就是证据。
所以直播程序里超时必须自己设,不能指望库很快失败。
这个坑在 30 课踩过一次,当时的结论一模一样:av.open 的返回值不代表网络已经通了。
判「能不能推」只看三样:端口通不通、协议在不在表里、服务器有没有回握手。
三、封装这一层可以真跑
写一个 flv,看它的字节长什么样。 项 值
flv 总字节 3933 落盘耗时 27.3 毫秒 开头三个字母 FLV 版本/标志 1/1 头长度 9 字节
头只有 9 字节,后面再跟 4 字节的「前一个 tag 长度 0」,所以第一个 tag 从第 13 字节开始。 这个 13 是所有解析 FLV 的人的固定偏移。 标志位那个 1 表示「有视频」:按位看是 0x01 视频、0x04 音频, 只有音频时这里是 4,音视频都有是 5。 这一小段素材只有画面,所以是 1。
四、把 tag 数一遍
FLV 的内容就是一个个 tag 首尾相接。 项 值
tag 总数 53 脚本 tag(18) 1 个,放元数据 视频 tag(9) 52 个 最大的 tag 799 字节
52 个视频 tag 装 50 帧,多出来的两个不是错: 一个是 AVC 序列头(装 SPS/PPS,码流的参数), 一个是结束标记(包类型 2,告诉播放器到此为止)。 每个 tag 有自己的 11 字节头: 类型 1 字节、数据长度 3 字节、时间戳 3 字节加扩展 1 字节、流号 3 字节, 后面跟数据,再跟 4 字节的「本 tag 长度」。 这 4 字节是给反向遍历用的,不算进数据长度。 最大的 tag 799 字节就是那个关键帧,后面几十字节的都是 B 帧或 P 帧。 这也解释了为什么「GOP 越长、关键帧越少、平均码率越低」。
五、手工分块传一遍
把 flv 字节按 1024 一块推进 TCP,收到再解回来。 项 值
分块大小 1024 字节 分了几块 4 发出/收到 3933/3933 传输耗时 0.37 毫秒 解出帧数(不冲) 48 解出帧数(冲完) 50
最后两行是这一节最值钱的地方:不冲解码器只出 48 帧,冲完才 50 帧。 因为码流里有 B 帧,解码器要先攒几帧才能按显示顺序输出。 这跟第十章「重采样要冲空包」是同一类事:只要库内部有缓存,收尾就必须显式冲一次。 而且这两处都不报错——少的那几帧不会有人告诉你。 真 RTMP 与这一段的差别只有最后一步:这里是把 1024 字节直接写进 socket。 真推流是把同样的字节切成 RTMP 的 chunk 再发,默认一块 128 字节。 所以「推流」里最不难的部分,其实是最外面那层包装。
六、动手 Demo:编码、封装、传输——最后一段换成手工分块
查一遍 rtmp/flv 在不在,硬推一次看报错,再写 50 帧 flv、拆开 tag、分块推给 TCP。
import io
import socket
import threading
import av
tab = av.formats_available
print("rtmp" in tab, "flv" in tab)
# 1) 硬推 rtmp:打开不报、第一包不报、收尾才报
c = av.open("rtmp://127.0.0.1:19350/live/test", "w", format="flv")
vs = c.add_stream("libx264", rate=25)
vs.width, vs.height, vs.pix_fmt = 32, 24, "yuv420p"
f = av.VideoFrame(32, 24, "yuv420p")
f.pts = 0
for op in vs.encode(f):
c.mux(op)
try:
for op in vs.encode(None): # 冲空包时才真的去连
c.mux(op)
except Exception as e:
print(type(e).__name__, e)
# 2) 写一个 flv,数 tag
o = av.open("push.flv", "w", format="flv")
vs = o.add_stream("libx264", rate=25)
vs.width, vs.height, vs.pix_fmt = 160, 120, "yuv420p"
for i in range(50):
fr = av.VideoFrame(160, 120, "yuv420p")
for p in fr.planes:
p.update(bytes([16 + (i * 3) % 200]) * p.buffer_size)
fr.pts = i
for op in vs.encode(fr):
o.mux(op)
for op in vs.encode(None):
o.mux(op)
o.close()
blob = io.open("push.flv", "rb").read()
print(len(blob), blob[:3])
pos, tags = 13, []
while pos + 11 <= len(blob):
typ = blob[pos] & 0x1F
size = int.from_bytes(blob[pos + 1:pos + 4], "big")
tags.append((typ, size))
pos += 11 + size + 4
print(len(tags), sum(1 for t in tags if t[0] == 9))
# 3) 手工分块推到本地 TCP,收齐再解
def serve(srv, box):
c2, _ = srv.accept()
while True:
d = c2.recv(65536)
if not d:
break
box["d"] += d
c2.close()
srv = socket.socket()
srv.bind(("127.0.0.1", 0))
srv.listen(1)
box = {"d": b""}
threading.Thread(target=serve, args=(srv, box), daemon=True).start()
cli = socket.create_connection(("127.0.0.1", srv.getsockname()[1]))
for i in range(0, len(blob), 1024):
cli.sendall(blob[i:i + 1024])
cli.close()
print(len(blob), len(box["d"]))
# 4) 解回来:不冲 48 帧、冲完 50 帧
io.open("recv.flv", "wb").write(box["d"])
c = av.open("recv.flv")
st = c.streams.video[0]
n1 = sum(1 for pk in c.demux(st) if pk.size for fr in pk.decode())
n2 = n1 + len(st.codec_context.decode(None))
print(n1, n2)
实跑输出:
--------------------------------------------------------------------
编码、封装、传输——最后一段换成手工分块
--------------------------------------------------------------------
**① 先把 rtmp 这条路查清楚**
名字 在格式表里 说明
--------------------------------------------
rtmp 不在 协议,没编进来
flv 在 容器名
rtsp 在 容器名
hls 在 容器名
mpegts 在 容器名
**`flv` 在表里、`rtmp` 不在。**
**前者是容器(怎么写这个文件),后者是协议(怎么送到对面)。**
**PyAV 这套库编进来时没带 rtmp 协议。**
**② 硬推 rtmp 会怎样**
项 值
------------------------------------------------------------------------------
打开 19350 端口 没有服务在听
走到了哪一步 写第一包也过了
报错 UndefinedError|[Errno 138] Error number -138 occurred
全程耗时 5532.6 毫秒
**这就是那个经典陷阱**:**「能打开」不等于「能工作」。**
**`av.open` 只建一个容器对象,第一包写进去也不报错,真正的连接要等收尾冲空包时才发生。**
**而且它先重试了几秒才放弃**——上面那个耗时就是证据。
**所以直播程序里超时必须自己设,不能指望库很快失败。**
**所以排查推流问题的口诀是**:
**先看端口通不通(socket 试一次),再看协议在不在(格式表),最后才看代码。**
**反过来查,常常查半天发现是库根本不会这个协议。**
**③ 封装这一层可以真跑:写一个 flv**
项 值
------------------------------
flv 总字节 3933
落盘耗时 23.1 毫秒
开头三个字母 FLV
版本/标志 1/1
头长度 9 字节
**头只有 9 字节,加 4 字节的「前一个 tag 长度 0」,所以第一个 tag 从第 13 字节开始。**
**这个 13 是所有解析 FLV 的人的固定偏移。**
**④ 把 tag 数一遍**
项 值
-------------------------------------
tag 总数 53
脚本 tag(18) 1 个,放元数据
视频 tag(9) 52 个
最大的 tag 799 字节
**52 个视频 tag 装 50 帧**,多出来两个不是错:**一个是 AVC 序列头(装 SPS/PPS,码流参数)**,
**一个是结束标记(AVC 包类型 2)**。
**⑤ 手工分块传一遍**
项 值
-----------------------------------
分块大小 1024 字节
分了几块 4
发出/收到 3933/3933
传输耗时 0.47 毫秒
解出帧数(不冲) 48
解出帧数(冲完) 50
**最后两行是这一节最值钱的地方**:**不冲解码器只出 48 帧,冲完才 50 帧。**
**因为码流里有 B 帧,解码器要先攒几帧才能按显示顺序输出。**
**这跟第十章「重采样要冲空包」是同一类事**:**只要库内部有缓存,收尾就必须显式冲一次。**
**⑥ 真正的推流差在哪**
这一节做的 真 RTMP 推流
-------------------------------------------------
编码:libx264 一样
封装:flv 字节 一样,只是不分块发
传输:TCP 直发 先握手,再切成 chunk 发
没有命令通道 要发 connect/publish 命令
**前两行与真推流完全一样,这就是为什么这一段值得真跑**:
**推流的核心产物(flv 字节)你已经亲手生成过了**,**换掉的只是最外面那层「怎么送到对面」。**
rtmp 不在表里,硬推时打开不报、收尾才报 Errno 138,还等了五秒半。
分块推 3933 字节,收到一模一样;解码不冲 48 帧、冲完 50 帧。
30 秒实验
- 把分块从 1024 改成 128(RTMP 的默认 chunk 大小),看分几块、耗时变不变。
- 去掉
decode(None),只统计解出的帧数,看差几帧(提示:2 帧)。 - 把 flv 的标志位与头长度打印出来,与 13.3 表里的值对一遍。
常见误区
- 以为
av.open("rtmp://…")成功就是能推了。 写第一包也不报,收尾才报。 - 以为 flv 只有视频 tag。 还有一个脚本 tag 装元数据。
- 以为解完流就出全了。 B 帧要重排,收尾得冲一次解码器。
自测三问(点开对答案)
1. 推流的三件事是什么? —— 编码、封装成 flv 字节、把这串字节送到对面。 2. 52 个视频 tag 装 50 帧,多的两个是什么? —— AVC 序列头(SPS/PPS)与结束标记。 3. 为什么解码要显式冲一次? —— B 帧要按显示顺序重排,最后几帧会留在解码器里。- ☐ (本节完,下一节 13.4 两个 C++ 封装类的小结)