use alloc::borrow::Cow;
use alloc::vec;
use alloc::vec::Vec;
use crate::convert::ConvertPlan;
use crate::converter::RowConverter;
use crate::negotiate::{ConvertIntent, best_match};
use crate::policy::{AlphaPolicy, ConvertOptions};
use crate::{
AlphaMode, ChannelLayout, ChannelType, ColorModel, ConvertError, PixelBuffer, PixelDescriptor,
PixelSliceMut,
};
use whereat::{At, ResultAtExt};
fn assert_not_cmyk(desc: &PixelDescriptor) {
assert!(
desc.color_model() != ColorModel::Cmyk,
"CMYK pixel data cannot be processed by zenpixels-convert. \
Use a CMS (e.g., moxcms) with an ICC profile for CMYK↔RGB conversion."
);
}
#[derive(Clone, Debug)]
pub struct Adapted<'a> {
pub data: Cow<'a, [u8]>,
pub descriptor: PixelDescriptor,
pub width: u32,
pub rows: u32,
}
#[track_caller]
pub fn adapt_for_encode<'a>(
data: &'a [u8],
descriptor: PixelDescriptor,
width: u32,
rows: u32,
stride: usize,
supported: &[PixelDescriptor],
) -> Result<Adapted<'a>, At<ConvertError>> {
adapt_for_encode_with_intent(
data,
descriptor,
width,
rows,
stride,
supported,
ConvertIntent::Fastest,
)
}
#[track_caller]
pub fn adapt_for_encode_with_intent<'a>(
data: &'a [u8],
descriptor: PixelDescriptor,
width: u32,
rows: u32,
stride: usize,
supported: &[PixelDescriptor],
intent: ConvertIntent,
) -> Result<Adapted<'a>, At<ConvertError>> {
assert_not_cmyk(&descriptor);
if supported.is_empty() {
return Err(whereat::at!(ConvertError::EmptyFormatList));
}
if supported.contains(&descriptor) {
return Ok(Adapted {
data: contiguous_from_strided(data, width, rows, stride, descriptor.bytes_per_pixel()),
descriptor,
width,
rows,
});
}
for &target in supported {
if descriptor.channel_type() == target.channel_type()
&& descriptor.layout() == target.layout()
&& descriptor.alpha() == target.alpha()
&& descriptor.primaries == target.primaries
&& descriptor.signal_range == target.signal_range
{
return Ok(Adapted {
data: contiguous_from_strided(
data,
width,
rows,
stride,
descriptor.bytes_per_pixel(),
),
descriptor: target,
width,
rows,
});
}
}
let target = best_match(descriptor, supported, intent)
.ok_or_else(|| whereat::at!(ConvertError::EmptyFormatList))?;
let mut converter = RowConverter::new(descriptor, target).at()?;
let src_bpp = descriptor.bytes_per_pixel();
let dst_bpp = target.bytes_per_pixel();
let dst_stride = (width as usize) * dst_bpp;
let mut output = vec![0u8; dst_stride * rows as usize];
for y in 0..rows {
let src_start = y as usize * stride;
let src_end = src_start + (width as usize * src_bpp);
let dst_start = y as usize * dst_stride;
let dst_end = dst_start + dst_stride;
converter.convert_row(
&data[src_start..src_end],
&mut output[dst_start..dst_end],
width,
);
}
Ok(Adapted {
data: Cow::Owned(output),
descriptor: target,
width,
rows,
})
}
#[track_caller]
pub fn convert_buffer(
src: &[u8],
width: u32,
rows: u32,
from: PixelDescriptor,
to: PixelDescriptor,
) -> Result<Vec<u8>, At<ConvertError>> {
assert_not_cmyk(&from);
assert_not_cmyk(&to);
if from == to {
return Ok(src.to_vec());
}
let mut converter = RowConverter::new(from, to).at()?;
let src_bpp = from.bytes_per_pixel();
let dst_bpp = to.bytes_per_pixel();
let src_stride = (width as usize) * src_bpp;
let dst_stride = (width as usize) * dst_bpp;
let mut output = vec![0u8; dst_stride * rows as usize];
for y in 0..rows {
let src_start = y as usize * src_stride;
let src_end = src_start + src_stride;
let dst_start = y as usize * dst_stride;
let dst_end = dst_start + dst_stride;
converter.convert_row(
&src[src_start..src_end],
&mut output[dst_start..dst_end],
width,
);
}
Ok(output)
}
#[track_caller]
pub(crate) fn convert_buffer_with_anchor(
src: &[u8],
width: u32,
rows: u32,
src_stride: usize,
from: PixelDescriptor,
to: PixelDescriptor,
anchor: zenpixels::hdr::DiffuseWhite,
) -> Result<PixelBuffer, At<ConvertError>> {
let mut buf = PixelBuffer::try_new(width, rows, to)
.map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
let dst_stride = buf.stride();
{
let mut slice = buf.as_slice_mut();
convert_into_with_anchor(
src,
width,
rows,
src_stride,
from,
to,
anchor,
slice.as_strided_bytes_mut(),
dst_stride,
)?;
}
Ok(buf)
}
#[track_caller]
#[allow(clippy::too_many_arguments)] pub(crate) fn convert_into_with_anchor(
src: &[u8],
width: u32,
rows: u32,
src_stride: usize,
from: PixelDescriptor,
to: PixelDescriptor,
anchor: zenpixels::hdr::DiffuseWhite,
dst: &mut [u8],
dst_stride: usize,
) -> Result<(), At<ConvertError>> {
assert_not_cmyk(&from);
assert_not_cmyk(&to);
let dst_row = (width as usize) * to.bytes_per_pixel();
if rows > 0 {
let needed = (rows as usize - 1) * dst_stride + dst_row;
if dst_stride < dst_row || dst.len() < needed {
return Err(whereat::at!(ConvertError::BufferSize {
expected: needed.max(dst_row),
actual: dst.len().min(dst_stride),
}));
}
}
let plan = ConvertPlan::new(from, to).at()?.with_pq_anchor(anchor);
let mut converter = RowConverter::from_plan(plan);
let src_row = (width as usize) * from.bytes_per_pixel();
for y in 0..rows as usize {
let src_start = y * src_stride;
let dst_start = y * dst_stride;
converter.convert_row(
&src[src_start..src_start + src_row],
&mut dst[dst_start..dst_start + dst_row],
width,
);
}
Ok(())
}
pub fn try_adapt_in_place(
buf: &mut PixelBuffer,
target: PixelDescriptor,
) -> Result<(), At<ConvertError>> {
let src = buf.descriptor();
let no_path = || {
Err(whereat::at!(ConvertError::NoPath {
from: src,
to: target
}))
};
if src.format == target.format {
buf.transform_in_place(|px| {
rewrap(px.bytes, px.width, px.rows, px.stride, target, px.color)
});
return Ok(());
}
if src.bytes_per_pixel() == target.bytes_per_pixel() {
let swappable = src.channel_type() == ChannelType::U8
&& target.channel_type() == ChannelType::U8
&& matches!(
(src.layout(), target.layout()),
(ChannelLayout::Rgba, ChannelLayout::Bgra)
| (ChannelLayout::Bgra, ChannelLayout::Rgba)
);
if !swappable {
return no_path();
}
buf.transform_in_place(|px| {
let width = px.width as usize;
let rows = px.rows as usize;
let _ = garb::bytes::rgba_to_bgra_inplace_strided(px.bytes, width, rows, px.stride);
rewrap(px.bytes, px.width, px.rows, px.stride, target, px.color)
});
return Ok(());
}
if !matches!(
src.alpha,
Some(AlphaMode::Undefined) | Some(AlphaMode::Opaque)
) {
return no_path();
}
if src.channel_type() != target.channel_type() {
return no_path();
}
let map: &'static [usize] = match (src.layout(), target.layout()) {
(ChannelLayout::Rgba, ChannelLayout::Rgb) => &[0, 1, 2],
(ChannelLayout::Bgra, ChannelLayout::Rgb) => &[2, 1, 0],
(ChannelLayout::GrayAlpha, ChannelLayout::Gray) => &[0],
_ => return no_path(),
};
let in_bpp = src.bytes_per_pixel();
let out_bpp = target.bytes_per_pixel();
let elem = src.bytes_per_channel();
buf.transform_in_place(|px| drop_lane_impl(px, target, map, in_bpp, out_bpp, elem));
Ok(())
}
fn drop_lane_impl<'a>(
px: zenpixels::InPlacePixels<'a>,
target: PixelDescriptor,
map: &'static [usize],
in_bpp: usize,
out_bpp: usize,
elem: usize,
) -> PixelSliceMut<'a> {
let width = px.width as usize;
let out_stride = px.stride - (px.stride % out_bpp);
for y in 0..px.rows as usize {
let sbase = y * px.stride;
let dbase = y * out_stride;
for x in 0..width {
let s = sbase + x * in_bpp;
let mut tmp = [0u8; 16];
tmp[..in_bpp].copy_from_slice(&px.bytes[s..s + in_bpp]);
let d = dbase + x * out_bpp;
for (k, &c) in map.iter().enumerate() {
px.bytes[d + k * elem..d + (k + 1) * elem]
.copy_from_slice(&tmp[c * elem..(c + 1) * elem]);
}
}
}
rewrap(px.bytes, px.width, px.rows, out_stride, target, px.color)
}
fn rewrap<'a>(
bytes: &'a mut [u8],
width: u32,
rows: u32,
stride: usize,
descriptor: PixelDescriptor,
color: Option<alloc::sync::Arc<zenpixels::ColorContext>>,
) -> PixelSliceMut<'a> {
let out = PixelSliceMut::new(bytes, width, rows, stride, descriptor)
.expect("in-place adaptation geometry is always valid");
match color {
Some(c) => out.with_color_context(c),
None => out,
}
}
#[track_caller]
pub fn adapt_for_encode_explicit<'a>(
data: &'a [u8],
descriptor: PixelDescriptor,
width: u32,
rows: u32,
stride: usize,
supported: &[PixelDescriptor],
options: &ConvertOptions,
) -> Result<Adapted<'a>, At<ConvertError>> {
assert_not_cmyk(&descriptor);
if supported.is_empty() {
return Err(whereat::at!(ConvertError::EmptyFormatList));
}
if supported.contains(&descriptor) {
return Ok(Adapted {
data: contiguous_from_strided(data, width, rows, stride, descriptor.bytes_per_pixel()),
descriptor,
width,
rows,
});
}
for &target in supported {
if descriptor.channel_type() == target.channel_type()
&& descriptor.layout() == target.layout()
&& descriptor.alpha() == target.alpha()
&& descriptor.primaries == target.primaries
&& descriptor.signal_range == target.signal_range
{
return Ok(Adapted {
data: contiguous_from_strided(
data,
width,
rows,
stride,
descriptor.bytes_per_pixel(),
),
descriptor: target,
width,
rows,
});
}
}
let target = best_match(descriptor, supported, ConvertIntent::Fastest)
.ok_or_else(|| whereat::at!(ConvertError::EmptyFormatList))?;
let plan = ConvertPlan::new_explicit(descriptor, target, options).at()?;
let drops_alpha = descriptor.alpha().is_some() && target.alpha().is_none();
if drops_alpha && options.alpha_policy == AlphaPolicy::DiscardIfOpaque {
let src_bpp = descriptor.bytes_per_pixel();
if !is_fully_opaque(data, width, rows, stride, src_bpp, &descriptor) {
return Err(whereat::at!(ConvertError::AlphaNotOpaque));
}
}
let mut converter = RowConverter::from_plan(plan);
let src_bpp = descriptor.bytes_per_pixel();
let dst_bpp = target.bytes_per_pixel();
let dst_stride = (width as usize) * dst_bpp;
let mut output = vec![0u8; dst_stride * rows as usize];
for y in 0..rows {
let src_start = y as usize * stride;
let src_end = src_start + (width as usize * src_bpp);
let dst_start = y as usize * dst_stride;
let dst_end = dst_start + dst_stride;
converter.convert_row(
&data[src_start..src_end],
&mut output[dst_start..dst_end],
width,
);
}
Ok(Adapted {
data: Cow::Owned(output),
descriptor: target,
width,
rows,
})
}
fn is_fully_opaque(
data: &[u8],
width: u32,
rows: u32,
stride: usize,
bpp: usize,
desc: &PixelDescriptor,
) -> bool {
if desc.alpha().is_none() {
return true;
}
let cs = desc.channel_type().byte_size();
let alpha_offset = (desc.layout().channels() - 1) * cs;
for y in 0..rows {
let row_start = y as usize * stride;
for x in 0..width as usize {
let off = row_start + x * bpp + alpha_offset;
match desc.channel_type() {
crate::ChannelType::U8 => {
if data[off] != 255 {
return false;
}
}
crate::ChannelType::U16 => {
let v = u16::from_ne_bytes([data[off], data[off + 1]]);
if v != 65535 {
return false;
}
}
crate::ChannelType::F32 => {
let v = f32::from_ne_bytes([
data[off],
data[off + 1],
data[off + 2],
data[off + 3],
]);
if v < 1.0 {
return false;
}
}
_ => return false,
}
}
}
true
}
fn contiguous_from_strided<'a>(
data: &'a [u8],
width: u32,
rows: u32,
stride: usize,
bpp: usize,
) -> Cow<'a, [u8]> {
let row_bytes = width as usize * bpp;
if stride == row_bytes {
let total = row_bytes * rows as usize;
Cow::Borrowed(&data[..total])
} else {
let mut packed = Vec::with_capacity(row_bytes * rows as usize);
for y in 0..rows as usize {
let start = y * stride;
packed.extend_from_slice(&data[start..start + row_bytes]);
}
Cow::Owned(packed)
}
}
#[cfg(test)]
mod anchor_tests {
use super::convert_buffer_with_anchor;
use crate::{PixelDescriptor, TransferFunction};
use alloc::vec;
use alloc::vec::Vec;
use zenpixels::hdr::DiffuseWhite;
fn pq_oetf(x: f64) -> f64 {
if x <= 0.0 {
return 0.0;
}
let m1 = 2610.0 / 16384.0;
let m2 = 2523.0 / 4096.0 * 128.0;
let c1 = 3424.0 / 4096.0;
let c2 = 2413.0 / 4096.0 * 32.0;
let c3 = 2392.0 / 4096.0 * 32.0;
let xp = x.powf(m1);
((c1 + c2 * xp) / (1.0 + c3 * xp)).powf(m2)
}
fn gray_rgb_f32(values: &[f32]) -> Vec<u8> {
let mut v = Vec::with_capacity(values.len() * 12);
for &g in values {
for _ in 0..3 {
v.extend_from_slice(&g.to_ne_bytes());
}
}
v
}
fn gray_rgba_f32(pixels: &[(f32, f32)]) -> Vec<u8> {
let mut v = Vec::with_capacity(pixels.len() * 16);
for &(g, a) in pixels {
for _ in 0..3 {
v.extend_from_slice(&g.to_ne_bytes());
}
v.extend_from_slice(&a.to_ne_bytes());
}
v
}
fn packed_stride(width: usize, desc: PixelDescriptor) -> usize {
width * desc.bytes_per_pixel()
}
fn pq16_target() -> (PixelDescriptor, PixelDescriptor) {
let target = PixelDescriptor::RGB16_BT2100_PQ;
let lin = PixelDescriptor::RGBF32_LINEAR.with_primaries(target.primaries);
(lin, target)
}
#[test]
fn anchor_pq16_encode_matches_st2084_oracle() {
let values = [0.001f32, 0.1, 1.0, 2.0, 49.0];
let (lin, target) = pq16_target();
let out = convert_buffer_with_anchor(
&gray_rgb_f32(&values),
values.len() as u32,
1,
packed_stride(values.len(), lin),
lin,
target,
DiffuseWhite::BT2408,
)
.unwrap();
let codes: &[u16] = bytemuck::cast_slice(out.as_slice().as_strided_bytes());
for (i, &v) in values.iter().enumerate() {
let got = i64::from(codes[i * 3]);
let want = (pq_oetf(f64::from(v) * 203.0 / 10_000.0) * 65535.0).round() as i64;
assert!(
(got - want).abs() <= 1,
"@203 at {v}: got {got} want {want}"
);
}
}
#[test]
fn anchor_changes_pq_output_in_kernel() {
let (lin, target) = pq16_target();
let src = gray_rgb_f32(&[1.0]);
let enc = |w: DiffuseWhite| {
let o = convert_buffer_with_anchor(&src, 1, 1, packed_stride(1, lin), lin, target, w)
.unwrap();
let ob = o.as_slice().as_strided_bytes();
i64::from(u16::from_ne_bytes([ob[0], ob[1]]))
};
let c100 = enc(DiffuseWhite::new(100.0));
let c203 = enc(DiffuseWhite::BT2408);
assert_ne!(c100, c203);
let want100 = (pq_oetf(100.0 / 10_000.0) * 65535.0).round() as i64;
assert!(
(c100 - want100).abs() <= 1,
"@100: got {c100} want {want100}"
);
}
#[test]
fn anchor_pq16_decode_divides_and_roundtrips() {
let values = [0.05f32, 0.2, 1.0, 5.0];
let (lin, target) = pq16_target();
let pq = convert_buffer_with_anchor(
&gray_rgb_f32(&values),
values.len() as u32,
1,
packed_stride(values.len(), lin),
lin,
target,
DiffuseWhite::BT2408,
)
.unwrap();
let back = convert_buffer_with_anchor(
pq.as_slice().as_strided_bytes(),
values.len() as u32,
1,
pq.stride(),
target,
lin,
DiffuseWhite::BT2408,
)
.unwrap();
let backf: &[f32] = bytemuck::cast_slice(back.as_slice().as_strided_bytes());
for (i, &v) in values.iter().enumerate() {
let got = backf[i * 3];
let rel = ((f64::from(got) - f64::from(v)) / f64::from(v)).abs();
assert!(rel < 0.02, "roundtrip @203 at {v}: got {got} (rel {rel})");
}
}
#[test]
fn anchor_threads_through_f32_pq_slice_kernel() {
let values = [0.1f32, 1.0, 4.0];
let target = PixelDescriptor::RGB16_BT2100_PQ;
let lin = PixelDescriptor::RGBF32_LINEAR.with_primaries(target.primaries);
let pqf32 = lin.with_transfer(TransferFunction::Pq);
let out = convert_buffer_with_anchor(
&gray_rgb_f32(&values),
values.len() as u32,
1,
packed_stride(values.len(), lin),
lin,
pqf32,
DiffuseWhite::BT2408,
)
.unwrap();
let encoded: &[f32] = bytemuck::cast_slice(out.as_slice().as_strided_bytes());
for (i, &v) in values.iter().enumerate() {
let got = f64::from(encoded[i * 3]);
let want = pq_oetf(f64::from(v) * 203.0 / 10_000.0);
assert!(
(got - want).abs() < 1e-3,
"f32 PQ @203 at {v}: got {got} want {want}"
);
}
}
#[test]
fn no_anchor_default_is_unscaled() {
let (lin, target) = pq16_target();
let out = convert_buffer_with_anchor(
&gray_rgb_f32(&[1.0]),
1,
1,
packed_stride(1, lin),
lin,
target,
DiffuseWhite::new(10_000.0),
)
.unwrap();
let ob = out.as_slice().as_strided_bytes();
assert_eq!(u16::from_ne_bytes([ob[0], ob[1]]), 65535);
}
#[test]
fn anchor_preserves_alpha_through_rgba_pq16() {
let rgb_pq = PixelDescriptor::RGB16_BT2100_PQ;
let src = PixelDescriptor::RGBAF32_LINEAR.with_primaries(rgb_pq.primaries);
let target = PixelDescriptor::RGBA16
.with_transfer(TransferFunction::Pq)
.with_primaries(rgb_pq.primaries);
let pixels = [(1.0f32, 0.5f32), (2.0, 0.25)];
let out = convert_buffer_with_anchor(
&gray_rgba_f32(&pixels),
pixels.len() as u32,
1,
packed_stride(pixels.len(), src),
src,
target,
DiffuseWhite::BT2408,
)
.unwrap();
let codes: &[u16] = bytemuck::cast_slice(out.as_slice().as_strided_bytes());
for (i, &(g, a)) in pixels.iter().enumerate() {
let r = i64::from(codes[i * 4]);
let want_rgb = (pq_oetf(f64::from(g) * 203.0 / 10_000.0) * 65535.0).round() as i64;
assert!(
(r - want_rgb).abs() <= 1,
"rgb @203 at {g}: got {r} want {want_rgb}"
);
let alpha = codes[i * 4 + 3];
let want_a = (f64::from(a) * 65535.0).round() as u16;
assert_eq!(
alpha, want_a,
"alpha must pass through linearly: got {alpha} want {want_a}"
);
}
}
#[test]
fn anchor_honors_source_stride() {
let (lin, target) = pq16_target();
let row_vals = [0.1f32, 1.0, 3.0];
let width = row_vals.len() as u32;
let rows = 2u32;
let row = packed_stride(row_vals.len(), lin);
let stride = row + 2 * 12;
let mut packed = gray_rgb_f32(&row_vals);
packed.extend_from_slice(&gray_rgb_f32(&row_vals));
let want = convert_buffer_with_anchor(
&packed,
width,
rows,
row,
lin,
target,
DiffuseWhite::BT2408,
)
.unwrap();
let mut strided = vec![0u8; stride * rows as usize];
for y in 0..rows as usize {
let s = y * stride;
strided[s..s + row].copy_from_slice(&gray_rgb_f32(&row_vals));
for b in strided[s + row..s + stride].chunks_exact_mut(4) {
b.copy_from_slice(&999.0f32.to_ne_bytes());
}
}
let got = convert_buffer_with_anchor(
&strided,
width,
rows,
stride,
lin,
target,
DiffuseWhite::BT2408,
)
.unwrap();
assert_eq!(
got.as_slice().as_strided_bytes(),
want.as_slice().as_strided_bytes(),
"strided source must convert identically to packed"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use zenpixels::descriptor::{ColorPrimaries, SignalRange};
use zenpixels::policy::{AlphaPolicy, DepthPolicy};
fn test_rgb8_data() -> Vec<u8> {
vec![255, 0, 0, 0, 255, 0]
}
fn buf_from(bytes: &[u8], w: u32, h: u32, desc: PixelDescriptor) -> zenpixels::PixelBuffer {
zenpixels::PixelBuffer::from_vec(bytes.to_vec(), w, h, desc).unwrap()
}
#[test]
fn in_place_bgra_to_rgba_swaps_bytes_and_updates_buffer() {
let mut buf = buf_from(
&[10u8, 20, 30, 255, 40, 50, 60, 128],
2,
1,
PixelDescriptor::BGRA8_SRGB,
);
try_adapt_in_place(&mut buf, PixelDescriptor::RGBA8_SRGB)
.expect("4bpp B<->R swap is in-place");
assert_eq!(buf.descriptor(), PixelDescriptor::RGBA8_SRGB);
assert_eq!(buf.as_slice().row(0), &[30u8, 20, 10, 255, 60, 50, 40, 128]);
}
#[test]
fn in_place_rgba_to_bgra_roundtrips() {
let original = [1u8, 2, 3, 4, 5, 6, 7, 8];
let mut buf = buf_from(&original, 2, 1, PixelDescriptor::RGBA8_SRGB);
try_adapt_in_place(&mut buf, PixelDescriptor::BGRA8_SRGB).expect("to bgra");
assert_eq!(buf.as_slice().row(0), &[3u8, 2, 1, 4, 7, 6, 5, 8]);
try_adapt_in_place(&mut buf, PixelDescriptor::RGBA8_SRGB).expect("back to rgba");
assert_eq!(buf.as_slice().row(0), &original[..]);
}
#[test]
fn in_place_swap_respects_stride_padding() {
let mut buf =
zenpixels::PixelBuffer::new_simd_aligned(1, 2, PixelDescriptor::BGRA8_SRGB, 16);
assert_eq!(buf.stride(), 16, "fixture must be strided");
{
let mut view = buf.as_slice_mut();
view.row_mut(0).copy_from_slice(&[10, 20, 30, 255]);
view.row_mut(1).copy_from_slice(&[40, 50, 60, 128]);
let backing = view.as_strided_bytes_mut();
backing[4..16].fill(0xAA);
backing[20..32].fill(0xBB);
}
try_adapt_in_place(&mut buf, PixelDescriptor::RGBA8_SRGB).expect("strided swap");
assert_eq!(buf.as_slice().row(0), &[30u8, 20, 10, 255]);
assert_eq!(buf.as_slice().row(1), &[60u8, 50, 40, 128]);
let view = buf.as_slice();
let backing = view.as_strided_bytes();
assert!(
backing[4..16].iter().all(|&b| b == 0xAA),
"row-0 padding must be untouched"
);
assert!(
backing[20..28].iter().all(|&b| b == 0xBB),
"row-1 padding must be untouched"
);
}
#[test]
fn in_place_metadata_retag_moves_no_bytes() {
let original = [1u8, 2, 3, 4, 5, 6];
let mut buf = buf_from(&original, 2, 1, PixelDescriptor::RGB8);
let target = PixelDescriptor::RGB8_SRGB.with_primaries(ColorPrimaries::DisplayP3);
try_adapt_in_place(&mut buf, target).expect("same-format retag");
assert_eq!(buf.descriptor(), target);
assert_eq!(buf.as_slice().row(0), &original[..]);
}
#[test]
fn in_place_rejects_live_alpha_drop_and_depth_changes_unchanged() {
let original = [1u8, 2, 3, 4, 5, 6, 7, 8];
let mut buf = buf_from(&original, 2, 1, PixelDescriptor::RGBA8_SRGB);
try_adapt_in_place(&mut buf, PixelDescriptor::RGB8_SRGB)
.expect_err("straight-alpha drop is not contract-exact");
assert_eq!(buf.descriptor(), PixelDescriptor::RGBA8_SRGB);
assert_eq!(buf.as_slice().row(0), &original[..]);
try_adapt_in_place(&mut buf, PixelDescriptor::RGBA16_SRGB)
.expect_err("depth change cannot be in-place");
assert_eq!(buf.as_slice().row(0), &original[..]);
}
#[test]
fn in_place_rgbx_to_rgb_compacts_and_buffer_adopts_geometry() {
let mut buf = buf_from(
&[
1u8, 2, 3, 0xEE, 4, 5, 6, 0xEE, 7, 8, 9, 0xEE, 10, 11, 12, 0xEE, ],
2,
2,
PixelDescriptor::RGBX8_SRGB,
);
try_adapt_in_place(&mut buf, PixelDescriptor::RGB8_SRGB)
.expect("padding drop is contract-exact");
assert_eq!(buf.descriptor(), PixelDescriptor::RGB8_SRGB);
assert_eq!(buf.stride(), 6);
assert_eq!(buf.as_slice().row(0), &[1u8, 2, 3, 4, 5, 6]);
assert_eq!(buf.as_slice().row(1), &[7u8, 8, 9, 10, 11, 12]);
}
#[test]
fn drop_lane_impl_keeps_divisible_stride_rows_in_place() {
let mut bytes = [
1u8, 2, 3, 0xEE, 4, 5, 6, 0xEE, 0xAA, 0xAA, 0xAA, 0xAA, 7, 8, 9, 0xEE, 10, 11, 12, 0xEE, 0xBB, 0xBB, 0xBB, 0xBB, ];
let px =
zenpixels::InPlacePixels::new(&mut bytes, 2, 2, 12, PixelDescriptor::RGBX8_SRGB, None);
let out = drop_lane_impl(px, PixelDescriptor::RGB8_SRGB, &[0, 1, 2], 4, 3, 1);
assert_eq!(out.stride(), 12, "divisible stride preserved verbatim");
assert_eq!(out.row(0), &[1u8, 2, 3, 4, 5, 6]);
assert_eq!(out.row(1), &[7u8, 8, 9, 10, 11, 12]);
drop(out);
assert_eq!(&bytes[8..12], &[0xAA; 4], "row-0 tail padding untouched");
assert_eq!(&bytes[20..24], &[0xBB; 4], "row-1 tail padding untouched");
}
#[test]
fn in_place_opaque_bgra_to_rgb_reorders_while_dropping() {
let mut buf = buf_from(
&[10u8, 20, 30, 255, 40, 50, 60, 255],
2,
1,
PixelDescriptor::BGRA8_SRGB.with_alpha_mode(Some(AlphaMode::Opaque)),
);
try_adapt_in_place(&mut buf, PixelDescriptor::RGB8_SRGB).expect("opaque drop allowed");
assert_eq!(buf.as_slice().row(0), &[30u8, 20, 10, 60, 50, 40]);
}
#[test]
fn in_place_opaque_rgba16_to_rgb16_drops_lane() {
let px16 = |r: u16, g: u16, b: u16| {
[r, g, b, 0xFFFF]
.iter()
.flat_map(|v| v.to_ne_bytes())
.collect::<Vec<u8>>()
};
let bytes: Vec<u8> = [px16(0x1234, 0x5678, 0x9ABC), px16(0x1111, 0x2222, 0x3333)].concat();
let mut buf = buf_from(
&bytes,
2,
1,
PixelDescriptor::RGBA16_SRGB.with_alpha_mode(Some(AlphaMode::Opaque)),
);
try_adapt_in_place(&mut buf, PixelDescriptor::RGB16_SRGB).expect("u16 lane drop");
let expected: Vec<u8> = [0x1234u16, 0x5678, 0x9ABC, 0x1111, 0x2222, 0x3333]
.iter()
.flat_map(|v| v.to_ne_bytes())
.collect();
assert_eq!(buf.as_slice().row(0), &expected[..]);
assert_eq!(buf.stride(), 12, "16-px input stride rounds to 12");
}
#[test]
fn in_place_opaque_graya_to_gray_matches_allocating_path() {
let original = [10u8, 255, 20, 255, 30, 255, 40, 255];
let src = PixelDescriptor::new(
ChannelType::U8,
ChannelLayout::GrayAlpha,
Some(AlphaMode::Opaque),
zenpixels::TransferFunction::Srgb,
);
let target = PixelDescriptor::GRAY8_SRGB;
let mut buf = buf_from(&original, 4, 1, src);
try_adapt_in_place(&mut buf, target).expect("graya drop");
let in_place_row = buf.as_slice().row(0).to_vec();
let allocated = convert_buffer(&original, 4, 1, src, target).expect("allocating path");
assert_eq!(in_place_row, allocated, "in-place must match allocating");
}
#[test]
fn transfer_agnostic_match_requires_same_primaries() {
let data = test_rgb8_data();
let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt2020);
let target = PixelDescriptor::RGB8_SRGB;
let result = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap();
assert!(
matches!(result.data, Cow::Owned(_)),
"different primaries must trigger conversion, not zero-copy relabel"
);
}
#[test]
fn signal_range_mismatch_refuses_not_relabels() {
let data = test_rgb8_data();
let source = PixelDescriptor::RGB8.with_signal_range(SignalRange::Narrow);
let target = PixelDescriptor::RGB8_SRGB;
let err = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap_err();
assert!(
matches!(*err.error(), ConvertError::NoPath { .. }),
"range crossing must refuse (no kernels), got: {}",
err.error()
);
}
#[test]
fn signal_range_match_zero_copies_narrow_verbatim() {
let data = test_rgb8_data();
let source = PixelDescriptor::RGB8
.with_primaries(ColorPrimaries::Bt709)
.with_signal_range(SignalRange::Narrow);
let full_target = PixelDescriptor::RGB8_SRGB;
let narrow_target = PixelDescriptor::RGB8_SRGB.with_signal_range(SignalRange::Narrow);
let result =
adapt_for_encode(&data, source, 2, 1, 6, &[full_target, narrow_target]).unwrap();
assert!(
matches!(result.data, Cow::Borrowed(_)),
"same-range target must zero-copy"
);
assert_eq!(result.descriptor.signal_range, SignalRange::Narrow);
}
#[test]
fn transfer_agnostic_match_allows_zero_copy_when_all_match() {
let data = test_rgb8_data();
let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt709);
let target = PixelDescriptor::RGB8_SRGB;
let result = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap();
assert!(
matches!(result.data, Cow::Borrowed(_)),
"should be zero-copy when only transfer differs"
);
assert_eq!(result.descriptor, target);
}
#[test]
fn exact_match_is_zero_copy() {
let data = test_rgb8_data();
let desc = PixelDescriptor::RGB8_SRGB;
let result = adapt_for_encode(&data, desc, 2, 1, 6, &[desc]).unwrap();
assert!(matches!(result.data, Cow::Borrowed(_)));
assert_eq!(result.descriptor, desc);
}
#[test]
#[should_panic(expected = "CMYK pixel data cannot be processed")]
fn cmyk_rejected_by_adapt_for_encode() {
let cmyk_data = vec![0u8; 4 * 4]; let _ = adapt_for_encode(
&cmyk_data,
PixelDescriptor::CMYK8,
2,
2,
8,
&[PixelDescriptor::RGB8_SRGB],
);
}
#[test]
#[should_panic(expected = "CMYK pixel data cannot be processed")]
fn cmyk_rejected_by_convert_buffer() {
let cmyk_data = vec![0u8; 4 * 4];
let _ = convert_buffer(
&cmyk_data,
2,
2,
PixelDescriptor::CMYK8,
PixelDescriptor::RGB8_SRGB,
);
}
#[test]
#[should_panic(expected = "CMYK pixel data cannot be processed")]
fn cmyk_rejected_by_convert_buffer_as_target() {
let rgb_data = vec![0u8; 3 * 4];
let _ = convert_buffer(
&rgb_data,
2,
2,
PixelDescriptor::RGB8_SRGB,
PixelDescriptor::CMYK8,
);
}
#[test]
fn explicit_variant_also_checks_primaries() {
let data = test_rgb8_data();
let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt2020);
let target = PixelDescriptor::RGB8_SRGB;
let options = ConvertOptions::forbid_lossy()
.with_alpha_policy(AlphaPolicy::DiscardUnchecked)
.with_depth_policy(DepthPolicy::Round);
let result =
adapt_for_encode_explicit(&data, source, 2, 1, 6, &[target], &options).unwrap();
assert!(
matches!(result.data, Cow::Owned(_)),
"explicit variant: different primaries must trigger conversion"
);
}
}