Files
obs-studio/plugins/mac-avcapture/OBSAVCapture.m
PatTheMav 2954532019 mac-avcapture: Use fallback frame rate by default for new devices
When a new device is selected, a best-possible frame rate is chosen
for the initial configuration of the device. This has to be set in the
source settings, as those are the "source of truth" for the properties
and the device configuration.

The object has to be created explicitly first before setting the
frame rate value. The source then has to be updated explicitly as well
to ensure that the change will be picked up by the next iteration
of the render thread to "tick" the source and thus make it configure
a capture session with the fallback framerate set.
2026-05-06 15:40:55 -04:00

1531 lines
59 KiB
Objective-C

//
// OBSAVCapture.m
// mac-avcapture
//
// Created by Patrick Heyer on 2023-03-07.
//
#import "OBSAVCapture.h"
#import "AVCaptureDeviceFormat+OBSListable.h"
/// The 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"];
AVCaptureDeviceFormat *lastFormat = [self.deviceInput.device.formats lastObject];
fps = [OBSAVCapture fallbackFrameRateForFormat:lastFormat];
if (lastFormat.videoSupportedFrameRateRanges.count == 0) {
return NO;
}
obs_data_set_obj(self.captureInfo->settings, "frame_rate", NULL);
obs_data_item_t *frameRateSetting = obs_data_item_byname(self.captureInfo->settings, "frame_rate");
obs_data_item_set_frames_per_second(&frameRateSetting, fps, NULL);
obs_source_update(self.captureInfo->source, self.captureInfo->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<AVFrameRateRange *> *)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<AVFrameRateRange *> *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