從Chrome原始碼看audio/video流媒體實現二

人人網FED發表於2018-08-12

第一篇主要介紹了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播放視訊的整體過程。

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

掘金微信小程式開發者大會


相關文章