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/// A [`SignalRange`](zenpixels::SignalRange) mismatch with every supported
108/// format fails with [`ConvertError::NoPath`]: no Narrow↔Full kernels
109/// exist, and neither the zero-copy paths nor the planner will relabel a
110/// range without rescaling. Offer a same-range target to accept narrow
111/// input verbatim.
112///
113/// # Arguments
114///
115/// * `data` - Raw pixel bytes, `rows * stride` bytes minimum.
116/// * `descriptor` - Format of the input data.
117/// * `width` - Pixels per row.
118/// * `rows` - Number of rows.
119/// * `stride` - Bytes between row starts (use `width * descriptor.bytes_per_pixel()` for packed).
120/// * `supported` - Formats the encoder accepts.
121#[track_caller]
122pub fn adapt_for_encode<'a>(
123    data: &'a [u8],
124    descriptor: PixelDescriptor,
125    width: u32,
126    rows: u32,
127    stride: usize,
128    supported: &[PixelDescriptor],
129) -> Result<Adapted<'a>, At<ConvertError>> {
130    adapt_for_encode_with_intent(
131        data,
132        descriptor,
133        width,
134        rows,
135        stride,
136        supported,
137        ConvertIntent::Fastest,
138    )
139}
140
141/// Negotiate format and convert with intent awareness.
142///
143/// Like [`adapt_for_encode`], but lets the caller specify a [`ConvertIntent`].
144#[track_caller]
145pub fn adapt_for_encode_with_intent<'a>(
146    data: &'a [u8],
147    descriptor: PixelDescriptor,
148    width: u32,
149    rows: u32,
150    stride: usize,
151    supported: &[PixelDescriptor],
152    intent: ConvertIntent,
153) -> Result<Adapted<'a>, At<ConvertError>> {
154    assert_not_cmyk(&descriptor);
155    if supported.is_empty() {
156        return Err(whereat::at!(ConvertError::EmptyFormatList));
157    }
158
159    // Check for exact match (zero-copy path).
160    if supported.contains(&descriptor) {
161        return Ok(Adapted {
162            data: contiguous_from_strided(data, width, rows, stride, descriptor.bytes_per_pixel()),
163            descriptor,
164            width,
165            rows,
166        });
167    }
168
169    // Check for transfer-agnostic match: if source has Unknown transfer
170    // and a supported format matches on everything except transfer, it's
171    // still a zero-copy path. Primaries and signal range must also match
172    // — relabeling BT.2020 as BT.709 without gamut conversion is wrong.
173    for &target in supported {
174        if descriptor.channel_type() == target.channel_type()
175            && descriptor.layout() == target.layout()
176            && descriptor.alpha() == target.alpha()
177            && descriptor.primaries == target.primaries
178            && descriptor.signal_range == target.signal_range
179        {
180            return Ok(Adapted {
181                data: contiguous_from_strided(
182                    data,
183                    width,
184                    rows,
185                    stride,
186                    descriptor.bytes_per_pixel(),
187                ),
188                descriptor: target,
189                width,
190                rows,
191            });
192        }
193    }
194
195    // Need conversion — pick best target.
196    let target = best_match(descriptor, supported, intent)
197        .ok_or_else(|| whereat::at!(ConvertError::EmptyFormatList))?;
198
199    let mut converter = RowConverter::new(descriptor, target).at()?;
200
201    let src_bpp = descriptor.bytes_per_pixel();
202    let dst_bpp = target.bytes_per_pixel();
203    let dst_stride = (width as usize) * dst_bpp;
204    let mut output = vec![0u8; dst_stride * rows as usize];
205
206    for y in 0..rows {
207        let src_start = y as usize * stride;
208        let src_end = src_start + (width as usize * src_bpp);
209        let dst_start = y as usize * dst_stride;
210        let dst_end = dst_start + dst_stride;
211        converter.convert_row(
212            &data[src_start..src_end],
213            &mut output[dst_start..dst_end],
214            width,
215        );
216    }
217
218    Ok(Adapted {
219        data: Cow::Owned(output),
220        descriptor: target,
221        width,
222        rows,
223    })
224}
225
226/// Convert a raw byte buffer from one format to another.
227///
228/// Assumes packed (stride = width * bpp) layout.
229#[track_caller]
230pub fn convert_buffer(
231    src: &[u8],
232    width: u32,
233    rows: u32,
234    from: PixelDescriptor,
235    to: PixelDescriptor,
236) -> Result<Vec<u8>, At<ConvertError>> {
237    assert_not_cmyk(&from);
238    assert_not_cmyk(&to);
239    if from == to {
240        return Ok(src.to_vec());
241    }
242
243    let mut converter = RowConverter::new(from, to).at()?;
244    let src_bpp = from.bytes_per_pixel();
245    let dst_bpp = to.bytes_per_pixel();
246    let src_stride = (width as usize) * src_bpp;
247    let dst_stride = (width as usize) * dst_bpp;
248    let mut output = vec![0u8; dst_stride * rows as usize];
249
250    for y in 0..rows {
251        let src_start = y as usize * src_stride;
252        let src_end = src_start + src_stride;
253        let dst_start = y as usize * dst_stride;
254        let dst_end = dst_start + dst_stride;
255        converter.convert_row(
256            &src[src_start..src_end],
257            &mut output[dst_start..dst_end],
258            width,
259        );
260    }
261
262    Ok(output)
263}
264
265/// Like [`convert_buffer`] but anchors the **PQ** transfer steps to an
266/// absolute-luminance white point — the cd/m² that relative-linear `1.0`
267/// represents (e.g. [`DiffuseWhite::BT2408`](zenpixels::hdr::DiffuseWhite) =
268/// 203). The PQ kernels then scale by `anchor / 10000` across the
269/// relative-linear ↔ PQ-absolute boundary, so a relative-linear buffer encodes
270/// to PQ at the right brightness with no caller-side pre-scale. Conversions
271/// without a PQ step are identical to [`convert_buffer`].
272///
273/// Runs on the built-in plan path (so the anchored PQ steps are never bypassed
274/// by a CMS matlut fast path). Honors a **strided** source — `src_stride` is the
275/// bytes between row starts (`width * from.bytes_per_pixel()` for packed) — and
276/// converts row-by-row with no pre-pack. Returns a freshly-allocated
277/// [`PixelBuffer`] (its row stride is the buffer's own — no hand-rolled `Vec`).
278/// The plan drives any channel change (e.g. `DropAlpha` for an RGB target, or
279/// alpha-preserving passthrough for an RGBA one), so the caller hands the source
280/// straight in. Alpha, if kept, is never PQ-encoded or anchor-scaled.
281#[track_caller]
282pub(crate) fn convert_buffer_with_anchor(
283    src: &[u8],
284    width: u32,
285    rows: u32,
286    src_stride: usize,
287    from: PixelDescriptor,
288    to: PixelDescriptor,
289    anchor: zenpixels::hdr::DiffuseWhite,
290) -> Result<PixelBuffer, At<ConvertError>> {
291    // Allocate through the existing PixelBuffer machinery (start-aligned,
292    // fallible) rather than a hand-rolled `vec![0u8; …]`, and convert into its
293    // backing at the buffer's own row stride.
294    let mut buf = PixelBuffer::try_new(width, rows, to)
295        .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
296    let dst_stride = buf.stride();
297    {
298        let mut slice = buf.as_slice_mut();
299        convert_into_with_anchor(
300            src,
301            width,
302            rows,
303            src_stride,
304            from,
305            to,
306            anchor,
307            slice.as_strided_bytes_mut(),
308            dst_stride,
309        )?;
310    }
311    Ok(buf)
312}
313
314/// Like [`convert_buffer_with_anchor`] but writes into a caller-provided `dst` —
315/// no output allocation. Honors a **strided destination**: `dst_stride` is the
316/// bytes between output row starts (pass `width * to.bytes_per_pixel()` for
317/// packed). `dst` must hold `(rows - 1) * dst_stride + width * to.bpp` bytes and
318/// `dst_stride` must be ≥ the packed row width; otherwise
319/// [`ConvertError::BufferSize`]. The same strided-source / alpha rules apply.
320#[track_caller]
321#[allow(clippy::too_many_arguments)] // mirrors convert_buffer_with_anchor + strided dst
322pub(crate) fn convert_into_with_anchor(
323    src: &[u8],
324    width: u32,
325    rows: u32,
326    src_stride: usize,
327    from: PixelDescriptor,
328    to: PixelDescriptor,
329    anchor: zenpixels::hdr::DiffuseWhite,
330    dst: &mut [u8],
331    dst_stride: usize,
332) -> Result<(), At<ConvertError>> {
333    assert_not_cmyk(&from);
334    assert_not_cmyk(&to);
335
336    let dst_row = (width as usize) * to.bytes_per_pixel();
337    if rows > 0 {
338        // A strided dst must hold each row's content without rows overlapping:
339        // `dst_stride >= dst_row`, and the last row ends at
340        // `(rows-1) * dst_stride + dst_row`.
341        let needed = (rows as usize - 1) * dst_stride + dst_row;
342        if dst_stride < dst_row || dst.len() < needed {
343            return Err(whereat::at!(ConvertError::BufferSize {
344                expected: needed.max(dst_row),
345                actual: dst.len().min(dst_stride),
346            }));
347        }
348    }
349
350    // `from == to` yields an Identity plan, whose row step copies src → dst — so
351    // the strided loop handles it too (no packed-only special case needed).
352    let plan = ConvertPlan::new(from, to).at()?.with_pq_anchor(anchor);
353    let mut converter = RowConverter::from_plan(plan);
354    let src_row = (width as usize) * from.bytes_per_pixel();
355
356    for y in 0..rows as usize {
357        let src_start = y * src_stride;
358        let dst_start = y * dst_stride;
359        converter.convert_row(
360            &src[src_start..src_start + src_row],
361            &mut dst[dst_start..dst_start + dst_row],
362            width,
363        );
364    }
365
366    Ok(())
367}
368
369/// Attempt to adapt a [`PixelBuffer`] to `target` **in place** — no
370/// allocation, no copy of the frame, and the buffer's descriptor /
371/// geometry / color context are updated **atomically** via
372/// [`PixelBuffer::transform_in_place`] (this is deliberately the only
373/// in-place adaptation entry point; a re-described view over an owner
374/// carrying the old descriptor is unrepresentable). Three transition
375/// classes succeed:
376///
377/// * **identical byte layout** (same [`PixelFormat`](zenpixels::PixelFormat)):
378///   descriptor re-tag only (transfer / primaries / signal range / alpha
379///   mode) — zero data movement;
380/// * **`Rgba8`-family ↔ `Bgra8`-family** (the X-padding forms included):
381///   garb's SIMD B↔R swap, per row — strided buffers handled, padding
382///   bytes untouched;
383/// * **alpha-lane removal with contract-droppable alpha** — RGBA→RGB,
384///   BGRA→RGB (with the B↔R reorder), GrayAlpha→Gray, at U8/U16/F32 as
385///   the formats exist — **only** when the source's alpha mode is
386///   [`AlphaMode::Undefined`] (X padding) or [`AlphaMode::Opaque`]
387///   (declared all-max): in those modes dropping the lane is value-exact
388///   by contract. The stride is kept as close as the slice rules allow —
389///   rounded down to a whole number of target pixels — so when the input
390///   stride already divides evenly rows compact **at their own bases**
391///   with zero cross-row movement and the freed bytes become row padding.
392///   Straight or premultiplied alpha returns `Err`: a blind discard would
393///   silently diverge from [`adapt_for_encode`]'s alpha policy (which
394///   mattes); for *measured*-opaque alpha use
395///   [`reduce_to_load_bearing_format_in_place`](crate::PixelBufferLoadBearingExt::reduce_to_load_bearing_format_in_place),
396///   which scans and proves it first.
397///
398/// `Err(ConvertError::NoPath)` means the transition needs a real
399/// conversion (bit-depth change, chroma removal, lane addition, live
400/// alpha) — the buffer is **untouched**; fall through to the allocating
401/// [`adapt_for_encode`] / [`convert_buffer`]:
402///
403/// ```rust,ignore
404/// if try_adapt_in_place(&mut buf, target).is_err() {
405///     // needs a real conversion — allocate via adapt_for_encode
406/// }
407/// ```
408pub fn try_adapt_in_place(
409    buf: &mut PixelBuffer,
410    target: PixelDescriptor,
411) -> Result<(), At<ConvertError>> {
412    let src = buf.descriptor();
413    let no_path = || {
414        Err(whereat::at!(ConvertError::NoPath {
415            from: src,
416            to: target
417        }))
418    };
419
420    // Same byte layout: metadata-only re-tag, no pixel work.
421    if src.format == target.format {
422        buf.transform_in_place(|px| {
423            rewrap(px.bytes, px.width, px.rows, px.stride, target, px.color)
424        });
425        return Ok(());
426    }
427
428    // Same-size physical reorder: the 4-byte B<->R swap between the Rgba
429    // and Bgra channel orders (U8 only — no 16-bit Bgra format exists).
430    if src.bytes_per_pixel() == target.bytes_per_pixel() {
431        let swappable = src.channel_type() == ChannelType::U8
432            && target.channel_type() == ChannelType::U8
433            && matches!(
434                (src.layout(), target.layout()),
435                (ChannelLayout::Rgba, ChannelLayout::Bgra)
436                    | (ChannelLayout::Bgra, ChannelLayout::Rgba)
437            );
438        if !swappable {
439            return no_path();
440        }
441        buf.transform_in_place(|px| {
442            let width = px.width as usize;
443            let rows = px.rows as usize;
444            // Geometry was validated at construction; the impossible
445            // size-mismatch error keeps the closure total.
446            let _ = garb::bytes::rgba_to_bgra_inplace_strided(px.bytes, width, rows, px.stride);
447            rewrap(px.bytes, px.width, px.rows, px.stride, target, px.color)
448        });
449        return Ok(());
450    }
451
452    // Shrinking alpha-lane removal. Allowed only when the source's alpha
453    // mode makes the drop value-exact BY CONTRACT (Undefined padding /
454    // declared Opaque) — discarding live Straight/Premultiplied alpha
455    // here would silently diverge from adapt_for_encode's matting policy.
456    if !matches!(
457        src.alpha,
458        Some(AlphaMode::Undefined) | Some(AlphaMode::Opaque)
459    ) {
460        return no_path();
461    }
462    if src.channel_type() != target.channel_type() {
463        return no_path();
464    }
465    // Channel-selection map in element units; mirrors the load-bearing
466    // rewrite's transition table for the lane-drop subset.
467    let map: &'static [usize] = match (src.layout(), target.layout()) {
468        (ChannelLayout::Rgba, ChannelLayout::Rgb) => &[0, 1, 2],
469        // Bgra stores B,G,R,A — dropping the lane into Rgb needs the
470        // B<->R reorder (U8 only; no Bgra16/F32 formats exist).
471        (ChannelLayout::Bgra, ChannelLayout::Rgb) => &[2, 1, 0],
472        (ChannelLayout::GrayAlpha, ChannelLayout::Gray) => &[0],
473        _ => return no_path(),
474    };
475
476    let in_bpp = src.bytes_per_pixel();
477    let out_bpp = target.bytes_per_pixel();
478    let elem = src.bytes_per_channel();
479
480    buf.transform_in_place(|px| drop_lane_impl(px, target, map, in_bpp, out_bpp, elem));
481    Ok(())
482}
483
484/// The lane-drop transform behind [`try_adapt_in_place`], split out so
485/// arbitrary-stride geometries (not constructible through the public
486/// buffer constructors) stay unit-testable.
487fn drop_lane_impl<'a>(
488    px: zenpixels::InPlacePixels<'a>,
489    target: PixelDescriptor,
490    map: &'static [usize],
491    in_bpp: usize,
492    out_bpp: usize,
493    elem: usize,
494) -> PixelSliceMut<'a> {
495    let width = px.width as usize;
496    // Output stride: the input stride rounded down to a whole number
497    // of target pixels (PixelSlice requires stride % bpp == 0). When
498    // the input stride is already a multiple of the narrower pixel,
499    // rows stay at their own bases (zero cross-row movement, freed
500    // bytes become row padding); otherwise rows shift up slightly.
501    // Either way dst(y, x) <= src(y, x) for every pixel, so a
502    // forward pass staged through a fixed temp never clobbers unread
503    // source.
504    let out_stride = px.stride - (px.stride % out_bpp);
505    for y in 0..px.rows as usize {
506        let sbase = y * px.stride;
507        let dbase = y * out_stride;
508        for x in 0..width {
509            let s = sbase + x * in_bpp;
510            let mut tmp = [0u8; 16];
511            tmp[..in_bpp].copy_from_slice(&px.bytes[s..s + in_bpp]);
512            let d = dbase + x * out_bpp;
513            for (k, &c) in map.iter().enumerate() {
514                px.bytes[d + k * elem..d + (k + 1) * elem]
515                    .copy_from_slice(&tmp[c * elem..(c + 1) * elem]);
516            }
517        }
518    }
519    rewrap(px.bytes, px.width, px.rows, out_stride, target, px.color)
520}
521
522/// Re-wrap transform output bytes under a new description, carrying the
523/// color context (in-place adaptations are color-class-preserving).
524fn rewrap<'a>(
525    bytes: &'a mut [u8],
526    width: u32,
527    rows: u32,
528    stride: usize,
529    descriptor: PixelDescriptor,
530    color: Option<alloc::sync::Arc<zenpixels::ColorContext>>,
531) -> PixelSliceMut<'a> {
532    let out = PixelSliceMut::new(bytes, width, rows, stride, descriptor)
533        .expect("in-place adaptation geometry is always valid");
534    match color {
535        Some(c) => out.with_color_context(c),
536        None => out,
537    }
538}
539
540/// Negotiate format and convert with explicit policies.
541///
542/// Like [`adapt_for_encode`], but enforces [`ConvertOptions`] policies
543/// on the conversion. Returns an error if a policy forbids the required
544/// conversion.
545#[track_caller]
546pub fn adapt_for_encode_explicit<'a>(
547    data: &'a [u8],
548    descriptor: PixelDescriptor,
549    width: u32,
550    rows: u32,
551    stride: usize,
552    supported: &[PixelDescriptor],
553    options: &ConvertOptions,
554) -> Result<Adapted<'a>, At<ConvertError>> {
555    assert_not_cmyk(&descriptor);
556    if supported.is_empty() {
557        return Err(whereat::at!(ConvertError::EmptyFormatList));
558    }
559
560    // Check for exact match (zero-copy path).
561    if supported.contains(&descriptor) {
562        return Ok(Adapted {
563            data: contiguous_from_strided(data, width, rows, stride, descriptor.bytes_per_pixel()),
564            descriptor,
565            width,
566            rows,
567        });
568    }
569
570    // Check for transfer-agnostic match (primaries and signal range must match).
571    for &target in supported {
572        if descriptor.channel_type() == target.channel_type()
573            && descriptor.layout() == target.layout()
574            && descriptor.alpha() == target.alpha()
575            && descriptor.primaries == target.primaries
576            && descriptor.signal_range == target.signal_range
577        {
578            return Ok(Adapted {
579                data: contiguous_from_strided(
580                    data,
581                    width,
582                    rows,
583                    stride,
584                    descriptor.bytes_per_pixel(),
585                ),
586                descriptor: target,
587                width,
588                rows,
589            });
590        }
591    }
592
593    // Need conversion — pick best target, then validate policies.
594    let target = best_match(descriptor, supported, ConvertIntent::Fastest)
595        .ok_or_else(|| whereat::at!(ConvertError::EmptyFormatList))?;
596
597    // Validate policies before doing work.
598    let plan = ConvertPlan::new_explicit(descriptor, target, options).at()?;
599
600    // Runtime opacity check for DiscardIfOpaque.
601    let drops_alpha = descriptor.alpha().is_some() && target.alpha().is_none();
602    if drops_alpha && options.alpha_policy == AlphaPolicy::DiscardIfOpaque {
603        let src_bpp = descriptor.bytes_per_pixel();
604        if !is_fully_opaque(data, width, rows, stride, src_bpp, &descriptor) {
605            return Err(whereat::at!(ConvertError::AlphaNotOpaque));
606        }
607    }
608
609    let mut converter = RowConverter::from_plan(plan);
610    let src_bpp = descriptor.bytes_per_pixel();
611    let dst_bpp = target.bytes_per_pixel();
612    let dst_stride = (width as usize) * dst_bpp;
613    let mut output = vec![0u8; dst_stride * rows as usize];
614
615    for y in 0..rows {
616        let src_start = y as usize * stride;
617        let src_end = src_start + (width as usize * src_bpp);
618        let dst_start = y as usize * dst_stride;
619        let dst_end = dst_start + dst_stride;
620        converter.convert_row(
621            &data[src_start..src_end],
622            &mut output[dst_start..dst_end],
623            width,
624        );
625    }
626
627    Ok(Adapted {
628        data: Cow::Owned(output),
629        descriptor: target,
630        width,
631        rows,
632    })
633}
634
635/// Check if all alpha values in a strided buffer are fully opaque.
636fn is_fully_opaque(
637    data: &[u8],
638    width: u32,
639    rows: u32,
640    stride: usize,
641    bpp: usize,
642    desc: &PixelDescriptor,
643) -> bool {
644    if desc.alpha().is_none() {
645        return true;
646    }
647    let cs = desc.channel_type().byte_size();
648    let alpha_offset = (desc.layout().channels() - 1) * cs;
649    for y in 0..rows {
650        let row_start = y as usize * stride;
651        for x in 0..width as usize {
652            let off = row_start + x * bpp + alpha_offset;
653            match desc.channel_type() {
654                crate::ChannelType::U8 => {
655                    if data[off] != 255 {
656                        return false;
657                    }
658                }
659                crate::ChannelType::U16 => {
660                    let v = u16::from_ne_bytes([data[off], data[off + 1]]);
661                    if v != 65535 {
662                        return false;
663                    }
664                }
665                crate::ChannelType::F32 => {
666                    let v = f32::from_ne_bytes([
667                        data[off],
668                        data[off + 1],
669                        data[off + 2],
670                        data[off + 3],
671                    ]);
672                    if v < 1.0 {
673                        return false;
674                    }
675                }
676                _ => return false,
677            }
678        }
679    }
680    true
681}
682
683/// Extract contiguous packed rows from potentially strided data.
684fn contiguous_from_strided<'a>(
685    data: &'a [u8],
686    width: u32,
687    rows: u32,
688    stride: usize,
689    bpp: usize,
690) -> Cow<'a, [u8]> {
691    let row_bytes = width as usize * bpp;
692    if stride == row_bytes {
693        // Already packed.
694        let total = row_bytes * rows as usize;
695        Cow::Borrowed(&data[..total])
696    } else {
697        // Need to strip padding.
698        let mut packed = Vec::with_capacity(row_bytes * rows as usize);
699        for y in 0..rows as usize {
700            let start = y * stride;
701            packed.extend_from_slice(&data[start..start + row_bytes]);
702        }
703        Cow::Owned(packed)
704    }
705}
706
707#[cfg(test)]
708mod anchor_tests {
709    //! `convert_buffer_with_anchor` — proof the absolute-luminance anchor
710    //! threads through the PQ `ConvertStep`s (not via any caller pre-scale),
711    //! that it honors a strided source, and that it preserves alpha.
712    use super::convert_buffer_with_anchor;
713    use crate::{PixelDescriptor, TransferFunction};
714    use alloc::vec;
715    use alloc::vec::Vec;
716    use zenpixels::hdr::DiffuseWhite;
717
718    /// f64 SMPTE ST 2084 inverse-EOTF (linear-light fraction → PQ code [0,1]).
719    fn pq_oetf(x: f64) -> f64 {
720        if x <= 0.0 {
721            return 0.0;
722        }
723        let m1 = 2610.0 / 16384.0;
724        let m2 = 2523.0 / 4096.0 * 128.0;
725        let c1 = 3424.0 / 4096.0;
726        let c2 = 2413.0 / 4096.0 * 32.0;
727        let c3 = 2392.0 / 4096.0 * 32.0;
728        let xp = x.powf(m1);
729        ((c1 + c2 * xp) / (1.0 + c3 * xp)).powf(m2)
730    }
731
732    /// Tight RGB f32 bytes from per-pixel gray values.
733    fn gray_rgb_f32(values: &[f32]) -> Vec<u8> {
734        let mut v = Vec::with_capacity(values.len() * 12);
735        for &g in values {
736            for _ in 0..3 {
737                v.extend_from_slice(&g.to_ne_bytes());
738            }
739        }
740        v
741    }
742
743    /// Tight RGBA f32 bytes: per-pixel gray RGB plus its alpha.
744    fn gray_rgba_f32(pixels: &[(f32, f32)]) -> Vec<u8> {
745        let mut v = Vec::with_capacity(pixels.len() * 16);
746        for &(g, a) in pixels {
747            for _ in 0..3 {
748                v.extend_from_slice(&g.to_ne_bytes());
749            }
750            v.extend_from_slice(&a.to_ne_bytes());
751        }
752        v
753    }
754
755    /// Packed bytes-per-row for `desc` at `width` pixels.
756    fn packed_stride(width: usize, desc: PixelDescriptor) -> usize {
757        width * desc.bytes_per_pixel()
758    }
759
760    fn pq16_target() -> (PixelDescriptor, PixelDescriptor) {
761        let target = PixelDescriptor::RGB16_BT2100_PQ;
762        // Tag the source gamut as the target's so no gamut step is inserted.
763        let lin = PixelDescriptor::RGBF32_LINEAR.with_primaries(target.primaries);
764        (lin, target)
765    }
766
767    #[test]
768    fn anchor_pq16_encode_matches_st2084_oracle() {
769        let values = [0.001f32, 0.1, 1.0, 2.0, 49.0];
770        let (lin, target) = pq16_target();
771        let out = convert_buffer_with_anchor(
772            &gray_rgb_f32(&values),
773            values.len() as u32,
774            1,
775            packed_stride(values.len(), lin),
776            lin,
777            target,
778            DiffuseWhite::BT2408,
779        )
780        .unwrap();
781        let codes: &[u16] = bytemuck::cast_slice(out.as_slice().as_strided_bytes());
782        for (i, &v) in values.iter().enumerate() {
783            let got = i64::from(codes[i * 3]);
784            // 1.0 of relative-linear sits at 203 / 10000 of the PQ-absolute range.
785            let want = (pq_oetf(f64::from(v) * 203.0 / 10_000.0) * 65535.0).round() as i64;
786            assert!(
787                (got - want).abs() <= 1,
788                "@203 at {v}: got {got} want {want}"
789            );
790        }
791    }
792
793    #[test]
794    fn anchor_changes_pq_output_in_kernel() {
795        let (lin, target) = pq16_target();
796        let src = gray_rgb_f32(&[1.0]);
797        let enc = |w: DiffuseWhite| {
798            let o = convert_buffer_with_anchor(&src, 1, 1, packed_stride(1, lin), lin, target, w)
799                .unwrap();
800            let ob = o.as_slice().as_strided_bytes();
801            i64::from(u16::from_ne_bytes([ob[0], ob[1]]))
802        };
803        let c100 = enc(DiffuseWhite::new(100.0));
804        let c203 = enc(DiffuseWhite::BT2408);
805        // The same relative-linear 1.0 lands at a different PQ code per anchor —
806        // i.e. the scale is applied inside the kernel, not by a caller.
807        assert_ne!(c100, c203);
808        let want100 = (pq_oetf(100.0 / 10_000.0) * 65535.0).round() as i64;
809        assert!(
810            (c100 - want100).abs() <= 1,
811            "@100: got {c100} want {want100}"
812        );
813    }
814
815    #[test]
816    fn anchor_pq16_decode_divides_and_roundtrips() {
817        // linear @ 203 → PQ16 → linear @ 203 recovers the input: the decode
818        // kernel's ÷scale is the exact inverse of the encode's ×scale.
819        let values = [0.05f32, 0.2, 1.0, 5.0];
820        let (lin, target) = pq16_target();
821        let pq = convert_buffer_with_anchor(
822            &gray_rgb_f32(&values),
823            values.len() as u32,
824            1,
825            packed_stride(values.len(), lin),
826            lin,
827            target,
828            DiffuseWhite::BT2408,
829        )
830        .unwrap();
831        let back = convert_buffer_with_anchor(
832            pq.as_slice().as_strided_bytes(),
833            values.len() as u32,
834            1,
835            pq.stride(),
836            target,
837            lin,
838            DiffuseWhite::BT2408,
839        )
840        .unwrap();
841        let backf: &[f32] = bytemuck::cast_slice(back.as_slice().as_strided_bytes());
842        for (i, &v) in values.iter().enumerate() {
843            let got = backf[i * 3];
844            let rel = ((f64::from(got) - f64::from(v)) / f64::from(v)).abs();
845            assert!(rel < 0.02, "roundtrip @203 at {v}: got {got} (rel {rel})");
846        }
847    }
848
849    #[test]
850    fn anchor_threads_through_f32_pq_slice_kernel() {
851        // RGBF32 linear → RGBF32 PQ exercises the SIMD slice kernel
852        // (LinearF32ToPqF32), a different code path than the u16 kernel.
853        let values = [0.1f32, 1.0, 4.0];
854        let target = PixelDescriptor::RGB16_BT2100_PQ;
855        let lin = PixelDescriptor::RGBF32_LINEAR.with_primaries(target.primaries);
856        let pqf32 = lin.with_transfer(TransferFunction::Pq);
857        let out = convert_buffer_with_anchor(
858            &gray_rgb_f32(&values),
859            values.len() as u32,
860            1,
861            packed_stride(values.len(), lin),
862            lin,
863            pqf32,
864            DiffuseWhite::BT2408,
865        )
866        .unwrap();
867        let encoded: &[f32] = bytemuck::cast_slice(out.as_slice().as_strided_bytes());
868        for (i, &v) in values.iter().enumerate() {
869            let got = f64::from(encoded[i * 3]);
870            let want = pq_oetf(f64::from(v) * 203.0 / 10_000.0);
871            assert!(
872                (got - want).abs() < 1e-3,
873                "f32 PQ @203 at {v}: got {got} want {want}"
874            );
875        }
876    }
877
878    #[test]
879    fn no_anchor_default_is_unscaled() {
880        // DiffuseWhite at 10000 nits ⇒ scale 1.0 ⇒ the kernel treats linear as
881        // already PQ-absolute (the prior behavior). 1.0 linear → PQ code 65535.
882        let (lin, target) = pq16_target();
883        let out = convert_buffer_with_anchor(
884            &gray_rgb_f32(&[1.0]),
885            1,
886            1,
887            packed_stride(1, lin),
888            lin,
889            target,
890            DiffuseWhite::new(10_000.0),
891        )
892        .unwrap();
893        let ob = out.as_slice().as_strided_bytes();
894        assert_eq!(u16::from_ne_bytes([ob[0], ob[1]]), 65535);
895    }
896
897    #[test]
898    fn anchor_preserves_alpha_through_rgba_pq16() {
899        // RGBA f32 linear → RGBA16 PQ: the RGB lanes take the PQ OETF + anchor;
900        // alpha rides through linearly (never PQ-encoded, never anchor-scaled) —
901        // the alpha-preserving `_rgba_slice` kernel path.
902        let rgb_pq = PixelDescriptor::RGB16_BT2100_PQ;
903        let src = PixelDescriptor::RGBAF32_LINEAR.with_primaries(rgb_pq.primaries);
904        let target = PixelDescriptor::RGBA16
905            .with_transfer(TransferFunction::Pq)
906            .with_primaries(rgb_pq.primaries);
907        let pixels = [(1.0f32, 0.5f32), (2.0, 0.25)];
908        let out = convert_buffer_with_anchor(
909            &gray_rgba_f32(&pixels),
910            pixels.len() as u32,
911            1,
912            packed_stride(pixels.len(), src),
913            src,
914            target,
915            DiffuseWhite::BT2408,
916        )
917        .unwrap();
918        let codes: &[u16] = bytemuck::cast_slice(out.as_slice().as_strided_bytes());
919        for (i, &(g, a)) in pixels.iter().enumerate() {
920            let r = i64::from(codes[i * 4]);
921            let want_rgb = (pq_oetf(f64::from(g) * 203.0 / 10_000.0) * 65535.0).round() as i64;
922            assert!(
923                (r - want_rgb).abs() <= 1,
924                "rgb @203 at {g}: got {r} want {want_rgb}"
925            );
926            // Alpha is linear → u16; PQ-encoding it would give a wildly wrong code.
927            let alpha = codes[i * 4 + 3];
928            let want_a = (f64::from(a) * 65535.0).round() as u16;
929            assert_eq!(
930                alpha, want_a,
931                "alpha must pass through linearly: got {alpha} want {want_a}"
932            );
933        }
934    }
935
936    #[test]
937    fn anchor_honors_source_stride() {
938        // A padded source stride with sentinel padding (999.0, which would clip
939        // to 65535 if it leaked) must convert identically to the packed source.
940        let (lin, target) = pq16_target();
941        let row_vals = [0.1f32, 1.0, 3.0];
942        let width = row_vals.len() as u32;
943        let rows = 2u32;
944        let row = packed_stride(row_vals.len(), lin);
945        let stride = row + 2 * 12; // two sentinel pixels of padding per row
946
947        let mut packed = gray_rgb_f32(&row_vals);
948        packed.extend_from_slice(&gray_rgb_f32(&row_vals));
949        let want = convert_buffer_with_anchor(
950            &packed,
951            width,
952            rows,
953            row,
954            lin,
955            target,
956            DiffuseWhite::BT2408,
957        )
958        .unwrap();
959
960        let mut strided = vec![0u8; stride * rows as usize];
961        for y in 0..rows as usize {
962            let s = y * stride;
963            strided[s..s + row].copy_from_slice(&gray_rgb_f32(&row_vals));
964            for b in strided[s + row..s + stride].chunks_exact_mut(4) {
965                b.copy_from_slice(&999.0f32.to_ne_bytes());
966            }
967        }
968        let got = convert_buffer_with_anchor(
969            &strided,
970            width,
971            rows,
972            stride,
973            lin,
974            target,
975            DiffuseWhite::BT2408,
976        )
977        .unwrap();
978        assert_eq!(
979            got.as_slice().as_strided_bytes(),
980            want.as_slice().as_strided_bytes(),
981            "strided source must convert identically to packed"
982        );
983    }
984}
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989    use zenpixels::descriptor::{ColorPrimaries, SignalRange};
990    use zenpixels::policy::{AlphaPolicy, DepthPolicy};
991
992    /// 2×1 RGB8 pixel data (6 bytes).
993    fn test_rgb8_data() -> Vec<u8> {
994        vec![255, 0, 0, 0, 255, 0]
995    }
996
997    // ── try_adapt_in_place ─────────────────────────────────────────
998
999    fn buf_from(bytes: &[u8], w: u32, h: u32, desc: PixelDescriptor) -> zenpixels::PixelBuffer {
1000        zenpixels::PixelBuffer::from_vec(bytes.to_vec(), w, h, desc).unwrap()
1001    }
1002
1003    #[test]
1004    fn in_place_bgra_to_rgba_swaps_bytes_and_updates_buffer() {
1005        // Bgra8 stores B,G,R,A. Two pixels.
1006        let mut buf = buf_from(
1007            &[10u8, 20, 30, 255, 40, 50, 60, 128],
1008            2,
1009            1,
1010            PixelDescriptor::BGRA8_SRGB,
1011        );
1012        try_adapt_in_place(&mut buf, PixelDescriptor::RGBA8_SRGB)
1013            .expect("4bpp B<->R swap is in-place");
1014        assert_eq!(buf.descriptor(), PixelDescriptor::RGBA8_SRGB);
1015        assert_eq!(buf.as_slice().row(0), &[30u8, 20, 10, 255, 60, 50, 40, 128]);
1016    }
1017
1018    #[test]
1019    fn in_place_rgba_to_bgra_roundtrips() {
1020        let original = [1u8, 2, 3, 4, 5, 6, 7, 8];
1021        let mut buf = buf_from(&original, 2, 1, PixelDescriptor::RGBA8_SRGB);
1022        try_adapt_in_place(&mut buf, PixelDescriptor::BGRA8_SRGB).expect("to bgra");
1023        assert_eq!(buf.as_slice().row(0), &[3u8, 2, 1, 4, 7, 6, 5, 8]);
1024        try_adapt_in_place(&mut buf, PixelDescriptor::RGBA8_SRGB).expect("back to rgba");
1025        assert_eq!(buf.as_slice().row(0), &original[..]);
1026    }
1027
1028    #[test]
1029    fn in_place_swap_respects_stride_padding() {
1030        // SIMD-aligned buffer: 1 px/row at simd_align 16 → stride 16,
1031        // 12 padding bytes per row that must be untouched.
1032        let mut buf =
1033            zenpixels::PixelBuffer::new_simd_aligned(1, 2, PixelDescriptor::BGRA8_SRGB, 16);
1034        assert_eq!(buf.stride(), 16, "fixture must be strided");
1035        {
1036            let mut view = buf.as_slice_mut();
1037            view.row_mut(0).copy_from_slice(&[10, 20, 30, 255]);
1038            view.row_mut(1).copy_from_slice(&[40, 50, 60, 128]);
1039            let backing = view.as_strided_bytes_mut();
1040            backing[4..16].fill(0xAA);
1041            backing[20..32].fill(0xBB);
1042        }
1043        try_adapt_in_place(&mut buf, PixelDescriptor::RGBA8_SRGB).expect("strided swap");
1044        assert_eq!(buf.as_slice().row(0), &[30u8, 20, 10, 255]);
1045        assert_eq!(buf.as_slice().row(1), &[60u8, 50, 40, 128]);
1046        let view = buf.as_slice();
1047        let backing = view.as_strided_bytes();
1048        assert!(
1049            backing[4..16].iter().all(|&b| b == 0xAA),
1050            "row-0 padding must be untouched"
1051        );
1052        assert!(
1053            backing[20..28].iter().all(|&b| b == 0xBB),
1054            "row-1 padding must be untouched"
1055        );
1056    }
1057
1058    #[test]
1059    fn in_place_metadata_retag_moves_no_bytes() {
1060        let original = [1u8, 2, 3, 4, 5, 6];
1061        let mut buf = buf_from(&original, 2, 1, PixelDescriptor::RGB8);
1062        let target = PixelDescriptor::RGB8_SRGB.with_primaries(ColorPrimaries::DisplayP3);
1063        try_adapt_in_place(&mut buf, target).expect("same-format retag");
1064        assert_eq!(buf.descriptor(), target);
1065        assert_eq!(buf.as_slice().row(0), &original[..]);
1066    }
1067
1068    #[test]
1069    fn in_place_rejects_live_alpha_drop_and_depth_changes_unchanged() {
1070        // RGBA(Straight) -> RGB would discard live alpha; must leave the
1071        // buffer untouched (adapt_for_encode mattes instead).
1072        let original = [1u8, 2, 3, 4, 5, 6, 7, 8];
1073        let mut buf = buf_from(&original, 2, 1, PixelDescriptor::RGBA8_SRGB);
1074        try_adapt_in_place(&mut buf, PixelDescriptor::RGB8_SRGB)
1075            .expect_err("straight-alpha drop is not contract-exact");
1076        assert_eq!(buf.descriptor(), PixelDescriptor::RGBA8_SRGB);
1077        assert_eq!(buf.as_slice().row(0), &original[..]);
1078
1079        // Bit-depth change likewise.
1080        try_adapt_in_place(&mut buf, PixelDescriptor::RGBA16_SRGB)
1081            .expect_err("depth change cannot be in-place");
1082        assert_eq!(buf.as_slice().row(0), &original[..]);
1083    }
1084
1085    #[test]
1086    fn in_place_rgbx_to_rgb_compacts_and_buffer_adopts_geometry() {
1087        // RGBX (Undefined padding): the X byte is contract-droppable.
1088        // 2 px/row, 2 rows, tight 4bpp stride 8 → out stride rounds down
1089        // to 6 (tight for 3bpp) and the buffer's own stride/descriptor
1090        // update atomically.
1091        let mut buf = buf_from(
1092            &[
1093                1u8, 2, 3, 0xEE, 4, 5, 6, 0xEE, // row 0
1094                7, 8, 9, 0xEE, 10, 11, 12, 0xEE, // row 1
1095            ],
1096            2,
1097            2,
1098            PixelDescriptor::RGBX8_SRGB,
1099        );
1100        try_adapt_in_place(&mut buf, PixelDescriptor::RGB8_SRGB)
1101            .expect("padding drop is contract-exact");
1102        assert_eq!(buf.descriptor(), PixelDescriptor::RGB8_SRGB);
1103        assert_eq!(buf.stride(), 6);
1104        assert_eq!(buf.as_slice().row(0), &[1u8, 2, 3, 4, 5, 6]);
1105        assert_eq!(buf.as_slice().row(1), &[7u8, 8, 9, 10, 11, 12]);
1106    }
1107
1108    #[test]
1109    fn drop_lane_impl_keeps_divisible_stride_rows_in_place() {
1110        // Stride 12 (divisible by 3): rows stay at their own bases —
1111        // zero cross-row movement, freed bytes become padding. Exercised
1112        // at the transform level because the public constructors only
1113        // produce pixel-tight or simd-aligned strides.
1114        let mut bytes = [
1115            1u8, 2, 3, 0xEE, 4, 5, 6, 0xEE, 0xAA, 0xAA, 0xAA, 0xAA, // row 0 + pad
1116            7, 8, 9, 0xEE, 10, 11, 12, 0xEE, 0xBB, 0xBB, 0xBB, 0xBB, // row 1 + pad
1117        ];
1118        let px =
1119            zenpixels::InPlacePixels::new(&mut bytes, 2, 2, 12, PixelDescriptor::RGBX8_SRGB, None);
1120        let out = drop_lane_impl(px, PixelDescriptor::RGB8_SRGB, &[0, 1, 2], 4, 3, 1);
1121        assert_eq!(out.stride(), 12, "divisible stride preserved verbatim");
1122        assert_eq!(out.row(0), &[1u8, 2, 3, 4, 5, 6]);
1123        assert_eq!(out.row(1), &[7u8, 8, 9, 10, 11, 12]);
1124        drop(out);
1125        assert_eq!(&bytes[8..12], &[0xAA; 4], "row-0 tail padding untouched");
1126        assert_eq!(&bytes[20..24], &[0xBB; 4], "row-1 tail padding untouched");
1127    }
1128
1129    #[test]
1130    fn in_place_opaque_bgra_to_rgb_reorders_while_dropping() {
1131        // Declared-Opaque BGRA -> RGB: lane drop + B<->R reorder.
1132        let mut buf = buf_from(
1133            &[10u8, 20, 30, 255, 40, 50, 60, 255],
1134            2,
1135            1,
1136            PixelDescriptor::BGRA8_SRGB.with_alpha_mode(Some(AlphaMode::Opaque)),
1137        );
1138        try_adapt_in_place(&mut buf, PixelDescriptor::RGB8_SRGB).expect("opaque drop allowed");
1139        assert_eq!(buf.as_slice().row(0), &[30u8, 20, 10, 60, 50, 40]);
1140    }
1141
1142    #[test]
1143    fn in_place_opaque_rgba16_to_rgb16_drops_lane() {
1144        // U16 lane drop: element-wise (2-byte) selection.
1145        let px16 = |r: u16, g: u16, b: u16| {
1146            [r, g, b, 0xFFFF]
1147                .iter()
1148                .flat_map(|v| v.to_ne_bytes())
1149                .collect::<Vec<u8>>()
1150        };
1151        let bytes: Vec<u8> = [px16(0x1234, 0x5678, 0x9ABC), px16(0x1111, 0x2222, 0x3333)].concat();
1152        let mut buf = buf_from(
1153            &bytes,
1154            2,
1155            1,
1156            PixelDescriptor::RGBA16_SRGB.with_alpha_mode(Some(AlphaMode::Opaque)),
1157        );
1158        try_adapt_in_place(&mut buf, PixelDescriptor::RGB16_SRGB).expect("u16 lane drop");
1159        let expected: Vec<u8> = [0x1234u16, 0x5678, 0x9ABC, 0x1111, 0x2222, 0x3333]
1160            .iter()
1161            .flat_map(|v| v.to_ne_bytes())
1162            .collect();
1163        assert_eq!(buf.as_slice().row(0), &expected[..]);
1164        assert_eq!(buf.stride(), 12, "16-px input stride rounds to 12");
1165    }
1166
1167    #[test]
1168    fn in_place_opaque_graya_to_gray_matches_allocating_path() {
1169        // Differential vs convert_buffer on the same transition.
1170        let original = [10u8, 255, 20, 255, 30, 255, 40, 255];
1171        let src = PixelDescriptor::new(
1172            ChannelType::U8,
1173            ChannelLayout::GrayAlpha,
1174            Some(AlphaMode::Opaque),
1175            zenpixels::TransferFunction::Srgb,
1176        );
1177        let target = PixelDescriptor::GRAY8_SRGB;
1178
1179        let mut buf = buf_from(&original, 4, 1, src);
1180        try_adapt_in_place(&mut buf, target).expect("graya drop");
1181        let in_place_row = buf.as_slice().row(0).to_vec();
1182
1183        let allocated = convert_buffer(&original, 4, 1, src, target).expect("allocating path");
1184        assert_eq!(in_place_row, allocated, "in-place must match allocating");
1185    }
1186
1187    #[test]
1188    fn transfer_agnostic_match_requires_same_primaries() {
1189        let data = test_rgb8_data();
1190        let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt2020);
1191        let target = PixelDescriptor::RGB8_SRGB; // BT.709 primaries
1192
1193        let result = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap();
1194
1195        // Must NOT zero-copy relabel — primaries differ, conversion is needed.
1196        // Before the fix, this would return Cow::Borrowed (zero-copy) via the
1197        // transfer-agnostic match, silently relabeling BT.2020 as BT.709.
1198        assert!(
1199            matches!(result.data, Cow::Owned(_)),
1200            "different primaries must trigger conversion, not zero-copy relabel"
1201        );
1202    }
1203
1204    /// A signal-range mismatch refuses loudly. No Narrow↔Full kernels
1205    /// exist, so neither zero-copy relabeling nor an allocating
1206    /// "conversion" is acceptable — the latter would emit narrow-coded
1207    /// values under a full-range label (this test's predecessor codified
1208    /// exactly that bug by asserting only that an allocation happened).
1209    #[test]
1210    fn signal_range_mismatch_refuses_not_relabels() {
1211        let data = test_rgb8_data();
1212        let source = PixelDescriptor::RGB8.with_signal_range(SignalRange::Narrow);
1213        let target = PixelDescriptor::RGB8_SRGB; // Full range
1214
1215        let err = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap_err();
1216        assert!(
1217            matches!(*err.error(), ConvertError::NoPath { .. }),
1218            "range crossing must refuse (no kernels), got: {}",
1219            err.error()
1220        );
1221    }
1222
1223    /// Narrow data is accepted verbatim when a same-range target is offered:
1224    /// the transfer-agnostic zero-copy arm applies as usual once the signal
1225    /// ranges agree.
1226    #[test]
1227    fn signal_range_match_zero_copies_narrow_verbatim() {
1228        let data = test_rgb8_data();
1229        let source = PixelDescriptor::RGB8
1230            .with_primaries(ColorPrimaries::Bt709)
1231            .with_signal_range(SignalRange::Narrow);
1232        let full_target = PixelDescriptor::RGB8_SRGB;
1233        let narrow_target = PixelDescriptor::RGB8_SRGB.with_signal_range(SignalRange::Narrow);
1234
1235        let result =
1236            adapt_for_encode(&data, source, 2, 1, 6, &[full_target, narrow_target]).unwrap();
1237        assert!(
1238            matches!(result.data, Cow::Borrowed(_)),
1239            "same-range target must zero-copy"
1240        );
1241        assert_eq!(result.descriptor.signal_range, SignalRange::Narrow);
1242    }
1243
1244    #[test]
1245    fn transfer_agnostic_match_allows_zero_copy_when_all_match() {
1246        let data = test_rgb8_data();
1247        // Source: RGB8 with unknown transfer, BT.709, Full range.
1248        let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt709);
1249        // Target: RGB8 sRGB with same primaries and range.
1250        let target = PixelDescriptor::RGB8_SRGB;
1251
1252        let result = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap();
1253
1254        // Should zero-copy (only transfer differs, which is the agnostic part).
1255        assert!(
1256            matches!(result.data, Cow::Borrowed(_)),
1257            "should be zero-copy when only transfer differs"
1258        );
1259        assert_eq!(result.descriptor, target);
1260    }
1261
1262    #[test]
1263    fn exact_match_is_zero_copy() {
1264        let data = test_rgb8_data();
1265        let desc = PixelDescriptor::RGB8_SRGB;
1266
1267        let result = adapt_for_encode(&data, desc, 2, 1, 6, &[desc]).unwrap();
1268
1269        assert!(matches!(result.data, Cow::Borrowed(_)));
1270        assert_eq!(result.descriptor, desc);
1271    }
1272
1273    #[test]
1274    #[should_panic(expected = "CMYK pixel data cannot be processed")]
1275    fn cmyk_rejected_by_adapt_for_encode() {
1276        let cmyk_data = vec![0u8; 4 * 4]; // 4 pixels
1277        let _ = adapt_for_encode(
1278            &cmyk_data,
1279            PixelDescriptor::CMYK8,
1280            2,
1281            2,
1282            8,
1283            &[PixelDescriptor::RGB8_SRGB],
1284        );
1285    }
1286
1287    #[test]
1288    #[should_panic(expected = "CMYK pixel data cannot be processed")]
1289    fn cmyk_rejected_by_convert_buffer() {
1290        let cmyk_data = vec![0u8; 4 * 4];
1291        let _ = convert_buffer(
1292            &cmyk_data,
1293            2,
1294            2,
1295            PixelDescriptor::CMYK8,
1296            PixelDescriptor::RGB8_SRGB,
1297        );
1298    }
1299
1300    #[test]
1301    #[should_panic(expected = "CMYK pixel data cannot be processed")]
1302    fn cmyk_rejected_by_convert_buffer_as_target() {
1303        let rgb_data = vec![0u8; 3 * 4];
1304        let _ = convert_buffer(
1305            &rgb_data,
1306            2,
1307            2,
1308            PixelDescriptor::RGB8_SRGB,
1309            PixelDescriptor::CMYK8,
1310        );
1311    }
1312
1313    #[test]
1314    fn explicit_variant_also_checks_primaries() {
1315        let data = test_rgb8_data();
1316        let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt2020);
1317        let target = PixelDescriptor::RGB8_SRGB;
1318        let options = ConvertOptions::forbid_lossy()
1319            .with_alpha_policy(AlphaPolicy::DiscardUnchecked)
1320            .with_depth_policy(DepthPolicy::Round);
1321
1322        let result =
1323            adapt_for_encode_explicit(&data, source, 2, 1, 6, &[target], &options).unwrap();
1324
1325        assert!(
1326            matches!(result.data, Cow::Owned(_)),
1327            "explicit variant: different primaries must trigger conversion"
1328        );
1329    }
1330}