Repository: letmaik/pyvirtualcam (language: python)

Issue:
macOS: OBS 18 support
OBS 18 comes with an updated macOS virtual camera that is incompatible with the previous one.
Therefore, pyvirtualcam needs to be updated to support it.

Users who need to use OBS 17 will have to install an older pyvirtualcam release going forward. Supporting both would be too much maintenance effort.

Compare:
https://github.com/obsproject/obs-studio/tree/27.2.4/plugins/mac-virtualcam/src/obs-plugin
https://github.com/obsproject/obs-studio/tree/28.0.1/plugins/mac-virtualcam/src/obs-plugin

Some initial work was done in https://github.com/letmaik/pyvirtualcam/compare/macos-obs18-support


Discussion / hints:

Fix patch:
diff --git a/.github/scripts/install-macos-obs-virtual-cam.sh b/.github/scripts/install-macos-obs-virtual-cam.sh
index ffaa974..95c9245 100755
--- a/.github/scripts/install-macos-obs-virtual-cam.sh
+++ b/.github/scripts/install-macos-obs-virtual-cam.sh
@@ -1,11 +1,11 @@
 #!/bin/bash
 set -e -x
 
-VERSION="27.2.1"
+VERSION="28.0.2"
 
-DMG_FILENAME="obs-mac-${VERSION}.dmg"
+DMG_FILENAME="obs-studio-${VERSION}-macos-x86_64.dmg"
 DMG_URL="https://cdn-fastly.obsproject.com/downloads/${DMG_FILENAME}"
-MOUNT_PATH="/Volumes/OBS-Studio ${VERSION}"
+MOUNT_PATH="/Volumes/OBS-${VERSION}-macOS-Intel"
 INSTALL_PATH="/Library/CoreMediaIO/Plug-Ins/DAL"
 
 sudo mkdir -p "${INSTALL_PATH}"
@@ -13,5 +13,5 @@ sudo mkdir -p "${INSTALL_PATH}"
 curl -L --retry 3 "${DMG_URL}" -o "${DMG_FILENAME}"
 
 sudo hdiutil attach "${DMG_FILENAME}"
-sudo cp -r "${MOUNT_PATH}/OBS.app/Contents/resources/data/obs-plugins/mac-virtualcam/obs-mac-virtualcam.plugin" "${INSTALL_PATH}"
-sudo hdiutil unmount "${MOUNT_PATH}" -force
\ No newline at end of file
+sudo cp -r "${MOUNT_PATH}/OBS.app/Contents/resources/obs-mac-virtualcam.plugin" "${INSTALL_PATH}"
+sudo hdiutil unmount "${MOUNT_PATH}" -force
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7cbda4e..69d4e7d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -58,22 +58,22 @@ jobs:
           python-version: '3.10'
           numpy-version: '1.21.*'
 
-        - os-image: macos-10.15
+        - os-image: macos-11
           os-name: mac
           macos-min-version: '10.9'
           python-version: '3.7'
           numpy-version: '1.14.*'
-        - os-image: macos-10.15
+        - os-image: macos-11
           os-name: mac
           macos-min-version: '10.9'
           python-version: '3.8'
           numpy-version: '1.17.*'
-        - os-image: macos-10.15
+        - os-image: macos-11
           os-name: mac
           macos-min-version: '10.9'
           python-version: '3.9'
           numpy-version: '1.19.*'
-        - os-image: macos-10.15
+        - os-image: macos-11
           os-name: mac
           macos-min-version: '10.9'
           python-version: '3.10'
@@ -171,22 +171,22 @@ jobs:
           python-version: '3.10'
           numpy-version: '1.21.*'
 
-        - os-image: macos-10.15
+        - os-image: macos-11
           os-name: mac
           macos-min-version: '10.9'
           python-version: '3.7'
           numpy-version: '1.14.*'
-        - os-image: macos-10.15
+        - os-image: macos-11
           os-name: mac
           macos-min-version: '10.9'
           python-version: '3.8'
           numpy-version: '1.17.*'
-        - os-image: macos-10.15
+        - os-image: macos-11
           os-name: mac
           macos-min-version: '10.9'
           python-version: '3.9'
           numpy-version: '1.19.*'
-        - os-image: macos-10.15
+        - os-image: macos-11
           os-name: mac
           macos-min-version: '10.9'
           python-version: '3.10'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a139702..5439cc6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [0.10.0] - 2022-10-01
+### Added
+- macOS: OBS 28 support (#95).
+
+### Removed
+- macOS: Drop OBS 26 / 27 support.
+
 ## [0.9.1] - 2022-02-23
 ### Fixed
 - macOS: Python 3.10 wheel was incorrectly named (universal2 instead of x86_64)
diff --git a/README.md b/README.md
index aa1efc8..b1fd368 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ To use the Unity Capture virtual camera, follow the [installation instructions](
 
 [OBS](https://obsproject.com/) includes a built-in virtual camera for macOS (since 26.1).
 
-**NOTE**: Currently, only OBS 26 and 27 is supported. OBS 28 is not yet supported, see [#95](https://github.com/letmaik/pyvirtualcam/issues/95).
+**NOTE**: Starting with pyvirtualcam 0.10, only OBS 28 is supported. Install an older version if you need OBS 26 / 27 support.
 
 To use the OBS virtual camera, follow these one-time setup steps:
 - [Install OBS](https://obsproject.com/).
diff --git a/pyvirtualcam/_version.py b/pyvirtualcam/_version.py
index d69d16e..61fb31c 100644
--- a/pyvirtualcam/_version.py
+++ b/pyvirtualcam/_version.py
@@ -1,1 +1,1 @@
-__version__ = "0.9.1"
+__version__ = "0.10.0"
diff --git a/pyvirtualcam/native_macos_obs/server/OBSDALMachServer.h b/pyvirtualcam/native_macos_obs/server/OBSDALMachServer.h
index dced3d0..99b1e15 100644
--- a/pyvirtualcam/native_macos_obs/server/OBSDALMachServer.h
+++ b/pyvirtualcam/native_macos_obs/server/OBSDALMachServer.h
@@ -6,6 +6,7 @@
 //
 
 #import <Foundation/Foundation.h>
+#import <CoreVideo/CoreVideo.h>
 
 NS_ASSUME_NONNULL_BEGIN
 
@@ -16,11 +17,10 @@ NS_ASSUME_NONNULL_BEGIN
 /*!
  Will eventually be used for sending frames to all connected clients
  */
-- (void)sendFrameWithSize:(NSSize)size
-		timestamp:(uint64_t)timestamp
-	     fpsNumerator:(uint32_t)fpsNumerator
-	   fpsDenominator:(uint32_t)fpsDenominator
-	       frameBytes:(uint8_t *)frameBytes;
+- (void)sendPixelBuffer:(CVPixelBufferRef)frame
+	      timestamp:(uint64_t)timestamp
+	   fpsNumerator:(uint32_t)fpsNumerator
+	 fpsDenominator:(uint32_t)fpsDenominator;
 
 - (void)stop;
 
diff --git a/pyvirtualcam/native_macos_obs/server/OBSDALMachServer.mm b/pyvirtualcam/native_macos_obs/server/OBSDALMachServer.mm
index 752ad7e..a4460bb 100644
--- a/pyvirtualcam/native_macos_obs/server/OBSDALMachServer.mm
+++ b/pyvirtualcam/native_macos_obs/server/OBSDALMachServer.mm
@@ -6,6 +6,7 @@
 //
 
 #import "OBSDALMachServer.h"
+#include <CoreVideo/CoreVideo.h>
 #include "MachProtocol.h"
 #include "Defines.h"
 
@@ -13,6 +14,7 @@ @interface OBSDALMachServer () <NSPortDelegate>
 @property NSPort *port;
 @property NSMutableSet *clientPorts;
 @property NSRunLoop *runLoop;
+@property uint32_t seed;
 @end
 
 @implementation OBSDALMachServer
@@ -100,19 +102,24 @@ - (void)sendMessageToClientsWithMsgId:(uint32_t)msgId
 				     receivePort:nil
 				      components:components];
 			message.msgid = msgId;
-			if (![message
+			if (![port isValid] ||
+			    ![message
 				    sendBeforeDate:
 					    [NSDate dateWithTimeIntervalSinceNow:
 							    1.0]]) {
 				blog(LOG_DEBUG,
 				     "failed to send message to %d, removing it from the clients!",
 				     ((NSMachPort *)port).machPort);
+
+				[port invalidate];
 				[removedPorts addObject:port];
 			}
 		} @catch (NSException *exception) {
 			blog(LOG_DEBUG,
 			     "failed to send message (exception) to %d, removing it from the clients!",
 			     ((NSMachPort *)port).machPort);
+
+			[port invalidate];
 			[removedPorts addObject:port];
 		}
 	}
@@ -121,23 +128,16 @@ - (void)sendMessageToClientsWithMsgId:(uint32_t)msgId
 	[self.clientPorts minusSet:removedPorts];
 }
 
-- (void)sendFrameWithSize:(NSSize)size
-		timestamp:(uint64_t)timestamp
-	     fpsNumerator:(uint32_t)fpsNumerator
-	   fpsDenominator:(uint32_t)fpsDenominator
-	       frameBytes:(uint8_t *)frameBytes
+- (void)sendPixelBuffer:(CVPixelBufferRef)frame
+	      timestamp:(uint64_t)timestamp
+	   fpsNumerator:(uint32_t)fpsNumerator
+	 fpsDenominator:(uint32_t)fpsDenominator
 {
 	if ([self.clientPorts count] <= 0) {
 		return;
 	}
 
 	@autoreleasepool {
-		CGFloat width = size.width;
-		NSData *widthData = [NSData dataWithBytes:&width
-						   length:sizeof(width)];
-		CGFloat height = size.height;
-		NSData *heightData = [NSData dataWithBytes:&height
-						    length:sizeof(height)];
 		NSData *timestampData = [NSData
 			dataWithBytes:&timestamp
 			       length:sizeof(timestamp)];
@@ -148,22 +148,35 @@ - (void)sendFrameWithSize:(NSSize)size
 			dataWithBytes:&fpsDenominator
 			       length:sizeof(fpsDenominator)];
 
-		// NOTE: I'm not totally sure about the safety of dataWithBytesNoCopy in this context.
-		// Seems like there could potentially be an issue if the frameBuffer went away before the
-		// mach message finished sending. But it seems to be working and avoids a memory copy. Alternately
-		// we could do something like
-		// NSData *frameData = [NSData dataWithBytes:(void *)frameBytes length:size.width * size.height * 2];
-		NSData *frameData = [NSData
-			dataWithBytesNoCopy:(void *)frameBytes
-				     length:size.width * size.height * 2
-			       freeWhenDone:NO];
+		IOSurfaceRef surface = CVPixelBufferGetIOSurface(frame);
+
+		if (!surface) {
+			blog(LOG_ERROR,
+			     "unable to access IOSurface associated with CVPixelBuffer");
+			return;
+		}
+
+		IOSurfaceLock(surface, 0, &_seed);
+		mach_port_t framePort = IOSurfaceCreateMachPort(surface);
+
+		if (!framePort) {
+			blog(LOG_ERROR,
+			     "unable to allocate mach port for IOSurface");
+			return;
+		}
+
 		[self sendMessageToClientsWithMsgId:MachMsgIdFrame
 					 components:@[
-						 widthData, heightData,
-						 timestampData, frameData,
+						 [NSMachPort
+							 portWithMachPort:framePort
+								  options:NSMachPortDeallocateNone],
+						 timestampData,
 						 fpsNumeratorData,
 						 fpsDenominatorData
 					 ]];
+
+		mach_port_deallocate(mach_task_self(), framePort);
+		IOSurfaceUnlock(surface, 0, &_seed);
 	}
 }
 
diff --git a/pyvirtualcam/native_macos_obs/server/README b/pyvirtualcam/native_macos_obs/server/README
index 17c2214..b479efd 100644
--- a/pyvirtualcam/native_macos_obs/server/README
+++ b/pyvirtualcam/native_macos_obs/server/README
@@ -1,5 +1,5 @@
 Copied from https://github.com/obsproject/obs-studio/tree/master/plugins/mac-virtualcam/src/obs-plugin.
 
-Current version: 4a50e6212043dc3dd03727020d787b9216a74411 (27.2.1)
+Current version: 967bce5e155e182ba6686a5781f3e88a85217c77 (28.0.2)
 
 Note: (void)run was changed to (BOOL)run to report success/failure
\ No newline at end of file
diff --git a/pyvirtualcam/native_macos_obs/virtual_output.h b/pyvirtualcam/native_macos_obs/virtual_output.h
index 637bcfc..6557b35 100644
--- a/pyvirtualcam/native_macos_obs/virtual_output.h
+++ b/pyvirtualcam/native_macos_obs/virtual_output.h
@@ -31,9 +31,12 @@
 class VirtualOutput {
   private:
     OBSDALMachServer* _mach_server = nil;
+    CVPixelBufferPoolRef _cv_pool;
+    FourCharCode _cv_format;
     uint32_t _frame_width;
     uint32_t _frame_height;
     uint32_t _frame_fourcc;
+    uint32_t _out_frame_size;
     uint32_t _fps_num;
     uint32_t _fps_den;
     std::vector<uint8_t> _buffer_tmp;
@@ -64,7 +67,7 @@ class VirtualOutput {
         _fps_num = fps * 1000;
         _fps_den = 1000;
 
-        uint32_t out_frame_size = uyvy_frame_size(width, height);
+        _out_frame_size = uyvy_frame_size(width, height);
 
         switch (_frame_fourcc) {
             case libyuv::FOURCC_RAW:
@@ -72,21 +75,21 @@ class VirtualOutput {
             case libyuv::FOURCC_J400:
                 // RGB|BGR|GRAY -> BGRA -> UYVY
                 _buffer_tmp.resize(bgra_frame_size(width, height));
-                _buffer_output.resize(out_frame_size);
+                _buffer_output.resize(_out_frame_size);
                 break;
             case libyuv::FOURCC_I420:
                 // I420 -> UYVY
-                _buffer_output.resize(out_frame_size);
+                _buffer_output.resize(_out_frame_size);
                 break;
             case libyuv::FOURCC_NV12:
                 // NV12 -> I420 -> UYVY
                 _buffer_tmp.resize(i420_frame_size(width, height));
-                _buffer_output.resize(out_frame_size);
+                _buffer_output.resize(_out_frame_size);
                 break;
             case libyuv::FOURCC_YUY2:
                 // YUYV -> I422 -> UYVY
                 _buffer_tmp.resize(i422_frame_size(width, height));
-                _buffer_output.resize(out_frame_size);
+                _buffer_output.resize(_out_frame_size);
                 break;
             case libyuv::FOURCC_UYVY:
                 break;
@@ -94,6 +97,22 @@ class VirtualOutput {
                 throw std::runtime_error("Unsupported image format.");
         }
 
+        _cv_format = kCVPixelFormatType_422YpCbCr8; // UYVY
+
+        NSDictionary *pAttr = @{};
+        NSDictionary *pbAttr = @{
+            (id)kCVPixelBufferPixelFormatTypeKey: @(_cv_format),
+            (id)kCVPixelBufferWidthKey: @(_frame_width),
+            (id)kCVPixelBufferHeightKey: @(_frame_height),
+            (id)kCVPixelBufferIOSurfacePropertiesKey: @{}
+        };
+        CVReturn status = CVPixelBufferPoolCreate(
+            kCFAllocatorDefault, (__bridge CFDictionaryRef)pAttr,
+            (__bridge CFDictionaryRef)pbAttr, &_cv_pool);
+        if (status != kCVReturnSuccess) {
+            throw std::runtime_error("unable to allocate pixel buffer pool");
+        }
+
         _mach_server = [[OBSDALMachServer alloc] init];
         BOOL started = [_mach_server run];
         if (!started) {
@@ -109,6 +128,8 @@ class VirtualOutput {
         [_mach_server stop];
         [_mach_server dealloc];
         _mach_server = nil;
+
+        CVPixelBufferPoolRelease(_cv_pool);
         
         // When the named server port is invalidated, the effect is not immediate.
         // Starting a new server immediately afterwards may then fail.
@@ -169,15 +190,37 @@ class VirtualOutput {
             default:
                 throw std::logic_error("not implemented");
         }
+        
+        CVPixelBufferRef frame_ref = nil;
+        CVReturn status = CVPixelBufferPoolCreatePixelBuffer(
+            kCFAllocatorDefault, _cv_pool, &frame_ref);
+
+        if (status != kCVReturnSuccess) {
+            // not an exception, in case it is temporary
+            fprintf(stderr, "unable to allocate pixel buffer (error %d)",
+                status);
+            return;
+        }
 
-        CGFloat width = _frame_width;
-        CGFloat height = _frame_height;
+        CVPixelBufferLockBaseAddress(frame_ref, 0);
 
-        [_mach_server sendFrameWithSize:NSMakeSize(width, height)
+        uint8_t *dst = (uint8_t *)CVPixelBufferGetBaseAddress(frame_ref);
+        int dst_size = CVPixelBufferGetDataSize(frame_ref);
+
+        if (dst_size == _out_frame_size) {
+			memcpy(dst, out_frame, _out_frame_size);
+        } else {
+            fprintf(stderr, "pixel buffer size mismatch");
+        }
+
+        CVPixelBufferUnlockBaseAddress(frame_ref, 0);
+
+        [_mach_server sendPixelBuffer:frame_ref
             timestamp:timestamp
             fpsNumerator:_fps_num
-            fpsDenominator:_fps_den
-            frameBytes:out_frame];
+            fpsDenominator:_fps_den];
+        
+        CVPixelBufferRelease(frame_ref);
     }
 
     std::string device()
diff --git a/setup.py b/setup.py
index 0d65073..7176e02 100644
--- a/setup.py
+++ b/setup.py
@@ -55,7 +55,7 @@ def __str__(self):
                 'pyvirtualcam/native_macos_obs/server/OBSDALMachServer.mm',
             ] + common_src),
             include_dirs=['pyvirtualcam/native_macos_obs'] + common_inc,
-            extra_link_args=["-framework", "Foundation"],
+            extra_link_args=["-framework", "Foundation", "-framework", "CoreVideo"],
             language='objc'
         )
     )


Test patch:
diff --git a/test/test_camera.py b/test/test_camera.py
index f61ca69..2d18b56 100644
--- a/test/test_camera.py
+++ b/test/test_camera.py
@@ -145,7 +145,7 @@ def test_sleep_until_next_frame():
             cam.sleep_until_next_frame()
         
         actual_fps = cam.current_fps
-        assert abs(target_fps - actual_fps) < 0.5
+        assert abs(target_fps - actual_fps) < 1.5
 
 @pytest.mark.parametrize("backend", list(pyvirtualcam.camera.BACKENDS))
 def test_device_name(backend: str):
diff --git a/test/test_capture.py b/test/test_capture.py
index f2a3cf8..07deeb2 100644
--- a/test/test_capture.py
+++ b/test/test_capture.py
@@ -183,6 +183,9 @@ def test_capture(backend: str, fmt: PixelFormat, mode: str, tmp_path: Path):
     if measure_latency:
         if fmt in [PixelFormat.UYVY, PixelFormat.YUYV]:
             pytest.skip('Latency test currently not supported for packed YUV formats')
+        if fmt in [PixelFormat.I420, PixelFormat.NV12] and platform.system() == 'Darwin':
+            # https://github.com/letmaik/pyvirtualcam/issues/82
+            pytest.skip('Latency test currently not supported for I420 & NV12 on macOS')
 
     # informational only
     imageio.imwrite(f'test_{fmt}_in.png', frames_rgb[fmt])
@@ -234,7 +237,11 @@ def test_capture(backend: str, fmt: PixelFormat, mode: str, tmp_path: Path):
         print(f'Latency: {latency_ms} ms')
     else:
         d = np.fabs(captured_rgb.astype(np.int16) - frames_rgb[fmt]).max()
-        assert d <= 2
+        if platform.system() == 'Darwin':
+            # https://github.com/letmaik/pyvirtualcam/issues/82
+            assert d <= 35
+        else:
+            assert d <= 2
 
 def test_frame_timestamp_ms():
     frame = np.zeros((1, 64), np.uint8)