Windows远程桌面实现之十 - 移植xdisp_virt之macOS系统屏幕截屏,鼠标键盘控制,声音 ,摄像头采集(三)

时间:2024-05-24 06:59:22

                                                                        by fanxiushu 2019-12-22 转载或引用请注明原始作者。


前一篇文章描述的是iOS平台下的相关内容的采集(包括屏幕,声音,摄像头等),
这一篇即将阐述的是macOS系统下的同样内容,同时还包括鼠标键盘的模拟控制。
同样的,如果对xdisp_virt项目没兴趣,可只关注文章中的跟macOS系统相关的采集内容。

根据iOS和macOS的近似性,本打算把上一篇文章中的相关系统采集函数代码直接使用到macOS系统来,
然而能直接使用的却只有 AVCaptureSession 类,也就是用于摄像头图像,麦克风声音采集的系统函数。

本来使用这个AVCaptureSession 在macOS系统能很简单的采集到麦克风和摄像头的数据,
可惜 AVCaptureSession是 obj-c 框架的,这本来没什么问题。
可是当我在macOS10.15(也就是写这篇文章时候最新的Catalina)编译成功xdisp_virt,
打算把程序放到 macOS10.13上去运行,结果出现
macos - dyld: Symbol not found: _objc_alloc_init  奇葩的运行时错误,
不用怀疑,是macOS10.13上的OBJ-C运行库与macOS10.15的OBJ-C运行库不兼容。
libobjc.A.dylib运行时库,跟libSystem.B.dylib这些基础库一样,又不能静态编译进程序。
我可不想在 13,14,15各个版本上分别编译出不同的xdisp_virt程序,经过一通折腾,
只好采用更底层的CoreMediaIO和CoreAudio框架来采集摄像头数据和麦克风数据,
这是基于 C/C++的,而且够底层,相对比较稳定。
至少我在macOS15上编译的xidsp_virt程序能在 macoS13, macOS12上运行。

因此下文中使用的全是 C/C++ 语言来阐述macOS平台下各种数据的采集,不再有OBJ-C或者SWIFT等其他语言掺和。

(一)摄像头图像数据的采集,使用CoreMediaIO框架。
需要包含 <CoreMediaIO/CMIOHardware.h>头文件
首先是枚举系统中的所有摄像头。使用如下函数片段:

    CMIOObjectPropertyAddress addr = { kCMIOHardwarePropertyDevices,
                  kCMIOObjectPropertyScopeGlobal, kCMIOObjectPropertyElementMaster };
    CMIODeviceID devs[200]; uint32_t used = 0;
    CMIOObjectGetPropertyData(kCMIOObjectSystemObject, &addr, 0, NULL, sizeof(devs), &used, devs);
     int cnt = used/sizeof(CMIODeviceID) ;
    printf("### camera cnt=%d\n", cnt );
其中 CMIOObjectGetPropertyData 函数功能比较强大,使用它可以枚举到很多信息,
因为xdisp_virt只根据序号来定位摄像头,因此根据枚举的顺序来查找 摄像头的 CMIODeviceID就可以了,
找到摄像头的CMIODeviceID之后,就该分析出摄像头对应的输入流了,假设找到摄像头的设备ID是 devID,如下枚举所有的输入流ID:
   CMIOObjectPropertyAddress addr2 = { kCMIODevicePropertyStreams ,
               kCMIODevicePropertyScopeInput ,kCMIOObjectPropertyElementMaster };
    uint32_t used = 0;
    CMIOStreamID strms[400];
    CMIOObjectGetPropertyData(devID, &addr2, 0, 0, sizeof(strms), &used, strms);
    int cnt = used / sizeof(CMIOStreamID);
    printf("### found devID=0x%X, stream cnt=%d\n", devID, cnt);

然后循环分析每个 CMIOStreamID 流, 判断是否是视频流,视频格式,如下:
        ////可以取kCMIOStreamPropertyFormatDescriptions, 返回的是CFArray
        CMIOObjectPropertyAddress addr3 = { kCMIOStreamPropertyFormatDescription , 0, kCMIOObjectPropertyElementMaster };
        CMFormatDescriptionRef fmt = 0;
        CMIOObjectGetPropertyData(strms[i], &addr3, 0, 0, sizeof(fmt), &used, &fmt);
根据fmt参数,判断是否视频流,如果不是就继续下一个流,xdisp_virt为了简单,就只枚举到第一个满意的流就停止枚举了。
每个流可以有多个流格式,比如不同的宽和高等参数,可以调用 CMIOObjectSetPropertyData 设置你需要的格式。

经过枚举之后,已经获取到了 devID(设备ID),streamID(这个设备对应的某个流ID)。
之后需要给这个流设置回调函数,回调函数的功能就是当启动设备的时候,摄像头的图像数据来了,就会调用这个回调函数,
因此我们在回调函数中采集到具体的图像数据了。
回调函数声明如下所示:
   void cam_stream_callback(CMIOStreamID streamID, void*, void* param) {
        cmio_capture* cm = (cmio_capture*)param ; //cmio_capture我们的类,
        CMSampleBufferRef sb;
        while (sb = (CMSampleBufferRef)CMSimpleQueueDequeue(cm->cam_queueRef)) {
            cm->cam_process_stream(sb); //我们的函数中处理每个 CMSampleBufferRef
        }
    }
 调用 CMIOStreamCopyBufferQueue 注册这个回调函数,如下:
    CMSimpleQueueRef queueRef = 0;
    OSStatus r = CMIOStreamCopyBufferQueue(strmID, cam_stream_callback, this, &queueRef);
预备工作就做好了,之后就该启动这个摄像头的这个流了,调用 CMIODeviceStartStream 函数,如下:
     CMIODeviceStartStream(devID, strmID); /// 启动这个流,
至此,摄像头就运行起来了。
然后我们在 cam_process_stream 函数中处理 CMSampleBufferRef ,
类似如下代码,跟上文阐述的iOS平台下采集图像过程类似,
CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sb);
OSType fmt = CVPixelBufferGetPixelFormatType(pixelBuffer);
。。。。。
不过这里主要图像格式,因为摄像头种类比较多,提供的数据格式也多,因此要对fmt参数做判断,
在我们的xdisp_virt只提供了 YUY2(svuy),UYVY(2vuy),MJPG(jpeg),I420(yuv420p)这几种的支持,其他都当出错处理。
这就是底层框架的麻烦,使用 AVCaptureSession 可以直接指定为BGRA格式,系统会帮忙转成32位RGB色。

(二)麦克风声音的采集,使用CoreAudio框架
需要包含  <CoreAudio/CoreAudio.h> 头文件
这里强调的是麦克风(Microphone),而不是电脑内部声音。
因为在macOS系统中,目前还没找到现成的函数来采集电脑内部声音。
通常需要经过非常麻烦的处理,
一个做法就是开发一个虚拟声卡驱动,虚拟声卡驱动提供一个麦克风输入端口,系统使用这个虚拟声卡作为默认声卡,
然后电脑内部的声音经过虚拟声卡内部,把系统声音输出到这个虚拟声卡的麦克风端口,
之后应用层程序打开这个麦克风端口,就能采集到电脑内部声音了。
这种做法其实就跟以前的windows平台下要采集电脑内部声音一样的做法,
不过自从WIN7开始,windows系统早就提供了WASAPI来采集电脑内部声音,已经不需要虚拟声卡驱动了。
macOS目前没有windows上的这个功能,只能老老实实的采用虚拟声卡驱动,有个 SoundFlower开源项目就是干这种事的。
有兴趣可下载使用,到目前为止,我未曾使用过SoundFlower。
回到CoreAudio采集麦克风声音上来。

其实这个跟上面的CoreMediaIO采集摄像头流程很类似,不过获取属性的函数变成了 AudioObjectGetPropertyData,
利用这个函数获取所有声音设备,然后再次利用此函数,找出哪些是麦克风输入设备,然后定位到具体的设备ID,
之后调用 AudioDeviceCreateIOProcID 注册一个回调函数,同样的,这个回调函数会接收到PCM声音数据,
在此回调函数中采集PCM就可以了。
调用 AudioDeviceStart 启动这个麦克风设备。

这里描述得很简单。因为过程与上面的CoreMediaIO类似,如果不明白可以在线查阅Apple文档
(其实Apple在线文档简单得没法跟微软的MSDN比,查了Apple文档等于没查,还不如直接查看对应的开发 .h头文件中的注释说明。

(三),macOS系统的屏幕截取。
上一篇文章,我们讲过iOS系统下屏幕截取,因为iOS的特殊性,需要一个不是运行在沙盒中的更高权限的程序来专门截屏,
而这个程序其实Xcode开发环境已经帮忙我做好了,只需要简单几步操作就可以生成一个现成的框架。
在macOS系统中,因为毕竟是PC平台,没那么多的权限限制,因此直接在我们自己的程序中调用对应的系统API函数就能采集屏幕数据。
而macOS系统的这个截屏API也是够简单的,使用CGDisplayStreamCreateWithDispatchQueue 函数就可以,没错就这么简单。
当然如果再做得稍微麻烦点,可以调用 CGDisplayStreamCreate 再配合CFRunLoop循环来截屏。
这些函数出自 CoreGraphics框架,也叫QuartZ服务,包括下面的数据键盘模拟也是来自QUARTZ 服务,
更多信关于QuartZ信息,可查阅Apple文档。
CGDisplayStreamCreateWithDispatchQueue的函数原型声明如下:

CG_EXTERN CGDisplayStreamRef __nullable CGDisplayStreamCreateWithDispatchQueue(CGDirectDisplayID display,
    size_t outputWidth, size_t outputHeight, int32_t pixelFormat, CFDictionaryRef __nullable properties,
    dispatch_queue_t  queue, CGDisplayStreamFrameAvailableHandler __nullable handler)
    CG_AVAILABLE_STARTING(10.8);

display参数就是对应的显示器的ID, 虽然在windows平台下的xdisp_virt已经实现了多显示器的功能,
不过在移植xdisp_virt到其他平台并没打算考虑多显示器的情况,因此可以设置这个display为 CGMainDisplayID(),
也就是只考虑主显示器。
outputWidth,outputHeight对应的输出宽高,测试好像这个值必须与屏幕保持一致,否则截取得数据就是黑屏。
pixelFormat对应的采集的图像数据格式,通常选择 BGRA也就是32位RGB 色。
properties对应的是一些操作属性,比如是否截取鼠标形状,是否考虑截取速度,也就是FPS。
不过这个属性只能在创建时候设置,如果截屏过程中需要改变某些属性,比如不截取鼠标形状,则是没办法修改。
只能是销毁原先的,然后再创建一个新的。
queue是对应的执行循环的队列,调用dispatch_queue_create创建一个就可以。
hander对应的就是block函数,相当于回调函数,当截取到屏幕数据的时候,这个回调函数就会被调用。
再来看看这个block函数的声明:
typedef void (^CGDisplayStreamFrameAvailableHandler)(
                    CGDisplayStreamFrameStatus status,
                    uint64_t displayTime,
                    IOSurfaceRef __nullable frameSurface,
                    CGDisplayStreamUpdateRef __nullable updateRef);
一共四个参数,第一个参数对应的状态,如果是kCGDisplayStreamFrameStatusStopped则这个截屏已经停止了
如果是 kCGDisplayStreamFrameStatusFrameComplete则是成功截取到一个屏幕数据,displayTime对应的精确时间。
frameSurface就是这个屏幕的表面
如何从 frameSurface中获取到具体的数据,其实这个参数跟 CVPixelBufferRef 的使用方法很类似。
首先调用 IOSurfaceLock 锁定表面,然后如下调用 ,因为是BGRA格式,所以只有一个Plane。
          int stride = IOSurfaceGetBytesPerRowOfPlane(frame_surface, 0); // BGRA
          void* buf = IOSurfaceGetBaseAddressOfPlane(frame_surface, 0 );
这样就获得了步距和对应的数据地址,把数据复制出来之后,调用 IOSurfaceUnlock 解锁。

这里还有一个参数updateRef,也是非常经典,这个参数什么意思呢?
也就是获取变化的矩形区域,而整个block回调函数的调用时机也是根据屏幕是否发生了变化来调用,
如果一段时间内,屏幕没有发送任何变化,这个block函数是不会被调用的,我们可以简单的如下调用来获取所有脏矩形:
const CGRect* rects = CGDisplayStreamUpdateGetRects(updateRef, kCGDisplayStreamUpdateDirtyRects, &count);//获取dirty rect

反过来再看看windows平台中的屏幕截取,win7只能使用GDI的API函数,是没法做到根据屏幕是否变化来截屏,
只能固定频率截屏,如果要达到上面的效果,只能是 mirror驱动,
WIN10虽然实现了DXGI截屏来达到上面的效果,但是开发繁琐,容易搞错,哪像macOS下的截屏一个函数就搞定。
当然其实因为系统不同,各有特长,也很难做对比。

(四),鼠标键盘模拟控制,
windows平台下SendInput一个函数就搞定,当然如果做得更深入点,实现驱动级别的鼠标键盘控制,那就能控制一切程序。
macOS平台下的实现其实也不难,比如 如下函数实现一个鼠标模拟:
static void post_mouse_event(CGMouseButton button, CGEventType type, const CGPoint point, bool is_clicked=false)
{
    CGEventRef theEvent = CGEventCreateMouseEvent(NULL, type, point, button);

    if(is_clicked)CGEventSetIntegerValueField(theEvent, kCGMouseEventClickState, 2); //双击

    CGEventSetType(theEvent, type);
    CGEventPost(kCGHIDEventTap, theEvent);

    CFRelease(theEvent);
}
其中button可选择 kCGMouseButtonLeft/Right,
type可选择 kCGEventMouseMoved, kCGEventLeftMouseDown/Up, kCGEventRightMouseDown/Up,
   kCGEventLeftMouseDragged, kCGEventRightMouseDragged.
其中macOS系统下的Drag操作和双击操作是不能像windows系统那样能自动识别出这两个操作,而是要我们在程序中进行控制。
也就是判断如果两次单击时间小于某个值,比小于如300毫秒,则需要模拟出双击,也就上面代码中出现
if(is_clicked)CGEventSetIntegerValueField(theEvent, kCGMouseEventClickState, 2); //双击
这样判断的原因。
同时,还得判断按下鼠标按键时候,拖动鼠标时候,不是发送
kCGEventMouseMoved 而是发送 kCGEventLeftMouseDragged或者 kCGEvenRightMouseDragged 。

接下来的是键盘的模拟,如下代码即可进行模拟:
 static int keyboard_event(void* handle, unsigned int flags, int vk_code, int scan_code)
{
    int vk = ps2_keycode_to_apple_keycode(vk_code);
    if (vk < 0){
        printf("*** not found ps2 vk code=0x%X\n", vk_code);
        return -1;
    }
    ////
    bool is_down = (flags & KF_DOWN) ? true : false;
    CGEventRef evt = CGEventCreateKeyboardEvent(NULL, vk, is_down);
    CGEventPost(kCGHIDEventTap, evt);
    CFRelease(evt);
    return 0;
}
但是其实最大的麻烦是vk_code虚拟键码的问题,
windows平台使用的是PS/2键盘码,而Apple使用的是另外一套键盘码,因此必须转换,
ps2_keycode_to_apple_keycode这个函数就是做转换的,这个不是现成的函数,需要自己去实现,
无非就是对照PS2键码表和Apple的键码表做转换,比较的无聊,这里也就不再赘述。

至此,macOS平台下移植xdisp_virt绝大部分工作就完成。
下图是在手机屏幕上远程显示 macOS High Seiria(macOS10.13.6),其中xdisp_virt程序是在macOS15中编译的。
Windows远程桌面实现之十 - 移植xdisp_virt之macOS系统屏幕截屏,鼠标键盘控制,声音 ,摄像头采集(三)

上图看起来诡异了点,下图是在另一个PC电脑上使用原生客户端远程效果:
Windows远程桌面实现之十 - 移植xdisp_virt之macOS系统屏幕截屏,鼠标键盘控制,声音 ,摄像头采集(三)

下图是在macOS Catalina (macOS10.15),也就是开发和编译xdisp_virt机器上的远程效果图:
Windows远程桌面实现之十 - 移植xdisp_virt之macOS系统屏幕截屏,鼠标键盘控制,声音 ,摄像头采集(三)

有兴趣稍后发布到 GITHUB上的macOS版本的xdisp_virt程序。