diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake
index dbca9df9073..6f5a5416620 100644
--- a/cmake/compile_definitions/macos.cmake
+++ b/cmake/compile_definitions/macos.cmake
@@ -35,6 +35,8 @@ list(APPEND SUNSHINE_EXTERNAL_LIBRARIES
${CORE_MEDIA_LIBRARY}
${CORE_VIDEO_LIBRARY}
${FOUNDATION_LIBRARY}
+ ${IO_KIT_LIBRARY}
+ ${SCREEN_CAPTURE_KIT_LIBRARY}
${VIDEO_TOOLBOX_LIBRARY})
set(APPLE_PLIST_TEMPLATE "${SUNSHINE_SOURCE_ASSETS_DIR}/macos/build/Info.plist.in")
@@ -45,16 +47,26 @@ 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/hid_gamepad.h"
+ "${CMAKE_SOURCE_DIR}/src/platform/macos/hid_gamepad.m"
+ "${CMAKE_SOURCE_DIR}/src/platform/macos/input.mm"
"${CMAKE_SOURCE_DIR}/src/platform/macos/microphone.mm"
"${CMAKE_SOURCE_DIR}/src/platform/macos/misc.mm"
"${CMAKE_SOURCE_DIR}/src/platform/macos/misc.h"
"${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/ffmpeg.cmake b/cmake/dependencies/ffmpeg.cmake
index 291a6e5e84a..6f31fd8353d 100644
--- a/cmake/dependencies/ffmpeg.cmake
+++ b/cmake/dependencies/ffmpeg.cmake
@@ -173,3 +173,58 @@ else()
endif()
set(FFMPEG_INCLUDE_DIRS "${FFMPEG_PREPARED_BINARIES}/include")
+
+# Sunshine's src/cbs.cpp uses libavcodec's INTERNAL headers (cbs_h264.h,
+# cbs_h2645.h, h2645_parse.h, etc.) which FFmpeg's `make install` does
+# not export. Stage the needed internal headers into the dist include
+# tree alongside the public ones. Done at configure time so subsequent
+# rebuilds don't pay the cost. Limited to a known-good list so we don't
+# shadow system headers (FFmpeg has its own "thread.h", "internal.h",
+# etc. that collide with libc++ when the entire source tree is on the
+# include path).
+get_filename_component(_FFMPEG_BINARY_PARENT "${FFMPEG_PREPARED_BINARIES}" DIRECTORY)
+set(_FFMPEG_SOURCE_CANDIDATES
+ "${_FFMPEG_BINARY_PARENT}/FFmpeg/FFmpeg"
+ "${CMAKE_SOURCE_DIR}/third-party/build-deps/build-prores-vt/FFmpeg/FFmpeg"
+)
+foreach(_candidate ${_FFMPEG_SOURCE_CANDIDATES})
+ if(EXISTS "${_candidate}/libavcodec/h2645_parse.h")
+ set(_FFMPEG_INTERNAL_HEADERS
+ libavcodec/h2645_parse.h
+ libavcodec/h2645_sei.h
+ libavcodec/h264_sei.h
+ libavcodec/hevc/sei.h
+ libavcodec/sei.h
+ libavcodec/cbs.h
+ libavcodec/cbs_internal.h
+ libavcodec/cbs_sei.h
+ libavcodec/get_bits.h
+ libavcodec/golomb.h
+ libavcodec/mathops.h
+ libavcodec/mpegutils.h
+ libavcodec/vlc.h
+ libavutil/attributes_internal.h
+ libavutil/internal.h
+ libavutil/thread.h
+ libavutil/timer.h
+ libavutil/reverse.h
+ libavutil/libm.h
+ libavutil/cpu_internal.h
+ )
+ foreach(_hdr ${_FFMPEG_INTERNAL_HEADERS})
+ if(EXISTS "${_candidate}/${_hdr}" AND NOT EXISTS "${FFMPEG_PREPARED_BINARIES}/include/${_hdr}")
+ get_filename_component(_hdr_dir "${FFMPEG_PREPARED_BINARIES}/include/${_hdr}" DIRECTORY)
+ file(MAKE_DIRECTORY "${_hdr_dir}")
+ configure_file("${_candidate}/${_hdr}" "${FFMPEG_PREPARED_BINARIES}/include/${_hdr}" COPYONLY)
+ endif()
+ endforeach()
+ # ffbuild/config.h is needed by libavutil/internal.h. Stage it
+ # at the include-path root so the relative #include "config.h"
+ # from mathops.h finds it.
+ if(EXISTS "${_candidate}/ffbuild/config.h" AND NOT EXISTS "${FFMPEG_PREPARED_BINARIES}/include/config.h")
+ configure_file("${_candidate}/ffbuild/config.h" "${FFMPEG_PREPARED_BINARIES}/include/config.h" COPYONLY)
+ endif()
+ message(STATUS "Sunshine cbs.cpp: staged FFmpeg internal headers from ${_candidate}")
+ break()
+ endif()
+endforeach()
diff --git a/cmake/dependencies/macos.cmake b/cmake/dependencies/macos.cmake
index 5e225fdac21..3a22e150a03 100644
--- a/cmake/dependencies/macos.cmake
+++ b/cmake/dependencies/macos.cmake
@@ -9,7 +9,21 @@ FIND_LIBRARY(CORE_AUDIO_LIBRARY CoreAudio)
FIND_LIBRARY(CORE_MEDIA_LIBRARY CoreMedia)
FIND_LIBRARY(CORE_VIDEO_LIBRARY CoreVideo)
FIND_LIBRARY(FOUNDATION_LIBRARY Foundation)
+# IOKit is needed for IOHIDUserDevice* (virtual gamepad device — hid_gamepad.m).
+# Actually creating devices at runtime requires the user to disable AMFI via
+# `nvram boot-args="amfi_get_out_of_my_way=1"`, but the symbols themselves
+# are unconditionally present and the host alloc_gamepad path probes
+# availability before relying on them.
+FIND_LIBRARY(IO_KIT_LIBRARY IOKit)
FIND_LIBRARY(VIDEO_TOOLBOX_LIBRARY VideoToolbox)
+# 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)
diff --git a/docs/changelog.md b/docs/changelog.md
index 9cf35f26907..4c89af85b58 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,11 @@
# Changelog
+## Unreleased
+
+- Added disabled-by-default experimental macOS ProRes VideoToolbox encoder
+ plumbing for custom clients. This is Sunshine-side protocol and encoder
+ support only and does not add stock Moonlight ProRes decoder compatibility.
+
@htmlonly
+### prores_mode
+
+
+
+
Description
+
+ Allows custom clients to request experimental macOS ProRes VideoToolbox video streams.
+ @warning{This does not add stock Moonlight client decoder support and should remain disabled unless
+ a custom client is explicitly being tested.}
+
+
+
+
Default
+
@code{}
+ 0
+ @endcode
+
+
+
Example
+
@code{}
+ prores_mode = 1
+ @endcode
+
+
+
Choices
+
0
+
disabled
+
+
+
1
+
accept an explicit ProRes request from a custom client
+
+
+
2
+
force ProRes for local development sessions
+
+
+
### capture
@@ -3004,6 +3042,55 @@ editing the `conf` file in a text editor. Use the examples as reference.
+### prores_profile
+
+
+
+
Description
+
+ Sets the FFmpeg `prores_videotoolbox` profile when experimental ProRes is enabled.
+ @note{This option only applies when using macOS.}
+
+
+
+
Default
+
@code{}
+ lt
+ @endcode
+
+
+
Example
+
@code{}
+ prores_profile = hq
+ @endcode
+
+
+
Choices
+
proxy
+
ProRes 422 Proxy
+
+
+
lt
+
ProRes 422 LT
+
+
+
standard
+
ProRes 422
+
+
+
hq
+
ProRes 422 HQ
+
+
+
4444
+
ProRes 4444
+
+
+
xq
+
ProRes 4444 XQ
+
+
+
## VA-API Encoder
### vaapi_strict_rc_buffer
diff --git a/src/config.cpp b/src/config.cpp
index 6d266c0ef22..b7af4bdbad2 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -350,6 +350,25 @@ namespace config {
} // namespace vt
+ namespace prores {
+
+ std::string profile_from_view(const std::string_view profile) {
+#define _CONVERT_(x) \
+ if (profile == #x##sv) \
+ return #x
+ _CONVERT_(proxy);
+ _CONVERT_(lt);
+ _CONVERT_(standard);
+ _CONVERT_(hq);
+ _CONVERT_(4444);
+ _CONVERT_(xq);
+#undef _CONVERT_
+ BOOST_LOG(warning) << "config: unknown prores_profile value: " << profile;
+ return "lt";
+ }
+
+ } // namespace prores
+
namespace sw {
int svtav1_preset_from_view(const std::string_view &preset) {
#define _CONVERT_(x, y) \
@@ -454,6 +473,8 @@ namespace config {
0, // hevc_mode
0, // av1_mode
+ 0, // prores_mode
+ "lt"s, // prores_profile
2, // min_threads
{
@@ -1101,6 +1122,8 @@ namespace config {
int_f(vars, "qp", video.qp);
int_between_f(vars, "hevc_mode", video.hevc_mode, {0, 3});
int_between_f(vars, "av1_mode", video.av1_mode, {0, 3});
+ int_between_f(vars, "prores_mode", video.prores_mode, {0, 2});
+ generic_f(vars, "prores_profile", video.prores_profile, prores::profile_from_view);
int_f(vars, "min_threads", video.min_threads);
string_f(vars, "sw_preset", video.sw.sw_preset);
if (!video.sw.sw_preset.empty()) {
diff --git a/src/config.h b/src/config.h
index e0e40501d0b..b999ff6ae19 100644
--- a/src/config.h
+++ b/src/config.h
@@ -31,6 +31,7 @@ namespace config {
};
void log_config_settings(const std::unordered_map &vars, bool save);
+ void apply_config(std::unordered_map &&vars);
struct video_t {
// ffmpeg params
@@ -38,6 +39,8 @@ namespace config {
int hevc_mode;
int av1_mode;
+ int prores_mode;
+ std::string prores_profile;
int min_threads; // Minimum number of threads/slices for CPU encoding
diff --git a/src/nvenc/nvenc_base.cpp b/src/nvenc/nvenc_base.cpp
index ab8aa7a91ff..acd1004fdfc 100644
--- a/src/nvenc/nvenc_base.cpp
+++ b/src/nvenc/nvenc_base.cpp
@@ -453,10 +453,11 @@ namespace nvenc {
}
{
- auto video_format_string = client_config.videoFormat == 0 ? "H.264 " :
- client_config.videoFormat == 1 ? "HEVC " :
- client_config.videoFormat == 2 ? "AV1 " :
- " ";
+ auto video_format_string = client_config.videoFormat == video::SUNSHINE_FORMAT_H264 ? "H.264 " :
+ client_config.videoFormat == video::SUNSHINE_FORMAT_HEVC ? "HEVC " :
+ client_config.videoFormat == video::SUNSHINE_FORMAT_AV1 ? "AV1 " :
+ client_config.videoFormat == video::SUNSHINE_FORMAT_PRORES ? "ProRes " :
+ " ";
std::string extra;
if (init_params.enableEncodeAsync) {
extra += " async";
diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp
index 72e9dc5cd05..bdea2430940 100644
--- a/src/nvhttp.cpp
+++ b/src/nvhttp.cpp
@@ -736,34 +736,39 @@ namespace nvhttp {
}
uint32_t codec_mode_flags = SCM_H264;
- if (video::last_encoder_probe_supported_yuv444_for_codec[0]) {
+ if (video::last_encoder_probe_supported_yuv444_for_codec[video::SUNSHINE_FORMAT_H264]) {
codec_mode_flags |= SCM_H264_HIGH8_444;
}
if (video::active_hevc_mode >= 2) {
codec_mode_flags |= SCM_HEVC;
- if (video::last_encoder_probe_supported_yuv444_for_codec[1]) {
+ if (video::last_encoder_probe_supported_yuv444_for_codec[video::SUNSHINE_FORMAT_HEVC]) {
codec_mode_flags |= SCM_HEVC_REXT8_444;
}
}
if (video::active_hevc_mode >= 3) {
codec_mode_flags |= SCM_HEVC_MAIN10;
- if (video::last_encoder_probe_supported_yuv444_for_codec[1]) {
+ if (video::last_encoder_probe_supported_yuv444_for_codec[video::SUNSHINE_FORMAT_HEVC]) {
codec_mode_flags |= SCM_HEVC_REXT10_444;
}
}
if (video::active_av1_mode >= 2) {
codec_mode_flags |= SCM_AV1_MAIN8;
- if (video::last_encoder_probe_supported_yuv444_for_codec[2]) {
+ if (video::last_encoder_probe_supported_yuv444_for_codec[video::SUNSHINE_FORMAT_AV1]) {
codec_mode_flags |= SCM_AV1_HIGH8_444;
}
}
if (video::active_av1_mode >= 3) {
codec_mode_flags |= SCM_AV1_MAIN10;
- if (video::last_encoder_probe_supported_yuv444_for_codec[2]) {
+ if (video::last_encoder_probe_supported_yuv444_for_codec[video::SUNSHINE_FORMAT_AV1]) {
codec_mode_flags |= SCM_AV1_HIGH10_444;
}
}
tree.put("root.ServerCodecModeSupport", codec_mode_flags);
+ if (video::active_prores_mode > 0) {
+ tree.put("root.SunshineExperimentalProRes", "1");
+ tree.put("root.SunshineExperimentalProResVideoFormat", video::SUNSHINE_FORMAT_PRORES);
+ tree.put("root.SunshineExperimentalProResProfile", config::video.prores_profile);
+ }
if (!config::nvhttp.external_ip.empty()) {
tree.put("root.ExternalIP", config::nvhttp.external_ip);
diff --git a/src/platform/common.h b/src/platform/common.h
index 96c42f4de08..56950afa0b6 100644
--- a/src/platform/common.h
+++ b/src/platform/common.h
@@ -241,6 +241,8 @@ namespace platf {
yuv420p10, ///< YUV 4:2:0 10-bit
nv12, ///< NV12
p010, ///< P010
+ nv24, ///< NV24 (YUV 4:4:4 8-bit BiPlanar)
+ p410, ///< P410 (YUV 4:4:4 10-bit BiPlanar)
ayuv, ///< AYUV
yuv444p16, ///< Planar 10-bit (shifted to 16-bit) YUV 4:4:4
y410, ///< Y410
@@ -257,6 +259,8 @@ namespace platf {
_CONVERT(yuv420p10);
_CONVERT(nv12);
_CONVERT(p010);
+ _CONVERT(nv24);
+ _CONVERT(p410);
_CONVERT(ayuv);
_CONVERT(yuv444p16);
_CONVERT(y410);
diff --git a/src/platform/macos/av_video.h b/src/platform/macos/av_video.h
deleted file mode 100644
index b2fa5d4b255..00000000000
--- a/src/platform/macos/av_video.h
+++ /dev/null
@@ -1,41 +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;
-
-@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;
-
-typedef bool (^FrameCallbackBlock)(CMSampleBufferRef);
-
-@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 be124b2d331..21abf7fbe6c 100644
--- a/src/platform/macos/display.mm
+++ b/src/platform/macos/display.mm
@@ -7,9 +7,9 @@
#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"
// Avoid conflict between AVFoundation and libavutil both defining AVMediaType
#define AVMediaType AVMediaType_FFmpeg
@@ -22,7 +22,7 @@
using namespace std::literals;
struct av_display_t: public display_t {
- AVVideo *av_capture {};
+ SCVideo *av_capture {};
CGDirectDisplayID display_id {};
~av_display_t() override {
@@ -83,10 +83,18 @@ capture_e capture(const push_captured_image_cb_t &push_captured_image_cb, const
av_capture.pixelFormat = kCVPixelFormatType_32BGRA;
return std::make_unique();
- } else if (pix_fmt == pix_fmt_e::nv12 || pix_fmt == pix_fmt_e::p010) {
+ } else if (pix_fmt == pix_fmt_e::nv12 || pix_fmt == pix_fmt_e::p010 ||
+ pix_fmt == pix_fmt_e::nv24 || pix_fmt == pix_fmt_e::p410) {
+ // nv12 / p010 are 4:2:0 BiPlanar (8 / 10 bit); nv24 / p410 are the
+ // 4:4:4 BiPlanar equivalents required by prores_videotoolbox for
+ // ProRes 422 profiles (encoder downsamples internally) and ProRes
+ // 4444 (native). nv12_zero_device is format-agnostic at the wrap
+ // layer — it sets the capture-side CVPixelBufferType and then wraps
+ // frames for AV_PIX_FMT_VIDEOTOOLBOX, so the same device handles all
+ // four.
auto device = std::make_unique();
- device->init(static_cast(av_capture), pix_fmt, setResolution, setPixelFormat);
+ device->init((void *) av_capture, pix_fmt, setResolution, setPixelFormat);
return device;
} else {
@@ -143,11 +151,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];
+ [(SCVideo *) display setFrameWidth:width frameHeight:height];
}
static void setPixelFormat(void *display, OSType pixelFormat) {
- static_cast(display).pixelFormat = pixelFormat;
+ ((SCVideo *) display).pixelFormat = pixelFormat;
}
};
@@ -163,7 +171,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"];
@@ -177,7 +185,19 @@ 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];
+ // 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;
@@ -196,7 +216,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/hid_gamepad.h b/src/platform/macos/hid_gamepad.h
new file mode 100644
index 00000000000..18724105755
--- /dev/null
+++ b/src/platform/macos/hid_gamepad.h
@@ -0,0 +1,72 @@
+/**
+ * @file src/platform/macos/hid_gamepad.h
+ * @brief Virtual HID gamepad via IOHIDUserDevice for macOS.
+ * @details Creates a system-wide virtual gamepad that macOS Game Controller
+ * framework recognizes. Requires SIP to be disabled.
+ */
+#pragma once
+
+#import
+#import
+
+/**
+ * HID report sent to IOHIDUserDevice. Packed to exactly 14 bytes.
+ * Matches the HID report descriptor defined in hid_gamepad.m.
+ */
+typedef struct __attribute__((packed)) {
+ uint8_t reportId; // Always 0x01
+ uint16_t buttons; // 16 button bits
+ uint8_t hatSwitch; // D-pad hat switch (0-7 = directions, 8 = neutral)
+ uint8_t leftTrigger; // 0-255
+ uint8_t rightTrigger; // 0-255
+ int16_t leftStickX; // -32768 to 32767
+ int16_t leftStickY; // -32768 to 32767
+ int16_t rightStickX; // -32768 to 32767
+ int16_t rightStickY; // -32768 to 32767
+} HIDGamepadReport;
+
+@interface HIDGamepad : NSObject
+
+@property (nonatomic, assign) int gamepadIndex;
+@property (nonatomic, assign) BOOL isConnected;
+@property (nonatomic, assign) IOHIDUserDeviceRef hidDevice;
+@property (nonatomic, strong) dispatch_queue_t hidQueue;
+
+/**
+ * Probes whether IOHIDUserDevice virtual gamepads can be created.
+ * Returns NO when SIP is enabled (device creation fails).
+ */
++ (BOOL)isAvailable;
+
+- (instancetype)initWithIndex:(int)index;
+
+/**
+ * Creates the IOHIDUserDevice and sends an initial neutral-state report.
+ * @return YES on success, NO on failure.
+ */
+- (BOOL)createDevice;
+
+/**
+ * Maps Sunshine's gamepad state to an HID report and sends it.
+ * @param buttons Sunshine's 32-bit buttonFlags (only lower 16 bits + HOME used)
+ * @param lsX Left stick X (-32768..32767)
+ * @param lsY Left stick Y (-32768..32767)
+ * @param rsX Right stick X (-32768..32767)
+ * @param rsY Right stick Y (-32768..32767)
+ * @param lt Left trigger (0..255)
+ * @param rt Right trigger (0..255)
+ */
+- (void)updateState:(uint32_t)buttons
+ leftStickX:(int16_t)lsX
+ leftStickY:(int16_t)lsY
+ rightStickX:(int16_t)rsX
+ rightStickY:(int16_t)rsY
+ leftTrigger:(uint8_t)lt
+ rightTrigger:(uint8_t)rt;
+
+/**
+ * Destroys the IOHIDUserDevice and cleans up resources.
+ */
+- (void)disconnect;
+
+@end
diff --git a/src/platform/macos/hid_gamepad.m b/src/platform/macos/hid_gamepad.m
new file mode 100644
index 00000000000..91ad38e7dde
--- /dev/null
+++ b/src/platform/macos/hid_gamepad.m
@@ -0,0 +1,312 @@
+/**
+ * @file src/platform/macos/hid_gamepad.m
+ * @brief Virtual HID gamepad implementation via IOHIDUserDevice.
+ * @details Creates a virtual Xbox-style gamepad recognized by macOS Game Controller framework.
+ * Requires SIP to be disabled for IOHIDUserDevice creation to succeed
+ * (bypasses com.apple.developer.hid.virtual.device entitlement check).
+ */
+#import "hid_gamepad.h"
+#import
+#import
+
+// Sunshine button flags (from platform/common.h)
+#define SF_DPAD_UP 0x0001
+#define SF_DPAD_DOWN 0x0002
+#define SF_DPAD_LEFT 0x0004
+#define SF_DPAD_RIGHT 0x0008
+#define SF_START 0x0010
+#define SF_BACK 0x0020
+#define SF_LEFT_STICK 0x0040
+#define SF_RIGHT_STICK 0x0080
+#define SF_LEFT_BUTTON 0x0100
+#define SF_RIGHT_BUTTON 0x0200
+#define SF_HOME 0x0400
+#define SF_A 0x1000
+#define SF_B 0x2000
+#define SF_X 0x4000
+#define SF_Y 0x8000
+
+// Hat switch directions (matching HID spec 4-bit values)
+#define HAT_N 0
+#define HAT_NE 1
+#define HAT_E 2
+#define HAT_SE 3
+#define HAT_S 4
+#define HAT_SW 5
+#define HAT_W 6
+#define HAT_NW 7
+#define HAT_NONE 8
+
+/**
+ * HID Report Descriptor for an Xbox-style gamepad.
+ *
+ * Layout (Report ID 0x01, 13 bytes after report ID):
+ * - 16 buttons (2 bytes)
+ * - 1 hat switch, 4-bit + 4-bit padding (1 byte)
+ * - 2 triggers, 8-bit each (2 bytes)
+ * - 4 stick axes, 16-bit signed each (8 bytes)
+ */
+static const uint8_t kHIDReportDescriptor[] = {
+ 0x05, 0x01, // Usage Page (Generic Desktop)
+ 0x09, 0x04, // Usage (Joystick) — generic, avoids SDL's GCController-only path
+ 0xA1, 0x01, // Collection (Application)
+ 0x85, 0x01, // Report ID (1)
+
+ // --- 16 Buttons ---
+ 0x05, 0x09, // Usage Page (Button)
+ 0x19, 0x01, // Usage Minimum (1)
+ 0x29, 0x10, // Usage Maximum (16)
+ 0x15, 0x00, // Logical Minimum (0)
+ 0x25, 0x01, // Logical Maximum (1)
+ 0x95, 0x10, // Report Count (16)
+ 0x75, 0x01, // Report Size (1)
+ 0x81, 0x02, // Input (Data, Variable, Absolute)
+
+ // --- Hat Switch (D-pad) ---
+ 0x05, 0x01, // Usage Page (Generic Desktop)
+ 0x09, 0x39, // Usage (Hat Switch)
+ 0x15, 0x00, // Logical Minimum (0)
+ 0x25, 0x07, // Logical Maximum (7)
+ 0x35, 0x00, // Physical Minimum (0)
+ 0x46, 0x3B, 0x01, // Physical Maximum (315)
+ 0x65, 0x14, // Unit (Degrees)
+ 0x75, 0x04, // Report Size (4)
+ 0x95, 0x01, // Report Count (1)
+ 0x81, 0x42, // Input (Data, Variable, Absolute, Null State)
+
+ // --- Hat Switch Padding (4 bits) ---
+ 0x75, 0x04, // Report Size (4)
+ 0x95, 0x01, // Report Count (1)
+ 0x81, 0x01, // Input (Constant)
+
+ // --- Triggers (2x 8-bit) ---
+ 0x05, 0x02, // Usage Page (Simulation Controls)
+ 0x09, 0xC5, // Usage (Brake) - Left Trigger
+ 0x09, 0xC4, // Usage (Accelerator) - Right Trigger
+ 0x15, 0x00, // Logical Minimum (0)
+ 0x26, 0xFF, 0x00, // Logical Maximum (255)
+ 0x75, 0x08, // Report Size (8)
+ 0x95, 0x02, // Report Count (2)
+ 0x81, 0x02, // Input (Data, Variable, Absolute)
+
+ // --- Stick Axes (4x 16-bit signed) ---
+ 0x05, 0x01, // Usage Page (Generic Desktop)
+ 0x09, 0x30, // Usage (X) - Left Stick X
+ 0x09, 0x31, // Usage (Y) - Left Stick Y
+ 0x09, 0x32, // Usage (Z) - Right Stick X
+ 0x09, 0x35, // Usage (Rz) - Right Stick Y
+ 0x16, 0x00, 0x80, // Logical Minimum (-32768)
+ 0x26, 0xFF, 0x7F, // Logical Maximum (32767)
+ 0x75, 0x10, // Report Size (16)
+ 0x95, 0x04, // Report Count (4)
+ 0x81, 0x02, // Input (Data, Variable, Absolute)
+
+ 0xC0 // End Collection
+};
+
+@implementation HIDGamepad
+
++ (BOOL)isAvailable {
+ // Probe by attempting to create a minimal IOHIDUserDevice.
+ // This will fail when SIP is enabled (entitlement check).
+ NSDictionary *props = @{
+ @kIOHIDVendorIDKey: @(0x1209), // Generic (pid.codes open-source VID)
+ @kIOHIDProductIDKey: @(0x5853), // Not in SDL's known controller database
+ @kIOHIDReportDescriptorKey: [NSData dataWithBytes:kHIDReportDescriptor
+ length:sizeof(kHIDReportDescriptor)],
+ };
+
+ IOHIDUserDeviceRef testDevice = IOHIDUserDeviceCreateWithProperties(
+ kCFAllocatorDefault,
+ (__bridge CFDictionaryRef)props,
+ 0
+ );
+
+ if (testDevice) {
+ CFRelease(testDevice);
+ return YES;
+ }
+
+ return NO;
+}
+
+- (instancetype)initWithIndex:(int)index {
+ self = [super init];
+ if (self) {
+ _gamepadIndex = index;
+ _isConnected = NO;
+ _hidDevice = NULL;
+ _hidQueue = nil;
+ }
+ return self;
+}
+
+- (void)dealloc {
+ [self disconnect];
+ [super dealloc];
+}
+
+- (BOOL)createDevice {
+ if (_hidDevice) {
+ return YES;
+ }
+
+ NSString *queueLabel = [NSString stringWithFormat:@"com.sunshine.hid.gamepad.%d", _gamepadIndex];
+ dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(
+ DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, 0);
+ _hidQueue = dispatch_queue_create([queueLabel UTF8String], attr);
+
+ NSDictionary *props = @{
+ @kIOHIDVendorIDKey: @(0x1209), // Generic (pid.codes open-source VID)
+ @kIOHIDProductIDKey: @(0x5853), // Not in SDL's known controller database
+ @kIOHIDManufacturerKey: @"Sunshine Virtual Gamepad",
+ @kIOHIDProductKey: [NSString stringWithFormat:@"Sunshine Gamepad %d", _gamepadIndex],
+ @kIOHIDSerialNumberKey: [NSString stringWithFormat:@"SUNSHINE-%d", _gamepadIndex],
+ @kIOHIDTransportKey: @"USB",
+ @kIOHIDReportDescriptorKey: [NSData dataWithBytes:kHIDReportDescriptor
+ length:sizeof(kHIDReportDescriptor)],
+ };
+
+ _hidDevice = IOHIDUserDeviceCreateWithProperties(
+ kCFAllocatorDefault,
+ (__bridge CFDictionaryRef)props,
+ 0
+ );
+
+ if (!_hidDevice) {
+ NSLog(@"[HIDGamepad] Failed to create IOHIDUserDevice for gamepad %d", _gamepadIndex);
+ _hidQueue = nil;
+ return NO;
+ }
+
+ // Set up dispatch queue and activate the device
+ IOHIDUserDeviceSetDispatchQueue(_hidDevice, _hidQueue);
+ IOHIDUserDeviceActivate(_hidDevice);
+
+ _isConnected = YES;
+
+ // Send initial neutral state
+ HIDGamepadReport report = {0};
+ report.reportId = 0x01;
+ report.hatSwitch = HAT_NONE;
+
+ IOReturn result = IOHIDUserDeviceHandleReportWithTimeStamp(
+ _hidDevice, mach_absolute_time(),
+ (const uint8_t *)&report, sizeof(report)
+ );
+ if (result != kIOReturnSuccess) {
+ NSLog(@"[HIDGamepad] Warning: failed to send initial report for gamepad %d (0x%x)", _gamepadIndex, result);
+ }
+
+ NSLog(@"[HIDGamepad] Gamepad %d created successfully (IOHIDUserDevice)", _gamepadIndex);
+ return YES;
+}
+
+/**
+ * Converts Sunshine d-pad button flags to HID hat switch value.
+ */
+static uint8_t dpadToHatSwitch(uint32_t buttons) {
+ BOOL up = (buttons & SF_DPAD_UP) != 0;
+ BOOL down = (buttons & SF_DPAD_DOWN) != 0;
+ BOOL left = (buttons & SF_DPAD_LEFT) != 0;
+ BOOL right = (buttons & SF_DPAD_RIGHT) != 0;
+
+ if (up && right) return HAT_NE;
+ if (up && left) return HAT_NW;
+ if (down && right) return HAT_SE;
+ if (down && left) return HAT_SW;
+ if (up) return HAT_N;
+ if (right) return HAT_E;
+ if (down) return HAT_S;
+ if (left) return HAT_W;
+ return HAT_NONE;
+}
+
+/**
+ * Maps Sunshine's 32-bit button flags to the 16-bit HID button field.
+ *
+ * HID button layout:
+ * bit 0: A bit 4: LB bit 8: L3
+ * bit 1: B bit 5: RB bit 9: R3
+ * bit 2: X bit 6: Back bit 10: Home
+ * bit 3: Y bit 7: Start bits 11-15: reserved
+ */
+static uint16_t mapButtons(uint32_t sf) {
+ uint16_t hid = 0;
+ if (sf & SF_A) hid |= (1 << 0);
+ if (sf & SF_B) hid |= (1 << 1);
+ if (sf & SF_X) hid |= (1 << 2);
+ if (sf & SF_Y) hid |= (1 << 3);
+ if (sf & SF_LEFT_BUTTON) hid |= (1 << 4);
+ if (sf & SF_RIGHT_BUTTON) hid |= (1 << 5);
+ if (sf & SF_BACK) hid |= (1 << 6);
+ if (sf & SF_START) hid |= (1 << 7);
+ if (sf & SF_LEFT_STICK) hid |= (1 << 8);
+ if (sf & SF_RIGHT_STICK) hid |= (1 << 9);
+ if (sf & SF_HOME) hid |= (1 << 10);
+ return hid;
+}
+
+- (void)updateState:(uint32_t)buttons
+ leftStickX:(int16_t)lsX
+ leftStickY:(int16_t)lsY
+ rightStickX:(int16_t)rsX
+ rightStickY:(int16_t)rsY
+ leftTrigger:(uint8_t)lt
+ rightTrigger:(uint8_t)rt {
+
+ if (!_isConnected || !_hidDevice) {
+ return;
+ }
+
+ HIDGamepadReport report;
+ report.reportId = 0x01;
+ report.buttons = mapButtons(buttons);
+ report.hatSwitch = dpadToHatSwitch(buttons);
+ report.leftTrigger = lt;
+ report.rightTrigger = rt;
+ report.leftStickX = lsX;
+ report.leftStickY = lsY;
+ report.rightStickX = rsX;
+ report.rightStickY = rsY;
+
+ IOReturn result = IOHIDUserDeviceHandleReportWithTimeStamp(
+ _hidDevice, mach_absolute_time(),
+ (const uint8_t *)&report, sizeof(report)
+ );
+ if (result != kIOReturnSuccess) {
+ NSLog(@"[HIDGamepad] Failed to send report for gamepad %d (0x%x)", _gamepadIndex, result);
+ }
+}
+
+- (void)disconnect {
+ if (!_hidDevice) {
+ return;
+ }
+
+ _isConnected = NO;
+
+ IOHIDUserDeviceRef device = _hidDevice;
+ dispatch_queue_t queue = _hidQueue;
+ int index = _gamepadIndex;
+
+ _hidDevice = NULL;
+
+ if (queue) {
+ // Cancel the device and release in the cancel handler
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
+ IOHIDUserDeviceSetCancelHandler(device, ^{
+ CFRelease(device);
+ dispatch_semaphore_signal(sem);
+ });
+ IOHIDUserDeviceCancel(device);
+ dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC));
+ _hidQueue = nil;
+ } else {
+ CFRelease(device);
+ }
+
+ NSLog(@"[HIDGamepad] Gamepad %d disconnected", index);
+}
+
+@end
diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.mm
similarity index 84%
rename from src/platform/macos/input.cpp
rename to src/platform/macos/input.mm
index 6eed2c1d365..66696abdcd2 100644
--- a/src/platform/macos/input.cpp
+++ b/src/platform/macos/input.mm
@@ -1,6 +1,11 @@
/**
- * @file src/platform/macos/input.cpp
+ * @file src/platform/macos/input.mm
* @brief Definitions for macOS input handling.
+ *
+ * Compiled as Objective-C++ (was input.cpp) so the gamepad path can
+ * hold a strong reference to HIDGamepad — the virtual IOHIDUserDevice
+ * wrapper in hid_gamepad.{h,m} — without going through a C bridge.
+ * Mouse/keyboard injection paths are unchanged.
*/
// standard includes
#include
@@ -22,6 +27,7 @@
#include "src/input.h"
#include "src/logging.h"
#include "src/platform/common.h"
+#include "src/platform/macos/hid_gamepad.h"
#include "src/utility.h"
/**
@@ -37,6 +43,14 @@ namespace platf {
constexpr double DEFAULT_SCROLLWHEEL_SCALING = 0.3125;
constexpr int DEFAULT_SCROLL_LINES_PER_DETENT = 5;
+ // MAX_GAMEPADS comes from src/platform/common.h (currently 16, matching
+ // Windows ViGEm + Linux uinput backends). Each slot owns one
+ // IOHIDUserDevice-backed virtual gamepad. HID device creation can only
+ // succeed when the user has booted with AMFI bypassed
+ // (`sudo nvram boot-args="amfi_get_out_of_my_way=1"`); otherwise
+ // alloc_gamepad reports failure and the gamepad is unsupported for
+ // this session.
+
struct macos_input_t {
public:
CGDirectDisplayID display {};
@@ -53,6 +67,13 @@ namespace platf {
int scroll_lines_per_detent {DEFAULT_SCROLL_LINES_PER_DETENT};
bool mouse_down[3] {}; // mouse button status
std::chrono::steady_clock::steady_clock::time_point last_mouse_event[3][2]; // timestamp of last mouse events
+
+ // gamepad related stuff. Each slot is either nil (free) or holds an
+ // HIDGamepad whose underlying IOHIDUserDevice is currently published.
+ // Probed once at input() construction so the alloc path doesn't pay
+ // the IOHIDUserDeviceCreate cost on every connect attempt.
+ HIDGamepad *gamepads[MAX_GAMEPADS] {};
+ bool hid_gamepad_available {};
};
// A struct to hold a Windows keycode to Mac virtual keycode mapping.
@@ -343,16 +364,59 @@ const KeyCodeMap kKeyCodesMap[] = {
}
int alloc_gamepad(input_t &input, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue) {
- BOOST_LOG(info) << "alloc_gamepad: Gamepad not yet implemented for MacOS."sv;
+ auto macos_input = static_cast(input.get());
+ if (!macos_input->hid_gamepad_available) {
+ BOOST_LOG(warning) << "alloc_gamepad: IOHIDUserDevice virtual gamepad not available. Boot with `nvram boot-args=\"amfi_get_out_of_my_way=1\"` to enable host-side gamepad support on macOS."sv;
+ return -1;
+ }
+
+ // Find a free slot. globalIndex from the protocol is advisory; we
+ // assign the lowest free slot ourselves so a disconnect/reconnect
+ // sequence reliably reuses the same IOHIDUserDevice slot.
+ for (int i = 0; i < MAX_GAMEPADS; i++) {
+ if (macos_input->gamepads[i] != nil) {
+ continue;
+ }
+
+ HIDGamepad *pad = [[HIDGamepad alloc] initWithIndex:i];
+ if (![pad createDevice]) {
+ [pad release];
+ BOOST_LOG(error) << "alloc_gamepad: HIDGamepad createDevice failed for slot "sv << i;
+ return -1;
+ }
+
+ macos_input->gamepads[i] = pad;
+ BOOST_LOG(info) << "alloc_gamepad: slot "sv << i << " allocated (IOHIDUserDevice virtual gamepad)"sv;
+ return i;
+ }
+
+ BOOST_LOG(warning) << "alloc_gamepad: no free gamepad slots (max "sv << MAX_GAMEPADS << ")"sv;
return -1;
}
void free_gamepad(input_t &input, int nr) {
- BOOST_LOG(info) << "free_gamepad: Gamepad not yet implemented for MacOS."sv;
+ auto macos_input = static_cast(input.get());
+ if (nr < 0 || nr >= MAX_GAMEPADS || macos_input->gamepads[nr] == nil) {
+ return;
+ }
+ [macos_input->gamepads[nr] disconnect];
+ [macos_input->gamepads[nr] release];
+ macos_input->gamepads[nr] = nil;
+ BOOST_LOG(info) << "free_gamepad: slot "sv << nr << " released"sv;
}
void gamepad_update(input_t &input, int nr, const gamepad_state_t &gamepad_state) {
- BOOST_LOG(info) << "gamepad: Gamepad not yet implemented for MacOS."sv;
+ auto macos_input = static_cast(input.get());
+ if (nr < 0 || nr >= MAX_GAMEPADS || macos_input->gamepads[nr] == nil) {
+ return;
+ }
+ [macos_input->gamepads[nr] updateState:gamepad_state.buttonFlags
+ leftStickX:gamepad_state.lsX
+ leftStickY:gamepad_state.lsY
+ rightStickX:gamepad_state.rsX
+ rightStickY:gamepad_state.rsY
+ leftTrigger:gamepad_state.lt
+ rightTrigger:gamepad_state.rt];
}
// returns current mouse location:
@@ -656,11 +720,33 @@ const KeyCodeMap kKeyCodesMap[] = {
BOOST_LOG(debug) << "macOS scroll speed: com.apple.scrollwheel.scaling="sv << macos_input->scrollwheel_scaling << ", lines per detent="sv << macos_input->scroll_lines_per_detent << ", pixels per line="sv << CGEventSourceGetPixelsPerLine(macos_input->source);
BOOST_LOG(debug) << "Display "sv << macos_input->display << ", pixel dimension: " << CGDisplayPixelsWide(macos_input->display) << "x"sv << CGDisplayPixelsHigh(macos_input->display);
+ // Probe HIDGamepad availability once at startup. The probe attempts
+ // an IOHIDUserDevice creation; it succeeds iff AMFI was disabled at
+ // boot (`nvram boot-args="amfi_get_out_of_my_way=1"`). Subsequent
+ // alloc_gamepad calls reuse this flag rather than re-probing.
+ macos_input->hid_gamepad_available = [HIDGamepad isAvailable] == YES;
+ if (macos_input->hid_gamepad_available) {
+ BOOST_LOG(info) << "IOHIDUserDevice virtual gamepad support is available — host-side gamepad will be advertised to clients"sv;
+ } else {
+ BOOST_LOG(info) << "IOHIDUserDevice virtual gamepad not available (AMFI enabled). To enable host-side gamepad support: `sudo nvram boot-args=\"amfi_get_out_of_my_way=1\"` and reboot"sv;
+ }
+
return result;
}
void freeInput(void *p) {
- const auto *input = static_cast(p);
+ auto *input = static_cast(p);
+
+ // Release any still-allocated virtual gamepads. Each disconnect
+ // tears down the IOHIDUserDevice synchronously (bounded 2s
+ // semaphore wait inside HIDGamepad::disconnect).
+ for (int i = 0; i < MAX_GAMEPADS; i++) {
+ if (input->gamepads[i] != nil) {
+ [input->gamepads[i] disconnect];
+ [input->gamepads[i] release];
+ input->gamepads[i] = nil;
+ }
+ }
CFRelease(input->source);
CFRelease(input->keyboard_source);
@@ -670,10 +756,19 @@ const KeyCodeMap kKeyCodesMap[] = {
}
std::vector &supported_gamepads(input_t *input) {
- static std::vector gamepads {
- supported_gamepad_t {"", false, "gamepads.macos_not_implemented"}
- };
-
+ // The two distinct states we report: AMFI-disabled (IOHIDUserDevice
+ // works → virtual gamepad available) vs AMFI-enabled (will refuse to
+ // create the device). The string keys plug into the web UI's
+ // translation table; only the first matters since stock Moonlight
+ // doesn't pass a gamepad type, it just calls alloc_gamepad and the
+ // host decides.
+ static bool initialized = false;
+ static std::vector gamepads;
+ if (!initialized) {
+ const BOOL hid_ok = [HIDGamepad isAvailable];
+ gamepads.push_back({"hid", true, hid_ok ? "gamepads.macos_hid" : "gamepads.macos_amfi_required"});
+ initialized = true;
+ }
return gamepads;
}
@@ -682,6 +777,12 @@ const KeyCodeMap kKeyCodesMap[] = {
* @return Capability flags.
*/
platform_caps::caps_t get_capabilities() {
+ // The IOHIDUserDevice-backed virtual gamepad we expose is a basic
+ // Xbox-style controller (16 buttons, hat, 2 triggers, 4 stick axes
+ // — see hid_gamepad.m's report descriptor). We do not expose a
+ // controller touchpad, gyro, or pen/touch surface, so no capability
+ // flags are set; the standard buttons+sticks+triggers are implied
+ // by alloc_gamepad succeeding.
return 0;
}
} // namespace platf
diff --git a/src/platform/macos/nv12_zero_device.cpp b/src/platform/macos/nv12_zero_device.cpp
index b4fb28cb736..ef21cd54d91 100644
--- a/src/platform/macos/nv12_zero_device.cpp
+++ b/src/platform/macos/nv12_zero_device.cpp
@@ -56,7 +56,30 @@ namespace platf {
}
int nv12_zero_device::init(void *display, pix_fmt_e pix_fmt, resolution_fn_t resolution_fn, const pixel_format_fn_t &pixel_format_fn) {
- pixel_format_fn(display, pix_fmt == pix_fmt_e::nv12 ? kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange : kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange);
+ // Map the abstract pix_fmt_e to the matching CVPixelBufferType. The
+ // 4:2:0 BiPlanar formats (NV12 / P010) cover H.264 / HEVC / AV1; the
+ // 4:4:4 BiPlanar formats (NV24 / P410) cover ProRes (422 profiles via
+ // encoder-internal downsample, 4444 profiles natively).
+ OSType cv_format;
+ switch (pix_fmt) {
+ case pix_fmt_e::nv12:
+ cv_format = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange;
+ break;
+ case pix_fmt_e::nv24:
+ cv_format = kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange;
+ break;
+ case pix_fmt_e::p410:
+ cv_format = kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange;
+ break;
+ case pix_fmt_e::p010:
+ default:
+ // p010 is the historical 10-bit 4:2:0 path; the default fall-through
+ // matches it because display.mm::make_avcodec_encode_device is the
+ // source of truth for which pix_fmt values reach this method.
+ cv_format = kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange;
+ break;
+ }
+ pixel_format_fn(display, cv_format);
this->display = display;
this->resolution_fn = std::move(resolution_fn);
diff --git a/src/platform/macos/sc_video.h b/src/platform/macos/sc_video.h
new file mode 100644
index 00000000000..37831570b93
--- /dev/null
+++ b/src/platform/macos/sc_video.h
@@ -0,0 +1,47 @@
+/**
+ * @file src/platform/macos/sc_video.h
+ * @brief Declarations for ScreenCaptureKit-based video capture on macOS.
+ *
+ * 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
+#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);
+
+@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;
+
+// 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;
+
+// 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
new file mode 100644
index 00000000000..eab24a328da
--- /dev/null
+++ b/src/platform/macos/sc_video.m
@@ -0,0 +1,461 @@
+/**
+ * @file src/platform/macos/sc_video.m
+ * @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.
+ * -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
+ * works regardless of compile mode on the other side.
+ */
+#import "sc_video.h"
+
+#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 ()
+
+@property (nonatomic, strong) SCStream *stream;
+@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;
+
+@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;
+ }
+
+ 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);
+ }
+
+ // 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: callers are not yet block-aware.
+ __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);
+ }];
+ 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);
+ 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;
+
+ // 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];
+ 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;
+ }
+
+ // 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;
+}
+
+/**
+ * @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 / 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 (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.
+ self.streamConfig.captureDynamicRange = SCCaptureDynamicRangeHDRLocalDisplay;
+ } else {
+ self.streamConfig.captureDynamicRange = SCCaptureDynamicRangeSDR;
+ }
+ }
+#else
+ (void) pixelFormat;
+#endif
+}
+
+- (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 applyDynamicRangeForPixelFormat:pixelFormat];
+ [self applyConfigurationIfRunning];
+ }
+}
+
+- (void)setMinFrameDuration:(CMTime)minFrameDuration {
+ _minFrameDuration = minFrameDuration;
+
+ if (self.streamConfig) {
+ self.streamConfig.minimumFrameInterval = minFrameDuration;
+ [self applyConfigurationIfRunning];
+ }
+}
+
+- (void)applyConfigurationIfRunning {
+ BOOL running;
+ @synchronized(self) {
+ running = self.streamRunning;
+ }
+ if (!running || !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 {
+ // 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 = newSignal;
+ }
+
+ // 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 {
+ 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.
+ dispatch_semaphore_t stopped = dispatch_semaphore_create(0);
+ [stream stopCaptureWithCompletionHandler:^(NSError *_Nullable error) {
+ (void) error;
+ dispatch_semaphore_signal(stopped);
+ }];
+ 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) {
+ // 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. 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) {
+ if (self.currentCallback == callback) {
+ self.currentCallback = nil;
+ self.currentSignal = nil;
+ }
+ }
+ 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);
+ }
+ dispatch_semaphore_t signal;
+ @synchronized(self) {
+ self.streamRunning = NO;
+ signal = self.currentSignal;
+ self.currentCallback = nil;
+ self.currentSignal = nil;
+ }
+ if (signal) {
+ dispatch_semaphore_signal(signal);
+ }
+}
+
+#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
diff --git a/src/platform/windows/display_vram.cpp b/src/platform/windows/display_vram.cpp
index d659c4bbed7..264e6c54bd3 100644
--- a/src/platform/windows/display_vram.cpp
+++ b/src/platform/windows/display_vram.cpp
@@ -1861,7 +1861,7 @@ namespace platf::dxgi {
amf_uint64 version;
auto result = fnAMFQueryVersion(&version);
if (result == AMF_OK) {
- if (config.videoFormat == 2 && version < AMF_MAKE_FULL_VERSION(1, 4, 30, 0)) {
+ if (config.videoFormat == video::SUNSHINE_FORMAT_AV1 && version < AMF_MAKE_FULL_VERSION(1, 4, 30, 0)) {
// AMF 1.4.30 adds ultra low latency mode for AV1. Don't use AV1 on earlier versions.
// This corresponds to driver version 23.5.2 (23.10.01.45) or newer.
BOOST_LOG(warning) << "AV1 encoding is disabled on AMF version "sv
@@ -1898,7 +1898,7 @@ namespace platf::dxgi {
return false;
}
if (config.chromaSamplingType == 1) {
- if (config.videoFormat == 0 || config.videoFormat == 2) {
+ if (config.videoFormat == video::SUNSHINE_FORMAT_H264 || config.videoFormat == video::SUNSHINE_FORMAT_AV1) {
// QSV doesn't support 4:4:4 in H.264 or AV1
return false;
}
diff --git a/src/rtsp.cpp b/src/rtsp.cpp
index 0953cd59f60..53af3c3fd5a 100644
--- a/src/rtsp.cpp
+++ b/src/rtsp.cpp
@@ -815,6 +815,12 @@ namespace rtsp_stream {
ss << "a=rtpmap:98 AV1/90000"sv << std::endl;
}
+ if (video::active_prores_mode > 0) {
+ ss << "a=x-sunshine-prores:1"sv << std::endl;
+ ss << "a=x-sunshine-prores-profile:"sv << config::video.prores_profile << std::endl;
+ ss << "a=rtpmap:99 prores/90000"sv << std::endl;
+ }
+
if (!session.surround_params.empty()) {
// If we have our own surround parameters, advertise them twice first
ss << "a=fmtp:97 surround-params="sv << session.surround_params << std::endl;
@@ -1115,20 +1121,39 @@ namespace rtsp_stream {
config.monitor.bitrate = (int) configuredBitrateKbps;
}
- if (config.monitor.videoFormat == 1 && video::active_hevc_mode == 1) {
+ if (video::active_prores_mode == 2 && config.monitor.videoFormat != video::SUNSHINE_FORMAT_PRORES) {
+ BOOST_LOG(warning) << "Forcing experimental ProRes because prores_mode is 2"sv;
+ config.monitor.videoFormat = video::SUNSHINE_FORMAT_PRORES;
+ }
+
+ if (!video::is_known_video_format(config.monitor.videoFormat)) {
+ BOOST_LOG(warning) << "Client requested unknown video format "sv << config.monitor.videoFormat;
+
+ respond(sock, session, &option, 400, "BAD REQUEST", req->sequenceNumber, {});
+ return;
+ }
+
+ if (config.monitor.videoFormat == video::SUNSHINE_FORMAT_HEVC && video::active_hevc_mode == 1) {
BOOST_LOG(warning) << "HEVC is disabled, yet the client requested HEVC"sv;
respond(sock, session, &option, 400, "BAD REQUEST", req->sequenceNumber, {});
return;
}
- if (config.monitor.videoFormat == 2 && video::active_av1_mode == 1) {
+ if (config.monitor.videoFormat == video::SUNSHINE_FORMAT_AV1 && video::active_av1_mode == 1) {
BOOST_LOG(warning) << "AV1 is disabled, yet the client requested AV1"sv;
respond(sock, session, &option, 400, "BAD REQUEST", req->sequenceNumber, {});
return;
}
+ if (!video::is_video_format_enabled_by_prores_gate(config.monitor.videoFormat, video::active_prores_mode)) {
+ BOOST_LOG(warning) << "Experimental ProRes is disabled, yet the client requested ProRes"sv;
+
+ respond(sock, session, &option, 400, "BAD REQUEST", req->sequenceNumber, {});
+ return;
+ }
+
// Check that any required encryption is enabled
auto encryption_mode = net::encryption_mode_for_address(sock.remote_endpoint().address());
if (encryption_mode == config::ENCRYPTION_MODE_MANDATORY &&
diff --git a/src/video.cpp b/src/video.cpp
index 00af66c3a69..a27faaa882b 100644
--- a/src/video.cpp
+++ b/src/video.cpp
@@ -119,6 +119,26 @@ namespace video {
} // namespace qsv
+ int prores_profile_from_config() {
+ const auto &profile = config::video.prores_profile;
+ if (profile == "proxy"sv) {
+ return AV_PROFILE_PRORES_PROXY;
+ }
+ if (profile == "standard"sv) {
+ return AV_PROFILE_PRORES_STANDARD;
+ }
+ if (profile == "hq"sv) {
+ return AV_PROFILE_PRORES_HQ;
+ }
+ if (profile == "4444"sv) {
+ return AV_PROFILE_PRORES_4444;
+ }
+ if (profile == "xq"sv) {
+ return AV_PROFILE_PRORES_XQ;
+ }
+ return AV_PROFILE_PRORES_LT;
+ }
+
util::Either dxgi_init_avcodec_hardware_input_buffer(platf::avcodec_encode_device_t *);
util::Either vaapi_init_avcodec_hardware_input_buffer(platf::avcodec_encode_device_t *);
util::Either cuda_init_avcodec_hardware_input_buffer(platf::avcodec_encode_device_t *);
@@ -513,6 +533,7 @@ namespace video {
{}, // Fallback options
"h264_nvenc"s,
},
+ {},
PARALLEL_ENCODING | REF_FRAMES_INVALIDATION | YUV444_SUPPORT | ASYNC_TEARDOWN // flags
};
#elif !defined(__APPLE__)
@@ -610,6 +631,7 @@ namespace video {
{}, // Fallback options
"h264_nvenc"s,
},
+ {},
PARALLEL_ENCODING
};
#endif
@@ -720,6 +742,7 @@ namespace video {
},
"h264_qsv"s,
},
+ {},
PARALLEL_ENCODING | CBR_WITH_VBR | RELAXED_COMPLIANCE | NO_RC_BUF_LIMIT | YUV444_SUPPORT
};
@@ -826,6 +849,7 @@ namespace video {
},
"h264_amf"s,
},
+ {},
PARALLEL_ENCODING
};
@@ -883,6 +907,7 @@ namespace video {
{}, // Fallback options
"h264_mf"s,
},
+ {},
PARALLEL_ENCODING | FIXED_GOP_SIZE // MF encoder doesn't support on-demand IDR frames
};
#endif
@@ -954,6 +979,7 @@ namespace video {
{}, // Fallback options
"libx264"s,
},
+ {},
H264_ONLY | PARALLEL_ENCODING | ALWAYS_REPROBE | YUV444_SUPPORT
};
@@ -1011,6 +1037,7 @@ namespace video {
{}, // Fallback options
"h264_vaapi"s,
},
+ {},
// RC buffer size will be set in platform code if supported
LIMITED_GOP_SIZE | PARALLEL_ENCODING | NO_RC_BUF_LIMIT
};
@@ -1082,6 +1109,7 @@ namespace video {
{}, // Fallback options
"h264_vulkan"s,
},
+ {},
LIMITED_GOP_SIZE | PARALLEL_ENCODING
};
#endif // SUNSHINE_BUILD_VULKAN
@@ -1096,8 +1124,15 @@ namespace video {
AV_PIX_FMT_VIDEOTOOLBOX,
AV_PIX_FMT_NV12,
AV_PIX_FMT_P010,
- AV_PIX_FMT_NONE,
- AV_PIX_FMT_NONE,
+ // YUV 4:4:4 BiPlanar formats are required by prores_videotoolbox: the
+ // 422 family (proxy / lt / standard / hq) wants 4:2:2 or higher chroma
+ // and the 4444 family wants 4:4:4 natively. Feeding 4:4:4 P410 lets
+ // the encoder downsample to the chosen 422 profile internally. H.264
+ // and HEVC VideoToolbox will simply fail the 4:4:4 probe on Apple
+ // Silicon (hardware encoder is 4:2:0 only for those codecs) and the
+ // capability bit stays false for them, which is correct.
+ AV_PIX_FMT_NV24,
+ AV_PIX_FMT_P410,
vt_init_avcodec_hardware_input_buffer
),
{
@@ -1134,12 +1169,16 @@ namespace video {
},
{
// Common options
+ // Note: max_ref_frames is intentionally omitted for H.264 because
+ // VideoToolbox on Apple Silicon produces all-IDR output when
+ // ReferenceBufferCount=1 is set for H.264, causing massive bandwidth
+ // inflation (~3x) and frame drops. HEVC and AV1 are unaffected and
+ // retain max_ref_frames=1. See LizardByte/Sunshine#5013.
{
{"allow_sw"s, &config::video.vt.vt_allow_sw},
{"require_sw"s, &config::video.vt.vt_require_sw},
{"realtime"s, &config::video.vt.vt_realtime},
{"prio_speed"s, 1},
- {"max_ref_frames"s, 1},
},
{}, // SDR-specific options
{}, // HDR-specific options
@@ -1151,7 +1190,27 @@ namespace video {
},
"h264_videotoolbox"s,
},
- DEFAULT
+ {
+ // Common options
+ {
+ {"allow_sw"s, 0},
+ {"realtime"s, 1},
+ {"profile"s, &config::video.prores_profile},
+ },
+ {}, // SDR-specific options
+ {}, // HDR-specific options
+ {}, // YUV444 SDR-specific options
+ {}, // YUV444 HDR-specific options
+ {}, // Fallback options
+ "prores_videotoolbox"s,
+ },
+ // YUV444_SUPPORT enables the 4:4:4 probe path; only ProRes 4444 /
+ // 4444 XQ profiles consume it natively on macOS but the flag is
+ // per-encoder family rather than per-codec, so H.264 / HEVC will
+ // probe at 4:4:4 too and fall through with their YUV444 capability
+ // bit set false (Apple Silicon's hardware H.264/HEVC encoder is
+ // 4:2:0 only).
+ YUV444_SUPPORT
};
#endif
@@ -1179,8 +1238,9 @@ namespace video {
static encoder_t *chosen_encoder;
int active_hevc_mode;
int active_av1_mode;
+ int active_prores_mode;
bool last_encoder_probe_supported_ref_frames_invalidation = false;
- std::array last_encoder_probe_supported_yuv444_for_codec = {};
+ std::array last_encoder_probe_supported_yuv444_for_codec = {};
void reset_display(std::shared_ptr &disp, const platf::mem_type_e &type, const std::string &display_name, const config_t &config) {
// We try this twice, in case we still get an error on reinitialization
@@ -1675,13 +1735,13 @@ namespace video {
}
switch (config.videoFormat) {
- case 0:
+ case SUNSHINE_FORMAT_H264:
// 10-bit h264 encoding is not supported by our streaming protocol
assert(!config.dynamicRange);
ctx->profile = (config.chromaSamplingType == 1) ? AV_PROFILE_H264_HIGH_444_PREDICTIVE : AV_PROFILE_H264_HIGH;
break;
- case 1:
+ case SUNSHINE_FORMAT_HEVC:
if (config.chromaSamplingType == 1) {
// HEVC uses the same RExt profile for both 8 and 10 bit YUV 4:4:4 encoding
ctx->profile = AV_PROFILE_HEVC_REXT;
@@ -1690,11 +1750,15 @@ namespace video {
}
break;
- case 2:
+ case SUNSHINE_FORMAT_AV1:
// AV1 supports both 8 and 10 bit encoding with the same Main profile
// but YUV 4:4:4 sampling requires High profile
ctx->profile = (config.chromaSamplingType == 1) ? AV_PROFILE_AV1_HIGH : AV_PROFILE_AV1_MAIN;
break;
+
+ case SUNSHINE_FORMAT_PRORES:
+ ctx->profile = prores_profile_from_config();
+ break;
}
// B-frames delay decoder output, so never use them
@@ -1877,7 +1941,7 @@ namespace video {
}
if (!(encoder.flags & NO_RC_BUF_LIMIT)) {
- if (!hardware && (ctx->slices > 1 || config.videoFormat == 1)) {
+ if (!hardware && (ctx->slices > 1 || config.videoFormat == SUNSHINE_FORMAT_HEVC)) {
// Use a larger rc_buffer_size for software encoding when slices are enabled,
// because libx264 can severely degrade quality if the buffer is too small.
// libx265 encounters this issue more frequently, so always scale the
@@ -1989,7 +2053,7 @@ namespace video {
std::move(encode_device_final),
// 0 ==> don't inject, 1 ==> inject for h264, 2 ==> inject for hevc
- config.videoFormat <= 1 ? (1 - (int) video_format[encoder_t::VUI_PARAMETERS]) * (1 + config.videoFormat) : 0
+ config.videoFormat <= SUNSHINE_FORMAT_HEVC ? (1 - (int) video_format[encoder_t::VUI_PARAMETERS]) * (1 + config.videoFormat) : 0
);
return session;
@@ -2598,9 +2662,9 @@ namespace video {
int flag = 0;
// This check only applies for H.264 and HEVC
- if (config.videoFormat <= 1) {
+ if (config.videoFormat <= SUNSHINE_FORMAT_HEVC) {
if (auto packet_avcodec = dynamic_cast(packet.get())) {
- if (cbs::validate_sps(packet_avcodec->av_packet, config.videoFormat ? AV_CODEC_ID_H265 : AV_CODEC_ID_H264)) {
+ if (cbs::validate_sps(packet_avcodec->av_packet, config.videoFormat == SUNSHINE_FORMAT_HEVC ? AV_CODEC_ID_H265 : AV_CODEC_ID_H264)) {
flag |= VUI_PARAMS;
}
} else {
@@ -2623,10 +2687,12 @@ namespace video {
auto test_hevc = active_hevc_mode >= 2 || (active_hevc_mode == 0 && !(encoder.flags & H264_ONLY));
auto test_av1 = active_av1_mode >= 2 || (active_av1_mode == 0 && !(encoder.flags & H264_ONLY));
+ auto test_prores = active_prores_mode > 0;
encoder.h264.capabilities.set();
encoder.hevc.capabilities.set();
encoder.av1.capabilities.set();
+ encoder.prores.capabilities.set();
// First, test encoder viability
config_t config_max_ref_frames {1920, 1080, 60, 6000, 1000, 1, 1, 1, 0, 0, 0};
@@ -2666,8 +2732,8 @@ namespace video {
encoder.h264[encoder_t::PASSED] = true;
if (test_hevc) {
- config_max_ref_frames.videoFormat = 1;
- config_autoselect.videoFormat = 1;
+ config_max_ref_frames.videoFormat = SUNSHINE_FORMAT_HEVC;
+ config_autoselect.videoFormat = SUNSHINE_FORMAT_HEVC;
if (disp->is_codec_supported(encoder.hevc.name, config_autoselect)) {
auto max_ref_frames_hevc = validate_config(disp, encoder, config_max_ref_frames);
@@ -2694,8 +2760,8 @@ namespace video {
}
if (test_av1) {
- config_max_ref_frames.videoFormat = 2;
- config_autoselect.videoFormat = 2;
+ config_max_ref_frames.videoFormat = SUNSHINE_FORMAT_AV1;
+ config_autoselect.videoFormat = SUNSHINE_FORMAT_AV1;
if (disp->is_codec_supported(encoder.av1.name, config_autoselect)) {
auto max_ref_frames_av1 = validate_config(disp, encoder, config_max_ref_frames);
@@ -2721,6 +2787,64 @@ namespace video {
encoder.av1.capabilities.reset();
}
+ if (test_prores) {
+ // ProRes profiles are intrinsically 10-bit (proxy / lt / standard / hq)
+ // or 12-bit (4444 / 4444 XQ) — there is no 8-bit ProRes input path in
+ // the FFmpeg encoder. Probe with dynamicRange = 1 so validate_config
+ // feeds the 10-bit pix_fmt (P010) to prores_videotoolbox rather than
+ // the 8-bit NV12 the H.264/HEVC probes use; otherwise the encoder
+ // legitimately refuses to open and PASSED stays false even though the
+ // downstream HDR probe would have succeeded.
+ config_t prores_max_ref_frames = config_max_ref_frames;
+ config_t prores_autoselect = config_autoselect;
+ prores_max_ref_frames.videoFormat = SUNSHINE_FORMAT_PRORES;
+ prores_autoselect.videoFormat = SUNSHINE_FORMAT_PRORES;
+ prores_max_ref_frames.dynamicRange = 1;
+ prores_autoselect.dynamicRange = 1;
+ // encoderCscMode = 3 (full range, BT.709) — prores_videotoolbox rejects
+ // the BT.601 colorspace the default SDR config carries (encoderCscMode
+ // = 1) even at 10-bit, because ProRes was never intended for SD content.
+ // BT.709 matches what test_hdr_and_yuv444 already uses for HDR probes
+ // and what the encoder actually expects. dynamicRange = 1 above promotes
+ // the color_trc to PQ where supported by the VT compression session;
+ // otherwise the encoder keeps BT.709 SDR tags, which it also accepts.
+ prores_max_ref_frames.encoderCscMode = 3;
+ prores_autoselect.encoderCscMode = 3;
+ // chromaSamplingType = 1 (4:4:4) selects the P410 pix_fmt slot.
+ // prores_videotoolbox's supported input pix_fmt list does not include
+ // 4:2:0 BiPlanar formats (P010) for any profile — the 422 family
+ // (proxy / lt / standard / hq) wants 4:2:2 or higher chroma, and the
+ // 4444 family wants 4:4:4 natively. Feeding 4:4:4 P410 lets the
+ // encoder downsample to the selected profile internally; feeding
+ // P010 makes it refuse to open with "Couldn't open".
+ prores_max_ref_frames.chromaSamplingType = 1;
+ prores_autoselect.chromaSamplingType = 1;
+
+ if (disp->is_codec_supported(encoder.prores.name, prores_autoselect)) {
+ auto max_ref_frames_prores = validate_config(disp, encoder, prores_max_ref_frames);
+ auto autoselect_prores = max_ref_frames_prores >= 0 ?
+ max_ref_frames_prores :
+ validate_config(disp, encoder, prores_autoselect);
+
+ encoder.prores[encoder_t::REF_FRAMES_RESTRICT] = max_ref_frames_prores >= 0;
+ encoder.prores[encoder_t::PASSED] = max_ref_frames_prores >= 0 || autoselect_prores >= 0;
+
+ // Any ProRes probe that succeeds inherently uses 10-bit input, so
+ // promote DYNAMIC_RANGE here. test_hdr_and_yuv444 below gates on
+ // PASSED and only sets DYNAMIC_RANGE itself; setting it eagerly here
+ // makes the encoder's capability advertisement consistent for clients
+ // that opt into ProRes via prores_mode > 0.
+ if (encoder.prores[encoder_t::PASSED]) {
+ encoder.prores[encoder_t::DYNAMIC_RANGE] = true;
+ }
+ } else {
+ BOOST_LOG(info) << "Encoder ["sv << encoder.prores.name << "] is not supported on this GPU"sv;
+ encoder.prores.capabilities.reset();
+ }
+ } else {
+ encoder.prores.capabilities.reset();
+ }
+
// Test HDR and YUV444 support
{
// H.264 is special because encoders may support YUV 4:4:4 without supporting 10-bit color depth
@@ -2772,8 +2896,9 @@ namespace video {
// HDR is not supported with H.264. Don't bother even trying it.
encoder.h264[encoder_t::DYNAMIC_RANGE] = false;
- test_hdr_and_yuv444(encoder.hevc, 1);
- test_hdr_and_yuv444(encoder.av1, 2);
+ test_hdr_and_yuv444(encoder.hevc, SUNSHINE_FORMAT_HEVC);
+ test_hdr_and_yuv444(encoder.av1, SUNSHINE_FORMAT_AV1);
+ test_hdr_and_yuv444(encoder.prores, SUNSHINE_FORMAT_PRORES);
}
encoder.h264[encoder_t::VUI_PARAMETERS] = encoder.h264[encoder_t::VUI_PARAMETERS] && !config::sunshine.flags[config::flag::FORCE_VIDEO_HEADER_REPLACE];
@@ -2808,6 +2933,15 @@ namespace video {
chosen_encoder = nullptr;
active_hevc_mode = config::video.hevc_mode;
active_av1_mode = config::video.av1_mode;
+ active_prores_mode = config::video.prores_mode;
+ // Bind `require_prores` to the user-configured value, NOT to the
+ // mutable `active_prores_mode` global. `adjust_encoder_constraints`
+ // below may demote `active_prores_mode` to 0 when no encoder supports
+ // ProRes; reading from the immutable config source keeps the
+ // "user explicitly asked for forced ProRes" intent intact across that
+ // demotion, so we can fail loudly instead of silently picking a
+ // non-ProRes encoder.
+ const bool require_prores = config::video.prores_mode >= 2;
last_encoder_probe_supported_ref_frames_invalidation = false;
auto adjust_encoder_constraints = [&](encoder_t *encoder) {
@@ -2827,6 +2961,11 @@ namespace video {
BOOST_LOG(warning) << "Encoder ["sv << encoder->name << "] does not support AV1 on this system"sv;
active_av1_mode = 0;
}
+
+ if (active_prores_mode > 0 && !encoder->prores[encoder_t::PASSED]) {
+ BOOST_LOG(warning) << "Encoder ["sv << encoder->name << "] does not support experimental ProRes on this system"sv;
+ active_prores_mode = 0;
+ }
};
if (!config::video.encoder.empty()) {
@@ -2859,7 +2998,7 @@ namespace video {
BOOST_LOG(info) << "// Testing for available encoders, this may generate errors. You can safely ignore those errors. //"sv;
// If we haven't found an encoder yet, but we want one with specific codec support, search for that now.
- if (chosen_encoder == nullptr && (active_hevc_mode >= 2 || active_av1_mode >= 2)) {
+ if (chosen_encoder == nullptr && (active_hevc_mode >= 2 || active_av1_mode >= 2 || require_prores)) {
KITTY_WHILE_LOOP(auto pos = std::begin(encoder_list), pos != std::end(encoder_list), {
auto encoder = *pos;
@@ -2870,7 +3009,9 @@ namespace video {
}
// Skip it if it doesn't support the specified codec at all
- if ((active_hevc_mode >= 2 && !encoder->hevc[encoder_t::PASSED]) || (active_av1_mode >= 2 && !encoder->av1[encoder_t::PASSED])) {
+ if ((active_hevc_mode >= 2 && !encoder->hevc[encoder_t::PASSED]) ||
+ (active_av1_mode >= 2 && !encoder->av1[encoder_t::PASSED]) ||
+ (require_prores && !encoder->prores[encoder_t::PASSED])) {
pos++;
continue;
}
@@ -2886,7 +3027,7 @@ namespace video {
});
if (chosen_encoder == nullptr) {
- BOOST_LOG(error) << "Couldn't find any working encoder that meets HEVC/AV1 requirements"sv;
+ BOOST_LOG(error) << "Couldn't find any working encoder that meets HEVC/AV1/forced-ProRes requirements"sv;
}
}
@@ -2930,12 +3071,14 @@ namespace video {
auto &encoder = *chosen_encoder;
last_encoder_probe_supported_ref_frames_invalidation = (encoder.flags & REF_FRAMES_INVALIDATION);
- last_encoder_probe_supported_yuv444_for_codec[0] = encoder.h264[encoder_t::PASSED] &&
- encoder.h264[encoder_t::YUV444];
- last_encoder_probe_supported_yuv444_for_codec[1] = encoder.hevc[encoder_t::PASSED] &&
- encoder.hevc[encoder_t::YUV444];
- last_encoder_probe_supported_yuv444_for_codec[2] = encoder.av1[encoder_t::PASSED] &&
- encoder.av1[encoder_t::YUV444];
+ last_encoder_probe_supported_yuv444_for_codec[SUNSHINE_FORMAT_H264] = encoder.h264[encoder_t::PASSED] &&
+ encoder.h264[encoder_t::YUV444];
+ last_encoder_probe_supported_yuv444_for_codec[SUNSHINE_FORMAT_HEVC] = encoder.hevc[encoder_t::PASSED] &&
+ encoder.hevc[encoder_t::YUV444];
+ last_encoder_probe_supported_yuv444_for_codec[SUNSHINE_FORMAT_AV1] = encoder.av1[encoder_t::PASSED] &&
+ encoder.av1[encoder_t::YUV444];
+ last_encoder_probe_supported_yuv444_for_codec[SUNSHINE_FORMAT_PRORES] = encoder.prores[encoder_t::PASSED] &&
+ encoder.prores[encoder_t::YUV444];
BOOST_LOG(debug) << "------ h264 ------"sv;
for (int x = 0; x < encoder_t::MAX_FLAGS; ++x) {
@@ -2967,6 +3110,17 @@ namespace video {
BOOST_LOG(info) << "Found AV1 encoder: "sv << encoder.av1.name << " ["sv << encoder.name << ']';
}
+ if (encoder.prores[encoder_t::PASSED]) {
+ BOOST_LOG(debug) << "------ prores -----"sv;
+ for (int x = 0; x < encoder_t::MAX_FLAGS; ++x) {
+ auto flag = (encoder_t::flag_e) x;
+ BOOST_LOG(debug) << encoder_t::from_flag(flag) << (encoder.prores[flag] ? ": supported"sv : ": unsupported"sv);
+ }
+ BOOST_LOG(debug) << "-------------------"sv;
+
+ BOOST_LOG(info) << "Found ProRes encoder: "sv << encoder.prores.name << " ["sv << encoder.name << ']';
+ }
+
if (active_hevc_mode == 0) {
active_hevc_mode = encoder.hevc[encoder_t::PASSED] ? (encoder.hevc[encoder_t::DYNAMIC_RANGE] ? 3 : 2) : 1;
}
@@ -2975,6 +3129,10 @@ namespace video {
active_av1_mode = encoder.av1[encoder_t::PASSED] ? (encoder.av1[encoder_t::DYNAMIC_RANGE] ? 3 : 2) : 1;
}
+ if (active_prores_mode > 0 && !encoder.prores[encoder_t::PASSED]) {
+ active_prores_mode = 0;
+ }
+
return 0;
}
@@ -3170,6 +3328,10 @@ namespace video {
return platf::pix_fmt_e::nv12;
case AV_PIX_FMT_P010:
return platf::pix_fmt_e::p010;
+ case AV_PIX_FMT_NV24:
+ return platf::pix_fmt_e::nv24;
+ case AV_PIX_FMT_P410:
+ return platf::pix_fmt_e::p410;
default:
return platf::pix_fmt_e::unknown;
}
diff --git a/src/video.h b/src/video.h
index 8fa25850036..d3d28fb1c5a 100644
--- a/src/video.h
+++ b/src/video.h
@@ -19,6 +19,28 @@ struct AVPacket;
namespace video {
+ inline constexpr int SUNSHINE_FORMAT_H264 = 0;
+ inline constexpr int SUNSHINE_FORMAT_HEVC = 1;
+ inline constexpr int SUNSHINE_FORMAT_AV1 = 2;
+ inline constexpr int SUNSHINE_FORMAT_PRORES = 3;
+ inline constexpr std::size_t SUNSHINE_FORMAT_COUNT = 4;
+
+ static_assert(SUNSHINE_FORMAT_H264 == 0);
+ static_assert(SUNSHINE_FORMAT_HEVC == 1);
+ static_assert(SUNSHINE_FORMAT_AV1 == 2);
+ static_assert(SUNSHINE_FORMAT_PRORES == 3);
+
+ inline bool is_known_video_format(int video_format) {
+ // Express the upper bound via SUNSHINE_FORMAT_COUNT so adding a future
+ // codec is purely a matter of bumping the enum — no need to remember
+ // to update this predicate.
+ return video_format >= SUNSHINE_FORMAT_H264 && video_format < SUNSHINE_FORMAT_COUNT;
+ }
+
+ inline bool is_video_format_enabled_by_prores_gate(int video_format, int prores_mode) {
+ return video_format != SUNSHINE_FORMAT_PRORES || prores_mode > 0;
+ }
+
/* Encoding configuration requested by remote client */
struct config_t {
int width; // Video width in pixels
@@ -34,7 +56,7 @@ namespace video {
SDR encoding colorspace (encoderCscMode >> 1) : 0 - BT.601, 1 - BT.709, 2 - BT.2020 */
int encoderCscMode;
- int videoFormat; // 0 - H.264, 1 - HEVC, 2 - AV1
+ int videoFormat; // 0 - H.264, 1 - HEVC, 2 - AV1, 3 - ProRes experimental
/* Encoding color depth (bit depth): 0 - 8-bit, 1 - 10-bit
HDR encoding activates when color depth is higher than 8-bit and the display which is being captured is operating in HDR mode */
@@ -189,18 +211,21 @@ namespace video {
codec_t av1;
codec_t hevc;
codec_t h264;
+ codec_t prores;
const codec_t &codec_from_config(const config_t &config) const {
switch (config.videoFormat) {
default:
BOOST_LOG(error) << "Unknown video format " << config.videoFormat << ", falling back to H.264";
// fallthrough
- case 0:
+ case SUNSHINE_FORMAT_H264:
return h264;
- case 1:
+ case SUNSHINE_FORMAT_HEVC:
return hevc;
- case 2:
+ case SUNSHINE_FORMAT_AV1:
return av1;
+ case SUNSHINE_FORMAT_PRORES:
+ return prores;
}
}
@@ -343,8 +368,9 @@ namespace video {
extern int active_hevc_mode;
extern int active_av1_mode;
+ extern int active_prores_mode;
extern bool last_encoder_probe_supported_ref_frames_invalidation;
- extern std::array last_encoder_probe_supported_yuv444_for_codec; // 0 - H.264, 1 - HEVC, 2 - AV1
+ extern std::array last_encoder_probe_supported_yuv444_for_codec;
void capture(
safe::mail_t mail,
diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html
index 27d84205dc4..466191a6e88 100644
--- a/src_assets/common/assets/web/config.html
+++ b/src_assets/common/assets/web/config.html
@@ -277,6 +277,7 @@