拉流比推流少一步:connect → createStream → play。 服务器回的不是 _result 而是 onStatus,事务号是 0——它天生配不上对。
4.2.3 拿到了流号,这一节在流上发 play 开始拉流。 推流的 publish 与它对称,4.3.1 再细讲。
一、play 命令只有流名
结构是 play + 事务号 + 空值 + 流名。
实测 24 字节,比 createStream 还短。
流名可以带参数:play 的参数常写成 test?token=abc。
服务器自己切问号,前面的当流名、后面的当鉴权。
所以「流名里能不能带问号」取决于服务器实现,不取决于协议。
play 还有几个可选参数(起始时间、是否从直播点开始),直播场景基本不用——点播才有「从第几秒开始播」的需求。 这也是 RTMP 从点播时代继承下来的痕迹。
二、响应为什么是 onStatus
推拉的建立过程里,服务器会主动推一堆「状态通知」,这类消息统一叫 onStatus。
它跟 _result 的区别只有一个,但很致命:事务号是 0。 因为 onStatus 不是「回答某条命令」,而是「告知一件事」,所以它不带事务号。 用事务号去匹配 onStatus 的代码一定配不上,只能按 code 字符串判断。
三个最常见的 code:
① NetStream.Play.Start:开始播了。
② NetStream.Play.Stop:播完了。
③ NetStream.Play.StreamNotFound:流不存在,查流名。
还有一个坑:NetStream.Play.Start 到了不等于「画面马上有」。
它只表示「服务器接受了这个播放请求」,真正解码还要等关键帧,那就是首屏延迟的来源。
三、播放之后马上到的三样东西
顺序是固定的,而且每一样都不能缺: ① 用户控制消息 StreamBegin:告诉客户端「流 1 开始了,时间戳从现在算」。 少了它,后面所有帧的时间戳都没有基准。 ② 数据消息 onMetaData:宽高、帧率、码率。 解码器要靠它初始化,直播里 duration 通常写 0。 ③ 视频关键帧:服务器通常从上一个关键帧开始发,所以要等,这就是首屏延迟。
三样的顺序有个实用价值:抓包时如果只看到①没有②,说明服务器没配元数据;如果连①都没有,说明 play 根本没被接受。 这个判断比读日志快。
四、推流与拉流的对称关系
publish 与 play 是一对镜像命令,参数结构几乎一样:
① publish:publish + 事务号 + 空值 + 流名 + 推流类型。
推流类型常见 live、record、append。
② play:play + 事务号 + 空值 + 流名。
没有类型参数,因为拉流就是拉。
推流类型这三个值要分清: ① live:只在内存里转发,断开就没。 ② record:同时录成文件。 ③ append:追加到已有文件后面。 选错的表现是「明明推成功了,回头找不到录像」。
两条命令都要求流已存在(createStream 之后),而且 publish 还要独占流名——同一个流名不能两个人同时推,除非服务器实现了「后进踢前出」。
五、动手 Demo:play 与 onStatus
编一条 play 命令,再编一条服务器回的 onStatus。 重点看 onStatus 的事务号为什么是 0。
import struct
def amf_str(s):
b = s.encode("utf-8")
return b"\x02" + struct.pack("!H", len(b)) + b
def amf_num(v):
return b"\x00" + struct.pack("!d", float(v))
def amf_obj(d):
out = b"\x03"
for k, v in d.items():
kb = k.encode("utf-8")
out += struct.pack("!H", len(kb)) + kb + amf_str(v)
return out + b"\x00\x00\x09"
play = amf_str("play") + amf_num(3) + b"\x05" + amf_str("test")
print("play 命令 %d 字节" % len(play))
st = (amf_str("onStatus") + amf_num(0) + b"\x05" + amf_obj({
"level": "status",
"code": "NetStream.Play.Start",
"description": "Start live",
}))
print("onStatus %d 字节" % len(st))
print("事务号位置的值 = %.0f" % struct.unpack("!d", st[10:18])[0])
# 只看 code 字段也能判断状态
print("含 NetStream.Play.Start:%s" % (b"NetStream.Play.Start" in st))
print("含 StreamNotFound:%s" % (b"StreamNotFound" in st))
实跑输出:
--------------------------------------------------------------------
播放——play 一条命令,服务器回一堆状态
--------------------------------------------------------------------
**① 拉流的命令序列**
**拉流比推流少一步:connect → createStream → play。**
**不需要 releaseStream,也不需要 FCPublish。**
play 命令 24 字节,参数是流名 test
**② 服务器回的是 onStatus,不是 _result**
onStatus 96 字节
命令 onStatus,事务号 0,code = NetStream.Play.Start
code 含义 该做什么
-------------------------------------------------------------
NetStream.Play.Start 开始播了 准备收帧
NetStream.Play.Stop 播完了 断开或重连
NetStream.Play.StreamNotFound 流不存在 查流名
**注意事务号是 0:状态通知不是「回答某条命令」,所以不带事务号。**
**用事务号去匹配 onStatus 的代码一定通不过测试**——**它只能按 code 字符串判断。**
**③ 播放之后马上到的三样东西**
**① 用户控制消息 StreamBegin**:**告诉客户端「流 1 开始了」,后面紧跟的帧才能对上时间轴。**
**② 数据消息 onMetaData**:**宽高、帧率、码率,用来初始化解码器。**
**③ 视频关键帧**:**服务器通常从上一个关键帧开始发,所以要等一个 GOP,这就是首屏延迟的来源。**
play 命令 24 字节、onStatus 96 字节——响应比请求大四倍,因为要带 level、code、description 三个字符串。 事务号那 8 字节解出来是 0,这就是「状态通知不配对任何请求」的直接证据。 实际判断只需要一次子串匹配:看消息里有没有那个 code 字符串,比完整解析 AMF0 简单得多。
30 秒实验
- 把事务号从 0 改成 3,看它是不是变得像 _result——结构像,但语义上 onStatus 永远不配对。
- 把 code 改成
NetStream.Play.StreamNotFound,看长度变多少——字符串长了,整条消息跟着长,这是 AMF0 的直观体现。 - 给流名加
?token=abc,看 play 命令长多少字节——多 10 个字节,其中 2 个是长度字段。
常见误区
- 「onStatus 可以用事务号配对」——它是 0,永远配不上,只能看 code。
- 「收到 Play.Start 就有画面了」——还要等关键帧与元数据。
- 「流名不能带问号」——能带,服务器自己切,取决于实现。
自测三问(点开对答案)
1. 拉流的命令序列是什么? —— **connect → createStream → play。**比推流少 releaseStream 与 FCPublish。 2. onStatus 的事务号为什么是 0? —— **它不回答任何命令,只是主动通知。**所以不带事务号。 3. 播放后马上到的三样东西是什么? —— **StreamBegin、onMetaData、关键帧。**缺一样都会出问题。- ☐ (本节完,下一节 4.2.5 RTMP相关名词解释)