// // Stream.mm // obs-mac-virtualcam // // Created by John Boiles on 4/10/20. // // obs-mac-virtualcam is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 2 of the License, or // (at your option) any later version. // // obs-mac-virtualcam is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with obs-mac-virtualcam. If not, see . #import "OBSDALStream.h" #import #import #import "Logging.h" #import "CMSampleBufferUtils.h" #import "OBSDALPlugIn.h" @interface OBSDALStream () { CMSimpleQueueRef _queue; CFTypeRef _clock; NSImage *_testCardImage; dispatch_source_t _frameDispatchSource; NSSize _testCardSize; Float64 _fps; } @property CMIODeviceStreamQueueAlteredProc alteredProc; @property void *alteredRefCon; @property (readonly) CMSimpleQueueRef queue; @property (readonly) CFTypeRef clock; @property UInt64 sequenceNumber; @property (readonly) NSImage *testCardImage; @property (readonly) NSSize testCardSize; @property (readonly) Float64 fps; @end @implementation OBSDALStream #define DEFAULT_FPS 30.0 #define DEFAULT_WIDTH 1280 #define DEFAULT_HEIGHT 720 - (instancetype _Nonnull)init { self = [super init]; if (self) { _frameDispatchSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)); __weak __typeof(self) wself = self; dispatch_source_set_event_handler(_frameDispatchSource, ^{ [wself fillFrame]; }); } return self; } - (void)dealloc { DLog(@"Stream Dealloc"); CMIOStreamClockInvalidate(_clock); CFRelease(_clock); _clock = NULL; CFRelease(_queue); _queue = NULL; dispatch_suspend(_frameDispatchSource); } - (void)startServingDefaultFrames { DLogFunc(@""); _testCardImage = nil; _testCardSize = NSZeroSize; _fps = 0; dispatch_time_t startTime = dispatch_time(DISPATCH_TIME_NOW, 0); uint64_t intervalTime = (int64_t) (NSEC_PER_SEC / self.fps); dispatch_source_set_timer(_frameDispatchSource, startTime, intervalTime, 0); dispatch_resume(_frameDispatchSource); } - (void)stopServingDefaultFrames { DLogFunc(@""); dispatch_suspend(_frameDispatchSource); } - (CMSimpleQueueRef)queue { if (_queue == NULL) { // Allocate a one-second long queue, which we can use our FPS constant for. OSStatus err = CMSimpleQueueCreate(kCFAllocatorDefault, (int32_t) self.fps, &_queue); if (err != noErr) { DLog(@"Err %d in CMSimpleQueueCreate", err); } } return _queue; } - (CFTypeRef)clock { if (_clock == NULL) { OSStatus err = CMIOStreamClockCreate(kCFAllocatorDefault, CFSTR("obs-mac-virtualcam::Stream::clock"), (__bridge void *) self, CMTimeMake(1, 10), 100, 10, &_clock); if (err != noErr) { DLog(@"Error %d from CMIOStreamClockCreate", err); } } return _clock; } - (NSSize)testCardSize { if (NSEqualSizes(_testCardSize, NSZeroSize)) { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; NSInteger width = [[defaults objectForKey:kTestCardWidthKey] integerValue]; NSInteger height = [[defaults objectForKey:kTestCardHeightKey] integerValue]; if (width == 0 || height == 0) { _testCardSize = NSMakeSize(DEFAULT_WIDTH, DEFAULT_HEIGHT); } else { _testCardSize = NSMakeSize(width, height); } } return _testCardSize; } - (Float64)fps { if (_fps == 0) { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; double fps = [[defaults objectForKey:kTestCardFPSKey] doubleValue]; if (fps == 0) { _fps = DEFAULT_FPS; } else { _fps = fps; } } return _fps; } - (NSImage *)testCardImage { if (_testCardImage == nil) { NSString *bundlePath = [[NSBundle bundleForClass:[OBSDALStream class]] bundlePath]; NSString *placeHolderPath = [bundlePath stringByAppendingString:@"/Contents/Resources/placeholder.png"]; NSFileManager *fileManager = [NSFileManager defaultManager]; NSURL *homeUrl = [fileManager homeDirectoryForCurrentUser]; NSURL *customUrl = [homeUrl URLByAppendingPathComponent: @"Library/Application Support/obs-studio/plugin_config/mac-virtualcam/placeholder.png"]; NSString *customPlaceHolder = customUrl.path; if ([fileManager isReadableFileAtPath:customPlaceHolder]) placeHolderPath = customPlaceHolder; DLog(@"PlaceHolder:%@", placeHolderPath); NSImage *placeholderImage = [[NSImage alloc] initWithContentsOfFile:placeHolderPath]; NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithBitmapDataPlanes:NULL pixelsWide:(NSInteger) self.testCardSize.width pixelsHigh:(NSInteger) self.testCardSize.height bitsPerSample:8 samplesPerPixel:4 hasAlpha:YES isPlanar:NO colorSpaceName:NSCalibratedRGBColorSpace bytesPerRow:0 bitsPerPixel:0]; rep.size = self.testCardSize; double hScale = placeholderImage.size.width / self.testCardSize.width; double vScale = placeholderImage.size.height / self.testCardSize.height; double scaling = fmax(hScale, vScale); double newWidth = placeholderImage.size.width / scaling; double newHeight = placeholderImage.size.height / scaling; double leftOffset = (self.testCardSize.width - newWidth) / 2; double topOffset = (self.testCardSize.height - newHeight) / 2; [NSGraphicsContext saveGraphicsState]; [NSGraphicsContext setCurrentContext:[NSGraphicsContext graphicsContextWithBitmapImageRep:rep]]; NSColor *backgroundColor = [NSColor blackColor]; [backgroundColor set]; NSRectFill(NSMakeRect(0, 0, self.testCardSize.width, self.testCardSize.height)); [placeholderImage drawInRect:NSMakeRect(leftOffset, topOffset, newWidth, newHeight) fromRect:NSZeroRect operation:NSCompositingOperationCopy fraction:1.0]; [NSGraphicsContext restoreGraphicsState]; NSImage *testCardImage = [[NSImage alloc] initWithSize:self.testCardSize]; [testCardImage addRepresentation:rep]; _testCardImage = testCardImage; } return _testCardImage; } - (CMSimpleQueueRef)copyBufferQueueWithAlteredProc:(CMIODeviceStreamQueueAlteredProc)alteredProc alteredRefCon:(void *)alteredRefCon { self.alteredProc = alteredProc; self.alteredRefCon = alteredRefCon; // Retain this since it's a copy operation CFRetain(self.queue); return self.queue; } - (CVPixelBufferRef)createPixelBufferWithTestAnimation { int width = (int) self.testCardSize.width; int height = (int) self.testCardSize.height; NSDictionary *options = [NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithBool:YES], kCVPixelBufferCGImageCompatibilityKey, [NSNumber numberWithBool:YES], kCVPixelBufferCGBitmapContextCompatibilityKey, nil]; CVPixelBufferRef pxbuffer = NULL; CVReturn status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, kCVPixelFormatType_32ARGB, (__bridge CFDictionaryRef) options, &pxbuffer); NSParameterAssert(status == kCVReturnSuccess && pxbuffer != NULL); CVPixelBufferLockBaseAddress(pxbuffer, 0); void *pxdata = CVPixelBufferGetBaseAddressOfPlane(pxbuffer, 0); NSParameterAssert(pxdata != NULL); CGColorSpaceRef rgbColorSpace = CGColorSpaceCreateDeviceRGB(); CGContextRef context = CGBitmapContextCreate(pxdata, width, height, 8, CVPixelBufferGetBytesPerRowOfPlane(pxbuffer, 0), rgbColorSpace, kCGImageAlphaPremultipliedFirst | kCGImageByteOrder32Big); CFRelease(rgbColorSpace); NSParameterAssert(context); NSGraphicsContext *nsContext = [NSGraphicsContext graphicsContextWithCGContext:context flipped:NO]; [NSGraphicsContext setCurrentContext:nsContext]; NSRect rect = NSMakeRect(0, 0, self.testCardImage.size.width, self.testCardImage.size.height); CGImageRef image = [self.testCardImage CGImageForProposedRect:&rect context:nsContext hints:nil]; CGContextDrawImage(context, CGRectMake(0, 0, CGImageGetWidth(image), CGImageGetHeight(image)), image); // DrawDialWithFrame( // NSMakeRect(0, 0, width, height), // (int(self.fps) - self.sequenceNumber % int(self.fps)) * 360 / // int(self.fps)); CGContextRelease(context); CVPixelBufferUnlockBaseAddress(pxbuffer, 0); return pxbuffer; } - (void)fillFrame { if (CMSimpleQueueGetFullness(self.queue) >= 1.0) { return; } CVPixelBufferRef pixelBuffer = [self createPixelBufferWithTestAnimation]; uint64_t hostTime = clock_gettime_nsec_np(CLOCK_UPTIME_RAW); CMSampleTimingInfo timingInfo = CMSampleTimingInfoForTimestamp(hostTime, (uint32_t) self.fps, 1); OSStatus err = CMIOStreamClockPostTimingEvent(timingInfo.presentationTimeStamp, hostTime, true, self.clock); if (err != noErr) { DLog(@"CMIOStreamClockPostTimingEvent err %d", err); } CMFormatDescriptionRef format; CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault, pixelBuffer, &format); self.sequenceNumber = CMIOGetNextSequenceNumber(self.sequenceNumber); CMSampleBufferRef buffer; err = CMIOSampleBufferCreateForImageBuffer(kCFAllocatorDefault, pixelBuffer, format, &timingInfo, self.sequenceNumber, kCMIOSampleBufferNoDiscontinuities, &buffer); CFRelease(pixelBuffer); CFRelease(format); if (err != noErr) { DLog(@"CMIOSampleBufferCreateForImageBuffer err %d", err); } CMSimpleQueueEnqueue(self.queue, buffer); // Inform the clients that the queue has been altered if (self.alteredProc != NULL) { (self.alteredProc)(self.objectId, buffer, self.alteredRefCon); } } - (void)queuePixelBuffer:(CVPixelBufferRef)frame timestamp:(uint64_t)timestamp fpsNumerator:(uint32_t)fpsNumerator fpsDenominator:(uint32_t)fpsDenominator { if (CMSimpleQueueGetFullness(self.queue) >= 1.0) { DLog(@"Queue is full, bailing out"); return; } OSStatus err = noErr; CMSampleTimingInfo timingInfo = CMSampleTimingInfoForTimestamp(timestamp, fpsNumerator, fpsDenominator); err = CMIOStreamClockPostTimingEvent(timingInfo.presentationTimeStamp, clock_gettime_nsec_np(CLOCK_UPTIME_RAW), true, self.clock); if (err != noErr) { DLog(@"CMIOStreamClockPostTimingEvent err %d", err); } self.sequenceNumber = CMIOGetNextSequenceNumber(self.sequenceNumber); CMSampleBufferRef sampleBuffer; // Generate the video format description from that pixel buffer CMVideoFormatDescriptionRef format; err = CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault, frame, &format); if (err != noErr) { DLog(@"CMVideoFormatDescriptionCreateForImageBuffer err %d", err); return; } err = CMIOSampleBufferCreateForImageBuffer(kCFAllocatorDefault, frame, format, &timingInfo, self.sequenceNumber, kCMIOSampleBufferNoDiscontinuities, &sampleBuffer); CFRelease(format); if (err != noErr) { DLog(@"CMIOSampleBufferCreateForImageBuffer err %d", err); return; } err = CMSimpleQueueEnqueue(self.queue, sampleBuffer); if (err != noErr) { CFRelease(sampleBuffer); DLog(@"CMSimpleQueueEnqueue err %d", err); return; } // Inform the clients that the queue has been altered if (self.alteredProc != NULL) { (self.alteredProc)(self.objectId, sampleBuffer, self.alteredRefCon); } } - (CMVideoFormatDescriptionRef)getFormatDescription { CMVideoFormatDescriptionRef formatDescription; OSStatus err = CMVideoFormatDescriptionCreate(kCFAllocatorDefault, kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, (int32_t) self.testCardSize.width, (int32_t) self.testCardSize.height, NULL, &formatDescription); if (err != noErr) { DLog(@"Error %d from CMVideoFormatDescriptionCreate", err); } return formatDescription; } #pragma mark - CMIOObject - (UInt32)getPropertyDataSizeWithAddress:(CMIOObjectPropertyAddress)address qualifierDataSize:(UInt32)qualifierDataSize qualifierData:(nonnull const void *)qualifierData { switch (address.mSelector) { case kCMIOStreamPropertyInitialPresentationTimeStampForLinkedAndSyncedAudio: return sizeof(CMTime); case kCMIOStreamPropertyOutputBuffersNeededForThrottledPlayback: return sizeof(UInt32); case kCMIOObjectPropertyName: return sizeof(CFStringRef); case kCMIOObjectPropertyManufacturer: return sizeof(CFStringRef); case kCMIOObjectPropertyElementName: return sizeof(CFStringRef); case kCMIOObjectPropertyElementCategoryName: return sizeof(CFStringRef); case kCMIOObjectPropertyElementNumberName: return sizeof(CFStringRef); case kCMIOStreamPropertyDirection: return sizeof(UInt32); case kCMIOStreamPropertyTerminalType: return sizeof(UInt32); case kCMIOStreamPropertyStartingChannel: return sizeof(UInt32); case kCMIOStreamPropertyLatency: return sizeof(UInt32); case kCMIOStreamPropertyFormatDescriptions: return sizeof(CFArrayRef); case kCMIOStreamPropertyFormatDescription: return sizeof(CMFormatDescriptionRef); case kCMIOStreamPropertyFrameRateRanges: return sizeof(AudioValueRange); case kCMIOStreamPropertyFrameRate: case kCMIOStreamPropertyFrameRates: return sizeof(Float64); case kCMIOStreamPropertyMinimumFrameRate: return sizeof(Float64); case kCMIOStreamPropertyClock: return sizeof(CFTypeRef); default: return 0; }; } - (void)getPropertyDataWithAddress:(CMIOObjectPropertyAddress)address qualifierDataSize:(UInt32)qualifierDataSize qualifierData:(nonnull const void *)qualifierData dataSize:(UInt32)dataSize dataUsed:(nonnull UInt32 *)dataUsed data:(nonnull void *)data { switch (address.mSelector) { case kCMIOObjectPropertyName: *static_cast(data) = CFSTR("OBS Virtual Camera"); *dataUsed = sizeof(CFStringRef); break; case kCMIOObjectPropertyElementName: *static_cast(data) = CFSTR("OBS Virtual Camera Stream Element"); *dataUsed = sizeof(CFStringRef); break; case kCMIOObjectPropertyManufacturer: case kCMIOObjectPropertyElementCategoryName: case kCMIOObjectPropertyElementNumberName: case kCMIOStreamPropertyTerminalType: case kCMIOStreamPropertyStartingChannel: case kCMIOStreamPropertyLatency: case kCMIOStreamPropertyInitialPresentationTimeStampForLinkedAndSyncedAudio: case kCMIOStreamPropertyOutputBuffersNeededForThrottledPlayback: break; case kCMIOStreamPropertyDirection: *static_cast(data) = 1; *dataUsed = sizeof(UInt32); break; case kCMIOStreamPropertyFormatDescriptions: *static_cast(data) = (__bridge_retained CFArrayRef) [NSArray arrayWithObject:(__bridge_transfer NSObject *) [self getFormatDescription]]; *dataUsed = sizeof(CFArrayRef); break; case kCMIOStreamPropertyFormatDescription: *static_cast(data) = [self getFormatDescription]; *dataUsed = sizeof(CMVideoFormatDescriptionRef); break; case kCMIOStreamPropertyFrameRateRanges: AudioValueRange range; range.mMinimum = self.fps; range.mMaximum = self.fps; *static_cast(data) = range; *dataUsed = sizeof(AudioValueRange); break; case kCMIOStreamPropertyFrameRate: case kCMIOStreamPropertyFrameRates: *static_cast(data) = self.fps; *dataUsed = sizeof(Float64); break; case kCMIOStreamPropertyMinimumFrameRate: *static_cast(data) = self.fps; *dataUsed = sizeof(Float64); break; case kCMIOStreamPropertyClock: *static_cast(data) = self.clock; // This one was incredibly tricky and cost me many hours to find. It seems that DAL expects // the clock to be retained when returned. It's unclear why, and that seems inconsistent // with other properties that don't have the same behavior. But this is what Apple's sample // code does. // https://github.com/lvsti/CoreMediaIO-DAL-Example/blob/0392cb/Sources/Extras/CoreMediaIO/DeviceAbstractionLayer/Devices/DP/Properties/CMIO_DP_Property_Clock.cpp#L75 CFRetain(*static_cast(data)); *dataUsed = sizeof(CFTypeRef); break; default: *dataUsed = 0; }; } - (BOOL)hasPropertyWithAddress:(CMIOObjectPropertyAddress)address { switch (address.mSelector) { case kCMIOObjectPropertyName: case kCMIOObjectPropertyElementName: case kCMIOStreamPropertyFormatDescriptions: case kCMIOStreamPropertyFormatDescription: case kCMIOStreamPropertyFrameRateRanges: case kCMIOStreamPropertyFrameRate: case kCMIOStreamPropertyFrameRates: case kCMIOStreamPropertyMinimumFrameRate: case kCMIOStreamPropertyClock: return true; case kCMIOObjectPropertyManufacturer: case kCMIOObjectPropertyElementCategoryName: case kCMIOObjectPropertyElementNumberName: case kCMIOStreamPropertyDirection: case kCMIOStreamPropertyTerminalType: case kCMIOStreamPropertyStartingChannel: case kCMIOStreamPropertyLatency: case kCMIOStreamPropertyInitialPresentationTimeStampForLinkedAndSyncedAudio: case kCMIOStreamPropertyOutputBuffersNeededForThrottledPlayback: DLog(@"TODO: %@", [OBSDALObjectStore StringFromPropertySelector:address.mSelector]); return false; default: return false; }; } - (BOOL)isPropertySettableWithAddress:(CMIOObjectPropertyAddress)address { switch (address.mSelector) { case kCMIOStreamPropertyFormatDescription: case kCMIOStreamPropertyFrameRate: // Suppress error logs complaining about the application not being able to set the desired format or frame rate. return true; default: return false; } } - (void)setPropertyDataWithAddress:(CMIOObjectPropertyAddress)address qualifierDataSize:(UInt32)qualifierDataSize qualifierData:(nonnull const void *)qualifierData dataSize:(UInt32)dataSize data:(nonnull const void *)data {} @end