音视频技术应用(20)- 截取一段视频


上一节演示了如何去封装一个视频文件,但是上一节的例子举得不够好,基本相当于是从源视频中读取音视频帧,然后再拷贝到新的视频文件里,现实生活中遇到这种场景可能就直接去拷贝文件了,不会做这么多的操作,现实生活中遇到得比较多的场景应该是截取视频封装格式转换这些,前者的需求就是从源视频中截取一段时间,并生成新的视频文件,后者的需求就是将源视频的封装格式转换成另一种封装格式,比如从MP4转换成TS

本节演示一下如何从源视频中截取一段视频,这里会从源视频中截取出10s~20s的位置,并生成一个新的视频文件。

代码如下:

/**
 * 从源视频中截取10s并进行重新封装为一个新的视频文件
 */

#include 
#include 

using namespace std;

extern "C" { // 指定函数是C语言函数,以C语言的方式去编译
#include 
}

// 以预处理指令的方式导入库
#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);
	cout << buf << endl;
}

#define CERR(err) if (err != 0) {PrintErr(err); return -1;} 

int main()
{
	// 打开媒体文件
	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 代表上下文是输出, 这里打开的是输入上下文,所以传0
	);

	// 查找音频和视频流
	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_2.mp4";
	AVFormatContext* ec = nullptr;
	re = avformat_alloc_output_context2(&ec, NULL, NULL, 
		out_url									// 这里可以根据文件后缀名来判断封装格式
	);

	// 添加音频和视频流
	auto mvs = avformat_new_stream(ec, nullptr); // 添加视频流,这里第二个编码器参数先不传,留待后面传入
	auto mas = avformat_new_stream(ec, nullptr); // 添加音频流			


	// 设置编码的音视频参数
	// 这里主要设置两个:1. 音视频流的时间基数,2. 编码器参数
	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);
	}
		
	// 打开输出IO
	re = avio_open(&ec->pb, out_url, AVIO_FLAG_WRITE);
	CERR(re);

	// 写入文件头
	re = avformat_write_header(ec, nullptr);
	CERR(re);

	// 打印输出的上下文信息
	av_dump_format(ec, 0, out_url, 1);			// 这里由于是输出,所以最后一个参数传 1

	///////////////////////////////////////////////////////////////////////////////////////////
	// 截取音视频数据
	// 这里我们截取10~20s之间的音视频数据
	// 截取的原理是先利用av_seek_frame()函数将输入文件移动到10s的起始位置,然后读取音视频数据并进行写入,
	// 当读取到的视频帧的pts > 20s 时便停止读取和写入,紧接着写入文件尾部信息,完成封装
	double begin_sec = 10.0;	// 起始时间
	double end_sec = 20.0;		// 结束时间
	long long begin_video_pts;	// 视频起始位置的pts
	long long end_video_pts;	// 视频结束位置的pts
	long long begin_audio_pts;	// 音频起始位置的pts
	long long end_audio_pts;	// 音频结束位置的pts

	// 计算音视频起始和结束位置的pts, 后面做seek操作或读取时会用到
	// 在FFmpeg中,时间基(time_base)是时间戳(pts/dts)的单位,时间戳乘以时间基,可以得到实际的时刻值。
	// 时刻值(s) = 时间戳(pts) * 时间基(time_base)
	
	if (vs && vs->time_base.num != 0)
	{
		// 计算移动起始和结束位置的pts(视频)
		// pts = 时刻值(s) / 时间基(num/den) = 时刻值 * 时间基的倒数(den / num)
		double t = (double)vs->time_base.den / (double)vs->time_base.num;
		begin_video_pts = begin_sec * t;
		end_video_pts = end_sec * t;
	}

	if (as && as->time_base.num != 0)
	{
		// 计算移动的起始和结束位置的pts(音频)
		double t = (double)as->time_base.den / (double)as->time_base.num;
		begin_audio_pts = begin_sec * t;
		end_audio_pts = end_sec * t;
	}


	// 尝试将视频移动到10s的位置,然后再开始读取和写入
	if (vs)
	{
		re = av_seek_frame(ic, vs->index, begin_video_pts, AVSEEK_FLAG_FRAME | AVSEEK_FLAG_BACKWARD);
		CERR(re);
	}

	// 准备读取和写入音视频帧数据
	AVPacket pkt;
	for (;;)
	{
		re = av_read_frame(ic, &pkt);
		if (re != 0)
		{
			PrintErr(re);
			break;
		}

		if (vs && pkt.stream_index == vs->index)
		{
			// 如果读取的是视频帧
			if (pkt.pts > end_video_pts)
			{
				// 如果当前视频帧的pts > 20s,则退出循环
				av_packet_unref(&pkt);
				break;
			}

			pkt.pts = av_rescale_q_rnd(pkt.pts - begin_video_pts, vs->time_base, mvs->time_base, 
				(AVRounding)(AV_ROUND_NEAR_INF|AV_ROUND_PASS_MINMAX));
			pkt.dts = av_rescale_q_rnd(pkt.dts - begin_video_pts, vs->time_base, mvs->time_base,
				(AVRounding)(AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX));
			pkt.duration = av_rescale_q(pkt.duration, vs->time_base, mvs->time_base);
		}
		else if (as && pkt.stream_index == as->index) 
		{
			// 如果读取的是音频帧
			pkt.pts = av_rescale_q_rnd(pkt.pts - begin_audio_pts, as->time_base, mas->time_base,
				(AVRounding)(AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX));
			pkt.dts = av_rescale_q_rnd(pkt.dts - begin_audio_pts, as->time_base, mas->time_base,
				(AVRounding)(AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX));
			pkt.duration = av_rescale_q(pkt.duration, as->time_base, mas->time_base);
		}
		
        pkt.pos = -1;
        
		// 重新计算音视频帧的pts/dts/duration
		// 为什么要重新计算?因为输出的媒体文件是对源文件进行截取得到的,即以源文件10s的位置作为了输出视频的起始位置,如果仍然沿用源视频的pts/dts/duration
		// 进行写入,那么很可能会导致输出的媒体文件音视频不同步。
		// 为了让输出的音视频帧在播放时能够同步,需要对输出的音视频帧的pts进行修正,既然以10s的位置作为了开关,那所有的pkt的pts都应该减去起始位置的pts,以视频
		// 为例,new_pts = old_pts - begin_video_pts; 
		// 当然,如果不计算的话,大部分播放器也能播放成功,但是不能保证所有的播放器都能播放成功,为了保证兼容性,最后计算一下。
		
		
		// 交替写入音频或视频数据
		re = av_interleaved_write_frame(ec, &pkt);
		if (re != 0)
		{
			PrintErr(re);
			break;
		}
	}

	// 写入尾部信息 包含文件偏移索引
	re = av_write_trailer(ec);
	CERR(re);


	// 关闭输入上下文
	avformat_close_input(&ic);

	// 关闭输出IO, 注意要在输出上下文ec的前面关闭,若ec提前关闭,此步会发生异常
	avio_closep(&ec->pb);
	// 关闭输出上下文
	avformat_free_context(ec);					// 注意,该函数传入的只是一级指针,也就是说没有办法改变指针的指向,所以需要手动置为NULL
	ec = nullptr;

	return 0;
}

截取的原理和相关的注意事项已经在代码里讲得很清楚了,这里谈谈av_rescale_q_rnd()这个函数,这个函数是用于不同时间基数的转换,比如 MP4TS, MP4封装格式中视频流的time_base12800, TS封装格式中视频流的time_base90000, 那如果在转换完成后不对音视频packet的time_base进行转换,那么很可能在播放时就会出现音视频不同步的问题,严重一点可能转换后的视频根本就播不了,上面的例子中由于仅仅是从源视频的10s位置处开始截取,输出的文件格式不变, 因此音视频流的time_base实际上也没有发生变化,所以这部分code其实可以简化成:

pkt.pts -= begin_video_pts;
pkt.dts -= begin_video_pts;