音视频技术应用(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 概述

在上下文中插入音频或视频流信息。在AVFormatContextstreams[]数组中插入音频或视频流信息。

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 概述

这里意思很明白,就是写入具体的视频或音频信息。

在写入具体的音视频信息的时候,需要注意两点:

  1. PTS的计算。你写入的音频或视频数据将来是要给播放器去播放的,这个时候就需要考虑一下计算pts,为什么呢,因为pts本身就是用来指导播放器端的播放行为的。举个例子,你生成了一个MP4文件,它每一帧的播放次序播放速度全部跟 PTSDTS相关。

  2. 写入次序的问题。另外需要注意,你写入的次序是什么(如果只是纯视频部分,很简单,每编码生成一个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:

  1. 该函数并不会改变传入pkt的引用计数,即不会对pkt引用的数据做清理。可以传NULL, 传NULL的话代表刷新它的写入缓冲;
  2. 写入的pktstream_index一定要与AVFormatContext中的 streams[]相对应。比如现在AVFormatContext中的 streams[]长度为2:下标0代表的是视频,下标1代表的是音频,那么你在写视频数据的时候,pkt 的stream_index也必须是0,写音频的话,stream index就是1
  3. pktpts也要计算好。如果写入的pts值计算错误,可能会打印一些错误信息,或写入失败,这里如果是计算视频帧的pts, 那就需要采用 streams[]中对应的AVStreamtime_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,然后再重新封装成一个新的文件。

posted @ 2022-01-25 18:09  夜行过客  阅读(1164)  评论(1编辑  收藏  举报