购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

3.2
分离音视频

本节介绍如何利用FFmpeg分离视频文件中的音视频数据,首先描述数据包的读写函数的用法,以及如何原样复制一个视频文件;然后叙述数据包的内部字段含义,以及如何从视频文件剥离音频流;最后阐述数据包的寻找函数用法,以及如何按照时间片切割视频文件。

3.2.1 原样复制视频文件

除查看音视频文件的参数信息外,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.2 从视频文件剥离音频流

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

3.2.3 切割视频文件

除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表示切割结束时间。 yljKXeolLtdwEqLeSUp/7euLCgmV6ipghZRydkPs/QFrGxy1RNQcoIsOmfg45YPq

    ffmpeg -ss 00:00:07 -i ../fuzhou.mp4 -to 00:00:15 -c copy ff_cut_video.mp4
点击中间区域
呼出菜单
上一章
目录
下一章
×