☰
DirectShow滤镜图实战:采集卡黑屏、抓帧与编码排查
2026/10/31 4:07:06 网站建设 项目流程

简介:这份资源是面向Windows平台多媒体开发初学者与进阶者的DirectShow学习资料包,围绕过滤器与过滤图这一核心机制,解决音视频采集、解码、格式转换与渲染等开发场景中的入门与调试问题。包内共90个文件,以h头文件和cpp源文件为主体,涵盖基类库BaseClasses中的转换过滤器、渲染基类、时钟与流控制等模块,并附有lib静态库、vcxproj与sln工程文件、def导出定义以及graphedt.exe、guidgen.exe等工具和两段YUV示例视频,压缩包约57.15MB,可直接编译调试。资源中还有《DirectShow开发指南及代码》文档与自定义过滤器DSK示例,便于对照源码理解过滤器注册、引脚连接与GraphEdit调试流程,掌握IMediaControl等COM接口的调用方式,并积累音视频同步与实时播放的排错经验。目前已有288人学习下载,适合希望从源码层面吃透DirectShow模块化设计的开发者参考。

1. 从「预览一片黑」说起:DirectShow 值不值得现在学

一台采集卡在厂商自带的工具里出图正常,接进自己的程序却只给一片黑;或者预览跑得好好的,一开录制帧率就腰斩。这类问题八成不是驱动坏了,而是滤镜图没搭对。DirectShow 是 Windows 上一套基于 COM 的多媒体流框架,把采集、解码、色彩转换、渲染拆成一个个可插拔的 Filter,再用 Pin 连成一张有向图。它资格很老,平台方的重心也早就挪到了 Media Foundation,可工业相机、采集卡、老式视频采集设备的驱动层至今仍大量只暴露 DirectShow 接口。这份资源适合两类人:手上有一批只能用 DirectShow 打开的设备,或者维护着一套跑了很多年、不能推倒重来的音视频采集代码。它不负责教你写界面,负责让你搞清楚数据从哪个 Pin 出来、经过谁、最后去了哪。

2. 滤镜图拆解:一条预览链路里到底挂了几个 Filter

很多人第一次接触 DirectShow 是从RenderStream一行调用开始的,一行下去画面就出来了,于是误以为这东西很简单。等到要抓帧、要转码、要同时预览和录像的时候,问题全冒出来了。原因在于RenderStream是「智能」的,它替你做了大量决策——选哪个 Pin、插入哪些中间 Filter、协商什么 MediaType——而你并不知道它选了什么。想真正掌控这条链路,第一步是把图拆开看。

2.1 Filter、Pin、MediaType:三个概念决定了你能不能连上

Filter 是 COM 组件,对外实现IBaseFilter,内部至少有一个 Pin。Pin 实现IPin,方向由PIN_DIRECTION标明,输入 Pin 只能被连一次,输出 Pin 可以连多个(但实际能不能多连取决于 Filter 实现)。两个 Pin 能不能连上,唯一的判据是 MediaType 有没有交集:输出 Pin 通过EnumMediaTypes报出自己能产出的候选类型,输入 Pin 用ReceiveConnection逐个试,第一个双方都接受的类型胜出。

MediaType 不是一个字符串,它由四部分组成:majortype(VIDEO / AUDIO / STREAM)、subtype(YUY2 / NV12 / MJPG / H264)、formattype(通常是 FORMAT_VideoInfo)、以及pbFormat指向的格式块,视频常见的是VIDEOINFOHEADER,里面装着宽高、位深、平均帧间隔AvgTimePerFrame。连接失败最常见的两个返回码是VFW_E_CANNOT_CONNECT(压根没有共同类型)和VFW_E_TYPE_NOT_ACCEPTED(类型有交集,但当前状态下不接受,比如分辨率超出设备能力)。

数据实际流动靠的是 Allocator。输出 Pin 实现IMemOutputPin::DecideAllocator,输入 Pin 可以自己提供一个IMemAllocator要求对方用,也可以让对方决定。缓冲区数量默认常常是 1 到 4 个,缓冲区一多延迟就大,一少就容易丢帧。这部分不用你手写,但要知道它存在——后面调延迟和丢帧,绕不开这里。

Filter 还有状态机:Stopped、Paused、Running,全部通过IMediaControl控制。Paused状态下数据已经被预填到 Allocator 里了,所以从 Paused 到 Running 切换几乎无延迟,这是做「先暂停后开录」体验的关键。很多设备从 Stopped 直接 Run 需要几百毫秒预热,第一帧是绿的或者黑的,就是从这来的。

2.2 用 GraphBuilder 手搭预览链路

下面这段是完整的最小链路:初始化 COM、创建滤镜图管理器、枚举视频输入设备、绑定采集 Filter、渲染预览 Pin。

#include <dshow.h> #include <wrl/client.h> using Microsoft::Wrl::ComPtr; // DirectShow 建议用 STA,VideoWindow 依赖窗口消息泵 HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); if (FAILED(hr)) return hr; ComPtr<IGraphBuilder> pGraph; hr = CoCreateInstance(CLSID_FilterGraph, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pGraph)); ComPtr<ICaptureGraphBuilder2> pBuild; hr = CoCreateInstance(CLSID_CaptureGraphBuilder2, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pBuild)); hr = pBuild->SetFiltergraph(pGraph.Get()); // 两个管理器绑定到同一张图 // 枚举视频输入设备类别 ComPtr<ICreateDevEnum> pDevEnum; hr = CoCreateInstance(CLSID_SystemDeviceEnum, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pDevEnum)); ComPtr<IEnumMoniker> pEnum; hr = pDevEnum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory, &pEnum, 0); ComPtr<IBaseFilter> pCap; ComPtr<IMoniker> pMoniker; ULONG fetched = 0; if (pEnum && pEnum->Next(1, &pMoniker, &fetched) == S_OK) { // FriendlyName 是给人看的,DevicePath 才是唯一标识,别只用名字选设备 ComPtr<IPropertyBag> pBag; pMoniker->BindToStorage(nullptr, nullptr, IID_PPV_ARGS(&pBag)); pMoniker->BindToObject(nullptr, nullptr, IID_PPV_ARGS(&pCap)); pGraph->AddFilter(pCap.Get(), L"Capture"); } // 渲染预览 Pin:第一个参数是 Pin 类别,不是 MediaType hr = pBuild->RenderStream(&PIN_CATEGORY_PREVIEW, &MEDIATYPE_Video, pCap.Get(), nullptr, nullptr); if (hr == VFW_S_NOPREVIEWPIN) { // 设备没有独立预览 Pin,需要自己插 Smart Tee,见 3.2 } ComPtr<IMediaControl> pCtrl; pGraph->QueryInterface(IID_PPV_ARGS(&pCtrl)); pCtrl->Run();

代码里的顺序不能乱。SetFiltergraph必须在RenderStream之前调用,否则 CaptureGraphBuilder 会自己建一张新图,你的pGraph就成了空壳,后面QueryInterface拿IVideoWindow会拿到 nullptr,调试半天找不到原因。CoInitializeEx用 apartment-threaded 而不是 multi-threaded,是因为IVideoWindow和IMediaEvent的部分实现假定调用线程有消息泵,用 MTA 在部分设备上会直接返回RPC_E_CHANGED_MODE。

RenderStream的四个参数分别是 Pin 类别、媒体类型、源 Filter、中间 Filter、目的 Filter。中间和目的传 nullptr 表示「你帮我选」。PIN_CATEGORY_PREVIEW和PIN_CATEGORY_CAPTURE是两回事:前者意味着「低分辨率低帧率,给我看就行」,后者意味着「满负荷出数据」。有些采集卡两个 Pin 都有,有些只有一个 Capture Pin,此时渲染预览会返回VFW_S_NOPREVIEWPIN,这是个成功码不是错误码,容易被FAILED(hr)直接吞掉。

2.3 能力协商:GetStreamCaps 与 SetFormat 的正确打开方式

设备支持的格式不是一个列表,而是一组带步长的范围。IAMStreamConfig::GetNumberOfCapabilities告诉你有多少项,GetStreamCaps逐项取出,每一项返回一个VIDEO_STREAM_CONFIG_CAPS,里面minOutputSize、maxOutputSize、OutputGranularityX/Y描述了可调范围。很多人直接把pmt->pbFormat里的宽高当成「设备支持的分辨率」拿去显示成下拉框,结果列表里只有一项 640x480,实际上设备能到 1920x1080——因为返回的是范围的下界。

ComPtr<IAMStreamConfig> pConfig; pBuild->FindInterface(&PIN_CATEGORY_CAPTURE, &MEDIATYPE_Video, pCap.Get(), IID_PPV_ARGS(&pConfig)); int count = 0, size = 0; pConfig->GetNumberOfCapabilities(&count, &size); for (int i = 0; i < count; ++i) { AM_MEDIA_TYPE* pmt = nullptr; VIDEO_STREAM_CONFIG_CAPS caps{}; if (SUCCEEDED(pConfig->GetStreamCaps(i, &pmt, (BYTE*)&caps))) { auto* vih = (VIDEOINFOHEADER*)pmt->pbFormat; // 按 granularity 对齐,别直接取 minOutputSize int w = caps.minOutputSize.cx; int h = caps.minOutputSize.cy; // MJPG 在 USB2.0 上是刚需:1080p 的 YUY2 带宽根本不够 if (pmt->subtype == MEDIASUBTYPE_MJPG) { /* 优先选 */ } DeleteMediaType(pmt); } }

设格式用SetFormat,但有个前提:图必须处于 Stopped 状态。Running 状态下调SetFormat,有的驱动返回S_OK却什么都不做,有的直接让整条链路挂死。这就是那个经典翻车现场——切换分辨率后画面还在,但是卡住不动了。

选 subtype 的时候,USB 接口的相机要特别当心。1080p30 的 YUY2 需要接近 1Gbps 的实际带宽,USB2.0 的 480Mbps 根本扛不住,驱动会静默降帧或者直接掉到 640x480。MJPG 是压缩流,带宽需求降一个数量级,代价是解码要占 CPU。同一个设备在别的软件里流畅、在你的程序里卡,先查 subtype 选的是不是 YUY2。

3. 抓帧与转码:SampleGrabber、Smart Tee 与编码器的组合拳

预览只是把数据丢给显卡,真正有价值的是把帧拿到手。DirectShow 抓帧有三条路:SampleGrabber、自定义 Renderer、以及直接从输出 Pin 拉数据。三条路的实时性、拷贝次数、可维护性完全不同,选错了后面全是补丁。

3.1 SampleGrabber 的两种取帧方式与拷贝代价

SampleGrabber 是个「透明」的中间 Filter:数据进来,原样出去,同时让你有机会看一眼。它有两种取帧方式。第一种是SetBufferSamples(TRUE)加GetCurrentBuffer,内部维护一份拷贝,你随时去取最新一帧。第二种是SetCallback注册ISampleGrabberCB,每来一帧回调一次。

ComPtr<IBaseFilter> pGrab; CoCreateInstance(CLSID_SampleGrabber, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pGrab)); ComPtr<ISampleGrabber> pGrabber; pGrab->QueryInterface(IID_PPV_ARGS(&pGrabber)); // 必须先设 MediaType 再插进图里,否则协商可能选到 RGB 触发自动转换 AM_MEDIA_TYPE mt{}; mt.majortype = MEDIATYPE_Video; mt.subtype = MEDIASUBTYPE_NV12; // 跟随设备原生格式,避免插转换 Filter pGrabber->SetMediaType(&mt); pGrabber->SetBufferSamples(FALSE); // 关掉内置拷贝 pGrabber->SetOneShot(FALSE); pGrabber->SetCallback(this, 0); // 0 = SampleCB,拿到 IMediaSample 指针零拷贝 // 加一个 Null Renderer 兜底,让图有个终点 ComPtr<IBaseFilter> pNull; CoCreateInstance(CLSID_NullRenderer, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pNull)); pGraph->AddFilter(pNull.Get(), L"Null");

关键参数是SetBufferSamples(FALSE)配合SampleCB。SampleCB拿到的是IMediaSample*,GetPointer直接指向 Allocator 的缓冲区,没有额外拷贝,但代价是你只能在这个回调里用,回调一返回缓冲区就被回收了,想留着必须自己 memcpy。BufferCB则把数据拷成BYTE*再给你,多一次拷贝但生命周期安全。做实时 AI 推理,我一般用SampleCB直接把指针喂给推理引擎,同步跑完再返回;做需要排队落盘的录制,用BufferCB,因为它天然有一份独立内存,可以丢进队列。

SetMediaType一定要在AddFilter之前调。设成MEDIASUBTYPE_NV12的意思是「我只接受这个」,如果设备出的是 YUY2,图的协商会插入一个色彩转换 Filter 转成 NV12,转换是有代价的。设成MEDIASUBTYPE_RGB24最省事,几乎所有设备都能转,但 CPU 占用会明显上去,1080p 下这个开销不小。

还有一点容易被忽略:SampleGrabber在较新的 SDK 里已经从公开头文件移除了,qedit.h需要自己从旧版 SDK 里拿或者手工声明ISampleGrabber接口和CLSID_SampleGrabber。这不是 bug,是平台方向变了,但接口本身在系统里依然可用。

3.2 Smart Tee:预览和采集同时要的时候别再 RenderStream 两次

想同时预览和录像,直觉做法是调两次RenderStream,一次 PREVIEW 一次 CAPTURE。在双 Pin 设备上这能跑通,因为两个 Pin 各自独立出流;但在单 Pin 设备上,第二次调用会失败,或者更糟——它悄悄把第一次的连接拆了。

正确做法是插一个 Smart Tee Filter。它有一个输入 Pin、两个输出 Pin:Capture Pin 出完整数据,Preview Pin 出可以丢帧的数据。Smart Tee 内部会让 Capture 优先,Preview 在带宽紧张时主动丢帧,这正是你要的行为。

ComPtr<IBaseFilter> pTee; CoCreateInstance(CLSID_SmartTee, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pTee)); pGraph->AddFilter(pTee.Get(), L"SmartTee"); // 手工连:采集 Filter -> SmartTee -> (Grabber -> Null) + (Renderer) hr = pBuild->RenderStream(&PIN_CATEGORY_CAPTURE, &MEDIATYPE_Video, pCap.Get(), pTee.Get(), nullptr);

把pTee作为中间 Filter 传进去,RenderStream 会自动把采集 Pin 连到 Tee 的输入。之后 Tee 的 Preview 输出可以再渲染到窗口,Capture 输出接 SampleGrabber 加 Null Renderer。注意顺序:先接 Capture 分支再接 Preview 分支,反过来的话 Tee 可能判定 Capture 未被使用而不做优先级调度。

3.3 从原始帧到 H.264:编码器接入的步骤与参数

采集出来的是 YUV 裸流,直接写盘体积惊人——1080p30 的 NV12 一秒就是 93MB。要落成 H.264,路径是编码 Filter 加 Mux 加 File Writer,或者干脆绕开 DirectShow,把帧从SampleCB拿出来自己送编码器。

走 DirectShow 内部编码的好处是零拷贝、时间戳现成,坏处是可选编码器受限于系统里注册了哪些 Filter。系统自带的编码器覆盖有限,H.264 通常要装第三方 Filter。接入步骤是:CoCreateInstance拿到编码器 Filter,AddFilter,然后用RenderStream从 Grabber 的输出连过去,再RenderStream到CLSID_AVIMux或CLSID_MPEG2Demultiplexer再加 File Writer。

参数上要盯三个:码率(IAMVideoCompression::SetQuality或编码器私有接口)、GOP 长度、以及 profile。GOP 太长的直接后果是丢包后花屏持续时间变长,直播场景一般设 30 到 60 帧。码率别光看设备侧,VIDEOINFOHEADER::dwBitRate这个字段很多 Filter 根本不读,要设得走编码器自己的接口。

自己送编码器则简单得多,从SampleCB里拿出IMediaSample的指针和时间戳,喂给外部编码库,REFERENCE_TIME除以 10 就是毫秒。这条路的坑在时间戳连续性——设备丢帧时时间戳会跳变,编码器遇到大跳变会插关键帧或者直接报错,得自己做一次单调化处理。

4. 时钟与线程:DirectShow 里最像玄学的三件事

同一份代码,换一台机器就表现不同;白天好好的,晚上跑就丢帧。这类问题查到最后往往落在时钟、线程和通知机制上。三样东西都没有「正确配置」,只有「和你的场景匹配」。

4.1 Reference Clock 与 IMediaSample 时间戳

DirectShow 的整条图共享一个参考时钟,通过IReferenceClock::GetTime拿到当前时间,单位是 100 纳秒,也就是REFERENCE_TIME。每个IMediaSample上有两个时间:GetTime是呈现时间(相对时钟),GetMediaTime是媒体时间(相对流起点)。同步播放时,Renderer 会比较当前时钟和样本的呈现时间,早了就等,晚了就丢。

自己写 Renderer 或者做抓帧分析,必须自己处理这个差值。抓帧场景里时钟通常是设备时钟,不是系统时钟,样本时间戳的间隔反映真实帧间隔。如果发现间隔忽大忽小,先别怀疑代码,去查设备是不是在自动降帧——USB 带宽不足时驱动会悄悄降,表现为时间戳间隔从 33ms 变成 66ms。

IMediaSample::SetTime在自定义 Renderer 里可以改时间戳,改错了会导致下游 Filter 判定样本过期直接丢弃。一条血泪经验:任何对时间戳的修改都必须在同一个参考时钟下做,混用设备时钟和系统时钟,同步逻辑永远对不上。

4.2 回调线程与消息泵:为什么你的 BufferCB 会卡死界面

BufferCB和SampleCB跑在 SampleGrabber 内部的流线程上,不是你的主线程。在这个回调里做任何耗时操作——写文件、跑推理、发 HTTP——都会堵住整条流,表现为预览越来越卡,最后彻底不动。

同一类错误还有IMediaEvent::WaitForCompletion。它会阻塞当前线程直到播放结束,在主线程调它,界面直接无响应。正确做法是用IMediaEventEx::SetNotifyWindow,让 DirectShow 把事件转成窗口消息投递给你。

ComPtr<IMediaEventEx> pEvent; pGraph->QueryInterface(IID_PPV_ARGS(&pEvent)); // 事件以 WM_GRAPHNOTIFY 投递到 hwnd,主线程不再阻塞 pEvent->SetNotifyWindow((OAHWND)hwnd, WM_GRAPHNOTIFY, 0); // 窗口过程里这样取事件,注意必须循环取干净 LRESULT OnGraphNotify(HWND hwnd) { long evCode = 0; LONG_PTR p1 = 0, p2 = 0; while (pEvent->GetEvent(&evCode, &p1, &p2, 0) == S_OK) { if (evCode == EC_COMPLETE) { /* 播放结束 */ } pEvent->FreeEventParams(evCode, p1, p2); // 漏掉这句就是内存泄漏 } return 0; }

FreeEventParams是必须的,每个事件都带参数,不释放就一直攒着。见过跑一晚上涨几个 G 的案例,查到最后就是这一行漏了。

另外,SetNotifyWindow的窗口句柄必须在图的整个生命周期内有效。窗口销毁了图还在跑,事件往一个野句柄投递,崩溃现场非常难看。

4.3 分辨率动态切换:Stop 之后重建还是动态重连

用户在下拉框里从 1080p 切到 720p,怎么做?最稳的是停图、断连、设格式、重连、再跑。整套流程走一遍大概 200 到 500 毫秒,画面会闪一下,但兼容性最好。

想不闪,可以只在 Capture 分支上做局部重建:pCtrl->Stop()整个图还是得停,但只拆掉 Tee 之后的连接,改完SetFormat再接回去。这样窗口不重建,观感好很多。

有些设备支持运行中改格式,SetFormat在 Running 状态返回S_OK,但实际生效要等下一次 Stop/Run。这种「假装成功」最坑,你以为切了,其实没有,后面读到的帧尺寸还是旧的。判断办法是在SetFormat之后立刻调GetFormat读回来,比对宽高是否真的变了,不一致就老老实实走整图重建。

切换过程中还有一个容易忽略的点:切换时正在处理的帧会被丢弃,如果你的应用依赖帧序号连续(比如做逐帧对齐),必须自己维护一个自增序号而不是信设备的时间戳。

5. 排查清单:五条把「黑屏/花屏/泄漏」定位到具体 Filter 的记录

DirectShow 的错误码经常说半句话,S_OK不代表成功,E_FAIL也不告诉你谁失败了。下面五条是实际项目里反复出现的形态,每条都给出现象、原因和可操作的解法。

5.1 现象:预览窗口全黑,RenderStream却返回S_OK

原因通常有三个层次。最常见的是图在跑但没有窗口——IVideoWindow::put_Owner没调,或者调了但窗口本身不可见,数据在渲染,画在别处。第二个层次是设备选中了但没出流,IMediaControl::Run返回了,实际流线程卡在等第一帧,多见于设备被别的进程独占。第三个层次才是真正的连接问题:RenderStream返回VFW_S_NOPREVIEWPIN,这是个成功码,但实际没建任何预览路径。

解法是分层验证。先QueryInterface拿IVideoWindow,确认非空并显式put_Owner、put_WindowStyle、SetWindowPosition,把窗口位置设成明显可见的区域排除「画在屏幕外」。再抓一帧看有没有数据,用 SampleGrabber 挂在链路上,GetCurrentBuffer返回 0 就说明根本没有数据流,问题在设备侧。用 GraphEdit 类工具把运行时图导出来对比,能一眼看出中间插了几个 Filter、渲染器到底连没连上。

5.2 现象:抓到的帧花屏、上下颠倒、或者整体发绿

发绿基本可以锁定色彩空间解释错了。设备出的是 NV12,你按 RGB24 去读,绿色通道被当成了亮度,画面就是一片绿。解法是先GetConnectedMediaType读出实际协商的 MediaType,再按subtype决定怎么解释缓冲区,别硬编码。

上下颠倒来自VIDEOINFOHEADER::biHeight的符号。负值表示自上而下存储,正值表示自下而上。DirectShow 里这两种都合法,渲染器会处理,但你直接memcpy出去不管符号,出来的图就是翻的。解法是读biHeight,小于 0 就按正向处理,大于 0 自己翻转行序。

花屏(局部块状错乱)多半是步长问题。缓冲区里每行占的字节数不一定是width * bytesPerPixel,可能按 4 字节或 16 字节对齐,要看biWidth和实际GetSize反推。按width * 3去读一个 4 字节对齐的 RGB24 缓冲,每行错几个字节,越往右越歪,就是典型的斜向撕裂。

5.3 现象:录制十分钟后内存涨到几个 G

先怀疑IMediaEvent的事件没释放。每次GetEvent之后必须FreeEventParams,循环取事件的时候漏掉这一句,事件队列会一直涨,尤其是有EC_VIDEO_SIZE_CHANGED这类高频事件的设备。

再怀疑 SampleGrabber 的缓冲。SetBufferSamples(TRUE)下内部持有一份固定大小的拷贝,正常不涨。但如果自己另存了一份队列又没有上限,编码跟不上采集,队列就会无限堆积。解法是给队列设硬上限,超了丢最旧的帧,宁可丢帧也不能 OOM。

第三处是 COM 引用计数。QueryInterface拿到的每个接口都要Release,AM_MEDIA_TYPE用完要DeleteMediaType,GetStreamCaps返回的pmt尤其容易漏——它在循环里,漏一次就是几十字节乘以设备能力项数,看着不多,但循环外面还有一层重连逻辑,重连一百次就明显了。

5.4 现象:32 位程序一切正常,编成 x64 就枚举不到任何设备

这是最典型的血泪经验之一。设备枚举走的是 COM 类别,而 Filter 是注册表里按位数分开注册的。老的采集卡驱动往往只提供了 32 位版本,64 位进程去CLSID_VideoInputDeviceCategory下枚举,列表就是空的,CreateClassEnumerator还返回S_FALSE而不是错误码。

解法有两条。要么整个程序编成 32 位,这是最省事的;要么做进程分离,主程序 64 位,采集跑在一个 32 位的辅助进程里,两边用共享内存传帧。后者的代价是帧数据要跨进程拷贝一次,1080p30 下这个开销大约占单核的几个百分点,能接受。

判断方法很简单:在 64 位进程里枚举结果为空,换 32 位立刻有设备,就基本可以确认。别去改注册表强行注册,那样即使枚举出来了,BindToObject也会失败。

5.5 现象:切换分辨率后卡死,或者画面变成绿屏

前面提过SetFormat必须在 Stopped 状态调。但还有一层:调用SetFormat之前,必须先把该 Pin 上的连接断掉。在连着的情况下改格式,有的驱动直接死锁,有的返回成功但内部状态错乱,下一帧出来就是绿的。

正确顺序是:IMediaControl::Stop→ 找到所有相关 Pin 调Disconnect→SetFormat→RenderStream重建连接 →Run。中间每一步都要检查返回值,Disconnect在没连的情况下返回S_FALSE,不算错。

还有一个隐蔽的:断连之后 Filter 还留在图里,重新RenderStream时可能复用了旧的 Filter 实例,而它内部缓存的格式还是老的。稳妥做法是把采集 Filter 之后的所有 Filter 从图里RemoveFilter掉再重建,代价是慢一点,但状态干净。

6. 交付前的自检:把滤镜图导出成可回放的诊断快照

调试 DirectShow 最大的问题是「现场不可复现」。用户那边黑屏,你这边一切正常,靠远程问「你点了哪个按钮」永远问不出结果。我后来养成的习惯是:图一建好,立刻把它序列化成.grf文件存下来。

ICaptureGraphBuilder2没有直接的导出接口,但IGraphBuilder支持QueryInterface到IPersistStream,用Save写到文件即可:

ComPtr<IPersistStream> pPersist; pGraph.As(&pPersist); ComPtr<IStream> pStream; SHCreateStreamOnFileEx(L"graph_dump.grf", STGM_CREATE | STGM_WRITE, 0, TRUE, nullptr, &pStream); pPersist->Save(pStream.Get(), TRUE);

存下来的文件可以用 GraphEdit 一类的工具直接打开,看到的就是当时真实的图结构:中间插了几个 Filter、MediaType 协商成了什么、哪个 Pin 没连上。现场出问题时让用户按一下「导出诊断图」,把文件发回来,比问十句都管用。

配套的是一份启动自检表,每次程序起来跑一遍,十几秒的事:

检查项判定方式不通过的典型原因
设备枚举非空CreateClassEnumerator返回S_OK且有设备位数不匹配,见 5.4
设备可绑定BindToObject成功设备被占用或驱动异常
图构建成功SetFiltergraph返回S_OK顺序错误,先 Render 后 Set
格式协商一致GetConnectedMediaType与预期 subtype 相同被插入转换 Filter
首帧到达500ms 内GetCurrentBuffer非 0设备未出流
时间戳单调连续 10 帧间隔偏差 < 20%带宽不足自动降帧
事件队列可清空循环GetEvent能取到S_OK后结束漏调FreeEventParams
停止可回收Stop后 200ms 内引用计数归零接口Release不配对

最容易翻车的是「首帧到达」这一项。很多设备从Run到出第一帧需要 300 到 800 毫秒,你把超时设成 100ms,自检永远报失败,然后你去改代码,其实是超时太短。反过来,超时设成 5 秒,真出问题的时候用户要等 5 秒才看到报错,体验很差。我一般设 1500ms,配合一次重试。

最后一件事,也是我踩得最深的一次:所有QueryInterface拿到的接口、所有CoCreateInstance创建的对象,用一个 RAII 包装器统一管,别手动Release。那次是图重建逻辑里漏了一个IBaseFilter的Release,单次泄漏几十字节,跑一天下来内存曲线缓慢上升,等到发现的时候已经排了两天。从那以后我每次重构采集模块,都强制走一遍这张自检表加.grf导出,看着啰嗦,但省下来的时间远多于花掉的。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询