Skip to main content

zenpixels_convert/
adapt.rs

1//! Codec adapter functions — the fastest path to a compliant encoder.
2//!
3//! These functions combine format negotiation with pixel conversion in a
4//! single call, replacing the per-codec format dispatch if-chains that
5//! every encoder would otherwise need to write.
6//!
7//! # Which function to use
8//!
9//! | Function | Negotiation | Policy | Use case |
10//! |----------|-------------|--------|----------|
11//! | [`adapt_for_encode`] | `Fastest` intent | Permissive | Simple encode path |
12//! | [`adapt_for_encode_with_intent`] | Caller-specified | Permissive | Encode after processing |
13//! | [`adapt_for_encode_explicit`] | `Fastest` intent | [`ConvertOptions`] | Policy-sensitive encode |
14//! | [`convert_buffer`] | None (caller picks) | Permissive | Direct format→format |
15//!
16//! # Zero-copy fast path
17//!
18//! All `adapt_for_encode*` functions check for an exact match first. If the
19//! source descriptor matches one of the supported formats, the function
20//! returns `Cow::Borrowed` — no allocation, no copy, no conversion. This
21//! means the common case (JPEG u8 sRGB → JPEG u8 sRGB) has zero overhead.
22//!
23//! A second fast path handles transfer-agnostic matches: if the source has
24//! `TransferFunction::Unknown` and a supported format matches on everything
25//! else (depth, layout, alpha), it's also zero-copy. This covers codecs
26//! that don't tag their output with a transfer function.
27//!
28//! # Strided buffers
29//!
30//! The `stride` parameter allows adapting buffers with row padding (common
31//! when rows are SIMD-aligned or when working with sub-regions of a larger
32//! buffer). If `stride > width * bpp`, the padding is stripped during
33//! conversion and the output is always packed (stride = width * bpp).
34//!
35//! # Example
36//!
37//! ```rust,ignore
38//! use zenpixels_convert::adapt::adapt_for_encode;
39//!
40//! let supported = &[
41//!     PixelDescriptor::RGB8_SRGB,
42//!     PixelDescriptor::GRAY8_SRGB,
43//! ];
44//!
45//! let adapted = adapt_for_encode(
46//!     raw_bytes, source_desc, width, rows, stride, supported,
47//! )?;
48//!
49//! match &adapted.data {
50//!     Cow::Borrowed(data) => {
51//!         // Fast path: source was already in a supported format.
52//!         encoder.write_direct(data, adapted.descriptor)?;
53//!     }
54//!     Cow::Owned(data) => {
55//!         // Converted: write the new data with the new descriptor.
56//!         encoder.write_converted(data, adapted.descriptor)?;
57//!     }
58//! }
59//! ```
60
61use alloc::borrow::Cow;
62use alloc::vec;
63use alloc::vec::Vec;
64
65use crate::convert::ConvertPlan;
66use crate::converter::RowConverter;
67use crate::negotiate::{ConvertIntent, best_match};
68use crate::policy::{AlphaPolicy, ConvertOptions};
69use crate::{
70    AlphaMode, ChannelLayout, ChannelType, ColorModel, ConvertError, PixelBuffer, PixelDescriptor,
71    PixelSliceMut,
72};
73use whereat::{At, ResultAtExt};
74
75/// Assert that a descriptor is not CMYK.
76///
77/// CMYK is device-dependent and cannot be adapted by zenpixels-convert.
78/// Use a CMS (e.g., moxcms) with an ICC profile for CMYK↔RGB conversion.
79fn assert_not_cmyk(desc: &PixelDescriptor) {
80    assert!(
81        desc.color_model() != ColorModel::Cmyk,
82        "CMYK pixel data cannot be processed by zenpixels-convert. \
83         Use a CMS (e.g., moxcms) with an ICC profile for CMYK↔RGB conversion."
84    );
85}
86
87/// Result of format adaptation: the converted data and its descriptor.
88#[derive(Clone, Debug)]
89pub struct Adapted<'a> {
90    /// Pixel data — borrowed if no conversion was needed, owned otherwise.
91    pub data: Cow<'a, [u8]>,
92    /// The pixel format of `data`.
93    pub descriptor: PixelDescriptor,
94    /// Width of the pixel data.
95    pub width: u32,
96    /// Number of rows.
97    pub rows: u32,
98}
99
100/// Negotiate format and convert pixel data for encoding.
101///
102/// Uses [`ConvertIntent::Fastest`] — minimizes conversion cost.
103///
104/// If the input already matches one of the `supported` formats, returns
105/// `Cow::Borrowed` (zero-copy). Otherwise, converts to the best match.
106///
107/// # Arguments
108///
109/// * `data` - Raw pixel bytes, `rows * stride` bytes minimum.
110/// * `descriptor` - Format of the input data.
111/// * `width` - Pixels per row.
112/// * `rows` - Number of rows.
113/// * `stride` - Bytes between row starts (use `width * descriptor.bytes_per_pixel()` for packed).
114/// * `supported` - Formats the encoder accepts.
115#[track_caller]
116pub fn adapt_for_encode<'a>(
117    data: &'a [u8],
118    descriptor: PixelDescriptor,
119    width: u32,
120    rows: u32,
121    stride: usize,
122    supported: &[PixelDescriptor],
123) -> Result<Adapted<'a>, At<ConvertError>> {
124    adapt_for_encode_with_intent(
125        data,
126        descriptor,
127        width,
128        rows,
129        stride,
130        supported,
131        ConvertIntent::Fastest,
132    )
133}
134
135/// Negotiate format and convert with intent awareness.
136///
137/// Like [`adapt_for_encode`], but lets the caller specify a [`ConvertIntent`].
138#[track_caller]
139pub fn adapt_for_encode_with_intent<'a>(
140    data: &'a [u8],
141    descriptor: PixelDescriptor,
142    width: u32,
143    rows: u32,
144    stride: usize,
145    supported: &[PixelDescriptor],
146    intent: ConvertIntent,
147) -> Result<Adapted<'a>, At<ConvertError>> {
148    assert_not_cmyk(&descriptor);
149    if supported.is_empty() {
150        return Err(whereat::at!(ConvertError::EmptyFormatList));
151    }
152
153    // Check for exact match (zero-copy path).
154    if supported.contains(&descriptor) {
155        return Ok(Adapted {
156            data: contiguous_from_strided(data, width, rows, stride, descriptor.bytes_per_pixel()),
157            descriptor,
158            width,
159            rows,
160        });
161    }
162
163    // Check for transfer-agnostic match: if source has Unknown transfer
164    // and a supported format matches on everything except transfer, it's
165    // still a zero-copy path. Primaries and signal range must also match
166    // — relabeling BT.2020 as BT.709 without gamut conversion is wrong.
167    for &target in supported {
168        if descriptor.channel_type() == target.channel_type()
169            && descriptor.layout() == target.layout()
170            && descriptor.alpha() == target.alpha()
171            && descriptor.primaries == target.primaries
172            && descriptor.signal_range == target.signal_range
173        {
174            return Ok(Adapted {
175                data: contiguous_from_strided(
176                    data,
177                    width,
178                    rows,
179                    stride,
180                    descriptor.bytes_per_pixel(),
181                ),
182                descriptor: target,
183                width,
184                rows,
185            });
186        }
187    }
188
189    // Need conversion — pick best target.
190    let target = best_match(descriptor, supported, intent)
191        .ok_or_else(|| whereat::at!(ConvertError::EmptyFormatList))?;
192
193    let mut converter = RowConverter::new(descriptor, target).at()?;
194
195    let src_bpp = descriptor.bytes_per_pixel();
196    let dst_bpp = target.bytes_per_pixel();
197    let dst_stride = (width as usize) * dst_bpp;
198    let mut output = vec![0u8; dst_stride * rows as usize];
199
200    for y in 0..rows {
201        let src_start = y as usize * stride;
202        let src_end = src_start + (width as usize * src_bpp);
203        let dst_start = y as usize * dst_stride;
204        let dst_end = dst_start + dst_stride;
205        converter.convert_row(
206            &data[src_start..src_end],
207            &mut output[dst_start..dst_end],
208            width,
209        );
210    }
211
212    Ok(Adapted {
213        data: Cow::Owned(output),
214        descriptor: target,
215        width,
216        rows,
217    })
218}
219
220/// Convert a raw byte buffer from one format to another.
221///
222/// Assumes packed (stride = width * bpp) layout.
223#[track_caller]
224pub fn convert_buffer(
225    src: &[u8],
226    width: u32,
227    rows: u32,
228    from: PixelDescriptor,
229    to: PixelDescriptor,
230) -> Result<Vec<u8>, At<ConvertError>> {
231    assert_not_cmyk(&from);
232    assert_not_cmyk(&to);
233    if from == to {
234        return Ok(src.to_vec());
235    }
236
237    let mut converter = RowConverter::new(from, to).at()?;
238    let src_bpp = from.bytes_per_pixel();
239    let dst_bpp = to.bytes_per_pixel();
240    let src_stride = (width as usize) * src_bpp;
241    let dst_stride = (width as usize) * dst_bpp;
242    let mut output = vec![0u8; dst_stride * rows as usize];
243
244    for y in 0..rows {
245        let src_start = y as usize * src_stride;
246        let src_end = src_start + src_stride;
247        let dst_start = y as usize * dst_stride;
248        let dst_end = dst_start + dst_stride;
249        converter.convert_row(
250            &src[src_start..src_end],
251            &mut output[dst_start..dst_end],
252            width,
253        );
254    }
255
256    Ok(output)
257}
258
259/// Attempt to adapt a [`PixelBuffer`] to `target` **in place** — no
260/// allocation, no copy of the frame, and the buffer's descriptor /
261/// geometry / color context are updated **atomically** via
262/// [`PixelBuffer::transform_in_place`] (this is deliberately the only
263/// in-place adaptation entry point; a re-described view over an owner
264/// carrying the old descriptor is unrepresentable). Three transition
265/// classes succeed:
266///
267/// * **identical byte layout** (same [`PixelFormat`](zenpixels::PixelFormat)):
268///   descriptor re-tag only (transfer / primaries / signal range / alpha
269///   mode) — zero data movement;
270/// * **`Rgba8`-family ↔ `Bgra8`-family** (the X-padding forms included):
271///   garb's SIMD B↔R swap, per row — strided buffers handled, padding
272///   bytes untouched;
273/// * **alpha-lane removal with contract-droppable alpha** — RGBA→RGB,
274///   BGRA→RGB (with the B↔R reorder), GrayAlpha→Gray, at U8/U16/F32 as
275///   the formats exist — **only** when the source's alpha mode is
276///   [`AlphaMode::Undefined`] (X padding) or [`AlphaMode::Opaque`]
277///   (declared all-max): in those modes dropping the lane is value-exact
278///   by contract. The stride is kept as close as the slice rules allow —
279///   rounded down to a whole number of target pixels — so when the input
280///   stride already divides evenly rows compact **at their own bases**
281///   with zero cross-row movement and the freed bytes become row padding.
282///   Straight or premultiplied alpha returns `Err`: a blind discard would
283///   silently diverge from [`adapt_for_encode`]'s alpha policy (which
284///   mattes); for *measured*-opaque alpha use
285///   [`reduce_to_load_bearing_format_in_place`](crate::PixelBufferLoadBearingExt::reduce_to_load_bearing_format_in_place),
286///   which scans and proves it first.
287///
288/// `Err(ConvertError::NoPath)` means the transition needs a real
289/// conversion (bit-depth change, chroma removal, lane addition, live
290/// alpha) — the buffer is **untouched**; fall through to the allocating
291/// [`adapt_for_encode`] / [`convert_buffer`]:
292///
293/// ```rust,ignore
294/// if try_adapt_in_place(&mut buf, target).is_err() {
295///     // needs a real conversion — allocate via adapt_for_encode
296/// }
297/// ```
298pub fn try_adapt_in_place(
299    buf: &mut PixelBuffer,
300    target: PixelDescriptor,
301) -> Result<(), At<ConvertError>> {
302    let src = buf.descriptor();
303    let no_path = || {
304        Err(whereat::at!(ConvertError::NoPath {
305            from: src,
306            to: target
307        }))
308    };
309
310    // Same byte layout: metadata-only re-tag, no pixel work.
311    if src.format == target.format {
312        buf.transform_in_place(|px| {
313            rewrap(px.bytes, px.width, px.rows, px.stride, target, px.color)
314        });
315        return Ok(());
316    }
317
318    // Same-size physical reorder: the 4-byte B<->R swap between the Rgba
319    // and Bgra channel orders (U8 only — no 16-bit Bgra format exists).
320    if src.bytes_per_pixel() == target.bytes_per_pixel() {
321        let swappable = src.channel_type() == ChannelType::U8
322            && target.channel_type() == ChannelType::U8
323            && matches!(
324                (src.layout(), target.layout()),
325                (ChannelLayout::Rgba, ChannelLayout::Bgra)
326                    | (ChannelLayout::Bgra, ChannelLayout::Rgba)
327            );
328        if !swappable {
329            return no_path();
330        }
331        buf.transform_in_place(|px| {
332            let width = px.width as usize;
333            let rows = px.rows as usize;
334            // Geometry was validated at construction; the impossible
335            // size-mismatch error keeps the closure total.
336            let _ = garb::bytes::rgba_to_bgra_inplace_strided(px.bytes, width, rows, px.stride);
337            rewrap(px.bytes, px.width, px.rows, px.stride, target, px.color)
338        });
339        return Ok(());
340    }
341
342    // Shrinking alpha-lane removal. Allowed only when the source's alpha
343    // mode makes the drop value-exact BY CONTRACT (Undefined padding /
344    // declared Opaque) — discarding live Straight/Premultiplied alpha
345    // here would silently diverge from adapt_for_encode's matting policy.
346    if !matches!(
347        src.alpha,
348        Some(AlphaMode::Undefined) | Some(AlphaMode::Opaque)
349    ) {
350        return no_path();
351    }
352    if src.channel_type() != target.channel_type() {
353        return no_path();
354    }
355    // Channel-selection map in element units; mirrors the load-bearing
356    // rewrite's transition table for the lane-drop subset.
357    let map: &'static [usize] = match (src.layout(), target.layout()) {
358        (ChannelLayout::Rgba, ChannelLayout::Rgb) => &[0, 1, 2],
359        // Bgra stores B,G,R,A — dropping the lane into Rgb needs the
360        // B<->R reorder (U8 only; no Bgra16/F32 formats exist).
361        (ChannelLayout::Bgra, ChannelLayout::Rgb) => &[2, 1, 0],
362        (ChannelLayout::GrayAlpha, ChannelLayout::Gray) => &[0],
363        _ => return no_path(),
364    };
365
366    let in_bpp = src.bytes_per_pixel();
367    let out_bpp = target.bytes_per_pixel();
368    let elem = src.bytes_per_channel();
369
370    buf.transform_in_place(|px| drop_lane_impl(px, target, map, in_bpp, out_bpp, elem));
371    Ok(())
372}
373
374/// The lane-drop transform behind [`try_adapt_in_place`], split out so
375/// arbitrary-stride geometries (not constructible through the public
376/// buffer constructors) stay unit-testable.
377fn drop_lane_impl<'a>(
378    px: zenpixels::InPlacePixels<'a>,
379    target: PixelDescriptor,
380    map: &'static [usize],
381    in_bpp: usize,
382    out_bpp: usize,
383    elem: usize,
384) -> PixelSliceMut<'a> {
385    let width = px.width as usize;
386    // Output stride: the input stride rounded down to a whole number
387    // of target pixels (PixelSlice requires stride % bpp == 0). When
388    // the input stride is already a multiple of the narrower pixel,
389    // rows stay at their own bases (zero cross-row movement, freed
390    // bytes become row padding); otherwise rows shift up slightly.
391    // Either way dst(y, x) <= src(y, x) for every pixel, so a
392    // forward pass staged through a fixed temp never clobbers unread
393    // source.
394    let out_stride = px.stride - (px.stride % out_bpp);
395    for y in 0..px.rows as usize {
396        let sbase = y * px.stride;
397        let dbase = y * out_stride;
398        for x in 0..width {
399            let s = sbase + x * in_bpp;
400            let mut tmp = [0u8; 16];
401            tmp[..in_bpp].copy_from_slice(&px.bytes[s..s + in_bpp]);
402            let d = dbase + x * out_bpp;
403            for (k, &c) in map.iter().enumerate() {
404                px.bytes[d + k * elem..d + (k + 1) * elem]
405                    .copy_from_slice(&tmp[c * elem..(c + 1) * elem]);
406            }
407        }
408    }
409    rewrap(px.bytes, px.width, px.rows, out_stride, target, px.color)
410}
411
412/// Re-wrap transform output bytes under a new description, carrying the
413/// color context (in-place adaptations are color-class-preserving).
414fn rewrap<'a>(
415    bytes: &'a mut [u8],
416    width: u32,
417    rows: u32,
418    stride: usize,
419    descriptor: PixelDescriptor,
420    color: Option<alloc::sync::Arc<zenpixels::ColorContext>>,
421) -> PixelSliceMut<'a> {
422    let out = PixelSliceMut::new(bytes, width, rows, stride, descriptor)
423        .expect("in-place adaptation geometry is always valid");
424    match color {
425        Some(c) => out.with_color_context(c),
426        None => out,
427    }
428}
429
430/// Negotiate format and convert with explicit policies.
431///
432/// Like [`adapt_for_encode`], but enforces [`ConvertOptions`] policies
433/// on the conversion. Returns an error if a policy forbids the required
434/// conversion.
435#[track_caller]
436pub fn adapt_for_encode_explicit<'a>(
437    data: &'a [u8],
438    descriptor: PixelDescriptor,
439    width: u32,
440    rows: u32,
441    stride: usize,
442    supported: &[PixelDescriptor],
443    options: &ConvertOptions,
444) -> Result<Adapted<'a>, At<ConvertError>> {
445    assert_not_cmyk(&descriptor);
446    if supported.is_empty() {
447        return Err(whereat::at!(ConvertError::EmptyFormatList));
448    }
449
450    // Check for exact match (zero-copy path).
451    if supported.contains(&descriptor) {
452        return Ok(Adapted {
453            data: contiguous_from_strided(data, width, rows, stride, descriptor.bytes_per_pixel()),
454            descriptor,
455            width,
456            rows,
457        });
458    }
459
460    // Check for transfer-agnostic match (primaries and signal range must match).
461    for &target in supported {
462        if descriptor.channel_type() == target.channel_type()
463            && descriptor.layout() == target.layout()
464            && descriptor.alpha() == target.alpha()
465            && descriptor.primaries == target.primaries
466            && descriptor.signal_range == target.signal_range
467        {
468            return Ok(Adapted {
469                data: contiguous_from_strided(
470                    data,
471                    width,
472                    rows,
473                    stride,
474                    descriptor.bytes_per_pixel(),
475                ),
476                descriptor: target,
477                width,
478                rows,
479            });
480        }
481    }
482
483    // Need conversion — pick best target, then validate policies.
484    let target = best_match(descriptor, supported, ConvertIntent::Fastest)
485        .ok_or_else(|| whereat::at!(ConvertError::EmptyFormatList))?;
486
487    // Validate policies before doing work.
488    let plan = ConvertPlan::new_explicit(descriptor, target, options).at()?;
489
490    // Runtime opacity check for DiscardIfOpaque.
491    let drops_alpha = descriptor.alpha().is_some() && target.alpha().is_none();
492    if drops_alpha && options.alpha_policy == AlphaPolicy::DiscardIfOpaque {
493        let src_bpp = descriptor.bytes_per_pixel();
494        if !is_fully_opaque(data, width, rows, stride, src_bpp, &descriptor) {
495            return Err(whereat::at!(ConvertError::AlphaNotOpaque));
496        }
497    }
498
499    let mut converter = RowConverter::from_plan(plan);
500    let src_bpp = descriptor.bytes_per_pixel();
501    let dst_bpp = target.bytes_per_pixel();
502    let dst_stride = (width as usize) * dst_bpp;
503    let mut output = vec![0u8; dst_stride * rows as usize];
504
505    for y in 0..rows {
506        let src_start = y as usize * stride;
507        let src_end = src_start + (width as usize * src_bpp);
508        let dst_start = y as usize * dst_stride;
509        let dst_end = dst_start + dst_stride;
510        converter.convert_row(
511            &data[src_start..src_end],
512            &mut output[dst_start..dst_end],
513            width,
514        );
515    }
516
517    Ok(Adapted {
518        data: Cow::Owned(output),
519        descriptor: target,
520        width,
521        rows,
522    })
523}
524
525/// Check if all alpha values in a strided buffer are fully opaque.
526fn is_fully_opaque(
527    data: &[u8],
528    width: u32,
529    rows: u32,
530    stride: usize,
531    bpp: usize,
532    desc: &PixelDescriptor,
533) -> bool {
534    if desc.alpha().is_none() {
535        return true;
536    }
537    let cs = desc.channel_type().byte_size();
538    let alpha_offset = (desc.layout().channels() - 1) * cs;
539    for y in 0..rows {
540        let row_start = y as usize * stride;
541        for x in 0..width as usize {
542            let off = row_start + x * bpp + alpha_offset;
543            match desc.channel_type() {
544                crate::ChannelType::U8 => {
545                    if data[off] != 255 {
546                        return false;
547                    }
548                }
549                crate::ChannelType::U16 => {
550                    let v = u16::from_ne_bytes([data[off], data[off + 1]]);
551                    if v != 65535 {
552                        return false;
553                    }
554                }
555                crate::ChannelType::F32 => {
556                    let v = f32::from_ne_bytes([
557                        data[off],
558                        data[off + 1],
559                        data[off + 2],
560                        data[off + 3],
561                    ]);
562                    if v < 1.0 {
563                        return false;
564                    }
565                }
566                _ => return false,
567            }
568        }
569    }
570    true
571}
572
573/// Extract contiguous packed rows from potentially strided data.
574fn contiguous_from_strided<'a>(
575    data: &'a [u8],
576    width: u32,
577    rows: u32,
578    stride: usize,
579    bpp: usize,
580) -> Cow<'a, [u8]> {
581    let row_bytes = width as usize * bpp;
582    if stride == row_bytes {
583        // Already packed.
584        let total = row_bytes * rows as usize;
585        Cow::Borrowed(&data[..total])
586    } else {
587        // Need to strip padding.
588        let mut packed = Vec::with_capacity(row_bytes * rows as usize);
589        for y in 0..rows as usize {
590            let start = y * stride;
591            packed.extend_from_slice(&data[start..start + row_bytes]);
592        }
593        Cow::Owned(packed)
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use zenpixels::descriptor::{ColorPrimaries, SignalRange};
601    use zenpixels::policy::{AlphaPolicy, DepthPolicy};
602
603    /// 2×1 RGB8 pixel data (6 bytes).
604    fn test_rgb8_data() -> Vec<u8> {
605        vec![255, 0, 0, 0, 255, 0]
606    }
607
608    // ── try_adapt_in_place ─────────────────────────────────────────
609
610    fn buf_from(bytes: &[u8], w: u32, h: u32, desc: PixelDescriptor) -> zenpixels::PixelBuffer {
611        zenpixels::PixelBuffer::from_vec(bytes.to_vec(), w, h, desc).unwrap()
612    }
613
614    #[test]
615    fn in_place_bgra_to_rgba_swaps_bytes_and_updates_buffer() {
616        // Bgra8 stores B,G,R,A. Two pixels.
617        let mut buf = buf_from(
618            &[10u8, 20, 30, 255, 40, 50, 60, 128],
619            2,
620            1,
621            PixelDescriptor::BGRA8_SRGB,
622        );
623        try_adapt_in_place(&mut buf, PixelDescriptor::RGBA8_SRGB)
624            .expect("4bpp B<->R swap is in-place");
625        assert_eq!(buf.descriptor(), PixelDescriptor::RGBA8_SRGB);
626        assert_eq!(buf.as_slice().row(0), &[30u8, 20, 10, 255, 60, 50, 40, 128]);
627    }
628
629    #[test]
630    fn in_place_rgba_to_bgra_roundtrips() {
631        let original = [1u8, 2, 3, 4, 5, 6, 7, 8];
632        let mut buf = buf_from(&original, 2, 1, PixelDescriptor::RGBA8_SRGB);
633        try_adapt_in_place(&mut buf, PixelDescriptor::BGRA8_SRGB).expect("to bgra");
634        assert_eq!(buf.as_slice().row(0), &[3u8, 2, 1, 4, 7, 6, 5, 8]);
635        try_adapt_in_place(&mut buf, PixelDescriptor::RGBA8_SRGB).expect("back to rgba");
636        assert_eq!(buf.as_slice().row(0), &original[..]);
637    }
638
639    #[test]
640    fn in_place_swap_respects_stride_padding() {
641        // SIMD-aligned buffer: 1 px/row at simd_align 16 → stride 16,
642        // 12 padding bytes per row that must be untouched.
643        let mut buf =
644            zenpixels::PixelBuffer::new_simd_aligned(1, 2, PixelDescriptor::BGRA8_SRGB, 16);
645        assert_eq!(buf.stride(), 16, "fixture must be strided");
646        {
647            let mut view = buf.as_slice_mut();
648            view.row_mut(0).copy_from_slice(&[10, 20, 30, 255]);
649            view.row_mut(1).copy_from_slice(&[40, 50, 60, 128]);
650            let backing = view.as_strided_bytes_mut();
651            backing[4..16].fill(0xAA);
652            backing[20..32].fill(0xBB);
653        }
654        try_adapt_in_place(&mut buf, PixelDescriptor::RGBA8_SRGB).expect("strided swap");
655        assert_eq!(buf.as_slice().row(0), &[30u8, 20, 10, 255]);
656        assert_eq!(buf.as_slice().row(1), &[60u8, 50, 40, 128]);
657        let view = buf.as_slice();
658        let backing = view.as_strided_bytes();
659        assert!(
660            backing[4..16].iter().all(|&b| b == 0xAA),
661            "row-0 padding must be untouched"
662        );
663        assert!(
664            backing[20..28].iter().all(|&b| b == 0xBB),
665            "row-1 padding must be untouched"
666        );
667    }
668
669    #[test]
670    fn in_place_metadata_retag_moves_no_bytes() {
671        let original = [1u8, 2, 3, 4, 5, 6];
672        let mut buf = buf_from(&original, 2, 1, PixelDescriptor::RGB8);
673        let target = PixelDescriptor::RGB8_SRGB.with_primaries(ColorPrimaries::DisplayP3);
674        try_adapt_in_place(&mut buf, target).expect("same-format retag");
675        assert_eq!(buf.descriptor(), target);
676        assert_eq!(buf.as_slice().row(0), &original[..]);
677    }
678
679    #[test]
680    fn in_place_rejects_live_alpha_drop_and_depth_changes_unchanged() {
681        // RGBA(Straight) -> RGB would discard live alpha; must leave the
682        // buffer untouched (adapt_for_encode mattes instead).
683        let original = [1u8, 2, 3, 4, 5, 6, 7, 8];
684        let mut buf = buf_from(&original, 2, 1, PixelDescriptor::RGBA8_SRGB);
685        try_adapt_in_place(&mut buf, PixelDescriptor::RGB8_SRGB)
686            .expect_err("straight-alpha drop is not contract-exact");
687        assert_eq!(buf.descriptor(), PixelDescriptor::RGBA8_SRGB);
688        assert_eq!(buf.as_slice().row(0), &original[..]);
689
690        // Bit-depth change likewise.
691        try_adapt_in_place(&mut buf, PixelDescriptor::RGBA16_SRGB)
692            .expect_err("depth change cannot be in-place");
693        assert_eq!(buf.as_slice().row(0), &original[..]);
694    }
695
696    #[test]
697    fn in_place_rgbx_to_rgb_compacts_and_buffer_adopts_geometry() {
698        // RGBX (Undefined padding): the X byte is contract-droppable.
699        // 2 px/row, 2 rows, tight 4bpp stride 8 → out stride rounds down
700        // to 6 (tight for 3bpp) and the buffer's own stride/descriptor
701        // update atomically.
702        let mut buf = buf_from(
703            &[
704                1u8, 2, 3, 0xEE, 4, 5, 6, 0xEE, // row 0
705                7, 8, 9, 0xEE, 10, 11, 12, 0xEE, // row 1
706            ],
707            2,
708            2,
709            PixelDescriptor::RGBX8_SRGB,
710        );
711        try_adapt_in_place(&mut buf, PixelDescriptor::RGB8_SRGB)
712            .expect("padding drop is contract-exact");
713        assert_eq!(buf.descriptor(), PixelDescriptor::RGB8_SRGB);
714        assert_eq!(buf.stride(), 6);
715        assert_eq!(buf.as_slice().row(0), &[1u8, 2, 3, 4, 5, 6]);
716        assert_eq!(buf.as_slice().row(1), &[7u8, 8, 9, 10, 11, 12]);
717    }
718
719    #[test]
720    fn drop_lane_impl_keeps_divisible_stride_rows_in_place() {
721        // Stride 12 (divisible by 3): rows stay at their own bases —
722        // zero cross-row movement, freed bytes become padding. Exercised
723        // at the transform level because the public constructors only
724        // produce pixel-tight or simd-aligned strides.
725        let mut bytes = [
726            1u8, 2, 3, 0xEE, 4, 5, 6, 0xEE, 0xAA, 0xAA, 0xAA, 0xAA, // row 0 + pad
727            7, 8, 9, 0xEE, 10, 11, 12, 0xEE, 0xBB, 0xBB, 0xBB, 0xBB, // row 1 + pad
728        ];
729        let px = zenpixels::InPlacePixels {
730            bytes: &mut bytes,
731            width: 2,
732            rows: 2,
733            stride: 12,
734            descriptor: PixelDescriptor::RGBX8_SRGB,
735            color: None,
736        };
737        let out = drop_lane_impl(px, PixelDescriptor::RGB8_SRGB, &[0, 1, 2], 4, 3, 1);
738        assert_eq!(out.stride(), 12, "divisible stride preserved verbatim");
739        assert_eq!(out.row(0), &[1u8, 2, 3, 4, 5, 6]);
740        assert_eq!(out.row(1), &[7u8, 8, 9, 10, 11, 12]);
741        drop(out);
742        assert_eq!(&bytes[8..12], &[0xAA; 4], "row-0 tail padding untouched");
743        assert_eq!(&bytes[20..24], &[0xBB; 4], "row-1 tail padding untouched");
744    }
745
746    #[test]
747    fn in_place_opaque_bgra_to_rgb_reorders_while_dropping() {
748        // Declared-Opaque BGRA -> RGB: lane drop + B<->R reorder.
749        let mut buf = buf_from(
750            &[10u8, 20, 30, 255, 40, 50, 60, 255],
751            2,
752            1,
753            PixelDescriptor::BGRA8_SRGB.with_alpha_mode(Some(AlphaMode::Opaque)),
754        );
755        try_adapt_in_place(&mut buf, PixelDescriptor::RGB8_SRGB).expect("opaque drop allowed");
756        assert_eq!(buf.as_slice().row(0), &[30u8, 20, 10, 60, 50, 40]);
757    }
758
759    #[test]
760    fn in_place_opaque_rgba16_to_rgb16_drops_lane() {
761        // U16 lane drop: element-wise (2-byte) selection.
762        let px16 = |r: u16, g: u16, b: u16| {
763            [r, g, b, 0xFFFF]
764                .iter()
765                .flat_map(|v| v.to_ne_bytes())
766                .collect::<Vec<u8>>()
767        };
768        let bytes: Vec<u8> = [px16(0x1234, 0x5678, 0x9ABC), px16(0x1111, 0x2222, 0x3333)].concat();
769        let mut buf = buf_from(
770            &bytes,
771            2,
772            1,
773            PixelDescriptor::RGBA16_SRGB.with_alpha_mode(Some(AlphaMode::Opaque)),
774        );
775        try_adapt_in_place(&mut buf, PixelDescriptor::RGB16_SRGB).expect("u16 lane drop");
776        let expected: Vec<u8> = [0x1234u16, 0x5678, 0x9ABC, 0x1111, 0x2222, 0x3333]
777            .iter()
778            .flat_map(|v| v.to_ne_bytes())
779            .collect();
780        assert_eq!(buf.as_slice().row(0), &expected[..]);
781        assert_eq!(buf.stride(), 12, "16-px input stride rounds to 12");
782    }
783
784    #[test]
785    fn in_place_opaque_graya_to_gray_matches_allocating_path() {
786        // Differential vs convert_buffer on the same transition.
787        let original = [10u8, 255, 20, 255, 30, 255, 40, 255];
788        let src = PixelDescriptor::new(
789            ChannelType::U8,
790            ChannelLayout::GrayAlpha,
791            Some(AlphaMode::Opaque),
792            zenpixels::TransferFunction::Srgb,
793        );
794        let target = PixelDescriptor::GRAY8_SRGB;
795
796        let mut buf = buf_from(&original, 4, 1, src);
797        try_adapt_in_place(&mut buf, target).expect("graya drop");
798        let in_place_row = buf.as_slice().row(0).to_vec();
799
800        let allocated = convert_buffer(&original, 4, 1, src, target).expect("allocating path");
801        assert_eq!(in_place_row, allocated, "in-place must match allocating");
802    }
803
804    #[test]
805    fn transfer_agnostic_match_requires_same_primaries() {
806        let data = test_rgb8_data();
807        let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt2020);
808        let target = PixelDescriptor::RGB8_SRGB; // BT.709 primaries
809
810        let result = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap();
811
812        // Must NOT zero-copy relabel — primaries differ, conversion is needed.
813        // Before the fix, this would return Cow::Borrowed (zero-copy) via the
814        // transfer-agnostic match, silently relabeling BT.2020 as BT.709.
815        assert!(
816            matches!(result.data, Cow::Owned(_)),
817            "different primaries must trigger conversion, not zero-copy relabel"
818        );
819    }
820
821    #[test]
822    fn transfer_agnostic_match_requires_same_signal_range() {
823        let data = test_rgb8_data();
824        let source = PixelDescriptor::RGB8.with_signal_range(SignalRange::Narrow);
825        let target = PixelDescriptor::RGB8_SRGB; // Full range
826
827        let result = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap();
828
829        // Must not zero-copy relabel — signal ranges differ.
830        assert!(
831            matches!(result.data, Cow::Owned(_)),
832            "different signal range must trigger conversion, not zero-copy relabel"
833        );
834    }
835
836    #[test]
837    fn transfer_agnostic_match_allows_zero_copy_when_all_match() {
838        let data = test_rgb8_data();
839        // Source: RGB8 with unknown transfer, BT.709, Full range.
840        let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt709);
841        // Target: RGB8 sRGB with same primaries and range.
842        let target = PixelDescriptor::RGB8_SRGB;
843
844        let result = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap();
845
846        // Should zero-copy (only transfer differs, which is the agnostic part).
847        assert!(
848            matches!(result.data, Cow::Borrowed(_)),
849            "should be zero-copy when only transfer differs"
850        );
851        assert_eq!(result.descriptor, target);
852    }
853
854    #[test]
855    fn exact_match_is_zero_copy() {
856        let data = test_rgb8_data();
857        let desc = PixelDescriptor::RGB8_SRGB;
858
859        let result = adapt_for_encode(&data, desc, 2, 1, 6, &[desc]).unwrap();
860
861        assert!(matches!(result.data, Cow::Borrowed(_)));
862        assert_eq!(result.descriptor, desc);
863    }
864
865    #[test]
866    #[should_panic(expected = "CMYK pixel data cannot be processed")]
867    fn cmyk_rejected_by_adapt_for_encode() {
868        let cmyk_data = vec![0u8; 4 * 4]; // 4 pixels
869        let _ = adapt_for_encode(
870            &cmyk_data,
871            PixelDescriptor::CMYK8,
872            2,
873            2,
874            8,
875            &[PixelDescriptor::RGB8_SRGB],
876        );
877    }
878
879    #[test]
880    #[should_panic(expected = "CMYK pixel data cannot be processed")]
881    fn cmyk_rejected_by_convert_buffer() {
882        let cmyk_data = vec![0u8; 4 * 4];
883        let _ = convert_buffer(
884            &cmyk_data,
885            2,
886            2,
887            PixelDescriptor::CMYK8,
888            PixelDescriptor::RGB8_SRGB,
889        );
890    }
891
892    #[test]
893    #[should_panic(expected = "CMYK pixel data cannot be processed")]
894    fn cmyk_rejected_by_convert_buffer_as_target() {
895        let rgb_data = vec![0u8; 3 * 4];
896        let _ = convert_buffer(
897            &rgb_data,
898            2,
899            2,
900            PixelDescriptor::RGB8_SRGB,
901            PixelDescriptor::CMYK8,
902        );
903    }
904
905    #[test]
906    fn explicit_variant_also_checks_primaries() {
907        let data = test_rgb8_data();
908        let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt2020);
909        let target = PixelDescriptor::RGB8_SRGB;
910        let options = ConvertOptions::forbid_lossy()
911            .with_alpha_policy(AlphaPolicy::DiscardUnchecked)
912            .with_depth_policy(DepthPolicy::Round);
913
914        let result =
915            adapt_for_encode_explicit(&data, source, 2, 1, 6, &[target], &options).unwrap();
916
917        assert!(
918            matches!(result.data, Cow::Owned(_)),
919            "explicit variant: different primaries must trigger conversion"
920        );
921    }
922}