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