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