🎬 第十三章 FFmpeg直播综合案例实战
📄 一句话概括

推流 = 编码 + 封装成 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 秒实验

  1. 把分块从 1024 改成 128(RTMP 的默认 chunk 大小),看分几块、耗时变不变。
  2. 去掉 decode(None),只统计解出的帧数,看差几帧(提示:2 帧)。
  3. 把 flv 的标志位与头长度打印出来,与 13.3 表里的值对一遍。

常见误区

  1. 以为 av.open("rtmp://…") 成功就是能推了。 写第一包也不报,收尾才报。
  2. 以为 flv 只有视频 tag。 还有一个脚本 tag 装元数据。
  3. 以为解完流就出全了。 B 帧要重排,收尾得冲一次解码器。
自测三问(点开对答案) 1. 推流的三件事是什么? —— 编码、封装成 flv 字节、把这串字节送到对面。 2. 52 个视频 tag 装 50 帧,多的两个是什么? —— AVC 序列头(SPS/PPS)与结束标记。 3. 为什么解码要显式冲一次? —— B 帧要按显示顺序重排,最后几帧会留在解码器里。

相关笔记