推流就是把编码好的帧一个个塞进 RTMP 消息里发出去。 消息类型只有三种:8 音频、9 视频、18 元数据。
4.2 走完了握手与命令,这一节终于开始搬画面。 4.3.2 讲对端的拉流,4.4 反过来拆「这些消息怎么装进块」。
一、推流的完整动作
顺序是固定的,一步都不能省: ① 采集与编码:把摄像头的原始帧编成 H.264、把音频编成 AAC。 这一步在推流软件里做,不在协议里。 ② 封成消息:每帧前面加 FLV 的 tag 信息,变成 RTMP 的 8/9 号消息。 ③ 发元数据:开推之前先发一条 18 号消息 onMetaData。 它告诉服务端与播放端「这路流是什么规格」。 ④ 持续发送:按帧率节奏往外送,音视频交错。
实测一个只含视频的 25 帧 FLV 文件,13 字节文件头之后是 27 个 tag: 1 个数据 tag(元数据)、26 个视频 tag。 元数据只有 184 字节,但它决定了后面所有帧怎么解。
二、元数据里装什么
onMetaData 的负载是 AMF0 编码的「@setDataFrame + 一个对象」,对象里常见这些字段:
① duration:时长,直播写 0。
写 0 会让播放器把进度条画成「未知」,这是正常现象。
② width/height:分辨率。
③ framerate:帧率,25 或 30。
④ videocodecid/audiocodecid:编码格式的数字标识,7 是 H.264、10 是 AAC。
⑤ videodatarate/audiodatarate:码率,单位 kbps。
元数据缺失的后果很具体: 播放器不知道分辨率,可能按 0×0 建解码器,直接黑屏。 所以「推流成功但对方黑屏」时,第一个该查的就是元数据有没有发出去。
三、音视频为什么要交错
一路推流里,音频与视频是两个独立的序列,各有自己的时间戳。 它们必须交错发送,否则会出现「声音先到几秒、画面才跟上」或者反过来。
交错的两个原则: ① 按时间戳排序:谁的下一帧时间戳更早就先发谁。 ② 不能等太久:视频关键帧再大也要发,不能为了等音频而无限推迟。
实际实现的常见做法是「视频优先、音频插空」: 视频帧一到就发,音频帧只要时间戳没落后太多就跟着发。 这样做的好处是画面不会等音频,坏处是音频在弱网下更容易积压。
时间戳的基准是「推流开始的时刻算 0」,之后按毫秒累加。 注意这与 RTP 的 90000 时钟不同:RTMP 用毫秒,所以 25 fps 的步长是 40。
四、推流端参数怎么定
三个参数决定画质与稳定性,而且互相牵制:
① 码率:720p 常用 2000~3000 kbps,1080p 常用 4000~6000 kbps。
给低了糊,给高了卡。
② 关键帧间隔(GOP):直播常用 1~2 秒。
它就是首屏延迟的主要来源,也是丢包后恢复时间的下限。
③ 编码预设:veryfast 起步,CPU 吃紧就用 ultrafast。
预设越慢,同码率画质越好,但推流端要扛得住。
一个常被忽略的坑:码率不是「设多少就发多少」。 静态画面时编码器会少发,复杂画面时会爆码率——服务端如果按「平均码率」做限速,复杂场景就会丢包。 所以要么留 20% 余量,要么用 CBR 模式。
五、动手 Demo:封一个 FLV,数清三种消息
真编 25 帧视频,封成 FLV,再按 11 字节 tag 头逐个数。 这个 demo 只编了视频,所以音频那行是 0——不是漏了,是没编。
import io, os, struct, tempfile
import av, numpy as np
tmp = os.path.join(tempfile.mkdtemp(), "a.flv")
c = av.open(tmp, "w", format="flv")
st = c.add_stream("libx264", rate=25)
st.width, st.height, st.pix_fmt = 320, 240, "yuv420p"
st.options = {"crf": "30", "preset": "veryfast", "g": "25"}
for i in range(25):
f = av.VideoFrame(320, 240, "yuv420p")
y = np.zeros((240, 320), np.uint8)
y[:, :] = np.linspace(16, 200, 320, dtype=np.uint8)[None, :]
f.planes[0].update(y.tobytes())
for pi in (1, 2):
f.planes[pi].update(bytes([128]) * f.planes[pi].buffer_size)
f.pts = i
for pk in st.encode(f):
c.mux(pk)
for pk in st.encode(None):
c.mux(pk)
c.close()
raw = io.open(tmp, "rb").read()
print("文件 %d 字节" % len(raw))
off, cnt = 13, {} # 前 13 字节 = 9 字节文件头 + 4 字节长度
while off + 11 <= len(raw):
t = raw[off]
size = int.from_bytes(raw[off + 1:off + 4], "big")
cnt[t] = cnt.get(t, 0) + 1
off += 11 + size + 4 # tag 头 11 + 数据 + 4 字节「上一 tag 长度」
for t, name in ((18, "数据"), (8, "音频"), (9, "视频")):
print("%s消息(类型 %d):%d 条" % (name, t, cnt.get(t, 0)))
实跑输出:
--------------------------------------------------------------------
直播推流——元数据、音频、视频三种消息
--------------------------------------------------------------------
**① 真封一个 FLV,看 tag 结构**
文件 2784 字节
FLV 头 9 字节 + 前一个 tag 长度 4 字节 = 13
**② 逐个 tag 拆开看**
tag 类型 数量 平均字节 总字节
---------------------------------------------
18 数据 1 184 184
8 音频 0 0 0
9 视频 27 80 2167
**③ tag 与 RTMP 消息是一一对应的**
**FLV 的 tag 类型 8/9/18,正好就是 RTMP 的消息类型 8/9/18。**
**这不是巧合:RTMP 的消息体直接就是 FLV 的 tag 数据体。**
**所以「RTMP 封 FLV」这句话的真实含义是「共享同一套负载格式」。**
**差在包装:FLV 的 tag 有 11 字节头,而 RTMP 用块头代替它。**
**那 11 字节是:类型 1 + 长度 3 + 时间戳 3 + 扩展 1 + 流 ID 3。**
**所以同一路流,用 FLV 文件存和用 RTMP 推,负载字节完全一样、外包装不同。**
**实际推流时,第一条消息不是音频也不是视频,而是 onMetaData。**
**它用 AMF0 编出来,内容就是宽高、帧率、码率这些——上面表里那一行 18 就是它。**
文件 2784 字节里,27 个 tag 中 1 个是元数据、26 个是视频。 音频 0 条是「这一路只编了视频」的结果,不是封包出错——真实推流一定会有音频 tag。 每个 tag 后面还跟 4 字节「上一 tag 长度」,所以遍历时步长是 11 + 数据长度 + 4。
30 秒实验
- 把
rate从 25 改成 15,看 tag 数与时间戳步长的变化——帧少了,但每帧时间戳步长从 40 变成 66。 - 把
g从 25 改成 100,看视频 tag 数怎么变——总帧数不变,但关键帧从 2 个变 1 个。 - 加上一条音频流再封一次,看类型 8 的 tag 出现多少条——这就是真实推流的比例。
常见误区
- 「推流就是发视频帧」——先要发元数据,否则播放端不知道该建什么解码器。
- 「码率设多少就发多少」——复杂画面会爆码率,服务端限速就会丢包。
- 「时间戳和 RTP 一样用 90000」——RTMP 用毫秒,25 fps 的步长是 40。
自测三问(点开对答案)
1. 推流用的三种消息类型是什么? —— **8 音频、9 视频、18 数据(元数据)。** 2. 为什么直播的 duration 写 0? —— **不知道什么时候结束。**写 0 会让播放器把进度条画成未知。 3. RTMP 时间戳的时间单位是什么? —— **毫秒。**与 RTP 的 90000 时钟不同。- ☐ (本节完,下一节 4.3.2 直播拉流)