• 音视频技术应用(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 << "" << 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,然后再重新封装成一个新的文件。

  • 相关阅读:
    checkedListBox 的用发
    C# 控件命名规范
    控件数据及相应的事件处理
    MDI 窗口的创建
    摄像头中运动物体识别
    1
    静态检测大风车初版
    不会难道我还不能附上链接吗
    计算机操作素材
    数字识别
  • 原文地址:https://www.cnblogs.com/yongdaimi/p/15844128.html
Copyright © 2020-2023  润新知