FFmpeg开发——基础篇(二)

前言

书接上回,我们比较详细的介绍了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.png

windows环境在编译时需要指定链接库,还是可以参考 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的解码过程是一致的。

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

推荐阅读更多精彩内容