Skip to main content

zenpixels_convert/
convert.rs

1//! Row-level pixel conversion kernels.
2//!
3//! Each kernel converts one row of `width` pixels from a source format to
4//! a destination format. Individual step kernels are pure functions with
5//! no allocation. Multi-step plans use [`ConvertScratch`] ping-pong
6//! buffers to avoid per-row heap allocation in streaming loops.
7
8use alloc::vec;
9use alloc::vec::Vec;
10use core::cmp::min;
11
12use crate::policy::{AlphaPolicy, ConvertOptions, DepthPolicy, LumaCoefficients};
13use crate::{
14    AlphaMode, ChannelLayout, ChannelType, ColorPrimaries, ConvertError, PixelDescriptor,
15    TransferFunction,
16};
17use whereat::{At, ResultAtExt};
18
19/// Pre-computed conversion plan.
20///
21/// Stores the chain of steps needed to convert from one format to another.
22/// Created once, applied to every row.
23#[derive(Clone, Debug)]
24pub struct ConvertPlan {
25    pub(crate) from: PixelDescriptor,
26    pub(crate) to: PixelDescriptor,
27    pub(crate) steps: Vec<ConvertStep>,
28    /// Relative-linear → PQ-absolute scale = `diffuse_white_nits / 10000`,
29    /// applied by the PQ kernels (encode multiplies pre-OETF, decode divides
30    /// post-EOTF). `1.0` is the unsignaled default and means "treat linear as
31    /// already PQ-absolute (1.0 = 10000 cd/m²)" — i.e. exactly the prior
32    /// behavior, so plans built without an anchor are byte-for-byte unchanged.
33    /// Set via [`with_pq_anchor`](Self::with_pq_anchor). HLG steps ignore it
34    /// (scene-referred — different anchoring, out of scope here).
35    pub(crate) pq_anchor_scale: f32,
36}
37
38/// A single conversion step.
39///
40/// Not `Copy` — some variants (e.g., [`ExternalTransform`]) carry an
41/// `Arc`. Peephole rewrites must use `.clone()` or index assignment with
42/// pattern matching instead of `*step` dereferences.
43///
44/// [`ExternalTransform`]: ConvertStep::ExternalTransform
45#[derive(Clone)]
46pub(crate) enum ConvertStep {
47    /// No-op (identity).
48    Identity,
49    /// BGRA → RGBA byte swizzle (or vice versa).
50    SwizzleBgraRgba,
51    /// Fused RGB → BGRA: byte swap + add opaque alpha in a single SIMD pass.
52    /// Equivalent to `[AddAlpha, SwizzleBgraRgba]` but writes the destination
53    /// once instead of twice.
54    RgbToBgra,
55    /// Add alpha channel (3ch → 4ch), filling with opaque.
56    AddAlpha,
57    /// Drop alpha channel (4ch → 3ch).
58    DropAlpha,
59    /// Composite onto solid matte color, then drop alpha (4ch → 3ch).
60    ///
61    /// Blends in linear light using the source descriptor's transfer
62    /// function: pixel RGB is EOTF'd per source TF, alpha-blended against
63    /// the pre-linearized matte, then OETF'd back to source TF. Alpha is
64    /// treated as linear regardless of color-channel TF. The matte
65    /// `(r, g, b)` is always interpreted as sRGB u8 (CSS-style background).
66    ///
67    /// Implemented uniformly across U8/U16/F32/F16 via per-TF
68    /// monomorphization; sRGB integer paths use LUT-based EOTF/OETF.
69    MatteComposite { r: u8, g: u8, b: u8 },
70    /// Gray → RGB (replicate gray to all 3 channels).
71    GrayToRgb,
72    /// Gray → RGBA (replicate + opaque alpha).
73    GrayToRgba,
74    /// RGB → Gray (Y' encoded luma — coefficients applied to encoded bytes).
75    ///
76    /// The semantic is BT.709/BT.601/etc. Y' (encoded luma), NOT linear-light
77    /// luminance L. This is fast, exactly round-trips for `R==G==B` inputs,
78    /// and matches what JPEG/video pipelines compute. Linear-light luminance
79    /// would require linearize → weight → encode and is not currently
80    /// surfaced; document any future linear-L pathway as a separate variant.
81    ///
82    /// Coefficients are resolved from `ConvertOptions::luma` at plan build
83    /// time (`new_explicit`). Default for plans built via `Self::new`
84    /// without options is `LumaCoefficients::Bt709`.
85    RgbToGray { coefficients: LumaCoefficients },
86    /// RGBA → Gray, drop alpha. See [`RgbToGray`](Self::RgbToGray) for
87    /// semantic and coefficient resolution.
88    RgbaToGray { coefficients: LumaCoefficients },
89    /// GrayAlpha → RGBA (replicate gray, keep alpha).
90    GrayAlphaToRgba,
91    /// GrayAlpha → RGB (replicate gray, drop alpha).
92    GrayAlphaToRgb,
93    /// Gray → GrayAlpha (add opaque alpha).
94    GrayToGrayAlpha,
95    /// GrayAlpha → Gray (drop alpha).
96    GrayAlphaToGray,
97    /// sRGB u8 → linear f32 (per channel, EOTF).
98    SrgbU8ToLinearF32,
99    /// Linear f32 → sRGB u8 (per channel, OETF).
100    LinearF32ToSrgbU8,
101    /// Naive u8 → f32 (v / 255.0, no gamma).
102    NaiveU8ToF32,
103    /// Naive f32 → u8 (clamp * 255 + 0.5, no gamma).
104    NaiveF32ToU8,
105    /// u16 → u8 ((v * 255 + 32768) >> 16).
106    U16ToU8,
107    /// u8 → u16 (v * 257).
108    U8ToU16,
109    /// u16 → f32 (v / 65535.0).
110    U16ToF32,
111    /// f32 → u16 (clamp * 65535 + 0.5).
112    F32ToU16,
113    /// f16 → f32 (IEEE 754 half-precision unpack, no TF).
114    F16ToF32,
115    /// f32 → f16 (round-to-nearest-even, no TF).
116    F32ToF16,
117    /// PQ (SMPTE ST 2084) u16 → linear f32 (EOTF).
118    PqU16ToLinearF32,
119    /// Linear f32 → PQ u16 (inverse EOTF / OETF).
120    LinearF32ToPqU16,
121    /// PQ f32 [0,1] → linear f32 (EOTF, no depth change).
122    PqF32ToLinearF32,
123    /// Linear f32 → PQ f32 [0,1] (OETF, no depth change).
124    LinearF32ToPqF32,
125    /// HLG (ARIB STD-B67) u16 → linear f32 (EOTF).
126    HlgU16ToLinearF32,
127    /// Linear f32 → HLG u16 (OETF).
128    LinearF32ToHlgU16,
129    /// HLG f32 [0,1] → linear f32 (EOTF, no depth change).
130    HlgF32ToLinearF32,
131    /// Linear f32 → HLG f32 [0,1] (OETF, no depth change).
132    LinearF32ToHlgF32,
133    /// sRGB f32 [0,1] → linear f32 (EOTF, no depth change). Clamps input.
134    SrgbF32ToLinearF32,
135    /// Linear f32 → sRGB f32 [0,1] (OETF, no depth change). Clamps output.
136    LinearF32ToSrgbF32,
137    /// sRGB f32 → linear f32 (EOTF, sign-preserving extended range).
138    /// Emitted when `ConvertOptions::clip_out_of_gamut == false`.
139    SrgbF32ToLinearF32Extended,
140    /// Linear f32 → sRGB f32 (OETF, sign-preserving extended range).
141    LinearF32ToSrgbF32Extended,
142    /// BT.709 f32 [0,1] → linear f32 (EOTF, no depth change).
143    Bt709F32ToLinearF32,
144    /// Linear f32 → BT.709 f32 [0,1] (OETF, no depth change).
145    LinearF32ToBt709F32,
146    /// Gamma 2.2 (Adobe RGB 1998) f32 [0,1] → linear f32 (EOTF, no depth change).
147    /// Uses the Adobe RGB 1998 canonical exponent 563/256 ≈ 2.19921875.
148    Gamma22F32ToLinearF32,
149    /// Linear f32 → Gamma 2.2 (Adobe RGB 1998) f32 [0,1] (OETF, no depth change).
150    LinearF32ToGamma22F32,
151    /// Straight → Premultiplied alpha.
152    StraightToPremul,
153    /// Premultiplied → Straight alpha.
154    PremulToStraight,
155    /// Linear RGB f32 → Oklab f32 (3-channel color model change).
156    LinearRgbToOklab,
157    /// Oklab f32 → Linear RGB f32 (3-channel color model change).
158    OklabToLinearRgb,
159    /// Linear RGBA f32 → Oklaba f32 (4-channel, alpha preserved).
160    LinearRgbaToOklaba,
161    /// Oklaba f32 → Linear RGBA f32 (4-channel, alpha preserved).
162    OklabaToLinearRgba,
163    /// Apply a 3×3 gamut matrix to linear RGB f32 (3 channels per pixel).
164    ///
165    /// Used for color primaries conversion (e.g., BT.709 ↔ Display P3 ↔ BT.2020).
166    /// Data must be in linear light. The matrix is row-major `[[f32; 3]; 3]`
167    /// flattened to `[f32; 9]`.
168    GamutMatrixRgbF32([f32; 9]),
169    /// Apply a 3×3 gamut matrix to linear RGBA f32 (4 channels, alpha passthrough).
170    GamutMatrixRgbaF32([f32; 9]),
171    /// Fused u8-sRGB RGB primaries conversion: LUT linearize → SIMD matrix →
172    /// SIMD f32→i32 → LUT encode, in one pass. Replaces the 3-step sequence
173    /// `[SrgbU8ToLinearF32, GamutMatrixRgbF32(m), LinearF32ToSrgbU8]`.
174    FusedSrgbU8GamutRgb([f32; 9]),
175    /// Fused u8-sRGB RGBA primaries conversion (alpha passthrough).
176    FusedSrgbU8GamutRgba([f32; 9]),
177    /// Fused u16-sRGB RGB primaries conversion via 65K-entry LUTs.
178    FusedSrgbU16GamutRgb([f32; 9]),
179    /// Fused u8-sRGB → linear-f32 RGB primaries conversion (cross-depth).
180    /// Output preserves extended range (no clamp).
181    FusedSrgbU8ToLinearF32Rgb([f32; 9]),
182    /// Fused linear-f32 → u8-sRGB RGB primaries conversion (cross-depth).
183    /// Always clamps since u8 can't represent out-of-gamut values.
184    FusedLinearF32ToSrgbU8Rgb([f32; 9]),
185}
186
187impl core::fmt::Debug for ConvertStep {
188    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
189        match self {
190            Self::Identity => f.write_str("Identity"),
191            Self::SwizzleBgraRgba => f.write_str("SwizzleBgraRgba"),
192            Self::RgbToBgra => f.write_str("RgbToBgra"),
193            Self::AddAlpha => f.write_str("AddAlpha"),
194            Self::DropAlpha => f.write_str("DropAlpha"),
195            Self::MatteComposite { r, g, b } => f
196                .debug_struct("MatteComposite")
197                .field("r", r)
198                .field("g", g)
199                .field("b", b)
200                .finish(),
201            Self::GrayToRgb => f.write_str("GrayToRgb"),
202            Self::GrayToRgba => f.write_str("GrayToRgba"),
203            Self::RgbToGray { coefficients } => f
204                .debug_struct("RgbToGray")
205                .field("coefficients", coefficients)
206                .finish(),
207            Self::RgbaToGray { coefficients } => f
208                .debug_struct("RgbaToGray")
209                .field("coefficients", coefficients)
210                .finish(),
211            Self::GrayAlphaToRgba => f.write_str("GrayAlphaToRgba"),
212            Self::GrayAlphaToRgb => f.write_str("GrayAlphaToRgb"),
213            Self::GrayToGrayAlpha => f.write_str("GrayToGrayAlpha"),
214            Self::GrayAlphaToGray => f.write_str("GrayAlphaToGray"),
215            Self::SrgbU8ToLinearF32 => f.write_str("SrgbU8ToLinearF32"),
216            Self::LinearF32ToSrgbU8 => f.write_str("LinearF32ToSrgbU8"),
217            Self::NaiveU8ToF32 => f.write_str("NaiveU8ToF32"),
218            Self::NaiveF32ToU8 => f.write_str("NaiveF32ToU8"),
219            Self::U16ToU8 => f.write_str("U16ToU8"),
220            Self::U8ToU16 => f.write_str("U8ToU16"),
221            Self::U16ToF32 => f.write_str("U16ToF32"),
222            Self::F32ToU16 => f.write_str("F32ToU16"),
223            Self::F16ToF32 => f.write_str("F16ToF32"),
224            Self::F32ToF16 => f.write_str("F32ToF16"),
225            Self::PqU16ToLinearF32 => f.write_str("PqU16ToLinearF32"),
226            Self::LinearF32ToPqU16 => f.write_str("LinearF32ToPqU16"),
227            Self::PqF32ToLinearF32 => f.write_str("PqF32ToLinearF32"),
228            Self::LinearF32ToPqF32 => f.write_str("LinearF32ToPqF32"),
229            Self::HlgU16ToLinearF32 => f.write_str("HlgU16ToLinearF32"),
230            Self::LinearF32ToHlgU16 => f.write_str("LinearF32ToHlgU16"),
231            Self::HlgF32ToLinearF32 => f.write_str("HlgF32ToLinearF32"),
232            Self::LinearF32ToHlgF32 => f.write_str("LinearF32ToHlgF32"),
233            Self::SrgbF32ToLinearF32 => f.write_str("SrgbF32ToLinearF32"),
234            Self::LinearF32ToSrgbF32 => f.write_str("LinearF32ToSrgbF32"),
235            Self::SrgbF32ToLinearF32Extended => f.write_str("SrgbF32ToLinearF32Extended"),
236            Self::LinearF32ToSrgbF32Extended => f.write_str("LinearF32ToSrgbF32Extended"),
237            Self::Bt709F32ToLinearF32 => f.write_str("Bt709F32ToLinearF32"),
238            Self::LinearF32ToBt709F32 => f.write_str("LinearF32ToBt709F32"),
239            Self::Gamma22F32ToLinearF32 => f.write_str("Gamma22F32ToLinearF32"),
240            Self::LinearF32ToGamma22F32 => f.write_str("LinearF32ToGamma22F32"),
241            Self::StraightToPremul => f.write_str("StraightToPremul"),
242            Self::PremulToStraight => f.write_str("PremulToStraight"),
243            Self::LinearRgbToOklab => f.write_str("LinearRgbToOklab"),
244            Self::OklabToLinearRgb => f.write_str("OklabToLinearRgb"),
245            Self::LinearRgbaToOklaba => f.write_str("LinearRgbaToOklaba"),
246            Self::OklabaToLinearRgba => f.write_str("OklabaToLinearRgba"),
247            Self::GamutMatrixRgbF32(m) => f.debug_tuple("GamutMatrixRgbF32").field(m).finish(),
248            Self::GamutMatrixRgbaF32(m) => f.debug_tuple("GamutMatrixRgbaF32").field(m).finish(),
249            Self::FusedSrgbU8GamutRgb(m) => f.debug_tuple("FusedSrgbU8GamutRgb").field(m).finish(),
250            Self::FusedSrgbU8GamutRgba(m) => {
251                f.debug_tuple("FusedSrgbU8GamutRgba").field(m).finish()
252            }
253            Self::FusedSrgbU16GamutRgb(m) => {
254                f.debug_tuple("FusedSrgbU16GamutRgb").field(m).finish()
255            }
256            Self::FusedSrgbU8ToLinearF32Rgb(m) => {
257                f.debug_tuple("FusedSrgbU8ToLinearF32Rgb").field(m).finish()
258            }
259            Self::FusedLinearF32ToSrgbU8Rgb(m) => {
260                f.debug_tuple("FusedLinearF32ToSrgbU8Rgb").field(m).finish()
261            }
262        }
263    }
264}
265
266/// Assert that a descriptor is not CMYK.
267///
268/// CMYK is device-dependent and cannot be converted by zenpixels-convert.
269/// Use a CMS (e.g., moxcms) with an ICC profile for CMYK↔RGB conversion.
270fn assert_not_cmyk(desc: &PixelDescriptor) {
271    assert!(
272        desc.color_model() != crate::ColorModel::Cmyk,
273        "CMYK pixel data cannot be processed by zenpixels-convert. \
274         Use a CMS (e.g., moxcms) with an ICC profile for CMYK↔RGB conversion."
275    );
276}
277
278impl ConvertPlan {
279    /// Assemble a plan with the default (no-anchor) PQ scale. The single place
280    /// `pq_anchor_scale` is defaulted, so every construction path starts at the
281    /// behavior-preserving `1.0`.
282    fn build(from: PixelDescriptor, to: PixelDescriptor, steps: Vec<ConvertStep>) -> Self {
283        Self {
284            from,
285            to,
286            steps,
287            pq_anchor_scale: 1.0,
288        }
289    }
290
291    /// Anchor this plan's **PQ** steps to an absolute-luminance white point —
292    /// the cd/m² that relative-linear `1.0` represents (e.g.
293    /// [`DiffuseWhite::BT2408`](zenpixels::hdr::DiffuseWhite::BT2408) = 203).
294    ///
295    /// The PQ kernels then scale by `nits / 10000` across the relative-linear ↔
296    /// PQ-absolute boundary (encode multiplies before the OETF, decode divides
297    /// after the EOTF), so a relative-linear buffer maps to PQ at the right
298    /// brightness without the caller pre-scaling. A decode+encode pair in one
299    /// plan shares the scale and round-trips exactly. The BT.2408 default (203)
300    /// reproduces the byte-parity-verified pre-scale that `quantize_to` used to
301    /// do by hand. HLG steps are unaffected (scene-referred anchoring differs).
302    #[must_use]
303    pub(crate) fn with_pq_anchor(mut self, anchor: zenpixels::hdr::DiffuseWhite) -> Self {
304        // `diffuse_white_nits` / `PQ_PEAK_NITS` makes the unit explicit: the scale
305        // is the fraction of PQ's 10000 cd/m² peak that relative-linear 1.0 sits at.
306        let diffuse_white_nits = f64::from(anchor.nits());
307        const PQ_PEAK_NITS: f64 = 10_000.0;
308        self.pq_anchor_scale = (diffuse_white_nits / PQ_PEAK_NITS) as f32;
309        self
310    }
311
312    /// Create a conversion plan from `from` to `to`.
313    ///
314    /// Returns `Err` if no conversion path exists. A
315    /// [`SignalRange`](zenpixels::SignalRange) mismatch always refuses
316    /// ([`ConvertError::NoPath`]): there are no Narrow↔Full conversion
317    /// kernels, and relabeling without rescaling would corrupt pixels — see
318    /// the signal-range notes on the [crate docs](crate#step-3-convert).
319    ///
320    /// # Panics
321    ///
322    /// Panics if either `from` or `to` uses [`ColorModel::Cmyk`](zenpixels::ColorModel::Cmyk).
323    /// CMYK requires a CMS with an ICC profile for conversion.
324    #[track_caller]
325    pub fn new(from: PixelDescriptor, to: PixelDescriptor) -> Result<Self, At<ConvertError>> {
326        assert_not_cmyk(&from);
327        assert_not_cmyk(&to);
328        if from == to {
329            return Ok(Self::build(from, to, vec![ConvertStep::Identity]));
330        }
331
332        // Refuse signal-range crossings: no Narrow↔Full steps exist (no
333        // expand/contract kernels), and a plan built from the *other*
334        // descriptor differences would emit the source's range-coded values
335        // under the target's range label — mislabeled pixels (lifted blacks
336        // when narrow data is labeled full, crushed when full data is later
337        // expanded as narrow), not a conversion. Until range kernels land,
338        // range is preserved verbatim or the conversion fails loudly.
339        // Same-range plans (including Narrow→Narrow) are unaffected.
340        if from.signal_range != to.signal_range {
341            return Err(whereat::at!(ConvertError::NoPath { from, to }));
342        }
343
344        // Refuse HLG↔PQ. HLG is scene-referred — these kernels apply only its
345        // OETF, with no OOTF and no `Lw`/peak — while PQ is absolute display
346        // light (cd/m²). Routing one to the other through the shared "linear"
347        // intermediate conflates the two luminance domains by orders of
348        // magnitude (scene-normalized [0,1] vs absolute [0,10000 cd/m²]): a
349        // deterministic but grossly **wrong** result. Until the OOTF +
350        // `(diffuse_white, Lw)` threading lands (#45 S2), fail loudly rather than
351        // emit wrong pixels — the same posture as the signal-range refusal.
352        // HLG↔SDR/linear are *not* refused here: those stay within a normalized
353        // domain (endpoint-correct), missing only the mid-tone OOTF gamma.
354        if matches!(
355            (from.transfer(), to.transfer()),
356            (TransferFunction::Hlg, TransferFunction::Pq)
357                | (TransferFunction::Pq, TransferFunction::Hlg)
358        ) {
359            return Err(whereat::at!(ConvertError::NoPath { from, to }));
360        }
361
362        let mut steps = Vec::with_capacity(3);
363
364        // Step 1: Layout conversion (within same depth class).
365        // Step 2: Depth conversion.
366        // Step 3: Alpha mode conversion.
367        //
368        // For cross-depth conversions, we convert layout at the source depth
369        // first, then change depth. This minimizes the number of channels
370        // we need to depth-convert.
371
372        let need_depth_change = from.channel_type() != to.channel_type();
373        let need_layout_change = from.layout() != to.layout();
374        let need_alpha_change =
375            from.alpha() != to.alpha() && from.alpha().is_some() && to.alpha().is_some();
376
377        // Depth/TF steps are needed when depth changes, or when transfer
378        // functions differ (at any depth — integer TF changes route through
379        // an F32 linear intermediate, handled in `depth_steps`).
380        let need_depth_or_tf = need_depth_change || from.transfer() != to.transfer();
381
382        // If we need to change depth AND layout, plan the optimal order.
383        if need_layout_change {
384            // When going to fewer channels, convert layout first (less depth work).
385            // When going to more channels, convert depth first (less layout work).
386            //
387            // Exception: Oklab layout steps require f32 data. When the source
388            // is integer (U8/U16) and the layout change involves Oklab, we must
389            // convert depth first regardless of channel count.
390            let src_ch = from.layout().channels();
391            let dst_ch = to.layout().channels();
392            let involves_oklab =
393                matches!(from.layout(), ChannelLayout::Oklab | ChannelLayout::OklabA)
394                    || matches!(to.layout(), ChannelLayout::Oklab | ChannelLayout::OklabA);
395
396            // Oklab conversion requires known primaries for the RGB→LMS matrix.
397            if involves_oklab && from.primaries == ColorPrimaries::Unknown {
398                return Err(whereat::at!(ConvertError::NoPath { from, to }));
399            }
400
401            let depth_first = need_depth_or_tf
402                && (dst_ch > src_ch || (involves_oklab && from.channel_type() != ChannelType::F32));
403
404            if depth_first {
405                // Depth first, then layout.
406                steps.extend(
407                    depth_steps(
408                        from.channel_type(),
409                        to.channel_type(),
410                        from.transfer(),
411                        to.transfer(),
412                    )
413                    .map_err(|e| whereat::at!(e))?,
414                );
415                steps.extend(layout_steps(from.layout(), to.layout()));
416            } else {
417                // Layout first, then depth.
418                steps.extend(layout_steps(from.layout(), to.layout()));
419                if need_depth_or_tf {
420                    steps.extend(
421                        depth_steps(
422                            from.channel_type(),
423                            to.channel_type(),
424                            from.transfer(),
425                            to.transfer(),
426                        )
427                        .map_err(|e| whereat::at!(e))?,
428                    );
429                }
430            }
431        } else if need_depth_or_tf {
432            steps.extend(
433                depth_steps(
434                    from.channel_type(),
435                    to.channel_type(),
436                    from.transfer(),
437                    to.transfer(),
438                )
439                .map_err(|e| whereat::at!(e))?,
440            );
441        }
442
443        // Alpha mode conversion (if both have alpha and modes differ).
444        if need_alpha_change {
445            match (from.alpha(), to.alpha()) {
446                (Some(AlphaMode::Straight), Some(AlphaMode::Premultiplied)) => {
447                    steps.push(ConvertStep::StraightToPremul);
448                }
449                (Some(AlphaMode::Premultiplied), Some(AlphaMode::Straight)) => {
450                    steps.push(ConvertStep::PremulToStraight);
451                }
452                _ => {}
453            }
454        }
455
456        // Primaries conversion: if source and destination have different known
457        // primaries, inject a gamut matrix in linear f32 space.
458        let need_primaries = from.primaries != to.primaries
459            && from.primaries != ColorPrimaries::Unknown
460            && to.primaries != ColorPrimaries::Unknown;
461
462        if need_primaries
463            && let Some(matrix) = crate::gamut::conversion_matrix(from.primaries, to.primaries)
464        {
465            // Flatten the 3×3 matrix for storage in the step enum.
466            let flat = [
467                matrix[0][0],
468                matrix[0][1],
469                matrix[0][2],
470                matrix[1][0],
471                matrix[1][1],
472                matrix[1][2],
473                matrix[2][0],
474                matrix[2][1],
475                matrix[2][2],
476            ];
477
478            // The gamut matrix must be applied in linear f32 space.
479            // Check if the existing steps already go through linear f32.
480            let mut goes_through_linear = false;
481            {
482                let mut desc = from;
483                for step in &steps {
484                    desc = intermediate_desc(desc, step);
485                    if desc.channel_type() == ChannelType::F32
486                        && desc.transfer() == TransferFunction::Linear
487                    {
488                        goes_through_linear = true;
489                    }
490                }
491            }
492
493            if goes_through_linear {
494                // Insert the gamut matrix right after the first step that
495                // produces linear f32. All subsequent steps encode to the
496                // target format.
497                let mut insert_pos = 0;
498                let mut desc = from;
499                for (i, step) in steps.iter().enumerate() {
500                    desc = intermediate_desc(desc, step);
501                    if desc.channel_type() == ChannelType::F32
502                        && desc.transfer() == TransferFunction::Linear
503                    {
504                        insert_pos = i + 1;
505                        break;
506                    }
507                }
508                let gamut_step = if desc.layout().has_alpha() {
509                    ConvertStep::GamutMatrixRgbaF32(flat)
510                } else {
511                    ConvertStep::GamutMatrixRgbF32(flat)
512                };
513                steps.insert(insert_pos, gamut_step);
514            } else {
515                // No existing linear f32 step — we must add linearize → gamut → delinearize.
516                // Determine layout for the gamut step.
517                let has_alpha = from.layout().has_alpha() || to.layout().has_alpha();
518                // Use the layout at the current point in the plan.
519                let mut desc = from;
520                for step in &steps {
521                    desc = intermediate_desc(desc, step);
522                }
523                let gamut_step = if desc.layout().has_alpha() || has_alpha {
524                    ConvertStep::GamutMatrixRgbaF32(flat)
525                } else {
526                    ConvertStep::GamutMatrixRgbF32(flat)
527                };
528
529                // Insert linearize → gamut → encode-to-target-tf at the end,
530                // before any alpha mode steps.
531                let linearize = match desc.transfer() {
532                    TransferFunction::Srgb => ConvertStep::SrgbF32ToLinearF32,
533                    TransferFunction::Bt709 => ConvertStep::Bt709F32ToLinearF32,
534                    TransferFunction::Pq => ConvertStep::PqF32ToLinearF32,
535                    TransferFunction::Hlg => ConvertStep::HlgF32ToLinearF32,
536                    TransferFunction::Gamma22 => ConvertStep::Gamma22F32ToLinearF32,
537                    TransferFunction::Linear => ConvertStep::Identity,
538                    _ => ConvertStep::SrgbF32ToLinearF32, // assume sRGB for Unknown
539                };
540                let to_target_tf = match to.transfer() {
541                    TransferFunction::Srgb => ConvertStep::LinearF32ToSrgbF32,
542                    TransferFunction::Bt709 => ConvertStep::LinearF32ToBt709F32,
543                    TransferFunction::Pq => ConvertStep::LinearF32ToPqF32,
544                    TransferFunction::Hlg => ConvertStep::LinearF32ToHlgF32,
545                    TransferFunction::Gamma22 => ConvertStep::LinearF32ToGamma22F32,
546                    TransferFunction::Linear => ConvertStep::Identity,
547                    _ => ConvertStep::LinearF32ToSrgbF32, // assume sRGB for Unknown
548                };
549
550                // Need to be in f32 first. If current is integer, add naive conversion.
551                let mut gamut_steps = Vec::new();
552                // Direct fused-step emissions for common cases.
553                if desc.channel_type() == ChannelType::U16
554                    && desc.transfer() == TransferFunction::Srgb
555                    && to.channel_type() == ChannelType::U16
556                    && to.transfer() == TransferFunction::Srgb
557                    && !desc.layout().has_alpha()
558                    && !to.layout().has_alpha()
559                {
560                    // u16 sRGB → u16 sRGB RGB: single-step matlut.
561                    gamut_steps.push(ConvertStep::FusedSrgbU16GamutRgb(flat));
562                    steps.extend(gamut_steps);
563                    if steps.is_empty() {
564                        steps.push(ConvertStep::Identity);
565                    }
566                    fuse_matlut_patterns(&mut steps);
567                    return Ok(Self::build(from, to, steps));
568                }
569                if desc.channel_type() == ChannelType::U8
570                    && matches!(desc.transfer(), TransferFunction::Srgb)
571                    && to.channel_type() == ChannelType::F32
572                    && to.transfer() == TransferFunction::Linear
573                    && !desc.layout().has_alpha()
574                    && !to.layout().has_alpha()
575                {
576                    // u8 sRGB → linear f32 RGB: cross-depth matlut.
577                    gamut_steps.push(ConvertStep::FusedSrgbU8ToLinearF32Rgb(flat));
578                    steps.extend(gamut_steps);
579                    if steps.is_empty() {
580                        steps.push(ConvertStep::Identity);
581                    }
582                    fuse_matlut_patterns(&mut steps);
583                    return Ok(Self::build(from, to, steps));
584                }
585                if desc.channel_type() == ChannelType::F32
586                    && desc.transfer() == TransferFunction::Linear
587                    && to.channel_type() == ChannelType::U8
588                    && to.transfer() == TransferFunction::Srgb
589                    && !desc.layout().has_alpha()
590                    && !to.layout().has_alpha()
591                {
592                    // linear f32 → u8 sRGB RGB: cross-depth matlut.
593                    gamut_steps.push(ConvertStep::FusedLinearF32ToSrgbU8Rgb(flat));
594                    steps.extend(gamut_steps);
595                    if steps.is_empty() {
596                        steps.push(ConvertStep::Identity);
597                    }
598                    fuse_matlut_patterns(&mut steps);
599                    return Ok(Self::build(from, to, steps));
600                }
601                if desc.channel_type() != ChannelType::F32 {
602                    // Use the fused sRGB u8→linear f32 if applicable.
603                    if desc.channel_type() == ChannelType::U8
604                        && matches!(
605                            desc.transfer(),
606                            TransferFunction::Srgb
607                                | TransferFunction::Bt709
608                                | TransferFunction::Unknown
609                        )
610                    {
611                        gamut_steps.push(ConvertStep::SrgbU8ToLinearF32);
612                        // Already linear, skip separate linearize.
613                        gamut_steps.push(gamut_step);
614                        gamut_steps.push(ConvertStep::LinearF32ToSrgbU8);
615                    } else if desc.channel_type() == ChannelType::U16
616                        && desc.transfer() == TransferFunction::Pq
617                    {
618                        gamut_steps.push(ConvertStep::PqU16ToLinearF32);
619                        gamut_steps.push(gamut_step);
620                        gamut_steps.push(ConvertStep::LinearF32ToPqU16);
621                    } else if desc.channel_type() == ChannelType::U16
622                        && desc.transfer() == TransferFunction::Hlg
623                    {
624                        gamut_steps.push(ConvertStep::HlgU16ToLinearF32);
625                        gamut_steps.push(gamut_step);
626                        gamut_steps.push(ConvertStep::LinearF32ToHlgU16);
627                    } else {
628                        // Generic: naive to f32, linearize, gamut, delinearize, naive back
629                        gamut_steps.push(ConvertStep::NaiveU8ToF32);
630                        if !matches!(linearize, ConvertStep::Identity) {
631                            gamut_steps.push(linearize);
632                        }
633                        gamut_steps.push(gamut_step);
634                        if !matches!(to_target_tf, ConvertStep::Identity) {
635                            gamut_steps.push(to_target_tf);
636                        }
637                        gamut_steps.push(ConvertStep::NaiveF32ToU8);
638                    }
639                } else {
640                    // Already f32, just linearize → gamut → encode
641                    if !matches!(linearize, ConvertStep::Identity) {
642                        gamut_steps.push(linearize);
643                    }
644                    gamut_steps.push(gamut_step);
645                    if !matches!(to_target_tf, ConvertStep::Identity) {
646                        gamut_steps.push(to_target_tf);
647                    }
648                }
649
650                steps.extend(gamut_steps);
651            }
652        }
653
654        if steps.is_empty() {
655            // Transfer-only difference or alpha-mode-only: identity path.
656            steps.push(ConvertStep::Identity);
657        }
658
659        // Peephole fusion: collapse common 3-step patterns into single fused
660        // kernels that avoid scratch-buffer round-trips.
661        fuse_matlut_patterns(&mut steps);
662
663        Ok(Self::build(from, to, steps))
664    }
665
666    /// Create a conversion plan with explicit policy enforcement.
667    ///
668    /// Validates that the planned conversion steps are allowed by the given
669    /// policies before creating the plan. Returns an error if a forbidden
670    /// operation would be required.
671    ///
672    /// # Panics
673    ///
674    /// Panics if either `from` or `to` uses [`ColorModel::Cmyk`](zenpixels::ColorModel::Cmyk).
675    /// CMYK requires a CMS with an ICC profile for conversion.
676    #[track_caller]
677    pub fn new_explicit(
678        from: PixelDescriptor,
679        to: PixelDescriptor,
680        options: &ConvertOptions,
681    ) -> Result<Self, At<ConvertError>> {
682        assert_not_cmyk(&from);
683        assert_not_cmyk(&to);
684        // Check alpha removal policy.
685        let drops_alpha = from.alpha().is_some() && to.alpha().is_none();
686        if drops_alpha && options.alpha_policy == AlphaPolicy::Forbid {
687            return Err(whereat::at!(ConvertError::AlphaRemovalForbidden));
688        }
689
690        // Check depth reduction policy. Compare by precision bits, not byte
691        // size — F16 and U16 are both 2 bytes but F16 carries only ~11 bits of
692        // precision vs U16's 16, so a U16→F16 hop IS a precision reduction and
693        // must be policy-gated.
694        let reduces_depth = crate::negotiate::channel_bits(from.channel_type())
695            > crate::negotiate::channel_bits(to.channel_type());
696        if reduces_depth && options.depth_policy == DepthPolicy::Forbid {
697            return Err(whereat::at!(ConvertError::DepthReductionForbidden));
698        }
699
700        // Check RGB→Gray requires luma coefficients.
701        let src_is_rgb = matches!(
702            from.layout(),
703            ChannelLayout::Rgb | ChannelLayout::Rgba | ChannelLayout::Bgra
704        );
705        let dst_is_gray = matches!(to.layout(), ChannelLayout::Gray | ChannelLayout::GrayAlpha);
706        if src_is_rgb && dst_is_gray && options.luma.is_none() {
707            return Err(whereat::at!(ConvertError::RgbToGray));
708        }
709
710        let mut plan = Self::new(from, to).at()?;
711
712        // Replace DropAlpha with MatteComposite when policy is CompositeOnto.
713        //
714        // The `matte_composite` kernel uses the straight-alpha over operator
715        // `fg*a + bg*(1-a)`, linearizing the sRGB matte and pixel RGB
716        // per-pixel using the source TF (kernel-side TF dispatch via the
717        // `MatteTf` trait). Alpha stays as-is (alpha is always linear,
718        // regardless of color-channel TF).
719        //
720        // One planner-side caveat handled here:
721        //
722        // **Premultiplied source.** If the source is premultiplied (our
723        // library's convention is encoded-space premul, per Canvas 2D),
724        // the straight kernel would multiply by `a` twice:
725        // `straight*a² + bg*(1-a)`. Fix: insert `PremulToStraight` before
726        // `MatteComposite`.
727        //
728        // We deliberately do NOT wrap with `SrgbF32ToLinearF32` /
729        // `LinearF32ToSrgbF32` to handle non-linear data: those steps
730        // linearize alpha too, which breaks the blend math.
731        if drops_alpha && let AlphaPolicy::CompositeOnto { r, g, b } = options.alpha_policy {
732            let src_is_premul = from.alpha() == Some(AlphaMode::Premultiplied);
733            let mut idx = 0;
734            while idx < plan.steps.len() {
735                if matches!(plan.steps[idx], ConvertStep::DropAlpha) {
736                    plan.steps[idx] = ConvertStep::MatteComposite { r, g, b };
737                    if src_is_premul {
738                        plan.steps.insert(idx, ConvertStep::PremulToStraight);
739                        idx += 1;
740                    }
741                }
742                idx += 1;
743            }
744        }
745
746        // When the caller opts out of clipping, swap pure-f32 sRGB transfer
747        // steps for their sign-preserving extended-range counterparts.
748        // Fused u8/u16 matlut steps are unaffected (integer I/O can't
749        // represent extended range anyway).
750        if !options.clip_out_of_gamut {
751            for step in &mut plan.steps {
752                match step {
753                    ConvertStep::SrgbF32ToLinearF32 => {
754                        *step = ConvertStep::SrgbF32ToLinearF32Extended;
755                    }
756                    ConvertStep::LinearF32ToSrgbF32 => {
757                        *step = ConvertStep::LinearF32ToSrgbF32Extended;
758                    }
759                    _ => {}
760                }
761            }
762        }
763
764        // Resolve luma coefficients on RgbToGray / RgbaToGray steps. The
765        // None case was rejected above (line 636), so unwrap is safe here.
766        // `layout_steps` constructs these variants with a Bt709 placeholder
767        // because it has no access to options; we replace with the user's
768        // explicit choice (or the permissive default of Bt709) here.
769        let user_luma = options.luma.unwrap_or(LumaCoefficients::Bt709);
770        for step in &mut plan.steps {
771            match step {
772                ConvertStep::RgbToGray { coefficients }
773                | ConvertStep::RgbaToGray { coefficients } => {
774                    *coefficients = user_luma;
775                }
776                _ => {}
777            }
778        }
779
780        Ok(plan)
781    }
782
783    /// Create a shell plan that records from/to but has no conversion steps.
784    ///
785    /// Used when an external CMS transform handles the conversion — the
786    /// plan exists only for `from()`/`to()` metadata; the actual row
787    /// work is driven by the external transform stored on `RowConverter`.
788    pub(crate) fn identity(from: PixelDescriptor, to: PixelDescriptor) -> Self {
789        Self::build(from, to, vec![ConvertStep::Identity])
790    }
791
792    /// Compose two plans into one: apply `self` then `other`.
793    ///
794    /// The composed plan executes both conversions in a single `convert_row`
795    /// call, using one intermediate buffer instead of two. Adjacent inverse
796    /// steps are cancelled (e.g., `SrgbU8ToLinearF32` + `LinearF32ToSrgbU8`
797    /// → identity).
798    ///
799    /// Returns `None` if `self.to` != `other.from` (incompatible plans).
800    pub fn compose(&self, other: &Self) -> Option<Self> {
801        if self.to != other.from {
802            return None;
803        }
804
805        let mut steps = self.steps.clone();
806
807        // Append other's steps, skipping its Identity if present.
808        for step in &other.steps {
809            if matches!(step, ConvertStep::Identity) {
810                continue;
811            }
812            steps.push(step.clone());
813        }
814
815        // Peephole: cancel adjacent inverse pairs.
816        let mut changed = true;
817        while changed {
818            changed = false;
819            let mut i = 0;
820            while i + 1 < steps.len() {
821                if are_inverse(&steps[i], &steps[i + 1]) {
822                    steps.remove(i + 1);
823                    steps.remove(i);
824                    changed = true;
825                    // Don't advance — check the new adjacent pair.
826                } else {
827                    i += 1;
828                }
829            }
830        }
831
832        // If everything cancelled, produce identity.
833        if steps.is_empty() {
834            steps.push(ConvertStep::Identity);
835        }
836
837        // Remove leading/trailing Identity if there are real steps.
838        if steps.len() > 1 {
839            steps.retain(|s| !matches!(s, ConvertStep::Identity));
840            if steps.is_empty() {
841                steps.push(ConvertStep::Identity);
842            }
843        }
844
845        // Composition runs at plan-build time, before any anchor is attached
846        // (`with_pq_anchor` is applied to the finished plan), so both inputs
847        // carry the default scale; the merged plan does too.
848        Some(Self::build(self.from, other.to, steps))
849    }
850
851    /// True if conversion is a no-op.
852    #[must_use]
853    pub fn is_identity(&self) -> bool {
854        self.steps.len() == 1 && matches!(self.steps[0], ConvertStep::Identity)
855    }
856
857    /// Maximum bytes-per-pixel across all intermediate formats in the plan.
858    ///
859    /// Used to pre-allocate scratch buffers for streaming conversion.
860    pub(crate) fn max_intermediate_bpp(&self) -> usize {
861        let mut desc = self.from;
862        let mut max_bpp = desc.bytes_per_pixel();
863        for step in &self.steps {
864            desc = intermediate_desc(desc, step);
865            max_bpp = max_bpp.max(desc.bytes_per_pixel());
866        }
867        max_bpp
868    }
869
870    /// Source descriptor.
871    pub fn from(&self) -> PixelDescriptor {
872        self.from
873    }
874
875    /// Target descriptor.
876    pub fn to(&self) -> PixelDescriptor {
877        self.to
878    }
879}
880
881/// Determine the layout conversion step(s).
882///
883/// Some layout conversions require two steps (e.g., BGRA -> RGB needs
884/// swizzle + drop alpha). Returns up to 2 steps.
885fn layout_steps(from: ChannelLayout, to: ChannelLayout) -> Vec<ConvertStep> {
886    if from == to {
887        return Vec::new();
888    }
889    match (from, to) {
890        (ChannelLayout::Bgra, ChannelLayout::Rgba) | (ChannelLayout::Rgba, ChannelLayout::Bgra) => {
891            vec![ConvertStep::SwizzleBgraRgba]
892        }
893        (ChannelLayout::Rgb, ChannelLayout::Rgba) => vec![ConvertStep::AddAlpha],
894        (ChannelLayout::Rgb, ChannelLayout::Bgra) => {
895            // Single fused SIMD pass (garb::bytes::rgb_to_bgra). For non-u8
896            // channel types `apply_step_u8` falls back to AddAlpha+Swizzle.
897            vec![ConvertStep::RgbToBgra]
898        }
899        (ChannelLayout::Rgba, ChannelLayout::Rgb) => vec![ConvertStep::DropAlpha],
900        (ChannelLayout::Bgra, ChannelLayout::Rgb) => {
901            // BGRA -> RGBA -> RGB: swizzle then drop alpha.
902            vec![ConvertStep::SwizzleBgraRgba, ConvertStep::DropAlpha]
903        }
904        (ChannelLayout::Gray, ChannelLayout::Rgb) => vec![ConvertStep::GrayToRgb],
905        (ChannelLayout::Gray, ChannelLayout::Rgba) => vec![ConvertStep::GrayToRgba],
906        (ChannelLayout::Gray, ChannelLayout::Bgra) => {
907            // Gray -> RGBA -> BGRA: expand then swizzle.
908            vec![ConvertStep::GrayToRgba, ConvertStep::SwizzleBgraRgba]
909        }
910        (ChannelLayout::Rgb, ChannelLayout::Gray) => vec![ConvertStep::RgbToGray {
911            coefficients: LumaCoefficients::Bt709,
912        }],
913        (ChannelLayout::Rgba, ChannelLayout::Gray) => vec![ConvertStep::RgbaToGray {
914            coefficients: LumaCoefficients::Bt709,
915        }],
916        (ChannelLayout::Bgra, ChannelLayout::Gray) => {
917            // BGRA -> RGBA -> Gray: swizzle then to gray.
918            vec![
919                ConvertStep::SwizzleBgraRgba,
920                ConvertStep::RgbaToGray {
921                    coefficients: LumaCoefficients::Bt709,
922                },
923            ]
924        }
925        (ChannelLayout::GrayAlpha, ChannelLayout::Rgba) => vec![ConvertStep::GrayAlphaToRgba],
926        (ChannelLayout::GrayAlpha, ChannelLayout::Bgra) => {
927            // GrayAlpha -> RGBA -> BGRA: expand then swizzle.
928            vec![ConvertStep::GrayAlphaToRgba, ConvertStep::SwizzleBgraRgba]
929        }
930        (ChannelLayout::GrayAlpha, ChannelLayout::Rgb) => vec![ConvertStep::GrayAlphaToRgb],
931        (ChannelLayout::Gray, ChannelLayout::GrayAlpha) => vec![ConvertStep::GrayToGrayAlpha],
932        (ChannelLayout::GrayAlpha, ChannelLayout::Gray) => vec![ConvertStep::GrayAlphaToGray],
933
934        // Oklab ↔ RGB conversions (via linear RGB).
935        (ChannelLayout::Rgb, ChannelLayout::Oklab) => vec![ConvertStep::LinearRgbToOklab],
936        (ChannelLayout::Oklab, ChannelLayout::Rgb) => vec![ConvertStep::OklabToLinearRgb],
937        (ChannelLayout::Rgba, ChannelLayout::OklabA) => vec![ConvertStep::LinearRgbaToOklaba],
938        (ChannelLayout::OklabA, ChannelLayout::Rgba) => vec![ConvertStep::OklabaToLinearRgba],
939
940        // Oklab ↔ RGB with alpha add/drop.
941        (ChannelLayout::Rgb, ChannelLayout::OklabA) => {
942            vec![ConvertStep::AddAlpha, ConvertStep::LinearRgbaToOklaba]
943        }
944        (ChannelLayout::OklabA, ChannelLayout::Rgb) => {
945            vec![ConvertStep::OklabaToLinearRgba, ConvertStep::DropAlpha]
946        }
947        (ChannelLayout::Oklab, ChannelLayout::Rgba) => {
948            vec![ConvertStep::OklabToLinearRgb, ConvertStep::AddAlpha]
949        }
950        (ChannelLayout::Rgba, ChannelLayout::Oklab) => {
951            vec![ConvertStep::DropAlpha, ConvertStep::LinearRgbToOklab]
952        }
953
954        // Oklab ↔ BGRA (swizzle to/from RGBA, then Oklab).
955        (ChannelLayout::Bgra, ChannelLayout::OklabA) => {
956            vec![
957                ConvertStep::SwizzleBgraRgba,
958                ConvertStep::LinearRgbaToOklaba,
959            ]
960        }
961        (ChannelLayout::OklabA, ChannelLayout::Bgra) => {
962            vec![
963                ConvertStep::OklabaToLinearRgba,
964                ConvertStep::SwizzleBgraRgba,
965            ]
966        }
967        (ChannelLayout::Bgra, ChannelLayout::Oklab) => {
968            vec![
969                ConvertStep::SwizzleBgraRgba,
970                ConvertStep::DropAlpha,
971                ConvertStep::LinearRgbToOklab,
972            ]
973        }
974        (ChannelLayout::Oklab, ChannelLayout::Bgra) => {
975            vec![
976                ConvertStep::OklabToLinearRgb,
977                ConvertStep::AddAlpha,
978                ConvertStep::SwizzleBgraRgba,
979            ]
980        }
981
982        // Gray ↔ Oklab (expand gray to RGB first).
983        (ChannelLayout::Gray, ChannelLayout::Oklab) => {
984            vec![ConvertStep::GrayToRgb, ConvertStep::LinearRgbToOklab]
985        }
986        (ChannelLayout::Oklab, ChannelLayout::Gray) => {
987            vec![
988                ConvertStep::OklabToLinearRgb,
989                ConvertStep::RgbToGray {
990                    coefficients: LumaCoefficients::Bt709,
991                },
992            ]
993        }
994        (ChannelLayout::Gray, ChannelLayout::OklabA) => {
995            vec![ConvertStep::GrayToRgba, ConvertStep::LinearRgbaToOklaba]
996        }
997        (ChannelLayout::OklabA, ChannelLayout::Gray) => {
998            vec![
999                ConvertStep::OklabaToLinearRgba,
1000                ConvertStep::RgbaToGray {
1001                    coefficients: LumaCoefficients::Bt709,
1002                },
1003            ]
1004        }
1005        (ChannelLayout::GrayAlpha, ChannelLayout::OklabA) => {
1006            vec![
1007                ConvertStep::GrayAlphaToRgba,
1008                ConvertStep::LinearRgbaToOklaba,
1009            ]
1010        }
1011        (ChannelLayout::OklabA, ChannelLayout::GrayAlpha) => {
1012            // Drop alpha from OklabA→Oklab, convert to RGB, then to GrayAlpha.
1013            // Alpha is lost; this is inherently lossy.
1014            vec![
1015                ConvertStep::OklabaToLinearRgba,
1016                ConvertStep::RgbaToGray {
1017                    coefficients: LumaCoefficients::Bt709,
1018                },
1019                ConvertStep::GrayToGrayAlpha,
1020            ]
1021        }
1022        (ChannelLayout::GrayAlpha, ChannelLayout::Oklab) => {
1023            vec![ConvertStep::GrayAlphaToRgb, ConvertStep::LinearRgbToOklab]
1024        }
1025        (ChannelLayout::Oklab, ChannelLayout::GrayAlpha) => {
1026            vec![
1027                ConvertStep::OklabToLinearRgb,
1028                ConvertStep::RgbToGray {
1029                    coefficients: LumaCoefficients::Bt709,
1030                },
1031                ConvertStep::GrayToGrayAlpha,
1032            ]
1033        }
1034
1035        // Oklab ↔ alpha variants.
1036        (ChannelLayout::Oklab, ChannelLayout::OklabA) => vec![ConvertStep::AddAlpha],
1037        (ChannelLayout::OklabA, ChannelLayout::Oklab) => vec![ConvertStep::DropAlpha],
1038
1039        _ => Vec::new(), // Unsupported layout conversion.
1040    }
1041}
1042
1043/// F32→F32 linearize step for a transfer function, or `None` if the TF is
1044/// already linear (or Unknown — caller decides how to handle Unknown).
1045fn f32_linearize_step(tf: TransferFunction) -> Option<ConvertStep> {
1046    match tf {
1047        TransferFunction::Linear => None,
1048        TransferFunction::Srgb => Some(ConvertStep::SrgbF32ToLinearF32),
1049        TransferFunction::Bt709 => Some(ConvertStep::Bt709F32ToLinearF32),
1050        TransferFunction::Pq => Some(ConvertStep::PqF32ToLinearF32),
1051        TransferFunction::Hlg => Some(ConvertStep::HlgF32ToLinearF32),
1052        TransferFunction::Gamma22 => Some(ConvertStep::Gamma22F32ToLinearF32),
1053        TransferFunction::Unknown => None,
1054        _ => None,
1055    }
1056}
1057
1058/// F32→F32 OETF step for a transfer function, or `None` if the TF is linear
1059/// (or Unknown).
1060fn f32_encode_step(tf: TransferFunction) -> Option<ConvertStep> {
1061    match tf {
1062        TransferFunction::Linear => None,
1063        TransferFunction::Srgb => Some(ConvertStep::LinearF32ToSrgbF32),
1064        TransferFunction::Bt709 => Some(ConvertStep::LinearF32ToBt709F32),
1065        TransferFunction::Pq => Some(ConvertStep::LinearF32ToPqF32),
1066        TransferFunction::Hlg => Some(ConvertStep::LinearF32ToHlgF32),
1067        TransferFunction::Gamma22 => Some(ConvertStep::LinearF32ToGamma22F32),
1068        TransferFunction::Unknown => None,
1069        _ => None,
1070    }
1071}
1072
1073/// F32→F32 TF-change steps: linearize (if not already linear) then encode
1074/// (if target is not linear).
1075///
1076/// Returns empty when `from == to`, or when either side is `Unknown` — when
1077/// one side's TF is unknown we can't mechanically compute a correct
1078/// conversion, so we preserve bytes as-is. Addressing the Unknown ambiguity
1079/// via explicit opt-in API is tracked as issue #19 [C]/[D] (deprecate-and-add).
1080fn f32_tf_pair_steps(from: TransferFunction, to: TransferFunction) -> Vec<ConvertStep> {
1081    if from == to || from == TransferFunction::Unknown || to == TransferFunction::Unknown {
1082        return Vec::new();
1083    }
1084    let mut steps = Vec::with_capacity(2);
1085    if let Some(s) = f32_linearize_step(from) {
1086        steps.push(s);
1087    }
1088    if let Some(s) = f32_encode_step(to) {
1089        steps.push(s);
1090    }
1091    steps
1092}
1093
1094/// Depth conversion step into F32 for any non-F32 channel type (U8, U16, F16).
1095/// Panics for F32 (caller must check); CMYK is rejected upstream by `assert_not_cmyk`.
1096fn to_f32_step(ct: ChannelType) -> ConvertStep {
1097    match ct {
1098        ChannelType::U8 => ConvertStep::NaiveU8ToF32,
1099        ChannelType::U16 => ConvertStep::U16ToF32,
1100        ChannelType::F16 => ConvertStep::F16ToF32,
1101        _ => unreachable!("to_f32_step called with F32 or unsupported channel type"),
1102    }
1103}
1104
1105/// F32→depth step for any non-F32 channel type.
1106fn f32_to_depth_step(ct: ChannelType) -> ConvertStep {
1107    match ct {
1108        ChannelType::U8 => ConvertStep::NaiveF32ToU8,
1109        ChannelType::U16 => ConvertStep::F32ToU16,
1110        ChannelType::F16 => ConvertStep::F32ToF16,
1111        _ => unreachable!("f32_to_depth_step called with F32 or unsupported channel type"),
1112    }
1113}
1114
1115/// Determine the depth conversion step(s), considering transfer functions.
1116///
1117/// Returns one or more steps. Multi-step conversions route through an F32
1118/// linear intermediate (e.g. PQ U16 → sRGB U8 goes PQ U16 → Linear F32 →
1119/// sRGB U8), and same-depth integer TF changes route through an F32 linear
1120/// intermediate too: passing integer bytes through unchanged under a new
1121/// TF label produces wrong pixels.
1122fn depth_steps(
1123    from: ChannelType,
1124    to: ChannelType,
1125    from_tf: TransferFunction,
1126    to_tf: TransferFunction,
1127) -> Result<Vec<ConvertStep>, ConvertError> {
1128    if from == to && from_tf == to_tf {
1129        return Ok(Vec::new());
1130    }
1131
1132    // Same depth, F32: apply EOTF/OETF in place.
1133    if from == to && from == ChannelType::F32 {
1134        return Ok(f32_tf_pair_steps(from_tf, to_tf));
1135    }
1136
1137    // Same depth, non-F32 (U8/U16/F16): TF change requires re-encoding. Route
1138    // through F32 linear intermediate — passing bytes through labeled as a
1139    // different TF produces wrong pixels.
1140    //
1141    // Exception: if either TF is Unknown, we don't know the correct conversion.
1142    // Preserve bytes exactly (no F32 round-trip — that would introduce U8/U16
1143    // rounding error for no semantic benefit). Addressed properly by issue
1144    // #19 [C]/[D] via opt-in deprecate-and-add.
1145    if from == to && from != ChannelType::F32 {
1146        if from_tf == TransferFunction::Unknown || to_tf == TransferFunction::Unknown {
1147            return Ok(Vec::new());
1148        }
1149        let mut steps = Vec::with_capacity(4);
1150        steps.push(to_f32_step(from));
1151        steps.extend(f32_tf_pair_steps(from_tf, to_tf));
1152        steps.push(f32_to_depth_step(to));
1153        return Ok(steps);
1154    }
1155
1156    match (from, to) {
1157        (ChannelType::U8, ChannelType::F32) => {
1158            // Fused sRGB EOTF kernel — sRGB only. BT.709 uses a different EOTF
1159            // (~17% linear-light error at mid-gray if we routed it through the
1160            // sRGB kernel) and must compose through the F32 BT.709 EOTF step.
1161            if from_tf == TransferFunction::Srgb && to_tf == TransferFunction::Linear {
1162                Ok(vec![ConvertStep::SrgbU8ToLinearF32])
1163            } else if from_tf == to_tf {
1164                Ok(vec![ConvertStep::NaiveU8ToF32])
1165            } else {
1166                // Cross-depth + cross-TF: linearize/encode after the U8→F32 scale.
1167                // Previously dropped the TF math and returned bytes labeled with
1168                // the target TF — silent wrong pixels for any TF pair other than
1169                // {Srgb,Bt709}→Linear.
1170                let mut steps = Vec::with_capacity(3);
1171                steps.push(ConvertStep::NaiveU8ToF32);
1172                steps.extend(f32_tf_pair_steps(from_tf, to_tf));
1173                Ok(steps)
1174            }
1175        }
1176        (ChannelType::F32, ChannelType::U8) => {
1177            // Fused sRGB OETF kernel — sRGB only (same reason as above).
1178            if from_tf == TransferFunction::Linear && to_tf == TransferFunction::Srgb {
1179                Ok(vec![ConvertStep::LinearF32ToSrgbU8])
1180            } else if from_tf == to_tf {
1181                Ok(vec![ConvertStep::NaiveF32ToU8])
1182            } else {
1183                // Linearize/encode in F32 first, then compress to U8.
1184                let mut steps = f32_tf_pair_steps(from_tf, to_tf);
1185                steps.push(ConvertStep::NaiveF32ToU8);
1186                Ok(steps)
1187            }
1188        }
1189        (ChannelType::U16, ChannelType::F32) => {
1190            // PQ/HLG U16 → Linear F32: apply EOTF during conversion.
1191            match (from_tf, to_tf) {
1192                (TransferFunction::Pq, TransferFunction::Linear) => {
1193                    Ok(vec![ConvertStep::PqU16ToLinearF32])
1194                }
1195                (TransferFunction::Hlg, TransferFunction::Linear) => {
1196                    Ok(vec![ConvertStep::HlgU16ToLinearF32])
1197                }
1198                (a, b) if a == b => Ok(vec![ConvertStep::U16ToF32]),
1199                _ => {
1200                    let mut steps = Vec::with_capacity(3);
1201                    steps.push(ConvertStep::U16ToF32);
1202                    steps.extend(f32_tf_pair_steps(from_tf, to_tf));
1203                    Ok(steps)
1204                }
1205            }
1206        }
1207        (ChannelType::F32, ChannelType::U16) => {
1208            // Linear F32 → PQ/HLG U16: apply OETF during conversion.
1209            match (from_tf, to_tf) {
1210                (TransferFunction::Linear, TransferFunction::Pq) => {
1211                    Ok(vec![ConvertStep::LinearF32ToPqU16])
1212                }
1213                (TransferFunction::Linear, TransferFunction::Hlg) => {
1214                    Ok(vec![ConvertStep::LinearF32ToHlgU16])
1215                }
1216                (a, b) if a == b => Ok(vec![ConvertStep::F32ToU16]),
1217                _ => {
1218                    let mut steps = f32_tf_pair_steps(from_tf, to_tf);
1219                    steps.push(ConvertStep::F32ToU16);
1220                    Ok(steps)
1221                }
1222            }
1223        }
1224        (ChannelType::U16, ChannelType::U8) => {
1225            // HDR U16 → SDR U8: go through linear F32 with proper EOTF → OETF.
1226            if from_tf == TransferFunction::Pq && to_tf == TransferFunction::Srgb {
1227                Ok(vec![
1228                    ConvertStep::PqU16ToLinearF32,
1229                    ConvertStep::LinearF32ToSrgbU8,
1230                ])
1231            } else if from_tf == TransferFunction::Hlg && to_tf == TransferFunction::Srgb {
1232                Ok(vec![
1233                    ConvertStep::HlgU16ToLinearF32,
1234                    ConvertStep::LinearF32ToSrgbU8,
1235                ])
1236            } else if from_tf == to_tf {
1237                Ok(vec![ConvertStep::U16ToU8])
1238            } else {
1239                let mut steps = Vec::with_capacity(4);
1240                steps.push(ConvertStep::U16ToF32);
1241                steps.extend(f32_tf_pair_steps(from_tf, to_tf));
1242                steps.push(ConvertStep::NaiveF32ToU8);
1243                Ok(steps)
1244            }
1245        }
1246        (ChannelType::U8, ChannelType::U16) => {
1247            if from_tf == to_tf {
1248                Ok(vec![ConvertStep::U8ToU16])
1249            } else {
1250                let mut steps = Vec::with_capacity(4);
1251                steps.push(ConvertStep::NaiveU8ToF32);
1252                steps.extend(f32_tf_pair_steps(from_tf, to_tf));
1253                steps.push(ConvertStep::F32ToU16);
1254                Ok(steps)
1255            }
1256        }
1257        // F16 paths route through F32. No fused TF kernels yet — these are
1258        // optimization targets for a future pass.
1259        (ChannelType::F16, ChannelType::F32) => {
1260            let mut steps = Vec::with_capacity(3);
1261            steps.push(ConvertStep::F16ToF32);
1262            if from_tf != to_tf {
1263                steps.extend(f32_tf_pair_steps(from_tf, to_tf));
1264            }
1265            Ok(steps)
1266        }
1267        (ChannelType::F32, ChannelType::F16) => {
1268            let mut steps = Vec::with_capacity(3);
1269            if from_tf != to_tf {
1270                steps.extend(f32_tf_pair_steps(from_tf, to_tf));
1271            }
1272            steps.push(ConvertStep::F32ToF16);
1273            Ok(steps)
1274        }
1275        (ChannelType::F16, ChannelType::U8) => {
1276            let mut steps = Vec::with_capacity(4);
1277            steps.push(ConvertStep::F16ToF32);
1278            if from_tf == TransferFunction::Linear && to_tf == TransferFunction::Srgb {
1279                steps.push(ConvertStep::LinearF32ToSrgbU8);
1280            } else if from_tf == to_tf {
1281                steps.push(ConvertStep::NaiveF32ToU8);
1282            } else {
1283                steps.extend(f32_tf_pair_steps(from_tf, to_tf));
1284                steps.push(ConvertStep::NaiveF32ToU8);
1285            }
1286            Ok(steps)
1287        }
1288        (ChannelType::U8, ChannelType::F16) => {
1289            let mut steps = Vec::with_capacity(4);
1290            if from_tf == TransferFunction::Srgb && to_tf == TransferFunction::Linear {
1291                steps.push(ConvertStep::SrgbU8ToLinearF32);
1292            } else if from_tf == to_tf {
1293                steps.push(ConvertStep::NaiveU8ToF32);
1294            } else {
1295                steps.push(ConvertStep::NaiveU8ToF32);
1296                steps.extend(f32_tf_pair_steps(from_tf, to_tf));
1297            }
1298            steps.push(ConvertStep::F32ToF16);
1299            Ok(steps)
1300        }
1301        (ChannelType::F16, ChannelType::U16) => {
1302            let mut steps = Vec::with_capacity(4);
1303            steps.push(ConvertStep::F16ToF32);
1304            if from_tf == TransferFunction::Linear && to_tf == TransferFunction::Pq {
1305                steps.push(ConvertStep::LinearF32ToPqU16);
1306            } else if from_tf == TransferFunction::Linear && to_tf == TransferFunction::Hlg {
1307                steps.push(ConvertStep::LinearF32ToHlgU16);
1308            } else if from_tf == to_tf {
1309                steps.push(ConvertStep::F32ToU16);
1310            } else {
1311                steps.extend(f32_tf_pair_steps(from_tf, to_tf));
1312                steps.push(ConvertStep::F32ToU16);
1313            }
1314            Ok(steps)
1315        }
1316        (ChannelType::U16, ChannelType::F16) => {
1317            let mut steps = Vec::with_capacity(4);
1318            if from_tf == TransferFunction::Pq && to_tf == TransferFunction::Linear {
1319                steps.push(ConvertStep::PqU16ToLinearF32);
1320            } else if from_tf == TransferFunction::Hlg && to_tf == TransferFunction::Linear {
1321                steps.push(ConvertStep::HlgU16ToLinearF32);
1322            } else if from_tf == to_tf {
1323                steps.push(ConvertStep::U16ToF32);
1324            } else {
1325                steps.push(ConvertStep::U16ToF32);
1326                steps.extend(f32_tf_pair_steps(from_tf, to_tf));
1327            }
1328            steps.push(ConvertStep::F32ToF16);
1329            Ok(steps)
1330        }
1331        _ => Err(ConvertError::NoPath {
1332            from: PixelDescriptor::new(from, ChannelLayout::Rgb, None, from_tf),
1333            to: PixelDescriptor::new(to, ChannelLayout::Rgb, None, to_tf),
1334        }),
1335    }
1336}
1337
1338// ---------------------------------------------------------------------------
1339// Row conversion kernels
1340// ---------------------------------------------------------------------------
1341
1342/// Pre-allocated scratch buffer for multi-step row conversions.
1343///
1344/// Eliminates per-row heap allocation by reusing two ping-pong halves
1345/// of a single buffer across calls. Create once per [`ConvertPlan`],
1346/// then pass to `convert_row_buffered` for each row.
1347pub(crate) struct ConvertScratch {
1348    /// Single allocation split into two halves via `split_at_mut`.
1349    /// Stored as `Vec<u32>` to guarantee 4-byte alignment, which lets
1350    /// garb and bytemuck use fast aligned paths instead of unaligned fallbacks.
1351    buf: Vec<u32>,
1352}
1353
1354impl ConvertScratch {
1355    /// Create empty scratch (buffer grows on first use).
1356    pub(crate) fn new() -> Self {
1357        Self { buf: Vec::new() }
1358    }
1359
1360    /// Ensure the buffer is large enough for two halves of the max
1361    /// intermediate format at the given width.
1362    fn ensure_capacity(&mut self, plan: &ConvertPlan, width: u32) {
1363        let half_bytes = (width as usize) * plan.max_intermediate_bpp();
1364        let total_u32 = (half_bytes * 2).div_ceil(4);
1365        if self.buf.len() < total_u32 {
1366            self.buf.resize(total_u32, 0);
1367        }
1368    }
1369}
1370
1371impl core::fmt::Debug for ConvertScratch {
1372    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1373        f.debug_struct("ConvertScratch")
1374            .field("capacity", &self.buf.capacity())
1375            .finish()
1376    }
1377}
1378
1379/// Convert one row of `width` pixels using a pre-computed plan.
1380///
1381/// `src` and `dst` must be sized for `width` pixels in their respective formats.
1382/// For multi-step plans, an internal scratch buffer is allocated per call.
1383/// Prefer [`RowConverter`](crate::RowConverter) in hot loops (reuses scratch buffers).
1384pub fn convert_row(plan: &ConvertPlan, src: &[u8], dst: &mut [u8], width: u32) {
1385    if plan.is_identity() {
1386        let len = min(src.len(), dst.len());
1387        dst[..len].copy_from_slice(&src[..len]);
1388        return;
1389    }
1390
1391    if plan.steps.len() == 1 {
1392        apply_step_u8(
1393            &plan.steps[0],
1394            src,
1395            dst,
1396            width,
1397            plan.from,
1398            plan.to,
1399            plan.pq_anchor_scale,
1400        );
1401        return;
1402    }
1403
1404    // Allocating fallback for one-off calls.
1405    let mut scratch = ConvertScratch::new();
1406    convert_row_buffered(plan, src, dst, width, &mut scratch);
1407}
1408
1409/// Convert one row of `width` pixels, reusing pre-allocated scratch buffers.
1410///
1411/// For multi-step plans this avoids per-row heap allocation by ping-ponging
1412/// between two halves of a scratch buffer. Single-step plans bypass scratch.
1413pub(crate) fn convert_row_buffered(
1414    plan: &ConvertPlan,
1415    src: &[u8],
1416    dst: &mut [u8],
1417    width: u32,
1418    scratch: &mut ConvertScratch,
1419) {
1420    if plan.is_identity() {
1421        let len = min(src.len(), dst.len());
1422        dst[..len].copy_from_slice(&src[..len]);
1423        return;
1424    }
1425
1426    if plan.steps.len() == 1 {
1427        apply_step_u8(
1428            &plan.steps[0],
1429            src,
1430            dst,
1431            width,
1432            plan.from,
1433            plan.to,
1434            plan.pq_anchor_scale,
1435        );
1436        return;
1437    }
1438
1439    scratch.ensure_capacity(plan, width);
1440
1441    let buf_bytes: &mut [u8] = bytemuck::cast_slice_mut(&mut scratch.buf);
1442    let half = buf_bytes.len() / 2;
1443    let (buf_a, buf_b) = buf_bytes.split_at_mut(half);
1444
1445    let num_steps = plan.steps.len();
1446    let mut current_desc = plan.from;
1447
1448    for (i, step) in plan.steps.iter().enumerate() {
1449        let is_last = i == num_steps - 1;
1450        let next_desc = if is_last {
1451            plan.to
1452        } else {
1453            intermediate_desc(current_desc, step)
1454        };
1455
1456        let next_len = (width as usize) * next_desc.bytes_per_pixel();
1457        let curr_len = (width as usize) * current_desc.bytes_per_pixel();
1458
1459        // Ping-pong: even steps read src/buf_b and write buf_a;
1460        // odd steps read buf_a and write buf_b. Each branch only
1461        // borrows each half in one mode, satisfying the borrow checker.
1462        if i % 2 == 0 {
1463            let input = if i == 0 { src } else { &buf_b[..curr_len] };
1464            if is_last {
1465                apply_step_u8(
1466                    step,
1467                    input,
1468                    dst,
1469                    width,
1470                    current_desc,
1471                    next_desc,
1472                    plan.pq_anchor_scale,
1473                );
1474            } else {
1475                apply_step_u8(
1476                    step,
1477                    input,
1478                    &mut buf_a[..next_len],
1479                    width,
1480                    current_desc,
1481                    next_desc,
1482                    plan.pq_anchor_scale,
1483                );
1484            }
1485        } else {
1486            let input = &buf_a[..curr_len];
1487            if is_last {
1488                apply_step_u8(
1489                    step,
1490                    input,
1491                    dst,
1492                    width,
1493                    current_desc,
1494                    next_desc,
1495                    plan.pq_anchor_scale,
1496                );
1497            } else {
1498                apply_step_u8(
1499                    step,
1500                    input,
1501                    &mut buf_b[..next_len],
1502                    width,
1503                    current_desc,
1504                    next_desc,
1505                    plan.pq_anchor_scale,
1506                );
1507            }
1508        }
1509
1510        current_desc = next_desc;
1511    }
1512}
1513
1514/// Check if two steps are inverses that cancel each other.
1515/// Collapse `[SrgbU8ToLinearF32, GamutMatrix*F32(m), LinearF32ToSrgbU8]`
1516/// into a single fused matlut step. Mutates in place.
1517fn fuse_matlut_patterns(steps: &mut Vec<ConvertStep>) {
1518    let mut i = 0;
1519    while i + 2 < steps.len() {
1520        let rewrite = match (&steps[i], &steps[i + 1], &steps[i + 2]) {
1521            (
1522                ConvertStep::SrgbU8ToLinearF32,
1523                ConvertStep::GamutMatrixRgbF32(m),
1524                ConvertStep::LinearF32ToSrgbU8,
1525            ) => Some(ConvertStep::FusedSrgbU8GamutRgb(*m)),
1526            (
1527                ConvertStep::SrgbU8ToLinearF32,
1528                ConvertStep::GamutMatrixRgbaF32(m),
1529                ConvertStep::LinearF32ToSrgbU8,
1530            ) => Some(ConvertStep::FusedSrgbU8GamutRgba(*m)),
1531            _ => None,
1532        };
1533        if let Some(fused) = rewrite {
1534            steps[i] = fused;
1535            steps.drain(i + 1..i + 3);
1536            continue;
1537        }
1538        i += 1;
1539    }
1540}
1541
1542fn are_inverse(a: &ConvertStep, b: &ConvertStep) -> bool {
1543    matches!(
1544        (a, b),
1545        // Self-inverse
1546        (ConvertStep::SwizzleBgraRgba, ConvertStep::SwizzleBgraRgba)
1547        // Layout inverses (lossless for opaque data)
1548        | (ConvertStep::AddAlpha, ConvertStep::DropAlpha)
1549        // Transfer function f32↔f32 (exact inverses in float)
1550        | (ConvertStep::SrgbF32ToLinearF32, ConvertStep::LinearF32ToSrgbF32)
1551        | (ConvertStep::LinearF32ToSrgbF32, ConvertStep::SrgbF32ToLinearF32)
1552        | (ConvertStep::PqF32ToLinearF32, ConvertStep::LinearF32ToPqF32)
1553        | (ConvertStep::LinearF32ToPqF32, ConvertStep::PqF32ToLinearF32)
1554        | (ConvertStep::HlgF32ToLinearF32, ConvertStep::LinearF32ToHlgF32)
1555        | (ConvertStep::LinearF32ToHlgF32, ConvertStep::HlgF32ToLinearF32)
1556        | (ConvertStep::Bt709F32ToLinearF32, ConvertStep::LinearF32ToBt709F32)
1557        | (ConvertStep::LinearF32ToBt709F32, ConvertStep::Bt709F32ToLinearF32)
1558        | (ConvertStep::Gamma22F32ToLinearF32, ConvertStep::LinearF32ToGamma22F32)
1559        | (ConvertStep::LinearF32ToGamma22F32, ConvertStep::Gamma22F32ToLinearF32)
1560        // Alpha mode (exact inverses in float)
1561        | (ConvertStep::StraightToPremul, ConvertStep::PremulToStraight)
1562        | (ConvertStep::PremulToStraight, ConvertStep::StraightToPremul)
1563        // Color model (exact inverses in float)
1564        | (ConvertStep::LinearRgbToOklab, ConvertStep::OklabToLinearRgb)
1565        | (ConvertStep::OklabToLinearRgb, ConvertStep::LinearRgbToOklab)
1566        | (ConvertStep::LinearRgbaToOklaba, ConvertStep::OklabaToLinearRgba)
1567        | (ConvertStep::OklabaToLinearRgba, ConvertStep::LinearRgbaToOklaba)
1568        // Cross-depth pairs (near-lossless for same depth class)
1569        | (ConvertStep::NaiveU8ToF32, ConvertStep::NaiveF32ToU8)
1570        | (ConvertStep::NaiveF32ToU8, ConvertStep::NaiveU8ToF32)
1571        | (ConvertStep::U8ToU16, ConvertStep::U16ToU8)
1572        | (ConvertStep::U16ToU8, ConvertStep::U8ToU16)
1573        | (ConvertStep::U16ToF32, ConvertStep::F32ToU16)
1574        | (ConvertStep::F32ToU16, ConvertStep::U16ToF32)
1575        | (ConvertStep::F16ToF32, ConvertStep::F32ToF16)
1576        | (ConvertStep::F32ToF16, ConvertStep::F16ToF32)
1577        // Cross-depth with transfer (near-lossless roundtrip)
1578        | (ConvertStep::SrgbU8ToLinearF32, ConvertStep::LinearF32ToSrgbU8)
1579        | (ConvertStep::LinearF32ToSrgbU8, ConvertStep::SrgbU8ToLinearF32)
1580        | (ConvertStep::PqU16ToLinearF32, ConvertStep::LinearF32ToPqU16)
1581        | (ConvertStep::LinearF32ToPqU16, ConvertStep::PqU16ToLinearF32)
1582        | (ConvertStep::HlgU16ToLinearF32, ConvertStep::LinearF32ToHlgU16)
1583        | (ConvertStep::LinearF32ToHlgU16, ConvertStep::HlgU16ToLinearF32)
1584        // Extended-range sRGB f32 pairs
1585        | (ConvertStep::SrgbF32ToLinearF32Extended, ConvertStep::LinearF32ToSrgbF32Extended)
1586        | (ConvertStep::LinearF32ToSrgbF32Extended, ConvertStep::SrgbF32ToLinearF32Extended)
1587    )
1588}
1589
1590/// Compute the descriptor after applying one step.
1591fn intermediate_desc(current: PixelDescriptor, step: &ConvertStep) -> PixelDescriptor {
1592    match step {
1593        ConvertStep::Identity => current,
1594        ConvertStep::SwizzleBgraRgba => {
1595            let new_layout = match current.layout() {
1596                ChannelLayout::Bgra => ChannelLayout::Rgba,
1597                ChannelLayout::Rgba => ChannelLayout::Bgra,
1598                other => other,
1599            };
1600            PixelDescriptor::new(
1601                current.channel_type(),
1602                new_layout,
1603                current.alpha(),
1604                current.transfer(),
1605            )
1606        }
1607        ConvertStep::AddAlpha => PixelDescriptor::new(
1608            current.channel_type(),
1609            ChannelLayout::Rgba,
1610            Some(AlphaMode::Straight),
1611            current.transfer(),
1612        ),
1613        ConvertStep::RgbToBgra => PixelDescriptor::new(
1614            current.channel_type(),
1615            ChannelLayout::Bgra,
1616            Some(AlphaMode::Straight),
1617            current.transfer(),
1618        ),
1619        ConvertStep::DropAlpha | ConvertStep::MatteComposite { .. } => PixelDescriptor::new(
1620            current.channel_type(),
1621            ChannelLayout::Rgb,
1622            None,
1623            current.transfer(),
1624        ),
1625        ConvertStep::GrayToRgb => PixelDescriptor::new(
1626            current.channel_type(),
1627            ChannelLayout::Rgb,
1628            None,
1629            current.transfer(),
1630        ),
1631        ConvertStep::GrayToRgba => PixelDescriptor::new(
1632            current.channel_type(),
1633            ChannelLayout::Rgba,
1634            Some(AlphaMode::Straight),
1635            current.transfer(),
1636        ),
1637        ConvertStep::RgbToGray { .. } | ConvertStep::RgbaToGray { .. } => PixelDescriptor::new(
1638            current.channel_type(),
1639            ChannelLayout::Gray,
1640            None,
1641            current.transfer(),
1642        ),
1643        ConvertStep::GrayAlphaToRgba => PixelDescriptor::new(
1644            current.channel_type(),
1645            ChannelLayout::Rgba,
1646            current.alpha(),
1647            current.transfer(),
1648        ),
1649        ConvertStep::GrayAlphaToRgb => PixelDescriptor::new(
1650            current.channel_type(),
1651            ChannelLayout::Rgb,
1652            None,
1653            current.transfer(),
1654        ),
1655        ConvertStep::GrayToGrayAlpha => PixelDescriptor::new(
1656            current.channel_type(),
1657            ChannelLayout::GrayAlpha,
1658            Some(AlphaMode::Straight),
1659            current.transfer(),
1660        ),
1661        ConvertStep::GrayAlphaToGray => PixelDescriptor::new(
1662            current.channel_type(),
1663            ChannelLayout::Gray,
1664            None,
1665            current.transfer(),
1666        ),
1667        ConvertStep::SrgbU8ToLinearF32
1668        | ConvertStep::NaiveU8ToF32
1669        | ConvertStep::U16ToF32
1670        | ConvertStep::PqU16ToLinearF32
1671        | ConvertStep::HlgU16ToLinearF32
1672        | ConvertStep::PqF32ToLinearF32
1673        | ConvertStep::HlgF32ToLinearF32
1674        | ConvertStep::SrgbF32ToLinearF32
1675        | ConvertStep::SrgbF32ToLinearF32Extended
1676        | ConvertStep::Bt709F32ToLinearF32
1677        | ConvertStep::Gamma22F32ToLinearF32 => PixelDescriptor::new(
1678            ChannelType::F32,
1679            current.layout(),
1680            current.alpha(),
1681            TransferFunction::Linear,
1682        ),
1683        ConvertStep::LinearF32ToSrgbU8 | ConvertStep::NaiveF32ToU8 | ConvertStep::U16ToU8 => {
1684            PixelDescriptor::new(
1685                ChannelType::U8,
1686                current.layout(),
1687                current.alpha(),
1688                TransferFunction::Srgb,
1689            )
1690        }
1691        ConvertStep::U8ToU16 => PixelDescriptor::new(
1692            ChannelType::U16,
1693            current.layout(),
1694            current.alpha(),
1695            current.transfer(),
1696        ),
1697        ConvertStep::F32ToU16 | ConvertStep::LinearF32ToPqU16 | ConvertStep::LinearF32ToHlgU16 => {
1698            let tf = match step {
1699                ConvertStep::LinearF32ToPqU16 => TransferFunction::Pq,
1700                ConvertStep::LinearF32ToHlgU16 => TransferFunction::Hlg,
1701                _ => current.transfer(),
1702            };
1703            PixelDescriptor::new(ChannelType::U16, current.layout(), current.alpha(), tf)
1704        }
1705        ConvertStep::LinearF32ToPqF32 => PixelDescriptor::new(
1706            ChannelType::F32,
1707            current.layout(),
1708            current.alpha(),
1709            TransferFunction::Pq,
1710        ),
1711        ConvertStep::LinearF32ToHlgF32 => PixelDescriptor::new(
1712            ChannelType::F32,
1713            current.layout(),
1714            current.alpha(),
1715            TransferFunction::Hlg,
1716        ),
1717        ConvertStep::LinearF32ToSrgbF32 | ConvertStep::LinearF32ToSrgbF32Extended => {
1718            PixelDescriptor::new(
1719                ChannelType::F32,
1720                current.layout(),
1721                current.alpha(),
1722                TransferFunction::Srgb,
1723            )
1724        }
1725        ConvertStep::LinearF32ToBt709F32 => PixelDescriptor::new(
1726            ChannelType::F32,
1727            current.layout(),
1728            current.alpha(),
1729            TransferFunction::Bt709,
1730        ),
1731        ConvertStep::LinearF32ToGamma22F32 => PixelDescriptor::new(
1732            ChannelType::F32,
1733            current.layout(),
1734            current.alpha(),
1735            TransferFunction::Gamma22,
1736        ),
1737        ConvertStep::StraightToPremul => PixelDescriptor::new(
1738            current.channel_type(),
1739            current.layout(),
1740            Some(AlphaMode::Premultiplied),
1741            current.transfer(),
1742        ),
1743        ConvertStep::PremulToStraight => PixelDescriptor::new(
1744            current.channel_type(),
1745            current.layout(),
1746            Some(AlphaMode::Straight),
1747            current.transfer(),
1748        ),
1749        ConvertStep::LinearRgbToOklab => PixelDescriptor::new(
1750            ChannelType::F32,
1751            ChannelLayout::Oklab,
1752            None,
1753            TransferFunction::Unknown,
1754        )
1755        .with_primaries(current.primaries),
1756        ConvertStep::OklabToLinearRgb => PixelDescriptor::new(
1757            ChannelType::F32,
1758            ChannelLayout::Rgb,
1759            None,
1760            TransferFunction::Linear,
1761        )
1762        .with_primaries(current.primaries),
1763        ConvertStep::LinearRgbaToOklaba => PixelDescriptor::new(
1764            ChannelType::F32,
1765            ChannelLayout::OklabA,
1766            Some(AlphaMode::Straight),
1767            TransferFunction::Unknown,
1768        )
1769        .with_primaries(current.primaries),
1770        ConvertStep::OklabaToLinearRgba => PixelDescriptor::new(
1771            ChannelType::F32,
1772            ChannelLayout::Rgba,
1773            current.alpha(),
1774            TransferFunction::Linear,
1775        )
1776        .with_primaries(current.primaries),
1777
1778        // Gamut matrix: same depth/layout/TF, but primaries change.
1779        // The actual target primaries are embedded in the matrix, not tracked
1780        // here — we mark them as Unknown since the step doesn't carry that info.
1781        // The final plan.to descriptor has the correct primaries.
1782        ConvertStep::GamutMatrixRgbF32(_) => PixelDescriptor::new(
1783            ChannelType::F32,
1784            current.layout(),
1785            current.alpha(),
1786            TransferFunction::Linear,
1787        ),
1788        ConvertStep::GamutMatrixRgbaF32(_) => PixelDescriptor::new(
1789            ChannelType::F32,
1790            current.layout(),
1791            current.alpha(),
1792            TransferFunction::Linear,
1793        ),
1794        // Fused steps: u8 sRGB in, u8 sRGB out (same layout, same alpha).
1795        ConvertStep::FusedSrgbU8GamutRgb(_) | ConvertStep::FusedSrgbU8GamutRgba(_) => {
1796            PixelDescriptor::new(
1797                ChannelType::U8,
1798                current.layout(),
1799                current.alpha(),
1800                TransferFunction::Srgb,
1801            )
1802        }
1803        ConvertStep::FusedSrgbU16GamutRgb(_) => PixelDescriptor::new(
1804            ChannelType::U16,
1805            current.layout(),
1806            current.alpha(),
1807            TransferFunction::Srgb,
1808        ),
1809        ConvertStep::FusedSrgbU8ToLinearF32Rgb(_) => PixelDescriptor::new(
1810            ChannelType::F32,
1811            current.layout(),
1812            current.alpha(),
1813            TransferFunction::Linear,
1814        ),
1815        ConvertStep::FusedLinearF32ToSrgbU8Rgb(_) => PixelDescriptor::new(
1816            ChannelType::U8,
1817            current.layout(),
1818            current.alpha(),
1819            TransferFunction::Srgb,
1820        ),
1821        // F16↔F32 depth-only steps. No TF implication: same TF on both sides.
1822        ConvertStep::F16ToF32 => PixelDescriptor::new(
1823            ChannelType::F32,
1824            current.layout(),
1825            current.alpha(),
1826            current.transfer(),
1827        ),
1828        ConvertStep::F32ToF16 => PixelDescriptor::new(
1829            ChannelType::F16,
1830            current.layout(),
1831            current.alpha(),
1832            current.transfer(),
1833        ),
1834    }
1835}
1836
1837#[path = "convert_kernels.rs"]
1838mod convert_kernels;
1839use convert_kernels::apply_step_u8;
1840pub(crate) use convert_kernels::{hlg_eotf, hlg_oetf, pq_eotf, pq_oetf};