Windows 的采集设备:屏幕、摄像头与声卡

从零做一个全平台播放器·约 1946 字

播放器要能把屏幕、摄像头、麦克风都当成一个源,但三者的来路完全不同:屏幕和窗口是抓出来的画面,摄像头是设备推来的码流,声音还要分麦克风与声卡回环两套。Windows 上这三条路各自都有自己的关口——抓屏要处理窗口最小化与奇数宽高,读摄像头要定格式白名单并管住回调线程,收声要用两种不同的驱动方式。这篇写这些关口分别是什么,以及采出来的帧以什么形态交给引擎。

设备的槽位:三类视频设备、两类音频设备

Windows 这侧的视频设备分摄像头、窗口、显示器三类,音频设备分麦克风与声卡回环两类。每一类下面挂的是实现,按槽位区分:屏幕与窗口走一个槽(窗口采集那套),摄像头走另一个槽(Media Foundation 那套),音频走 WASAPI 那一个槽。

视频三类设备与音频两类设备各由哪个槽供,以及默认槽取到的是谁

默认槽按平台取,Windows 的视频默认槽指向窗口采集那一套,音频默认槽指向 WASAPI。取默认视频源拿到的是主显示器,不是摄像头。原因是窗口采集这一族的枚举顺序是显示器在前窗口在后,而「取默认视频源」拿的是设备表里的第一个——所以不点名摄像头槽位的话,拿到的是屏幕。

两个槽的注册时机也不同,一个挂在 ffmpeg 开关里,另一个不依赖 ffmpeg。关掉 ffmpeg 编译时,摄像头还能用,屏幕采集那一套不注册。

设备表本身、以及列出设备、按 id 找、按序号取这几个动作在帧源那一篇写过,这里只说 Windows 这几个槽里填的是谁。

屏幕采集:两条抓法,主路是 WinRT

屏幕和窗口归同一个管理器,枚举时先显示器后窗口,抓的时候是两条实现:

GDI 那条用窗口的设备上下文加一次 BitBlt,只能抓到走 GDI 画的窗口——硬件加速渲染和 DWM 合成的画面抓不到,所以它只是回退。WinRT Graphics Capture 那条是主路:系统版本够就用它。可用性在启动时探一次,探到就用 RT,探不到才回退。

屏幕与窗口的抓法、目标怎么分流,以及宽高补偶那一步在哪做

目标分流在 RT 那条里:窗口目标用 CreateForWindow,显示器目标用 CreateForMonitor。两类的宽高来源也不同,窗口走窗口矩形,显示器走显示器信息里的那一段矩形。

回退那条也把画面画进一张 D3D11 纹理,不是 CPU 位图。它先把纹理的表面拿来取一个 GDI 设备上下文,检查双方都支持位块传输,再拷过去。这一步拷完必须把取到的上下文放掉,不然下一次抓不到数据。所以回退路交出来的同样是纹理帧。

宽高补偶,以及最小化窗口怎么处理

平面格式与编码器都按偶数宽高工作,奇数宽高往下走会出绿边或者直接失败,所以两条抓法都在宽高上补了一步,只是补的位置不同。

RT 那条让帧池保持真实尺寸,只把输出纹理补成偶数:逐帧拷贝时源区域按真实尺寸给,目标纹理多出来的最右一列与最下一行不被覆盖,留黑。GDI 那条是把窗口矩形本身先补成偶数,再按补过的尺寸取画面,所以它可能比窗口实际多大出一个像素。

窗口被最小化或隐藏时抓不到有效画面。打开时的处理是把窗口恢复一下、压到窗口叠序的最底下、等一小会儿让合成器把它画出来,然后照常抓。这一下只在打开那一次做,是每个开关周期里第一次遇到不可见才补。

之后抓取线程照跑,取尺寸那一步发现窗口不可见或已最小化就跳过这一帧、继续下一轮——线程不退出,也没有停采动作。枚举窗口时用的判据比这个宽:最小化的窗口照样进设备列表,好让上层拿到它、把它恢复过来再抓。

帧池与线程:三处硬约束

抓屏这条路上有三处踩出来的约束,都写在了线程参数上。

抓取线程整条按多线程套间初始化。早先用过单线程套间的队列派发,撞到过一个错误套间的报错并崩溃,后来整条线程改成多线程套间。

帧到达不用回调,改成在自己的线程里按固定节拍问帧池要帧。原因是回调里同步做「重建帧池」这一步会让帧池停止派发后续帧——画面直接停住,所以回调那一套整条不用。

重建帧池必须在本线程做。窗口尺寸变化时逐帧重建帧池与输出纹理;退出时也是在本线程先把会话和帧池关掉再结束任务。

摄像头:异步读与格式白名单

摄像头走 Media Foundation 的源阅读器:创建时挂上异步回调,回调里取到样本、把数据拷进队列,然后立刻再要下一帧。拷贝在 MF 的回调线程上做,推帧在自己的任务线程上做,两件事分开,队列只留很少几帧、满了丢最旧。

格式不是全都收。枚举设备支持的格式时只认 NV12、YUY2、YVYU、UYVY 这几种平面与打包 YUV,其余直接丢掉;一个设备一个能用的都没有,它就不进设备列表。JPEG 压缩的那一档不是丢掉:把阅读器输出的子类型改写成 YUY2,交给 MF 内置的解码器解出 YUY2 再往下走。行距优先读设备给出的默认值,读不到才按格式估一个。

读失败就置成未打开并上报设备丢失。这里没有热插拔重枚举——刷新设备表在摄像头这一侧是空实现,要重新拿到设备得显式重开一次。

关流时先清队列,再发一次 flush 并等回调回来,带一个超时兜底。不等这一步就释放阅读器,回调可能还在路上。

麦克风与声卡回环:差异只在请求标志

两套音频采集共用一个基类,子类提供的差别只有打开流时的标志位:

麦克风用事件驱动:起一条线程等在事件上,事件到了就把这一批包抽干。声卡回环用的是回环标志,而回环与事件回调互斥、不能同开,所以它没有事件可等,只能按一个很短的时间间隔轮询着抽包。

打开的方式一样:共享模式,先按系统混音格式开一段缓冲,再按更短的间隔去读。

三条采集路的驱动方式与交出来的帧形态

设备表里放三样:默认麦克风、默认渲染端点对应的那一个回环、以及其余麦克风。回环设备的名字带一个后缀区分,它的设备 id 用的是渲染端点的 id。默认渲染端点之外的其他声卡不建回环源。

采出来的帧:三种形态,一条口子

三条路交出来的东西不一样。屏幕采集给的是纹理帧,纹理由那套跨线程共享的对象承载、出图之后发一次栅栏信号;摄像头给的是 CPU 平面帧,内存是队列里那个缓冲;麦克风与回环给的是 PCM 数据块。

设备源把音频与视频两路合在一条上,再经原始帧源这一层交给上层的观察者。后面讲录制的那条链就是从这个口子接进来的——离线录制、编码、封装用的是同一批帧。

这一层在音视频两轨都就绪之前会把帧丢掉,先到的首帧不会留到就绪之后再补。设备打开之后、双轨就绪之前那几帧是拿不到的。

引擎 avox 在 GitHub 开源:github.com/xxxzhou/avox,抓屏、摄像头与 WASAPI 这三条路都在里面。

音视频开发 播放器 屏幕采集 Media Foundation WASAPI C++