🎬 第八章 FFmpeg命令行实现流媒体功能及直播应用
📄 一句话概括

拉 UDP 时,接收端必须在发之前就把端口绑好。 顺序反了就是一帧都没有——而且全程不报错。

📝 本节和前面几节的关系

8.4.2 站在发送端,这一节换到接收端。 同一个协议,两种角色踩的坑完全不一样。

一、先开接收端,再发

这一节的实验都靠一个中间转发器:A 端口收、B 端口转,中间可以按规则丢包。 接收端挂在 B 上,发送端往 A 打,这样才方便「制造故障」。

先绑端口再发,解出 23 帧,一次成功。 成功率不是必然的——它取决于接收端有没有在发之前把 socket 建好。

二、顺序反了,一帧都没有

把顺序调过来:先把整段发完,再开接收端。

顺序 解出帧


先开接收端,再发 23 先发完,再开接收端 0

一帧都没有,而且不报错、不警告,看起来就像「流本来就没发过来」。 UDP 不排队、不缓存——没人接的那一刻,包就是没了。

这条对命令行同样成立:ffplay 要先起,ffmpeg 推流再跟上。 反过来写,ffplay 起来时对面已经推完了,只能干等着。

三、中间丢掉一部分包

转发器上加一条规则:每 N 个包扔掉 1 个。

丢包规则 转发包 扔掉包 解出帧


不丢 15 0 21 每 20 包丢 1 15 0 21 每 5 包丢 1 12 3 0

第二行值得单独说:「每 20 包丢 1」的扔掉包数是 0。 因为整段只发了 15 个包,20 包一丢的规则一次都没轮到。 这不是规则失效,是样本太少——做丢包实验时最容易忽略的一点。

第三行只丢 3 个包,帧数就从 21 掉到 0。 为什么这么脆?因为解复用器要先读到 PAT 与 PMT 表, 这两张表一丢,后面所有 TS 包都成了「不知道属于哪一路」的垃圾。

所以工程上真正用的 UDP 直播都不是裸 UDP: 要么 RTP 骑在 UDP 上(有序号),要么 SRT 自己带重传与纠错。

四、命令行拉 UDP

FFmpeg 家族拉 UDP 只要一条命令:

ffplay -f mpegts udp://127.0.0.1:5004?fifo_size=1000000

-f mpegts 是必须的:UDP 里没有任何「我是什么格式」的信息, 不像 HTTP 有 Content-Type、也不像文件有扩展名,只能自己说。

fifo_size 是接收缓冲,调大能抗一点抖动,但会加大延迟。 overrun_nonfatal=1 建议一起带上,缓冲溢出时只丢数据、不让进程退出。

再补一个常用参数:timeout 控制读超时(微秒)。 不设的话,对面不发东西时会一直挂着,看起来像「程序卡死」。

五、动手 Demo:顺序、丢包、解码,三件事一次量完

下面这段先验「顺序」,再用转发器造三档丢包,最后看解出多少帧。 关键是接收端要用 fifo_size 加大缓冲,否则本地回环也会溢。

import io, socket, threading, time
from fractions import Fraction
import av

def free_port():
    s = socket.socket()
    s.bind(("127.0.0.1", 0))
    p = s.getsockname()[1]
    s.close()
    return p

def ts_bytes(n=25):
    u"""把 s7.mp4 的前 n 帧封成 TS,返回全部字节。"""
    buf = io.BytesIO()
    o = av.open(buf, "w", format="mpegts")
    st = o.add_stream("libx264", rate=Fraction(25, 1))
    st.width, st.height, st.pix_fmt = 160, 90, "yuv420p"
    c, k = av.open("s7.mp4"), 0
    for f in c.decode(video=0):
        for q in st.encode(f):
            o.mux(q)
        k += 1
        if k >= n:
            break
    for q in st.encode(None):
        o.mux(q)
    o.close()
    c.close()
    return buf.getvalue()

def pull(port, cap=40):
    st = {"n": 0, "err": None}

    def go():
        try:
            c = av.open("udp://127.0.0.1:%d?fifo_size=1000000"
                        "&overrun_nonfatal=1" % port,
                        options={"timeout": "8000000"})
            for _f in c.decode(video=0):
                st["n"] += 1
                if st["n"] >= cap:
                    break
            c.close()
        except Exception as e:
            st["err"] = type(e).__name__

    th = threading.Thread(target=go)
    th.daemon = True
    th.start()
    return th, st

TS = ts_bytes()
chunks = [TS[i:i + 1316] for i in range(0, len(TS), 1316)]
print(u"TS 共 %d 字节,切成 %d 个 UDP 包" % (len(TS), len(chunks)))

# ① 先开接收端
p = free_port()
th, st = pull(p)
time.sleep(1.5)
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for ch in chunks:
    tx.sendto(ch, ("127.0.0.1", p))
    time.sleep(0.002)
tx.close()
th.join(9.0)
print(u"先开接收端:%d 帧" % st["n"])

# ② 先发完再开接收端
p2 = free_port()
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for ch in chunks:
    tx.sendto(ch, ("127.0.0.1", p2))
    time.sleep(0.002)
tx.close()
time.sleep(0.2)
th2, st2 = pull(p2)
th2.join(9.0)
print(u"先发完再开:%d 帧" % st2["n"])

实跑输出:

--------------------------------------------------------------------
8.4.3 FFmpeg 实现 UDP 直播拉流
--------------------------------------------------------------------

**① 先开接收端,再发:能解出帧**

**先绑端口再发,解出 23 帧。**

**② 先发完,再开接收端:一帧都没有**

  顺序                   解出帧
  ---------------------------------
  先开接收端,再发       23
  先发完,再开接收端     0

**顺序反了就是一帧都没有,而且不报错。**
**UDP 不排队、不缓存——没人接的那一刻,包就是没了。**

**③ 在中间丢掉一部分 TS 包**

  丢包规则         转发包     扔掉包     解出帧
  -------------------------------------------------
  不丢             15         0          21
  每 20 包丢 1     15         0          21
  每 5 包丢 1      12         3          0

**这次丢的是 TS 包,不是 RTP 包——连「少了哪个」都不知道。**
**接收端只能靠 TS 自己的连续性计数器(占 4 位)去猜,猜到了也没法补。**

**所以工程上真正要用的 UDP 直播都不是裸 UDP:**
**要么 RTP 骑在 UDP 上(有序号),要么 SRT 自己带重传与纠错。**

**④ 命令行拉 UDP**

`ffplay -f mpegts udp://127.0.0.1:5004?fifo_size=1000000`

**`-f mpegts` 是必须的:UDP 里没有「我是什么格式」的信息,得自己说。**
**`fifo_size` 是接收缓冲,调大能抗一点抖动,但会加大延迟。**

第一段能解出帧,第二段是 0——顺序就是全部。

丢包那段用的是转发器:A 收、B 转,k % 20 == 0 时直接 continue。 整段只有 15 个包,所以「每 20 包丢 1」一次都没触发——这一行最能说明样本量的重要。

30 秒实验

  1. 把 fifo_size 去掉:看本地回环会不会因为缓冲太小而丢包。
  2. 把 overrun_nonfatal=1 去掉:缓冲溢出时看进程是不是直接退出。
  3. 把发送间隔从 2 毫秒改成 20 毫秒:看接收端还能不能追上。

常见误区

  1. 先跑发送端、再跑 ffplay。UDP 没有缓存,发完就没了。
  2. 不写 -f mpegts。UDP 不携带格式信息,解复用器会乱猜。
  3. 以为「丢几个包只是花屏」。丢的若是 PAT/PMT,整条流直接解不出。
自测三问(点开对答案) 1. 为什么先发完再开接收端会一帧都没有? —— UDP 不缓存,没人在监听时包被内核直接丢弃,之后也不会补。 2. 「每 20 包丢 1」为什么扔掉包数是 0? —— 整段只发了 15 个包,20 包一丢的规则一次都没轮到。 3. 为什么丢 3 个包就能让 21 帧变成 0 帧? —— PAT 与 PMT 表一丢,后面的 TS 包就不知道该属于哪一路。

相关笔记