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_cow`] | `Fastest` intent | Permissive | Simple encode path |
12//! | [`adapt_for_encode_with_intent_cow`] | Caller-specified | Permissive | Encode after processing |
13//! | [`adapt_for_encode_explicit_cow`] | `Fastest` intent | [`ConvertOptions`] | Policy-sensitive encode |
14//! | [`convert_buffer`] | None (caller picks) | Permissive | Direct format→format |
15//!
16//! The corresponding names without `_cow` are deprecated compatibility
17//! wrappers returning the packed [`Adapted`] type published in 0.2.14.
18//!
19//! # Zero-copy fast path
20//!
21//! All `adapt_for_encode*_cow` functions check for an exact match first. If the
22//! source descriptor matches one of the supported formats, the function
23//! returns `PixelCow::Borrowed` — no allocation, no copy, no conversion. This
24//! means the common case (JPEG u8 sRGB → JPEG u8 sRGB) has zero overhead.
25//!
26//! A second fast path handles transfer-agnostic matches: if the source has
27//! `TransferFunction::Unknown` and a supported format matches on everything
28//! else (depth, layout, alpha), it's also zero-copy. This covers codecs
29//! that don't tag their output with a transfer function.
30//!
31//! # Strided buffers
32//!
33//! The `stride` parameter allows adapting buffers with row padding (common
34//! when rows are SIMD-aligned or when working with sub-regions of a larger
35//! buffer). If `stride > width * bpp`, the padding is stripped during
36//! conversion and the output is always packed (stride = width * bpp).
37//!
38//! # Example
39//!
40//! ```rust,ignore
41//! use zenpixels_convert::adapt::adapt_for_encode;
42//!
43//! let supported = &[
44//!     PixelDescriptor::RGB8_SRGB,
45//!     PixelDescriptor::GRAY8_SRGB,
46//! ];
47//!
48//! let adapted = adapt_for_encode(
49//!     raw_bytes, source_desc, width, rows, stride, supported,
50//! )?;
51//!
52//! match &adapted.data {
53//!     Cow::Borrowed(data) => {
54//!         // Fast path: source was already in a supported format.
55//!         encoder.write_direct(data, adapted.descriptor)?;
56//!     }
57//!     Cow::Owned(data) => {
58//!         // Converted: write the new data with the new descriptor.
59//!         encoder.write_converted(data, adapted.descriptor)?;
60//!     }
61//! }
62//! ```
63
64use alloc::borrow::Cow;
65use alloc::vec;
66use alloc::vec::Vec;
67
68use crate::convert::ConvertPlan;
69use crate::converter::RowConverter;
70use crate::negotiate::{ConvertIntent, best_match};
71use crate::policy::{AlphaPolicy, ConvertOptions};
72use crate::{
73    AlphaMode, ChannelLayout, ChannelType, ColorModel, ConvertError, PixelBuffer, PixelCow,
74    PixelDescriptor, PixelSlice, PixelSliceMut,
75};
76use whereat::{At, ResultAtExt};
77
78/// Reject CMYK on either side of a `from`/`to` pair at the public-API
79/// boundary, surfacing it as a [`ConvertError::NoPath`] (which carries
80/// both descriptors and gets a CMYK-aware hint from its `Display` impl).
81///
82/// **CMYK contract.** CMYK is a device-dependent subtractive colour
83/// model: faithful CMYK↔RGB conversion requires an ICC profile and a
84/// CMS, neither of which lives in this crate. Silently reinterpreting
85/// C/M/Y/K as R/G/B/A would produce visibly wrong colours AND wrong
86/// transparency — much worse than a clean rejection. Use moxcms (or
87/// another CMS) for CMYK conversion.
88///
89/// Replaces the pre-#44 `assert_not_cmyk` that panicked the process.
90/// We fold the CMYK case into the existing `NoPath { from, to }` variant
91/// rather than introducing a `CmykUnsupported` arm: zero workspace
92/// consumers match anything but `AllocationFailed` today, and a
93/// dedicated variant would be future-facing surface no caller earns
94/// (see imazen/zenpixels#44 discussion). Callers wanting to route to
95/// a CMS can inspect `from.color_model() == ColorModel::Cmyk` on the
96/// returned `NoPath` — information-equivalent.
97fn reject_cmyk(from: PixelDescriptor, to: PixelDescriptor) -> Result<(), At<ConvertError>> {
98    if from.color_model() == ColorModel::Cmyk || to.color_model() == ColorModel::Cmyk {
99        return Err(whereat::at!(ConvertError::NoPath { from, to }));
100    }
101    Ok(())
102}
103
104/// Validate that a raw input buffer is long enough to hold the declared
105/// geometry: `rows × stride` bytes minimum (the contract documented on
106/// every raw-bytes entry point on this module).
107///
108/// The multiplication is `checked_mul` so a 32-bit `usize` wrap on i686 /
109/// wasm32 surfaces as [`ConvertError::AllocationFailed`] (the same class
110/// `PixelBuffer::from_pixels` uses) instead of silently passing through
111/// with a small number that then OOBs in the slice loop. Pre-#44 the raw
112/// entry points slice into `data` with no length check at all and panic
113/// with index-OOB on a truncated buffer.
114fn ensure_src_buffer_fits(
115    data_len: usize,
116    rows: u32,
117    stride: usize,
118) -> Result<(), At<ConvertError>> {
119    if rows == 0 {
120        return Ok(());
121    }
122    let needed = (rows as usize)
123        .checked_mul(stride)
124        .ok_or_else(|| whereat::at!(ConvertError::AllocationFailed))?;
125    if data_len < needed {
126        return Err(whereat::at!(ConvertError::BufferSize {
127            expected: needed,
128            actual: data_len,
129        }));
130    }
131    Ok(())
132}
133
134/// Compute `rows × stride` as a `usize` byte count for an output
135/// allocation, surfacing 32-bit overflow as
136/// [`ConvertError::AllocationFailed`] instead of a silent wrap (which
137/// would `vec![0u8; small]` and then OOB inside the conversion loop).
138fn checked_byte_alloc(rows: u32, stride: usize) -> Result<usize, At<ConvertError>> {
139    (rows as usize)
140        .checked_mul(stride)
141        .ok_or_else(|| whereat::at!(ConvertError::AllocationFailed))
142}
143
144/// Result of format adaptation: the converted data and its descriptor.
145#[deprecated(
146    since = "0.2.15",
147    note = "use PixelCow via adapt_for_encode_cow; this packed compatibility type is retained for 0.2.x callers"
148)]
149#[derive(Clone, Debug)]
150pub struct Adapted<'a> {
151    /// Pixel data — borrowed if no conversion was needed, owned otherwise.
152    pub data: Cow<'a, [u8]>,
153    /// The pixel format of `data`.
154    pub descriptor: PixelDescriptor,
155    /// Width of the pixel data.
156    pub width: u32,
157    /// Number of rows.
158    pub rows: u32,
159}
160
161#[allow(deprecated)]
162impl Adapted<'_> {
163    /// Borrow this compatibility result as a validated packed pixel view.
164    pub fn as_pixel_slice(&self) -> Result<PixelSlice<'_>, At<ConvertError>> {
165        let stride = (self.width as usize)
166            .checked_mul(self.descriptor.bytes_per_pixel())
167            .ok_or_else(|| whereat::at!(ConvertError::AllocationFailed))?;
168        PixelSlice::new(&self.data, self.width, self.rows, stride, self.descriptor)
169            .map_err(|error| error.map_error(ConvertError::from))
170    }
171}
172
173#[allow(deprecated)]
174fn into_adapted<'a>(pixels: PixelCow<'a>) -> Adapted<'a> {
175    match pixels {
176        PixelCow::Borrowed(slice) => Adapted {
177            data: slice.contiguous_bytes(),
178            descriptor: slice.descriptor(),
179            width: slice.width(),
180            rows: slice.rows(),
181        },
182        PixelCow::Owned(buffer) => Adapted {
183            data: Cow::Owned(buffer.copy_to_contiguous_bytes()),
184            descriptor: buffer.descriptor(),
185            width: buffer.width(),
186            rows: buffer.height(),
187        },
188    }
189}
190
191fn borrow_or_copy<'a>(
192    data: &'a [u8],
193    width: u32,
194    rows: u32,
195    stride: usize,
196    descriptor: PixelDescriptor,
197) -> Result<PixelCow<'a>, At<ConvertError>> {
198    if let Ok(slice) = PixelSlice::new(data, width, rows, stride, descriptor) {
199        return Ok(PixelCow::Borrowed(slice));
200    }
201
202    // Raw codec rows are allowed to have byte padding that is not a whole
203    // pixel. PixelSlice deliberately rejects that geometry, so normalize it
204    // into an aligned owned buffer instead of weakening PixelSlice's invariant.
205    let row_bytes = (width as usize)
206        .checked_mul(descriptor.bytes_per_pixel())
207        .ok_or_else(|| whereat::at!(ConvertError::AllocationFailed))?;
208    if stride < row_bytes {
209        return Err(whereat::at!(ConvertError::Buffer(
210            zenpixels::BufferError::StrideTooSmall,
211        )));
212    }
213    let mut output = PixelBuffer::try_new(width, rows, descriptor)
214        .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
215    {
216        let mut destination = output.as_slice_mut();
217        for y in 0..rows {
218            let start = y as usize * stride;
219            destination
220                .row_mut(y)
221                .copy_from_slice(&data[start..start + row_bytes]);
222        }
223    }
224    Ok(PixelCow::Owned(output))
225}
226
227/// Negotiate format and convert pixel data for encoding.
228///
229/// Uses [`ConvertIntent::Fastest`] — minimizes conversion cost.
230///
231/// If the input already matches one of the `supported` formats, returns
232/// `Cow::Borrowed` (zero-copy). Otherwise, converts to the best match.
233///
234/// A [`SignalRange`](zenpixels::SignalRange) mismatch with every supported
235/// format fails with [`ConvertError::NoPath`]: no Narrow↔Full kernels
236/// exist, and neither the zero-copy paths nor the planner will relabel a
237/// range without rescaling. Offer a same-range target to accept narrow
238/// input verbatim.
239///
240/// # Arguments
241///
242/// * `data` - Raw pixel bytes, `rows * stride` bytes minimum.
243/// * `descriptor` - Format of the input data.
244/// * `width` - Pixels per row.
245/// * `rows` - Number of rows.
246/// * `stride` - Bytes between row starts (use `width * descriptor.bytes_per_pixel()` for packed).
247/// * `supported` - Formats the encoder accepts.
248#[track_caller]
249#[deprecated(since = "0.2.15", note = "use adapt_for_encode_cow")]
250#[allow(deprecated)]
251pub fn adapt_for_encode<'a>(
252    data: &'a [u8],
253    descriptor: PixelDescriptor,
254    width: u32,
255    rows: u32,
256    stride: usize,
257    supported: &[PixelDescriptor],
258) -> Result<Adapted<'a>, At<ConvertError>> {
259    adapt_for_encode_cow(data, descriptor, width, rows, stride, supported).map(into_adapted)
260}
261
262/// Negotiate format and return borrowed-or-owned, stride-aware pixels.
263#[track_caller]
264pub fn adapt_for_encode_cow<'a>(
265    data: &'a [u8],
266    descriptor: PixelDescriptor,
267    width: u32,
268    rows: u32,
269    stride: usize,
270    supported: &[PixelDescriptor],
271) -> Result<PixelCow<'a>, At<ConvertError>> {
272    adapt_for_encode_with_intent_cow(
273        data,
274        descriptor,
275        width,
276        rows,
277        stride,
278        supported,
279        ConvertIntent::Fastest,
280    )
281}
282
283/// Negotiate format and convert with intent awareness.
284///
285/// Like [`adapt_for_encode`], but lets the caller specify a [`ConvertIntent`].
286#[track_caller]
287#[deprecated(since = "0.2.15", note = "use adapt_for_encode_with_intent_cow")]
288#[allow(deprecated)]
289pub fn adapt_for_encode_with_intent<'a>(
290    data: &'a [u8],
291    descriptor: PixelDescriptor,
292    width: u32,
293    rows: u32,
294    stride: usize,
295    supported: &[PixelDescriptor],
296    intent: ConvertIntent,
297) -> Result<Adapted<'a>, At<ConvertError>> {
298    adapt_for_encode_with_intent_cow(data, descriptor, width, rows, stride, supported, intent)
299        .map(into_adapted)
300}
301
302/// Negotiate format with caller-specified intent and return [`PixelCow`].
303#[track_caller]
304pub fn adapt_for_encode_with_intent_cow<'a>(
305    data: &'a [u8],
306    descriptor: PixelDescriptor,
307    width: u32,
308    rows: u32,
309    stride: usize,
310    supported: &[PixelDescriptor],
311    intent: ConvertIntent,
312) -> Result<PixelCow<'a>, At<ConvertError>> {
313    ensure_src_buffer_fits(data.len(), rows, stride)?;
314    if supported.is_empty() {
315        return Err(whereat::at!(ConvertError::EmptyFormatList));
316    }
317    // CMYK check after EmptyFormatList so we always have a concrete target
318    // descriptor to pair into `NoPath { from, to }`.
319    reject_cmyk(descriptor, supported[0])?;
320
321    // Check for exact match (zero-copy path).
322    if supported.contains(&descriptor) {
323        return borrow_or_copy(data, width, rows, stride, descriptor);
324    }
325
326    // Check for transfer-agnostic match: if source has Unknown transfer
327    // and a supported format matches on everything except transfer, it's
328    // still a zero-copy path. Primaries and signal range must also match
329    // — relabeling BT.2020 as BT.709 without gamut conversion is wrong.
330    for &target in supported {
331        if descriptor.channel_type() == target.channel_type()
332            && descriptor.layout() == target.layout()
333            && descriptor.alpha() == target.alpha()
334            && descriptor.primaries == target.primaries
335            && descriptor.signal_range == target.signal_range
336        {
337            return borrow_or_copy(data, width, rows, stride, target);
338        }
339    }
340
341    // Need conversion — pick best target.
342    let target = best_match(descriptor, supported, intent)
343        .ok_or_else(|| whereat::at!(ConvertError::EmptyFormatList))?;
344
345    let mut converter = RowConverter::new(descriptor, target).at()?;
346
347    let src_bpp = descriptor.bytes_per_pixel();
348    let mut output = PixelBuffer::try_new(width, rows, target)
349        .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
350    {
351        let mut destination = output.as_slice_mut();
352        for y in 0..rows {
353            let src_start = y as usize * stride;
354            let src_end = src_start + (width as usize * src_bpp);
355            converter.convert_row(&data[src_start..src_end], destination.row_mut(y), width);
356        }
357    }
358    Ok(PixelCow::Owned(output))
359}
360
361/// Convert a raw byte buffer from one format to another.
362///
363/// Assumes packed (stride = width * bpp) layout.
364#[track_caller]
365pub fn convert_buffer(
366    src: &[u8],
367    width: u32,
368    rows: u32,
369    from: PixelDescriptor,
370    to: PixelDescriptor,
371) -> Result<Vec<u8>, At<ConvertError>> {
372    reject_cmyk(from, to)?;
373    let src_bpp = from.bytes_per_pixel();
374    let src_stride = (width as usize) * src_bpp;
375    ensure_src_buffer_fits(src.len(), rows, src_stride)?;
376    if from == to {
377        return Ok(src.to_vec());
378    }
379
380    let mut converter = RowConverter::new(from, to).at()?;
381    let dst_bpp = to.bytes_per_pixel();
382    let dst_stride = (width as usize) * dst_bpp;
383    let mut output = vec![0u8; checked_byte_alloc(rows, dst_stride)?];
384
385    for y in 0..rows {
386        let src_start = y as usize * src_stride;
387        let src_end = src_start + src_stride;
388        let dst_start = y as usize * dst_stride;
389        let dst_end = dst_start + dst_stride;
390        converter.convert_row(
391            &src[src_start..src_end],
392            &mut output[dst_start..dst_end],
393            width,
394        );
395    }
396
397    Ok(output)
398}
399
400/// Like [`convert_buffer`] but anchors the **PQ** transfer steps to an
401/// absolute-luminance white point — the cd/m² that relative-linear `1.0`
402/// represents (e.g. [`DiffuseWhite::BT2408`](zenpixels::hdr::DiffuseWhite) =
403/// 203). The PQ kernels then scale by `anchor / 10000` across the
404/// relative-linear ↔ PQ-absolute boundary, so a relative-linear buffer encodes
405/// to PQ at the right brightness with no caller-side pre-scale. Conversions
406/// without a PQ step are identical to [`convert_buffer`].
407///
408/// Runs on the built-in plan path (so the anchored PQ steps are never bypassed
409/// by a CMS matlut fast path). Honors a **strided** source — `src_stride` is the
410/// bytes between row starts (`width * from.bytes_per_pixel()` for packed) — and
411/// converts row-by-row with no pre-pack. Returns a freshly-allocated
412/// [`PixelBuffer`] (its row stride is the buffer's own — no hand-rolled `Vec`).
413/// The plan drives any channel change (e.g. `DropAlpha` for an RGB target, or
414/// alpha-preserving passthrough for an RGBA one), so the caller hands the source
415/// straight in. Alpha, if kept, is never PQ-encoded or anchor-scaled.
416#[track_caller]
417pub(crate) fn convert_buffer_with_anchor(
418    src: &[u8],
419    width: u32,
420    rows: u32,
421    src_stride: usize,
422    from: PixelDescriptor,
423    to: PixelDescriptor,
424    anchor: zenpixels::hdr::DiffuseWhite,
425) -> Result<PixelBuffer, At<ConvertError>> {
426    // Reject CMYK and validate the src buffer *before* allocating the dst —
427    // failing fast saves the alloc on a broken request, and points the
428    // error at the outer entry point instead of the inner helper.
429    reject_cmyk(from, to)?;
430    ensure_src_buffer_fits(src.len(), rows, src_stride)?;
431    // Allocate through the existing PixelBuffer machinery (start-aligned,
432    // fallible) rather than a hand-rolled `vec![0u8; …]`, and convert into its
433    // backing at the buffer's own row stride.
434    let mut buf = PixelBuffer::try_new(width, rows, to)
435        .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
436    let dst_stride = buf.stride();
437    {
438        let mut slice = buf.as_slice_mut();
439        convert_into_with_anchor(
440            src,
441            width,
442            rows,
443            src_stride,
444            from,
445            to,
446            anchor,
447            slice.as_strided_bytes_mut(),
448            dst_stride,
449        )?;
450    }
451    Ok(buf)
452}
453
454/// Like [`convert_buffer_with_anchor`] but writes into a caller-provided `dst` —
455/// no output allocation. Honors a **strided destination**: `dst_stride` is the
456/// bytes between output row starts (pass `width * to.bytes_per_pixel()` for
457/// packed). `dst` must hold `(rows - 1) * dst_stride + width * to.bpp` bytes and
458/// `dst_stride` must be ≥ the packed row width; otherwise
459/// [`ConvertError::BufferSize`]. The same strided-source / alpha rules apply.
460#[track_caller]
461#[allow(clippy::too_many_arguments)] // mirrors convert_buffer_with_anchor + strided dst
462pub(crate) fn convert_into_with_anchor(
463    src: &[u8],
464    width: u32,
465    rows: u32,
466    src_stride: usize,
467    from: PixelDescriptor,
468    to: PixelDescriptor,
469    anchor: zenpixels::hdr::DiffuseWhite,
470    dst: &mut [u8],
471    dst_stride: usize,
472) -> Result<(), At<ConvertError>> {
473    reject_cmyk(from, to)?;
474    ensure_src_buffer_fits(src.len(), rows, src_stride)?;
475
476    let dst_row = (width as usize) * to.bytes_per_pixel();
477    if rows > 0 {
478        // A strided dst must hold each row's content without rows overlapping:
479        // `dst_stride >= dst_row`, and the last row ends at
480        // `(rows-1) * dst_stride + dst_row`.
481        let needed = (rows as usize - 1) * dst_stride + dst_row;
482        if dst_stride < dst_row || dst.len() < needed {
483            return Err(whereat::at!(ConvertError::BufferSize {
484                expected: needed.max(dst_row),
485                actual: dst.len().min(dst_stride),
486            }));
487        }
488    }
489
490    // `from == to` yields an Identity plan, whose row step copies src → dst — so
491    // the strided loop handles it too (no packed-only special case needed).
492    let plan = ConvertPlan::new(from, to).at()?.with_pq_anchor(anchor);
493    let mut converter = RowConverter::from_plan(plan);
494    let src_row = (width as usize) * from.bytes_per_pixel();
495
496    for y in 0..rows as usize {
497        let src_start = y * src_stride;
498        let dst_start = y * dst_stride;
499        converter.convert_row(
500            &src[src_start..src_start + src_row],
501            &mut dst[dst_start..dst_start + dst_row],
502            width,
503        );
504    }
505
506    Ok(())
507}
508
509/// Attempt to adapt a [`PixelBuffer`] to `target` **in place** — no
510/// allocation, no copy of the frame, and the buffer's descriptor /
511/// geometry / color context are updated **atomically** via
512/// [`PixelBuffer::transform_in_place`] (this is deliberately the only
513/// in-place adaptation entry point; a re-described view over an owner
514/// carrying the old descriptor is unrepresentable). Three transition
515/// classes succeed:
516///
517/// * **identical byte layout** (same [`PixelFormat`](zenpixels::PixelFormat)):
518///   descriptor re-tag only (transfer / primaries / signal range / alpha
519///   mode) — zero data movement;
520/// * **`Rgba8`-family ↔ `Bgra8`-family** (the X-padding forms included):
521///   garb's SIMD B↔R swap, per row — strided buffers handled, padding
522///   bytes untouched;
523/// * **alpha-lane removal with contract-droppable alpha** — RGBA→RGB,
524///   BGRA→RGB (with the B↔R reorder), GrayAlpha→Gray, at U8/U16/F32 as
525///   the formats exist — **only** when the source's alpha mode is
526///   [`AlphaMode::Undefined`] (X padding) or [`AlphaMode::Opaque`]
527///   (declared all-max): in those modes dropping the lane is value-exact
528///   by contract. The stride is kept as close as the slice rules allow —
529///   rounded down to a whole number of target pixels — so when the input
530///   stride already divides evenly rows compact **at their own bases**
531///   with zero cross-row movement and the freed bytes become row padding.
532///   Straight or premultiplied alpha returns `Err`: a blind discard would
533///   silently diverge from [`adapt_for_encode`]'s alpha policy (which
534///   mattes); for *measured*-opaque alpha use
535///   [`reduce_to_load_bearing_format_in_place`](crate::PixelBufferLoadBearingExt::reduce_to_load_bearing_format_in_place),
536///   which scans and proves it first.
537///
538/// `Err(ConvertError::NoPath)` means the transition needs a real
539/// conversion (bit-depth change, chroma removal, lane addition, live
540/// alpha) — the buffer is **untouched**; fall through to the allocating
541/// [`adapt_for_encode`] / [`convert_buffer`]:
542///
543/// ```rust,ignore
544/// if try_adapt_in_place(&mut buf, target).is_err() {
545///     // needs a real conversion — allocate via adapt_for_encode
546/// }
547/// ```
548pub fn try_adapt_in_place(
549    buf: &mut PixelBuffer,
550    target: PixelDescriptor,
551) -> Result<(), At<ConvertError>> {
552    let src = buf.descriptor();
553    let no_path = || {
554        Err(whereat::at!(ConvertError::NoPath {
555            from: src,
556            to: target
557        }))
558    };
559
560    // Same byte layout: metadata-only re-tag, no pixel work.
561    if src.format == target.format {
562        buf.transform_in_place(|px| {
563            rewrap(px.bytes, px.width, px.rows, px.stride, target, px.color)
564        });
565        return Ok(());
566    }
567
568    // Same-size physical reorder: the 4-byte B<->R swap between the Rgba
569    // and Bgra channel orders (U8 only — no 16-bit Bgra format exists).
570    if src.bytes_per_pixel() == target.bytes_per_pixel() {
571        let swappable = src.channel_type() == ChannelType::U8
572            && target.channel_type() == ChannelType::U8
573            && matches!(
574                (src.layout(), target.layout()),
575                (ChannelLayout::Rgba, ChannelLayout::Bgra)
576                    | (ChannelLayout::Bgra, ChannelLayout::Rgba)
577            );
578        if !swappable {
579            return no_path();
580        }
581        buf.transform_in_place(|px| {
582            let width = px.width as usize;
583            let rows = px.rows as usize;
584            // Geometry was validated at construction; the impossible
585            // size-mismatch error keeps the closure total.
586            let _ = garb::bytes::rgba_to_bgra_inplace_strided(px.bytes, width, rows, px.stride);
587            rewrap(px.bytes, px.width, px.rows, px.stride, target, px.color)
588        });
589        return Ok(());
590    }
591
592    // Shrinking alpha-lane removal. Allowed only when the source's alpha
593    // mode makes the drop value-exact BY CONTRACT (Undefined padding /
594    // declared Opaque) — discarding live Straight/Premultiplied alpha
595    // here would silently diverge from adapt_for_encode's matting policy.
596    if !matches!(
597        src.alpha,
598        Some(AlphaMode::Undefined) | Some(AlphaMode::Opaque)
599    ) {
600        return no_path();
601    }
602    if src.channel_type() != target.channel_type() {
603        return no_path();
604    }
605    // Channel-selection map in element units; mirrors the load-bearing
606    // rewrite's transition table for the lane-drop subset.
607    let map: &'static [usize] = match (src.layout(), target.layout()) {
608        (ChannelLayout::Rgba, ChannelLayout::Rgb) => &[0, 1, 2],
609        // Bgra stores B,G,R,A — dropping the lane into Rgb needs the
610        // B<->R reorder (U8 only; no Bgra16/F32 formats exist).
611        (ChannelLayout::Bgra, ChannelLayout::Rgb) => &[2, 1, 0],
612        (ChannelLayout::GrayAlpha, ChannelLayout::Gray) => &[0],
613        _ => return no_path(),
614    };
615
616    let in_bpp = src.bytes_per_pixel();
617    let out_bpp = target.bytes_per_pixel();
618    let elem = src.bytes_per_channel();
619
620    buf.transform_in_place(|px| drop_lane_impl(px, target, map, in_bpp, out_bpp, elem));
621    Ok(())
622}
623
624/// The lane-drop transform behind [`try_adapt_in_place`], split out so
625/// arbitrary-stride geometries (not constructible through the public
626/// buffer constructors) stay unit-testable.
627fn drop_lane_impl<'a>(
628    px: zenpixels::InPlacePixels<'a>,
629    target: PixelDescriptor,
630    map: &'static [usize],
631    in_bpp: usize,
632    out_bpp: usize,
633    elem: usize,
634) -> PixelSliceMut<'a> {
635    let width = px.width as usize;
636    // Output stride: the input stride rounded down to a whole number
637    // of target pixels (PixelSlice requires stride % bpp == 0). When
638    // the input stride is already a multiple of the narrower pixel,
639    // rows stay at their own bases (zero cross-row movement, freed
640    // bytes become row padding); otherwise rows shift up slightly.
641    // Either way dst(y, x) <= src(y, x) for every pixel, so a
642    // forward pass staged through a fixed temp never clobbers unread
643    // source.
644    let out_stride = px.stride - (px.stride % out_bpp);
645    for y in 0..px.rows as usize {
646        let sbase = y * px.stride;
647        let dbase = y * out_stride;
648        for x in 0..width {
649            let s = sbase + x * in_bpp;
650            let mut tmp = [0u8; 16];
651            tmp[..in_bpp].copy_from_slice(&px.bytes[s..s + in_bpp]);
652            let d = dbase + x * out_bpp;
653            for (k, &c) in map.iter().enumerate() {
654                px.bytes[d + k * elem..d + (k + 1) * elem]
655                    .copy_from_slice(&tmp[c * elem..(c + 1) * elem]);
656            }
657        }
658    }
659    rewrap(px.bytes, px.width, px.rows, out_stride, target, px.color)
660}
661
662/// Re-wrap transform output bytes under a new description, carrying the
663/// color context (in-place adaptations are color-class-preserving).
664fn rewrap<'a>(
665    bytes: &'a mut [u8],
666    width: u32,
667    rows: u32,
668    stride: usize,
669    descriptor: PixelDescriptor,
670    color: Option<alloc::sync::Arc<zenpixels::ColorContext>>,
671) -> PixelSliceMut<'a> {
672    let out = PixelSliceMut::new(bytes, width, rows, stride, descriptor)
673        .expect("in-place adaptation geometry is always valid");
674    match color {
675        Some(c) => out.with_color_context(c),
676        None => out,
677    }
678}
679
680/// Negotiate format and convert with explicit policies.
681///
682/// Like [`adapt_for_encode`], but enforces [`ConvertOptions`] policies
683/// on the conversion. Returns an error if a policy forbids the required
684/// conversion.
685#[track_caller]
686#[deprecated(since = "0.2.15", note = "use adapt_for_encode_explicit_cow")]
687#[allow(deprecated)]
688pub fn adapt_for_encode_explicit<'a>(
689    data: &'a [u8],
690    descriptor: PixelDescriptor,
691    width: u32,
692    rows: u32,
693    stride: usize,
694    supported: &[PixelDescriptor],
695    options: &ConvertOptions,
696) -> Result<Adapted<'a>, At<ConvertError>> {
697    adapt_for_encode_explicit_cow(data, descriptor, width, rows, stride, supported, options)
698        .map(into_adapted)
699}
700
701/// Negotiate with explicit conversion policies and return [`PixelCow`].
702#[track_caller]
703pub fn adapt_for_encode_explicit_cow<'a>(
704    data: &'a [u8],
705    descriptor: PixelDescriptor,
706    width: u32,
707    rows: u32,
708    stride: usize,
709    supported: &[PixelDescriptor],
710    options: &ConvertOptions,
711) -> Result<PixelCow<'a>, At<ConvertError>> {
712    ensure_src_buffer_fits(data.len(), rows, stride)?;
713    if supported.is_empty() {
714        return Err(whereat::at!(ConvertError::EmptyFormatList));
715    }
716    reject_cmyk(descriptor, supported[0])?;
717
718    // Check for exact match (zero-copy path).
719    if supported.contains(&descriptor) {
720        return borrow_or_copy(data, width, rows, stride, descriptor);
721    }
722
723    // Check for transfer-agnostic match (primaries and signal range must match).
724    for &target in supported {
725        if descriptor.channel_type() == target.channel_type()
726            && descriptor.layout() == target.layout()
727            && descriptor.alpha() == target.alpha()
728            && descriptor.primaries == target.primaries
729            && descriptor.signal_range == target.signal_range
730        {
731            return borrow_or_copy(data, width, rows, stride, target);
732        }
733    }
734
735    // Need conversion — pick best target, then validate policies.
736    let target = best_match(descriptor, supported, ConvertIntent::Fastest)
737        .ok_or_else(|| whereat::at!(ConvertError::EmptyFormatList))?;
738
739    // Validate policies before doing work.
740    let plan = ConvertPlan::new_explicit(descriptor, target, options).at()?;
741
742    // Runtime opacity check for DiscardIfOpaque.
743    let drops_alpha = descriptor.alpha().is_some() && target.alpha().is_none();
744    if drops_alpha && options.alpha_policy == AlphaPolicy::DiscardIfOpaque {
745        let src_bpp = descriptor.bytes_per_pixel();
746        if !is_fully_opaque(data, width, rows, stride, src_bpp, &descriptor) {
747            return Err(whereat::at!(ConvertError::AlphaNotOpaque));
748        }
749    }
750
751    let mut converter = RowConverter::from_plan(plan);
752    let src_bpp = descriptor.bytes_per_pixel();
753    let mut output = PixelBuffer::try_new(width, rows, target)
754        .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
755    {
756        let mut destination = output.as_slice_mut();
757        for y in 0..rows {
758            let src_start = y as usize * stride;
759            let src_end = src_start + (width as usize * src_bpp);
760            converter.convert_row(&data[src_start..src_end], destination.row_mut(y), width);
761        }
762    }
763    Ok(PixelCow::Owned(output))
764}
765
766/// Check if all alpha values in a strided buffer are fully opaque.
767fn is_fully_opaque(
768    data: &[u8],
769    width: u32,
770    rows: u32,
771    stride: usize,
772    bpp: usize,
773    desc: &PixelDescriptor,
774) -> bool {
775    if desc.alpha().is_none() {
776        return true;
777    }
778    let cs = desc.channel_type().byte_size();
779    let alpha_offset = (desc.layout().channels() - 1) * cs;
780    for y in 0..rows {
781        let row_start = y as usize * stride;
782        for x in 0..width as usize {
783            let off = row_start + x * bpp + alpha_offset;
784            match desc.channel_type() {
785                crate::ChannelType::U8 => {
786                    if data[off] != 255 {
787                        return false;
788                    }
789                }
790                crate::ChannelType::U16 => {
791                    let v = u16::from_ne_bytes([data[off], data[off + 1]]);
792                    if v != 65535 {
793                        return false;
794                    }
795                }
796                crate::ChannelType::F32 => {
797                    let v = f32::from_ne_bytes([
798                        data[off],
799                        data[off + 1],
800                        data[off + 2],
801                        data[off + 3],
802                    ]);
803                    if v < 1.0 {
804                        return false;
805                    }
806                }
807                _ => return false,
808            }
809        }
810    }
811    true
812}
813
814#[cfg(test)]
815mod anchor_tests {
816    //! `convert_buffer_with_anchor` — proof the absolute-luminance anchor
817    //! threads through the PQ `ConvertStep`s (not via any caller pre-scale),
818    //! that it honors a strided source, and that it preserves alpha.
819    use super::convert_buffer_with_anchor;
820    use crate::{PixelDescriptor, TransferFunction};
821    use alloc::vec;
822    use alloc::vec::Vec;
823    use zenpixels::hdr::DiffuseWhite;
824
825    /// f64 SMPTE ST 2084 inverse-EOTF (linear-light fraction → PQ code [0,1]).
826    fn pq_oetf(x: f64) -> f64 {
827        if x <= 0.0 {
828            return 0.0;
829        }
830        let m1 = 2610.0 / 16384.0;
831        let m2 = 2523.0 / 4096.0 * 128.0;
832        let c1 = 3424.0 / 4096.0;
833        let c2 = 2413.0 / 4096.0 * 32.0;
834        let c3 = 2392.0 / 4096.0 * 32.0;
835        let xp = x.powf(m1);
836        ((c1 + c2 * xp) / (1.0 + c3 * xp)).powf(m2)
837    }
838
839    /// Tight RGB f32 bytes from per-pixel gray values.
840    fn gray_rgb_f32(values: &[f32]) -> Vec<u8> {
841        let mut v = Vec::with_capacity(values.len() * 12);
842        for &g in values {
843            for _ in 0..3 {
844                v.extend_from_slice(&g.to_ne_bytes());
845            }
846        }
847        v
848    }
849
850    /// Tight RGBA f32 bytes: per-pixel gray RGB plus its alpha.
851    fn gray_rgba_f32(pixels: &[(f32, f32)]) -> Vec<u8> {
852        let mut v = Vec::with_capacity(pixels.len() * 16);
853        for &(g, a) in pixels {
854            for _ in 0..3 {
855                v.extend_from_slice(&g.to_ne_bytes());
856            }
857            v.extend_from_slice(&a.to_ne_bytes());
858        }
859        v
860    }
861
862    /// Packed bytes-per-row for `desc` at `width` pixels.
863    fn packed_stride(width: usize, desc: PixelDescriptor) -> usize {
864        width * desc.bytes_per_pixel()
865    }
866
867    fn pq16_target() -> (PixelDescriptor, PixelDescriptor) {
868        let target = PixelDescriptor::RGB16_BT2100_PQ;
869        // Tag the source gamut as the target's so no gamut step is inserted.
870        let lin = PixelDescriptor::RGBF32_LINEAR.with_primaries(target.primaries);
871        (lin, target)
872    }
873
874    #[test]
875    fn anchor_pq16_encode_matches_st2084_oracle() {
876        let values = [0.001f32, 0.1, 1.0, 2.0, 49.0];
877        let (lin, target) = pq16_target();
878        let out = convert_buffer_with_anchor(
879            &gray_rgb_f32(&values),
880            values.len() as u32,
881            1,
882            packed_stride(values.len(), lin),
883            lin,
884            target,
885            DiffuseWhite::BT2408,
886        )
887        .unwrap();
888        let codes: &[u16] = bytemuck::cast_slice(out.as_slice().as_strided_bytes());
889        for (i, &v) in values.iter().enumerate() {
890            let got = i64::from(codes[i * 3]);
891            // 1.0 of relative-linear sits at 203 / 10000 of the PQ-absolute range.
892            let want = (pq_oetf(f64::from(v) * 203.0 / 10_000.0) * 65535.0).round() as i64;
893            assert!(
894                (got - want).abs() <= 1,
895                "@203 at {v}: got {got} want {want}"
896            );
897        }
898    }
899
900    #[test]
901    fn anchor_changes_pq_output_in_kernel() {
902        let (lin, target) = pq16_target();
903        let src = gray_rgb_f32(&[1.0]);
904        let enc = |w: DiffuseWhite| {
905            let o = convert_buffer_with_anchor(&src, 1, 1, packed_stride(1, lin), lin, target, w)
906                .unwrap();
907            let ob = o.as_slice().as_strided_bytes();
908            i64::from(u16::from_ne_bytes([ob[0], ob[1]]))
909        };
910        let c100 = enc(DiffuseWhite::new(100.0));
911        let c203 = enc(DiffuseWhite::BT2408);
912        // The same relative-linear 1.0 lands at a different PQ code per anchor —
913        // i.e. the scale is applied inside the kernel, not by a caller.
914        assert_ne!(c100, c203);
915        let want100 = (pq_oetf(100.0 / 10_000.0) * 65535.0).round() as i64;
916        assert!(
917            (c100 - want100).abs() <= 1,
918            "@100: got {c100} want {want100}"
919        );
920    }
921
922    #[test]
923    fn anchor_pq16_decode_divides_and_roundtrips() {
924        // linear @ 203 → PQ16 → linear @ 203 recovers the input: the decode
925        // kernel's ÷scale is the exact inverse of the encode's ×scale.
926        let values = [0.05f32, 0.2, 1.0, 5.0];
927        let (lin, target) = pq16_target();
928        let pq = convert_buffer_with_anchor(
929            &gray_rgb_f32(&values),
930            values.len() as u32,
931            1,
932            packed_stride(values.len(), lin),
933            lin,
934            target,
935            DiffuseWhite::BT2408,
936        )
937        .unwrap();
938        let back = convert_buffer_with_anchor(
939            pq.as_slice().as_strided_bytes(),
940            values.len() as u32,
941            1,
942            pq.stride(),
943            target,
944            lin,
945            DiffuseWhite::BT2408,
946        )
947        .unwrap();
948        let backf: &[f32] = bytemuck::cast_slice(back.as_slice().as_strided_bytes());
949        for (i, &v) in values.iter().enumerate() {
950            let got = backf[i * 3];
951            let rel = ((f64::from(got) - f64::from(v)) / f64::from(v)).abs();
952            assert!(rel < 0.02, "roundtrip @203 at {v}: got {got} (rel {rel})");
953        }
954    }
955
956    #[test]
957    fn anchor_threads_through_f32_pq_slice_kernel() {
958        // RGBF32 linear → RGBF32 PQ exercises the SIMD slice kernel
959        // (LinearF32ToPqF32), a different code path than the u16 kernel.
960        let values = [0.1f32, 1.0, 4.0];
961        let target = PixelDescriptor::RGB16_BT2100_PQ;
962        let lin = PixelDescriptor::RGBF32_LINEAR.with_primaries(target.primaries);
963        let pqf32 = lin.with_transfer(TransferFunction::Pq);
964        let out = convert_buffer_with_anchor(
965            &gray_rgb_f32(&values),
966            values.len() as u32,
967            1,
968            packed_stride(values.len(), lin),
969            lin,
970            pqf32,
971            DiffuseWhite::BT2408,
972        )
973        .unwrap();
974        let encoded: &[f32] = bytemuck::cast_slice(out.as_slice().as_strided_bytes());
975        for (i, &v) in values.iter().enumerate() {
976            let got = f64::from(encoded[i * 3]);
977            let want = pq_oetf(f64::from(v) * 203.0 / 10_000.0);
978            assert!(
979                (got - want).abs() < 1e-3,
980                "f32 PQ @203 at {v}: got {got} want {want}"
981            );
982        }
983    }
984
985    #[test]
986    fn no_anchor_default_is_unscaled() {
987        // DiffuseWhite at 10000 nits ⇒ scale 1.0 ⇒ the kernel treats linear as
988        // already PQ-absolute (the prior behavior). 1.0 linear → PQ code 65535.
989        let (lin, target) = pq16_target();
990        let out = convert_buffer_with_anchor(
991            &gray_rgb_f32(&[1.0]),
992            1,
993            1,
994            packed_stride(1, lin),
995            lin,
996            target,
997            DiffuseWhite::new(10_000.0),
998        )
999        .unwrap();
1000        let ob = out.as_slice().as_strided_bytes();
1001        assert_eq!(u16::from_ne_bytes([ob[0], ob[1]]), 65535);
1002    }
1003
1004    #[test]
1005    fn anchor_preserves_alpha_through_rgba_pq16() {
1006        // RGBA f32 linear → RGBA16 PQ: the RGB lanes take the PQ OETF + anchor;
1007        // alpha rides through linearly (never PQ-encoded, never anchor-scaled) —
1008        // the alpha-preserving `_rgba_slice` kernel path.
1009        let rgb_pq = PixelDescriptor::RGB16_BT2100_PQ;
1010        let src = PixelDescriptor::RGBAF32_LINEAR.with_primaries(rgb_pq.primaries);
1011        let target = PixelDescriptor::RGBA16
1012            .with_transfer(TransferFunction::Pq)
1013            .with_primaries(rgb_pq.primaries);
1014        let pixels = [(1.0f32, 0.5f32), (2.0, 0.25)];
1015        let out = convert_buffer_with_anchor(
1016            &gray_rgba_f32(&pixels),
1017            pixels.len() as u32,
1018            1,
1019            packed_stride(pixels.len(), src),
1020            src,
1021            target,
1022            DiffuseWhite::BT2408,
1023        )
1024        .unwrap();
1025        let codes: &[u16] = bytemuck::cast_slice(out.as_slice().as_strided_bytes());
1026        for (i, &(g, a)) in pixels.iter().enumerate() {
1027            let r = i64::from(codes[i * 4]);
1028            let want_rgb = (pq_oetf(f64::from(g) * 203.0 / 10_000.0) * 65535.0).round() as i64;
1029            assert!(
1030                (r - want_rgb).abs() <= 1,
1031                "rgb @203 at {g}: got {r} want {want_rgb}"
1032            );
1033            // Alpha is linear → u16; PQ-encoding it would give a wildly wrong code.
1034            let alpha = codes[i * 4 + 3];
1035            let want_a = (f64::from(a) * 65535.0).round() as u16;
1036            assert_eq!(
1037                alpha, want_a,
1038                "alpha must pass through linearly: got {alpha} want {want_a}"
1039            );
1040        }
1041    }
1042
1043    #[test]
1044    fn anchor_honors_source_stride() {
1045        // A padded source stride with sentinel padding (999.0, which would clip
1046        // to 65535 if it leaked) must convert identically to the packed source.
1047        let (lin, target) = pq16_target();
1048        let row_vals = [0.1f32, 1.0, 3.0];
1049        let width = row_vals.len() as u32;
1050        let rows = 2u32;
1051        let row = packed_stride(row_vals.len(), lin);
1052        let stride = row + 2 * 12; // two sentinel pixels of padding per row
1053
1054        let mut packed = gray_rgb_f32(&row_vals);
1055        packed.extend_from_slice(&gray_rgb_f32(&row_vals));
1056        let want = convert_buffer_with_anchor(
1057            &packed,
1058            width,
1059            rows,
1060            row,
1061            lin,
1062            target,
1063            DiffuseWhite::BT2408,
1064        )
1065        .unwrap();
1066
1067        let mut strided = vec![0u8; stride * rows as usize];
1068        for y in 0..rows as usize {
1069            let s = y * stride;
1070            strided[s..s + row].copy_from_slice(&gray_rgb_f32(&row_vals));
1071            for b in strided[s + row..s + stride].chunks_exact_mut(4) {
1072                b.copy_from_slice(&999.0f32.to_ne_bytes());
1073            }
1074        }
1075        let got = convert_buffer_with_anchor(
1076            &strided,
1077            width,
1078            rows,
1079            stride,
1080            lin,
1081            target,
1082            DiffuseWhite::BT2408,
1083        )
1084        .unwrap();
1085        assert_eq!(
1086            got.as_slice().as_strided_bytes(),
1087            want.as_slice().as_strided_bytes(),
1088            "strided source must convert identically to packed"
1089        );
1090    }
1091}
1092
1093#[cfg(test)]
1094#[allow(deprecated)]
1095mod tests {
1096    use super::*;
1097    use zenpixels::descriptor::{ColorPrimaries, SignalRange};
1098    use zenpixels::policy::{AlphaPolicy, DepthPolicy};
1099
1100    /// 2×1 RGB8 pixel data (6 bytes).
1101    fn test_rgb8_data() -> Vec<u8> {
1102        vec![255, 0, 0, 0, 255, 0]
1103    }
1104
1105    // ── try_adapt_in_place ─────────────────────────────────────────
1106
1107    fn buf_from(bytes: &[u8], w: u32, h: u32, desc: PixelDescriptor) -> zenpixels::PixelBuffer {
1108        zenpixels::PixelBuffer::from_vec(bytes.to_vec(), w, h, desc).unwrap()
1109    }
1110
1111    #[test]
1112    fn in_place_bgra_to_rgba_swaps_bytes_and_updates_buffer() {
1113        // Bgra8 stores B,G,R,A. Two pixels.
1114        let mut buf = buf_from(
1115            &[10u8, 20, 30, 255, 40, 50, 60, 128],
1116            2,
1117            1,
1118            PixelDescriptor::BGRA8_SRGB,
1119        );
1120        try_adapt_in_place(&mut buf, PixelDescriptor::RGBA8_SRGB)
1121            .expect("4bpp B<->R swap is in-place");
1122        assert_eq!(buf.descriptor(), PixelDescriptor::RGBA8_SRGB);
1123        assert_eq!(buf.as_slice().row(0), &[30u8, 20, 10, 255, 60, 50, 40, 128]);
1124    }
1125
1126    #[test]
1127    fn in_place_rgba_to_bgra_roundtrips() {
1128        let original = [1u8, 2, 3, 4, 5, 6, 7, 8];
1129        let mut buf = buf_from(&original, 2, 1, PixelDescriptor::RGBA8_SRGB);
1130        try_adapt_in_place(&mut buf, PixelDescriptor::BGRA8_SRGB).expect("to bgra");
1131        assert_eq!(buf.as_slice().row(0), &[3u8, 2, 1, 4, 7, 6, 5, 8]);
1132        try_adapt_in_place(&mut buf, PixelDescriptor::RGBA8_SRGB).expect("back to rgba");
1133        assert_eq!(buf.as_slice().row(0), &original[..]);
1134    }
1135
1136    #[test]
1137    fn in_place_swap_respects_stride_padding() {
1138        // SIMD-aligned buffer: 1 px/row at simd_align 16 → stride 16,
1139        // 12 padding bytes per row that must be untouched.
1140        let mut buf =
1141            zenpixels::PixelBuffer::new_simd_aligned(1, 2, PixelDescriptor::BGRA8_SRGB, 16);
1142        assert_eq!(buf.stride(), 16, "fixture must be strided");
1143        {
1144            let mut view = buf.as_slice_mut();
1145            view.row_mut(0).copy_from_slice(&[10, 20, 30, 255]);
1146            view.row_mut(1).copy_from_slice(&[40, 50, 60, 128]);
1147            let backing = view.as_strided_bytes_mut();
1148            backing[4..16].fill(0xAA);
1149            backing[20..32].fill(0xBB);
1150        }
1151        try_adapt_in_place(&mut buf, PixelDescriptor::RGBA8_SRGB).expect("strided swap");
1152        assert_eq!(buf.as_slice().row(0), &[30u8, 20, 10, 255]);
1153        assert_eq!(buf.as_slice().row(1), &[60u8, 50, 40, 128]);
1154        let view = buf.as_slice();
1155        let backing = view.as_strided_bytes();
1156        assert!(
1157            backing[4..16].iter().all(|&b| b == 0xAA),
1158            "row-0 padding must be untouched"
1159        );
1160        assert!(
1161            backing[20..28].iter().all(|&b| b == 0xBB),
1162            "row-1 padding must be untouched"
1163        );
1164    }
1165
1166    #[test]
1167    fn in_place_metadata_retag_moves_no_bytes() {
1168        let original = [1u8, 2, 3, 4, 5, 6];
1169        let mut buf = buf_from(&original, 2, 1, PixelDescriptor::RGB8);
1170        let target = PixelDescriptor::RGB8_SRGB.with_primaries(ColorPrimaries::DisplayP3);
1171        try_adapt_in_place(&mut buf, target).expect("same-format retag");
1172        assert_eq!(buf.descriptor(), target);
1173        assert_eq!(buf.as_slice().row(0), &original[..]);
1174    }
1175
1176    #[test]
1177    fn in_place_rejects_live_alpha_drop_and_depth_changes_unchanged() {
1178        // RGBA(Straight) -> RGB would discard live alpha; must leave the
1179        // buffer untouched (adapt_for_encode mattes instead).
1180        let original = [1u8, 2, 3, 4, 5, 6, 7, 8];
1181        let mut buf = buf_from(&original, 2, 1, PixelDescriptor::RGBA8_SRGB);
1182        try_adapt_in_place(&mut buf, PixelDescriptor::RGB8_SRGB)
1183            .expect_err("straight-alpha drop is not contract-exact");
1184        assert_eq!(buf.descriptor(), PixelDescriptor::RGBA8_SRGB);
1185        assert_eq!(buf.as_slice().row(0), &original[..]);
1186
1187        // Bit-depth change likewise.
1188        try_adapt_in_place(&mut buf, PixelDescriptor::RGBA16_SRGB)
1189            .expect_err("depth change cannot be in-place");
1190        assert_eq!(buf.as_slice().row(0), &original[..]);
1191    }
1192
1193    #[test]
1194    fn in_place_rgbx_to_rgb_compacts_and_buffer_adopts_geometry() {
1195        // RGBX (Undefined padding): the X byte is contract-droppable.
1196        // 2 px/row, 2 rows, tight 4bpp stride 8 → out stride rounds down
1197        // to 6 (tight for 3bpp) and the buffer's own stride/descriptor
1198        // update atomically.
1199        let mut buf = buf_from(
1200            &[
1201                1u8, 2, 3, 0xEE, 4, 5, 6, 0xEE, // row 0
1202                7, 8, 9, 0xEE, 10, 11, 12, 0xEE, // row 1
1203            ],
1204            2,
1205            2,
1206            PixelDescriptor::RGBX8_SRGB,
1207        );
1208        try_adapt_in_place(&mut buf, PixelDescriptor::RGB8_SRGB)
1209            .expect("padding drop is contract-exact");
1210        assert_eq!(buf.descriptor(), PixelDescriptor::RGB8_SRGB);
1211        assert_eq!(buf.stride(), 6);
1212        assert_eq!(buf.as_slice().row(0), &[1u8, 2, 3, 4, 5, 6]);
1213        assert_eq!(buf.as_slice().row(1), &[7u8, 8, 9, 10, 11, 12]);
1214    }
1215
1216    #[test]
1217    fn drop_lane_impl_keeps_divisible_stride_rows_in_place() {
1218        // Stride 12 (divisible by 3): rows stay at their own bases —
1219        // zero cross-row movement, freed bytes become padding. Exercised
1220        // at the transform level because the public constructors only
1221        // produce pixel-tight or simd-aligned strides.
1222        let mut bytes = [
1223            1u8, 2, 3, 0xEE, 4, 5, 6, 0xEE, 0xAA, 0xAA, 0xAA, 0xAA, // row 0 + pad
1224            7, 8, 9, 0xEE, 10, 11, 12, 0xEE, 0xBB, 0xBB, 0xBB, 0xBB, // row 1 + pad
1225        ];
1226        let px =
1227            zenpixels::InPlacePixels::new(&mut bytes, 2, 2, 12, PixelDescriptor::RGBX8_SRGB, None);
1228        let out = drop_lane_impl(px, PixelDescriptor::RGB8_SRGB, &[0, 1, 2], 4, 3, 1);
1229        assert_eq!(out.stride(), 12, "divisible stride preserved verbatim");
1230        assert_eq!(out.row(0), &[1u8, 2, 3, 4, 5, 6]);
1231        assert_eq!(out.row(1), &[7u8, 8, 9, 10, 11, 12]);
1232        drop(out);
1233        assert_eq!(&bytes[8..12], &[0xAA; 4], "row-0 tail padding untouched");
1234        assert_eq!(&bytes[20..24], &[0xBB; 4], "row-1 tail padding untouched");
1235    }
1236
1237    #[test]
1238    fn in_place_opaque_bgra_to_rgb_reorders_while_dropping() {
1239        // Declared-Opaque BGRA -> RGB: lane drop + B<->R reorder.
1240        let mut buf = buf_from(
1241            &[10u8, 20, 30, 255, 40, 50, 60, 255],
1242            2,
1243            1,
1244            PixelDescriptor::BGRA8_SRGB.with_alpha_mode(Some(AlphaMode::Opaque)),
1245        );
1246        try_adapt_in_place(&mut buf, PixelDescriptor::RGB8_SRGB).expect("opaque drop allowed");
1247        assert_eq!(buf.as_slice().row(0), &[30u8, 20, 10, 60, 50, 40]);
1248    }
1249
1250    #[test]
1251    fn in_place_opaque_rgba16_to_rgb16_drops_lane() {
1252        // U16 lane drop: element-wise (2-byte) selection.
1253        let px16 = |r: u16, g: u16, b: u16| {
1254            [r, g, b, 0xFFFF]
1255                .iter()
1256                .flat_map(|v| v.to_ne_bytes())
1257                .collect::<Vec<u8>>()
1258        };
1259        let bytes: Vec<u8> = [px16(0x1234, 0x5678, 0x9ABC), px16(0x1111, 0x2222, 0x3333)].concat();
1260        let mut buf = buf_from(
1261            &bytes,
1262            2,
1263            1,
1264            PixelDescriptor::RGBA16_SRGB.with_alpha_mode(Some(AlphaMode::Opaque)),
1265        );
1266        try_adapt_in_place(&mut buf, PixelDescriptor::RGB16_SRGB).expect("u16 lane drop");
1267        let expected: Vec<u8> = [0x1234u16, 0x5678, 0x9ABC, 0x1111, 0x2222, 0x3333]
1268            .iter()
1269            .flat_map(|v| v.to_ne_bytes())
1270            .collect();
1271        assert_eq!(buf.as_slice().row(0), &expected[..]);
1272        assert_eq!(buf.stride(), 12, "16-px input stride rounds to 12");
1273    }
1274
1275    #[test]
1276    fn in_place_opaque_graya_to_gray_matches_allocating_path() {
1277        // Differential vs convert_buffer on the same transition.
1278        let original = [10u8, 255, 20, 255, 30, 255, 40, 255];
1279        let src = PixelDescriptor::new(
1280            ChannelType::U8,
1281            ChannelLayout::GrayAlpha,
1282            Some(AlphaMode::Opaque),
1283            zenpixels::TransferFunction::Srgb,
1284        );
1285        let target = PixelDescriptor::GRAY8_SRGB;
1286
1287        let mut buf = buf_from(&original, 4, 1, src);
1288        try_adapt_in_place(&mut buf, target).expect("graya drop");
1289        let in_place_row = buf.as_slice().row(0).to_vec();
1290
1291        let allocated = convert_buffer(&original, 4, 1, src, target).expect("allocating path");
1292        assert_eq!(in_place_row, allocated, "in-place must match allocating");
1293    }
1294
1295    #[test]
1296    fn transfer_agnostic_match_requires_same_primaries() {
1297        let data = test_rgb8_data();
1298        let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt2020);
1299        let target = PixelDescriptor::RGB8_SRGB; // BT.709 primaries
1300
1301        let result = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap();
1302
1303        // Must NOT zero-copy relabel — primaries differ, conversion is needed.
1304        // Before the fix, this would return Cow::Borrowed (zero-copy) via the
1305        // transfer-agnostic match, silently relabeling BT.2020 as BT.709.
1306        assert!(
1307            matches!(result.data, Cow::Owned(_)),
1308            "different primaries must trigger conversion, not zero-copy relabel"
1309        );
1310    }
1311
1312    /// A signal-range mismatch refuses loudly. No Narrow↔Full kernels
1313    /// exist, so neither zero-copy relabeling nor an allocating
1314    /// "conversion" is acceptable — the latter would emit narrow-coded
1315    /// values under a full-range label (this test's predecessor codified
1316    /// exactly that bug by asserting only that an allocation happened).
1317    #[test]
1318    fn signal_range_mismatch_refuses_not_relabels() {
1319        let data = test_rgb8_data();
1320        let source = PixelDescriptor::RGB8.with_signal_range(SignalRange::Narrow);
1321        let target = PixelDescriptor::RGB8_SRGB; // Full range
1322
1323        let err = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap_err();
1324        assert!(
1325            matches!(*err.error(), ConvertError::NoPath { .. }),
1326            "range crossing must refuse (no kernels), got: {}",
1327            err.error()
1328        );
1329    }
1330
1331    /// Narrow data is accepted verbatim when a same-range target is offered:
1332    /// the transfer-agnostic zero-copy arm applies as usual once the signal
1333    /// ranges agree.
1334    #[test]
1335    fn signal_range_match_zero_copies_narrow_verbatim() {
1336        let data = test_rgb8_data();
1337        let source = PixelDescriptor::RGB8
1338            .with_primaries(ColorPrimaries::Bt709)
1339            .with_signal_range(SignalRange::Narrow);
1340        let full_target = PixelDescriptor::RGB8_SRGB;
1341        let narrow_target = PixelDescriptor::RGB8_SRGB.with_signal_range(SignalRange::Narrow);
1342
1343        let result =
1344            adapt_for_encode(&data, source, 2, 1, 6, &[full_target, narrow_target]).unwrap();
1345        assert!(
1346            matches!(result.data, Cow::Borrowed(_)),
1347            "same-range target must zero-copy"
1348        );
1349        assert_eq!(result.descriptor.signal_range, SignalRange::Narrow);
1350    }
1351
1352    #[test]
1353    fn transfer_agnostic_match_allows_zero_copy_when_all_match() {
1354        let data = test_rgb8_data();
1355        // Source: RGB8 with unknown transfer, BT.709, Full range.
1356        let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt709);
1357        // Target: RGB8 sRGB with same primaries and range.
1358        let target = PixelDescriptor::RGB8_SRGB;
1359
1360        let result = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap();
1361
1362        // Should zero-copy (only transfer differs, which is the agnostic part).
1363        assert!(
1364            matches!(result.data, Cow::Borrowed(_)),
1365            "should be zero-copy when only transfer differs"
1366        );
1367        assert_eq!(result.descriptor, target);
1368    }
1369
1370    #[test]
1371    fn exact_match_is_zero_copy() {
1372        let data = test_rgb8_data();
1373        let desc = PixelDescriptor::RGB8_SRGB;
1374
1375        let result = adapt_for_encode(&data, desc, 2, 1, 6, &[desc]).unwrap();
1376
1377        assert!(matches!(result.data, Cow::Borrowed(_)));
1378        assert_eq!(result.descriptor, desc);
1379    }
1380
1381    // Pre-#44 these were `#[should_panic]` tests that pinned the
1382    // `assert_not_cmyk` ABORT behaviour. After the typed-error fix they
1383    // assert the same rejection — `ConvertError::NoPath { from, to }` with
1384    // one side being CMYK — without killing the process. The newer
1385    // `cmyk_input_returns_typed_error_*` tests cover the same surface plus
1386    // the inverse direction.
1387    #[test]
1388    fn cmyk_rejected_by_adapt_for_encode() {
1389        let cmyk_data = vec![0u8; 4 * 4]; // 4 pixels
1390        let err = adapt_for_encode(
1391            &cmyk_data,
1392            PixelDescriptor::CMYK8,
1393            2,
1394            2,
1395            8,
1396            &[PixelDescriptor::RGB8_SRGB],
1397        )
1398        .unwrap_err();
1399        assert!(matches!(
1400            *err.error(),
1401            ConvertError::NoPath { from, .. } if from.color_model() == ColorModel::Cmyk
1402        ));
1403    }
1404
1405    #[test]
1406    fn cmyk_rejected_by_convert_buffer() {
1407        let cmyk_data = vec![0u8; 4 * 4];
1408        let err = convert_buffer(
1409            &cmyk_data,
1410            2,
1411            2,
1412            PixelDescriptor::CMYK8,
1413            PixelDescriptor::RGB8_SRGB,
1414        )
1415        .unwrap_err();
1416        assert!(matches!(
1417            *err.error(),
1418            ConvertError::NoPath { from, .. } if from.color_model() == ColorModel::Cmyk
1419        ));
1420    }
1421
1422    #[test]
1423    fn cmyk_rejected_by_convert_buffer_as_target() {
1424        let rgb_data = vec![0u8; 3 * 4];
1425        let err = convert_buffer(
1426            &rgb_data,
1427            2,
1428            2,
1429            PixelDescriptor::RGB8_SRGB,
1430            PixelDescriptor::CMYK8,
1431        )
1432        .unwrap_err();
1433        assert!(matches!(
1434            *err.error(),
1435            ConvertError::NoPath { to, .. } if to.color_model() == ColorModel::Cmyk
1436        ));
1437    }
1438
1439    #[test]
1440    fn explicit_variant_also_checks_primaries() {
1441        let data = test_rgb8_data();
1442        let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt2020);
1443        let target = PixelDescriptor::RGB8_SRGB;
1444        let options = ConvertOptions::forbid_lossy()
1445            .with_alpha_policy(AlphaPolicy::DiscardUnchecked)
1446            .with_depth_policy(DepthPolicy::Round);
1447
1448        let result =
1449            adapt_for_encode_explicit(&data, source, 2, 1, 6, &[target], &options).unwrap();
1450
1451        assert!(
1452            matches!(result.data, Cow::Owned(_)),
1453            "explicit variant: different primaries must trigger conversion"
1454        );
1455    }
1456
1457    // ── #44.1 — CMYK rejection produces typed errors, not panics ──────────
1458
1459    #[test]
1460    fn cmyk_input_returns_typed_error_from_adapt_for_encode() {
1461        // 2×1 CMYK8 = 8 bytes; the value pattern is arbitrary, it never
1462        // reaches the conversion loop because CMYK is rejected up front.
1463        let data = [0u8; 8];
1464        let cmyk = PixelDescriptor::CMYK8;
1465        let target = PixelDescriptor::RGB8_SRGB;
1466        let err = adapt_for_encode(&data, cmyk, 2, 1, 8, &[target]).unwrap_err();
1467        assert!(
1468            matches!(
1469                *err.error(),
1470                ConvertError::NoPath { from, .. } if from.color_model() == ColorModel::Cmyk
1471            ),
1472            "got: {:?}",
1473            err.error()
1474        );
1475        // Display message must carry the moxcms hint so server-side error
1476        // logs are actionable without pattern-matching the variant.
1477        let msg = format!("{}", err.error());
1478        assert!(
1479            msg.contains("CMYK") && msg.contains("moxcms"),
1480            "Display message lost CMYK hint: {msg}"
1481        );
1482    }
1483
1484    #[test]
1485    fn cmyk_input_returns_typed_error_from_convert_buffer() {
1486        let data = [0u8; 8];
1487        let err = convert_buffer(
1488            &data,
1489            2,
1490            1,
1491            PixelDescriptor::CMYK8,
1492            PixelDescriptor::RGB8_SRGB,
1493        )
1494        .unwrap_err();
1495        assert!(matches!(
1496            *err.error(),
1497            ConvertError::NoPath { from, .. } if from.color_model() == ColorModel::Cmyk
1498        ));
1499    }
1500
1501    #[test]
1502    fn cmyk_target_returns_typed_error_from_convert_buffer() {
1503        // RGB→CMYK should also be rejected (no inverse direction either).
1504        let data = [0u8; 6];
1505        let err = convert_buffer(
1506            &data,
1507            2,
1508            1,
1509            PixelDescriptor::RGB8_SRGB,
1510            PixelDescriptor::CMYK8,
1511        )
1512        .unwrap_err();
1513        assert!(matches!(
1514            *err.error(),
1515            ConvertError::NoPath { to, .. } if to.color_model() == ColorModel::Cmyk
1516        ));
1517    }
1518
1519    // ── #44.2 — truncated src buffer returns typed BufferSize ────────────
1520
1521    #[test]
1522    fn truncated_src_returns_buffer_size_error_from_adapt_for_encode() {
1523        // Declared 2×2 RGB8 = 12 bytes needed, only provide 6 (one row).
1524        let data = [255u8, 0, 0, 0, 255, 0];
1525        let err = adapt_for_encode(
1526            &data,
1527            PixelDescriptor::RGB8_SRGB,
1528            2,
1529            2,
1530            6, // packed stride; 2 rows × 6 = 12 needed
1531            &[PixelDescriptor::RGB8_SRGB],
1532        )
1533        .unwrap_err();
1534        assert!(
1535            matches!(
1536                *err.error(),
1537                ConvertError::BufferSize {
1538                    expected: 12,
1539                    actual: 6
1540                }
1541            ),
1542            "got: {:?}",
1543            err.error()
1544        );
1545    }
1546
1547    #[test]
1548    fn truncated_src_returns_buffer_size_error_from_convert_buffer() {
1549        // Declared 4×1 RGBA8 = 16 bytes, provide 8.
1550        let data = [0u8; 8];
1551        let err = convert_buffer(
1552            &data,
1553            4,
1554            1,
1555            PixelDescriptor::RGBA8_SRGB,
1556            PixelDescriptor::RGB8_SRGB,
1557        )
1558        .unwrap_err();
1559        assert!(matches!(*err.error(), ConvertError::BufferSize { .. }));
1560    }
1561
1562    // ── #44.3 — 32-bit allocation overflow surfaces typed error ─────────
1563
1564    #[test]
1565    fn zero_rows_does_not_trigger_size_check() {
1566        // Empty inputs are well-defined: rows=0 means no data needed.
1567        let data: &[u8] = &[];
1568        let result = adapt_for_encode(
1569            data,
1570            PixelDescriptor::RGB8_SRGB,
1571            0,
1572            0,
1573            0,
1574            &[PixelDescriptor::RGB8_SRGB],
1575        );
1576        assert!(result.is_ok());
1577    }
1578
1579    #[test]
1580    fn extreme_rows_stride_returns_allocation_failed_not_panic() {
1581        // u32::MAX rows × non-trivial stride will overflow usize on 32-bit.
1582        // On 64-bit, `data.len()` is then the gating check (we don't actually
1583        // have a u32::MAX-row buffer to feed it). The discriminator is that
1584        // the function returns a typed error in *both* cases rather than
1585        // panicking with index-OOB or producing a wrap-and-corrupt allocation.
1586        let data = [0u8; 1];
1587        let err = convert_buffer(
1588            &data,
1589            1,
1590            u32::MAX,
1591            PixelDescriptor::RGB8_SRGB,
1592            PixelDescriptor::RGB8_SRGB, // identity (early-return), but src check still runs
1593        )
1594        .unwrap_err();
1595        assert!(
1596            matches!(
1597                *err.error(),
1598                ConvertError::AllocationFailed | ConvertError::BufferSize { .. }
1599            ),
1600            "got: {:?}",
1601            err.error()
1602        );
1603    }
1604}