简介:这份资源是面向C#开发者的FFmpeg.AutoGen实战学习示例,适合希望在.NET环境中调用FFmpeg完成音视频处理的初中级开发者。压缩包内以CSharpVideoDemo为核心,演示了通过NuGet引入绑定后,如何打开多媒体文件、查找流信息、调用解码器与编码器、处理AVFrame帧数据,以及使用sws_scale做色彩空间转换、借助AVFilterGraph搭建过滤器链,并涉及AVFormatContext等对象的内存管理与异步处理思路。资源共174个文件,以111个C/C++头文件、16个dll动态库、8个cs源码及若干工程配置与资源文件为主,整体约55.53MB,目录结构便于对照源码逐层理解绑定调用流程。目前已有732人学习下载,可作为从API绑定到编解码、封装输出的完整参考,帮助读者快速搭建可运行的多媒体处理工程并在此基础上扩展自定义滤镜或流媒体功能。
1. FFmpeg.AutoGen 代码例子:从托管代码到原生音视频处理的那层窗户纸
如果你写过 C# 的音视频处理,大概率经历过这个场景:想用 FFmpeg 做转码或解码,结果发现要么调命令行进程、要么用某个封装库,性能和灵活性总差一口气。FFmpeg.AutoGen 解决的就是这个问题——它把 FFmpeg 的 C 接口自动生成成 C# 的 P/Invoke 绑定,让你能在 .NET 里直接调avcodec_send_packet、avformat_open_input这些原生函数。标题里的「代码例子.rar」本质上就是一套可运行的绑定调用示例集,覆盖解封装、解码、编码、封装这几条主线。这篇文章不依赖那个压缩包的具体内容,而是把 FFmpeg.AutoGen 从环境搭建到跑通第一个解码器的完整路径讲清楚,适合已经会 C#、想切入音视频底层处理的工程师。
2. FFmpeg.AutoGen 的绑定机制与最小可运行环境
2.1 自动生成绑定到底生成了什么
FFmpeg.AutoGen 的核心工作方式,是用 CppSharp 这类工具解析 FFmpeg 的头文件,把libavformat、libavcodec、libavutil、libswscale、libswresample里的结构体、枚举、函数签名翻译成 C# 的struct、enum和[DllImport]声明。你拿到的不是手写封装,而是近乎一比一的映射。这意味着两件事:第一,FFmpeg 的 API 怎么用,C# 里就怎么用,没有额外的抽象层;第二,C 里的指针操作、内存生命周期管理,在 C# 里同样要面对,AVFrame*、AVPacket*这些指针不会自动变成托管对象。
常见做法是直接引用FFmpeg.AutoGen这个 NuGet 包,它里面已经包含了生成好的绑定代码。但绑定代码本身只是声明,真正干活的是 FFmpeg 的原生动态库。所以环境搭建的关键不是绑定,而是让 .NET 运行时能找到正确版本的avcodec-xx.dll、avformat-xx.dll等文件。
2.2 动态库版本匹配与加载路径设置
FFmpeg 的 DLL 版本号跟 FFmpeg 主版本绑定,比如 FFmpeg 6.x 对应avcodec-60.dll,FFmpeg 7.x 对应avcodec-61.dll。绑定包里的函数签名是按某个版本生成的,如果你放的原生库版本对不上,运行时就会在调用某个函数时抛EntryPointNotFoundException。我一般会先确认绑定包对应的 FFmpeg 版本,然后去下同版本的 shared build。
# 查看 FFmpeg 动态库导出的函数,确认版本匹配 # Windows 下用 dumpbin,Linux 下用 nm dumpbin /exports avcodec-60.dll | findstr avcodec_send_packet上面这条命令的作用是验证你手里的avcodec-60.dll确实导出了avcodec_send_packet这个符号。如果找不到,说明版本不对或者下的是静态库。参数上没什么可调的,关键是findstr后面的函数名要跟你代码里调的一致。
加载路径的设置,在 .NET Core 及以上版本里,推荐用NativeLibrary.SetDllImportResolver来指定。因为 FFmpeg 的 DLL 之间还有依赖关系,比如avformat依赖avcodec,avcodec依赖avutil,如果只把路径加到PATH环境变量里,有时候解析顺序会出问题。
using System.Reflection; using System.Runtime.InteropServices; // 在程序启动时注册解析器,把所有 FFmpeg 原生库指向同一个目录 NativeLibrary.SetDllImportResolver(typeof(FFmpeg.AutoGen.ffmpeg).Assembly, (libraryName, assembly, searchPath) => { // 拼接你存放 DLL 的目录 var libPath = Path.Combine(AppContext.BaseDirectory, "ffmpeg", libraryName + ".dll"); if (File.Exists(libPath)) return NativeLibrary.Load(libPath); return IntPtr.Zero; // 回退到默认解析 });这段代码的逻辑是:当 .NET 需要加载任何 FFmpeg 相关的原生库时,都先去ffmpeg子目录里找同名 DLL。参数libraryName是运行时传入的库名,比如avcodec-60。注意IntPtr.Zero表示交给默认逻辑处理,不要直接抛异常,否则一些系统库的解析会失败。
2.3 第一个能跑的解封装代码
环境通了之后,最小验证不是解码,而是解封装——打开一个媒体文件,读出流信息。这一步能跑通,说明avformat和avutil的加载没问题。
using FFmpeg.AutoGen; // 初始化 FFmpeg 的日志和网络模块(如果涉及网络流) ffmpeg.avformat_network_init(); // 分配输入格式上下文 AVFormatContext* fmtCtx = null; // 打开输入文件,最后一个参数是选项字典,这里传 null int ret = ffmpeg.avformat_open_input(&fmtCtx, "test.mp4", null, null); if (ret < 0) { // 把错误码转成可读信息 Console.WriteLine($"打开失败: {ffmpeg.av_err2str(ret)}"); return; } // 读取流信息,这一步会填充 fmtCtx->streams ret = ffmpeg.avformat_find_stream_info(fmtCtx, null); if (ret < 0) { /* 同样处理错误 */ } // 遍历所有流,打印编解码器类型 for (int i = 0; i < fmtCtx->nb_streams; i++) { var stream = fmtCtx->streams[i]; var codecType = stream->codecpar->codec_type; Console.WriteLine($"流 {i}: 类型={codecType}, 编码ID={stream->codecpar->codec_id}"); } // 释放上下文 ffmpeg.avformat_close_input(&fmtCtx);逻辑说明:avformat_open_input负责探测容器格式并打开文件,avformat_find_stream_info会实际读取一部分数据来确认每个流的编码参数。参数上,第三个参数可以指定输入格式(比如强制"h264"),一般传null让 FFmpeg 自动探测;第四个参数是AVDictionary**,用来传超时、缓冲区大小等选项。失败时看av_err2str的输出,常见的是Invalid data found或No such file。
3. 解码与编码:从 AVPacket 到 AVFrame 的完整链路
3.1 解码循环的四个关键函数
解封装之后,拿到的是AVPacket,里面是压缩数据。要变成能处理的AVFrame,需要经过avcodec_send_packet和avcodec_receive_frame这一对函数。这是 FFmpeg 新解码 API 的核心,跟老的avcodec_decode_video2不一样,它把发送和接收拆开了,一个 packet 可能产生零个或多个 frame。
// 假设已经找到了视频流,并拿到了对应的 AVCodecContext* codecCtx AVPacket* packet = ffmpeg.av_packet_alloc(); AVFrame* frame = ffmpeg.av_frame_alloc(); while (ffmpeg.av_read_frame(fmtCtx, packet) >= 0) { // 只处理视频流 if (packet->stream_index == videoStreamIndex) { // 发送压缩数据到解码器 int sendRet = ffmpeg.avcodec_send_packet(codecCtx, packet); if (sendRet < 0 && sendRet != ffmpeg.AVERROR(ffmpeg.EAGAIN)) { Console.WriteLine($"发送失败: {ffmpeg.av_err2str(sendRet)}"); break; } // 循环接收解码后的帧,一个 packet 可能解出多帧 while (true) { int recvRet = ffmpeg.avcodec_receive_frame(codecCtx, frame); if (recvRet == ffmpeg.AVERROR(ffmpeg.EAGAIN) || recvRet == ffmpeg.AVERROR_EOF) break; if (recvRet < 0) { /* 错误处理 */ } // 此时 frame 里是 YUV 数据,可以送去做转换或编码 Console.WriteLine($"解码出一帧: {frame->width}x{frame->height}, 格式={frame->format}"); // 必须调用 unref 来释放帧内部的引用计数 ffmpeg.av_frame_unref(frame); } } // packet 用完也要 unref,否则内存泄漏 ffmpeg.av_packet_unref(packet); }逻辑说明:avcodec_send_packet把压缩数据喂给解码器,返回EAGAIN表示解码器内部缓冲区满了,需要先接收帧。avcodec_receive_frame返回EAGAIN表示当前没有更多帧可出,需要继续送 packet。参数上,codecCtx必须已经用avcodec_open2打开过。这里最容易翻车的地方是忘记av_frame_unref和av_packet_unref,跑几分钟内存就涨上去了。
3.2 像素格式转换与缩放
解码出来的AVFrame通常是 YUV420P,如果要显示或保存成图片,需要转成 RGB。这一步用libswscale。
// 创建转换上下文:从 codecCtx 的像素格式转到 RGB24,尺寸不变 SwsContext* swsCtx = ffmpeg.sws_getContext( codecCtx->width, codecCtx->height, codecCtx->pix_fmt, codecCtx->width, codecCtx->height, AVPixelFormat.AV_PIX_FMT_RGB24, ffmpeg.SWS_BILINEAR, null, null, null); // 分配一个 AVFrame 来存 RGB 数据 AVFrame* rgbFrame = ffmpeg.av_frame_alloc(); rgbFrame->format = (int)AVPixelFormat.AV_PIX_FMT_RGB24; rgbFrame->width = codecCtx->width; rgbFrame->height = codecCtx->height; ffmpeg.av_frame_get_buffer(rgbFrame, 0); // 执行转换 ffmpeg.sws_scale(swsCtx, frame->data, frame->linesize, 0, codecCtx->height, rgbFrame->data, rgbFrame->linesize);参数说明:sws_getContext的前三个参数是输入尺寸和格式,中间三个是输出尺寸和格式,SWS_BILINEAR是缩放算法,追求质量可以换SWS_BICUBIC。sws_scale的srcSliceY和srcSliceH一般传 0 和高度,表示处理整帧。转换完的rgbFrame->data[0]就是连续的 RGB 字节,可以直接写文件或送显示。
3.3 编码:把 AVFrame 压成 AVPacket
编码是解码的逆过程,用avcodec_send_frame和avcodec_receive_packet。初始化编码器时,要设置码率、帧率、GOP 大小这些参数。
// 查找 H.264 编码器 AVCodec* codec = ffmpeg.avcodec_find_encoder(AVCodecID.AV_CODEC_ID_H264); AVCodecContext* encCtx = ffmpeg.avcodec_alloc_context3(codec); // 设置编码参数 encCtx->bit_rate = 400000; // 码率 400kbps encCtx->width = 640; encCtx->height = 480; encCtx->time_base = new AVRational { num = 1, den = 25 }; // 25fps encCtx->framerate = new AVRational { num = 25, den = 1 }; encCtx->gop_size = 10; // 每 10 帧一个关键帧 encCtx->pix_fmt = AVPixelFormat.AV_PIX_FMT_YUV420P; // 打开编码器 ffmpeg.avcodec_open2(encCtx, codec, null); // 发送一帧 ffmpeg.avcodec_send_frame(encCtx, frame); // 接收编码后的 packet AVPacket* encPacket = ffmpeg.av_packet_alloc(); while (ffmpeg.avcodec_receive_packet(encCtx, encPacket) == 0) { // encPacket->data 里是 H.264 裸流,可以写文件或封装 ffmpeg.av_packet_unref(encPacket); }参数上,time_base和framerate要匹配,否则时间戳会乱。gop_size影响关键帧间隔,直播场景一般设小一点。编码器打开后,send_frame传null可以刷新编码器缓冲区,把剩余帧都取出来。
4. 避坑与排查:FFmpeg.AutoGen 里那些让人后悔药的细节
4.1 内存泄漏:av_frame_unref 和 av_packet_unref 缺一不可
现象:程序跑几分钟后内存占用持续上升,最终 OOM。原因:AVFrame和AVPacket内部有引用计数,av_frame_alloc只是分配了结构体,数据缓冲区是在av_frame_get_buffer或解码时分配的。如果不调用av_frame_unref,引用计数不降,缓冲区不释放。解决:每次avcodec_receive_frame处理完 frame 后立刻av_frame_unref;每次av_read_frame处理完 packet 后立刻av_packet_unref。不要指望 GC,这些是非托管内存。
4.2 时间戳错乱:time_base 换算没做对
现象:编码出来的视频播放速度不对,或者音视频不同步。原因:AVFrame->pts是基于codecCtx->time_base的,而封装时AVStream->time_base可能不一样。直接拿 frame 的 pts 塞给 packet 就会错。解决:用av_packet_rescale_ts做转换,或者手动按av_q2d换算。常见做法是编码前设好encCtx->time_base,编码后把 packet 的 pts 从编码器时基转到流时基。
4.3 找不到入口点:DLL 版本和绑定不匹配
现象:运行时抛EntryPointNotFoundException,提示某个函数在 DLL 里找不到。原因:NuGet 包里的绑定是按某个 FFmpeg 版本生成的,你放的原生库是另一个版本,函数签名或导出名变了。解决:确认绑定包的版本说明,下载对应主版本的 shared build。Windows 上可以用dumpbin /exports看导出函数,Linux 上用nm -D。如果版本实在对不上,可以自己用 CppSharp 重新生成绑定。
4.4 解码花屏:packet 边界和关键帧问题
现象:解码出来的画面有绿块或撕裂。原因:可能是送进解码器的 packet 不完整,或者跳过了关键帧直接送 P 帧。解决:确保av_read_frame返回的 packet 完整送入,不要手动切割。如果是网络流,要处理丢包重传或等待关键帧。另外检查codecCtx->pix_fmt和实际帧格式是否一致,不一致时 sws_scale 会出错。
4.5 编码器打开失败:参数组合不合法
现象:avcodec_open2返回负数。原因:编码器对宽高、像素格式、码率有约束,比如 H.264 的宽高必须是偶数,AV_PIX_FMT_YUV420P要求宽高是 2 的倍数。解决:打开前检查codec->supported_framerates、codec->pix_fmts这些字段,把参数调整到支持范围内。也可以先不设bit_rate,让编码器用默认值,打开成功后再调。
5. 用硬件加速和自定义 IO 把方案推到生产级
5.1 硬件解码的接入方式
软解跑通之后,如果 CPU 占用太高,就该考虑硬件加速了。FFmpeg 支持 DXVA2、D3D11VA、VAAPI、VideoToolbox 等。在 FFmpeg.AutoGen 里,硬件解码的入口是av_hwdevice_ctx_create和av_hwframe_transfer_data。流程是:先创建硬件设备上下文,把它挂到codecCtx->hw_device_ctx上,然后正常解码。解出来的AVFrame格式是AV_PIX_FMT_D3D11之类的硬件格式,需要再用av_hwframe_transfer_data拷回系统内存才能做后续处理。
// 创建 D3D11VA 设备上下文 AVBufferRef* hwDeviceCtx = null; ffmpeg.av_hwdevice_ctx_create(&hwDeviceCtx, AVHWDeviceType.AV_HWDEVICE_TYPE_D3D11VA, null, null, 0); codecCtx->hw_device_ctx = ffmpeg.av_buffer_ref(hwDeviceCtx); // 解码后,如果 frame->format 是硬件格式,转回 NV12 或 YUV420P if (frame->format == (int)AVPixelFormat.AV_PIX_FMT_D3D11) { AVFrame* swFrame = ffmpeg.av_frame_alloc(); ffmpeg.av_hwframe_transfer_data(swFrame, frame, 0); // 后续用 swFrame 做转换或编码 }参数上,AVHWDeviceType根据平台选,Windows 用 D3D11VA,Linux 用 VAAPI。注意硬件帧不能直接 sws_scale,必须先 transfer。另外hw_device_ctx要记得av_buffer_unref释放。
5.2 自定义 AVIOContext 处理内存数据
很多时候媒体数据不在文件里,而在内存缓冲区或网络流里。这时候需要自定义AVIOContext,把读写回调指向自己的函数。FFmpeg.AutoGen 里可以用avio_alloc_context配合AVIOContext的委托。
// 定义读回调,从 MemoryStream 里读数据 avio_alloc_context_read_packet readPacket = (opaque, buf, bufSize) => { var stream = GCHandle.FromIntPtr(opaque).Target as MemoryStream; byte[] temp = new byte[bufSize]; int read = stream.Read(temp, 0, bufSize); Marshal.Copy(temp, 0, (IntPtr)buf, read); return read; }; // 分配 AVIOContext,缓冲区大小 4096 byte* avioBuffer = (byte*)ffmpeg.av_malloc(4096); AVIOContext* avioCtx = ffmpeg.avio_alloc_context(avioBuffer, 4096, 0, GCHandle.ToIntPtr(GCHandle.Alloc(memoryStream)), readPacket, null, null); // 把 avioCtx 挂到 fmtCtx->pb 上,再调 avformat_open_input fmtCtx->pb = avioCtx; ffmpeg.avformat_open_input(&fmtCtx, null, null, null);逻辑说明:avio_alloc_context的第三个参数write_flag设 0 表示只读。opaque用来传自定义数据,这里用GCHandle把MemoryStream钉住。读回调里把数据拷到 FFmpeg 提供的缓冲区,返回实际读取字节数。返回 0 表示 EOF。注意avioBuffer要用av_malloc分配,不能用托管数组,否则 FFmpeg 内部释放会出错。
5.3 验证解码正确性的一个笨办法
怎么确认解码出来的帧是对的?我一般会做两件事:第一,把解码后的 YUV 数据按帧号存成文件,用ffplay直接播 YUV 裸流,命令是ffplay -f rawvideo -pixel_format yuv420p -video_size 640x480 frame.yuv。第二,对比 FFmpeg 命令行转出来的帧,用ffmpeg -i input.mp4 -f rawvideo -pix_fmt yuv420p ref.yuv,然后二进制比对。如果对不上,先查像素格式,再查 linesize 对齐——AVFrame->linesize[0]可能大于宽度,拷贝时要按行拷,不能直接整块 memcpy。
// 按行拷贝 YUV 数据,处理 linesize 对齐 for (int y = 0; y < height; y++) { Marshal.Copy((IntPtr)(frame->data[0] + y * frame->linesize[0]), yuvBuffer, y * width, width); }这个细节坑了我很久,因为很多示例代码直接Marshal.Copy(frame->data[0], ...)拷width * height字节,在宽度不是 32 倍数时就会错位。希望帮到你。
本文还有配套的精品资源,点击获取