Android硬編解碼工具MediaCodec解析——從豬肉餐館的故事講起(三)

語言: CN / TW / HK

theme: smartblue

持續創作,加速成長!這是我參與「掘金日新計劃 · 6 月更文挑戰」的第3天,點選檢視活動詳情

更多博文,請看音影片系統學習的浪漫馬車之總目錄

實踐專案: 介紹一個自己剛出爐的安卓音影片播放錄製開源專案

影片理論基礎:\ 影片基礎知識掃盲\ 音影片開發基礎知識之YUV顏色編碼\ 解析H264影片編碼原理——從孫藝珍的電影說起(一)\ 解析H264影片編碼原理——從孫藝珍的電影說起(二)\ H264碼流結構一探究竟

Android平臺MediaCodec系列:\ Android硬編解碼利器MediaCodec解析——從豬肉餐館的故事講起(一)\ Android硬編解碼工具MediaCodec解析——從豬肉餐館的故事講起(二)
Android硬編解碼工具MediaCodec解析——從豬肉餐館的故事講起(三)

上篇回顧

前面兩篇文章Android硬編解碼利器MediaCodec解析——從豬肉餐館的故事講起(一)Android硬編解碼工具MediaCodec解析——從豬肉餐館的故事講起(二)已經從豬肉餐館的故事帶各位比較詳細地闡述了Android平臺硬解碼工具MediaCodec的工作流程和具體的程式碼,但是前兩篇文章的分析是基於靜態的,那麼今天就讓程式碼“動起來”,通過log和輔助程式碼去更加深入掌握MediaCodec的解碼流程

如果還沒看過前面兩篇博文,還是建議看一下,因為本文和前兩篇是有很大關聯的。

程式碼執行log分析

首先點選第一個item: 1656132293893.png

進入到這個介面:

1656132523846.png

看下此時的Log:

1656132591754.png

log列印位置在com.android.grafika.PlayMovieActivity,主要看下“SurfaceTexture ready (984x1384)”這一行:

java @Override public void onSurfaceTextureAvailable(SurfaceTexture st, int width, int height) { // There's a short delay between the start of the activity and the initialization // of the SurfaceTexture that backs the TextureView. We don't want to try to // send a video stream to the TextureView before it has initialized, so we disable // the "play" button until this callback fires. Log.d(TAG, "SurfaceTexture ready (" + width + "x" + height + ")"); mSurfaceTextureReady = true; updateControls(); }

還記得Android硬編解碼工具MediaCodec解析——從豬肉餐館的故事講起(二)畫的整體流程圖麼:

image.png

onSurfaceTextureAvailable這個回撥方法就是告訴我們,TextureView的SurfaceTexture已經初始化好了,可以開始渲染了。此時才會將播放按鈕置為可點選。log“SurfaceTexture ready (984x1384)” 中的“(984x1384)”即為TextureView的尺寸。

此時,輕輕點選播放按鈕,於是影片開始動起來了,可謂是穿梭時間旳畫面的鐘,從反方向開始移動~:

Screenrecording_20220618_125703 00_00_00-00_00_30.gif

首先輸出了這條log:

D/fuyao-Grafika: Extractor selected track 0 (video/avc): {track-id=1, level=32, mime=video/avc, profile=1, language=``` , color-standard=4, display-width=320, csd-1=java.nio.HeapByteBuffer[pos=0 lim=8 cap=8], color-transfer=3, durationUs=2033333, display-height=240, width=320, color-range=2, max-input-size=383, frame-rate=16, height=240, csd-0=java.nio.HeapByteBuffer[pos=0 lim=38 cap=38]}

它是在MediaExtractor選中媒體軌道的時候列印的,打印出具體當前影片軌道格式相關資訊:

```java /* * Selects the video track, if any. * * @return the track index, or -1 if no video track is found. / private static int selectTrack(MediaExtractor extractor) { // Select the first video track we find, ignore the rest. //當前媒體檔案共有多少個軌道(影片軌道、音訊軌道、字幕軌道等等) int numTracks = extractor.getTrackCount(); for (int i = 0; i < numTracks; i++) { //第i個軌道的MediaFormat MediaFormat format = extractor.getTrackFormat(i); //format對應的mime型別 String mime = format.getString(MediaFormat.KEY_MIME); //找到影片軌道的index if (mime.startsWith("video/")) { if (VERBOSE) { //注意這行的log列印 Log.d(TAG, "Extractor selected track " + i + " (" + mime + "): " + format); } return i; } }

return -1;

} ```

稍微解釋下log中的幾個關鍵引數:

1.log中的level和profile指的是畫質級別,以下解釋引用於# H264編碼profile & level控制

H.264有四種畫質級別,分別是baseline, extended, main, high: \   1、Baseline Profile:基本畫質。支援I/P 幀,只支援無交錯(Progressive)和CAVLC; \   2、Extended profile:進階畫質。支援I/P/B/SP/SI 幀,只支援無交錯(Progressive)和CAVLC;(用的少) \   3、Main profile:主流畫質。提供I/P/B 幀,支援無交錯(Progressive)和交錯(Interlaced), \     也支援CAVLC 和CABAC 的支援; \   4、High profile:高階畫質。在main Profile 的基礎上增加了8x8內部預測、自定義量化、 無損影片編碼和更多的YUV 格式; \ H.264 Baseline profile、Extended profile和Main profile都是針對8位樣本資料、4:2:0格式(YUV)的影片序列。在相同配置情況下,High profile(HP)可以比Main profile(MP)降低10%的位元速率。 \ 根據應用領域的不同,Baseline profile多應用於實時通訊領域,Main profile多應用於流媒體領域,High profile則多應用於廣電和儲存領域。

2.mime為video/avc,這個上篇文章已經講過,video/avc即為H264。

3.color-standard:指的是影片的顏色格式,

```java /* * An optional key describing the color primaries, white point and * luminance factors for video content. * * The associated value is an integer: 0 if unspecified, or one of the * COLOR_STANDARD_ values. / public static final String KEY_COLOR_STANDARD = "color-standard";

/* BT.709 color chromacity coordinates with KR = 0.2126, KB = 0.0722. / public static final int COLOR_STANDARD_BT709 = 1;

/* BT.601 625 color chromacity coordinates with KR = 0.299, KB = 0.114. / public static final int COLOR_STANDARD_BT601_PAL = 2;

/* BT.601 525 color chromacity coordinates with KR = 0.299, KB = 0.114. / public static final int COLOR_STANDARD_BT601_NTSC = 4;

/* BT.2020 color chromacity coordinates with KR = 0.2627, KB = 0.0593. / public static final int COLOR_STANDARD_BT2020 = 6; ```

還記得# 音影片開發基礎知識之YUV顏色編碼 裡面說過,RGB到YUV有不同的轉化標準:

目前一般解碼後的影片格式為yuv,但是一般顯示卡渲染的格式是RGB,所以需要把yuv轉化為RGB。

這裡涉及到 Color Range 這個概念。Color Range 分為兩種,一種是 Full Range,一種是 Limited RangeFull Range 的 R、G、B 取值範圍都是 0~255。而 Limited Range 的 R、G、B 取值範圍是 16~235。

對於每種Color Range來說,還有不同的轉換標準,常見的標準主要是 BT601 和 BT709(BT601 是標清的標準,而 BT709 是高清的標準)。

這裡該影片的color-standard為4,即轉換標準為BT.601 525。

4.color-range: 上面引用部分已經提及,當前color-range為2,看下谷歌文件的常量值說明:

```java /* Limited range. Y component values range from 16 to 235 for 8-bit content. * Cr, Cy values range from 16 to 240 for 8-bit content. * This is the default for video content. / public static final int COLOR_RANGE_LIMITED = 2;

/* Full range. Y, Cr and Cb component values range from 0 to 255 for 8-bit content. / public static final int COLOR_RANGE_FULL = 1; ```

所以當前影片的color-range為Limited range。

其他引數因為數量太多,大家也大部分可以看明白,就不一一解釋了。

看接下來的log:

1656169709805.png

第一行是這裡列印的:

java //拿到可用的ByteBuffer的index int inputBufIndex = decoder.dequeueInputBuffer(TIMEOUT_USEC); //根據index得到對應的輸入ByteBuffer ByteBuffer inputBuf = decoderInputBuffers[inputBufIndex]; Log.d(TAG, "decoderInputBuffers inputBuf:" + inputBuf + ",inputBufIndex:" + inputBufIndex);

列印的是inputBuffer的情況,上一篇已經講過,這裡就如同生豬肉採購員詢問廚師有沒有空籃子,廚師在TIMEOUT_USEC微秒時間內告訴了採購員籃子的編號,然後採購員根據編號找到對應的空籃子。

根據log可以看出:

decoderInputBuffers inputBuf:java.nio.DirectByteBuffer[pos=0 lim=6291456 cap=6291456],inputBufIndex:2

這個空Buffer大小為6291456位元組(pos表示當前操作指標指向的位置,lim表示當前可讀或者可寫的最大數量,cap表示其容量),inputBufIndex為2,即該Buffer在MediaCodec的輸入Buffer陣列的位置是2。

submitted frame 0 to dec, size=339

這個log的frame 0表示MediaExtractor的readSampleData讀取出來的第幾塊資料,在這裡就是第幾幀,size=339表示該幀大小為339位元組,當然這是壓縮的資料大小。

下面一條log輸出端取資料的,即顧客詢問廚師豬肉炒好了沒有:

D/fuyao-Grafika: dequeueOutputBuffer decoderBufferIndex:-1,mBufferInfo:[email protected]

D/fuyao-Grafika: no output from decoder available

這條log來源:

java int outputBufferIndex = decoder.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC); Log.d(TAG, "dequeueOutputBuffer decoderBufferIndex:" + outputBufferIndex + ",mBufferInfo:" + mBufferInfo);

decoderBufferIndex為-1,則等於MediaCodec.INFO_TRY_AGAIN_LATER,即當前輸出端還沒有資料,即廚師告訴顧客,豬肉還沒做好。

如果看過之前我寫的解析H264影片編碼原理——從孫藝珍的電影說起(一)解析H264影片編碼原理——從孫藝珍的電影說起(二),就知道影片編碼是一個非常複雜的過程,涉及大量的數學演算法,所以解碼也不會簡單,基本不會剛放一幀資料到input端,output端就立馬拿到解碼後的資料。

從後面的log可以看到,經過很多次在input端放入資料,又嘗試在output端取出資料的迴圈之後,終於在第一次在input端放入資料的77ms秒之後,在output端拿到了資料:

1656214626785.png

startup lag是官方demo已經有的統計從第一次在input端放入資料到第一次從output端拿到資料的時間長。

接下來就是取到具體資料的log:

1656214977778.png

decoderBufferIndex為0,即取到的解碼資料所在的buffer在output端buffer陣列第0個。

ecoderOutputBuffers.length:8是我專門把output陣列數量打印出來: java ByteBuffer[] decoderOutputBuffers = decoder.getOutputBuffers(); Log.d(TAG, "ecoderOutputBuffers.length:" + decoderOutputBuffers.length);

可見output端buffer陣列大小為8個(經過實踐發現,該數值並不是固定的)。

outputBuffer:java.nio.DirectByteBuffer[pos=0 lim=115200 cap=115200]表示該buffer的可用資料和容量都為115200。後面解碼出來的資料也是這個大小,因為解碼之後的資料就是一幀畫面的yuv資料,因為畫面的解析度固定,yuv格式也是固定,所以大小自然也是一樣的。

而在output拿到資料之前的上一次取資料的log需要注意下:

D/fuyao-Grafika:dequeueOutputBuffer decoderBufferIndex:-2,mBufferInfo:[email protected]

D/fuyao-Grafika: decoder output format changed: {crop-right=319, color-format=21, slice-height=240, image-data=java.nio.HeapByteBuffer[pos=0 lim=104 cap=104], mime=video/raw, stride=320, color-standard=4, color-transfer=3, crop-bottom=239, crop-left=0, width=320, color-range=2, crop-top=0, height=240}

decoderBufferIndex為2,即MediaCodec.INFO_OUTPUT_FORMAT_CHANGED。在拿到資料之後,會現有一個通知輸出資料格式變化的通知,我們可以在這裡拿到輸出資料的格式。

1.crop-left=0,crop-right=319,crop-top=0,crop-bottom=239表示的是真正的影片區域的4個頂點在整個影片幀的座標位置。

有讀者可能會問,影片不是充滿一幀麼?其實不是的,看下官網的解讀 https://developer.android.google.cn/reference/android/media/MediaCodec#accessing-raw-video-bytebuffers-on-older-devices :

The MediaFormat#KEY_WIDTH and MediaFormat#KEY_HEIGHT keys specify the size of the video frames; however, for most encondings the video (picture) only occupies a portion of the video frame. This is represented by the 'crop rectangle'.

You need to use the following keys to get the crop rectangle of raw output images from the output format. If these keys are not present, the video occupies the entire video frame.The crop rectangle is understood in the context of the output frame before applying any rotation.

具體key的意義:

Format Key | Type | Description | | ----------------------------- | ------- | ----------------------------------------------------------- | | MediaFormat#KEY_CROP_LEFT | Integer | The left-coordinate (x) of the crop rectangle | | MediaFormat#KEY_CROP_TOP | Integer | The top-coordinate (y) of the crop rectangle | | MediaFormat#KEY_CROP_RIGHT | Integer | The right-coordinate (x) MINUS 1 of the crop rectangle | | MediaFormat#KEY_CROP_BOTTOM | Integer | The bottom-coordinate (y) MINUS 1 of the crop rectangle

官網又給了一段通過這4個值計算影片有效區域的程式碼:

java  MediaFormat format = decoder.getOutputFormat(…);  int width = format.getInteger(MediaFormat.KEY_WIDTH);  if (format.containsKey(MediaFormat.KEY_CROP_LEFT)         && format.containsKey(MediaFormat.KEY_CROP_RIGHT)) {     width = format.getInteger(MediaFormat.KEY_CROP_RIGHT) + 1                 - format.getInteger(MediaFormat.KEY_CROP_LEFT);  }  int height = format.getInteger(MediaFormat.KEY_HEIGHT);  if (format.containsKey(MediaFormat.KEY_CROP_TOP)         && format.containsKey(MediaFormat.KEY_CROP_BOTTOM)) {     height = format.getInteger(MediaFormat.KEY_CROP_BOTTOM) + 1                  - format.getInteger(MediaFormat.KEY_CROP_TOP);  }

2.color-format:顏色編碼格式。21即為COLOR_FormatYUV420SemiPlanar,也常叫做叫作NV21。關於yuv具體格式在音影片開發基礎知識之YUV顏色編碼 已有敘述,不過文章並沒有具體講NV21,NV21的於半平面格式(semi planner),y獨立放一個數組,uv放一個數組,先V後U交錯存放(圖來自: 淺析 YUV 顏色空間

image.png 比如一個4*4的畫面,分佈如下圖所示:

  1. Y Y Y Y
  2. Y Y Y Y
  3. Y Y Y Y
  4. Y Y Y Y
  5. V U V U
  6. V U V U

3.slice-height:指的是幀的高度,即有多少行,不過這個行數可能是記憶體對齊過的,有時候為了提高讀取速度,影片幀高度會填充到2的次冪數值。

4.stride:跨距,是影象儲存的時候有的一個概念。它指的是影象儲存時記憶體中每行畫素所佔用的空間。 同樣的,這個也是經過記憶體對齊的,所以是大於等於原影片的每行畫素個數。很多影片花屏問題的根源就是忽略了stride這個屬性。

其他引數上面已講過,就不贅述。

拿到輸出的解碼資料就通過releaseOutputBuffer渲染到Surface: java //將輸出buffer陣列的第outputBufferIndex個buffer繪製到surface。doRender為true繪製到配置的surface decoder.releaseOutputBuffer(outputBufferIndex, doRender);

我們看到log的output最後一幀資料是:

output EOS

當呼叫: java int outputBufferIndex = decoder.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC);

得到的mBufferInfo.flags為MediaCodec.BUFFER_FLAG_END_OF_STREAM(intput端在影片最後一幀的時候傳入)的時候,說明該幀已經是影片最後一幀了,此時就跳出解碼的大迴圈,準備釋放資源:

java finally { // release everything we grabbed if (decoder != null) { //Call stop() to return the codec to the Uninitialized state, whereupon it may be configured again. decoder.stop(); decoder.release(); decoder = null; } if (extractor != null) { extractor.release(); extractor = null; } }

還記得Android硬編解碼利器MediaCodec解析——從豬肉餐館的故事講起(一) 提及過得MediaCodec的狀態機麼:

image.png

先呼叫了stop方法,就進入了Uninitialized狀態,即豬肉餐館要收拾桌椅了,收拾完桌椅之後,再呼叫release就釋放資源,即豬肉餐館關門了。

將解碼輸出資料儲存下來

接下來來做一件有趣的事情,就是將每次輸出的解碼資料儲存為圖片。

建立一個方法接收輸出的一幀資料,然後通過系統提供的YuvImage可以將yuv資料轉化為jpeg資料,然後通過BitmapFactory.decodeByteArray將jpeg資料轉化為Bitmap,再儲存到本地資料夾中。

```java private void outputFrameAsPic(byte[] ba, int i) { Log.d(TAG, "outputBuffer i:" + i); YuvImage yuvImage = new YuvImage(ba, ImageFormat.NV21, mVideoWidth, mVideoHeight, null); ByteArrayOutputStream baos = new ByteArrayOutputStream(); //將yuv轉化為jpeg yuvImage.compressToJpeg(new Rect(0, 0, mVideoWidth, mVideoHeight), 100, baos); byte[] jdata = baos.toByteArray();//rgb Bitmap bmp = BitmapFactory.decodeByteArray(jdata, 0, jdata.length); if (bmp != null) { try { File parent = new File(Environment.getExternalStorageDirectory().getAbsolutePath()+"/moviePlayer/"); if (!parent.exists()){ parent.mkdirs(); }

        File myCaptureFile = new File(parent.getAbsolutePath(),String.format("img%s.png", i));
        if (!myCaptureFile.exists()){
            myCaptureFile.createNewFile();
        }
        BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(myCaptureFile));
        bmp.compress(Bitmap.CompressFormat.JPEG, 80, bos);
        Log.d(TAG, "bmp.compress myCaptureFile:" + myCaptureFile.getAbsolutePath());
        bos.flush();
        bos.close();
    } catch (Exception e) {
        e.printStackTrace();
        Log.d(TAG, "outputFrameAsPic Exception:" + e);
    }
}

} ```

然後在每次獲得output端Buffer的地方呼叫該方法:

```java ByteBuffer outputBuffer = decoderOutputBuffers[outputBufferIndex]; Log.d(TAG, "outputBuffer:" + outputBuffer);

outputBuffer.position(mBufferInfo.offset); outputBuffer.limit(mBufferInfo.offset + mBufferInfo.size);

byte[] ba = new byte[outputBuffer.remaining()]; //byteBuffer資料放入ba outputBuffer.get(ba); //輸出的一幀儲存為本地的一張圖片 outputFrameAsPic(ba, decodeFrameIndex); ```

再執行下程式,得到以下圖片:

1656245697579.png

可見每一幀都成功截圖並儲存到本地~~

同步與非同步模式

最後說下,MediaCodec編解碼是分為同步和非同步模式的(Android 5.0開始支援非同步狀態),同步就是比如生豬肉採購員和顧客必須 在Android硬編解碼工具MediaCodec解析——從豬肉餐館的故事講起(二)的關於MediaCodec的解碼流程程式碼,是屬於同步,所謂的同步,是相對於非同步而言的。同步和非同步最大的不同,個人認為就是前者是要求我們主動去諮詢MeidaCodec有沒有可用的Buffer可以用,後者是MeidaCodec來通知我們已經有有了可用的buffer。就像原來是豬肉採購員主動詢問廚師有沒有空籃子可以用,現在變為廚師發個微信告訴採購員現在有空籃子可以用。

對於非同步來說,MediaCodec的工作狀態和同步有一點不同:

1656302196828.png

非同步的情況下從Configured會直接進入Running狀態,然後等待MediaCodec的回撥通知再處理資料即可,以下為官方給的程式碼模板:

java MediaCodec codec = MediaCodec.createByCodecName(name);  MediaFormat mOutputFormat; // member variable  codec.setCallback(new MediaCodec.Callback() {   @Override   void onInputBufferAvailable(MediaCodec mc, int inputBufferId) {     ByteBuffer inputBuffer = codec.getInputBuffer(inputBufferId);     // fill inputBuffer with valid data     …     codec.queueInputBuffer(inputBufferId, …);   }     @Override   void onOutputBufferAvailable(MediaCodec mc, int outputBufferId, …) {     ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);     MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId); // option A     // bufferFormat is equivalent to mOutputFormat     // outputBuffer is ready to be processed or rendered.     …     codec.releaseOutputBuffer(outputBufferId, …);   }     @Override   void onOutputFormatChanged(MediaCodec mc, MediaFormat format) {     // Subsequent data will conform to new format.     // Can ignore if using getOutputFormat(outputBufferId)     mOutputFormat = format; // option B   }     @Override   void onError(…) {     …   }  });  codec.configure(format, …);  mOutputFormat = codec.getOutputFormat(); // option B  codec.start();  // wait for processing to complete  codec.stop();  codec.release();

總結

本文在上一文章分析程式碼的基礎上運行了程式碼,通過分析log分析解碼流程的細節,讓各位對解碼流程有更清晰的認識。並將解碼出來的每幀截圖儲存到本地,驗證了影片解碼的output端每次獲取的資料確實是表示一幀的資料。最後講了一下MediaCodec編解碼非同步模式相關。

美好的時光總是過得很快,不知不覺已經用了三篇博文講MediaCodec了,剩下的編碼部分其實和解碼也差不多,無非是換個豬肉餐館哈哈,我有空再寫寫,因為我已經迫不及待地想進入下一個系列了——OpenGL系列。 因為解碼成功後,就是渲染到螢幕了,而當前Android平臺最主流的渲染工具,就是OpenGL了。

原創不易,如果覺得本文對自己有幫助,別忘了隨手點贊和關注,這也是我創作的最大動力~