音视频 ijkplayer源码解析系列2--如何解码图像

381 阅读7分钟

整个系列的文档链接如下,贴出来方便大家直接跳转:

音视频 ijkplayer 源码解析系列1--播放器介绍

音视频 ijkplayer源码解析系列2--如何解码图像

音视频ijkplayer源码解析系列3--解码流程

音视频ijkplayer源码解析系列4--ijkplayer里面如何使用SDL渲染

音视频ijkplayer源码解析系列5--初始化源码拆解

# 音视频ijkplayer源码解析系列6--pipeline和node解析

考虑到ijkplayer里面播放流程相对复杂,我们就先从如何从一个视频文件(比如MP4格式),解码拿到一帧一帧的图像的流程开始,逐渐展开讲解ijkplayer。

接下来我们先不考虑ijkplayer,单纯从ffmpeg原生api的角度考虑,如何解码一个视频文件得到图像资源。我之前写了一个小的demo工程,可以参考:示例Demo,注意分支为:feature/ffmpeg-player-demo,直接查看ryan_ffmepg_player.c这个文件即可。

视频流的处理流程为:(MP4文件)解协议->解封装->视频解码->颜色空间转换->渲染。可以参考下面这张图(图片出处

图像解码流程2.png

结合上图我们开始讲解如何解码得到每一帧图像

1、解封装流程

解封装(Demuxing)的概念是什么?就是将输入的封装格式的数据,分离成为音频流压缩编码数据和视频流压缩编码数据。例如FLV 格式的数据,经过解封装操作后,输出 H.264编码的视频码流和 AAC 编码的音频码流。

  1. 首先我们得先通过avformat_alloc_context创建封装格式上下文对象AVFormatContext

  2. 然后借助avformat_open_input我们打开输入文件,开始解封装

  3. 解封装以后形成的多个stream流数据(比如音频、视频、字幕流)就会被存储到我们在步骤1里创建封装格式上下文对象AVFormatContext里了

  4. 这时候借助avformat_find_stream_info函数,我们就可以拿到AVFormatContext里相关的视频信息了

  5. 最后在借助av_find_best_stream,从AVFormatContext里面找到我们需要的流数据。比如我们实例需要图像数据其代码就是

    int videoIndex = av_find_best_stream(pFormatCtx, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0);
    

    这样我们就取到了所需要视频流的index

结果上述流程我们不仅生成了包含了音频文件信息的AVFormatContext,更是拿到了所需要流的index。接下来我们就该初始化这个视频流对应的解码器了。

2、 初始化解码器

借助在刚才的流程中拿到的视频流index,我们就可以拿到视频流中的解码器参数

AVCodecParameters *pCodecpar = pFormatCtx->streams[videoIndex]->codecpar;

接着从解码器参数中的编码id查到对应的解码器

AVCodec *pCodec = avcodec_find_decoder(pCodecpar->codec_id);  //寻找合适的解码器

紧接着我们需要开始构造解码器的上下文,否则是无法正常初始化的。流程主要就是先创建上下文,然后用之前拿到的解码器参数初始化上下文

AVCodecContext *pCodecCtx = avcodec_alloc_context3(*pCodec); //为AVCodecContext分配内存
avcodec_parameters_to_context(*pCodecCtx, pCodecpar) < 0 //使用解码器参数初始化上下文

此时我们就可以打开解码器了

avcodec_open2(*pCodecCtx, *pCodec, NULL)

那么此时恭喜你,我们成功打开了解码器,要可以开始正式的解码图像了

3、解码

观察解码用到的函数:

int av_read_frame(AVFormatContext *s, AVPacket *pkt);

我们可以发现,解码函数需要两个入参,第一个参数就是我们在解封装流程中创建的封装格式上下文,第二个参数就是解码的结束一个Packet(音频数据都是以包(Packet)的形式来传递的),不了解Packet是什么的同学可以回去看我们的第一章。

AVFormatContext我们已经有了,我们还需要一个接受解码结果的AVPacket的对象,所以我们首先需要先创建AVPacket的对象

AVPacket *packet = (AVPacket *) av_malloc(sizeof(AVPacket));

解码的流程其实就是while循环不停的执行av_read_frame,拿到数据包Packet。下面是完整的解码代码,大家可以先看下,紧接着我们就来详细的拆解这个流程。

while (av_read_frame(pFormatCtx, packet) >= 0) {
    //只要视频压缩数据(根据流的索引位置判断)
        if (packet->stream_index == videoIndex) {
    //7.解码一帧视频压缩数据,得到视频像素数据
            ret = avcodec_send_packet(pCodecCtx, packet);
            if (ret < 0) {
                LOGE("%s", "解码错误");
                return;
            }
            got_picture = avcodec_receive_frame(pCodecCtx, pFrame);
​
            LOGE("%s%d", "got_picture:", got_picture);
            }
        }
​
    //释放资源
        av_packet_unref(packet);
}

一个Packet数据包其实是有多个类型的数据的,比如视频、音频,我们需要从中拿到对应的视频数据。在我们拿到一帧packet的时候,是需要根据数据类型分别来做处理的,在我们这个场景下我们只处理图像的,所以会使用if (packet->stream_index == videoIndex)先前置判断当前数据是视频数据。

这样我们就拿到了视频的Packet,紧接着就是解码流程了

ret = avcodec_send_packet(pCodecCtx, packet);
if (ret < 0) {
LOGE("%s", "解码错误");
return;
}
got_picture = avcodec_receive_frame(pCodecCtx, pFrame);

avcodec_send_packet告诉ffmpeg你可以帮我用这个pCodecCtx对应的解码器上下文,解码packet这个数据包了。这时候ffmpeg就开始帮你解码了。

解码成功以后,我们继续调用avcodec_receive_frame,从解码器中拿出解码出来的一帧视频帧。

从这里我们又发现了有需要引入一个新的对象AVFrame,其实就是一帧音频或者视频图像,只需要在刚才我们创建packet对象的时候,一起创建下AVFrame对象即可AVFrame *pFrame = av_frame_alloc();

至此我们完成了解码,但是解码本身肯定是为了处理或者显示用的,我们可以浅浅的讲其中一个场景:“如何将图像展示在屏幕上”

4、如何将图像展示在屏幕上

我们解码出来的视频格式往往和我们需要的视频格式是不一致的,就比如在android上我们从java层拿到的展示view窗口ANativeWindow_Buffer他需要的图像格式是RGBA8888的格式,那么我们需要将解码出来的frame进行颜色空间的转换,才能正常显示出来。

刚才解码出frame的while循环里,我们可以执行一下的代码

// 视频缓冲区
ANativeWindow_Buffer native_outBuffer;
//为0说明解码完成,非0正在解码
if (!got_picture) {
  // 绘制之前配置nativewindow 设置窗口buffer的大小和格式
  ANativeWindow_setBuffersGeometry(nativeWindow,pCodecCtx->width,pCodecCtx->height,WINDOW_FORMAT_RGBA_8888);
  //上锁
  ANativeWindow_lock(nativeWindow, &native_outBuffer, NULL);
​
  //AVFrame转为像素格式RGBA8888,宽高
  //2 6输入、输出数据
  //3 7输入、输出画面一行的数据的大小 AVFrame 转换是一行一行转换的
  //4 输入数据第一列要转码的位置 从0开始
  //5 输入画面的高度
  sws_scale(sws_ctx, frame->data, frame->linesize, 0, frame->height,
  rgb_frame->data, rgb_frame->linesize);
​
  // rgb_frame是有画面数据
  uint8_t *dst= (uint8_t *) native_outBuffer.bits;
  // 拿到一行有多少个字节 RGBA
  int destStride= native_outBuffer.stride*4;
  // RGBA像素数据的首地址
  uint8_t* src= rgb_frame->data[0];
  // RGBA实际内存一行数量
  int srcStride = rgb_frame->linesize[0];
  for (int i = 0; i < pCodecCtx->height; ++i) {
    // 将rgb_frame中每一行的数据复制给nativewindow
    // memcpy函数一定要报这个dst和src的一行数据大小是一致,不然会直接crash
    memcpy(dst + i * destStride,  src + i * srcStride, srcStride);
  }
  // 解锁
  ANativeWindow_unlockAndPost(nativeWindow);
  usleep(1000 * 16);
​
  frame_count++;
  LOGI("解码第%d帧, 文件大小%d, frame->linesize: %d, rgb_frame->linesize[0]: %d", frame_count, sizeof(dst), frame->linesize[0], rgb_frame->linesize[0]);
  }
}

我们来抽象下整个流程帮助大家理解:

  1. 设置ANativeWindow的大小、格式并加锁

    1. 颜色空间转换吧图像格式转换成ANativeWindow的格式
    2. 转换出来的数据写入ANativeWindow的数据缓冲区
  2. 绘制完成并解锁,此时刷新数据缓冲区出发绘制