From b470c330c59d411ad18eb03b5a629d0f8e920896 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:13:10 +0000 Subject: [PATCH] Gate 360 FPS on entitlement and effective hardware decoding --- docs/core-protocol.md | 21 + native/opennow-core/src/cloudmatch.rs | 70 +++- native/opennow-core/src/frame_rate.rs | 364 ++++++++++++++++++ native/opennow-core/src/main.rs | 3 + native/opennow-core/src/settings.rs | 6 +- native/opennow-core/src/streamer.rs | 2 +- .../crates/opennow-streamer-core/src/lib.rs | 18 +- .../opennow-streamer-core/src/nvst_rtsp.rs | 47 ++- .../src/macos/video.rs | 13 +- .../src/format.rs | 20 +- opennow-qt/cmake/Tests.cmake | 16 + .../onboarding/DesktopOnboardingPicture.qml | 12 +- .../settings/DesktopSettingsScreen.qml | 16 +- .../pages/DesktopSettingsStreamPage.qml | 6 +- opennow-qt/qml/screens/SettingsScreen.qml | 14 +- opennow-qt/qml/state/ShellStore.qml | 26 +- .../qml/state/settings/SettingsState.qml | 123 ++++-- opennow-qt/src/acceptance/SmokeAcceptance.cpp | 10 +- .../tests/FrameRateSettingsAcceptance.qml | 214 ++++++++++ opennow-qt/tests/OnboardingAcceptance.qml | 12 + 20 files changed, 959 insertions(+), 54 deletions(-) create mode 100644 native/opennow-core/src/frame_rate.rs create mode 100644 opennow-qt/tests/FrameRateSettingsAcceptance.qml diff --git a/docs/core-protocol.md b/docs/core-protocol.md index d06c64b8f..0a4cf1a3c 100644 --- a/docs/core-protocol.md +++ b/docs/core-protocol.md @@ -523,6 +523,19 @@ attempts; only a presented first frame resets this budget. Ending the session ca recovery. A native stop stalled for 30 seconds reports an error without launching another transport over the still-owned resources. +For the embedded Qt client, `session.create` also accepts an optional numeric `maxEntitledFps`: +the highest frame rate the signed-in membership entitles at the requested resolution, or `0` +or absent when that is not confirmed. Qt derives it from the normalized `entitledResolutions` +entries it already displays; the core never invents it. The requested frame rate is bounded by +the documented resolution ceiling (1920x1080 and 1920x1200 top out at 360 FPS, every other +resolution at 240 FPS) and, above 240 FPS, by both a hardware decoder for the selected codec +confirmed in `runtimeCapabilities` and an entitlement limit that covers the requested rate. +An absent or software-only capability probe and an absent or lower entitlement limit both bound +the request to the unconditional 240 FPS ceiling, so the saved preference cannot request the +conditional tier without affirmative evidence. This is a necessary condition, not a throughput +qualification: the client does not measure decoder throughput, and CloudMatch remains the +authority on the finalized profile. + For the embedded Qt client, `session.create` and `streamer.prepare` accept an optional `runtimeCapabilities` object copied from the in-process streamer's protocol-7 `hello` response. The core filters its available `videoBackends` by the persisted `nativeVideoBackend` preference @@ -1027,6 +1040,14 @@ game or keyboard values with the existing respective defaults before create, immediate resume, or claim requests. The interface locale never enters these query parameters. +`settings.choices.get({runtimeCapabilities})` also returns `frameRates` for the documented +frame-rate tiers, using the same `value`, `disabled`, and nullable `reason` descriptor shape. +The conditional top tier reports a reason for each unconfirmed condition: a resolution outside +full HD, an unreported capability probe, or a reported probe without a hardware decoder for the +selected codec. Missing or unreported capabilities never imply support, and only rates at or +below 240 FPS are unconditional. The entitlement limit stays a Qt-side decision because the +core holds no subscription data. + `settings.choices.get({runtimeCapabilities})` returns `colorQualities` for the current persisted settings and embedded streamer capability snapshot. Each of the four descriptors contains `value`, `disabled`, and nullable `reason`. The operation diff --git a/native/opennow-core/src/cloudmatch.rs b/native/opennow-core/src/cloudmatch.rs index 08d39c54c..ea56c5698 100644 --- a/native/opennow-core/src/cloudmatch.rs +++ b/native/opennow-core/src/cloudmatch.rs @@ -1306,7 +1306,7 @@ fn monitor_display_data(hdr: bool, settings: &Value) -> Value { fn build_create_body(app_id: &str, params: &Value, settings: &Value, device_id: &str) -> Value { let (width, height) = parse_resolution(&setting_string(settings, "resolution", "1920x1080")); - let fps = setting_i64(settings, "fps", 60).clamp(30, 240); + let fps = crate::frame_rate::request_frame_rate(settings, params, width, height); let bitrate = setting_i64(settings, "maxBitrateMbps", 75).clamp(1, 200) * 1000; let codec = codec_wire(&setting_string(settings, "codec", "auto")); let hdr = setting_bool(settings, "enableHdr", false) @@ -4190,6 +4190,74 @@ mod tests { assert_eq!(features["vsync"], false); } + #[test] + fn session_create_requests_the_documented_frame_rate_ceiling() { + let hardware = json!({"protocolVersion":7, "videoBackends":[{"backend":"vaapi", + "available":true, "codecs":[{"codec":"h265", "available":true, + "colorQualities":["8bit_420"]}]}]}); + let software = json!({"protocolVersion":7, "videoBackends":[{"backend":"software", + "available":true, "codecs":[{"codec":"h265", "available":true, + "colorQualities":["8bit_420"]}]}]}); + let request = |resolution: &str, fps: i64, capabilities: &Value, entitled: i64| { + let body = build_create_body( + "12345", + &json!({"title":"Portal 2", "runtimeCapabilities":capabilities, + "maxEntitledFps":entitled}), + &json!({"resolution":resolution, "fps":fps, "codec":"h265"}), + "device-id", + ); + body["sessionRequestData"]["clientRequestMonitorSettings"][0]["framesPerSecond"].clone() + }; + assert_eq!(request("1920x1080", 360, &hardware, 360), json!(360)); + assert_eq!(request("1920x1200", 360, &hardware, 360), json!(360)); + for resolution in [ + "2560x1440", + "2560x1600", + "3440x1440", + "3840x2160", + "3840x1080", + ] { + assert_eq!( + request(resolution, 360, &hardware, 360), + json!(240), + "{resolution} must not request the full-HD-only tier" + ); + assert_eq!(request(resolution, 240, &hardware, 360), json!(240)); + } + assert_eq!(request("1920x1080", 999, &hardware, 360), json!(360)); + assert_eq!(request("1920x1080", 1, &hardware, 360), json!(30)); + assert_eq!( + request("1920x1080", 360, &software, 360), + json!(240), + "a software-only decode path cannot request the top tier" + ); + assert_eq!( + request("1920x1080", 360, &json!({}), 360), + json!(240), + "an unreported capability probe is not affirmative support" + ); + assert_eq!( + request("1920x1080", 360, &hardware, 0), + json!(240), + "unconfirmed entitlement cannot request the top tier" + ); + assert_eq!( + request("1920x1080", 360, &hardware, 240), + json!(240), + "a 240 FPS entitlement cannot request the top tier" + ); + assert_eq!( + request("1920x1080", 360, &hardware, 120), + json!(120), + "a lower entitlement bounds the request" + ); + assert_eq!( + request("1920x1080", 240, &software, 0), + json!(240), + "base rates stay unaffected by the capability verdict" + ); + } + #[test] fn manual_av1_uses_native_nvst_even_with_a_legacy_transport_value() { let body = build_create_body( diff --git a/native/opennow-core/src/frame_rate.rs b/native/opennow-core/src/frame_rate.rs new file mode 100644 index 000000000..d78d2ccdf --- /dev/null +++ b/native/opennow-core/src/frame_rate.rs @@ -0,0 +1,364 @@ +use serde_json::{Value, json}; + +pub const SUPPORTED_FRAME_RATES: [i64; 8] = [30, 60, 90, 120, 144, 165, 240, 360]; + +pub fn resolution_ceiling(width: i64, height: i64) -> i64 { + if width == 1920 && matches!(height, 1080 | 1200) { + 360 + } else { + 240 + } +} + +fn is_full_hd(width: i64, height: i64) -> bool { + resolution_ceiling(width, height) > 240 +} + +fn resolution(settings: &Value) -> (i64, i64) { + settings["resolution"] + .as_str() + .and_then(|value| value.split_once(['x', 'X'])) + .and_then(|(width, height)| Some((width.parse().ok()?, height.parse().ok()?))) + .unwrap_or((1920, 1080)) +} + +fn hardware_decode_available(settings: &Value, capabilities: &Value) -> bool { + let requested_backend = crate::streamer::requested_embedded_backend(settings); + let requested_codec = settings["codec"] + .as_str() + .unwrap_or("auto") + .trim() + .to_ascii_lowercase(); + let explicit_codec = match requested_codec.as_str() { + "" | "auto" => None, + value => Some(value), + }; + capabilities["videoBackends"] + .as_array() + .into_iter() + .flatten() + .filter(|backend| backend["available"].as_bool().unwrap_or(false)) + .filter(|backend| { + let name = backend["backend"].as_str().unwrap_or(""); + !matches!(name, "software" | "ffmpeg") + && (requested_backend == "auto" + || requested_backend == name + || (requested_backend == "nvdec" && name == "cuda")) + }) + .flat_map(|backend| backend["codecs"].as_array().into_iter().flatten()) + .any(|entry| { + if entry["available"].as_bool() != Some(true) { + return false; + } + let name = entry["codec"].as_str().unwrap_or(""); + match &explicit_codec { + Some(codec) => name.eq_ignore_ascii_case(codec), + None => ["h264", "h265", "av1"] + .iter() + .any(|codec| name.eq_ignore_ascii_case(codec)), + } + }) +} + +const BASE_FRAME_RATE_CEILING: i64 = 240; + +pub fn request_frame_rate(settings: &Value, params: &Value, width: i64, height: i64) -> i64 { + let requested = settings["fps"].as_i64().unwrap_or(60).clamp(30, 360); + let mut rate = requested.min(resolution_ceiling(width, height)); + if rate > BASE_FRAME_RATE_CEILING { + let capabilities = ¶ms["runtimeCapabilities"]; + let entitled = params["maxEntitledFps"].as_i64().unwrap_or(0).clamp(0, 360); + let cap = if entitled > 0 { + entitled.min(BASE_FRAME_RATE_CEILING) + } else { + BASE_FRAME_RATE_CEILING + }; + if !hardware_decode_available(settings, capabilities) || entitled < rate { + rate = cap; + } + } + rate +} + +pub fn frame_rate_choices(settings: &Value, capabilities: &Value) -> Value { + let (width, height) = resolution(settings); + let full_hd = is_full_hd(width, height); + let hardware = hardware_decode_available(settings, capabilities); + json!( + SUPPORTED_FRAME_RATES + .into_iter() + .map(|value| { + let reason = if value <= BASE_FRAME_RATE_CEILING { + None + } else if !full_hd { + Some( + "360 FPS is offered at full HD (1920x1080 or 1920x1200) only. Choose a full HD resolution first." + .to_owned(), + ) + } else if capabilities["videoBackends"].as_array().is_none() { + Some( + "360 FPS needs a confirmed hardware video decoder for the selected codec. This device has not reported one." + .to_owned(), + ) + } else if !hardware { + Some( + "360 FPS needs a hardware video decoder for the selected codec. This device has none available." + .to_owned(), + ) + } else { + None + }; + json!({"value":value, "disabled":reason.is_some(), "reason":reason}) + }) + .collect::>() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn capabilities(backend: &str, codec: &str) -> Value { + json!({"protocolVersion":7, "videoBackends":[{"backend":backend, "platform":"linux", + "available":true, "codecs":[{"codec":codec, "available":true, + "colorQualities":["8bit_420"]}]}]}) + } + + fn descriptor(choices: &Value, value: i64) -> Value { + choices + .as_array() + .unwrap() + .iter() + .find(|choice| choice["value"] == value) + .cloned() + .unwrap() + } + + #[test] + fn supported_frame_rates_end_at_the_documented_top_tier() { + assert_eq!(SUPPORTED_FRAME_RATES, [30, 60, 90, 120, 144, 165, 240, 360]); + assert_eq!(SUPPORTED_FRAME_RATES.last(), Some(&360)); + } + + #[test] + fn resolution_ceiling_is_documented_full_hd_only() { + assert_eq!(resolution_ceiling(1920, 1080), 360); + assert_eq!(resolution_ceiling(1920, 1200), 360); + for (width, height) in [ + (1280, 720), + (1600, 900), + (1920, 1440), + (2560, 1440), + (2560, 1600), + (3440, 1440), + (3840, 2160), + (5120, 1440), + (3840, 1080), + (2560, 1080), + ] { + assert_eq!( + resolution_ceiling(width, height), + 240, + "{width}x{height} must not request the full-HD-only tier" + ); + } + } + + #[test] + fn only_the_top_tier_is_conditional_and_every_rate_reports_a_reason() { + let settings = + json!({"resolution":"1920x1080", "codec":"h265", "nativeVideoBackend":"auto"}); + let choices = frame_rate_choices(&settings, &capabilities("vaapi", "h265")); + assert_eq!( + choices.as_array().unwrap().len(), + SUPPORTED_FRAME_RATES.len() + ); + for rate in [30, 60, 90, 120, 144, 165, 240] { + let entry = descriptor(&choices, rate); + assert_eq!(entry["disabled"], false, "{rate} stays selectable"); + assert_eq!(entry["reason"], Value::Null, "{rate} needs no reason"); + } + assert_eq!(descriptor(&choices, 360)["disabled"], false); + + let non_full_hd = frame_rate_choices( + &json!({"resolution":"2560x1440", "codec":"h265", "nativeVideoBackend":"auto"}), + &capabilities("vaapi", "h265"), + ); + let entry = descriptor(&non_full_hd, 360); + assert_eq!(entry["disabled"], true); + assert!( + entry["reason"] + .as_str() + .unwrap() + .contains("full HD (1920x1080 or 1920x1200) only") + ); + assert_eq!(descriptor(&non_full_hd, 240)["disabled"], false); + } + + #[test] + fn top_tier_requires_a_hardware_decoder_for_the_selected_codec() { + let full_hd = json!({"resolution":"1920x1200", "codec":"av1", "nativeVideoBackend":"auto"}); + let software_only = json!({"videoBackends":[{"backend":"software", "available":true, + "codecs":[{"codec":"av1", "available":true}]}]}); + let entry = descriptor(&frame_rate_choices(&full_hd, &software_only), 360); + assert_eq!(entry["disabled"], true); + assert!( + entry["reason"] + .as_str() + .unwrap() + .contains("needs a hardware video decoder") + ); + + let mismatched_codec = capabilities("vaapi", "h265"); + assert_eq!( + descriptor(&frame_rate_choices(&full_hd, &mismatched_codec), 360)["disabled"], + true, + "an unrelated hardware decoder must not unlock the top tier" + ); + + let explicit_backend = + json!({"resolution":"1920x1200", "codec":"h264", "nativeVideoBackend":"cuda"}); + assert_eq!( + descriptor( + &frame_rate_choices(&explicit_backend, &capabilities("vaapi", "h264")), + 360 + )["disabled"], + true, + "a backend the preference excludes cannot unlock the top tier" + ); + let nvdec_alias = + json!({"resolution":"1920x1200", "codec":"av1", "nativeVideoBackend":"nvdec"}); + assert_eq!( + descriptor( + &frame_rate_choices(&nvdec_alias, &capabilities("cuda", "av1")), + 360 + )["disabled"], + false, + "the nvdec alias still matches the CUDA backend" + ); + + let unavailable = json!({"videoBackends":[{"backend":"vaapi", "available":false, + "codecs":[{"codec":"h265", "available":true}]}]}); + assert_eq!( + descriptor(&frame_rate_choices(&full_hd, &unavailable), 360)["disabled"], + true, + "an unavailable backend cannot unlock the top tier" + ); + assert_eq!( + descriptor(&frame_rate_choices(&full_hd, &json!({})), 360)["disabled"], + true, + "unreported capabilities never unlock the top tier" + ); + } + + #[test] + fn request_rate_applies_the_resolution_device_and_entitlement_ceiling() { + let hardware = capabilities("vaapi", "h265"); + let software = json!({"videoBackends":[{"backend":"software", "available":true, + "codecs":[{"codec":"h265", "available":true}]}]}); + let settings = json!({"resolution":"1920x1080", "fps":360, "codec":"h265", + "nativeVideoBackend":"auto"}); + let request = |capabilities: &Value, entitled: i64, width: i64, height: i64| { + request_frame_rate( + &settings, + &json!({"runtimeCapabilities":capabilities, "maxEntitledFps":entitled}), + width, + height, + ) + }; + assert_eq!(request(&hardware, 360, 1920, 1080), 360); + assert_eq!( + request(&software, 360, 1920, 1080), + 240, + "a reported software-only probe cannot request the top tier" + ); + assert_eq!( + request(&json!({}), 360, 1920, 1080), + 240, + "an unreported probe is not affirmative capability" + ); + assert_eq!( + request(&json!({"videoBackends":[]}), 360, 1920, 1080), + 240, + "a probe that reported no backend cannot request the top tier" + ); + assert_eq!( + request(&hardware, 0, 1920, 1080), + 240, + "unconfirmed entitlement cannot request the top tier" + ); + assert_eq!( + request(&hardware, 240, 1920, 1080), + 240, + "a 240 FPS entitlement cannot request the top tier" + ); + assert_eq!( + request(&hardware, 120, 1920, 1080), + 120, + "a lower entitlement bounds the request" + ); + assert_eq!( + request(&hardware, 360, 2560, 1440), + 240, + "the resolution ceiling applies regardless of entitlement" + ); + assert_eq!( + request_frame_rate( + &json!({"resolution":"1920x1080", "fps":999, "codec":"h265"}), + &json!({"runtimeCapabilities":hardware, "maxEntitledFps":360}), + 1920, + 1080 + ), + 360, + "a stored value above the ceiling is still bounded" + ); + assert_eq!( + request_frame_rate( + &json!({"resolution":"1920x1080", "fps":120, "codec":"h265"}), + &json!({"runtimeCapabilities":software, "maxEntitledFps":0}), + 1920, + 1080 + ), + 120, + "base rates ignore the capability and entitlement verdicts" + ); + } + + #[test] + fn legacy_software_preference_never_qualifies_for_the_top_tier() { + let hardware = capabilities("vaapi", "h264"); + let settings = json!({"resolution":"1920x1080", "fps":360, "codec":"h264", + "nativeVideoBackend":"auto", "decoderPreference":"software"}); + let params = json!({"runtimeCapabilities":hardware, "maxEntitledFps":360}); + assert_eq!( + request_frame_rate(&settings, ¶ms, 1920, 1080), + 240, + "the legacy software preference is a software decode path" + ); + assert_eq!( + request_frame_rate( + &json!({"resolution":"1920x1080", "fps":360, "codec":"h264", + "nativeVideoBackend":"auto", "decoderPreference":"auto"}), + ¶ms, + 1920, + 1080 + ), + 360, + "the same hardware profile stays eligible without the software preference" + ); + } + + #[test] + fn auto_selection_accepts_any_hardware_codec_without_mutating_inputs() { + let settings = + json!({"resolution":"1920x1080", "codec":"auto", "nativeVideoBackend":"auto"}); + let original = settings.clone(); + let choices = frame_rate_choices(&settings, &capabilities("vulkan", "h265")); + assert_eq!(descriptor(&choices, 360)["disabled"], false); + assert_eq!(settings, original); + + let missing = frame_rate_choices(&settings, &json!({})); + assert_eq!(descriptor(&missing, 240)["disabled"], false); + assert_eq!(descriptor(&missing, 360)["disabled"], true); + } +} diff --git a/native/opennow-core/src/main.rs b/native/opennow-core/src/main.rs index 563ff2f5f..e0b86a981 100644 --- a/native/opennow-core/src/main.rs +++ b/native/opennow-core/src/main.rs @@ -10,6 +10,7 @@ mod credential_vault; mod device_identity; mod diagnostics; mod discord; +mod frame_rate; mod gfn; mod language; mod media; @@ -373,6 +374,8 @@ fn dispatch(method: &str, params: &Value, core: &AppCore) -> DispatchResult { let settings = core.settings.lock().expect("settings poisoned").all(); Ok(( json!({"colorQualities":streamer::StreamerService::color_quality_choices( + &settings, ¶ms["runtimeCapabilities"]), + "frameRates":frame_rate::frame_rate_choices( &settings, ¶ms["runtimeCapabilities"])}), None, )) diff --git a/native/opennow-core/src/settings.rs b/native/opennow-core/src/settings.rs index 9e490718c..99d06a621 100644 --- a/native/opennow-core/src/settings.rs +++ b/native/opennow-core/src/settings.rs @@ -416,7 +416,7 @@ impl SettingsStore { 100, 100, ); - clamp_integer(&mut self.values, "fps", 30, 240, 60); + clamp_integer(&mut self.values, "fps", 30, 360, 60); clamp_integer(&mut self.values, "maxBitrateMbps", 1, 200, 75); clamp_integer(&mut self.values, "windowWidth", 960, 7680, 1400); clamp_integer(&mut self.values, "windowHeight", 540, 4320, 900); @@ -2163,7 +2163,9 @@ mod tests { assert_eq!(preferences.all()["statsShowFps"], json!(false)); assert_eq!(preferences.all()["statsShowRegion"], json!(false)); assert_eq!(preferences.all()["statsOverlayScale"], json!(1.5)); - assert_eq!(store.set("fps", json!(999)).unwrap(), json!(240)); + assert_eq!(store.set("fps", json!(999)).unwrap(), json!(360)); + assert_eq!(store.set("fps", json!(360)).unwrap(), json!(360)); + assert_eq!(store.set("fps", json!(240)).unwrap(), json!(240)); assert_eq!(store.set("maxBitrateMbps", json!(200)).unwrap(), json!(200)); assert_eq!( store.set("launchInConsoleMode", json!(false)).unwrap(), diff --git a/native/opennow-core/src/streamer.rs b/native/opennow-core/src/streamer.rs index 1cf0d88a2..d8ab56703 100644 --- a/native/opennow-core/src/streamer.rs +++ b/native/opennow-core/src/streamer.rs @@ -1038,7 +1038,7 @@ fn probe_capabilities(executable: &Path, settings: &Value) -> Result String { +pub(crate) fn requested_embedded_backend(settings: &Value) -> String { let requested = settings["nativeVideoBackend"] .as_str() .unwrap_or("auto") diff --git a/native/opennow-streamer/crates/opennow-streamer-core/src/lib.rs b/native/opennow-streamer/crates/opennow-streamer-core/src/lib.rs index 4cc6052c6..4a632e1a2 100644 --- a/native/opennow-streamer/crates/opennow-streamer-core/src/lib.rs +++ b/native/opennow-streamer/crates/opennow-streamer-core/src/lib.rs @@ -81,6 +81,7 @@ enum State { const ENCODED_MEDIA_QUEUE_CAPACITY: usize = 8; const NVST_RECOVERY_ATTEMPT_LIMIT: usize = 1; const NATIVE_INPUT_POLL_INTERVAL: Duration = Duration::from_micros(250); +const MAX_STREAM_FPS: u32 = 360; trait NvstSessionResources { fn take_rumble(&self) -> ([Option; 4], usize) { @@ -2517,7 +2518,7 @@ fn media_stream_config(context: &SessionContext) -> MediaStreamConfig { .and_then(Value::as_u64) .and_then(|value| u32::try_from(value).ok()) .unwrap_or(60) - .clamp(1, 240); + .clamp(1, MAX_STREAM_FPS); let bitrate_mbps = context .settings .get("maxBitrateMbps") @@ -4153,17 +4154,28 @@ mod tests { }); high_fps["session"]["negotiatedStreamProfile"] = json!({ "codec": "AV1", - "fps": 300, + "fps": 400, "colorQuality": "10bit_444" }); let high_fps: SessionContext = serde_json::from_value(high_fps).expect("context"); assert_eq!(media_stream_config(&high_fps).codec, MediaVideoCodec::Av1); - assert_eq!(media_stream_config(&high_fps).fps, 240); + assert_eq!(media_stream_config(&high_fps).fps, 360); assert_eq!( media_stream_config(&high_fps).color_quality, MediaColorQuality::TenBit420 ); + let mut top_tier = synthetic_context("top-tier-config", json!([])); + top_tier["settings"] = json!({ + "codec": "H265", + "resolution": "1920x1080", + "fps": 360, + "maxBitrateMbps": 100 + }); + top_tier["session"]["negotiatedStreamProfile"] = json!({"fps": 360}); + let top_tier: SessionContext = serde_json::from_value(top_tier).expect("context"); + assert_eq!(media_stream_config(&top_tier).fps, 360); + let mut rejected_vrr = synthetic_context("rejected-vrr-config", json!([])); rejected_vrr["settings"] = json!({ "enableCloudGsync": true }); rejected_vrr["session"]["negotiatedStreamProfile"] = json!({ diff --git a/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs b/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs index baf8fa428..5aa3e2748 100644 --- a/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs +++ b/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs @@ -1026,7 +1026,7 @@ fn negotiated_fps(context: &SessionContext) -> u64 { .and_then(Value::as_u64) .or_else(|| context.settings.get("fps").and_then(Value::as_u64)) .unwrap_or(60) - .clamp(30, 240) + .clamp(30, u64::from(super::MAX_STREAM_FPS)) } fn negotiated_codec(context: &SessionContext) -> String { @@ -1486,6 +1486,51 @@ mod tests { assert!(rustls::crypto::CryptoProvider::get_default().is_some()); } + #[test] + fn announce_carries_the_documented_top_tier_frame_rate() { + let mut value = context(); + value.session.extra["negotiatedStreamProfile"] = json!({"codec":"AV1", "fps":360}); + let sdp = build_announce( + &value, + AnnounceParams { + key: &"01".repeat(32), + key_id: 7, + port: 49006, + address: "192.0.2.10", + ufrag: "abcd", + password: "abcdefghijklmnopqrstuv", + fingerprint: "AA:BB", + video_port: 5004, + video_packet_size: 1280, + rtcp_on_sctp: true, + microphone_available: false, + }, + ); + assert!(sdp.contains("a=x-nv-video[0].maxFPS:360")); + assert!(sdp.contains("a=x-nv-packetPacing.maxDelayUs:4000")); + + let mut runaway = context(); + runaway.session.extra["negotiatedStreamProfile"] = json!({"codec":"AV1", "fps":600}); + let sdp = build_announce( + &runaway, + AnnounceParams { + key: &"01".repeat(32), + key_id: 7, + port: 49006, + address: "192.0.2.10", + ufrag: "abcd", + password: "abcdefghijklmnopqrstuv", + fingerprint: "AA:BB", + video_port: 5004, + video_packet_size: 1280, + rtcp_on_sctp: true, + microphone_available: false, + }, + ); + assert!(sdp.contains("a=x-nv-video[0].maxFPS:360")); + assert!(!sdp.contains("a=x-nv-video[0].maxFPS:600")); + } + #[test] fn owned_announce_matches_current_official_bundle_baseline() { let value = context(); diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/video.rs b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/video.rs index bd1d90902..81416827e 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/video.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-macos/src/macos/video.rs @@ -448,7 +448,7 @@ unsafe extern "C-unwind" fn decompression_callback( fn frame_duration_seconds(duration: CMTime) -> f64 { if duration.value > 0 && duration.timescale > 0 { - (duration.value as f64 / f64::from(duration.timescale)).clamp(1.0 / 240.0, 1.0 / 24.0) + (duration.value as f64 / f64::from(duration.timescale)).clamp(1.0 / 360.0, 1.0 / 24.0) } else { 1.0 / 60.0 } @@ -633,6 +633,17 @@ mod tests { assert!((frame_duration_seconds(duration) - 1.0 / 120.0).abs() < f64::EPSILON); } + #[test] + fn converts_top_tier_core_media_duration_without_clamping_to_240() { + let duration = CMTime { + value: 250, + timescale: 90_000, + flags: CMTimeFlags(1), + epoch: 0, + }; + assert!((frame_duration_seconds(duration) - 1.0 / 360.0).abs() < f64::EPSILON); + } + #[test] fn converts_core_media_time_to_cross_platform_100ns_units() { let time = CMTime { diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/format.rs b/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/format.rs index 04fb2ccfc..ec21f9226 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/format.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform-windows/src/format.rs @@ -107,9 +107,9 @@ impl VideoFormat { ))); } let fps = self.frame_rate_numerator.get() as f64 / self.frame_rate_denominator.get() as f64; - if !(1.0..=240.0).contains(&fps) { + if !(1.0..=360.0).contains(&fps) { return Err(BackendError::InvalidConfig( - "video frame rate must be between 1 and 240 fps".to_owned(), + "video frame rate must be between 1 and 360 fps".to_owned(), )); } if self.average_bitrate == 0 { @@ -550,6 +550,22 @@ mod tests { .validate() .is_err() ); + assert!( + VideoFormat { + frame_rate_numerator: NonZeroU32::new(360).unwrap(), + ..video_format() + } + .validate() + .is_ok() + ); + assert!( + VideoFormat { + frame_rate_numerator: NonZeroU32::new(480).unwrap(), + ..video_format() + } + .validate() + .is_err() + ); } #[test] diff --git a/opennow-qt/cmake/Tests.cmake b/opennow-qt/cmake/Tests.cmake index 94f705696..91d6302df 100644 --- a/opennow-qt/cmake/Tests.cmake +++ b/opennow-qt/cmake/Tests.cmake @@ -467,6 +467,22 @@ if(BUILD_TESTING) --route settings-input --smoke-language-settings --language-keyboard-selection --reduced-motion) set_tests_properties(qml-language-keyboard-selection PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 15) + qt_add_resources(opennow-qt "frame-rate-settings-acceptance" + PREFIX "/acceptance" BASE tests FILES tests/FrameRateSettingsAcceptance.qml) + foreach(surface desktop console) + if(surface STREQUAL "desktop") + set(frame_rate_route settings-streaming) + else() + set(frame_rate_route settings-video) + endif() + foreach(width 900 1400) + add_test(NAME qml-frame-rate-settings-${surface}-${width} + COMMAND opennow-qt --smoke-test --allow-multiple-instances --${surface} + --route ${frame_rate_route} --smoke-frame-rate-settings --smoke-width ${width} --reduced-motion) + set_tests_properties(qml-frame-rate-settings-${surface}-${width} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 15) + endforeach() + endforeach() qt_add_resources(opennow-qt "custom-background-acceptance" PREFIX "/acceptance" BASE tests FILES tests/CustomBackgroundAcceptance.qml) qt_add_resources(opennow-qt "stream-stats-acceptance" diff --git a/opennow-qt/qml/desktop/onboarding/DesktopOnboardingPicture.qml b/opennow-qt/qml/desktop/onboarding/DesktopOnboardingPicture.qml index 625455e37..4245bd076 100644 --- a/opennow-qt/qml/desktop/onboarding/DesktopOnboardingPicture.qml +++ b/opennow-qt/qml/desktop/onboarding/DesktopOnboardingPicture.qml @@ -189,13 +189,17 @@ Column { description: qsTr("Rates follow your membership and resolution.") Segments { objectName: "onboardingFps" + readonly property var canonical: root.store.canonicalFpsValues() readonly property int current: Number(root.settings.fps ?? 60) - options: [60,90,120,144,240].indexOf(current) >= 0 ? [60,90,120,144,240] - : [{label: current === 0 ? qsTr("Auto") : String(current), value: current},60,90,120,144,240] + options: canonical.indexOf(current) >= 0 ? canonical + : [{label: current === 0 ? qsTr("Auto") : String(current), value: current}].concat(canonical) optionWidth: 46 selectedIndex: options.findIndex(item => Number(optionValue(item)) === current) - disabledValues: root.store.unentitledFpsValues(root.resolution) - disabledHint: qsTr("Not available on your current membership") + disabledValues: root.store.lockedFpsValues(root.resolution) + disabledHint: root.store.unentitledFpsValues(root.resolution).length + ? qsTr("Not available on your current membership") + : root.store.lockedFpsReason() + || qsTr("Not available on your current membership") onSelected: (index, item) => root.store.setOnboardingSetting("fps", Number(optionValue(item))) } } diff --git a/opennow-qt/qml/desktop/settings/DesktopSettingsScreen.qml b/opennow-qt/qml/desktop/settings/DesktopSettingsScreen.qml index 67401942a..72317ec1f 100644 --- a/opennow-qt/qml/desktop/settings/DesktopSettingsScreen.qml +++ b/opennow-qt/qml/desktop/settings/DesktopSettingsScreen.qml @@ -285,6 +285,10 @@ FocusScope { return ShellStore.unentitledFpsValues(root.currentResolutionValue()) } + function lockedFpsValues() { + return ShellStore.lockedFpsValues(root.currentResolutionValue()) + } + function fpsEntitlementNote() { if (!root.fpsEntitlementKnown()) return ShellStore.signedIn @@ -294,12 +298,22 @@ FocusScope { const tier = root.liveTierBadge() || qsTr("Membership") if (entitled.length === 0) return qsTr("%1 · no exact entitlement for this resolution").arg(tier) - const max = entitled[entitled.length - 1] + const selectable = ShellStore.selectableFpsValues(root.currentResolutionValue()) + const max = selectable.length ? selectable[selectable.length - 1] : entitled[entitled.length - 1] return qsTr("%1 · up to %2 FPS at %3").arg(tier).arg(max) .arg(root.currentResolutionValue().replace("x", "×")) } function fpsLockedHint() { + if (!root.unentitledFpsValues().length) { + const reason = ShellStore.lockedFpsReason() + if (reason !== "") + return reason + } else if (root.lockedFpsValues().length > root.unentitledFpsValues().length) { + const reason = ShellStore.lockedFpsReason() + if (reason !== "") + return reason + } const tier = root.liveTierBadge() return tier ? qsTr("Not entitled on %1 — upgrade on NVIDIA to unlock").arg(tier) diff --git a/opennow-qt/qml/desktop/settings/pages/DesktopSettingsStreamPage.qml b/opennow-qt/qml/desktop/settings/pages/DesktopSettingsStreamPage.qml index 9b663670d..3dfc0b1cc 100644 --- a/opennow-qt/qml/desktop/settings/pages/DesktopSettingsStreamPage.qml +++ b/opennow-qt/qml/desktop/settings/pages/DesktopSettingsStreamPage.qml @@ -68,10 +68,12 @@ Column { DesktopSettingsRow { width: parent.width; paperStyle: true; glyph: "speed"; title: qsTr("Frame rate"); description: page.settingsScreen.fpsEntitlementNote() DesktopSettingsSegmented { + objectName: "desktopFrameRateControl" + readonly property var canonical: ShellStore.canonicalFpsValues().map(value => String(value)) readonly property string current: Number(page.settingsScreen.valueSetting("fps",60)) === 0 ? "AUTO" : String(page.settingsScreen.valueSetting("fps",60)) - options: ["60","90","120","144","240"].indexOf(current) >= 0 ? ["60","90","120","144","240"] : [current,"60","90","120","144","240"] + options: canonical.indexOf(current) >= 0 ? canonical : [current].concat(canonical) optionWidth: 50; selectedIndex: options.indexOf(current) - disabledValues: page.settingsScreen.unentitledFpsValues(); disabledHint: page.settingsScreen.fpsLockedHint() + disabledValues: page.settingsScreen.lockedFpsValues(); disabledHint: page.settingsScreen.fpsLockedHint() onSelected: (index,value) => page.settingsScreen.setSetting("fps",value === "AUTO" ? 0 : Number(value)) } } diff --git a/opennow-qt/qml/screens/SettingsScreen.qml b/opennow-qt/qml/screens/SettingsScreen.qml index 0be2eaf3d..0a12ae067 100644 --- a/opennow-qt/qml/screens/SettingsScreen.qml +++ b/opennow-qt/qml/screens/SettingsScreen.qml @@ -211,14 +211,11 @@ FocusScope { } function fpsChoices() { - // Full canonical list; unentitled rates are locked via - // fpsLockedValues() instead of hidden, so members can see what a - // higher tier unlocks. Falls back to everything enabled offline. return ShellStore.canonicalFpsValues() } function fpsLockedValues() { - return ShellStore.unentitledFpsValues(String(ShellStore.settings.resolution || "")) + return ShellStore.lockedFpsValues(String(ShellStore.settings.resolution || "")) } function fpsNote() { @@ -232,8 +229,13 @@ FocusScope { : qsTr("Membership") if (entitled.length === 0) return qsTr("Only rates your membership entitles are selectable") - return qsTr("Only rates your membership entitles are selectable · %1 up to %2 FPS") - .arg(tier).arg(entitled[entitled.length - 1]) + const resolution = String(ShellStore.settings.resolution || "") + const selectable = ShellStore.selectableFpsValues(resolution) + const top = selectable.length ? selectable[selectable.length - 1] : entitled[entitled.length - 1] + const note = qsTr("Only rates your membership entitles are selectable · %1 up to %2 FPS") + .arg(tier).arg(top) + const reason = ShellStore.lockedFpsReason() + return top < entitled[entitled.length - 1] && reason !== "" ? note + " · " + reason : note } function captureShortcut(event) { diff --git a/opennow-qt/qml/state/ShellStore.qml b/opennow-qt/qml/state/ShellStore.qml index 814ad2899..4f4485963 100644 --- a/opennow-qt/qml/state/ShellStore.qml +++ b/opennow-qt/qml/state/ShellStore.qml @@ -55,6 +55,7 @@ QtObject { subscription: root.subscription scopeGeneration: root.authGeneration settingsActive: String(AppController.route).indexOf("settings") === 0 + capabilitiesActive: String(AppController.route).indexOf("settings") === 0 || root.onboardingRequired nativeHdrOutputSupported: HdrOutput.supported providerIdpId: root.authSession && root.authSession.provider ? String(root.authSession.provider.idpId || "") : "" providerCode: root.authSession && root.authSession.provider ? String(root.authSession.provider.code || "") : "" @@ -483,7 +484,10 @@ QtObject { root.streamState = "requesting" root.pendingRequestedColorQuality = String(root.settings.colorQuality || "8bit_420") root.streamCreateRequestId = CoreClient.request("session.create", - Object.assign({}, root.pendingLaunchParams, {runtimeCapabilities: root.nativeRuntimeCapabilities}), 60000) + Object.assign({}, root.pendingLaunchParams, { + runtimeCapabilities: root.nativeRuntimeCapabilities, + maxEntitledFps: settingsOwner.maxEntitledFps(String(root.settings.resolution || "")) + }), 60000) } } function onRequestFailed(id, code, message) { @@ -999,6 +1003,26 @@ QtObject { return settingsOwner.unentitledFpsValues(resolution) } + function lockedFpsValues(resolution) { + return settingsOwner.lockedFpsValues(resolution) + } + + function selectableFpsValues(resolution) { + return settingsOwner.selectableFpsValues(resolution) + } + + function frameRateReason(value) { + return settingsOwner.frameRateReason(value) + } + + function maxEntitledFps(resolution) { + return settingsOwner.maxEntitledFps(resolution) + } + + function lockedFpsReason() { + return settingsOwner.lockedFpsReason() + } + function resolveEntitledFps(resolution, requested) { return settingsOwner.resolveEntitledFps(resolution, requested) } diff --git a/opennow-qt/qml/state/settings/SettingsState.qml b/opennow-qt/qml/state/settings/SettingsState.qml index 582a91201..2555b9f7e 100644 --- a/opennow-qt/qml/state/settings/SettingsState.qml +++ b/opennow-qt/qml/state/settings/SettingsState.qml @@ -40,6 +40,7 @@ QtObject { property double scopeGeneration: 0 property bool nativeHdrOutputSupported: false property bool settingsActive: false + property bool capabilitiesActive: false property var keyboardLayouts: [] property var languageResult: ({}) property string languageState: "idle" @@ -47,13 +48,14 @@ QtObject { property string languageRequestId: "" property var colorDescriptors: [] property string colorRequestId: "" + property var frameRateDescriptors: [] property string cancellingRequestId: "" property var settingWrites: ({}) readonly property string languageContext: JSON.stringify([ready, scopeGeneration, providerIdpId, settings.sessionProxyEnabled, settings.sessionProxyUrl]) readonly property string colorContext: JSON.stringify([ready, nativeRuntimeReady, nativeRuntimeCapabilities, nativeHdrOutputSupported, settings.codec, settings.nativeVideoBackend, - settings.decoderPreference, settings.enableHdr]) + settings.decoderPreference, settings.enableHdr, settings.resolution]) readonly property string gameLanguageDescription: qsTr("Requested when the game supports it; some games require an in-game change. Applies to the next session.") readonly property string keyboardLayoutDescription: qsTr("Physical key mapping requested from GeForce NOW. Applies to the next session.") readonly property string interfaceLanguageDescription: qsTr("OpenNOW interface only. Community translated through Crowdin.") @@ -126,16 +128,15 @@ QtObject { cancelOwnedRequest(request) if (settingsActive && ready) Qt.callLater(root.ensureGameLanguages) } - onSettingsActiveChanged: if (settingsActive) { - ensureGameLanguages() - colorRefresh.restart() - } + onSettingsActiveChanged: if (settingsActive) ensureGameLanguages() + onCapabilitiesActiveChanged: if (capabilitiesActive) colorRefresh.restart() onColorContextChanged: { const request = colorRequestId colorRequestId = "" colorDescriptors = [] + frameRateDescriptors = [] cancelOwnedRequest(request) - if (settingsActive) colorRefresh.restart() + if (capabilitiesActive) colorRefresh.restart() } property Timer languageDeadline: Timer { interval: 15000 @@ -150,7 +151,7 @@ QtObject { property Timer colorRefresh: Timer { interval: 0 onTriggered: { - if (!root.ready || !root.nativeRuntimeReady || !root.settingsActive || root.colorRequestId !== "") return + if (!root.ready || !root.nativeRuntimeReady || !root.capabilitiesActive || root.colorRequestId !== "") return root.colorRequestId = root.coreClient.request("settings.choices.get", {runtimeCapabilities:root.nativeRuntimeCapabilities}, 15000) } } @@ -221,6 +222,8 @@ QtObject { if (id !== "" && id === colorRequestId) { colorRequestId = "" colorDescriptors = result.colorQualities || [] + frameRateDescriptors = result.frameRates || [] + clampFpsToEntitlement() return true } return finishSettingWrite(id, result, "") @@ -238,6 +241,7 @@ QtObject { if (id !== "" && id === colorRequestId) { colorRequestId = "" colorDescriptors = [] + frameRateDescriptors = [] return true } return finishSettingWrite(id, null, message) @@ -318,21 +322,47 @@ QtObject { return result } - // Canonical frame rates offered by GeForce NOW clients. The Rust core - // clamps fps to 30–240, so 360 (an Electron-legacy preset) is excluded. function canonicalFpsValues() { - return [30, 60, 90, 120, 144, 165, 240] + return [30, 60, 90, 120, 144, 165, 240, 360] } - // Official catalog rates per resolution. 1080p rigs offer 240 FPS; - // other modes top out at 120 FPS. Exact MES tuples (e.g. 90 FPS) are - // preserved separately and never synthesized from a higher envelope. function presetFpsForResolution(width, height) { if (width === 1920 && (height === 1080 || height === 1200)) - return [30, 60, 120, 240] + return [30, 60, 120, 240, 360] return [30, 60, 120] } + readonly property var capabilityGatedFpsValues: [360] + + function frameRateDescriptor(value) { + for (let index = 0; index < frameRateDescriptors.length; ++index) { + if (Number(frameRateDescriptors[index].value) === Number(value)) + return frameRateDescriptors[index] + } + return null + } + + function frameRateGated(value) { + return capabilityGatedFpsValues.indexOf(Number(value)) >= 0 + } + + function frameRateEligible(value) { + const descriptor = frameRateDescriptor(value) + if (descriptor !== null) + return descriptor.disabled !== true + return !frameRateGated(value) + } + + function maxEntitledFps(resolution) { + const entitled = entitledFpsForResolution(resolution) + return entitled.length ? entitled[entitled.length - 1] : 0 + } + + function frameRateReason(value) { + const descriptor = frameRateDescriptor(value) + return descriptor && descriptor.disabled === true ? String(descriptor.reason || "") : "" + } + function isFpsCoveredByEntitlement(width, height, fps) { const raw = (subscription && subscription.entitledResolutions) || [] for (let index = 0; index < raw.length; ++index) { @@ -390,25 +420,66 @@ QtObject { return locked } - // Clamp a requested fps to the nearest entitled rate at or below it, - // mirroring resolveEntitledStreamProfile. Returns the input when the - // subscription is unknown. - function resolveEntitledFps(resolution, requested) { + function selectableFpsValues(resolution) { + const locked = lockedFpsValues(resolution) + return canonicalFpsValues().filter(value => locked.indexOf(value) < 0) + } + + function knownLockedFpsValues(resolution) { + const locked = unentitledFpsValues(resolution) + const canonical = canonicalFpsValues() + for (let index = 0; index < canonical.length; ++index) { + const descriptor = frameRateDescriptor(canonical[index]) + if (descriptor && descriptor.disabled === true && locked.indexOf(canonical[index]) < 0) + locked.push(canonical[index]) + } + return locked + } + + function lockedFpsValues(resolution) { + const locked = knownLockedFpsValues(resolution) + const canonical = canonicalFpsValues() const entitled = entitledFpsForResolution(resolution) - if (entitled.length === 0) + for (let index = 0; index < canonical.length; ++index) { + const value = canonical[index] + const unconfirmed = frameRateGated(value) + && (entitled.indexOf(value) < 0 || !frameRateEligible(value)) + if (unconfirmed && locked.indexOf(value) < 0) + locked.push(value) + } + return locked + } + + function lockedFpsReason() { + const canonical = canonicalFpsValues() + for (let index = 0; index < canonical.length; ++index) { + const reason = frameRateReason(canonical[index]) + if (reason !== "") + return reason + } + const locked = lockedFpsValues(settings.resolution) + for (let index = 0; index < locked.length; ++index) { + if (frameRateGated(locked[index])) + return qsTr("Capability not confirmed") + } + return "" + } + + function resolveEntitledFps(resolution, requested) { + const locked = knownLockedFpsValues(resolution) + const selectable = canonicalFpsValues().filter(value => locked.indexOf(value) < 0) + if (selectable.length === 0) return requested const wanted = Math.trunc(Number(requested || 0)) - if (wanted === 0 || entitled.indexOf(wanted) >= 0) + if (wanted === 0 || selectable.indexOf(wanted) >= 0) return wanted - for (let index = entitled.length - 1; index >= 0; --index) { - if (entitled[index] <= wanted) - return entitled[index] + for (let index = selectable.length - 1; index >= 0; --index) { + if (selectable[index] <= wanted) + return selectable[index] } - return entitled[0] + return selectable[0] } - // Persistently correct settings.fps when the resolution or subscription - // changed underneath it (e.g. tier downgrade). No-op while offline. function clampFpsToEntitlement() { const clamped = resolveEntitledFps(settings.resolution, settings.fps) if (Number(clamped) !== Number(settings.fps)) diff --git a/opennow-qt/src/acceptance/SmokeAcceptance.cpp b/opennow-qt/src/acceptance/SmokeAcceptance.cpp index cbd8bce69..fe05ebe82 100644 --- a/opennow-qt/src/acceptance/SmokeAcceptance.cpp +++ b/opennow-qt/src/acceptance/SmokeAcceptance.cpp @@ -79,6 +79,7 @@ int AcceptanceSession::startSmokeWorkload() if (m_smokeTest && m_arguments.contains(u"--smoke-frame-generation-stats"_s)) return startFrameGenerationStatsWorkload(); if (m_smokeTest && (m_arguments.contains(u"--smoke-language-settings"_s) + || m_arguments.contains(u"--smoke-frame-rate-settings"_s) || m_arguments.contains(u"--smoke-frame-generation"_s) || m_arguments.contains(u"--smoke-ten-bit-warning"_s) || m_arguments.contains(u"--smoke-onboarding"_s) @@ -89,12 +90,15 @@ int AcceptanceSession::startSmokeWorkload() || m_arguments.contains(u"--smoke-custom-background"_s))) { const bool controllerMetadata = m_arguments.contains(u"--smoke-controller-metadata"_s); const bool languageSettings = m_arguments.contains(u"--smoke-language-settings"_s); + const bool frameRateSettings = m_arguments.contains(u"--smoke-frame-rate-settings"_s); const bool onboarding = m_arguments.contains(u"--smoke-onboarding"_s); const bool tenBitWarning = m_arguments.contains(u"--smoke-ten-bit-warning"_s); const bool customBackground = m_arguments.contains(u"--smoke-custom-background"_s); const bool streamStats = m_arguments.contains(u"--smoke-stream-stats"_s); QQmlComponent component(&m_engine, QUrl(languageSettings ? u"qrc:/acceptance/LanguageSettingsAcceptance.qml"_s + : frameRateSettings + ? u"qrc:/acceptance/FrameRateSettingsAcceptance.qml"_s : m_arguments.contains(u"--smoke-onboarding"_s) ? m_arguments.contains(u"--onboarding-replay-check"_s) ? u"qrc:/acceptance/OnboardingReplayAcceptance.qml"_s @@ -134,13 +138,13 @@ int AcceptanceSession::startSmokeWorkload() fixture->setProperty("imageUrl", QUrl::fromLocalFile(localImage->fileName()).toString()); localImage->close(); } - QTimer::singleShot(150, this, [this, fixture, customBackground, onboarding, tenBitWarning, languageSettings] { + QTimer::singleShot(150, this, [this, fixture, customBackground, onboarding, tenBitWarning, languageSettings, frameRateSettings] { auto *window = qobject_cast(m_engine.rootObjects().first()); QVariant passed; const bool ok = window && QMetaObject::invokeMethod(fixture, "run", Q_RETURN_ARG(QVariant, passed), Q_ARG(QVariant, QVariant::fromValue(window->contentItem()))) && passed.toBool() && !m_qmlWarningOccurred; if (!ok) { m_application.exit(EXIT_FAILURE); return; } - const auto finish = [this, window, fixture, customBackground, onboarding, tenBitWarning, languageSettings] { + const auto finish = [this, window, fixture, customBackground, onboarding, tenBitWarning, languageSettings, frameRateSettings] { if (languageSettings) { const QList keys = m_arguments.contains(u"--language-keyboard-selection"_s) ? QList{Qt::Key_Tab, Qt::Key_Return} : QList{Qt::Key_Escape}; @@ -151,7 +155,7 @@ int AcceptanceSession::startSmokeWorkload() QGuiApplication::sendEvent(window, &release); } } - if (customBackground || onboarding || tenBitWarning || languageSettings) { + if (customBackground || onboarding || tenBitWarning || languageSettings || frameRateSettings) { QVariant verified; if (!QMetaObject::invokeMethod(fixture, "verify", Q_RETURN_ARG(QVariant, verified)) || !verified.toBool() || m_qmlWarningOccurred) { diff --git a/opennow-qt/tests/FrameRateSettingsAcceptance.qml b/opennow-qt/tests/FrameRateSettingsAcceptance.qml new file mode 100644 index 000000000..a5a0d7917 --- /dev/null +++ b/opennow-qt/tests/FrameRateSettingsAcceptance.qml @@ -0,0 +1,214 @@ +import QtQuick +import OpenNOW + +QtObject { + id: fixture + property var shell: null + property var control: null + property var owner: ShellStore.settingsOwnerState + property QtObject client: QtObject { + property int serial: 0 + property var calls: [] + function request(method, params, timeout) { + const id = "frame-rate-fixture-" + (++serial) + calls = calls.concat([{id:id, method:method, params:params, timeout:timeout}]) + return id + } + function cancel(id) {} + } + + function check(ok, message) { if (!ok) throw new Error("Frame rate settings: " + message) } + function find(item, name) { + if (item.objectName === name) return item + for (const child of item.children || []) { + const result = find(child, name) + if (result) return result + } + return null + } + function descriptors(hardware, reason) { + const choices = [] + for (const value of [30, 60, 90, 120, 144, 165, 240, 360]) { + const locked = value === 360 && !hardware + choices.push({value:value, disabled:locked, reason:locked ? reason : null}) + } + return choices + } + function acceptFailure(id, message) { owner.acceptFailure(id, message) } + function acceptResponse(id, result) { return owner.acceptResponse(id, result) } + function capability(hardware) { + ShellStore.nativeRuntimeReady = true + ShellStore.nativeRuntimeCapabilities = hardware + ? {protocolVersion:7, videoBackends:[{backend:"vaapi", available:true, + codecs:[{codec:"h265", available:true, colorQualities:["8bit_420"]}]}]} + : {protocolVersion:7, videoBackends:[{backend:"software", available:true, + codecs:[{codec:"h265", available:true, colorQualities:["8bit_420"]}]}]} + } + function request(hardware, reason) { + owner.colorRefresh.triggered() + check(owner.colorRequestId !== "", "the capability request did not start") + owner.acceptResponse(owner.colorRequestId, {colorQualities:[], frameRates:descriptors(hardware, reason)}) + } + function entitlement(width, height, fps) { + ShellStore.subscription = {membershipTier:"ULTIMATE", entitledResolutions:[ + {width:width, height:height, fps:fps}]} + } + function optionEnabled(value) { + const option = find(shell, "settingsOption-" + value) + check(option !== null, "the rendered control exposes " + value) + return option.enabled + } + + function verify() { + const locked = Qt.application.arguments.indexOf("--frame-rate-locked") >= 0 + entitlement(1920, 1080, 360) + owner.settings = Object.assign({}, owner.settings, {resolution:"1920x1080", fps:360, codec:"h265"}) + capability(!locked) + request(!locked, "360 FPS needs a hardware video decoder for the selected codec. This device has none available.") + if (locked) + check(owner.lockedFpsValues("1920x1080").indexOf(360) >= 0, "the captured profile locks 360") + else + check(owner.selectableFpsValues("1920x1080").indexOf(360) >= 0, + "the captured profile can select 360") + if (Qt.application.arguments.indexOf("--screenshot") >= 0) { + const content = find(shell, "desktopSettingsContent") + if (content) { + Qt.callLater(() => { + const rate = find(shell, "desktopFrameRateControl") + if (rate) + content.contentY = Math.min(content.contentHeight - content.height, + rate.mapToItem(content.contentItem, 0, 0).y - 24) + }) + } else { + const list = find(shell, "consoleSettingsList") + const model = shell.settingsModel() + const index = model.findIndex(item => item.key === "fps") + if (list && index >= 0) { + list.currentIndex = index + list.positionViewAtIndex(index, ListView.Center) + } + } + } + return true + } + + function run(parent) { + owner.coreClient = client + owner.ready = true + ShellStore.nativeRuntimeReady = true + check(owner.capabilitiesActive === true, "the production settings route activates the choices lifecycle") + check(owner.settingsActive === true, "the production settings route activates the language lifecycle") + owner.settings = Object.assign({}, owner.settings, {resolution:"1920x1080", fps:360, codec:"h265"}) + ShellStore.subscription = null + check(owner.maxEntitledFps("1920x1080") === 0, "unloaded membership reports no entitlement limit") + check(owner.lockedFpsValues("1920x1080").indexOf(360) >= 0, + "an unconfirmed conditional tier is not offered") + check(owner.selectableFpsValues("1920x1080").indexOf(240) >= 0, + "base rates stay selectable without a verdict") + const canonical = owner.canonicalFpsValues() + check(canonical.indexOf(360) === canonical.length - 1, "360 is the canonical top rate") + check(owner.presetFpsForResolution(1920, 1080).indexOf(360) >= 0, + "full HD advertises the documented top tier") + check(owner.presetFpsForResolution(1920, 1200).indexOf(360) >= 0, "full HD 16:10 advertises it") + check(owner.presetFpsForResolution(2560, 1440).indexOf(360) < 0, + "other resolutions never advertise it") + + entitlement(1920, 1080, 360) + check(owner.maxEntitledFps("1920x1080") === 360, "the entitlement limit reflects the membership") + + owner.colorRequestId = "in-flight" + check(owner.frameRateDescriptor(360) === null, "the descriptor is not known yet") + owner.clampFpsToEntitlement() + check(owner.settings.fps === 360, "an unknown verdict never rewrites the saved preference") + check(owner.settingWrites.fps === undefined, "no settings write is issued for an unknown verdict") + check(owner.resolveEntitledFps("1920x1080", 360) === 360, + "the preference resolver leaves an unconfirmed rate alone") + check(owner.lockedFpsValues("1920x1080").indexOf(360) >= 0, + "the offered surface still refuses to advertise the unconfirmed rate") + acceptResponse("in-flight", {colorQualities:[], frameRates:descriptors(true, "")}) + check(owner.settings.fps === 360, "the saved preference survives the descriptor reply") + check(owner.selectableFpsValues("1920x1080").indexOf(360) >= 0, + "the confirmed descriptor makes the tier selectable") + capability(false) + request(false, "Synthetic device cannot decode 360 FPS") + check(owner.frameRateDescriptor(360).disabled === true, "the core device verdict is stored") + check(owner.lockedFpsValues("1920x1080").indexOf(360) >= 0, "a device-locked rate is visible but locked") + check(owner.lockedFpsValues("1920x1080").indexOf(240) < 0, "base rates stay selectable") + check(owner.selectableFpsValues("1920x1080").indexOf(360) < 0, "a device-locked rate is not selectable") + check(owner.resolveEntitledFps("1920x1080", 360) === 240, + "a device-locked saved rate resolves to the nearest eligible rate") + check(ShellStore.lockedFpsReason().indexOf("360") >= 0, "the capability reason reaches the surfaces") + + capability(true) + request(true, "") + check(owner.selectableFpsValues("1920x1080").indexOf(360) >= 0, + "full HD with hardware decode and entitlement selects the top tier") + check(owner.resolveEntitledFps("1920x1080", 360) === 360, "an eligible saved rate is preserved") + check(ShellStore.lockedFpsReason() === "", "an eligible profile reports no capability reason") + + owner.colorRequestId = "dropped-verdict" + acceptFailure("dropped-verdict", "Synthetic capability loss") + check(owner.lockedFpsValues("1920x1080").indexOf(360) >= 0, + "a lost capability verdict is not affirmative support") + check(ShellStore.lockedFpsReason() === "Capability not confirmed", + "the unconfirmed tier explains itself") + ShellStore.subscription = null + check(owner.lockedFpsValues("1920x1080").indexOf(360) >= 0, + "an unloaded membership keeps the conditional tier locked") + entitlement(1920, 1080, 360) + request(true, "") + + owner.settings = Object.assign({}, owner.settings, {resolution:"2560x1440"}) + capability(true) + request(false, "360 FPS is offered at full HD only. Choose a full HD resolution first.") + check(owner.lockedFpsValues("2560x1440").indexOf(360) >= 0, + "the documented full-HD ceiling locks the top tier at other resolutions") + + entitlement(2560, 1440, 120) + check(owner.unentitledFpsValues("2560x1440").indexOf(360) >= 0 + && owner.unentitledFpsValues("2560x1440").indexOf(240) >= 0, + "the membership still locks rates it does not entitle") + check(owner.resolveEntitledFps("2560x1440", 360) === 120, + "membership and device locks resolve together") + + entitlement(1920, 1080, 240) + owner.settings = Object.assign({}, owner.settings, {resolution:"1920x1080"}) + capability(true) + request(true, "") + check(owner.entitledFpsForResolution("1920x1080").indexOf(360) < 0, + "a 240 FPS entitlement never synthesizes the top tier") + check(owner.lockedFpsValues("1920x1080").indexOf(360) >= 0, + "the membership lock alone keeps the top tier out of reach") + + shell = find(parent, "desktopSettingsScreen") + if (shell !== null) { + control = find(shell, "desktopFrameRateControl") + check(control !== null, "the desktop frame-rate selector exists") + entitlement(1920, 1080, 360) + owner.settings = Object.assign({}, owner.settings, {resolution:"1920x1080"}) + capability(true) + request(true, "") + check(optionEnabled(360) === true, "an entitled and capable profile enables the top tier chip") + owner.settings = Object.assign({}, owner.settings, {resolution:"2560x1440"}) + capability(true) + request(false, "360 FPS is offered at full HD only.") + check(optionEnabled(360) === false, "the resolution verdict disables the top tier chip") + entitlement(2560, 1440, 240) + check(optionEnabled(240) === true, "an entitled rate the device can run stays selectable") + check(optionEnabled(360) === false, "the membership lock keeps the top tier unreachable") + owner.settings = Object.assign({}, owner.settings, {resolution:"1920x1080"}) + } else { + shell = find(parent, "consoleSettingsScreen") + check(shell !== null, "a production settings surface exists") + check(shell.fpsChoices().indexOf(360) >= 0, "the console selector offers the canonical top rate") + check(shell.fpsLockedValues().indexOf(360) >= 0, "the console shares the capability verdict") + const model = shell.settingsModel() + const row = model.find(item => item.key === "fps") + check(row && row.values.indexOf(360) >= 0, "the console settings row carries the top rate") + check(row && row.disabledValues.indexOf(360) >= 0, "the console settings row locks it") + check(shell.fpsNote().indexOf("360") >= 0 || shell.fpsNote().indexOf("240") >= 0, + "the console note reports the effective ceiling") + } + return true + } +} diff --git a/opennow-qt/tests/OnboardingAcceptance.qml b/opennow-qt/tests/OnboardingAcceptance.qml index 1fd19e8a3..e331fe86d 100644 --- a/opennow-qt/tests/OnboardingAcceptance.qml +++ b/opennow-qt/tests/OnboardingAcceptance.qml @@ -72,6 +72,18 @@ QtObject { check(bitrate, "bitrate control missing") bitrate.moved(85) check(ShellStore.onboardingSettings.maxBitrateMbps === 85, "bitrate did not update its real key") + check(ShellStore.settingsOwnerState.capabilitiesActive === true, + "the onboarding route activates the frame-rate choices lifecycle") + const frameRate = find(screen, "onboardingFps") + check(frameRate, "frame-rate control missing") + const canonical = ShellStore.canonicalFpsValues() + check(canonical.indexOf(360) >= 0, "the shared canonical rates expose the documented top tier") + for (const value of canonical) + check(frameRate.options.some(option => frameRate.optionValue(option) === value), + "the onboarding selector offers the shared rate " + value) + const fasterIndex = frameRate.options.findIndex(option => frameRate.optionValue(option) === 120) + frameRate.selected(fasterIndex, frameRate.options[fasterIndex]) + check(ShellStore.onboardingSettings.fps === 120, "frame rate did not stage its real key") screen.goToStep(3) const generation = find(screen, "onboardingFrameGeneration") check(generation && generation.selectedIndex === 0, "frame generation did not default off")