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