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
Choices0disabled
1accept an explicit ProRes request from a custom client
2force 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
ChoicesproxyProRes 422 Proxy
ltProRes 422 LT
standardProRes 422
hqProRes 422 HQ
4444ProRes 4444
xqProRes 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 @@

{{ $t('config.configuration') }}

"min_threads": 2, "hevc_mode": 0, "av1_mode": 0, + "prores_mode": 0, "capture": "", "encoder": "", }, @@ -325,6 +326,7 @@

{{ $t('config.configuration') }}

"vt_coder": "auto", "vt_software": "auto", "vt_realtime": "enabled", + "prores_profile": "lt", }, }, { diff --git a/src_assets/common/assets/web/configs/tabs/encoders/VideotoolboxEncoder.vue b/src_assets/common/assets/web/configs/tabs/encoders/VideotoolboxEncoder.vue index 084e5f1def0..3a856614949 100644 --- a/src_assets/common/assets/web/configs/tabs/encoders/VideotoolboxEncoder.vue +++ b/src_assets/common/assets/web/configs/tabs/encoders/VideotoolboxEncoder.vue @@ -37,6 +37,29 @@ const config = ref(props.config) v-model="config.vt_realtime" default="true" > + + +
+ + +
{{ $t('config.prores_mode_desc') }}
+
+
+ + +
{{ $t('config.prores_profile_desc') }}
+
diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index f4eee2cdccf..5059dcc5b48 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -153,6 +153,11 @@ "av1_mode_2": "Sunshine will advertise support for AV1 Main 8-bit profile", "av1_mode_3": "Sunshine will advertise support for AV1 Main 8-bit and 10-bit (HDR) profiles", "av1_mode_desc": "Allows the client to request AV1 Main 8-bit or 10-bit video streams. AV1 is more CPU-intensive to encode, so enabling this may reduce performance when using software encoding.", + "prores_mode": "Experimental ProRes Support", + "prores_mode_0": "Disabled (default)", + "prores_mode_1": "Accept an explicit ProRes request from a custom client", + "prores_mode_2": "Force ProRes for local development sessions", + "prores_mode_desc": "Allows custom clients to request experimental macOS ProRes VideoToolbox streams. This does not add stock Moonlight client decoder support.", "back_button_timeout": "Home/Guide Button Emulation Timeout", "back_button_timeout_desc": "If the Back/Select button is held down for the specified number of milliseconds, a Home/Guide button press is emulated. If set to a value < 0 (default), holding the Back/Select button will not emulate the Home/Guide button.", "bind_address": "Bind address", @@ -404,6 +409,14 @@ "virtual_sink_desc": "Manually specify a virtual audio device to use. If unset, the device is chosen automatically. We strongly recommend leaving this field blank to use automatic device selection!", "virtual_sink_placeholder": "Steam Streaming Speakers", "vt_coder": "VideoToolbox Coder", + "prores_profile": "ProRes Profile", + "prores_profile_4444": "4444", + "prores_profile_desc": "Sets the FFmpeg prores_videotoolbox profile when experimental ProRes is enabled.", + "prores_profile_hq": "HQ", + "prores_profile_lt": "LT (default)", + "prores_profile_proxy": "Proxy", + "prores_profile_standard": "Standard", + "prores_profile_xq": "XQ", "vt_realtime": "VideoToolbox Realtime Encoding", "vt_software": "VideoToolbox Software Encoding", "vt_software_allowed": "Allowed", diff --git a/tests/unit/test_video.cpp b/tests/unit/test_video.cpp index ed578f7d8db..8feb4578b51 100644 --- a/tests/unit/test_video.cpp +++ b/tests/unit/test_video.cpp @@ -4,8 +4,15 @@ */ #include "../tests_common.h" +#include #include +// SUNSHINE_FORMAT_* enumerator-value invariants are asserted next to the +// definitions in src/video.h:28-31; not duplicated here. The COUNT +// sentinel is the test-relevant invariant — it gates is_known_video_format +// and must follow PRORES. +static_assert(video::SUNSHINE_FORMAT_COUNT == video::SUNSHINE_FORMAT_PRORES + 1); + struct EncoderTest: PlatformTestSuite, testing::WithParamInterface { void SetUp() override { auto &encoder = *GetParam(); @@ -45,6 +52,63 @@ INSTANTIATE_TEST_SUITE_P( } ); +TEST(ProResConfigTest, DefaultsDisabled) { + EXPECT_EQ(config::video.prores_mode, 0); + EXPECT_EQ(config::video.prores_profile, "lt"); +} + +TEST(ProResConfigTest, ParsesExplicitModeAndProfile) { + auto mode = config::video.prores_mode; + auto profile = config::video.prores_profile; + + config::apply_config({ + {"prores_mode", "1"}, + {"prores_profile", "hq"}, + }); + + EXPECT_EQ(config::video.prores_mode, 1); + EXPECT_EQ(config::video.prores_profile, "hq"); + + config::video.prores_mode = mode; + config::video.prores_profile = profile; +} + +TEST(ProResConfigTest, NormalizesInvalidValues) { + auto mode = config::video.prores_mode; + auto profile = config::video.prores_profile; + + config::video.prores_mode = 0; + config::video.prores_profile = "lt"; + config::apply_config({ + {"prores_mode", "9"}, + {"prores_profile", "bad_profile"}, + }); + + EXPECT_EQ(config::video.prores_mode, 0); + EXPECT_EQ(config::video.prores_profile, "lt"); + + config::video.prores_mode = mode; + config::video.prores_profile = profile; +} + +TEST(ProResProtocolGateTest, KnownFormatsIncludeExperimentalProRes) { + EXPECT_TRUE(video::is_known_video_format(video::SUNSHINE_FORMAT_H264)); + EXPECT_TRUE(video::is_known_video_format(video::SUNSHINE_FORMAT_HEVC)); + EXPECT_TRUE(video::is_known_video_format(video::SUNSHINE_FORMAT_AV1)); + EXPECT_TRUE(video::is_known_video_format(video::SUNSHINE_FORMAT_PRORES)); + EXPECT_FALSE(video::is_known_video_format(video::SUNSHINE_FORMAT_PRORES + 1)); +} + +TEST(ProResProtocolGateTest, ProResRequestsAreRejectedWhenDisabled) { + EXPECT_TRUE(video::is_video_format_enabled_by_prores_gate(video::SUNSHINE_FORMAT_H264, 0)); + EXPECT_FALSE(video::is_video_format_enabled_by_prores_gate(video::SUNSHINE_FORMAT_PRORES, 0)); +} + +TEST(ProResProtocolGateTest, ProResRequestsAreAcceptedWhenEnabled) { + EXPECT_TRUE(video::is_video_format_enabled_by_prores_gate(video::SUNSHINE_FORMAT_PRORES, 1)); + EXPECT_TRUE(video::is_video_format_enabled_by_prores_gate(video::SUNSHINE_FORMAT_PRORES, 2)); +} + TEST_P(EncoderTest, ValidateEncoder) { // todo:: test something besides fixture setup }