前言:对于从未接触过音视频编解码的同学来说,使用FFmpeg的学习曲线恐怕略显陡峭。本人由于工作需要,正好需要在项目中使用。因此特地将开发过程总结下来。只当提供给有兴趣的同学参考和学习。
由于FFmpeg是使用C语言开发,所有和函数调用都是面向过程的。以我目前的学习经验来说,通常我会把一个功能的代码全部放在main函数中实现。经过测试和修改认为功能正常,再以C++面向对象的方式逐步将代码分解和封装。因此在对本套指南中我也会采用先代码实现再功能封装的步骤。
一、开发前的准备工作
开发工具为VS2013+Qt5,目录结构:
- bin:工作和测试目录
- doc:开发文档目录
- include:ffmpeg头文件配置目录
- lib:ffmpeg静态库配置目录
- src:源码目录
属性页配置:
- 常规-输出目录:..\..\bin
- 调试-工作目录:..\..\bin
- C/C++-常规-附加包含目录:..\..\include
- 链接器-常规-附加库目录:..\..\lib
- 链接器-系统-子系统:控制台 (/SUBSYSTEM:CONSOLE)
二、编解码基础知识
(1)封装格式
所谓封装格式是指音视频的组合格式,例如最常见的封装格式有mp4、mp3、flv等。简单来说,我们平时接触到的带有后缀的音视频文件都是一种封装格式。不同的封装格式遵循不同的协议标准。有兴趣的同学可以自行扩展,更深的东西我也不懂。
(2)编码格式
以mp4为例,通常应该包含有视频和音频。视频的编码格式为YUV420P,音频的编码格式为PCM。再以YUV420编码格式为例。我们知道通常图像的显示为RGB(红绿蓝三原色),在视频压缩的时候会首先将代表每一帧画面的RGB压缩为YUV,再按照关键帧(I帧),过渡帧(P帧或B帧)进行运算和编码。解码的过程正好相反,解码器会读到I帧,并根据I帧运算和解码P帧以及B帧。并最终根据视频文件预设的FPS还原每一帧画面的RGB数据。最后推送给显卡。所以通常我们说的编码过程就包括:画面采集、转码、编码再封装。
(3)视频解码和音频解码有什么区别
玩游戏的同学肯定对FPS不陌生,FPS太低画面会感觉闪烁不够连贯,FPS越高需要显卡性能越好。一些高速摄像机的采集速度能够达到11000帧/秒,那么在播放这类影片的时候我们是否也需要以11000帧/秒播放呢?当然不是,通常我们会按照25帧/秒或者60帧/秒设定图像的FPS值。但是由于视频存在关键帧和过渡帧的区别,关键帧保存了完整的画面而过渡帧只是保存了与前一帧画面的变化部分,需要通过关键帧计算获得。因此我们需要对每一帧都进行解码,即获取画面的YUV数据。同时只对我们真正需要显示的画面进行转码,即将YUV数据转换成RGB数据,包括计算画面的宽高等。
但是音频则不然,音频的播放必须和采集保持同步。提高或降低音频的播放速度都会让音质发生变化,这也是变声器的原理。因此在实际开发中为了保证播放的音视频同步,我们往往会按照音频的播放速度来控制视频的解码转码速度。
三、代码实现
(1)注册FFmpeg组件:注册和初始化FFmpeg封装器和网络设备
av_register_all();
avformat_network_init();
avdevice_register_all();
(2)打开文件和创建输入设备
AVFormatContext *pFormatCtx = NULL; int errnum = avformat_open_input(&pFormatCtx, filename, NULL, NULL); if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; }
AVFormatContext 表示一个封装器,在读取多媒体文件的时候,它负责保存与封装和编解码有关的上下文信息。avformat_open_input函数可以根据文件后缀名来创建封装器。
(3)遍历流并初始化解码器
for (int i = 0; i < pFormatCtx->nb_streams; ++i) { AVCodecContext *pCodecCtx = pFormatCtx->streams[i]->codec; // 解码器上下文 if (pCodecCtx->codec_type == AVMEDIA_TYPE_VIDEO) { // 视频通道 int videoIndex = i; // 视频的宽,高 int srcWidth = pCodecCtx->width; int srcHeight = pCodecCtx->height; // 创建视频解码器,打开解码器 AVCodec *codec = avcodec_find_decoder(pCodecCtx->codec_id); if (!codec) { // 无法创建对应的解码器 } errnum = avcodec_open2(pCodecCtx, codec, NULL); if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; } cout << "video decoder open success!" << endl; } if (pCodecCtx->codec_type == AVMEDIA_TYPE_AUDIO) { // 音频通道 int audioIndex = i; // 创建音频解码器,打开解码器 AVCodec *codec = avcodec_find_decoder(pCodecCtx->codec_id); if (!codec) { // 无法创建对应的解码器 } errnum = avcodec_open2(pCodecCtx, codec, NULL); if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; } int sampleRate = pCodecCtx->sample_rate; // 音频采样率 int channels = pCodecCtx->channels; // 声道数 AVSampleFormat fmt = pCodecCtx->sample_fmt; // 样本格式 cout << "audio decoder open success!" << endl; } }
封装器中保存了各种流媒体的通道,通常视频通道为0,音频通道为1。除此以外可能还包含字幕流通道等。
第2步和第3步基本就是打开多媒体文件的主要步骤,解码和转码的所有参数都可以在这里获取。接下来我们就需要循环进行读取、解码、转码直到播放完成。
(4)读取压缩数据:之所以称为压缩数据主要是为了区分AVPacket和AVFrame两个结构体。AVPacket表示一幅经过了关键帧或过渡帧编码后的画面,AVFrame表示一个AVPacket经过解码后的完整YUV画面
AVPacket *pkt = NULL; pkt = av_packet_alloc(); // 初始化AVPacket // 读取一帧数据 errnum = av_read_frame(pFormatCtx, pkt); if (errnum == AVERROR_EOF) { // 已经读取到文件尾 av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; } if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; }
(5)解码
errnum = avcodec_send_packet(pCodecCtx, pkt); if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; } AVFrame *yuv = av_frame_alloc(); AVFrame *pcm = av_frame_alloc(); if (pkt->stream_index == videoIndex) { // 判断当前解码帧为视频帧 errnum = avcodec_receive_frame(pCodecCtx, yuv); // 解码视频 if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; } } if (pkt->stream_index == audioIndex) { // 判断当前解码帧为音频帧 errnum = avcodec_receive_frame(pCodecCtx, pcm); // 解码音频 if (errnum < 0) { av_strerror(errnum, errbuf, sizeof(errbuf)); cout << errbuf << endl; } }
(6)视频转码
// 720p输出标准 int outWidth = 720; int outHeight = 480; char *outData = new char[outWidth * outHeight * 4] SwsContext *videoSwsCtx = NULL; videoSwsCtx = sws_getCachedContext(videoSwsCtx, srcWidth, srcHeight, (AVPixelFormat)pixFmt, // 输入 outWidth, outHeight, AV_PIX_FMT_BGRA, // 输出 SWS_BICUBIC, // 算法 0, 0, 0); // 分配数据空间 uint8_t *dstData[AV_NUM_DATA_POINTERS] = { 0 }; dstData[0] = (uint8_t *)outData; int dstStride[AV_NUM_DATA_POINTERS] = { 0 }; dstStride[0] = outWidth * 4; int h = sws_scale(videoSwsCtx, yuv->data, yuv->linesize, 0, srcHeight, dstData, dstStride); if (h != outHeight) { // 转码失败 }
这里需要解释一下outWidth * outHeight * 4计算理由:720p标准的视频画面包含720 * 480个像素点,每一个像素点包含了RGBA4类数据,每一类数据分别由1个byte即8个bit表示。因此一幅完整画面所占的大小为outWidth * outHeight * 4。
(7)音频转码
char *outData = new char[10000]; 输出指针 AVCodecContext *pCodecCtx = pFormatCtx->streams[audioIndex]->codec; // 获取音频解码器上下文 SwrContext *audioSwrCtx = NULL; audioSwrCtx = swr_alloc(); audioSwrCtx = swr_alloc_set_opts(audioSwrCtx, AV_CH_LAYOUT_STEREO, AV_SAMPLE_FMT_S16, 44100, // 输出参数:双通道立体声 CD音质 pCodecCtx->channel_layout, pCodecCtx->sample_fmt, pCodecCtx->sample_rate, // 输入参数 0, 0); swr_init(audioSwrCtx); uint8_t *out[AV_NUM_DATA_POINTERS] = { 0 }; out[0] = (uint8_t *)outData; // 计算输出空间 int dst_nb_samples = av_rescale_rnd(pcm->nb_samples, pCodecCtx->sample_rate, pCodecCtx->sample_rate, AV_ROUND_UP); int len = swr_convert(audioSwrCtx, out, dst_nb_samples, (const uint8_t **)pcm->data, pcm->nb_samples); int channels = av_get_channel_layout_nb_channels(AV_CH_LAYOUT_STEREO); // AV_CH_LAYOUT_STEREO -> 2 根据声道类型得到声道数 // 实际音频数据长度 int dst_bufsize = av_samples_get_buffer_size(NULL, channels, // 通道数 pcm->nb_samples,// 1024 AV_SAMPLE_FMT_S16, 0); if (dst_bufsize < 0) { // 音频转码错误 }
至此我们已经基本完成了对一个多媒体文件的解码工作,不过离真正的播放还有一些工作没有完成。包括对代码的封装和界面设计我们都会放在下一篇博客中介绍。
完整的项目代码:https://gitee.com/learnhow/ffmpeg_studio/tree/master/_64bit/src/av_player