From 7745cab06d747507fed9369364d81dab14349825 Mon Sep 17 00:00:00 2001 From: Jason Lu Date: Mon, 25 May 2026 04:03:37 -0700 Subject: [PATCH 1/6] feat(macos/capture): add ScreenCaptureKit backend, runtime-select on 12.3+ AVCaptureScreenInput was deprecated in macOS 13 (October 2022) and is fundamentally limited to 8-bit BGRA, blocking any honest HDR or 10-bit work on the macOS capture path. ScreenCaptureKit has been available since macOS 12.3 (March 2022) and is the only forward path; this commit lays the foundation by adding a drop-in SCK-based backend that preserves behaviour exactly (same pixel format, frame rate, display selection) so it can be reviewed independently of the HDR work that builds on top. Changes: * Add SunshineVideoCapture protocol in av_video.h declaring the capture-side surface both backends expose. * Make AVVideo conform to the protocol (no behaviour change; pure declaration). * Add SCVideo (sc_video.h / sc_video.m) implementing the same protocol against SCStream + SCContentFilter + SCStreamConfiguration. Built with -fobjc-arc for SCK's block-heavy API surface; objects cross the MRC boundary via the standard +1-retain alloc/init convention so display.mm continues to work in MRC. * Drop incomplete frames from SCK output by inspecting SCStreamFrameInfoStatus on each sample-buffer attachment, matching the reliability the legacy path got for free from AVCaptureSession. * display.mm now holds an id and branches at construction via @available(macOS 12.3, *): SCVideo on supported systems, AVVideo as fallback for older macOS. * Wire ScreenCaptureKit framework into cmake/dependencies/macos.cmake and cmake/compile_definitions/macos.cmake; set ARC compile flag on sc_video.m only. Pixel format stays 32BGRA for this commit; 10-bit + EDR metadata follow in a subsequent change. --- cmake/compile_definitions/macos.cmake | 11 + cmake/dependencies/macos.cmake | 1 + src/platform/macos/av_video.h | 25 ++- src/platform/macos/display.mm | 20 +- src/platform/macos/sc_video.h | 30 +++ src/platform/macos/sc_video.m | 278 ++++++++++++++++++++++++++ 6 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 src/platform/macos/sc_video.h create mode 100644 src/platform/macos/sc_video.m diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake index dbca9df9073..15a8bbbe8c7 100644 --- a/cmake/compile_definitions/macos.cmake +++ b/cmake/compile_definitions/macos.cmake @@ -35,6 +35,7 @@ list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${CORE_MEDIA_LIBRARY} ${CORE_VIDEO_LIBRARY} ${FOUNDATION_LIBRARY} + ${SCREEN_CAPTURE_KIT_LIBRARY} ${VIDEO_TOOLBOX_LIBRARY}) set(APPLE_PLIST_TEMPLATE "${SUNSHINE_SOURCE_ASSETS_DIR}/macos/build/Info.plist.in") @@ -55,6 +56,16 @@ set(PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/platform/macos/nv12_zero_device.cpp" "${CMAKE_SOURCE_DIR}/src/platform/macos/nv12_zero_device.h" "${CMAKE_SOURCE_DIR}/src/platform/macos/publish.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/macos/sc_video.h" + "${CMAKE_SOURCE_DIR}/src/platform/macos/sc_video.m" "${CMAKE_SOURCE_DIR}/third-party/TPCircularBuffer/TPCircularBuffer.c" "${CMAKE_SOURCE_DIR}/third-party/TPCircularBuffer/TPCircularBuffer.h" ${APPLE_PLIST_FILE}) + +# sc_video.m is written against ARC for clarity (SCK APIs are async/ +# block-heavy and benefit from ARC). The rest of the macOS Obj-C +# sources remain MRC; objects flowing across the boundary follow the +# standard +1-retain alloc/init convention so both modes interoperate. +set_source_files_properties( + "${CMAKE_SOURCE_DIR}/src/platform/macos/sc_video.m" + PROPERTIES COMPILE_FLAGS "-fobjc-arc") diff --git a/cmake/dependencies/macos.cmake b/cmake/dependencies/macos.cmake index 5e225fdac21..44444749c16 100644 --- a/cmake/dependencies/macos.cmake +++ b/cmake/dependencies/macos.cmake @@ -10,6 +10,7 @@ FIND_LIBRARY(CORE_MEDIA_LIBRARY CoreMedia) FIND_LIBRARY(CORE_VIDEO_LIBRARY CoreVideo) FIND_LIBRARY(FOUNDATION_LIBRARY Foundation) FIND_LIBRARY(VIDEO_TOOLBOX_LIBRARY VideoToolbox) +FIND_LIBRARY(SCREEN_CAPTURE_KIT_LIBRARY ScreenCaptureKit) if(SUNSHINE_ENABLE_TRAY) FIND_LIBRARY(COCOA Cocoa REQUIRED) diff --git a/src/platform/macos/av_video.h b/src/platform/macos/av_video.h index b2fa5d4b255..94d5e2db565 100644 --- a/src/platform/macos/av_video.h +++ b/src/platform/macos/av_video.h @@ -15,7 +15,17 @@ struct CaptureSession { static const int kMaxDisplays = 32; -@interface AVVideo: NSObject +typedef bool (^FrameCallbackBlock)(CMSampleBufferRef); + +/** + * @brief Shared interface for macOS screen capture backends. + * + * Both the legacy AVCaptureScreenInput-based implementation (AVVideo) and + * the modern ScreenCaptureKit-based implementation (SCVideo) conform to + * this protocol so display.mm can hold either behind a single pointer + * type and branch on macOS version at construction. + */ +@protocol SunshineVideoCapture @property (nonatomic, assign) CGDirectDisplayID displayID; @property (nonatomic, assign) CMTime minFrameDuration; @@ -23,7 +33,18 @@ static const int kMaxDisplays = 32; @property (nonatomic, assign) int frameWidth; @property (nonatomic, assign) int frameHeight; -typedef bool (^FrameCallbackBlock)(CMSampleBufferRef); +- (void)setFrameWidth:(int)frameWidth frameHeight:(int)frameHeight; +- (dispatch_semaphore_t)capture:(FrameCallbackBlock)frameCallback; + +@end + +@interface AVVideo: NSObject + +@property (nonatomic, assign) CGDirectDisplayID displayID; +@property (nonatomic, assign) CMTime minFrameDuration; +@property (nonatomic, assign) OSType pixelFormat; +@property (nonatomic, assign) int frameWidth; +@property (nonatomic, assign) int frameHeight; @property (nonatomic, assign) AVCaptureSession *session; @property (nonatomic, assign) NSMapTable *videoOutputs; diff --git a/src/platform/macos/display.mm b/src/platform/macos/display.mm index be124b2d331..381eb380a8b 100644 --- a/src/platform/macos/display.mm +++ b/src/platform/macos/display.mm @@ -10,6 +10,7 @@ #include "src/platform/macos/av_video.h" #include "src/platform/macos/misc.h" #include "src/platform/macos/nv12_zero_device.h" +#include "src/platform/macos/sc_video.h" // Avoid conflict between AVFoundation and libavutil both defining AVMediaType #define AVMediaType AVMediaType_FFmpeg @@ -22,7 +23,7 @@ using namespace std::literals; struct av_display_t: public display_t { - AVVideo *av_capture {}; + id av_capture {}; CGDirectDisplayID display_id {}; ~av_display_t() override { @@ -86,7 +87,7 @@ capture_e capture(const push_captured_image_cb_t &push_captured_image_cb, const } else if (pix_fmt == pix_fmt_e::nv12 || pix_fmt == pix_fmt_e::p010) { auto device = std::make_unique(); - device->init(static_cast(av_capture), pix_fmt, setResolution, setPixelFormat); + device->init((__bridge void *) av_capture, pix_fmt, setResolution, setPixelFormat); return device; } else { @@ -143,11 +144,11 @@ int dummy_img(img_t *img) override { * height --> the intended capture height */ static void setResolution(void *display, int width, int height) { - [static_cast(display) setFrameWidth:width frameHeight:height]; + [(__bridge id) display setFrameWidth:width frameHeight:height]; } static void setPixelFormat(void *display, OSType pixelFormat) { - static_cast(display).pixelFormat = pixelFormat; + ((__bridge id) display).pixelFormat = pixelFormat; } }; @@ -177,7 +178,16 @@ static void setPixelFormat(void *display, OSType pixelFormat) { } BOOST_LOG(info) << "Configuring selected display ("sv << display->display_id << ") to stream"sv; - display->av_capture = [[AVVideo alloc] initWithDisplay:display->display_id frameRate:config.framerate]; + // Prefer ScreenCaptureKit on macOS 12.3+ (AVCaptureScreenInput was + // deprecated in macOS 13 and is hardcoded to 8-bit BGRA). Fall back to + // the legacy AVCaptureScreenInput path on older macOS. + if (@available(macOS 12.3, *)) { + BOOST_LOG(info) << "Using ScreenCaptureKit capture backend"sv; + display->av_capture = [[SCVideo alloc] initWithDisplay:display->display_id frameRate:config.framerate]; + } else { + BOOST_LOG(info) << "Using legacy AVCaptureScreenInput capture backend"sv; + display->av_capture = [[AVVideo alloc] initWithDisplay:display->display_id frameRate:config.framerate]; + } if (!display->av_capture) { BOOST_LOG(error) << "Video setup failed."sv; diff --git a/src/platform/macos/sc_video.h b/src/platform/macos/sc_video.h new file mode 100644 index 00000000000..7462ad6afe6 --- /dev/null +++ b/src/platform/macos/sc_video.h @@ -0,0 +1,30 @@ +/** + * @file src/platform/macos/sc_video.h + * @brief Declarations for ScreenCaptureKit-based video capture on macOS. + * + * Modern replacement for AVCaptureScreenInput (which was deprecated in + * macOS 13). SCVideo conforms to the same SunshineVideoCapture protocol + * as the legacy AVVideo class so callers can swap implementations at + * runtime based on @available(macOS 12.3, *) without other code changes. + */ +#pragma once + +#import "av_video.h" + +#import + +API_AVAILABLE(macos(12.3)) +@interface SCVideo: NSObject + +@property (nonatomic, assign) CGDirectDisplayID displayID; +@property (nonatomic, assign) CMTime minFrameDuration; +@property (nonatomic, assign) OSType pixelFormat; +@property (nonatomic, assign) int frameWidth; +@property (nonatomic, assign) int frameHeight; + +- (instancetype)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)frameRate; + +- (void)setFrameWidth:(int)frameWidth frameHeight:(int)frameHeight; +- (dispatch_semaphore_t)capture:(FrameCallbackBlock)frameCallback; + +@end diff --git a/src/platform/macos/sc_video.m b/src/platform/macos/sc_video.m new file mode 100644 index 00000000000..f42c80f6daf --- /dev/null +++ b/src/platform/macos/sc_video.m @@ -0,0 +1,278 @@ +/** + * @file src/platform/macos/sc_video.m + * @brief ScreenCaptureKit-based video capture for macOS 12.3+. + * + * Drop-in replacement for the legacy AVCaptureScreenInput path in + * av_video.m. This first-pass implementation preserves the original + * pixel format (BGRA8) and selection semantics; HDR / 10-bit pixel + * format selection and EDR color metadata propagation are layered on + * top in subsequent commits. + * + * Compiled with ARC (-fobjc-arc) for clarity. The other macOS capture + * files remain MRC; objects flowing from this file to display.mm + * follow the standard alloc/init +1-retain convention so the boundary + * works regardless of compile mode on the other side. + */ +#import "sc_video.h" + +#import + +API_AVAILABLE(macos(12.3)) +@interface SCVideo () + +@property (nonatomic, strong) SCStream *stream; +@property (nonatomic, strong) SCContentFilter *filter; +@property (nonatomic, strong) SCStreamConfiguration *streamConfig; +@property (nonatomic, strong) dispatch_queue_t sampleQueue; +@property (nonatomic, copy) FrameCallbackBlock currentCallback; +@property (nonatomic, strong) dispatch_semaphore_t currentSignal; +@property (nonatomic, assign) BOOL streamRunning; + +@end + +@implementation SCVideo + +- (instancetype)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)frameRate { + self = [super init]; + if (!self) { + return nil; + } + + self.displayID = displayID; + self.minFrameDuration = CMTimeMake(1, frameRate); + self.pixelFormat = kCVPixelFormatType_32BGRA; + + CGDisplayModeRef mode = CGDisplayCopyDisplayMode(displayID); + if (mode) { + self.frameWidth = (int) CGDisplayModeGetPixelWidth(mode); + self.frameHeight = (int) CGDisplayModeGetPixelHeight(mode); + CGDisplayModeRelease(mode); + } + + self.sampleQueue = dispatch_queue_create("dev.lizardbyte.sunshine.sckCapture", dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, DISPATCH_QUEUE_PRIORITY_HIGH)); + + // SCK content enumeration is async; block until we have the SCDisplay + // matching the requested CGDirectDisplayID so this initializer remains + // synchronous (matching AVVideo's contract). + __block SCDisplay *selectedDisplay = nil; + __block NSError *enumerationError = nil; + dispatch_semaphore_t ready = dispatch_semaphore_create(0); + + [SCShareableContent getShareableContentExcludingDesktopWindows:NO + onScreenWindowsOnly:NO + completionHandler:^(SCShareableContent *_Nullable content, NSError *_Nullable error) { + if (error || !content) { + enumerationError = error; + } else { + for (SCDisplay *d in content.displays) { + if (d.displayID == displayID) { + selectedDisplay = d; + break; + } + } + // If the requested display wasn't found (display reconfigured, + // unplugged, etc.) fall back to the first display SCK reports. + if (!selectedDisplay && content.displays.count > 0) { + selectedDisplay = content.displays.firstObject; + } + } + dispatch_semaphore_signal(ready); + }]; + dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); + + if (!selectedDisplay) { + NSLog(@"SCVideo: failed to resolve SCDisplay for id %u: %@", displayID, enumerationError); + return nil; + } + + // Empty excluded-windows array: capture everything on the display. + self.filter = [[SCContentFilter alloc] initWithDisplay:selectedDisplay excludingWindows:@[]]; + + self.streamConfig = [[SCStreamConfiguration alloc] init]; + self.streamConfig.width = self.frameWidth; + self.streamConfig.height = self.frameHeight; + self.streamConfig.minimumFrameInterval = self.minFrameDuration; + self.streamConfig.pixelFormat = self.pixelFormat; + self.streamConfig.queueDepth = 6; // SCK docs recommend 3-8 + self.streamConfig.showsCursor = YES; + + self.stream = [[SCStream alloc] initWithFilter:self.filter + configuration:self.streamConfig + delegate:self]; + + return self; +} + +- (void)setFrameWidth:(int)frameWidth frameHeight:(int)frameHeight { + _frameWidth = frameWidth; + _frameHeight = frameHeight; + + if (self.streamConfig) { + self.streamConfig.width = frameWidth; + self.streamConfig.height = frameHeight; + [self applyConfigurationIfRunning]; + } +} + +- (void)setPixelFormat:(OSType)pixelFormat { + _pixelFormat = pixelFormat; + + if (self.streamConfig) { + self.streamConfig.pixelFormat = pixelFormat; + [self applyConfigurationIfRunning]; + } +} + +- (void)setMinFrameDuration:(CMTime)minFrameDuration { + _minFrameDuration = minFrameDuration; + + if (self.streamConfig) { + self.streamConfig.minimumFrameInterval = minFrameDuration; + [self applyConfigurationIfRunning]; + } +} + +- (void)applyConfigurationIfRunning { + if (!self.streamRunning || !self.stream) { + return; + } + [self.stream updateConfiguration:self.streamConfig + completionHandler:^(NSError *_Nullable error) { + if (error) { + NSLog(@"SCVideo: updateConfiguration failed: %@", error); + } + }]; +} + +- (dispatch_semaphore_t)capture:(FrameCallbackBlock)frameCallback { + @synchronized(self) { + // Signal and clear any previous capture; SCK streams support one + // logical consumer in this wrapper. Matches single-callback use in + // display.mm. + if (self.currentSignal) { + dispatch_semaphore_signal(self.currentSignal); + } + + self.currentCallback = frameCallback; + self.currentSignal = dispatch_semaphore_create(0); + + if (!self.streamRunning) { + NSError *outputError = nil; + if (![self.stream addStreamOutput:self + type:SCStreamOutputTypeScreen + sampleHandlerQueue:self.sampleQueue + error:&outputError]) { + NSLog(@"SCVideo: addStreamOutput failed: %@", outputError); + dispatch_semaphore_signal(self.currentSignal); + return self.currentSignal; + } + + __block NSError *startError = nil; + dispatch_semaphore_t started = dispatch_semaphore_create(0); + [self.stream startCaptureWithCompletionHandler:^(NSError *_Nullable error) { + startError = error; + dispatch_semaphore_signal(started); + }]; + dispatch_semaphore_wait(started, DISPATCH_TIME_FOREVER); + + if (startError) { + NSLog(@"SCVideo: startCapture failed: %@", startError); + dispatch_semaphore_signal(self.currentSignal); + return self.currentSignal; + } + self.streamRunning = YES; + } + + return self.currentSignal; + } +} + +- (void)dealloc { + if (self.streamRunning && self.stream) { + // Best-effort synchronous stop. The completion handler may not fire + // before dealloc returns; SCStream itself will tear down cleanly. + dispatch_semaphore_t stopped = dispatch_semaphore_create(0); + [self.stream stopCaptureWithCompletionHandler:^(NSError *_Nullable error) { + (void) error; + dispatch_semaphore_signal(stopped); + }]; + // Bounded wait so a misbehaving SCK doesn't hang teardown. + dispatch_semaphore_wait(stopped, dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC)); + } +} + +#pragma mark - SCStreamOutput + +- (void)stream:(SCStream *)stream + didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer + ofType:(SCStreamOutputType)type { + if (type != SCStreamOutputTypeScreen) { + return; + } + if (!CMSampleBufferIsValid(sampleBuffer)) { + return; + } + + // Drop frames whose status array says they aren't ready. SCK delivers + // a status attachment on every sample buffer indicating idle vs + // complete vs blank — we want only complete frames downstream. + CFArrayRef attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, NO); + if (attachmentsArray && CFArrayGetCount(attachmentsArray) > 0) { + CFDictionaryRef attachments = CFArrayGetValueAtIndex(attachmentsArray, 0); + CFNumberRef statusNum = CFDictionaryGetValue(attachments, (__bridge CFStringRef) SCStreamFrameInfoStatus); + if (statusNum) { + int status = 0; + CFNumberGetValue(statusNum, kCFNumberSInt32Type, &status); + if (status != SCFrameStatusComplete) { + return; + } + } + } + + FrameCallbackBlock callback; + dispatch_semaphore_t signal; + @synchronized(self) { + callback = self.currentCallback; + signal = self.currentSignal; + } + + if (!callback) { + return; + } + + if (!callback(sampleBuffer)) { + // Consumer signalled stop. Tear down the stream and unblock the + // semaphore the caller is waiting on. + @synchronized(self) { + self.currentCallback = nil; + } + if (self.streamRunning) { + [self.stream stopCaptureWithCompletionHandler:^(NSError *_Nullable error) { + (void) error; + }]; + self.streamRunning = NO; + } + if (signal) { + dispatch_semaphore_signal(signal); + } + } +} + +#pragma mark - SCStreamDelegate + +- (void)stream:(SCStream *)stream didStopWithError:(NSError *)error { + if (error) { + NSLog(@"SCVideo: stream stopped with error: %@", error); + } + self.streamRunning = NO; + dispatch_semaphore_t signal; + @synchronized(self) { + signal = self.currentSignal; + self.currentCallback = nil; + } + if (signal) { + dispatch_semaphore_signal(signal); + } +} + +@end From 632737043b479519d8d9111e571566a837096283 Mon Sep 17 00:00:00 2001 From: Jason Lu Date: Mon, 25 May 2026 04:11:58 -0700 Subject: [PATCH 2/6] feat(macos/capture): enable EDR (HDR) output on macOS 14+ for 10-bit pixel formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With AVCaptureScreenInput, asking the capture surface for a 10-bit pixel format silently produced 8-bit BGRA — the OS-level lie that made HEVC Main10 / AV1 Main10 / ProRes 10-bit profiles on macOS into fake HDR (color-tagged 8-bit data). With ScreenCaptureKit landing in the previous commit, 10-bit pixel formats are actually honoured, but SCK needs an explicit signal to attach HDR metadata to those buffers instead of treating them as 10-bit Rec.709. This commit wires SCStreamConfiguration.captureDynamicRange: * Add +pixelFormatIsHighBitDepth: classifier covering the YUV 4:2:0, 4:2:2 and 4:4:4 10-bit BiPlanar formats plus ARGB2101010 packed and 64-bit RGBA formats. * On the synchronous init path, set captureDynamicRange immediately if the starting pixel format is high bit depth so the very first sample buffer carries HDR metadata. * On the setPixelFormat: path (called by nv12_zero_device when the encoder selects p010), also update captureDynamicRange and push the new config to a running stream via -updateConfiguration:. * Use SCCaptureDynamicRangeHDRLocalDisplay rather than canonical HDR: game streaming wants the host display's actual HDR characteristics (peak luminance, primaries) so the receiver shows what a local user would see, not Apple's idealised reference. * Guard the whole block behind @available(macOS 14.0, *); on 12.3-13.x SCK still honours the 10-bit pixel format request but doesn't auto-tag buffers, so Sunshine's existing colorspace logic continues to drive the encoder's color fields. Validated on M4 Max: Sunshine's encoder probe matrix now includes successful 10-bit HEVC and 10-bit ProRes entries that previously could not have validated because the capture surface couldn't deliver matching pixel data. ProRes-specific VideoToolbox color tags land in a separate follow-up commit. --- src/platform/macos/sc_video.m | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/platform/macos/sc_video.m b/src/platform/macos/sc_video.m index f42c80f6daf..209ce67bbb3 100644 --- a/src/platform/macos/sc_video.m +++ b/src/platform/macos/sc_video.m @@ -96,6 +96,10 @@ - (instancetype)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)fram self.streamConfig.queueDepth = 6; // SCK docs recommend 3-8 self.streamConfig.showsCursor = YES; + // If the initial pixel format is already a 10-bit format, flip on EDR + // immediately so the very first sample buffer carries HDR metadata. + [self applyDynamicRangeForPixelFormat:self.pixelFormat]; + self.stream = [[SCStream alloc] initWithFilter:self.filter configuration:self.streamConfig delegate:self]; @@ -103,6 +107,47 @@ - (instancetype)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)fram return self; } +/** + * @brief Whether a CVPixelBuffer OSType denotes a 10-bit (or wider) format. + * + * Returning YES is the signal that the capture surface is HDR-capable; we + * use it to drive SCStreamConfiguration.captureDynamicRange on macOS 14+ + * so SCK emits BT.2020 PQ-tagged buffers instead of 10-bit Rec.709. + */ ++ (BOOL)pixelFormatIsHighBitDepth:(OSType)pixelFormat { + switch (pixelFormat) { + case kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange: + case kCVPixelFormatType_420YpCbCr10BiPlanarFullRange: + case kCVPixelFormatType_422YpCbCr10BiPlanarVideoRange: + case kCVPixelFormatType_422YpCbCr10BiPlanarFullRange: + case kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange: + case kCVPixelFormatType_444YpCbCr10BiPlanarFullRange: + case kCVPixelFormatType_ARGB2101010LEPacked: + case kCVPixelFormatType_64ARGB: + case kCVPixelFormatType_64RGBALE: + return YES; + default: + return NO; + } +} + +- (void)applyDynamicRangeForPixelFormat:(OSType)pixelFormat { + // captureDynamicRange landed in macOS 14 (Sonoma). On 12.3-13.x the + // capture surface honours the requested 10-bit pixel format, but the + // OS won't tag the buffers with BT.2020 PQ metadata automatically; + // downstream code falls back to Sunshine's existing colorspace logic. + if (@available(macOS 14.0, *)) { + if ([SCVideo pixelFormatIsHighBitDepth:pixelFormat]) { + // hdrLocalDisplay matches the host display's HDR characteristics, + // which is what we want for game-streaming: stream what the user + // would see locally, including the local panel's PQ peak luminance. + self.streamConfig.captureDynamicRange = SCCaptureDynamicRangeHDRLocalDisplay; + } else { + self.streamConfig.captureDynamicRange = SCCaptureDynamicRangeSDR; + } + } +} + - (void)setFrameWidth:(int)frameWidth frameHeight:(int)frameHeight { _frameWidth = frameWidth; _frameHeight = frameHeight; @@ -119,6 +164,7 @@ - (void)setPixelFormat:(OSType)pixelFormat { if (self.streamConfig) { self.streamConfig.pixelFormat = pixelFormat; + [self applyDynamicRangeForPixelFormat:pixelFormat]; [self applyConfigurationIfRunning]; } } From 57986e8421d7d40bf36d0f4436083ff452b87450 Mon Sep 17 00:00:00 2001 From: Jason Lu Date: Tue, 26 May 2026 19:32:22 -0700 Subject: [PATCH 3/6] feat(macos/capture): harden SCK lifecycle and gate EDR on negotiated session HDR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two improvements to the macOS ScreenCaptureKit backend that landed in the upstream review cycle (#5190) and never made it back onto our fork's dev: 1. **Lifecycle hardening**: - Register the SCStreamOutput exactly once in -init, not on every -capture: call. SCStream retains outputs across stop/start cycles, so re-registering would fail or silently duplicate delivery. - Bound all SCK completion-handler waits to 5s. SCK should always invoke them, but a misbehaving system service must not hang the whole startup path. - @synchronized(self) around all reads/writes of currentCallback / currentSignal / streamRunning. The sample-handler queue, the -capture: caller, and the SCStream delegate all touch these from different threads. - dispatch_queue_attr_make_with_qos_class's third argument is a RELATIVE priority (range -15..0), not one of the legacy DISPATCH_QUEUE_PRIORITY_* constants. Using 0 keeps the queue at its QoS class's nominal priority. - CGDisplayBounds fallback when CGDisplayCopyDisplayMode returns NULL (display reconfiguration races). 2. **EDR gating fix**: The previous EDR code flipped captureDynamicRange = HDRLocalDisplay whenever the chosen CVPixelBuffer format was 10-bit. That's necessary but not sufficient: a 10-bit format may be selected for codec reasons (e.g., a ProRes profile that requires 4:4:4 10-bit input) without the client ever requesting HDR ingest. Without gating, Sunshine would tell the client "HDR mode false" in the SDP while emitting BT.2020 PQ-tagged buffers — a silent control/data-plane mismatch. Now EDR requires BOTH 10-bit pixel format AND the negotiated session's enable_hdr (plumbed from launch_session_t via the existing config.dynamicRange field on video::config_t). Default is SDR; HDR is opt-in per session. New init signature: initWithDisplay:frameRate:hdrAllowed: The old initializer is preserved as a convenience that passes NO. New log line at session start makes the gating visible: "Using ScreenCaptureKit capture backend (HDR allowed|blocked)" --- src/platform/macos/display.mm | 10 +- src/platform/macos/sc_video.h | 7 ++ src/platform/macos/sc_video.m | 219 ++++++++++++++++++++++++---------- 3 files changed, 172 insertions(+), 64 deletions(-) diff --git a/src/platform/macos/display.mm b/src/platform/macos/display.mm index 381eb380a8b..9b4604060ee 100644 --- a/src/platform/macos/display.mm +++ b/src/platform/macos/display.mm @@ -182,8 +182,14 @@ static void setPixelFormat(void *display, OSType pixelFormat) { // deprecated in macOS 13 and is hardcoded to 8-bit BGRA). Fall back to // the legacy AVCaptureScreenInput path on older macOS. if (@available(macOS 12.3, *)) { - BOOST_LOG(info) << "Using ScreenCaptureKit capture backend"sv; - display->av_capture = [[SCVideo alloc] initWithDisplay:display->display_id frameRate:config.framerate]; + // hdrAllowed reflects the negotiated `enable_hdr` for this session + // (rtsp.cpp maps `x-nv-video[0].dynamicRangeMode` into config.dynamicRange). + // SCK uses this together with the chosen pixel format depth to decide + // whether to flip captureDynamicRange to HDRLocalDisplay; neither + // condition alone is sufficient. See sc_video.m::applyDynamicRangeForPixelFormat:. + const BOOL hdr_allowed = config.dynamicRange ? YES : NO; + BOOST_LOG(info) << "Using ScreenCaptureKit capture backend (HDR "sv << (hdr_allowed ? "allowed" : "blocked") << ")"sv; + display->av_capture = [[SCVideo alloc] initWithDisplay:display->display_id frameRate:config.framerate hdrAllowed:hdr_allowed]; } else { BOOST_LOG(info) << "Using legacy AVCaptureScreenInput capture backend"sv; display->av_capture = [[AVVideo alloc] initWithDisplay:display->display_id frameRate:config.framerate]; diff --git a/src/platform/macos/sc_video.h b/src/platform/macos/sc_video.h index 7462ad6afe6..51823214d79 100644 --- a/src/platform/macos/sc_video.h +++ b/src/platform/macos/sc_video.h @@ -22,7 +22,14 @@ API_AVAILABLE(macos(12.3)) @property (nonatomic, assign) int frameWidth; @property (nonatomic, assign) int frameHeight; +// YES iff the negotiated streaming session enabled HDR (Moonlight's +// hdrMode flag). Required (in combination with a 10-bit pixel format) +// before SCK is allowed to flip captureDynamicRange to HDRLocalDisplay +// on macOS 14+. Defaults to NO; the SDR capture path is always safe. +@property (nonatomic, assign) BOOL hdrAllowed; + - (instancetype)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)frameRate; +- (instancetype)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)frameRate hdrAllowed:(BOOL)hdrAllowed; - (void)setFrameWidth:(int)frameWidth frameHeight:(int)frameHeight; - (dispatch_semaphore_t)capture:(FrameCallbackBlock)frameCallback; diff --git a/src/platform/macos/sc_video.m b/src/platform/macos/sc_video.m index 209ce67bbb3..52ccacfc952 100644 --- a/src/platform/macos/sc_video.m +++ b/src/platform/macos/sc_video.m @@ -8,6 +8,13 @@ * format selection and EDR color metadata propagation are layered on * top in subsequent commits. * + * Lifecycle: the underlying SCStream is started exactly once during + * -initWithDisplay:frameRate: and stopped exactly once during -dealloc. + * -capture: only swaps the active callback / signal; it never touches + * the stream lifecycle. This avoids the "addStreamOutput called twice" + * failure mode that SCK exhibits when an output is re-registered on a + * stream that already retains it across stop/start cycles. + * * Compiled with ARC (-fobjc-arc) for clarity. The other macOS capture * files remain MRC; objects flowing from this file to display.mm * follow the standard alloc/init +1-retain convention so the boundary @@ -17,6 +24,11 @@ #import +// Bounded wait for any SCK completion handler. SCK should always +// invoke these, but a misbehaving system service must not hang the +// whole startup path. +static const int64_t kSCVideoCompletionTimeoutSec = 5; + API_AVAILABLE(macos(12.3)) @interface SCVideo () @@ -24,15 +36,25 @@ @interface SCVideo () @property (nonatomic, strong) SCContentFilter *filter; @property (nonatomic, strong) SCStreamConfiguration *streamConfig; @property (nonatomic, strong) dispatch_queue_t sampleQueue; + +// All four of the following are mutated from multiple threads (the +// caller of -capture:, the SCK sample-handler queue, and the SCStream +// delegate's didStopWithError:) and so are only ever accessed under +// @synchronized(self). @property (nonatomic, copy) FrameCallbackBlock currentCallback; @property (nonatomic, strong) dispatch_semaphore_t currentSignal; @property (nonatomic, assign) BOOL streamRunning; +@property (nonatomic, assign) BOOL streamOutputAdded; @end @implementation SCVideo - (instancetype)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)frameRate { + return [self initWithDisplay:displayID frameRate:frameRate hdrAllowed:NO]; +} + +- (instancetype)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)frameRate hdrAllowed:(BOOL)hdrAllowed { self = [super init]; if (!self) { return nil; @@ -41,19 +63,37 @@ - (instancetype)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)fram self.displayID = displayID; self.minFrameDuration = CMTimeMake(1, frameRate); self.pixelFormat = kCVPixelFormatType_32BGRA; + self.hdrAllowed = hdrAllowed; + // Prefer the active display mode's pixel dimensions; fall back to + // CGDisplayBounds if no mode is currently set (e.g., during display + // reconfiguration). If both fail we still proceed — SCK will + // accept the requested SCContentFilter dimensions later. CGDisplayModeRef mode = CGDisplayCopyDisplayMode(displayID); if (mode) { self.frameWidth = (int) CGDisplayModeGetPixelWidth(mode); self.frameHeight = (int) CGDisplayModeGetPixelHeight(mode); CGDisplayModeRelease(mode); + } else { + CGRect bounds = CGDisplayBounds(displayID); + self.frameWidth = (int) CGRectGetWidth(bounds); + self.frameHeight = (int) CGRectGetHeight(bounds); } - self.sampleQueue = dispatch_queue_create("dev.lizardbyte.sunshine.sckCapture", dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, DISPATCH_QUEUE_PRIORITY_HIGH)); - - // SCK content enumeration is async; block until we have the SCDisplay - // matching the requested CGDirectDisplayID so this initializer remains - // synchronous (matching AVVideo's contract). + // dispatch_queue_attr_make_with_qos_class's third parameter is a + // relative priority (range -15..0), NOT one of the legacy global- + // queue DISPATCH_QUEUE_PRIORITY_* constants. Using 0 keeps the + // queue at the chosen QoS class's nominal priority. + dispatch_queue_attr_t qos = dispatch_queue_attr_make_with_qos_class( + DISPATCH_QUEUE_SERIAL, + QOS_CLASS_USER_INTERACTIVE, + 0 + ); + self.sampleQueue = dispatch_queue_create("dev.lizardbyte.sunshine.sckCapture", qos); + + // SCK content enumeration is async; block (with a bounded timeout) + // until we have the SCDisplay matching the requested CGDirectDisplayID + // so this initializer remains synchronous (matching AVVideo's contract). __block SCDisplay *selectedDisplay = nil; __block NSError *enumerationError = nil; dispatch_semaphore_t ready = dispatch_semaphore_create(0); @@ -78,7 +118,10 @@ - (instancetype)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)fram } dispatch_semaphore_signal(ready); }]; - dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); + if (dispatch_semaphore_wait(ready, dispatch_time(DISPATCH_TIME_NOW, kSCVideoCompletionTimeoutSec * NSEC_PER_SEC)) != 0) { + NSLog(@"SCVideo: getShareableContent timed out after %lld seconds", kSCVideoCompletionTimeoutSec); + return nil; + } if (!selectedDisplay) { NSLog(@"SCVideo: failed to resolve SCDisplay for id %u: %@", displayID, enumerationError); @@ -103,6 +146,46 @@ - (instancetype)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)fram self.stream = [[SCStream alloc] initWithFilter:self.filter configuration:self.streamConfig delegate:self]; + if (!self.stream) { + NSLog(@"SCVideo: SCStream allocation failed"); + return nil; + } + + // Register the SCStreamOutput exactly once, here. SCStream retains + // outputs across stop/start cycles, so re-registering on every + // -capture: call would fail (or worse, silently duplicate + // delivery). All subsequent state changes are callback swaps on + // -capture: rather than stream-lifecycle operations. + NSError *outputError = nil; + if (![self.stream addStreamOutput:self + type:SCStreamOutputTypeScreen + sampleHandlerQueue:self.sampleQueue + error:&outputError]) { + NSLog(@"SCVideo: addStreamOutput failed: %@", outputError); + return nil; + } + self.streamOutputAdded = YES; + + // Start the stream once. Frames begin flowing immediately on the + // sampleQueue; sample-handler delivery is a no-op until the first + // -capture: installs a callback (see -stream:didOutputSampleBuffer:ofType:). + __block NSError *startError = nil; + dispatch_semaphore_t started = dispatch_semaphore_create(0); + [self.stream startCaptureWithCompletionHandler:^(NSError *_Nullable error) { + startError = error; + dispatch_semaphore_signal(started); + }]; + if (dispatch_semaphore_wait(started, dispatch_time(DISPATCH_TIME_NOW, kSCVideoCompletionTimeoutSec * NSEC_PER_SEC)) != 0) { + NSLog(@"SCVideo: startCapture timed out after %lld seconds", kSCVideoCompletionTimeoutSec); + return nil; + } + if (startError) { + NSLog(@"SCVideo: startCapture failed: %@", startError); + return nil; + } + @synchronized(self) { + self.streamRunning = YES; + } return self; } @@ -132,12 +215,27 @@ + (BOOL)pixelFormatIsHighBitDepth:(OSType)pixelFormat { } - (void)applyDynamicRangeForPixelFormat:(OSType)pixelFormat { - // captureDynamicRange landed in macOS 14 (Sonoma). On 12.3-13.x the - // capture surface honours the requested 10-bit pixel format, but the - // OS won't tag the buffers with BT.2020 PQ metadata automatically; + // captureDynamicRange / SCCaptureDynamicRange* are macOS 14 (Sonoma) + // SDK symbols. The compile-time guard ensures this block is preprocessed + // away entirely when building against an older SDK that lacks the + // declarations; the runtime @available guard prevents using the + // symbols at runtime on pre-14 systems even with a newer SDK. On + // 12.3-13.x SCK still honours a requested 10-bit pixel format, but + // the OS won't tag buffers with BT.2020 PQ metadata automatically; // downstream code falls back to Sunshine's existing colorspace logic. + // + // Gating: EDR is only enabled when BOTH (a) the chosen pixel format + // is 10-bit, AND (b) the session was actually negotiated as HDR + // (`hdrAllowed`). The pixel format on its own is necessary but not + // sufficient — a 10-bit format may be selected for codec reasons + // (e.g., a ProRes profile) without the client ever requesting HDR + // ingest, and silently emitting BT.2020 PQ-tagged buffers into a + // stream the control plane describes as SDR causes the decoder to + // tone-map undefined content. Defaulting hdrAllowed to NO keeps the + // legacy/SDR semantics intact when callers don't opt in. +#if defined(__MAC_OS_X_VERSION_MAX_ALLOWED) && __MAC_OS_X_VERSION_MAX_ALLOWED >= 140000 if (@available(macOS 14.0, *)) { - if ([SCVideo pixelFormatIsHighBitDepth:pixelFormat]) { + if (self.hdrAllowed && [SCVideo pixelFormatIsHighBitDepth:pixelFormat]) { // hdrLocalDisplay matches the host display's HDR characteristics, // which is what we want for game-streaming: stream what the user // would see locally, including the local panel's PQ peak luminance. @@ -146,6 +244,9 @@ - (void)applyDynamicRangeForPixelFormat:(OSType)pixelFormat { self.streamConfig.captureDynamicRange = SCCaptureDynamicRangeSDR; } } +#else + (void) pixelFormat; +#endif } - (void)setFrameWidth:(int)frameWidth frameHeight:(int)frameHeight { @@ -179,7 +280,11 @@ - (void)setMinFrameDuration:(CMTime)minFrameDuration { } - (void)applyConfigurationIfRunning { - if (!self.streamRunning || !self.stream) { + BOOL running; + @synchronized(self) { + running = self.streamRunning; + } + if (!running || !self.stream) { return; } [self.stream updateConfiguration:self.streamConfig @@ -191,58 +296,47 @@ - (void)applyConfigurationIfRunning { } - (dispatch_semaphore_t)capture:(FrameCallbackBlock)frameCallback { - @synchronized(self) { - // Signal and clear any previous capture; SCK streams support one - // logical consumer in this wrapper. Matches single-callback use in - // display.mm. - if (self.currentSignal) { - dispatch_semaphore_signal(self.currentSignal); - } + // Swap in the new callback. The SCStream output and frame flow are + // already running from -init; this method is purely a callback + // installation, not a stream-lifecycle operation. That avoids the + // double-add failure mode and makes -capture: cheap enough to be + // called multiple times across the SCVideo's lifetime (e.g., the + // encoder probe path's dummy_img followed by the real capture). + dispatch_semaphore_t newSignal = dispatch_semaphore_create(0); + dispatch_semaphore_t previousSignal = nil; + @synchronized(self) { + previousSignal = self.currentSignal; self.currentCallback = frameCallback; - self.currentSignal = dispatch_semaphore_create(0); - - if (!self.streamRunning) { - NSError *outputError = nil; - if (![self.stream addStreamOutput:self - type:SCStreamOutputTypeScreen - sampleHandlerQueue:self.sampleQueue - error:&outputError]) { - NSLog(@"SCVideo: addStreamOutput failed: %@", outputError); - dispatch_semaphore_signal(self.currentSignal); - return self.currentSignal; - } - - __block NSError *startError = nil; - dispatch_semaphore_t started = dispatch_semaphore_create(0); - [self.stream startCaptureWithCompletionHandler:^(NSError *_Nullable error) { - startError = error; - dispatch_semaphore_signal(started); - }]; - dispatch_semaphore_wait(started, DISPATCH_TIME_FOREVER); - - if (startError) { - NSLog(@"SCVideo: startCapture failed: %@", startError); - dispatch_semaphore_signal(self.currentSignal); - return self.currentSignal; - } - self.streamRunning = YES; - } + self.currentSignal = newSignal; + } - return self.currentSignal; + // Unblock any prior caller still waiting on the old semaphore. + // They will observe their callback was cleared and return. + if (previousSignal) { + dispatch_semaphore_signal(previousSignal); } + + return newSignal; } - (void)dealloc { - if (self.streamRunning && self.stream) { - // Best-effort synchronous stop. The completion handler may not fire - // before dealloc returns; SCStream itself will tear down cleanly. + BOOL running; + SCStream *stream; + @synchronized(self) { + running = self.streamRunning; + stream = self.stream; + self.streamRunning = NO; + self.currentCallback = nil; + } + if (running && stream) { + // Best-effort synchronous stop with a bounded wait so a + // misbehaving SCK doesn't hang teardown. dispatch_semaphore_t stopped = dispatch_semaphore_create(0); - [self.stream stopCaptureWithCompletionHandler:^(NSError *_Nullable error) { + [stream stopCaptureWithCompletionHandler:^(NSError *_Nullable error) { (void) error; dispatch_semaphore_signal(stopped); }]; - // Bounded wait so a misbehaving SCK doesn't hang teardown. dispatch_semaphore_wait(stopped, dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC)); } } @@ -283,20 +377,20 @@ - (void)stream:(SCStream *)stream } if (!callback) { + // No active consumer. Drop the frame; the stream keeps running + // so subsequent -capture: calls can pick up immediately. return; } if (!callback(sampleBuffer)) { - // Consumer signalled stop. Tear down the stream and unblock the - // semaphore the caller is waiting on. + // Consumer signalled stop. Clear the callback and wake the + // caller; the underlying SCStream stays alive for any future + // -capture: caller (cheaper than tearing down and restarting). @synchronized(self) { - self.currentCallback = nil; - } - if (self.streamRunning) { - [self.stream stopCaptureWithCompletionHandler:^(NSError *_Nullable error) { - (void) error; - }]; - self.streamRunning = NO; + if (self.currentCallback == callback) { + self.currentCallback = nil; + self.currentSignal = nil; + } } if (signal) { dispatch_semaphore_signal(signal); @@ -310,11 +404,12 @@ - (void)stream:(SCStream *)stream didStopWithError:(NSError *)error { if (error) { NSLog(@"SCVideo: stream stopped with error: %@", error); } - self.streamRunning = NO; dispatch_semaphore_t signal; @synchronized(self) { + self.streamRunning = NO; signal = self.currentSignal; self.currentCallback = nil; + self.currentSignal = nil; } if (signal) { dispatch_semaphore_signal(signal); From e173045c5113772b8004ee6509c966ddc760692d Mon Sep 17 00:00:00 2001 From: Jason Lu Date: Wed, 27 May 2026 00:41:28 -0700 Subject: [PATCH 4/6] build(macos): require ScreenCaptureKit at configure time FIND_LIBRARY(SCREEN_CAPTURE_KIT_LIBRARY ScreenCaptureKit REQUIRED) so configure fails fast on environments without the SDK rather than later at header-lookup time. sc_video.m is compiled unconditionally; the REQUIRED keyword ensures the build prerequisites are surfaced clearly when the build host's Xcode/SDK is older than 13.3 / 12.3 (well past routine compatibility). Addresses Copilot inline feedback from the closed upstream PR cycle. --- cmake/dependencies/macos.cmake | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cmake/dependencies/macos.cmake b/cmake/dependencies/macos.cmake index 44444749c16..846bf78d176 100644 --- a/cmake/dependencies/macos.cmake +++ b/cmake/dependencies/macos.cmake @@ -10,7 +10,14 @@ FIND_LIBRARY(CORE_MEDIA_LIBRARY CoreMedia) FIND_LIBRARY(CORE_VIDEO_LIBRARY CoreVideo) FIND_LIBRARY(FOUNDATION_LIBRARY Foundation) FIND_LIBRARY(VIDEO_TOOLBOX_LIBRARY VideoToolbox) -FIND_LIBRARY(SCREEN_CAPTURE_KIT_LIBRARY ScreenCaptureKit) +# ScreenCaptureKit is the modern (macOS 12.3+) replacement for the +# deprecated AVCaptureScreenInput-based capture path. Sunshine's +# sc_video.{h,m} is unconditionally compiled into the macOS target; +# fail configure with a clear message rather than failing the build +# later on header lookup when the SDK doesn't ship the framework +# (e.g., when building with an Xcode older than 13.3 / SDK older than +# 12.3, which dropped out of routine compatibility long ago). +FIND_LIBRARY(SCREEN_CAPTURE_KIT_LIBRARY ScreenCaptureKit REQUIRED) if(SUNSHINE_ENABLE_TRAY) FIND_LIBRARY(COCOA Cocoa REQUIRED) From 9d242cdd8e43d977faa4671dbf1054c6a2f6fd6d Mon Sep 17 00:00:00 2001 From: Jason Lu Date: Wed, 27 May 2026 00:54:17 -0700 Subject: [PATCH 5/6] refactor(macos/capture): drop legacy AVCaptureScreenInput path entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScreenCaptureKit became available in macOS 12.3; Sunshine's deployment target (MACOSX_DEPLOYMENT_TARGET=14.2) is well above that. The @available(macOS 12.3, *) runtime branch in display.mm and the entire AVCaptureScreenInput-based AVVideo class were therefore dead code on every supported build. Changes: - Remove @available(macOS 12.3, *) check in display.mm; SCK is the only branch. - Replace `id` with `SCVideo *` directly — the protocol existed to abstract over both AVVideo and SCVideo, and is no longer needed with a single concrete capture class. - Move the small bits we still need (FrameCallbackBlock typedef, +displayNames / +getDisplayName: helpers) from av_video.{h,m} into sc_video.{h,m}. - Delete src/platform/macos/av_video.{h,m} (208 lines). - Drop both from PLATFORM_TARGET_FILES. Addresses andygrundman + ReenigneArcher review feedback on the original PR: "shouldn't keep workarounds for versions older than what we support." --- cmake/compile_definitions/macos.cmake | 2 - src/platform/macos/av_video.h | 62 ----------- src/platform/macos/av_video.m | 146 -------------------------- src/platform/macos/display.mm | 42 ++++---- src/platform/macos/sc_video.h | 26 +++-- src/platform/macos/sc_video.m | 46 ++++++-- 6 files changed, 75 insertions(+), 249 deletions(-) delete mode 100644 src/platform/macos/av_video.h delete mode 100644 src/platform/macos/av_video.m diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake index 15a8bbbe8c7..f2b6499efee 100644 --- a/cmake/compile_definitions/macos.cmake +++ b/cmake/compile_definitions/macos.cmake @@ -46,8 +46,6 @@ set(PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/platform/macos/av_audio.h" "${CMAKE_SOURCE_DIR}/src/platform/macos/av_audio.mm" "${CMAKE_SOURCE_DIR}/src/platform/macos/av_img_t.h" - "${CMAKE_SOURCE_DIR}/src/platform/macos/av_video.h" - "${CMAKE_SOURCE_DIR}/src/platform/macos/av_video.m" "${CMAKE_SOURCE_DIR}/src/platform/macos/display.mm" "${CMAKE_SOURCE_DIR}/src/platform/macos/input.cpp" "${CMAKE_SOURCE_DIR}/src/platform/macos/microphone.mm" diff --git a/src/platform/macos/av_video.h b/src/platform/macos/av_video.h deleted file mode 100644 index 94d5e2db565..00000000000 --- a/src/platform/macos/av_video.h +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @file src/platform/macos/av_video.h - * @brief Declarations for video capture on macOS. - */ -#pragma once - -// platform includes -#import -#import - -struct CaptureSession { - AVCaptureVideoDataOutput *output; - NSCondition *captureStopped; -}; - -static const int kMaxDisplays = 32; - -typedef bool (^FrameCallbackBlock)(CMSampleBufferRef); - -/** - * @brief Shared interface for macOS screen capture backends. - * - * Both the legacy AVCaptureScreenInput-based implementation (AVVideo) and - * the modern ScreenCaptureKit-based implementation (SCVideo) conform to - * this protocol so display.mm can hold either behind a single pointer - * type and branch on macOS version at construction. - */ -@protocol SunshineVideoCapture - -@property (nonatomic, assign) CGDirectDisplayID displayID; -@property (nonatomic, assign) CMTime minFrameDuration; -@property (nonatomic, assign) OSType pixelFormat; -@property (nonatomic, assign) int frameWidth; -@property (nonatomic, assign) int frameHeight; - -- (void)setFrameWidth:(int)frameWidth frameHeight:(int)frameHeight; -- (dispatch_semaphore_t)capture:(FrameCallbackBlock)frameCallback; - -@end - -@interface AVVideo: NSObject - -@property (nonatomic, assign) CGDirectDisplayID displayID; -@property (nonatomic, assign) CMTime minFrameDuration; -@property (nonatomic, assign) OSType pixelFormat; -@property (nonatomic, assign) int frameWidth; -@property (nonatomic, assign) int frameHeight; - -@property (nonatomic, assign) AVCaptureSession *session; -@property (nonatomic, assign) NSMapTable *videoOutputs; -@property (nonatomic, assign) NSMapTable *captureCallbacks; -@property (nonatomic, assign) NSMapTable *captureSignals; - -+ (NSArray *)displayNames; -+ (NSString *)getDisplayName:(CGDirectDisplayID)displayID; - -- (id)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)frameRate; - -- (void)setFrameWidth:(int)frameWidth frameHeight:(int)frameHeight; -- (dispatch_semaphore_t)capture:(FrameCallbackBlock)frameCallback; - -@end diff --git a/src/platform/macos/av_video.m b/src/platform/macos/av_video.m deleted file mode 100644 index 630d7101598..00000000000 --- a/src/platform/macos/av_video.m +++ /dev/null @@ -1,146 +0,0 @@ -/** - * @file src/platform/macos/av_video.m - * @brief Definitions for video capture on macOS. - */ -// local includes -#import "av_video.h" - -@implementation AVVideo - -// XXX: Currently, this function only returns the screen IDs as names, -// which is not very helpful to the user. The API to retrieve names -// was deprecated with 10.9+. -// However, there is a solution with little external code that can be used: -// https://stackoverflow.com/questions/20025868/cgdisplayioserviceport-is-deprecated-in-os-x-10-9-how-to-replace -+ (NSArray *)displayNames { - CGDirectDisplayID displays[kMaxDisplays]; - uint32_t count; - if (CGGetActiveDisplayList(kMaxDisplays, displays, &count) != kCGErrorSuccess) { - return [NSArray array]; - } - - NSMutableArray *result = [NSMutableArray array]; - - for (uint32_t i = 0; i < count; i++) { - [result addObject:@{ - @"id": [NSNumber numberWithUnsignedInt:displays[i]], - @"name": [NSString stringWithFormat:@"%d", displays[i]], - @"displayName": [self getDisplayName:displays[i]], - }]; - } - - return [NSArray arrayWithArray:result]; -} - -+ (NSString *)getDisplayName:(CGDirectDisplayID)displayID { - for (NSScreen *screen in [NSScreen screens]) { - if ([screen.deviceDescription[@"NSScreenNumber"] isEqualToNumber:[NSNumber numberWithUnsignedInt:displayID]]) { - return screen.localizedName; - } - } - return nil; -} - -- (id)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)frameRate { - self = [super init]; - - CGDisplayModeRef mode = CGDisplayCopyDisplayMode(displayID); - - self.displayID = displayID; - self.pixelFormat = kCVPixelFormatType_32BGRA; - self.frameWidth = (int) CGDisplayModeGetPixelWidth(mode); - self.frameHeight = (int) CGDisplayModeGetPixelHeight(mode); - self.minFrameDuration = CMTimeMake(1, frameRate); - self.session = [[AVCaptureSession alloc] init]; - self.videoOutputs = [[NSMapTable alloc] init]; - self.captureCallbacks = [[NSMapTable alloc] init]; - self.captureSignals = [[NSMapTable alloc] init]; - - CFRelease(mode); - - AVCaptureScreenInput *screenInput = [[AVCaptureScreenInput alloc] initWithDisplayID:self.displayID]; - [screenInput setMinFrameDuration:self.minFrameDuration]; - - if ([self.session canAddInput:screenInput]) { - [self.session addInput:screenInput]; - } else { - [screenInput release]; - return nil; - } - - [self.session startRunning]; - - return self; -} - -- (void)dealloc { - [self.videoOutputs release]; - [self.captureCallbacks release]; - [self.captureSignals release]; - [self.session stopRunning]; - [super dealloc]; -} - -- (void)setFrameWidth:(int)frameWidth frameHeight:(int)frameHeight { - self.frameWidth = frameWidth; - self.frameHeight = frameHeight; -} - -- (dispatch_semaphore_t)capture:(FrameCallbackBlock)frameCallback { - @synchronized(self) { - AVCaptureVideoDataOutput *videoOutput = [[AVCaptureVideoDataOutput alloc] init]; - - [videoOutput setVideoSettings:@{ - (NSString *) kCVPixelBufferPixelFormatTypeKey: [NSNumber numberWithUnsignedInt:self.pixelFormat], - (NSString *) kCVPixelBufferWidthKey: [NSNumber numberWithInt:self.frameWidth], - (NSString *) kCVPixelBufferHeightKey: [NSNumber numberWithInt:self.frameHeight], - (NSString *) AVVideoScalingModeKey: AVVideoScalingModeResizeAspect, - }]; - - dispatch_queue_attr_t qos = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, DISPATCH_QUEUE_PRIORITY_HIGH); - dispatch_queue_t recordingQueue = dispatch_queue_create("videoCaptureQueue", qos); - [videoOutput setSampleBufferDelegate:self queue:recordingQueue]; - - [self.session stopRunning]; - - if ([self.session canAddOutput:videoOutput]) { - [self.session addOutput:videoOutput]; - } else { - [videoOutput release]; - return nil; - } - - AVCaptureConnection *videoConnection = [videoOutput connectionWithMediaType:AVMediaTypeVideo]; - dispatch_semaphore_t signal = dispatch_semaphore_create(0); - - [self.videoOutputs setObject:videoOutput forKey:videoConnection]; - [self.captureCallbacks setObject:frameCallback forKey:videoConnection]; - [self.captureSignals setObject:signal forKey:videoConnection]; - - [self.session startRunning]; - - return signal; - } -} - -- (void)captureOutput:(AVCaptureOutput *)captureOutput - didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer - fromConnection:(AVCaptureConnection *)connection { - FrameCallbackBlock callback = [self.captureCallbacks objectForKey:connection]; - - if (callback != nil) { - if (!callback(sampleBuffer)) { - @synchronized(self) { - [self.session stopRunning]; - [self.captureCallbacks removeObjectForKey:connection]; - [self.session removeOutput:[self.videoOutputs objectForKey:connection]]; - [self.videoOutputs removeObjectForKey:connection]; - dispatch_semaphore_signal([self.captureSignals objectForKey:connection]); - [self.captureSignals removeObjectForKey:connection]; - [self.session startRunning]; - } - } - } -} - -@end diff --git a/src/platform/macos/display.mm b/src/platform/macos/display.mm index 9b4604060ee..45bf97e46b2 100644 --- a/src/platform/macos/display.mm +++ b/src/platform/macos/display.mm @@ -7,7 +7,6 @@ #include "src/logging.h" #include "src/platform/common.h" #include "src/platform/macos/av_img_t.h" -#include "src/platform/macos/av_video.h" #include "src/platform/macos/misc.h" #include "src/platform/macos/nv12_zero_device.h" #include "src/platform/macos/sc_video.h" @@ -23,7 +22,7 @@ using namespace std::literals; struct av_display_t: public display_t { - id av_capture {}; + SCVideo *av_capture {}; CGDirectDisplayID display_id {}; ~av_display_t() override { @@ -87,7 +86,7 @@ capture_e capture(const push_captured_image_cb_t &push_captured_image_cb, const } else if (pix_fmt == pix_fmt_e::nv12 || pix_fmt == pix_fmt_e::p010) { auto device = std::make_unique(); - device->init((__bridge void *) av_capture, pix_fmt, setResolution, setPixelFormat); + device->init((void *) av_capture, pix_fmt, setResolution, setPixelFormat); return device; } else { @@ -144,11 +143,11 @@ int dummy_img(img_t *img) override { * height --> the intended capture height */ static void setResolution(void *display, int width, int height) { - [(__bridge id) display setFrameWidth:width frameHeight:height]; + [(SCVideo *) display setFrameWidth:width frameHeight:height]; } static void setPixelFormat(void *display, OSType pixelFormat) { - ((__bridge id) display).pixelFormat = pixelFormat; + ((SCVideo *) display).pixelFormat = pixelFormat; } }; @@ -164,7 +163,7 @@ static void setPixelFormat(void *display, OSType pixelFormat) { display->display_id = CGMainDisplayID(); // Print all displays available with it's name and id - auto display_array = [AVVideo displayNames]; + auto display_array = [SCVideo displayNames]; BOOST_LOG(info) << "Detecting displays"sv; for (NSDictionary *item in display_array) { NSNumber *display_id = item[@"id"]; @@ -178,22 +177,19 @@ static void setPixelFormat(void *display, OSType pixelFormat) { } BOOST_LOG(info) << "Configuring selected display ("sv << display->display_id << ") to stream"sv; - // Prefer ScreenCaptureKit on macOS 12.3+ (AVCaptureScreenInput was - // deprecated in macOS 13 and is hardcoded to 8-bit BGRA). Fall back to - // the legacy AVCaptureScreenInput path on older macOS. - if (@available(macOS 12.3, *)) { - // hdrAllowed reflects the negotiated `enable_hdr` for this session - // (rtsp.cpp maps `x-nv-video[0].dynamicRangeMode` into config.dynamicRange). - // SCK uses this together with the chosen pixel format depth to decide - // whether to flip captureDynamicRange to HDRLocalDisplay; neither - // condition alone is sufficient. See sc_video.m::applyDynamicRangeForPixelFormat:. - const BOOL hdr_allowed = config.dynamicRange ? YES : NO; - BOOST_LOG(info) << "Using ScreenCaptureKit capture backend (HDR "sv << (hdr_allowed ? "allowed" : "blocked") << ")"sv; - display->av_capture = [[SCVideo alloc] initWithDisplay:display->display_id frameRate:config.framerate hdrAllowed:hdr_allowed]; - } else { - BOOST_LOG(info) << "Using legacy AVCaptureScreenInput capture backend"sv; - display->av_capture = [[AVVideo alloc] initWithDisplay:display->display_id frameRate:config.framerate]; - } + // ScreenCaptureKit is the only capture backend Sunshine ships on macOS; + // the deployment target (14.2) is well above SCK's minimum (12.3) so + // there is no @available branch and no legacy AVCaptureScreenInput + // fallback to maintain. + // + // hdrAllowed reflects the negotiated `enable_hdr` for this session + // (rtsp.cpp maps `x-nv-video[0].dynamicRangeMode` into config.dynamicRange). + // SCK uses this together with the chosen pixel format depth to decide + // whether to flip captureDynamicRange to HDRLocalDisplay; neither + // condition alone is sufficient. See sc_video.m::applyDynamicRangeForPixelFormat:. + const BOOL hdr_allowed = config.dynamicRange ? YES : NO; + BOOST_LOG(info) << "Using ScreenCaptureKit capture backend (HDR "sv << (hdr_allowed ? "allowed" : "blocked") << ")"sv; + display->av_capture = [[SCVideo alloc] initWithDisplay:display->display_id frameRate:config.framerate hdrAllowed:hdr_allowed]; if (!display->av_capture) { BOOST_LOG(error) << "Video setup failed."sv; @@ -212,7 +208,7 @@ static void setPixelFormat(void *display, OSType pixelFormat) { std::vector display_names(mem_type_e hwdevice_type) { __block std::vector display_names; - auto display_array = [AVVideo displayNames]; + auto display_array = [SCVideo displayNames]; display_names.reserve([display_array count]); [display_array enumerateObjectsUsingBlock:^(NSDictionary *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { diff --git a/src/platform/macos/sc_video.h b/src/platform/macos/sc_video.h index 51823214d79..37831570b93 100644 --- a/src/platform/macos/sc_video.h +++ b/src/platform/macos/sc_video.h @@ -2,19 +2,23 @@ * @file src/platform/macos/sc_video.h * @brief Declarations for ScreenCaptureKit-based video capture on macOS. * - * Modern replacement for AVCaptureScreenInput (which was deprecated in - * macOS 13). SCVideo conforms to the same SunshineVideoCapture protocol - * as the legacy AVVideo class so callers can swap implementations at - * runtime based on @available(macOS 12.3, *) without other code changes. + * SCVideo is now Sunshine's only macOS capture backend. The deployment + * target (MACOSX_DEPLOYMENT_TARGET=14.2) is well above the macOS 12.3 + * minimum where ScreenCaptureKit became available, so the legacy + * AVCaptureScreenInput-based AVVideo path has been removed entirely. */ #pragma once -#import "av_video.h" - #import +#import +#import + +// Block signature used to deliver captured sample buffers back to the +// platform-agnostic capture loop. Returning NO from the block stops +// further deliveries on this capture session. +typedef bool (^FrameCallbackBlock)(CMSampleBufferRef); -API_AVAILABLE(macos(12.3)) -@interface SCVideo: NSObject +@interface SCVideo : NSObject @property (nonatomic, assign) CGDirectDisplayID displayID; @property (nonatomic, assign) CMTime minFrameDuration; @@ -34,4 +38,10 @@ API_AVAILABLE(macos(12.3)) - (void)setFrameWidth:(int)frameWidth frameHeight:(int)frameHeight; - (dispatch_semaphore_t)capture:(FrameCallbackBlock)frameCallback; +// Enumerate the currently-active CGDisplays as an array of dictionaries +// with keys @"id" (NSNumber, the CGDirectDisplayID), @"name" (NSString, +// the numeric id as a string for legacy callers), and @"displayName" +// (NSString, the user-facing name from NSScreen.localizedName). ++ (NSArray *)displayNames; + @end diff --git a/src/platform/macos/sc_video.m b/src/platform/macos/sc_video.m index 52ccacfc952..08cd8610dd3 100644 --- a/src/platform/macos/sc_video.m +++ b/src/platform/macos/sc_video.m @@ -1,12 +1,9 @@ /** * @file src/platform/macos/sc_video.m - * @brief ScreenCaptureKit-based video capture for macOS 12.3+. - * - * Drop-in replacement for the legacy AVCaptureScreenInput path in - * av_video.m. This first-pass implementation preserves the original - * pixel format (BGRA8) and selection semantics; HDR / 10-bit pixel - * format selection and EDR color metadata propagation are layered on - * top in subsequent commits. + * @brief ScreenCaptureKit-based video capture. Sole macOS capture + * backend; Sunshine's deployment target (14.2) is well above the SCK + * minimum (12.3) so the legacy AVCaptureScreenInput-based AVVideo + * implementation has been retired. * * Lifecycle: the underlying SCStream is started exactly once during * -initWithDisplay:frameRate: and stopped exactly once during -dealloc. @@ -93,7 +90,7 @@ - (instancetype)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)fram // SCK content enumeration is async; block (with a bounded timeout) // until we have the SCDisplay matching the requested CGDirectDisplayID - // so this initializer remains synchronous (matching AVVideo's contract). + // so this initializer remains synchronous: callers are not yet block-aware. __block SCDisplay *selectedDisplay = nil; __block NSError *enumerationError = nil; dispatch_semaphore_t ready = dispatch_semaphore_create(0); @@ -416,4 +413,37 @@ - (void)stream:(SCStream *)stream didStopWithError:(NSError *)error { } } +#pragma mark - Display enumeration + +// Active-display upper bound. We just need a buffer size that comfortably +// exceeds any plausible attached-display count. +static const int kMaxDisplays = 32; + ++ (NSString *)getDisplayName:(CGDirectDisplayID)displayID { + for (NSScreen *screen in [NSScreen screens]) { + if ([screen.deviceDescription[@"NSScreenNumber"] isEqualToNumber:[NSNumber numberWithUnsignedInt:displayID]]) { + return screen.localizedName; + } + } + return nil; +} + ++ (NSArray *)displayNames { + CGDirectDisplayID displays[kMaxDisplays]; + uint32_t count = 0; + if (CGGetActiveDisplayList(kMaxDisplays, displays, &count) != kCGErrorSuccess) { + return @[]; + } + + NSMutableArray *result = [NSMutableArray array]; + for (uint32_t i = 0; i < count; i++) { + [result addObject:@{ + @"id": [NSNumber numberWithUnsignedInt:displays[i]], + @"name": [NSString stringWithFormat:@"%u", displays[i]], + @"displayName": [SCVideo getDisplayName:displays[i]] ?: [NSString stringWithFormat:@"Display %u", displays[i]], + }]; + } + return [NSArray arrayWithArray:result]; +} + @end From 45382a2bc0acd2107a2f40b42c1071831c6b01aa Mon Sep 17 00:00:00 2001 From: Jason Lu Date: Wed, 27 May 2026 02:02:10 -0700 Subject: [PATCH 6/6] review: address sc_video.m bot review feedback Two Copilot findings on the SCK PR: 1. **dealloc signals pending semaphore.** -dealloc was clearing currentCallback but never signalling currentSignal. If the stream stopped without firing -stream:didStopWithError:, any caller still waiting on the semaphore returned by -capture: would stall forever. Snapshot the pending signal in the @synchronized block, then send it after clearing the callback so the waiter wakes up to observe their callback is nil and exits. 2. **Drop unused streamOutputAdded property.** Set in -init but never read. Removed both the @property declaration and the assignment. The "register output exactly once at init" invariant is now structural (the call is in -init, not gated on a flag) and the dead state can't drift. --- src/platform/macos/sc_video.m | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/platform/macos/sc_video.m b/src/platform/macos/sc_video.m index 08cd8610dd3..eab24a328da 100644 --- a/src/platform/macos/sc_video.m +++ b/src/platform/macos/sc_video.m @@ -41,7 +41,6 @@ @interface SCVideo () @property (nonatomic, copy) FrameCallbackBlock currentCallback; @property (nonatomic, strong) dispatch_semaphore_t currentSignal; @property (nonatomic, assign) BOOL streamRunning; -@property (nonatomic, assign) BOOL streamOutputAdded; @end @@ -161,7 +160,6 @@ - (instancetype)initWithDisplay:(CGDirectDisplayID)displayID frameRate:(int)fram NSLog(@"SCVideo: addStreamOutput failed: %@", outputError); return nil; } - self.streamOutputAdded = YES; // Start the stream once. Frames begin flowing immediately on the // sampleQueue; sample-handler delivery is a no-op until the first @@ -320,12 +318,26 @@ - (dispatch_semaphore_t)capture:(FrameCallbackBlock)frameCallback { - (void)dealloc { BOOL running; SCStream *stream; + dispatch_semaphore_t pendingSignal; @synchronized(self) { running = self.streamRunning; stream = self.stream; + pendingSignal = self.currentSignal; self.streamRunning = NO; self.currentCallback = nil; + self.currentSignal = nil; + } + + // Unblock any caller still waiting on the semaphore that -capture: + // returned. Without this, if the stream stops without triggering + // -stream:didStopWithError: (or the delegate callback can't be + // delivered during teardown), the waiting thread would stall + // forever. Signalling after clearing currentCallback means the + // caller wakes up to observe their callback was cleared and exits. + if (pendingSignal) { + dispatch_semaphore_signal(pendingSignal); } + if (running && stream) { // Best-effort synchronous stop with a bounded wait so a // misbehaving SCK doesn't hang teardown.