鐘實(shí)現(xiàn)揭秘)
前言最近在學(xué)習(xí)ffmpeg的rtsp拉流實(shí)時(shí)播放遇到一些問題。舊的實(shí)現(xiàn)方式是通過網(wǎng)絡(luò)拉取一幀就顯示一幀實(shí)時(shí)播放但是如果網(wǎng)絡(luò)延遲隔了幾百毫秒甚至幾千毫秒才拿到下一幀畫面那么期間就會(huì)卡主導(dǎo)致整體播放流程一卡一卡的。所以使用播放時(shí)鐘約定播放很有必要。有一些必要的了解實(shí)現(xiàn)方式是在一個(gè)線程函數(shù)內(nèi)持續(xù)循環(huán)讀取幀來顯示voidYViewTask::Main(){while(is_running_){AVFrame*framelist_.Pop();if(!frame){break;// 只可能是被 Abort 叫醒 → 收工}std::cout* ;yview_-DrawFrame(frame);av_frame_free(frame);}}YViewTask::Main()是C線程執(zhí)行函數(shù)。list_ 是一個(gè)鏈表用于緩存已經(jīng)讀取到的AVFrame幀其結(jié)構(gòu)如下AvObjListAVFrame list_;// 通用隊(duì)列templatetypenameTclassAvObjList{public:explicitAvObjList(size_t max100):max_(max){}~AvObjList(){Clear();}// 禁止拷貝因?yàn)楹衜utexAvObjList(constAvObjList)delete;AvObjListoperator(constAvObjList)delete;// 生產(chǎn)者慢則丟棄最舊的boolAdd(T*obj){std::unique_lockstd::mutexlock(mux_);if(!obj){LOGWARNING(pkt is null!);returnfalse;}// 要么被終止了(abort_ true) || 要么還有空位not_full_.wait(lock,[this]{returnabort_||list_.size()max_;});if(abort_){AVDeleterT::Free(obj);returnfalse;}list_.push_back(obj);not_empty_.notify_one();// 通知鏈表有元素returntrue;}// 消費(fèi)者T*Pop(){std::unique_lockstd::mutexlock(mux_);// 要么被終止了(abort_ true) || 要么鏈表不為空not_empty_.wait(lock,[this]{returnabort_||!list_.empty();});if(list_.empty()){returnnullptr;// 只有被 Abort 叫醒時(shí)才會(huì)走到這里}T*objlist_.front();list_.pop_front();not_full_.notify_one();// 通知鏈表騰出位置了returnobj;}voidClear(){std::unique_lockstd::mutexlock(mux_);for(T*obj:list_){AVDeleterT::Free(obj);}list_.clear();not_full_.notify_all();}size_tSize(){std::unique_lockstd::mutexlock(mux_);returnlist_.size();}voidAbort(){std::unique_lockstd::mutexlock(mux_);abort_true;not_empty_.notify_all();not_full_.notify_all();}private:std::mutex mux_;size_t max_;std::listT*list_;boolabort_false;std::condition_variable not_empty_;// 鏈表有元素了std::condition_variable not_full_;// 鏈表有空位了};yview_-DrawFrame(frame);內(nèi)部通過SDL渲染顯示。下方兩個(gè)變量是線程中常用的必須說明一下doublems_per_tick_av_q2d(v_time_base)*1000.0;// 時(shí)間刻度v_time_base是解復(fù)用視頻流的timebase計(jì)算出毫秒doubleprebuffer_ms_300.0;// 緩存時(shí)間和最大遲到時(shí)間如上內(nèi)容知道即可對下方學(xué)習(xí)理解有幫助。最終加上“播放時(shí)鐘”代碼如下voidYViewTask::Main(){int64_tpts0AV_NOPTS_VALUE;int64_tlast_ptsAV_NOPTS_VALUE;doublewall00.0;doubleframe_ms100.0;while(is_running_){AVFrame*framelist_.Pop();if(!frame){break;// 只可能是被 Abort 叫醒 → 收工}if(0.0ms_per_tick_AV_NOPTS_VALUE!frame-pts){constdoublecur_ms(double)now_ms();// 判斷首幀if(AV_NOPTS_VALUEpts0){pts0frame-pts;wall0cur_msprebuffer_ms_;}else{constdoubled(double)(frame-pts-last_pts)*ms_per_tick_;if(0.0d1000.0d){frame_msd;// 記錄真實(shí)真間隔備用}elseif(0.0d||1000.0d){// 時(shí)間戳倒退或大跳重連 / RTSP 時(shí)間戳跳變→ 重錨// 不重錨的話 due 會(huì)落到很遠(yuǎn)的過去狂丟幀或未來畫面凍住pts0frame-pts;wall0cur_ms;}}last_ptsframe-pts;// 算這一幀該幾點(diǎn)出現(xiàn)constdoubleduewall0(double)(frame-pts-pts0)*ms_per_tick_;// 計(jì)算最晚遲到時(shí)間constdoublelate_tol(prebuffer_ms_0.0)?prebuffer_ms_:frame_ms;// 晚到了if(cur_ms-duelate_tol){if(cur_ms-due1000.0){// 落后 1 秒以上不是晚一幀是起播堆積或斷流后恢復(fù)// → 重錨把現(xiàn)在當(dāng)新起跑線從這一幀起重新排表pts0frame-pts;wall0cur_ms;std::coutX((cur_ms-due)) ;}// 300~1000ms 這一段不丟往下走直接顯示}// 還沒到該顯示的時(shí)刻就等 —— 這一句才是播放時(shí)鐘while(is_running_(double)now_ms()due){MSleep(1);}}std::cout* ;yview_-DrawFrame(frame);av_frame_free(frame);}}當(dāng)前時(shí)間和睡眠函數(shù)inlineint64_tnow_ms(){returnstd::chrono::duration_caststd::chrono::milliseconds(std::chrono::steady_clock::now().time_since_epoch()).count();}inlinevoidMSleep(int64_tms){if(ms0)return;int64_tdeadlinenow_ms()ms;while(now_ms()deadline){std::this_thread::sleep_for(std::chrono::milliseconds(1));}}測試rtsp拉流地址rtsp://stream.strba.sk:1935/strba/VYHLAD_JAZERO.streamYViewTask::Main() 逐行講解零、一句話總綱這段代碼就干一件事給每一張畫片算一個(gè)該上臺(tái)的墻上時(shí)刻到點(diǎn)就畫沒到點(diǎn)就盯著鐘等。舊做法網(wǎng)上一來幀就畫 → 網(wǎng)絡(luò)快就快放網(wǎng)絡(luò)慢就卡住 像公交站站停忽快忽慢 新做法先看鐘鐘說該第 5 張了才去拿第 5 張 像地鐵按時(shí)刻表發(fā)車一、先認(rèn)識(shí) 6 個(gè)道具代碼里的名字用比喻說真實(shí)含義frame-pts畫片右上角印的號(hào)碼這一幀的顯示時(shí)間戳單位是流自己的刻度ms_per_tick_換算尺1 個(gè)刻度 多少毫秒av_q2d(time_base) × 1000這路流是1/90000所以尺子上 1 刻度 0.0111 mspts0起跑號(hào)碼拿哪一張當(dāng)?shù)?0 張起跑線的參考幀號(hào)碼wall0起跑線它該在墻上幾點(diǎn)出現(xiàn)參考幀的應(yīng)播時(shí)刻毫秒cur_ms現(xiàn)在墻上幾點(diǎn)now_ms()單調(diào)時(shí)鐘due這一張?jiān)撋吓_(tái)的時(shí)刻wall0 (pts - pts0) × ms_per_tick_還有兩個(gè)配角名字干什么的prebuffer_ms_起播先攢 300ms 的貨同時(shí)也當(dāng)容忍遲到量frame_ms最近觀測到的真實(shí)幀間隔prebuffer_ms_為 0 時(shí)拿它當(dāng)容忍量二、骨架把細(xì)節(jié)全刪掉的樣子while(還在營業(yè)){從筐里拿一張畫片// 沒貨就睡在這被叫收工就下班if(這張有號(hào)碼){定好或校準(zhǔn) 起跑線// 首幀定異常時(shí)重定算出它該上臺(tái)的時(shí)刻 dueif(已經(jīng)晚得離譜)重排時(shí)間表if(還沒到點(diǎn))盯著鐘等// ← 這一等才是播放時(shí)鐘}上臺(tái)畫出來 扔掉}下面逐塊拆。三、逐行拆解塊 1 · 開場四行準(zhǔn)備四個(gè)記賬本int64_tpts0AV_NOPTS_VALUE;// 起跑號(hào)碼還沒定用無效值占位int64_tlast_ptsAV_NOPTS_VALUE;// 上一張的號(hào)碼用來量間隔doublewall00.0;// 起跑線的墻上時(shí)刻doubleframe_ms100.0;// 真實(shí)幀間隔先隨便給個(gè) 100 占位大白話這四個(gè)變量要在整個(gè)循環(huán)里一直記著所以必須寫在while外面。寫在里面的話每次循環(huán)都被清零永遠(yuǎn)記不住上一幀是誰。為什么pts0用AV_NOPTS_VALUE當(dāng)占位因?yàn)?pts 可能是 0 甚至是負(fù)數(shù)用 0 當(dāng)還沒定的標(biāo)志會(huì)誤判。FFmpeg 給了個(gè)專門的無效值常量用它最安全。塊 2 · 拿一張畫片這里會(huì)睡著AVFrame*framelist_.Pop();if(!frame){break;// 只可能是被 Abort 叫醒 → 收工}大白話Pop()內(nèi)部是筐空了就睡誰來塞貨就醒。所以它不會(huì)因?yàn)闀簳r(shí)沒貨就返回空手。返回nullptr只有一種可能有人喊了收工list_.Abort()。既然是收工那就break走人。塊 3 · 有號(hào)碼才排時(shí)間表if(0.0ms_per_tick_AV_NOPTS_VALUE!frame-pts){大白話只有尺子有效且這張有印號(hào)碼時(shí)才能排時(shí)間表。兩個(gè)條件缺一個(gè)就直接跳過排表、馬上畫當(dāng)作不能定時(shí)的流處理。3.1 先看現(xiàn)在幾點(diǎn)constdoublecur_ms(double)now_ms();必須用steady_clocknow_ms()里封裝的。它只會(huì)往前走用戶改系統(tǒng)時(shí)間、NTP 對時(shí)都不會(huì)讓它跳。如果用system_clock某一刻系統(tǒng)時(shí)間被撥回 1 分鐘所有幀都會(huì)突然遲到 60 秒畫面直接亂掉。3.2 首幀把第一張定成起跑線if(AV_NOPTS_VALUEpts0){pts0frame-pts;wall0cur_msprebuffer_ms_;// ← 注意這個(gè) 300}大白話起跑號(hào)碼 第一張的號(hào)碼。起跑線 現(xiàn)在再往后推 300ms。為什么推 300ms因?yàn)榈谝粠瑒偟绞謺r(shí)筐里是空的。馬上畫的話第二幀還沒到就又得等 —— 畫面一開始就一頓。所以先等 300ms 讓網(wǎng)絡(luò)把貨攢起來這叫起播預(yù)緩沖。這就是那個(gè) prebuffer_ms_的唯一含義。它只在首幀出現(xiàn)一次。3.3 非首幀量一下這一張和上一張隔了多久constdoubled(double)(frame-pts-last_pts)*ms_per_tick_;if(0.0d1000.0d){frame_msd;// 正常間隔 → 記下來當(dāng)真間隔}elseif(0.0d||1000.0d){pts0frame-pts;// 號(hào)碼倒退 或 跳得太遠(yuǎn) → 這張當(dāng)新的起跑線wall0cur_ms;}大白話d 兩張畫片之間隔了多少毫秒。分三種情況d 的取值意思怎么辦0 d 1000ms正常的兩幀間隔記進(jìn)frame_ms把這兩幀之間的真間隔存下來備用d ≥ 1000ms號(hào)碼往前跳了一大截重錨把這張當(dāng)新的起點(diǎn)起跑線挪到現(xiàn)在d 0號(hào)碼倒退了同上重錨為什么跳得遠(yuǎn)就必須重錨假設(shè) 3 秒沒來畫面下一張的號(hào)碼直接比上一張大 3 秒不重錨的話due wall0 (pts - pts0) × 尺子會(huì)算出一個(gè)3 秒前就該播的時(shí)刻于是這一幀一進(jìn)來就已經(jīng)遲到 3 秒后面每一幀都永遠(yuǎn)追不上畫面徹底垮掉。所以這里把這張當(dāng)成新的第 0 張時(shí)間表從它重排。順帶說一個(gè)陷阱d 1000和后面那句cur_ms - due 1000里的兩個(gè) 1000是完全無關(guān)的兩個(gè)量。前者比較的是這張和上一張之間隔多久幀間距后者比較的是我們落后時(shí)間表多久跑步落后量。一個(gè)量的是步長一個(gè)量的是差距別混。3.4 記住這張的號(hào)碼然后算它該幾點(diǎn)上臺(tái)last_ptsframe-pts;constdoubleduewall0(double)(frame-pts-pts0)*ms_per_tick_;大白話last_pts必須先更新因?yàn)橄乱惠喴盟块g隔。due的公式就一句話該上臺(tái)的時(shí)刻 起跑線 (這張?zhí)柎a - 起跑號(hào)碼) × 換算尺(pts - pts0)是這張比起點(diǎn)晚幾個(gè)刻度是個(gè)整數(shù)乘上ms_per_tick_就變成毫秒加上起跑線就是墻上時(shí)刻。這和號(hào)碼 × 單價(jià) 價(jià)錢是一樣的道理尺子就是那個(gè)單價(jià)。3.5 算容忍量然后判斷是不是已經(jīng)晚了constdoublelate_tol(prebuffer_ms_0.0)?prebuffer_ms_:frame_ms;大白話允許它遲到多少。默認(rèn)用prebuffer_ms_300ms—— 既然起播都敢攢 300ms那晚 300ms 內(nèi)都算正常波動(dòng)。如果調(diào)用方把prebuffer_ms_傳成 0表示不預(yù)緩沖就退而用frame_ms真實(shí)的幀間隔至少有一幀的余量。if(cur_ms-duelate_tol){// 已經(jīng)晚了if(cur_ms-due1000.0){// 而且晚得離譜超過 1 秒pts0frame-pts;// 重新起跑wall0cur_ms;std::coutX((cur_ms-due)) ;}// 300~1000ms 這一段不丟往下走直接顯示}大白話這里把晚了分成兩檔遲到量判斷做法≤ 300ms正常抖動(dòng)什么都不做按原時(shí)間表走300ms ~ 1秒小落后不丟幀直接往下走馬上顯示 1秒大落后起播堆積 / 斷流恢復(fù)重錨到現(xiàn)在并且馬上顯示同時(shí)打印一個(gè)X(落后毫秒數(shù))為什么超過 1 秒要重錨因?yàn)槁浜?1 秒以上不可能是這一幀來晚了一定是整條時(shí)間表已經(jīng)失效了斷流、重連、時(shí)間戳跳變。這時(shí)候繼續(xù)按老表走只會(huì)讓后面每一幀都判成遲到一幀都追不回來。把現(xiàn)在當(dāng)新起跑線從這一幀起重新排表才是唯一出路。為什么 300~1000ms 不丟幀直接畫因?yàn)橹皩?shí)測過丟幀并不能讓畫面更順。源最大積壓也就 0~1 幀把幀扔掉只會(huì)讓畫面缺一塊出現(xiàn)空洞而不會(huì)讓播放追上時(shí)間。既然丟也追不上不如把它畫出來。塊 4 · 盯著鐘等真正的播放時(shí)鐘while(is_running_(double)now_ms()due){MSleep(1);}大白話這一幀還沒到上臺(tái)的時(shí)候就在這兒原地轉(zhuǎn)每 1ms 看一眼鐘。鐘一到due立刻跳出、畫出去。塊 5 · 上臺(tái)然后扔掉std::cout* ;yview_-DrawFrame(frame);av_frame_free(frame);大白話畫一幀打印一個(gè)*方便數(shù)到底畫了多少幀然后把這一幀的內(nèi)存還回去。av_frame_free不能省每一幀都是av_frame_alloc出來的獨(dú)立內(nèi)存用完不還就一路泄漏跑久了內(nèi)存爆掉。四、帶數(shù)字走一遍假設(shè)流是1/90000的刻度ms_per_tick_ 0.01111幀間隔約 90.9ms11fpsprebuffer_ms_ 300。#情形cur_msframe-ptspts0wall0算出duecur?due結(jié)果1首幀10009000900013001300?300等 300ms 到 1300 才畫預(yù)緩沖2正常109117182900013001390.9?300d90.9記入frame_ms等 0.9ms 畫3正常139125364900013001481.8?91等 91ms 畫4晚 500ms198033546900013001572.7407超 300 但沒超 1000 →不丟直接畫5晚 1.5 秒307041728900013001663.61406超 1000 → 打印X(1406)重錨pts041728 / wall03070立刻畫6緊跟一幀3161499104172830703160.90.1時(shí)間表已重置恢復(fù)均勻7斷流 3 秒600029736441728307011475.9?5475但d 3000 1000→ 在 3.3 步就重錨了 →due6000立刻畫第 5、7 行是同一件事的兩種入口5 是表算對了但我人遲了7 是號(hào)碼跳了表就該重排。兩條路都會(huì)把pts0/wall0重置到現(xiàn)在。五、最容易搞混的兩個(gè) 10001000.0 d3.3 步cur_ms - due 10003.5 步量的是什么幀間距這張和上張隔多久落后量我們落后時(shí)間表多久觸發(fā)條件源那邊號(hào)碼跳了斷流/重連我們自己播慢了起播堆積、卡頓恢復(fù)動(dòng)作重錨打印X 重錨典型場景源停了 3 秒下一幀號(hào)碼直接大 3 秒首幀堆了 2 秒的貨才開始播動(dòng)作一樣都是重錨但原因完全不同。前者是原料有問題后者是我們干活慢了。