- 數據怎么送進編碼器?
- 怎么從編碼器取數據?
- 如何做流控?
在開始之前,我們先了解一下 MediaCodec 的基本知識。
MediaCodec 基礎
Developer 官網 上的描述已經很清楚了,下面簡要總結一下。
首先是工作流程:
生產者不斷把輸入數據送進 codec,消費者則不斷消費 codec 的輸出數據。
接下來是調用流程:
- 選擇編碼器:根據 mimeType 和 colorFormat,以及是否為編碼器,選擇出一個 MediaCodecInfo;
- 創建編碼器:MediaCodec.createByCodecName(codecInfo.getName());
- 對於 API 21 以上的系統,我們可以選擇異步消費輸出:mVideoCodec.setCallback;
- 配置編碼器:設置各種編碼器參數(MediaFormat),再調用 mVideoCodec.configure(文檔也沒有明確說 setCallback 應該在 configure 之前,但既然示例是這樣寫的,我們還是保持這樣好了,畢竟相機采集也是踩過坑了的);
- 對於 API 19 以上的系統,我們可以選擇 Surface 輸入:mVideoCodec.createInputSurface;
- 啟動編碼器:mVideoCodec.start;
- 輸入數據到編碼器:輸入數據到來時,Surface 輸入模式下(提前用 Surface 創建 EGLSurface),調用 GLES API 繪制,最后 eglSwapBuffers 即可;普通模式下我們需要 dequeueInputBuffer、填入數據、queueInputBuffer;
- 消費編碼器輸出數據:異步模式下,我們在 onOutputBufferAvailable 中使用 buffer 內的數據,然后 releaseOutputBuffer 即可;同步模式下我們需要 dequeueOutputBuffer、使用 buffer 內的數據、releaseOutputBuffer;
- 停止並銷毀編碼器:先告知編碼器我們要結束編碼,Surface 輸入時調用 mVideoCodec.signalEndOfInputStream,普通輸入則可以為在 queueInputBuffer 時指定 MediaCodec.BUFFER_FLAG_END_OF_STREAM 這個 flag;告知編碼器后我們就可以等到編碼器輸出的 buffer 帶着 MediaCodec.BUFFER_FLAG_END_OF_STREAM 這個 flag 了,等到之后我們調用 mVideoEncoder.release 銷毀編碼器;
簡單了解了 MediaCodec 基礎之后,我們就可以開始看看 WebRTC 是怎么做硬編碼的了。
數據怎么送進編碼器?
WebRTC 的硬編碼封裝在 MediaCodecVideoEncoder 類中,只看 Java 代碼我們會發現這個類的方法基本都是 package private 的,而且並沒有被其他類調用過,一開始我也很疑惑,但當我開始看 native 代碼的時候就發現了蹊蹺。
原來對 MediaCodecVideoEncoder 接口的調用都發生在 native 層,就在 webrtc/sdk/android/src/jni/androidmediaencoder_jni.cc 這個文件中。Java 調用 native 代碼想必大家都知道,但 native 代碼如何調用 Java 代碼?搞懂這個文件里的代碼我們就能熟練掌握這個技能。
那我們就可以看看 MediaCodecVideoEncoder 的代碼了:
- 初始化編碼器、EGL 環境:initEncode,其中會選擇合適的編碼器配置並啟動,使用 Surface 模式輸入時,我們會創建 EglBase14 和 GlRectDrawer,用於把 texture 數據繪制到編碼器的輸入 Surface 中;
- 輸入 texture 數據到編碼器:encodeTexture,其中的代碼比較簡單,GL 繪制 drawer.drawOes,交換 buffer eglBase.swapBuffers;但這里有一個 checkKeyFrameRequired 調用,我們會在流控部分展開講;
- 輸入內存數據到編碼器:dequeueInputBuffer 和 encodeBuffer,其中的代碼和官方樣例沒什么區別;
怎么從編碼器取數據?
消費輸出數據也基本和官方樣例一樣:
- dequeueOutputBuffer 取出一幀數據;
- releaseOutputBuffer 歸還輸出 buffer;
但這里有幾點值得一提:
- 通常編碼傳輸時每個關鍵幀頭部都需要帶上編碼配置數據(PPS,SPS),但 MediaCodec 會在首次輸出時專門輸出編碼配置數據,后面的關鍵幀里是不攜帶這些數據的,所以需要我們手動做一個拼接;
- 這里調用 mediaCodec.dequeueOutputBuffer 時第二個參數 timeout 傳的是 0,表示不會等待,由於這里並沒有一個單獨的線程不停調用,所以這樣沒什么問題,反倒可以防止阻塞,但如果我們單獨起了一個線程專門取輸出數據,那這就會導致 CPU 資源的浪費了,可以加上一個合適的值,例如 3~10ms;
如何做流控?
首先我們要對流控有一個基本的概念。
流控基本概念
流控就是流量控制。這里我們舉兩個例子:TCP 和視頻編碼。對 TCP 來說就是控制單位時間內發送數據包的數據量,對編碼來說就是控制單位時間內輸出數據的數據量。為什么要控制?就是為了在一定的限制條件下,收益最大化。
TCP 傳輸的限制條件是網絡帶寬,流控就是在避免造成或者加劇網絡擁塞的前提下,盡可能利用網絡帶寬。帶寬夠、網絡好,我們就加快速度發送數據包,出現了延遲增大、丟包之后,就放慢發包的速度(因為繼續高速發包,可能會加劇網絡擁塞,反而發得更慢)。
視頻編碼的限制條件最初是解碼器的能力,碼率太高就會無法解碼,后來隨着 codec 的發展,解碼能力不再是瓶頸,限制條件變成了傳輸帶寬/文件大小,我們希望在控制數據量的前提下,畫面質量盡可能高。
一般編碼器都可以設置一個目標碼率,但編碼器的實際輸出碼率不會完全符合設置,因為在編碼過程中實際可以控制的並不是最終輸出的碼率,而是編碼過程中的一個量化參數(Quantization Parameter,QP),它和碼率並沒有固定的關系,而是取決於圖像內容。這一點不在這里展開,感興趣的朋友可以閱讀視頻壓縮編碼和音頻壓縮編碼的基本原理。
無論是要發送的 TCP 數據包,還是要編碼的圖像,都可能出現“尖峰”,也就是短時間內出現較大的數據量。TCP 面對尖峰,可以選擇不為所動(尤其是網絡已經擁塞的時候),這沒有太大的問題,但如果視頻編碼也對尖峰不為所動,那圖像質量就會大打折扣了。因為如果有幾幀數據量特別大,但我們仍要把碼率控制在原來的水平,那勢必要損失更多的信息,因此圖像失真就會更嚴重,通常的表現是畫面出現很多小方塊,看上去像是打了馬賽克一樣,我們稱之為“方塊效應”:
安卓硬編碼流控
MediaCodec 流控相關的接口並不多,一是配置時設置目標碼率和碼率控制模式,二是動態調整目標碼率(API 19+)。
配置時指定目標碼率和碼率控制模式:
mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE,bitRate);mediaFormat.setInteger(MediaFormat.KEY_BITRATE_MODE,MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR);// 其他配置mVideoCodec.configure(mediaFormat,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE);
碼率控制模式在 MediaCodecInfo.EncoderCapabilities 類中定義了三種,在 framework 層有另一套名字和它們的值一一對應:
- CQ 對應於 OMX_Video_ControlRateDisable,它表示完全不控制碼率,盡最大可能保證圖像質量;
- CBR 對應於 OMX_Video_ControlRateConstant,它表示編碼器會盡量把輸出碼率控制為設定值,即我們前面提到的“不為所動”;
- VBR 對應於 OMX_Video_ControlRateVariable,它表示編碼器會根據圖像內容的復雜度(實際上是幀間變化量的大小)來動態調整輸出碼率,圖像復雜則碼率高,圖像簡單則碼率低;
動態調整目標碼率:
Bundleparam=newBundle();param.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE,bitrate);mediaCodec.setParameters(param);
API 是很簡單,但我們究竟該用哪種模式?調整碼率在各個模式下是否有用,效果如何?接下來就讓我們探討一下這些問題。
MediaCodec 流控測試
首先我們考慮一下如何測試,而測試最關鍵的部分就是控制變量了。大家可能會想到通過相機采集數據后送進編碼器,但相機采集的問題就在於每次測試采進來的內容肯定是不一樣的,那結果的差異就不能排除內容差異的干擾。所以我們需要一個內容不變的數據源,視頻文件就是一個很好的選擇:使用同一個視頻文件,用 MediaExtractor 提取視頻數據,解碼器解碼后把數據送進編碼器,再按照同樣的調整碼率策略,對比編碼器輸出碼率。
此外,我們可以讓解碼器把數據輸出到一個 SurfaceTexture,並在 SurfaceTexture 的回調中讓數據花開兩枝,一枝到預覽,一枝到編碼器,這樣我們就可以一邊測試,一遍欣賞視頻內容了 :)
測試的大體思路就是這樣,實現過程的細節這里就不贅述了,測試項目的源碼可以從 GitHub 獲取,項目里有彩蛋 :)
下面我們看看各個模式調整碼率的效果對比:
紅色折線是統計的每秒輸出碼率,藍色圓點是調整碼率的操作,從上到下依次是 CQ、VBR、CBR。可以看到,CQ 模式下輸出碼率和設置的目標碼率確實沒什么關系,而 VBR 和 CBR 輸出碼率基本都緊跟目標碼率,但明顯 CBR 更穩,且 VBR 下調碼率會出現“血崩”的情況。
如果解碼器支持碼率波動較大,顯然輸出碼率隨視頻內容波動是更好的選擇,因為這樣能提升整體畫質,但 VBR 輸出碼率的走勢真的是在跟隨圖像復雜度(幀間變化量)的嗎?這個問題留着以后探究。
接下來我們看看各個模式下不調整碼率時的輸出碼率:
從上到下依次是 CQ、VBR、CBR,可以看到,CBR 確實很穩。
而對比調整碼率和不調整碼率 CQ 的結果,我們可以發現 CQ 模式下調整碼率不起作用,這符合預期,因為 CQ 的定義就是如此。雖然 MediaFormat 里面有一個隱藏的 KEY_QUALITY,文檔表明是搭配 CQ 使用的,但在 Nexus 5X 7.1.2 上實測,修改 quality 不會影響輸出碼率。
更多測試結果,可以從 GitHub 項目中獲取。
回到前面的問題,我們究竟應該用哪種模式?
- 對於質量要求高、不在乎帶寬(例如本地存文件)、解碼器支持碼率劇烈波動的情況,顯然 CQ 是不二之選;
- VBR 輸出碼率會在一定范圍內波動,對於小幅晃動,方塊效應會有所改善,但對劇烈晃動仍無能為力,而連續調低碼率則會導致碼率急劇下降,如果無法接受這個問題,那 VBR 就不是好的選擇;
- WebRTC 使用的是 CBR,穩定可控是 CBR 的優點,一旦穩定可控,那我們就可以自己實現比較可靠的控制了;
- VBR 在畫面內容保持靜止時,碼率會降得很低,一旦畫面內容開始動起來,碼率上升會跟不上,就會導致畫面質量很差;
- VBR 上調碼率后,有可能導致中間網絡路徑的丟包/延遲增加,進而導致問題;
- CBR 會存在關鍵幀后的幾幀內容模糊的問題,如果關鍵幀間隔較短,可以觀察到明顯的「呼吸效應」;
- WebRTC 使用的方案是 CBR + 長關鍵幀間隔,這樣「呼吸效應」就不是那么明顯,而 CBR 確實能增強畫面質量;
前兩點援引自 Twitch blog。
當然在編寫這個測試項目的過程中,也是遇到了幾個小問題的:
- 主線程創建 extractor、decoder、encoder,子線程使用,extractor/decoder 會拋 IllegalStateException,創建和使用在同一個線程就沒問題(主線程或子線程都可以);
- decoder 直接輸出到 encoder 的 Surface,編出來的視頻是花屏,原因是 encoder 的輸出尺寸和視頻尺寸不一樣,把輸出尺寸改為和視頻尺寸一樣就可以了;但 decoder -> SurfaceTexture -> encoder,中間加入一次 OpenGL 繪制,輸出尺寸不同就沒問題;
- 調整碼率后 encoder 輸出碼率直降為 1/10,死活沒想明白怎么回事,在 StackOverflow 上面一問,還真有人幫我把代碼看出問題了,原來是調整碼率時,單位用得不對,忘記 * 1000 了,還好不是啥坑 :)
關鍵幀
MediaCodec 有兩種方式觸發輸出關鍵幀,一是由配置時設置的 KEY_FRAME_RATE 和 KEY_I_FRAME_INTERVAL 參數自動觸發,二是運行過程中通過 setParameters 手動觸發輸出關鍵幀。
自動觸發實際是按照幀數觸發的,例如設置幀率為 25 fps,關鍵幀間隔為 2s,那就會每 50 幀輸出一個關鍵幀,一旦實際幀率低於配置幀率,那就會導致關鍵幀間隔時間變長。由於 MediaCodec 啟動后就不能修改配置幀率/關鍵幀間隔了,所以如果希望改變關鍵幀間隔幀數,就必須重啟編碼器。
手動觸發輸出關鍵幀:
Bundleparam=newBundle();param.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME,0);mediaCodec.setParameters(param);
對於 H.264 編碼,WebRTC 設置的關鍵幀間隔時間為 20s,顯然僅靠自動觸發是不可能的,因此它會根據實際輸出幀的情況,決定何時手動觸發輸出一個關鍵幀,也就是前面提到的 checkKeyFrameRequired 函數了。而這樣做的原因,就是更可控,這和碼率模式使用 CBR 是一個道理。
WebRTC H.264 編碼時,關鍵幀還真是 20s 一個,那一旦發生了丟包,怎么解碼呢?肯定有其他補救措施,這個問題,也留在之后再探究了。
方塊效應優化
前面我們提到,VBR 的碼率存在一個波動范圍,因此使用 VBR 可以在一定程度上優化方塊效應,但對於視頻內容的劇烈變化,VBR 就只能望洋興嘆了。
WebRTC 的做法是,獲取每個輸出幀的 QP 值,如果 QP 值過大,就說明圖像復雜度太高,如果 QP 值持續超過上界,那就重啟編碼器,用更低的輸出分辨率來編碼;如果 QP 值過低,則說明圖像復雜度太低,如果 QP 值持續低於下界,也會重啟編碼器,用更高的輸出分辨率來編碼。關於 QP 值的獲取,可以查看 WebRTC 相關代碼。
有哪些坑?
- 雖然 mVideoCodec.createInputSurface 從 API 18 就已經引入,但用在某些 API 18 的機型上會導致編碼器輸出數據量特別小,畫面是黑屏,所以 Surface 輸入模式從 API 19 啟用;
- Grafika 的示例中,它是在相機數據回調中先消費輸出,再繪制輸入,這樣會導致每幀的輸出都要等一幀的時間,其實我們可以先繪制輸入,再消費輸出,dequeueOutputBuffer 可以指定一個超時時間,通常 10ms 就足夠絕大部分的幀編完了,這樣我們就可以優化掉這一幀的延遲;當然,這個優化只有當此處成為瓶頸了才有必要,如果網絡傳輸如果還有幾秒的延遲,那這幾十毫秒的優化是沒有任何意義的;
- 如果不正確設置 presentationTime,有些設備的編碼器會丟掉輸入幀,或者輸出幀圖像質量很差,參見 bigflake FAQ #8
- 取出 output buffer 后,要手動設置 position 和 limit,有些設備的編碼器不會設置這兩個值,導致無法正確取出數據;取出 input buffer 后,要手動調用 clear;參見 bigflake FAQ #11
總結
在 WebRTC 源碼導讀系列的前三篇中,我們依次分析了采集、預覽、編碼,在下一篇里,我將對這三塊內容做一個整理,並把 WebRTC 中相關的 Java 代碼剝離出來,形成一個可以單獨使用的模塊:VideoCRE(Capture, Render, Encode),以及分享在這個過程中針對內存抖動問題做的一系列優化。敬請期待 :)
參考文章
https://blog.piasy.com/2017/08/08/WebRTC-Android-HW-Encode-Video/