FFmpeg学习(一):封装和解封装

1,141 阅读17分钟

0. 前言

封装(muxing/multiplexing,又称为复用),即将多媒体文件的多个独立的数据流(可以是音频、视频、字幕、通用数据等),合并在一个容器文件或流媒体中保存,其目的是为了不同类型的数据能够集中存储和同步传输。常见的mp4,flv,avi等媒体文件即是这一过程的产物。
解封装(demuxing/demultiplexing,又称为解复用),是封装的逆操作,即从一个多媒体容器文件中分离出各个独立的数据流,以供后续能单独使用不同的数据流。如播放前需要从mp4文件中提取出视频数据和音频数据,分别使用。
封装和解封装是所有音视频开发中最基础的操作,对于多媒体文件的各类复杂任务基本都离不开这两项子任务。
今天,主要讨论使用FFmpeg来处理封装/解封装的任务。

1. libavformat介绍

在FFmpeg中,libavformat(lavf)是专用于封装和解封装的库,提供了一套通用的框架来处理音频、视频、字幕流数据,其支持各种不同多媒体容器文件,以及网络流协议,并且包含一个I/O模块,支持以不同的协议来访问数据。

1.2 主要数据结构

首先我们来了解一下libavformat中主要使用到的数据结构。
需要说明的是,FFmpeg里面很多位置采用了“策略模式”的设计模式,比如AVFormatContext,和AVInputFormat/AVOutputFormat这些类,其中AVFormatContext主要是存储封装/解封装过程中的数据状态,而AVInputFormat/AVOutputFormat则是确定封装/解封装的行为,不同的容器格式其处理方式都有所不同。

1.2.1 AVFormatContext

AVFormatContext是封装/解封装过程中上下文环境,所谓上下文环境,即是媒体文件在处理过程中需要保存的数据,和状态信息的集合,我们可以理解成封装/转封装任务的过程中的数据信息。下面罗列了一些AVFormatContext中常用的字段和相关的方法。
AVFormatContext.png

1.2.2 AVInputFormat

AVInputFormat描述的是FFmpeg中能够支持的一种容器文件格式(如mp4,flv等),在解封装过程中被用作输入。
AVInputFormat并不是需要我们动态创建和维护的数据对象,它描述的是FFmpeg对于解封装器(demuxer)的处理操作。
在编译好FFmpeg之后,所有能支持解封装器就已经能够确定。我们可以理解成FFmpeg内部有静态地维护一组AVInputFormat序列,表示所有能够支持的解封装器,我们在解封装时只是需要找到对应的AVInputFormat,可以通过av_demuxer_iterate()对序列进行遍历,获取所有能够支持的解封装器,以下是雷神的博客中截取的关于AVInputFormat的一张结构图,可以借助理解。 截屏2025-01-19 17.29.46.png

以下是AVInputFormat的主要结构: AVInputFormat.png

1.2.3 AVOutputFormat

AVOutputFormat的结构设计和AVInputFormat类似,但区别在于,它描述的是用于封装时的主要操作,是封装过程的输出。 AVOutputFormat.png

1.2.4 AVStream

AVStream描述的是容器中的数据流(track,或称为“轨道”),比如一般视频文件中视频、音频、字幕这些数据,都各有一条流。封装时待写入的所有流,或者解封装时解析到的所有流,都被记录到AVFormatContext的streams列表中。
AVStream实际上也关联了数据流信息和所用编解码信息,其存放在编解码参数codecpar成员中,这部分结构在编解码部分会具体描述。
以下是AVStream的主要结构:

AVStream.png

1.2.5 AVPacket

封装和解封装阶段操作的主要操作对象,便是AVPacket,其表示的是编码之后的数据(压缩数据),往往作为封装器的输入,以及解封装器的输出。对于视频流而言,一个AVPacket中是包含一帧编码后的视频帧,对于音频流而言,一个AVPacket中可能包含几个压缩帧。
AVPacket采用引用计数的方式来管理内存,其引用计数存放在buf字段,而其数据内容具体存放在data成员所指向的内存空间中。我们可以使用av_packet_ref()av_packet_unref()分别来增加和减少引用数。
以下是AVPacket的主要结构:

AVPacket.png

2. 主要使用流程

对于封装/解封装操作,FFmpeg提供了一套通用的处理框架来处理不同的容器格式,下面来了解一下几个常用功能的主要使用流程。

2.1 解封装

解封装可以分为以下几个步骤:

  1. 初始化网络模块(可选)。这部分主要是调用av_network_init()方法来初始化网络模块,据ffmpeg的文档来说,这个方法主要是为了避免旧版本GnuTLS或OpenSSL库引入的线程安全问题。所以在使用新版本的库,或者非网络文件时,可以不用调这个方法。但是一般对于可能处理网络媒体文件的时候,都会调用一下,无伤大雅。
  2. 打开媒体格式url,创建AVFormatContext。这部分我们主要是使用avformat_open_input(),这是一个非常方便的方法,如果我们不指定AVInputFormat,它会打开文件并自动检测多媒体文件格式,并创建AVFormatContext,并关联对应的AVInputFormat。
    (某些情况下我们可能想要自己创建并设置AVInputFormat和AVFormatContext,一般是有自定义IO的需求,这些操作我们放到下次文章来讲)
  3. 读取音视频流的头部,获取流信息和编码格式信息。这里主要是为了获取流的一些基本信息,如帧率、码率、视频宽高等,以及流采用的编码格式等,为后续解码做准备。我们通过调用avformat_find_stream_info()来完成这个功能,这是ffmpeg中比较复杂的函数之一,里面执行了大量的读取嗅探操作(默认读取20帧),甚至会尝试解码。在访问网络文件时,它还有被阻塞的可能,可以通过自定义超时回调,或设置probe相关的设置来进行优化。
  4. 逐帧读取音视频流的数据。这部分比较好理解,即是从文件中读取音视频流的数据包,存储至AVPacket结构中,主要调用av_read_frame()方法。值得注意的是,这部分的读取操作并不区分不同的数据流,而是所有流的数据包均混在一起,所有在读到AVPacket之后要进行区分。
  5. 关闭输入文件,释放资源。结束操作,调用avformat_close_input()

解封装流程.png

2.2 封装

封装的主要流程如下所示:

  1. 初始化封装的上下文环境,创建AVFormatContext和AVOutputFormat。这里使用的是avformat_alloc_output_context2()方法,该方法可以根据输入的url信息,来创建AVFormatContext,对应的AVOutputFormat也被设置在AVFormatContext的oformat成员字段中。
  2. 打开待写入的输出文件。这里需要注意的是,除非在1中嗅探到的AVOuputFomrat格式是AVFMT_NOFILE属性,则需要手动设置一个AVIOContext给AVFormatContext的pb字段,才能够进行写入操作,这里我们调用avio_open()来打开写入文件。
  3. 创建输出流。这里通过avformat_new_stream()方法来创建输出文件中的不同数据流,并设置流的相关属性,如timebase,编码信息等,在转封装的时候,可以从输入的流中拷贝相关的数据。
  4. 写入文件头部。主要是头部avformat_write_header()方法来写入输出容器格式的头部数据,会将流的信息写入。值的注意的是,在avformat_write_header()之后,stream的timebase可能会发生改变,会根据使用容器格式的不同而设置具体的值。
  5. 逐帧写入编码帧。这里主要是调用av_interleaved_write_frame()或者av_write_frame(),前者是ffmpeg内部会帮助我们做一些音频帧和视频帧的同步处理,音视频帧交叉写入,而后者需要我们手动维护。
  6. 写入文件尾部数据,ffmpeg内部会写入一些封装格式的结束标记等。主要是调用av_write_trailer()
  7. 结束,释放资源。这里主要是关闭上面流程中打开的相关组件和资源,调用avio_closep()来关闭打开的文件,调用avformat_free_context()来释放AVFormatContext。

封装流程.png

2.3 Seek操作

Seek操作经常被用于解封装的过程中,比如在播放时拖动进度条到指定的时间点,或者编辑转码时用户进行片段裁剪,这里也一起记录一下使用方式。
首先需要注意的是,Seek操作是需要媒体容器文件支持,比如部分流媒体协议可能不支持这种操作。
在FFmpeg中Seek时最常使用到的接口是av_seek_frame(),接口调用的时机需要在avformat_find_stream_info()之后,主要是需要获得流的信息。在调用成功后av_read_frame()获得的下一帧即为需要找到的帧。
avformat_seek_file()是和av_seek_frame()类似的一个新接口,但是可以指定一个最大最小区间范围,会忽略AVSEEK_FLAG_BACKWARD。由于输入了可接受区间,所以我们可以通过调整区间范围来获得一些更精确的控制,比如获得指定时间后最接近的一个关键帧。

以下是函数接口的原型:

// 参数说明
// s : 对应的容器文件的AVFormatContext
// stream_index : 输入时间对应的流id,主要用于确认时间基,如果输入-1则以AV_TIME_BASE为时间基
// timestamp  : 以指定流时间基为单位的时间戳
// flags : 用于设定seek时的方向和模式,见下图
int av_seek_frame(AVFormatContext *s, int stream_index, int64_t timestamp,int flags);

// 参数说明
// s : 对应的容器文件的AVFormatContext
// stream_index : 输入时间对应的流id,主要用于确认时间基,如果输入-1则以AV_TIME_BASE为时间基
// min_ts : 最小可接受的时间
// ts  : 以指定流时间基为单位的时间戳
// max_ts : 最大可接受的时间
// flags : 用于设定seek时的方向和模式,见下图
int avformat_seek_file(AVFormatContext *s, int stream_index, int64_t min_ts, int64_t ts, int64_t max_ts, int flags);

seek flags.png 参数中输入的stream_index主要用来确认输入时间戳的时间基,seek操作是应用于整个媒体文件的,不是单独的某一路,调用成功后获取到的视频帧和音频帧应该都是指定时间之后的。

我们经常使用av_seek_frame(),并设置AVSEEK_FLAG_BACKWARD来定位到指定时间之前最接近的一张关键帧,比如用户在播放器上拖动进度条时,这种方式非常高效,但是并不精确。比如在剪辑的场景下,用户希望是可以控制每一帧。Seek到关键帧的方式不需要解码,但是和输入的时间点相比可能有差距。如一个30fps,gop为16的视频,最大误差可能相距16帧,误差为16/30=0.533s。
为获得更高精确度的seek,达到帧粒度的控制,我们可以通过以下的方式:

  1. 只含有I、P帧的视频(一般支持AVSEEK_FLAG_ANY)
    直接使用AVSEEK_FLAG_ANY,定位到对应的非关键帧则可。
  2. 含有B帧的视频,一般是通过两个步骤:
    (1). 先通过AVSEEK_FLAG_BACKWARD,来到达指定时间点前最接近的I帧。
    (2). 逐帧解码,缓存GOP内每一帧的时间,取到GOP内最接近的一帧。
    (这里也有一个trick,可以只缓存“dts < seek时间”的帧。因为有dts < pts,假设待确定的帧的pts=输入的seek时间, 则其dts < seek时间)

3. 示例

3.1 转封装

转封装的示例是进行了一次解封装和一次封装的完整流程,从原视频中解出每一路数据流,并将其封装至新视频文件中。示例的代码已经上传至github:github.com/zzakafool/M…,参考3_Remuxing。

// src : 输入的url
// dst : 输出的url
void remuxing(std::string src, std::string dst) {
    // 初始化解封装相关的组件
    //   创建AVFormatContext和AVInputFormat
    AVFormatContext *inFmtCtx = nullptr;
    if(avformat_open_input(&inFmtCtx, src.c_str(), NULL, NULL) < 0) {
        av_log(NULL, AV_LOG_ERROR, "Open src file failed.");
        return;
    }

    // 读取输入多媒体文件的相关信息
    if(avformat_find_stream_info(inFmtCtx, NULL) < 0) {
        av_log(NULL, AV_LOG_ERROR, "Find stream info failed");
        avformat_close_input(&inFmtCtx);
        return;
    }

    // 初始化一个AVPacket,用于逐帧读入
    AVPacket *inPacket = av_packet_alloc();
    if(inPacket == nullptr) {
        av_log(NULL, AV_LOG_ERROR, "Allocate Packet failed.\n");
        avformat_close_input(&inFmtCtx);
        return;
    }
    
    // 初始化封装相关的组件
    //   初始化AVFormatContext,同步文件名确定AVOutputFormat
    AVFormatContext *outFmtCtx = nullptr;
    if(avformat_alloc_output_context2(&outFmtCtx, NULL, NULL, dst.c_str()) < 0) {
        av_log(NULL, AV_LOG_ERROR, "Allocate output context failed.\n");
        av_packet_free(&inPacket);
        avformat_close_input(&inFmtCtx);
        return;
    }

    // 打开输出的文件
    if(!(outFmtCtx->oformat->flags & AVFMT_NOFILE)) {
        auto ret = avio_open(&outFmtCtx->pb, dst.c_str(), AVIO_FLAG_WRITE);
        if(ret < 0) {
            av_log(NULL, AV_LOG_ERROR, "IO Open failed.\n");
        }
    }

    // 从原文件的流信息,创建新的输出流
    // 这里用一个map来记录 输入流id 和 输出流id 的对应关系
    std::map<int, int> streamIdxMap;
    for(int i = 0; i < inFmtCtx->nb_streams; ++i) {
        if(inFmtCtx->streams[i]->codecpar->codec_id == AV_CODEC_ID_NONE) {
            continue;
        }
        AVStream * strm = avformat_new_stream(outFmtCtx, NULL);
        strm->id = outFmtCtx->nb_streams - 1;
        strm->index = outFmtCtx->nb_streams - 1;
        streamIdxMap[i] = strm->index;
        avcodec_parameters_copy(strm->codecpar, inFmtCtx->streams[i]->codecpar);
        strm->time_base = inFmtCtx->streams[i]->time_base;
        av_log(NULL, AV_LOG_INFO, "src timebase : %d / %d\n", strm->time_base.num, strm->time_base.den);
    }

    // 写入头部信息
    if(avformat_write_header(outFmtCtx, NULL) < 0) {
        av_log(NULL, AV_LOG_ERROR, "Write header failed.\n");
        av_packet_free(&inPacket);
        avformat_close_input(&inFmtCtx);
        avformat_free_context(outFmtCtx);
        return;
    }

    // 注意,写完header之后,stream的timebase可能会发生改变。
    for(int i = 0; i < outFmtCtx->nb_streams; ++i) {
        auto &strm = outFmtCtx->streams[i];
        av_log(NULL, AV_LOG_INFO, "new timebase : %d / %d\n", strm->time_base.num, strm->time_base.den);
    }

    // 逐帧写入
    while(av_read_frame(inFmtCtx, inPacket) >= 0) {
        if(streamIdxMap.find(inPacket->stream_index) == streamIdxMap.end()) {
            av_packet_unref(inPacket);
            continue;
        }
        // av_log(NULL, AV_LOG_INFO, "read a packet\n");
        auto &oldStream = inFmtCtx->streams[inPacket->stream_index];
        auto &newStream = outFmtCtx->streams[streamIdxMap[inPacket->stream_index]];

        // 注意,这里上面提到了,在avformat_write_header()之后,流所使用的
        // time_base可能会发生改变,所以,对于帧要写入的dts,pts,都需要进行一个转换
        inPacket->dts = av_rescale_q(inPacket->dts, oldStream->time_base, newStream->time_base);
        inPacket->pts = av_rescale_q(inPacket->pts, oldStream->time_base, newStream->time_base);
        inPacket->stream_index = streamIdxMap[inPacket->stream_index];
        inPacket->time_base = newStream->time_base;

        // 交叉写入音频和视频帧
        if(av_interleaved_write_frame(outFmtCtx, inPacket) < 0) {
            av_log(NULL, AV_LOG_ERROR, "Error during write packet\n");
        }
        av_packet_unref(inPacket);
    }

    // 写入尾部数据
    if(av_write_trailer(outFmtCtx) < 0) {
        av_packet_free(&inPacket);
        avformat_close_input(&inFmtCtx);
        avformat_free_context(outFmtCtx);
        av_log(NULL, AV_LOG_ERROR, "Write trailer failed\n");
        return;
    }

    // 释放相关资源
    avio_closep(&outFmtCtx->pb);
    av_packet_free(&inPacket);
    avformat_close_input(&inFmtCtx);
    avformat_free_context(outFmtCtx);
}

3.2 裁剪转封装

裁剪转封装,和转封装类似,但是新加入了起始时间和结束时间的参数,会在转封装时对视频的时间进行一个判断。
这里主要逻辑和转封装类似,但是用到了上面提及的seek操作,并针对裁剪情况下的时间戳进行了一个处理。
需要注意的是,因为没有重新进行编解码,这里的裁剪的逻辑并不精确,由于是直接seek到起始时间前的关键帧,开始处会有上面提及过的最多一个gop的误差,结束时间的误差相对较小。
示例的代码已经上传至github:github.com/zzakafool/M…,参考5_RemuxingTrim。

#include "RemuxingTrim.h"
#include <map>

extern "C" {
#include <libavformat/avformat.h>
}

// 主要用于记录每一路流的首帧时间
struct PacketTime {
    int64_t dts = 0;
    int64_t pts = 0;
};

// 用于记录转封装时路的映射信息
struct StreamMapItem {
    int srcStreamId = -1;
    int dstStreamId = -1;
    PacketTime firstPacketTime;
    bool isFirstPkt = true;
};

// src : 输入的url
// dst : 输出的url
// startTimeMs : 裁剪的起始时间,单位(ms), 如果<=0则从视频第一帧开始
// endTimeMs : 裁剪的结束时间,单位(ms), 如果<=0则直到视频最后一帧结束
void remuxingTrim(std::string src, std::string dst, int64_t startTimeMs, int64_t endTimeMs) {
    // 初始化解封装相关的组件
    //   创建AVFormatContext和AVInputFormat
    AVFormatContext *inFmtCtx = nullptr;
    if(avformat_open_input(&inFmtCtx, src.c_str(), NULL, NULL) < 0) {
        av_log(NULL, AV_LOG_ERROR, "Open src file failed.");
        return;
    }

    // 读取输入多媒体文件的相关信息
    if(avformat_find_stream_info(inFmtCtx, NULL) < 0) {
        av_log(NULL, AV_LOG_ERROR, "Find stream info failed");
        avformat_close_input(&inFmtCtx);
        return;
    }

    // 有输入裁剪起始时间,需要进行seek
    if(startTimeMs > 0) {
        if(av_seek_frame(inFmtCtx, -1, av_rescale_q(startTimeMs, {1, 1000}, AV_TIME_BASE_Q), AVSEEK_FLAG_BACKWARD) < 0) {
            av_log(NULL, AV_LOG_ERROR, "Seek to startTime : %lld failed", startTimeMs);
        }
    }

    // 初始化一个AVPacket,用于逐帧读入
    AVPacket *inPacket = av_packet_alloc();
    if(inPacket == nullptr) {
        av_log(NULL, AV_LOG_ERROR, "Allocate Packet failed.\n");
        avformat_close_input(&inFmtCtx);
        return;
    }
    
    // 初始化封装相关的组件
    //   初始化AVFormatContext,同步文件名确定AVOutputFormat
    AVFormatContext *outFmtCtx = nullptr;
    if(avformat_alloc_output_context2(&outFmtCtx, NULL, NULL, dst.c_str()) < 0) {
        av_log(NULL, AV_LOG_ERROR, "Allocate output context failed.\n");
        av_packet_free(&inPacket);
        avformat_close_input(&inFmtCtx);
        return;
    }

    // 打开输出的文件
    if(!(outFmtCtx->oformat->flags & AVFMT_NOFILE)) {
        auto ret = avio_open(&outFmtCtx->pb, dst.c_str(), AVIO_FLAG_WRITE);
        if(ret < 0) {
            av_log(NULL, AV_LOG_ERROR, "IO Open failed.\n");
        }
    }

    // 从原文件的流信息,创建新的输出流
    // 这里用一个map来记录 输入流id 和 输出流id 的对应关系
    std::map<int, StreamMapItem> streamIdxMap;
    for(int i = 0; i < inFmtCtx->nb_streams; ++i) {
        if(inFmtCtx->streams[i]->codecpar->codec_id == AV_CODEC_ID_NONE) {
            continue;
        }
        AVStream * strm = avformat_new_stream(outFmtCtx, NULL);
        strm->id = outFmtCtx->nb_streams - 1;
        strm->index = outFmtCtx->nb_streams - 1;

        StreamMapItem mapItem;
        mapItem.srcStreamId = i;
        mapItem.dstStreamId = strm->index;
        streamIdxMap[i] = mapItem;
        avcodec_parameters_copy(strm->codecpar, inFmtCtx->streams[i]->codecpar);
        strm->time_base = inFmtCtx->streams[i]->time_base;
        av_log(NULL, AV_LOG_INFO, "src timebase : %d / %d\n", strm->time_base.num, strm->time_base.den);
    }

    // 写入头部信息
    if(avformat_write_header(outFmtCtx, NULL) < 0) {
        av_log(NULL, AV_LOG_ERROR, "Write header failed.\n");
        av_packet_free(&inPacket);
        avformat_close_input(&inFmtCtx);
        avformat_free_context(outFmtCtx);
        return;
    }

    // 注意,写完header之后,stream的timebase可能会发生改变。
    for(int i = 0; i < outFmtCtx->nb_streams; ++i) {
        auto &strm = outFmtCtx->streams[i];
        av_log(NULL, AV_LOG_INFO, "new timebase : %d / %d\n", strm->time_base.num, strm->time_base.den);
    }

    // 逐帧写入
    while(av_read_frame(inFmtCtx, inPacket) >= 0) {
        if(streamIdxMap.find(inPacket->stream_index) == streamIdxMap.end()) {
            av_packet_unref(inPacket);
            continue;
        }

        // 记录每一路流的第一帧时间,用于裁剪
        if(streamIdxMap[inPacket->stream_index].isFirstPkt) {
            streamIdxMap[inPacket->stream_index].firstPacketTime.dts = inPacket->dts;
            streamIdxMap[inPacket->stream_index].firstPacketTime.pts = inPacket->pts;
            streamIdxMap[inPacket->stream_index].isFirstPkt = false;
        }

        // av_log(NULL, AV_LOG_INFO, "read a packet\n");
        auto &oldStream = inFmtCtx->streams[inPacket->stream_index];
        auto &newStream = outFmtCtx->streams[streamIdxMap[inPacket->stream_index].dstStreamId];

        // 裁剪逻辑 -----------------------------------------------------
        //   裁剪结束判断,兼容B帧的处理,直接用dts判断
        if(endTimeMs > 0 && inPacket->dts > av_rescale_q(endTimeMs, {1, 1000}, oldStream->time_base)) {
            break;
        }
        //   兼容B帧视频,pts大于结束时间的视频,写入文件,但是pts置为AV_NOPTS_VALUE,不播放
        //   一般只有最后一个gop有一两帧,不这么处理,某些播放器可能会跳帧
        if(endTimeMs > 0 && inPacket->pts > av_rescale_q(endTimeMs, {1, 1000}, oldStream->time_base)) {
            inPacket->pts = AV_NOPTS_VALUE;
        }

        // 重新修改时间戳,由于去掉了前面一段,所以需要根据裁剪的第一帧重新计算dts和pts
        //   --- 注意这里,由于dts < pts,dts 要保留首帧的dts和首帧的pts的偏移量
        inPacket->dts = inPacket->pts - streamIdxMap[inPacket->stream_index].firstPacketTime.pts 
                        + streamIdxMap[inPacket->stream_index].firstPacketTime.dts - streamIdxMap[inPacket->stream_index].firstPacketTime.pts; 
        inPacket->pts = inPacket->pts - streamIdxMap[inPacket->stream_index].firstPacketTime.pts;
        // ------------------------------------------------------------

        // 注意,这里上面提到了,在avformat_write_header()之后,流所使用的
        // time_base可能会发生改变,所以,对于帧要写入的dts,pts,都需要进行一个转换
        inPacket->dts = av_rescale_q(inPacket->dts, oldStream->time_base, newStream->time_base);
        inPacket->pts = av_rescale_q(inPacket->pts, oldStream->time_base, newStream->time_base);
        inPacket->stream_index = streamIdxMap[inPacket->stream_index].dstStreamId;
        inPacket->time_base = newStream->time_base;

        // 交叉写入音频和视频帧
        if(av_interleaved_write_frame(outFmtCtx, inPacket) < 0) {
            av_log(NULL, AV_LOG_ERROR, "Error during write packet\n");
        }
        av_packet_unref(inPacket);
    }

    // 写入尾部数据
    if(av_write_trailer(outFmtCtx) < 0) {
        av_packet_free(&inPacket);
        avformat_close_input(&inFmtCtx);
        avformat_free_context(outFmtCtx);
        av_log(NULL, AV_LOG_ERROR, "Write trailer failed\n");
        return;
    }

    // 释放相关资源
    avio_closep(&outFmtCtx->pb);
    av_packet_free(&inPacket);
    avformat_close_input(&inFmtCtx);
    avformat_free_context(outFmtCtx);
}

4. 参考资料

  1. 《liavformat文档》: ffmpeg.org
  2. 《FFmpeg源代码结构图 - 解码》: 雷霄骅
  3. 《深入理解FFmpeg》 :刘歧等