From 146b4d26e4fb008d90a0d368ba2e7f08b79a26dc Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:58:20 +0000 Subject: [PATCH] fix(hdr): use measured Windows monitor metadata for native HDR --- docs/core-protocol.md | 33 ++-- docs/hdr.md | 2 +- native/opennow-core/src/cloudmatch.rs | 176 +++++++++++++++++- native/opennow-core/src/settings.rs | 10 +- native/opennow-core/src/streamer.rs | 65 ++++++- opennow-qt/cmake/PlatformHdr.cmake | 7 + opennow-qt/cmake/Tests.cmake | 11 ++ opennow-qt/src/app/ApplicationStartup.cpp | 2 + opennow-qt/src/core/CoreClient.cpp | 19 +- opennow-qt/src/core/CoreClient.h | 4 + .../src/streaming/rendering/HdrOutput.cpp | 39 +++- .../src/streaming/rendering/HdrOutput.h | 7 + .../streaming/rendering/WindowsHdrDisplay.cpp | 91 +++++++++ .../streaming/rendering/WindowsHdrDisplay.h | 32 ++++ opennow-qt/tests/tst_coreclient.cpp | 15 ++ opennow-qt/tests/tst_windowshdrdisplay.cpp | 72 +++++++ 16 files changed, 543 insertions(+), 42 deletions(-) create mode 100644 opennow-qt/src/streaming/rendering/WindowsHdrDisplay.cpp create mode 100644 opennow-qt/src/streaming/rendering/WindowsHdrDisplay.h create mode 100644 opennow-qt/tests/tst_windowshdrdisplay.cpp diff --git a/docs/core-protocol.md b/docs/core-protocol.md index 732f6f4b8..2ed578024 100644 --- a/docs/core-protocol.md +++ b/docs/core-protocol.md @@ -793,26 +793,37 @@ only for this validated HDR request. `requestedStreamingFeatures.trueHdr=false` separate because TrueHDR is the server's AI SDR-to-HDR filter, not native HDR. 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` carries validated output luminance -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. +when the current output reports it. `desiredContentMaxLuminance` is the peak in nits; +`desiredContentMinLuminance` is the minimum in 0.0001-nit units, rounded after +multiplication by 10000. Wayland color-management supplies only this luminance pair. +`desiredContentMaxFrameAverageLuminance` is omitted for a pair-only snapshot because +Wayland exposes no comparable sustained full-frame value. When the runtime supplies +a complete validated optional group, the core also writes its full-frame nits into +that field and writes the red, green, blue, and white chromaticities as rounded +`xy * 50000` integers in `displayPrimaryX0/Y0`, `displayPrimaryX1/Y1`, +`displayPrimaryX2/Y2`, and `displayWhitePointX/Y`, respectively. The official PC +client's Bifrost serializer confirms the field order and scales; the mapping from +the display's maximum full-frame luminance to the frame-average field is inferred +from native struct offsets, not an observed HDR session. 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 sends `displayData:null`. HDR does not invent display -primaries or a white point: the Qt output snapshot provides luminance only, so those -fields are omitted until validated chromaticities cross the Qt/core boundary. +primaries or a white point; those fields require validated chromaticities from the +current runtime output. 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: +report. Optionally it also carries `maximumFullFrameNits` and normalized floating-point +`redX/redY`, `greenX/greenY`, `blueX/blueY`, and `whiteX/whiteY`. The core validates +the pair again and drops it when the bounds are not finite, negative, above 10000 cd/m², +or not strictly increasing. It accepts the optional group only when every coordinate +is finite, within [0, 1], has a valid xy sum and positive y, the primaries form a +nondegenerate triangle, and `minimumNits < maximumFullFrameNits <= maximumNits`. +Missing or invalid optional fields discard the entire group but retain a valid pair. +The snapshot is transient: `settings.set` rejects it, the settings loader discards persisted copies, and the core never saves runtime capability results. diff --git a/docs/hdr.md b/docs/hdr.md index c060fe70e..8bbfe832a 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` for native HDR and `trueHdr=false`. TrueHDR requests a separate server-side AI SDR-to-HDR filter, which OpenNOW does not offer. SDR requests send `displayData:null`. HDR requests include measured output luminance when a validated snapshot is available, or requested-content defaults of 1000 nits maximum, 400 nits frame average, and zero minimum. Neither source supplies display primaries or a white point, so the core does not claim those values. Complete monitor chromaticity requires a validated output-metadata contract from the Qt shell; the luminance defaults are not physical-display measurements. 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. +CloudMatch receives `sdrHdrMode=1` for native HDR and `trueHdr=false`. TrueHDR requests a separate server-side AI SDR-to-HDR filter, which OpenNOW does not offer. SDR requests send `displayData:null`. HDR requests include measured output luminance when a validated snapshot is available, or requested-content defaults of 1000 nits maximum, 400 nits frame average, and zero minimum. A complete, validated runtime metadata group can additionally supply maximum full-frame luminance and display primaries and white point. Missing or invalid optional metadata is omitted rather than inferred. The luminance defaults are not physical-display measurements. Wayland color-management continues to supply only its target luminance pair: its PQ description does not establish a display peak from primary color volume luminance or expose a 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 298134b83..97c966029 100644 --- a/native/opennow-core/src/cloudmatch.rs +++ b/native/opennow-core/src/cloudmatch.rs @@ -1308,13 +1308,6 @@ fn build_resume_body(app_id: &str, session: &Value, settings: &Value, device_id: "metaData":null, "adUpdates":null}) } -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 { if !hdr { return Value::Null; @@ -1324,12 +1317,34 @@ fn monitor_display_data(hdr: bool, settings: &Value) -> Value { "desiredContentMinLuminance":0, "desiredContentMaxFrameAverageLuminance":400 }); - if let Some((minimum, maximum)) = measured_display_luminance(settings, hdr) { - data["desiredContentMaxLuminance"] = json!(maximum); - data["desiredContentMinLuminance"] = json!((minimum * 10_000.0).round() as u64); + if let Some(display) = + crate::streamer::validated_native_hdr_display(&settings["nativeHdrDisplay"]) + { + data["desiredContentMaxLuminance"] = json!(display.maximum_nits); + data["desiredContentMinLuminance"] = + json!((display.minimum_nits * 10_000.0).round() as u64); if let Some(object) = data.as_object_mut() { object.remove("desiredContentMaxFrameAverageLuminance"); } + if let Some(metadata) = display.metadata { + data["desiredContentMaxFrameAverageLuminance"] = + json!(metadata.maximum_full_frame_nits); + for (key, coordinate) in [ + "displayPrimaryX0", + "displayPrimaryY0", + "displayPrimaryX1", + "displayPrimaryY1", + "displayPrimaryX2", + "displayPrimaryY2", + "displayWhitePointX", + "displayWhitePointY", + ] + .iter() + .zip(metadata.coordinates) + { + data[*key] = json!((coordinate * 50_000.0).round() as u64); + } + } } data } @@ -4167,6 +4182,147 @@ mod tests { assert!(sdr_data.is_null()); } + #[test] + fn measured_monitor_metadata_survives_resolution_and_uses_wire_units() { + for peak in [400, 620, 1068] { + let full_frame = if peak == 620 { peak } else { peak - 80 }; + let display = json!({"minimumNits":0.005,"maximumNits":peak, + "maximumFullFrameNits":full_frame, + "redX":0.64,"redY":0.33,"greenX":0.30,"greenY":0.60, + "blueX":0.15,"blueY":0.06,"whiteX":0.3127,"whiteY":0.329}); + let capabilities = json!({"protocolVersion":7,"nativeHdrSupported":true,"videoBackends":[{ + "backend":"vaapi","available":true,"codecs":[ + {"codec":"h265","available":true,"colorQualities":["8bit_420","10bit_420"]} + ]}],"nativeHdrDisplay":display}); + let resolved = crate::streamer::StreamerService::embedded_session_settings( + &json!({"codec":"h265","enableHdr":true,"nativeHdrDisplay":{ + "minimumNits":0.0,"maximumNits":1000,"maximumFullFrameNits":400, + "redX":0.7}}), + &capabilities, + ) + .unwrap(); + for (key, value) in display.as_object().unwrap() { + assert_eq!( + resolved["nativeHdrDisplay"][key].as_f64(), + value.as_f64(), + "{key}" + ); + } + let body = build_create_body("123", &json!({}), &resolved, "device"); + let request = &body["sessionRequestData"]; + let data = &request["clientRequestMonitorSettings"][0]["displayData"]; + assert_eq!(request["requestedStreamingFeatures"]["trueHdr"], false); + assert_eq!( + data["desiredContentMaxLuminance"].as_f64(), + Some(peak as f64) + ); + assert_eq!(data["desiredContentMinLuminance"], 50); + assert_eq!( + data["desiredContentMaxFrameAverageLuminance"].as_f64(), + Some(full_frame as f64) + ); + for (key, expected) in [ + ("displayPrimaryX0", 32000), + ("displayPrimaryY0", 16500), + ("displayPrimaryX1", 15000), + ("displayPrimaryY1", 30000), + ("displayPrimaryX2", 7500), + ("displayPrimaryY2", 3000), + ("displayWhitePointX", 15635), + ("displayWhitePointY", 16450), + ] { + assert_eq!(data[key], expected, "{key} at {peak} nits"); + } + let sdr = build_create_body( + "123", + &json!({}), + &json!({"codec":"h265", + "enableHdr":false,"nativeHdrSupported":true,"nativeHdrDisplay":display}), + "device", + ); + assert!( + sdr["sessionRequestData"]["clientRequestMonitorSettings"][0]["displayData"] + .is_null() + ); + assert_eq!( + sdr["sessionRequestData"]["requestedStreamingFeatures"]["trueHdr"], + false + ); + } + } + + #[test] + fn incomplete_or_invalid_monitor_metadata_keeps_only_valid_luminance() { + let valid = json!({"minimumNits":0.005,"maximumNits":620, + "maximumFullFrameNits":400, + "redX":0.64,"redY":0.33,"greenX":0.30,"greenY":0.60, + "blueX":0.15,"blueY":0.06,"whiteX":0.3127,"whiteY":0.329}); + 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 (key, value) in [ + ("maximumFullFrameNits", json!(0.005)), + ("maximumFullFrameNits", json!(621)), + ("maximumFullFrameNits", json!(null)), + ("redX", json!(1.1)), + ("redY", json!(-0.1)), + ("greenX", json!("0.3")), + ("blueY", json!(null)), + ("whiteX", json!(0.9)), + ("whiteY", json!(0.0)), + ("greenX", json!(0.64)), + ("greenX", json!(0.640001)), + ] { + let mut display = valid.clone(); + display[key] = value; + if key == "blueY" { + display.as_object_mut().unwrap().remove(key); + } + if key == "greenX" && display[key] == json!(0.64) { + display["greenY"] = json!(0.33); + } + if key == "greenX" && display[key] == json!(0.640001) { + display["greenY"] = json!(0.330001); + } + let resolved = crate::streamer::StreamerService::embedded_session_settings( + &json!({"codec":"h265","enableHdr":true,"nativeHdrDisplay":valid}), + &capabilities(display), + ) + .unwrap(); + assert_eq!( + resolved["nativeHdrDisplay"], + json!({"minimumNits":0.005,"maximumNits":620.0}), + "{key}" + ); + let body = build_create_body("123", &json!({}), &resolved, "device"); + let data = + &body["sessionRequestData"]["clientRequestMonitorSettings"][0]["displayData"]; + assert_eq!(data["desiredContentMaxLuminance"].as_f64(), Some(620.0)); + assert_eq!(data["desiredContentMinLuminance"], 50); + assert!(data.get("desiredContentMaxFrameAverageLuminance").is_none()); + assert!(data.get("displayPrimaryX0").is_none()); + assert!(data.get("displayWhitePointY").is_none()); + } + let no_display = crate::streamer::StreamerService::embedded_session_settings( + &json!({"codec":"h265","enableHdr":true,"nativeHdrDisplay":valid}), + &json!({"protocolVersion":7,"nativeHdrSupported":true,"videoBackends":[{ + "backend":"vaapi","available":true,"codecs":[ + {"codec":"h265","available":true,"colorQualities":["8bit_420","10bit_420"]} + ]}]}), + ) + .unwrap(); + assert!(no_display.get("nativeHdrDisplay").is_none()); + let fallback = build_create_body("123", &json!({}), &no_display, "device"); + let data = + &fallback["sessionRequestData"]["clientRequestMonitorSettings"][0]["displayData"]; + assert_eq!(data["desiredContentMaxLuminance"], 1000); + assert_eq!(data["desiredContentMinLuminance"], 0); + assert_eq!(data["desiredContentMaxFrameAverageLuminance"], 400); + } + #[test] fn malformed_display_luminance_keeps_documented_defaults() { for display in [ diff --git a/native/opennow-core/src/settings.rs b/native/opennow-core/src/settings.rs index 077872974..cfa71fab5 100644 --- a/native/opennow-core/src/settings.rs +++ b/native/opennow-core/src/settings.rs @@ -2174,7 +2174,10 @@ mod tests { store .set( "nativeHdrDisplay", - json!({"minimumNits":0.005,"maximumNits":620}) + json!({"minimumNits":0.005,"maximumNits":620, + "maximumFullFrameNits":400,"redX":0.64,"redY":0.33, + "greenX":0.30,"greenY":0.60,"blueX":0.15,"blueY":0.06, + "whiteX":0.3127,"whiteY":0.329}) ) .is_err() ); @@ -2186,7 +2189,10 @@ 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}); + persisted["nativeHdrDisplay"] = json!({"minimumNits":0.005,"maximumNits":620, + "maximumFullFrameNits":400,"redX":0.64,"redY":0.33, + "greenX":0.30,"greenY":0.60,"blueX":0.15,"blueY":0.06, + "whiteX":0.3127,"whiteY":0.329}); 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()); diff --git a/native/opennow-core/src/streamer.rs b/native/opennow-core/src/streamer.rs index 82b72494a..1fbc5443e 100644 --- a/native/opennow-core/src/streamer.rs +++ b/native/opennow-core/src/streamer.rs @@ -556,10 +556,16 @@ impl StreamerService { 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 let Some(display) = validated_native_hdr_display(&capabilities["nativeHdrDisplay"]) { + let mut snapshot = json!({"minimumNits":display.minimum_nits, + "maximumNits":display.maximum_nits}); + if let Some(metadata) = display.metadata { + snapshot["maximumFullFrameNits"] = json!(metadata.maximum_full_frame_nits); + for (key, coordinate) in HDR_CHROMATICITY_KEYS.iter().zip(metadata.coordinates) { + snapshot[*key] = json!(coordinate); + } + } + resolved["nativeHdrDisplay"] = snapshot; } if hdr { resolved["colorQuality"] = json!(color); @@ -1641,7 +1647,22 @@ fn apply_child_telemetry(message: &Value, state: &Arc>) { } } -pub(crate) fn validated_native_hdr_display(display: &Value) -> Option<(f64, f64)> { +const HDR_CHROMATICITY_KEYS: [&str; 8] = [ + "redX", "redY", "greenX", "greenY", "blueX", "blueY", "whiteX", "whiteY", +]; + +pub(crate) struct NativeHdrDisplay { + pub minimum_nits: f64, + pub maximum_nits: f64, + pub metadata: Option, +} + +pub(crate) struct NativeHdrMetadata { + pub maximum_full_frame_nits: f64, + pub coordinates: [f64; 8], +} + +pub(crate) fn validated_native_hdr_display(display: &Value) -> Option { let display = display.as_object()?; let minimum = display.get("minimumNits")?.as_f64()?; let maximum = display.get("maximumNits")?.as_f64()?; @@ -1653,7 +1674,39 @@ pub(crate) fn validated_native_hdr_display(display: &Value) -> Option<(f64, f64) { return None; } - Some((minimum, maximum)) + let metadata = (|| { + let full_frame = display.get("maximumFullFrameNits")?.as_f64()?; + if !full_frame.is_finite() || full_frame <= minimum || full_frame > maximum { + return None; + } + let mut coordinates = [0.0; 8]; + for (coordinate, key) in coordinates.iter_mut().zip(HDR_CHROMATICITY_KEYS) { + *coordinate = display.get(key)?.as_f64()?; + } + if coordinates.chunks_exact(2).any(|xy| { + !xy[0].is_finite() + || !xy[1].is_finite() + || !(0.0..=1.0).contains(&xy[0]) + || !(0.0..=1.0).contains(&xy[1]) + || xy[0] + xy[1] > 1.0 + || xy[1] == 0.0 + }) { + return None; + } + let [rx, ry, gx, gy, bx, by, ..] = coordinates; + if ((gx - rx) * (by - ry) - (gy - ry) * (bx - rx)).abs() <= 1e-6 { + return None; + } + Some(NativeHdrMetadata { + maximum_full_frame_nits: full_frame, + coordinates, + }) + })(); + Some(NativeHdrDisplay { + minimum_nits: minimum, + maximum_nits: maximum, + metadata, + }) } fn streamer_context(mut session: Value, settings: &Value) -> Value { diff --git a/opennow-qt/cmake/PlatformHdr.cmake b/opennow-qt/cmake/PlatformHdr.cmake index 494e10522..f4e2df58a 100644 --- a/opennow-qt/cmake/PlatformHdr.cmake +++ b/opennow-qt/cmake/PlatformHdr.cmake @@ -4,6 +4,13 @@ add_library(opennow-platform-hdr STATIC target_link_libraries(opennow-platform-hdr PUBLIC Qt6::Gui PRIVATE Qt6::GuiPrivate) target_include_directories(opennow-platform-hdr PUBLIC "${CMAKE_CURRENT_LIST_DIR}/../src") +if(WIN32) + target_sources(opennow-platform-hdr PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/../src/streaming/rendering/WindowsHdrDisplay.cpp" + "${CMAKE_CURRENT_LIST_DIR}/../src/streaming/rendering/WindowsHdrDisplay.h") + target_link_libraries(opennow-platform-hdr PRIVATE dxgi user32) +endif() + if(APPLE) enable_language(OBJCXX) set_property(TARGET opennow-platform-hdr PROPERTY OBJCXX_STANDARD 20) diff --git a/opennow-qt/cmake/Tests.cmake b/opennow-qt/cmake/Tests.cmake index f38834735..acb8a1e6d 100644 --- a/opennow-qt/cmake/Tests.cmake +++ b/opennow-qt/cmake/Tests.cmake @@ -27,6 +27,17 @@ if(BUILD_TESTING) add_test(NAME opennow-waylandhdroutput-tests COMMAND opennow-waylandhdroutput-tests -o -,txt) set_tests_properties(opennow-waylandhdroutput-tests PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 15) + qt_add_executable(opennow-windowshdrdisplay-tests + tests/tst_windowshdrdisplay.cpp + src/streaming/rendering/WindowsHdrDisplay.cpp) + target_include_directories(opennow-windowshdrdisplay-tests PRIVATE src) + target_link_libraries(opennow-windowshdrdisplay-tests PRIVATE Qt6::Test Qt6::Gui) + if(WIN32) + target_link_libraries(opennow-windowshdrdisplay-tests PRIVATE dxgi user32) + endif() + add_test(NAME opennow-windowshdrdisplay-tests COMMAND opennow-windowshdrdisplay-tests -o -,txt) + set_tests_properties(opennow-windowshdrdisplay-tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 15) qt_add_executable(opennow-hdrcolor-tests tests/tst_hdrcolor.cpp src/streaming/rendering/HdrChromeEffect.cpp src/streaming/rendering/HdrOutput.cpp) target_include_directories(opennow-hdrcolor-tests PRIVATE src) diff --git a/opennow-qt/src/app/ApplicationStartup.cpp b/opennow-qt/src/app/ApplicationStartup.cpp index 4a796683f..c646555a6 100644 --- a/opennow-qt/src/app/ApplicationStartup.cpp +++ b/opennow-qt/src/app/ApplicationStartup.cpp @@ -225,6 +225,8 @@ static int runApplicationSession(int argc, char *argv[], QString &restartExecuta snapshot.available = display.available; snapshot.minimumNits = display.minimumNits; snapshot.maximumNits = display.maximumNits; + snapshot.maximumFullFrameNits = display.maximumFullFrameNits; + snapshot.chromaticity = display.chromaticity; coreClient.setNativeHdrDisplay(snapshot); }); qmlRegisterType("OpenNOW", 1, 0, "HdrChromeEffect"); diff --git a/opennow-qt/src/core/CoreClient.cpp b/opennow-qt/src/core/CoreClient.cpp index 60942b817..1d4165e5e 100644 --- a/opennow-qt/src/core/CoreClient.cpp +++ b/opennow-qt/src/core/CoreClient.cpp @@ -266,9 +266,22 @@ QString CoreClient::request(const QString &method, const QJsonObject ¶ms, in 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}}); + QJsonObject display{{u"minimumNits"_s, m_nativeHdrDisplay.minimumNits}, + {u"maximumNits"_s, m_nativeHdrDisplay.maximumNits}}; + if (m_nativeHdrDisplay.maximumFullFrameNits) + display.insert(u"maximumFullFrameNits"_s, *m_nativeHdrDisplay.maximumFullFrameNits); + if (m_nativeHdrDisplay.chromaticity) { + const auto &c = *m_nativeHdrDisplay.chromaticity; + display.insert(u"redX"_s, c.redX); + display.insert(u"redY"_s, c.redY); + display.insert(u"greenX"_s, c.greenX); + display.insert(u"greenY"_s, c.greenY); + display.insert(u"blueX"_s, c.blueX); + display.insert(u"blueY"_s, c.blueY); + display.insert(u"whiteX"_s, c.whiteX); + display.insert(u"whiteY"_s, c.whiteY); + } + capabilities.insert(u"nativeHdrDisplay"_s, display); } else { capabilities.remove(u"nativeHdrDisplay"_s); } diff --git a/opennow-qt/src/core/CoreClient.h b/opennow-qt/src/core/CoreClient.h index 414e475a1..722ead874 100644 --- a/opennow-qt/src/core/CoreClient.h +++ b/opennow-qt/src/core/CoreClient.h @@ -9,6 +9,8 @@ #include #include #include +#include +#include "streaming/rendering/WindowsHdrDisplay.h" class CoreClient final : public QObject { @@ -34,6 +36,8 @@ class CoreClient final : public QObject bool available = false; double minimumNits = 0.0; double maximumNits = 0.0; + std::optional maximumFullFrameNits; + std::optional chromaticity; }; Q_INVOKABLE bool start(const QString &program, const QStringList &arguments = {}); diff --git a/opennow-qt/src/streaming/rendering/HdrOutput.cpp b/opennow-qt/src/streaming/rendering/HdrOutput.cpp index bf541798d..cc78a5978 100644 --- a/opennow-qt/src/streaming/rendering/HdrOutput.cpp +++ b/opennow-qt/src/streaming/rendering/HdrOutput.cpp @@ -73,16 +73,29 @@ void HdrOutput::attach(QQuickWindow *window) } }, Qt::DirectConnection); connect(window, &QWindow::screenChanged, this, [this] { - if (m_supported) { - m_supported = false; - emit changed(); - } - m_probeRequested.store(true); - m_window->update(); + const bool wasSupported = m_supported; + m_supported = false; + if (wasSupported && !m_display.available) emit changed(); + invalidateDisplay(); }); +#if defined(Q_OS_WIN) + connect(window, &QWindow::xChanged, this, &HdrOutput::invalidateDisplay); + connect(window, &QWindow::yChanged, this, &HdrOutput::invalidateDisplay); + connect(window, &QWindow::widthChanged, this, &HdrOutput::invalidateDisplay); + connect(window, &QWindow::heightChanged, this, &HdrOutput::invalidateDisplay); +#endif m_probeTimer.start(); } +void HdrOutput::invalidateDisplay() +{ + const bool changed = m_display.available; + m_display = {}; + if (changed) emit this->changed(); + m_probeRequested.store(true); + if (m_window) m_window->update(); +} + HdrOutput::State HdrOutput::renderState() { return {s_mode.load(), s_whiteNits.load(), s_supported.load()}; @@ -110,9 +123,17 @@ void HdrOutput::publish(State state) 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; +#if defined(Q_OS_WIN) + const auto windows = state.supported ? activeWindowsHdrDisplay(m_window) : std::nullopt; + if (windows) { + next.available = true; + next.minimumNits = windows->minimumNits; + next.maximumNits = windows->maximumNits; + next.maximumFullFrameNits = windows->maximumFullFrameNits; + next.chromaticity = windows->chromaticity; + } +#endif + const bool displayChanged = next != m_display; m_display = next; if (m_supported == state.supported && m_mode == state.outputMode && !displayChanged) return; m_supported = state.supported; diff --git a/opennow-qt/src/streaming/rendering/HdrOutput.h b/opennow-qt/src/streaming/rendering/HdrOutput.h index 748cb9250..92658523a 100644 --- a/opennow-qt/src/streaming/rendering/HdrOutput.h +++ b/opennow-qt/src/streaming/rendering/HdrOutput.h @@ -5,6 +5,8 @@ #include #include #include +#include +#include "streaming/rendering/WindowsHdrDisplay.h" class QQuickWindow; class HdrOutputPass; @@ -46,6 +48,10 @@ class HdrOutput final : public QObject bool available = false; double minimumNits = 0.0; double maximumNits = 0.0; + std::optional maximumFullFrameNits; + std::optional chromaticity; + + bool operator==(const DisplayData &) const = default; }; [[nodiscard]] DisplayData displayData() const; @@ -56,6 +62,7 @@ class HdrOutput final : public QObject void updateOutput(); void publish(State state); void requestChrome(bool required); + void invalidateDisplay(); QPointer m_window; QTimer m_probeTimer; std::atomic m_probeRequested{true}; diff --git a/opennow-qt/src/streaming/rendering/WindowsHdrDisplay.cpp b/opennow-qt/src/streaming/rendering/WindowsHdrDisplay.cpp new file mode 100644 index 000000000..a4c37eda8 --- /dev/null +++ b/opennow-qt/src/streaming/rendering/WindowsHdrDisplay.cpp @@ -0,0 +1,91 @@ +#include "streaming/rendering/WindowsHdrDisplay.h" + +#include +#include + +#ifdef Q_OS_WIN +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include + +static_assert(DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 == 12); +#endif + +std::optional validatedWindowsHdrDisplay( + int colorSpace, double minimumNits, double maximumNits, double maximumFullFrameNits, + const HdrChromaticity &chromaticity) +{ + if (colorSpace != 12 || !std::isfinite(minimumNits) || minimumNits < 0.0 + || !std::isfinite(maximumNits) || maximumNits <= minimumNits + || maximumNits > 10'000.0) return std::nullopt; + + WindowsHdrDisplay display{minimumNits, maximumNits}; + if (!std::isfinite(maximumFullFrameNits) || maximumFullFrameNits <= minimumNits + || maximumFullFrameNits > maximumNits) return display; + const double coordinates[] = {chromaticity.redX, chromaticity.redY, + chromaticity.greenX, chromaticity.greenY, chromaticity.blueX, chromaticity.blueY, + chromaticity.whiteX, chromaticity.whiteY}; + bool valid = true; + for (int i = 0; i < 8; i += 2) + valid = valid && std::isfinite(coordinates[i]) && std::isfinite(coordinates[i + 1]) + && coordinates[i] >= 0.0 && coordinates[i] <= 1.0 + && coordinates[i + 1] > 0.0 && coordinates[i + 1] <= 1.0 + && coordinates[i] + coordinates[i + 1] <= 1.0; + const double area = (chromaticity.greenX - chromaticity.redX) + * (chromaticity.blueY - chromaticity.redY) + - (chromaticity.greenY - chromaticity.redY) + * (chromaticity.blueX - chromaticity.redX); + if (valid && std::isfinite(area) && std::abs(area) > 1e-6) { + display.maximumFullFrameNits = maximumFullFrameNits; + display.chromaticity = chromaticity; + } + return display; +} + +std::optional activeWindowsHdrDisplay(QWindow *window) +{ +#ifdef Q_OS_WIN + if (!window || !window->isVisible()) return std::nullopt; + const auto hwnd = reinterpret_cast(window->winId()); + const HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONULL); + if (!monitor) return std::nullopt; + + using Microsoft::WRL::ComPtr; + ComPtr factory; + if (FAILED(CreateDXGIFactory1(IID_PPV_ARGS(&factory)))) return std::nullopt; + std::optional fallback; + for (UINT adapterIndex = 0; adapterIndex < 64; ++adapterIndex) { + ComPtr adapter; + if (factory->EnumAdapters1(adapterIndex, &adapter) == DXGI_ERROR_NOT_FOUND) break; + if (!adapter) continue; + for (UINT outputIndex = 0; outputIndex < 64; ++outputIndex) { + ComPtr output; + if (adapter->EnumOutputs(outputIndex, &output) == DXGI_ERROR_NOT_FOUND) break; + if (!output) continue; + DXGI_OUTPUT_DESC identity{}; + if (FAILED(output->GetDesc(&identity)) || identity.Monitor != monitor) continue; + ComPtr output6; + if (FAILED(output.As(&output6))) continue; + DXGI_OUTPUT_DESC1 desc{}; + if (FAILED(output6->GetDesc1(&desc))) continue; + const auto display = validatedWindowsHdrDisplay(int(desc.ColorSpace), desc.MinLuminance, + desc.MaxLuminance, desc.MaxFullFrameLuminance, + {desc.RedPrimary[0], desc.RedPrimary[1], desc.GreenPrimary[0], + desc.GreenPrimary[1], desc.BluePrimary[0], desc.BluePrimary[1], + desc.WhitePoint[0], desc.WhitePoint[1]}); + if (display && display->chromaticity) return display; + if (display && !fallback) fallback = display; + } + } + return fallback; +#else + Q_UNUSED(window) + return std::nullopt; +#endif +} diff --git a/opennow-qt/src/streaming/rendering/WindowsHdrDisplay.h b/opennow-qt/src/streaming/rendering/WindowsHdrDisplay.h new file mode 100644 index 000000000..b67d5d239 --- /dev/null +++ b/opennow-qt/src/streaming/rendering/WindowsHdrDisplay.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +class QWindow; + +struct HdrChromaticity { + double redX; + double redY; + double greenX; + double greenY; + double blueX; + double blueY; + double whiteX; + double whiteY; + + bool operator==(const HdrChromaticity &) const = default; +}; + +struct WindowsHdrDisplay { + double minimumNits; + double maximumNits; + std::optional maximumFullFrameNits; + std::optional chromaticity; + + bool operator==(const WindowsHdrDisplay &) const = default; +}; + +[[nodiscard]] std::optional validatedWindowsHdrDisplay( + int colorSpace, double minimumNits, double maximumNits, double maximumFullFrameNits, + const HdrChromaticity &chromaticity); +[[nodiscard]] std::optional activeWindowsHdrDisplay(QWindow *window); diff --git a/opennow-qt/tests/tst_coreclient.cpp b/opennow-qt/tests/tst_coreclient.cpp index ae45261e1..772f174af 100644 --- a/opennow-qt/tests/tst_coreclient.cpp +++ b/opennow-qt/tests/tst_coreclient.cpp @@ -487,6 +487,7 @@ private slots: QCOMPARE(actual.value(QStringLiteral("appId")), params.value(QStringLiteral("appId"))); const auto runtime = actual.value(QStringLiteral("runtimeCapabilities")).toObject(); QCOMPARE(runtime.value(QStringLiteral("nativeHdrSupported")).toBool(), supported); + QVERIFY(!runtime.contains(QStringLiteral("nativeHdrDisplay"))); QCOMPARE(runtime.value(QStringLiteral("protocolVersion")).toInt(), 7); QVERIFY(!actual.contains(QStringLiteral("settings"))); QCOMPARE(params.value(QStringLiteral("runtimeCapabilities")).toObject(), capabilities); @@ -508,6 +509,9 @@ private slots: display.available = true; display.minimumNits = 0.005; display.maximumNits = 620; + display.maximumFullFrameNits = 400; + display.chromaticity = HdrChromaticity{0.68, 0.32, 0.265, 0.69, + 0.15, 0.06, 0.3127, 0.329}; client.setNativeHdrDisplay(display); QVERIFY(!client.request(method, {{QStringLiteral("appId"), QStringLiteral("123")}}).isEmpty()); QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 2'000); @@ -517,7 +521,17 @@ private slots: const auto injected = runtime.value(QStringLiteral("nativeHdrDisplay")).toObject(); QCOMPARE(injected.value(QStringLiteral("minimumNits")).toDouble(), 0.005); QCOMPARE(injected.value(QStringLiteral("maximumNits")).toDouble(), 620.0); + QCOMPARE(injected.value(QStringLiteral("maximumFullFrameNits")).toDouble(), 400.0); + QCOMPARE(injected.value(QStringLiteral("redX")).toDouble(), 0.68); + QCOMPARE(injected.value(QStringLiteral("redY")).toDouble(), 0.32); + QCOMPARE(injected.value(QStringLiteral("greenX")).toDouble(), 0.265); + QCOMPARE(injected.value(QStringLiteral("greenY")).toDouble(), 0.69); + QCOMPARE(injected.value(QStringLiteral("blueX")).toDouble(), 0.15); + QCOMPARE(injected.value(QStringLiteral("blueY")).toDouble(), 0.06); + QCOMPARE(injected.value(QStringLiteral("whiteX")).toDouble(), 0.3127); + QCOMPARE(injected.value(QStringLiteral("whiteY")).toDouble(), 0.329); responses.clear(); + client.setNativeHdrSupported(true); client.setNativeHdrDisplay({}); const QJsonObject stale{ {QStringLiteral("runtimeCapabilities"), @@ -530,6 +544,7 @@ private slots: const auto absent = responses.first().at(1).toJsonObject() .value(QStringLiteral("params")).toObject() .value(QStringLiteral("runtimeCapabilities")).toObject(); + QCOMPARE(absent.value(QStringLiteral("nativeHdrSupported")).toBool(), true); QVERIFY(!absent.contains(QStringLiteral("nativeHdrDisplay"))); } client.stop(); diff --git a/opennow-qt/tests/tst_windowshdrdisplay.cpp b/opennow-qt/tests/tst_windowshdrdisplay.cpp new file mode 100644 index 000000000..b49b2939e --- /dev/null +++ b/opennow-qt/tests/tst_windowshdrdisplay.cpp @@ -0,0 +1,72 @@ +#include "streaming/rendering/WindowsHdrDisplay.h" + +#include +#include + +class WindowsHdrDisplayTests final : public QObject +{ + Q_OBJECT + +private slots: + void acceptsValidDisplayAndOptionalMetadata() + { + const HdrChromaticity primaries{0.68, 0.32, 0.265, 0.69, + 0.15, 0.06, 0.3127, 0.329}; + const auto display = validatedWindowsHdrDisplay(12, 0.005, 1000, 600, primaries); + QVERIFY(display); + QCOMPARE(display->minimumNits, 0.005); + QCOMPARE(display->maximumNits, 1000.0); + QVERIFY(display->maximumFullFrameNits); + QCOMPARE(*display->maximumFullFrameNits, 600.0); + QVERIFY(display->chromaticity); + QVERIFY(*display->chromaticity == primaries); + + const auto missing = validatedWindowsHdrDisplay(12, 0, 400, 0, {}); + QVERIFY(missing); + QVERIFY(!missing->maximumFullFrameNits); + QVERIFY(!missing->chromaticity); + } + + void rejectsInactiveOrInvalidLuminance() + { + const HdrChromaticity primaries{0.68, 0.32, 0.265, 0.69, + 0.15, 0.06, 0.3127, 0.329}; + const double nan = std::numeric_limits::quiet_NaN(); + const double infinity = std::numeric_limits::infinity(); + QVERIFY(!validatedWindowsHdrDisplay(0, 0, 1000, 600, primaries)); + QVERIFY(!validatedWindowsHdrDisplay(12, -1, 1000, 600, primaries)); + QVERIFY(!validatedWindowsHdrDisplay(12, nan, 1000, 600, primaries)); + QVERIFY(!validatedWindowsHdrDisplay(12, 1000, 1000, 600, primaries)); + QVERIFY(!validatedWindowsHdrDisplay(12, 0, infinity, 600, primaries)); + QVERIFY(!validatedWindowsHdrDisplay(12, 0, 10001, 600, primaries)); + for (double invalid : {-1.0, nan, 1001.0}) { + const auto display = validatedWindowsHdrDisplay(12, 0, 1000, invalid, primaries); + QVERIFY(display); + QVERIFY(!display->maximumFullFrameNits); + QVERIFY(!display->chromaticity); + } + } + + void dropsInvalidChromaticityWithoutInventingValues() + { + const HdrChromaticity degenerate{0.1, 0.1, 0.2, 0.2, + 0.3, 0.3, 0.25, 0.25}; + const auto invalid = validatedWindowsHdrDisplay(12, 0.005, 1000, 600, degenerate); + QVERIFY(invalid); + QVERIFY(!invalid->maximumFullFrameNits); + QVERIFY(!invalid->chromaticity); + const auto nan = std::numeric_limits::quiet_NaN(); + const HdrChromaticity nonfinite{0.68, 0.32, 0.265, 0.69, + 0.15, 0.06, nan, 0.329}; + QVERIFY(!validatedWindowsHdrDisplay(12, 0, 1000, 600, nonfinite)->chromaticity); + const HdrChromaticity outOfRange{0.68, 0.32, 0.265, 0.69, + 1.1, 0.06, 0.3127, 0.329}; + QVERIFY(!validatedWindowsHdrDisplay(12, 0, 1000, 600, outOfRange)->chromaticity); + const HdrChromaticity impossible{0.68, 0.32, 0.265, 0.69, + 0.15, 0.06, 0.9, 0.329}; + QVERIFY(!validatedWindowsHdrDisplay(12, 0, 1000, 600, impossible)->chromaticity); + } +}; + +QTEST_GUILESS_MAIN(WindowsHdrDisplayTests) +#include "tst_windowshdrdisplay.moc"