diff --git a/packages/shoji_wm/src/output.ts b/packages/shoji_wm/src/output.ts index 989531b1..d8f98f82 100644 --- a/packages/shoji_wm/src/output.ts +++ b/packages/shoji_wm/src/output.ts @@ -36,6 +36,7 @@ function cloneOutputState( position: { ...snapshot.position }, scale: snapshot.scale, availableModes: snapshot.availableModes.map((mode) => ({ ...mode })), + hdrSupported: snapshot.hdrSupported, }, ]), ); @@ -65,6 +66,7 @@ function normalizeOutputState( position: { ...snapshot.position }, scale: snapshot.scale, availableModes: snapshot.availableModes.map((mode) => ({ ...mode })), + hdrSupported: snapshot.hdrSupported, }, ]), ); @@ -101,6 +103,7 @@ function cloneOutputConfigEntry(config: OutputConfigEntry): OutputConfigEntry { ? { ...config.position } : undefined, scale: config.scale, + hdr: config.hdr, }; } diff --git a/packages/shoji_wm/src/types.ts b/packages/shoji_wm/src/types.ts index 21511f2b..a4c0d41b 100644 --- a/packages/shoji_wm/src/types.ts +++ b/packages/shoji_wm/src/types.ts @@ -919,6 +919,14 @@ export interface OutputExtendConfigEntry { resolution?: OutputResolutionPreference; position?: OutputPositionPreference; scale?: number; + /** + * Drive this output as HDR10 (PQ/BT.2020 signaling) when its EDID + * advertises ST 2084 support; ignored otherwise. Experimental: SDR + * content is composited in sRGB and PQ-encoded as a final pass. + * この出力の EDID が ST 2084 対応を示す場合、HDR10(PQ/BT.2020)で + * 駆動します。実験的機能です。 + */ + hdr?: boolean; } export interface OutputDisabledConfigEntry { @@ -950,6 +958,8 @@ export interface OutputStateSnapshot { }; scale: number; availableModes: OutputMode[]; + /** EDID advertises HDR (CTA-861 static metadata block). */ + hdrSupported?: boolean; } export interface OutputInfo extends OutputStateSnapshot { diff --git a/src/shojiwm/src/backend/clipped_surface.frag b/src/shojiwm/src/backend/clipped_surface.frag index 35e82997..25e18cfd 100644 --- a/src/shojiwm/src/backend/clipped_surface.frag +++ b/src/shojiwm/src/backend/clipped_surface.frag @@ -1,18 +1,31 @@ -precision highp float; - -uniform float alpha; -varying vec2 v_coords; +// Smithay substitutes the variant defines at the marker below, by plain string +// replacement (see gles/shaders/mod.rs). Without it the replacement is a no-op +// and all three compiled variants get identical source with EXTERNAL and +// NO_ALPHA *undefined* — an external-OES buffer would then be sampled through a +// `sampler2D` declaration, and NO_ALPHA (which smithay documents as mandatory +// for custom texture shaders) would never be honoured. +// +// Do not write the marker's name anywhere else in this file: the replacement is +// global, so a second occurrence — even inside a comment — would inject a +// `#define` mid-line and break the following line out of its comment. +//_DEFINES_ +// `#extension` must precede every non-preprocessor token in GLSL ES 1.00, so +// it has to come before `precision` and any declaration. #if defined(EXTERNAL) #extension GL_OES_EGL_image_external : require #endif +precision highp float; + #if defined(EXTERNAL) uniform samplerExternalOES tex; #else uniform sampler2D tex; #endif +uniform float alpha; +varying vec2 v_coords; uniform float clip_scale; uniform vec2 slot_size; uniform vec2 slot_origin; @@ -28,6 +41,148 @@ uniform vec2 sample_buffer_size; uniform vec2 sample_uv_snap_axes; uniform float sample_uv_compensation_enabled; +// Per-surface color management. Compositing happens on sRGB-encoded BT.709 +// values, so content tagged with any other transfer/primaries via +// `wp_color_management_v1` has to be converted on the way in — otherwise it is +// decoded as if it were sRGB (which it never was) and again by +// `output_encode.frag`, which is what makes PQ video look washed out. +// +// This is the SDR-correct conversion: HDR content is tone-mapped down into the +// compositing range rather than carried at HDR luminance, because the encode +// pass clamps to [0,1] before its own transfer. Carrying real HDR through +// needs linear compositing and is a separate change. +// +// 0 = passthrough (untagged, or already sRGB in sRGB primaries), 1 = ST 2084 PQ, +// 2 = extended linear (scRGB), 3 = sRGB transfer needing only a gamut change. +uniform float src_transfer; +// 0 = BT.709/sRGB primaries, 1 = BT.2020. +uniform float src_primaries; +// Content reference white in cd/m², from the surface's `Luminances` (or the +// protocol's per-transfer-function defaults when unset). Doubles as the +// compositing space's white point: 1.0 here means this many nits. +uniform float src_ref_nits; +// Tone-mapping knee parameters, already in the PQ domain because that is where +// BT.2390 operates. Precomputed on the CPU via `color::colorimetry:: +// pq_inverse_eotf` — deriving them here would cost four extra pow-heavy calls +// per fragment for values that are constant across the whole surface. +uniform float src_pq_lo; // content black +uniform float src_pq_hi; // content peak +uniform float dst_pq_hi; // what compositing-space 1.0 can represent + +// BT.2020 -> BT.709 linear-light gamut matrix, column-major. Inverse of the +// BT.2087 matrix in `output_encode.frag`; both are cross-checked against the +// CPU derivation in `color/colorimetry.rs`. +const mat3 BT2020_TO_BT709 = mat3( + 1.660491, -0.124550, -0.018151, + -0.587641, 1.132900, -0.100579, + -0.072850, -0.008349, 1.118730 +); + +// SMPTE ST 2084 (PQ) EOTF: PQ signal -> absolute cd/m². Inverse of +// `pq_inv_eotf` in `output_encode.frag`. +vec3 pq_eotf(vec3 e) { + const float m1 = 0.1593017578125; // 1305/8192 + const float m2 = 78.84375; // 2523/32 + const float c1 = 0.8359375; // 107/128 + const float c2 = 18.8515625; // 2413/128 + const float c3 = 18.6875; // 2392/128 + vec3 ep = pow(clamp(e, 0.0, 1.0), vec3(1.0 / m2)); + vec3 num = max(ep - vec3(c1), vec3(0.0)); + vec3 den = max(vec3(c2) - c3 * ep, vec3(0.000001)); + return 10000.0 * pow(num / den, vec3(1.0 / m1)); +} + +// sRGB inverse EOTF (IEC 61966-2-1 piecewise encode). +vec3 srgb_inv_eotf(vec3 c) { + vec3 lo = c * 12.92; + vec3 hi = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055; + return mix(hi, lo, vec3(lessThanEqual(c, vec3(0.0031308)))); +} + +// SMPTE ST 2084 (PQ) inverse EOTF: absolute cd/m² -> PQ signal. Mirrors the +// one in `output_encode.frag`; needed here to lift non-PQ sources into the +// domain BT.2390 works in. +vec3 pq_inv_eotf(vec3 nits) { + const float m1 = 0.1593017578125; + const float m2 = 78.84375; + const float c1 = 0.8359375; + const float c2 = 18.8515625; + const float c3 = 18.6875; + vec3 y = clamp(nits / 10000.0, 0.0, 1.0); + vec3 ym = pow(y, vec3(m1)); + return pow((vec3(c1) + c2 * ym) / (vec3(1.0) + c3 * ym), vec3(m2)); +} + +// ITU-R BT.2390 EETF, applied per channel on a PQ signal. +// +// Below the knee this is the *identity*, so content already inside the target +// range passes through untouched — the decisive advantage over a global curve +// like Reinhard, which pulls midtones down alongside highlights and leaves the +// whole image flat. Above the knee a Hermite spline rolls smoothly off to the +// target peak. Operating in PQ rather than linear light spreads the +// compression error evenly across *perceived* brightness. +float bt2390_channel(float e) { + float range = max(src_pq_hi - src_pq_lo, 0.000001); + float e1 = clamp((e - src_pq_lo) / range, 0.0, 1.0); + float max_lum = clamp((dst_pq_hi - src_pq_lo) / range, 0.0, 1.0); + float ks = 1.5 * max_lum - 0.5; + + float e2 = e1; + if (ks < 1.0 && e1 > ks) { + float t = (e1 - ks) / (1.0 - ks); + float t2 = t * t; + float t3 = t2 * t; + e2 = (2.0 * t3 - 3.0 * t2 + 1.0) * ks + + (t3 - 2.0 * t2 + t) * (1.0 - ks) + + (-2.0 * t3 + 3.0 * t2) * max_lum; + } + return e2 * range + src_pq_lo; +} + +vec3 bt2390_eetf(vec3 pq) { + return vec3( + bt2390_channel(pq.r), + bt2390_channel(pq.g), + bt2390_channel(pq.b) + ); +} + +// sRGB EOTF (IEC 61966-2-1 piecewise decode). +vec3 srgb_eotf(vec3 c) { + vec3 lo = c / 12.92; + vec3 hi = pow((max(c, vec3(0.0)) + vec3(0.055)) / 1.055, vec3(2.4)); + return mix(hi, lo, vec3(lessThanEqual(c, vec3(0.04045)))); +} + +// Convert one sampled, *unpremultiplied* texel into the compositing space. +vec3 to_compositing_space(vec3 c) { + float ref_nits = max(src_ref_nits, 0.0001); + + // Lift the source into the PQ domain, which is where the tone curve works. + vec3 pq; + if (src_transfer > 2.5) { + // sRGB transfer in wider primaries: decode, scale to absolute nits. + pq = pq_inv_eotf(srgb_eotf(c) * ref_nits); + } else if (src_transfer > 1.5) { + // Extended linear (scRGB): 1.0 is defined as 80 cd/m². + pq = pq_inv_eotf(max(c, vec3(0.0)) * 80.0); + } else { + // Already a PQ signal. + pq = clamp(c, 0.0, 1.0); + } + + // Compress the content's range into what the compositing space can hold. + // SDR sources land entirely below the knee and pass through unchanged. + vec3 linear = pq_eotf(bt2390_eetf(pq)); + + if (src_primaries > 0.5) { + linear = BT2020_TO_BT709 * linear; + } + // Normalize against the content's own reference white so diffuse white + // lands on 1.0 rather than being scaled by an unrelated display value. + return srgb_inv_eotf(clamp(linear / ref_nits, 0.0, 1.0)); +} + float rounded_alpha(vec2 coords, vec2 size) { if (coords.x < 0.0 || coords.y < 0.0 || coords.x > size.x || coords.y > size.y) { return 0.0; @@ -80,6 +235,19 @@ void main() { } vec4 color = texture2D(tex, sample_coords); +#if defined(NO_ALPHA) + // Buffer carries no alpha channel. Force it here, immediately after the + // sample — *not* at the end, where it would overwrite the rounded-corner + // coverage written by `rounded_alpha` below and square off every corner. + color = vec4(color.rgb, 1.0); +#endif + if (src_transfer > 0.5) { + // Wayland buffers carry premultiplied alpha. A transfer function is + // non-linear, so it has to be applied to the unpremultiplied value or + // partially transparent edges pick up haloes; re-premultiply after. + float a = max(color.a, 0.0001); + color.rgb = to_compositing_space(color.rgb / a) * a; + } vec2 local_coords = (input_to_local * vec3(v_coords, 1.0)).xy; if (rect_bounds_enabled > 0.5) { vec2 slot_coords = local_coords - slot_origin; diff --git a/src/shojiwm/src/backend/clipped_surface.rs b/src/shojiwm/src/backend/clipped_surface.rs index 81c8a201..09c6cdfd 100644 --- a/src/shojiwm/src/backend/clipped_surface.rs +++ b/src/shojiwm/src/backend/clipped_surface.rs @@ -146,6 +146,16 @@ pub struct ClippedSurfaceElement { sample_buffer_size: [f32; 2], sample_uv_snap_axes: [f32; 2], sample_uv_compensation_enabled: f32, + /// Per-surface color management, resolved once at element construction. + /// `src_transfer == 0.0` is the untagged fast path and makes the shader + /// skip the conversion entirely, so nothing changes for sRGB clients. + src_transfer: f32, + src_primaries: f32, + src_ref_nits: f32, + /// Tone-mapping knee, already PQ-encoded on the CPU. + src_pq_lo: f32, + src_pq_hi: f32, + dst_pq_hi: f32, } #[derive(Debug)] @@ -207,6 +217,7 @@ impl ClippedSurfaceElement { clip: ContentClip, forced_geometry: Option>, debug_label: Option, + image_description: Option, ) -> Result { if renderer .egl_context() @@ -273,6 +284,30 @@ impl ClippedSurfaceElement { "sample_uv_compensation_enabled", smithay::backend::renderer::gles::UniformType::_1f, ), + UniformName::new( + "src_transfer", + smithay::backend::renderer::gles::UniformType::_1f, + ), + UniformName::new( + "src_primaries", + smithay::backend::renderer::gles::UniformType::_1f, + ), + UniformName::new( + "src_ref_nits", + smithay::backend::renderer::gles::UniformType::_1f, + ), + UniformName::new( + "src_pq_lo", + smithay::backend::renderer::gles::UniformType::_1f, + ), + UniformName::new( + "src_pq_hi", + smithay::backend::renderer::gles::UniformType::_1f, + ), + UniformName::new( + "dst_pq_hi", + smithay::backend::renderer::gles::UniformType::_1f, + ), ], )?); renderer @@ -482,6 +517,59 @@ impl ClippedSurfaceElement { } } + // Resolve the surface's color-management tag into shader uniforms once, + // here, rather than per-fragment. `src_transfer == 0.0` is the untagged + // path and makes the shader skip the conversion, so sRGB clients are + // byte-identical to before. + let ( + src_transfer, + src_primaries, + src_ref_nits, + src_pq_lo, + src_pq_hi, + dst_pq_hi, + ) = match image_description { + Some(description) => { + let primaries = match description.primaries { + crate::color::ColorPrimaries::Srgb => 0.0, + crate::color::ColorPrimaries::Bt2020 => 1.0, + }; + let transfer = match description.tf { + // sRGB in sRGB primaries already *is* the compositing + // space. In a wider gamut it still needs the matrix, so it + // takes the decode-only branch instead of the fast path. + crate::color::TransferCharacteristics::Srgb if primaries == 0.0 => 0.0, + crate::color::TransferCharacteristics::Srgb => 3.0, + crate::color::TransferCharacteristics::St2084Pq => 1.0, + crate::color::TransferCharacteristics::ExtLinear => 2.0, + }; + let luminances = description.effective_luminances(); + // MaxCLL is the measured peak of the content; fall back to the + // description's declared maximum when the client didn't send it. + let max_nits = description + .max_cll + .map(|cll| cll as f32) + .unwrap_or(luminances.max); + // BT.2390 operates on PQ signals, so encode the knee here + // rather than paying four pow() calls per fragment for values + // that are constant across the surface. The target peak is the + // content's own reference white, which is what compositing-space + // 1.0 represents. + let to_pq = |nits: f32| { + crate::color::colorimetry::pq_inverse_eotf(nits as f64) as f32 + }; + ( + transfer, + primaries, + luminances.reference, + to_pq(luminances.min), + to_pq(max_nits), + to_pq(luminances.reference), + ) + } + None => (0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + }; + Ok(Self { inner, geometry: render_geometry, @@ -507,6 +595,12 @@ impl ClippedSurfaceElement { } else { 0.0 }, + src_transfer, + src_primaries, + src_ref_nits, + src_pq_lo, + src_pq_hi, + dst_pq_hi, }) } @@ -648,6 +742,12 @@ impl ClippedSurfaceElement { "sample_uv_compensation_enabled", self.sample_uv_compensation_enabled, ), + Uniform::new("src_transfer", self.src_transfer), + Uniform::new("src_primaries", self.src_primaries), + Uniform::new("src_ref_nits", self.src_ref_nits), + Uniform::new("src_pq_lo", self.src_pq_lo), + Uniform::new("src_pq_hi", self.src_pq_hi), + Uniform::new("dst_pq_hi", self.dst_pq_hi), ] } } diff --git a/src/shojiwm/src/backend/hdr_pipeline.rs b/src/shojiwm/src/backend/hdr_pipeline.rs new file mode 100644 index 00000000..261a960d --- /dev/null +++ b/src/shojiwm/src/backend/hdr_pipeline.rs @@ -0,0 +1,401 @@ +//! Two-stage HDR10 output pipeline. +//! +//! Stage 1 composites the output's full element list (windows, decorations, +//! effects, cursor, overlays) into a persistent fp16 offscreen texture with +//! its own damage tracker — the same pattern `render_output_capture_mirror` +//! uses for screencopy, so damage semantics are identical. +//! +//! Stage 2 hands the DRM pass a single [`HdrEncodeElement`] that draws the +//! intermediate through `output_encode.frag`: sRGB EOTF decode → +//! BT.709→BT.2020 gamut matrix → scale to `sdr_nits` absolute luminance → +//! ST 2084 (PQ) encode, straight into the 10-bit scanout buffer. +//! +//! Compositing itself still happens on sRGB-encoded values: per-element +//! linearization needs sRGB texture views across every draw program and is +//! deliberately out of scope here. The fp16 intermediate exists so PQ-tagged +//! client content (which exceeds the SDR range once decoded) has headroom +//! when that lands. + +use smithay::{ + backend::renderer::{ + Bind, Color32F, Offscreen, + damage::OutputDamageTracker, + element::{Element, Id, Kind, RenderElement}, + gles::{ + GlesError, GlesFrame, GlesRenderer, GlesTexProgram, GlesTexture, Uniform, UniformName, + UniformType, + }, + utils::{CommitCounter, OpaqueRegions}, + }, + output::Output, + utils::{Buffer, Physical, Rectangle, Scale, Size, Transform, user_data::UserDataMap}, +}; +use tracing::{info, warn}; + +use smithay::backend::allocator::Fourcc; + +/// Probe whether the GL context can render into fp16 (RGBA16F) targets. +/// Smithay only gates the texture allocation on GLES 3.0; actual +/// renderability additionally needs GL_EXT_color_buffer_half_float, which +/// surfaces as a framebuffer-completeness failure on bind. +pub fn probe_fp16_render_support( + renderer: &mut GlesRenderer +) -> bool { + let size = Size::::from( + ( + 16, + 16, + ) + ); + match Offscreen::::create_buffer( + renderer, + Fourcc::Abgr16161616f, + size + ) { + Ok( + mut texture + ) => match renderer + .bind( + &mut texture + ) { + Ok(_) => true, + Err( + error + ) => { + info!( + ?error, + "fp16 render targets unsupported (bind failed)" + ); + false + } + }, + Err( + error + ) => { + info!( + ?error, + "fp16 render targets unsupported (alloc failed)" + ); + false + } + } +} + +/// Luminance that sRGB full white maps to on the PQ signal (cd/m²). +/// ITU-R BT.2408 reference white by default; `SHOJI_SDR_NITS` overrides +/// for taste/testing. +pub(crate) fn sdr_reference_nits() -> f32 { + static NITS: std::sync::OnceLock = std::sync::OnceLock::new(); + *NITS.get_or_init(|| { + std::env::var( + "SHOJI_SDR_NITS" + ) + .ok() + .and_then(|value| value + .trim() + .parse::() + .ok()) + .filter(|nits| (10.0..=1000.0) + .contains( + nits + )) + .unwrap_or( + 203.0 + ) + }) +} + +struct HdrEncodeProgram( + GlesTexProgram +); + +fn ensure_encode_program( + renderer: &mut GlesRenderer +) -> Result { + if renderer + .egl_context() + .user_data() + .get::() + .is_none() + { + let program = renderer.compile_custom_texture_shader( + include_str!( + "output_encode.frag" + ), + &[ + UniformName::new( + "sdr_nits", + UniformType::_1f + ) + ], + )?; + renderer + .egl_context() + .user_data() + .insert_if_missing( + || HdrEncodeProgram( + program + ) + ); + } + Ok(renderer + .egl_context() + .user_data() + .get::() + .unwrap() + .0 + .clone()) +} + +/// Per-output HDR pipeline state, keyed by output name in +/// `ShojiWM::hdr_pipelines` (mirroring `output_capture_mirrors`). +pub struct HdrPipeline { + texture: GlesTexture, + damage_tracker: OutputDamageTracker, + size: Size, + scale: Scale, + transform: Transform, + /// Stable element id so the DRM damage tracker sees one persistent + /// element instead of a brand-new fullscreen quad every frame. + element_id: Id, + commit_counter: CommitCounter, + /// The texture holds last frame's composite (buffer age 1) once we've + /// rendered at least once without errors. + contents_valid: bool, +} + +/// Composite `elements` into the fp16 intermediate and return the single +/// PQ-encode element the DRM pass should render instead of the raw list. +/// Returns `Ok(None)` if the output has no mode yet. +pub fn render_hdr_pipeline( + renderer: &mut GlesRenderer, + pipeline: &mut Option, + output: &Output, + elements: &[E], + clear_color: [f32; 4], +) -> Result, Box> +where + E: RenderElement, +{ + let Some( + mode + ) = output + .current_mode() else { + return Ok( + None + ); + }; + let size = mode.size; + let scale: Scale = output + .current_scale() + .fractional_scale() + .into(); + let transform = output + .current_transform(); + + let recreate = pipeline + .as_ref() + .is_none_or(|pipeline| { + pipeline.size != size || pipeline.scale != scale || pipeline.transform != transform + }); + if recreate { + let buffer_size = size + .to_logical(1) + .to_buffer( + 1, + Transform::Normal + ); + let texture = + Offscreen::::create_buffer( + renderer, + Fourcc::Abgr16161616f, + buffer_size + )?; + *pipeline = Some(HdrPipeline { + texture, + damage_tracker: OutputDamageTracker::new( + size, + scale, + transform + ), + size, + scale, + transform, + element_id: Id::new(), + commit_counter: CommitCounter::default(), + contents_valid: false, + }); + } + let pipeline = pipeline + .as_mut() + .expect( + "pipeline was just created" + ); + + let program = ensure_encode_program( + renderer + )?; + + // Stage 1: composite into the fp16 intermediate. Age 1 keeps partial + // redraws once the texture holds the previous frame. + let age = if pipeline.contents_valid { 1 } else { 0 }; + let render_result = { + let mut target = renderer.bind( + &mut pipeline.texture + )?; + pipeline.damage_tracker + .render_output( + renderer, + &mut target, + age, + elements, + Color32F::new( + clear_color[0], + clear_color[1], + clear_color[2], + clear_color[3], + ), + ) + }; + let damaged = match render_result { + Ok( + result + ) => result.damage + .is_some(), + Err( + error + ) => { + pipeline.contents_valid = false; + return Err( + Box::new( + error + ) + ); + } + }; + pipeline.contents_valid = true; + if damaged { + pipeline.commit_counter + .increment(); + } + + let buffer_size = size + .to_logical(1) + .to_buffer( + 1, + Transform::Normal + ); + Ok(Some(HdrEncodeElement { + id: pipeline.element_id + .clone(), + commit: pipeline.commit_counter, + texture: pipeline.texture + .clone(), + program, + src: Rectangle::from_size( + ( + buffer_size.w as f64, + buffer_size.h as f64 + ).into() + ), + geometry: Rectangle::from_size( + size + ), + sdr_nits: sdr_reference_nits(), + })) +} + +/// Fullscreen quad that draws the fp16 intermediate through the PQ encode +/// shader. Reports itself opaque so the DRM compositor skips the clear. +pub struct HdrEncodeElement { + id: Id, + commit: CommitCounter, + texture: GlesTexture, + program: GlesTexProgram, + src: Rectangle, + geometry: Rectangle, + sdr_nits: f32, +} + +impl Element for HdrEncodeElement { + fn id( + &self + ) -> &Id { + &self.id + } + + fn current_commit( + &self + ) -> CommitCounter { + self.commit + } + + fn src( + &self + ) -> Rectangle { + self.src + } + + fn geometry( + &self, + _scale: Scale + ) -> Rectangle { + self.geometry + } + + fn opaque_regions( + &self, + _scale: Scale + ) -> OpaqueRegions { + OpaqueRegions::from_slice( + &[Rectangle::from_size( + self.geometry.size + )] + ) + } + + fn alpha( + &self + ) -> f32 { + 1.0 + } + + fn kind( + &self + ) -> Kind { + Kind::Unspecified + } +} + +impl RenderElement for HdrEncodeElement { + fn draw( + &self, + frame: &mut GlesFrame<'_, '_>, + src: Rectangle, + dst: Rectangle, + damage: &[Rectangle], + opaque_regions: &[Rectangle], + _cache: Option<&UserDataMap>, + ) -> Result<(), GlesError> { + let result = frame.render_texture_from_to( + &self.texture, + src, + dst, + damage, + opaque_regions, + Transform::Normal, + 1.0, + Some(&self.program), + &[Uniform::new("sdr_nits", self.sdr_nits)], + ); + if let Err( + error + ) = &result { + warn!( + ?error, + "HDR encode pass draw failed" + ); + } + result + } +} diff --git a/src/shojiwm/src/backend/mod.rs b/src/shojiwm/src/backend/mod.rs index bf1b038a..c441f606 100644 --- a/src/shojiwm/src/backend/mod.rs +++ b/src/shojiwm/src/backend/mod.rs @@ -5,6 +5,7 @@ pub mod damage; pub mod damage_blink; pub mod decoration; pub mod fps_counter; +pub mod hdr_pipeline; pub mod icon; pub mod image_copy_capture_render; pub mod rounded; diff --git a/src/shojiwm/src/backend/output_encode.frag b/src/shojiwm/src/backend/output_encode.frag new file mode 100644 index 00000000..69cfc41e --- /dev/null +++ b/src/shojiwm/src/backend/output_encode.frag @@ -0,0 +1,66 @@ +#version 100 + +//_DEFINES_ + +#if defined(EXTERNAL) +#extension GL_OES_EGL_image_external : require +#endif + +precision highp float; +#if defined(EXTERNAL) +uniform samplerExternalOES tex; +#else +uniform sampler2D tex; +#endif + +uniform float alpha; +// Absolute luminance (cd/m2) that sRGB full white maps to on the PQ signal. +uniform float sdr_nits; +varying vec2 v_coords; + +#if defined(DEBUG_FLAGS) +uniform float tint; +#endif + +// sRGB EOTF (IEC 61966-2-1 piecewise decode). +vec3 srgb_eotf(vec3 c) { + vec3 lo = c / 12.92; + vec3 hi = pow((c + vec3(0.055)) / 1.055, vec3(2.4)); + return mix(hi, lo, vec3(lessThanEqual(c, vec3(0.04045)))); +} + +// BT.709 -> BT.2020 linear-light gamut matrix (BT.2087), column-major. +// Cross-checked against the CPU derivation in color/colorimetry.rs tests. +const mat3 BT709_TO_BT2020 = mat3( + 0.627404, 0.069097, 0.016391, + 0.329283, 0.919540, 0.088013, + 0.043313, 0.011362, 0.895595 +); + +// SMPTE ST 2084 (PQ) inverse EOTF: absolute luminance -> PQ signal. +vec3 pq_inv_eotf(vec3 nits) { + const float m1 = 0.1593017578125; // 1305/8192 + const float m2 = 78.84375; // 2523/32 + const float c1 = 0.8359375; // 107/128 + const float c2 = 18.8515625; // 2413/128 + const float c3 = 18.6875; // 2392/128 + vec3 y = clamp(nits / 10000.0, 0.0, 1.0); + vec3 ym = pow(y, vec3(m1)); + return pow((vec3(c1) + c2 * ym) / (vec3(1.0) + c3 * ym), vec3(m2)); +} + +void main() { + // The intermediate holds the finished composite as sRGB-encoded values. + vec4 color = texture2D(tex, v_coords); + vec3 linear = srgb_eotf(clamp(color.rgb, 0.0, 1.0)); + vec3 bt2020 = BT709_TO_BT2020 * linear; + vec3 pq = pq_inv_eotf(bt2020 * sdr_nits); + vec4 result = vec4(pq, 1.0) * alpha; + +#if defined(DEBUG_FLAGS) + if (tint == 1.0) + result = vec4(0.0, 0.2, 0.0, 0.2) + result * 0.8; +#endif + + gl_FragColor = result; +} diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index ee6a20b6..be754f99 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -788,6 +788,9 @@ pub struct BackendData { DrmDeviceFd, >, pub renderer: GlesRenderer, + /// This GPU can render into fp16 targets (probed once at device_added); + /// gates HDR10 output modes, which need the fp16 intermediate. + pub supports_fp16: bool, surfaces: HashMap, } @@ -898,14 +901,28 @@ pub fn device_added( allocator, exporter, Some(gbm), - [Format::Argb8888], + [ + Format::Abgr2101010, + Format::Argb2101010, + Format::Argb8888 + ], render_formats, ); + let supports_fp16 = crate::backend::hdr_pipeline::probe_fp16_render_support( + &mut renderer + ); + info!( + ?node, + supports_fp16, + "probed fp16 render target support" + ); + let backend = BackendData { drm_scanner: DrmScanner::new(), drm_output_manager, renderer, + supports_fp16, surfaces: HashMap::new(), }; state.tty_backends.insert(node.clone(), backend); @@ -1337,6 +1354,7 @@ render_elements! { RelocatedBackdrop=RelocateRenderElement, TransformedBackdrop=RelocateRenderElement>>, Cursor=PointerRenderElement, + HdrEncode=crate::backend::hdr_pipeline::HdrEncodeElement, } fn tty_render_element_name(element: &TtyRenderElements) -> &'static str { @@ -1360,6 +1378,7 @@ fn tty_render_element_name(element: &TtyRenderElements) -> &'static str { TtyRenderElements::RelocatedBackdrop(_) => "RelocatedBackdrop", TtyRenderElements::TransformedBackdrop(_) => "TransformedBackdrop", TtyRenderElements::Cursor(_) => "Cursor", + TtyRenderElements::HdrEncode(_) => "HdrEncode", _ => "Generic", } } @@ -5169,6 +5188,77 @@ fn render_surface( elements.extend(content_for_capture); } + // HDR10 outputs composite the full element list (cursor and overlays + // included — anything drawn outside the encode pass would end up + // sRGB-encoded inside a PQ signal) into the fp16 intermediate, and + // the DRM pass renders a single PQ-encode element instead. + let mut hdr_encode_active = false; + if matches!( + state + .output_color + .get( + output + .name() + .as_str() + ) + .map( + |color| color.mode + ), + Some(crate::color::OutputColorMode::Hdr10 { .. }) + ) { + let output_name = output + .name(); + let mut pipeline = state.hdr_pipelines + .remove( + &output_name + ); + match crate::backend::hdr_pipeline::render_hdr_pipeline( + &mut backend.renderer, + &mut pipeline, + &output, + &elements, + CLEAR_COLOR, + ) { + Ok( + Some( + encode_element + ) + ) => { + elements = vec![TtyRenderElements::HdrEncode(encode_element)]; + hdr_encode_active = true; + } + Ok( + None + ) => {} + Err( + err + ) => { + // Fall through with the raw element list: the frame shows + // washed-out colors on the PQ signal but stays visible. + warn!( + output = %output_name, + ?err, + "HDR encode pipeline failed; rendering unencoded frame" + ); + } + } + if let Some( + pipeline + ) = pipeline { + state.hdr_pipelines + .insert( + output_name, + pipeline, + ); + } + } else { + state.hdr_pipelines + .remove( + output.name() + .as_str() + ); + } + let fullscreen_scanout_candidate = if fullscreen_overlay_visible { None } else { @@ -5242,6 +5332,11 @@ fn render_surface( if should_tear { frame_flags = frame_flags.difference(FrameFlags::ALLOW_CURSOR_PLANE_SCANOUT); } + if hdr_encode_active { + // Nothing may bypass the encode pass: direct scanout or plane + // promotion would put sRGB pixels straight into the PQ signal. + frame_flags = FrameFlags::empty(); + } // Keep every real damage frame asynchronous for the whole tearing period. In // particular, a visible software-cursor update must not fall back to a synced flip: // alternating async game frames with vblank-bound cursor frames produces visibly uneven @@ -11336,6 +11431,94 @@ fn connector_connected( } let mode = select_output_mode(&connector, &state.display_config.default_mode); + + // Resolve the output's color pipeline before the DRM surface exists so + // the connector properties (max bpc / Colorspace / HDR_OUTPUT_METADATA) + // are already part of the connector state when initialize_output performs + // the first atomic commit on this CRTC. + // Note: at startup and on hotplug the connector connects before the + // TypeScript config evaluates, so a config-side `hdr: true` usually + // isn't visible here yet — refresh_tty_output_color_modes re-resolves + // once the display config update lands. + let hdr_requested = state + .runtime_output_configs + .get(&output_name) + .and_then(|config| config.hdr) + .unwrap_or(false) + || crate::color::hdr_output_requested_via_env(&output_name); + let color_state = { + let backend = state.tty_backends + .get(&node) + .unwrap(); + let device = backend.drm_output_manager.device(); + let edid_hdr = crate::color::drm_metadata::read_edid_hdr( + device, + &connector + ); + let color_mode = if backend.supports_fp16 { + crate::color::resolve_output_mode( + &output_name, + hdr_requested, + edid_hdr.as_ref() + ) + } else { + // The PQ encode pass composites through an fp16 intermediate; + // without renderable fp16 targets HDR10 cannot be driven. + if hdr_requested { + warn!( + output = %output_name, + "HDR requested but GPU lacks fp16 render targets; staying SDR" + ); + } + crate::color::OutputColorMode::Sdr + }; + let hdr_metadata_blob = match color_mode { + crate::color::OutputColorMode::Hdr10 { .. } => { + match crate::color::drm_metadata::apply_hdr_connector_state( + device, + &connector, + &color_mode, + ) { + Ok(blob) => blob, + Err(error) => { + warn!( + output = %output_name, + ?error, + "failed to apply HDR connector state; falling back to SDR signaling" + ); + None + } + } + } + crate::color::OutputColorMode::Sdr => { + // Clear leftovers from a previous session so the sink drops + // out of HDR mode. + crate::color::drm_metadata::reset_hdr_connector_state( + device, + &connector + ); + None + } + }; + crate::color::OutputColorState::new( + color_mode, + edid_hdr, + hdr_metadata_blob + ) + }; + info!( + output = %output_name, + mode = ?color_state.mode, + edid_hdr = ?color_state.edid_hdr, + "resolved output color mode" + ); + state + .output_color + .insert( + output_name.clone(), + color_state + ); + let available_modes = connector .modes() .iter() @@ -11456,6 +11639,24 @@ fn connector_disconnected( let Some(surface) = backend.surfaces.remove(&crtc) else { return; }; + if let Some( + color_state + ) = state.output_color.remove( + &output_name + ) { + if let Some( + blob + ) = color_state.hdr_metadata_blob { + // The kernel only frees property blobs when the DRM fd closes, + // and this device stays open across hotplugs — destroy explicitly + // so replug cycles don't leak blobs. + crate::color::drm_metadata::destroy_metadata_blob( + backend.drm_output_manager + .device(), + blob, + ); + } + } let output = surface.output; state.space.unmap_output(&output); state.remove_output_global(&output); @@ -11465,6 +11666,7 @@ fn connector_disconnected( // instance. Keep it across hot-unplug so a reconnected connector receives its mode, scale, // and position immediately. The TS runtime suppresses unchanged configuration payloads, so // deleting the Rust-side entry here would otherwise leave the new Output at scale 1. + state.hdr_pipelines.remove(&output_name); state.runtime_animation_outputs.remove(&output_name); state.damage_blink_visible.remove(&output_name); state.damage_blink_pending.remove(&output_name); @@ -11576,6 +11778,162 @@ pub fn tty_output_available_modes( None } +/// Re-resolve every connected output's color mode against the current +/// runtime display config (`hdr: true` opt-in). Outputs connect before the +/// TypeScript config evaluates — both at session startup and on hotplug — +/// so this is where a config-side HDR request actually takes effect. It is +/// a live switch: the connector properties persist across smithay's +/// commits (legacy SET_PROPERTY), and the render path re-reads +/// `output_color` every frame to engage or drop the PQ encode pass. +pub fn refresh_tty_output_color_modes( + state: &mut crate::state::ShojiWM +) { + let mut changed = false; + for backend in state.tty_backends + .values() { + let connectors = backend + .drm_scanner + .crtcs() + .map( + |(info, crtc)| ( + info + .clone(), + crtc, + ) + ).collect::>(); + for (connector, crtc) in connectors { + if !backend.surfaces.contains_key( + &crtc + ) { + continue; + } + let output_name = format!( + "{}-{}", + connector + .interface() + .as_str(), + connector + .interface_id(), + ); + let Some(current) = state.output_color + .get( + &output_name + ) + .copied() else { + continue; + }; + let hdr_requested = state + .runtime_output_configs + .get( + &output_name + ) + .and_then(|config| config.hdr) + .unwrap_or(false) + || crate::color::hdr_output_requested_via_env(&output_name); + let desired_mode = if backend.supports_fp16 { + crate::color::resolve_output_mode( + &output_name, + hdr_requested, + current.edid_hdr + .as_ref(), + ) + } else { + crate::color::OutputColorMode::Sdr + }; + if desired_mode == current.mode { + continue; + } + + let device = backend.drm_output_manager.device(); + if let Some(blob) = current.hdr_metadata_blob { + crate::color::drm_metadata::destroy_metadata_blob( + device, + blob, + ); + } + let ( + mode, + hdr_metadata_blob + ) = match desired_mode { + crate::color::OutputColorMode::Hdr10 { .. } => { + match crate::color::drm_metadata::apply_hdr_connector_state( + device, + &connector, + &desired_mode, + ) { + Ok(blob) => ( + desired_mode, + blob, + ), + Err(error) => { + warn!( + output = %output_name, + ?error, + "failed to apply HDR connector state; staying SDR" + ); + crate::color::drm_metadata::reset_hdr_connector_state( + device, + &connector, + ); + ( + crate::color::OutputColorMode::Sdr, + None, + ) + } + } + } + crate::color::OutputColorMode::Sdr => { + crate::color::drm_metadata::reset_hdr_connector_state( + device, + &connector, + ); + ( + crate::color::OutputColorMode::Sdr, + None, + ) + } + }; + if mode == current.mode { + continue; + } + info!( + output = %output_name, + ?mode, + "output color mode changed by runtime display config" + ); + state.output_color + .insert( + output_name, + crate::color::OutputColorState::new( + mode, + current.edid_hdr, + hdr_metadata_blob + ), + ); + changed = true; + } + } + if changed { + // Force a full repaint on every output so the first frame after the + // switch is (de)PQ-encoded; the render path picks the pipeline from + // `output_color` per frame. + for output in state.space.outputs() { + if let Some(geometry) = state.space.output_geometry(output) { + state.pending_decoration_damage + .push( + crate::ssd::LogicalRect::new( + geometry.loc.x, + geometry.loc.y, + geometry.size.w, + geometry.size.h, + ) + ); + } + } + state.schedule_redraw(); + } +} + pub fn tty_connected_outputs(state: &crate::state::ShojiWM) -> Vec { let mut outputs = Vec::new(); for backend in state.tty_backends.values() { diff --git a/src/shojiwm/src/backend/window.rs b/src/shojiwm/src/backend/window.rs index ad7bef08..2970c76f 100644 --- a/src/shojiwm/src/backend/window.rs +++ b/src/shojiwm/src/backend/window.rs @@ -18,7 +18,13 @@ use smithay::{ reexports::wayland_server::Resource, utils::{Logical, Physical, Point, Rectangle, Scale}, wayland::{ - compositor::{RectangleKind, RegionAttributes, with_states}, + compositor::{ + RectangleKind, + RegionAttributes, + TraversalAction, + with_states, + with_surface_tree_downward, + }, session_lock::LockSurface, shell::wlr_layer::Layer as WlrLayer, shell::xdg::XdgToplevelSurfaceData, @@ -799,6 +805,55 @@ pub fn clipped_surface_elements( // SSD clip is allowed to crop the client surface tree. let clip = clip.filter(|clip| clip.clips_surface); + // Color-management tag for the window's root surface, applied to every + // element in its tree. Approximation: subsurfaces are distinct protocol + // surfaces and may carry their own descriptions, so a player that puts + // video on a subsurface while leaving the toplevel untagged is not handled + // yet. The common case — a client tagging its main surface — is. + // Color-management tags, keyed by the element id `WaylandSurfaceRenderElement` + // derives from each surface. Collected per-surface rather than taken from the + // toplevel, because a subsurface carries its own description — players that + // put video on a subsurface leave the toplevel untagged. + // + // Pairing by id rather than rebuilding the element list ourselves keeps + // smithay's surface-tree walk (and its view-offset handling) as the single + // source of truth for positioning. + let surface_descriptions: std::collections::HashMap = + match window.underlying_surface() { + // Skipped entirely until something in the session has ever been + // tagged, which is one relaxed atomic load in the common case. + WindowSurface::Wayland(toplevel) + if crate::protocols::color_management::any_surface_tagged() => + { + let mut found = std::collections::HashMap::new(); + with_surface_tree_downward( + toplevel.wl_surface(), + (), + |_, _, _| TraversalAction::DoChildren(()), + // Read the description out of the `SurfaceData` smithay + // hands us. Calling `surface_image_description` here instead + // would re-take the surface's user-data lock that the + // traversal is still holding, and that lock is not + // reentrant — it deadlocks the compositor on the first + // window that maps. + |surface, states, _| { + if let Some(description) = + crate::protocols::color_management::image_description_from_states( + states, + ) + { + found.insert(Id::from_wayland_resource(surface), description); + } + }, + |_, _, _| true, + ); + found + } + // X11 has no color-management protocol, so XWayland clients are + // always untagged and take the passthrough path. + _ => std::collections::HashMap::new(), + }; + let elements = surface_elements(window, renderer, location, output_scale, alpha); if clip.is_none() || std::env::var_os("SHOJI_GAP_BYPASS_CLIP").is_some() { return Ok(elements.into_iter().map(WindowClipElement::Raw).collect()); @@ -908,6 +963,14 @@ pub fn clipped_surface_elements( let mut output = Vec::with_capacity(elements.len()); for element in elements { if Element::id(&element) == &root_id { + // Resolve before the move: `element` is consumed below. + let element_description = + surface_descriptions + .get( + Element::id( + &element, + ), + ).copied(); output.push(WindowClipElement::Clipped(ClippedSurfaceElement::new( renderer, element, @@ -917,6 +980,7 @@ pub fn clipped_surface_elements( clip, geometry, debug_label.clone(), + element_description, )?)); } else { output.push(WindowClipElement::Raw(element)); @@ -940,6 +1004,13 @@ pub fn clipped_surface_elements( "gap debug clipped surface candidate", ); } + // Resolve before the move: `element` is consumed below. + let element_description = surface_descriptions + .get( + Element::id( + &element, + ) + ).copied(); if geometry_override.is_some() { ClippedSurfaceElement::new( renderer, @@ -950,6 +1021,7 @@ pub fn clipped_surface_elements( clip, geometry_override, debug_label.clone(), + element_description, ) .map(WindowClipElement::Clipped) } else { @@ -983,6 +1055,11 @@ pub fn clipped_popup_elements( clip, None, Some("popup clipped by ManagedWindow.forceRectSize".to_owned()), + // Popups are separate protocol surfaces with their own + // descriptions; inheriting the toplevel's would be wrong. They + // are effectively never color-tagged, so leave them untagged + // rather than guess. + None, ) }) .collect() diff --git a/src/shojiwm/src/color/colorimetry.rs b/src/shojiwm/src/color/colorimetry.rs new file mode 100644 index 00000000..de9352f0 --- /dev/null +++ b/src/shojiwm/src/color/colorimetry.rs @@ -0,0 +1,245 @@ +//! Gamut conversion matrix derivation (RGB → CIE XYZ → RGB) and the SMPTE +//! ST 2084 (PQ) reference curves. +//! +//! The GPU encode pass (`backend/output_encode.frag`) hardcodes the +//! BT.709→BT.2020 matrix and PQ constants for GLSL ES 1.00 compatibility; +//! the unit tests here re-derive them from first principles so a typo in +//! either place fails CI instead of shipping subtly wrong color. + +use super::ColorPrimaries; +use super::primaries::PrimariesChromaticities; + +pub type Mat3 = [[f64; 3]; 3]; + +/// RGB → CIE XYZ for the given primaries, white point normalized to Y = 1. +fn rgb_to_xyz( + chroma: PrimariesChromaticities +) -> Mat3 { + // Columns of the un-scaled matrix are the primaries' XYZ coordinates. + let xyz = |x: f64, y: f64| [x / y, 1.0, (1.0 - x - y) / y]; + let red = xyz( + chroma.red.x as f64, + chroma.red.y as f64 + ); + let green = xyz( + chroma.green.x as f64, + chroma.green.y as f64 + ); + let blue = xyz( + chroma.blue.x as f64, + chroma.blue.y as f64 + ); + let white = xyz( + chroma.white.x as f64, + chroma.white.y as f64 + ); + + // Solve for the per-primary scales that make RGB(1,1,1) hit the white + // point: S = M⁻¹ · W. + let unscaled: Mat3 = [ + [red[0], green[0], blue[0]], + [red[1], green[1], blue[1]], + [red[2], green[2], blue[2]], + ]; + let scales = mat3_mul_vec( + &invert( + &unscaled + ), + &white + ); + [ + [red[0] * scales[0], green[0] * scales[1], blue[0] * scales[2]], + [red[1] * scales[0], green[1] * scales[1], blue[1] * scales[2]], + [red[2] * scales[0], green[2] * scales[1], blue[2] * scales[2]], + ] +} + +fn invert( + m: &Mat3 +) -> Mat3 { + let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) + - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); + let inv_det = 1.0 / det; + [ + [ + (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det, + (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det, + (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det, + ], + [ + (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det, + (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det, + (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det, + ], + [ + (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det, + (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det, + (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det, + ], + ] +} + +fn mat3_mul( + a: &Mat3, + b: &Mat3 +) -> Mat3 { + let mut out = [[0.0; 3]; 3]; + for ( + row_index, + row + ) in out.iter_mut().enumerate() { + for ( + col_index, + cell + ) in row.iter_mut().enumerate() { + *cell = (0..3) + .map(|k| a[row_index][k] * b[k][col_index]) + .sum::(); + } + } + out +} + +fn mat3_mul_vec( + m: &Mat3, + v: &[f64; 3] +) -> [f64; 3] { + [ + m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], + m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], + m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2], + ] +} + +/// Row-major linear-light conversion matrix between two named gamuts +/// (same D65 white point on both sides, so no chromatic adaptation step). +pub fn gamut_conversion_matrix( + from: ColorPrimaries, + to: ColorPrimaries +) -> Mat3 { + mat3_mul( + &invert( + &rgb_to_xyz( + to.chromaticities() + ) + ), + &rgb_to_xyz( + from.chromaticities() + ), + ) +} + +// SMPTE ST 2084 constants. +const PQ_M1: f64 = 1305.0 / 8192.0; +const PQ_M2: f64 = 2523.0 / 32.0; +const PQ_C1: f64 = 107.0 / 128.0; +const PQ_C2: f64 = 2413.0 / 128.0; +const PQ_C3: f64 = 2392.0 / 128.0; + +/// PQ inverse EOTF: absolute luminance (cd/m²) → PQ signal in [0, 1]. +pub fn pq_inverse_eotf( + nits: f64 +) -> f64 { + let y = (nits / 10000.0).clamp( + 0.0, + 1.0 + ); + let ym = y.powf( + PQ_M1 + ); + ((PQ_C1 + PQ_C2 * ym) / (1.0 + PQ_C3 * ym)).powf(PQ_M2) +} + +/// PQ EOTF: PQ signal in [0, 1] → absolute luminance (cd/m²). +pub fn pq_eotf( + signal: f64 +) -> f64 { + let e = signal.clamp( + 0.0, + 1.0 + ).powf(1.0 / PQ_M2); + let y = ((e - PQ_C1).max(0.0)) / (PQ_C2 - PQ_C3 * e); + 10000.0 * y.powf(1.0 / PQ_M1) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The matrix hardcoded in `backend/output_encode.frag` (row-major + /// here; the shader stores it column-major for GLSL). + const SHADER_BT709_TO_BT2020: Mat3 = [ + [0.627404, 0.329283, 0.043313], + [0.069097, 0.919540, 0.011362], + [0.016391, 0.088013, 0.895595], + ]; + + #[test] + fn derived_matrix_matches_shader_constants() { + let derived = gamut_conversion_matrix(ColorPrimaries::Srgb, ColorPrimaries::Bt2020); + for row in 0..3 { + for col in 0..3 { + let delta = (derived[row][col] - SHADER_BT709_TO_BT2020[row][col]) + .abs(); + assert!( + delta < 2e-4, + "matrix[{row}][{col}]: derived {} vs shader {}", + derived[row][col], + SHADER_BT709_TO_BT2020[row][col] + ); + } + } + } + + #[test] + fn white_maps_to_white() { + // Both gamuts share D65, so full white must stay full white. + let m = gamut_conversion_matrix( + ColorPrimaries::Srgb, + ColorPrimaries::Bt2020 + ); + let white = mat3_mul_vec( + &m, + &[ + 1.0, + 1.0, + 1.0 + ] + ); + for channel in white { + assert!((channel - 1.0) + .abs() < 1e-6, "white drifted: {white:?}"); + } + } + + #[test] + fn pq_reference_points() { + // Published reference values for the ST 2084 curve. + assert!((pq_inverse_eotf(0.0) - 0.0) + .abs() < 1e-6); + assert!((pq_inverse_eotf(10000.0) - 1.0) + .abs() < 1e-6); + // 100 cd/m² ≈ 0.508 PQ, 203 cd/m² (HDR reference white) ≈ 0.5806 PQ. + assert!((pq_inverse_eotf(100.0) - 0.5081) + .abs() < 1e-3); + assert!((pq_inverse_eotf(203.0) - 0.5806) + .abs() < 1e-3); + } + + #[test] + fn pq_roundtrip() { + for nits in [0.005, 1.0, 80.0, 203.0, 1000.0, 4000.0, 10000.0] { + let roundtrip = pq_eotf( + pq_inverse_eotf( + nits + ) + ); + assert!( + (roundtrip - nits) + .abs() / nits < 1e-6, + "PQ roundtrip drifted: {nits} -> {roundtrip}" + ); + } + } +} diff --git a/src/shojiwm/src/color/drm_metadata.rs b/src/shojiwm/src/color/drm_metadata.rs new file mode 100644 index 00000000..ecccdb7e --- /dev/null +++ b/src/shojiwm/src/color/drm_metadata.rs @@ -0,0 +1,503 @@ +//! EDID HDR capability probing and DRM connector properties for HDR10 +//! signaling: `max bpc`, `Colorspace`, and the `HDR_OUTPUT_METADATA` +//! (SMPTE ST 2086 / CTA-861.3) property blob. +//! +//! Property writes use the legacy SET_PROPERTY ioctl on purpose: the kernel +//! folds them into the connector's atomic state, and smithay's commit path +//! never touches these three properties, so they persist across the +//! DrmOutputManager's own atomic commits. + +use std::io; + +use smithay::reexports::drm::control::{Device as ControlDevice, connector, property}; +use tracing::{debug, warn}; + +use super::{ColorPrimaries, OutputColorMode}; + +/// CTA-861-G HDR static metadata parsed from the EDID. +#[derive( + Debug, + Clone, + Copy, + PartialEq +)] +pub struct EdidHdrMetadata { + /// Display accepts SMPTE ST 2084 (PQ) EOTF. + pub supports_pq: bool, + /// Display accepts Hybrid Log-Gamma EOTF. + pub supports_hlg: bool, + /// Desired max luminance (cd/m²), if the panel reports one. + pub max_luminance: Option, + /// Desired max frame-average luminance (cd/m²), if reported. + pub max_frame_avg_luminance: Option, + /// Desired min luminance (cd/m²), if reported (needs max to decode). + pub min_luminance: Option, +} + +const PROP_EDID: &str = "EDID"; +const PROP_COLORSPACE: &str = "Colorspace"; +const PROP_HDR_OUTPUT_METADATA: &str = "HDR_OUTPUT_METADATA"; +const PROP_MAX_BPC: &str = "max bpc"; + +/// Kernel uapi `hdr_metadata_infoframe` (drm_mode.h), CTA-861.3 static +/// metadata type 1. Chromaticities in 0.00002 units, max mastering +/// luminance in cd/m², min in 0.0001 cd/m². +#[repr(C)] +struct HdrMetadataInfoframe { + eotf: u8, + metadata_type: u8, + display_primaries: [[u16; 2]; 3], + white_point: [u16; 2], + max_display_mastering_luminance: u16, + min_display_mastering_luminance: u16, + max_cll: u16, + max_fall: u16, +} + +/// Kernel uapi `hdr_output_metadata` (drm_mode.h). +#[repr(C)] +struct HdrOutputMetadata { + metadata_type: u32, + hdmi_metadata_type1: HdrMetadataInfoframe, +} + +/// CTA-861-G EOTF code for SMPTE ST 2084 (PQ). +const HDMI_EOTF_ST2084: u8 = 2; +/// CTA-861-G static metadata descriptor type 1. +const HDMI_STATIC_METADATA_TYPE1: u8 = 0; + +fn find_connector_property( + device: &impl ControlDevice, + conn: &connector::Info, + name: &str, +) -> Option<(property::Info, property::RawValue)> { + let props = device.get_properties(conn.handle()).ok()?; + for (handle, value) in props.iter() { + let Ok(info) = device.get_property(*handle) else { + continue; + }; + if info.name().to_str() == Ok(name) { + return Some((info, *value)); + } + } + None +} + +/// Read the connector's EDID blob and extract the CTA-861-G HDR static +/// metadata data block, if the display has one. +pub fn read_edid_hdr( + device: &impl ControlDevice, + conn: &connector::Info, +) -> Option { + let (_, blob_id) = find_connector_property( + device, + conn, + PROP_EDID)?; + if blob_id == 0 { + return None; + } + let edid = device.get_property_blob(blob_id).ok()?; + parse_edid_hdr(&edid) +} + +/// Scan EDID extension blocks for a CTA-861 block containing the HDR +/// static metadata data block (extended tag 0x06). +pub fn parse_edid_hdr(edid: &[u8]) -> Option { + if edid.len() < 128 { + return None; + } + let extension_count = edid[126] as usize; + for block_index in 1..=extension_count { + let start = block_index * 128; + let Some(block) = edid.get(start..start + 128) else { + break; + }; + // CTA-861 extension block tag. + if block[0] != 0x02 { + continue; + } + // Byte 2: offset of the detailed timing descriptors; the data + // block collection sits between byte 4 and that offset. + let dtd_offset = (block[2] as usize).min(128); + if dtd_offset < 4 { + continue; + } + let mut index = 4; + while index < dtd_offset { + let header = block[index]; + let tag = header >> 5; + let length = (header & 0x1f) as usize; + if index + 1 + length > dtd_offset { + break; + } + // Extended tag block (7) with extended tag 0x06 = HDR static + // metadata. Payload: [eotf bitfield, descriptor bitfield, + // optional max/max-frame-avg/min luminance codes]. + if tag == 0x07 && length >= 2 && block[index + 1] == 0x06 { + let payload = &block[index + 2..index + 1 + length]; + let eotfs = payload[0]; + let max_code = payload.get(2) + .copied() + .filter(|&code| code != 0); + let max_frame_avg_code = payload + .get(3) + .copied() + .filter(|&code| code != 0); + let min_code = payload + .get(4) + .copied(); + let max_luminance = max_code.map(cta_luminance); + let max_frame_avg_luminance = max_frame_avg_code.map(cta_luminance); + // Min luminance decoding needs the max value as reference. + let min_luminance = match (max_luminance, min_code) { + (Some(max), Some(code)) => { + let fraction = code as f32 / 255.0; + Some(max * fraction * fraction / 100.0) + } + _ => None, + }; + return Some(EdidHdrMetadata { + supports_pq: eotfs & (1 << 2) != 0, + supports_hlg: eotfs & (1 << 3) != 0, + max_luminance, + max_frame_avg_luminance, + min_luminance, + }); + } + index += 1 + length; + } + } + None +} + +/// CTA-861-G luminance code decoding: 50 * 2^(code/32) cd/m². +fn cta_luminance(code: u8) -> f32 { + 50.0 * 2f32.powf(code as f32 / 32.0) +} + +/// Put the connector into HDR10 signaling: max bpc >= 10, Colorspace = +/// BT2020_RGB, and an ST 2086 metadata blob. Returns the blob id so the +/// caller can destroy it on disconnect. +pub fn apply_hdr_connector_state( + device: &impl ControlDevice, + conn: &connector::Info, + mode: &OutputColorMode, +) -> io::Result> { + let OutputColorMode::Hdr10 { + max_display_luminance, + min_display_luminance, + } = *mode + else { + return Ok(None); + }; + + // Raise the link depth so the 10-bit scanout format isn't dithered + // back down; missing property is fine (some drivers always run 10-bit). + if let Some((info, current)) = find_connector_property(device, conn, PROP_MAX_BPC) { + let target = match info.value_type() { + property::ValueType::UnsignedRange(_, max) => (*max).min(10), + _ => 10, + }; + if current < target { + device.set_property(conn.handle(), info.handle(), target)?; + } + } + + // Without a Colorspace property the sink would interpret the PQ signal + // as sRGB — bail instead of producing garbage. + let (colorspace_info, _) = find_connector_property(device, conn, PROP_COLORSPACE) + .ok_or_else(|| io::Error::other("connector has no Colorspace property"))?; + let bt2020_value = match colorspace_info.value_type() { + property::ValueType::Enum(values) => values + .values() + .1 + .iter() + .find(|entry| entry + .name() + .to_str() == Ok("BT2020_RGB")) + .map(|entry| entry.value()), + _ => None, + } + .ok_or_else(|| io::Error::other("Colorspace property has no BT2020_RGB entry"))?; + + let (metadata_info, _) = find_connector_property( + device, + conn, + PROP_HDR_OUTPUT_METADATA + ) + .ok_or_else(|| io::Error::other("connector has no HDR_OUTPUT_METADATA property"))?; + + let chroma = ColorPrimaries::Bt2020.chromaticities(); + let metadata = HdrOutputMetadata { + metadata_type: HDMI_STATIC_METADATA_TYPE1 as u32, + hdmi_metadata_type1: HdrMetadataInfoframe { + eotf: HDMI_EOTF_ST2084, + metadata_type: HDMI_STATIC_METADATA_TYPE1, + display_primaries: [ + [ + chroma.red.to_cta861().0, + chroma.red.to_cta861().1 + ], + [ + chroma.green.to_cta861().0, + chroma.green.to_cta861().1 + ], + [ + chroma.blue.to_cta861().0, + chroma.blue.to_cta861().1 + ], + ], + white_point: [ + chroma.white.to_cta861().0, + chroma.white.to_cta861().1 + ], + max_display_mastering_luminance: max_display_luminance.round() as u16, + min_display_mastering_luminance: (min_display_luminance * 10000.0).round() as u16, + // 0 = unknown; we don't track content light levels yet. + max_cll: 0, + max_fall: 0, + }, + }; + let blob = device.create_property_blob(&metadata)?; + let property::Value::Blob(blob_id) = blob else { + return Err(io::Error::other("create_property_blob returned non-blob")); + }; + device.set_property( + conn.handle(), + metadata_info.handle(), + blob_id + )?; + device.set_property( + conn.handle(), + colorspace_info.handle(), + bt2020_value + )?; + debug!( + connector = ?conn.handle(), + blob_id, + "applied HDR10 connector state" + ); + Ok(Some(blob_id)) +} + +/// Best-effort reset for SDR outputs: clears HDR metadata and Colorspace +/// leftovers from a previous session so the sink drops out of HDR mode. +pub fn reset_hdr_connector_state( + device: &impl ControlDevice, + conn: &connector::Info +) { + if let Some((info, current)) = find_connector_property( + device, + conn, + PROP_HDR_OUTPUT_METADATA + ) + { + if current != 0 { + if let Err(error) = device.set_property( + conn.handle(), + info.handle(), + 0 + ) { + warn!( + ?error, + "failed to clear HDR_OUTPUT_METADATA" + ); + } + } + } + if let Some((info, current)) = find_connector_property( + device, + conn, + PROP_COLORSPACE + ) { + let default_value = match info.value_type() { + property::ValueType::Enum(values) => values + .values() + .1 + .iter() + .find(|entry| entry.name() + .to_str() == Ok("Default")) + .map(|entry| entry.value()), + _ => None, + }; + if let Some(default_value) = default_value { + if current != default_value { + if let Err(error) = + device.set_property( + conn.handle(), + info.handle(), + default_value + ) + { + warn!( + ?error, + "failed to reset Colorspace" + ); + } + } + } + } +} + +/// Free an HDR_OUTPUT_METADATA blob created by [`apply_hdr_connector_state`]. +/// Best-effort: the kernel reclaims blobs at fd close anyway. +pub fn destroy_metadata_blob( + device: &impl ControlDevice, + blob: u64 +) { + if let Err( + error + ) = device.destroy_property_blob( + blob + ) { + warn!( + ?error, + blob, + "failed to destroy HDR metadata blob" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Base EDID block + one CTA-861 extension carrying an HDR static + /// metadata data block (extended tag 0x06). + fn edid_with_hdr_block( + eotfs: u8, + max: u8, + favg: u8, + min: u8 + ) -> Vec { + let mut edid = vec![0u8; 256]; + // one extension block + edid[126] = 1; + let ext = 128; + // CTA-861 tag + edid[ext] = 0x02; + // DTDs start at byte 12; data blocks in 4..12 + edid[ext + 2] = 12; + // extended tag block, length 6 + edid[ext + 4] = (0x07 << 5) | 6; + // HDR static metadata + edid[ext + 5] = 0x06; + edid[ext + 6] = eotfs; + // static metadata type 1 + edid[ext + 7] = 0x01; + edid[ext + 8] = max; + edid[ext + 9] = favg; + edid[ext + 10] = min; + edid + } + + #[test] + fn parses_hdr_static_metadata() { + // EOTF bits: SDR (0) + ST 2084 (2). Code 96 = 50 * 2^3 = 400 cd/m². + let edid = edid_with_hdr_block( + 0b0000_0101, + 96, + 64, + 255); + let hdr = parse_edid_hdr( + &edid + ).expect( + "HDR block should parse" + ); + assert!( + hdr.supports_pq + ); + assert!( + !hdr.supports_hlg + ); + assert_eq!( + hdr.max_luminance, + Some( + 400.0 + ) + ); + assert_eq!( + hdr.max_frame_avg_luminance, + Some( + 200.0 + ) + ); + // min code 255 => max * 1.0² / 100. + assert_eq!( + hdr.min_luminance, + Some( + 4.0 + ) + ); + } + + #[test] + fn ignores_edid_without_hdr_block() { + // Plain base block, no extensions. + let edid = vec![0u8; 128]; + assert_eq!( + parse_edid_hdr( + &edid + ), + None + ); + // CTA extension present but empty data block collection. + let mut edid = vec![0u8; 256]; + edid[126] = 1; + edid[128] = 0x02; + edid[130] = 4; + assert_eq!( + parse_edid_hdr( + &edid + ), + None, + ); + } + + #[test] + fn zero_luminance_codes_mean_unknown() { + let edid = edid_with_hdr_block( + 0b0000_0100, + 0, + 0, + 0, + ); + let hdr = parse_edid_hdr( + &edid + ).expect( + "HDR block should parse", + ); + assert!( + hdr.supports_pq, + ); + assert_eq!( + hdr.max_luminance, + None, + ); + assert_eq!( + hdr.min_luminance, + None, + ); + } + + #[test] + fn hdr_metadata_blob_matches_kernel_layout() { + // The kernel copies sizeof(struct hdr_output_metadata) bytes; a + // layout drift would corrupt the infoframe silently. + assert_eq!( + std::mem::size_of::(), + 26, + ); + assert_eq!( + std::mem::size_of::(), + 32, + ); + assert_eq!( + std::mem::offset_of!( + HdrOutputMetadata, + hdmi_metadata_type1, + ), + 4, + ); + } +} diff --git a/src/shojiwm/src/color/mod.rs b/src/shojiwm/src/color/mod.rs new file mode 100644 index 00000000..4e84eb67 --- /dev/null +++ b/src/shojiwm/src/color/mod.rs @@ -0,0 +1,262 @@ +//! Color management core: output color modes, blend spaces, and the +//! parametric image descriptions shared by the `wp_color_management_v1` +//! protocol (`protocols/color_management.rs`), the DRM signaling layer +//! (`drm_metadata`), and — in a later phase — the render pipeline. + +pub mod colorimetry; +pub mod drm_metadata; +pub mod primaries; + +use drm_metadata::EdidHdrMetadata; +use tracing::warn; + +/// Named primaries we support parametrically (no custom chromaticities yet). +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq +)] +pub enum ColorPrimaries { + Srgb, + Bt2020, +} + +/// Transfer characteristics we support parametrically. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq +)] +pub enum TransferCharacteristics { + Srgb, + St2084Pq, + ExtLinear, +} + +/// Luminance metadata in cd/m², from `set_luminances` or the +/// per-transfer-function protocol defaults. +#[derive( + Debug, + Clone, + Copy, + PartialEq +)] +pub struct Luminances { + pub min: f32, + pub max: f32, + pub reference: f32, +} + +impl TransferCharacteristics { + /// Protocol-defined default luminances: PQ has fixed absolute levels, + /// everything else defaults to the SDR 80 cd/m² reference. + pub fn default_luminances(self) -> Luminances { + match self { + TransferCharacteristics::St2084Pq => Luminances { + min: 0.005, + max: 10000.0, + reference: 203.0, + }, + TransferCharacteristics::Srgb | TransferCharacteristics::ExtLinear => Luminances { + min: 0.2, + max: 80.0, + reference: 80.0, + }, + } + } +} + +/// An immutable parametric image description, as created through +/// `wp_image_description_creator_params_v1` or synthesized for an output. +#[derive( + Debug, + Clone, + Copy, + PartialEq +)] +pub struct ImageDescription { + pub primaries: ColorPrimaries, + pub tf: TransferCharacteristics, + /// `None` => the transfer characteristic's default luminances apply. + pub luminances: Option, + /// Maximum content light level (cd/m²), if the client provided one. + pub max_cll: Option, + /// Maximum frame-average light level (cd/m²), if the client provided one. + pub max_fall: Option, +} + +impl ImageDescription { + pub const SRGB: Self = Self { + primaries: ColorPrimaries::Srgb, + tf: TransferCharacteristics::Srgb, + luminances: None, + max_cll: None, + max_fall: None, + }; + + pub fn effective_luminances(&self) -> Luminances { + self.luminances + .unwrap_or_else(|| self.tf.default_luminances()) + } +} + +/// What an output is driven as. Decided per-connector in `tty.rs` from +/// EDID capability + the `SHOJI_HDR_OUTPUTS` gate; SDR is the zero-cost +/// default. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum OutputColorMode { + /// Today's path, bit-for-bit: 8/10-bit scanout, sRGB signal. + Sdr, + /// PQ/BT.2020 signal: 10-bit scanout + HDR_OUTPUT_METADATA blob. + Hdr10 { + max_display_luminance: f32, + min_display_luminance: f32, + }, +} + +/// The space all compositing (blur, liquid-glass, blending) happens in. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq +)] +pub enum BlendSpace { + /// Non-linear sRGB in Abgr8888 — unchanged current behavior. + Srgb, + /// Linear-light BT.2020 in Abgr16161616F (fp16). Requires + /// GL_EXT_color_buffer_half_float; not wired up yet (phase 3). + LinearBt2020, +} + +/// Per-output color state, keyed by output name in `ShojiWM::output_color`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OutputColorState { + pub mode: OutputColorMode, + pub blend_space: BlendSpace, + /// The signal description clients observe through + /// `wp_color_management_output_v1`. + pub description: ImageDescription, + /// EDID-derived HDR capabilities (CTA-861-G static metadata block). + pub edid_hdr: Option, + /// DRM blob id of the HDR_OUTPUT_METADATA currently applied to the + /// connector; destroyed on disconnect. + pub hdr_metadata_blob: Option, +} + +impl OutputColorState { + pub fn new( + mode: OutputColorMode, + edid_hdr: Option, + hdr_metadata_blob: Option, + ) -> Self { + let description = match mode { + OutputColorMode::Sdr => ImageDescription::SRGB, + OutputColorMode::Hdr10 { + max_display_luminance, + min_display_luminance, + } => ImageDescription { + primaries: ColorPrimaries::Bt2020, + tf: TransferCharacteristics::St2084Pq, + luminances: Some(Luminances { + min: min_display_luminance, + max: max_display_luminance, + reference: 203.0, + }), + max_cll: None, + max_fall: None, + }, + }; + // Blending still happens on sRGB-encoded values; the HDR path + // (backend/hdr_pipeline.rs) composites into an fp16 intermediate + // and PQ-encodes as a final pass, so HDR outputs display correctly. + // BlendSpace::LinearBt2020 activates once per-element + // linearization lands. + Self { + mode, + blend_space: BlendSpace::Srgb, + description, + edid_hdr, + hdr_metadata_blob, + } + } +} + +/// True while the runtime display config opts any output into HDR +/// (`hdr: true`). Kept in an atomic because the protocol layer's +/// capability checks run per-request without access to compositor state. +static SESSION_HDR_CONFIGURED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// Called whenever a runtime display config update lands, with "does any +/// output request HDR". Widens/narrows the protocol advertisement for +/// clients that bind afterwards. +pub fn set_session_hdr_configured(enabled: bool) { + SESSION_HDR_CONFIGURED + .store( + enabled, + std::sync::atomic::Ordering::Relaxed + ); +} + +/// HDR gate for the protocol advertisement (PQ/BT.2020 capabilities): +/// open when the runtime display config opts an output in, or via the +/// `SHOJI_HDR_OUTPUTS=DP-1,DP-2` (or `all`) env override. Off by default +/// because the render pipeline still composites in sRGB. +pub fn hdr_experiment_enabled() -> bool { + SESSION_HDR_CONFIGURED + .load( + std::sync::atomic::Ordering::Relaxed + ) + || std::env::var("SHOJI_HDR_OUTPUTS").is_ok_and(|value| !value.trim().is_empty()) +} + +/// Env-override opt-in for one output, independent of the runtime display +/// config (useful for `cargo run` sessions without a config). +pub fn hdr_output_requested_via_env( + output_name: &str +) -> bool { + std::env::var("SHOJI_HDR_OUTPUTS").is_ok_and(|value| { + value + .split(',') + .map(str::trim) + .any(|entry| entry == "all" || entry == output_name) + }) +} + +/// Decide how to drive a connector: HDR10 only when the user opted the +/// output in (runtime display config `hdr: true` or the env override, +/// resolved by the caller into `hdr_requested`) *and* its EDID advertises +/// ST 2084 support. +pub fn resolve_output_mode( + output_name: &str, + hdr_requested: bool, + edid_hdr: Option<&EdidHdrMetadata>, +) -> OutputColorMode { + if !hdr_requested { + return OutputColorMode::Sdr; + } + let Some(edid) = edid_hdr else { + warn!( + output = output_name, + "HDR requested but EDID has no HDR static metadata block; staying SDR" + ); + return OutputColorMode::Sdr; + }; + if !edid.supports_pq { + warn!( + output = output_name, + "HDR requested but display does not advertise ST 2084 (PQ); staying SDR" + ); + return OutputColorMode::Sdr; + } + OutputColorMode::Hdr10 { + max_display_luminance: edid.max_luminance.unwrap_or(1000.0), + min_display_luminance: edid.min_luminance.unwrap_or(0.005), + } +} diff --git a/src/shojiwm/src/color/primaries.rs b/src/shojiwm/src/color/primaries.rs new file mode 100644 index 00000000..5240b894 --- /dev/null +++ b/src/shojiwm/src/color/primaries.rs @@ -0,0 +1,103 @@ +//! CIE 1931 chromaticity data for the named primaries we support, plus the +//! two wire encodings that consume it (Wayland protocol micro-units and +//! CTA-861-G / DRM HDR metadata units). + +use super::ColorPrimaries; + +#[derive( + Debug, + Clone, + Copy, + PartialEq +)] +pub struct Chromaticity { + pub x: f32, + pub y: f32, +} + +impl Chromaticity { + /// `wp_image_description_info_v1.primaries` encoding: CIE xy × 1,000,000. + pub fn to_protocol(self) -> ( + i32, + i32 + ) { + ( + (self.x * 1_000_000.0).round() as i32, + (self.y * 1_000_000.0).round() as i32, + ) + } + + /// CTA-861-G / kernel `hdr_output_metadata` encoding: CIE xy in units + /// of 0.00002 (i.e. × 50,000). + pub fn to_cta861(self) -> ( + u16, + u16 + ) { + ( + (self.x * 50_000.0).round() as u16, + (self.y * 50_000.0).round() as u16, + ) + } +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq +)] +pub struct PrimariesChromaticities { + pub red: Chromaticity, + pub green: Chromaticity, + pub blue: Chromaticity, + pub white: Chromaticity, +} + +/// BT.709 primaries with D65 white point (shared by sRGB). +pub const SRGB: PrimariesChromaticities = PrimariesChromaticities { + red: Chromaticity { + x: 0.640, + y: 0.330 + }, + green: Chromaticity { + x: 0.300, + y: 0.600 + }, + blue: Chromaticity { + x: 0.150, + y: 0.060 + }, + white: Chromaticity { + x: 0.3127, + y: 0.3290 + }, +}; + +/// BT.2020 primaries with D65 white point. +pub const BT2020: PrimariesChromaticities = PrimariesChromaticities { + red: Chromaticity { + x: 0.708, + y: 0.292 + }, + green: Chromaticity { + x: 0.170, + y: 0.797 + }, + blue: Chromaticity { + x: 0.131, + y: 0.046 + }, + white: Chromaticity { + x: 0.3127, + y: 0.3290 + }, +}; + +impl ColorPrimaries { + pub fn chromaticities(self) -> PrimariesChromaticities { + match self { + ColorPrimaries::Srgb => SRGB, + ColorPrimaries::Bt2020 => BT2020, + } + } +} diff --git a/src/shojiwm/src/config.rs b/src/shojiwm/src/config.rs index 5b355406..db534d42 100644 --- a/src/shojiwm/src/config.rs +++ b/src/shojiwm/src/config.rs @@ -35,6 +35,7 @@ pub struct RuntimeOutputConfig { pub resolution: Option, pub position: Option, pub scale: Option, + pub hdr: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] @@ -111,3 +112,33 @@ fn normalize_tty_output_name(name: &str) -> &str { } name } + +#[cfg(test)] +mod tests { + use super::*; + + /// The `hdr` opt-in arrives from the TypeScript display config; missing + /// means None so older configs keep their SDR behavior. + #[test] + fn runtime_output_config_parses_hdr_flag() { + let update: RuntimeDisplayConfigUpdate = serde_json::from_str( + r#"{"outputs":{ + "HDMI-A-3":{"mode":"extend","resolution":"best","hdr":true}, + "eDP-1":{"mode":"extend","resolution":"best"} + }}"#, + ) + .expect("display config update should parse"); + assert_eq!( + update + .outputs["HDMI-A-3"] + .as_ref() + .unwrap() + .hdr, + Some(true) + ); + assert_eq!( + update.outputs["eDP-1"].as_ref().unwrap().hdr, + None, + ); + } +} diff --git a/src/shojiwm/src/handlers/mod.rs b/src/shojiwm/src/handlers/mod.rs index 7fd89fd7..b6110241 100644 --- a/src/shojiwm/src/handlers/mod.rs +++ b/src/shojiwm/src/handlers/mod.rs @@ -864,7 +864,51 @@ impl crate::protocols::screencopy::ScreencopyHandler for ShojiWM { } } +impl crate::protocols::color_management::ColorManagementHandler for ShojiWM { + fn output_image_description( + &mut self, + wl_output: &smithay::reexports::wayland_server::protocol::wl_output::WlOutput, + ) -> crate::color::ImageDescription { + Output::from_resource(wl_output) + .and_then(|output| { + self.output_color + .get(output.name().as_str()) + .map(|state| state.description) + }) + .unwrap_or(crate::color::ImageDescription::SRGB) + } + + fn surface_preferred_description( + &mut self, + _surface: &WlSurface, + ) -> crate::color::ImageDescription { + // The compositor composites in sRGB, so sRGB content is what we + // prefer from every client regardless of the output's signal mode. + // Revisit when the fp16 linear blend space (phase 3) lands. + crate::color::ImageDescription::SRGB + } + + fn defer_image_description_info( + &mut self, + info: smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_image_description_info_v1::WpImageDescriptionInfoV1, + description: crate::color::ImageDescription, + ) { + // Idle callbacks run after the current wayland dispatch completes, + // so the `done` destructor event cannot destroy the info object + // inside the dispatch that created it (wayland-backend would then + // write the object's data through a freed pointer; issue #1). If + // the client is gone by the time this runs, the sends are no-ops. + self.loop_handle.insert_idle(move |_state| { + crate::protocols::color_management::send_information( + &info, + &description, + ); + }); + } +} + crate::delegate_screencopy!(ShojiWM); crate::delegate_tearing_control!(ShojiWM); +crate::delegate_color_management!(ShojiWM); crate::delegate_wlr_foreign_toplevel!(ShojiWM); crate::workspace_manager::delegate_ext_workspace_manager!(ShojiWM); diff --git a/src/shojiwm/src/main.rs b/src/shojiwm/src/main.rs index 2a51927d..e84f8fec 100644 --- a/src/shojiwm/src/main.rs +++ b/src/shojiwm/src/main.rs @@ -12,6 +12,7 @@ use tracing_subscriber::EnvFilter; pub mod activation_environment; pub mod backend; +pub mod color; pub mod config; pub mod config_error; pub mod cursor; diff --git a/src/shojiwm/src/protocols/color_management.rs b/src/shojiwm/src/protocols/color_management.rs new file mode 100644 index 00000000..1546a5cb --- /dev/null +++ b/src/shojiwm/src/protocols/color_management.rs @@ -0,0 +1,1016 @@ +//! `wp_color_management_v1` global. +//! +//! Advertises the compositor's color capabilities, exposes per-output image +//! descriptions, and records per-surface parametric image descriptions in +//! the surface `data_map` (same passive pattern as `tearing_control.rs`). +//! +//! Scope: parametric-only (no ICC), perceptual render intent, sRGB always; +//! PQ/BT.2020 entries are added when the `SHOJI_HDR_OUTPUTS` experiment is +//! enabled (`crate::color::hdr_experiment_enabled`). +//! +//! Note: the protocol specifies `set_image_description` as double-buffered +//! (applied on `wl_surface.commit`). We apply it immediately instead — the +//! same simplification `tearing_control.rs` documents; clients set this +//! once before their first commit in practice. + +use std::sync::{ + Mutex, + atomic::{ + AtomicBool, + AtomicU32, + Ordering + }, +}; + +use smithay::reexports::wayland_protocols::wp::color_management::v1::server::{ + wp_color_management_output_v1::{ + self, + WpColorManagementOutputV1 + }, + wp_color_management_surface_feedback_v1::{ + self, + WpColorManagementSurfaceFeedbackV1 + }, + wp_color_management_surface_v1::{ + self, + WpColorManagementSurfaceV1 + }, + wp_color_manager_v1::{ + self, + Feature, + Primaries, + RenderIntent, + TransferFunction, + WpColorManagerV1, + }, + wp_image_description_creator_icc_v1::{ + self, + WpImageDescriptionCreatorIccV1 + }, + wp_image_description_creator_params_v1::{ + self, + WpImageDescriptionCreatorParamsV1 + }, + wp_image_description_info_v1::{ + self, + WpImageDescriptionInfoV1 + }, + wp_image_description_v1::{ + self, + WpImageDescriptionV1 + }, +}; +use smithay::reexports::wayland_server::{ + Client, + DataInit, + Dispatch, + DisplayHandle, + GlobalDispatch, + New, + Resource, + WEnum, + backend::ClientId, + protocol::{wl_output::WlOutput, + wl_surface::WlSurface}, +}; +use smithay::wayland::compositor::{ + SurfaceData, + with_states, +}; + +use crate::color::{ + ColorPrimaries, + ImageDescription, + Luminances, + TransferCharacteristics, + hdr_experiment_enabled, +}; + +const VERSION: u32 = 1; + +/// Compositor-side lookups the protocol cannot answer from surface state. +pub trait ColorManagementHandler { + /// The image description of the signal this output is driven with. + fn output_image_description(&mut self, output: &WlOutput) -> ImageDescription; + /// The description the compositor prefers for this surface's content. + fn surface_preferred_description(&mut self, surface: &WlSurface) -> ImageDescription; + /// Deliver [`send_information`] for `info` *after* the current dispatch + /// completes (e.g. from a calloop idle callback). + /// + /// This MUST NOT send synchronously: `done` is a destructor event, and + /// wayland-backend (up to at least 0.3.15) stores a newly created + /// object's data through a raw pointer *after* the request handler + /// returns, without checking that the object is still alive. Destroying + /// the info object inside the dispatch that created it is therefore a + /// write-after-free that corrupts the heap. + fn defer_image_description_info( + &mut self, + info: WpImageDescriptionInfoV1, + description: ImageDescription, + ); +} + +/// Aggregate dispatch bound so each impl below doesn't repeat nine clauses. +pub trait ColorManagementDispatch: + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + ColorManagementHandler + + 'static +{ +} + +impl ColorManagementDispatch for T where + T: Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + ColorManagementHandler + + 'static +{ +} + +/// Per-surface color state, stored in the surface's `data_map`. +#[derive(Debug, Default)] +pub struct ColorSurfaceData { + /// A `wp_color_management_surface_v1` already exists for this surface; + /// the protocol requires erroring on a second one. + has_surface_object: AtomicBool, + /// Committed image description; `None` => untagged (compositor assumes + /// sRGB). + description: Mutex>, +} + +fn with_color_surface_data( + surface: &WlSurface, + f: impl FnOnce(&ColorSurfaceData) -> T, +) -> T { + with_states(surface, |states| { + states + .data_map + .insert_if_missing_threadsafe(ColorSurfaceData::default); + f(states.data_map.get::().unwrap()) + }) +} + +/// Set once any surface is ever tagged, and never cleared. +/// +/// The render path has to walk a window's surface tree to find per-subsurface +/// descriptions, and almost no session ever has a tagged surface at all. This +/// turns that walk into a single relaxed load in the common case. Deliberately +/// sticky rather than a live count: untagging is rare, the walk is cheap, and a +/// stale `true` only costs a walk that finds nothing — whereas a stale `false` +/// would silently skip the conversion. +static ANY_SURFACE_TAGGED: AtomicBool = AtomicBool::new(false); + +/// Whether any surface has ever carried an image description this session. +/// A `false` return means the render path can skip its per-window tree walk. +pub fn any_surface_tagged() -> bool { + ANY_SURFACE_TAGGED.load(Ordering::Relaxed) +} + +/// Render-side read from `SurfaceData` the caller already holds. +/// +/// Use this — never [`surface_image_description`] — from inside a +/// `with_surface_tree_downward` (or `with_surface_tree_upward`) processor. +/// Smithay holds the surface's user-data lock for the whole traversal and calls +/// the processor while it is held, and that lock is a plain non-reentrant +/// `std::sync::Mutex`. Taking it again on the same thread deadlocks the +/// compositor outright: no panic, no error, no log line, just a thread parked in +/// `futex_wait` forever. The processor is already handed the `&SurfaceData` this +/// needs, so there is never a reason to lock again. +pub fn image_description_from_states(states: &SurfaceData) -> Option { + states + .data_map + .get::() + .and_then(|data| *data.description.lock().unwrap()) +} + +/// Render-side read: the surface's committed image description, or `None` +/// for untagged content (treat as sRGB). +/// +/// Takes the surface's user-data lock. Not safe to call from inside a surface +/// tree traversal — see [`image_description_from_states`]. +pub fn surface_image_description(surface: &WlSurface) -> Option { + if !surface.is_alive() { + return None; + } + with_states(surface, image_description_from_states) +} + +/// Manager state for the `wp_color_manager_v1` global. +#[derive(Debug)] +pub struct ColorManagementState; + +impl ColorManagementState { + /// Create and advertise the `wp_color_manager_v1` global. + pub fn new(display: &DisplayHandle) -> Self + where + D: GlobalDispatch + ColorManagementDispatch, + { + display.create_global::(VERSION, ()); + Self + } +} + +/// Sent once on bind: parametric-only, perceptual intent, sRGB always; +/// HDR entries only while the experiment gate is on so clients never +/// submit PQ content the render pipeline can't handle yet. +fn send_supported(manager: &WpColorManagerV1) { + manager.supported_intent(RenderIntent::Perceptual); + manager.supported_feature(Feature::Parametric); + manager.supported_primaries_named(Primaries::Srgb); + manager.supported_tf_named(TransferFunction::Srgb); + if hdr_experiment_enabled() { + manager.supported_feature(Feature::SetLuminances); + manager.supported_primaries_named(Primaries::Bt2020); + manager.supported_tf_named(TransferFunction::St2084Pq); + manager.supported_tf_named(TransferFunction::ExtLinear); + // Gamescope >= 3.16.4 requires this and checks the feature before + // using it; without it the client either errors out on + // `create_windows_scrgb` or silently drops to SDR. + manager.supported_feature(Feature::WindowsScrgb); + } + manager.done(); +} + +/// Monotonic identity for `wp_image_description_v1.ready`. Unique per +/// object is conformant (equal ids must mean identical descriptions; +/// distinct ids carry no meaning). +fn next_identity() -> u32 { + static NEXT_IDENTITY: AtomicU32 = AtomicU32::new(1); + NEXT_IDENTITY.fetch_add( + 1, + Ordering::Relaxed + ) +} + +/// Object data for `wp_color_management_output_v1`. +#[derive(Debug)] +pub struct ColorOutputData { + output: WlOutput, +} + +/// Object data for `wp_color_management_surface_v1`. +#[derive(Debug)] +pub struct ColorSurfaceObjData { + surface: WlSurface, +} + +/// Object data for `wp_color_management_surface_feedback_v1`. +#[derive(Debug)] +pub struct FeedbackData { + surface: WlSurface, +} + +/// Object data for `wp_image_description_v1`. `None` marks a failed +/// description (created only to consume the id on an error path). +#[derive(Debug)] +pub struct ImageDescriptionData { + description: Option, + /// Whether `get_information` may be answered. Windows-scRGB descriptions + /// are usable but not introspectable: the protocol forbids the request on + /// them because the encoding's reference white and target colour volume + /// are undefined, not merely unknown to us. + allow_information: bool, +} + +/// Accumulator for `wp_image_description_creator_params_v1`. +#[derive(Debug, Default)] +pub struct ParametricCreatorData { + params: Mutex, +} + +#[derive(Debug, Default, Clone, Copy)] +struct CreatorParams { + primaries: Option, + tf: Option, + luminances: Option, + max_cll: Option, + max_fall: Option, +} + +/// Initialize a description object, mark it ready, and return it. +fn init_ready_description( + data_init: &mut DataInit<'_, D>, + id: New, + description: ImageDescription, + allow_information: bool, +) -> WpImageDescriptionV1 +where + D: Dispatch + 'static, +{ + let object = data_init.init( + id, + ImageDescriptionData { + description: Some(description), + allow_information, + }, + ); + object.ready(next_identity()); + object +} + +fn protocol_primaries(primaries: ColorPrimaries) -> Primaries { + match primaries { + ColorPrimaries::Srgb => Primaries::Srgb, + ColorPrimaries::Bt2020 => Primaries::Bt2020, + } +} + +fn protocol_tf(tf: TransferCharacteristics) -> TransferFunction { + match tf { + TransferCharacteristics::Srgb => TransferFunction::Srgb, + TransferCharacteristics::St2084Pq => TransferFunction::St2084Pq, + TransferCharacteristics::ExtLinear => TransferFunction::ExtLinear, + } +} + +/// Send the full information event burst, ending with the `done` destructor +/// event. Only call this *outside* the dispatch that created `info` — see +/// [`ColorManagementHandler::defer_image_description_info`]. +pub fn send_information( + info: &WpImageDescriptionInfoV1, + description: &ImageDescription +) { + let chroma = description.primaries.chromaticities(); + let (r_x, r_y) = chroma.red.to_protocol(); + let (g_x, g_y) = chroma.green.to_protocol(); + let (b_x, b_y) = chroma.blue.to_protocol(); + let (w_x, w_y) = chroma.white.to_protocol(); + info.primaries( + r_x, + r_y, + g_x, + g_y, + b_x, + b_y, + w_x, + w_y + ); + info.primaries_named( + protocol_primaries( + description.primaries + ) + ); + info.tf_named( + protocol_tf( + description.tf + ) + ); + let luminances = description.effective_luminances(); + info.luminances( + (luminances.min * 10000.0).round() as u32, + luminances.max.round() as u32, + luminances.reference.round() as u32, + ); + // `done` is a destructor event: the info object dies here. + info.done(); +} + +impl GlobalDispatch for ColorManagementState +where + D: GlobalDispatch + ColorManagementDispatch, +{ + fn bind( + _state: &mut D, + _dh: &DisplayHandle, + _client: &Client, + manager: New, + _data: &(), + data_init: &mut DataInit<'_, D>, + ) { + let manager = data_init.init( + manager, + () + ); + send_supported( + &manager + ); + } +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + _state: &mut D, + _client: &Client, + manager: &WpColorManagerV1, + request: wp_color_manager_v1::Request, + _data: &(), + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, D>, + ) { + match request { + wp_color_manager_v1::Request::GetOutput { + id, + output + } => { + data_init.init( + id, + ColorOutputData { + output + } + ); + } + wp_color_manager_v1::Request::GetSurface { id, surface } => { + // Always init to consume the id, even on the error path. + let already_present = with_color_surface_data( + &surface, + |data| { + data + .has_surface_object + .swap( + true, + Ordering::Relaxed + ) + } + ); + data_init.init( + id, + ColorSurfaceObjData { + surface: surface.clone(), + }, + ); + if already_present { + manager.post_error( + wp_color_manager_v1::Error::SurfaceExists, + "wl_surface already has a wp_color_management_surface_v1", + ); + } + } + wp_color_manager_v1::Request::GetSurfaceFeedback { + id, + surface + } => { + data_init.init( + id, + FeedbackData { + surface + } + ); + } + wp_color_manager_v1::Request::CreateIccCreator { obj } => { + data_init.init( + obj, + () + ); + manager.post_error( + wp_color_manager_v1::Error::UnsupportedFeature, + "icc_v2_v4 is not supported", + ); + } + wp_color_manager_v1::Request::CreateParametricCreator { obj } => { + data_init.init( + obj, + ParametricCreatorData::default() + ); + } + wp_color_manager_v1::Request::CreateWindowsScrgb { image_description } => { + // Advertised alongside the other HDR entries, so refuse it on + // the same terms: without the gate there is no extended-linear + // path through the render pipeline to honour it with. + if !hdr_experiment_enabled() { + data_init.init( + image_description, + ImageDescriptionData { + description: None, + allow_information: false, + }, + ); + manager.post_error( + wp_color_manager_v1::Error::UnsupportedFeature, + "windows_scrgb is not supported", + ); + return; + } + // Wholly fixed by the protocol: sRGB/BT.709 primaries and + // white point, extended-linear transfer, nominal R=G=B=1.0 at + // 80 cd/m² rising to 125.0 at 10k cd/m². The luminances have + // to be stated rather than defaulted — `ExtLinear`'s own + // defaults are the SDR 80 cd/m² set, which would silently + // clamp the whole point of the encoding. Reference white is + // formally *unknown* for Windows-scRGB; the protocol says to + // assume BT.2408's 203 cd/m² where one is needed, and the + // compositing path needs one. + init_ready_description( + data_init, + image_description, + ImageDescription { + primaries: ColorPrimaries::Srgb, + tf: TransferCharacteristics::ExtLinear, + luminances: Some(Luminances { + min: 0.0, + max: 10_000.0, + reference: 203.0, + }), + max_cll: None, + max_fall: None, + }, + // The protocol disallows `get_information` on the result. + false, + ); + } + wp_color_manager_v1::Request::Destroy => {} + _ => {} + } + } +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + state: &mut D, + _client: &Client, + _output_obj: &WpColorManagementOutputV1, + request: wp_color_management_output_v1::Request, + data: &ColorOutputData, + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, D>, + ) { + match request { + wp_color_management_output_v1::Request::GetImageDescription { + image_description, + } => { + let description = state.output_image_description(&data.output); + init_ready_description( + data_init, + image_description, + description, + true, + ); + } + wp_color_management_output_v1::Request::Destroy => {} + _ => {} + } + } +} + +fn reset_surface_color(surface: &WlSurface) { + if !surface.is_alive() { + return; + } + with_color_surface_data(surface, |data| { + data.has_surface_object.store(false, Ordering::Relaxed); + *data.description.lock().unwrap() = None; + }); +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + _state: &mut D, + _client: &Client, + surface_obj: &WpColorManagementSurfaceV1, + request: wp_color_management_surface_v1::Request, + data: &ColorSurfaceObjData, + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, D>, + ) { + match request { + wp_color_management_surface_v1::Request::SetImageDescription { + image_description, + render_intent, + } => { + if !data.surface.is_alive() { + surface_obj.post_error( + wp_color_management_surface_v1::Error::Inert, + "the wl_surface has been destroyed", + ); + return; + } + if render_intent != WEnum::Value(RenderIntent::Perceptual) { + surface_obj.post_error( + wp_color_management_surface_v1::Error::RenderIntent, + "unsupported render intent", + ); + return; + } + let description = image_description + .data::() + .and_then(|data| data.description); + let Some(description) = description else { + surface_obj.post_error( + wp_color_management_surface_v1::Error::ImageDescription, + "image description is not usable", + ); + return; + }; + with_color_surface_data(&data.surface, |surface_data| { + *surface_data.description.lock().unwrap() = Some(description); + }); + ANY_SURFACE_TAGGED + .store( + true, + Ordering::Relaxed, + ); + } + wp_color_management_surface_v1::Request::UnsetImageDescription => { + if data.surface.is_alive() { + with_color_surface_data(&data.surface, |surface_data| { + *surface_data.description.lock().unwrap() = None; + }); + } + } + wp_color_management_surface_v1::Request::Destroy => { + reset_surface_color(&data.surface); + } + _ => {} + } + } + + fn destroyed( + _state: &mut D, + _client: ClientId, + _surface_obj: &WpColorManagementSurfaceV1, + data: &ColorSurfaceObjData, + ) { + // Safety net for clients that drop the object without an explicit + // destroy request (e.g. on disconnect). + reset_surface_color(&data.surface); + } +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + state: &mut D, + _client: &Client, + feedback: &WpColorManagementSurfaceFeedbackV1, + request: wp_color_management_surface_feedback_v1::Request, + data: &FeedbackData, + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, D>, + ) { + match request { + wp_color_management_surface_feedback_v1::Request::GetPreferred { + image_description, + } + | wp_color_management_surface_feedback_v1::Request::GetPreferredParametric { + image_description, + } => { + if !data.surface.is_alive() { + data_init + .init( + image_description, + ImageDescriptionData { + description: None, + allow_information: false, + }, + ); + feedback.post_error( + wp_color_management_surface_feedback_v1::Error::Inert, + "the wl_surface has been destroyed", + ); + return; + } + let description = state.surface_preferred_description( + &data.surface + ); + init_ready_description( + data_init, + image_description, + description, + true, + ); + } + wp_color_management_surface_feedback_v1::Request::Destroy => {} + _ => {} + } + } +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + _state: &mut D, + _client: &Client, + _creator: &WpImageDescriptionCreatorIccV1, + _request: wp_image_description_creator_icc_v1::Request, + _data: &(), + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, D>, + ) { + // Only reachable after the unsupported_feature error already killed + // the client; nothing to do. + } +} + +impl Dispatch + for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + _state: &mut D, + _client: &Client, + creator: &WpImageDescriptionCreatorParamsV1, + request: wp_image_description_creator_params_v1::Request, + data: &ParametricCreatorData, + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, D>, + ) { + use wp_image_description_creator_params_v1::{Error, Request}; + match request { + Request::SetTfNamed { tf } => { + let WEnum::Value(named) = tf else { + creator.post_error( + Error::InvalidTf, + "invalid transfer function" + ); + return; + }; + let mapped = match named { + TransferFunction::Srgb => Some(TransferCharacteristics::Srgb), + TransferFunction::St2084Pq if hdr_experiment_enabled() => { + Some(TransferCharacteristics::St2084Pq) + } + TransferFunction::ExtLinear if hdr_experiment_enabled() => { + Some(TransferCharacteristics::ExtLinear) + } + _ => None, + }; + let Some(mapped) = mapped else { + creator.post_error( + Error::InvalidTf, + "unsupported transfer function" + ); + return; + }; + let mut params = data.params.lock().unwrap(); + if params.tf.replace(mapped).is_some() { + creator.post_error( + Error::AlreadySet, + "transfer function already set" + ); + } + } + Request::SetPrimariesNamed { primaries } => { + let WEnum::Value(named) = primaries else { + creator.post_error( + Error::InvalidPrimariesNamed, + "invalid primaries" + ); + return; + }; + let mapped = match named { + Primaries::Srgb => Some(ColorPrimaries::Srgb), + Primaries::Bt2020 if hdr_experiment_enabled() => Some(ColorPrimaries::Bt2020), + _ => None, + }; + let Some(mapped) = mapped else { + creator.post_error( + Error::InvalidPrimariesNamed, + "unsupported primaries" + ); + return; + }; + let mut params = data.params + .lock() + .unwrap(); + if params.primaries.replace(mapped).is_some() { + creator.post_error( + Error::AlreadySet, + "primaries already set" + ); + } + } + Request::SetLuminances { + min_lum, + max_lum, + reference_lum, + } => { + if !hdr_experiment_enabled() { + creator.post_error( + Error::UnsupportedFeature, + "set_luminances is not supported", + ); + return; + } + let luminances = Luminances { + min: min_lum as f32 / 10000.0, + max: max_lum as f32, + reference: reference_lum as f32, + }; + if luminances.max <= luminances.min || luminances.reference <= luminances.min { + creator.post_error( + Error::InvalidLuminance, + "invalid luminance ordering" + ); + return; + } + let mut params = data.params + .lock() + .unwrap(); + if params.luminances.replace(luminances).is_some() { + creator.post_error( + Error::AlreadySet, + "luminances already set" + ); + } + } + Request::SetMaxCll { max_cll } => { + let mut params = data.params + .lock() + .unwrap(); + if params.max_cll.replace(max_cll).is_some() { + creator.post_error( + Error::AlreadySet, + "max_cll already set" + ); + } + } + Request::SetMaxFall { max_fall } => { + let mut params = data.params + .lock() + .unwrap(); + if params.max_fall.replace(max_fall).is_some() { + creator.post_error( + Error::AlreadySet, + "max_fall already set" + ); + } + } + Request::SetTfPower { .. } => { + creator.post_error( + Error::UnsupportedFeature, + "set_tf_power is not supported" + ); + } + Request::SetPrimaries { .. } => { + creator.post_error( + Error::UnsupportedFeature, + "set_primaries is not supported" + ); + } + Request::SetMasteringDisplayPrimaries { .. } => { + creator.post_error( + Error::UnsupportedFeature, + "set_mastering_display_primaries is not supported", + ); + } + Request::SetMasteringLuminance { .. } => { + creator.post_error( + Error::UnsupportedFeature, + "set_mastering_luminance is not supported", + ); + } + Request::Create { image_description } => { + let params = *data.params.lock().unwrap(); + match (params.primaries, params.tf) { + ( + Some(primaries), + Some(tf) + ) => { + init_ready_description( + data_init, + image_description, + ImageDescription { + primaries, + tf, + luminances: params.luminances, + max_cll: params.max_cll, + max_fall: params.max_fall, + }, + true, + ); + } + _ => { + data_init.init( + image_description, + ImageDescriptionData { + description: None, + allow_information: false, + }, + ); + creator.post_error( + Error::IncompleteSet, + "primaries and transfer function are required", + ); + } + } + } + _ => {} + } + } +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + state: &mut D, + _client: &Client, + description_obj: &WpImageDescriptionV1, + request: wp_image_description_v1::Request, + data: &ImageDescriptionData, + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, D>, + ) { + match request { + wp_image_description_v1::Request::GetInformation { information } => { + let info = data_init.init(information, ()); + match &data.description { + // The info burst ends with the `done` destructor event; + // it must not be sent from inside this dispatch (which + // created `info`) or wayland-backend writes the new + // object's data through a freed pointer afterwards. + Some(description) if data.allow_information => { + state + .defer_image_description_info( + info, + *description, + ); + } + _ => { + description_obj.post_error( + wp_image_description_v1::Error::NoInformation, + "image description has no information", + ); + } + } + } + wp_image_description_v1::Request::Destroy => {} + _ => {} + } + } +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + _state: &mut D, + _client: &Client, + _info: &WpImageDescriptionInfoV1, + _request: wp_image_description_info_v1::Request, + _data: &(), + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, D>, + ) { + // The interface has no requests. + } +} + +/// Delegate the `wp_color_management_v1` globals to [`ColorManagementState`]. +#[macro_export] +macro_rules! delegate_color_management { + ($(@<$( $lt:tt $( : $clt:tt $(+ $dlt:tt )* )? ),+>)? $ty: ty) => { + smithay::reexports::wayland_server::delegate_global_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_color_manager_v1::WpColorManagerV1: () + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_color_manager_v1::WpColorManagerV1: () + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_color_management_output_v1::WpColorManagementOutputV1: $crate::protocols::color_management::ColorOutputData + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_color_management_surface_v1::WpColorManagementSurfaceV1: $crate::protocols::color_management::ColorSurfaceObjData + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_color_management_surface_feedback_v1::WpColorManagementSurfaceFeedbackV1: $crate::protocols::color_management::FeedbackData + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_image_description_creator_icc_v1::WpImageDescriptionCreatorIccV1: () + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_image_description_creator_params_v1::WpImageDescriptionCreatorParamsV1: $crate::protocols::color_management::ParametricCreatorData + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_image_description_v1::WpImageDescriptionV1: $crate::protocols::color_management::ImageDescriptionData + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_image_description_info_v1::WpImageDescriptionInfoV1: () + ] => $crate::protocols::color_management::ColorManagementState); + }; +} diff --git a/src/shojiwm/src/protocols/mod.rs b/src/shojiwm/src/protocols/mod.rs index 839c6343..f37b827a 100644 --- a/src/shojiwm/src/protocols/mod.rs +++ b/src/shojiwm/src/protocols/mod.rs @@ -1,2 +1,3 @@ pub mod screencopy; pub mod tearing_control; +pub mod color_management; diff --git a/src/shojiwm/src/ssd/window_model.rs b/src/shojiwm/src/ssd/window_model.rs index 68a8c5ba..19e5618b 100644 --- a/src/shojiwm/src/ssd/window_model.rs +++ b/src/shojiwm/src/ssd/window_model.rs @@ -518,6 +518,9 @@ pub struct WaylandOutputSnapshot { pub position: OutputPositionSnapshot, pub scale: f64, pub available_modes: Vec, + /// EDID advertises HDR (CTA-861 static metadata). Can + /// be used to check display capabilities. + pub hdr_supported: bool, } #[derive(Debug, Clone, Copy, PartialEq, Serialize)] diff --git a/src/shojiwm/src/state.rs b/src/shojiwm/src/state.rs index ae12e951..760c4ca3 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -252,6 +252,7 @@ pub struct ShojiWM { pub fractional_scale_manager_state: FractionalScaleManagerState, pub screencopy_state: crate::protocols::screencopy::ScreencopyManagerState, pub tearing_control_state: crate::protocols::tearing_control::TearingControlManagerState, + pub color_management_state: crate::protocols::color_management::ColorManagementState, pub foreign_toplevel_list_state: smithay::wayland::foreign_toplevel_list::ForeignToplevelListState, pub wlr_foreign_toplevel_manager_state: @@ -328,6 +329,10 @@ pub struct ShojiWM { pub runtime_scheduler_kick_active: bool, pub runtime_animation_outputs: std::collections::HashSet, pub runtime_output_globals: HashMap, + /// Per-output color mode/signal state, keyed by output name (tty only). + pub output_color: HashMap, + /// Per-output fp16 composite + PQ encode state for HDR10 outputs. + pub hdr_pipelines: HashMap, pub managed_window_animations: HashMap>, pub managed_window_animation_sequence: u64, pub runtime_output_configs: std::collections::BTreeMap, @@ -970,6 +975,8 @@ impl ShojiWM { crate::protocols::screencopy::ScreencopyManagerState::new::(&dh, |_| true); let tearing_control_state = crate::protocols::tearing_control::TearingControlManagerState::new::(&dh); + let color_management_state = + crate::protocols::color_management::ColorManagementState::new::(&dh); let foreign_toplevel_list_state = smithay::wayland::foreign_toplevel_list::ForeignToplevelListState::new::(&dh); let wlr_foreign_toplevel_manager_state = @@ -1159,6 +1166,7 @@ impl ShojiWM { fractional_scale_manager_state, screencopy_state, tearing_control_state, + color_management_state, foreign_toplevel_list_state, wlr_foreign_toplevel_manager_state, ext_workspace_manager_state, @@ -1222,6 +1230,8 @@ impl ShojiWM { runtime_scheduler_kick_active: false, runtime_animation_outputs: Default::default(), runtime_output_globals: Default::default(), + output_color: Default::default(), + hdr_pipelines: Default::default(), managed_window_animations: Default::default(), managed_window_animation_sequence: 0, runtime_output_configs: Default::default(), @@ -2084,6 +2094,12 @@ impl ShojiWM { }, scale: output.current_scale().fractional_scale(), available_modes, + hdr_supported: self + .output_color + .get( + &name, + ) + .is_some_and(|color| color.edid_hdr.is_some()), }, ) }) @@ -2217,6 +2233,15 @@ impl ShojiWM { } } } + // The HDR opt-in lives in this config: widen/narrow the protocol + // advertisement and re-resolve connected outputs, which connect + // before the TypeScript config evaluates. + crate::color::set_session_hdr_configured( + self.runtime_output_configs + .values() + .any(|config| config.hdr == Some(true)), + ); + crate::backend::tty::refresh_tty_output_color_modes(self); self.apply_runtime_display_configuration(); self.notify_runtime_outputs_changed(); } @@ -2333,6 +2358,7 @@ impl ShojiWM { smithay::wayland::image_copy_capture::CaptureFailureReason::Unknown, ); self.output_capture_mirrors.remove(&name); + self.hdr_pipelines.remove(&name); self.runtime_animation_outputs.remove(&name); self.layer_effect_evaluation_cache.remove(&name); self.popup_effect_evaluation_cache.remove(&name);