diff --git a/src/sinker/mixed/planar_8bit.rs b/src/sinker/mixed/planar_8bit.rs index ba9da76..e26ef8d 100644 --- a/src/sinker/mixed/planar_8bit.rs +++ b/src/sinker/mixed/planar_8bit.rs @@ -33,12 +33,12 @@ impl<'a> MixedSinker<'a, Yuv420p> { /// /// ```compile_fail /// // Attaching RGBA to a sink that doesn't write it is rejected - /// // at compile time. Yuv440p (4:4:0 planar) has not yet been - /// // wired for RGBA; once that lands the negative example here - /// // moves to the next not‑yet‑wired format. - /// use colconv::{sinker::MixedSinker, yuv::Yuv440p}; + /// // at compile time. Yuv420p10 (10‑bit 4:2:0 planar) has not yet + /// // been wired for RGBA — Tranche 5 covers it; once that lands the + /// // negative example here moves to the next not‑yet‑wired format. + /// use colconv::{sinker::MixedSinker, yuv::Yuv420p10}; /// let mut buf = vec![0u8; 16 * 8 * 4]; - /// let _ = MixedSinker::::new(16, 8).with_rgba(&mut buf); + /// let _ = MixedSinker::::new(16, 8).with_rgba(&mut buf); /// ``` #[cfg_attr(not(tarpaulin), inline(always))] pub fn with_rgba(mut self, buf: &'a mut [u8]) -> Result { @@ -689,7 +689,39 @@ impl PixelSink for MixedSinker<'_, Yuv444p> { // // 4:4:0 planar 8‑bit — full-width chroma, half-height. Per-row math // matches 4:4:4 (full-width U / V); only the walker reads chroma row -// `r / 2`. Reuses `yuv_444_to_rgb_row` verbatim. +// `r / 2`. Reuses `yuv_444_to_rgb_row` and `yuv_444_to_rgba_row` +// verbatim. + +impl<'a> MixedSinker<'a, Yuv440p> { + /// Attaches a packed 32‑bit RGBA output buffer. + /// + /// See [`MixedSinker::::with_rgba`] for the rationale and + /// constraints. Yuv440p has no alpha plane, so every alpha byte is + /// filled with `0xFF` (opaque). + /// + /// Returns `Err(RgbaBufferTooShort)` if + /// `buf.len() < width × height × 4`, or `Err(GeometryOverflow)` on + /// 32‑bit targets when the product overflows. + #[cfg_attr(not(tarpaulin), inline(always))] + pub fn with_rgba(mut self, buf: &'a mut [u8]) -> Result { + self.set_rgba(buf)?; + Ok(self) + } + + /// In-place variant of [`with_rgba`](Self::with_rgba). + #[cfg_attr(not(tarpaulin), inline(always))] + pub fn set_rgba(&mut self, buf: &'a mut [u8]) -> Result<&mut Self, MixedSinkerError> { + let expected = self.frame_bytes(4)?; + if buf.len() < expected { + return Err(MixedSinkerError::RgbaBufferTooShort { + expected, + actual: buf.len(), + }); + } + self.rgba = Some(buf); + Ok(self) + } +} impl Yuv440pSink for MixedSinker<'_, Yuv440p> {} @@ -740,6 +772,7 @@ impl PixelSink for MixedSinker<'_, Yuv440p> { let Self { rgb, + rgba, luma, hsv, rgb_scratch, @@ -753,9 +786,39 @@ impl PixelSink for MixedSinker<'_, Yuv440p> { luma[one_plane_start..one_plane_end].copy_from_slice(&row.y()[..w]); } + // Strategy A output mode resolution — see Yuv420p impl above. + // Reuses the Yuv444p RGBA dispatcher since 4:4:0's per-row math + // is identical (full-width chroma). let want_rgb = rgb.is_some(); + let want_rgba = rgba.is_some(); let want_hsv = hsv.is_some(); - if !want_rgb && !want_hsv { + let need_rgb_kernel = want_rgb || want_hsv; + + if want_rgba && !need_rgb_kernel { + let rgba_buf = rgba.as_deref_mut().unwrap(); + let rgba_plane_end = + one_plane_end + .checked_mul(4) + .ok_or(MixedSinkerError::GeometryOverflow { + width: w, + height: h, + channels: 4, + })?; + let rgba_plane_start = one_plane_start * 4; + yuv_444_to_rgba_row( + row.y(), + row.u(), + row.v(), + &mut rgba_buf[rgba_plane_start..rgba_plane_end], + w, + row.matrix(), + row.full_range(), + use_simd, + ); + return Ok(()); + } + + if !need_rgb_kernel { return Ok(()); } @@ -806,6 +869,20 @@ impl PixelSink for MixedSinker<'_, Yuv440p> { use_simd, ); } + + if let Some(buf) = rgba.as_deref_mut() { + let rgba_plane_end = + one_plane_end + .checked_mul(4) + .ok_or(MixedSinkerError::GeometryOverflow { + width: w, + height: h, + channels: 4, + })?; + let rgba_plane_start = one_plane_start * 4; + expand_rgb_to_rgba_row(rgb_row, &mut buf[rgba_plane_start..rgba_plane_end], w); + } + Ok(()) } } diff --git a/src/sinker/mixed/tests.rs b/src/sinker/mixed/tests.rs index bee03c6..d17849d 100644 --- a/src/sinker/mixed/tests.rs +++ b/src/sinker/mixed/tests.rs @@ -2493,8 +2493,8 @@ fn nv42_rgba_simd_matches_scalar_with_random_yuv() { } // Cross-format Strategy A invariant: when both RGB+RGBA are -// attached, all 8 wired families derive RGBA from the RGB row via -// expand_rgb_to_rgba_row. This test runs all 8 process methods with +// attached, all 9 wired families derive RGBA from the RGB row via +// expand_rgb_to_rgba_row. This test runs all 9 process methods with // the same gray input and asserts every RGBA sample equals the RGB // sample with alpha = 0xFF — proving the fan-out shape never // diverges from the kernel output. @@ -2637,6 +2637,20 @@ fn strategy_a_rgb_and_rgba_byte_identical_for_all_wired_families() { nv42_to(&src, true, ColorMatrix::Bt601, &mut sink).unwrap(); assert_match(&rgb, &rgba, "Nv42"); } + + { + let (yp, up, vp) = solid_yuv440p_frame(w, h, 200, 128, 128); + let src = Yuv440pFrame::new(&yp, &up, &vp, w, h, w, w, w); + let mut rgb = std::vec![0u8; ws * hs * 3]; + let mut rgba = std::vec![0u8; ws * hs * 4]; + let mut sink = MixedSinker::::new(ws, hs) + .with_rgb(&mut rgb) + .unwrap() + .with_rgba(&mut rgba) + .unwrap(); + yuv440p_to(&src, true, ColorMatrix::Bt601, &mut sink).unwrap(); + assert_match(&rgb, &rgba, "Yuv440p"); + } } // ---- Yuv420p10 -------------------------------------------------------- @@ -4657,6 +4671,128 @@ fn yuv440p_gray_to_gray() { } } +// ---- Yuv440p RGBA (Ship 8 PR 4c) tests -------------------------------- + +#[test] +#[cfg_attr( + miri, + ignore = "SIMD-dispatched row kernels use intrinsics unsupported by Miri" +)] +fn yuv440p_rgba_only_converts_gray_to_gray_with_opaque_alpha() { + let (yp, up, vp) = solid_yuv440p_frame(16, 8, 128, 128, 128); + let src = Yuv440pFrame::new(&yp, &up, &vp, 16, 8, 16, 16, 16); + + let mut rgba = std::vec![0u8; 16 * 8 * 4]; + let mut sink = MixedSinker::::new(16, 8) + .with_rgba(&mut rgba) + .unwrap(); + yuv440p_to(&src, true, ColorMatrix::Bt601, &mut sink).unwrap(); + + for px in rgba.chunks(4) { + assert!(px[0].abs_diff(128) <= 1, "R"); + assert_eq!(px[0], px[1], "RGB monochromatic"); + assert_eq!(px[1], px[2], "RGB monochromatic"); + assert_eq!(px[3], 0xFF, "alpha must default to opaque"); + } +} + +#[test] +#[cfg_attr( + miri, + ignore = "SIMD-dispatched row kernels use intrinsics unsupported by Miri" +)] +fn yuv440p_with_rgb_and_with_rgba_produce_byte_identical_rgb_bytes() { + let w = 32u32; + let h = 16u32; + let ws = w as usize; + let hs = h as usize; + let (yp, up, vp) = solid_yuv440p_frame(w, h, 180, 60, 200); + let src = Yuv440pFrame::new(&yp, &up, &vp, w, h, w, w, w); + + let mut rgb = std::vec![0u8; ws * hs * 3]; + let mut rgba = std::vec![0u8; ws * hs * 4]; + let mut sink = MixedSinker::::new(ws, hs) + .with_rgb(&mut rgb) + .unwrap() + .with_rgba(&mut rgba) + .unwrap(); + yuv440p_to(&src, true, ColorMatrix::Bt601, &mut sink).unwrap(); + + for i in 0..(ws * hs) { + assert_eq!(rgba[i * 4], rgb[i * 3], "R differs at pixel {i}"); + assert_eq!(rgba[i * 4 + 1], rgb[i * 3 + 1], "G differs at pixel {i}"); + assert_eq!(rgba[i * 4 + 2], rgb[i * 3 + 2], "B differs at pixel {i}"); + assert_eq!(rgba[i * 4 + 3], 0xFF, "A not opaque at pixel {i}"); + } +} + +#[test] +fn yuv440p_rgba_buffer_too_short_returns_err() { + let mut rgba_short = std::vec![0u8; 16 * 8 * 4 - 1]; + let result = MixedSinker::::new(16, 8).with_rgba(&mut rgba_short); + let Err(err) = result else { + panic!("expected RgbaBufferTooShort error"); + }; + assert!(matches!( + err, + MixedSinkerError::RgbaBufferTooShort { + expected: 512, + actual: 511, + } + )); +} + +#[test] +#[cfg_attr( + miri, + ignore = "SIMD-dispatched row kernels use intrinsics unsupported by Miri" +)] +fn yuv440p_rgba_simd_matches_scalar_with_random_yuv() { + // Width 1922 forces both the SIMD main loop AND scalar tail across + // every backend block size (16/32/64). 4:4:0 chroma is full-width + // but half-height, so chroma plane is `w * h/2`. + let w = 1922usize; + let h = 4usize; + let ch = h / 2; + let mut yp = std::vec![0u8; w * h]; + let mut up = std::vec![0u8; w * ch]; + let mut vp = std::vec![0u8; w * ch]; + pseudo_random_u8(&mut yp, 0xC001_C0DE); + pseudo_random_u8(&mut up, 0xCAFE_F00D); + pseudo_random_u8(&mut vp, 0xDEAD_BEEF); + let src = Yuv440pFrame::new( + &yp, &up, &vp, w as u32, h as u32, w as u32, w as u32, w as u32, + ); + + for &matrix in &[ + ColorMatrix::Bt601, + ColorMatrix::Bt709, + ColorMatrix::Bt2020Ncl, + ColorMatrix::YCgCo, + ] { + for &full_range in &[true, false] { + let mut rgba_simd = std::vec![0u8; w * h * 4]; + let mut rgba_scalar = std::vec![0u8; w * h * 4]; + + let mut s_simd = MixedSinker::::new(w, h) + .with_rgba(&mut rgba_simd) + .unwrap(); + yuv440p_to(&src, full_range, matrix, &mut s_simd).unwrap(); + + let mut s_scalar = MixedSinker::::new(w, h) + .with_rgba(&mut rgba_scalar) + .unwrap(); + s_scalar.set_simd(false); + yuv440p_to(&src, full_range, matrix, &mut s_scalar).unwrap(); + + assert_eq!( + rgba_simd, rgba_scalar, + "Yuv440p RGBA SIMD ≠ scalar (matrix={matrix:?}, full_range={full_range})" + ); + } + } +} + #[test] #[cfg_attr( miri,