從Chrome源碼看audio/video流媒體實現二(轉)


第一篇主要介紹了Chrome加載音視頻的緩沖控制機制和編解碼基礎,本篇將比較深入地介紹解碼播放的過程。以Chromium 69版本做研究。

由於Chromium默認不能播放Mp4,所以需要需要改一下源碼重新編譯一下。

1. 編譯一個能播放mp4的Chromium

自行編譯出來的Chromium是無法播放mp4視頻,在官網下載的也不行,終端會提示這個錯誤:

[69542:775:0714/132557.522659:ERROR:render_media_log.cc(30)] MediaEvent: PIPELINE_ERROR DEMUXER_ERROR_NO_SUPPORTED_STREAMS

說是在demux即多路解復用的時候發生了錯誤,不支持當前流格式,也就是Chromium不支持mp4格式的解析,這是為什么呢?經過一番搜索和摸索,發現只要把ffmpeg的編譯模式從Chromium改成Chrome就可以。編輯third_party/ffmpeg/ffmpeg_options.gni這個文件,把前面幾行代碼改一下——把_default_ffmpeg_branding強制設置成Chrome,再重新編譯一下就行了,如下代碼所示:

# if (is_chrome_branded) { _default_ffmpeg_branding = "Chrome" # } else { # _default_ffmpeg_branding = "Chromium" # }復制代碼

編譯出來的Chromium就能播放視頻了。Chromium工程的編譯目標branding可以設置成Chrome(正式版)/Chromium/Chrome OS三種模式,ffmpeg的編譯設定會根據這個branding類型自動選擇它自己的branding,如上代碼的判斷,如果branding是Chrome,會額外多加一些解碼器,就能夠播放mp4了。

不過如果你想編譯成正式版Chrome,由於缺少相關的主題theme文件,是編譯不了的。

另外它還有一個proprietary_codecs的設置:

proprietary_codecs = is_chrome_branded || is_chromecast復制代碼

編譯正式版的Chrome會默認打開,它的作用是增加一些額外的解碼器,如對EME(Encrypted Media Extensions)加密媒體擴展的支持。

最后一開始不能打開和能打開播放的效果對比如下圖所示:

那么為什么Chromium不直接打開mp4支持呢,它可能受到mp4或者ffmpeg的一些開源協議和專利限制。

2. mp4格式和解復用

一個視頻可以有3個軌道(Track):視頻、音頻和文本,但是數據的存儲是一維的,從1個字節到第n個字節,那么視頻應該放哪里,音頻應該放哪里,mp4/avi等格式此做了規定,把視軌音軌合成一個mp4文件的過程就叫多路復用(mux),而把mp4文件里的音視頻軌分離出來就叫多路解復用(demux),這兩個詞是從通信領域來的。

假設現在有個需求,需要取出用戶上傳視頻的第一幀做為封面。這就要求我們去解析mp4文件,並做解碼。


我們以這個 mountain.mp4時長為1s大小487kB的mp4視頻做為研究對象,用sublime等編輯器打開顯示其原始的二進制內容,如下圖所示:
 

上圖是用16進制表示的原始二進制內容,兩個16進制(0000)就表示1個字節,如上圖第4個字節是0x18。

 

mp4是使用box盒子表示它的數據存儲的,標准規定了若干種盒子類型,每種盒子存放的數據類型不一樣,box可以嵌套box。每個box的前4個字節表示它占用的空間大小,如上面第一個box是0x18 = 24字節,也就是說在接下來的24字節都是這個box的內容,所以上圖到第2行的3431就是第一個box的內容。在前4個表示大小的字節之后緊接着的4個字節是盒子類型,值為ASCII編碼,第一個盒子的類型為:

6674 7970 => ftyp

ftyp盒子的作用是用來標志當前文件類型,緊接着的4個字節表示它是一個微軟的MPEG-4格式,即平常說的mp4:

6d70 3432 => mp42

綜上,第1個盒子整體解析如下圖所示:

同樣對第二個盒子做分析,如下圖所示:

它是一個moov的盒子,moov存儲了盒子的metadata信息,包括有多少個音視頻軌道,視頻寬高是多少,有多少sample(幀),幀數據位於什么位置等等關鍵信息。注意mp4格式多媒體數據存儲可以是不連續的,往后播放的可能反而放在前面,但是沒關系。因為這些位置信息都可以從moov這個盒子里面找到。若干個sample組成一個chunk,即一個chunk可以包含1到多個sample,chunk的位置也是在moov盒子里面。


最后面是一個mdat的盒子,這個就是放多媒體數據的盒子,大小為492242B,它占據了mp4文件的絕大部分空間。moov里的chunk的位置偏移offset就是相對於mdat的。

上面我們一個字節一個字節對照着解析比較累,可以用一些現成的工具,如這個在線的MP4Box.js或者是這個MP4Parser,如下圖所示,moov里面總共有兩個軌道的盒子:


展開視頻軌道的子盒子,找到stsz這個盒子,可以看到總共有24幀,每一幀的大小也是可以見到,如下圖所示:

 

 

 

這里我們發現最大的一幀有98KB,最小的一幀只有3KB,一幀就表示一張圖像,為什么不同幀差別會這么大呢?

因為有些幀是關鍵幀(I幀,Intra frame),包含了該幀的完整圖象信息,所以比較大,I幀可做為參考幀。另一些幀只是記錄和參考幀的差異,叫幀間預測幀(Inter frame),所以比較小,預測幀有前向預測幀P幀和雙向預測幀B幀,P幀是參考前面解碼過的圖像,而B幀參考雙向的。所以只是拿到預測幀是沒有意義的,需要它前面的那個參考幀才能解碼。參考幀(h264)的壓縮比類似jpg,一般可達7比1,而預測幀的壓縮比可達幾十比1。

接着這些幀是怎么存放的呢,它們分別是放在哪些chunk里面的呢,每個chunk的位置又在哪里?如下圖stco的盒子所示:

可以看到,總共有3個chunk,每個chunk的位置offsset也都指明。而每個chunk有多少個sample的信息是放在stsc這個盒子里面,如下圖所示:

從[1, 3)即第1個到第2個chunk每個chunk有10個sample,而從[3, end)即第3個chunk是4 sample。這樣如果我要找第13幀在mdat盒子里的偏移,那么可以知道它是在第2個chunk里的第3個sample,所以參考上面的數據計算起始位置:

 

 

而終止位置是236645 + 6274 = 242919,所以第13幀存放在mdat的[236645, 242919)區間。這里有一篇文章介紹了怎么取mp4幀數據的算法,和我們上面分析的過程類似。

這個幀(13幀)這么小,它很可能不是一個關鍵幀。具體怎么判斷它是不是一個關鍵幀,主要通過幀頭部信息里的nal類型,值為5的則為關鍵幀,這個要涉及到具體的解碼過程了。

還有一個問題,怎么知道這個mp4是h264編碼,而不是h265之類的,這個通過avc1盒子可以知道,如下圖所示:


avc1就是h.264的別名,這個盒子里面放了很多解碼需要的參數,如level、SPS、PPS等,如最大參考幀的數目等,參考上圖注解。如果最大參考幀放得比較寬,可以使用的參考幀比較多的時候,壓縮比能得到提升,但是解碼的效率就會降低,並且在seek尋址的時候也不方便,需要往后讀很多幀,或者往前保留很多幀,特別是流式播放的時候可能需要提前下載很多內容。上面SPS分析得到的最大參考幀數目是3(max_num_ref_frames).

接下來怎么對圖像幀進行解碼還原成rgb圖像呢?


3. 視頻幀解碼

I幀的解碼不需要參考幀,解碼過程比較類似於JPG,P幀和B幀需要依賴前后幀才能還原完整內容,所以幀的解碼順序通常不是按照播放順序來的。我們不妨研究一下上面的示例視頻的所有幀的類型,可以借助一個在線網站Online Video GOP Analyzer,分析結果如下圖所示:

x軸表示從0到23共24幀,y軸表示每一幀的大小,綠色的是關鍵幀I幀,紅色的是前向預測幀P幀,藍色的表示雙向預測幀B幀,可以清楚地看到,在體積上I幀 > P幀 > B幀。這24個幀的排列順序:

I B B B P B B B P ... B P I

首尾兩幀都是I幀,剛好形成一個GOP圖像序列(group of pictures),在一個GOP序列里面,I幀是起始幀,接下來是B幀和P幀(可能會沒有B幀)。

上圖的幀順序是按照每個幀播放時間戳PTS(presentation timestamp)依次遞增,其中第12幀(中間紅色柱子)推導的播放時間點PTS是0.54s。

但是存儲順序和解碼順序並不是按照播放的順序來的,可對比第2步里的幀的大小圖:


其中,sample_sizes是存儲的順序,柱形圖的順序是按照PST,兩者對比可以看到每一幀的解碼時間戳DTS(decode timestamp)是按照以下順序:

I P B B B P B B B ...

在一個GOP序列里面,I幀是起始幀,最先解析,然后就是P幀,最后才是B幀,可以猜測因為P幀依賴於I幀,所以要先P幀要先於B幀,而B幀可能要依賴於I幀和P幀,所以最后才能解析。那怎么才能知道具體的依賴關系,也就是每一幀的參考幀列表呢?

首先每一幀的播放順序POC(Picture Order Count)可以從每一幀的頭部信息計算得到,借助一些如JW Reference軟件,能夠查到從存儲順序的第1幀到第5幀POC依次為:

0 8 2 4 6 ...

這里是按照2遞增的,換算成1的話就是:

0 4 1 2 3 ...

與上面的分析一致。

接着怎么知道幀間預測幀B幀和P幀的參考幀是誰呢?在回答這個問題之前需要知道參考幀參考的是什么,在jpg/h264里面把圖片划分為一個個的宏塊(macroblock),一個宏塊是16 * 16px,以宏塊為單位進行存儲,記錄的顏色信息是以YCbCr格式,Y是指亮度也就是灰度,Cb是指藍色分量,Cr是紅色的分量。如下圖所示:


 

 

幀間預測幀的宏塊只是記錄了差值,所以需要找到參考幀列表的相似宏塊。由相似宏塊和差值還原完整內容。

而參考幀列表是在解碼過程中動態維護的,放到一個DPB(decoded picture buffer)的數據結構里面,里面有兩個list,list0放的是前向的,list1放的是后向的,依據最大參考幀數目DPB的空間有限,滿了之后會有一定的策略清空或者重置。

在參考幀里面找到匹配的宏塊就叫運動估計,借助匹配塊恢復完整宏塊就是運動補償。如下圖所示:


 

上圖第3幀B幀的一個塊找到的匹配塊有3個,分別是相鄰的I、P、B,這3幀就是它的參考幀。箭頭方向就是表示運動矢量,通過上圖示意,可以知道物體是從上往下運動的(注意上面的順序是存儲順序IPBB,而播放順序是IBBP)。

運動估計和運動補償的算法有多種,h264有推薦的使用算法。

至此我們知道了解碼的基本原理,具體怎么把那一幀的圖像解碼為rgb圖片,我在《wasm + ffmpeg實現前端截取視頻幀功能》把ffmpeg編譯成wasm,然后在前端頁實現了這個功能。主要利用ffmpeg的解碼,Chrome也是用的ffmpeg做為它的解碼引擎。關鍵調用函數為avcodec_decode_video2(這個已被deprecated,下文會繼續提及)。

借助ffmpeg,我們能夠把所有的幀解析出來變成rgb圖片,這些圖片怎么形成一個視頻呢?

4. 視頻播放

最直觀的做法就是根據幀率,如上面的示例視頻幀率為25fps,1s有25幀,每一幀播放間隔時長為1s / 25 = 0.04s,即每隔40ms就播放一幀,這樣就形成一個視頻了。利用ffmpeg的av_frame_get_best_effort_timestamp函數可以得到每一幀的PST播放時間,理論上以開始播放的時間為起點,在相應的時間差播放對應PST的幀就可以了。實現上可以讓播放視頻的線程sleep相鄰兩個幀的pst時間差,時間到了線程喚醒后再display顯示新的幀。

實際上為了更好地保證音視頻同步,需要以當前音頻播放的時間做一個修正。例如,如果解碼視頻的線程卡了跟不上了,和音頻的時間audioClock相差太多,超過一個閾值如0.1s那么這一幀就丟掉了,不要展示了,相反如果是解碼視頻線程快了,那么delay一下,讓播放視頻的線程休眠更長的時間,保持當前幀不動。更科學的方法是讓音頻和視頻同時以當前播放的時間做修正,即記錄一下開始播放的系統時間,用當前系統時間減掉開始時間就得到播放時間。Chrome就是這么做的,當我們看Chrome源碼的時候會發現這個過程比上面描述得要復雜。


5. Chrome視頻播放過程

我們從多路解復用開始說起,Chrome的多路解復用是在src/media/filters/ffmpeg_demuxer.cc里面進行的,先借助buffer數據初始化一個format_context,記錄視頻格式信息,然后調avformat_find_stream_info得到所有的streams,一個stream包含一個軌道,循環streams,根據codec_id區分audio、video、text三種軌道,記錄每種軌道的數量,設置播放時長duration,用fist_pts初始化播放開始時間start_time。並實例化一個DemuxerStream對象,這個對象會記錄視頻寬高、是否有旋轉角度等,初始化audio_config和video_config,給解碼的時候使用。這里面的每一步幾乎都是通過PostTask進行的,即把函數當作一個任務拋給media線程處理,同時傳遞一個處理完成的回調函數。如果其中有一步掛了就不會進行下一步,例如遇到不支持的容器格式,在第一步初始化就會失敗,就不會調回調函數往下走了。

解碼是使用ffmpeg的avcodec_send_packet和avcodec_receive_frame進行的音視頻解碼,上文提到的avcodec_decode_video2已經被棄用,ffmpeg3起引入了新的解碼函數。

解碼和解復用都是在media線程處理的,如下圖所示:

音頻解碼完成會放到audio_buffer_renderer_algorithm的AudioBufferQueue里面,等待AudioOutputDevice線程讀取。為什么起名叫algorithm,因為它還有一個作用就是實現WSOLA語音時長調整算法,即所謂的變速不變調,因為在JS里面我們是可以設置audio/video的playback調整播放速度。

視頻解碼完成會放到video_buffer_renderer_algorithm.cc的buffer隊列里面,這個類的作用也是為了保證流暢的播放體驗,包括上面討論的時鍾同步的關系。

准備渲染的時候會先給video_frame_compositor.cc,這個在media里的合成器充當media和Chrome Compositor(最終合成)的一個中介,由它把處理好的frame給最終合成並渲染,之前的文章已經提過Chrome是使用skia做為渲染庫,主要通過它提供的Cavans類操作繪圖。

Chrome使用的ffmpeg是有所刪減的,支持的格式有限,不然的話光是ffmpeg就要10多個MB了。

以上就是整體的過程,具體的細節如怎么做音視頻同步等,本篇沒有深入去研究。


7. 小結

本篇介紹了很多了視頻解碼的概念,包括mp4容器的格式特點,怎么進行多路解復用取出音視頻數據,什么是I幀、B幀和P幀。介紹了在解碼過程中播放時間PST和解碼順序DST往往是不一致的(如果有B幀),B幀和P幀通過運動估計、運動補償進行解碼還原。最后介紹Chrome是怎么利用ffmpeg進行解碼,分析了Chrome播放視頻的整體過程。

閱讀完本篇內容並不能成為一個多媒體高手,但是可以對多媒體的很多概念有一個基本了解,當你去參加一些多媒體技術會議的時候就不會聽得霧里雲里的。本篇把很多多媒體基礎串了起來,這也是我研究了很久才得到的一些認知,我的感受是多媒體領域的水很深,需要有耐心扎進去才能有所成,但多媒體又是提高生活質量的一個很重要的媒介。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM