// // OBSAVCapture.m // mac-avcapture // // Created by Patrick Heyer on 2023-03-07. // #import "OBSAVCapture.h" #import "AVCaptureDeviceFormat+OBSListable.h" /// Tthe maximum number of frame rate ranges to show complete information for before providing a more generic description of the supported frame rates inside of a device format description. static const UInt32 kMaxFrameRateRangesInDescription = 10; @implementation OBSAVCapture - (instancetype)init { return [self initWithCaptureInfo:nil]; } - (instancetype)initWithCaptureInfo:(OBSAVCaptureInfo *)capture_info { self = [super init]; if (self) { CMIOObjectPropertyAddress propertyAddress = {kCMIOHardwarePropertyAllowScreenCaptureDevices, kCMIOObjectPropertyScopeGlobal, kCMIOObjectPropertyElementMain}; UInt32 allow = 1; CMIOObjectSetPropertyData(kCMIOObjectSystemObject, &propertyAddress, 0, NULL, sizeof(allow), &allow); _errorDomain = @"com.obsproject.obs-studio.av-capture"; _presetList = @{ AVCaptureSessionPresetLow: @"Low", AVCaptureSessionPresetMedium: @"Medium", AVCaptureSessionPresetHigh: @"High", AVCaptureSessionPreset320x240: @"320x240", AVCaptureSessionPreset352x288: @"352x288", AVCaptureSessionPreset640x480: @"640x480", AVCaptureSessionPreset960x540: @"960x540", AVCaptureSessionPreset1280x720: @"1280x720", AVCaptureSessionPreset1920x1080: @"1920x1080", AVCaptureSessionPreset3840x2160: @"3840x2160", }; _sessionQueue = dispatch_queue_create("session queue", DISPATCH_QUEUE_SERIAL); OBSAVCaptureVideoInfo newInfo = {0}; _videoInfo = newInfo; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(deviceDisconnected:) name:AVCaptureDeviceWasDisconnectedNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(deviceConnected:) name:AVCaptureDeviceWasConnectedNotification object:nil]; if (capture_info) { _captureInfo = capture_info; NSString *UUID = [OBSAVCapture stringFromSettings:_captureInfo->settings withSetting:@"device"]; NSString *presetName = [OBSAVCapture stringFromSettings:_captureInfo->settings withSetting:@"preset"]; BOOL isPresetEnabled = obs_data_get_bool(_captureInfo->settings, "use_preset"); if (capture_info->isFastPath) { _isFastPath = YES; _isPresetBased = NO; } else { BOOL isBufferingEnabled = obs_data_get_bool(_captureInfo->settings, "buffering"); obs_source_set_async_unbuffered(_captureInfo->source, !isBufferingEnabled); } __weak OBSAVCapture *weakSelf = self; dispatch_async(_sessionQueue, ^{ NSError *error = nil; OBSAVCapture *instance = weakSelf; if ([instance createSession:&error]) { if ([instance switchCaptureDevice:UUID withError:nil]) { BOOL isSessionConfigured = NO; if (isPresetEnabled) { isSessionConfigured = [instance configureSessionWithPreset:presetName withError:nil]; } else { isSessionConfigured = [instance configureSession:nil]; } if (isSessionConfigured) { [instance startCaptureSession]; } } } else { [instance AVCaptureLog:LOG_ERROR withFormat:error.localizedDescription]; } }); } } return self; } #pragma mark - Capture Session Handling - (BOOL)createSession:(NSError *__autoreleasing *)error { AVCaptureSession *session = [[AVCaptureSession alloc] init]; [session beginConfiguration]; if (!session) { if (error) { NSDictionary *userInfo = @{NSLocalizedDescriptionKey: @"Failed to create AVCaptureSession"}; *error = [NSError errorWithDomain:self.errorDomain code:-101 userInfo:userInfo]; } return NO; } AVCaptureVideoDataOutput *videoOutput = [[AVCaptureVideoDataOutput alloc] init]; if (!videoOutput) { if (error) { NSDictionary *userInfo = @{NSLocalizedDescriptionKey: @"Failed to create AVCaptureVideoDataOutput"}; *error = [NSError errorWithDomain:self.errorDomain code:-102 userInfo:userInfo]; } return NO; } AVCaptureAudioDataOutput *audioOutput = [[AVCaptureAudioDataOutput alloc] init]; if (!audioOutput) { if (error) { NSDictionary *userInfo = @{NSLocalizedDescriptionKey: @"Failed to create AVCaptureAudioDataOutput"}; *error = [NSError errorWithDomain:self.errorDomain code:-103 userInfo:userInfo]; } return NO; } dispatch_queue_t videoQueue = dispatch_queue_create(nil, nil); if (!videoQueue) { if (error) { NSDictionary *userInfo = @{NSLocalizedDescriptionKey: @"Failed to create video dispatch queue"}; *error = [NSError errorWithDomain:self.errorDomain code:-104 userInfo:userInfo]; } return NO; } dispatch_queue_t audioQueue = dispatch_queue_create(nil, nil); if (!audioQueue) { if (error) { NSDictionary *userInfo = @{NSLocalizedDescriptionKey: @"Failed to create audio dispatch queue"}; *error = [NSError errorWithDomain:self.errorDomain code:-105 userInfo:userInfo]; } return NO; } if ([session canAddOutput:videoOutput]) { [session addOutput:videoOutput]; [videoOutput setSampleBufferDelegate:self queue:videoQueue]; } if ([session canAddOutput:audioOutput]) { [session addOutput:audioOutput]; [audioOutput setSampleBufferDelegate:self queue:audioQueue]; } [session commitConfiguration]; self.session = session; self.videoOutput = videoOutput; self.videoQueue = videoQueue; self.audioOutput = audioOutput; self.audioQueue = audioQueue; return YES; } - (BOOL)switchCaptureDevice:(NSString *)uuid withError:(NSError *__autoreleasing *)error { AVCaptureDevice *device = [AVCaptureDevice deviceWithUniqueID:uuid]; if (self.deviceInput.device || !device) { [self stopCaptureSession]; [self.session removeInput:self.deviceInput]; [self.deviceInput.device unlockForConfiguration]; self.deviceInput = nil; self.isDeviceLocked = NO; self.presetFormat = nil; } if (!device) { if (uuid.length < 1) { [self AVCaptureLog:LOG_INFO withFormat:@"No device selected"]; self.deviceUUID = uuid; return NO; } else { [self AVCaptureLog:LOG_WARNING withFormat:@"Unable to initialize device with unique ID '%@'", uuid]; return NO; } } const char *deviceName = device.localizedName.UTF8String; obs_data_set_string(self.captureInfo->settings, "device_name", deviceName); obs_data_set_string(self.captureInfo->settings, "device", device.uniqueID.UTF8String); [self AVCaptureLog:LOG_INFO withFormat:@"Selected device '%@'", device.localizedName]; self.deviceUUID = device.uniqueID; BOOL isAudioSupported = [device hasMediaType:AVMediaTypeAudio] || [device hasMediaType:AVMediaTypeMuxed]; obs_source_set_audio_active(self.captureInfo->source, isAudioSupported); AVCaptureDeviceInput *deviceInput = [AVCaptureDeviceInput deviceInputWithDevice:device error:error]; if (!deviceInput) { return NO; } [self.session beginConfiguration]; if ([self.session canAddInput:deviceInput]) { [self.session addInput:deviceInput]; self.deviceInput = deviceInput; } else { if (error) { NSDictionary *userInfo = @{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Unable to add device '%@' as deviceInput to capture session", self.deviceUUID] }; *error = [NSError errorWithDomain:self.errorDomain code:-107 userInfo:userInfo]; } [self.session commitConfiguration]; return NO; } AVCaptureDeviceFormat *deviceFormat = device.activeFormat; CMMediaType mediaType = CMFormatDescriptionGetMediaType(deviceFormat.formatDescription); if (mediaType != kCMMediaType_Video && mediaType != kCMMediaType_Muxed) { if (error) { NSDictionary *userInfo = @{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"CMMediaType '%@' is not supported", [OBSAVCapture stringFromFourCharCode:mediaType]] }; *error = [NSError errorWithDomain:self.errorDomain code:-108 userInfo:userInfo]; } [self.session removeInput:deviceInput]; [self.session commitConfiguration]; return NO; } if (self.isFastPath) { self.videoOutput.videoSettings = nil; NSMutableDictionary *videoSettings = [NSMutableDictionary dictionaryWithDictionary:self.videoOutput.videoSettings]; FourCharCode targetPixelFormatType = kCVPixelFormatType_32BGRA; [videoSettings setObject:@(targetPixelFormatType) forKey:(__bridge NSString *) kCVPixelBufferPixelFormatTypeKey]; self.videoOutput.videoSettings = videoSettings; } else { self.videoOutput.videoSettings = nil; FourCharCode subType = [[self.videoOutput.videoSettings objectForKey:(__bridge NSString *) kCVPixelBufferPixelFormatTypeKey] unsignedIntValue]; if ([OBSAVCapture formatFromSubtype:subType] != VIDEO_FORMAT_NONE) { [self AVCaptureLog:LOG_DEBUG withFormat:@"Using native fourcc '%@'", [OBSAVCapture stringFromFourCharCode:subType]]; } else { [self AVCaptureLog:LOG_DEBUG withFormat:@"Using fallback fourcc '%@' ('%@', 0x%08x unsupported)", [OBSAVCapture stringFromFourCharCode:kCVPixelFormatType_32BGRA], [OBSAVCapture stringFromFourCharCode:subType], subType]; NSMutableDictionary *videoSettings = [NSMutableDictionary dictionaryWithDictionary:self.videoOutput.videoSettings]; [videoSettings setObject:@(kCVPixelFormatType_32BGRA) forKey:(__bridge NSString *) kCVPixelBufferPixelFormatTypeKey]; self.videoOutput.videoSettings = videoSettings; } } [self.session commitConfiguration]; return YES; } - (void)startCaptureSession { if (!self.session.running) { [self.session startRunning]; } } - (void)stopCaptureSession { if (self.session.running) { [self.session stopRunning]; } if (self.captureInfo->isFastPath) { if (self.captureInfo->texture) { obs_enter_graphics(); gs_texture_destroy(self.captureInfo->texture); obs_leave_graphics(); self.captureInfo->texture = NULL; } if (self.captureInfo->currentSurface) { IOSurfaceDecrementUseCount(self.captureInfo->currentSurface); CFRelease(self.captureInfo->currentSurface); self.captureInfo->currentSurface = NULL; } if (self.captureInfo->previousSurface) { IOSurfaceDecrementUseCount(self.captureInfo->previousSurface); CFRelease(self.captureInfo->previousSurface); self.captureInfo->previousSurface = NULL; } } else { if (self.captureInfo->source) { obs_source_output_video(self.captureInfo->source, NULL); } } } - (BOOL)configureSessionWithPreset:(AVCaptureSessionPreset)preset withError:(NSError *__autoreleasing *)error { if (!self.deviceInput.device) { if (error) { NSDictionary *userInfo = @{NSLocalizedDescriptionKey: @"Unable to set session preset without capture device"}; *error = [NSError errorWithDomain:self.errorDomain code:-108 userInfo:userInfo]; } return NO; } if (![self.deviceInput.device supportsAVCaptureSessionPreset:preset]) { if (error) { NSDictionary *userInfo = @{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Preset %@ not supported by device %@", [OBSAVCapture stringFromCapturePreset:preset], self.deviceInput.device.localizedName] }; *error = [NSError errorWithDomain:self.errorDomain code:-201 userInfo:userInfo]; } return NO; } if ([self.session canSetSessionPreset:preset]) { if (self.isDeviceLocked) { if ([preset isEqualToString:self.session.sessionPreset]) { if (self.deviceInput.device.activeFormat) { self.deviceInput.device.activeFormat = self.presetFormat.activeFormat; self.deviceInput.device.activeVideoMinFrameDuration = self.presetFormat.minFrameRate; self.deviceInput.device.activeVideoMaxFrameDuration = self.presetFormat.maxFrameRate; } self.presetFormat = nil; } [self.deviceInput.device unlockForConfiguration]; self.isDeviceLocked = NO; } if ([self.session canSetSessionPreset:preset]) { self.session.sessionPreset = preset; } } else { if (error) { NSDictionary *userInfo = @{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Preset %@ not supported by capture session", [OBSAVCapture stringFromCapturePreset:preset]] }; *error = [NSError errorWithDomain:self.errorDomain code:-202 userInfo:userInfo]; } return NO; } self.isPresetBased = YES; return YES; } - (BOOL)configureSession:(NSError *__autoreleasing *)error { OBSAVCaptureMediaFPS fps; if (!obs_data_get_frames_per_second(self.captureInfo->settings, "frame_rate", &fps, NULL)) { [self AVCaptureLog:LOG_DEBUG withFormat:@"No valid framerate found in settings"]; return NO; } CMTime time = {.value = fps.denominator, .timescale = fps.numerator, .flags = 1}; const char *selectedFormat = obs_data_get_string(self.captureInfo->settings, "supported_format"); NSString *selectedFormatNSString = selectedFormat != NULL ? @(selectedFormat) : @""; AVCaptureDeviceFormat *format = nil; FourCharCode subtype; OBSAVCaptureColorSpace colorSpace; bool fpsSupported = false; if (![selectedFormatNSString isEqualToString:@""]) { for (AVCaptureDeviceFormat *formatCandidate in [self.deviceInput.device.formats reverseObjectEnumerator]) { if ([selectedFormatNSString isEqualToString:formatCandidate.obsPropertyListInternalRepresentation]) { CMFormatDescriptionRef formatDescription = formatCandidate.formatDescription; FourCharCode formatFourCC = CMFormatDescriptionGetMediaSubType(formatDescription); format = formatCandidate; subtype = formatFourCC; colorSpace = [OBSAVCapture colorspaceFromDescription:formatDescription]; break; } } } else { //try to migrate from the legacy suite of properties int legacyVideoRange = (int) obs_data_get_int(self.captureInfo->settings, "video_range"); int legacyInputFormat = (int) obs_data_get_int(self.captureInfo->settings, "input_format"); int legacyColorSpace = (int) obs_data_get_int(self.captureInfo->settings, "color_space"); CMVideoDimensions legacyDimensions = [OBSAVCapture legacyDimensionsFromSettings:self.captureInfo->settings]; for (AVCaptureDeviceFormat *formatCandidate in [self.deviceInput.device.formats reverseObjectEnumerator]) { CMFormatDescriptionRef formatDescription = formatCandidate.formatDescription; CMVideoDimensions formatDimensions = CMVideoFormatDescriptionGetDimensions(formatDescription); int formatColorSpace = [OBSAVCapture colorspaceFromDescription:formatDescription]; int formatInputFormat = [OBSAVCapture formatFromSubtype:CMFormatDescriptionGetMediaSubType(formatDescription)]; int formatVideoRange = [OBSAVCapture isFullRangeFormat:formatInputFormat] ? VIDEO_RANGE_FULL : VIDEO_RANGE_PARTIAL; bool foundFormat = legacyVideoRange == formatVideoRange && legacyInputFormat == formatInputFormat && legacyColorSpace == formatColorSpace && legacyDimensions.width == formatDimensions.width && legacyDimensions.height == formatDimensions.height; if (foundFormat) { format = formatCandidate; subtype = formatInputFormat; colorSpace = formatColorSpace; break; } } } if (!format) { [self AVCaptureLog:LOG_WARNING withFormat:@"Configured format not found on device"]; return NO; } for (AVFrameRateRange *range in format.videoSupportedFrameRateRanges) { if (CMTimeCompare(range.maxFrameDuration, time) >= 0 && CMTimeCompare(range.minFrameDuration, time) <= 0) { fpsSupported = true; break; } } if (!fpsSupported) { OBSAVCaptureMediaFPS fallbackFPS = [OBSAVCapture fallbackFrameRateForFormat:format]; if (fallbackFPS.denominator > 0 && fallbackFPS.numerator > 0) { [self AVCaptureLog:LOG_WARNING withFormat:@"Frame rate is not supported: %g FPS (%u/%u), \n" " falling back to value supported by device: %G FPS (%u/%u)", media_frames_per_second_to_fps(fps), fps.numerator, fps.denominator, media_frames_per_second_to_fps(fallbackFPS), fallbackFPS.numerator, fallbackFPS.denominator]; obs_data_set_frames_per_second(self.captureInfo->settings, "frame_rate", fallbackFPS, NULL); time.value = fallbackFPS.denominator; time.timescale = fallbackFPS.numerator; } else { [self AVCaptureLog:LOG_WARNING withFormat:@"Frame rate is not supported: %g FPS (%u/%u), \n" " no supported fallback FPS found", media_frames_per_second_to_fps(fps), fps.numerator, fps.denominator]; return NO; } } [self.session beginConfiguration]; self.isDeviceLocked = [self.deviceInput.device lockForConfiguration:error]; if (!self.isDeviceLocked) { [self AVCaptureLog:LOG_WARNING withFormat:@"Could not lock device for configuration"]; return NO; } [self AVCaptureLog:LOG_INFO withFormat:@"Capturing '%@' (%@):\n" " Using Format : %@ \n" " FPS : %g (%u/%u)\n" " Frame Interval : %g\u00a0s\n", self.deviceInput.device.localizedName, self.deviceInput.device.uniqueID, format.obsPropertyListDescription, media_frames_per_second_to_fps(fps), fps.numerator, fps.denominator, media_frames_per_second_to_frame_interval(fps)]; OBSAVCaptureVideoInfo newInfo = {.colorSpace = _videoInfo.colorSpace, .fourCC = _videoInfo.fourCC, .isValid = false}; self.videoInfo = newInfo; self.captureInfo->configuredColorSpace = colorSpace; self.captureInfo->configuredFourCC = subtype; self.isPresetBased = NO; if (!self.presetFormat) { OBSAVCapturePresetInfo *presetInfo = [[OBSAVCapturePresetInfo alloc] init]; presetInfo.activeFormat = self.deviceInput.device.activeFormat; presetInfo.minFrameRate = self.deviceInput.device.activeVideoMinFrameDuration; presetInfo.maxFrameRate = self.deviceInput.device.activeVideoMaxFrameDuration; self.presetFormat = presetInfo; } self.deviceInput.device.activeFormat = format; self.deviceInput.device.activeVideoMinFrameDuration = time; self.deviceInput.device.activeVideoMaxFrameDuration = time; [self.session commitConfiguration]; return YES; } - (BOOL)updateSessionwithError:(NSError *__autoreleasing *)error { switch (self.captureInfo->lastError) { case OBSAVCaptureError_SampleBufferFormat: if (self.captureInfo->sampleBufferDescription) { FourCharCode mediaSubType = CMFormatDescriptionGetMediaSubType(self.captureInfo->sampleBufferDescription); [self AVCaptureLog:LOG_ERROR withFormat:@"Incompatible sample buffer format received for sync AVCapture source: %@ (0x%x)", [OBSAVCapture stringFromFourCharCode:mediaSubType], mediaSubType]; } break; case OBSAVCaptureError_ColorSpace: { if (self.captureInfo->sampleBufferDescription) { FourCharCode mediaSubType = CMFormatDescriptionGetMediaSubType(self.captureInfo->sampleBufferDescription); BOOL isSampleBufferFullRange = [OBSAVCapture isFullRangeFormat:mediaSubType]; OBSAVCaptureColorSpace sampleBufferColorSpace = [OBSAVCapture colorspaceFromDescription:self.captureInfo->sampleBufferDescription]; OBSAVCaptureVideoRange sampleBufferRangeType = isSampleBufferFullRange ? VIDEO_RANGE_FULL : VIDEO_RANGE_PARTIAL; [self AVCaptureLog:LOG_ERROR withFormat:@"Failed to get colorspace parameters for colorspace %u and range %u", sampleBufferColorSpace, sampleBufferRangeType]; } break; default: self.captureInfo->lastError = OBSAVCaptureError_NoError; self.captureInfo->sampleBufferDescription = NULL; break; } } switch (self.captureInfo->lastAudioError) { case OBSAVCaptureError_AudioBuffer: { [OBSAVCapture AVCaptureLog:LOG_ERROR withFormat:@"Unable to retrieve required AudioBufferList size from sample buffer."]; break; } default: self.captureInfo->lastAudioError = OBSAVCaptureError_NoError; break; } NSString *newDeviceUUID = [OBSAVCapture stringFromSettings:self.captureInfo->settings withSetting:@"device"]; NSString *presetName = [OBSAVCapture stringFromSettings:self.captureInfo->settings withSetting:@"preset"]; BOOL isPresetEnabled = obs_data_get_bool(self.captureInfo->settings, "use_preset"); BOOL updateSession = YES; if (![self.deviceUUID isEqualToString:newDeviceUUID]) { if (![self switchCaptureDevice:newDeviceUUID withError:error]) { obs_source_update_properties(self.captureInfo->source); return NO; } } else if (self.isPresetBased && isPresetEnabled && [presetName isEqualToString:self.session.sessionPreset]) { updateSession = NO; } if (updateSession) { if (isPresetEnabled) { [self configureSessionWithPreset:presetName withError:error]; } else { if (![self configureSession:error]) { obs_source_update_properties(self.captureInfo->source); return NO; } } __weak OBSAVCapture *weakSelf = self; dispatch_async(self.sessionQueue, ^{ [weakSelf startCaptureSession]; }); } BOOL isAudioAvailable = [self.deviceInput.device hasMediaType:AVMediaTypeAudio] || [self.deviceInput.device hasMediaType:AVMediaTypeMuxed]; obs_source_set_audio_active(self.captureInfo->source, isAudioAvailable); if (!self.isFastPath) { BOOL isBufferingEnabled = obs_data_get_bool(self.captureInfo->settings, "buffering"); obs_source_set_async_unbuffered(self.captureInfo->source, !isBufferingEnabled); } return YES; } #pragma mark - OBS Settings Helpers + (CMVideoDimensions)legacyDimensionsFromSettings:(void *)settings { CMVideoDimensions zero = {0}; NSString *jsonString = [OBSAVCapture stringFromSettings:settings withSetting:@"resolution"]; NSDictionary *data = [NSJSONSerialization JSONObjectWithData:[jsonString dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil]; if (data.count == 0) { return zero; } NSInteger width = [[data objectForKey:@"width"] intValue]; NSInteger height = [[data objectForKey:@"height"] intValue]; if (!width || !height) { return zero; } CMVideoDimensions dimensions = {.width = (int32_t) clamp_Uint(width, 0, UINT32_MAX), .height = (int32_t) clamp_Uint(height, 0, UINT32_MAX)}; return dimensions; } + (OBSAVCaptureMediaFPS)fallbackFrameRateForFormat:(AVCaptureDeviceFormat *)format { struct obs_video_info video_info; bool result = obs_get_video_info(&video_info); double outputFPS = result ? ((double) video_info.fps_num / (double) video_info.fps_den) : 0; double closestUpTo = 0; double closestAbove = DBL_MAX; OBSAVCaptureMediaFPS closestUpToMFPS = {}; OBSAVCaptureMediaFPS closestAboveMFPS = {}; for (AVFrameRateRange *range in format.videoSupportedFrameRateRanges) { if (range.maxFrameRate > closestUpTo && range.maxFrameRate <= outputFPS) { closestUpTo = range.maxFrameRate; closestUpToMFPS.numerator = (uint32_t) clamp_Uint(range.minFrameDuration.timescale, 0, UINT32_MAX); closestUpToMFPS.denominator = (uint32_t) clamp_Uint(range.minFrameDuration.value, 0, UINT32_MAX); } if (range.minFrameRate > outputFPS && range.minFrameRate < closestAbove) { closestAbove = range.minFrameRate; closestAboveMFPS.numerator = (uint32_t) clamp_Uint(range.maxFrameDuration.timescale, 0, UINT32_MAX); closestAboveMFPS.denominator = (uint32_t) clamp_Uint(range.maxFrameDuration.value, 0, UINT32_MAX); } } if (closestUpTo > 0) { return closestUpToMFPS; } else { return closestAboveMFPS; } } + (NSString *)aspectRatioStringFromDimensions:(CMVideoDimensions)dimensions { if (dimensions.width <= 0 || dimensions.height <= 0) { return @""; } double divisor = (double) gcd(dimensions.width, dimensions.height); if (divisor <= 50) { if (dimensions.width > dimensions.height) { double x = (double) dimensions.width / (double) dimensions.height; return [NSString stringWithFormat:@"%.2f:1", x]; } else { double y = (double) dimensions.height / (double) dimensions.width; return [NSString stringWithFormat:@"1:%.2f", y]; } } else { SInt32 x = dimensions.width / (SInt32) divisor; SInt32 y = dimensions.height / (SInt32) divisor; if (x == 8 && y == 5) { x = 16; y = 10; } return [NSString stringWithFormat:@"%i:%i", x, y]; } } + (NSString *)stringFromSettings:(void *)settings withSetting:(NSString *)setting { return [OBSAVCapture stringFromSettings:settings withSetting:setting withDefault:@""]; } + (NSString *)stringFromSettings:(void *)settings withSetting:(NSString *)setting withDefault:(NSString *)defaultValue { NSString *result; if (settings) { const char *setting_value = obs_data_get_string(settings, setting.UTF8String); if (!setting_value) { result = [NSString stringWithString:defaultValue]; } else { result = @(setting_value); } } else { result = [NSString stringWithString:defaultValue]; } return result; } + (NSString *)effectsWarningForDevice:(AVCaptureDevice *)device { int effectsCount = 0; NSString *effectWarning = nil; if (@available(macOS 12.0, *)) { if (device.portraitEffectActive) { effectWarning = @"Warning.Effect.Portrait"; effectsCount++; } } if (@available(macOS 12.3, *)) { if (device.centerStageActive) { effectWarning = @"Warning.Effect.CenterStage"; effectsCount++; } } if (@available(macOS 13.0, *)) { if (device.studioLightActive) { effectWarning = @"Warning.Effect.StudioLight"; effectsCount++; } } if (@available(macOS 14.0, *)) { /// Reaction effects do not follow the same paradigm as other effects in terms of checking whether they are active. According to Apple, this is because a device instance property `reactionEffectsActive` would have been ambiguous (conflicting with whether a reaction is currently rendering). /// /// Instead, Apple exposes the `AVCaptureDevice.reactionEffectGesturesEnabled` class property (an equivalent exists for all other effects, but is hidden/private) to tell us whether the effect is enabled application-wide, as well as the `device.canPerformReactionEffects` instance property to tell us whether the device's active format currently supports the effect. /// /// The logical conjunction of these two properties tells us whether the effect is 'active'; i.e. whether putting our thumbs inside the video frame will make fireworks appear. The device instance properties for other effects are a convenience 'shorthand' for this private class/instance property combination. if (device.canPerformReactionEffects && AVCaptureDevice.reactionEffectGesturesEnabled) { effectWarning = @"Warning.Effect.Reactions"; effectsCount++; } } if (@available(macOS 15.0, *)) { if (device.backgroundReplacementActive) { effectWarning = @"Warning.Effect.BackgroundReplacement"; effectsCount++; } } if (effectsCount > 1) { effectWarning = @"Warning.Effect.Multiple"; } return effectWarning; } #pragma mark - Format Conversion Helpers + (NSString *)stringFromSubType:(FourCharCode)subtype { switch (subtype) { case kCVPixelFormatType_422YpCbCr8: return @"UYVY (2vuy)"; case kCVPixelFormatType_422YpCbCr8_yuvs: return @"YUY2 (yuvs)"; case kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange: return @"NV12 (420v)"; case kCVPixelFormatType_420YpCbCr8BiPlanarFullRange: return @"NV12 (420f)"; case kCVPixelFormatType_420YpCbCr10BiPlanarFullRange: return @"P010 (xf20)"; case kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange: return @"P010 (x420)"; case kCVPixelFormatType_32ARGB: return @"ARGB - 32ARGB"; case kCVPixelFormatType_32BGRA: return @"BGRA - 32BGRA"; case kCMVideoCodecType_Animation: return @"Apple Animation"; case kCMVideoCodecType_Cinepak: return @"Cinepak"; case kCMVideoCodecType_JPEG: return @"JPEG"; case kCMVideoCodecType_JPEG_OpenDML: return @"MJPEG - JPEG OpenDML"; case kCMVideoCodecType_SorensonVideo: return @"Sorenson Video"; case kCMVideoCodecType_SorensonVideo3: return @"Sorenson Video 3"; case kCMVideoCodecType_H263: return @"H.263"; case kCMVideoCodecType_H264: return @"H.264"; case kCMVideoCodecType_MPEG4Video: return @"MPEG-4"; case kCMVideoCodecType_MPEG2Video: return @"MPEG-2"; case kCMVideoCodecType_MPEG1Video: return @"MPEG-1"; case kCMVideoCodecType_DVCNTSC: return @"DV NTSC"; case kCMVideoCodecType_DVCPAL: return @"DV PAL"; case kCMVideoCodecType_DVCProPAL: return @"Panasonic DVCPro Pal"; case kCMVideoCodecType_DVCPro50NTSC: return @"Panasonic DVCPro-50 NTSC"; case kCMVideoCodecType_DVCPro50PAL: return @"Panasonic DVCPro-50 PAL"; case kCMVideoCodecType_DVCPROHD720p60: return @"Panasonic DVCPro-HD 720p60"; case kCMVideoCodecType_DVCPROHD720p50: return @"Panasonic DVCPro-HD 720p50"; case kCMVideoCodecType_DVCPROHD1080i60: return @"Panasonic DVCPro-HD 1080i60"; case kCMVideoCodecType_DVCPROHD1080i50: return @"Panasonic DVCPro-HD 1080i50"; case kCMVideoCodecType_DVCPROHD1080p30: return @"Panasonic DVCPro-HD 1080p30"; case kCMVideoCodecType_DVCPROHD1080p25: return @"Panasonic DVCPro-HD 1080p25"; case kCMVideoCodecType_AppleProRes4444: return @"Apple ProRes 4444"; case kCMVideoCodecType_AppleProRes422HQ: return @"Apple ProRes 422 HQ"; case kCMVideoCodecType_AppleProRes422: return @"Apple ProRes 422"; case kCMVideoCodecType_AppleProRes422LT: return @"Apple ProRes 422 LT"; case kCMVideoCodecType_AppleProRes422Proxy: return @"Apple ProRes 422 Proxy"; default: return @"Unknown"; } } + (NSString *)stringFromColorspace:(enum video_colorspace)colorspace { switch (colorspace) { case VIDEO_CS_DEFAULT: return @"Default"; case VIDEO_CS_601: return @"CS 601"; case VIDEO_CS_709: return @"CS 709"; case VIDEO_CS_SRGB: return @"sRGB"; case VIDEO_CS_2100_PQ: return @"CS 2100 (PQ)"; case VIDEO_CS_2100_HLG: return @"CS 2100 (HLG)"; default: return @"Unknown"; } } + (NSString *)stringFromVideoRange:(enum video_range_type)videoRange { switch (videoRange) { case VIDEO_RANGE_FULL: return @"Full"; case VIDEO_RANGE_PARTIAL: return @"Partial"; case VIDEO_RANGE_DEFAULT: return @"Default"; } } + (NSString *)stringFromCapturePreset:(AVCaptureSessionPreset)preset { NSDictionary *presetDescriptions = @{ AVCaptureSessionPresetLow: @"Low", AVCaptureSessionPresetMedium: @"Medium", AVCaptureSessionPresetHigh: @"High", AVCaptureSessionPreset320x240: @"320x240", AVCaptureSessionPreset352x288: @"352x288", AVCaptureSessionPreset640x480: @"640x480", AVCaptureSessionPreset960x540: @"960x460", AVCaptureSessionPreset1280x720: @"1280x720", AVCaptureSessionPreset1920x1080: @"1920x1080", AVCaptureSessionPreset3840x2160: @"3840x2160", }; NSString *presetDescription = [presetDescriptions objectForKey:preset]; if (!presetDescription) { return [NSString stringWithFormat:@"Unknown (%@)", preset]; } else { return presetDescription; } } + (NSString *)stringFromFourCharCode:(OSType)fourCharCode { char cString[5] = {(fourCharCode >> 24) & 0xFF, (fourCharCode >> 16) & 0xFF, (fourCharCode >> 8) & 0xFF, fourCharCode & 0xFF, 0}; NSString *codeString = @(cString); return codeString; } + (FourCharCode)fourCharCodeFromString:(NSString *)codeString { FourCharCode fourCharCode; const char *cString = codeString.UTF8String; fourCharCode = (cString[0] << 24) | (cString[1] << 16) | (cString[2] << 8) | cString[3]; return fourCharCode; } + (BOOL)isValidColorspace:(enum video_colorspace)colorspace { switch (colorspace) { case VIDEO_CS_DEFAULT: case VIDEO_CS_601: case VIDEO_CS_709: return YES; default: return NO; } } + (BOOL)isValidVideoRange:(enum video_range_type)videoRange { switch (videoRange) { case VIDEO_RANGE_DEFAULT: case VIDEO_RANGE_PARTIAL: case VIDEO_RANGE_FULL: return YES; default: return NO; } } + (BOOL)isFullRangeFormat:(FourCharCode)pixelFormat { switch (pixelFormat) { case kCVPixelFormatType_420YpCbCr8PlanarFullRange: case kCVPixelFormatType_420YpCbCr8BiPlanarFullRange: case kCVPixelFormatType_420YpCbCr10BiPlanarFullRange: case kCVPixelFormatType_422YpCbCr8FullRange: return YES; default: return NO; } } + (OBSAVCaptureVideoFormat)formatFromSubtype:(FourCharCode)subtype { switch (subtype) { case kCVPixelFormatType_422YpCbCr8: return VIDEO_FORMAT_UYVY; case kCVPixelFormatType_422YpCbCr8_yuvs: return VIDEO_FORMAT_YUY2; case kCVPixelFormatType_32BGRA: return VIDEO_FORMAT_BGRA; case kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange: case kCVPixelFormatType_420YpCbCr8BiPlanarFullRange: return VIDEO_FORMAT_NV12; case kCVPixelFormatType_420YpCbCr10BiPlanarFullRange: case kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange: return VIDEO_FORMAT_P010; default: return VIDEO_FORMAT_NONE; } } + (FourCharCode)fourCharCodeFromFormat:(OBSAVCaptureVideoFormat)format withRange:(enum video_range_type)videoRange { switch (format) { case VIDEO_FORMAT_UYVY: return kCVPixelFormatType_422YpCbCr8; case VIDEO_FORMAT_YUY2: return kCVPixelFormatType_422YpCbCr8_yuvs; case VIDEO_FORMAT_NV12: if (videoRange == VIDEO_RANGE_FULL) { return kCVPixelFormatType_420YpCbCr8BiPlanarFullRange; } else { return kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange; } case VIDEO_FORMAT_P010: if (videoRange == VIDEO_RANGE_FULL) { return kCVPixelFormatType_420YpCbCr10BiPlanarFullRange; } else { return kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange; } case VIDEO_FORMAT_BGRA: return kCVPixelFormatType_32BGRA; default: return 0; } } + (FourCharCode)fourCharCodeFromFormat:(OBSAVCaptureVideoFormat)format { return [OBSAVCapture fourCharCodeFromFormat:format withRange:VIDEO_RANGE_PARTIAL]; } + (NSString *)frameRateDescription:(NSArray *)ranges { // The videoSupportedFrameRateRanges property seems to provide frame rate ranges in this order, but since that // ordering does not seem to be guaranteed, ensure they are sorted anyway. NSArray *sortedRangesDescending = [ranges sortedArrayUsingComparator:^NSComparisonResult(AVFrameRateRange *_Nonnull lhs, AVFrameRateRange *_Nonnull rhs) { if (lhs.maxFrameRate > rhs.maxFrameRate) { return NSOrderedAscending; } else if (lhs.maxFrameRate < rhs.maxFrameRate) { return NSOrderedDescending; } if (lhs.minFrameRate > rhs.minFrameRate) { return NSOrderedAscending; } else if (lhs.minFrameRate < rhs.minFrameRate) { return NSOrderedDescending; } return NSOrderedSame; }]; NSString *frameRateDescription; NSMutableArray *frameRateDescriptions = [[NSMutableArray alloc] initWithCapacity:ranges.count]; for (AVFrameRateRange *range in [sortedRangesDescending reverseObjectEnumerator]) { double minFrameRate = round(range.minFrameRate * 100) / 100; double maxFrameRate = round(range.maxFrameRate * 100) / 100; if (minFrameRate == maxFrameRate) { if (fmod(minFrameRate, 1.0) == 0 && fmod(maxFrameRate, 1.0) == 0) { [frameRateDescriptions addObject:[NSString stringWithFormat:@"%.0f", maxFrameRate]]; } else { [frameRateDescriptions addObject:[NSString stringWithFormat:@"%.2f", maxFrameRate]]; } } else { if (fmod(minFrameRate, 1.0) == 0 && fmod(maxFrameRate, 1.0) == 0) { [frameRateDescriptions addObject:[NSString stringWithFormat:@"%.0f-%.0f", minFrameRate, maxFrameRate]]; } else { [frameRateDescriptions addObject:[NSString stringWithFormat:@"%.2f-%.2f", minFrameRate, maxFrameRate]]; } } } if (frameRateDescriptions.count > 0 && frameRateDescriptions.count <= kMaxFrameRateRangesInDescription) { frameRateDescription = [frameRateDescriptions componentsJoinedByString:@", "]; frameRateDescription = [frameRateDescription stringByAppendingString:@" FPS"]; } else if (frameRateDescriptions.count > kMaxFrameRateRangesInDescription) { frameRateDescription = [NSString stringWithFormat:@"%.0f-%.0f FPS (%lu values)", sortedRangesDescending.lastObject.minFrameRate, sortedRangesDescending.firstObject.maxFrameRate, sortedRangesDescending.count]; } return frameRateDescription; } + (OBSAVCaptureColorSpace)colorspaceFromDescription:(CMFormatDescriptionRef)description { CFPropertyListRef matrix = CMFormatDescriptionGetExtension(description, kCMFormatDescriptionExtension_YCbCrMatrix); if (!matrix) { return VIDEO_CS_DEFAULT; } CFComparisonResult is601 = CFStringCompare(matrix, kCVImageBufferYCbCrMatrix_ITU_R_601_4, 0); CFComparisonResult is709 = CFStringCompare(matrix, kCVImageBufferYCbCrMatrix_ITU_R_709_2, 0); CFComparisonResult is2020 = CFStringCompare(matrix, kCVImageBufferYCbCrMatrix_ITU_R_2020, 0); if (is601 == kCFCompareEqualTo) { return VIDEO_CS_601; } else if (is709 == kCFCompareEqualTo) { return VIDEO_CS_709; } else if (is2020 == kCFCompareEqualTo) { CFPropertyListRef transferFunction = CMFormatDescriptionGetExtension(description, kCMFormatDescriptionExtension_TransferFunction); if (!matrix) { return VIDEO_CS_DEFAULT; } CFComparisonResult isPQ = CFStringCompare(transferFunction, kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ, 0); CFComparisonResult isHLG = CFStringCompare(transferFunction, kCVImageBufferTransferFunction_ITU_R_2100_HLG, 0); if (isPQ == kCFCompareEqualTo) { return VIDEO_CS_2100_PQ; } else if (isHLG == kCFCompareEqualTo) { return VIDEO_CS_2100_HLG; } } return VIDEO_CS_DEFAULT; } #pragma mark - Notification Handlers - (void)deviceConnected:(NSNotification *)notification { AVCaptureDevice *device = notification.object; if (!device) { return; } if (![[device uniqueID] isEqualTo:self.deviceUUID]) { obs_source_update_properties(self.captureInfo->source); return; } if (self.deviceInput.device) { [self AVCaptureLog:LOG_INFO withFormat:@"Received connect event with active device '%@' (UUID %@)", self.deviceInput.device.localizedName, self.deviceInput.device.uniqueID]; obs_source_update_properties(self.captureInfo->source); return; } [self AVCaptureLog:LOG_INFO withFormat:@"Received connect event for device '%@' (UUID %@)", device.localizedName, device.uniqueID]; NSError *error; NSString *presetName = [OBSAVCapture stringFromSettings:self.captureInfo->settings withSetting:@"preset"]; BOOL isPresetEnabled = obs_data_get_bool(self.captureInfo->settings, "use_preset"); BOOL isFastPath = self.captureInfo->isFastPath; if ([self switchCaptureDevice:device.uniqueID withError:&error]) { BOOL success; if (isPresetEnabled && !isFastPath) { success = [self configureSessionWithPreset:presetName withError:&error]; } else { success = [self configureSession:&error]; } if (success) { dispatch_async(self.sessionQueue, ^{ [self startCaptureSession]; }); } else { [self AVCaptureLog:LOG_ERROR withFormat:error.localizedDescription]; } } else { [self AVCaptureLog:LOG_ERROR withFormat:error.localizedDescription]; } obs_source_update_properties(self.captureInfo->source); } - (void)deviceDisconnected:(NSNotification *)notification { AVCaptureDevice *device = notification.object; if (!device) { return; } if (![[device uniqueID] isEqualTo:self.deviceUUID]) { obs_source_update_properties(self.captureInfo->source); return; } if (!self.deviceInput.device) { [self AVCaptureLog:LOG_ERROR withFormat:@"Received disconnect event for inactive device '%@' (UUID %@)", device.localizedName, device.uniqueID]; obs_source_update_properties(self.captureInfo->source); return; } [self AVCaptureLog:LOG_INFO withFormat:@"Received disconnect event for device '%@' (UUID %@)", device.localizedName, device.uniqueID]; __weak OBSAVCapture *weakSelf = self; dispatch_async(self.sessionQueue, ^{ OBSAVCapture *instance = weakSelf; [instance stopCaptureSession]; [instance.session removeInput:instance.deviceInput]; instance.deviceInput = nil; instance = nil; }); obs_source_update_properties(self.captureInfo->source); } #pragma mark - AVCapture Delegate Methods - (void)captureOutput:(AVCaptureOutput *)output didDropSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection { return; } - (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection { CMItemCount sampleCount = CMSampleBufferGetNumSamples(sampleBuffer); if (!_captureInfo || sampleCount < 1) { return; } CMTime presentationTimeStamp = CMSampleBufferGetOutputPresentationTimeStamp(sampleBuffer); CMTime presentationNanoTimeStamp = CMTimeConvertScale(presentationTimeStamp, 1E9, kCMTimeRoundingMethod_Default); CMFormatDescriptionRef description = CMSampleBufferGetFormatDescription(sampleBuffer); CMMediaType mediaType = CMFormatDescriptionGetMediaType(description); switch (mediaType) { case kCMMediaType_Video: { CMVideoDimensions sampleBufferDimensions = CMVideoFormatDescriptionGetDimensions(description); CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); FourCharCode mediaSubType = CMFormatDescriptionGetMediaSubType(description); OBSAVCaptureVideoInfo newInfo = {.fourCC = _videoInfo.fourCC, .colorSpace = _videoInfo.colorSpace, .isValid = false}; BOOL usePreset = obs_data_get_bool(_captureInfo->settings, "use_preset"); if (_isFastPath) { if (mediaSubType != kCVPixelFormatType_32BGRA && mediaSubType != kCVPixelFormatType_ARGB2101010LEPacked) { _captureInfo->lastError = OBSAVCaptureError_SampleBufferFormat; CMFormatDescriptionCreate(kCFAllocatorDefault, mediaType, mediaSubType, NULL, &_captureInfo->sampleBufferDescription); obs_source_update_properties(_captureInfo->source); break; } else { _captureInfo->lastError = OBSAVCaptureError_NoError; _captureInfo->sampleBufferDescription = NULL; } CVPixelBufferLockBaseAddress(imageBuffer, 0); IOSurfaceRef frameSurface = CVPixelBufferGetIOSurface(imageBuffer); CVPixelBufferUnlockBaseAddress(imageBuffer, 0); IOSurfaceRef previousSurface = NULL; if (frameSurface && !pthread_mutex_lock(&_captureInfo->mutex)) { NSRect frameSize = _captureInfo->frameSize; if (frameSize.size.width != sampleBufferDimensions.width || frameSize.size.height != sampleBufferDimensions.height) { frameSize = CGRectMake(0, 0, sampleBufferDimensions.width, sampleBufferDimensions.height); } previousSurface = _captureInfo->currentSurface; _captureInfo->currentSurface = frameSurface; CFRetain(_captureInfo->currentSurface); IOSurfaceIncrementUseCount(_captureInfo->currentSurface); pthread_mutex_unlock(&_captureInfo->mutex); newInfo.isValid = true; if (_videoInfo.isValid != newInfo.isValid) { obs_source_update_properties(_captureInfo->source); } _captureInfo->frameSize = frameSize; _videoInfo = newInfo; } if (previousSurface) { IOSurfaceDecrementUseCount(previousSurface); CFRelease(previousSurface); } break; } else { OBSAVCaptureVideoFrame *frame = _captureInfo->videoFrame; frame->timestamp = presentationNanoTimeStamp.value; enum video_format videoFormat = [OBSAVCapture formatFromSubtype:mediaSubType]; if (videoFormat == VIDEO_FORMAT_NONE) { _captureInfo->lastError = OBSAVCaptureError_SampleBufferFormat; CMFormatDescriptionCreate(kCFAllocatorDefault, mediaType, mediaSubType, NULL, &_captureInfo->sampleBufferDescription); } else { _captureInfo->lastError = OBSAVCaptureError_NoError; _captureInfo->sampleBufferDescription = NULL; #ifdef DEBUG if (frame->format != VIDEO_FORMAT_NONE && frame->format != videoFormat) { [self AVCaptureLog:LOG_DEBUG withFormat:@"Switching fourcc: '%@' (0x%x) -> '%@' (0x%x)", [OBSAVCapture stringFromFourCharCode:frame->format], frame -> format, [OBSAVCapture stringFromFourCharCode:mediaSubType], mediaSubType]; } #endif bool isFrameYuv = format_is_yuv(frame->format); bool isSampleBufferYuv = format_is_yuv(videoFormat); frame->format = videoFormat; frame->width = sampleBufferDimensions.width; frame->height = sampleBufferDimensions.height; BOOL isSampleBufferFullRange = [OBSAVCapture isFullRangeFormat:mediaSubType]; if (isSampleBufferYuv) { OBSAVCaptureColorSpace sampleBufferColorSpace = [OBSAVCapture colorspaceFromDescription:description]; OBSAVCaptureVideoRange sampleBufferRangeType = isSampleBufferFullRange ? VIDEO_RANGE_FULL : VIDEO_RANGE_PARTIAL; BOOL isColorSpaceMatching = NO; SInt64 configuredColorSpace = _captureInfo->configuredColorSpace; if (usePreset) { isColorSpaceMatching = sampleBufferColorSpace == _videoInfo.colorSpace; } else { isColorSpaceMatching = configuredColorSpace == _videoInfo.colorSpace; } BOOL isFourCCMatching = NO; SInt64 configuredFourCC = _captureInfo->configuredFourCC; if (usePreset) { isFourCCMatching = mediaSubType == _videoInfo.fourCC; } else { isFourCCMatching = configuredFourCC == _videoInfo.fourCC; } if (isColorSpaceMatching && isFourCCMatching) { newInfo.isValid = true; } else { frame->full_range = isSampleBufferFullRange; bool success = video_format_get_parameters_for_format( sampleBufferColorSpace, sampleBufferRangeType, frame->format, frame->color_matrix, frame->color_range_min, frame->color_range_max); if (!success) { _captureInfo->lastError = OBSAVCaptureError_ColorSpace; CMFormatDescriptionCreate(kCFAllocatorDefault, mediaType, mediaSubType, NULL, &_captureInfo->sampleBufferDescription); newInfo.isValid = false; } else { newInfo.colorSpace = sampleBufferColorSpace; newInfo.fourCC = mediaSubType; newInfo.isValid = true; } } } else if (!isFrameYuv && !isSampleBufferYuv) { newInfo.isValid = true; } } if (newInfo.isValid != _videoInfo.isValid) { obs_source_update_properties(_captureInfo->source); } _videoInfo = newInfo; if (newInfo.isValid) { CVPixelBufferLockBaseAddress(imageBuffer, kCVPixelBufferLock_ReadOnly); if (!CVPixelBufferIsPlanar(imageBuffer)) { frame->linesize[0] = (UInt32) CVPixelBufferGetBytesPerRow(imageBuffer); frame->data[0] = CVPixelBufferGetBaseAddress(imageBuffer); } else { size_t planeCount = CVPixelBufferGetPlaneCount(imageBuffer); for (size_t i = 0; i < planeCount; i++) { frame->linesize[i] = (UInt32) CVPixelBufferGetBytesPerRowOfPlane(imageBuffer, i); frame->data[i] = CVPixelBufferGetBaseAddressOfPlane(imageBuffer, i); } } obs_source_output_video(_captureInfo->source, frame); CVPixelBufferUnlockBaseAddress(imageBuffer, kCVPixelBufferLock_ReadOnly); } else { obs_source_output_video(_captureInfo->source, NULL); } break; } } case kCMMediaType_Audio: { size_t requiredBufferListSize; OSStatus status = noErr; status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer( sampleBuffer, &requiredBufferListSize, NULL, 0, NULL, NULL, kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment, NULL); if (status != noErr) { _captureInfo->lastAudioError = OBSAVCaptureError_AudioBuffer; obs_source_update_properties(_captureInfo->source); break; } AudioBufferList *bufferList = (AudioBufferList *) malloc(requiredBufferListSize); CMBlockBufferRef blockBuffer = NULL; OSStatus error = noErr; error = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer( sampleBuffer, NULL, bufferList, requiredBufferListSize, kCFAllocatorSystemDefault, kCFAllocatorSystemDefault, kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment, &blockBuffer); if (error == noErr) { _captureInfo->lastAudioError = OBSAVCaptureError_NoError; OBSAVCaptureAudioFrame *audio = _captureInfo->audioFrame; for (size_t i = 0; i < bufferList->mNumberBuffers; i++) { audio->data[i] = bufferList->mBuffers[i].mData; } audio->timestamp = presentationNanoTimeStamp.value; audio->frames = (uint32_t) CMSampleBufferGetNumSamples(sampleBuffer); const AudioStreamBasicDescription *basicDescription = CMAudioFormatDescriptionGetStreamBasicDescription(description); audio->samples_per_sec = (uint32_t) basicDescription->mSampleRate; audio->speakers = (enum speaker_layout) basicDescription->mChannelsPerFrame; switch (basicDescription->mBitsPerChannel) { case 8: audio->format = AUDIO_FORMAT_U8BIT; break; case 16: audio->format = AUDIO_FORMAT_16BIT; break; case 32: audio->format = AUDIO_FORMAT_32BIT; break; default: audio->format = AUDIO_FORMAT_UNKNOWN; break; } obs_source_output_audio(_captureInfo->source, audio); } else { _captureInfo->lastAudioError = OBSAVCaptureError_AudioBuffer; obs_source_output_audio(_captureInfo->source, NULL); } if (blockBuffer != NULL) { CFRelease(blockBuffer); } if (bufferList != NULL) { free(bufferList); bufferList = NULL; } break; } default: break; } } #pragma mark - Log Helpers - (void)AVCaptureLog:(int)logLevel withFormat:(NSString *)format, ... { va_list args; va_start(args, format); NSString *logMessage = [[NSString alloc] initWithFormat:format arguments:args]; va_end(args); const char *name_value = obs_source_get_name(self.captureInfo->source); NSString *sourceName = @((name_value) ? name_value : ""); blog(logLevel, "%s: %s", sourceName.UTF8String, logMessage.UTF8String); } + (void)AVCaptureLog:(int)logLevel withFormat:(NSString *)format, ... { va_list args; va_start(args, format); NSString *logMessage = [[NSString alloc] initWithFormat:format arguments:args]; va_end(args); blog(logLevel, "%s", logMessage.UTF8String); } @end