最近更新于 2024-02-20 22:15

1 前言

以前只是单纯拿 FFmpeg 命令行工具来编码视频(图片合成视频、视频格式转换、编码器转换),并没有深入学习过,这次打算尝试一下使用 FFmpeg 的库搞搞开发。

2 测试环境

  • Intel i7-12700H(Iris Xe Graphics)

  • NVIDIA RTX4060 Laptop GPU

  • Windows 11 专业版 23H2

  • ffmpeg-master-latest-win64-gpl-shared – Auto-Build (2024-02-18 12:47):https://github.com/BtbN/FFmpeg-Builds/releases

  • Visual Studio 2022(指定 C17 标准编译测试)

3 配置开发环境

纯配置命令行工具可以参考:https://blog.iyatt.com/?p=8613
这里要做开发,下载的时候选动态库版本,里面也包含了命令行工具
file
file

bin 目录中的是命令行工具(可执行文件),添加到 PATH 环境变量,就能在终端直接执行。FFmpeg 是 C 语言开发的,include 中是头文件,lib 目录中的 lib 文件相当于动态库的索引,bin 目录中的 dll 文件是动态库的实现。
file

file

现在选一个路径创建项目目录,我在桌面创建了一个 FFmpeg 目录。将上面的 includelib 两个目录复制到创建的目录中,另外将上面的 bin 目录中的 .dll 文件也复制到项目目录的 lib 目录中。在项目目录中再创建一个 src 目录,用于存放自己编写的代码。
file

file

file


Visual Studio 中记得勾选安装 C++ 开发,这里面包含 C/C++ 开发的工具链
file

3.1 创建 Visual Studio 项目演示

创建项目演示

打开 VS 创建一个空项目
file
file

路径选在创建的 src 目录
file

新建源码
file

文件命名时注意扩展名写 .c,Visual Studio 默认给的扩展名是 .cpp(C++,C Plus Plus)。谨记 C 语言和 C++ 是两门语言,只是 C++ 基本兼容了 C 语言的语法,但是两者在编译器处理上很大不同,单纯源码兼容性还高一些,这里是直接链接 FFmpeg 的 C 语言库文件(已经按照 C 语言编译),最明显的一点就是 C++ 为了函数重载,在编译时为了区别同名函数,会加上参数类型重命名,而 C 语言没有这个,在已经编译好的库文件中,C++ 引用 C 库就可能出现问题。一定要在 C++ 中引用 C 的话,需要使用 extern “C” { } 把引用 C 头文件部分用大括号括起来。这样编译的是时候在括起来的部分就会自动调用 C 语言编译器来编译,其它部分按照 C++ 编译。
file

file

然后在项目名上右键-属性
file

注意属性页上方可选,是配置给 Debug(调试)还是 Release(发布版)或者所用,以及 64 位配置还是 32 位配置,对应着编译模式。
初学阶段也不涉及发布自己编写的软件可执行程序,不会针对不同版本配置不同的参数,这个其实无所谓。
file

file

在 C/C++ -> 常规 -> 附加包目录 中添加上 include 的路径(两个英文句点代表上级目录)
根据自己的项目目录来,按照我这里的项目结构,在三个上级目录以上才是 include
file
file

同样的在 链接器 -> 常规 -> 附加库目录 写入 lib 路径
file

在 链接器 -> 输入 -> 附加依赖项 中添加上需要使用的库的 lib 文件

下面的测试案例中需要的库文件
file

也可以不在属性里配置 lib 文件,直接在代码中引用头文件后加上对应的语句,比如这里的

#pragma comment(lib, "avutil.lib")

写个简单的例子测试
在前面新建的 .c 文件写入下面内容

#include <stdio.h>

#include <libavutil/avutil.h>

int main()
{
    printf("%s\n", av_version_info());
}

调试运行
这段代码会显示当前 FFmpeg 库的版本
file
file

3.2 创建 CMake 项目演示(Visual Studio 或 Visual Studio Code)

VS 默认项目管理是自己的方式,VS 也是支持 CMake 的,在添加 C++ 开发工具时,里面默认就包含了 CMake,即可以采用 VS + CMake + MSVC 的组合,把 VS 换成 VScode 也行。以前在 Linux 下搞开发的时候,我基本就是用 CMake 管理项目,采用 VScode + CMake + GNU 的搭配,所以我是比较倾向于使用 CMake。VS 项目管理属性里的那一堆东西就全部在 CMakeLists.txt 里配置了,下面我给出了一个参考模板。
file

CMakeLists.txt 文件参考

cmake_minimum_required (VERSION 3.8) # CMake 最低版本要求
project(test) # 项目名称
set(CMAKE_C_STANDARD 17) # C 语言编译标准(C17)
set(exe_name test) # 自定义变量,用于统一设定生成的可执行文件名字,这里设定为 test,就会生成 test.exe 文件

include_directories(${CMAKE_SOURCE_DIR}/../../include/) # 头文件路径 - 路径注意用斜杠,下同
# link_directories(${CMAKE_SOURCE_DIR}/../../lib/) # 链接库方式一。这种方式指定库目录,源码中需要使用 #pragma 链接

set(EXECUTABLE_OUTPUT_PATH ${CMAKE_SOURCE_DIR}/build/) # 指定生成文件路径
add_compile_options(/GS-) # 这里填写的参数会传递给编译器。/CG- 禁用安全检查

# 链接库方式二
# 三段参数:
# ffmpeg 是个变量,存放找到的库
# 要寻找的库文件
# 查找的路径
find_library(ffmpeg avutil.lib ${CMAKE_SOURCE_DIR}/../../lib/)
aux_source_directory(src ${CMAKE_SOURCE_DIR}/) # 自动寻找指定目录下的源文件。${CMAKE_SOURCE_DIR} 变量存储着主脚本文件CMakeLists.txt自己所在路径,即本文件同级目录。
add_executable(${exe_name} main.c) # 编译源文件
target_link_libraries(${exe_name} ${ffmpeg}) # 将上面找到的库文件链接到编译好的目标文件中

# 链接库方式三:手动指定链接,这里列出了所有 FFmpeg 库文件
# set(FFmpeg_LIB_PATH ${CMAKE_SOURCE_DIR}/../../lib/)
# target_link_libraries(${exe_name} 
#     ${FFmpeg_LIB_PATH}/avcodec.lib
#     ${FFmpeg_LIB_PATH}/avdevice.lib
#     ${FFmpeg_LIB_PATH}/avfilter.lib
#     ${FFmpeg_LIB_PATH}/avformat.lib
#     ${FFmpeg_LIB_PATH}/avutil.lib
#     ${FFmpeg_LIB_PATH}/postproc.lib
#     ${FFmpeg_LIB_PATH}/swresample.lib
#     ${FFmpeg_LIB_PATH}/swscale.lib
# )

file

4 一些可能用到的

4.1 VS 禁止安全检查

Visual Studio 非常讨厌的一点就是默认强制使用安全函数,而其它编译器至少我还没见过支持的。实际上安全函数不会用,一样不安全,标准函数用好了一样安全,这个是看使用者。

在项目名上,右键打开属性。点开 C/C++ -> 代码生成 -> 安全检查,选择禁用
file

4.2 文件选择

这里写一个文件选择的 demo,基于 Windows API

#include <Windows.h>
#include <stdio.h> // printf 函数用到

int main()
{
    OPENFILENAME ofn; // 打开文件对话框结构体
    char file_name[MAX_PATH]; // 存储文件路径的缓冲区,打开文件后路径就保存在这里

    // 初始化填充为 0
    ZeroMemory(&ofn, sizeof(ofn));
    ZeroMemory(file_name, sizeof(file_name));

    ofn.lStructSize = sizeof(ofn);
    ofn.lpstrFile = file_name;
    ofn.lpstrFile[0] = '\0';
    ofn.nMaxFile = sizeof(file_name);
    ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST;

    if (GetOpenFileName(&ofn)) // 打开文件对话框
    {
        printf("你选择的路径是:");
        WriteConsole(GetStdHandle(STD_OUTPUT_HANDLE), file_name, wcslen(file_name), NULL, NULL); // 打印到终端,不要使用 printf,这里是宽字符,printf 不能正常显示
    }

    return 0;
}

file
file

5 FFmpeg 官方参考案例

官方文档:https://ffmpeg.org/documentation.html
选择 API 版本进入对应的文档,在 API 文档中 Examples 栏中有案例程序
file

我打算是把参考案例理一遍搞懂,可能做一定功能修改,添加中文注释,再放到下文中,基于 API 6.0。

5.1 视频格式转换

这个案例来源于 remux.c
单纯重新打包文件格式,不会重新编码。传入两个参数,第一个参数输入视频,第二个参数为要生成的视频文件名,程序通过扩展名识别视频格式。

相当于命令

ffmpeg -i 输入视频 -c:v copy 输出视频
#include <libavutil/timestamp.h>
#include <libavformat/avformat.h>

/**
 * @brief 打印 AVPacket 信息
 * @param fmt_ctx
 * @param pkt
 * @param tag
 */
static void log_packet(const AVFormatContext* fmt_ctx, const AVPacket* pkt, const char* tag)
{
    AVRational* time_base = &fmt_ctx->streams[pkt->stream_index]->time_base;

    printf("%s: pts:%s pts_time:%s dts:%s dts_time:%s duration:%s duration_time:%s stream_index:%d\n",
        tag,
        av_ts2str(pkt->pts), av_ts2timestr(pkt->pts, time_base),
        av_ts2str(pkt->dts), av_ts2timestr(pkt->dts, time_base),
        av_ts2str(pkt->duration), av_ts2timestr(pkt->duration, time_base),
        pkt->stream_index);
}

int main(int argc, char** argv)
{
    if (argc < 3)
    {
        printf("用法:%s 输入 输出\n"
            "使用 libavformat 和 libavcodec 转换视频格式\n"
            "根据文件扩展名推测格式\n", argv[0]);
        return 1;
    }

    AVFormatContext* ifmt_ctx = NULL; // 用于储存输入格式的上下文信息
    const char* in_filename = argv[1]; // 从命令行参数列表获取输入文件名
    int ret; // 用于接受后续多个函数返回值,以便判断异常
    if ((ret = avformat_open_input(&ifmt_ctx, in_filename, NULL, NULL)) < 0)
    {
        fprintf(stderr, "不能打开输入文件:%s\n", in_filename);
        goto end;
    }

    if ((ret = avformat_find_stream_info(ifmt_ctx, 0)) < 0)
    {
        fprintf(stderr, "检索输入流信息失败\n");
        goto end;
    }

    av_dump_format(ifmt_ctx, 0, in_filename, 0); // 打印输入文件的格式和流信息

    const char* out_filename = argv[2]; // 从命令行参数列表获取输出文件名
    AVFormatContext* ofmt_ctx = NULL; // 用于储存输出文件的音视频格式的上下文信息
    avformat_alloc_output_context2(&ofmt_ctx, NULL, NULL, out_filename);
    if (!ofmt_ctx)
    {
        fprintf(stderr, "不能创建输出格式上下文\n");
        ret = AVERROR_UNKNOWN;
        goto end;
    }

    unsigned int stream_mapping_size = ifmt_ctx->nb_streams; // 获取输入文件的流个数
    int* stream_mapping = av_calloc(stream_mapping_size, sizeof(int*));
    if (!stream_mapping)
    {
        ret = AVERROR(ENOMEM);
        goto end;
    }

    const AVOutputFormat* ofmt = ofmt_ctx->oformat; // 获取输出格式上下文的格式

    int stream_index = 0;

    // 遍历输入文件的每个流
    for (unsigned int i = 0; i < stream_mapping_size; ++i)
    {
        AVStream* in_stream = ifmt_ctx->streams[i]; // 输入流
        AVCodecParameters* in_codecpar = in_stream->codecpar; // 获取输入流的编解码参数

        if (in_codecpar->codec_type != AVMEDIA_TYPE_AUDIO &&
            in_codecpar->codec_type != AVMEDIA_TYPE_VIDEO &&
            in_codecpar->codec_type != AVMEDIA_TYPE_SUBTITLE) // 输入类型不是音频、视频或做字幕时,不复制该流
        {
            stream_mapping[i] = -1;
            continue;
        }

        stream_mapping[i] = stream_index++;

        AVStream* out_stream = avformat_new_stream(ofmt_ctx, NULL);
        if (!out_stream)
        {
            fprintf(stderr, "创建输出流失败\n");
            ret = AVERROR_UNKNOWN;
            goto end;
        }

        if ((ret = avcodec_parameters_copy(out_stream->codecpar, in_codecpar)) < 0)
        {
            fprintf(stderr, "复制输入流的编解码参数到输出流失败\n");
            goto end;
        }
        out_stream->codecpar->codec_tag = 0; // 设置为 0,让 FFmpeg 自己选择
    }

    av_dump_format(ofmt_ctx, 0, out_filename, 1); // 打印输出文件的格式和流信息

    if (!(ofmt->flags & AVFMT_NOFILE)) // 是否需要输出文件
    {
        if ((ret = avio_open(&ofmt_ctx->pb, out_filename, AVIO_FLAG_WRITE)) < 0)
        {
            fprintf(stderr, "打开输出文件失败: %s\n", out_filename);
            goto end;
        }
    }

    if ((ret = avformat_write_header(ofmt_ctx, NULL)) < 0)
    {
        fprintf(stderr, "在写入输出文件的头部信息时发生错误\n");
        goto end;
    }

    AVPacket* pkt = av_packet_alloc(); // 分配空间,用于存储压缩数据(视频、音频)的数据结构
    if (!pkt)
    {
        fprintf(stderr, "不能创建 AVPacket\n");
        return 1;
    }

    while (1)
    {
        if ((ret = av_read_frame(ifmt_ctx, pkt)) < 0)
        {
            break;
        }

        AVStream* in_stream = ifmt_ctx->streams[pkt->stream_index];
        if (pkt->stream_index >= stream_mapping_size ||
            stream_mapping[pkt->stream_index] < 0)
        {
            av_packet_unref(pkt);
            continue;
        }

        pkt->stream_index = stream_mapping[pkt->stream_index];
        AVStream* out_stream = ofmt_ctx->streams[pkt->stream_index];
        log_packet(ifmt_ctx, pkt, "in");

        av_packet_rescale_ts(pkt, in_stream->time_base, out_stream->time_base);
        pkt->pos = -1;
        log_packet(ofmt_ctx, pkt, "out");

        if ((ret = av_interleaved_write_frame(ofmt_ctx, pkt)) < 0)
        {
            fprintf(stderr, "Error muxing packet\n");
            break;
        }
    }

    av_write_trailer(ofmt_ctx);

end:
    av_packet_free(&pkt);

    avformat_close_input(&ifmt_ctx);

    if (ofmt_ctx && !(ofmt->flags & AVFMT_NOFILE))
    {
        avio_closep(&ofmt_ctx->pb);
    }
    avformat_free_context(ofmt_ctx);

    av_freep(&stream_mapping);

    if (ret < 0 && ret != AVERROR_EOF)
    {
        fprintf(stderr, "发生错误:%s\n", av_err2str(ret));
        return 1;
    }

    return 0;
}

file