
让我先说个场景我之前在搞一套工业视觉检测系统现场工业相机录下来的视频文件动辄几十分钟程序要在指定时间点精准抓帧去做缺陷识别。最开始图省事用Process.Start拉起ffmpeg命令行干活结果在高并发抽帧时CPU像坐过山车而且进程调度完全不可控。后来老老实实把FFmpeg的C API通过P/Invoke接进C#才真正治好了这个病。这篇文章我就把C#调用FFmpeg做视频帧提取的完整链路从头到尾拆一遍。不光是贴代码还会把时间基、PTS、seek机制这些最容易翻车的底层逻辑讲透最后给出参数级拆解和我在生产环境里踩过的坑。适合那些用C#做主程序但需要内嵌视频帧提取能力的朋友——比如上位机开发者、视觉工程师、想做视频预览的客户端开发者。你会踩的那些坑这篇文章里基本都有。1. 帧提取的核心是时间基不懂AV_TIME_BASE就是瞎猜时间戳很多人在C#里做帧提取上来就写ffmpeg -ss 00:01:23 -i input.mp4 -frames:v 1 out.jpg然后发现有时候准有时候不准。这个问题的根源就是没理解FFmpeg的时间表示方式。1.1 时间戳为什么要用分数而不是秒FFmpeg内部几乎不用秒这个单位来表示时间点。它用的是一个称为time_base的有理数分数。AVStream.time_base这个字段代表了当前流的时间精度比如1/90000就表示时间戳的每个单位是1/90000秒。为什么这样设计因为视频流的帧率不一定是整数比如NTSC制式是30000/1001帧每秒约等于29.97。如果用浮点数秒去表示时间经过多次运算会累积误差用分数就能做到无损的精确换算。这个思想和你小学学的分数运算一样——1/3写成浮点数0.333333是有穷精度但写成分数就永远不会丢精度。具体到代码里你会遇到三类时间基时间基值用途AV_TIME_BASE1,000,000FFmpeg全局统一时间基微秒AVStream.time_base如1/90000每个流自己的时间基存在容器中AVCodecContext.time_base如1/90000编码器解码器使用的时间基和codec相关1.2 PTS/DTS不是所有帧都能直接显示接着是PTS显示时间戳和DTS解码时间戳。很简单因为有了B帧双向预测帧解码顺序和显示顺序会不一致。PTS决定这帧什么时候显示DTS决定这帧什么时候解码。打个比方一段采访视频送进来的是倒着念的稿子解码顺序但剪辑师要求最终播出时正着放显示顺序。DTS就是剪辑师收到稿子的顺序PTS就是稿子在成片里的位置。做帧提取时你主要关心PTS。比如你想取视频第30秒那一帧正常的做法是把目标时间换算成目标流的时间基里的数值然后seek到那附近再解码直到PTS匹配。这里大部分人的坑是直接把30这个秒数当时间戳用——在time_base1/90000的流里30和30×90000差了十万八千里。1.3 换算公式和参考代码C#里用FFmpeg.AutoGen调用时换算就是这么写// 假设目标时间点是 30.5 秒 double targetSeconds 30.5; long targetTimestamp (long)(targetSeconds * stream.TimeBase.Denominator / stream.TimeBase.Numerator); // 或者更常见的方式先把秒转成AV_TIME_BASE单位再由av_rescale_q转到流时间基 long targetInAVTimeBase (long)(targetSeconds * AV_TIME_BASE); long targetInStreamTimeBase ffmpeg.av_rescale_q(targetInAVTimeBase, AV_TIME_BASE_Q, stream.TimeBase);这里AV_TIME_BASE_Q是AVRational类型代表1/1000000不要和常量AV_TIME_BASE整数1000000搞混了。我见过不止一个人把av_rescale_q的分子分母传反导致seek出来的帧差了十万八千里。这个函数的值是(a * bq.num / bq.den)本质就是一个分数乘法和约分。我在实际项目中强烈建议所有对外暴露的接口都统一用秒double作为入参内部才做时间基换算。这样上层业务不管是UI输入还是定时任务都清爽也不会到处散落时间基转换代码。2. 环境准备FFmpeg二进制选择与C#封装方式选型环境这块看着简单其实坑不少。尤其是FFmpeg的license区分和C#侧调用方式的选择会直接影响你的项目能不能商业分发、能不能拿到高性能。2.1 GPL还是LGPL这个必须第一行就想清楚FFmpeg官网编译好的版本根据配置不同分GPL和LGPL。简单说LGPL版本可以动态链接DLL方式到商业闭源软件里而不开源你的代码适合做商业软件的SDK集成。GPL版本包含了x264等GPL协议的库功能更全但如果你要闭源商用引用它会传染整个程序都变成GPL开源。从ffmpeg官网下载的Windows Build通常同时提供两种。我自己的经验是如果你的产品要发给客户走LGPL build然后在功能裁剪上做取舍如果只是为了内部工具、学习研究GPL build功能最全省心。怎么确认你下载的是哪个下载完后在命令行跑一句ffmpeg -version输出的第二行就有--enable-gpl或--enable-lgpl字样梅工是看编译配置的最快方式。2.2 四种C#调用方式从新手到生产级的选型对比C#调用FFmpeg主流有四条路我用一个表给你横向对比方案实现方式性能可控性上手成本适用场景命令行Process调用Process.Start(ffmpeg ...)差进程调度开销大差黑盒极低一次性任务、离线转码FFmpegCore库NuGet包装ffmpeg命令行中一般低快速完成转码/缩略图FFmpeg.NET部分API包装出帧回调中有限中需要抽取单帧/简单流处理FFmpeg.AutoGenP/Invoke直接调FFmpeg C API高完整高生产级视频处理、批量抽帧我的选择是FFmpeg.AutoGen原因有几个第一命令行方案每次都要启动一个新进程光初始化就有好几毫秒在高批量抽帧的场景下是无谓的开销第二进程方式你拿不到解码中丢失的帧细节、buffer状态、硬件加速上下文排查问题全靠猜第三AutoGen是直接P/Invoke原生API内存布局完全由你掌控可以做Frame复用GC压力小得多。2.3 FFmpeg.AutoGen的初始化和运行时目录配置用FFmpeg.AutoGen最关键的一步是告诉它DLL在哪。默认它会从PATH环境变量里找avcodec-61.dll这类文件。在Windows开发机上你有两种做法// 方式一把bin目录加入PATH Environment.SetEnvironmentVariable(PATH, C:\ffmpeg\bin; Environment.GetEnvironmentVariable(PATH)); // 然后调用 ffmpeg.RootPath C:\ffmpeg\bin;我这里用的是FFmpeg 6.x时代的AutoGen如Ffmpeg.AutoGen 6.0需要ffmpeg.RootPath指定原生库所在目录。如果直接写ffmpeg.RootPath ...不行再加DllImportResolver自定义解析。// 方式二更稳的方法用NativeLibrary.SetDllImportResolver统一拦截 NativeLibrary.SetDllImportResolver(typeof(ffmpeg).Assembly, (name, assembly, path) { string fullName Path.Combine(C:\ffmpeg\bin, name .dll); return NativeLibrary.Load(fullName); });我在生产环境里用的是第二种因为部署时FFmpeg目录和EXE目录通常是分离的不写死PATH更可控。同时注意一点AutoGen只是自动生成了P/Invoke声明DLL必须是你机器上实际存在的版本不匹配会在调用时报EntryPointNotFoundException调试时第一件事先确认这个。3. 帧提取完整代码链路从打开文件到Bitmap逐行拆解核心环节来了。这一节我会给你一份完整的C#代码从avformat_open_input一直走到C#的Bitmap。这份代码我根据生产项目简化过但关键点都保留。3.1 打开文件、找视频流、打开解码器的骨架using FFmpeg.AutoGen; using System.Runtime.InteropServices; public unsafe class VideoFrameExtractor : IDisposable { private AVFormatContext* formatCtx; private AVCodecContext* codecCtx; private AVCodecParameters* codecParams; private int videoStreamIndex -1; private AVStream* videoStream; private SwsContext* swsCtx; // 输出参数 private int targetWidth, targetHeight; private readonly object extractLock new object(); public VideoFrameExtractor(string filePath) { ffmpeg.avformat_network_init(); formatCtx ffmpeg.avformat_alloc_context(); // 打开输入文件 var ret ffmpeg.avformat_open_input(formatCtx, filePath, null, null); if (ret 0) throw new InvalidOperationException($无法打开文件: {ret}); ret ffmpeg.avformat_find_stream_info(formatCtx, null); if (ret 0) throw new InvalidOperationException($无法获取流信息: {ret}); // 找到视频流索引 videoStreamIndex ffmpeg.av_find_best_stream(formatCtx, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, null, 0); if (videoStreamIndex 0) throw new InvalidOperationException(未找到视频流); videoStream formatCtx-streams[videoStreamIndex]; codecParams videoStream-codecpar; var codec ffmpeg.avcodec_find_decoder((AVCodecID)codecParams-codec_id); if (codec null) throw new InvalidOperationException($未找到解码器: {codecParams-codec_id}); codecCtx ffmpeg.avcodec_alloc_context3(codec); ret ffmpeg.avcodec_parameters_to_context(codecCtx, codecParams); if (ret 0) throw new InvalidOperationException(无法复制参数到解码上下文); ret ffmpeg.avcodec_open2(codecCtx, codec, null); if (ret 0) throw new InvalidOperationException($无法打开解码器: {ret}); }这里特别注意avcodec_parameters_to_context这一步必不可少。新版FFmpeg已经把codecctx-width、codecctx-time_base这些字段的赋值交给了AVCodecParameters你直接设width/height不会生效必须做参数拷贝。3.2 按秒定位帧的seek策略定位一帧本质是seek到目标时间附近然后解码直到遇到PTS匹配的帧。但FFmpeg的av_seek_frame有几个微妙点public bool SeekToSecond(double targetSeconds) { // 1. 换算成流时间基 long timestampInStream ffmpeg.av_rescale_q( (long)(targetSeconds * AV_TIME_BASE), AV_TIME_BASE_Q, videoStream-time_base); // 2. 用AVSEEK_FLAG_BACKWARDseek到目标点之前的最近关键帧 int ret ffmpeg.av_seek_frame(formatCtx, videoStreamIndex, timestampInStream, AVSEEK_FLAGS.AVSEEK_FLAG_BACKWARD); if (ret 0) return false; // 3. 关键清空解码器缓冲 ffmpeg.avcodec_flush_buffers(codecCtx); return true; }AVSEEK_FLAG_BACKWARD的含义是seek到目标点之前最近的那个关键帧。必须要这个flag吗如果你的目标时间点恰好在关键帧之后BWD还是FWD都没差但如果在关键帧之前没有BACKWARD会seek到后面的关键帧导致你找到的帧比目标晚很多。注意seek后一定要调avcodec_flush_buffers否则解码器里残存的旧帧缓冲会串场。3.3 解码循环找到PTS匹配的那一帧seek到关键帧后后面某几帧可能PTS已经跳过你的目标时间点了你需要逐帧解码、比较public AVFrame* DecodeFrameUntil(double targetSeconds) { var packet ffmpeg.av_packet_alloc(); var frame ffmpeg.av_frame_alloc(); long targetInStream ffmpeg.av_rescale_q( (long)(targetSeconds * AV_TIME_BASE), AV_TIME_BASE_Q, videoStream-time_base); try { while (ffmpeg.av_read_frame(formatCtx, packet) 0) { if (packet-stream_index ! videoStreamIndex) { ffmpeg.av_packet_unref(packet); continue; } int sendRet ffmpeg.avcodec_send_packet(codecCtx, packet); ffmpeg.av_packet_unref(packet); if (sendRet 0) continue; while (true) { int recvRet ffmpeg.avcodec_receive_frame(codecCtx, frame); if (recvRet ffmpeg.AVERROR(ffmpeg.EAGAIN) || recvRet ffmpeg.AVERROR_EOF) break; if (recvRet 0) return null; // 比较PTS if (frame-pts targetInStream) { return frame; // 调用方负责av_frame_unref } ffmpeg.av_frame_unref(frame); } } return null; } finally { ffmpeg.av_packet_free(packet); } }avcodec_send_packet和avcodec_receive_frame是FFmpeg 3.x以后推荐的解耦API。一个packet可能产生0到多个frame比如H.264的某些帧包含多个slice所以send一次后要用while循环把receive拿到干净。每次receive_frame后如果不保留该帧必须av_frame_unref否则下一帧会把这一帧的数据覆盖最后你会拿到一堆重复的帧。3.4 像素格式转换AVFrame转Bitmap的最短路径解码出来的AVFrame通常是AV_PIX_FMT_YUV420P而C#的Bitmap原生格式是BGRA或者RGB必须要用sws_scale转格式。这里的转法也有讲究const int AVPIX_FMT_RGB24 132; // AV_PIX_FMT_RGB24的枚举值仅作示例用实际用枚举 const AVPixelFormat targetPixelFormat AVPixelFormat.AV_PIX_FMT_BGR24;我不建议用AV_PIX_FMT_RGBA转完再手动调ARGBAV_PIX_FMT_BGR24是Windows上效率最高的选择可以直接用System.Drawing.Imaging.PixelFormat.Format24bppRgb语义承载注意字节序BGR。public byte[] DrawScaledFrame(AVFrame* srcFrame, int outWidth, int outHeight) { if (swsCtx null || targetWidth ! outWidth || targetHeight ! outHeight) { // sws_getContext每次重开会很耗时最好复用 if (swsCtx ! null) ffmpeg.sws_freeContext(swsCtx); swsCtx ffmpeg.sws_getContext( codecCtx-width, codecCtx-height, (AVPixelFormat)codecCtx-pix_fmt, outWidth, outHeight, AVPixelFormat.AV_PIX_FMT_BGR24, ffmpeg.SWS_BILINEAR, null, null, null); targetWidth outWidth; targetHeight outHeight; } var dstFrame ffmpeg.av_frame_alloc(); int dstBufSize ffmpeg.av_image_get_buffer_size(AVPixelFormat.AV_PIX_FMT_BGR24, outWidth, outHeight, 1); var dstBuffer (byte*)ffmpeg.av_malloc((ulong)dstBufSize); ffmpeg.av_image_fill_arrays( (AVFrame*)dstFrame, // 或者用新的av_image_fill_arrays重载 dstFrame-data, dstFrame-linesize, dstBuffer, AVPixelFormat.AV_PIX_FMT_BGR24, outWidth, outHeight, 1); ffmpeg.sws_scale(swsCtx, srcFrame-data, srcFrame-linesize, 0, codecCtx-height, dstFrame-data, dstFrame-linesize); // 拷贝到托管数组 int rowSize dstFrame-linesize[0]; int validDataSize outWidth * 3; // BGR24 每像素3字节 byte[] result new byte[validDataSize * outHeight]; for (int y 0; y outHeight; y) { Marshal.Copy((IntPtr)(dstFrame-data[0] y * rowSize), result, y * validDataSize, validDataSize); } ffmpeg.av_free(dstBuffer); ffmpeg.av_frame_free(dstFrame); return result; }这里linesize不一定是width*3由于内存对齐可能会有padding。所以不能简单Marshal.Copy一整块必须逐行按有效数据长度拷贝。这个坑几乎人人踩一遍我第一次写的版本直接拷贝整个linesize在宽度不是128像素倍数时后半段画面会有一条诡异的斜色带。得到byte[]之后C#侧构造Bitmap就很简单了public Bitmap CreateBitmapFromBgr24(byte[] data, int width, int height) { var bitmap new Bitmap(width, height, System.Drawing.Imaging.PixelFormat.Format24bppRgb); var bitmapData bitmap.LockBits(new Rectangle(0, 0, width, height), System.Drawing.Imaging.ImageLockMode.WriteOnly, System.Drawing.Imaging.PixelFormat.Format24bppRgb); try { Marshal.Copy(data, 0, bitmapData.Scan0, data.Length); } finally { bitmap.UnlockBits(bitmapData); } return bitmap; }注意Format24bppRgb底层的字节序就是BGR正好和AV_PIX_FMT_BGR24对上。如果你换用AV_PIX_FMT_RGB24就得手动交换R和B通道白白浪费CPU。4. 参数级拆解那些决定准不准、快不快、稳不稳的细节很多人把ffmpeg -ss ... -i ... -frames:v 1当成黑盒命令用一旦换到C# API层面所有裸奔的参数都需要你自己负责。这一节我把几个影响帧提取质量的关键参数逐一拆开讲。4.1 seek精度设置AVSEEK_FLAG_FRAME与BACKWARD的区别av_seek_frame的flag组合本质是在精确但不一定找得到和大概位置但必然能落到关键帧之间取舍Flag行为典型误用AVSEEK_FLAG_BACKWARD找目标之前的最近关键帧想要目标帧却得到关键帧AVSEEK_FLAG_FRAME精确到帧级别但需要索引支持某些MP4能精确TS流可能失败0默认按流位置找最近的关键帧方向由实现决定往往找不到你想要的我的推荐是固定用AVSEEK_FLAG_BACKWARD目标对齐到关键帧然后用解码循环去追赶目标PTS。虽然这样会多解码几帧但对文件兼容性最好尤其遇到网络流、摄像头流这类没有完整索引的文件时不会出错。4.2 帧率控制和抽帧策略按秒筛选还是按帧号筛选如果你的需求是每秒抽一帧有两个实现路线路线一对每秒钟都seek一次然后只取最近的一帧。优点是定位精准缺点是seek本身有开销即使是本地文件也要数十毫秒级。路线二只seek到开头然后连续解码在解码循环里判断帧的PTS是否跨越目标时间点。优点是线性读盘效率最高适合从头到尾抽一段区间的所有帧缺点是如果想要第100分钟的帧得解码完前100分钟。我自己的常用策略是分段线性读取把目标时间点排序对每个目标先seek到其前一个关键帧然后连续解码到该时间点。如果两个目标时间点很近比如两秒内连续解码比再次seek更划算。4.3 像素格式与pix_fmt的选择从视频流里解码出来的原始帧绝大多数是YUV420P。要转成RGB/BGRsws_scale支持各类格式互转但你得想清楚最终目标是什么如果只是生成缩略图AV_PIX_FMT_RGB24足够内存占3字节/像素。如果喂给OpenCV或图像识别模型通常要BGR24OpenCV的默认格式就是BGR或者BGRA。如果你要做Alpha通道合成那就得BGRA4字节/像素。一个容易忽视的点sws_getContext的flags参数影响转换质量。SWS_BILINEAR是速度和质量的平衡SWS_POINT在缩小图像时会有明显锯齿SWS_LANCZOS在高质量缩略图场景值得用但慢1.5倍左右。我抽帧给视觉检测用BILINEAR给人看的预览用LANCZOS。4.4 输出分辨率直接改目标宽高比抽帧时常常要统一输出尺寸。在C# API里你不需要像命令行那样写-vf scale1280:720只要在sws_scale里改输出宽高就行。注意宽高比要和源一致否则画面变形这是大家都知道的但很少有人主动加黑边来保持原结构。如果你不想变形但也不想裁切可以在转换前算好目标尺寸int srcW codecCtx-width; int srcH codecCtx-height; int dstW 1280; int dstH (int)(dstW * (double)srcH / srcW); if (dstH 720) { dstH 720; dstW (int)(dstH * (double)srcW / srcH); }这段逻辑本质就是等比缩放求最大内接矩形很多视频处理库内部也是这么算的。5. 性能调优拿C# FFmpeg做生产级批量抽帧的正确姿势如果你的程序一天要抽几万帧命令行方案肯定扛不住。就算换成AutoGen不优化也能把CPU打满。这一节分享我在生产环境的性能调优实践。5.1 帧对象复用杜绝频繁分配释放解码循环里频繁av_frame_alloc/free和av_packet_alloc/free是性能杀手。正确的做法是在循环外统一分配循环内只做unref而不free。var packet ffmpeg.av_packet_alloc(); var frame ffmpeg.av_frame_alloc(); try { while (ffmpeg.av_read_frame(formatCtx, packet) 0) { // ... 处理packet ... ffmpeg.av_packet_unref(packet); // 每次receive到的frame不再使用时 av_frame_unref(frame) } } finally { ffmpeg.av_packet_free(packet); ffmpeg.av_frame_free(frame); }在性能测试中这个改动alone就能降低约30%的内存分配开销GC压力肉眼可见地下降。C#侧GC本来就要承担托管对象分配不要再让原生侧也跟着反复malloc。5.2 解码器的硬件加速d3d11va和dxva2到底选谁热搜词里出现了ffmpeg用d3d11va与dxva2有什么区别这个问题在帧提取里也很关键。简单说两者都是Windows上的Direct3D硬件解码方案dxva2老一代API基于Direct3D 9兼容Win7时代的老显卡。限制多帧拷贝方式固定。d3d11va新一代API基于Direct3D 11支持Win10/11现代GPU可以做零拷贝纹理共享。在FFmpeg中开启硬件解码的路径是av_hwdevice_ctx_create创建硬件设备上下文然后在avcodec_open2之前把codecCtx-hw_device_ctx设进去。但注意不是所有解码器都支持硬解H.264/HEVC等主流编码支持良好但VP9在某些设备上仍走软解。我的经验是对于纯帧提取硬件解码不一定更快。因为你要从GPU显存里把图像拷回内存av_hwframe_transfer_data这个拷贝本身有开销。如果只是偶尔抽几帧且CPU够用裸软解更稳如果是长时间高频抽帧硬解能把CPU占用降低30%-50%。// 用d3d11va创建硬件上下文示例片段 AVBufferRef* hwDeviceCtx null; ret ffmpeg.av_hwdevice_ctx_create(hwDeviceCtx, AVHWDeviceType.AV_HWDEVICE_TYPE_D3D11VA, null, null, 0); if (ret 0) { codecCtx-hw_device_ctx ffmpeg.av_buffer_ref(hwDeviceCtx); }然后在receive_frame后if (frame-format (int)AVPixelFormat.AV_PIX_FMT_D3D11) { var hwFrame ffmpeg.av_frame_alloc(); ffmpeg.av_hwframe_transfer_data(hwFrame, frame, 0); // GPU-CPU // 用hwFrame继续走sws_scale }注意avcodec_open2之前必须把硬件上下文设置好而且硬件解码出来的frame我们通常转成AV_PIX_FMT_NV12再喂给sws_scale不能直接把NV12当YUV420P处理。5.3 多文件并发的线程模型生产上抽帧往往不是一个文件一个文件顺序抽而是要并发处理多个文件。这时候要特别注意FFmpeg的线程安全边界同一个AVFormatContext和AVCodecContext不能被多线程同时访问但不同的上下文实例之间互不干扰。所以最稳的模型是一个文件用一个独立实例实例内部串行解码文件之间并行。Parallel.ForEach(fileList, new ParallelOptions { MaxDegreeOfParallelism 2 }, file { var extractor new VideoFrameExtractor(file.FullName); try { var frame extractor.ExtractFrameAt(30.0); // ... } finally { extractor.Dispose(); } });并行度不要拉满到CPU核心数。因为FFmpeg内部解码器本身会开多线程codecCtx-thread_count默认是CPU核数你外层再开N个进程实例线程会严重竞争。实测4核机器上跑2路并行抽帧收益最大再多反而掉帧率。6. 我在生产环境踩过的坑时间戳偏移、内存泄漏与异步回调崩溃最后这部分属于血泪经验。这里面的每个坑我都修过不止一次有些是网上查半天查不到的。6.1 时间戳偏移打开视频后前几帧PTS是负的有些H.264文件开头会带B帧重排seek后解码的第一帧PTS可能小于你指定的目标时间甚至部分容器的初始PTS从负数开始。我遇到过用frame-pts target判断永远等不到目标帧的情况原因是有个流的前几帧PTS是AV_NOPTS_VALUE。解法是做PTS合法性判断frame-pts ! AV_NOPTS_VALUE以后才开始比较同时别忘了用frame-best_effort_timestamp做fallback。best_effort_timestamp是FFmpeg在无法精确给出PTS时估算出来的一个值通常更接近实际显示时间。6.2 内存泄漏的三大元凶我在压测时用任务管理器看着内存曲线一路上涨最后GC都救不回来。查下来主要三个原因第一packet/frame未unref。av_read_frame返回的packet里data指向的是内部buffer必须av_packet_unref释放引用。每漏一个unref就漏一块size为帧大小的原生内存。这个最常见。第二sws_scale输出buffer未手动free。av_frame_alloc分配的frame本身带引用还要配av_frame_unref或av_frame_free释放data直接用av_malloc分配的输出buffer必须用av_free回收不能依赖GC。第三随手new SwsContext从不free。每次抽帧都新建SwsContext却不sws_freeContext内存涨得比泄漏还快。正确的是复用SwsContext只在分辨率变化时重建。6.3 异步回调崩溃委托被GC回收用FFmpeg.AutoGen时如果开了硬解或者做了自定义IO回调C#侧传入的委托会被FFmpeg原生代码持有。C#的GC不知道原生侧还在用这个委托会在某个时刻垃圾回收它然后原生调用时直接access violation。解决办法是把委托存成字段声明为GC.KeepAlive保证生命周期private avio_alloc_context_read_packet readPacketCallback; private avio_alloc_context_seek seekCallback; public void Init() { readPacketCallback (buffer, bufferSize) { /* ... */ }; seekCallback (opaque, offset, whence) { /* ... */ }; GC.KeepAlive(readPacketCallback); GC.KeepAlive(seekCallback); }我栽过一次的地方是把回调写成了lambda以为闭包捕获了局部变量就不会被回收。乳沟式写法还是被回收了——因为闭包本身变成了一个委托实例GC不追踪原生指向。后来统一改成类字段并显式KeepAlive再也没复现过。6.4 帧率过滤vfr视频会少帧还是多帧有些视频是VFR可变帧率比如屏幕录制、电话会议录像。按frameCount来编号会得到非恒定时间间隔的帧某些帧间距可能有几百毫秒。如果你用每隔N帧取一帧的策略实际抽出来的帧时间间隔不均匀视觉上会感觉卡顿。解决方法是不要按帧号抽直接按时间点抽。我在做视频缩略图的时候就是按targetTime 0, 5s, 10s, 15s...这样固定时间点去seek和抽取完全无视帧号。这样即使源视频是VFR输出缩略图的时间轴也是均匀的。6.5 帧提取结果与播放器预览不一致编码器延迟导致的偏移当你用av_read_frame读帧时容器层读到的时间戳可能有偏差。特别是直播录制文件FLV、TS容器时间戳和实际帧显示时间差个一两帧很正常。如果你要做抽这帧存下来建议在比较PTS时留出半帧容差long tolerance ffmpeg.av_rescale_q(1, AVRational { num 1, den 2 }, videoStream-time_base); if (frame-pts targetInStream - tolerance)这半帧的误差通常不会影响缩略图、单帧快照但在做精确的帧级对齐比如逐帧对比视频内容时必须逐帧解码做双边匹配不能只依赖时间戳。最后再说两句实际项目中能用上的思路做完这套帧提取组件之后我又基于它扩展了几个方向顺手分享给你。一个是批量生成视频预览墙mosaic墙把所有目标视频的关键帧抽出来拼成大图C#侧用Graphics就能拼性能瓶颈全部在FFmpeg那侧抽帧逻辑完全复用。另一个是边下边抽视频文件还在下载只要拿到足够长度的MVF头avformat_open_input能打开就能边下边seek——这对监控类应用的快速响应很有用不用等整个文件传完。另外一个心得是不要试图把FFmpeg的所有能力都封装成C#类。我最后只暴露了Open/Seek/ExtractFrame/ExtractFramesByInterval/SaveToFile/Dispose这几个API内部虽然直接用AutoGen但外部不让业务代码碰原生指针全面和可维护性都大幅提升。视频处理里最容易失控的就是资源生命周期边界做清楚了业务层怎么调都不会炸内存。还有个小技巧如果只是生成视频指定位置的预览图且对分辨率要求不高可以先用ffprobe读流信息avformat_find_stream_info拿到关键帧位置后用极小的解码循环只解到那几帧就收工。这样CPU占用能再低一个量级移动端或者低配工控机上跑起来很从容。