diff --git a/subwave_wayland/src/color_management.rs b/subwave_wayland/src/color_management.rs index b3223ed..12be10c 100644 --- a/subwave_wayland/src/color_management.rs +++ b/subwave_wayland/src/color_management.rs @@ -1,95 +1,79 @@ -//! Wayland `wp-color-management-v1` integration for per-surface color tagging. +//! Wayland `wp-color-management-v1` capability discovery. //! -//! When the compositor advertises `wp_color_manager_v1`, this module can tag the -//! **video surface** with its HDR image description (BT.2020 + PQ) so the -//! compositor knows to tone-map correctly. The **subtitle surface** is left -//! **untagged** — per the protocol spec, untagged surfaces are treated as sRGB -//! by the compositor, which is exactly what we want for ARGB32 subtitle bitmaps. -//! -//! This eliminates the color-shift flicker that occurs when an SDR SHM subtitle -//! overlay is composited over an HDR DMABuf video plane without the compositor -//! knowing each surface's color space. +//! The surface Subwave gives to `waylandsink` is only a transparent mapping +//! ancestor. It does not carry video pixels and must not receive the stream's +//! HDR image description. GStreamer 1.28+ applies color metadata to the nested +//! surface that carries the actual video buffer. Subwave keeps both its mapping +//! anchor and subtitle surface untagged so compositors treat them as SDR/sRGB. use wayland_client::{Connection, Dispatch, QueueHandle}; -use wayland_protocols::wp::color_management::v1::client::{ - wp_color_management_surface_v1::WpColorManagementSurfaceV1, - wp_color_manager_v1::{self, WpColorManagerV1}, - wp_image_description_creator_params_v1::WpImageDescriptionCreatorParamsV1, - wp_image_description_v1::{self, WpImageDescriptionV1}, +use wayland_protocols::wp::color_management::v1::client::wp_color_manager_v1::{ + self, WpColorManagerV1, }; -use crate::Result; - /// HDR metadata extracted from GStreamer caps / tags. +/// +/// Retained as public API for callers that inspect stream metadata. Subwave no +/// longer applies this metadata to its transparent Wayland host surface. #[derive(Debug, Clone)] pub struct HdrMetadata { /// Mastering display colour volume primaries (CIE 1931 xy × 50000). /// Order: Rx, Ry, Gx, Gy, Bx, By, Wx, Wy /// e.g. from GStreamer: "34000:16000:13250:34500:7500:3000:15635:16450" pub mastering_primaries: Option<[u32; 8]>, - /// Mastering display min luminance (× 10000) and max luminance (cd/m²). - /// e.g. from "10000000:50" → min=50 (0.005 cd/m²), max=10000000 (1000 cd/m²) + /// Mastering display minimum luminance (× 10000). pub mastering_luminance_min: Option, + /// Mastering display maximum luminance (× 10000). pub mastering_luminance_max: Option, - /// MaxCLL (cd/m²) from content-light-level first field + /// MaxCLL (cd/m²) from content-light-level first field. pub max_cll: Option, - /// MaxFALL (cd/m²) from content-light-level second field + /// MaxFALL (cd/m²) from content-light-level second field. pub max_fall: Option, } impl HdrMetadata { /// Parse GStreamer's `mastering-display-info` string. /// - /// Format: `"Rx:Ry:Gx:Gy:Bx:By:Wx:Wy:MaxLum:MinLum"` - /// Values are CIE 1931 xy coordinates × 50000 and luminances in 1/10000 cd/m². + /// Format: `"Rx:Ry:Gx:Gy:Bx:By:Wx:Wy:MaxLum:MinLum"`. + /// Coordinates use units of 1/50000 and luminances use units of + /// 1/10000 cd/m². pub fn parse_mastering_display(s: &str) -> Option<([u32; 8], u32, u32)> { - let parts: Vec = s.split(':').filter_map(|p| p.parse().ok()).collect(); - if parts.len() >= 10 { - let primaries = [ - parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6], parts[7], - ]; - // GStreamer gives max luminance first, then min - let max_lum = parts[8]; - let min_lum = parts[9]; - Some((primaries, max_lum, min_lum)) - } else { - None + let parts: Vec = s.split(':').filter_map(|part| part.parse().ok()).collect(); + if parts.len() < 10 { + return None; } + + let primaries = [ + parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6], parts[7], + ]; + Some((primaries, parts[8], parts[9])) } /// Parse GStreamer's `content-light-level` string. /// /// Format: `"MaxCLL:MaxFALL"` in cd/m². pub fn parse_content_light_level(s: &str) -> Option<(u32, u32)> { - let parts: Vec = s.split(':').filter_map(|p| p.parse().ok()).collect(); - if parts.len() >= 2 { - Some((parts[0], parts[1])) - } else { - None + let parts: Vec = s.split(':').filter_map(|part| part.parse().ok()).collect(); + if parts.len() < 2 { + return None; } + + Some((parts[0], parts[1])) } - /// Detect whether a GStreamer colorimetry string indicates HDR (PQ/HLG). - /// - /// GStreamer colorimetry format: `"range:matrix:transfer:primaries"` - /// where transfer=14 is SMPTE ST 2084 (PQ), transfer=15 is ARIB STD-B67 (HLG), - /// and primaries=7 is BT.2020. + /// Detect whether a GStreamer colorimetry string uses PQ or HLG transfer. pub fn is_hdr_colorimetry(colorimetry: &str) -> bool { let parts: Vec<&str> = colorimetry.split(':').collect(); - if parts.len() >= 4 { - let transfer = parts[2]; - // 14 = SMPTE ST 2084 (PQ), 15 = ARIB STD-B67 (HLG) - transfer == "14" || transfer == "15" - } else { - false + if parts.len() < 4 { + return false; } + + // 14 = SMPTE ST 2084 (PQ), 15 = ARIB STD-B67 (HLG). + matches!(parts[2], "14" | "15") } - /// Returns true if the pixel format can actually carry HDR data. - /// 8-bit formats like BGRx/BGRA/NV12 cannot — they indicate that - /// vapostproc already tone-mapped the content to SDR. + /// Return whether a pixel format can carry HDR data. pub fn is_hdr_capable_format(format: &str) -> bool { - // Formats that can carry >8-bit or HDR data matches!( format, "P010_10LE" @@ -107,37 +91,18 @@ impl HdrMetadata { | "VUYA" | "BGR10A2_LE" | "RGB10A2_LE" - | "DMA_DRM" // DMABuf — format negotiated separately + | "DMA_DRM" ) } } -/// Tracks everything we need to set color descriptions on surfaces. +/// Retains the compositor color-manager global as a capability signal. pub struct ColorManager { - pub(crate) manager: WpColorManagerV1, - /// HDR image description (for video surface) — created on demand - hdr_desc: Option, - /// Color management surface wrapper for the video surface - video_cm_surface: Option, - /// Whether we have successfully tagged the video surface - video_tagged: bool, - /// Currently applied metadata (so we can detect changes) - applied_colorimetry: Option, - /// Compositor-advertised optional features - pub(crate) supports_set_luminances: bool, - pub(crate) supports_set_mastering_primaries: bool, -} - -/// State for tracking image-description readiness via events. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DescriptionRole { - HdrPq, + manager: WpColorManagerV1, } impl ColorManager { - /// Try to bind `wp_color_manager_v1` from the registry globals. - /// - /// Returns `None` if the compositor does not advertise the protocol. + /// Bind `wp_color_manager_v1` when advertised by the compositor. pub(crate) fn bind_if_available( globals: &[(u32, String, u32)], registry: &wayland_client::protocol::wl_registry::WlRegistry, @@ -145,226 +110,25 @@ impl ColorManager { ) -> Option { let global = globals .iter() - .find(|(_, iface, _)| iface == "wp_color_manager_v1")?; - - let version = global.2.min(2); // protocol is at version 2 + .find(|(_, interface, _)| interface == "wp_color_manager_v1")?; + let version = global.2.min(2); let manager: WpColorManagerV1 = registry.bind(global.0, version, qh, ()); log::info!( - "[color-mgmt] Bound wp_color_manager_v1 v{version} (global name={})", - global.0 + "[color-mgmt] Bound wp_color_manager_v1 v{version}; waylandsink owns video tagging" ); - - Some(Self { - manager, - hdr_desc: None, - video_cm_surface: None, - video_tagged: false, - applied_colorimetry: None, - supports_set_luminances: false, - supports_set_mastering_primaries: false, - }) - } - - /// Create the color-management surface wrapper (needed before we can tag). - /// Does NOT set any image description yet — the surface remains in its - /// compositor-default state (sRGB) until `tag_video_hdr` is called. - pub(crate) fn wrap_video_surface( - &mut self, - video_surface: &wayland_client::protocol::wl_surface::WlSurface, - qh: &QueueHandle, - ) { - if self.video_cm_surface.is_none() { - let cm_surface: WpColorManagementSurfaceV1 = - self.manager.get_surface(video_surface, qh, ()); - self.video_cm_surface = Some(cm_surface); - log::info!("[color-mgmt] Created color management surface wrapper for video"); - } - // NOTE: subtitle surface is deliberately NOT wrapped. - // Per the protocol: "By default, a surface does not have an associated - // image description… Compositors should handle such surfaces as sRGB" - // This is exactly what we want for ARGB32 subtitle overlays. - } - - /// Tag the video surface with a BT.2020 + PQ HDR image description. - /// - /// Call this when the video caps indicate HDR content. The description - /// is created with the provided metadata (or sensible defaults) and applied - /// to the video surface. The subtitle surface is left untagged (sRGB). - /// - /// `colorimetry` is the GStreamer colorimetry string for change detection. - pub(crate) fn tag_video_hdr( - &mut self, - colorimetry: &str, - metadata: Option<&HdrMetadata>, - video_surface: &wayland_client::protocol::wl_surface::WlSurface, - qh: &QueueHandle, - event_queue: &mut wayland_client::EventQueue, - ) -> Result<()> { - // Skip if we already applied the same colorimetry - if self.applied_colorimetry.as_deref() == Some(colorimetry) { - return Ok(()); - } - - // Ensure we have a CM surface wrapper - self.wrap_video_surface(video_surface, qh); - - let cm_surface = self - .video_cm_surface - .as_ref() - .ok_or_else(|| crate::Error::Wayland("No color management surface".into()))?; - - // Destroy old description if any - if let Some(old) = self.hdr_desc.take() { - old.destroy(); - } - - // Create new parametric HDR description - let creator: WpImageDescriptionCreatorParamsV1 = - self.manager.create_parametric_creator(qh, ()); - - // Primaries: BT.2020 - creator.set_primaries_named(wp_color_manager_v1::Primaries::Bt2020); - - // Transfer function: ST 2084 (PQ) - creator.set_tf_named(wp_color_manager_v1::TransferFunction::St2084Pq); - - // Luminances: reference white, min, max in cd/m² - // set_luminances(min_lum, max_lum, reference_lum) - // min_lum: minimum luminance (cd/m²) × 10000 - // max_lum: maximum luminance (cd/m²) unscaled - // reference_lum: reference white luminance (cd/m²) unscaled - // - // With ST 2084 PQ the protocol says max_lum is ignored and taken as - // min_lum + 10000, but we still set it for correctness. - // - // GStreamer mastering-display-info format: - // "Rx:Ry:Gx:Gy:Bx:By:Wx:Wy:MaxLum:MinLum" - // MaxLum and MinLum are in units of 1/10000 cd/m² - - // ── Optional features (only if compositor advertises support) ── - // Calling these without compositor support raises protocol error - // `unsupported_feature`, which can corrupt the description or - // disconnect us entirely. - - if self.supports_set_luminances { - if let Some(meta) = metadata { - if let (Some(max_raw), Some(min_raw)) = - (meta.mastering_luminance_max, meta.mastering_luminance_min) - { - let min_lum = min_raw; // already × 10000 - let max_lum = (max_raw / 10000).max(1); // convert to cd/m² - let reference_lum = 203_u32; // PQ reference white - creator.set_luminances(min_lum, max_lum, reference_lum); - log::info!( - "[color-mgmt] Mastering luminance: min={min_raw}/10000 cd/m², max={max_lum} cd/m²" - ); - } else { - creator.set_luminances(50, 1000, 203); - } - } else { - creator.set_luminances(50, 1000, 203); - } - } else { - log::debug!("[color-mgmt] Skipping set_luminances (not advertised by compositor)"); - } - - if self.supports_set_mastering_primaries { - if let Some(meta) = metadata { - if let Some(prims) = meta.mastering_primaries { - let scale = |v: u32| -> i32 { (v * 20) as i32 }; - creator.set_mastering_display_primaries( - scale(prims[0]), - scale(prims[1]), - scale(prims[2]), - scale(prims[3]), - scale(prims[4]), - scale(prims[5]), - scale(prims[6]), - scale(prims[7]), - ); - log::info!("[color-mgmt] Set mastering display primaries from stream metadata"); - } - - if let Some(max_cll) = meta.max_cll { - creator.set_max_cll(max_cll); - } - if let Some(max_fall) = meta.max_fall { - creator.set_max_fall(max_fall); - } - } - } else { - log::debug!( - "[color-mgmt] Skipping mastering primaries/CLL/FALL (not advertised by compositor)" - ); - } - - let desc = creator.create(qh, DescriptionRole::HdrPq); - - // Roundtrip to receive the ready/failed event for the description - let mut state = super::subsurface_manager::State::new(); - event_queue - .roundtrip(&mut state) - .map_err(|e| crate::Error::Wayland(format!("Roundtrip for HDR desc: {}", e)))?; - - // Apply to the video surface - cm_surface.set_image_description(&desc, wp_color_manager_v1::RenderIntent::Perceptual); - video_surface.commit(); - - self.hdr_desc = Some(desc); - self.video_tagged = true; - self.applied_colorimetry = Some(colorimetry.to_string()); - - log::info!("[color-mgmt] Tagged video surface with BT.2020+PQ (colorimetry={colorimetry})"); - - Ok(()) - } - - /// Remove the HDR tag from the video surface, returning it to compositor - /// default (sRGB). Call when switching to SDR content. - pub(crate) fn untag_video( - &mut self, - video_surface: &wayland_client::protocol::wl_surface::WlSurface, - ) { - if !self.video_tagged { - return; - } - if let Some(ref cm) = self.video_cm_surface { - cm.unset_image_description(); - video_surface.commit(); - log::info!("[color-mgmt] Removed HDR tag from video surface (back to sRGB default)"); - } - if let Some(desc) = self.hdr_desc.take() { - desc.destroy(); - } - self.video_tagged = false; - self.applied_colorimetry = None; + Some(Self { manager }) } - /// Returns true if the video surface is currently tagged with an HDR description. - pub fn is_video_tagged_hdr(&self) -> bool { - self.video_tagged - } - - /// Clean up color management resources. + /// Destroy the capability handle. pub fn destroy(&mut self) { - if let Some(cm) = self.video_cm_surface.take() { - cm.destroy(); - } - if let Some(desc) = self.hdr_desc.take() { - desc.destroy(); - } self.manager.destroy(); - self.video_tagged = false; - self.applied_colorimetry = None; - log::debug!("[color-mgmt] Destroyed color management resources"); + log::debug!("[color-mgmt] Destroyed color management capability handle"); } } -// ── Dispatch implementations ────────────────────────────────────────────── - impl Dispatch for super::subsurface_manager::State { fn event( - state: &mut Self, + _state: &mut Self, _proxy: &WpColorManagerV1, event: wp_color_manager_v1::Event, _data: &(), @@ -376,18 +140,7 @@ impl Dispatch for super::subsurface_manager::State { log::debug!("[color-mgmt] Compositor supports render intent: {render_intent:?}"); } wp_color_manager_v1::Event::SupportedFeature { feature } => { - log::info!("[color-mgmt] Compositor supports feature: {feature:?}"); - // Track features we care about - use wayland_client::WEnum; - match feature { - WEnum::Value(wp_color_manager_v1::Feature::SetLuminances) => { - state.cm_supports_set_luminances = true; - } - WEnum::Value(wp_color_manager_v1::Feature::SetMasteringDisplayPrimaries) => { - state.cm_supports_set_mastering_primaries = true; - } - _ => {} - } + log::debug!("[color-mgmt] Compositor supports feature: {feature:?}"); } wp_color_manager_v1::Event::SupportedTfNamed { tf } => { log::debug!("[color-mgmt] Compositor supports transfer function: {tf:?}"); @@ -396,63 +149,30 @@ impl Dispatch for super::subsurface_manager::State { log::debug!("[color-mgmt] Compositor supports primaries: {primaries:?}"); } wp_color_manager_v1::Event::Done => { - log::info!( - "[color-mgmt] Compositor capabilities done (luminances={}, mastering_primaries={})", - state.cm_supports_set_luminances, - state.cm_supports_set_mastering_primaries, - ); + log::info!("[color-mgmt] Compositor capability advertisement complete"); } _ => {} } } } -impl Dispatch for super::subsurface_manager::State { - fn event( - _state: &mut Self, - _proxy: &WpColorManagementSurfaceV1, - _event: ::Event, - _data: &(), - _conn: &Connection, - _qh: &QueueHandle, - ) { - // wp_color_management_surface_v1 has no events in the current protocol version - } -} +#[cfg(test)] +mod tests { + use super::HdrMetadata; -impl Dispatch for super::subsurface_manager::State { - fn event( - _state: &mut Self, - _proxy: &WpImageDescriptionCreatorParamsV1, - _event: ::Event, - _data: &(), - _conn: &Connection, - _qh: &QueueHandle, - ) { - // The params creator has no events (it is destroyed by the `create` request) + #[test] + fn detects_hdr_transfer_functions() { + assert!(HdrMetadata::is_hdr_colorimetry("0:0:14:7")); + assert!(HdrMetadata::is_hdr_colorimetry("0:0:15:7")); + assert!(!HdrMetadata::is_hdr_colorimetry("0:0:1:1")); + assert!(!HdrMetadata::is_hdr_colorimetry("invalid")); } -} -impl Dispatch for super::subsurface_manager::State { - fn event( - _state: &mut Self, - _proxy: &WpImageDescriptionV1, - event: wp_image_description_v1::Event, - data: &DescriptionRole, - _conn: &Connection, - _qh: &QueueHandle, - ) { - match event { - wp_image_description_v1::Event::Failed { msg, cause } => { - log::error!( - "[color-mgmt] Image description {:?} FAILED: cause={cause:?} msg={msg}", - data - ); - } - _ => { - // Ready events (ready / ready2) — logged for diagnostics - log::info!("[color-mgmt] Image description {:?} event: {event:?}", data); - } - } + #[test] + fn distinguishes_hdr_capable_formats() { + assert!(HdrMetadata::is_hdr_capable_format("P010_10LE")); + assert!(HdrMetadata::is_hdr_capable_format("DMA_DRM")); + assert!(!HdrMetadata::is_hdr_capable_format("BGRA")); + assert!(!HdrMetadata::is_hdr_capable_format("NV12")); } } diff --git a/subwave_wayland/src/pipeline.rs b/subwave_wayland/src/pipeline.rs index 40ccc0d..a23c3ea 100644 --- a/subwave_wayland/src/pipeline.rs +++ b/subwave_wayland/src/pipeline.rs @@ -117,6 +117,10 @@ fn pgs_display_set_event( } } +fn waylandsink_supports_native_color_management(version: (u32, u32, u32, u32)) -> bool { + version.0 > 1 || (version.0 == 1 && version.1 >= 28) +} + pub struct SubsurfacePipeline { speed: f64, pub pipeline: Arc, @@ -201,17 +205,28 @@ impl SubsurfacePipeline { })?; if vapostproc.has_property("hdr-tone-mapping") { - if compositor_has_cm { - // Compositor supports color management — let HDR pixels pass - // through to waylandsink untouched. The compositor will do - // the tone-mapping using the image description we set on the - // surface via wp-color-management-v1. + let gst_version = gst::version(); + let waylandsink_manages_color = + waylandsink_supports_native_color_management(gst_version); + let native_hdr = compositor_has_cm && waylandsink_manages_color; + + if native_hdr { + // GStreamer 1.28+ tags the nested surface that carries the video + // buffer. The transparent Subwave host remains untagged/sRGB. vapostproc.set_property("hdr-tone-mapping", false); - log::info!("[pipeline] vapostproc hdr-tone-mapping DISABLED (compositor has CM)"); + log::info!( + "[pipeline] vapostproc hdr-tone-mapping DISABLED (waylandsink owns HDR surface metadata)" + ); } else { - // No compositor CM — vapostproc must tone-map HDR→SDR itself. + // Never put stream metadata on the transparent host as a fallback. + // Tone-map to SDR when either side cannot manage the real surface. vapostproc.set_property("hdr-tone-mapping", true); - log::info!("[pipeline] vapostproc hdr-tone-mapping ENABLED (no compositor CM)"); + log::info!( + "[pipeline] vapostproc hdr-tone-mapping ENABLED (compositor_cm={compositor_has_cm}, gstreamer={}.{}.{})", + gst_version.0, + gst_version.1, + gst_version.2, + ); } } @@ -981,7 +996,9 @@ impl Drop for SubsurfacePipeline { #[cfg(test)] mod tests { - use super::{pgs_display_set_event, WaylandSubtitlePayload}; + use super::{ + pgs_display_set_event, waylandsink_supports_native_color_management, WaylandSubtitlePayload, + }; use crate::{ pgs_decoder::{PgsDisplaySet, PgsFrame}, subtitle_scheduler::{DecodedSubtitleEvent, SubtitleAction, SubtitleScheduler}, @@ -994,6 +1011,13 @@ mod tests { Duration::from_millis(value) } + #[test] + fn native_wayland_color_management_requires_gstreamer_1_28() { + assert!(!waylandsink_supports_native_color_management((1, 27, 9, 0))); + assert!(waylandsink_supports_native_color_management((1, 28, 0, 0))); + assert!(waylandsink_supports_native_color_management((2, 0, 0, 0))); + } + #[test] fn non_empty_pgs_display_sets_have_no_scheduled_end() { let frames = vec![PgsFrame { diff --git a/subwave_wayland/src/subsurface_manager.rs b/subwave_wayland/src/subsurface_manager.rs index 3e46ee9..ecd8751 100644 --- a/subwave_wayland/src/subsurface_manager.rs +++ b/subwave_wayland/src/subsurface_manager.rs @@ -54,9 +54,6 @@ pub struct WaylandSubsurfaceManager { /// Subtitle surface subtitle_surface: WlSurface, - /// Viewport for controlling surface size independently of buffer size - video_viewport: Option, - /// Viewport for background surface background_viewport: Option, @@ -91,9 +88,10 @@ pub struct WaylandSubsurfaceManager { subtitle_file: Mutex>, subtitle_pool_dims: Mutex>, // (w,h,stride) - /// Color management (wp-color-management-v1) for per-surface HDR/SDR tagging. - /// When available, the video surface is tagged BT.2020+PQ and the subtitle - /// surface is tagged sRGB, so the compositor can tone-map each independently. + /// Compositor color-management capability handle. + /// + /// The transparent host and subtitle surfaces deliberately remain untagged; + /// GStreamer's `waylandsink` tags its nested video-content surface directly. color_manager: Mutex>, } @@ -114,17 +112,12 @@ impl std::fmt::Debug for WaylandSubsurfaceManager { /// State for Wayland event dispatching pub(crate) struct State { pub(crate) globals: Vec<(u32, String, u32)>, // (name, interface, version) - /// Color management feature flags (populated by wp_color_manager_v1 events) - pub(crate) cm_supports_set_luminances: bool, - pub(crate) cm_supports_set_mastering_primaries: bool, } impl State { pub(crate) fn new() -> Self { Self { globals: Vec::new(), - cm_supports_set_luminances: false, - cm_supports_set_mastering_primaries: false, } } } @@ -133,8 +126,6 @@ const VIDEO_ANCHOR_WIDTH: i32 = 1; const VIDEO_ANCHOR_HEIGHT: i32 = 1; const VIDEO_ANCHOR_STRIDE: i32 = VIDEO_ANCHOR_WIDTH * 4; const VIDEO_ANCHOR_SIZE: usize = (VIDEO_ANCHOR_STRIDE * VIDEO_ANCHOR_HEIGHT) as usize; -const INITIAL_VIDEO_WIDTH: i32 = 1280; -const INITIAL_VIDEO_HEIGHT: i32 = 720; fn create_transparent_video_anchor( shm: &WlShm, @@ -244,24 +235,14 @@ impl WaylandSubsurfaceManager { }; // ── Color management (optional) ────────────────────────────── - // We only bind the global here. Actual image descriptions are - // created lazily when the video caps indicate HDR content (see - // `notify_video_colorimetry`). The subtitle surface is - // deliberately left untagged so the compositor defaults to sRGB. - let mut color_manager = ColorManager::bind_if_available(&state.globals, ®istry, &qh); - if let Some(ref mut cm) = color_manager { - // Roundtrip to receive the capability events (supported TFs, features, etc.) + // Bind only to detect compositor support. The surface passed to + // `waylandsink` is a transparent mapping ancestor, not video content, + // so it must remain untagged. GStreamer tags its nested video surface. + let color_manager = ColorManager::bind_if_available(&state.globals, ®istry, &qh); + if color_manager.is_some() { event_queue.roundtrip(&mut state).map_err(|e| { - Error::Wayland(format!("Failed to roundtrip for color-mgmt: {}", e)) + Error::Wayland(format!("Failed to roundtrip for color-mgmt: {e}")) })?; - // Transfer the feature flags that the Dispatch handler stored in State - cm.supports_set_luminances = state.cm_supports_set_luminances; - cm.supports_set_mastering_primaries = state.cm_supports_set_mastering_primaries; - log::info!( - "[color-mgmt] Feature flags: luminances={}, mastering_primaries={}", - cm.supports_set_luminances, - cm.supports_set_mastering_primaries, - ); } // Create a proxy for the parent surface without taking ownership @@ -326,14 +307,6 @@ impl WaylandSubsurfaceManager { None }; - let video_viewport = if let Some(ref viewporter) = viewporter { - let viewport = viewporter.get_viewport(&video_surface, &qh, ()); - log::debug!("Created viewport for video surface"); - Some(viewport) - } else { - None - }; - let subtitle_viewport = if let Some(ref viewporter) = viewporter { let viewport = viewporter.get_viewport(&subtitle_surface, &qh, ()); log::debug!("Created viewport for subtitle surface"); @@ -386,14 +359,14 @@ impl WaylandSubsurfaceManager { // subsurface ancestor is mapped, so retain a transparent buffer here. let (video_anchor_buffer, video_anchor_pool) = create_transparent_video_anchor(&shm, &qh)?; - if let Some(ref viewport) = video_viewport { - // Scale the transparent anchor without setting a source rectangle. - // GStreamer owns source cropping on its nested video surface. - viewport.set_destination(INITIAL_VIDEO_WIDTH, INITIAL_VIDEO_HEIGHT); - } video_surface.attach(Some(&video_anchor_buffer), 0, 0); video_surface.damage_buffer(0, 0, VIDEO_ANCHOR_WIDTH, VIDEO_ANCHOR_HEIGHT); - log::debug!("Mapped GStreamer video host with transparent 1x1 anchor buffer"); + log::debug!( + "Mapped GStreamer video host with an untagged transparent 1x1 anchor buffer" + ); + // Keep the anchor at 1x1: subsurfaces are not clipped to their parent, + // so GStreamer's nested surfaces can cover the full render rectangle + // without turning this inert mapping buffer into a full-screen layer. // Commit children so the compositor can pick up the roles, ordering, // and video host mapping on the next parent commit. @@ -423,7 +396,6 @@ impl WaylandSubsurfaceManager { background_surface, subtitle_subsurface, subtitle_surface, - video_viewport, background_viewport, subtitle_viewport, position: Arc::new(Mutex::new((0, 0))), @@ -469,8 +441,6 @@ impl WaylandSubsurfaceManager { let position_weak = Arc::downgrade(&subsurface_manager.position); let size_weak = Arc::downgrade(&subsurface_manager.size); let subsurface_clone = subsurface_manager.video_subsurface.clone(); - let video_surface_clone = subsurface_manager.video_surface.clone(); - let viewport_clone = subsurface_manager.video_viewport.clone(); let background_subsurface_clone = subsurface_manager.background_subsurface.clone(); let background_surface_clone = subsurface_manager.background_surface.clone(); let background_viewport_clone = subsurface_manager.background_viewport.clone(); @@ -525,21 +495,8 @@ impl WaylandSubsurfaceManager { log::error!("No subtitle viewport in pre-commit hook"); } - // Only scale the transparent host buffer. GStreamer manages the - // source rectangle and video scaling on its nested surfaces. - if let Some(ref viewport) = viewport_clone { - viewport.set_destination(dest_w, dest_h); - video_surface_clone.damage_buffer( - 0, - 0, - VIDEO_ANCHOR_WIDTH, - VIDEO_ANCHOR_HEIGHT, - ); - video_surface_clone.commit(); - log::debug!("Video host viewport updated to {}x{}", dest_w, dest_h); - } else { - log::error!("No video viewport in pre-commit hook"); - } + // The transparent video host remains an immutable 1x1 mapping + // anchor. GStreamer sizes and tags its nested content surfaces. } }); @@ -754,10 +711,11 @@ impl WaylandSubsurfaceManager { log::debug!("Buffer offset changed to {}x{}, surface committed", x, y,); } - /// Set the destination size of the intermediary video host surface. + /// Update the logical video area without scaling the intermediary host. /// - /// `source` is retained for API compatibility but intentionally ignored: - /// GStreamer's nested Wayland surfaces own video cropping and scaling. + /// The source rectangle is retained for API compatibility but ignored. + /// GStreamer owns cropping and sizing on its nested content surfaces, while + /// the host must remain an inert 1x1 mapping anchor. pub fn set_video_viewport( &self, source: Option<(i32, i32, i32, i32)>, @@ -767,22 +725,12 @@ impl WaylandSubsurfaceManager { log::debug!("Ignoring host viewport source; GStreamer owns video source cropping"); } - let Some((width, height)) = dest else { - return; - }; - if width <= 0 || height <= 0 { - log::warn!("Ignoring non-positive video viewport {width}x{height}"); - return; - } - - if let Some(ref viewport) = self.video_viewport { - viewport.set_destination(width, height); - self.video_surface - .damage_buffer(0, 0, VIDEO_ANCHOR_WIDTH, VIDEO_ANCHOR_HEIGHT); - self.video_surface.commit(); - log::debug!("Video host viewport destination set to {width}x{height}"); - } else { - log::error!("No video viewport available"); + if let Some((width, height)) = dest { + if width <= 0 || height <= 0 { + log::warn!("Ignoring non-positive video viewport {width}x{height}"); + } else { + self.set_size(width, height); + } } } @@ -821,61 +769,24 @@ impl WaylandSubsurfaceManager { self.color_manager.lock().is_some() } - /// Returns `true` if the video surface is currently tagged with an HDR - /// image description. When true, the compositor tone-maps the subtitle - /// surface (sRGB by default) independently of the HDR video surface. + /// Returns whether Subwave tagged the intermediary host as HDR. + /// + /// This is always false: the host carries only a transparent mapping pixel. + /// GStreamer's nested video surface owns the actual HDR image description. pub fn is_video_tagged_hdr(&self) -> bool { - self.color_manager - .lock() - .as_ref() - .is_some_and(|cm| cm.is_video_tagged_hdr()) + false } - /// Notify the subsurface manager of the current video stream's colorimetry. - /// - /// When the video is HDR (PQ/HLG transfer) and the compositor supports - /// color management, this tags the video surface with a BT.2020+PQ image - /// description. When the video is SDR, any HDR tag is removed so the - /// compositor treats the video surface as sRGB. + /// Observe video colorimetry without applying it to the mapping anchor. /// - /// `colorimetry` is the GStreamer colorimetry string (e.g. `"0:0:14:7"`). - /// `metadata` contains optional mastering display / content light level info. - /// - /// This is safe to call on every caps change — it no-ops if the colorimetry - /// hasn't changed. + /// Retained for API compatibility. GStreamer 1.28+ applies this metadata to + /// the nested surface that actually carries video pixels. pub fn notify_video_colorimetry( &self, colorimetry: &str, - metadata: Option<&crate::color_management::HdrMetadata>, + _metadata: Option<&crate::color_management::HdrMetadata>, ) { - let mut cm_lock = self.color_manager.lock(); - let Some(ref mut cm) = *cm_lock else { - return; // no color management support - }; - - let is_hdr = crate::color_management::HdrMetadata::is_hdr_colorimetry(colorimetry); - - if is_hdr { - // Take a single lock on the event queue for both the handle and the roundtrip - let mut eq = self.event_queue.lock(); - let qh = eq.handle(); - match cm.tag_video_hdr(colorimetry, metadata, &self.video_surface, &qh, &mut eq) { - Ok(()) => { - drop(eq); - if let Err(e) = self.flush() { - log::warn!("[color-mgmt] Flush after HDR tag failed: {e}"); - } - } - Err(e) => { - log::warn!("[color-mgmt] Failed to tag video HDR (non-fatal): {e}"); - } - } - } else { - cm.untag_video(&self.video_surface); - if let Err(e) = self.flush() { - log::warn!("[color-mgmt] Flush after untag failed: {e}"); - } - } + log::debug!("[color-mgmt] Delegating {colorimetry} to waylandsink; host remains untagged"); } /// Flush any pending Wayland events @@ -887,12 +798,11 @@ impl WaylandSubsurfaceManager { Ok(()) } - /// Force a full surface damage and commit (useful for debugging visibility) + /// Force the mutable overlay surfaces to redraw. + /// + /// The video host is intentionally omitted because its transparent 1x1 + /// mapping anchor is immutable after construction. pub fn force_damage_and_commit(&self) { - // Damage the entire surface to force a redraw - self.video_surface.damage(0, 0, i32::MAX, i32::MAX); - self.video_surface.damage_buffer(0, 0, i32::MAX, i32::MAX); - self.video_surface.commit(); self.background_surface.damage(0, 0, i32::MAX, i32::MAX); self.background_surface .damage_buffer(0, 0, i32::MAX, i32::MAX); @@ -901,7 +811,7 @@ impl WaylandSubsurfaceManager { self.subtitle_surface .damage_buffer(0, 0, i32::MAX, i32::MAX); self.subtitle_surface.commit(); - eprintln!("Forced full damage and commit on video surface"); + log::debug!("Forced full damage and commit on overlay surfaces"); } /// Create or update the black background buffer @@ -1066,10 +976,10 @@ impl Drop for WaylandSubsurfaceManager { } // Destroy viewports if they exist - if let Some(ref viewport) = self.video_viewport { + if let Some(ref viewport) = self.background_viewport { viewport.destroy(); } - if let Some(ref viewport) = self.background_viewport { + if let Some(ref viewport) = self.subtitle_viewport { viewport.destroy(); } diff --git a/subwave_wayland/src/video.rs b/subwave_wayland/src/video.rs index 5c21029..9e1cf6a 100644 --- a/subwave_wayland/src/video.rs +++ b/subwave_wayland/src/video.rs @@ -697,10 +697,11 @@ impl SubsurfaceVideo { } MessageView::StateChanged(_state_changed) => {} MessageView::AsyncDone(_) => { - // ── Detect HDR and update color management ── + // ── Observe negotiated video colorimetry ── // After a state transition completes (PAUSED→PLAYING, - // or after a seek) the caps are settled. Query vsink - // for colorimetry and notify the subsurface manager. + // or after a seek) the caps are settled. This remains + // diagnostic only: waylandsink tags its nested content + // surface, while Subwave's mapping anchor stays untagged. if let Some(vsink) = gst_pipeline.by_name("vsink") { if let Some(pad) = vsink.static_pad("sink") { if let Some(caps) = pad.current_caps() { @@ -744,10 +745,9 @@ impl SubsurfaceVideo { } } - // Only tag as HDR if the pixel format can actually - // carry HDR data. If vapostproc already tone-mapped - // to BGRx/8-bit, the pixels are SDR even if - // colorimetry metadata says PQ. + // Track whether negotiated pixels still carry HDR. + // `notify_video_colorimetry` deliberately never applies + // this child metadata to Subwave's mapping anchor. let format_ok = crate::color_management::HdrMetadata::is_hdr_capable_format(&pixel_format); let tx_cm = tx.clone();