Windows遠程桌面實現之十 - 移植xdisp_virt之macOS系統屏幕截屏,鼠標鍵盤控制,聲音 ,攝像頭採集(三)

                                                                        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中編譯的。


上圖看起來詭異了點,下圖是在另一個PC電腦上使用原生客戶端遠程效果:


下圖是在macOS Catalina (macOS10.15),也就是開發和編譯xdisp_virt機器上的遠程效果圖:


有興趣稍後發佈到 GITHUB上的macOS版本的xdisp_virt程序。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章