diff --git a/docs/configuration.md b/docs/configuration.md index e03d9eccedf..a2d994ef55e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1420,6 +1420,31 @@ editing the `conf` file in a text editor. Use the examples as reference. +### 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 5392bca2ee0..827d28268ba 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -4,6 +4,7 @@ */ // standard includes #include +#include #include #include #include @@ -715,6 +716,9 @@ namespace config { 0, // av1_mode 2, // min_threads + + 0, // max_stream_width + 0, // max_stream_height { "superfast"s, // preset "zerolatency"s, // tune @@ -1572,6 +1576,60 @@ namespace config { * * @param vars Parsed configuration entries; consumed keys are erased. */ + 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 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_view &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; + } + void apply_config(std::unordered_map &&vars) { log_config_settings(vars, true); @@ -1579,6 +1637,13 @@ 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() && !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); 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 2795cf17958..f92bb4bb282 100644 --- a/src/config.h +++ b/src/config.h @@ -56,6 +56,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; std::string sw_tune; @@ -398,4 +401,14 @@ namespace config { * @return Parsed configuration key-value entries. */ std::unordered_map parse_config(const std::string_view &file_content); + /** + * @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 e260141a7d7..406a33f54b6 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -385,6 +385,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 dd8d4c9dfa1..0ea735b13a9 100644 --- a/src/rtsp.cpp +++ b/src/rtsp.cpp @@ -1181,6 +1181,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/config.html b/src_assets/common/assets/web/config.html index e62c61ed838..46820aa8c60 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -261,6 +261,7 @@

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

"dd_config_revert_on_disconnect": "disabled", "dd_mode_remapping": {"mixed": [], "resolution_only": [], "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 51b5b8c227c..46998f5bd02 100644 --- a/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayModesSettings.vue +++ b/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayModesSettings.vue @@ -18,6 +18,13 @@ const config = ref(props.config)
{{ $t("config.max_bitrate_desc") }}
+ +
+ + +
{{ $t("config.max_stream_resolution_desc") }}
+
+
diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 7de1459e7e1..967f6106e44 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -281,6 +281,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",