本节介绍如何利用FFmpeg分离视频文件中的音视频数据,首先描述数据包的读写函数的用法,以及如何原样复制一个视频文件;然后叙述数据包的内部字段含义,以及如何从视频文件剥离音频流;最后阐述数据包的寻找函数用法,以及如何按照时间片切割视频文件。
除查看音视频文件的参数信息外,FFmpeg还能用来加工编辑音视频文件,其中最简单的应用便是原样复制文件。复制音视频文件的核心操作包括两个部分,第一个部分是从源文件依次读取每个数据包,第二个部分是向目标文件依次写入每个数据包。其中读取数据包用到了av_read_frame函数,写入数据包用到了av_write_frame函数,有时使用av_interleaved_write_frame代替av_write_frame。这几个函数的用法说明如下。
· av_read_frame:从源文件读取一个音视频数据包。返回值为0表示读取成功,小于0表示读取失败。
· av_write_frame:向目标文件直接写入一个音视频数据包。返回值为0表示写入成功,小于0表示写入失败。
· av_interleaved_write_frame:与av_write_frame类似,区别在于写入时会自动缓存和重新排序。具体而言,该函数会比较当前数据包与上次数据包的DTS,根据DTS的大小判断它们的解码先后顺序,再对两个数据包重新排序写入。如果是读写本地的音视频文件,那么av_write_frame和av_interleaved_write_frame两个函数没什么区别,因为文件内部的数据包DTS已经排好序了。只有在网络上传输音视频数据的时候,才需要考虑对数据包重新排序,因为可能出现由于网络抖动导致数据包顺序错乱的情况,所以有必要纠正数据包排序被打乱的问题。
注意无论是av_read_frame还是av_write_frame,它们实际上都在操作AVPacket变量,而非AVFrame变量。初学者有时望文生义,误以为av_read_frame和av_write_frame会操作AVFrame变量,确实本来按照函数的命名规范,这两个函数理应叫作av_read_packet和av_write_packet,只不过截至FFmpeg 5.x版本,它们的函数名称还是没改过来,所以姑且先这么用。
当然,读取数据包和写入数据包只是文件复制的核心操作,具体到复制音视频文件的完整实现上,还需纳入文件打开、文件关闭、写文件头、写文件尾等详细操作过程。比如对源文件调用读取函数av_read_frame之前,得先依次进行avformat_open_input→avformat_find_stream_info→av_find_best_stream等函数调用;等到全部读取完毕,还要调用avformat_close_input函数关闭源文件。对目标文件调用写入函数av_write_frame之前,得先依次进行avformat_alloc_output_context2→avio_open→avformat_new_stream→avformat_write_header等函数调用;等到全部写入完毕,还要依次进行av_write_trailer→avio_close→avformat_free_context等函数调用。
综合源文件(输入文件)的完整读取流程,以及目标文件(输出文件)的完整写入流程,编写FFmpeg的音视频文件的复制代码如下(完整代码见chapter03/copyfile.c)。
AVFormatContext *in_fmt_ctx = NULL; // 输入文件的封装器实例 // 打开音视频文件 int ret = avformat_open_input(&in_fmt_ctx, src_name, NULL, NULL); if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "Can't open file %s.\n", src_name); return -1; } av_log(NULL, AV_LOG_INFO, "Success open input_file %s.\n", src_name); // 查找音视频文件中的流信息 ret = avformat_find_stream_info(in_fmt_ctx, NULL); if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "Can't find stream information.\n"); return -1; } AVStream *src_video = NULL; // 找到视频流的索引 int video_index = av_find_best_stream(in_fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0); if (video_index >= 0) { src_video = in_fmt_ctx->streams[video_index]; } AVStream *src_audio = NULL; // 找到音频流的索引 int audio_index = av_find_best_stream(in_fmt_ctx, AVMEDIA_TYPE_AUDIO, -1, -1, NULL, 0); if (audio_index >= 0) { src_audio = in_fmt_ctx->streams[audio_index]; } AVFormatContext *out_fmt_ctx; // 输出文件的封装器实例 // 分配音视频文件的封装实例 ret = avformat_alloc_output_context2(&out_fmt_ctx, NULL, NULL, dest_name); if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "Can't alloc output_file %s.\n", dest_name); return -1; } // 打开输出流 ret = avio_open(&out_fmt_ctx->pb, dest_name, AVIO_FLAG_READ_WRITE); if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "Can't open output_file %s.\n", dest_name); return -1; } av_log(NULL, AV_LOG_INFO, "Success open output_file %s.\n", dest_name); if (video_index >= 0) { // 源文件有视频流,就给目标文件创建视频流 AVStream *dest_video = avformat_new_stream(out_fmt_ctx, NULL); // 创建数据流 // 把源文件的视频参数原样复制过来 avcodec_parameters_copy(dest_video->codecpar, src_video->codecpar); dest_video->time_base = src_video->time_base; dest_video->codecpar->codec_tag = 0; } if (audio_index >= 0) { // 源文件有音频流,就给目标文件创建音频流 AVStream *dest_audio = avformat_new_stream(out_fmt_ctx, NULL); // 创建数据流 // 把源文件的音频参数原样复制过来 avcodec_parameters_copy(dest_audio->codecpar, src_audio->codecpar); dest_audio->codecpar->codec_tag = 0; } ret = avformat_write_header(out_fmt_ctx, NULL); // 写文件头 if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "write file_header occur error %d.\n", ret); return -1; } av_log(NULL, AV_LOG_INFO, "Success write file_header.\n"); AVPacket *packet = av_packet_alloc(); // 分配一个数据包 while (av_read_frame(in_fmt_ctx, packet) >= 0) { // 轮询数据包 // 有的文件视频流没在第一路,需要调整到第一路,因为目标的视频流默认在第一路 if (packet->stream_index == video_index) { // 视频包 packet->stream_index = 0; ret = av_write_frame(out_fmt_ctx, packet); // 往文件写入一个数据包 } else { // 音频包 packet->stream_index = 1; ret = av_write_frame(out_fmt_ctx, packet); // 往文件写入一个数据包 } if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "write frame occur error %d.\n", ret); break; } av_packet_unref(packet); // 清除数据包 } av_write_trailer(out_fmt_ctx); // 写文件尾 av_log(NULL, AV_LOG_INFO, "Success copy file.\n"); av_packet_free(&packet); // 释放数据包资源 avio_close(out_fmt_ctx->pb); // 关闭输出流 avformat_free_context(out_fmt_ctx); // 释放封装器的实例 avformat_close_input(&in_fmt_ctx); // 关闭音视频文件
接着执行下面的编译命令:
gcc copyfile.c -o copyfile -I/usr/local/ffmpeg/include -L/usr/local/ffmpeg/lib -lavformat -lavdevice -lavfilter -lavcodec -lavutil -lswscale -lswresample -lpostproc -lm
编译完成后,执行以下命令启动测试程序,期望原样复制指定的视频文件。
./copyfile ../fuzhou.mp4
程序运行完毕,发现控制台输出以下日志信息,说明成功把源文件复制到了目标文件output_copyfile.mp4。
Success open input_file ../fuzhou.mp4. Success open output_file output_copyfile.mp4. Success write file_header. Success copy file.
最后打开影音播放器,可以正常播放output_copyfile.mp4,表明上述代码正确实现了文件复制功能。
运行下面的ffmpeg命令也可以原样复制视频文件。命令中的-c copy表示原样复制。
ffmpeg -i ../fuzhou.mp4 -c copy ff_same_copy.mp4
3.2.1节通过av_read_frame和av_write_frame两个函数实现了文件复制功能,其中音视频数据的载体位于AVPacket类型的数据包,AVPacket结构内部的字段说明如下。
· pts:数据包的播放时间戳。
· dts:数据包的解码时间戳。
· data:数据包的内容指针。
· size:数据包的大小,单位为字节。
· stream_index:数据包归属数据流的索引值。在视频文件中,视频包的索引值通常为0,音频包的索引值通常为1。
· duration:数据包的持续时间。时间单位参考time_base字段的时间基。
· pos:数据包在当前数据流中的位置。
· time_base:数据包的时间基。
虽然AVPacket结构包含的字段不少,但是很多字段不会由开发者直接处理,开发者通常只关注其中的stream_index字段。根据stream_index字段的取值,可以判断某个数据包究竟是归属视频流还是归属音频流,据此能够对视频包和音频包分别处理。最简单的应用就是从视频文件中剥离音频流(或者单独保存视频流),对源文件调用av_read_frame函数获取数据包之后,检查该包的stream_index字段,如果索引值等于视频流的索引,就调用av_write_frame函数把该视频包写入目标文件。
具体到代码上,剥离音频流与3.2.1节的复制文件相比有两个不同之处,一个是目标文件只需创建视频流,无须创建音频流;另一个是增加判断数据包的stream_index字段,只要把视频包写入目标文件即可。据此编写FFmpeg剥离音频流的代码如下(完整代码见chapter03/peelaudio.c)。
AVFormatContext *in_fmt_ctx = NULL; // 输入文件的封装器实例 // 打开音视频文件 int ret = avformat_open_input(&in_fmt_ctx, src_name, NULL, NULL); if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "Can't open file %s.\n", src_name); return -1; } av_log(NULL, AV_LOG_INFO, "Success open input_file %s.\n", src_name); // 查找音视频文件中的流信息 ret = avformat_find_stream_info(in_fmt_ctx, NULL); if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "Can't find stream information.\n"); return -1; } AVStream *src_video = NULL; // 找到视频流的索引 int video_index = av_find_best_stream(in_fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0); if (video_index >= 0) { src_video = in_fmt_ctx->streams[video_index]; } else { av_log(NULL, AV_LOG_ERROR, "Can't find video stream.\n"); return -1; } AVFormatContext *out_fmt_ctx; // 输出文件的封装器实例 // 分配音视频文件的封装实例 ret = avformat_alloc_output_context2(&out_fmt_ctx, NULL, NULL, dest_name); if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "Can't alloc output_file %s.\n", dest_name); return -1; } // 打开输出流 ret = avio_open(&out_fmt_ctx->pb, dest_name, AVIO_FLAG_READ_WRITE); if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "Can't open output_file %s.\n", dest_name); return -1; } av_log(NULL, AV_LOG_INFO, "Success open output_file %s.\n", dest_name); if (video_index >= 0) { // 源文件有视频流,就给目标文件创建视频流 AVStream *dest_video = avformat_new_stream(out_fmt_ctx, NULL); // 创建数据流 // 把源文件的视频参数原样复制过来 avcodec_parameters_copy(dest_video->codecpar, src_video->codecpar); dest_video->time_base = src_video->time_base; dest_video->codecpar->codec_tag = 0; } ret = avformat_write_header(out_fmt_ctx, NULL); // 写文件头 if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "write file_header occur error %d.\n", ret); return -1; } av_log(NULL, AV_LOG_INFO, "Success write file_header.\n"); AVPacket *packet = av_packet_alloc(); // 分配一个数据包 while (av_read_frame(in_fmt_ctx, packet) >= 0) { // 轮询数据包 if (packet->stream_index == video_index) { // 为视频流 packet->stream_index = 0; // 视频流默认在第一路 ret = av_write_frame(out_fmt_ctx, packet); // 往文件写入一个数据包 if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "write frame occur error %d.\n", ret); break; } } av_packet_unref(packet); // 清除数据包 } av_write_trailer(out_fmt_ctx); // 写文件尾 av_log(NULL, AV_LOG_INFO, "Success peel audio.\n"); av_packet_free(&packet); // 释放数据包资源 avio_close(out_fmt_ctx->pb); // 关闭输出流 avformat_free_context(out_fmt_ctx); // 释放封装器的实例 avformat_close_input(&in_fmt_ctx); // 关闭音视频文件
接着执行下面的编译命令:
gcc peelaudio.c -o peelaudio -I/usr/local/ffmpeg/include -L/usr/local/ffmpeg/lib -lavformat -lavdevice -lavfilter -lavcodec -lavutil -lswscale -lswresample -lpostproc -lm
编译完成后,执行以下命令启动测试程序,期望从指定文件去除音频流。
./peelaudio ../fuzhou.mp4
程序运行完毕,发现控制台输出以下日志信息,说明已经从目标文件成功剥离音频流,只留下视频流。
Success open input_file ../fuzhou.mp4. Success open output_file output_peelaudio.mp4. Success write file_header. Success peel audio.
最后打开影音播放器,可以正常播放output_peelaudio.mp4,可知上述代码正确实现了音频剥离功能。
提示,运行下面的ffmpeg命令也可去除视频文件中的音频流。命令中的-an表示不处理音频流。
ffmpeg -i ../fuzhou.mp4 -c copy -an ff_only_video.mp4
除av_read_frame和av_write_frame这两个读写函数外,FFmpeg还提供了av_seek_frame寻找函数,该函数用于定位指定时间戳的数据包。如果先调用av_seek_frame函数,再调用av_read_frame函数,那么将从指定时间戳位置开始读取,而非从音视频文件的开始位置读取。不过av_seek_frame函数的寻址结果并非指定时间戳所处的精确位置,而是离该时间戳最近的关键帧位置,因为只有关键帧(I帧)才属于完整的视频图像,而P帧和B帧都得参考其他帧才行,所以av_seek_frame函数返回能够单独解码的关键帧。当然,这里所说的关键帧其实是关键帧压缩之后对应的数据包,其类型为AVPacket结构,而非AVFrame结构。
至于av_seek_frame函数要求的时间戳,则由数据流的时间基计算而来。假设某个以秒为单位的时间点为A,则其对应的时间戳=A×频率=A÷时间基=A÷(time_base.num/time_base.den)。鉴于FFmpeg提供了库函数av_q2d,已经封装好了time_base.num/time_base.den,因此A对应的时间戳=A÷av_q2d(time_base)。利用av_seek_frame函数找到指定时间戳的数据包之后,即可将其后的数据包写入目标文件,从而实现视频切割功能。
在写入目标文件之前,还要调整数据包的播放时间戳和解码时间戳,因为新文件的时间戳从0开始计数,所以新的时间戳要用旧的时间戳减去实际寻址的时间戳,也就是减去寻址得到的关键帧时间戳。于是编写FFmpeg切割视频的代码如下(完整代码见chapter03/splitvideo.c)。
double begin_time = 5.0; // 切割开始时间,单位为秒 double end_time = 15.0; // 切割结束时间,单位为秒 // 计算开始切割位置的播放时间戳 int64_t begin_video_pts = begin_time / av_q2d(src_video->time_base); // 计算结束切割位置的播放时间戳 int64_t end_video_pts = end_time / av_q2d(src_video->time_base); av_log(NULL, AV_LOG_INFO, "begin_video_pts=%d, end_video_pts=%d\n", begin_video_pts, end_video_pts); // 寻找关键帧,并非begin_video_pts所处的精确位置,而是离begin_video_pts最近的关键帧 ret = av_seek_frame(in_fmt_ctx, video_index, begin_video_pts, AVSEEK_FLAG_FRAME | AVSEEK_FLAG_BACKWARD); if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "seek video frame occur error %d.\n", ret); return -1; } int64_t key_frame_pts = -1; // 关键帧的播放时间戳 AVPacket *packet = av_packet_alloc(); // 分配一个数据包 while (av_read_frame(in_fmt_ctx, packet) >= 0) { // 轮询数据包 if (packet->stream_index == video_index) { // 为视频流 packet->stream_index = 0; // 视频流默认在第一路 if (key_frame_pts == -1) { // 保存最靠近begin_video_pts的关键帧时间戳 key_frame_pts = packet->pts; } if (packet->pts > key_frame_pts + end_video_pts - begin_video_pts) { break; // 比切割的结束时间大,就结束切割 } packet->pts = packet->pts - key_frame_pts; // 调整视频包的播放时间戳 packet->dts = packet->dts - key_frame_pts; // 调整视频包的解码时间戳 ret = av_write_frame(out_fmt_ctx, packet); // 往文件写入一个数据包 if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "write frame occur error %d.\n", ret); break; } } av_packet_unref(packet); // 清除数据包 }
接着执行下面的编译命令:
gcc splitvideo.c -o splitvideo -I/usr/local/ffmpeg/include -L/usr/local/ffmpeg/lib -lavformat -lavdevice -lavfilter -lavcodec -lavutil -lswscale -lswresample -lpostproc -lm
编译完成后,执行以下命令启动测试程序,期望从指定文件切割出一段视频。
./splitvideo ../fuzhou.mp4
程序运行完毕,发现控制台输出以下日志信息,说明完成了目标文件的切割操作。
Success open input_file ../fuzhou.mp4. Success open output_file output_splitvideo.mp4. Success write file_header. begin_video_pts=64000, end_video_pts=192000 Success split video.
最后打开影音播放器,可以正常播放output_splitvideo.mp4,可知上述代码正确实现了视频切割功能。
运行下面的ffmpeg命令也可从视频文件中切割出一段视频。命令中的-ss 00:00:07表示切割开始时间,-to 00:00:15表示切割结束时间。
ffmpeg -ss 00:00:07 -i ../fuzhou.mp4 -to 00:00:15 -c copy ff_cut_video.mp4