MediaCodeC解码视频指定帧,迅捷、精确

提要

最近在整理硬编码MediaCodec相关的学习笔记,以及代码文档,分享出来以供参考。本人水平有限,项目难免有思虑不当之处,若有问题可以提Issues项目地址传送门
此篇文章,主要是分享如何用MediaCodeC解码视频指定时间的一帧,回调Bitmap对象。之前还有一篇MediaCodeC硬解码视频,并将视频帧存储为图片文件,主要内容是将视频完整解码,并存储为JPEG文件,大家感兴趣可以去看一看。

如何使用

VideoDecoder2上手简单直接,首先需要创建一个解码器对象:

val videoDecoder2 = VideoDecoder2(dataSource)

dataSoure就是视频文件地址

解码器会在对象创建的时候,对视频文件进行分析,得出时长、帧率等信息。有了解码器对象后,在需要解码帧的地方,直接调用函数:

videoDecoder2.getFrame(time, { it->
                    //成功回调,it为对应帧Bitmap对象

                }, {
                 //失败回调
              })

time 接受一个Float数值,级别为秒

getFrame函数式一个异步回调,会自动回调到主线程里来。同时这个函数也没有过度调用限制。也就是说——,你可以频繁调用而不用担心出现其他问题。

代码结构、实现过程

代码结构

VideoDecoder2目前只支持硬编码解码,在某些机型或者版本下,可能会出现兼容问题。后续会继续补上软解码的功能模块。
先来看一下VideoDecoder2的代码框架,有哪些类构成,以及这些类起到的作用。

VideoDecoder2中,DecodeFrame承担着核心任务,由它发起这一帧的解码工作。获取了目标帧的YUV数据后;由GLCore来将这一帧转为Bitmap对象,它内部封装了OpenGL环境的搭建,以及配置了Surface供给MediaCodeC使用。
FrameCache主要是做着缓存的工作,内部有内存缓存LruCache以及磁盘缓存DiskLruCache,因为缓存的存在,很大程度上提高了二次读取的效率。

工作流程

VideoDecoder2的工作流程,是一个线性任务队列串行的方式。其工作流程图如下:

具体流程:

  • 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硬解码视频,并将视频帧存储为图片文件,这一篇是解码指定帧。音视频相关的知识体系还很大,会继续学习下去。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容

  • 教程一:视频截图(Tutorial 01: Making Screencaps) 首先我们需要了解视频文件的一些基...
    90后的思维阅读 4,650评论 0 3
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,084评论 1 32
  • 原文地址 Android MediaCodec stuff 这篇文章是关于 MediaCodec 这一系列类,它主...
    sheepm阅读 68,265评论 17 102
  • MediaCodec的官方文档 一、Android MediaCodec简单介绍 Android中可以使用Medi...
    黄海佳阅读 6,146评论 1 16
  • ### YUV颜色空间 视频是由一帧一帧的数据连接而成,而一帧视频数据其实就是一张图片。 yuv是一种图片储存格式...
    天使君阅读 3,248评论 0 4