Skip to main content

zenpixels_convert/
converter.rs

1//! Pre-computed row converter.
2//!
3//! [`RowConverter`] wraps a [`ConvertPlan`] with pre-allocated scratch
4//! buffers for zero-allocation-per-row streaming conversion.
5
6use alloc::boxed::Box;
7
8use crate::convert::{ConvertPlan, ConvertScratch, convert_row_buffered};
9use crate::{ChannelLayout, ConvertError, PixelDescriptor};
10use whereat::{At, ResultAtExt};
11
12/// Pre-computed pixel format converter with pre-allocated scratch buffers.
13///
14/// Create once, then call [`convert_row`](Self::convert_row) for each row.
15/// Multi-step conversions reuse internal scratch buffers, eliminating
16/// per-row heap allocation.
17///
18/// # Example
19///
20/// ```rust,ignore
21/// use zenpixels::{RowConverter, PixelDescriptor};
22///
23/// let mut conv = RowConverter::new(
24///     PixelDescriptor::RGB8_SRGB,
25///     PixelDescriptor::RGBA8_SRGB,
26/// )?;
27///
28/// for y in 0..height {
29///     conv.convert_row(&src_row, &mut dst_row, width);
30/// }
31/// ```
32pub struct RowConverter {
33    plan: ConvertPlan,
34    scratch: ConvertScratch,
35    /// External CMS transform that bypasses the plan entirely.
36    /// Set by `new_explicit_with_cms` when a plugin accepts the conversion.
37    external: Option<ExternalTransform>,
38}
39
40/// External CMS transform variant.
41enum ExternalTransform {
42    /// Stateless, shareable. Supports cheap clone and parallel use.
43    Shared(alloc::sync::Arc<dyn crate::cms::RowTransform>),
44    /// Owned, stateful. Unique per `RowConverter`.
45    Owned(Box<dyn crate::cms::RowTransformMut>),
46}
47
48impl RowConverter {
49    /// Create a converter from `from` to `to`.
50    ///
51    /// Cross-profile conversions (different primaries or transfer) go
52    /// through the default CMS dispatch chain — see
53    /// [`new_explicit_with_cms`](Self::new_explicit_with_cms) for details.
54    /// Returns `Err` if no conversion path exists between the formats.
55    #[track_caller]
56    pub fn new(from: PixelDescriptor, to: PixelDescriptor) -> Result<Self, At<ConvertError>> {
57        Self::new_explicit_with_cms(from, to, &crate::policy::ConvertOptions::permissive(), None)
58    }
59
60    /// Create a converter with explicit policy options.
61    ///
62    /// Like [`new`](Self::new) but validates [`ConvertOptions`] policies
63    /// (alpha removal, depth reduction, RGB→Gray). Cross-profile
64    /// conversions go through the default CMS dispatch chain.
65    ///
66    /// [`ConvertOptions`]: crate::policy::ConvertOptions
67    #[track_caller]
68    pub fn new_explicit(
69        from: PixelDescriptor,
70        to: PixelDescriptor,
71        options: &crate::policy::ConvertOptions,
72    ) -> Result<Self, At<ConvertError>> {
73        Self::new_explicit_with_cms(from, to, options, None)
74    }
75
76    /// Create a converter that may delegate the color conversion to a
77    /// [`PluggableCms`].
78    ///
79    /// When `cms` is `Some` and the source and destination have different
80    /// primaries or transfer functions, the plugin is asked to supply a
81    /// row transform for the full `(from, to)` pair. If it accepts, the
82    /// plan becomes a single external-transform step; the built-in gamut
83    /// matrix and matlut fast paths are bypassed for that conversion.
84    /// If the plugin declines or there is no color work to do, behavior
85    /// matches [`new_explicit`](Self::new_explicit).
86    ///
87    /// [`PluggableCms`]: crate::cms::PluggableCms
88    #[track_caller]
89    pub fn new_explicit_with_cms(
90        from: PixelDescriptor,
91        to: PixelDescriptor,
92        options: &crate::policy::ConvertOptions,
93        cms: Option<&dyn crate::cms::PluggableCms>,
94    ) -> Result<Self, At<ConvertError>> {
95        use crate::policy::{AlphaPolicy, DepthPolicy};
96
97        // CMS dispatch chain (runs only when primaries differ — transfer-only
98        // changes stay on the built-in plan path so `compose` can peephole
99        // them and options like `clip_out_of_gamut` propagate through):
100        //   1. user-supplied plugin (if Some)
101        //   2. ZenCmsLite (default) — named-profile matlut fast path
102        //
103        // Per-plugin semantics:
104        //   - None → declined, try next plugin
105        //   - Some(Ok(t)) → accepted, stop the chain
106        //   - Some(Err(e)) → tried-and-failed, propagate the error and stop.
107        //     Falling through to another backend could silently produce
108        //     different output — surface the failure instead.
109        let primaries_differ = from.primaries != to.primaries;
110        if primaries_differ {
111            let src_src = from.color_profile_source();
112            let dst_src = to.color_profile_source();
113            let src_fmt = from.pixel_format();
114            let dst_fmt = to.pixel_format();
115
116            // Helper: ask one plugin for a transform, preferring shared.
117            // Returns:
118            //   Ok(Some(t)) → plugin accepted
119            //   Ok(None)    → plugin declined
120            //   Err(e)      → plugin tried and failed (At<CmsPluginError>
121            //                 carries the plugin's internal failure point
122            //                 plus a zenpixels-convert crate boundary stamp
123            //                 added via `whereat::at_crate!`).
124            let try_cms = |plugin: &dyn crate::cms::PluggableCms| -> Result<
125                Option<ExternalTransform>,
126                whereat::At<crate::cms::CmsPluginError>,
127            > {
128                if let Some(result) = plugin.build_shared_source_transform(
129                    src_src.clone(),
130                    dst_src.clone(),
131                    src_fmt,
132                    dst_fmt,
133                    options,
134                ) {
135                    return whereat::at_crate!(result).map(|t| Some(ExternalTransform::Shared(t)));
136                }
137                if let Some(result) = plugin.build_source_transform(
138                    src_src.clone(),
139                    dst_src.clone(),
140                    src_fmt,
141                    dst_fmt,
142                    options,
143                ) {
144                    return whereat::at_crate!(result).map(|t| Some(ExternalTransform::Owned(t)));
145                }
146                Ok(None)
147            };
148
149            // Convert a plugin failure into ConvertError::CmsError. The
150            // `At<CmsPluginError>` Display impl already renders the full
151            // frame trace + crate info (plugin + crate boundary).
152            let plugin_err = |e: whereat::At<crate::cms::CmsPluginError>| {
153                whereat::at!(ConvertError::CmsError(alloc::format!("{e}")))
154            };
155
156            let mut external: Option<ExternalTransform> = None;
157            if let Some(plugin) = cms {
158                match try_cms(plugin) {
159                    Ok(Some(t)) => external = Some(t),
160                    Ok(None) => {}
161                    Err(e) => return Err(plugin_err(e)),
162                }
163            }
164            if external.is_none() {
165                match try_cms(&crate::cms_lite::ZenCmsLite) {
166                    Ok(Some(t)) => external = Some(t),
167                    Ok(None) => {}
168                    Err(e) => return Err(plugin_err(e)),
169                }
170            }
171
172            if let Some(external) = external {
173                // Policy checks still apply.
174                let drops_alpha = from.alpha().is_some() && to.alpha().is_none();
175                if drops_alpha && options.alpha_policy == AlphaPolicy::Forbid {
176                    return Err(whereat::at!(ConvertError::AlphaRemovalForbidden));
177                }
178                // Precision bits, not byte size — see convert.rs for rationale.
179                let reduces_depth = crate::negotiate::channel_bits(from.channel_type())
180                    > crate::negotiate::channel_bits(to.channel_type());
181                if reduces_depth && options.depth_policy == DepthPolicy::Forbid {
182                    return Err(whereat::at!(ConvertError::DepthReductionForbidden));
183                }
184                let src_is_rgb = matches!(
185                    from.layout(),
186                    ChannelLayout::Rgb | ChannelLayout::Rgba | ChannelLayout::Bgra
187                );
188                let dst_is_gray =
189                    matches!(to.layout(), ChannelLayout::Gray | ChannelLayout::GrayAlpha);
190                if src_is_rgb && dst_is_gray && options.luma.is_none() {
191                    return Err(whereat::at!(ConvertError::RgbToGray));
192                }
193
194                return Ok(Self {
195                    plan: ConvertPlan::identity(from, to),
196                    scratch: ConvertScratch::new(),
197                    external: Some(external),
198                });
199            }
200        }
201
202        // Same profiles, or CMS chain declined — built-in plan.
203        let plan = ConvertPlan::new_explicit(from, to, options).at()?;
204        Ok(Self {
205            plan,
206            scratch: ConvertScratch::new(),
207            external: None,
208        })
209    }
210
211    /// Create a converter from a pre-computed plan.
212    pub fn from_plan(plan: ConvertPlan) -> Self {
213        Self {
214            plan,
215            scratch: ConvertScratch::new(),
216            external: None,
217        }
218    }
219
220    /// Convert one row of `width` pixels.
221    ///
222    /// `src` must contain at least `width * from.bytes_per_pixel()` bytes.
223    /// `dst` must contain at least `width * to.bytes_per_pixel()` bytes.
224    ///
225    /// Multi-step conversions reuse internal scratch buffers — no heap
226    /// allocation after the first call at a given width.
227    #[inline]
228    pub fn convert_row(&mut self, src: &[u8], dst: &mut [u8], width: u32) {
229        match &mut self.external {
230            Some(ExternalTransform::Shared(arc)) => arc.transform_row(src, dst, width),
231            Some(ExternalTransform::Owned(b)) => b.transform_row(src, dst, width),
232            None => convert_row_buffered(&self.plan, src, dst, width, &mut self.scratch),
233        }
234    }
235
236    /// Convert multiple rows from a strided source buffer to a strided destination.
237    ///
238    /// The source and destination can have different strides.
239    #[track_caller]
240    pub fn convert_rows(
241        &mut self,
242        src: &[u8],
243        src_stride: usize,
244        dst: &mut [u8],
245        dst_stride: usize,
246        width: u32,
247        rows: u32,
248    ) -> Result<(), At<ConvertError>> {
249        for y in 0..rows {
250            let src_start = y as usize * src_stride;
251            let src_end = src_start + (width as usize * self.plan.from().bytes_per_pixel());
252            let dst_start = y as usize * dst_stride;
253            let dst_end = dst_start + (width as usize * self.plan.to().bytes_per_pixel());
254
255            if src_end > src.len() || dst_end > dst.len() {
256                return Err(whereat::at!(ConvertError::BufferSize {
257                    expected: dst_end,
258                    actual: dst.len(),
259                }));
260            }
261
262            self.convert_row(
263                &src[src_start..src_end],
264                &mut dst[dst_start..dst_end],
265                width,
266            );
267        }
268        Ok(())
269    }
270
271    /// True if the conversion is a no-op (formats are identical).
272    #[inline]
273    #[must_use]
274    pub fn is_identity(&self) -> bool {
275        // External transforms (CMS plugins, named-profile matlut) shadow
276        // the plan with real conversion work — only identity when no
277        // external is set and the plan itself is identity.
278        self.external.is_none() && self.plan.is_identity()
279    }
280
281    /// Source pixel format.
282    #[inline]
283    pub fn from_descriptor(&self) -> PixelDescriptor {
284        self.plan.from()
285    }
286
287    /// Target pixel format.
288    #[inline]
289    pub fn to_descriptor(&self) -> PixelDescriptor {
290        self.plan.to()
291    }
292
293    /// Compose two converters: apply `self` then `other` in a single pass.
294    ///
295    /// Adjacent inverse steps cancel (e.g., sRGB→linear then linear→sRGB
296    /// becomes identity). This eliminates intermediate buffers in zenpipe's
297    /// TransformSource when chaining format conversions.
298    ///
299    /// Returns `None` if the converters are incompatible (self.to != other.from).
300    pub fn compose(&self, other: &Self) -> Option<Self> {
301        self.plan.compose(&other.plan).map(Self::from_plan)
302    }
303
304    /// Access the underlying conversion plan.
305    pub fn plan(&self) -> &ConvertPlan {
306        &self.plan
307    }
308}
309
310impl Clone for RowConverter {
311    fn clone(&self) -> Self {
312        // Shared external transforms clone cheaply via Arc; owned ones
313        // can't be cloned and are dropped (clone falls back to the
314        // built-in plan for that case).
315        let external = match &self.external {
316            Some(ExternalTransform::Shared(arc)) => {
317                Some(ExternalTransform::Shared(alloc::sync::Arc::clone(arc)))
318            }
319            Some(ExternalTransform::Owned(_)) | None => None,
320        };
321        Self {
322            plan: self.plan.clone(),
323            scratch: ConvertScratch::new(),
324            external,
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use alloc::vec;
332
333    use super::*;
334    use crate::convert::ConvertPlan;
335    use crate::policy::{AlphaPolicy, ConvertOptions, DepthPolicy};
336    use crate::{AlphaMode, ChannelLayout, ChannelType, ConvertError, TransferFunction};
337
338    /// Helper: build a RowConverter and convert a single pixel.
339    fn convert_pixel(
340        from: PixelDescriptor,
341        to: PixelDescriptor,
342        src: &[u8],
343    ) -> alloc::vec::Vec<u8> {
344        let mut conv = RowConverter::new(from, to).unwrap();
345        let dst_bpp = to.bytes_per_pixel();
346        let mut dst = vec![0u8; dst_bpp];
347        conv.convert_row(src, &mut dst, 1);
348        dst
349    }
350
351    // -----------------------------------------------------------------------
352    // Identity / no-op
353    // -----------------------------------------------------------------------
354
355    #[test]
356    fn identity_conversion() {
357        let desc = PixelDescriptor::RGB8_SRGB;
358        let mut conv = RowConverter::new(desc, desc).unwrap();
359        assert!(conv.is_identity());
360        assert_eq!(conv.from_descriptor(), desc);
361        assert_eq!(conv.to_descriptor(), desc);
362
363        let src = [10u8, 20, 30, 40, 50, 60];
364        let mut dst = [0u8; 6];
365        conv.convert_row(&src, &mut dst, 2);
366        assert_eq!(dst, src);
367    }
368
369    // -----------------------------------------------------------------------
370    // Layout conversions
371    // -----------------------------------------------------------------------
372
373    #[test]
374    fn rgb8_to_rgba8() {
375        let dst = convert_pixel(
376            PixelDescriptor::RGB8_SRGB,
377            PixelDescriptor::RGBA8_SRGB,
378            &[100, 150, 200],
379        );
380        assert_eq!(dst, [100, 150, 200, 255]);
381    }
382
383    #[test]
384    fn rgba8_to_rgb8() {
385        let dst = convert_pixel(
386            PixelDescriptor::RGBA8_SRGB,
387            PixelDescriptor::RGB8_SRGB,
388            &[100, 150, 200, 128],
389        );
390        assert_eq!(dst, [100, 150, 200]);
391    }
392
393    #[test]
394    fn bgra8_to_rgba8() {
395        let dst = convert_pixel(
396            PixelDescriptor::BGRA8_SRGB,
397            PixelDescriptor::RGBA8_SRGB,
398            &[200, 150, 100, 255], // BGRA
399        );
400        assert_eq!(dst, [100, 150, 200, 255]); // RGBA
401    }
402
403    #[test]
404    fn rgba8_to_bgra8() {
405        let dst = convert_pixel(
406            PixelDescriptor::RGBA8_SRGB,
407            PixelDescriptor::BGRA8_SRGB,
408            &[100, 150, 200, 255],
409        );
410        assert_eq!(dst, [200, 150, 100, 255]);
411    }
412
413    #[test]
414    fn rgb8_to_bgra8() {
415        // RGB → RGBA → BGRA (two-step).
416        let dst = convert_pixel(
417            PixelDescriptor::RGB8_SRGB,
418            PixelDescriptor::BGRA8_SRGB,
419            &[100, 150, 200],
420        );
421        assert_eq!(dst, [200, 150, 100, 255]);
422    }
423
424    #[test]
425    fn bgra8_to_rgb8() {
426        // BGRA → RGBA → RGB (two-step).
427        let dst = convert_pixel(
428            PixelDescriptor::BGRA8_SRGB,
429            PixelDescriptor::RGB8_SRGB,
430            &[200, 150, 100, 255],
431        );
432        assert_eq!(dst, [100, 150, 200]);
433    }
434
435    #[test]
436    fn gray8_to_rgb8() {
437        let dst = convert_pixel(
438            PixelDescriptor::GRAY8_SRGB,
439            PixelDescriptor::RGB8_SRGB,
440            &[128],
441        );
442        assert_eq!(dst, [128, 128, 128]);
443    }
444
445    #[test]
446    fn gray8_to_rgba8() {
447        let dst = convert_pixel(
448            PixelDescriptor::GRAY8_SRGB,
449            PixelDescriptor::RGBA8_SRGB,
450            &[200],
451        );
452        assert_eq!(dst, [200, 200, 200, 255]);
453    }
454
455    #[test]
456    fn gray8_to_bgra8() {
457        // Gray → RGBA → BGRA (two-step).
458        let dst = convert_pixel(
459            PixelDescriptor::GRAY8_SRGB,
460            PixelDescriptor::BGRA8_SRGB,
461            &[128],
462        );
463        // Gray broadcasts to (128,128,128) then swizzles — all same so still (128,128,128,255).
464        assert_eq!(dst, [128, 128, 128, 255]);
465    }
466
467    #[test]
468    fn rgb8_to_gray8() {
469        // BT.709 luma: (54*R + 183*G + 19*B + 128) >> 8
470        let dst = convert_pixel(
471            PixelDescriptor::RGB8_SRGB,
472            PixelDescriptor::GRAY8_SRGB,
473            &[255, 0, 0],
474        );
475        // (54*255 + 0 + 0 + 128) >> 8 = (13770 + 128) >> 8 = 13898 >> 8 = 54
476        assert_eq!(dst, [54]);
477    }
478
479    #[test]
480    fn rgba8_to_gray8() {
481        let dst = convert_pixel(
482            PixelDescriptor::RGBA8_SRGB,
483            PixelDescriptor::GRAY8_SRGB,
484            &[0, 255, 0, 255],
485        );
486        // (0 + 183*255 + 0 + 128) >> 8 = (46665 + 128) >> 8 = 46793 >> 8 = 182
487        assert_eq!(dst, [182]);
488    }
489
490    #[test]
491    fn bgra8_to_gray8() {
492        // BGRA → RGBA → Gray (two-step).
493        let dst = convert_pixel(
494            PixelDescriptor::BGRA8_SRGB,
495            PixelDescriptor::GRAY8_SRGB,
496            &[0, 255, 0, 255], // BGRA: B=0, G=255, R=0
497        );
498        // After swizzle: RGBA = [0, 255, 0, 255], then gray = 182.
499        assert_eq!(dst, [182]);
500    }
501
502    // -----------------------------------------------------------------------
503    // GrayAlpha conversions
504    // -----------------------------------------------------------------------
505
506    #[test]
507    fn gray8_to_grayalpha8() {
508        let from = PixelDescriptor::new(
509            ChannelType::U8,
510            ChannelLayout::Gray,
511            None,
512            TransferFunction::Srgb,
513        );
514        let to = PixelDescriptor::new(
515            ChannelType::U8,
516            ChannelLayout::GrayAlpha,
517            Some(AlphaMode::Straight),
518            TransferFunction::Srgb,
519        );
520        let dst = convert_pixel(from, to, &[100]);
521        assert_eq!(dst, [100, 255]);
522    }
523
524    #[test]
525    fn grayalpha8_to_gray8() {
526        let from = PixelDescriptor::new(
527            ChannelType::U8,
528            ChannelLayout::GrayAlpha,
529            Some(AlphaMode::Straight),
530            TransferFunction::Srgb,
531        );
532        let to = PixelDescriptor::new(
533            ChannelType::U8,
534            ChannelLayout::Gray,
535            None,
536            TransferFunction::Srgb,
537        );
538        let dst = convert_pixel(from, to, &[100, 200]);
539        assert_eq!(dst, [100]); // Alpha dropped.
540    }
541
542    #[test]
543    fn grayalpha8_to_rgba8() {
544        let from = PixelDescriptor::new(
545            ChannelType::U8,
546            ChannelLayout::GrayAlpha,
547            Some(AlphaMode::Straight),
548            TransferFunction::Srgb,
549        );
550        let dst = convert_pixel(from, PixelDescriptor::RGBA8_SRGB, &[128, 200]);
551        assert_eq!(dst, [128, 128, 128, 200]);
552    }
553
554    #[test]
555    fn grayalpha8_to_rgb8() {
556        let from = PixelDescriptor::new(
557            ChannelType::U8,
558            ChannelLayout::GrayAlpha,
559            Some(AlphaMode::Straight),
560            TransferFunction::Srgb,
561        );
562        let dst = convert_pixel(from, PixelDescriptor::RGB8_SRGB, &[128, 200]);
563        assert_eq!(dst, [128, 128, 128]);
564    }
565
566    #[test]
567    fn grayalpha8_to_bgra8() {
568        // GrayAlpha → RGBA → BGRA (two-step).
569        let from = PixelDescriptor::new(
570            ChannelType::U8,
571            ChannelLayout::GrayAlpha,
572            Some(AlphaMode::Straight),
573            TransferFunction::Srgb,
574        );
575        let dst = convert_pixel(from, PixelDescriptor::BGRA8_SRGB, &[128, 200]);
576        // Gray broadcasts to (128,128,128,200) then BGRA swizzle — all same so (128,128,128,200).
577        assert_eq!(dst, [128, 128, 128, 200]);
578    }
579
580    // -----------------------------------------------------------------------
581    // Depth conversions
582    // -----------------------------------------------------------------------
583
584    #[test]
585    fn u8_to_u16_roundtrip() {
586        let u8_desc = PixelDescriptor::RGB8_SRGB;
587        let u16_desc = PixelDescriptor::new(
588            ChannelType::U16,
589            ChannelLayout::Rgb,
590            None,
591            TransferFunction::Srgb,
592        );
593        let src = [0u8, 128, 255];
594        let wide = convert_pixel(u8_desc, u16_desc, &src);
595        let wide16: &[u16] = bytemuck::cast_slice(&wide);
596        assert_eq!(wide16[0], 0); // 0 * 257 = 0
597        assert_eq!(wide16[1], 128 * 257); // 32896
598        assert_eq!(wide16[2], 255 * 257); // 65535
599
600        // Narrow back to u8.
601        let narrow = convert_pixel(u16_desc, u8_desc, &wide);
602        assert_eq!(narrow, [0, 128, 255]);
603    }
604
605    #[test]
606    fn naive_u8_to_f32() {
607        // Non-sRGB transfer → naive path.
608        let u8_desc = PixelDescriptor::new(
609            ChannelType::U8,
610            ChannelLayout::Rgb,
611            None,
612            TransferFunction::Linear,
613        );
614        let f32_desc = PixelDescriptor::new(
615            ChannelType::F32,
616            ChannelLayout::Rgb,
617            None,
618            TransferFunction::Linear,
619        );
620        let dst = convert_pixel(u8_desc, f32_desc, &[0, 128, 255]);
621        let f: &[f32] = bytemuck::cast_slice(&dst);
622        assert!((f[0] - 0.0).abs() < 1e-6);
623        assert!((f[1] - 128.0 / 255.0).abs() < 1e-5);
624        assert!((f[2] - 1.0).abs() < 1e-6);
625    }
626
627    #[test]
628    fn naive_f32_to_u8() {
629        let f32_desc = PixelDescriptor::new(
630            ChannelType::F32,
631            ChannelLayout::Rgb,
632            None,
633            TransferFunction::Linear,
634        );
635        let u8_desc = PixelDescriptor::new(
636            ChannelType::U8,
637            ChannelLayout::Rgb,
638            None,
639            TransferFunction::Linear,
640        );
641        let src_f: [f32; 3] = [0.0, 0.5, 1.0];
642        let src: &[u8] = bytemuck::cast_slice(&src_f);
643        let dst = convert_pixel(f32_desc, u8_desc, src);
644        assert_eq!(dst[0], 0);
645        assert_eq!(dst[1], 128); // 0.5 * 255 + 0.5 = 128.0 → 128
646        assert_eq!(dst[2], 255);
647    }
648
649    #[test]
650    fn srgb_u8_to_linear_f32() {
651        let dst = convert_pixel(
652            PixelDescriptor::RGB8_SRGB,
653            PixelDescriptor::new(
654                ChannelType::F32,
655                ChannelLayout::Rgb,
656                None,
657                TransferFunction::Linear,
658            ),
659            &[0, 128, 255],
660        );
661        let f: &[f32] = bytemuck::cast_slice(&dst);
662        assert!((f[0] - 0.0).abs() < 1e-6);
663        // sRGB 128/255 ≈ 0.2158 linear
664        assert!((f[1] - 0.2158).abs() < 0.01);
665        assert!((f[2] - 1.0).abs() < 1e-5);
666    }
667
668    #[test]
669    fn linear_f32_to_srgb_u8() {
670        let f32_lin = PixelDescriptor::new(
671            ChannelType::F32,
672            ChannelLayout::Rgb,
673            None,
674            TransferFunction::Linear,
675        );
676        let src_f: [f32; 3] = [0.0, 0.5, 1.0];
677        let src: &[u8] = bytemuck::cast_slice(&src_f);
678        let dst = convert_pixel(f32_lin, PixelDescriptor::RGB8_SRGB, src);
679        assert_eq!(dst[0], 0);
680        // linear 0.5 ≈ sRGB 188
681        assert!((dst[1] as i32 - 188).abs() <= 1);
682        assert_eq!(dst[2], 255);
683    }
684
685    #[test]
686    fn u16_to_f32_and_back() {
687        let u16_desc = PixelDescriptor::new(
688            ChannelType::U16,
689            ChannelLayout::Rgb,
690            None,
691            TransferFunction::Srgb,
692        );
693        let f32_desc = PixelDescriptor::new(
694            ChannelType::F32,
695            ChannelLayout::Rgb,
696            None,
697            TransferFunction::Srgb,
698        );
699        let src16: [u16; 3] = [0, 32768, 65535];
700        let src: &[u8] = bytemuck::cast_slice(&src16);
701        let mid = convert_pixel(u16_desc, f32_desc, src);
702        let f: &[f32] = bytemuck::cast_slice(&mid);
703        assert!((f[0] - 0.0).abs() < 1e-6);
704        assert!((f[1] - 0.5000076).abs() < 1e-4);
705        assert!((f[2] - 1.0).abs() < 1e-6);
706
707        // Round-trip back.
708        let back = convert_pixel(f32_desc, u16_desc, &mid);
709        let back16: &[u16] = bytemuck::cast_slice(&back);
710        assert_eq!(back16[0], 0);
711        assert!((back16[1] as i32 - 32768).abs() <= 1);
712        assert_eq!(back16[2], 65535);
713    }
714
715    // -----------------------------------------------------------------------
716    // HDR transfer function conversions
717    // -----------------------------------------------------------------------
718
719    #[test]
720    fn pq_u16_to_linear_f32_roundtrip() {
721        let pq_u16 = PixelDescriptor::new(
722            ChannelType::U16,
723            ChannelLayout::Rgb,
724            None,
725            TransferFunction::Pq,
726        );
727        let lin_f32 = PixelDescriptor::new(
728            ChannelType::F32,
729            ChannelLayout::Rgb,
730            None,
731            TransferFunction::Linear,
732        );
733        let src16: [u16; 3] = [0, 32768, 65535];
734        let src: &[u8] = bytemuck::cast_slice(&src16);
735        let mid = convert_pixel(pq_u16, lin_f32, src);
736        let f: &[f32] = bytemuck::cast_slice(&mid);
737        assert_eq!(f[0], 0.0);
738        assert!(f[1] > 0.0 && f[1] < 1.0);
739
740        // Round-trip.
741        let back = convert_pixel(lin_f32, pq_u16, &mid);
742        let back16: &[u16] = bytemuck::cast_slice(&back);
743        assert_eq!(back16[0], 0);
744        assert!((back16[1] as i32 - 32768).abs() <= 2);
745    }
746
747    #[test]
748    fn pq_f32_to_linear_f32_roundtrip() {
749        let pq_f32 = PixelDescriptor::new(
750            ChannelType::F32,
751            ChannelLayout::Rgb,
752            None,
753            TransferFunction::Pq,
754        );
755        let lin_f32 = PixelDescriptor::new(
756            ChannelType::F32,
757            ChannelLayout::Rgb,
758            None,
759            TransferFunction::Linear,
760        );
761        let src_f: [f32; 3] = [0.0, 0.5, 1.0];
762        let src: &[u8] = bytemuck::cast_slice(&src_f);
763        let mid = convert_pixel(pq_f32, lin_f32, src);
764        let back = convert_pixel(lin_f32, pq_f32, &mid);
765        let back_f: &[f32] = bytemuck::cast_slice(&back);
766        for i in 0..3 {
767            assert!(
768                (back_f[i] - src_f[i]).abs() < 1e-4,
769                "PQ F32 roundtrip ch{i}: {:.6} vs {:.6}",
770                back_f[i],
771                src_f[i]
772            );
773        }
774    }
775
776    #[test]
777    fn hlg_u16_to_linear_f32_roundtrip() {
778        let hlg_u16 = PixelDescriptor::new(
779            ChannelType::U16,
780            ChannelLayout::Rgb,
781            None,
782            TransferFunction::Hlg,
783        );
784        let lin_f32 = PixelDescriptor::new(
785            ChannelType::F32,
786            ChannelLayout::Rgb,
787            None,
788            TransferFunction::Linear,
789        );
790        let src16: [u16; 3] = [0, 32768, 65535];
791        let src: &[u8] = bytemuck::cast_slice(&src16);
792        let mid = convert_pixel(hlg_u16, lin_f32, src);
793        let f: &[f32] = bytemuck::cast_slice(&mid);
794        assert_eq!(f[0], 0.0);
795        assert!(f[1] > 0.0);
796
797        let back = convert_pixel(lin_f32, hlg_u16, &mid);
798        let back16: &[u16] = bytemuck::cast_slice(&back);
799        assert_eq!(back16[0], 0);
800        assert!((back16[1] as i32 - 32768).abs() <= 2);
801    }
802
803    #[test]
804    fn hlg_f32_to_linear_f32_roundtrip() {
805        let hlg_f32 = PixelDescriptor::new(
806            ChannelType::F32,
807            ChannelLayout::Rgb,
808            None,
809            TransferFunction::Hlg,
810        );
811        let lin_f32 = PixelDescriptor::new(
812            ChannelType::F32,
813            ChannelLayout::Rgb,
814            None,
815            TransferFunction::Linear,
816        );
817        let src_f: [f32; 3] = [0.0, 0.5, 1.0];
818        let src: &[u8] = bytemuck::cast_slice(&src_f);
819        let mid = convert_pixel(hlg_f32, lin_f32, src);
820        let back = convert_pixel(lin_f32, hlg_f32, &mid);
821        let back_f: &[f32] = bytemuck::cast_slice(&back);
822        for i in 0..3 {
823            assert!(
824                (back_f[i] - src_f[i]).abs() < 1e-4,
825                "HLG F32 roundtrip ch{i}: {:.6} vs {:.6}",
826                back_f[i],
827                src_f[i]
828            );
829        }
830    }
831
832    #[test]
833    fn pq_to_hlg_via_linear_f32() {
834        let pq = PixelDescriptor::new(
835            ChannelType::F32,
836            ChannelLayout::Rgb,
837            None,
838            TransferFunction::Pq,
839        );
840        let hlg = PixelDescriptor::new(
841            ChannelType::F32,
842            ChannelLayout::Rgb,
843            None,
844            TransferFunction::Hlg,
845        );
846        let src_f: [f32; 3] = [0.0, 0.5, 1.0];
847        let src: &[u8] = bytemuck::cast_slice(&src_f);
848        let dst = convert_pixel(pq, hlg, src);
849        let f: &[f32] = bytemuck::cast_slice(&dst);
850        // PQ 0 → HLG 0.
851        assert_eq!(f[0], 0.0);
852        // PQ 0.5 → some HLG value.
853        assert!(f[1] > 0.0 && f[1] <= 1.0);
854    }
855
856    #[test]
857    fn hdr_u16_to_sdr_u8_pq() {
858        // PQ U16 → sRGB U8 (two-step: EOTF + OETF).
859        let pq_u16 = PixelDescriptor::new(
860            ChannelType::U16,
861            ChannelLayout::Rgb,
862            None,
863            TransferFunction::Pq,
864        );
865        let src16: [u16; 3] = [32768, 32768, 32768];
866        let src: &[u8] = bytemuck::cast_slice(&src16);
867        let dst = convert_pixel(pq_u16, PixelDescriptor::RGB8_SRGB, src);
868        // Should produce some valid sRGB value.
869        assert!(dst[0] > 0 && dst[0] < 255);
870    }
871
872    #[test]
873    fn hdr_u16_to_sdr_u8_hlg() {
874        let hlg_u16 = PixelDescriptor::new(
875            ChannelType::U16,
876            ChannelLayout::Rgb,
877            None,
878            TransferFunction::Hlg,
879        );
880        let src16: [u16; 3] = [32768, 32768, 32768];
881        let src: &[u8] = bytemuck::cast_slice(&src16);
882        let dst = convert_pixel(hlg_u16, PixelDescriptor::RGB8_SRGB, src);
883        assert!(dst[0] > 0 && dst[0] < 255);
884    }
885
886    // -----------------------------------------------------------------------
887    // Alpha premultiplication
888    // -----------------------------------------------------------------------
889
890    #[test]
891    fn straight_to_premul_u8() {
892        let straight = PixelDescriptor::RGBA8_SRGB;
893        let premul = PixelDescriptor::new(
894            ChannelType::U8,
895            ChannelLayout::Rgba,
896            Some(AlphaMode::Premultiplied),
897            TransferFunction::Srgb,
898        );
899        // 50% alpha: RGB channels halved.
900        let dst = convert_pixel(straight, premul, &[200, 100, 50, 128]);
901        // (200 * 128 + 128) / 255 = 100, (100 * 128 + 128) / 255 = 50, (50 * 128 + 128) / 255 = 25
902        assert!((dst[0] as i32 - 100).abs() <= 1);
903        assert!((dst[1] as i32 - 50).abs() <= 1);
904        assert!((dst[2] as i32 - 25).abs() <= 1);
905        assert_eq!(dst[3], 128);
906    }
907
908    #[test]
909    fn premul_to_straight_u8() {
910        let premul = PixelDescriptor::new(
911            ChannelType::U8,
912            ChannelLayout::Rgba,
913            Some(AlphaMode::Premultiplied),
914            TransferFunction::Srgb,
915        );
916        let straight = PixelDescriptor::RGBA8_SRGB;
917        // Premul with alpha=128: channels are already halved.
918        let dst = convert_pixel(premul, straight, &[100, 50, 25, 128]);
919        // Unpremultiply: (100 * 255 + 64) / 128 = 199, etc.
920        assert!((dst[0] as i32 - 200).abs() <= 1);
921        assert!((dst[1] as i32 - 100).abs() <= 1);
922        assert!((dst[2] as i32 - 50).abs() <= 1);
923        assert_eq!(dst[3], 128);
924    }
925
926    #[test]
927    fn premul_to_straight_zero_alpha() {
928        let premul = PixelDescriptor::new(
929            ChannelType::U8,
930            ChannelLayout::Rgba,
931            Some(AlphaMode::Premultiplied),
932            TransferFunction::Srgb,
933        );
934        let straight = PixelDescriptor::RGBA8_SRGB;
935        let dst = convert_pixel(premul, straight, &[0, 0, 0, 0]);
936        assert_eq!(dst, [0, 0, 0, 0]);
937    }
938
939    #[test]
940    fn straight_to_premul_f32() {
941        let straight = PixelDescriptor::new(
942            ChannelType::F32,
943            ChannelLayout::Rgba,
944            Some(AlphaMode::Straight),
945            TransferFunction::Linear,
946        );
947        let premul = PixelDescriptor::new(
948            ChannelType::F32,
949            ChannelLayout::Rgba,
950            Some(AlphaMode::Premultiplied),
951            TransferFunction::Linear,
952        );
953        let src_f: [f32; 4] = [1.0, 0.5, 0.25, 0.5];
954        let src: &[u8] = bytemuck::cast_slice(&src_f);
955        let dst = convert_pixel(straight, premul, src);
956        let f: &[f32] = bytemuck::cast_slice(&dst);
957        assert!((f[0] - 0.5).abs() < 1e-6);
958        assert!((f[1] - 0.25).abs() < 1e-6);
959        assert!((f[2] - 0.125).abs() < 1e-6);
960        assert!((f[3] - 0.5).abs() < 1e-6);
961    }
962
963    // -----------------------------------------------------------------------
964    // Oklab conversions
965    // -----------------------------------------------------------------------
966
967    #[test]
968    fn rgb8_to_oklabf32_does_not_panic() {
969        let mut conv =
970            RowConverter::new(PixelDescriptor::RGB8_SRGB, PixelDescriptor::OKLABF32).unwrap();
971        assert!(!conv.is_identity());
972
973        let src = [128u8, 64, 200];
974        let mut dst = [0u8; 12];
975        conv.convert_row(&src, &mut dst, 1);
976
977        let oklab: [f32; 3] = bytemuck::cast(dst);
978        assert!(
979            oklab[0] >= 0.0 && oklab[0] <= 1.0,
980            "L out of range: {}",
981            oklab[0]
982        );
983    }
984
985    #[test]
986    fn oklabf32_roundtrip() {
987        // Linear RGB F32 → Oklab F32 → Linear RGB F32.
988        let lin = PixelDescriptor::new(
989            ChannelType::F32,
990            ChannelLayout::Rgb,
991            None,
992            TransferFunction::Linear,
993        );
994        let oklab = PixelDescriptor::OKLABF32;
995
996        let src_f: [f32; 3] = [0.5, 0.3, 0.8];
997        let src: &[u8] = bytemuck::cast_slice(&src_f);
998        let mid = convert_pixel(lin, oklab, src);
999        let back = convert_pixel(oklab, lin, &mid);
1000        let back_f: &[f32] = bytemuck::cast_slice(&back);
1001        for i in 0..3 {
1002            assert!(
1003                (back_f[i] - src_f[i]).abs() < 1e-4,
1004                "Oklab roundtrip ch{i}: {:.6} vs {:.6}",
1005                back_f[i],
1006                src_f[i]
1007            );
1008        }
1009    }
1010
1011    #[test]
1012    fn oklabaf32_preserves_alpha() {
1013        let lin_rgba = PixelDescriptor::new(
1014            ChannelType::F32,
1015            ChannelLayout::Rgba,
1016            Some(AlphaMode::Straight),
1017            TransferFunction::Linear,
1018        );
1019        let oklaba = PixelDescriptor::OKLABAF32;
1020        let src_f: [f32; 4] = [0.5, 0.3, 0.8, 0.7];
1021        let src: &[u8] = bytemuck::cast_slice(&src_f);
1022        let mid = convert_pixel(lin_rgba, oklaba, src);
1023        let mid_f: &[f32] = bytemuck::cast_slice(&mid);
1024        assert!(
1025            (mid_f[3] - 0.7).abs() < 1e-6,
1026            "Alpha not preserved in Oklaba"
1027        );
1028
1029        let back = convert_pixel(oklaba, lin_rgba, &mid);
1030        let back_f: &[f32] = bytemuck::cast_slice(&back);
1031        assert!(
1032            (back_f[3] - 0.7).abs() < 1e-6,
1033            "Alpha not preserved on round-trip"
1034        );
1035        for i in 0..3 {
1036            assert!(
1037                (back_f[i] - src_f[i]).abs() < 1e-4,
1038                "Oklaba roundtrip ch{i}: {:.6} vs {:.6}",
1039                back_f[i],
1040                src_f[i]
1041            );
1042        }
1043    }
1044
1045    // -----------------------------------------------------------------------
1046    // Multi-row conversion
1047    // -----------------------------------------------------------------------
1048
1049    #[test]
1050    fn convert_rows_basic() {
1051        let mut conv =
1052            RowConverter::new(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBA8_SRGB).unwrap();
1053
1054        let src = [10u8, 20, 30, 40, 50, 60];
1055        let src_stride = 3; // tight, 1 pixel per row
1056        let mut dst = [0u8; 8]; // 2 rows × 4 bytes
1057        let dst_stride = 4;
1058
1059        conv.convert_rows(&src, src_stride, &mut dst, dst_stride, 1, 2)
1060            .unwrap();
1061        assert_eq!(&dst[0..4], &[10, 20, 30, 255]);
1062        assert_eq!(&dst[4..8], &[40, 50, 60, 255]);
1063    }
1064
1065    #[test]
1066    fn convert_rows_buffer_too_small() {
1067        let mut conv =
1068            RowConverter::new(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBA8_SRGB).unwrap();
1069
1070        let src = [10u8, 20, 30];
1071        let mut dst = [0u8; 4];
1072        let err = conv.convert_rows(&src, 3, &mut dst, 4, 1, 2).unwrap_err();
1073        assert!(matches!(*err.error(), ConvertError::BufferSize { .. }));
1074    }
1075
1076    // -----------------------------------------------------------------------
1077    // ConvertPlan::new_explicit policy checks
1078    // -----------------------------------------------------------------------
1079
1080    #[test]
1081    fn new_explicit_alpha_forbid() {
1082        let from = PixelDescriptor::RGBA8_SRGB;
1083        let to = PixelDescriptor::RGB8_SRGB;
1084        let opts = ConvertOptions::forbid_lossy().with_depth_policy(DepthPolicy::Round);
1085        let err = ConvertPlan::new_explicit(from, to, &opts).unwrap_err();
1086        assert_eq!(*err.error(), ConvertError::AlphaRemovalForbidden);
1087    }
1088
1089    #[test]
1090    fn new_explicit_depth_forbid() {
1091        let from = PixelDescriptor::new(
1092            ChannelType::U16,
1093            ChannelLayout::Rgb,
1094            None,
1095            TransferFunction::Srgb,
1096        );
1097        let to = PixelDescriptor::RGB8_SRGB;
1098        let opts = ConvertOptions::forbid_lossy().with_alpha_policy(AlphaPolicy::DiscardUnchecked);
1099        let err = ConvertPlan::new_explicit(from, to, &opts).unwrap_err();
1100        assert_eq!(*err.error(), ConvertError::DepthReductionForbidden);
1101    }
1102
1103    #[test]
1104    fn new_explicit_rgb_to_gray_requires_luma() {
1105        let opts = ConvertOptions::forbid_lossy()
1106            .with_alpha_policy(AlphaPolicy::DiscardUnchecked)
1107            .with_depth_policy(DepthPolicy::Round);
1108        let err = ConvertPlan::new_explicit(
1109            PixelDescriptor::RGB8_SRGB,
1110            PixelDescriptor::GRAY8_SRGB,
1111            &opts,
1112        )
1113        .unwrap_err();
1114        assert_eq!(*err.error(), ConvertError::RgbToGray);
1115    }
1116
1117    #[test]
1118    fn new_explicit_allows_when_policies_permit() {
1119        let opts = ConvertOptions::permissive().with_alpha_policy(AlphaPolicy::DiscardUnchecked);
1120        let plan = ConvertPlan::new_explicit(
1121            PixelDescriptor::RGBA8_SRGB,
1122            PixelDescriptor::GRAY8_SRGB,
1123            &opts,
1124        )
1125        .unwrap();
1126        assert!(!plan.is_identity());
1127    }
1128
1129    #[test]
1130    fn clip_out_of_gamut_false_preserves_negatives() {
1131        // P3 pure green → sRGB produces negative red (out of sRGB gamut).
1132        // With clip_out_of_gamut=false, the extended-range transfer must
1133        // preserve those negatives instead of clamping to zero.
1134        let p3 = PixelDescriptor::new(
1135            ChannelType::F32,
1136            ChannelLayout::Rgb,
1137            None,
1138            TransferFunction::Srgb,
1139        )
1140        .with_primaries(zenpixels::ColorPrimaries::DisplayP3);
1141        let srgb = PixelDescriptor::new(
1142            ChannelType::F32,
1143            ChannelLayout::Rgb,
1144            None,
1145            TransferFunction::Srgb,
1146        );
1147
1148        let opts = ConvertOptions::permissive().with_clip_out_of_gamut(false);
1149        let mut conv = crate::RowConverter::new_explicit(p3, srgb, &opts).unwrap();
1150
1151        let src: [f32; 3] = [0.0, 1.0, 0.0];
1152        let mut dst = [0.0f32; 3];
1153        conv.convert_row(
1154            bytemuck::cast_slice(&src),
1155            bytemuck::cast_slice_mut(&mut dst),
1156            1,
1157        );
1158        assert!(
1159            dst[0] < 0.0,
1160            "extended range should preserve negative red, got {}",
1161            dst[0]
1162        );
1163    }
1164
1165    #[test]
1166    fn clip_out_of_gamut_true_clamps_negatives() {
1167        // Default clip_out_of_gamut=true clamps sRGB transfer to [0, 1].
1168        let p3 = PixelDescriptor::new(
1169            ChannelType::F32,
1170            ChannelLayout::Rgb,
1171            None,
1172            TransferFunction::Srgb,
1173        )
1174        .with_primaries(zenpixels::ColorPrimaries::DisplayP3);
1175        let srgb = PixelDescriptor::new(
1176            ChannelType::F32,
1177            ChannelLayout::Rgb,
1178            None,
1179            TransferFunction::Srgb,
1180        );
1181
1182        let opts = ConvertOptions::permissive();
1183        assert!(opts.clip_out_of_gamut);
1184        let mut conv = crate::RowConverter::new_explicit(p3, srgb, &opts).unwrap();
1185
1186        let src: [f32; 3] = [0.0, 1.0, 0.0];
1187        let mut dst = [0.0f32; 3];
1188        conv.convert_row(
1189            bytemuck::cast_slice(&src),
1190            bytemuck::cast_slice_mut(&mut dst),
1191            1,
1192        );
1193        assert!(
1194            dst[0] >= 0.0,
1195            "clamped path should not produce negatives, got {}",
1196            dst[0]
1197        );
1198    }
1199
1200    // -----------------------------------------------------------------------
1201    // Pluggable CMS
1202    // -----------------------------------------------------------------------
1203
1204    /// Mock CMS that paints every output pixel red. Used to verify that the
1205    /// plugin gets hooked into the plan and actually drives the row.
1206    struct PaintRedCms {
1207        accepted: core::sync::atomic::AtomicUsize,
1208    }
1209
1210    struct PaintRedTransform;
1211
1212    impl crate::cms::RowTransformMut for PaintRedTransform {
1213        fn transform_row(&mut self, _src: &[u8], dst: &mut [u8], width: u32) {
1214            for px in dst.chunks_exact_mut(3).take(width as usize) {
1215                px[0] = 255;
1216                px[1] = 0;
1217                px[2] = 0;
1218            }
1219        }
1220    }
1221
1222    impl crate::cms::PluggableCms for PaintRedCms {
1223        fn build_source_transform(
1224            &self,
1225            _src: zenpixels::ColorProfileSource<'_>,
1226            _dst: zenpixels::ColorProfileSource<'_>,
1227            _src_format: zenpixels::PixelFormat,
1228            _dst_format: zenpixels::PixelFormat,
1229            _options: &crate::policy::ConvertOptions,
1230        ) -> Option<
1231            Result<Box<dyn crate::cms::RowTransformMut>, whereat::At<crate::cms::CmsPluginError>>,
1232        > {
1233            self.accepted
1234                .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
1235            Some(Ok(Box::new(PaintRedTransform)))
1236        }
1237    }
1238
1239    #[test]
1240    fn pluggable_cms_drives_row_when_profiles_differ() {
1241        // P3 RGB8 → sRGB RGB8: profiles differ, plugin must be asked and
1242        // its transform must be the one that runs.
1243        let p3 = PixelDescriptor::RGB8_SRGB.with_primaries(zenpixels::ColorPrimaries::DisplayP3);
1244        let srgb = PixelDescriptor::RGB8_SRGB;
1245
1246        let cms = PaintRedCms {
1247            accepted: core::sync::atomic::AtomicUsize::new(0),
1248        };
1249        let opts = ConvertOptions::permissive();
1250        let mut conv =
1251            crate::RowConverter::new_explicit_with_cms(p3, srgb, &opts, Some(&cms)).unwrap();
1252
1253        assert_eq!(cms.accepted.load(core::sync::atomic::Ordering::Relaxed), 1);
1254
1255        let src = [10u8, 20, 30, 40, 50, 60];
1256        let mut dst = [0u8; 6];
1257        conv.convert_row(&src, &mut dst, 2);
1258        assert_eq!(dst, [255, 0, 0, 255, 0, 0]);
1259    }
1260
1261    #[test]
1262    fn pluggable_cms_skipped_when_profiles_match() {
1263        // Same profile on both sides — plan is identity, plugin is never
1264        // consulted.
1265        let cms = PaintRedCms {
1266            accepted: core::sync::atomic::AtomicUsize::new(0),
1267        };
1268        let opts = ConvertOptions::permissive();
1269        let conv = crate::RowConverter::new_explicit_with_cms(
1270            PixelDescriptor::RGB8_SRGB,
1271            PixelDescriptor::RGB8_SRGB,
1272            &opts,
1273            Some(&cms),
1274        )
1275        .unwrap();
1276        assert!(conv.is_identity());
1277        assert_eq!(cms.accepted.load(core::sync::atomic::Ordering::Relaxed), 0);
1278    }
1279
1280    #[test]
1281    fn pluggable_cms_shared_path_used_when_offered() {
1282        // Plugin offers a shareable stateless transform — RowConverter
1283        // must pick that path over the owned one.
1284        struct SharedPaintBlueCms;
1285        struct PaintBlueShared;
1286
1287        impl crate::cms::RowTransform for PaintBlueShared {
1288            fn transform_row(&self, _src: &[u8], dst: &mut [u8], width: u32) {
1289                for px in dst.chunks_exact_mut(3).take(width as usize) {
1290                    px[0] = 0;
1291                    px[1] = 0;
1292                    px[2] = 255;
1293                }
1294            }
1295        }
1296
1297        impl crate::cms::PluggableCms for SharedPaintBlueCms {
1298            fn build_source_transform(
1299                &self,
1300                _src: zenpixels::ColorProfileSource<'_>,
1301                _dst: zenpixels::ColorProfileSource<'_>,
1302                _src_format: zenpixels::PixelFormat,
1303                _dst_format: zenpixels::PixelFormat,
1304                _options: &crate::policy::ConvertOptions,
1305            ) -> Option<
1306                Result<
1307                    Box<dyn crate::cms::RowTransformMut>,
1308                    whereat::At<crate::cms::CmsPluginError>,
1309                >,
1310            > {
1311                panic!("owned path must not be used when shared is offered");
1312            }
1313
1314            fn build_shared_source_transform(
1315                &self,
1316                _src: zenpixels::ColorProfileSource<'_>,
1317                _dst: zenpixels::ColorProfileSource<'_>,
1318                _src_format: zenpixels::PixelFormat,
1319                _dst_format: zenpixels::PixelFormat,
1320                _options: &crate::policy::ConvertOptions,
1321            ) -> Option<
1322                Result<
1323                    alloc::sync::Arc<dyn crate::cms::RowTransform>,
1324                    whereat::At<crate::cms::CmsPluginError>,
1325                >,
1326            > {
1327                Some(Ok(alloc::sync::Arc::new(PaintBlueShared)))
1328            }
1329        }
1330
1331        let p3 = PixelDescriptor::RGB8_SRGB.with_primaries(zenpixels::ColorPrimaries::DisplayP3);
1332        let srgb = PixelDescriptor::RGB8_SRGB;
1333        let opts = ConvertOptions::permissive();
1334        let conv =
1335            crate::RowConverter::new_explicit_with_cms(p3, srgb, &opts, Some(&SharedPaintBlueCms))
1336                .unwrap();
1337
1338        // Clone must carry the shared transform (via Arc).
1339        let mut conv2 = conv.clone();
1340        let mut dst = [0u8; 3];
1341        conv2.convert_row(&[1, 2, 3], &mut dst, 1);
1342        assert_eq!(
1343            dst,
1344            [0, 0, 255],
1345            "cloned converter should inherit shared transform"
1346        );
1347    }
1348
1349    #[test]
1350    fn pluggable_cms_declines_falls_back_to_builtin() {
1351        // Plugin returns None — must fall back to the built-in gamut path.
1352        struct DeclineCms;
1353        impl crate::cms::PluggableCms for DeclineCms {
1354            fn build_source_transform(
1355                &self,
1356                _src: zenpixels::ColorProfileSource<'_>,
1357                _dst: zenpixels::ColorProfileSource<'_>,
1358                _src_format: zenpixels::PixelFormat,
1359                _dst_format: zenpixels::PixelFormat,
1360                _options: &crate::policy::ConvertOptions,
1361            ) -> Option<
1362                Result<
1363                    Box<dyn crate::cms::RowTransformMut>,
1364                    whereat::At<crate::cms::CmsPluginError>,
1365                >,
1366            > {
1367                None
1368            }
1369        }
1370
1371        let p3 = PixelDescriptor::RGB8_SRGB.with_primaries(zenpixels::ColorPrimaries::DisplayP3);
1372        let srgb = PixelDescriptor::RGB8_SRGB;
1373        let opts = ConvertOptions::permissive();
1374        let mut conv =
1375            crate::RowConverter::new_explicit_with_cms(p3, srgb, &opts, Some(&DeclineCms)).unwrap();
1376
1377        // Built-in path should produce non-red output from grey source.
1378        let src = [128u8, 128, 128];
1379        let mut dst = [0u8; 3];
1380        conv.convert_row(&src, &mut dst, 1);
1381        // P3 grey → sRGB grey: R ≈ G ≈ B. Not the all-red sentinel the
1382        // plugin would have written, proving we took the built-in path.
1383        assert_ne!(dst, [255, 0, 0]);
1384    }
1385
1386    // -----------------------------------------------------------------------
1387    // Plan accessors
1388    // -----------------------------------------------------------------------
1389
1390    #[test]
1391    fn plan_accessors() {
1392        let from = PixelDescriptor::RGB8_SRGB;
1393        let to = PixelDescriptor::RGBA8_SRGB;
1394        let conv = RowConverter::new(from, to).unwrap();
1395        let plan = conv.plan();
1396        assert_eq!(plan.from(), from);
1397        assert_eq!(plan.to(), to);
1398        assert!(!plan.is_identity());
1399    }
1400
1401    #[test]
1402    fn from_plan() {
1403        let plan =
1404            ConvertPlan::new(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBA8_SRGB).unwrap();
1405        let conv = RowConverter::from_plan(plan);
1406        assert!(!conv.is_identity());
1407    }
1408
1409    // -----------------------------------------------------------------------
1410    // Multi-pixel conversion
1411    // -----------------------------------------------------------------------
1412
1413    #[test]
1414    fn convert_multiple_pixels() {
1415        let mut conv =
1416            RowConverter::new(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBA8_SRGB).unwrap();
1417        let src = [10, 20, 30, 40, 50, 60, 70, 80, 90];
1418        let mut dst = [0u8; 12];
1419        conv.convert_row(&src, &mut dst, 3);
1420        assert_eq!(dst, [10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255]);
1421    }
1422
1423    // -----------------------------------------------------------------------
1424    // Combined layout + depth conversions
1425    // -----------------------------------------------------------------------
1426
1427    #[test]
1428    fn gray8_to_rgbaf32_linear() {
1429        // Gray U8 sRGB → RGBA F32 Linear: depth expands, then layout expands.
1430        let from = PixelDescriptor::GRAY8_SRGB;
1431        let to = PixelDescriptor::new(
1432            ChannelType::F32,
1433            ChannelLayout::Rgba,
1434            Some(AlphaMode::Straight),
1435            TransferFunction::Linear,
1436        );
1437        let dst = convert_pixel(from, to, &[128]);
1438        let f: &[f32] = bytemuck::cast_slice(&dst);
1439        // sRGB 128 ≈ linear 0.2158, broadcasted, alpha = 1.0.
1440        assert!((f[0] - 0.2158).abs() < 0.01);
1441        assert!((f[0] - f[1]).abs() < 1e-6);
1442        assert!((f[0] - f[2]).abs() < 1e-6);
1443        assert!((f[3] - 1.0).abs() < 1e-6);
1444    }
1445
1446    #[test]
1447    fn rgba8_to_rgb16() {
1448        // Layout contracts (drop alpha), then depth expands.
1449        let from = PixelDescriptor::RGBA8_SRGB;
1450        let to = PixelDescriptor::new(
1451            ChannelType::U16,
1452            ChannelLayout::Rgb,
1453            None,
1454            TransferFunction::Srgb,
1455        );
1456        let dst = convert_pixel(from, to, &[100, 150, 200, 255]);
1457        let u16s: &[u16] = bytemuck::cast_slice(&dst);
1458        assert_eq!(u16s[0], 100 * 257);
1459        assert_eq!(u16s[1], 150 * 257);
1460        assert_eq!(u16s[2], 200 * 257);
1461    }
1462
1463    // -----------------------------------------------------------------------
1464    // U16 and F32 layout conversion kernels
1465    // -----------------------------------------------------------------------
1466
1467    #[test]
1468    fn gray_u16_to_rgb_u16() {
1469        let from = PixelDescriptor::new(
1470            ChannelType::U16,
1471            ChannelLayout::Gray,
1472            None,
1473            TransferFunction::Srgb,
1474        );
1475        let to = PixelDescriptor::new(
1476            ChannelType::U16,
1477            ChannelLayout::Rgb,
1478            None,
1479            TransferFunction::Srgb,
1480        );
1481        let src16: [u16; 1] = [40000];
1482        let src: &[u8] = bytemuck::cast_slice(&src16);
1483        let dst = convert_pixel(from, to, src);
1484        let out: &[u16] = bytemuck::cast_slice(&dst);
1485        assert_eq!(out, [40000, 40000, 40000]);
1486    }
1487
1488    #[test]
1489    fn gray_f32_to_rgba_f32() {
1490        let from = PixelDescriptor::new(
1491            ChannelType::F32,
1492            ChannelLayout::Gray,
1493            None,
1494            TransferFunction::Linear,
1495        );
1496        let to = PixelDescriptor::new(
1497            ChannelType::F32,
1498            ChannelLayout::Rgba,
1499            Some(AlphaMode::Straight),
1500            TransferFunction::Linear,
1501        );
1502        let src_f: [f32; 1] = [0.6];
1503        let src: &[u8] = bytemuck::cast_slice(&src_f);
1504        let dst = convert_pixel(from, to, src);
1505        let out: &[f32] = bytemuck::cast_slice(&dst);
1506        assert!((out[0] - 0.6).abs() < 1e-6);
1507        assert!((out[1] - 0.6).abs() < 1e-6);
1508        assert!((out[2] - 0.6).abs() < 1e-6);
1509        assert!((out[3] - 1.0).abs() < 1e-6);
1510    }
1511
1512    #[test]
1513    fn rgb_f32_to_rgba_f32() {
1514        let from = PixelDescriptor::new(
1515            ChannelType::F32,
1516            ChannelLayout::Rgb,
1517            None,
1518            TransferFunction::Linear,
1519        );
1520        let to = PixelDescriptor::new(
1521            ChannelType::F32,
1522            ChannelLayout::Rgba,
1523            Some(AlphaMode::Straight),
1524            TransferFunction::Linear,
1525        );
1526        let src_f: [f32; 3] = [0.2, 0.4, 0.8];
1527        let src: &[u8] = bytemuck::cast_slice(&src_f);
1528        let dst = convert_pixel(from, to, src);
1529        let out: &[f32] = bytemuck::cast_slice(&dst);
1530        assert!((out[0] - 0.2).abs() < 1e-6);
1531        assert!((out[1] - 0.4).abs() < 1e-6);
1532        assert!((out[2] - 0.8).abs() < 1e-6);
1533        assert!((out[3] - 1.0).abs() < 1e-6);
1534    }
1535
1536    #[test]
1537    fn rgba_u16_to_rgb_u16() {
1538        let from = PixelDescriptor::new(
1539            ChannelType::U16,
1540            ChannelLayout::Rgba,
1541            Some(AlphaMode::Straight),
1542            TransferFunction::Srgb,
1543        );
1544        let to = PixelDescriptor::new(
1545            ChannelType::U16,
1546            ChannelLayout::Rgb,
1547            None,
1548            TransferFunction::Srgb,
1549        );
1550        let src16: [u16; 4] = [10000, 20000, 30000, 65535];
1551        let src: &[u8] = bytemuck::cast_slice(&src16);
1552        let dst = convert_pixel(from, to, src);
1553        let out: &[u16] = bytemuck::cast_slice(&dst);
1554        assert_eq!(out, [10000, 20000, 30000]);
1555    }
1556
1557    #[test]
1558    fn gray_alpha_u16_to_rgba_u16() {
1559        let from = PixelDescriptor::new(
1560            ChannelType::U16,
1561            ChannelLayout::GrayAlpha,
1562            Some(AlphaMode::Straight),
1563            TransferFunction::Srgb,
1564        );
1565        let to = PixelDescriptor::new(
1566            ChannelType::U16,
1567            ChannelLayout::Rgba,
1568            Some(AlphaMode::Straight),
1569            TransferFunction::Srgb,
1570        );
1571        let src16: [u16; 2] = [50000, 32768];
1572        let src: &[u8] = bytemuck::cast_slice(&src16);
1573        let dst = convert_pixel(from, to, src);
1574        let out: &[u16] = bytemuck::cast_slice(&dst);
1575        assert_eq!(out, [50000, 50000, 50000, 32768]);
1576    }
1577
1578    #[test]
1579    fn gray_alpha_f32_to_rgb_f32() {
1580        let from = PixelDescriptor::new(
1581            ChannelType::F32,
1582            ChannelLayout::GrayAlpha,
1583            Some(AlphaMode::Straight),
1584            TransferFunction::Linear,
1585        );
1586        let to = PixelDescriptor::new(
1587            ChannelType::F32,
1588            ChannelLayout::Rgb,
1589            None,
1590            TransferFunction::Linear,
1591        );
1592        let src_f: [f32; 2] = [0.75, 0.5];
1593        let src: &[u8] = bytemuck::cast_slice(&src_f);
1594        let dst = convert_pixel(from, to, src);
1595        let out: &[f32] = bytemuck::cast_slice(&dst);
1596        assert!((out[0] - 0.75).abs() < 1e-6);
1597        assert!((out[1] - 0.75).abs() < 1e-6);
1598        assert!((out[2] - 0.75).abs() < 1e-6);
1599    }
1600
1601    #[test]
1602    fn gray_u16_to_gray_alpha_u16() {
1603        let from = PixelDescriptor::new(
1604            ChannelType::U16,
1605            ChannelLayout::Gray,
1606            None,
1607            TransferFunction::Srgb,
1608        );
1609        let to = PixelDescriptor::new(
1610            ChannelType::U16,
1611            ChannelLayout::GrayAlpha,
1612            Some(AlphaMode::Straight),
1613            TransferFunction::Srgb,
1614        );
1615        let src16: [u16; 1] = [12345];
1616        let src: &[u8] = bytemuck::cast_slice(&src16);
1617        let dst = convert_pixel(from, to, src);
1618        let out: &[u16] = bytemuck::cast_slice(&dst);
1619        assert_eq!(out, [12345, 65535]);
1620    }
1621
1622    #[test]
1623    fn gray_alpha_f32_to_gray_f32() {
1624        let from = PixelDescriptor::new(
1625            ChannelType::F32,
1626            ChannelLayout::GrayAlpha,
1627            Some(AlphaMode::Straight),
1628            TransferFunction::Linear,
1629        );
1630        let to = PixelDescriptor::new(
1631            ChannelType::F32,
1632            ChannelLayout::Gray,
1633            None,
1634            TransferFunction::Linear,
1635        );
1636        let src_f: [f32; 2] = [0.33, 0.9];
1637        let src: &[u8] = bytemuck::cast_slice(&src_f);
1638        let dst = convert_pixel(from, to, src);
1639        let out: &[f32] = bytemuck::cast_slice(&dst);
1640        assert!((out[0] - 0.33).abs() < 1e-6);
1641    }
1642
1643    // -----------------------------------------------------------------------
1644    // Transfer function roundtrips (ext.rs branches)
1645    // -----------------------------------------------------------------------
1646
1647    #[test]
1648    fn bt709_linear_f32_roundtrip() {
1649        use crate::TransferFunctionExt;
1650        let tf = TransferFunction::Bt709;
1651        let values = [0.0f32, 0.1, 0.25, 0.5, 0.75, 1.0];
1652        for &v in &values {
1653            let linear = tf.linearize(v);
1654            let back = tf.delinearize(linear);
1655            assert!(
1656                (back - v).abs() < 1e-5,
1657                "Bt709 roundtrip failed for {v}: linearize={linear}, delinearize={back}"
1658            );
1659        }
1660    }
1661
1662    #[test]
1663    fn unknown_transfer_roundtrip() {
1664        use crate::TransferFunctionExt;
1665        let tf = TransferFunction::Unknown;
1666        let values = [0.0f32, 0.1, 0.5, 0.99, 1.0];
1667        for &v in &values {
1668            let linear = tf.linearize(v);
1669            assert!(
1670                (linear - v).abs() < 1e-7,
1671                "Unknown linearize should be identity: {v} -> {linear}"
1672            );
1673            let back = tf.delinearize(linear);
1674            assert!(
1675                (back - v).abs() < 1e-7,
1676                "Unknown delinearize should be identity: {linear} -> {back}"
1677            );
1678        }
1679    }
1680
1681    #[test]
1682    fn oklab_unknown_primaries_returns_error() {
1683        use crate::ColorPrimaries;
1684        let from = PixelDescriptor::new(
1685            ChannelType::F32,
1686            ChannelLayout::Rgb,
1687            None,
1688            TransferFunction::Linear,
1689        )
1690        .with_primaries(ColorPrimaries::Unknown);
1691        let to = PixelDescriptor::new(
1692            ChannelType::F32,
1693            ChannelLayout::Oklab,
1694            None,
1695            TransferFunction::Linear,
1696        )
1697        .with_primaries(ColorPrimaries::Unknown);
1698        let result = RowConverter::new(from, to);
1699        assert!(result.is_err(), "Oklab with Unknown primaries should fail");
1700    }
1701
1702    // -----------------------------------------------------------------------
1703    // Compose
1704    // -----------------------------------------------------------------------
1705
1706    #[test]
1707    fn compose_roundtrip_cancels_to_identity() {
1708        let a = RowConverter::new(PixelDescriptor::RGBA8_SRGB, PixelDescriptor::RGBAF32_LINEAR)
1709            .unwrap();
1710        let b = RowConverter::new(PixelDescriptor::RGBAF32_LINEAR, PixelDescriptor::RGBA8_SRGB)
1711            .unwrap();
1712        let composed = a.compose(&b).unwrap();
1713        assert!(composed.is_identity(), "sRGB→linear→sRGB should cancel");
1714    }
1715
1716    #[test]
1717    fn compose_chain_reduces_steps() {
1718        // RGBA8 sRGB → RGBAF32 linear → RGBAF32 sRGB
1719        // Steps: SrgbU8ToLinearF32 then LinearF32ToSrgbF32
1720        // The sRGB→linear step from plan A and linear→sRGB from plan B
1721        // should NOT cancel (different depth target), but composing avoids
1722        // an intermediate allocation.
1723        let a = RowConverter::new(PixelDescriptor::RGBA8_SRGB, PixelDescriptor::RGBAF32_LINEAR)
1724            .unwrap();
1725        let srgb_f32 = PixelDescriptor::new(
1726            ChannelType::F32,
1727            ChannelLayout::Rgba,
1728            Some(AlphaMode::Straight),
1729            TransferFunction::Srgb,
1730        );
1731        let b = RowConverter::new(PixelDescriptor::RGBAF32_LINEAR, srgb_f32).unwrap();
1732        let composed = a.compose(&b).unwrap();
1733        assert!(!composed.is_identity());
1734        assert_eq!(composed.from_descriptor(), PixelDescriptor::RGBA8_SRGB);
1735        assert_eq!(composed.to_descriptor(), srgb_f32);
1736    }
1737
1738    #[test]
1739    fn compose_incompatible_returns_none() {
1740        let a = RowConverter::new(PixelDescriptor::RGBA8_SRGB, PixelDescriptor::RGBAF32_LINEAR)
1741            .unwrap();
1742        let b = RowConverter::new(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBA8_SRGB).unwrap();
1743        assert!(a.compose(&b).is_none(), "RGBAF32_LINEAR != RGB8_SRGB");
1744    }
1745
1746    #[test]
1747    fn compose_premul_roundtrip_cancels() {
1748        let premul = PixelDescriptor::new(
1749            ChannelType::F32,
1750            ChannelLayout::Rgba,
1751            Some(AlphaMode::Premultiplied),
1752            TransferFunction::Linear,
1753        );
1754        let a = RowConverter::new(PixelDescriptor::RGBAF32_LINEAR, premul).unwrap();
1755        let b = RowConverter::new(premul, PixelDescriptor::RGBAF32_LINEAR).unwrap();
1756        let composed = a.compose(&b).unwrap();
1757        assert!(
1758            composed.is_identity(),
1759            "straight→premul→straight should cancel"
1760        );
1761    }
1762
1763    // -----------------------------------------------------------------------
1764    // CMYK guard
1765    // -----------------------------------------------------------------------
1766
1767    #[test]
1768    #[should_panic(expected = "CMYK pixel data cannot be processed")]
1769    fn cmyk_rejected_by_row_converter() {
1770        let _ = RowConverter::new(PixelDescriptor::CMYK8, PixelDescriptor::RGB8_SRGB);
1771    }
1772}