Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1518,6 +1518,31 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin
</tr>
</table>

### max_stream_resolution

<table>
<tr>
<td>Description</td>
<td colspan="2">
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.
</td>
</tr>
<tr>
<td>Default</td>
<td colspan="2">@code{}
@endcode</td>
</tr>
<tr>
<td>Example</td>
<td colspan="2">@code{}
max_stream_resolution = 2560x1440
@endcode</td>
</tr>
</table>

### minimum_fps_target

<table>
Expand Down
68 changes: 68 additions & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// standard includes
#include <algorithm>
#include <array>
#include <charconv>
#include <cstdint>
#include <filesystem>
#include <format>
#include <fstream>
Expand Down Expand Up @@ -713,6 +715,9 @@ namespace config {
0, // av1_mode

2, // min_threads

0, // max_stream_width
0, // max_stream_height
{
"superfast"s, // preset
"zerolatency"s, // tune
Expand Down Expand Up @@ -1589,6 +1594,62 @@ 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;
}

// 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<int>(w * video.max_stream_height / h);
const auto scaled_height = width_limited ? static_cast<int>(h * video.max_stream_width / w) : video.max_stream_height;
// Encoders and decoders expect even dimensions
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;
height = capped_height;
return true;
}

/**
* @brief Parse a `<width>x<height>` 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 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');
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 < 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 < 2) {
return false;
}

width = parsed_width;
height = parsed_height;
return true;
}

/**
* @brief Apply parsed configuration entries to the global runtime configuration.
*
Expand All @@ -1601,6 +1662,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 <width>x<height>, 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);
Expand Down
14 changes: 14 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions src/nvhttp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
4 changes: 4 additions & 0 deletions src/rtsp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src_assets/common/assets/web/configs/config_tabs.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"refresh_rate_only": []
},
"max_bitrate": 0,
"max_stream_resolution": "",
"minimum_fps_target": 0
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ const config = ref(props.config)
<div class="form-text">{{ $t("config.max_bitrate_desc") }}</div>
</div>

<!--max_stream_resolution-->
<div class="mb-3">
<label for="max_stream_resolution" class="form-label">{{ $t("config.max_stream_resolution") }}</label>
<input type="text" class="form-control" id="max_stream_resolution" placeholder="2560x1440" v-model="config.max_stream_resolution" />
<div class="form-text">{{ $t("config.max_stream_resolution_desc") }}</div>
</div>

<!--minimum_fps_target-->
<div class="mb-3">
<label for="minimum_fps_target" class="form-label">{{ $t("config.minimum_fps_target") }}</label>
Expand Down
2 changes: 2 additions & 0 deletions src_assets/common/assets/web/public/assets/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
104 changes: 104 additions & 0 deletions tests/unit/test_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,107 @@ TEST_F(ConfigPersistenceTest, ParsesNvencTwoPassModesAndDefaultsUnknownValues) {
EXPECT_EQ(config::video.nv.two_pass, expected) << setting;
}
}

Comment thread
martona marked this conversation as resolved.
Resolved
/**
* @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<StreamResolutionCapCase> {};

/**
* @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 &param = 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}
)
);
Loading