, 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/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") }}
+
+
{{ $t("config.max_stream_resolution_desc") }}
+
+
{{ $t("config.minimum_fps_target") }}
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",
diff --git a/tests/unit/test_config.cpp b/tests/unit/test_config.cpp
index 5cff436cf65..f3bb7ceaf1c 100644
--- a/tests/unit/test_config.cpp
+++ b/tests/unit/test_config.cpp
@@ -324,3 +324,107 @@ TEST_F(ConfigPersistenceTest, ParsesNvencTwoPassModesAndDefaultsUnknownValues) {
EXPECT_EQ(config::video.nv.two_pass, expected) << setting;
}
}
+
+/**
+ * @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]() {
+ 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,
+ "1x1440"sv,
+ "2560x1"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 {};
+
+/**
+ * @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]() {
+ 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}
+ )
+);