我們的桌面應用程式無需機器人即可擷取會議,並將其串流至雲端。數月以來,錄製引擎一直是產品中最難以可靠實現的部分。我們修復了一類邊緣案例,發布後,下一週又會出現新的問題。根本原因不同,但模式相同。
該引擎在我們 Electron 應用程式的渲染進程中運行。我們嘗試了顯而易見的修復:更嚴格的生命週期管理、將工作移出主執行緒、將其與 React 的渲染週期隔離開來。每次更改都帶來了邊際效益,但沒有一個解決了真正問題。渲染進程不適合進行即時音訊和視訊擷取。擷取引擎無法容忍垃圾回收暫停、節流或其他瀏覽器運行時為了保持響應性而執行的操作。
因此,我們採用了原生方法:macOS 使用 ScreenCaptureKit,Windows 使用 libobs,並透過一個共享的 Swift 層將它們整合在一起。
將原生運行時橋接到 React 通常意味著需要手動編寫原生附加元件綁定。您需要序列化每個跨越邊界的數值,透過字串類型名稱路由事件,並在新增屬性時更新三個檔案:Swift 類別、C++ 綁定和 TypeScript 包裝器。這樣做可以運作,但只要有人遺漏一個步驟,就會立即失去同步。
如果 Swift 中的每個 @Published 屬性都能自動成為 React 中的 Jotai atom,那會怎麼樣?完全響應式、型別安全、無需膠水程式碼。這就是我們的內部工具 Atomic 所做的。
從 React 的角度來看,這些 atom 與任何其他 Jotai atom 都無法區分。資料位於不同執行緒的 Swift 運行時中的事實是不可見的。
@NodeExport 宏在編譯時生成整個橋接。類型會自動對應(Int → number,String? → string | null)。Swift 中的數值變更會在 Node 的事件循環上安排回呼。我們在 Swift 端新增的每個新屬性都能在 React 中立即使用。由於 Atomic 是基於 Swift 建構的,而不是 Apple 框架(我們在 Windows 上使用 OpenCombine),因此相同的橋接可以在兩個平台上運行。
在 macOS 上,ScreenCaptureKit 為我們提供了硬體加速擷取和原生內容選擇器。在 Windows 上,我們透過一個名為 OBSKit 的 Swift 包裝器使用 libobs。這兩個引擎的架構有根本性的不同。
在 macOS 上,我們從三個獨立的來源接收原始樣本緩衝區,並自行組裝檔案。在 Windows 上,擷取、混合、編碼和多工處理作為單一圖形運行。我們對其進行配置,然後一個檔案監視器將新寫入的位元組串流到我們的上傳會話。
Windows 擷取有其自身的挑戰。我們主要使用 Windows Graphics Capture (WGC),如果它未能及時傳送幀,我們將回退到 BitBlt。我們還會偵測全黑幀(在某些模擬視窗或遊戲中很常見),並在錄製過程中途切換方法。
這就是 macOS 引擎複雜性所在。三個擷取來源、三個硬體時鐘、三個不同的時間概念。
兩個音訊來源都會被加上時間戳記並轉換為全域幀索引。混合器同步排水兩個佇列,僅在兩者都有足夠資料時才產生輸出。如果一個來源停滯(靜音麥克風、虛擬裝置凍結),混合器會在 500 毫秒後偵測到並切換到單一來源模式,直到它恢復。
還有一個更微妙的問題:音訊驅動程式謊報其取樣率。某些虛擬驅動程式報告 48kHz,但以 44.1kHz 傳送緩衝區。在 30 分鐘的會議中,這種漂移會變得可聽見。我們的修復是基於信賴度的校正:我們測量實際緩衝區的節奏,如果它在連續三個緩衝區中始終與報告的格式不符,我們將以正確的速率重新解釋串流,並進行交叉淡入淡出以避免雜訊。
在 Windows 上,大部分這種複雜性都由擷取引擎的內部混合器抽象化了。權衡是控制:在 macOS 上,我們自己偵測並修復謊報驅動程式等邊緣案例;在 Windows 上,我們為了簡單性而犧牲了這種細緻的控制。
常規的 MP4 會在其檔案結尾寫入中繼資料。如果在此之前崩潰,錄製就會丟失。在兩個平台上,我們都使用片段式 MP4。
每個片段都是獨立的。在第 30 分鐘崩潰,最多只會丟失最後一秒。片段同時傳送到本機儲存和雲端。如果網路中斷,片段會保留在本機,並在連線恢復時自動恢復上傳。
桌面錄製曾經是我們最常見的支援請求來源。現在,它是應用程式中一個不起眼的、但卻能正常工作的組件。整個重寫在兩個月內完成,Atomic 是關鍵:一旦橋接建立,新增功能就意味著編寫 Swift 並即時觀看 UI 更新。
並非所有東西都適合放在渲染進程中。有時你需要採用原生方法。
如果你對承擔這類問題感興趣,歡迎加入我們。