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};
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}
29
30/// A single conversion step.
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub(crate) enum ConvertStep {
33    /// No-op (identity).
34    Identity,
35    /// BGRA → RGBA byte swizzle (or vice versa).
36    SwizzleBgraRgba,
37    /// Add alpha channel (3ch → 4ch), filling with opaque.
38    AddAlpha,
39    /// Drop alpha channel (4ch → 3ch).
40    DropAlpha,
41    /// Composite onto solid matte color, then drop alpha (4ch → 3ch).
42    ///
43    /// Blends in linear light: src and matte are converted from sRGB to linear,
44    /// alpha-blended, then converted back. For f32 data, pixel values are
45    /// assumed already linear; only the sRGB u8 matte is linearized.
46    MatteComposite { r: u8, g: u8, b: u8 },
47    /// Gray → RGB (replicate gray to all 3 channels).
48    GrayToRgb,
49    /// Gray → RGBA (replicate + opaque alpha).
50    GrayToRgba,
51    /// RGB → Gray (BT.709 luma).
52    RgbToGray,
53    /// RGBA → Gray (BT.709 luma, drop alpha).
54    RgbaToGray,
55    /// GrayAlpha → RGBA (replicate gray, keep alpha).
56    GrayAlphaToRgba,
57    /// GrayAlpha → RGB (replicate gray, drop alpha).
58    GrayAlphaToRgb,
59    /// Gray → GrayAlpha (add opaque alpha).
60    GrayToGrayAlpha,
61    /// GrayAlpha → Gray (drop alpha).
62    GrayAlphaToGray,
63    /// sRGB u8 → linear f32 (per channel, EOTF).
64    SrgbU8ToLinearF32,
65    /// Linear f32 → sRGB u8 (per channel, OETF).
66    LinearF32ToSrgbU8,
67    /// Naive u8 → f32 (v / 255.0, no gamma).
68    NaiveU8ToF32,
69    /// Naive f32 → u8 (clamp * 255 + 0.5, no gamma).
70    NaiveF32ToU8,
71    /// u16 → u8 ((v * 255 + 32768) >> 16).
72    U16ToU8,
73    /// u8 → u16 (v * 257).
74    U8ToU16,
75    /// u16 → f32 (v / 65535.0).
76    U16ToF32,
77    /// f32 → u16 (clamp * 65535 + 0.5).
78    F32ToU16,
79    /// PQ (SMPTE ST 2084) u16 → linear f32 (EOTF).
80    PqU16ToLinearF32,
81    /// Linear f32 → PQ u16 (inverse EOTF / OETF).
82    LinearF32ToPqU16,
83    /// PQ f32 [0,1] → linear f32 (EOTF, no depth change).
84    PqF32ToLinearF32,
85    /// Linear f32 → PQ f32 [0,1] (OETF, no depth change).
86    LinearF32ToPqF32,
87    /// HLG (ARIB STD-B67) u16 → linear f32 (EOTF).
88    HlgU16ToLinearF32,
89    /// Linear f32 → HLG u16 (OETF).
90    LinearF32ToHlgU16,
91    /// HLG f32 [0,1] → linear f32 (EOTF, no depth change).
92    HlgF32ToLinearF32,
93    /// Linear f32 → HLG f32 [0,1] (OETF, no depth change).
94    LinearF32ToHlgF32,
95    /// sRGB f32 [0,1] → linear f32 (EOTF, no depth change).
96    SrgbF32ToLinearF32,
97    /// Linear f32 → sRGB f32 [0,1] (OETF, no depth change).
98    LinearF32ToSrgbF32,
99    /// BT.709 f32 [0,1] → linear f32 (EOTF, no depth change).
100    Bt709F32ToLinearF32,
101    /// Linear f32 → BT.709 f32 [0,1] (OETF, no depth change).
102    LinearF32ToBt709F32,
103    /// Straight → Premultiplied alpha.
104    StraightToPremul,
105    /// Premultiplied → Straight alpha.
106    PremulToStraight,
107    /// Linear RGB f32 → Oklab f32 (3-channel color model change).
108    LinearRgbToOklab,
109    /// Oklab f32 → Linear RGB f32 (3-channel color model change).
110    OklabToLinearRgb,
111    /// Linear RGBA f32 → Oklaba f32 (4-channel, alpha preserved).
112    LinearRgbaToOklaba,
113    /// Oklaba f32 → Linear RGBA f32 (4-channel, alpha preserved).
114    OklabaToLinearRgba,
115    /// Apply a 3×3 gamut matrix to linear RGB f32 (3 channels per pixel).
116    ///
117    /// Used for color primaries conversion (e.g., BT.709 ↔ Display P3 ↔ BT.2020).
118    /// Data must be in linear light. The matrix is row-major `[[f32; 3]; 3]`
119    /// flattened to `[f32; 9]`.
120    GamutMatrixRgbF32([f32; 9]),
121    /// Apply a 3×3 gamut matrix to linear RGBA f32 (4 channels, alpha passthrough).
122    GamutMatrixRgbaF32([f32; 9]),
123}
124
125impl ConvertPlan {
126    /// Create a conversion plan from `from` to `to`.
127    ///
128    /// Returns `Err` if no conversion path exists.
129    #[track_caller]
130    pub fn new(from: PixelDescriptor, to: PixelDescriptor) -> Result<Self, At<ConvertError>> {
131        if from == to {
132            return Ok(Self {
133                from,
134                to,
135                steps: vec![ConvertStep::Identity],
136            });
137        }
138
139        let mut steps = Vec::with_capacity(3);
140
141        // Step 1: Layout conversion (within same depth class).
142        // Step 2: Depth conversion.
143        // Step 3: Alpha mode conversion.
144        //
145        // For cross-depth conversions, we convert layout at the source depth
146        // first, then change depth. This minimizes the number of channels
147        // we need to depth-convert.
148
149        let need_depth_change = from.channel_type() != to.channel_type();
150        let need_layout_change = from.layout() != to.layout();
151        let need_alpha_change =
152            from.alpha() != to.alpha() && from.alpha().is_some() && to.alpha().is_some();
153
154        // Depth/TF steps are needed when depth changes, or when both are F32
155        // and transfer functions differ.
156        let need_depth_or_tf = need_depth_change
157            || (from.channel_type() == ChannelType::F32 && from.transfer() != to.transfer());
158
159        // If we need to change depth AND layout, plan the optimal order.
160        if need_layout_change {
161            // When going to fewer channels, convert layout first (less depth work).
162            // When going to more channels, convert depth first (less layout work).
163            //
164            // Exception: Oklab layout steps require f32 data. When the source
165            // is integer (U8/U16) and the layout change involves Oklab, we must
166            // convert depth first regardless of channel count.
167            let src_ch = from.layout().channels();
168            let dst_ch = to.layout().channels();
169            let involves_oklab =
170                matches!(from.layout(), ChannelLayout::Oklab | ChannelLayout::OklabA)
171                    || matches!(to.layout(), ChannelLayout::Oklab | ChannelLayout::OklabA);
172
173            // Oklab conversion requires known primaries for the RGB→LMS matrix.
174            if involves_oklab && from.primaries == ColorPrimaries::Unknown {
175                return Err(whereat::at!(ConvertError::NoPath { from, to }));
176            }
177
178            let depth_first = need_depth_or_tf
179                && (dst_ch > src_ch || (involves_oklab && from.channel_type() != ChannelType::F32));
180
181            if depth_first {
182                // Depth first, then layout.
183                steps.extend(
184                    depth_steps(
185                        from.channel_type(),
186                        to.channel_type(),
187                        from.transfer(),
188                        to.transfer(),
189                    )
190                    .map_err(|e| whereat::at!(e))?,
191                );
192                steps.extend(layout_steps(from.layout(), to.layout()));
193            } else {
194                // Layout first, then depth.
195                steps.extend(layout_steps(from.layout(), to.layout()));
196                if need_depth_or_tf {
197                    steps.extend(
198                        depth_steps(
199                            from.channel_type(),
200                            to.channel_type(),
201                            from.transfer(),
202                            to.transfer(),
203                        )
204                        .map_err(|e| whereat::at!(e))?,
205                    );
206                }
207            }
208        } else if need_depth_or_tf {
209            steps.extend(
210                depth_steps(
211                    from.channel_type(),
212                    to.channel_type(),
213                    from.transfer(),
214                    to.transfer(),
215                )
216                .map_err(|e| whereat::at!(e))?,
217            );
218        }
219
220        // Alpha mode conversion (if both have alpha and modes differ).
221        if need_alpha_change {
222            match (from.alpha(), to.alpha()) {
223                (Some(AlphaMode::Straight), Some(AlphaMode::Premultiplied)) => {
224                    steps.push(ConvertStep::StraightToPremul);
225                }
226                (Some(AlphaMode::Premultiplied), Some(AlphaMode::Straight)) => {
227                    steps.push(ConvertStep::PremulToStraight);
228                }
229                _ => {}
230            }
231        }
232
233        // Primaries conversion: if source and destination have different known
234        // primaries, inject a gamut matrix in linear f32 space.
235        let need_primaries = from.primaries != to.primaries
236            && from.primaries != ColorPrimaries::Unknown
237            && to.primaries != ColorPrimaries::Unknown;
238
239        if need_primaries
240            && let Some(matrix) = crate::gamut::conversion_matrix(from.primaries, to.primaries)
241        {
242            // Flatten the 3×3 matrix for storage in the step enum.
243            let flat = [
244                matrix[0][0],
245                matrix[0][1],
246                matrix[0][2],
247                matrix[1][0],
248                matrix[1][1],
249                matrix[1][2],
250                matrix[2][0],
251                matrix[2][1],
252                matrix[2][2],
253            ];
254
255            // The gamut matrix must be applied in linear f32 space.
256            // Check if the existing steps already go through linear f32.
257            let mut goes_through_linear = false;
258            {
259                let mut desc = from;
260                for &step in &steps {
261                    desc = intermediate_desc(desc, step);
262                    if desc.channel_type() == ChannelType::F32
263                        && desc.transfer() == TransferFunction::Linear
264                    {
265                        goes_through_linear = true;
266                    }
267                }
268            }
269
270            if goes_through_linear {
271                // Insert the gamut matrix right after the first step that
272                // produces linear f32. All subsequent steps encode to the
273                // target format.
274                let mut insert_pos = 0;
275                let mut desc = from;
276                for (i, &step) in steps.iter().enumerate() {
277                    desc = intermediate_desc(desc, step);
278                    if desc.channel_type() == ChannelType::F32
279                        && desc.transfer() == TransferFunction::Linear
280                    {
281                        insert_pos = i + 1;
282                        break;
283                    }
284                }
285                let gamut_step = if desc.layout().has_alpha() {
286                    ConvertStep::GamutMatrixRgbaF32(flat)
287                } else {
288                    ConvertStep::GamutMatrixRgbF32(flat)
289                };
290                steps.insert(insert_pos, gamut_step);
291            } else {
292                // No existing linear f32 step — we must add linearize → gamut → delinearize.
293                // Determine layout for the gamut step.
294                let has_alpha = from.layout().has_alpha() || to.layout().has_alpha();
295                // Use the layout at the current point in the plan.
296                let mut desc = from;
297                for &step in &steps {
298                    desc = intermediate_desc(desc, step);
299                }
300                let gamut_step = if desc.layout().has_alpha() || has_alpha {
301                    ConvertStep::GamutMatrixRgbaF32(flat)
302                } else {
303                    ConvertStep::GamutMatrixRgbF32(flat)
304                };
305
306                // Insert linearize → gamut → encode-to-target-tf at the end,
307                // before any alpha mode steps.
308                let linearize = match desc.transfer() {
309                    TransferFunction::Srgb => ConvertStep::SrgbF32ToLinearF32,
310                    TransferFunction::Bt709 => ConvertStep::Bt709F32ToLinearF32,
311                    TransferFunction::Pq => ConvertStep::PqF32ToLinearF32,
312                    TransferFunction::Hlg => ConvertStep::HlgF32ToLinearF32,
313                    TransferFunction::Linear => ConvertStep::Identity,
314                    _ => ConvertStep::SrgbF32ToLinearF32, // assume sRGB for Unknown
315                };
316                let to_target_tf = match to.transfer() {
317                    TransferFunction::Srgb => ConvertStep::LinearF32ToSrgbF32,
318                    TransferFunction::Bt709 => ConvertStep::LinearF32ToBt709F32,
319                    TransferFunction::Pq => ConvertStep::LinearF32ToPqF32,
320                    TransferFunction::Hlg => ConvertStep::LinearF32ToHlgF32,
321                    TransferFunction::Linear => ConvertStep::Identity,
322                    _ => ConvertStep::LinearF32ToSrgbF32, // assume sRGB for Unknown
323                };
324
325                // Need to be in f32 first. If current is integer, add naive conversion.
326                let mut gamut_steps = Vec::new();
327                if desc.channel_type() != ChannelType::F32 {
328                    // Use the fused sRGB u8→linear f32 if applicable.
329                    if desc.channel_type() == ChannelType::U8
330                        && matches!(
331                            desc.transfer(),
332                            TransferFunction::Srgb
333                                | TransferFunction::Bt709
334                                | TransferFunction::Unknown
335                        )
336                    {
337                        gamut_steps.push(ConvertStep::SrgbU8ToLinearF32);
338                        // Already linear, skip separate linearize.
339                        gamut_steps.push(gamut_step);
340                        gamut_steps.push(ConvertStep::LinearF32ToSrgbU8);
341                    } else if desc.channel_type() == ChannelType::U16
342                        && desc.transfer() == TransferFunction::Pq
343                    {
344                        gamut_steps.push(ConvertStep::PqU16ToLinearF32);
345                        gamut_steps.push(gamut_step);
346                        gamut_steps.push(ConvertStep::LinearF32ToPqU16);
347                    } else if desc.channel_type() == ChannelType::U16
348                        && desc.transfer() == TransferFunction::Hlg
349                    {
350                        gamut_steps.push(ConvertStep::HlgU16ToLinearF32);
351                        gamut_steps.push(gamut_step);
352                        gamut_steps.push(ConvertStep::LinearF32ToHlgU16);
353                    } else {
354                        // Generic: naive to f32, linearize, gamut, delinearize, naive back
355                        gamut_steps.push(ConvertStep::NaiveU8ToF32);
356                        if linearize != ConvertStep::Identity {
357                            gamut_steps.push(linearize);
358                        }
359                        gamut_steps.push(gamut_step);
360                        if to_target_tf != ConvertStep::Identity {
361                            gamut_steps.push(to_target_tf);
362                        }
363                        gamut_steps.push(ConvertStep::NaiveF32ToU8);
364                    }
365                } else {
366                    // Already f32, just linearize → gamut → encode
367                    if linearize != ConvertStep::Identity {
368                        gamut_steps.push(linearize);
369                    }
370                    gamut_steps.push(gamut_step);
371                    if to_target_tf != ConvertStep::Identity {
372                        gamut_steps.push(to_target_tf);
373                    }
374                }
375
376                steps.extend(gamut_steps);
377            }
378        }
379
380        if steps.is_empty() {
381            // Transfer-only difference or alpha-mode-only: identity path.
382            steps.push(ConvertStep::Identity);
383        }
384
385        Ok(Self { from, to, steps })
386    }
387
388    /// Create a conversion plan with explicit policy enforcement.
389    ///
390    /// Validates that the planned conversion steps are allowed by the given
391    /// policies before creating the plan. Returns an error if a forbidden
392    /// operation would be required.
393    #[track_caller]
394    pub fn new_explicit(
395        from: PixelDescriptor,
396        to: PixelDescriptor,
397        options: &ConvertOptions,
398    ) -> Result<Self, At<ConvertError>> {
399        // Check alpha removal policy.
400        let drops_alpha = from.alpha().is_some() && to.alpha().is_none();
401        if drops_alpha && options.alpha_policy == AlphaPolicy::Forbid {
402            return Err(whereat::at!(ConvertError::AlphaRemovalForbidden));
403        }
404
405        // Check depth reduction policy.
406        let reduces_depth = from.channel_type().byte_size() > to.channel_type().byte_size();
407        if reduces_depth && options.depth_policy == DepthPolicy::Forbid {
408            return Err(whereat::at!(ConvertError::DepthReductionForbidden));
409        }
410
411        // Check RGB→Gray requires luma coefficients.
412        let src_is_rgb = matches!(
413            from.layout(),
414            ChannelLayout::Rgb | ChannelLayout::Rgba | ChannelLayout::Bgra
415        );
416        let dst_is_gray = matches!(to.layout(), ChannelLayout::Gray | ChannelLayout::GrayAlpha);
417        if src_is_rgb && dst_is_gray && options.luma.is_none() {
418            return Err(whereat::at!(ConvertError::RgbToGray));
419        }
420
421        let mut plan = Self::new(from, to).at()?;
422
423        // Replace DropAlpha with MatteComposite when policy is CompositeOnto.
424        if drops_alpha && let AlphaPolicy::CompositeOnto { r, g, b } = options.alpha_policy {
425            for step in &mut plan.steps {
426                if matches!(step, ConvertStep::DropAlpha) {
427                    *step = ConvertStep::MatteComposite { r, g, b };
428                }
429            }
430        }
431
432        Ok(plan)
433    }
434
435    /// Compose two plans into one: apply `self` then `other`.
436    ///
437    /// The composed plan executes both conversions in a single `convert_row`
438    /// call, using one intermediate buffer instead of two. Adjacent inverse
439    /// steps are cancelled (e.g., `SrgbU8ToLinearF32` + `LinearF32ToSrgbU8`
440    /// → identity).
441    ///
442    /// Returns `None` if `self.to` != `other.from` (incompatible plans).
443    pub fn compose(&self, other: &Self) -> Option<Self> {
444        if self.to != other.from {
445            return None;
446        }
447
448        let mut steps = self.steps.clone();
449
450        // Append other's steps, skipping its Identity if present.
451        for &step in &other.steps {
452            if step == ConvertStep::Identity {
453                continue;
454            }
455            steps.push(step);
456        }
457
458        // Peephole: cancel adjacent inverse pairs.
459        let mut changed = true;
460        while changed {
461            changed = false;
462            let mut i = 0;
463            while i + 1 < steps.len() {
464                if are_inverse(steps[i], steps[i + 1]) {
465                    steps.remove(i + 1);
466                    steps.remove(i);
467                    changed = true;
468                    // Don't advance — check the new adjacent pair.
469                } else {
470                    i += 1;
471                }
472            }
473        }
474
475        // If everything cancelled, produce identity.
476        if steps.is_empty() {
477            steps.push(ConvertStep::Identity);
478        }
479
480        // Remove leading/trailing Identity if there are real steps.
481        if steps.len() > 1 {
482            steps.retain(|s| *s != ConvertStep::Identity);
483            if steps.is_empty() {
484                steps.push(ConvertStep::Identity);
485            }
486        }
487
488        Some(Self {
489            from: self.from,
490            to: other.to,
491            steps,
492        })
493    }
494
495    /// True if conversion is a no-op.
496    #[must_use]
497    pub fn is_identity(&self) -> bool {
498        self.steps.len() == 1 && self.steps[0] == ConvertStep::Identity
499    }
500
501    /// Maximum bytes-per-pixel across all intermediate formats in the plan.
502    ///
503    /// Used to pre-allocate scratch buffers for streaming conversion.
504    pub(crate) fn max_intermediate_bpp(&self) -> usize {
505        let mut desc = self.from;
506        let mut max_bpp = desc.bytes_per_pixel();
507        for &step in &self.steps {
508            desc = intermediate_desc(desc, step);
509            max_bpp = max_bpp.max(desc.bytes_per_pixel());
510        }
511        max_bpp
512    }
513
514    /// Source descriptor.
515    pub fn from(&self) -> PixelDescriptor {
516        self.from
517    }
518
519    /// Target descriptor.
520    pub fn to(&self) -> PixelDescriptor {
521        self.to
522    }
523}
524
525/// Determine the layout conversion step(s).
526///
527/// Some layout conversions require two steps (e.g., BGRA -> RGB needs
528/// swizzle + drop alpha). Returns up to 2 steps.
529fn layout_steps(from: ChannelLayout, to: ChannelLayout) -> Vec<ConvertStep> {
530    if from == to {
531        return Vec::new();
532    }
533    match (from, to) {
534        (ChannelLayout::Bgra, ChannelLayout::Rgba) | (ChannelLayout::Rgba, ChannelLayout::Bgra) => {
535            vec![ConvertStep::SwizzleBgraRgba]
536        }
537        (ChannelLayout::Rgb, ChannelLayout::Rgba) => vec![ConvertStep::AddAlpha],
538        (ChannelLayout::Rgb, ChannelLayout::Bgra) => {
539            // Rgb -> RGBA -> BGRA: add alpha then swizzle.
540            vec![ConvertStep::AddAlpha, ConvertStep::SwizzleBgraRgba]
541        }
542        (ChannelLayout::Rgba, ChannelLayout::Rgb) => vec![ConvertStep::DropAlpha],
543        (ChannelLayout::Bgra, ChannelLayout::Rgb) => {
544            // BGRA -> RGBA -> RGB: swizzle then drop alpha.
545            vec![ConvertStep::SwizzleBgraRgba, ConvertStep::DropAlpha]
546        }
547        (ChannelLayout::Gray, ChannelLayout::Rgb) => vec![ConvertStep::GrayToRgb],
548        (ChannelLayout::Gray, ChannelLayout::Rgba) => vec![ConvertStep::GrayToRgba],
549        (ChannelLayout::Gray, ChannelLayout::Bgra) => {
550            // Gray -> RGBA -> BGRA: expand then swizzle.
551            vec![ConvertStep::GrayToRgba, ConvertStep::SwizzleBgraRgba]
552        }
553        (ChannelLayout::Rgb, ChannelLayout::Gray) => vec![ConvertStep::RgbToGray],
554        (ChannelLayout::Rgba, ChannelLayout::Gray) => vec![ConvertStep::RgbaToGray],
555        (ChannelLayout::Bgra, ChannelLayout::Gray) => {
556            // BGRA -> RGBA -> Gray: swizzle then to gray.
557            vec![ConvertStep::SwizzleBgraRgba, ConvertStep::RgbaToGray]
558        }
559        (ChannelLayout::GrayAlpha, ChannelLayout::Rgba) => vec![ConvertStep::GrayAlphaToRgba],
560        (ChannelLayout::GrayAlpha, ChannelLayout::Bgra) => {
561            // GrayAlpha -> RGBA -> BGRA: expand then swizzle.
562            vec![ConvertStep::GrayAlphaToRgba, ConvertStep::SwizzleBgraRgba]
563        }
564        (ChannelLayout::GrayAlpha, ChannelLayout::Rgb) => vec![ConvertStep::GrayAlphaToRgb],
565        (ChannelLayout::Gray, ChannelLayout::GrayAlpha) => vec![ConvertStep::GrayToGrayAlpha],
566        (ChannelLayout::GrayAlpha, ChannelLayout::Gray) => vec![ConvertStep::GrayAlphaToGray],
567
568        // Oklab ↔ RGB conversions (via linear RGB).
569        (ChannelLayout::Rgb, ChannelLayout::Oklab) => vec![ConvertStep::LinearRgbToOklab],
570        (ChannelLayout::Oklab, ChannelLayout::Rgb) => vec![ConvertStep::OklabToLinearRgb],
571        (ChannelLayout::Rgba, ChannelLayout::OklabA) => vec![ConvertStep::LinearRgbaToOklaba],
572        (ChannelLayout::OklabA, ChannelLayout::Rgba) => vec![ConvertStep::OklabaToLinearRgba],
573
574        // Oklab ↔ RGB with alpha add/drop.
575        (ChannelLayout::Rgb, ChannelLayout::OklabA) => {
576            vec![ConvertStep::AddAlpha, ConvertStep::LinearRgbaToOklaba]
577        }
578        (ChannelLayout::OklabA, ChannelLayout::Rgb) => {
579            vec![ConvertStep::OklabaToLinearRgba, ConvertStep::DropAlpha]
580        }
581        (ChannelLayout::Oklab, ChannelLayout::Rgba) => {
582            vec![ConvertStep::OklabToLinearRgb, ConvertStep::AddAlpha]
583        }
584        (ChannelLayout::Rgba, ChannelLayout::Oklab) => {
585            vec![ConvertStep::DropAlpha, ConvertStep::LinearRgbToOklab]
586        }
587
588        // Oklab ↔ BGRA (swizzle to/from RGBA, then Oklab).
589        (ChannelLayout::Bgra, ChannelLayout::OklabA) => {
590            vec![
591                ConvertStep::SwizzleBgraRgba,
592                ConvertStep::LinearRgbaToOklaba,
593            ]
594        }
595        (ChannelLayout::OklabA, ChannelLayout::Bgra) => {
596            vec![
597                ConvertStep::OklabaToLinearRgba,
598                ConvertStep::SwizzleBgraRgba,
599            ]
600        }
601        (ChannelLayout::Bgra, ChannelLayout::Oklab) => {
602            vec![
603                ConvertStep::SwizzleBgraRgba,
604                ConvertStep::DropAlpha,
605                ConvertStep::LinearRgbToOklab,
606            ]
607        }
608        (ChannelLayout::Oklab, ChannelLayout::Bgra) => {
609            vec![
610                ConvertStep::OklabToLinearRgb,
611                ConvertStep::AddAlpha,
612                ConvertStep::SwizzleBgraRgba,
613            ]
614        }
615
616        // Gray ↔ Oklab (expand gray to RGB first).
617        (ChannelLayout::Gray, ChannelLayout::Oklab) => {
618            vec![ConvertStep::GrayToRgb, ConvertStep::LinearRgbToOklab]
619        }
620        (ChannelLayout::Oklab, ChannelLayout::Gray) => {
621            vec![ConvertStep::OklabToLinearRgb, ConvertStep::RgbToGray]
622        }
623        (ChannelLayout::Gray, ChannelLayout::OklabA) => {
624            vec![ConvertStep::GrayToRgba, ConvertStep::LinearRgbaToOklaba]
625        }
626        (ChannelLayout::OklabA, ChannelLayout::Gray) => {
627            vec![ConvertStep::OklabaToLinearRgba, ConvertStep::RgbaToGray]
628        }
629        (ChannelLayout::GrayAlpha, ChannelLayout::OklabA) => {
630            vec![
631                ConvertStep::GrayAlphaToRgba,
632                ConvertStep::LinearRgbaToOklaba,
633            ]
634        }
635        (ChannelLayout::OklabA, ChannelLayout::GrayAlpha) => {
636            // Drop alpha from OklabA→Oklab, convert to RGB, then to GrayAlpha.
637            // Alpha is lost; this is inherently lossy.
638            vec![
639                ConvertStep::OklabaToLinearRgba,
640                ConvertStep::RgbaToGray,
641                ConvertStep::GrayToGrayAlpha,
642            ]
643        }
644        (ChannelLayout::GrayAlpha, ChannelLayout::Oklab) => {
645            vec![ConvertStep::GrayAlphaToRgb, ConvertStep::LinearRgbToOklab]
646        }
647        (ChannelLayout::Oklab, ChannelLayout::GrayAlpha) => {
648            vec![
649                ConvertStep::OklabToLinearRgb,
650                ConvertStep::RgbToGray,
651                ConvertStep::GrayToGrayAlpha,
652            ]
653        }
654
655        // Oklab ↔ alpha variants.
656        (ChannelLayout::Oklab, ChannelLayout::OklabA) => vec![ConvertStep::AddAlpha],
657        (ChannelLayout::OklabA, ChannelLayout::Oklab) => vec![ConvertStep::DropAlpha],
658
659        _ => Vec::new(), // Unsupported layout conversion.
660    }
661}
662
663/// Determine the depth conversion step(s), considering transfer functions.
664///
665/// Returns one or two steps. Two steps are needed when the conversion
666/// requires going through an intermediate format (e.g. PQ U16 → sRGB U8
667/// goes PQ U16 → Linear F32 → sRGB U8).
668fn depth_steps(
669    from: ChannelType,
670    to: ChannelType,
671    from_tf: TransferFunction,
672    to_tf: TransferFunction,
673) -> Result<Vec<ConvertStep>, ConvertError> {
674    if from == to && from_tf == to_tf {
675        return Ok(Vec::new());
676    }
677
678    // Same depth, different transfer function.
679    // For integer types, TF changes are metadata-only (no math).
680    // For F32, we can apply EOTF/OETF in place.
681    if from == to && from != ChannelType::F32 {
682        return Ok(Vec::new());
683    }
684
685    if from == to && from == ChannelType::F32 {
686        return match (from_tf, to_tf) {
687            (TransferFunction::Pq, TransferFunction::Linear) => {
688                Ok(vec![ConvertStep::PqF32ToLinearF32])
689            }
690            (TransferFunction::Linear, TransferFunction::Pq) => {
691                Ok(vec![ConvertStep::LinearF32ToPqF32])
692            }
693            (TransferFunction::Hlg, TransferFunction::Linear) => {
694                Ok(vec![ConvertStep::HlgF32ToLinearF32])
695            }
696            (TransferFunction::Linear, TransferFunction::Hlg) => {
697                Ok(vec![ConvertStep::LinearF32ToHlgF32])
698            }
699            // PQ ↔ HLG: go through linear.
700            (TransferFunction::Pq, TransferFunction::Hlg) => Ok(vec![
701                ConvertStep::PqF32ToLinearF32,
702                ConvertStep::LinearF32ToHlgF32,
703            ]),
704            (TransferFunction::Hlg, TransferFunction::Pq) => Ok(vec![
705                ConvertStep::HlgF32ToLinearF32,
706                ConvertStep::LinearF32ToPqF32,
707            ]),
708            (TransferFunction::Srgb, TransferFunction::Linear) => {
709                Ok(vec![ConvertStep::SrgbF32ToLinearF32])
710            }
711            (TransferFunction::Linear, TransferFunction::Srgb) => {
712                Ok(vec![ConvertStep::LinearF32ToSrgbF32])
713            }
714            (TransferFunction::Bt709, TransferFunction::Linear) => {
715                Ok(vec![ConvertStep::Bt709F32ToLinearF32])
716            }
717            (TransferFunction::Linear, TransferFunction::Bt709) => {
718                Ok(vec![ConvertStep::LinearF32ToBt709F32])
719            }
720            // sRGB ↔ BT.709: go through linear.
721            (TransferFunction::Srgb, TransferFunction::Bt709) => Ok(vec![
722                ConvertStep::SrgbF32ToLinearF32,
723                ConvertStep::LinearF32ToBt709F32,
724            ]),
725            (TransferFunction::Bt709, TransferFunction::Srgb) => Ok(vec![
726                ConvertStep::Bt709F32ToLinearF32,
727                ConvertStep::LinearF32ToSrgbF32,
728            ]),
729            // sRGB/BT.709 ↔ PQ/HLG: go through linear.
730            (TransferFunction::Srgb, TransferFunction::Pq) => Ok(vec![
731                ConvertStep::SrgbF32ToLinearF32,
732                ConvertStep::LinearF32ToPqF32,
733            ]),
734            (TransferFunction::Srgb, TransferFunction::Hlg) => Ok(vec![
735                ConvertStep::SrgbF32ToLinearF32,
736                ConvertStep::LinearF32ToHlgF32,
737            ]),
738            (TransferFunction::Pq, TransferFunction::Srgb) => Ok(vec![
739                ConvertStep::PqF32ToLinearF32,
740                ConvertStep::LinearF32ToSrgbF32,
741            ]),
742            (TransferFunction::Hlg, TransferFunction::Srgb) => Ok(vec![
743                ConvertStep::HlgF32ToLinearF32,
744                ConvertStep::LinearF32ToSrgbF32,
745            ]),
746            (TransferFunction::Bt709, TransferFunction::Pq) => Ok(vec![
747                ConvertStep::Bt709F32ToLinearF32,
748                ConvertStep::LinearF32ToPqF32,
749            ]),
750            (TransferFunction::Bt709, TransferFunction::Hlg) => Ok(vec![
751                ConvertStep::Bt709F32ToLinearF32,
752                ConvertStep::LinearF32ToHlgF32,
753            ]),
754            (TransferFunction::Pq, TransferFunction::Bt709) => Ok(vec![
755                ConvertStep::PqF32ToLinearF32,
756                ConvertStep::LinearF32ToBt709F32,
757            ]),
758            (TransferFunction::Hlg, TransferFunction::Bt709) => Ok(vec![
759                ConvertStep::HlgF32ToLinearF32,
760                ConvertStep::LinearF32ToBt709F32,
761            ]),
762            _ => Ok(Vec::new()),
763        };
764    }
765
766    match (from, to) {
767        (ChannelType::U8, ChannelType::F32) => {
768            if (from_tf == TransferFunction::Srgb || from_tf == TransferFunction::Bt709)
769                && to_tf == TransferFunction::Linear
770            {
771                Ok(vec![ConvertStep::SrgbU8ToLinearF32])
772            } else {
773                Ok(vec![ConvertStep::NaiveU8ToF32])
774            }
775        }
776        (ChannelType::F32, ChannelType::U8) => {
777            if from_tf == TransferFunction::Linear
778                && (to_tf == TransferFunction::Srgb || to_tf == TransferFunction::Bt709)
779            {
780                Ok(vec![ConvertStep::LinearF32ToSrgbU8])
781            } else {
782                Ok(vec![ConvertStep::NaiveF32ToU8])
783            }
784        }
785        (ChannelType::U16, ChannelType::F32) => {
786            // PQ/HLG U16 → Linear F32: apply EOTF during conversion.
787            match (from_tf, to_tf) {
788                (TransferFunction::Pq, TransferFunction::Linear) => {
789                    Ok(vec![ConvertStep::PqU16ToLinearF32])
790                }
791                (TransferFunction::Hlg, TransferFunction::Linear) => {
792                    Ok(vec![ConvertStep::HlgU16ToLinearF32])
793                }
794                _ => Ok(vec![ConvertStep::U16ToF32]),
795            }
796        }
797        (ChannelType::F32, ChannelType::U16) => {
798            // Linear F32 → PQ/HLG U16: apply OETF during conversion.
799            match (from_tf, to_tf) {
800                (TransferFunction::Linear, TransferFunction::Pq) => {
801                    Ok(vec![ConvertStep::LinearF32ToPqU16])
802                }
803                (TransferFunction::Linear, TransferFunction::Hlg) => {
804                    Ok(vec![ConvertStep::LinearF32ToHlgU16])
805                }
806                _ => Ok(vec![ConvertStep::F32ToU16]),
807            }
808        }
809        (ChannelType::U16, ChannelType::U8) => {
810            // HDR U16 → SDR U8: go through linear F32 with proper EOTF → OETF.
811            if from_tf == TransferFunction::Pq && to_tf == TransferFunction::Srgb {
812                Ok(vec![
813                    ConvertStep::PqU16ToLinearF32,
814                    ConvertStep::LinearF32ToSrgbU8,
815                ])
816            } else if from_tf == TransferFunction::Hlg && to_tf == TransferFunction::Srgb {
817                Ok(vec![
818                    ConvertStep::HlgU16ToLinearF32,
819                    ConvertStep::LinearF32ToSrgbU8,
820                ])
821            } else {
822                Ok(vec![ConvertStep::U16ToU8])
823            }
824        }
825        (ChannelType::U8, ChannelType::U16) => Ok(vec![ConvertStep::U8ToU16]),
826        _ => Err(ConvertError::NoPath {
827            from: PixelDescriptor::new(from, ChannelLayout::Rgb, None, from_tf),
828            to: PixelDescriptor::new(to, ChannelLayout::Rgb, None, to_tf),
829        }),
830    }
831}
832
833// ---------------------------------------------------------------------------
834// Row conversion kernels
835// ---------------------------------------------------------------------------
836
837/// Pre-allocated scratch buffer for multi-step row conversions.
838///
839/// Eliminates per-row heap allocation by reusing two ping-pong halves
840/// of a single buffer across calls. Create once per [`ConvertPlan`],
841/// then pass to `convert_row_buffered` for each row.
842pub(crate) struct ConvertScratch {
843    /// Single allocation split into two halves via `split_at_mut`.
844    /// Stored as `Vec<u32>` to guarantee 4-byte alignment, which lets
845    /// garb and bytemuck use fast aligned paths instead of unaligned fallbacks.
846    buf: Vec<u32>,
847}
848
849impl ConvertScratch {
850    /// Create empty scratch (buffer grows on first use).
851    pub(crate) fn new() -> Self {
852        Self { buf: Vec::new() }
853    }
854
855    /// Ensure the buffer is large enough for two halves of the max
856    /// intermediate format at the given width.
857    fn ensure_capacity(&mut self, plan: &ConvertPlan, width: u32) {
858        let half_bytes = (width as usize) * plan.max_intermediate_bpp();
859        let total_u32 = (half_bytes * 2).div_ceil(4);
860        if self.buf.len() < total_u32 {
861            self.buf.resize(total_u32, 0);
862        }
863    }
864}
865
866impl core::fmt::Debug for ConvertScratch {
867    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
868        f.debug_struct("ConvertScratch")
869            .field("capacity", &self.buf.capacity())
870            .finish()
871    }
872}
873
874/// Convert one row of `width` pixels using a pre-computed plan.
875///
876/// `src` and `dst` must be sized for `width` pixels in their respective formats.
877/// For multi-step plans, an internal scratch buffer is allocated per call.
878/// Prefer [`RowConverter`](crate::RowConverter) in hot loops (reuses scratch buffers).
879pub fn convert_row(plan: &ConvertPlan, src: &[u8], dst: &mut [u8], width: u32) {
880    if plan.is_identity() {
881        let len = min(src.len(), dst.len());
882        dst[..len].copy_from_slice(&src[..len]);
883        return;
884    }
885
886    if plan.steps.len() == 1 {
887        apply_step_u8(plan.steps[0], src, dst, width, plan.from, plan.to);
888        return;
889    }
890
891    // Allocating fallback for one-off calls.
892    let mut scratch = ConvertScratch::new();
893    convert_row_buffered(plan, src, dst, width, &mut scratch);
894}
895
896/// Convert one row of `width` pixels, reusing pre-allocated scratch buffers.
897///
898/// For multi-step plans this avoids per-row heap allocation by ping-ponging
899/// between two halves of a scratch buffer. Single-step plans bypass scratch.
900pub(crate) fn convert_row_buffered(
901    plan: &ConvertPlan,
902    src: &[u8],
903    dst: &mut [u8],
904    width: u32,
905    scratch: &mut ConvertScratch,
906) {
907    if plan.is_identity() {
908        let len = min(src.len(), dst.len());
909        dst[..len].copy_from_slice(&src[..len]);
910        return;
911    }
912
913    if plan.steps.len() == 1 {
914        apply_step_u8(plan.steps[0], src, dst, width, plan.from, plan.to);
915        return;
916    }
917
918    scratch.ensure_capacity(plan, width);
919
920    let buf_bytes: &mut [u8] = bytemuck::cast_slice_mut(&mut scratch.buf);
921    let half = buf_bytes.len() / 2;
922    let (buf_a, buf_b) = buf_bytes.split_at_mut(half);
923
924    let num_steps = plan.steps.len();
925    let mut current_desc = plan.from;
926
927    for (i, &step) in plan.steps.iter().enumerate() {
928        let is_last = i == num_steps - 1;
929        let next_desc = if is_last {
930            plan.to
931        } else {
932            intermediate_desc(current_desc, step)
933        };
934
935        let next_len = (width as usize) * next_desc.bytes_per_pixel();
936        let curr_len = (width as usize) * current_desc.bytes_per_pixel();
937
938        // Ping-pong: even steps read src/buf_b and write buf_a;
939        // odd steps read buf_a and write buf_b. Each branch only
940        // borrows each half in one mode, satisfying the borrow checker.
941        if i % 2 == 0 {
942            let input = if i == 0 { src } else { &buf_b[..curr_len] };
943            if is_last {
944                apply_step_u8(step, input, dst, width, current_desc, next_desc);
945            } else {
946                apply_step_u8(
947                    step,
948                    input,
949                    &mut buf_a[..next_len],
950                    width,
951                    current_desc,
952                    next_desc,
953                );
954            }
955        } else {
956            let input = &buf_a[..curr_len];
957            if is_last {
958                apply_step_u8(step, input, dst, width, current_desc, next_desc);
959            } else {
960                apply_step_u8(
961                    step,
962                    input,
963                    &mut buf_b[..next_len],
964                    width,
965                    current_desc,
966                    next_desc,
967                );
968            }
969        }
970
971        current_desc = next_desc;
972    }
973}
974
975/// Check if two steps are inverses that cancel each other.
976fn are_inverse(a: ConvertStep, b: ConvertStep) -> bool {
977    matches!(
978        (a, b),
979        // Self-inverse
980        (ConvertStep::SwizzleBgraRgba, ConvertStep::SwizzleBgraRgba)
981        // Layout inverses (lossless for opaque data)
982        | (ConvertStep::AddAlpha, ConvertStep::DropAlpha)
983        // Transfer function f32↔f32 (exact inverses in float)
984        | (ConvertStep::SrgbF32ToLinearF32, ConvertStep::LinearF32ToSrgbF32)
985        | (ConvertStep::LinearF32ToSrgbF32, ConvertStep::SrgbF32ToLinearF32)
986        | (ConvertStep::PqF32ToLinearF32, ConvertStep::LinearF32ToPqF32)
987        | (ConvertStep::LinearF32ToPqF32, ConvertStep::PqF32ToLinearF32)
988        | (ConvertStep::HlgF32ToLinearF32, ConvertStep::LinearF32ToHlgF32)
989        | (ConvertStep::LinearF32ToHlgF32, ConvertStep::HlgF32ToLinearF32)
990        | (ConvertStep::Bt709F32ToLinearF32, ConvertStep::LinearF32ToBt709F32)
991        | (ConvertStep::LinearF32ToBt709F32, ConvertStep::Bt709F32ToLinearF32)
992        // Alpha mode (exact inverses in float)
993        | (ConvertStep::StraightToPremul, ConvertStep::PremulToStraight)
994        | (ConvertStep::PremulToStraight, ConvertStep::StraightToPremul)
995        // Color model (exact inverses in float)
996        | (ConvertStep::LinearRgbToOklab, ConvertStep::OklabToLinearRgb)
997        | (ConvertStep::OklabToLinearRgb, ConvertStep::LinearRgbToOklab)
998        | (ConvertStep::LinearRgbaToOklaba, ConvertStep::OklabaToLinearRgba)
999        | (ConvertStep::OklabaToLinearRgba, ConvertStep::LinearRgbaToOklaba)
1000        // Cross-depth pairs (near-lossless for same depth class)
1001        | (ConvertStep::NaiveU8ToF32, ConvertStep::NaiveF32ToU8)
1002        | (ConvertStep::NaiveF32ToU8, ConvertStep::NaiveU8ToF32)
1003        | (ConvertStep::U8ToU16, ConvertStep::U16ToU8)
1004        | (ConvertStep::U16ToU8, ConvertStep::U8ToU16)
1005        | (ConvertStep::U16ToF32, ConvertStep::F32ToU16)
1006        | (ConvertStep::F32ToU16, ConvertStep::U16ToF32)
1007        // Cross-depth with transfer (near-lossless roundtrip)
1008        | (ConvertStep::SrgbU8ToLinearF32, ConvertStep::LinearF32ToSrgbU8)
1009        | (ConvertStep::LinearF32ToSrgbU8, ConvertStep::SrgbU8ToLinearF32)
1010        | (ConvertStep::PqU16ToLinearF32, ConvertStep::LinearF32ToPqU16)
1011        | (ConvertStep::LinearF32ToPqU16, ConvertStep::PqU16ToLinearF32)
1012        | (ConvertStep::HlgU16ToLinearF32, ConvertStep::LinearF32ToHlgU16)
1013        | (ConvertStep::LinearF32ToHlgU16, ConvertStep::HlgU16ToLinearF32)
1014    )
1015}
1016
1017/// Compute the descriptor after applying one step.
1018fn intermediate_desc(current: PixelDescriptor, step: ConvertStep) -> PixelDescriptor {
1019    match step {
1020        ConvertStep::Identity => current,
1021        ConvertStep::SwizzleBgraRgba => {
1022            let new_layout = match current.layout() {
1023                ChannelLayout::Bgra => ChannelLayout::Rgba,
1024                ChannelLayout::Rgba => ChannelLayout::Bgra,
1025                other => other,
1026            };
1027            PixelDescriptor::new(
1028                current.channel_type(),
1029                new_layout,
1030                current.alpha(),
1031                current.transfer(),
1032            )
1033        }
1034        ConvertStep::AddAlpha => PixelDescriptor::new(
1035            current.channel_type(),
1036            ChannelLayout::Rgba,
1037            Some(AlphaMode::Straight),
1038            current.transfer(),
1039        ),
1040        ConvertStep::DropAlpha | ConvertStep::MatteComposite { .. } => PixelDescriptor::new(
1041            current.channel_type(),
1042            ChannelLayout::Rgb,
1043            None,
1044            current.transfer(),
1045        ),
1046        ConvertStep::GrayToRgb => PixelDescriptor::new(
1047            current.channel_type(),
1048            ChannelLayout::Rgb,
1049            None,
1050            current.transfer(),
1051        ),
1052        ConvertStep::GrayToRgba => PixelDescriptor::new(
1053            current.channel_type(),
1054            ChannelLayout::Rgba,
1055            Some(AlphaMode::Straight),
1056            current.transfer(),
1057        ),
1058        ConvertStep::RgbToGray | ConvertStep::RgbaToGray => PixelDescriptor::new(
1059            current.channel_type(),
1060            ChannelLayout::Gray,
1061            None,
1062            current.transfer(),
1063        ),
1064        ConvertStep::GrayAlphaToRgba => PixelDescriptor::new(
1065            current.channel_type(),
1066            ChannelLayout::Rgba,
1067            current.alpha(),
1068            current.transfer(),
1069        ),
1070        ConvertStep::GrayAlphaToRgb => PixelDescriptor::new(
1071            current.channel_type(),
1072            ChannelLayout::Rgb,
1073            None,
1074            current.transfer(),
1075        ),
1076        ConvertStep::GrayToGrayAlpha => PixelDescriptor::new(
1077            current.channel_type(),
1078            ChannelLayout::GrayAlpha,
1079            Some(AlphaMode::Straight),
1080            current.transfer(),
1081        ),
1082        ConvertStep::GrayAlphaToGray => PixelDescriptor::new(
1083            current.channel_type(),
1084            ChannelLayout::Gray,
1085            None,
1086            current.transfer(),
1087        ),
1088        ConvertStep::SrgbU8ToLinearF32
1089        | ConvertStep::NaiveU8ToF32
1090        | ConvertStep::U16ToF32
1091        | ConvertStep::PqU16ToLinearF32
1092        | ConvertStep::HlgU16ToLinearF32
1093        | ConvertStep::PqF32ToLinearF32
1094        | ConvertStep::HlgF32ToLinearF32
1095        | ConvertStep::SrgbF32ToLinearF32
1096        | ConvertStep::Bt709F32ToLinearF32 => PixelDescriptor::new(
1097            ChannelType::F32,
1098            current.layout(),
1099            current.alpha(),
1100            TransferFunction::Linear,
1101        ),
1102        ConvertStep::LinearF32ToSrgbU8 | ConvertStep::NaiveF32ToU8 | ConvertStep::U16ToU8 => {
1103            PixelDescriptor::new(
1104                ChannelType::U8,
1105                current.layout(),
1106                current.alpha(),
1107                TransferFunction::Srgb,
1108            )
1109        }
1110        ConvertStep::U8ToU16 => PixelDescriptor::new(
1111            ChannelType::U16,
1112            current.layout(),
1113            current.alpha(),
1114            current.transfer(),
1115        ),
1116        ConvertStep::F32ToU16 | ConvertStep::LinearF32ToPqU16 | ConvertStep::LinearF32ToHlgU16 => {
1117            let tf = match step {
1118                ConvertStep::LinearF32ToPqU16 => TransferFunction::Pq,
1119                ConvertStep::LinearF32ToHlgU16 => TransferFunction::Hlg,
1120                _ => current.transfer(),
1121            };
1122            PixelDescriptor::new(ChannelType::U16, current.layout(), current.alpha(), tf)
1123        }
1124        ConvertStep::LinearF32ToPqF32 => PixelDescriptor::new(
1125            ChannelType::F32,
1126            current.layout(),
1127            current.alpha(),
1128            TransferFunction::Pq,
1129        ),
1130        ConvertStep::LinearF32ToHlgF32 => PixelDescriptor::new(
1131            ChannelType::F32,
1132            current.layout(),
1133            current.alpha(),
1134            TransferFunction::Hlg,
1135        ),
1136        ConvertStep::LinearF32ToSrgbF32 => PixelDescriptor::new(
1137            ChannelType::F32,
1138            current.layout(),
1139            current.alpha(),
1140            TransferFunction::Srgb,
1141        ),
1142        ConvertStep::LinearF32ToBt709F32 => PixelDescriptor::new(
1143            ChannelType::F32,
1144            current.layout(),
1145            current.alpha(),
1146            TransferFunction::Bt709,
1147        ),
1148        ConvertStep::StraightToPremul => PixelDescriptor::new(
1149            current.channel_type(),
1150            current.layout(),
1151            Some(AlphaMode::Premultiplied),
1152            current.transfer(),
1153        ),
1154        ConvertStep::PremulToStraight => PixelDescriptor::new(
1155            current.channel_type(),
1156            current.layout(),
1157            Some(AlphaMode::Straight),
1158            current.transfer(),
1159        ),
1160        ConvertStep::LinearRgbToOklab => PixelDescriptor::new(
1161            ChannelType::F32,
1162            ChannelLayout::Oklab,
1163            None,
1164            TransferFunction::Unknown,
1165        )
1166        .with_primaries(current.primaries),
1167        ConvertStep::OklabToLinearRgb => PixelDescriptor::new(
1168            ChannelType::F32,
1169            ChannelLayout::Rgb,
1170            None,
1171            TransferFunction::Linear,
1172        )
1173        .with_primaries(current.primaries),
1174        ConvertStep::LinearRgbaToOklaba => PixelDescriptor::new(
1175            ChannelType::F32,
1176            ChannelLayout::OklabA,
1177            Some(AlphaMode::Straight),
1178            TransferFunction::Unknown,
1179        )
1180        .with_primaries(current.primaries),
1181        ConvertStep::OklabaToLinearRgba => PixelDescriptor::new(
1182            ChannelType::F32,
1183            ChannelLayout::Rgba,
1184            current.alpha(),
1185            TransferFunction::Linear,
1186        )
1187        .with_primaries(current.primaries),
1188
1189        // Gamut matrix: same depth/layout/TF, but primaries change.
1190        // The actual target primaries are embedded in the matrix, not tracked
1191        // here — we mark them as Unknown since the step doesn't carry that info.
1192        // The final plan.to descriptor has the correct primaries.
1193        ConvertStep::GamutMatrixRgbF32(_) => PixelDescriptor::new(
1194            ChannelType::F32,
1195            current.layout(),
1196            current.alpha(),
1197            TransferFunction::Linear,
1198        ),
1199        ConvertStep::GamutMatrixRgbaF32(_) => PixelDescriptor::new(
1200            ChannelType::F32,
1201            current.layout(),
1202            current.alpha(),
1203            TransferFunction::Linear,
1204        ),
1205    }
1206}
1207
1208#[path = "convert_kernels.rs"]
1209mod convert_kernels;
1210use convert_kernels::apply_step_u8;
1211pub(crate) use convert_kernels::{hlg_eotf, hlg_oetf, pq_eotf, pq_oetf};