音视频技术应用(19)- 封装视频的步骤
一. 创建上下文
1.1 概述
解封装是直接调用avformat_open_input()
函数就生成了一个上下文,但是封装却需要创建一个上下文。因为有这样一个区别,在解封装过程中,上下文中有很多信息是由FFmpeg
的接口填入的,但是如果是封装的话,很多信息需要我们自己填入(毕竟FFmpeg
不知道你最终想要生成的视频的具体参数是什么)。FFmpeg
提供了一个函数avformat_alloc_output_context2()
用于创建此上下文,当上下文创建完毕之后,我们需要自行设定上下文参数。
1.2 需要用到的接口说明
// 创建封装的上下文
int avformat_alloc_output_context2(AVFormatContext **ctx, // 生成的封装的上下文(可以看到它是一个二级指针,本身就是用做赋值用的)
ff_const59 AVOutputFormat *oformat, // 输出的格式指定,一般传NULL, 因为输出的格式可以直接通过文件名后缀进行指定
const char *format_name, // 输出的格式名称,一般传NULL,也是可以通过文件名后缀进行指定
const char *filename); // 最终输出的文件名,比如abc.mp4, 那FFmpeg就会以mp4的封装格式进行创建
二. 添加音频视频流
2.1 概述
在上下文中插入音频或视频流信息。在AVFormatContext
的streams[]
数组中插入音频或视频流信息。
2.2 需要用到的接口说明
// 添加流信息
AVStream *avformat_new_stream(AVFormatContext *s, // 上面创建的封装的上下文
const AVCodec *c); // 指定编码音频或视频时所用的编码器对象
注意:new stream是有次序的,可以看到上述接口并没有需要传入索引号,那第一个new的stream,它的索引号就是
0
,第二个new的stream,它的索引号就是1
,尽量使用通用的方式,比如0
代表video
,1
代表audio
,这样可以使一些播放器能够兼容(有些播放器没有去区分流的信息是音频还是视频, 默认它们就以索引号为0的当做视频,索引号为1的当做音频)。另外,这个新new的
AVStream
的空间不需要管理,因为它是关联在AVFormatContext
对象当中,当AVFormatContext
在做清理的时候,会把它也给清理掉。
三. 打开输出IO
3.1 概述
不管你是想在本地生成一个视频文件,还是想通过rtmp
进行推流,都需要指定一个具体的输出对象。如果是推流的话,则是通过网络接口往外发,如果是生成文件的话,则需要打开输入输出的IO。
3.2 需要用到的接口说明
// 打开输出的IO
int avio_open(AVIOContext **s, // 封装器的IO上下文,它是AVFormatContext的成员 pb
const char *url, // 打开的地址,就是输出的文件路径,前面也传了一个filename,不过那个filename主要是用于做格式的判断
int flags); // 涉及IO操作的FLAG,如果是写文件的话,可以传 AVIO_FLAG_WRITE
// 实际调用
avio_open(&c->pb, url, AVIO_FLAG_WRITE);
// 关闭封装器的IO上下文
int avio_closep(AVIOContext **s); // 实测, AVFormatContext在清理的时候并没有关闭IO上下文,所以需要在AVFormatContext在做清理之前 // 把该封装器的IO上下文给关掉。
接下来就是具体写文件的操作了。具体的写文件操作包含以下三部分:写入文件头,写入帧数据,写入尾部数据。
四. 写入文件头
4.1 概述
比如操作的视频的编码格式是H264
,则需要写入一些标题信息,比如头部,协议版本之类的信息。
4.2 需要用到的接口说明
// 写入头部信息
int avformat_write_header(AVFormatContext *s, // 上面创建的封装的上下文
AVDictionary **options); // 一些额外的设定参数,若不指定,可传递NULL
五. 写入帧数据(需要考虑写入次序)
5.1 概述
这里意思很明白,就是写入具体的视频或音频信息。
在写入具体的音视频信息的时候,需要注意两点:
-
PTS的计算。你写入的音频或视频数据将来是要给播放器去播放的,这个时候就需要考虑一下计算
pts
,为什么呢,因为pts
本身就是用来指导播放器端的播放行为的。举个例子,你生成了一个MP4
文件,它每一帧的播放次序和播放速度全部跟PTS
和DTS
相关。 -
写入次序的问题。另外需要注意,你写入的次序是什么(如果只是纯视频部分,很简单,每编码生成一个
AVPacket
我们就把它写进去,但是加上音频部分就没有这么容易了,音视频之间的pts
是否也要保持一定的次序,而且不能相差太大,因为将来解码读取的时候肯定是一段一段在读的,可能音频读了一堆,但是视频还没有,还要继续等,那这样就会造成一些播放的延迟,有些播放器如果没有处理好的话,可能会造成音视频不同步,正常情况下每个播放器都有一个同步校验,它是有一个超时机制的,就是超过多少时间播放器就不去同步了,如果硬去同步的话,会导致整个画面停住,这样给用户的感觉不好,所以超过一段时间后,播放器就自动播放了,因此这里需要考虑一下写入次序问题)。FFmpeg
提供了几种方案,一种是由我们自己自行计算次序,还有一种方案就是通过FFmpeg
提供的接口来计算次序(通过内部缓冲来实现写入的次序)。
5.2 需要用到的接口说明
// 用于PTS的转换 (a * bq / cq) [最终文件的pts = 原来的pts * 原来的time_base / 最终文件的time_base]
int64_t av_rescale_q_rnd(int64_t a, // 源文件的PTS
AVRational bq, // 源文件的time_base
AVRational cq, // 目标的time_base
enum AVRounding rnd) av_const; // 转换的规则,因为内部涉及到除法运算,而最终生成的结果又是整型,可以根据该规则确定是要四舍五入 // 还是其它
// 写入帧数据[FFmpeg提供的写入方案一]
int av_write_frame(AVFormatContext *s, // 上面创建的封装的上下文
AVPacket *pkt); // 已编码的音视频帧
// 写入帧数据[FFmpeg提供的写入方案二]
int av_interleaved_write_frame(AVFormatContext *s, // 上面创建的封装的上下文
AVPacket *pkt); // 已编码的音视频帧
关于
av_write_frame()
写入方案中的pkt
:
- 该函数并不会改变传入pkt的引用计数,即不会对pkt引用的数据做清理。可以传
NULL
, 传NULL
的话代表刷新它的写入缓冲;- 写入的
pkt
的stream_index
一定要与AVFormatContext
中的streams[]
相对应。比如现在AVFormatContext
中的streams[]
长度为2
:下标0
代表的是视频,下标1
代表的是音频,那么你在写视频数据的时候,pkt 的stream_index也必须是0
,写音频的话,stream index就是1
。pkt
的pts
也要计算好。如果写入的pts
值计算错误,可能会打印一些错误信息,或写入失败,这里如果是计算视频帧的pts
, 那就需要采用 streams[]中对应的AVStream
的time_base
, 比如假设视频流的下标为0
,那就需要取streams[0]->time_base
来参与pts
的计算。
关于
av_interleaved_write_frame()
:它是
FFmpeg
引入的另外一个函数,也用于完成音视频帧的写入。既然av_write_frame()
已经可以实现写入音视频帧了,为什么要再引入这样一个函数呢?有这样一种场景: 假设原来存在有一个视频文件,它里面同时包含有音频和视频,现在需要根据原有的视频文件进行重新封装,新的封装格式要求其中的视频部分需要转码成新的格式,其中的音频部分不必转码直接保留。这样可能会造成一种情况:音频如果不需要转码的话,那可以直接读一个音频Packet就写进去,读一个音频Packet就写进去... 但是视频部分因为要做转码,所以视频部分可能需要花费一定时间才能编码成新的指定格式,然后才能写入,这样的话就带来一个问题,可能音频帧已经写入了很多,但是视频帧却写入很少,这样视频帧和音频帧之间的差距就会很大(比如相差了3秒),这个时候假设一个播放器它的缓冲区小于3秒,这样的话音视频就不同步了,因此这个时候就需要对
pts
进行计算, 也就是拿到音频的packet之后,也不能立刻写进去,可能需要把待写入的packet先排好序,然后再写进去,确保视频帧和音频帧的间隔相差不大。
av_interleaved_write_frame()
函数就是用来解决上述问题的,从字面意思上就可以看出,该函数的写入方式是用于interleaved(交错写入),有区别于av_write_frame()
,后者则是直接写入,那它们两个有什么差别呢?差别一:处理写入数据的方式不同,
av_interleaved_write_frame()
会在内部先缓冲,再写入,av_write_frame()
则会直接写入。这个函数会在内部缓冲数据包,也就是说你通过这个函数传入了一个
音视频Packet
,它并不会直接写入,它会在内部把传入的音视频Packet
缓存起来,然后根据dts
进行排序,排好充之后再写到文件当中去,以确保音视频的差距不会太大。另外,由于该接口内部实现了缓冲机制,所以必然会涉及到对缓冲数据的处理,如果写入文件结尾处需要将接口内部的缓冲数据一齐写入到文件,这个时候可以给该接口传
NULL
,即:av_interleaved_write_frame(NULL)
,传NULL
它就会把剩余的缓冲数据全部写入到文件当中。针对该接口内部的缓冲大小,可以通过
AVFormatContext
中的max_interleave_delta
成员进行设置。根据实际情况,如果你的音视频流的时间相差过大的话,可以把这个缓冲值加大,如果相关不大,想节省内存的话,可以把该值改小一点。差别二:对写入packet的引用计数的处理方式不同
如果传入的packet是采取引用计数的方式,
av_interleaved_write_frame()
在使用完这个packet的时候,会对该packet的引用计数减1,调用此函数后就不可以在外部再访问该packet,因为这样,所以如果写入方式是av_interleaved_write_frame()
, 就不需要再调用av_packet_unref()
,当然如果引用计数已经是0,你再调用一次也不会有问题。而如果是
av_write_frame()
这种方式写入,它是不会改变packet的引用计数的,这个时候下次如果再读一帧数据,则需要手动调用av_packet_unref()
.
六. 写入尾部数据(pts索引)
6.1 概述
最后还需要写入尾部的数据,就是当所有的帧数据全部编码出来后,需要把每一帧在文件当中的偏移位置和pts
等数据写入尾部,如果尾部没有写入的话,可能会造成你的视频能播放,但是不能seek
,也不能看到视频的时长
。
6.2 需要用到的接口说明
// 写入尾部信息
int av_write_trailer(AVFormatContext *s); // 上面创建的封装的上下文
七. 代码演示
7.1 概述
下面演示如何重封装一个新的MP4
文件,新封装的视频文件内容取自原有的视频文件:v1080.mp4
7.2 示例Code
#include <iostream>
#include <thread>
using namespace std;
extern "C" { //指定函数是c语言函数,函数名不包含重载标注
//引用ffmpeg头文件
#include <libavformat/avformat.h>
}
//预处理指令导入库
#pragma comment(lib,"avformat.lib")
#pragma comment(lib,"avutil.lib")
#pragma comment(lib,"avcodec.lib")
void PrintErr(int err)
{
char buf[1024] = { 0 };
av_strerror(err, buf, sizeof(buf) - 1);
cerr << endl;
}
#define CERR(err) if(err!=0){ PrintErr(err);getchar();return -1;}
int main(int argc, char* argv[])
{
//打开媒体文件
const char* url = "v1080.mp4";
////////////////////////////////////////////////////////////////////////////////////
/// 解封装
//解封装输入上下文
AVFormatContext* ic = nullptr;
auto re = avformat_open_input(&ic, url,
NULL, //封装器格式 null 自动探测 根据后缀名或者文件头
NULL //参数设置,rtsp需要设置
);
CERR(re);
//获取媒体信息 无头部格式
re = avformat_find_stream_info(ic, NULL);
CERR(re);
//打印封装信息
av_dump_format(ic, 0, url,
0 //0表示上下文是输入 1 输出
);
AVStream* as = nullptr; //音频流
AVStream* vs = nullptr; //视频流
for (int i = 0; i < ic->nb_streams; i++)
{
//音频
if (ic->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO)
{
as = ic->streams[i];
cout << "=====音频=====" << endl;
cout << "sample_rate:" << as->codecpar->sample_rate << endl;
}
else if (ic->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO)
{
vs = ic->streams[i];
cout << "=========视频=========" << endl;
cout << "width:" << vs->codecpar->width << endl;
cout << "height:" << vs->codecpar->height << endl;
}
}
////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////
/// 解封装
//编码器上下文
const char* out_url = "test_mux.mp4";
AVFormatContext* ec = nullptr;
re = avformat_alloc_output_context2(&ec, NULL, NULL,
out_url //根据文件名推测封装格式
);
CERR(re);
//添加视频流、音频流
auto mvs = avformat_new_stream(ec, NULL); //视频流
auto mas = avformat_new_stream(ec, NULL); //音频流
//打开输出IO
re = avio_open(&ec->pb, out_url, AVIO_FLAG_WRITE);
CERR(re);
//设置编码音视频流参数
//ec->streams[0];
//mvs->codecpar;//视频参数
if (vs)
{
mvs->time_base = vs->time_base;// 时间基数与原视频一致
//从解封装复制参数
avcodec_parameters_copy(mvs->codecpar, vs->codecpar);
}
if (as)
{
mas->time_base = as->time_base;
//从解封装复制参数
avcodec_parameters_copy(mas->codecpar, as->codecpar);
}
//写入文件头
re = avformat_write_header(ec, NULL);
CERR(re);
//打印输出上下文
av_dump_format(ec, 0, out_url, 1);
////////////////////////////////////////////////////////////////////////////////////
AVPacket pkt;
for (;;)
{
re = av_read_frame(ic, &pkt);
if (re != 0)
{
PrintErr(re);
break;
}
if (vs && pkt.stream_index == vs->index)
{
cout << "视频:";
}
else if (as && pkt.stream_index == as->index)
{
cout << "音频:";
}
cout << pkt.pts << " : " << pkt.dts << " :" << pkt.size << endl;
//写入音视频帧 会清理pkt
re = av_interleaved_write_frame(ec,
&pkt);
if (re != 0)
{
PrintErr(re);
}
//av_packet_unref(&pkt);
//this_thread::sleep_for(100ms);
}
//写入结尾 包含文件偏移索引
re = av_write_trailer(ec);
if (re != 0)PrintErr(re);
avformat_close_input(&ic);
avio_closep(&ec->pb);
avformat_free_context(ec);
ec = nullptr;
return 0;
}
注意:上面仅是一个最简单的封装,虽然可以正常跑通,但是可以说是毫无意义,基本上等于是把源视频复制了一遍,接下来会尝试从源视频截取10s,然后再重新封装成一个新的文件。