简介:面向音视频流媒体开发者,这份压缩包提供了海康、国标PS流解析的两种实现方案——基于ffmpeg的解析版本与直接解析PS流版本,适合需要对接海康设备或国标PS流做二次开发、播放与转码的技术人员。压缩包共27个文件,包括6个C++源文件、5个头文件,以及Visual Studio解决方案与工程文件(sln、vcxproj、filters)和调试过程生成的obj、pdb等,整体仅2.24MB,代码精简,便于直接阅读和编译调试。已有1096人学习下载。内容包含两套可对照的工程:ffmpeg_demux_ps演示如何用ffmpeg API解封装并提取帧信息;直接解析版本则提供PsAnaly.h与test_PsAnaly.cpp,不依赖ffmpeg手动解析PS流结构,可获取PTS/DTS、分离音视频数据包,并附有解析笔记,能帮助读者从容器格式到具体解码流程建立完整认知,是研究流媒体协议实现与优化的重要参考资料。
1. 拉流却花屏:PS流为什么是安防音视频的第一道门槛
从某主流安防厂商的摄像头拉流,用 FFmpeg 转封装成 MP4,结果视频花屏、没有声音。打开抓包文件一看,满屏000001BA——这不是 H.264 的00000001起始码,也不是 MP4 的ftypbox,而是 PS 流的 Pack 头。安防设备的音视频流媒体输出,默认不走 MP4 也不走 TS,而是把 H.264 帧装进 PS 流,再用 RTP 推到平台侧。解析不好这一层,后面的转码、录像、AI 分析都无从落地。搞懂 PS 流的层级和时间戳体系,是进入流媒体对接深水区的第一道门槛,也是这份资源里最值得抠透的底层知识。
2. 起始码与层级:PS流的骨架是怎么撑起来的
PS 流(Program Stream,节目流)是为可靠传输设计的复用格式。它不是一个裸编码帧的简单拼接,而是由四种基本单元按严格层级组成:Pack 头、系统头、PSM(节目流映射)、PES 包。国标 PS 流在结构上比 MPEG-TS 简单,但细节陷阱反而更多。
2.1 三个起始码定江山:Pack、System Header、PSM
整个 PS 流的自同步完全靠起始码。所有的单元都从00 00 01开始,第四个字节区分类型。解析器的第一件事不是解码,而是找到并确认这些起始码:
| 起始码 | 对应单元 | 作用 |
|---|---|---|
00 00 01 BA | Pack Header | 包起点,携带 SCR 系统时钟 |
00 00 01 BB | System Header | 描述码率、视频/音频流上限,可以缺省 |
00 00 01 BC | PSM | 流 ID 与编码类型的映射表 |
00 00 01 C0~CF | Audio PES | 音频帧载体,常见0xC0 |
00 00 01 E0~EF | Video PES | 视频帧载体,常见0xE0 |
00 00 01 BD | 私有流 | 厂商私有扩展数据 |
结构化理解:一个 PS 包最外层是 Pack Header,它后面可以跟系统头、PSM,然后跟若干个 PES 包。PSM 起到的是「节目映射表」作用,告诉解析器0xE0是 H.264、0xC0是 G.711 还是 AAC。问题在于:PSM 不像 TS 里的 PMT 那样周期性必发——不少设备只在第一个 I 帧前发一次 PSM,甚至从不发。解析器如果一进来就等 PSM,等不到就直接卡死。常见的实现思路是:PSM 出现就更新映射表,没出现就按已知默认值(0xE0=视频,0xC0=音频)继续解析。
Pack 头本身也有讲究。BA后面的 6 个字节里藏着 SCR(System Clock Reference),它被分成三段存储,分别落在不同的字节位里。我们做解析时一般不抠 SCR 的位级拼接,因为后面 PES 头里的 PTS/DTS 才是驱动渲染的关键,SCR 更多是给系统时钟同步用。但解析 Pack 头时至少要正确跳过它的变长扩展段——BA后第 4 字节的高 2 位决定是否有扩展段,扩展段连接字段复杂,容易踩坑。
2.2 PES 是中间人:从 0xE0 / 0xC0 到一帧编码数据
PES(Packetized Elementary Stream)是 PS 流的第二层,也是离编码数据最近的一层。一个 PES 包的典型布局是:00 00 01+stream_id+ 2 字节包长 + PES 头标志区 + 可选扩展字段 + 编码负载。
视频stream_id是0xE0,音频是0xC0。负载部分按约定应该是一个完整的 Access Unit(即一个编码帧),比如一个 H.264 的 IDR 帧或非 IDR 帧。但要注意:很多厂商设备会在 IDR 帧的 PES 负载前面直接拼上 SPS/PPS,等于每遇到一个 IDR 帧,你都能在 PES 负载里找到三个 NAL 单元,而不是一个。这是设备层的「省事」实现,也意味着解析时不能理所当然地把「一个 PES 等于一个视频帧」。
包长的处理是另一个隐藏问题。PES 头里 2 字节包长,指向 PES 负载的字节总数。标准允许包长为 0,表示长度不限定——这个设计本意是针对某些私有流。可实际抓到的海量设备流里,视频和音频 PES 也出现过包长为 0 的情况。一旦遇到,如果解析器还把包长当作跳跃依据,整个后续数据全错位。我一般会这么设计:包长字段只用作预校验,真正的 PES 边界必须靠下一个起始码探测来确认,两者结合才稳。
2.3 时间戳三角:SCR、PTS、DTS 谁负责什么
PS 流里存在三套时间生产源,很多人在这里栽跟头。SCR 位于 Pack 头,是系统时钟参考,5 字节位拼接,单位是 90kHz,表示当前 PS 包送达系统时的期望时钟值。通常流媒体平台不会直接把 SCR 当渲染时钟,因为 Pack 头的到达时刻受 RTP 网络抖动影响很大。
PTS 和 DTS 都在 PES 头里,单位同样是 90kHz。PTS 表示这个帧应该被呈现的时间,DTS 表示解码时间。对没有 B 帧的编码流,PTS 等于 DTS;一旦 B 帧出现,DTS 会比 PTS 提前,因为解码器必须先解码后面的参考帧。很多解析器实现只读 PTS,不考虑 DTS,这在直播预览场景没问题,可一旦做录像回放或抽帧分析,解出的帧序就是错的。
需要注意 PES 头里的时间戳编码不是简单的 4 字节 int,而是 5 字节、33 位值,均匀塞在 5 个字节的若干位上。拼位时容易出错,源码里通常会有这样一段宏:
// 从 5 字节 PES 时间戳字段拼接出 33 位 90kHz 值 #define PES_PTS(hdr) \ (((uint64_t)((hdr)[0] & 0x0E) << 29) | \ ((uint64_t)((hdr)[1] & 0xFF) << 22) | \ ((uint64_t)((hdr)[2] & 0xFE) << 14) | \ ((uint64_t)((hdr)[3] & 0xFF) << 7) | \ ((uint64_t)((hdr)[4] & 0xFE) >> 1))这组位操作的含义是:33 位 PTS 被拆成 3+8+7+8+7 五段,分布在 5 个字节中。为什么这样设计?因为 MPEG-2 体系里所有时间戳都塞进 5 字节,低位丢弃 1 位以保证偶校验对齐。做解析时一定要先处理成 uint64_t 再算差值,不能按 int32 比较,否则回绕问题会出现在播放几分钟之后。
3. 国标承载:RTP 怎么把 PS 流塞进流媒体管道
国标 GB/T 28181 的信令走 SIP,媒体走 RTP。媒体面最核心的一条规则是:RTP 的 payload 直接就是 PS 流,没有中间的 TS 封装。这个选择背后是带宽和实时性的权衡,也决定了解析器的入口形态。
3.1 为什么选 PS 而不是 TS
TS(Transport Stream)每个包固定 188 字节,设计目标是有损信道下的随机接入——丢了包还能靠下一个包头的同步恢复。代价是每个 TS 包都要重复带 PID、连续性计数器等头信息,带宽浪费约 8%,解析时要先做 PAT/PMT 映射,再做 PES 重组,多一层状态。
PS 不需要这些。可变长包、一次承载一整套音视频帧,头开销远小于 TS,解析时扫描到00 00 01 BA就能建立同步。国标选 PS 的另一个原因是场景差异:平台侧大概率是有线内网,丢包率可控,不需要 TS 那种包级容错;而视频录像更需要完整帧边界,PS 的「一个 PS 包多帧」结构更适合直接落盘。很多新手用 TS 解析的思路去解析 PS,先找固定包长、再找 PID,结果发现 PS 根本没有 PID 的概念,走了弯路。
3.2 RTP 打包 PS 的语义:一包一帧还是一包多个 PS 包
国标媒体协商里,RTP payload type 通常用动态值96,时基固定为 90000。常见的打包模式有两种,解析器必须兼容。第一种模式是一个 RTP 包承载一个完整 PS 包,包边界与 RTP 包边界对齐,最容易处理,只要按 RTP 序列号排序后直接透传给解析器。第二种模式是一个 PS 包被拆到多个 RTP 包里,特别是大码率的 1080p 流,单包塞不下。
第二种模式要求解析器在 RTP 层做重组装:按序列号递增顺序收集载荷,直到 PS 包完整。怎么判断一个 RTP 包里哪个位置是 PS 包边界?不能靠 RTP 头里的 M 标记位——虽然很多实现约定 M=1 表示 PS 包尾,但不同厂商的实现并不一致,有的设备 M 位含义完全不同。可靠做法是:把 RTP 载荷累积到一段缓存里,用起始码扫描判断 PS 包是否结束,这也是第 4 章状态机的前置条件。
RTP 时间戳与 PS 时基同为 90kHz,这给时间戳恢复提供了捷径。理想情况下,RTP 时间戳可以直接映射为 PTS 基准,但注意 RTP 时钟和录像侧时钟可能来自不同晶振,长时间运行会有微小漂移。流媒体平台常见的做法是:预览用 RTP 时间戳驱动,录像时间戳用 PTS 重写,兼顾实时性和一致性。
3.3 从 SDP 参数到解析器配置
抓一个典型的国标设备 SDP,媒体行往往长这样:
m=video 60000 RTP/AVP 96 a=rtpmap:96 PS/90000 a=control:videoa=rtpmap:96 PS/90000就是在明确告诉接收端:动态负载类型 96 对应 PS 格式,时基 90000 赫兹。解析器的初始化参数,本质上就是从这里拿到的 payload type 和时基。做调试时,第一件事就是确认 SDP 里的 payload type 和实际 RTP 头里的 payload type 是否一致,很多平台在m=行写的 96,码流里却发的是 98,这属于协商与媒体不一致的典型翻车点。
与之配套的还有a=recvonly或a=sendonly之类的方向属性,以及音视频两条 m 行的端口。解析器的资源管理按 SSRC 区分通道:同一路视频里语音和图像共用 SSRC 还是分开用 SSRC,各厂商风格不一。我一般会在初始化时直接忽略 SSRC 分组,让下层按起始码自动识别轨道,这样对设备的依赖最小。
3.4 厂商实现差异:同一协议,三种脾气
接入设备多了会发现,虽然协议写的是同一份国标,但实现上差异很大。某厂商的流重视规范性:每个 PS 包都包含系统头和 PSM,时间戳稳定,解析器只要按标准走就不会出错。另一厂商的流骨感得多:系统头从头到尾不出现,PSM 只在流起始发一次,Pack 头的 SCR 甚至恒为 0。还有一种中间派:系统头发但不完整,PSM 里的 stream_type 与真实编码类型对不上,需要靠 PES 负载里 NAL 头的类型去反推编码格式。
这些差异不是 bug,而是嵌入式平台省资源的策略。写解析器时不要假设每一个字段都可信:系统头是参考不是依据,PSM 是提示不是承诺,真正可信的只有起始码和 NAL 头。解析器状态机的每一分支都应该在「字段缺失、字段为 0、字段非法」三种情况下有自己的默认动作,否则接新设备就成了一次次回归测试。
4. 状态机与缓冲:从字节流到音视频帧的源码级实现
PS 解析器本质上是一个带缓冲的有限状态机。这一章给出核心骨架,它覆盖了从 RTP 载荷到音视频裸帧的全部路径,也是解析类代码里最容易反复改的部分。
4.1 数据入口:RTP 重组装与累积缓冲
RTP 层过来的数据首先进入一个累积缓冲。缓冲不一定要很大,但要能处理包边界错位的场景。核心结构是一个 byte buffer 加两个游标:filled表示当前已填充字节数,head表示当前消费位置。每次 RTP 包到达,把 payload 追加到filled位置;状态机从head开始消费,消费完的数据移动或直接丢弃。
struct ps_ring { uint8_t *buf; size_t cap; // 缓冲总容量,建议 2MB 以上 size_t filled; // 当前已写入字节数 size_t head; // 当前消费游标 uint64_t rtp_ts; // 最近一个 RTP 包的时间戳 uint32_t rtp_seq; // 最近一个 RTP 包的序列号 };为什么不直接按 PES 包长跳过?因为前面说过,包长字段可能为 0,可能错,也可能设备在 PES 负载里混入填充数据。累积缓冲配合起始码探测是最稳的,代价是多几十毫秒的延迟,对视频平台完全可以接受。2MB 缓冲的余量能覆盖一个 1080p I 帧可能被拆到十几个 RTP 包的情况。
4.2 状态机骨架:逐字节扫描与消费切换
解析主循环对每一个新到字节喂入状态机。三个状态分别对应「找 PS 包边界」「解析 PS 包内头」「消费并分发 PES」:
enum ps_state { PS_SEEK_PACK, // 等待 00 00 01 BA PS_HEADER, // 处理 Pack/PSM/系统头 PS_PES, // 处理 PES 头 + 负载 }; static void ps_feed(struct ps_demuxer *dmx, uint8_t c) { switch (dmx->state) { case PS_SEEK_PACK: // 滑动窗口匹配 00 00 01 BA dmx->buf[dmx->filled++] = c; if (dmx->filled >= 4) { uint8_t *p = dmx->buf + dmx->filled - 4; if (p[0] == 0 && p[1] == 0 && p[2] == 1 && p[3] == 0xBA) { dmx->head = dmx->filled - 4; // 回到包起点 dmx->state = PS_HEADER; } } break; case PS_HEADER: // 解析完 Pack 头后,遇到 PSM 更新映射,遇到 PES 起始码转 PS_PES ... break; case PS_PES: // 解析 PES 头,提取 stream_id 与 PTS,然后回调裸帧 ... break; } }逐字节喂入看起来笨,但这是处理「起始码可能跨界」的唯一可靠方法。RTP 包不会恰好把00 00 01 BA完整放在一个包里,跨界是常态,而逐字节滑窗天然规避了这个问题。性能上,单字节 switch 在嵌入式平台上略吃力,可以改成块级扫描——先用 memchr 找 0x00,再检查后两字节,但逻辑等价。
4.3 PES 头解析:流 ID、包长、PTS 一起拿
进入 PS_PES 状态后,先等满 PES 固定头(起始码之后至少 9 字节),再解析流 ID 和时间戳。这里把长度不定的 PES 头完整解析出来:
// 解析起始码之后的 PES 头,返回 PES 负载起始偏移,-1 表示数据不足 static int ps_parse_pes(struct ps_demuxer *dmx, const uint8_t *p, size_t n) { if (n < 9) return -1; // 至少要流ID+包长+头标志 uint8_t sid = p[0]; // 0xE0 视频,0xC0 音频 uint16_t plen = (p[1] << 8) | p[2]; uint8_t flags = p[4]; // PTS/DTS 标志位 size_t hlen = 9 + p[5]; // PES 头总长 if (n < hlen) return -1; uint64_t pts = 0, dts = 0; if ((flags & 0x80) != 0) { // PTS 存在 pts = PES_PTS(p + 6); if ((flags & 0x40) != 0) // DTS 也存在 dts = PES_PTS(p + 10); } dmx->has_pts = (flags & 0x80) != 0; dmx->cur_pts = pts; dmx->cur_dts = dts; dmx->stream_id = sid; // 只有当 plen 合法才用;plen == 0 时靠起始码探测边界 dmx->payload = (uint8_t *)p + hlen; dmx->payload_len = n - hlen; if (plen > 0 && plen + 3 + hlen <= n) dmx->payload_len = plen; // 以声明包长裁剪 return hlen; }这里有个关键取舍:p[4]是 PTS_DTS_flags 所在字节,但不同设备对这个字节的前缀位(本来是10)写得不规范,有的写00,有的写11。正确做法是只按低 6 位判断 PTS/DTS 标志,不要校验高 2 位。下面紧接着要考虑 PES 头扩展字段——如果p[5]指示有扩展,PTS 的位置会往后移,但常见设备流基本不使能扩展,出问题时优先怀疑这里。
逻辑上为什么plen合法时还要用起始码兜底?因为摄像头 SD 卡录像里常出现一种情况:PES 头声明的包长大于实际负载,多出来的长度被填充字节占满。如果只按包长切,会把下一个帧的起始码吃掉。我的习惯是「声明长度优先,但边界必须落在下一个起始码之前,两者冲突时信起始码」。
4.4 时间戳恢复与音视频分轨
时间戳恢复不是简单把 PTS 摘出来,而是要把它和 RTP 时钟对齐。最稳妥的做法是差分恢复:记录上一个 RTP 时间戳与上一个 PTS,当新 PES 到来时,用 RTP 时间戳差值修正 PTS,避免累积漂移:
// 通过 RTP 时间戳差分修正本地 PTS static uint64_t ts_adjust(uint32_t rtp_ts, uint32_t last_rtp, uint64_t last_pts) { int32_t delta = (int32_t)(rtp_ts - last_rtp); // 回绕安全 if (delta < -45000 || delta > 45000) // 抖动过大 return last_pts; // 保护性丢弃 return (uint64_t)((int64_t)last_pts + delta); }int32_t绕回是 90kHz 时基下最容易埋的雷。RTP 时间戳是 32 位无符号,约 13 小时回绕一次,直接用减法再赋值给 int32_t,就同时解决了回绕和抖动过滤。45000 对应 0.5 秒,超过这个范围的跳变直接丢弃,防止把断流重连的跳帧当成正常帧推给下游。
音视频分轨不用复杂的数据结构,两个出队队列外加一个回调指针就够:
struct ps_track { uint8_t stream_id; // 0xE0 / 0xC0 bool got_key; // 是否已遇到关键帧 uint64_t last_pts; void (*on_frame)(uint8_t *data, size_t len, uint64_t pts, uint64_t dts, void *ctx); };got_key的作用是:视频轨在遇到第一个 IDR 帧之前,别把 P 帧推给解码器,否则解码器直接黑屏或花屏;音频轨则是got_header的变体,G.711 无参数集还好,AAC 就要先等 ADTS 头或 ASC。这个字段虽然小,却避免了一类非常隐蔽的启动时序问题。
5. 常见问题与避坑:PS流解析最容易翻车的六个地方
解析 PS 流的门槛不在标准文本里,而在设备差异里。这里列出接入调试时踩过的六个高频坑,按现象、原因、解决三段记录,几乎每条都能在某种设备上复现。
5.1 起始码跨 RTP 包边界,解析器丢了同步
现象:解析刚开始正常,几秒后突然狂报sync lost,一查发现00 00 01 BA被 RTP 分包切成了两半。
原因:设备的 1080p 码流下 I 帧很大,一个 PS 包被拆成多个 RTP 包。如果解析器只在 RTP 包内做起始码匹配,包边界处的起始码就被漏掉。
解决:起始码匹配不能限定在单包内,要在累积缓冲上做。RTP 层只负责按序列号追加载荷,PS 层用滑窗在缓冲上找边界,两边解耦。从那以后我做任何解析器,都强制要求「边界探测在缓冲上进行,接收层永不做格式判断」。
5.2 PES 包长写 0,按字段跳跃后全部错位
现象:解析出的视频帧尺寸忽大忽小,解码器频繁报 invalid nal,有时能播放但音画不同步。
原因:设备在 PES 头里填了packet_length = 0,表示长度未定义。解析器按 0 计算负载长度,导致把整个缓冲都当成一个帧喂给了解码器。
解决:不要用 PES 包长作为 PES 边界,用下一个起始码探测作为边界。包长只作为裁剪上限:声明长度小于实际可用数据时,优先按声明长度切;声明为 0 时,完全交给状态机的起始码扫描。一句话:起始码是唯一的信任源。
5.3 PTS 回绕导致时间戳断崖式跳变
现象:程序稳定运行十几小时后,回放时间突然倒退十几小时,录像文件索引错乱。
原因:90kHz 时基下,33 位 PTS 约 2.8 小时回绕一次。如果时间戳比较用的还是 int32 或没有做差分计算,回绕瞬间看起来就像时间倒流。
解决:时间戳比较一律转int32_t差值,用(int32_t)(cur - last)判断增量,而不是直接比大小。同时把 PTS 统一扩到 uint64_t 再写入封装层,保证录像文件内部单调递增。
5.4 SPS/PPS 不在 PSM 里,解码器一直等参数集
现象:VLC 偶尔能播,自己的播放器全黑屏,控制台刷一堆no parameter set。
原因:H.264 的参数集按国标规范应该在 PSM 或单独信令里传递,但很多设备直接把 SPS/PPS 塞进 IDR 帧的 PES 负载头部,不带任何标记。解析器如果按「PES 负载 = 一个 NAL 单元」去切,就把参数集剥掉了。
解决:把「PES 负载可能包含多个 NAL 单元」作为默认假设。解析时用 NAL 起始码再次切分负载,发现 SPS(NAL type 7)和 PPS(NAL type 8)就缓存;每次解码关键帧前,把缓存的参数集一并交给解码器。本质上是多维护一个「参数集合成」层。
5.5 音频 PES 负载为空,通道开启后静音
现象:视频正常,音频轨没有任何数据。抓包发现0xC0的 PES 包频繁出现,但负载长度只有 0。
原因:部分设备在双向语音开启前,会周期发送空音频 PES 包来维持 RTP 通道的活动状态。解析器把这些空包当正常音频帧送去解码,解码器报错后静音。
解决:PES 负载长度小于最小音频帧长度(G.711 为 1 字节)时,直接跳过不回调,同时保持轨道开启状态。顺便建议把「连续多少个空 PES 才关轨」做一次统计,避免把短暂的静音误判为断流。
5.6 RTP 乱序导致 PS 包拼接错乱
现象:有丢包时解析器命中错误起始码,后面一整个 GOP 报废,直到下一个 IDR 才恢复。
原因:RTP 序列号乱序到达时,累积缓冲里 PS 包尾部可能先于头部到位,状态机在未对齐位置误匹配到了负载里的00 00 01。
解决:RTP 层加一个浅排序队列,按序列号滑窗排序后再写入 PS 缓冲。队列不需要很深,16~32 个包就够;代价是要容忍约一个窗口的延迟。丢包时直接丢弃该 GOP 的后续帧,等下一个 IDR 重建,不要试图用错误数据恢复同步。
6. 设计取舍:流式状态机与缓冲策略的再思考
PS 解析器的设计里,最值得反复权衡的不是算法复杂度,而是「信任边界」和「内存策略」这两个维度。
先说信任边界。整套解析过程中,可信度从高到低是:起始码 > NAL 头 > PES 包长 > PSM/系统头。起始码是二进制层面的强同步标记,只要数据不错位,它几乎不会误报;NAL 头是编码层信息,稳定度次之;包长是设备填写的告示牌,可能写 0、写错、甚至故意不写;PSM 和系统头只是辅助信息。一个稳健的解析器,应该把所有「可信任的高位」做成硬逻辑,把「不可信任的低位」做成兜底和容错。很多年轻工程师习惯先把标准结构体定义好,然后按结构体逐字段读,结果遇到一个不按常理出牌的设备就崩。从这次接入的经验看,信起始码、防包长、缓存参数集,这三个动作能挡住八成以上的兼容性问题。
再说内存策略。PS 解析有两种典型的缓冲路线:整包缓存和流式透传。整包缓存把一整个 PS 包先收满再解析,逻辑简单,但要为 1080p I 帧预留 1~2MB 内存;流式透传边收边解析,内存省,却要求回调接口支持分片交付,下游解码器不一定接得住。平台侧实现我倾向于折中:PS 包按起始码收,收满后整包回调;但 RTP 层到 PS 层的累积缓冲设上限,超过上限强制同步搜索,避免内存被异常流打爆。这里有个参数调优的血泪教训:缓冲上限不能设太小,否则高分辨率低码率设备偶尔出现的大 I 帧会被误杀,直接导致连续花屏。
最后一个取舍是时间戳策略。标准文档倾向于以 SCR 为准,但实际平台都以 RTP 时间戳做基准,PTS 只不过是对齐帧序的参照。这个「文档与现实错位」不是实现错误,而是 SCR 本质是发送端时钟,经过网络抖动到达后已不可信;RTP 时间戳虽然也经过网络,但它是容器层维护的节奏信号,和播放器的时钟模型天然匹配。从那以后,我每次接入新设备都会先做一个两分钟的实流测试,剥出前 20 个 PS 包的 PTS 和 RTP 时间戳手动画一下关系曲线,确认线性关系成立后才写死策略。这条习惯救了我很多次,希望帮到你。
本文还有配套的精品资源,点击获取