简介:本资源是一套面向C#开发者与多媒体应用工程师的FFmpeg底层调用实践工程,聚焦于通过FFmpeg.AutoGen库在.NET环境中原生调用FFmpeg 3.4 API,解决音视频解码、流信息解析、帧级数据处理等核心问题。资源包共148个文件,包含111个FFmpeg头文件(.h)用于接口映射、16个动态链接库(.dll)及运行时依赖、8个关键C#源码(.cs)实现初始化、媒体打开、解码器配置与帧解码全流程,另有可执行程序(.exe)、配置文件(.config)及WinForm界面代码,完整复现从环境搭建到音视频帧提取的端到端开发链路。目前已有2065人学习下载,项目结构清晰,含frmPlayer播放器窗体、FFmpegBinariesHelper二进制加载辅助类及多层设计器文件,便于理解跨语言封装机制与资源生命周期管理,是深入掌握C#多媒体底层开发不可多得的实操参考。 做音视频处理的老哥应该都遇到过这个场景:产品经理甩来一个视频,说“帮我把每一帧抽出来给算法跑一下”。你第一反应是装个 FFmpeg 命令行, Process.Start 一行接一行地调,数据往文件里写,再回头去读文件。能用,但总觉得别扭,尤其是要抽几百帧、还要跟算法联动的时候,磁盘和进程切换的代价特别大。后来我把 C# 这边直接调用 FFmpeg API 的路子彻底捋顺了,核心库就是 FFmpeg.AutoGen ,视频帧直接进内存,转码、抽帧、加滤镜全在进程内完成,不用再经过临时文件。这篇文章就把完整思路、环境配置和能跑的代码写出来,适合已经写过一点 C#、想摆脱命令行调 FFmpeg 的人,也适合被“DllNotFoundException”折磨到想砸电脑的新手。

我不会把例子写成一个只能打印“hello ffmpeg”的玩具。咱直接从“打开视频、定位视频流、解码一帧、转成 RGB 图像”这条完整链路走一遍,代码我都验证过基本套路,你看完能直接改成自己的工具类。

1. 项目整体思路:到底该不该直接用 FFmpeg API

1.1 从命令行到 API 的取舍

很多人最开始接触 FFmpeg,用的都是命令行:

ffmpeg -i input.mp4 -vf "select=eq(n\,100)" -frames:v 1 out.png

这条命令能截图,但问题也不少。每执行一次就要启动一个进程,光初始化就得几十毫秒甚至上百毫秒;参数一旦复杂起来,转义地狱先不说,日志解析也让人头大;如果你要连续处理几千个视频,进程反复创建销毁,性能会很难看。更麻烦的是,命令行模式很难做到“边解码边喂给算法”,因为数据要先落到磁盘,再被另一个程序读回来,白白多了一次磁盘 IO。

而直接在 C# 里调用 FFmpeg API,等于把 FFmpeg 当成一个库嵌在自己的进程里。 avformat_open_input 打开文件, av_read_frame 读包, avcodec_send_packet 和 avcodec_receive_frame 解出原始帧,整个过程全在内存里,帧数据拿到手想做卷积、做检测、转 Bitmap 都行。这就是我用 FFmpeg.AutoGen 而不是套壳库的原因。它不是再包一层命令行,而是把 FFmpeg 的头文件翻译成了 C# 的 P/Invoke 声明,让你能用接近 C 的姿势直接操作 FFmpeg 内部结构体。

当然,有得必有失。直接调 API 的代价是代码啰嗦,错误处理必须自己写,FFmpeg 版本一变,结构体字段可能要跟着改。但如果你的需求是高性能抽帧、实时流处理、自定义滤镜链,这个代价是值得的。

1.2 FFmpeg.AutoGen 和其他方案怎么选

我见过不少人一上来就问“C# 怎么用 FFmpeg”,然后在网上搜到一堆包装好的 NuGet 包。常见的几条路大概是:

方案 本质 优点 缺点
直接 Process 调 ffmpeg.exe 命令行 简单、隔离性最好 性能一般,帧级操作难做,依赖本机安装的 ffmpeg
Xabe.FFmpeg / FFMpegCore 封装命令行 日常转码截图够用,API 友好 底层还是启动进程,不适合高频帧处理和流式处理
FFmpeg.AutoGen P/Invoke 绑定 直接调 native API,性能高、灵活、不吃临时文件 需要手动管内存,版本必须匹配,代码门槛高
自己手写 P/Invoke 底层绑定 完全可控 维护成本极高,FFmpeg 结构体太多,不现实

如果你只是偶尔把 mp4 转成 mp3,用命令行封装库确实省事。我选择 FFmpeg.AutoGen 的典型场景是:程序跑在服务器上,需要把视频流一帧一帧解出来,塞给 GPU 推理,中间不落盘。这种场景下,每毫秒都很关键,进程启动开销完全不能忍。

还有一个容易被忽略的问题:团队协作时,命令行方案要求每台部署机器上都装好 FFmpeg 并配置 PATH,版本还不一定统一。用共享库的方案,可以把 avcodec-*.dll 、 avformat-*.dll 等直接放进程序目录,部署时一起带上,环境一致性更好。

1.3 一个关键前提:版本必须配套

这句话我要放在最前面: FFmpeg.AutoGen 的版本必须跟你实际使用的 FFmpeg 动态库版本配套,差一个大版本都可能会随机崩溃。

原因很简单。FFmpeg 的 C 接口虽然整体稳定,但结构体布局、函数签名在不同主版本之间是有变化的。 FFmpeg.AutoGen 是根据某个特定版本的源码“拍平”出来的 C# 声明,比如 6.x 的绑定对应 FFmpeg 6.x,7.x 的绑定对应 FFmpeg 7.x。如果你拿着 7.x 的绑定去加载 6.x 的 avformat-60.dll ,有些字段偏移量对不上,轻则返回错误码,重则直接 AccessViolationException 。

我自己的经验是: 先定 FFmpeg 版本,再去 NuGet 找对应版本的 FFmpeg.AutoGen 。不要随手装最新包,也不要随手下载最新版 FFmpeg,否则你会陷入“为什么报错千奇百怪”的泥潭。

2. 环境准备:把 FFmpeg 动态库和 AutoGen 绑到一起

2.1 获取 FFmpeg 共享库

我们要的不是那个 ffmpeg.exe 的 release 包,而是 shared 版本。shared 版本里不仅有两个 exe,还有一堆 DLL: avcodec-*.dll 、 avformat-*.dll 、 avutil-*.dll 、 swscale-*.dll 、 swresample-*.dll 、 avfilter-*.dll 等。我们需要的就是这些 DLL。

Windows 上比较常见的获取渠道是:

  • BtbN 的 GitHub Actions 构建产物,选 ffmpeg-master-latest-win64-gpl-shared.zip 或者带版本号的 shared 包。
  • gyan.dev 的 release 包,选 full / shared 版本。

下载完解压后,你会看到 bin 目录,里面有一堆 DLL。这些 DLL 要能被 C# 程序找到。最省事的做法是:在项目根目录建一个 ffmpeg 文件夹,把这些 DLL 全复制进去,然后在 csproj 里设置“复制到输出目录”。

<ItemGroup>
  <None Include="ffmpeg\**\*.dll">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    <Link>ffmpeg\%(RecursiveDir)%(Filename)%(Extension)</Link>
  </None>
</ItemGroup>

如果你不想把 DLL 打进项目里,也可以在代码里手动指定 FFmpeg 目录。后面我会说具体怎么写。总之,程序要能在运行目录或 PATH 下找到 avformat-*.dll ,否则第一行调用就会抛 DllNotFoundException 。

2.2 NuGet 引入 FFmpeg.AutoGen

在项目里执行:

dotnet add package FFmpeg.AutoGen

或者用 Visual Studio 的 NuGet 包管理器搜索 FFmpeg.AutoGen ,安装时注意看版本。

安装完之后,你会在代码里看到一个 FFmpeg.AutoGen.ffmpeg 静态类,所有 P/Invoke 方法都挂在它下面。这个类命名是小写 ffmpeg ,别写错了。我最早踩过的坑就是 using FFmpeg.AutoGen; 之后找不到 FFmpeg 类,找了半天才发现它就叫 ffmpeg 。

接下来,要让项目允许 unsafe 代码,因为我们操作指针离不开它。在 csproj 里加上:

<PropertyGroup>
  <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

别嫌麻烦,FFmpeg 的 C 接口到处都是指针, FFmpeg.AutoGen 生成的 API 天然就是 unsafe 的。

2.3 处理 DLL 加载路径

如果你把 DLL 复制到了程序根目录,通常在 Windows 上能直接加载。但如果你放在 ffmpeg 子目录,光靠默认搜索路径是不够的。我会在主程序启动时调用一次 SetDllDirectory ,把 FFmpeg 目录加进进程搜索路径。

internal static class FFmpegLoader
{
    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern bool SetDllDirectory(string lpPathName);

    public static void Configure()
    {
        var ffmpegDir = Path.Combine(AppContext.BaseDirectory, "ffmpeg");
        if (Directory.Exists(ffmpegDir))
        {
            if (!SetDllDirectory(ffmpegDir))
                throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
        }
    }
}

然后启动时:

FFmpegLoader.Configure();

再调用 FFmpeg 的任意 API。或者,.NET Core 3.0+ 还提供了 NativeLibrary.SetDllImportResolver ,可以更精确地控制某个程序集加载非托管库的行为。但如果只是本机部署, SetDllDirectory 已经够简单了。

2.4 用 av_version_info 验证环境

环境配好后,先跑一个小例子验证能不能加载成功:

using FFmpeg.AutoGen;

unsafe
{
    byte* version = ffmpeg.av_version_info();
    Console.WriteLine(Marshal.PtrToStringAnsi((IntPtr)version));
}

如果能打印出类似 6.1.1 、 7.0.2 的版本号,说明 DLL 加载和版本匹配都没问题。如果这里就抛 DllNotFoundException ,那先别往下写业务代码,回头检查 DLL 目录、位数和版本。这一步一定要先跑通,不然后面所有调试都像是在猜灯谜。

3. 核心实现:用 FFmpeg.AutoGen 打开视频并抽帧

3.1 打开媒体文件并读取基本信息

先写最基础的一段:打开一个视频文件,拿到总时长和流数量。

using FFmpeg.AutoGen;

unsafe
{
    ffmpeg.av_log_set_level(ffmpeg.AV_LOG_ERROR);

    AVFormatContext* pFormatContext = null;
    var ret = ffmpeg.avformat_open_input(
        &pFormatContext,
        @"D:\videos\input.mp4",
        null,
        null);

    if (ret < 0)
        throw new InvalidOperationException($"打开文件失败,错误码: {ret}");

    try
    {
        ret = ffmpeg.avformat_find_stream_info(pFormatContext, null);
        if (ret < 0)
            throw new InvalidOperationException($"读取流信息失败,错误码: {ret}");

        var durationSeconds = pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE;
        Console.WriteLine($"文件时长: {durationSeconds:F2} 秒");
        Console.WriteLine($"流数量: {pFormatContext->nb_streams}");
    }
    finally
    {
        ffmpeg.avformat_close_input(&pFormatContext);
    }
}

这里有个细节要注意: pFormatContext 初始必须是 null ,让 FFmpeg 内部自己分配。千万不要先 avformat_alloc_context 再传进去,虽然有时也能跑,但容易把释放逻辑搞乱。最后用 avformat_close_input 关闭,它会负责释放内部资源。

AV_TIME_BASE 是 FFmpeg 内部的时间基,等于 1000000, duration 字段单位是微秒。除以 1000000 就是秒。如果你需要更精确的按流时间基计算,得去读 AVStream.time_base ,这个后面会用到。

3.2 定位视频流并打开解码器

文件打开之后,要遍历所有流,找到类型为视频的那个。然后从 stream->codecpar 拿到编码参数,找到对应解码器,并创建一个解码上下文。

int videoStreamIndex = -1;
AVCodec* pCodec = null;
AVCodecContext* pCodecContext = null;

for (int i = 0; i < pFormatContext->nb_streams; i++)
{
    var stream = pFormatContext->streams[i];
    if (stream->codecpar->codec_type == AVMediaType.AVMEDIA_TYPE_VIDEO)
    {
        videoStreamIndex = i;
        pCodec = ffmpeg.avcodec_find_decoder(stream->codecpar->codec_id);
        if (pCodec == null)
            throw new InvalidOperationException("找不到对应解码器");

        pCodecContext = ffmpeg.avcodec_alloc_context3(pCodec);
        if (pCodecContext == null)
            throw new InvalidOperationException("创建解码上下文失败");

        if (ffmpeg.avcodec_parameters_to_context(pCodecContext, stream->codecpar) < 0)
            throw new InvalidOperationException("复制编码参数失败");

        if (ffmpeg.avcodec_open2(pCodecContext, pCodec, null) < 0)
            throw new InvalidOperationException("打开解码器失败");

        break;
    }
}

if (videoStreamIndex < 0 || pCodecContext == null)
    throw new InvalidOperationException("视频流不存在");

你可能注意到,这里我没有直接复制 AVCodecParameters 的宽高、像素格式到局部变量,而是通过 avcodec_parameters_to_context 把参数灌进 AVCodecContext 。这也是官方推荐的做法,因为有些参数在解码上下文里会被更新,比如某些码流需要解码后才能确定真实分辨率。

avcodec_alloc_context3 只分配上下文,真正的打开动作是 avcodec_open2 。在打开之后, pCodecContext->width 、 pCodecContext->height 、 pCodecContext->pix_fmt 才是解码后帧的格式信息。

3.3 循环解码:send_packet 和 receive_frame

FFmpeg 现代版本的解码接口和以前不一样了。以前是 avcodec_decode_video2 一把梭,现在是“生产者-消费者”模式:

  • avcodec_send_packet :把压缩数据包喂给解码器。
  • avcodec_receive_frame :从解码器里取解出来的原始帧。

这种设计允许解码器内部维护多帧缓冲,处理 B 帧、延迟帧更科学。循环读包的基本结构是这样:

var pPacket = ffmpeg.av_packet_alloc();
var pFrame = ffmpeg.av_frame_alloc();

int frameCount = 0;

try
{
    while (ffmpeg.av_read_frame(pFormatContext, pPacket) >= 0)
    {
        if (pPacket->stream_index != videoStreamIndex)
        {
            ffmpeg.av_packet_unref(pPacket);
            continue;
        }

        var sendRet = ffmpeg.avcodec_send_packet(pCodecContext, pPacket);
        ffmpeg.av_packet_unref(pPacket);

        if (sendRet < 0)
            break;

        while (true)
        {
            var recvRet = ffmpeg.avcodec_receive_frame(pCodecContext, pFrame);
            if (recvRet == ffmpeg.AVERROR(ffmpeg.EAGAIN) || recvRet == ffmpeg.AVERROR_EOF)
                break;

            if (recvRet < 0)
                throw new InvalidOperationException($"解码失败,错误码: {recvRet}");

            frameCount++;
            Console.WriteLine($"第 {frameCount} 帧,尺寸: {pFrame->width}x{pFrame->height}");

            // 这里就是拿帧数据的地方,下一节会讲怎么转 RGB
            ffmpeg.av_frame_unref(pFrame);
        }
    }
}
finally
{
    ffmpeg.av_frame_free(&pFrame);
    ffmpeg.av_packet_free(&pPacket);
}

这里有个新手容易踩的坑: av_read_frame 返回的 AVPacket ,必须调用 av_packet_unref ,因为每读一个包,FFmpeg 内部会分配引用计数内存。读下一个包之前不释放,内存会一直涨。尤其处理长视频,内存涨到一定程度,OOM 可能不是 .NET 报的,而是 native 那边崩溃。

avcodec_receive_frame 返回 EAGAIN 表示当前没有可输出的帧,需要继续喂包;返回 EOF 表示解码器输出完毕。还有一个隐藏知识点: 一个 packet 可能解出 0 帧、1 帧,也可能解出多帧 。很多人只调用一次 receive_frame 就以为拿到一帧了,实际上必须用循环把缓冲里的帧全部取干净。

3.4 把 AVFrame 转成 RGB 图像

解码出来的 AVFrame 像素格式通常是 YUV420P、NV12 这类,不能直接拿给 System.Drawing.Bitmap 用。需要用 sws_scale 做颜色空间转换,转成 BGR24 或者 RGB24。为什么是 BGR?因为 Windows 的 GDI+ 位图默认就是 BGR,转成 BGR24 后拷贝到 Bitmap 里最省事。

下面这段是核心转换函数:

private static unsafe byte[] ConvertFrameToBgr24(AVCodecContext* pCodecContext, AVFrame* pFrame)
{
    var swsContext = ffmpeg.sws_getContext(
        pCodecContext->width,
        pCodecContext->height,
        (AVPixelFormat)pCodecContext->pix_fmt,
        pFrame->width,
        pFrame->height,
        AVPixelFormat.AV_PIX_FMT_BGR24,
        ffmpeg.SWS_FAST_BILINEAR,
        null,
        null,
        null);

    if (swsContext == null)
        throw new InvalidOperationException("无法创建图像转换上下文");

    try
    {
        var width = pFrame->width;
        var height = pFrame->height;
        var bgrData = new byte[width * height * 3];

        fixed (byte* pBgr = bgrData)
        {
            var dstData = new byte*[4];
            var dstLinesize = new int[4];

            dstData[0] = pBgr;
            dstLinesize[0] = width * 3;

            ffmpeg.sws_scale(
                swsContext,
                pFrame->data,
                pFrame->linesize,
                0,
                height,
                dstData,
                dstLinesize);
        }

        return bgrData;
    }
    finally
    {
        ffmpeg.sws_freeContext(swsContext);
    }
}

再强调一次: sws_getContext 是有开销的。如果一个视频要抽几十帧,不要每帧都创建和释放 SwsContext ,把它提到循环外面,创建一个,反复用。上面这个函数为了简洁是每帧创建,但真实项目一定要复用。

拿到 bgrData 之后,如果一定要生成 Bitmap ,可以这样:

using var bmp = new Bitmap(width, height, PixelFormat.Format24bppRgb);
var bmpData = bmp.LockBits(
    new Rectangle(0, 0, width, height),
    ImageLockMode.WriteOnly,
    PixelFormat.Format24bppRgb);

try
{
    var srcStride = width * 3;
    for (int y = 0; y < height; y++)
    {
        byte* src = bgrData + y * srcStride;
        byte* dst = (byte*)bmpData.Scan0 + y * bmpData.Stride;
        Buffer.MemoryCopy(src, dst, bmpData.Stride, srcStride);
    }
}
finally
{
    bmp.UnlockBits(bmpData);
}

特别注意: bgrData 每行是 width * 3 ,但 Bitmap 的 Stride 不一定等于这个值。系统为了内存对齐, Stride 可能比 width * 3 大一点。所以不能直接整块 Marshal.Copy ,要一行一行地把有效像素复制过去,用 Stride 做跳步。我用 Buffer.MemoryCopy 就是为了配合指针在 unsafe 环境里高效拷贝。

如果你的算法不需要真正显示图片,比如只是算颜色直方图、做目标检测,拿到 bgrData 就够了,甚至可以改成 RGB24 或者直接吃 YUV 数据,省一次转换开销。这个优化点很多人容易忽略。

4. 常见问题与排查经验实录

4.1 DllNotFoundException 还是 avformat 加载失败

这是出现频率最高的问题。我的排查顺序是:

  1. 确认程序输出目录里有没有 avformat-*.dll 、 avcodec-*.dll 、 avutil-*.dll 等文件,数量是否完整。
  2. 确认 FFmpeg 版本跟 FFmpeg.AutoGen 包版本是否对应。比如 AutoGen 6.x 就不太可能直接加载只有 avformat-61.dll 的 FFmpeg 7.x 包。
  3. 确认项目没有开“Prefer 32-bit”。FFmpeg 的 windows 构建基本都是 x64,如果你的 C# 程序以 x86 模式运行,会直接加载失败。把 PlatformTarget 设为 x64 。
  4. 用 SetDllDirectory 或 NativeLibrary.SetDllImportResolver 把 DLL 目录加进去。

如果所有都做了还是报错,先用 Dependencies.exe 打开 avformat-*.dll ,看看它依赖的 DLL 是不是缺失了。shared 版本通常带 libgcc_s_seh-1.dll 、 libstdc++-6.dll 、 libwinpthread-1.dll ,这些都是 MinGW 的运行时。你如果只复制了 avformat 开头的几个 DLL,漏了这几个运行时,加载一样会失败。

4.2 AccessViolationException,程序直接崩

这类问题最坑,因为错误可能不是抛在 C# 代码里,而是 native 栈直接非法访问。常见原因:

  • 版本不对。绑定和动态库大版本不一致,结构体字段偏移错位。
  • 使用了已释放的指针。比如 avformat_close_input 之后还在循环里读 streams 字段。
  • 没做空指针检查。 avcodec_find_decoder 可能返回 null,但某些码流编码器很冷门,找不到解码器也正常。
  • frame->linesize 不等于 width * bytesPerPixel 。YUV 格式里一个像素可能占 1.5 字节或 2 字节,随手按 width * 3 去拷内存,很容易越界。

这类问题不好靠日志排查,我的习惯是开一个小项目,用最小的代码逐步排除。先只打开文件,再只查流信息,再只解一帧,每步都打印返回码,基本能定位到是哪一步错的。

4.3 中文路径打不开文件

FFmpeg 的路径字符串是 UTF-8。但 FFmpeg.AutoGen 不同版本的签名不一样,有的地方是 string ,有的地方是 byte* 。如果遇到中文路径打不开,先把路径转成 UTF-8 字节串再传:

var pathBytes = Encoding.UTF8.GetBytes(path + '\0');
fixed (byte* pPath = pathBytes)
{
    ret = ffmpeg.avformat_open_input(&pFormatContext, pPath, null, null);
}

这种方式比较保险,尤其处理带中文、带空格的路径时,不再依赖 P/Invoke 默认的字符串编组行为。我踩过一次,路径里有个中文“测试”两个字,用字符串重载时 avformat_open_input 返回 -2 ,转成 UTF-8 字节流后瞬间正常。这不算 FFmpeg 的 bug,只是不同平台对本地编码的处理不一致。

4.4 明明能解出帧,但像素是花的或颜色不对

多半是像素格式没对上。 sws_getContext 的输入像素格式要跟解码器实际输出的格式一致,也就是 pCodecContext->pix_fmt ,或者读取 pFrame->format 。有些视频流解码前无法确定真实格式,必须在拿到第一帧之后根据 pFrame->format 来创建转换上下文。

另外, sws_scale 的 srcStride 来自 pFrame->linesize ,不是简单乘出来的。YUV 格式里,Y 平面和 UV 平面的 width 、 linesize 都不同,一定要直接用 linesize 数组。

4.5 抽几帧就可以了,不想解全部视频

如果只是要抽第 N 帧,可以在 avformat_find_stream_info 之后用 av_seek_frame 先跳转,再解码。比如按时间点跳:

long timestamp = (long)(targetSecond * ffmpeg.AV_TIME_BASE);
ffmpeg.av_seek_frame(pFormatContext, -1, timestamp, ffmpeg.AVSEEK_FLAG_BACKWARD);

这里 stream_index 传 -1 表示按时间基查找。跳完之后继续 av_read_frame ,需要丢弃几帧才能稳定到目标帧,因为视频关键帧 GOP 的存在,跳到 target 后要从上一个关键帧开始解码。实际项目里我会额外设置一个“跳过前 N 帧”的逻辑,直到时间戳大于目标点。

5. 在真实项目里更稳的用法与建议

5.1 把 FFmpeg 逻辑封装到独立进程或服务

很多人用着用着就会发现:native 代码一旦崩溃,整个 .NET 进程也可能一起完蛋。 AccessViolationException 在某些情况下是致命的,C# 的 catch 根本拦不住。

所以在服务端

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

邀请您加入社区

更多推荐