C# WinForm基于DirectShow实现边预览边保存视频音频与截图
简介这是一份基于DirectShow.dll进行二次开发的C# WinForm程序源码包适合需要在WinForm中快速实现摄像头/麦克风采集、预览与录制功能的桌面应用开发者。程序可自动识别并选择摄像头和麦克风支持录制带音频的WMV和AVI视频AVI分辨率与帧率可自定义WMV默认做了清晰度优化预览过程中既可单独拍照也能边录视频边抓图并集成了视频/音频压缩功能。资源共32个文件以C#源码、Visual Studio工程配置、可直接运行的exe及封装dll为主同时配有说明文档和演示图片整体约201KB结构紧凑关键代码处还保留了作者排错时的注释便于二次开发。已有945人学习/下载对于接触DirectShow较少的C#开发者而言是一份能减少踩坑、快速上手的实用参考。 做 WinForm 视音频采集这套东西很多人绕了一圈最后还是回到 DirectShow.dll 上来。不是别的方案不好而是到了真实项目里你要同时满足预览不卡、保存不断流、截图不掉帧、音频不错位这四个要求时能稳稳兜住底层的还是 DirectShow 这套老牌 COM 架构。这篇就围绕一个实际做过的项目——C# WinForm 基于 DirectShow.dll 编写实现边预览边保存视频、音频和图片——把选型逻辑、核心机制、代码骨架、踩坑过程和后期打磨全部分享出来给正准备做同类功能的读者一条可以直接照着走的路。1. 为什么 WinForm 视音频采集我还是选了 DirectShow先交代一下背景这个项目需要的不是简简单单打开摄像头看画面而是要同时做三件事——实时预览、把视频流保存成文件、随时抓取当前画面保存为图片并且音频要跟着视频一起录制。一开始我试过不少方案但每种都有让人难受的地方。AForge.NET用起来最省事封装简单几个事件一挂就能出画面。但它底层走的还是 DirectShow只是包了一层托管接口。问题在于帧率控制比较粗糙做连续长时间录制时偶尔会有帧丢失而且它基于 .NET Framework 的老接口在现代 C# 项目里用着总感觉绑手绑脚。OpenCV的 VideoCapture 也很流行但它更多服务于图像算法场景WinForm 里要实时预览得自己做控件的绘制而且对多路采集、硬件编码支持不友好。UWP/MediaCapture是微软主推的新方向功能确实强大但它是为 WinRT 设计的在传统 WinForm 项目里引用起来要处理一堆兼容性细节部署到 Windows 7/Server 环境更是麻烦。很多工控上位机场景的客户系统还是老环境这块直接劝退。最关键的还是实际项目里遇到过一个硬需求在不中断预览的情况下动态调整录制参数。用 AForge 或 MediaCapture 时改分辨率或帧率基本都要重建采集链路而 DirectShow 的滤波图Filter Graph设计天生就是可拆可接的能在运行时替换中间环节。所以我最后决定直接用 DirectShow.dll 做底层C# 通过 P/Invoke 调用WinForm 做交互层。这条路前期工作量确实大一点但后面收益也明显灵活、可控、性能直接落在底层出问题能精确到是哪根 PIN 没接好。如果读者是第一次接触 DirectShow我建议先别急着写代码花半小时把下面这套架构理解到位后面调 bug 会省很多时间。2. DirectShow 的核心机制与代码骨架2.1 滤波图、PIN 和缓冲回调的基本逻辑DirectShow 的核心是滤波图Filter Graph你可以把它想象成一条水管管道摄像头是水源预览窗口是水龙头视频编码器是净水器保存文件是蓄水池。每个组件在 DirectShow 里叫做Filter滤波器Filter 之间靠PIN管脚连接传输数据。这个项目里最关键的 Filter 是SampleGrabber它能把流经它的每一帧数据拦截到应用层让我们在 C# 里拿到原始图像数据。另外还有Null Renderer空渲染器它接收数据但不显示适合在项目里做数据中转站而不需要真正显示的场景。整个滤波图这样搭摄像头/采集卡 Source Filter ↓ Smart Tee智能分流器 ↓预览路径 ↓采集路径 Video Renderer SampleGrabber ↓ AVI Mux → File WriterSmart Tee 负责把一路视频流分成两路一路直接到预览窗口实时显示另一路进 SampleGrabber 让应用层取数据。取到的数据我做了两个去向一份交给 AVI Mux 编码写入文件一份在内存里暂存截图时直接从最近一帧取。2.2 C# 里 P/Invoke 调用 DirectShow 的基本结构用 C# 调 DirectShow 一般有两种方式一是直接用 P/Invoke 一层层声明 COM 接口二是借助现成封装库。我调研后选了DsNET这个开源库它把 DirectShow 的 COM 接口做成了强类型封装C# 里用起来比手写 COM Interop 舒服很多。DsNET 的底层依然是直接调用 DirectShow.dll该有的控制力一点不少。预览开始的核心代码大概这样// 创建滤波图管理器 graph new FilgraphManager(); // 枚举系统里的视频采集设备摄像头、采集卡 DsDevice[] devices DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice); // 选择第一个设备加入滤波图 IBaseFilter sourceFilter (IBaseFilter)devices[0].Moniker.BindToObject(null, null, ref IID_IBaseFilter); graph.AddFilter(sourceFilter, Source); // 添加 SampleGrabber 用于拦截帧数据 SampleGrabber grabber new SampleGrabber(); IBaseFilter grabberFilter (IBaseFilter)grabber; graph.AddFilter(grabberFilter, Grabber); // 设置 grabber 的媒体类型这里设为 RGB24方便后续缩略图和保存 AMMediaType mt new AMMediaType(); mt.majorType MediaType.Video; mt.subType MediaSubType.RGB24; grabber.SetMediaType(mt); // 添加 Smart Tee 实现预览和采集分流 IBaseFilter smartTee (IBaseFilter)new SmartTee(); graph.AddFilter(smartTee, SmartTee); // 连接Source 输出 PIN → SmartTee 输入 PIN IPin sourceOut sourceFilter.FindPin(Capture); IPin teeIn smartTee.FindPin(Input); graph.Connect(sourceOut, teeIn); // SmartTee 预览输出 → 视频渲染器显示到窗体 IPin previewOut smartTee.FindPin(Preview); // 添加 VideoRenderer 并连接 previewOut // SmartTee 采集输出 → SampleGrabber 输入 IPin captureOut smartTee.FindPin(Capture); IPin grabberIn grabberFilter.FindPin(Input); graph.Connect(captureOut, grabberIn);这里有个关键点一定先调用 graph 的 Render 或手动连接预览路径再启动运行。我第一次做的时候先调了 Run结果画面出来却看不到预览后来排查发现是 SmartTee 的输出 PIN 没有全部连上就启动了导致渲染链路没准备好。2.3 帧回调怎么不卡界面SampleGrabber 抓帧有两种工作模式推模式Push和拉模式Pull。项目里用的是 Buffer 回调模式也就是在SetCallback里传入一个回调对象DirectShow 每采集到一帧就把数据送到这个回调里。这个回调运行在 DirectShow 的工作线程上不在 UI 线程所以我们要做的第一件事就是千万别在这个回调里操作 UI 控件。我的做法是回调里只做两件事把帧数据拷贝到自己的缓冲池再丢一个自定义事件给 UI 线程处理。public bool BufferCB(double sampleTime, IntPtr pBuffer, int bufferLen) { // 在 DirectShow 回调线程中只拷贝数据不碰 UI lock (frameLock) { lastFrame new byte[bufferLen]; Marshal.Copy(pBuffer, lastFrame, 0, bufferLen); lastWidth width; lastHeight height; } // 通知 UI 线程有新的帧到达 OnFrameCaptured?.BeginInvoke(null, null); return true; }UI 线程那边再去做 Bitmap 转换和绘制这样即使抓帧速度很快界面也不会卡死。3. 边预览边保存的架构分工与实现要点3.1 三种核心用途的职责划分这个项目标题里明确写了边预览边保存视频、音频和图片所以要拆成三件事来看预览把视频流实时显示到窗体的 Panel 控件里要求流畅、低延迟。这是直接由 Video Renderer 负责的和 C# 代码关系最小但也是最容易出问题的一环后面会讲闪屏问题。保存视频需要把 SampleGrabber 拿到的帧数据经编码后写入文件。这里我用 AVI Mux File Writer 组合直接把 SmartTee 的采集输出通过 AVI Mux 写入 AVI 文件。视频编码可以用默认的无压缩或者简单的压缩器等录制的时候要让音频一起进 AVI。保存图片截图不是一个独立采集通路而是直接读取内存里最新的lastFrame缓冲转成 Bitmap 再存盘。因为预览帧率一般都有 25fps 以上所以内存里永远有刚刚那一帧截图基本能做到指哪截哪不会有明显的时延。3.2 音频部分别让它成为断流的大坑音频的接入是这个项目里比较费神的一部分。DirectShow 处理音频的方式跟视频链路是独立的但 AVI Mux 可以同时接受视频输入和音频输入再把它们合成到一个文件里。实现方式是枚举系统音频采集设备麦克风或线路输入作为音频 Source Filter 加入滤波图。把音频 Source 的输出连接到 AVI Mux 的音频输入 PIN。把视频链路SmartTee 的采集输出连接到 AVI Mux 的视频输入 PIN。AVI Mux 输出连接的 File Writer 来写文件。这里有个需要提前落地的细节要先把音频 PIN 连好再连视频 PIN。如果顺序反了AVI Mux 可能只认到一路流合成文件里没有声音。我一开始没注意测试出来的 AVI 全是无声视频排查了半天才发现是 PIN 连接顺序的问题。还有一个常见问题是音频和视频的时间基准Reference Clock不一致导致录制时间长了音画不同步。解决办法是调用graph.SetDefaultSyncSource()让整个滤波图使用同一个时钟源或者把音频采集 Filter 的时钟同步到视频采集 Filter 上。实测统一时钟源后录制 30 分钟的音画同步误差在可接受范围内。3.3 保存视频的启动与停止时序视频录制的启动/停止看起来简单但踩坑后发现关键在停止时的时序// 开始录制 graph.Run(); // 此时滤波图已经在跑了数据持续写入 AVI Mux // 停止录制 graph.Stop(); // 必须先把整个滤波图停掉 // 再断开 PIN确保 AVI 文件完整写完 graph.Disconnect(avimuxOutPin);如果顺序反过来——先断开 PIN 再停滤波图——AVI 文件就容易损坏或者直接 0 字节。因为 AVI Mux 需要一个完整的 Stop 信号来写入文件头信息如果你直接把连接剪断了它根本不知道数据流是正常结束的。3.4 图片保存的两种触发方式图片保存我实现了两种触发方式一种是用户点击按钮手动截图另一种是固定间隔自动抓图用于做定时拍照场景。核心代码是把BufferCB里保存的原始 RGB24 数据转成 Bitmapprivate void SaveSnapshot(string filePath) { byte[] data; lock (frameLock) { data (byte[])lastFrame.Clone(); } using (Bitmap bmp new Bitmap(width, height, PixelFormat.Format24bppRgb)) { BitmapData bmpData bmp.LockBits( new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); Marshal.Copy(data, 0, bmpData.Scan0, data.Length); bmp.UnlockBits(bmpData); bmp.Save(filePath, ImageFormat.Jpeg); } }注意Marshal.Copy的拷贝方向从 DirectShow 回调的 IntPtr 拷到 byte 数组再从 byte 数组拷到 Bitmap 的扫描线。两个方向如果搞反了图片会逻辑上正确但颜色通道完全错乱红蓝互换、上下颠倒等。上面第二处Marshal.Copy的方向就是从数组到指针注释要记清楚。4. 实测中踩过的坑和完整排查过程4.1 预览画面黑屏或闪屏像素格式不匹配第一次接好管线上电测试预览窗口弹出来了但画面黑屏偶尔闪烁一下才出图像。第一反应是摄像头坏了换了 AForge 的测试程序发现摄像头正常。那问题就锁定在我自己搭的滤波图里。排查链路是这样的先检查 SmartTee 的预览输出到 Video Renderer 的媒体类型协商是否成功用一个 GraphEdit 的替代工具 GraphStudioNext 打开滤波图发现 Source Filter 的输出媒体类型是YUY2而 Video Renderer 默认协商出来的也是 YUY2照理说没问题。再往下看 SampleGrabber 这边我设置的媒体类型是 RGB24实际上 SampleGrabber 会把采集输出路径的媒体类型强制改成 RGB24但预览路径依然走 YUY2。真正的问题出在预览路径和采集路径使用了不同的色彩空间导致 Video Renderer 在切换帧格式时出现短暂黑屏。解决办法是在 SampleGrabber 里也把子类型设置成 YUY2然后等到帧到达后再在 C# 层转 RGB24。也就是让底层所有环节保持原生格式一致C# 层统一负责转换。这里还引出了另一个经验用 DirectShow 做多路分流时尽量让两个分支的媒体类型保持一致否则渲染器会频繁做格式转换轻则闪屏重则帧率掉一半。4.2 保存的视频文件总是损坏AVI Mux 停止顺序问题拿到录制的 AVI 文件后有的播放器能打开但拖进度条就崩还有的直接提示文件损坏。一开始我怀疑是编码器的问题换了好几种压缩器还是一样。后来我用 GraphStudioNext 重新走了完整流程才发现问题是停止录制时 PIN 断开时机不对。之前代码写在graph.Stop()之后直接释放了滤波图但 AVI Mux 还没来得及把索引信息写入文件。对 AVI 格式来说索引写在文件尾部你如果不给它完整的停止信号索引永远不会落盘。修正后的停止顺序是先调用graph.Stop()停掉滤波图数据流停止等 AVI Mux 完成文件头/索引写入可以监听 EC_VIDEO_SIZE_CHANGED 或直接延时 200ms再调用graph.RemoveFilter()移除 AVI Mux 和 File Writer最后释放 COM 对象。这样四个步骤做完每次保存的 AVI 文件都完整可播放。这里必须提醒一下把graph.Stop()和graph.RemoveFilter()合并成一个方法的做法看着省事实际上直接破坏了 AVI Mux 的清理流程。4.3 高分辨率下窗体显示不全UI 布局适配这个热搜词笔记本 分辨率低vs winform界面的高宽和高过长正好撞到了这个项目里。开发机的分辨率是 2K程序窗体按 1200x800 设计拿到客户一台 1366x768 的笔记本上窗体高度超出了屏幕底部按钮根本点不到。WinForm 里解决这个问题最简单有效的方式是用 TableLayoutPanel AutoSize 结合窗体最大化上限。我在主窗体加载时加了一段检测代码Rectangle screenArea Screen.PrimaryScreen.WorkingArea; int maxHeight screenArea.Height; int maxWidth screenArea.Width; this.MaximumSize new Size(maxWidth, maxHeight); this.WindowState FormWindowState.Maximized;同时给窗体里的预览 Panel 设置了Anchor Top | Bottom | Left | Right让它可以随窗体大小自动伸缩。这样换到低分辨率笔记本上时预览区域变小但按钮、状态栏这些控件都还在可视范围内。另外也把 DPI 感知关了避免在高 DPI 环境下字体缩放导致布局彻底乱掉。4.4 PropertyGrid 只读设置被回写编辑框状态没禁用这个项目里我用 PropertyGrid 展示采集参数分辨率、帧率、编码格式等用户能改的部分应该可编辑只读的参数不能改。但实际测试时发现 PropertyGrid 设置了ReadOnly true后仍能弹编辑框只是改了没生效。查了挺久才弄明白PropertyGrid 的ReadOnly是在显示层面阻止修改但如果属性本身的 setter 没有限制且编辑器被某种方式激活了值还是会被写进去。最稳妥的做法是给属性加[ReadOnly(true)]特性并在 setter 里加守卫private int frameRate; [ReadOnly(true)] public int FrameRate { get { return frameRate; } set { throw new NotSupportedException(该属性为只读); } }与其让用户傻傻地改完发现不生效不如直接在 setter 里拒绝修改。这个看起来和 DirectShow 无关但做完整项目时就是这么琐碎又关键。4.5 长时间录制的内存泄漏录制 1 小时以上内存占用从 200MB 涨到 1.2GB最后程序崩了。用内存分析工具一看全是byte[]对象堆积。原因是BufferCB里每次Marshal.Copy都新建一个 byte 数组高频帧率下30fps × 720p 每秒约 3MB 数据一小时下来就累计了 10GB 的瞬时分配虽然大多数会被 GC 回收但瞬时峰值让 GC 频繁忙碌最终有些对象进入 LOH大对象堆迟迟回收不掉。优化方案是用对象池复用缓冲提前分配 3~5 个固定大小的 byte 数组轮换使用BufferCB里先拷贝到池中的空闲缓冲区UI 线程用完再标记释放。这个改动把长时录制内存占用稳定在 400MB 以内。此外还要注意Marshal.Copy之后的 byte 数组要是在 UI 线程还会继续引用不能直接复用否则会出现图像错乱。5. 打磨成稳定工具的进阶建议5.1 参数配置持久化让每一个采集设备记住自己的设置每位客户用的摄像头、采集卡可能都不一样。有的支持 1080p60有的只支持 720p30如果每次启动都从默认参数走画面比例、清晰度都不对。我实现了一个简单的配置文件JSON 格式每次枚举到设备后用设备名称做 key把用户调整过的分辨率、帧率、编码器、音频设备保存下来下次启动自动读。这个功能听起来简单实际使用中价值非常高尤其是做项目交付时能够减少大量帮客户重新配置设备的售后时间。5.2 截图增强连拍、自动命名与时间戳水印截图不只是点一下按钮就行。项目里我还加了定时连拍功能、序列化文件命名按日期序号和可选的时间戳水印。时间戳水印实现也不复杂截图保存前在 Bitmap 上用Graphics.DrawString画一行文字即可但注意要在截图的副本上绘制不能直接修改lastFrame对应的 Bitmap否则会影响预览线程的读取。5.3 线程安全与 UI 操作纪律这个项目里线程安全是核心中的核心。DirectShow 回调线程、UI 线程、文件保存线程三者并发访问同一帧数据如果不同步轻则图像花屏重则程序崩溃。我的经验是遵守三条铁律回调线程里绝不碰 UI 控件只用BeginInvoke抛事件帧数据之间采用读写锁或lock 双缓冲防止一边写一边读录制停止时先停采集再停界面刷新不然反复几次就能肉眼看到内存占用往上走。5.4 安装包制作经验做到交付环节WinForm 制作安装包这个热搜词就完全对上了。我用了 Visual Studio Installer Projects 扩展装好之后可以直接在解决方案里加 Setup Project。第一步一定要先把项目的发布配置改成 Release注意把 DirectShow 相关依赖、第三方 DLL如 DsNET 引用到的 DLL都加入到安装包的应用程序文件夹里。还有一个坑是如果目标机器是 64 位 Win10但安装包设置成了 x86DirectShow 的设备枚举可能会漏掉一部分建议有条件直接生成 AnyCPU 或 x64 安装包。再配合自定义安装目录和桌面快捷方式整个交付流程就完整了。5.5 从 AVI 到更现代封装格式的扩展思路目前这个项目稳定在 AVI 上AVI 本身是微软老牌格式兼容性没得说文件大小确实不太友好。下一步我计划引入一个中间层把 SampleGrabber 拿到的原始帧直接交给 FFmpeg 原生进程做 H.264 编码和 MP4 封装。这样保留 DirectShow 做底层采集的灵活性和低延迟编码交给 FFmpeg 去处理。好处是文件体积直接缩小一个数量级坏处是多了一个进程间通信环节需要传递帧数据可以用共享内存或者命名管道这部分还在测试中。最后再分享两个小细节第一个是关于设备插拔。客户现场经常有人不小心把 USB 摄像头拔了再插上这时候 DirectShow 滤波图会直接罢工最简单的处理是监听系统的设备变更消息检测到摄像头被拔插后自动重建滤波图。这个功能代码不多但很实用比通知用户请重启软件体面多了。第二个是关于BufferCB回调里做耗时操作的禁忌。有人为了代码简单直接在回调里把数据转成 Bitmap 再保存文件低分辨率下看不出来一旦切到 1080p回调的执行时间超过帧间隔DirectShow 内部缓冲就会被拖垮视频流开始周期性地暂停。所以记住一句话回调里只做最轻量的拷贝和事件通知一切重活都丢给工作线程。做 DirectShow 这套方案前期搭架构确实比用现成控件繁琐但它在采集链路上的稳定性和可裁剪性是实打实的。如果你项目里也有类似的预览 多格式保存 动态调节需求照着这几条思路走能少走不少弯路。本文还有配套的精品资源点击获取