天天看點

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

上篇回顧

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

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

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

代碼運作log分析

首先點選第一個item:

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

進入到這個界面:

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

看下此時的Log:

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

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

@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解析——從豬肉餐館的故事講起(二)畫的整體流程圖麼:

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

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

C++學習資料免費擷取方法:關注音視訊開發T哥,+「連結」即可免費擷取2023年最新C++音視訊開發進階獨家學習資料!

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

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

首先輸出了這條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選中媒體軌道的時候列印的,列印出具體目前視訊軌道格式相關資訊:

/**
 * 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:指的是視訊的顔色格式,

/**
 * 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 Range。Full 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,看下谷歌文檔的常量值說明:

/** 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:

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

第一行是這裡列印的:

//拿到可用的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:android.media.MediaCodec$BufferInfo@fcbc6e2
D/fuyao-Grafika: no output from decoder available

這條log來源:

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端拿到了資料:

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

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

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

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

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

ecoderOutputBuffers.length:8是我專門把output數組數量列印出來:

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:android.media.MediaCodec$BufferInfo@9bec00c
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個頂點在整個視訊幀的坐标位置。

有讀者可能會問,視訊不是充滿一幀麼?其實不是的,看下官網的解讀 developer.android.google.cn/reference/a… :

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個值計算視訊有效區域的代碼:

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 顔色空間)

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

比如一個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:

//将輸出buffer數組的第outputBufferIndex個buffer繪制到surface。doRender為true繪制到配置的surface
decoder.releaseOutputBuffer(outputBufferIndex, doRender);           

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

output EOS

當調用:

int outputBufferIndex = decoder.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC);           

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

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的狀态機麼:

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

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

将解碼輸出資料儲存下來

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

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

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的地方調用該方法:

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);           

再運作下程式,得到以下圖檔:

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

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

同步與異步模式

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

對于異步來說,MediaCodec的工作狀态和同步有一點不同:

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

異步的情況下從Configured會直接進入Running狀态,然後等待MediaCodec的回調通知再處理資料即可,以下為官方給的代碼模闆:

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了。

作者:半島鐵盒裡的貓 連結:https://juejin.cn/post/7113767096512675870 來源:稀土掘金 著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。

在開發的路上你不是一個人,歡迎加入C++音視訊開發交流群大家庭正在跳轉讨論交流

#程式員##C++##音視訊開發#