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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 84 additions & 7 deletions src/sinker/mixed/planar_8bit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Yuv440p>::new(16, 8).with_rgba(&mut buf);
/// let _ = MixedSinker::<Yuv420p10>::new(16, 8).with_rgba(&mut buf);
/// ```
#[cfg_attr(not(tarpaulin), inline(always))]
pub fn with_rgba(mut self, buf: &'a mut [u8]) -> Result<Self, MixedSinkerError> {
Expand Down Expand Up @@ -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::<Yuv420p>::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, MixedSinkerError> {
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> {}

Expand Down Expand Up @@ -740,6 +772,7 @@ impl PixelSink for MixedSinker<'_, Yuv440p> {

let Self {
rgb,
rgba,
luma,
hsv,
rgb_scratch,
Expand All @@ -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(());
}

Expand Down Expand Up @@ -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(())
}
}
140 changes: 138 additions & 2 deletions src/sinker/mixed/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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::<Yuv440p>::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 --------------------------------------------------------
Expand Down Expand Up @@ -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::<Yuv440p>::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::<Yuv440p>::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::<Yuv440p>::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::<Yuv440p>::new(w, h)
.with_rgba(&mut rgba_simd)
.unwrap();
yuv440p_to(&src, full_range, matrix, &mut s_simd).unwrap();

let mut s_scalar = MixedSinker::<Yuv440p>::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,
Expand Down
Loading