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 @@ -1420,6 +1420,31 @@ editing the `conf` file in a text editor. Use the examples as reference.
</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
65 changes: 65 additions & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
// standard includes
#include <algorithm>
#include <charconv>
#include <filesystem>
#include <format>
#include <fstream>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1572,13 +1576,74 @@ 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 `<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 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<std::string, std::string> &&vars) {
log_config_settings(vars, true);

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_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
13 changes: 13 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -398,4 +401,14 @@ namespace config {
* @return Parsed configuration key-value entries.
*/
std::unordered_map<std::string, std::string> 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
4 changes: 4 additions & 0 deletions src/nvhttp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
4 changes: 4 additions & 0 deletions src/rtsp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src_assets/common/assets/web/config.html
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ <h1>{{ $t('config.configuration') }}</h1>
"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
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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 @@ -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",
Expand Down