diff --git a/docs/core-protocol.md b/docs/core-protocol.md index 1665f5e0f..6544d2491 100644 --- a/docs/core-protocol.md +++ b/docs/core-protocol.md @@ -694,11 +694,37 @@ capabilities cannot request HDR through the external-streamer probe path. CloudMatch receives `sessionRequestData.sdrHdrMode=1`, monitor `sdrHdrMode=1`, and `requestedStreamingFeatures.trueHdr=true` only for this validated HDR request. CloudMatch uses bit-depth/chroma enums `1/0` for 10-bit 4:2:0 and `1/1` for 10-bit 4:4:4. -For HDR, monitor `displayData` requests maximum luminance 1000 nits, minimum luminance 0, -and maximum frame-average luminance 400 nits, -matching the Mac native session payload. These are fixed requested-content defaults, not -measurements of the physical display; no caller-supplied luminance is accepted in this -contract. SDR luminance values and all display primaries remain zero protocol defaults. +For HDR, monitor `displayData` carries the output's validated HDR static metadata +when the current output reports it. `desiredContentMaxLuminance` and +`desiredContentMinLuminance` come from the Wayland color-management target luminance +range in cd/m²; this mirrors the official client's feature-gated mirroring of its +system display properties into the same fields. `desiredContentMaxFrameAverageLuminance` +is omitted while a validated output snapshot is in use, because the output description +exposes no comparable sustained full-frame value and no permitted capture establishes +that mapping. + +Without a validated output snapshot, HDR requests keep the fixed requested-content +defaults of maximum luminance 1000 nits, minimum luminance 0, and maximum frame-average +luminance 400 nits, matching the Mac native session payload. Those defaults are requested +content characteristics rather than measurements of the physical display and are not +presented as calibration. SDR luminance values and all display primaries remain zero +protocol defaults; the official typed `displayData` schema contains no primaries or white +point fields, and no permitted capture establishes a scale for measured chromaticities. + +The validated snapshot travels from Qt in `runtimeCapabilities.nativeHdrDisplay` as +`minimumNits` and `maximumNits` in cd/m², omitting either value the output does not +report. The core validates the pair again and drops it when the bounds are not finite, +negative, above 10000 cd/m², or not strictly increasing. The snapshot is transient: +`settings.set` rejects it, the settings loader discards persisted copies, and the core +never saves runtime capability results. + +Stale snapshots are discarded rather than reused. Qt injects the snapshot only while the +current output reports a validated range and removes any caller-supplied or previously +injected value when it does not; the core strips any earlier snapshot from the resolved +settings before carrying the current capability, so a display change that invalidates the +output falls back to the requested-content defaults instead of replaying the previous +display's luminance. Claim and resume requests do not carry monitor settings at all, so +they cannot replay one either. The normalized server response carries `negotiatedStreamProfile.enableHdr: boolean`. It comes from the returned session's `sdrHdrMode`, then the returned monitor's mode, then the @@ -1050,7 +1076,9 @@ streamer protocol 7 are unchanged. CoreClient injects the current native window's `nativeHdrSupported` into `runtimeCapabilities`, overriding any caller-supplied flag, just as it does for -`session.create` and `streamer.prepare`. SettingsState observes +`session.create` and `streamer.prepare`. It injects `nativeHdrDisplay` alongside it only +when the output reported a validated luminance range, so an unavailable snapshot is +omitted rather than sent as zero. SettingsState observes `HdrOutput.supported` only to cancel and refetch choices when the display changes; it does not author the wire capability or expose platform handles. diff --git a/docs/hdr.md b/docs/hdr.md index 5bbc52267..949baafc7 100644 --- a/docs/hdr.md +++ b/docs/hdr.md @@ -52,7 +52,7 @@ the requested mode. Unsupported profiles remain explicit errors rather than CPU ## Color and ownership contract -CloudMatch receives `sdrHdrMode=1`, `trueHdr=true`, and requested-content luminance defaults of 1000 nits maximum, 400 nits frame average, and zero minimum. These are requested content characteristics, not measurements of the physical display. The accepted HDR mode is carried through resume, stream preparation, and NVST's dynamic-range and bit-depth settings. +CloudMatch receives `sdrHdrMode=1` and `trueHdr=true`. With a validated output snapshot, the monitor's requested-content luminance carries the display's own target luminance range; otherwise HDR requests keep the documented requested-content defaults of 1000 nits maximum, 400 nits frame average, and zero minimum. The requested-content defaults are not measurements of the physical display. The Wayland color-management target luminance range is the only output metadata either contract accepts, because a PQ description cannot report a display peak in its primary color volume luminance and the description exposes no sustained full-frame value. The accepted HDR mode is carried through resume, stream preparation, and NVST's dynamic-range and bit-depth settings. Decoder metadata carries transfer function, primaries, matrix, range, and supported chroma location. Explicit metadata takes precedence over negotiated defaults; unspecified fields use those defaults. Unsupported combinations and precision-losing paths fail explicitly. Pixel depth alone never selects an HDR transfer function. diff --git a/native/opennow-core/src/cloudmatch.rs b/native/opennow-core/src/cloudmatch.rs index 96440f60e..08d39c54c 100644 --- a/native/opennow-core/src/cloudmatch.rs +++ b/native/opennow-core/src/cloudmatch.rs @@ -290,7 +290,7 @@ impl CloudMatchService { resume["sessionRequestData"]["clientRequestMonitorSettings"][0]["sdrHdrMode"] = json!(hdr_mode); resume["sessionRequestData"]["clientRequestMonitorSettings"][0]["displayData"] = - monitor_display_data(hdr_mode == 1); + monitor_display_data(hdr_mode == 1, settings); resume["sessionRequestData"]["requestedStreamingFeatures"]["trueHdr"] = json!(hdr_mode == 1); } @@ -1279,14 +1279,29 @@ fn build_resume_body(app_id: &str, session: &Value, settings: &Value, device_id: "metaData":null, "adUpdates":null}) } -fn monitor_display_data(hdr: bool) -> Value { - json!({ +fn measured_display_luminance(settings: &Value, hdr: bool) -> Option<(f64, f64)> { + if !hdr { + return None; + } + crate::streamer::validated_native_hdr_display(&settings["nativeHdrDisplay"]) +} + +fn monitor_display_data(hdr: bool, settings: &Value) -> Value { + let mut data = json!({ "displayPrimaryX0":0,"displayPrimaryY0":0,"displayPrimaryX1":0,"displayPrimaryY1":0, "displayPrimaryX2":0,"displayPrimaryY2":0,"displayWhitePointX":0,"displayWhitePointY":0, "desiredContentMaxLuminance":if hdr { 1000 } else { 0 }, "desiredContentMinLuminance":0, "desiredContentMaxFrameAverageLuminance":if hdr { 400 } else { 0 } - }) + }); + if let Some((minimum, maximum)) = measured_display_luminance(settings, hdr) { + data["desiredContentMaxLuminance"] = json!(maximum); + data["desiredContentMinLuminance"] = json!(minimum); + if let Some(object) = data.as_object_mut() { + object.remove("desiredContentMaxFrameAverageLuminance"); + } + } + data } fn build_create_body(app_id: &str, params: &Value, settings: &Value, device_id: &str) -> Value { @@ -1372,7 +1387,7 @@ fn build_create_body(app_id: &str, params: &Value, settings: &Value, device_id: "monitorId":0,"positionX":0,"positionY":0, "widthInPixels":width,"heightInPixels":height,"framesPerSecond":fps, "sdrHdrMode":if hdr { 1 } else { 0 }, - "displayData":monitor_display_data(hdr), + "displayData":monitor_display_data(hdr, settings), "hdr10PlusGamingData":null, "dpi":if cfg!(target_os = "macos") { 144 } else { 96 } }], @@ -3742,6 +3757,171 @@ mod tests { } } + #[test] + fn validated_display_luminance_replaces_requested_content_defaults() { + let capabilities = json!({"protocolVersion":7,"nativeHdrSupported":true,"videoBackends":[{ + "backend":"vaapi","available":true,"codecs":[ + {"codec":"h265","available":true,"colorQualities":["8bit_420","10bit_420"]} + ] + }],"nativeHdrDisplay":{"minimumNits":0.005,"maximumNits":620}}); + let settings = crate::streamer::StreamerService::embedded_session_settings( + &json!({"enableHdr":true}), + &capabilities, + ) + .unwrap(); + let body = build_create_body("123", &json!({}), &settings, "device"); + let display_data = + &body["sessionRequestData"]["clientRequestMonitorSettings"][0]["displayData"]; + assert_eq!(display_data["desiredContentMaxLuminance"], 620.0); + assert_eq!(display_data["desiredContentMinLuminance"], 0.005); + assert!( + display_data + .get("desiredContentMaxFrameAverageLuminance") + .is_none() + ); + assert_eq!(display_data["displayPrimaryX0"], 0); + assert_eq!(display_data["displayWhitePointY"], 0); + assert_eq!(body["sessionRequestData"]["sdrHdrMode"], 1); + let sdr = build_create_body( + "123", + &json!({}), + &json!({"enableHdr":false,"nativeHdrDisplay":{"minimumNits":0.005,"maximumNits":620}}), + "device", + ); + let sdr_data = &sdr["sessionRequestData"]["clientRequestMonitorSettings"][0]["displayData"]; + assert_eq!(sdr_data["desiredContentMaxLuminance"], 0); + assert_eq!(sdr_data["desiredContentMaxFrameAverageLuminance"], 0); + assert_eq!(sdr_data["desiredContentMinLuminance"], 0); + } + + #[test] + fn malformed_display_luminance_keeps_documented_defaults() { + for display in [ + json!({"minimumNits":600.0,"maximumNits":400.0}), + json!({"minimumNits":-1.0,"maximumNits":400.0}), + json!({"minimumNits":0.0,"maximumNits":10001.0}), + json!({"minimumNits":0.0}), + json!({"minimumNits":"0","maximumNits":400.0}), + json!([0.0, 400.0]), + ] { + let settings = json!({"enableHdr":true,"nativeHdrSupported":true, + "codec":"h265","nativeHdrDisplay":display}); + let body = build_create_body("123", &json!({}), &settings, "device"); + let display_data = + &body["sessionRequestData"]["clientRequestMonitorSettings"][0]["displayData"]; + assert_eq!(display_data["desiredContentMaxLuminance"], 1000); + assert_eq!(display_data["desiredContentMinLuminance"], 0); + assert_eq!(display_data["desiredContentMaxFrameAverageLuminance"], 400); + } + } + + #[test] + fn native_hdr_display_capability_requires_a_validated_pair() { + let capabilities = |display: Value| { + json!({"protocolVersion":7,"nativeHdrSupported":true,"videoBackends":[{ + "backend":"vaapi","available":true,"codecs":[ + {"codec":"h265","available":true,"colorQualities":["8bit_420","10bit_420"]} + ] + }],"nativeHdrDisplay":display}) + }; + for (display, minimum, maximum) in [ + (json!({"minimumNits":0.005,"maximumNits":620}), 0.005, 620.0), + (json!({"minimumNits":0,"maximumNits":400}), 0.0, 400.0), + ] { + let resolved = crate::streamer::StreamerService::embedded_session_settings( + &json!({"enableHdr":true,"nativeHdrSupported":true}), + &capabilities(display), + ) + .unwrap(); + assert_eq!(resolved["nativeHdrDisplay"]["minimumNits"], minimum); + assert_eq!(resolved["nativeHdrDisplay"]["maximumNits"], maximum); + } + for display in [ + json!({"minimumNits":400.0,"maximumNits":400.0}), + json!({"minimumNits":0.0,"maximumNits":10001.0}), + json!({"maximumNits":620}), + json!({"minimumNits":0.005}), + json!("620"), + ] { + let resolved = crate::streamer::StreamerService::embedded_session_settings( + &json!({"enableHdr":true,"nativeHdrSupported":true}), + &capabilities(display), + ) + .unwrap(); + assert!(resolved.get("nativeHdrDisplay").is_none()); + } + } + + #[test] + fn stale_display_snapshot_is_dropped_across_output_transitions() { + let capabilities = |display: Value| { + json!({"protocolVersion":7,"nativeHdrSupported":true,"videoBackends":[{ + "backend":"vaapi","available":true,"codecs":[ + {"codec":"h265","available":true,"colorQualities":["8bit_420","10bit_420"]} + ] + }],"nativeHdrDisplay":display}) + }; + let previous = json!({"enableHdr":true,"nativeHdrSupported":true,"codec":"h265", + "nativeHdrDisplay":{"minimumNits":0.005,"maximumNits":620}}); + let resolved = crate::streamer::StreamerService::embedded_session_settings( + &previous, + &json!({"protocolVersion":7,"nativeHdrSupported":true,"videoBackends":[{ + "backend":"vaapi","available":true,"codecs":[ + {"codec":"h265","available":true,"colorQualities":["8bit_420","10bit_420"]} + ] + }]}), + ) + .unwrap(); + assert!(resolved.get("nativeHdrDisplay").is_none()); + let body = build_create_body("123", &json!({}), &resolved, "device"); + let display_data = + &body["sessionRequestData"]["clientRequestMonitorSettings"][0]["displayData"]; + assert_eq!(display_data["desiredContentMaxLuminance"], 1000); + assert_eq!(display_data["desiredContentMinLuminance"], 0); + assert_eq!(display_data["desiredContentMaxFrameAverageLuminance"], 400); + for invalid in [ + json!({"minimumNits":620,"maximumNits":620}), + json!({"minimumNits":0.005,"maximumNits":10001}), + json!("unavailable"), + ] { + let stale = crate::streamer::StreamerService::embedded_session_settings( + &previous, + &capabilities(invalid), + ) + .unwrap(); + assert!(stale.get("nativeHdrDisplay").is_none()); + let body = build_create_body("123", &json!({}), &stale, "device"); + let display_data = + &body["sessionRequestData"]["clientRequestMonitorSettings"][0]["displayData"]; + assert_eq!(display_data["desiredContentMaxLuminance"], 1000); + assert_eq!(display_data["desiredContentMinLuminance"], 0); + assert_eq!(display_data["desiredContentMaxFrameAverageLuminance"], 400); + } + let migrated = crate::streamer::StreamerService::embedded_session_settings( + &previous, + &capabilities(json!({"minimumNits":0.0005,"maximumNits":400})), + ) + .unwrap(); + let body = build_create_body("123", &json!({}), &migrated, "device"); + let display_data = + &body["sessionRequestData"]["clientRequestMonitorSettings"][0]["displayData"]; + assert_eq!(display_data["desiredContentMaxLuminance"], 400.0); + assert_eq!(display_data["desiredContentMinLuminance"], 0.0005); + assert!( + display_data + .get("desiredContentMaxFrameAverageLuminance") + .is_none() + ); + let resume = build_resume_body( + "123", + &json!({"sessionId":"s","status":2, + "sessionRequestData":{"sdrHdrMode":1,"clientRequestMonitorSettings":[{"sdrHdrMode":1}]}}), + &resolved, + "device", + ); + assert!(resume.to_string().find("desiredContent").is_none()); + } + #[test] fn accepted_hdr_mode_survives_resume_and_explicit_sdr_fallback_wins() { let base = trusted_cloudmatch_base(DEFAULT_STREAMING_BASE).unwrap(); diff --git a/native/opennow-core/src/settings.rs b/native/opennow-core/src/settings.rs index c139d7dc7..9e490718c 100644 --- a/native/opennow-core/src/settings.rs +++ b/native/opennow-core/src/settings.rs @@ -80,7 +80,8 @@ impl SettingsStore { value }; values.insert(key, value); - } else if key != "nativeHdrSupported" { + } else if !matches!(key.as_str(), "nativeHdrSupported" | "nativeHdrDisplay") + { if policy == LoadPolicy::ReadWrite && key == "sessionTimeRemainingDisplay" && matches!(value.as_str(), Some("stats" | "both")) @@ -1854,6 +1855,14 @@ mod tests { let mut store = SettingsStore::load(Some(directory.clone())).unwrap(); assert_eq!(store.all()["enableHdr"], false); assert!(store.set("nativeHdrSupported", json!(true)).is_err()); + assert!( + store + .set( + "nativeHdrDisplay", + json!({"minimumNits":0.005,"maximumNits":620}) + ) + .is_err() + ); assert_eq!(store.set("enableHdr", json!(true)).unwrap(), true); assert_eq!( SettingsStore::load(Some(directory.clone())).unwrap().all()["enableHdr"], @@ -1862,12 +1871,15 @@ mod tests { let path = directory.join("settings.json"); let mut persisted: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); persisted["nativeHdrSupported"] = json!(true); + persisted["nativeHdrDisplay"] = json!({"minimumNits":0.005,"maximumNits":620}); fs::write(&path, serde_json::to_vec(&persisted).unwrap()).unwrap(); let mut loaded = SettingsStore::load(Some(directory.clone())).unwrap(); assert!(loaded.all().get("nativeHdrSupported").is_none()); + assert!(loaded.all().get("nativeHdrDisplay").is_none()); loaded.set("enableHdr", json!(true)).unwrap(); let persisted: Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap(); assert!(persisted.get("nativeHdrSupported").is_none()); + assert!(persisted.get("nativeHdrDisplay").is_none()); fs::remove_dir_all(directory).unwrap(); } diff --git a/native/opennow-core/src/streamer.rs b/native/opennow-core/src/streamer.rs index bed80c35c..4d34d7ae8 100644 --- a/native/opennow-core/src/streamer.rs +++ b/native/opennow-core/src/streamer.rs @@ -508,6 +508,14 @@ impl StreamerService { .as_bool() .unwrap_or(false) ); + if let Some(object) = resolved.as_object_mut() { + object.remove("nativeHdrDisplay"); + } + if let Some((minimum, maximum)) = + validated_native_hdr_display(&capabilities["nativeHdrDisplay"]) + { + resolved["nativeHdrDisplay"] = json!({"minimumNits":minimum, "maximumNits":maximum}); + } if hdr { resolved["colorQuality"] = json!(color); } @@ -1408,6 +1416,21 @@ fn apply_child_telemetry(message: &Value, state: &Arc>) { } } +pub(crate) fn validated_native_hdr_display(display: &Value) -> Option<(f64, f64)> { + let display = display.as_object()?; + let minimum = display.get("minimumNits")?.as_f64()?; + let maximum = display.get("maximumNits")?.as_f64()?; + if !minimum.is_finite() + || !maximum.is_finite() + || minimum < 0.0 + || maximum > 10_000.0 + || maximum <= minimum + { + return None; + } + Some((minimum, maximum)) +} + fn streamer_context(mut session: Value, settings: &Value) -> Value { let mut normalized = settings.clone(); let negotiated_codec = session["negotiatedStreamProfile"]["codec"].as_str(); diff --git a/opennow-qt/src/app/ApplicationStartup.cpp b/opennow-qt/src/app/ApplicationStartup.cpp index df8e4459b..79dd05250 100644 --- a/opennow-qt/src/app/ApplicationStartup.cpp +++ b/opennow-qt/src/app/ApplicationStartup.cpp @@ -209,6 +209,12 @@ static int runApplicationSession(int argc, char *argv[], QString &restartExecuta QObject::connect(&localization, &Localization::localeChanged, &hdrOutput, &HdrOutput::changed); QObject::connect(&hdrOutput, &HdrOutput::changed, &coreClient, [&] { coreClient.setNativeHdrSupported(hdrOutput.supported()); + const auto display = hdrOutput.displayData(); + CoreClient::NativeHdrDisplay snapshot; + snapshot.available = display.available; + snapshot.minimumNits = display.minimumNits; + snapshot.maximumNits = display.maximumNits; + coreClient.setNativeHdrDisplay(snapshot); }); qmlRegisterType("OpenNOW", 1, 0, "HdrChromeEffect"); qmlRegisterType("OpenNOW", 1, 0, "NativeTimer"); diff --git a/opennow-qt/src/core/CoreClient.cpp b/opennow-qt/src/core/CoreClient.cpp index 0936383db..95f3fc0fe 100644 --- a/opennow-qt/src/core/CoreClient.cpp +++ b/opennow-qt/src/core/CoreClient.cpp @@ -265,6 +265,13 @@ QString CoreClient::request(const QString &method, const QJsonObject ¶ms, in || method == u"settings.choices.get"_s) { auto capabilities = runtimeParams.value(u"runtimeCapabilities"_s).toObject(); capabilities.insert(u"nativeHdrSupported"_s, m_nativeHdrSupported); + if (m_nativeHdrDisplay.available) { + capabilities.insert(u"nativeHdrDisplay"_s, + QJsonObject{{u"minimumNits"_s, m_nativeHdrDisplay.minimumNits}, + {u"maximumNits"_s, m_nativeHdrDisplay.maximumNits}}); + } else { + capabilities.remove(u"nativeHdrDisplay"_s); + } runtimeParams.insert(u"runtimeCapabilities"_s, capabilities); } const QJsonObject message{{u"type"_s, u"request"_s}, diff --git a/opennow-qt/src/core/CoreClient.h b/opennow-qt/src/core/CoreClient.h index bf0864463..414e475a1 100644 --- a/opennow-qt/src/core/CoreClient.h +++ b/opennow-qt/src/core/CoreClient.h @@ -30,6 +30,12 @@ class CoreClient final : public QObject [[nodiscard]] QString lastError() const; [[nodiscard]] int protocolVersion() const; + struct NativeHdrDisplay { + bool available = false; + double minimumNits = 0.0; + double maximumNits = 0.0; + }; + Q_INVOKABLE bool start(const QString &program, const QStringList &arguments = {}); Q_INVOKABLE void stop(); Q_INVOKABLE QString request(const QString &method, @@ -39,6 +45,7 @@ class CoreClient final : public QObject Q_INVOKABLE void logShellDiagnostic(const QString &message); Q_INVOKABLE void markUiReady(); void setNativeHdrSupported(bool supported) { m_nativeHdrSupported = supported; } + void setNativeHdrDisplay(const NativeHdrDisplay &display) { m_nativeHdrDisplay = display; } signals: void stateChanged(); @@ -94,4 +101,5 @@ private slots: int m_restartAttempts = 0; bool m_manualStop = false; bool m_nativeHdrSupported = false; + NativeHdrDisplay m_nativeHdrDisplay; }; diff --git a/opennow-qt/src/streaming/rendering/HdrOutput.cpp b/opennow-qt/src/streaming/rendering/HdrOutput.cpp index 78e92c293..bf541798d 100644 --- a/opennow-qt/src/streaming/rendering/HdrOutput.cpp +++ b/opennow-qt/src/streaming/rendering/HdrOutput.cpp @@ -100,16 +100,33 @@ void HdrOutput::publish(State state) s_whiteNits.store(state.whiteNits); s_supported.store(state.supported); QMetaObject::invokeMethod(this, [this, state]() mutable { + const auto wayland = m_waylandOutput->state(); #if defined(Q_OS_LINUX) - state.supported = state.supported && m_waylandOutput->state().supported; + state.supported = state.supported && wayland.supported; #endif - if (m_supported == state.supported && m_mode == state.outputMode) return; + DisplayData next; + if (wayland.supported) { + next.available = true; + next.minimumNits = double(wayland.targetMinimumNits); + next.maximumNits = double(wayland.targetMaximumNits); + } + const bool displayChanged = next.available != m_display.available + || next.minimumNits != m_display.minimumNits + || next.maximumNits != m_display.maximumNits; + m_display = next; + if (m_supported == state.supported && m_mode == state.outputMode && !displayChanged) return; m_supported = state.supported; m_mode = state.outputMode; emit changed(); }, Qt::QueuedConnection); } +HdrOutput::DisplayData HdrOutput::displayData() const +{ + if (!m_supported) return {}; + return m_display; +} + void HdrOutput::requestChrome(bool required) { if (!required) m_chromeGuiReady.store(false); diff --git a/opennow-qt/src/streaming/rendering/HdrOutput.h b/opennow-qt/src/streaming/rendering/HdrOutput.h index 9cde26dce..748cb9250 100644 --- a/opennow-qt/src/streaming/rendering/HdrOutput.h +++ b/opennow-qt/src/streaming/rendering/HdrOutput.h @@ -42,6 +42,13 @@ class HdrOutput final : public QObject QString status() const; static State renderState(); + struct DisplayData { + bool available = false; + double minimumNits = 0.0; + double maximumNits = 0.0; + }; + [[nodiscard]] DisplayData displayData() const; + signals: void changed(); @@ -59,6 +66,7 @@ class HdrOutput final : public QObject std::unique_ptr m_waylandOutput; bool m_supported = false; int m_mode = 0; + DisplayData m_display; static std::atomic s_mode; static std::atomic s_whiteNits; static std::atomic s_supported; diff --git a/opennow-qt/src/streaming/rendering/WaylandHdrOutput.cpp b/opennow-qt/src/streaming/rendering/WaylandHdrOutput.cpp index 4688d1ae3..089183737 100644 --- a/opennow-qt/src/streaming/rendering/WaylandHdrOutput.cpp +++ b/opennow-qt/src/streaming/rendering/WaylandHdrOutput.cpp @@ -36,7 +36,12 @@ struct WaylandHdrOutput::Private { { QMutexLocker lock(&mutex); - if (snapshot.supported == value.supported && snapshot.whiteNits == value.whiteNits) + if (snapshot.supported == value.supported && snapshot.whiteNits == value.whiteNits + && snapshot.minimumNits == value.minimumNits + && snapshot.maximumNits == value.maximumNits + && snapshot.targetMinimumNits == value.targetMinimumNits + && snapshot.targetMaximumNits == value.targetMaximumNits + && snapshot.targetPrimaries == value.targetPrimaries) return; snapshot = value; } @@ -142,6 +147,15 @@ struct WaylandHdrOutput::Private else schedule(); } + static std::array chromaticities(int32_t r_x, int32_t r_y, int32_t g_x, + int32_t g_y, int32_t b_x, int32_t b_y, int32_t w_x, int32_t w_y) + { + constexpr double scale = 1'000'000.0; + return {double(r_x) / scale, double(r_y) / scale, double(g_x) / scale, + double(g_y) / scale, double(b_x) / scale, double(b_y) / scale, + double(w_x) / scale, double(w_y) / scale}; + } + static void informationDone(void *data, wp_image_description_info_v1 *) { static_cast(data)->finishRequest(true); @@ -166,8 +180,13 @@ struct WaylandHdrOutput::Private close(fd); static_cast(data)->metadata.icc = true; }, - [](void *data, wp_image_description_info_v1 *, int32_t, int32_t, int32_t, int32_t, - int32_t, int32_t, int32_t, int32_t) { static_cast(data)->metadata.primaries = true; }, + [](void *data, wp_image_description_info_v1 *, int32_t r_x, int32_t r_y, + int32_t g_x, int32_t g_y, int32_t b_x, int32_t b_y, int32_t w_x, int32_t w_y) { + auto *d = static_cast(data); + d->metadata.primaries = true; + d->metadata.primariesValue = + chromaticities(r_x, r_y, g_x, g_y, b_x, b_y, w_x, w_y); + }, [](void *, wp_image_description_info_v1 *, uint32_t) {}, [](void *data, wp_image_description_info_v1 *, uint32_t) { static_cast(data)->metadata.power = true; @@ -182,8 +201,13 @@ struct WaylandHdrOutput::Private d->metadata.maximum = max; d->metadata.white = ref; }, - [](void *, wp_image_description_info_v1 *, int32_t, int32_t, int32_t, int32_t, - int32_t, int32_t, int32_t, int32_t) {}, + [](void *data, wp_image_description_info_v1 *, int32_t r_x, int32_t r_y, + int32_t g_x, int32_t g_y, int32_t b_x, int32_t b_y, int32_t w_x, int32_t w_y) { + auto *d = static_cast(data); + d->metadata.targetPrimaries = true; + d->metadata.targetPrimariesValue = + chromaticities(r_x, r_y, g_x, g_y, b_x, b_y, w_x, w_y); + }, [](void *data, wp_image_description_info_v1 *, uint32_t min, uint32_t max) { auto *d = static_cast(data); d->metadata.targetLuminance = true; @@ -363,7 +387,18 @@ WaylandHdrOutput::State WaylandHdrOutput::stateForDescription(const Description || value.targetMinimum >= value.white || !std::isfinite(value.targetMaximum) || value.targetMaximum <= value.white || value.targetMaximum > 10000.0) return {}; - return {true, float(value.white)}; + const auto &primaries = value.targetPrimaries ? value.targetPrimariesValue : value.primariesValue; + for (double coordinate : primaries) + if (!std::isfinite(coordinate) || coordinate < 0.0 || coordinate > 1.0) return {}; + State state; + state.supported = true; + state.whiteNits = float(value.white); + state.minimumNits = float(value.minimum); + state.maximumNits = float(value.maximum); + state.targetMinimumNits = float(value.targetMinimum); + state.targetMaximumNits = float(value.targetMaximum); + state.targetPrimaries = primaries; + return state; } bool WaylandHdrOutput::eventFilter(QObject *watched, QEvent *event) diff --git a/opennow-qt/src/streaming/rendering/WaylandHdrOutput.h b/opennow-qt/src/streaming/rendering/WaylandHdrOutput.h index 885d564e4..b346447a5 100644 --- a/opennow-qt/src/streaming/rendering/WaylandHdrOutput.h +++ b/opennow-qt/src/streaming/rendering/WaylandHdrOutput.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include class QWindow; @@ -12,6 +13,11 @@ class WaylandHdrOutput final : public QObject struct State { bool supported = false; float whiteNits = 203.0f; + float minimumNits = 0.0f; + float maximumNits = 0.0f; + float targetMinimumNits = 0.0f; + float targetMaximumNits = 0.0f; + std::array targetPrimaries{}; }; explicit WaylandHdrOutput(QObject *parent = nullptr); @@ -36,11 +42,14 @@ class WaylandHdrOutput final : public QObject bool icc = false; bool luminances = false; bool targetLuminance = false; + bool targetPrimaries = false; double minimum = 0; double maximum = 0; double white = 0; double targetMinimum = 0; double targetMaximum = 0; + std::array primariesValue{}; + std::array targetPrimariesValue{}; }; [[nodiscard]] static State stateForDescription(const Description &description); struct Private; diff --git a/opennow-qt/tests/tst_coreclient.cpp b/opennow-qt/tests/tst_coreclient.cpp index fcdfe0e22..31f08a036 100644 --- a/opennow-qt/tests/tst_coreclient.cpp +++ b/opennow-qt/tests/tst_coreclient.cpp @@ -475,6 +475,46 @@ private slots: client.stop(); } + void injectsValidatedNativeHdrDisplayCapability() + { + CoreClient client; + QSignalSpy responses(&client, &CoreClient::responseReceived); + QVERIFY(client.start(fakeCorePath())); + QTRY_COMPARE_WITH_TIMEOUT(client.state(), QStringLiteral("ready"), 2'000); + for (const auto &method : {QStringLiteral("session.create"), QStringLiteral("streamer.prepare"), + QStringLiteral("settings.choices.get")}) { + responses.clear(); + CoreClient::NativeHdrDisplay display; + display.available = true; + display.minimumNits = 0.005; + display.maximumNits = 620; + client.setNativeHdrDisplay(display); + QVERIFY(!client.request(method, {{QStringLiteral("appId"), QStringLiteral("123")}}).isEmpty()); + QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 2'000); + const auto runtime = responses.first().at(1).toJsonObject() + .value(QStringLiteral("params")).toObject() + .value(QStringLiteral("runtimeCapabilities")).toObject(); + const auto injected = runtime.value(QStringLiteral("nativeHdrDisplay")).toObject(); + QCOMPARE(injected.value(QStringLiteral("minimumNits")).toDouble(), 0.005); + QCOMPARE(injected.value(QStringLiteral("maximumNits")).toDouble(), 620.0); + responses.clear(); + client.setNativeHdrDisplay({}); + const QJsonObject stale{ + {QStringLiteral("runtimeCapabilities"), + QJsonObject{{QStringLiteral("nativeHdrDisplay"), + QJsonObject{{QStringLiteral("minimumNits"), 0.005}, + {QStringLiteral("maximumNits"), 620}}}, + {QStringLiteral("protocolVersion"), 7}}}}; + QVERIFY(!client.request(method, stale).isEmpty()); + QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 2'000); + const auto absent = responses.first().at(1).toJsonObject() + .value(QStringLiteral("params")).toObject() + .value(QStringLiteral("runtimeCapabilities")).toObject(); + QVERIFY(!absent.contains(QStringLiteral("nativeHdrDisplay"))); + } + client.stop(); + } + void injectedHdrOutputControlsRealCoreColorDescriptors() { QTemporaryDir directory; @@ -492,6 +532,11 @@ private slots: QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 5'000); for (bool supported : {false, true, false}) { client.setNativeHdrSupported(supported); + CoreClient::NativeHdrDisplay display; + display.available = true; + display.minimumNits = 0.005; + display.maximumNits = 620; + client.setNativeHdrDisplay(display); const auto capabilities = QJsonDocument::fromJson(R"({"protocolVersion":7, "videoBackends":[{"backend":"vaapi","available":true,"codecs":[ {"codec":"h265","available":true,"hdrSupported":true, diff --git a/opennow-qt/tests/tst_hdrcolor.cpp b/opennow-qt/tests/tst_hdrcolor.cpp index 898641480..c08957f6f 100644 --- a/opennow-qt/tests/tst_hdrcolor.cpp +++ b/opennow-qt/tests/tst_hdrcolor.cpp @@ -214,6 +214,7 @@ private slots: QVERIFY(!output.supported()); QCOMPARE(output.outputMode(), 0); QVERIFY(!output.chromeRequired()); + QVERIFY(!output.displayData().available); const auto state = HdrOutput::renderState(); QCOMPARE(state.mode, 0); QVERIFY(!state.supported); diff --git a/opennow-qt/tests/tst_waylandhdroutput.cpp b/opennow-qt/tests/tst_waylandhdroutput.cpp index 7c155daf0..796dc5672 100644 --- a/opennow-qt/tests/tst_waylandhdroutput.cpp +++ b/opennow-qt/tests/tst_waylandhdroutput.cpp @@ -19,6 +19,7 @@ class WaylandHdrOutputTest : public QObject value.maximum = 10000; value.white = 203; value.targetMaximum = 1000; + value.primariesValue = {0.68, 0.32, 0.265, 0.69, 0.15, 0.06, 0.3127, 0.329}; return value; } @@ -92,6 +93,67 @@ private slots: QVERIFY(!WaylandHdrOutput::stateForDescription(value).supported); } + void publishedLuminancesAndTargetPrimariesCarryMeasuredValues() + { + auto value = hdrDescription(); + value.minimum = 0.005; + value.maximum = 10000; + value.white = 203; + value.targetMinimum = 0.0005; + value.targetMaximum = 620; + const auto state = WaylandHdrOutput::stateForDescription(value); + QVERIFY(state.supported); + QCOMPARE(state.whiteNits, 203.0f); + QCOMPARE(state.minimumNits, 0.005f); + QCOMPARE(state.maximumNits, 10000.0f); + QCOMPARE(state.targetMinimumNits, 0.0005f); + QCOMPARE(state.targetMaximumNits, 620.0f); + QCOMPARE(state.targetPrimaries[0], 0.68); + QCOMPARE(state.targetPrimaries[7], 0.329); + } + + void targetPrimariesOverrideTheEncodingVolume() + { + auto value = hdrDescription(); + value.targetPrimaries = true; + value.targetPrimariesValue = {0.708, 0.292, 0.17, 0.797, 0.131, 0.046, 0.3127, 0.329}; + const auto state = WaylandHdrOutput::stateForDescription(value); + QVERIFY(state.supported); + QCOMPARE(state.targetPrimaries[0], 0.708); + auto withoutTarget = hdrDescription(); + const auto encodingVolume = WaylandHdrOutput::stateForDescription(withoutTarget); + QVERIFY(encodingVolume.supported); + QCOMPARE(encodingVolume.targetPrimaries[0], 0.68); + } + + void invalidPrimariesFailClosed() + { + const double nan = std::numeric_limits::quiet_NaN(); + const double infinity = std::numeric_limits::infinity(); + for (size_t index = 0; index < 8; ++index) { + for (double invalid : {nan, infinity, -0.1, 1.5}) { + auto value = hdrDescription(); + value.targetPrimaries = true; + value.targetPrimariesValue[index] = invalid; + QVERIFY(!WaylandHdrOutput::stateForDescription(value).supported); + auto encoding = hdrDescription(); + encoding.primariesValue[index] = invalid; + QVERIFY(!WaylandHdrOutput::stateForDescription(encoding).supported); + } + } + } + + void unsupportedDescriptionsPublishNoMeasuredValues() + { + const auto state = WaylandHdrOutput::stateForDescription({}); + QVERIFY(!state.supported); + QCOMPARE(state.minimumNits, 0.0f); + QCOMPARE(state.maximumNits, 0.0f); + QCOMPARE(state.targetMinimumNits, 0.0f); + QCOMPARE(state.targetMaximumNits, 0.0f); + for (double coordinate : state.targetPrimaries) QCOMPARE(coordinate, 0.0); + } + void nonWaylandAndWindowLifecycleFailClosed() { QVERIFY(!QGuiApplication::platformName().startsWith(QStringLiteral("wayland")));