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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions docs/core-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/hdr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
190 changes: 185 additions & 5 deletions native/opennow-core/src/cloudmatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
}],
Expand Down Expand Up @@ -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();
Expand Down
14 changes: 13 additions & 1 deletion native/opennow-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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"],
Expand All @@ -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();
}

Expand Down
23 changes: 23 additions & 0 deletions native/opennow-core/src/streamer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -1408,6 +1416,21 @@ fn apply_child_telemetry(message: &Value, state: &Arc<Mutex<Snapshot>>) {
}
}

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();
Expand Down
Loading
Loading