🎬 第十二章 ZLMediaKit搭建直播平台
📄 一句话概括

这个示例读一个 mp4,按帧的时间戳一帧一帧地往外推。 推文件的关键不是发得多快,是发得多准。

📝 本节和前面几节的关系

12.3.1 是「问状态」,这一节是「送数据」。 12.3.3 补一个 C++ 语法点,示例里到处都在用。

一、它不是「把文件发过去」

test_pusher.cpp 做的事看着多余:读 mp4,按时间戳等时间再发。 为什么不一口气发完?两个原因。

① 播放器按时间戳出画。 发得再快,播放器也得按时间戳等,快发不会让画面提前。 ② 服务器与播放器都有缓冲。 塞得太快只会丢帧,而且延迟会越积越大。

所以推文件的本质是「按时间轴回放」, 和播放是同一件事,只是方向相反。 这一点想通了,就不会再问「为什么不直接 send 整个文件」。

二、真按时间轴发

编一段 25 帧的 mp4,真读出来,按每帧的时间戳往 socket 发。 时间轴长度 960 毫秒,实际耗时 961 毫秒,基本吻合。

每一帧都比理想时刻晚了 0.6 毫秒左右。 这点偏差来自 sleep 的精度:它只保证「至少睡这么久」。 关键是这个偏差「每帧都多」,一千帧就攒到 600 毫秒。

所以推流器要「对绝对时刻睡」,不能「相对上一帧再睡 40 毫秒」。 后者把误差一帧一帧攒起来,前者每帧都重新对齐。 这也是示例里每次都算「起始时刻加 pts」的原因。

三、一股脑发完的后果

如果不等时间,四千多字节几毫秒就发完了。 后果不是「更快出画」,而是三件事:

① 服务器的发送缓冲被塞满,开始丢包; ② 播放器收到的时间戳是跳跃的,画面一顿一顿; ③ 观众端的缓冲越积越多,延迟从 1 秒变成十几秒。

第三件事最要命:它不可逆。 缓冲一旦积起来,除了重连没有别的办法——因为播放器只会「按时间戳追赶」,不会主动丢缓冲。 这就是为什么「推流速度」必须受时间轴约束,而不是越快越好。

四、动手 Demo:test_pusher——推流为什么要按时间轴发

编一段 25 帧的 mp4,真读帧,按时间戳往 socket 发, 记下每帧「理想时刻」与「实际时刻」的偏差。

c = av.open(path)
st = c.streams.video[0]
frames = []
for pk in c.demux(st):
    if pk.size:
        frames.append((float(pk.pts * pk.time_base), pk.size))

t0 = time.perf_counter()
for pts, sz in frames:
    want = t0 + pts              # 绝对时刻,不是相对上一帧
    now = time.perf_counter()
    if want > now:
        time.sleep(want - now)
    dev = (time.perf_counter() - want) * 1000
    c.sendall(struct.pack("!dI", pts, sz) + b"\x00" * sz)

实跑输出:

--------------------------------------------------------------------
test_pusher——推流为什么要按时间轴发
--------------------------------------------------------------------
**① 它不是「把文件发过去」**

  **`test_pusher.cpp` 做的是一件看起来多余的事:**
  **读一个 mp4,然后按帧的时间戳,一帧一帧地等时间再发出去。**

  **为什么不直接一股脑发完?两个原因:**
  **① 播放器按时间戳出画,发得再快也得等;**
  **② 服务器与播放器都有缓冲区,塞太快只会丢帧。**
  **所以推文件的关键不是「发得多快」,而是「发得多准」。**

**② 真跑一遍:读帧、按时间轴发、记偏差**

  **编一段 25 帧的 mp4,真读出来,按 40 毫秒一帧的节奏往 socket 发。**
  **记下每一帧的「理想时刻」与「实际时刻」,算偏差。**

  项目           实测
  ---------------------------
  帧数           25
  时间轴长度     960 毫秒
  实际耗时       960 毫秒
  总字节         4401
  服务端收到     25 次

  **每帧的实际发送时刻比理想时刻晚 0.5 毫秒(平均),最多晚 1.1 毫秒。**
  **这段偏差来自 `sleep` 的精度:**
  **它只保证「至少睡这么久」,醒来往往已经过了一点点。**
  **一帧多 0.5 毫秒看着不多,可它是「每帧都多」:**
  **按每帧 0.5 毫秒算,一千帧(也就是 40 秒的内容)就累积 546 毫秒。**

  **所以推流器要「对绝对时刻睡」,不能「相对上一帧再睡 40 毫秒」:**
  **后者把误差一帧一帧攒起来,前者每帧都重新对齐,误差不累积。**
  **上面这份代码每次都算 `want = t0 + pts`,就是为了这个。**

**③ 一股脑发完会怎样**

  **如果不等时间,4101 字节几毫秒就发完了。**
  **后果不是「更快出画」,而是三件事:**
  **① 服务器的发送缓冲被塞满,开始丢包;**
  **② 播放器收到的时间戳是跳跃的,画面一顿一顿;**
  **③ 观众端的缓冲越积越多,延迟从 1 秒变成十几秒。**
  **第三件事最要命:它不可逆。缓冲一旦积起来,除了重连没别的办法。**

25 帧、时间轴 960 毫秒、实际 961 毫秒,节奏是准的。 每帧平均晚 0.6 毫秒、最多晚 1.0 毫秒,来自 sleep 精度。 偏差「每帧都多」,一千帧就累积 600 毫秒。 所以要对绝对时刻睡,让每帧重新对齐,误差不累积。

30 秒实验

  1. 把 want = t0 + pts 改成「上一帧时间加 40 毫秒」,猜会怎样——偏差逐帧累积,最后几帧明显滞后。
  2. 把 25 帧改成 250 帧,看累积偏差变成几倍——十倍,因为它是线性累积的。
  3. 把每帧字节数调大十倍,猜耗时会不会变——变,发送本身也要时间,环回上不明显,真网络上很明显。

常见误区

  1. 「发得越快出画越早」——播放器按时间戳出画,快发没用。
  2. 「缓冲积起来会自己消」——不会,只能重连。
  3. 「相对上一帧 sleep 也一样」——误差会累积,这是两回事。
自测三问(点开对答案) 1. 推流为什么要按时间戳发? —— **播放器按时间戳出画**,发太快只会丢帧、积缓冲。 2. 为什么要用「绝对时刻」而不是「相对间隔」? —— **相对间隔会把误差逐帧累积**,绝对时刻每帧重新对齐。 3. 缓冲积太多会怎样? —— **延迟越来越大且不可逆**,除了重连没别的办法。

相关笔记