
简介这份 DirectShow 开发资料包面向需要掌握 Windows 音视频采集、播放与滤镜开发的 C 程序员及多媒体方向学习者围绕过滤器、过滤图、GraphEdit 调试和 COM 接口调用等核心知识提供可编译参考与示例代码帮助理解从源过滤器到渲染过滤器的数据流转过程以及实时流处理中的模块化扩展方法。压缩包共 90 个文件约 57.15MB包含 39 个 .h 头文件、33 个 .cpp 源文件、4 个 .lib 静态库以及 .sln/.vcxproj 工程、示例可执行程序和 avi 测试素材覆盖基础类库、自定义测试滤镜与图形调试工具等模块目录按类库与测试工程分块组织便于对照学习过滤器连接、格式转换、音视频同步与播放控制。已有 288 人学习下载。读者可从中获得过滤图搭建、自定义过滤器编写、采集解码渲染链路验证及常见问题排查思路对直播、录制、视频编辑等应用场景也有直接借鉴意义适合作为旧版 Windows 多媒体应用开发的参考资料。1. DirectShow 到底卡在哪一条从摄像头到窗口的链路为什么会黑屏预览软件能出图接到自研程序里就是一片黑日志里只留一句0x80070005。这种场景在基于 DirectShow 做采集的工程里太常见了。DirectShow 是 Windows 上一套基于 COM 的流媒体框架它把采集设备、解码器、渲染窗口抽象成一个个 Filter用 Pin 连成一张有向图再由图管理器统一调度时钟和数据搬运。它不算新但在工业相机、采集卡、老编码器这些还在用 DirectShow 驱动的硬件生态里仍然是能最快跑通的一条路。这篇写给两类人一是被采集链路卡住、需要把格式和缓冲捏在手里的开发者二是评估“该不该用这套框架”的技术负责人。后面按“图怎么搭、参数怎么设、帧怎么取、坑在哪”逐段拆开每一步都给到能直接抄的代码和参数。2. 过滤器图怎么搭三条最小链路与媒体类型协商的先后顺序先把模型吃透再动手写代码能省掉后面一大半的调试时间。DirectShow 的抽象只有三层但每层都对应一个具体的失败面Filter 是能力单元Pin 是连接契约Allocator 是内存池。绝大多数的“连不上”“跑起来就卡”追到根上都是这三层里某一层的假设没对齐。2.1 Filter、Pin、Allocator为什么不能像 VideoCapture 那样一行搞定一个 Filter 就是一个实现了IBaseFilter的 COM 组件它对外暴露若干 Pin。Pin 分方向输出 Pin 产出数据输入 Pin 消化数据。两个 Pin 能不能连上取决于两件事——方向对不对以及双方能不能协商出一个共同的媒体类型Media Type。媒体类型是一份结构化描述主类型是 Video 还是 Audio子类型是 MJPG、YUY2 还是 NV12外加VIDEOINFOHEADER里的宽高、位深、帧率。协商的顺序是有讲究的。上游输出 Pin 先给出自己支持的候选类型集合下游输入 Pin 用ReceiveConnection逐个试着接受第一个双方都认的就定下来。如果都不认ConnectDirect直接返回VFW_E_CANNOT_CONNECT。这个顺序意味着你想要的格式必须出现在上游的候选集里并且排在下游能接受的位置否则光改下游没用。Allocator 是第三层。类型谈妥后上下游要就“一次搬多少字节、准备几个缓冲”达成一致这块内存池由输出 Pin 创建输入 Pin 通过IMemInputPin::GetAllocator拿到句柄直接往里写中间不做拷贝。这个设计决定了 DirectShow 的延迟下限也决定了为什么“回调里做耗时操作”会直接把整张图拖死。那为什么不干脆用 OpenCV 的VideoCapture一行读完因为当你需要同时挂两路相机、需要在采集和渲染之间插一个硬件编码器、需要精确控制 USB 带宽下的缓冲数量时抽象层会把这些旋钮全部藏起来。选型上我一般的判断是单路预览加简单存图用高层封装多路、要插自定义处理节点、要和采集卡的私有接口打交道直接上 DirectShow 的图。方案可控粒度典型适用场景主要代价高层视频读取封装分辨率、帧率单路、快速原型拿不到 Allocator、插不进自定义 FilterDirectShow 过滤器图Pin 级、媒体类型级、缓冲级多路采集、硬件编码器串联代码量大COM 生命周期要自己管设备厂商私有 SDK取决于厂商需要触发线、硬件时间戳换设备就得重写2.2 用 CaptureGraphBuilder2 搭一条“采集到预览”的最小链路真正手写每条 Pin 的连接很啰嗦常规做法是用ICaptureGraphBuilder2这个辅助对象它负责把源 Filter、中间 Filter、渲染器按规则串起来。下面这段是能编译跑通的最小骨架放在一个 STA 线程里执行。#include dshow.h #pragma comment(lib, strmiids.lib) // 必须在同一线程里成对调用DirectShow 多数滤镜默认按 STA 行为工作 HRESULT hr CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); if (FAILED(hr)) return hr; IGraphBuilder* pGraph nullptr; ICaptureGraphBuilder2* pBuild nullptr; IMediaControl* pCtrl nullptr; IMediaEvent* pEvent nullptr; // 1. 建图管理器 hr CoCreateInstance(CLSID_FilterGraph, nullptr, CLSCTX_INPROC_SERVER, IID_IGraphBuilder, (void**)pGraph); // 2. 建捕获图辅助对象并把图交给它托管 hr CoCreateInstance(CLSID_CaptureGraphBuilder2, nullptr, CLSCTX_INPROC_SERVER, IID_ICaptureGraphBuilder2, (void**)pBuild); hr pBuild-SetFiltergraph(pGraph); // 3. 按“友好名”加载源滤镜真实项目里应先枚举设备再绑定 moniker IBaseFilter* pCap nullptr; hr pGraph-AddSourceFilter(L你的设备友好名, LCapture, pCap); // 4. 一条 RenderStream 完成 源-解码(若需要)-渲染器 的自动串联 // PIN_CATEGORY_CAPTURE 表示取采集分支PIN_CATEGORY_PREVIEW 表示取预览分支 hr pBuild-RenderStream(PIN_CATEGORY_PREVIEW, MEDIATYPE_Video, pCap, nullptr, nullptr); // 5. 取控制接口与事件接口Run 之后用事件接口阻塞等“真正开始出帧” pGraph-QueryInterface(IID_IMediaControl, (void**)pCtrl); pGraph-QueryInterface(IID_IMediaEvent, (void**)pEvent); hr pCtrl-Run(); long ev 0; pEvent-WaitForCompletion(3000, ev); // 仅用于自检正式代码不要在这里长等逻辑上分五步先CoInitializeEx建立 COM 环境再用CoCreateInstance拿到图管理器与捕获图辅助对象SetFiltergraph把两者绑定AddSourceFilter把设备加入图最后RenderStream让框架自动补全中间节点和渲染器。参数上有三处最容易填错。CLSCTX_INPROC_SERVER不能省DirectShow 的滤镜都是进程内组件PIN_CATEGORY_PREVIEW和PIN_CATEGORY_CAPTURE的区别在于很多采集设备的输出 Pin 会通过一个 Smart Tee 分成两路预览分支允许丢帧采集分支不允许要抓帧存盘就该走PIN_CATEGORY_CAPTUREWaitForCompletion的 3000 是毫秒它内部会阻塞消息泵正式代码里应该用IMediaEventEx::SetNotifyWindow改成窗口消息回调否则 UI 先假死。还有一条血泪经验AddSourceFilter传入的是“友好名”重名或者包含特殊字符时容易绑错设备。生产代码里应该走ICreateDevEnum枚举出IMoniker再用BindToObject绑定这样拿到的是设备唯一标识而不是一个可能重复的字符串。2.3 媒体类型谈不下来时先看 HRESULT 再动代码RenderStream失败时返回的 HRESULT 基本能定位到具体环节。下面这几个是我调试时翻得最多的HRESULT含义通常的真实原因0x80040154类未注册32/64 位不匹配滤镜只有一种位宽的版本0x800401F0COM 未初始化捕获线程里漏了CoInitializeEx0x80070005拒绝访问设备被别的进程独占或相机权限未开VFW_E_CANNOT_CONNECT无法连接上下游没有共同媒体类型VFW_E_TYPE_NOT_ACCEPTED类型不被接受分辨率/帧率超出设备能力集VFW_E_NOT_RUNNING图未运行在Run之前就去取帧看到VFW_E_CANNOT_CONNECT不要急着改代码先确认设备到底支持什么格式——这就引到下一章的枚举与能力集读取。3. 摄像头与采集卡枚举把分辨率、帧率、像素格式钉死在代码里设备枚举和能力集读取是整个链路里最该先写扎实的一段。很多“换一台机器就不出图”的问题本质是代码里硬编码了一个只有原机器支持的格式组合。3.1 用 ICreateDevEnum 枚举设备并正确释放 monikerICreateDevEnum是 COM 提供的设备分类枚举器视频采集设备对应的分类标识是CLSID_VideoInputDeviceCategory。它返回的每一项是一个IMoniker既能取友好名显示也能直接绑定成 Filter。// 返回所有视频采集设备的友好名同时保留 moniker 供后续绑定 std::vectorstd::wstring EnumCaptureDevices(std::vectorIMoniker* monikers) { std::vectorstd::wstring names; ICreateDevEnum* pEnum nullptr; IEnumMoniker* pCls nullptr; HRESULT hr CoCreateInstance(CLSID_SystemDeviceEnum, nullptr, CLSCTX_INPROC_SERVER, IID_ICreateDevEnum, (void**)pEnum); if (FAILED(hr)) return names; // 第二个参数是分类标识视频采集用 CLSID_VideoInputDeviceCategory hr pEnum-CreateClassEnumerator(CLSID_VideoInputDeviceCategory, pCls, 0); if (hr ! S_OK) { pEnum-Release(); return names; } // S_FALSE 表示该类下无设备 IMoniker* pMk nullptr; while (pCls-Next(1, pMk, nullptr) S_OK) { IPropertyBag* pBag nullptr; if (SUCCEEDED(pMk-BindToStorage(nullptr, nullptr, IID_IPropertyBag, (void**)pBag))) { VARIANT v; VariantInit(v); // 友好名在某些虚拟设备上可能为空需兜底 if (SUCCEEDED(pBag-Read(LFriendlyName, v, nullptr))) names.emplace_back(v.bstrVal); VariantClear(v); pBag-Release(); } monikers.push_back(pMk); // 引用计数 1由调用方负责 Release } pCls-Release(); pEnum-Release(); return names; }这里的关键是返回值判断CreateClassEnumerator在“该类下没有任何设备”时返回的是S_FALSE而不是失败很多人只判FAILED就往下走结果拿到空指针崩溃。monikers里的每个IMoniker引用计数都已加一调用方必须逐一Release这是最常见的泄漏点之一。绑定设备时用pMk-BindToObject(nullptr, nullptr, IID_IBaseFilter, (void**)pFilter)然后pGraph-AddFilter(pFilter, LCapture)。这样不管设备友好名重不重复绑定的都是同一个物理设备。3.2 IAMStreamConfig读能力集、写目标格式的两个动作拿到源 Filter 后向它的输出 Pin 查询IAMStreamConfig。注意要按引脚分类去找直接遍历 Pin 会找到一堆用不上的。读取能力集用GetNumberOfCapabilities加GetStreamCaps两次调用。// 遍历某个输出引脚支持的所有视频格式打印宽高与帧率 void DumpFormats(IAMStreamConfig* pCfg) { int count 0, size 0; if (FAILED(pCfg-GetNumberOfCapabilities(count, size))) return; // 缓冲区大小必须是 size结构取决于主类型视频一般是 VIDEO_STREAM_CONFIG_CAPS std::vectorBYTE buf(size); for (int i 0; i count; i) { AM_MEDIA_TYPE* pmt nullptr; if (FAILED(pCfg-GetStreamCaps(i, pmt, buf.data()))) continue; if (pmt-formattype FORMAT_VideoInfo) { auto* vih (VIDEOINFOHEADER*)pmt-pbFormat; // AvgTimePerFrame 单位是 100ns帧率 10000000 / AvgTimePerFrame double fps vih-AvgTimePerFrame ? 1e7 / vih-AvgTimePerFrame : 0; printf(idx%d %ldx%ld subtype%d fps%.2f\n, i, vih-bmiHeader.biWidth, vih-bmiHeader.biHeight, pmt-subtype.Data1, fps); } FreeMediaType(*pmt); // 必须释放否则每轮泄漏一块 } }GetStreamCaps返回的AM_MEDIA_TYPE里带着一块由框架分配的格式块必须用FreeMediaType释放再CoTaskMemFree掉结构本身有些版本里DeleteMediaType一步到位。我在早期项目里漏掉这一步跑一晚上枚举循环内存直接涨到几个 G。设定目标格式则是构造一个同样结构的AM_MEDIA_TYPE传给SetFormat// 把分辨率、像素格式、帧率钉死失败时返回具体 HRESULT 便于降级 HRESULT SetCaptureFormat(IAMStreamConfig* pCfg, LONG w, LONG h, const GUID subtype, double fps) { AM_MEDIA_TYPE* pmt nullptr; if (FAILED(pCfg-GetFormat(pmt))) return E_FAIL; pmt-majortype MEDIATYPE_Video; pmt-subtype subtype; // 例如 MEDIASUBTYPE_MJPG / MEDIASUBTYPE_YUY2 pmt-formattype FORMAT_VideoInfo; auto* vih (VIDEOINFOHEADER*)pmt-pbFormat; vih-bmiHeader.biWidth w; vih-bmiHeader.biHeight h; vih-bmiHeader.biSizeImage 0; // 压缩格式可置 0由设备自行填充 vih-AvgTimePerFrame (REFERENCE_TIME)(1e7 / fps 0.5); HRESULT hr pCfg-SetFormat(pmt); FreeMediaType(*pmt); CoTaskMemFree(pmt); return hr; }三个参数的取值顺序建议是先定像素格式再定分辨率最后定帧率。原因是像素格式决定了单帧字节数进而决定 USB 带宽能不能撑住目标帧率。1080p60 的 YUY2 未压缩数据量约 249 MB/s普通 USB 控制器根本喂不饱这时候必须切到 MJPG 让设备内部压缩。3.3 格式谈不下来时的降级链与缓冲协商SetFormat返回失败不代表设备不支持这个分辨率很多情况下是“这个分辨率 这个像素格式 这个帧率”的组合不成立。我一般按下面的顺序降级每一级都记日志方便现场定位优先级像素格式理由1MJPG带宽占用最低高分辨率高帧率首选2NV12便于后续送硬件编码或 GPU 上屏3YUY2兼容性最好但对带宽敏感4RGB24只在必须逐像素处理时用带宽最差降级时同步下调帧率30 到 25 再到 15仍失败才降分辨率。这条链子写在配置里不要写死在代码里现场换个相机就能改。另一个常被忽略的旋钮是IAMBufferNegotiation。默认缓冲数量由设备决定想要低延迟就得自己提要求// 向输出引脚建议缓冲参数设备不保证采纳需用 GetAllocatorProperties 回读确认 IAMBufferNegotiation* pNeg nullptr; if (SUCCEEDED(pPin-QueryInterface(IID_IAMBufferNegotiation, (void**)pNeg))) { ALLOCATOR_PROPERTIES req{}; req.cBuffers 3; // 缓冲个数少 延迟低但抗抖动能力差 req.cbBuffer w * h * 2; // 单缓冲字节数YUY2 按 2 字节/像素估 req.cbAlign 1; ALLOCATOR_PROPERTIES act{}; pNeg-SuggestAllocatorProperties(req); pNeg-GetAllocatorProperties(act); // 回读设备可能把 cBuffers 抬到 8 pNeg-Release(); }cBuffers设成 2 以下容易丢帧设成 8 以上延迟会明显上去。1 秒 30 帧的场景下3 到 4 个缓冲是我实测比较平衡的取值代价是遇到 USB 瞬时抖动时更容易掉帧这条得根据现场线材质量调。4. 帧数据到手SampleGrabber 回调、时间戳与丢帧判定的三处细节图跑起来了接下来才是真正要处理的部分——把每一帧拿到自己的内存里。这里有两条路用系统自带的采样抓取滤镜或者自己实现一个渲染器。4.1 SampleGrabber 的两种取帧模式轮询与回调SampleGrabber是一个直通滤镜插在链路中间不改变数据只把流过的样本抄一份出来。它必须先固定媒体类型通常把子类型设为GUID_NULL表示不限再选择取帧模式。// 在 源 - SampleGrabber - 渲染器 这条链里插入抓帧节点 ISampleGrabber* pGrab nullptr; IBaseFilter* pGrabF nullptr; CoCreateInstance(CLSID_SampleGrabber, nullptr, CLSCTX_INPROC_SERVER, IID_IBaseFilter, (void**)pGrabF); pGraph-AddFilter(pGrabF, LGrabber); pGrabF-QueryInterface(IID_ISampleGrabber, (void**)pGrab); AM_MEDIA_TYPE mt{}; mt.majortype MEDIATYPE_Video; mt.subtype MEDIASUBTYPE_NV12; // 指定下游需要的格式GUID_NULL 表示不限 mt.formattype FORMAT_VideoInfo; pGrab-SetMediaType(mt); // OneShot FALSE 表示持续回调TRUE 表示只抓一帧用于拍快照 pGrab-SetOneShot(FALSE); // BufferSamples TRUE 表示框架自己留一份拷贝此时才能安全地在回调外读数据 pGrab-SetBufferSamples(TRUE); pGrab-SetCallback(myCallback, 1); // 1 BufferCB带拷贝安全但慢SetBufferSamples(TRUE)与回调模式 0SampleCB是互斥的取舍SampleCB拿到的指针只在回调期间有效出了回调就被复用想留数据必须自己深拷贝BufferCB传给的是框架内部那份拷贝回调返回后仍可读但多了一次内存拷贝。1080p60 下这次拷贝大概是每帧 6 MB对内存带宽是有成本的量力选择。抓帧链路的一个必备细节SampleGrabber是直通滤镜它自己不能作为终点后面必须再接一个渲染器或者空渲染器否则图跑不起来。只要数据不要画面时接空渲染器省掉一次上屏。4.2 回调函数里的三条红线不阻塞、不分配、不碰 UI回调是在流线程上执行的那个线程同时负责整张图的数据搬运。你在里面多做 5 毫秒整条链路就少 5 毫秒的余量缓冲突发时立刻表现为丢帧。// 极简实现只把指针和元数据塞进无锁队列真正的处理放到消费线程 class GrabberCB : public ISampleGrabberCB { public: STDMETHODIMP BufferCB(double /*SampleTime*/, BYTE* pBuffer, long len) override { Frame f; f.len len; f.data.assign(pBuffer, pBuffer len); // 唯一允许的分配且应预分配复用 // 队列满就丢最旧的一帧绝不阻塞流线程 if (!queue.push(std::move(f))) { dropped_.fetch_add(1); } return S_OK; } STDMETHODIMP SampleCB(double, IMediaSample*) override { return E_NOTIMPL; } // QueryInterface / AddRef / Release 按 COM 规则实现略 private: RingQueueFrame queue; std::atomicuint64_t dropped_{0}; };三条红线不阻塞不加锁、不等事件、不做文件 IO、不频繁分配缓冲池预分配回调里只搬运、不碰 UIWindows 控件只能在创建它的线程上操作跨线程直接调SetWindowText之类的接口运气好是卡顿运气差是崩溃。回调里唯一该做的是把数据搬进队列并更新一个原子计数。4.3 时间戳与丢帧IMediaSample 里的两个时间到底填什么IMediaSample::GetTime返回一对REFERENCE_TIME单位是 100 纳秒分别表示开始时间和结束时间。这两个值来自参考时钟Reference Clock要么是系统时钟要么是设备提供的硬件时钟。同时还有GetMediaTime返回的是流时间从图开始运行算起。两者的区别直接决定了你怎么判断丢帧用相邻两帧的GetTime差值和理论帧间隔30 帧就是 333333 个 100ns 单位比较差值明显偏大说明中间有帧没送到。而GetMediaTime更适合做“这段数据在流里的位置”这类判断比如切片存储。如果设备提供硬件时钟优先让图使用它。做法是在图管理器上查询IReferenceClock或者对源滤镜查询IAMClockAdjust做微调。软件时钟在长时间采集下会累积漂移一小时下来差出几百毫秒并不稀奇做多路同步时这是致命的。5. 排查记录从类未注册到退出挂死的 5 个 DirectShow 现场下面这五条是我在不同项目里反复遇到的按出现频率从高到低排。5.1 现象程序在开发机正常客户机上返回 0x80040154原因几乎固定进程是 64 位而链路上某个滤镜只有 32 位版本或者反过来。DirectShow 的滤镜是进程内组件位宽必须和宿主进程一致注册表里两个视图SysWOW64与System32是分开的补偿不了。解决先确认目标进程的位宽再确认每个自定义滤镜的编译目标。第三方滤镜如果只有一种位宽只能让宿主进程跟着它。注册用管理员权限执行注册工具注册完在对应位宽的注册表视图里查一眼类标识确认真的写进去了而不是被静默拦下。5.2 现象预览窗口有画面抓帧拿到的全是零字节或者黑图原因有两类。一是抓帧滤镜插在了预览分支上而预览分支在带宽紧张时会被允许丢帧甚至暂停二是采样抓取滤镜后面没有接终结节点图虽然跑起来了但数据流在那一端就断了。解决抓帧链路固定走采集分支用引脚分类去找而不是随手拿第一个输出引脚采样抓取滤镜后面接空渲染器作为终点。另外确认SetBufferSamples的取值和回调模式匹配用SampleCB却去回调外读指针拿到的就是已经被覆写的内存。5.3 现象跑十几分钟后帧率缓慢下降最后卡死原因是回调里阻塞了流线程。最常见的写法是在BufferCB里直接做图像编码或者写文件设备输出是等时的你处理慢了缓冲池里可用的缓冲被逐一占满设备那边无缓冲可用就开始丢帧丢得多了驱动会重试甚至暂停推流。解决回调只做入队处理放到独立线程队列设上限满了丢旧帧并计数。用原子计数统计丢弃量跑一段时间看这个数是否稳定为零比看帧率曲线更早发现问题。缓冲数量调到 3 到 4 之间用协商接口写入并回读确认。5.4 现象热插拔相机后程序崩溃或者再也打不开设备原因是设备被拔出时图里还持有它的引用而代码只处理了正常退出流程。图上会抛出EC_DEVICE_LOST这个事件但前提是你挂了事件回调并且在收。解决用IMediaEventEx::SetNotifyWindow把事件投到窗口消息收到设备丢失后立刻停止图、释放源滤镜、清空抓帧队列然后重新枚举设备并重建整张图。重建不要试图复用旧的 Filter 指针DirectShow 的滤镜对象状态不适合反复激活重建比修复省事得多。5.5 现象关闭程序时界面已经消失进程还挂在任务管理器里原因是释放顺序错了。直接Release图管理器时如果图还在运行状态流线程还在回调而回调对象已经被销毁就是标准的野指针。另外事件回调没解绑消息队列里还留着指向已销毁窗口的句柄。解决固定一个顺序——先IMediaControl::Stop再解除事件通知SetNotifyWindow(nullptr, 0, 0)接着按创建的反序逐个Release抓帧回调、各 Filter、捕获图辅助对象、图管理器最后CoUninitialize。在调试版里给每个 COM 对象加一个引用计数日志退出时打印剩余引用能立刻定位是谁没释放。6. 把链路压到 30ms 以内缓冲协商、时钟对齐与一个可复用的自检循环端到端延迟 设备曝光 缓冲排队 传输 处理 上屏。多数人只优化“处理”那一段其实排队占的比重更大。三个缓冲、1080p30光排队就可能贡献 100 ms。我的做法是先量化再动手在回调里记录两个时间点一个是样本的开始时间戳转换成毫秒一个是回调进队列的时刻两者相减就是这条链路在这一帧上的总延迟取最近 100 帧的中位数看趋势。// 延迟采样单位换算成毫秒中位数比平均值更能反映真实体感 double LatencyMs(IMediaSample* pSample) { REFERENCE_TIME t0 0, t1 0; if (FAILED(pSample-GetTime(t0, t1))) return -1; auto now std::chrono::steady_clock::now().time_since_epoch(); double nowMs std::chrono::durationdouble, std::milli(now).count(); return nowMs - t0 / 10000.0; // 100ns - ms }这个数如果长期在 100 ms 以上先把缓冲数量往下调再看是不是解码节点引入了额外排队。压缩格式比如 MJPG在链路上必须要有一个解码器解码器的输出缓冲往往默认开得很大这块也得协商。时钟这块多路同步是另一个坑。两路相机各自用系统时钟跑一小时能差出几百毫秒画面就对不上了。可行的办法是让其中一路作为主时钟另一路通过IAMClockAdjust向主时钟靠拢或者干脆在应用层用帧时间戳做重排。我倾向后者虽然多一层缓冲但行为可预测出问题也好查。最后一个习惯每次换设备或换驱动后跑一遍自检循环枚举所有格式、逐个尝试设定、记录哪些组合成功、再跑 30 秒统计实际帧率和丢帧数。这套自检大概两百行代码能省掉在现场对着黑屏猜的半天。DirectShow 这套框架不新但它的失败模式高度集中把这五六个坑记住剩下的都是查表。希望帮到你。本文还有配套的精品资源点击获取