From bb3d2be35fcc5004ee27dfcfd959398b64ab2f11 Mon Sep 17 00:00:00 2001 From: marton Date: Sat, 15 Aug 2026 21:08:49 -0400 Subject: [PATCH 1/6] feat(stream): add max_stream_resolution option to cap encode resolution Add a host-side cap on the streamed video resolution. Client requests exceeding the cap are downscaled preserving aspect ratio, with even dimensions. The cap is applied both to the RTSP viewport (encode size) and to the launch session (so display device configuration switches the host display to the actual streamed size rather than the uncapped request). (cherry picked from commit 8868e2199f55508cf0cc1582955eea202ae26c9b) --- docs/configuration.md | 25 +++++++++++++ src/config.cpp | 36 +++++++++++++++++++ src/config.h | 14 ++++++++ src/nvhttp.cpp | 4 +++ src/rtsp.cpp | 4 +++ .../assets/web/configs/config_tabs.json | 1 + .../assets/web/public/assets/locale/en.json | 2 ++ 7 files changed, 86 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 2d81d8c0dd0..b1daefe044e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1518,6 +1518,31 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin +### max_stream_resolution + + + + + + + + + + + + + + +
Description + The maximum resolution (formatted as WIDTHxHEIGHT) that Sunshine will stream at. Larger client requests + are downscaled, preserving aspect ratio. Lowering this reduces encoding latency on hosts where the + encoder cannot keep up with the requested resolution. If empty, the resolution requested by Moonlight + is used. +
Default@code{} + @endcode
Example@code{} + max_stream_resolution = 2560x1440 + @endcode
+ ### minimum_fps_target diff --git a/src/config.cpp b/src/config.cpp index de28fe97c6f..8966071ce53 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -713,6 +713,9 @@ namespace config { 0, // av1_mode 2, // min_threads + + 0, // max_stream_width + 0, // max_stream_height { "superfast"s, // preset "zerolatency"s, // tune @@ -1589,6 +1592,26 @@ namespace config { } } + bool cap_stream_resolution(int &width, int &height) { + if (video.max_stream_width <= 0 || video.max_stream_height <= 0 || width <= 0 || height <= 0 || + (width <= video.max_stream_width && height <= video.max_stream_height)) { + return false; + } + + const double scale = std::min( + (double) video.max_stream_width / width, + (double) video.max_stream_height / height + ); + // Encoders and decoders expect even dimensions + const int capped_width = ((int) (width * scale)) & ~1; + const int capped_height = ((int) (height * scale)) & ~1; + BOOST_LOG(info) << "Capping stream resolution: "sv << width << 'x' << height + << " -> "sv << capped_width << 'x' << capped_height; + width = capped_width; + height = capped_height; + return true; + } + /** * @brief Apply parsed configuration entries to the global runtime configuration. * @@ -1601,6 +1624,19 @@ namespace config { int_between_f(vars, "hevc_mode", video.hevc_mode, {0, 3}); int_between_f(vars, "av1_mode", video.av1_mode, {0, 3}); int_f(vars, "min_threads", video.min_threads); + + std::string max_stream_resolution; + string_f(vars, "max_stream_resolution", max_stream_resolution); + if (!max_stream_resolution.empty()) { + int w = 0, h = 0; + if (std::sscanf(max_stream_resolution.c_str(), "%dx%d", &w, &h) == 2 && w > 0 && h > 0) { + video.max_stream_width = w; + video.max_stream_height = h; + } else { + BOOST_LOG(warning) << "max_stream_resolution must be formatted as x, e.g. 2560x1440: "sv << max_stream_resolution; + } + } + string_f(vars, "sw_preset", video.sw.sw_preset); if (!video.sw.sw_preset.empty()) { video.sw.svtav1_preset = sw::svtav1_preset_from_view(video.sw.sw_preset); diff --git a/src/config.h b/src/config.h index ead9e9812db..dc890e2ee40 100644 --- a/src/config.h +++ b/src/config.h @@ -78,6 +78,9 @@ namespace config { int min_threads; ///< Minimum number of threads or slices for CPU encoding. + int max_stream_width; ///< Maximum streamed video width; larger client requests are downscaled. 0 disables the cap. + int max_stream_height; ///< Maximum streamed video height; larger client requests are downscaled. 0 disables the cap. + struct { std::string sw_preset; ///< FFmpeg software-encoder preset. std::string sw_tune; ///< FFmpeg software-encoder tuning profile. @@ -458,4 +461,15 @@ namespace config { * @return True when the `all` preference was persisted and applied; otherwise, false. */ bool select_all_gamepad_drivers_if_licensed(bool virtualhid_licensed); + + /** + * @brief Apply the configured stream resolution cap to a client-requested resolution. + * + * The result preserves the aspect ratio of the request and is rounded down to even + * dimensions as required by encoders and decoders. + * @param width Client-requested width, updated in place when the cap applies. + * @param height Client-requested height, updated in place when the cap applies. + * @return `true` when the resolution was capped. + */ + bool cap_stream_resolution(int &width, int &height); } // namespace config diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index bf1bf6a6c80..7601cb89690 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -509,6 +509,10 @@ namespace nvhttp { } x++; } + // Cap the session resolution up front so downstream consumers (e.g. the display device + // configuration that switches the host display mode) target the actual streamed size + // instead of the uncapped client request. + config::cap_stream_resolution(launch_session->width, launch_session->height); launch_session->unique_id = (get_arg(args, "uniqueid", "unknown")); launch_session->appid = (int) util::from_view(get_arg(args, "appid", "unknown")); launch_session->enable_sops = util::from_view(get_arg(args, "sops", "0")); diff --git a/src/rtsp.cpp b/src/rtsp.cpp index 4532c2b09c5..cd2dd9e86de 100644 --- a/src/rtsp.cpp +++ b/src/rtsp.cpp @@ -1134,6 +1134,10 @@ namespace rtsp_stream { config.monitor.height = (int) util::from_view(args.at("x-nv-video[0].clientViewportHt"sv)); config.monitor.width = (int) util::from_view(args.at("x-nv-video[0].clientViewportWd"sv)); + + // Optionally cap the encode resolution below what the client requested: encoder latency + // scales with pixel count, and the capture backend already scales to the encode resolution. + config::cap_stream_resolution(config.monitor.width, config.monitor.height); config.monitor.framerate = (int) util::from_view(args.at("x-nv-video[0].maxFPS"sv)); config.monitor.framerateX100 = (int) util::from_view(args.at("x-nv-video[0].clientRefreshRateX100"sv)); // Validate framerateX100 against framerate. Some clients (e.g. Moonlight Android) send the diff --git a/src_assets/common/assets/web/configs/config_tabs.json b/src_assets/common/assets/web/configs/config_tabs.json index b1940b33d82..5a6154154f0 100644 --- a/src_assets/common/assets/web/configs/config_tabs.json +++ b/src_assets/common/assets/web/configs/config_tabs.json @@ -60,6 +60,7 @@ "refresh_rate_only": [] }, "max_bitrate": 0, + "max_stream_resolution": "", "minimum_fps_target": 0 } }, 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 23f5baa943c..75a2f5d6475 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -322,6 +322,8 @@ "log_path_desc": "The file where the current Sunshine log is stored. At startup, up to five previous logs are retained with .1 through .5 suffixes.", "max_bitrate": "Maximum Bitrate", "max_bitrate_desc": "The maximum bitrate (in Kbps) that Sunshine will encode the stream at. If set to 0, it will always use the bitrate requested by Moonlight.", + "max_stream_resolution": "Maximum Stream Resolution", + "max_stream_resolution_desc": "The maximum resolution (formatted as WIDTHxHEIGHT, e.g. 2560x1440) that Sunshine will stream at. Larger client requests are downscaled, preserving aspect ratio. Lowering this reduces encoding latency on hosts where the encoder cannot keep up with the requested resolution. If empty, the resolution requested by Moonlight is used.", "minimum_fps_target": "Minimum FPS Target", "minimum_fps_target_desc": "The lowest effective FPS a stream can reach. A value of 0 is treated as roughly half of the stream's FPS. A setting of 20 is recommended if you stream 24 or 30fps content.", "min_log_level": "Log Level", From 11423dfd31dc8e744af33d2a935fda05f7f01425 Mon Sep 17 00:00:00 2001 From: marton Date: Sun, 16 Aug 2026 00:01:20 -0400 Subject: [PATCH 2/6] fix(web): render max_stream_resolution input in the Audio/Video tab The option was registered in the config options map but had no input markup in the tab component, so it never appeared in the web UI. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KPZiLfh6KcTYcUtnydjZfC (cherry picked from commit bea1841c43ad344008ff6ce398051c063846e204) --- .../web/configs/tabs/audiovideo/DisplayModesSettings.vue | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayModesSettings.vue b/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayModesSettings.vue index fc5c310c1f2..e00219bac69 100644 --- a/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayModesSettings.vue +++ b/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayModesSettings.vue @@ -15,6 +15,13 @@ const config = ref(props.config)
{{ $t("config.max_bitrate_desc") }}
+ +
+ + +
{{ $t("config.max_stream_resolution_desc") }}
+
+
From 2e317a1adae8122be2a8e64592d75c33f780ab89 Mon Sep 17 00:00:00 2001 From: marton Date: Sun, 16 Aug 2026 00:32:41 -0400 Subject: [PATCH 3/6] refactor(config): parse max_stream_resolution with std::from_chars (cherry picked from commit 2b40aa91648cfdac88ef2d07ce3586737d4112a1) --- src/config.cpp | 45 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/src/config.cpp b/src/config.cpp index 8966071ce53..13a99937527 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -5,6 +5,7 @@ // standard includes #include #include +#include #include #include #include @@ -1612,6 +1613,40 @@ namespace config { return true; } + /** + * @brief Parse a `x` resolution string. + * + * @param value Resolution text such as `2560x1440`. + * @param width Parsed width, written only on success. + * @param height Parsed height, written only on success. + * @return `true` when the string is a valid positive resolution. + */ + bool parse_stream_resolution(const std::string &value, int &width, int &height) { + const auto separator = value.find('x'); + if (separator == std::string::npos) { + return false; + } + + const auto *const begin = value.data(); + const auto *const end = begin + value.size(); + + int parsed_width = 0; + const auto [width_end, width_ec] = std::from_chars(begin, begin + separator, parsed_width); + if (width_ec != std::errc {} || width_end != begin + separator || parsed_width <= 0) { + return false; + } + + int parsed_height = 0; + const auto [height_end, height_ec] = std::from_chars(begin + separator + 1, end, parsed_height); + if (height_ec != std::errc {} || height_end != end || parsed_height <= 0) { + return false; + } + + width = parsed_width; + height = parsed_height; + return true; + } + /** * @brief Apply parsed configuration entries to the global runtime configuration. * @@ -1627,14 +1662,8 @@ namespace config { std::string max_stream_resolution; string_f(vars, "max_stream_resolution", max_stream_resolution); - if (!max_stream_resolution.empty()) { - int w = 0, h = 0; - if (std::sscanf(max_stream_resolution.c_str(), "%dx%d", &w, &h) == 2 && w > 0 && h > 0) { - video.max_stream_width = w; - video.max_stream_height = h; - } else { - BOOST_LOG(warning) << "max_stream_resolution must be formatted as x, e.g. 2560x1440: "sv << max_stream_resolution; - } + if (!max_stream_resolution.empty() && !parse_stream_resolution(max_stream_resolution, video.max_stream_width, video.max_stream_height)) { + BOOST_LOG(warning) << "max_stream_resolution must be formatted as x, e.g. 2560x1440: "sv << max_stream_resolution; } string_f(vars, "sw_preset", video.sw.sw_preset); From d77e907d540a307df17b0f2b5389a09a6a138b9c Mon Sep 17 00:00:00 2001 From: marton Date: Sun, 16 Aug 2026 02:24:11 -0400 Subject: [PATCH 4/6] refactor(config): use string_view instead of const string (cherry picked from commit 07524f4c8430159522c871e413ec9bc497f2548b) --- src/config.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.cpp b/src/config.cpp index 13a99937527..b362d552c45 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -1621,7 +1621,7 @@ namespace config { * @param height Parsed height, written only on success. * @return `true` when the string is a valid positive resolution. */ - bool parse_stream_resolution(const std::string &value, int &width, int &height) { + bool parse_stream_resolution(const std::string_view &value, int &width, int &height) { const auto separator = value.find('x'); if (separator == std::string::npos) { return false; From 04b6b3c8439f30eb8b1a8dc2be65c73d72386734 Mon Sep 17 00:00:00 2001 From: marton Date: Fri, 13 Nov 2026 00:12:08 -0500 Subject: [PATCH 5/6] refactor(macos): use integer math to scale screen resolution cap; add related tests --- src/config.cpp | 19 ++++---- tests/unit/test_config.cpp | 96 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/config.cpp b/src/config.cpp index b362d552c45..81a9ebbc4c7 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -1594,18 +1595,20 @@ namespace config { } bool cap_stream_resolution(int &width, int &height) { - if (video.max_stream_width <= 0 || video.max_stream_height <= 0 || width <= 0 || height <= 0 || - (width <= video.max_stream_width && height <= video.max_stream_height)) { + if (video.max_stream_width <= 0 || video.max_stream_height <= 0 || width <= 0 || height <= 0 || (width <= video.max_stream_width && height <= video.max_stream_height)) { return false; } - const double scale = std::min( - (double) video.max_stream_width / width, - (double) video.max_stream_height / height - ); + // Scale by whichever dimension is further over its limit, in integer math so that dimension + // lands exactly on the cap. + const std::int64_t w = width; + const std::int64_t h = height; + const bool width_limited = w * video.max_stream_height >= h * video.max_stream_width; + const auto scaled_width = width_limited ? video.max_stream_width : static_cast(w * video.max_stream_height / h); + const auto scaled_height = width_limited ? static_cast(h * video.max_stream_width / w) : video.max_stream_height; // Encoders and decoders expect even dimensions - const int capped_width = ((int) (width * scale)) & ~1; - const int capped_height = ((int) (height * scale)) & ~1; + const int capped_width = std::max(2, scaled_width & ~1); + const int capped_height = std::max(2, scaled_height & ~1); BOOST_LOG(info) << "Capping stream resolution: "sv << width << 'x' << height << " -> "sv << capped_width << 'x' << capped_height; width = capped_width; diff --git a/tests/unit/test_config.cpp b/tests/unit/test_config.cpp index 5cff436cf65..85f12e316bc 100644 --- a/tests/unit/test_config.cpp +++ b/tests/unit/test_config.cpp @@ -324,3 +324,99 @@ TEST_F(ConfigPersistenceTest, ParsesNvencTwoPassModesAndDefaultsUnknownValues) { EXPECT_EQ(config::video.nv.two_pass, expected) << setting; } } + +TEST_F(ConfigPersistenceTest, ParsesMaxStreamResolutionAndRejectsMalformedValues) { + const auto original_video = config::video; + const auto restore_video = util::fail_guard([original_video]() { + config::video = original_video; + }); + const auto apps_file = config_file().parent_path() / "sunshine_test_config_apps.json"; + ASSERT_EQ(file_handler::write_file(apps_file.string().c_str(), "{}"), 0); + config::stream.file_apps = apps_file.string(); + + config::video.max_stream_width = 0; + config::video.max_stream_height = 0; + config::apply_config_for_test("max_stream_resolution = 2560x1440\n"); + EXPECT_EQ(config::video.max_stream_width, 2560); + EXPECT_EQ(config::video.max_stream_height, 1440); + + constexpr std::array malformed { + "2560"sv, + "2560x"sv, + "x1440"sv, + "0x1440"sv, + "2560x0"sv, + "-2560x1440"sv, + "2560x1440x"sv, + "2560 x 1440"sv, + "widthxheight"sv, + }; + for (const auto setting : malformed) { + config::video.max_stream_width = 1; + config::video.max_stream_height = 1; + config::apply_config_for_test(std::string {"max_stream_resolution = "} + std::string {setting} + "\n"); + EXPECT_EQ(config::video.max_stream_width, 1) << setting; + EXPECT_EQ(config::video.max_stream_height, 1) << setting; + } +} + +/** + * @brief Expected outcome of capping one client-requested resolution. + */ +struct StreamResolutionCapCase { + int max_width; ///< Configured maximum width. + int max_height; ///< Configured maximum height. + int width; ///< Client-requested width. + int height; ///< Client-requested height. + bool capped; ///< Whether the cap applies. + int expected_width; ///< Width after capping. + int expected_height; ///< Height after capping. +}; + +/** + * @brief Parameterized fixture for config::cap_stream_resolution. + */ +struct StreamResolutionCapTest: testing::TestWithParam {}; + +TEST_P(StreamResolutionCapTest, CapsPreservingAspectRatioWithEvenDimensions) { + const auto original_video = config::video; + const auto restore_video = util::fail_guard([original_video]() { + config::video = original_video; + }); + const auto ¶m = GetParam(); + config::video.max_stream_width = param.max_width; + config::video.max_stream_height = param.max_height; + + int width = param.width; + int height = param.height; + EXPECT_EQ(config::cap_stream_resolution(width, height), param.capped); + EXPECT_EQ(width, param.expected_width); + EXPECT_EQ(height, param.expected_height); +} + +INSTANTIATE_TEST_SUITE_P( + ConfigCapStreamResolution, + StreamResolutionCapTest, + testing::Values( + // Disabled cap + StreamResolutionCapCase {0, 0, 3840, 2160, false, 3840, 2160}, + StreamResolutionCapCase {2560, 0, 3840, 2160, false, 3840, 2160}, + // Requests within the cap are untouched + StreamResolutionCapCase {2560, 1440, 2560, 1440, false, 2560, 1440}, + StreamResolutionCapCase {2560, 1440, 1920, 1080, false, 1920, 1080}, + // Invalid requests are untouched + StreamResolutionCapCase {2560, 1440, 0, 2160, false, 0, 2160}, + // Same aspect ratio lands exactly on the cap + StreamResolutionCapCase {2560, 1440, 3840, 2160, true, 2560, 1440}, + // Width-limited ultrawide: 1440 * 2560 / 3440 = 1071.6, rounded down to even + StreamResolutionCapCase {2560, 1440, 3440, 1440, true, 2560, 1070}, + // Height-limited portrait request + StreamResolutionCapCase {2560, 1440, 2160, 3840, true, 810, 1440}, + // A single dimension over the cap still scales both + StreamResolutionCapCase {1920, 1080, 1920, 1200, true, 1728, 1080}, + // Floating-point scaling used to land two pixels short of the cap here + StreamResolutionCapCase {1280, 720, 640, 1142, true, 402, 720}, + // Extreme aspect ratios never collapse to zero + StreamResolutionCapCase {100, 100, 10000, 2, true, 100, 2} + ) +); From 55bd3891d18fee0672c2baa94c3146d2b143ef92 Mon Sep 17 00:00:00 2001 From: marton Date: Sat, 14 Nov 2026 02:00:57 -0500 Subject: [PATCH 6/6] fix(macos): reject resolution cap dimensions below 2; expand documentation and tests --- src/config.cpp | 6 +++--- tests/unit/test_config.cpp | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/config.cpp b/src/config.cpp index 81a9ebbc4c7..77d2d466e3d 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -1622,7 +1622,7 @@ namespace config { * @param value Resolution text such as `2560x1440`. * @param width Parsed width, written only on success. * @param height Parsed height, written only on success. - * @return `true` when the string is a valid positive resolution. + * @return `true` when both dimensions are at least 2, the smallest even size the cap can produce. */ bool parse_stream_resolution(const std::string_view &value, int &width, int &height) { const auto separator = value.find('x'); @@ -1635,13 +1635,13 @@ namespace config { int parsed_width = 0; const auto [width_end, width_ec] = std::from_chars(begin, begin + separator, parsed_width); - if (width_ec != std::errc {} || width_end != begin + separator || parsed_width <= 0) { + if (width_ec != std::errc {} || width_end != begin + separator || parsed_width < 2) { return false; } int parsed_height = 0; const auto [height_end, height_ec] = std::from_chars(begin + separator + 1, end, parsed_height); - if (height_ec != std::errc {} || height_end != end || parsed_height <= 0) { + if (height_ec != std::errc {} || height_end != end || parsed_height < 2) { return false; } diff --git a/tests/unit/test_config.cpp b/tests/unit/test_config.cpp index 85f12e316bc..f3bb7ceaf1c 100644 --- a/tests/unit/test_config.cpp +++ b/tests/unit/test_config.cpp @@ -325,6 +325,9 @@ TEST_F(ConfigPersistenceTest, ParsesNvencTwoPassModesAndDefaultsUnknownValues) { } } +/** + * @brief Verify resolution-cap parsing rejects malformed values without changing the configured dimensions. + */ TEST_F(ConfigPersistenceTest, ParsesMaxStreamResolutionAndRejectsMalformedValues) { const auto original_video = config::video; const auto restore_video = util::fail_guard([original_video]() { @@ -346,6 +349,8 @@ TEST_F(ConfigPersistenceTest, ParsesMaxStreamResolutionAndRejectsMalformedValues "x1440"sv, "0x1440"sv, "2560x0"sv, + "1x1440"sv, + "2560x1"sv, "-2560x1440"sv, "2560x1440x"sv, "2560 x 1440"sv, @@ -378,6 +383,9 @@ struct StreamResolutionCapCase { */ struct StreamResolutionCapTest: testing::TestWithParam {}; +/** + * @brief Verify resolution caps preserve aspect ratio and produce even dimensions while leaving uncapped requests unchanged. + */ TEST_P(StreamResolutionCapTest, CapsPreservingAspectRatioWithEvenDimensions) { const auto original_video = config::video; const auto restore_video = util::fail_guard([original_video]() {