前言
书接上回,我们比较详细的介绍了ffmpeg开发过程中会接触到的主要结构体,当然,其实还有AVFilter模块,但是对于初学者而言,忽略掉过滤器部分也无伤大雅,并不影响对于ffmpeg开发流程的主体的学习,而且AVFilter也不算是特别常用,在音视频开发中也有其他方式可以实现AVFilter的效果,因此暂时可以先忽略。
本文我们用一段相对完整,但是不算复杂的ffmpeg程序来实现我们上文提到的那些知识。
环境准备
在进行ffmpeg开发之前,一般建议大家自行获得ffmpeg源码,手动编译获得相应的动态库(dll/so),然后再正式进行c/c++开发工作。
ffmpeg可以在windows,linux系统上开发,一般推荐linux上来开发(本人用的linux环境,但也有windows环境),因为windows其实也是模拟了一些linux的环境的。
windows环境安装与编译
windows环境下主要参考这篇文章ffmpeg库编译安装及入门指南。
注意以下几点:
- 博文中作者的建议安装选项大家都尽可能安装上。
- ffmpeg源码尽可能下载最新版本。
- 编译ffmpeg库的build-ffmpeg.sh脚本替换如下
#!/bin/sh
basepath=$(cd `dirname $0`;pwd)
echo ${basepath}
cd ${basepath}/ffmpeg-5.1.2-src
pwd
export PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:/d/repos/ffmpeg/x264_install/lib/pkgconfig
echo ${PKG_CONFIG_PATH}
./configure --prefix=${basepath}/ffmpeg_5.2.1_install \
--enable-debug --disable-asm --disable-stripping --disable-optimizations \
--enable-gpl --enable-libx264 --disable-static --enable-shared \
--extra-cflags=-l${basepath}/x264_install/include --extra-ldflags=-L${basepath}/x264_install/lib
make -j8
make install
主要是添加一些可debug配置,为后面调试做准备
linux环境安装与编译
在linux中就不需要安装MSYS2了,而缺的编译工具什么的按照提示使用linux的软件包管理管理工具(比如apt等)安装即可。
然后下载最新源码,libx264源码,编译过程仍然可以使用或者 ffmpeg库编译安装及入门指南中提供的编译脚本。
注意build-ffmpeg.sh脚本同样需要替换一下脚本:
#!/bin/sh
basepath=$(cd `dirname $0`;pwd)
echo ${basepath}
cd ${basepath}/ffmpeg-5.1.2-src
pwd
export PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:/d/repos/ffmpeg/x264_install/lib/pkgconfig
echo ${PKG_CONFIG_PATH}
./configure \
--enable-debug --disable-asm --disable-stripping --disable-optimizations \
--enable-gpl --enable-libx264 --disable-static --enable-shared \
--extra-cflags=-l${basepath}/x264_install/include --extra-ldflags=-L${basepath}/x264_install/lib
make -j8
make install
去掉了--prefix=xxx配置,把ffmpeg生成物推送到系统默认的环境变量的路径中,免得还需要自行配置,后面就可以直接调用和使用依赖库了。如果想自定义产物生成目录也可直接参考windows的脚本。
代码编辑器
代码编辑器可以使用 Visual Studio Code,Clion,或者其他趁手的都行。
生成产物与开发使用
编译成功之后,不仅有ffmpeg依赖库(lib文件夹)和头文件(include文件夹),还有ffmpeg,ffprobe,ffplayer这样的可执行程序,可以直接在命令行中进行调用。
在后面的开发过程中,我们至少会用到头文件和依赖库。
对于windows环境而言,为了简单起见,每新建一个工程,可以把ffmpeg生成的头文件都添加进来,然后按需调(虽然有些不环保)。
image.pngwindows环境在编译时需要指定链接库,还是可以参考 ffmpeg库编译安装及入门指南;linux如果编译产物在系统默认目录中的话则不需要。
ffmpeg开发
环境安装完毕之后,正式进入正题。
我们要开发的程序的功能是,读取一个视频文件,解码音频和视频部分,并且把解码后的视频中的一帧或者几帧图保存成ppm格式。
这里主要包含到ffmpeg的解封装,解码,色彩空间转换的过程,以及对解码数据的认识。
至于ppm,它是一个未压缩的RGB图片的格式(jpg就是压缩后的图片格式),文件在操作系统中可以正常打开查看,这不是本文的重点。
函数入口
接下来我们直接看代码
#include <cstdio>
#include "common.h"
#include "iostream"
// 因为ffmpeg中的库都是C编写的,使用cpp开发,引用C库需要extern "C"配置,适配C/cpp函数名编译的不同规则
extern "C"{
#include "libavformat/avformat.h"
#include "libavformat/avio.h"
#include "libavcodec/avcodec.h"
#include "libswscale/swscale.h"
#include "libavutil//imgutils.h"
}
using namespace std;
AVFormatContext *av_fmt_ctx_input = nullptr;
int video_stm_index = -1;
int audio_stm_index = -1;
int ret = 0;
// 提前定义好结构体,便于解码音频和视频时的变量的统一管理
typedef struct StreamContext{
//解码音频的解码器上下文
AVCodecContext *audioAVCodecCtx = nullptr;
//解码视频的解码器上下文
AVCodecContext *videoAVCodecCtx= nullptr;
//表示视频的数据流
AVStream *videoStream= nullptr;
//表示音频的数据流
AVStream *audioStream= nullptr;
//色彩空间转换后的AVFrame
AVFrame *rgbFrame = nullptr;
};
// 根据定义好的结构体声明一个变量
struct StreamContext streamContext;
// 色彩空间转换模块的上下文
SwsContext *swsContext = nullptr;
/********其他函数***********/
//.....
// 后文补充
//......
/********其他函数***********/
// 入口函数
int main(int argc,char *args[]) {
// 同目录下存放任意一个MP4文件,便于直接读取
const char *input_file = "bunny.mp4";
// avformat_open_input,解封装,并读取文件头信息,创建av_fmt_ctx_input结构体对象
if ((ret = avformat_open_input(&av_fmt_ctx_input,input_file, nullptr, nullptr))<0){
print_log("avformat_open_input", ret); // 错误处理,print_log是自定义的一个函数,用于打印一些错误信息
return -1;
}
// 主要针对某些没有文件头的视频文件情况,会尝试从文件主体中去读取一些文件的信息
ret = avformat_find_stream_info(av_fmt_ctx_input, nullptr);
if(ret<0){
print_log("avformat_find_stream_info", ret);
return ret;
}
// 打印一下av_fmt_ctx_input目前持有的信息,(如果不想要也可以去掉)
av_dump_format(av_fmt_ctx_input,-1,input_file,0);
// 1,分别对视频和音频的解码进行初始化的准备
// 就是获取对应的流,以及初始化对应的解码器
if (initVideo() < 0 || initAudio() < 0){
return -1;
}
// 初始化这个用来转换的AVFrame,
// 需要手动设置frame->data,frame->linesize这两个空间 在前一篇文章中说到过
ret = initRGBFrame();
if (ret<0){
print_log("initRGBFrame",-1);
return ret;
}
// 创建AVPakcet结构体的对象,前一篇文章说过它是存放编码数据的结构体
AVPacket *av_packet = av_packet_alloc();
// av_read_frame 读取视频文件的中的数据流 到av_packet中,
// 此时av_packet中就存放了一块编码过的数据
while (av_read_frame(av_fmt_ctx_input,av_packet)>=0){
// av_packet->stream_index表示这个packet数据来自AVFormatContext中的streams数组的哪个下标
// 通过判断来区分packet里面装的是音频数据还是视频数据,需要分开解码
if (av_packet->stream_index == video_stm_index){ // video_stm_index就是我们找到的视频流所在的数组下标
ret = decodeData(av_packet,streamContext.videoAVCodecCtx,1);
}else if (av_packet->stream_index == audio_stm_index){
// decode audio
}
if (ret<0){
break;
}
}
// 集中释放AVCodecContext,AVPacket,AVFormatContext等资源
avcodec_free_context(&(streamContext.audioAVCodecCtx));
avcodec_free_context(&(streamContext.videoAVCodecCtx));
av_packet_free(&av_packet);
avformat_close_input(&av_fmt_ctx_input);
return 0;
}
上面是程序的变量和入口函数,也就是整个程序的主框架了。
从上面的注释可以比较通畅的了解程序的执行过程。从中也能找到前一篇文章中提到的许多代码片段,这里其实算是做了一个整合。
音视频配置初始化
接下来我们看看initVideo和initAudio,其实两者基本是一致的,理论上可以合并成一个函数。
int initVideo(){
//av_find_best_stream 用于从av_fmt_ctx_input中找到类型为AVMEDIA_TYPE_VIDEO的流的数组下标
// 当然由于我们此时已经直到AVFormatContext->nb_streams 流数组的长度,所以可以手动遍历。
// av_find_best_stream函数就是手动遍历查找的。
video_stm_index = av_find_best_stream(av_fmt_ctx_input,AVMEDIA_TYPE_VIDEO,-1,-1, nullptr,0);
if (video_stm_index == -1 ){ // 如果-1,表示没有找到我们想要的数组下标,返回错误
print_log("video_index_error",video_stm_index);
return -1;
}
cout<< "video stream index: "<<video_stm_index<<endl; //打印信息
//拿到了视频流
streamContext.videoStream = av_fmt_ctx_input->streams[video_stm_index];
// 接着开始准备进行解码器的初始化
// 上一篇文章我们说过,视频流中有解码该流的数据的解码器id
// 此时我们通过解码器id,找到对应的解码器的详细信息(AVCodec),或者也可以直接把它理解为解码器
// avcodec_find_decoder是找对应的解码器,avcodec_find_encoder是找对应的编码器,别弄错了
auto codec = avcodec_find_decoder(streamContext.videoStream->codecpar->codec_id);
// 然后通过这个codec,创建该解码器的上下文,
// 但是此时上下文里还没有视频流的有效信息
auto av_codec_ctx = avcodec_alloc_context3(codec);
// 于是我们把视频流的有效信息赋值到解码器上下文中
ret = avcodec_parameters_to_context(av_codec_ctx,streamContext.videoStream->codecpar);
if (ret<0){
print_log("video avcodec_parameters_to_context",ret);
return ret;
}
// 对解码器进行初始化,准备开始解码
ret = avcodec_open2(av_codec_ctx,codec, nullptr);
if (ret<0){
print_log("video avcodec_open2",ret);
return ret;
}
streamContext.videoAVCodecCtx = av_codec_ctx;
return 0;
}
int initAudio(){
audio_stm_index = av_find_best_stream(av_fmt_ctx_input,AVMEDIA_TYPE_AUDIO,-1,-1, nullptr,0);
if (audio_stm_index == -1){
print_log("audio_index_error",audio_stm_index);
return -1;
}
cout<< "audio stream index "<<audio_stm_index<<endl;
streamContext.audioStream = av_fmt_ctx_input->streams[audio_stm_index];
auto codec = avcodec_find_decoder(streamContext.audioStream->codecpar->codec_id);
auto av_codec_ctx = avcodec_alloc_context3(codec);
ret = avcodec_parameters_to_context(av_codec_ctx,streamContext.audioStream->codecpar);
if (ret<0){
print_log("audio avcodec_parameters_to_context",ret);
return ret;
}
ret = avcodec_open2(av_codec_ctx,codec, nullptr);
if (ret<0){
print_log("audio avcodec_open2",ret);
return ret;
}
streamContext.audioAVCodecCtx = av_codec_ctx;
return 0;
}
根据上面的代码和注释,也能发现,关于AVStream,AVCodec,AVCodecContext的使用基本都符合前一篇文章中对于对应结构体的基本使用说明。当然这个过程中是有许多详细的参数是可以设置的,也可以把他们变得复杂一点,但是目前这不是重点。
手动配置AVFrame->data
接下来我们看看initRGBFrame的逻辑。
int initRGBFrame(){
//先创建一个AVFrame结构体
streamContext.rgbFrame = av_frame_alloc();
auto width = streamContext.videoAVCodecCtx->width;
auto height = streamContext.videoAVCodecCtx->height;
// 通过像素格式,图片宽高,来计算当前所需的缓冲空间大小,最后一个字段是对齐字数
auto bufferSize = av_image_get_buffer_size(AV_PIX_FMT_RGB24,width,height,1);
uint8_t * buffer = (uint8_t *)av_malloc(bufferSize);
// AV_PIX_FMT_RGB24 packed RGB 8:8:8, 24bpp, BGRBGR...
// 在data[8]数组中保存在data[0]中
//根据缓冲大小,像素格式,宽高来填充 rgbFrame->data和rgbFrame->linesize
av_image_fill_arrays(streamContext.rgbFrame->data,streamContext.rgbFrame->linesize,buffer,
AV_PIX_FMT_RGB24,width,height,1);
// 创建视频帧转换的上下文,libswscale可以提供颜色转换,图片尺寸放缩等能力
swsContext = sws_getContext(width,height,streamContext.videoAVCodecCtx->pix_fmt,width,height,
AV_PIX_FMT_RGB24,0, nullptr, nullptr, nullptr);
if (swsContext == nullptr){
return -1;
}
return 0;
}
手动创建并填充AVFrame的过程,需要首先创建AVFrame的结构体,然后申请填充 rgbFrame->data和rgbFrame->linesize这两个字段,前一篇文章中说到过,不是编解码过程中使用AVFrame需要我们手动申请这块内存。具体可以看FFmpeg开发——基础篇————AVFrame。
现在我们准备先把视频解码成YUV帧,然后把YUV帧通过libswscale转换成RGB帧。解码过程中使用AVFrame是不需要我们手动申请或填充data等字段的,但是scale转换过程自然就需要了。
解码与转换
做完了上述的准备之后,可以正式开始进行解码操作了:从数据流中读取数据到AVPacket中,然后把AVPakcet中的数据发送给解码器,接着从解码器中读取数据到AVFrame中,就获得了一个解码后的帧。
int decodeData(AVPacket *av_packet,AVCodecContext *av_codec_ctx,int is_video) {
// 发送数据到解码器
ret = avcodec_send_packet(av_codec_ctx,av_packet);
if (ret<0){
print_log("video avcodec_send_packet",ret);
return ret;
}
// 创建一个AVFrame用来承接解码后的数据(此时不用在手动填充data等字段了)
AVFrame *av_frame = av_frame_alloc();
while (true){
// 从解码器中读取解码后的数据到AVFrame中
ret = avcodec_receive_frame(av_codec_ctx,av_frame);
if (ret == AVERROR_EOF){ // 到文件结束
ret = 0;
break;
} else if (ret == AVERROR(EAGAIN)){ // avpacket的数据不够形成一帧数据,需要继续往解码器发送avpacket
ret = 0;
break;
}else if(ret<0){ // 其他错误
print_log("video decode error",ret);
break;
}else{
// ret>=0 表示正常,此时会得到的av_frame基本上都是YUV420P的色彩格式,
if(is_video>0){ // 处理视频数据
// sws_scale函数可以对AVFrame进行转换(颜色空间转换,图片宽高放缩等)
// (YUV420P) to (packed RGB 8:8:8)
ret = sws_scale(swsContext, ( uint8_t const* const*)av_frame->data, av_frame->linesize, 0, av_frame->height,
streamContext.rgbFrame->data, streamContext.rgbFrame->linesize);
if (ret<0){
print_log("sws_scale_frame",ret);
break;
}
// 此时rgbFrame内就保存了RGB格式的数据,接下来我们只要把数据写入到文件即可
saveRGBImage(0);
ret = -1;
break; // 只解码一帧就退出
}else{
}
}
}
av_frame_free(&av_frame);
return ret;
}
这里主要涉及到两个点,解码过程和转换过程。
解码过程的API调用比较简单,也可以看AVFrame之编解码使用方式。
转换过程本质上是YUV2RGB的算法以及数据存储方式,关于前者其实在移动开发中关于视频的一些基本概念——YUV与RGB的转换介绍了相关转换原理;而数据存储方式则在文章FFmpeg开发——基础篇(一)之 AVFrame的data与linesize中有介绍到Planar和packed两种存储放在在AVFrame->data中的表现形式。了解不同的存储方式在ffmpeg中的表现形式我们才能正确的保存数据。
保存文件
然后我们最后看看数据保存过程
void saveRGBImage(int index){
char fileName[32];
sprintf(fileName,"frame_%d.ppm",index); // 定义一下文件名frame_0.ppm
FILE *file = fopen(fileName,"wb"); // 打开文件
if (file == nullptr){
return;
}
int width = streamContext.videoAVCodecCtx->width;
int height = streamContext.videoAVCodecCtx->height;
int line_size = streamContext.rgbFrame->linesize[0];
// 写入ppm文件的文件头,P6
fprintf(file, "P6\n%d %d\n255\n", width, height);
for (int i = 0; i < height; ++i) {
//相当于一行一行的写入数据,(也可以计算数据总数,一次性写入)
// line_size是一行的长度,从第0行开始,每次写入一行长度的数据
fwrite(streamContext.rgbFrame->data[0]+i*line_size,1,line_size,file);
}
fclose(file);
}
ppm格式的详细信息见PPM文件格式详解
log打印的函数
char* print_log(const char *tag,int ret){
const int max_buf = 1024;
char buf_log[2048] = "";
// av_strerror函数能够根据当前错误码给我们返回一些错误信息
// 虽然非常粗糙,但是聊胜于无。
av_strerror(ret,buf_log,max_buf);
cout<< tag << " error: %d %s" << ret << buf_log << endl;
return "";
}
总结
把上述代码合并之后,就是这个程序的完整代码。
我们可以从一个视频文件中读取数据,解码,然后获取其中第一帧YUV帧,转换为RGB帧,最后把RGB帧保存为一张未压缩的图片文件。
虽然我们对音频的解码做了初始化准备配置,本来想做些其他功能,后来感觉有点多余,demo中处理视频就行了,它和video的解码过程是一致的。
网友评论