美文网首页
FFmpeg开发——基础篇(二)

FFmpeg开发——基础篇(二)

作者: 拉丁吴 | 来源:发表于2024-04-25 01:54 被阅读0次

    前言

    书接上回,我们比较详细的介绍了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的解码过程是一致的。

    相关文章

      网友评论

          本文标题:FFmpeg开发——基础篇(二)

          本文链接:https://www.haomeiwen.com/subject/hrzgxjtx.html