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::{ConvertError, PixelDescriptor};
70use whereat::{At, ResultAtExt};
71
72/// Result of format adaptation: the converted data and its descriptor.
73#[derive(Clone, Debug)]
74pub struct Adapted<'a> {
75    /// Pixel data — borrowed if no conversion was needed, owned otherwise.
76    pub data: Cow<'a, [u8]>,
77    /// The pixel format of `data`.
78    pub descriptor: PixelDescriptor,
79    /// Width of the pixel data.
80    pub width: u32,
81    /// Number of rows.
82    pub rows: u32,
83}
84
85/// Negotiate format and convert pixel data for encoding.
86///
87/// Uses [`ConvertIntent::Fastest`] — minimizes conversion cost.
88///
89/// If the input already matches one of the `supported` formats, returns
90/// `Cow::Borrowed` (zero-copy). Otherwise, converts to the best match.
91///
92/// # Arguments
93///
94/// * `data` - Raw pixel bytes, `rows * stride` bytes minimum.
95/// * `descriptor` - Format of the input data.
96/// * `width` - Pixels per row.
97/// * `rows` - Number of rows.
98/// * `stride` - Bytes between row starts (use `width * descriptor.bytes_per_pixel()` for packed).
99/// * `supported` - Formats the encoder accepts.
100#[track_caller]
101pub fn adapt_for_encode<'a>(
102    data: &'a [u8],
103    descriptor: PixelDescriptor,
104    width: u32,
105    rows: u32,
106    stride: usize,
107    supported: &[PixelDescriptor],
108) -> Result<Adapted<'a>, At<ConvertError>> {
109    adapt_for_encode_with_intent(
110        data,
111        descriptor,
112        width,
113        rows,
114        stride,
115        supported,
116        ConvertIntent::Fastest,
117    )
118}
119
120/// Negotiate format and convert with intent awareness.
121///
122/// Like [`adapt_for_encode`], but lets the caller specify a [`ConvertIntent`].
123#[track_caller]
124pub fn adapt_for_encode_with_intent<'a>(
125    data: &'a [u8],
126    descriptor: PixelDescriptor,
127    width: u32,
128    rows: u32,
129    stride: usize,
130    supported: &[PixelDescriptor],
131    intent: ConvertIntent,
132) -> Result<Adapted<'a>, At<ConvertError>> {
133    if supported.is_empty() {
134        return Err(whereat::at!(ConvertError::EmptyFormatList));
135    }
136
137    // Check for exact match (zero-copy path).
138    if supported.contains(&descriptor) {
139        return Ok(Adapted {
140            data: contiguous_from_strided(data, width, rows, stride, descriptor.bytes_per_pixel()),
141            descriptor,
142            width,
143            rows,
144        });
145    }
146
147    // Check for transfer-agnostic match: if source has Unknown transfer
148    // and a supported format matches on everything except transfer, it's
149    // still a zero-copy path. Primaries and signal range must also match
150    // — relabeling BT.2020 as BT.709 without gamut conversion is wrong.
151    for &target in supported {
152        if descriptor.channel_type() == target.channel_type()
153            && descriptor.layout() == target.layout()
154            && descriptor.alpha() == target.alpha()
155            && descriptor.primaries == target.primaries
156            && descriptor.signal_range == target.signal_range
157        {
158            return Ok(Adapted {
159                data: contiguous_from_strided(
160                    data,
161                    width,
162                    rows,
163                    stride,
164                    descriptor.bytes_per_pixel(),
165                ),
166                descriptor: target,
167                width,
168                rows,
169            });
170        }
171    }
172
173    // Need conversion — pick best target.
174    let target = best_match(descriptor, supported, intent)
175        .ok_or_else(|| whereat::at!(ConvertError::EmptyFormatList))?;
176
177    let mut converter = RowConverter::new(descriptor, target).at()?;
178
179    let src_bpp = descriptor.bytes_per_pixel();
180    let dst_bpp = target.bytes_per_pixel();
181    let dst_stride = (width as usize) * dst_bpp;
182    let mut output = vec![0u8; dst_stride * rows as usize];
183
184    for y in 0..rows {
185        let src_start = y as usize * stride;
186        let src_end = src_start + (width as usize * src_bpp);
187        let dst_start = y as usize * dst_stride;
188        let dst_end = dst_start + dst_stride;
189        converter.convert_row(
190            &data[src_start..src_end],
191            &mut output[dst_start..dst_end],
192            width,
193        );
194    }
195
196    Ok(Adapted {
197        data: Cow::Owned(output),
198        descriptor: target,
199        width,
200        rows,
201    })
202}
203
204/// Convert a raw byte buffer from one format to another.
205///
206/// Assumes packed (stride = width * bpp) layout.
207#[track_caller]
208pub fn convert_buffer(
209    src: &[u8],
210    width: u32,
211    rows: u32,
212    from: PixelDescriptor,
213    to: PixelDescriptor,
214) -> Result<Vec<u8>, At<ConvertError>> {
215    if from == to {
216        return Ok(src.to_vec());
217    }
218
219    let mut converter = RowConverter::new(from, to).at()?;
220    let src_bpp = from.bytes_per_pixel();
221    let dst_bpp = to.bytes_per_pixel();
222    let src_stride = (width as usize) * src_bpp;
223    let dst_stride = (width as usize) * dst_bpp;
224    let mut output = vec![0u8; dst_stride * rows as usize];
225
226    for y in 0..rows {
227        let src_start = y as usize * src_stride;
228        let src_end = src_start + src_stride;
229        let dst_start = y as usize * dst_stride;
230        let dst_end = dst_start + dst_stride;
231        converter.convert_row(
232            &src[src_start..src_end],
233            &mut output[dst_start..dst_end],
234            width,
235        );
236    }
237
238    Ok(output)
239}
240
241/// Negotiate format and convert with explicit policies.
242///
243/// Like [`adapt_for_encode`], but enforces [`ConvertOptions`] policies
244/// on the conversion. Returns an error if a policy forbids the required
245/// conversion.
246#[track_caller]
247pub fn adapt_for_encode_explicit<'a>(
248    data: &'a [u8],
249    descriptor: PixelDescriptor,
250    width: u32,
251    rows: u32,
252    stride: usize,
253    supported: &[PixelDescriptor],
254    options: &ConvertOptions,
255) -> Result<Adapted<'a>, At<ConvertError>> {
256    if supported.is_empty() {
257        return Err(whereat::at!(ConvertError::EmptyFormatList));
258    }
259
260    // Check for exact match (zero-copy path).
261    if supported.contains(&descriptor) {
262        return Ok(Adapted {
263            data: contiguous_from_strided(data, width, rows, stride, descriptor.bytes_per_pixel()),
264            descriptor,
265            width,
266            rows,
267        });
268    }
269
270    // Check for transfer-agnostic match (primaries and signal range must match).
271    for &target in supported {
272        if descriptor.channel_type() == target.channel_type()
273            && descriptor.layout() == target.layout()
274            && descriptor.alpha() == target.alpha()
275            && descriptor.primaries == target.primaries
276            && descriptor.signal_range == target.signal_range
277        {
278            return Ok(Adapted {
279                data: contiguous_from_strided(
280                    data,
281                    width,
282                    rows,
283                    stride,
284                    descriptor.bytes_per_pixel(),
285                ),
286                descriptor: target,
287                width,
288                rows,
289            });
290        }
291    }
292
293    // Need conversion — pick best target, then validate policies.
294    let target = best_match(descriptor, supported, ConvertIntent::Fastest)
295        .ok_or_else(|| whereat::at!(ConvertError::EmptyFormatList))?;
296
297    // Validate policies before doing work.
298    let plan = ConvertPlan::new_explicit(descriptor, target, options).at()?;
299
300    // Runtime opacity check for DiscardIfOpaque.
301    let drops_alpha = descriptor.alpha().is_some() && target.alpha().is_none();
302    if drops_alpha && options.alpha_policy == AlphaPolicy::DiscardIfOpaque {
303        let src_bpp = descriptor.bytes_per_pixel();
304        if !is_fully_opaque(data, width, rows, stride, src_bpp, &descriptor) {
305            return Err(whereat::at!(ConvertError::AlphaNotOpaque));
306        }
307    }
308
309    let mut converter = RowConverter::from_plan(plan);
310    let src_bpp = descriptor.bytes_per_pixel();
311    let dst_bpp = target.bytes_per_pixel();
312    let dst_stride = (width as usize) * dst_bpp;
313    let mut output = vec![0u8; dst_stride * rows as usize];
314
315    for y in 0..rows {
316        let src_start = y as usize * stride;
317        let src_end = src_start + (width as usize * src_bpp);
318        let dst_start = y as usize * dst_stride;
319        let dst_end = dst_start + dst_stride;
320        converter.convert_row(
321            &data[src_start..src_end],
322            &mut output[dst_start..dst_end],
323            width,
324        );
325    }
326
327    Ok(Adapted {
328        data: Cow::Owned(output),
329        descriptor: target,
330        width,
331        rows,
332    })
333}
334
335/// Check if all alpha values in a strided buffer are fully opaque.
336fn is_fully_opaque(
337    data: &[u8],
338    width: u32,
339    rows: u32,
340    stride: usize,
341    bpp: usize,
342    desc: &PixelDescriptor,
343) -> bool {
344    if desc.alpha().is_none() {
345        return true;
346    }
347    let cs = desc.channel_type().byte_size();
348    let alpha_offset = (desc.layout().channels() - 1) * cs;
349    for y in 0..rows {
350        let row_start = y as usize * stride;
351        for x in 0..width as usize {
352            let off = row_start + x * bpp + alpha_offset;
353            match desc.channel_type() {
354                crate::ChannelType::U8 => {
355                    if data[off] != 255 {
356                        return false;
357                    }
358                }
359                crate::ChannelType::U16 => {
360                    let v = u16::from_ne_bytes([data[off], data[off + 1]]);
361                    if v != 65535 {
362                        return false;
363                    }
364                }
365                crate::ChannelType::F32 => {
366                    let v = f32::from_ne_bytes([
367                        data[off],
368                        data[off + 1],
369                        data[off + 2],
370                        data[off + 3],
371                    ]);
372                    if v < 1.0 {
373                        return false;
374                    }
375                }
376                _ => return false,
377            }
378        }
379    }
380    true
381}
382
383/// Extract contiguous packed rows from potentially strided data.
384fn contiguous_from_strided<'a>(
385    data: &'a [u8],
386    width: u32,
387    rows: u32,
388    stride: usize,
389    bpp: usize,
390) -> Cow<'a, [u8]> {
391    let row_bytes = width as usize * bpp;
392    if stride == row_bytes {
393        // Already packed.
394        let total = row_bytes * rows as usize;
395        Cow::Borrowed(&data[..total])
396    } else {
397        // Need to strip padding.
398        let mut packed = Vec::with_capacity(row_bytes * rows as usize);
399        for y in 0..rows as usize {
400            let start = y * stride;
401            packed.extend_from_slice(&data[start..start + row_bytes]);
402        }
403        Cow::Owned(packed)
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use zenpixels::descriptor::{ColorPrimaries, SignalRange};
411    use zenpixels::policy::{AlphaPolicy, DepthPolicy, GrayExpand};
412
413    /// 2×1 RGB8 pixel data (6 bytes).
414    fn test_rgb8_data() -> Vec<u8> {
415        vec![255, 0, 0, 0, 255, 0]
416    }
417
418    #[test]
419    fn transfer_agnostic_match_requires_same_primaries() {
420        let data = test_rgb8_data();
421        let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt2020);
422        let target = PixelDescriptor::RGB8_SRGB; // BT.709 primaries
423
424        let result = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap();
425
426        // Must NOT zero-copy relabel — primaries differ, conversion is needed.
427        // Before the fix, this would return Cow::Borrowed (zero-copy) via the
428        // transfer-agnostic match, silently relabeling BT.2020 as BT.709.
429        assert!(
430            matches!(result.data, Cow::Owned(_)),
431            "different primaries must trigger conversion, not zero-copy relabel"
432        );
433    }
434
435    #[test]
436    fn transfer_agnostic_match_requires_same_signal_range() {
437        let data = test_rgb8_data();
438        let source = PixelDescriptor::RGB8.with_signal_range(SignalRange::Narrow);
439        let target = PixelDescriptor::RGB8_SRGB; // Full range
440
441        let result = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap();
442
443        // Must not zero-copy relabel — signal ranges differ.
444        assert!(
445            matches!(result.data, Cow::Owned(_)),
446            "different signal range must trigger conversion, not zero-copy relabel"
447        );
448    }
449
450    #[test]
451    fn transfer_agnostic_match_allows_zero_copy_when_all_match() {
452        let data = test_rgb8_data();
453        // Source: RGB8 with unknown transfer, BT.709, Full range.
454        let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt709);
455        // Target: RGB8 sRGB with same primaries and range.
456        let target = PixelDescriptor::RGB8_SRGB;
457
458        let result = adapt_for_encode(&data, source, 2, 1, 6, &[target]).unwrap();
459
460        // Should zero-copy (only transfer differs, which is the agnostic part).
461        assert!(
462            matches!(result.data, Cow::Borrowed(_)),
463            "should be zero-copy when only transfer differs"
464        );
465        assert_eq!(result.descriptor, target);
466    }
467
468    #[test]
469    fn exact_match_is_zero_copy() {
470        let data = test_rgb8_data();
471        let desc = PixelDescriptor::RGB8_SRGB;
472
473        let result = adapt_for_encode(&data, desc, 2, 1, 6, &[desc]).unwrap();
474
475        assert!(matches!(result.data, Cow::Borrowed(_)));
476        assert_eq!(result.descriptor, desc);
477    }
478
479    #[test]
480    fn explicit_variant_also_checks_primaries() {
481        let data = test_rgb8_data();
482        let source = PixelDescriptor::RGB8.with_primaries(ColorPrimaries::Bt2020);
483        let target = PixelDescriptor::RGB8_SRGB;
484        let options = ConvertOptions {
485            gray_expand: GrayExpand::Broadcast,
486            alpha_policy: AlphaPolicy::DiscardUnchecked,
487            depth_policy: DepthPolicy::Round,
488            luma: None,
489        };
490
491        let result =
492            adapt_for_encode_explicit(&data, source, 2, 1, 6, &[target], &options).unwrap();
493
494        assert!(
495            matches!(result.data, Cow::Owned(_)),
496            "explicit variant: different primaries must trigger conversion"
497        );
498    }
499}