这个示例读一个 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 秒实验
- 把
want = t0 + pts改成「上一帧时间加 40 毫秒」,猜会怎样——偏差逐帧累积,最后几帧明显滞后。 - 把 25 帧改成 250 帧,看累积偏差变成几倍——十倍,因为它是线性累积的。
- 把每帧字节数调大十倍,猜耗时会不会变——变,发送本身也要时间,环回上不明显,真网络上很明显。
常见误区
- 「发得越快出画越早」——播放器按时间戳出画,快发没用。
- 「缓冲积起来会自己消」——不会,只能重连。
- 「相对上一帧 sleep 也一样」——误差会累积,这是两回事。
自测三问(点开对答案)
1. 推流为什么要按时间戳发? —— **播放器按时间戳出画**,发太快只会丢帧、积缓冲。 2. 为什么要用「绝对时刻」而不是「相对间隔」? —— **相对间隔会把误差逐帧累积**,绝对时刻每帧重新对齐。 3. 缓冲积太多会怎样? —— **延迟越来越大且不可逆**,除了重连没别的办法。- ☐ (本节完,下一节 12.3.3 lambda 函数介绍)