diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake
index 1d184929ac9..92c435c290c 100644
--- a/cmake/compile_definitions/macos.cmake
+++ b/cmake/compile_definitions/macos.cmake
@@ -36,6 +36,7 @@ list(APPEND SUNSHINE_EXTERNAL_LIBRARIES
${CORE_VIDEO_LIBRARY}
${FOUNDATION_LIBRARY}
${IOKIT_LIBRARY}
+ ${SCREEN_CAPTURE_KIT_LIBRARY}
${VIDEO_TOOLBOX_LIBRARY})
set(APPLE_PLIST_TEMPLATE "${SUNSHINE_SOURCE_ASSETS_DIR}/macos/build/Info.plist.in")
@@ -46,8 +47,6 @@ set(PLATFORM_TARGET_FILES
"${CMAKE_SOURCE_DIR}/src/platform/macos/av_audio.h"
"${CMAKE_SOURCE_DIR}/src/platform/macos/av_audio.mm"
"${CMAKE_SOURCE_DIR}/src/platform/macos/av_img_t.h"
- "${CMAKE_SOURCE_DIR}/src/platform/macos/av_video.h"
- "${CMAKE_SOURCE_DIR}/src/platform/macos/av_video.m"
"${CMAKE_SOURCE_DIR}/src/platform/macos/display.mm"
"${CMAKE_SOURCE_DIR}/src/platform/macos/input.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/macos/microphone.mm"
@@ -56,6 +55,16 @@ set(PLATFORM_TARGET_FILES
"${CMAKE_SOURCE_DIR}/src/platform/macos/nv12_zero_device.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/macos/nv12_zero_device.h"
"${CMAKE_SOURCE_DIR}/src/platform/macos/publish.cpp"
+ "${CMAKE_SOURCE_DIR}/src/platform/macos/sc_video.h"
+ "${CMAKE_SOURCE_DIR}/src/platform/macos/sc_video.m"
"${CMAKE_SOURCE_DIR}/third-party/TPCircularBuffer/TPCircularBuffer.c"
"${CMAKE_SOURCE_DIR}/third-party/TPCircularBuffer/TPCircularBuffer.h"
${APPLE_PLIST_FILE})
+
+# sc_video.m is written against ARC for clarity (SCK APIs are async/
+# block-heavy and benefit from ARC). The rest of the macOS Obj-C
+# sources remain MRC; objects flowing across the boundary follow the
+# standard +1-retain alloc/init convention so both modes interoperate.
+set_source_files_properties(
+ "${CMAKE_SOURCE_DIR}/src/platform/macos/sc_video.m"
+ PROPERTIES COMPILE_FLAGS "-fobjc-arc")
diff --git a/cmake/dependencies/macos.cmake b/cmake/dependencies/macos.cmake
index 4a027ef9dd5..b78800d0228 100644
--- a/cmake/dependencies/macos.cmake
+++ b/cmake/dependencies/macos.cmake
@@ -11,6 +11,14 @@ FIND_LIBRARY(CORE_VIDEO_LIBRARY CoreVideo)
FIND_LIBRARY(FOUNDATION_LIBRARY Foundation)
FIND_LIBRARY(IOKIT_LIBRARY IOKit)
FIND_LIBRARY(VIDEO_TOOLBOX_LIBRARY VideoToolbox)
+# ScreenCaptureKit is the modern (macOS 12.3+) replacement for the
+# deprecated AVCaptureScreenInput-based capture path. Sunshine's
+# sc_video.{h,m} is unconditionally compiled into the macOS target;
+# fail configure with a clear message rather than failing the build
+# later on header lookup when the SDK doesn't ship the framework
+# (e.g., when building with an Xcode older than 13.3 / SDK older than
+# 12.3, which dropped out of routine compatibility long ago).
+FIND_LIBRARY(SCREEN_CAPTURE_KIT_LIBRARY ScreenCaptureKit REQUIRED)
if(SUNSHINE_ENABLE_TRAY)
FIND_LIBRARY(COCOA Cocoa REQUIRED)
diff --git a/docs/changelog.md b/docs/changelog.md
index 9cf35f26907..4c89af85b58 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,11 @@
# Changelog
+## Unreleased
+
+- Added disabled-by-default experimental macOS ProRes VideoToolbox encoder
+ plumbing for custom clients. This is Sunshine-side protocol and encoder
+ support only and does not add stock Moonlight ProRes decoder compatibility.
+
@htmlonly
+### prores_mode
+
+
+
+
Description
+
+ Allows custom clients to request experimental macOS ProRes VideoToolbox video streams.
+ @warning{This does not add stock Moonlight client decoder support and should remain disabled unless
+ a custom client is explicitly being tested.}
+
+
+
+
Default
+
@code{}
+ 0
+ @endcode
+
+
+
Example
+
@code{}
+ prores_mode = 1
+ @endcode
+
+
+
Choices
+
0
+
disabled
+
+
+
1
+
accept an explicit ProRes request from a custom client
+
+
+
2
+
force ProRes for local development sessions
+
+
+
### capture
@@ -3004,6 +3042,55 @@ editing the `conf` file in a text editor. Use the examples as reference.
+### prores_profile
+
+
+
+
Description
+
+ Sets the FFmpeg `prores_videotoolbox` profile when experimental ProRes is enabled.
+ @note{This option only applies when using macOS.}
+
+
+
+
Default
+
@code{}
+ lt
+ @endcode
+
+
+
Example
+
@code{}
+ prores_profile = hq
+ @endcode
+
+
+
Choices
+
proxy
+
ProRes 422 Proxy
+
+
+
lt
+
ProRes 422 LT
+
+
+
standard
+
ProRes 422
+
+
+
hq
+
ProRes 422 HQ
+
+
+
4444
+
ProRes 4444
+
+
+
xq
+
ProRes 4444 XQ
+
+
+
## VA-API Encoder
### vaapi_strict_rc_buffer
diff --git a/src/config.cpp b/src/config.cpp
index 6d266c0ef22..b7af4bdbad2 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -350,6 +350,25 @@ namespace config {
} // namespace vt
+ namespace prores {
+
+ std::string profile_from_view(const std::string_view profile) {
+#define _CONVERT_(x) \
+ if (profile == #x##sv) \
+ return #x
+ _CONVERT_(proxy);
+ _CONVERT_(lt);
+ _CONVERT_(standard);
+ _CONVERT_(hq);
+ _CONVERT_(4444);
+ _CONVERT_(xq);
+#undef _CONVERT_
+ BOOST_LOG(warning) << "config: unknown prores_profile value: " << profile;
+ return "lt";
+ }
+
+ } // namespace prores
+
namespace sw {
int svtav1_preset_from_view(const std::string_view &preset) {
#define _CONVERT_(x, y) \
@@ -454,6 +473,8 @@ namespace config {
0, // hevc_mode
0, // av1_mode
+ 0, // prores_mode
+ "lt"s, // prores_profile
2, // min_threads
{
@@ -1101,6 +1122,8 @@ namespace config {
int_f(vars, "qp", video.qp);
int_between_f(vars, "hevc_mode", video.hevc_mode, {0, 3});
int_between_f(vars, "av1_mode", video.av1_mode, {0, 3});
+ int_between_f(vars, "prores_mode", video.prores_mode, {0, 2});
+ generic_f(vars, "prores_profile", video.prores_profile, prores::profile_from_view);
int_f(vars, "min_threads", video.min_threads);
string_f(vars, "sw_preset", video.sw.sw_preset);
if (!video.sw.sw_preset.empty()) {
diff --git a/src/config.h b/src/config.h
index e0e40501d0b..b999ff6ae19 100644
--- a/src/config.h
+++ b/src/config.h
@@ -31,6 +31,7 @@ namespace config {
};
void log_config_settings(const std::unordered_map &vars, bool save);
+ void apply_config(std::unordered_map &&vars);
struct video_t {
// ffmpeg params
@@ -38,6 +39,8 @@ namespace config {
int hevc_mode;
int av1_mode;
+ int prores_mode;
+ std::string prores_profile;
int min_threads; // Minimum number of threads/slices for CPU encoding
diff --git a/src/nvenc/nvenc_base.cpp b/src/nvenc/nvenc_base.cpp
index 13b2fa03a14..6029481f9f5 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 3f30a9804f4..fde8a1ec29b 100644
--- a/src/nvhttp.cpp
+++ b/src/nvhttp.cpp
@@ -770,6 +770,11 @@ namespace nvhttp {
const uint32_t codec_mode_flags = get_codec_mode_flags();
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 e0e87928c33..4ecc6755ed3 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
yuv444p, ///< Planar 8-bit YUV 4:4:4
@@ -258,6 +260,8 @@ namespace platf {
_CONVERT(yuv420p10);
_CONVERT(nv12);
_CONVERT(p010);
+ _CONVERT(nv24);
+ _CONVERT(p410);
_CONVERT(ayuv);
_CONVERT(yuv444p16);
_CONVERT(yuv444p);
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 5d6c9a7362f..11bbfe0bfab 100644
--- a/src/platform/macos/display.mm
+++ b/src/platform/macos/display.mm
@@ -16,9 +16,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
@@ -211,7 +211,7 @@ void wake_displays_for_detection(const std::string &display_name) {
} // namespace
struct av_display_t: public display_t {
- AVVideo *av_capture {};
+ SCVideo *av_capture {};
CGDirectDisplayID display_id {};
IOPMAssertionID display_sleep_assertion {kIOPMNullAssertionID};
@@ -301,10 +301,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 {
@@ -361,11 +369,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;
}
};
@@ -386,7 +394,7 @@ static void setPixelFormat(void *display, OSType pixelFormat) {
BOOST_LOG(info) << "Detecting displays"sv;
log_display_environment_diagnostics();
- auto display_array = [AVVideo displayNames];
+ auto display_array = [SCVideo displayNames];
bool matched_configured_display = display_name.empty();
for (NSDictionary *item in display_array) {
NSNumber *display_id = item[@"id"];
@@ -409,7 +417,19 @@ static void setPixelFormat(void *display, OSType pixelFormat) {
log_display_diagnostic(display->display_id, "selected for AVFoundation capture");
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;
@@ -428,7 +448,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/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 0900d999de6..36b8c6ed890 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 | YUV444_SUPPORT
};
#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
),
{
@@ -1155,7 +1190,27 @@ namespace video {
},
"h264_videotoolbox"s,
},
- PARALLEL_ENCODING
+ {
+ // 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).
+ PARALLEL_ENCODING | YUV444_SUPPORT
};
#endif
@@ -1183,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
@@ -1687,13 +1743,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;
@@ -1702,11 +1758,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
@@ -1889,7 +1949,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
@@ -2001,7 +2061,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;
@@ -2610,9 +2670,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 {
@@ -2635,10 +2695,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};
@@ -2678,8 +2740,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);
@@ -2706,8 +2768,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);
@@ -2733,6 +2795,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
{
auto test_yuv444 = [&](auto &flag_map, auto video_format) {
@@ -2810,6 +2930,9 @@ namespace video {
test_yuv444(encoder.av1, 2);
test_yuv420_hdr(encoder.av1, 2);
test_yuv444_hdr(encoder.av1, 2);
+ test_yuv444(encoder.prores, SUNSHINE_FORMAT_PRORES);
+ test_yuv420_hdr(encoder.prores, SUNSHINE_FORMAT_PRORES);
+ test_yuv444_hdr(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];
@@ -2844,6 +2967,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_hevc = [&](encoder_t *encoder) {
@@ -2878,6 +3010,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()) {
@@ -2911,7 +3048,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;
@@ -2923,7 +3060,8 @@ 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])) {
+ (active_av1_mode >= 2 && !encoder->av1[encoder_t::PASSED]) ||
+ (require_prores && !encoder->prores[encoder_t::PASSED])) {
pos++;
continue;
}
@@ -2954,7 +3092,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;
}
}
@@ -2999,12 +3137,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) {
@@ -3041,6 +3181,17 @@ namespace video {
// 4 - HDR yuv444
// 5 - HDR yuv420 & HDR yuv444
+ 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 = 1;
if (encoder.hevc[encoder_t::PASSED]) {
@@ -3069,6 +3220,10 @@ namespace video {
BOOST_LOG(debug) << "ENCODER STATUS ACTIVE_AV1_MODE: "sv << active_av1_mode;
}
+ if (active_prores_mode > 0 && !encoder.prores[encoder_t::PASSED]) {
+ active_prores_mode = 0;
+ }
+
return 0;
}
@@ -3268,6 +3423,10 @@ namespace video {
return platf::pix_fmt_e::yuv444p;
case AV_PIX_FMT_YUV444P16:
return platf::pix_fmt_e::yuv444p16;
+ 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 5b474d3f101..63c0c35ddc7 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 */
@@ -191,18 +213,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;
}
}
@@ -345,8 +370,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 @@