音视频技术应用(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()
这个函数,这个函数是用于不同时间基数的转换,比如 MP4
转TS
, MP4
封装格式中视频流的time_base
是12800
, TS
封装格式中视频流的time_base
是90000
, 那如果在转换完成后不对音视频packet的time_base
进行转换,那么很可能在播放时就会出现音视频不同步的问题,严重一点可能转换后的视频根本就播不了,上面的例子中由于仅仅是从源视频的10s位置处开始截取,输出的文件格式不变, 因此音视频流的time_base
实际上也没有发生变化,所以这部分code其实可以简化成:
pkt.pts -= begin_video_pts;
pkt.dts -= begin_video_pts;