MediaCodeC解碼視訊指定幀,迅捷、精確

AiLo發表於2019-03-03

原創文章,轉載請聯絡作者

若待明朝風雨過,人在天涯!春在天涯

原文地址

提要

最近在整理硬編碼MediaCodec相關的學習筆記,以及程式碼文件,分享出來以供參考。本人水平有限,專案難免有思慮不當之處,若有問題可以提Issues專案地址傳送門
此篇文章,主要是分享如何用MediaCodeC解碼視訊指定時間的一幀,回撥Bitmap物件。之前還有一篇MediaCodeC硬解碼視訊,並將視訊幀儲存為圖片檔案,主要內容是將視訊完整解碼,並儲存為JPEG檔案,大家感興趣可以去看一看。

如何使用

VideoDecoder2上手簡單直接,首先需要建立一個解碼器物件:

val videoDecoder2 = VideoDecoder2(dataSource)
複製程式碼

dataSoure就是視訊檔案地址

解碼器會在物件建立的時候,對視訊檔案進行分析,得出時長、幀率等資訊。有了解碼器物件後,在需要解碼幀的地方,直接呼叫函式:

videoDecoder2.getFrame(time, { it->
					//成功回撥,it為對應幀Bitmap物件
                  
                }, {
                 //失敗回撥
              })
                
複製程式碼

time 接受一個Float數值,級別為秒

getFrame函式式一個非同步回撥,會自動回撥到主執行緒裡來。同時這個函式也沒有過度呼叫限制。也就是說——,你可以頻繁呼叫而不用擔心出現其他問題。

程式碼結構、實現過程

程式碼結構

VideoDecoder2目前只支援硬編碼解碼,在某些機型或者版本下,可能會出現相容問題。後續會繼續補上軟解碼的功能模組。
先來看一下VideoDecoder2的程式碼框架,有哪些類構成,以及這些類起到的作用。

MediaCodeC解碼視訊指定幀,迅捷、精確
VideoDecoder2中,DecodeFrame承擔著核心任務,由它發起這一幀的解碼工作。獲取了目標幀的YUV資料後;由GLCore來將這一幀轉為Bitmap物件,它內部封裝了OpenGL環境的搭建,以及配置了Surface供給MediaCodeC使用。
FrameCache主要是做著快取的工作,內部有記憶體快取LruCache以及磁碟快取DiskLruCache,因為快取的存在,很大程度上提高了二次讀取的效率。

工作流程

VideoDecoder2的工作流程,是一個線性任務佇列序列的方式。其工作流程圖如下:

MediaCodeC解碼視訊指定幀,迅捷、精確
具體流程:

  • 1.當執行getFrame函式時,首先從快取從獲取這一幀的圖片快取。
  • 2.如果快取中沒有這一幀的快取,那麼首先判斷任務佇列中正在執行的任務是否和此時需要的任務重複,如果不重複,則建立一個DecodeFrame任務加入佇列。
  • 3.任務佇列的任務是在一個特定的子執行緒內,線性執行。新的任務會被加入佇列尾端,而已有任務則會被提高優先順序,移到佇列中index為1的位置。
  • 4、DecodeFrame獲取到這一幀的Bitmap後,會將這一幀快取為記憶體快取,並在會在快取執行緒內作磁碟快取,方便二次讀取。

接下來分析一下,實現過程中的幾個重要的點。

實現過程

  • 如何定位和目標時間戳相近的取樣點
  • 如何使用MediaCodeC獲取視訊特定時間幀
  • 快取是如何工作,起到的作用有哪些
定位精確幀

精確其實是一個相對而言的概念,MediaExtractorseekTo函式,有三個可供選擇的標記:SEEK_TO_PREVIOUS_SYNC, SEEK_TO_CLOSEST_SYNC, SEEK_TO_NEXT_SYNC,分別是seek指定幀的上一幀,最近幀和下一幀。
其實,seekTo並無法每次都準確的跳到指定幀,這個函式只會seek到目標時間的最接近的(CLOSEST)、上一幀(PREVIOUS)和下一幀(NEXT)。因為視訊編碼的關係,解碼器只會從關鍵幀開始解碼,也就是I幀。因為只有I幀才包含完整的資訊。而P幀和B幀包含的資訊並不完全,只有依靠前後幀的資訊才能解碼。所以這裡的解決辦法是:先定位到目標時間的上一幀,然後advance,直到讀取的時間和目標時間的差值最小,或者讀取的時間和目標時間的差值小於幀間隔

val MediaFormat.fps: Int
    get() = try {
        getInteger(MediaFormat.KEY_FRAME_RATE)
    } catch (e: Exception) {
        0
    }

/*
    * 
    * return : 每一幀持續時間,微秒
    * */
    val perFrameTime by lazy {
        1000000L / mediaFormat.fps
    }

/*
    * 
    * 查詢這個時間點對應的最接近的一幀。
    * 這一幀的時間點如果和目標時間相差不到 一幀間隔 就算相近
    * 
    * maxRange:查詢範圍
    * */
    fun getValidSampleTime(time: Long, @IntRange(from = 2) maxRange: Int = 5): Long {
        checkExtractor.seekTo(time, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
        var count = 0
        var sampleTime = checkExtractor.sampleTime
        while (count < maxRange) {
            checkExtractor.advance()
            val s = checkExtractor.sampleTime
            if (s != -1L) {
                count++
                // 選取和目標時間差值最小的那個
                sampleTime = time.minDifferenceValue(sampleTime, s)
                if (Math.abs(sampleTime - time) <= perFrameTime) {
                    //如果這個差值在 一幀間隔 內,即為成功
                    return sampleTime
                }
            } else {
                count = maxRange
            }
        }
        return sampleTime
    }
複製程式碼

幀間隔其實就是:1s/幀率

使用MediaCodeC解碼指定幀

獲取到相對精確的取樣點(幀)後,接下來就是使用MediaCodeC解碼了。首先,使用MediaExtractorseekTo函式定位到目標取樣點。

mediaExtractor.seekTo(time, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
複製程式碼

然後MediaCodeCMediaExtractor讀取的資料壓入輸入佇列,不斷迴圈,直到拿到想要的目標幀的資料。

/*
* 持續壓入資料,直到拿到目標幀
* */
private fun handleFrame(time: Long, info: MediaCodec.BufferInfo, emitter: ObservableEmitter<Bitmap>? = null) {
    var outputDone = false
    var inputDone = false
    videoAnalyze.mediaExtractor.seekTo(time, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
    while (!outputDone) {
        if (!inputDone) {
            decoder.dequeueValidInputBuffer(DEF_TIME_OUT) { inputBufferId, inputBuffer ->
                val sampleSize = videoAnalyze.mediaExtractor.readSampleData(inputBuffer, 0)
                if (sampleSize < 0) {
                    decoder.queueInputBuffer(inputBufferId, 0, 0, 0L,
                            MediaCodec.BUFFER_FLAG_END_OF_STREAM)
                    inputDone = true
                } else {
                    // 將資料壓入到輸入佇列
                    val presentationTimeUs = videoAnalyze.mediaExtractor.sampleTime
                    Log.d(TAG, "${if (emitter != null) "main time" else "fuck time"} dequeue time is $presentationTimeUs ")
                    decoder.queueInputBuffer(inputBufferId, 0,
                            sampleSize, presentationTimeUs, 0)
                    videoAnalyze.mediaExtractor.advance()
                }
            }
  
        decoder.disposeOutput(info, DEF_TIME_OUT, {
            outputDone = true
        }, { id ->
            Log.d(TAG, "out time ${info.presentationTimeUs} ")
            if (decodeCore.updateTexture(info, id, decoder)) {
                if (info.presentationTimeUs == time) {
                    // 遇到目標時間幀,才生產Bitmap
                    outputDone = true
                    val bitmap = decodeCore.generateFrame()
                    frameCache.cacheFrame(time, bitmap)
                    emitter?.onNext(bitmap)
                }
            }
        })
    }
    decoder.flush()
}
複製程式碼

需要注意的是,解碼的時候,並不是壓入一幀資料,就能得到一幀輸出資料的。
常規的做法是,持續不斷向輸入佇列填充幀資料,直到拿到想要的目標幀資料。
原因還是因為視訊幀的編碼,並不是每一幀都是關鍵幀,有些幀的解碼必須依靠前後幀的資訊。

快取
  • LruCache,記憶體快取
  • DiskLruCache

LruCache自不用多說,磁碟快取使用的是著名的DiskLruCache。快取在VideoDecoder2中佔有很重要的位置,它有效的提高了解碼器二次讀取的效率,從而不用多次解碼以及使用OpenGL繪製。

之前在Oppo R15的測試機型上,進行了一輪解碼測試。
使用MediaCodeC解碼一幀到到的Bitmap,大概需要100~200ms的時間。
而使用磁碟快取的話,讀取時間大概在50~60ms徘徊,效率增加了一倍。

在磁碟快取使用的過程中,有對DiskLruCache進行二次封裝,內部使用單執行緒佇列形式。進行磁碟快取,對外提供了非同步和同步兩種方式獲取快取。可以直接搭配DiskLruCache使用——DiskCacheAssist.kt

總結

到目前為止,視訊解碼的部分已經完成。上一篇是對視訊完整解碼並儲存為圖片檔案,MediaCodeC硬解碼視訊,並將視訊幀儲存為圖片檔案,這一篇是解碼指定幀。音視訊相關的知識體系還很大,會繼續學習下去。

結語

此處有專案地址,點選傳送

相關文章