前言
最近在学习ffmpeg的rtsp拉流实时播放;遇到一些问题。
旧的实现方式是,通过网络拉取一帧,就显示一帧,实时播放;但是,如果网络延迟,隔了几百毫秒甚至几千毫秒才拿到下一帧画面,那么期间就会卡主,导致整体播放流程一卡一卡的。
所以,使用播放时钟约定播放很有必要。
有一些必要的了解:
实现方式是在一个线程函数内,持续循环读取帧来显示:
voidYViewTask::Main(){while(is_running_){AVFrame*frame=list_.Pop();if(!frame){break;// 只可能是被 Abort 叫醒 → 收工}std::cout<<"* ";yview_->DrawFrame(frame);av_frame_free(&frame);}}YViewTask::Main()是C++线程执行函数。
list_ 是一个链表,用于缓存已经读取到的AVFrame帧,其结构如下:AvObjList<AVFrame> list_;
// 通用队列template<typenameT>classAvObjList{public:explicitAvObjList(size_t max=100):max_(max){}~AvObjList(){Clear();}// 禁止拷贝,因为含有mutexAvObjList(constAvObjList&)=delete;AvObjList&operator=(constAvObjList&)=delete;// 生产者,慢则丢弃最旧的boolAdd(T*obj){std::unique_lock<std::mutex>lock(mux_);if(!obj){LOGWARNING("pkt is null!");returnfalse;}// 要么被终止了(abort_ = true) || 要么还有空位not_full_.wait(lock,[this]{returnabort_||list_.size()<max_;});if(abort_){AVDeleter<T>::Free(obj);returnfalse;}list_.push_back(obj);not_empty_.notify_one();// 通知,链表有元素returntrue;}// 消费者T*Pop(){std::unique_lock<std::mutex>lock(mux_);// 要么被终止了(abort_ = true) || 要么链表不为空not_empty_.wait(lock,[this]{returnabort_||!list_.empty();});if(list_.empty()){returnnullptr;// 只有被 Abort 叫醒时才会走到这里}T*obj=list_.front();list_.pop_front();not_full_.notify_one();// 通知,链表腾出位置了returnobj;}voidClear(){std::unique_lock<std::mutex>lock(mux_);for(T*obj:list_){AVDeleter<T>::Free(obj);}list_.clear();not_full_.notify_all();}size_tSize(){std::unique_lock<std::mutex>lock(mux_);returnlist_.size();}voidAbort(){std::unique_lock<std::mutex>lock(mux_);abort_=true;not_empty_.notify_all();not_full_.notify_all();}private:std::mutex mux_;size_t max_;std::list<T*>list_;boolabort_=false;std::condition_variable not_empty_;// 链表有元素了std::condition_variable not_full_;// 链表有空位了};yview_->DrawFrame(frame);内部通过SDL渲染显示。
下方两个变量是线程中常用的,必须说明一下:
doublems_per_tick_=av_q2d(v_time_base)*1000.0;// 时间刻度,v_time_base是解复用视频流的timebase,计算出毫秒doubleprebuffer_ms_=300.0;// 缓存时间和最大迟到时间如上内容知道即可,对下方学习理解有帮助。
最终加上“播放时钟”代码如下:
voidYViewTask::Main(){int64_tpts0=AV_NOPTS_VALUE;int64_tlast_pts=AV_NOPTS_VALUE;doublewall0=0.0;doubleframe_ms=100.0;while(is_running_){AVFrame*frame=list_.Pop();if(!frame){break;// 只可能是被 Abort 叫醒 → 收工}if(0.0<ms_per_tick_&&AV_NOPTS_VALUE!=frame->pts){constdoublecur_ms=(double)now_ms();// 判断首帧if(AV_NOPTS_VALUE==pts0){pts0=frame->pts;wall0=cur_ms+prebuffer_ms_;}else{constdoubled=(double)(frame->pts-last_pts)*ms_per_tick_;if(0.0<d&&1000.0>d){frame_ms=d;// 记录真实真间隔,备用}elseif(0.0>d||1000.0<d){// 时间戳倒退或大跳(重连 / RTSP 时间戳跳变)→ 重锚,// 不重锚的话 due 会落到很远的过去(狂丢帧)或未来(画面冻住)pts0=frame->pts;wall0=cur_ms;}}last_pts=frame->pts;// 算这一帧该几点出现;constdoubledue=wall0+(double)(frame->pts-pts0)*ms_per_tick_;// 计算最晚迟到时间constdoublelate_tol=(prebuffer_ms_>0.0)?prebuffer_ms_:frame_ms;// 晚到了if(cur_ms-due>late_tol){if(cur_ms-due>1000.0){// 落后 1 秒以上不是"晚一帧",是起播堆积或断流后恢复// → 重锚:把"现在"当新起跑线,从这一帧起重新排表pts0=frame->pts;wall0=cur_ms;std::cout<<"X("<<(cur_ms-due)<<") ";}// 300~1000ms 这一段:不丢,往下走,直接显示}// 还没到该显示的时刻,就等 —— 这一句才是"播放时钟"while(is_running_&&(double)now_ms()<due){MSleep(1);}}std::cout<<"* ";yview_->DrawFrame(frame);av_frame_free(&frame);}}当前时间和睡眠函数:
inlineint64_tnow_ms(){returnstd::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();}inlinevoidMSleep(int64_tms){if(ms<=0)return;int64_tdeadline=now_ms()+ms;while(now_ms()<deadline){std::this_thread::sleep_for(std::chrono::milliseconds(1));}}测试rtsp拉流地址:rtsp://stream.strba.sk:1935/strba/VYHLAD_JAZERO.stream
YViewTask::Main() 逐行讲解
零、一句话总纲
这段代码就干一件事:给每一张画片算一个"该上台的墙上时刻",到点就画,没到点就盯着钟等。
旧做法:网上一来帧就画 → 网络快就快放,网络慢就卡住 (像公交,站站停,忽快忽慢) 新做法:先看钟,钟说该第 5 张了,才去拿第 5 张 (像地铁,按时刻表发车)一、先认识 6 个道具
| 代码里的名字 | 用比喻说 | 真实含义 |
|---|---|---|
frame->pts | 画片右上角印的号码 | 这一帧的显示时间戳,单位是"流自己的刻度" |
ms_per_tick_ | 换算尺:1 个刻度 = 多少毫秒 | av_q2d(time_base) × 1000,这路流是1/90000,所以尺子上 1 刻度 = 0.0111 ms |
pts0 | 起跑号码:拿哪一张当第 0 张 | 起跑线的参考帧号码 |
wall0 | 起跑线:它该在墙上几点出现 | 参考帧的应播时刻(毫秒) |
cur_ms | 现在墙上几点 | now_ms(),单调时钟 |
due | 这一张该上台的时刻 | wall0 + (pts - pts0) × ms_per_tick_ |
还有两个配角:
| 名字 | 干什么的 |
|---|---|
prebuffer_ms_ | 起播先攒 300ms 的货,同时也当"容忍迟到量" |
frame_ms | 最近观测到的真实帧间隔,prebuffer_ms_为 0 时拿它当容忍量 |
二、骨架(把细节全删掉的样子)
while(还在营业){从筐里拿一张画片// 没货就睡在这,被叫"收工"就下班if(这张有号码){定好或校准 起跑线// 首帧定,异常时重定算出它该上台的时刻 dueif(已经晚得离谱)重排时间表if(还没到点)盯着钟等// ← 这一等,才是"播放时钟"}上台(画出来) 扔掉}下面逐块拆。
三、逐行拆解
块 1 · 开场四行:准备四个记账本
int64_tpts0=AV_NOPTS_VALUE;// 起跑号码,还没定,用"无效值"占位int64_tlast_pts=AV_NOPTS_VALUE;// 上一张的号码,用来量间隔doublewall0=0.0;// 起跑线的墙上时刻doubleframe_ms=100.0;// 真实帧间隔,先随便给个 100 占位大白话:这四个变量要在整个循环里一直记着,所以必须写在while外面。写在里面的话每次循环都被清零,永远记不住上一帧是谁。
为什么pts0用AV_NOPTS_VALUE当占位?因为 pts 可能是 0 甚至是负数,用 0 当"还没定"的标志会误判。FFmpeg 给了个专门的"无效值"常量,用它最安全。
块 2 · 拿一张画片:这里会睡着
AVFrame*frame=list_.Pop();if(!frame){break;// 只可能是被 Abort 叫醒 → 收工}大白话:Pop()内部是"筐空了就睡,谁来塞货就醒"。所以它不会因为"暂时没货"就返回空手。
返回nullptr只有一种可能:有人喊了"收工"(list_.Abort())。既然是收工,那就break走人。
块 3 · 有号码才排时间表
if(0.0<ms_per_tick_&&AV_NOPTS_VALUE!=frame->pts){大白话:只有"尺子有效"且"这张有印号码"时,才能排时间表。两个条件缺一个,就直接跳过排表、马上画(当作不能定时的流处理)。
3.1 先看现在几点
constdoublecur_ms=(double)now_ms();必须用steady_clock(now_ms()里封装的)。它只会往前走,用户改系统时间、NTP 对时都不会让它跳。如果用system_clock,某一刻系统时间被拨回 1 分钟,所有帧都会突然"迟到 60 秒",画面直接乱掉。
3.2 首帧:把第一张定成起跑线
if(AV_NOPTS_VALUE==pts0){pts0=frame->pts;wall0=cur_ms+prebuffer_ms_;// ← 注意这个 + 300}大白话:
- 起跑号码 = 第一张的号码。
- 起跑线 ="现在"再往后推 300ms。
为什么推 300ms?因为第一帧刚到手时,筐里是空的。马上画的话,第二帧还没到就又得等 —— 画面一开始就一顿。所以先等 300ms 让网络把货攒起来,这叫起播预缓冲。
这就是那个+ prebuffer_ms_的唯一含义。它只在首帧出现一次。
3.3 非首帧:量一下这一张和上一张隔了多久
constdoubled=(double)(frame->pts-last_pts)*ms_per_tick_;if(0.0<d&&1000.0>d){frame_ms=d;// 正常间隔 → 记下来当真间隔}elseif(0.0>d||1000.0<d){pts0=frame->pts;// 号码倒退 或 跳得太远 → 这张当新的起跑线wall0=cur_ms;}大白话:d= 两张画片之间隔了多少毫秒。分三种情况:
| d 的取值 | 意思 | 怎么办 |
|---|---|---|
0 < d < 1000ms | 正常的两帧间隔 | 记进frame_ms(把这两帧之间的真间隔存下来备用) |
d ≥ 1000ms | 号码往前跳了一大截 | 重锚:把这张当新的起点,起跑线挪到"现在" |
d < 0 | 号码倒退了 | 同上,重锚 |
为什么跳得远就必须重锚?假设 3 秒没来画面,下一张的号码直接比上一张大 3 秒:
- 不重锚的话,
due = wall0 + (pts - pts0) × 尺子会算出一个"3 秒前就该播"的时刻; - 于是这一帧一进来就已经"迟到 3 秒",后面每一帧都永远追不上,画面彻底垮掉。
所以这里把这张当成新的第 0 张,时间表从它重排。
顺带说一个陷阱:
d > 1000和后面那句cur_ms - due > 1000里的两个 1000,是完全无关的两个量。
- 前者比较的是"这张和上一张之间隔多久"(帧间距);
- 后者比较的是"我们落后时间表多久"(跑步落后量)。
一个量的是步长,一个量的是差距,别混。
3.4 记住这张的号码,然后算它该几点上台
last_pts=frame->pts;constdoubledue=wall0+(double)(frame->pts-pts0)*ms_per_tick_;大白话:last_pts必须先更新,因为下一轮要拿它量间隔。
due的公式就一句话:
该上台的时刻 = 起跑线 + (这张号码 - 起跑号码) × 换算尺(pts - pts0)是"这张比起点晚几个刻度",是个整数;- 乘上
ms_per_tick_就变成毫秒; - 加上起跑线,就是墙上时刻。
这和"号码 × 单价 = 价钱"是一样的道理,尺子就是那个单价。
3.5 算容忍量,然后判断是不是已经晚了
constdoublelate_tol=(prebuffer_ms_>0.0)?prebuffer_ms_:frame_ms;大白话:允许它迟到多少。默认用prebuffer_ms_(300ms)—— 既然起播都敢攒 300ms,那晚 300ms 内都算正常波动。如果调用方把prebuffer_ms_传成 0(表示不预缓冲),就退而用frame_ms(真实的帧间隔),至少有一帧的余量。
if(cur_ms-due>late_tol){// 已经晚了if(cur_ms-due>1000.0){// 而且晚得离谱(超过 1 秒)pts0=frame->pts;// 重新起跑wall0=cur_ms;std::cout<<"X("<<(cur_ms-due)<<") ";}// 300~1000ms 这一段:不丢,往下走,直接显示}大白话:这里把"晚了"分成两档:
| 迟到量 | 判断 | 做法 |
|---|---|---|
| ≤ 300ms | 正常抖动 | 什么都不做,按原时间表走 |
| 300ms ~ 1秒 | 小落后 | 不丢帧,直接往下走,马上显示 |
| > 1秒 | 大落后(起播堆积 / 断流恢复) | 重锚到"现在",并且马上显示,同时打印一个X(落后毫秒数) |
为什么超过 1 秒要重锚?因为落后 1 秒以上不可能是"这一帧来晚了",一定是整条时间表已经失效了(断流、重连、时间戳跳变)。这时候继续按老表走,只会让后面每一帧都判成"迟到",一帧都追不回来。把"现在"当新起跑线,从这一帧起重新排表,才是唯一出路。
为什么 300~1000ms 不丢帧,直接画?因为之前实测过:丢帧并不能让画面更顺。源最大积压也就 0~1 帧,把帧扔掉只会让画面缺一块(出现空洞),而不会让播放追上时间。既然丢也追不上,不如把它画出来。
块 4 · 盯着钟等(真正的"播放时钟")
while(is_running_&&(double)now_ms()<due){MSleep(1);}大白话:这一帧还没到上台的时候,就在这儿原地转,每 1ms 看一眼钟。钟一到due,立刻跳出、画出去。
块 5 · 上台,然后扔掉
std::cout<<"* ";yview_->DrawFrame(frame);av_frame_free(&frame);大白话:画一帧,打印一个*(方便数到底画了多少帧),然后把这一帧的内存还回去。
av_frame_free不能省:每一帧都是av_frame_alloc出来的独立内存,用完不还就一路泄漏,跑久了内存爆掉。
四、带数字走一遍
假设:流是1/90000的刻度,ms_per_tick_ = 0.01111,帧间隔约 90.9ms(11fps),prebuffer_ms_ = 300。
| # | 情形 | cur_ms | frame->pts | pts0 | wall0 | 算出due | cur−due | 结果 |
|---|---|---|---|---|---|---|---|---|
| 1 | 首帧 | 1000 | 9000 | 9000 | 1300 | 1300 | −300 | 等 300ms 到 1300 才画(预缓冲) |
| 2 | 正常 | 1091 | 17182 | 9000 | 1300 | 1390.9 | −300 | d=90.9记入frame_ms,等 0.9ms 画 |
| 3 | 正常 | 1391 | 25364 | 9000 | 1300 | 1481.8 | −91 | 等 91ms 画 |
| 4 | 晚 500ms | 1980 | 33546 | 9000 | 1300 | 1572.7 | +407 | 超 300 但没超 1000 →不丢,直接画 |
| 5 | 晚 1.5 秒 | 3070 | 41728 | 9000 | 1300 | 1663.6 | +1406 | 超 1000 → 打印X(1406),重锚pts0=41728 / wall0=3070,立刻画 |
| 6 | 紧跟一帧 | 3161 | 49910 | 41728 | 3070 | 3160.9 | 0.1 | 时间表已重置,恢复均匀 |
| 7 | 断流 3 秒 | 6000 | 297364 | 41728 | 3070 | 11475.9 | −5475 | 但d = 3000 > 1000→ 在 3.3 步就重锚了 →due=6000,立刻画 |
第 5、7 行是同一件事的两种入口:5 是"表算对了但我人迟了",7 是"号码跳了表就该重排"。两条路都会把pts0/wall0重置到"现在"。
五、最容易搞混的两个 1000
1000.0 < d(3.3 步) | cur_ms - due > 1000(3.5 步) | |
|---|---|---|
| 量的是什么 | 帧间距:这张和上张隔多久 | 落后量:我们落后时间表多久 |
| 触发条件 | 源那边号码跳了(断流/重连) | 我们自己播慢了(起播堆积、卡顿恢复) |
| 动作 | 重锚 | 打印X+ 重锚 |
| 典型场景 | 源停了 3 秒,下一帧号码直接大 3 秒 | 首帧堆了 2 秒的货才开始播 |
动作一样(都是重锚),但原因完全不同。前者是"原料有问题",后者是"我们干活慢了"。