Android硬编解码工具MediaCodec解析——从猪肉餐馆的故事讲起(三)

持续创作,加速成长!这是我参与「掘金日新计划 · 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)”这一行:

@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选中媒体轨道的时候打印的,打印出具体当前视频轨道格式相关信息:

/**
 * 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 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,看下谷歌文档的常量值说明:

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

第一行是这里打印的:

//拿到可用的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端拿到了数据:

1656214626785.png

startup lag是官方demo已经有的统计从第一次在input端放入数据到第一次从output端拿到数据的时间长。

接下来就是取到具体数据的log:

1656214977778.png

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 颜色空间

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:

//将输出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的状态机么:

image.png

先调用了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);

再运行下程序,得到以下图片:

1656245697579.png

可见每一帧都成功截图并保存到本地~~

同步与异步模式

最后说下,MediaCodec编解码是分为同步和异步模式的(Android 5.0开始支持异步状态),同步就是比如生猪肉采购员和顾客必须 在Android硬编解码工具MediaCodec解析——从猪肉餐馆的故事讲起(二)的关于MediaCodec的解码流程代码,是属于同步,所谓的同步,是相对于异步而言的。同步和异步最大的不同,个人认为就是前者是要求我们主动去咨询MeidaCodec有没有可用的Buffer可以用,后者是MeidaCodec来通知我们已经有有了可用的buffer。就像原来是猪肉采购员主动询问厨师有没有空篮子可以用,现在变为厨师发个微信告诉采购员现在有空篮子可以用。

对于异步来说,MediaCodec的工作状态和同步有一点不同:

1656302196828.png

异步的情况下从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了。

原创不易,如果觉得本文对自己有帮助,别忘了随手点赞和关注,这也是我创作的最大动力~

猜你喜欢

转载自juejin.im/post/7113767096512675870