Skip to main content

zenpixels_convert/
negotiate.rs

1//! Format negotiation — pick the best conversion target.
2//!
3//! The cost model separates **effort** (computational work) from **loss**
4//! (information destroyed). The [`ConvertIntent`] controls how these axes
5//! are weighted: `Fastest` prioritizes low effort, while `LinearLight` and
6//! `Blend` prioritize low loss.
7//!
8//! Consumers that can perform conversions internally (e.g., a JPEG encoder
9//! with a fused f32→u8+DCT path) can express this via [`FormatOption`]
10//! with a custom [`ConversionCost`], so the negotiation picks their fast
11//! path instead of doing a redundant conversion.
12//!
13//! # How negotiation works
14//!
15//! For each candidate target in the supported format list, the cost model
16//! computes five independent cost components:
17//!
18//! 1. **Transfer cost**: Effort of applying/removing EOTF (sRGB→linear,
19//!    PQ→linear, etc.). Unknown↔anything is free (relabeling).
20//! 2. **Depth cost**: Effort of depth conversion (u8→f32, u16→u8). Loss
21//!    considers provenance — if the data was originally u8 and was widened
22//!    to f32 for processing, converting back to u8 has zero loss.
23//! 3. **Layout cost**: Effort of adding/removing/swizzling channels.
24//!    RGB→Gray is very lossy (500); BGRA→RGBA is cheap (5, swizzle only).
25//! 4. **Alpha cost**: Effort of alpha mode conversion. Straight→premul is
26//!    cheap; premul→straight involves division (worse rounding at low alpha).
27//! 5. **Primaries cost**: Effort of gamut matrix (3×3 multiply). Loss
28//!    considers provenance — narrowing to a gamut that contains the origin
29//!    gamut has near-zero loss.
30//!
31//! These five costs are summed, then the consumer's cost override is added,
32//! then a suitability penalty (e.g., "u8 sRGB is bad for linear-light
33//! resize") is added to the loss axis. Finally, effort and loss are
34//! combined into a single score via [`ConvertIntent`]-specific weights.
35//! The candidate with the lowest score wins.
36//!
37//! # Provenance
38//!
39//! [`Provenance`] is the key to avoiding unnecessary quality loss. Without
40//! it, converting f32→u8 always reports high loss. With it, the cost model
41//! knows the data was originally u8 (e.g., from JPEG) and the round-trip
42//! is lossless.
43//!
44//! Update provenance when operations change the data's effective precision
45//! or gamut:
46//!
47//! - JPEG u8 → f32 for resize → back to u8: **No update needed.** Origin
48//!   is still u8.
49//! - sRGB data → convert to BT.2020 → saturation boost → back to sRGB:
50//!   **Call `invalidate_primaries(Bt2020)`** because the data now genuinely
51//!   uses the wider gamut.
52//! - 16-bit PNG → process in f32 → back to u16: **No update needed.**
53//!   Origin is still u16.
54//!
55//! # Consumer cost overrides
56//!
57//! [`FormatOption::with_cost`] lets codecs advertise fast internal paths.
58//! Example: a JPEG encoder with a fused f32→u8+DCT kernel can accept
59//! f32 data directly, saving the caller a separate f32→u8 conversion:
60//!
61//! ```rust,ignore
62//! let options = &[
63//!     FormatOption::from(PixelDescriptor::RGB8_SRGB),    // native: zero cost
64//!     FormatOption::with_cost(
65//!         PixelDescriptor::RGBF32_LINEAR,
66//!         ConversionCost::new(5, 0),  // fast fused path
67//!     ),
68//! ];
69//! ```
70//!
71//! Without the override, the negotiator would prefer RGB8_SRGB (no
72//! conversion needed) even when the source is already f32. With the
73//! override, it sees that delivering f32 directly costs only 5 effort
74//! (the encoder's fused path) vs. 40+ effort (our f32→u8 conversion).
75//!
76//! # Suitability penalties
77//!
78//! The cost model adds quality-of-operation penalties independent of
79//! conversion cost. For example, bilinear resize in sRGB u8 produces
80//! gamma-darkening artifacts (measured ΔE ≈ 13.7) regardless of how
81//! cheap the u8→u8 identity "conversion" is. `LinearLight` intent
82//! penalizes non-linear formats by 120 loss points, steering the
83//! negotiator toward f32 linear even when u8 sRGB is cheaper to deliver.
84
85use core::ops::Add;
86
87use crate::{
88    AlphaMode, ChannelLayout, ChannelType, ColorPrimaries, PixelDescriptor, TransferFunction,
89};
90
91// ---------------------------------------------------------------------------
92// Public types
93// ---------------------------------------------------------------------------
94
95/// Tracks where pixel data came from, so the cost model can distinguish
96/// "f32 that was widened from u8 JPEG" (lossless round-trip back to u8)
97/// from "f32 that was decoded from a 16-bit EXR" (lossy truncation to u8).
98///
99/// Without provenance, `depth_cost(f32 → u8)` always reports high loss.
100/// With provenance, it can see that the data's *true* precision is u8,
101/// so the round-trip is lossless.
102///
103/// # Gamut provenance
104///
105/// `origin_primaries` tracks the gamut of the original source, enabling
106/// lossless round-trip detection for gamut conversions. For example,
107/// sRGB data placed in BT.2020 for processing can round-trip back to
108/// sRGB losslessly — but only if no operations expanded the actual color
109/// usage (e.g., saturation boost filling the wider gamut). When an
110/// operation does expand gamut usage, the caller must update provenance
111/// via [`invalidate_primaries`](Self::invalidate_primaries) to reflect
112/// that the data now genuinely uses the wider gamut.
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114#[non_exhaustive]
115pub struct Provenance {
116    /// The channel depth of the original source data.
117    ///
118    /// For a JPEG (u8 sRGB) decoded into f32 for resize, this is `U8`.
119    /// For an EXR (f32) loaded directly, this is `F32`.
120    /// For a 16-bit PNG, this is `U16`.
121    pub origin_depth: ChannelType,
122
123    /// The color primaries of the original source data.
124    ///
125    /// For a standard sRGB JPEG, this is `Bt709`. For a Display P3 image,
126    /// this is `DisplayP3`. Used to detect when converting to a narrower
127    /// gamut is lossless (the source fits entirely within the target).
128    ///
129    /// **Important:** If an operation expands the data's gamut usage
130    /// (e.g., saturation boost in BT.2020 that pushes colors outside
131    /// the original sRGB gamut), call [`invalidate_primaries`](Self::invalidate_primaries)
132    /// to update this to the current working primaries. Otherwise the
133    /// cost model will incorrectly report the gamut narrowing as lossless.
134    pub origin_primaries: ColorPrimaries,
135}
136
137impl Provenance {
138    /// Assume the descriptor's properties *are* the true origin characteristics.
139    ///
140    /// This is the conservative default: if you don't know the data's history,
141    /// assume its current format is its true origin.
142    #[inline]
143    pub fn from_source(desc: PixelDescriptor) -> Self {
144        Self {
145            origin_depth: desc.channel_type(),
146            origin_primaries: desc.primaries,
147        }
148    }
149
150    /// Create provenance with an explicit origin depth. Primaries default to BT.709.
151    ///
152    /// Use this when the data has been widened from a known source depth.
153    /// For example, a JPEG (u8) decoded into f32 for resize:
154    ///
155    /// ```rust,ignore
156    /// let provenance = Provenance::with_origin_depth(ChannelType::U8);
157    /// ```
158    #[inline]
159    pub const fn with_origin_depth(origin_depth: ChannelType) -> Self {
160        Self {
161            origin_depth,
162            origin_primaries: ColorPrimaries::Bt709,
163        }
164    }
165
166    /// Create provenance with explicit origin depth and primaries.
167    #[inline]
168    pub const fn with_origin(origin_depth: ChannelType, origin_primaries: ColorPrimaries) -> Self {
169        Self {
170            origin_depth,
171            origin_primaries,
172        }
173    }
174
175    /// Create provenance with an explicit origin primaries. Depth defaults to
176    /// the descriptor's current channel type.
177    #[inline]
178    pub fn with_origin_primaries(desc: PixelDescriptor, primaries: ColorPrimaries) -> Self {
179        Self {
180            origin_depth: desc.channel_type(),
181            origin_primaries: primaries,
182        }
183    }
184
185    /// Mark the gamut provenance as invalid (matches current format).
186    ///
187    /// Call this after any operation that expands the data's color usage
188    /// beyond the original gamut. For example, if sRGB data is converted
189    /// to BT.2020 and then saturation is boosted to fill the wider gamut,
190    /// the origin is no longer sRGB — the data genuinely uses BT.2020.
191    ///
192    /// After this call, converting to a narrower gamut (e.g., back to sRGB)
193    /// will correctly report gamut clipping loss.
194    #[inline]
195    pub fn invalidate_primaries(&mut self, current: ColorPrimaries) {
196        self.origin_primaries = current;
197    }
198}
199
200/// What the consumer plans to do with the converted pixels.
201///
202/// Shifts the format negotiation cost model to prefer formats
203/// suited for the intended operation.
204#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
205#[non_exhaustive]
206pub enum ConvertIntent {
207    /// Minimize conversion effort. Good for encoding — the codec
208    /// already knows what it wants, just get there cheaply.
209    #[default]
210    Fastest,
211
212    /// Pixel-accurate operations that need linear light:
213    /// resize, blur, anti-aliasing, mipmap generation.
214    /// Prefers f32 Linear. Straight alpha is fine.
215    LinearLight,
216
217    /// Compositing and blending (Porter-Duff, layer merge).
218    /// Prefers f32 Linear with Premultiplied alpha.
219    Blend,
220
221    /// Perceptual adjustments: sharpening, contrast, saturation.
222    /// Prefers sRGB space (perceptually uniform).
223    Perceptual,
224}
225
226/// Two-axis conversion cost: computational effort vs. information loss.
227///
228/// These are independent concerns:
229/// - A fast conversion can be very lossy (f32 HDR → u8 sRGB clamp).
230/// - A slow conversion can be lossless (u8 sRGB → f32 Linear).
231/// - A consumer's fused path can be fast with the same loss as our path.
232///
233/// [`ConvertIntent`] controls how the two axes are weighted for ranking.
234#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
235pub struct ConversionCost {
236    /// Computational work (cycles, not quality). Lower is faster.
237    pub effort: u16,
238    /// Information destroyed (precision, gamut, channels). Lower is more faithful.
239    pub loss: u16,
240}
241
242impl ConversionCost {
243    /// Zero cost — identity conversion.
244    pub const ZERO: Self = Self { effort: 0, loss: 0 };
245
246    /// Create a cost with explicit effort and loss.
247    pub const fn new(effort: u16, loss: u16) -> Self {
248        Self { effort, loss }
249    }
250}
251
252impl Add for ConversionCost {
253    type Output = Self;
254    fn add(self, rhs: Self) -> Self {
255        Self {
256            effort: self.effort.saturating_add(rhs.effort),
257            loss: self.loss.saturating_add(rhs.loss),
258        }
259    }
260}
261
262/// A supported format with optional consumer-provided cost override.
263///
264/// Use this with [`best_match_with`] when the consumer can handle some
265/// formats more efficiently than the default conversion path.
266///
267/// # Example
268///
269/// A JPEG encoder with a fast internal f32→u8 path:
270///
271/// ```rust,ignore
272/// use zenpixels::{FormatOption, ConversionCost};
273/// use zenpixels::PixelDescriptor;
274///
275/// let options = &[
276///     FormatOption::from(PixelDescriptor::RGB8_SRGB),     // native, zero cost
277///     FormatOption::with_cost(
278///         PixelDescriptor::RGBF32_LINEAR,
279///         ConversionCost::new(5, 0),  // fast fused path, no extra loss
280///     ),
281/// ];
282/// ```
283#[derive(Clone, Copy, Debug)]
284pub struct FormatOption {
285    /// The pixel format the consumer can accept.
286    pub descriptor: PixelDescriptor,
287    /// Additional cost the consumer incurs to handle this format
288    /// after we deliver it. Zero for native formats.
289    pub consumer_cost: ConversionCost,
290}
291
292impl FormatOption {
293    /// Create an option with explicit consumer cost.
294    pub const fn with_cost(descriptor: PixelDescriptor, consumer_cost: ConversionCost) -> Self {
295        Self {
296            descriptor,
297            consumer_cost,
298        }
299    }
300}
301
302impl From<PixelDescriptor> for FormatOption {
303    fn from(descriptor: PixelDescriptor) -> Self {
304        Self {
305            descriptor,
306            consumer_cost: ConversionCost::ZERO,
307        }
308    }
309}
310
311// ---------------------------------------------------------------------------
312// Public functions
313// ---------------------------------------------------------------------------
314
315/// Pick the best conversion target from `supported` for the given source.
316///
317/// Returns `None` only if `supported` is empty.
318///
319/// This is the simple API — all consumer costs are assumed zero,
320/// and provenance is inferred from the source descriptor (conservative).
321/// Use [`best_match_with`] for consumer cost overrides, or
322/// [`negotiate`] for full control over provenance and consumer costs.
323pub fn best_match(
324    source: PixelDescriptor,
325    supported: &[PixelDescriptor],
326    intent: ConvertIntent,
327) -> Option<PixelDescriptor> {
328    negotiate(
329        source,
330        Provenance::from_source(source),
331        supported.iter().map(|&d| FormatOption::from(d)),
332        intent,
333    )
334}
335
336/// Pick the best conversion target from `options`, accounting for
337/// consumer-provided cost overrides.
338///
339/// Returns `None` only if `options` is empty.
340///
341/// Each [`FormatOption`] specifies a format the consumer can accept
342/// and what it costs the consumer to handle that format internally.
343/// The total cost is `our_conversion + consumer_cost`, weighted by intent.
344///
345/// Provenance is inferred from the source descriptor. Use [`negotiate`]
346/// when the data has been widened from a lower-precision origin.
347pub fn best_match_with(
348    source: PixelDescriptor,
349    options: &[FormatOption],
350    intent: ConvertIntent,
351) -> Option<PixelDescriptor> {
352    negotiate(
353        source,
354        Provenance::from_source(source),
355        options.iter().copied(),
356        intent,
357    )
358}
359
360/// Fully-parameterized format negotiation.
361///
362/// This is the most flexible entry point: it takes explicit provenance
363/// (so the cost model knows the data's true origin precision) and
364/// consumer cost overrides (so fused conversion paths are accounted for).
365///
366/// # When to use
367///
368/// Use this when the current pixel format doesn't represent the data's
369/// true precision. For example, a JPEG image (u8 sRGB) decoded into f32
370/// for gamma-correct resize: the data is *currently* f32, but its origin
371/// precision is u8. Converting back to u8 for JPEG encoding is lossless
372/// (within ±1 LSB), and the cost model should reflect that.
373///
374/// ```rust,ignore
375/// let provenance = Provenance::with_origin_depth(ChannelType::U8);
376/// let target = negotiate(
377///     current_f32_desc,
378///     provenance,
379///     encoder_options.iter().copied(),
380///     ConvertIntent::Fastest,
381/// );
382/// ```
383pub fn negotiate(
384    source: PixelDescriptor,
385    provenance: Provenance,
386    options: impl Iterator<Item = FormatOption>,
387    intent: ConvertIntent,
388) -> Option<PixelDescriptor> {
389    best_of(
390        source,
391        provenance,
392        options.map(|o| (o.descriptor, o.consumer_cost)),
393        intent,
394    )
395}
396
397/// Recommend the ideal working format for a given intent, based on the source format.
398///
399/// Unlike [`best_match`], this isn't constrained to a list — it returns what
400/// the consumer *should* be working in for optimal results.
401///
402/// Key principles:
403/// - **Fastest** preserves the source format (identity).
404/// - **LinearLight** upgrades to f32 Linear for gamma-correct operations.
405/// - **Blend** upgrades to f32 Linear with premultiplied alpha.
406/// - **Perceptual** keeps u8 sRGB for SDR-8 sources (LUT-fast), upgrades others to f32 sRGB.
407/// - Never downgrades precision — a u16 source won't be recommended as u8.
408pub fn ideal_format(source: PixelDescriptor, intent: ConvertIntent) -> PixelDescriptor {
409    match intent {
410        ConvertIntent::Fastest => source,
411
412        ConvertIntent::LinearLight => {
413            if source.channel_type() == ChannelType::F32
414                && source.transfer() == TransferFunction::Linear
415            {
416                return source;
417            }
418            PixelDescriptor::new(
419                ChannelType::F32,
420                source.layout(),
421                source.alpha(),
422                TransferFunction::Linear,
423            )
424        }
425
426        ConvertIntent::Blend => {
427            let alpha = if source.layout().has_alpha() {
428                Some(AlphaMode::Premultiplied)
429            } else {
430                source.alpha()
431            };
432            if source.channel_type() == ChannelType::F32
433                && source.transfer() == TransferFunction::Linear
434                && source.alpha() == alpha
435            {
436                return source;
437            }
438            PixelDescriptor::new(
439                ChannelType::F32,
440                source.layout(),
441                alpha,
442                TransferFunction::Linear,
443            )
444        }
445
446        ConvertIntent::Perceptual => {
447            let tier = precision_tier(source);
448            match tier {
449                PrecisionTier::Sdr8 => {
450                    if source.transfer() == TransferFunction::Srgb
451                        || source.transfer() == TransferFunction::Unknown
452                    {
453                        return source;
454                    }
455                    PixelDescriptor::new(
456                        ChannelType::U8,
457                        source.layout(),
458                        source.alpha(),
459                        TransferFunction::Srgb,
460                    )
461                }
462                _ => PixelDescriptor::new(
463                    ChannelType::F32,
464                    source.layout(),
465                    source.alpha(),
466                    TransferFunction::Srgb,
467                ),
468            }
469        }
470    }
471}
472
473/// Compute the two-axis conversion cost for `from` → `to`.
474///
475/// This is the cost of *our* conversion kernels — it doesn't include
476/// any consumer-side cost. Intent-independent.
477///
478/// Provenance is inferred from `from` (conservative: assumes current
479/// depth is the true origin). Use [`conversion_cost_with_provenance`]
480/// when the data has been widened from a lower-precision source.
481#[must_use]
482pub fn conversion_cost(from: PixelDescriptor, to: PixelDescriptor) -> ConversionCost {
483    conversion_cost_with_provenance(from, to, Provenance::from_source(from))
484}
485
486/// Compute the two-axis conversion cost with explicit provenance.
487///
488/// Like [`conversion_cost`], but uses the provided [`Provenance`] to
489/// determine whether a depth narrowing is actually lossy. For example,
490/// `f32 → u8` reports zero loss when `provenance.origin_depth == U8`,
491/// because the data was originally u8 and the round-trip is lossless.
492#[must_use]
493pub fn conversion_cost_with_provenance(
494    from: PixelDescriptor,
495    to: PixelDescriptor,
496    provenance: Provenance,
497) -> ConversionCost {
498    transfer_cost(from.transfer(), to.transfer())
499        + depth_cost(
500            from.channel_type(),
501            to.channel_type(),
502            provenance.origin_depth,
503        )
504        + layout_cost(from.layout(), to.layout())
505        + alpha_cost(from.alpha(), to.alpha(), from.layout(), to.layout())
506        + primaries_cost(from.primaries, to.primaries, provenance.origin_primaries)
507}
508
509// ---------------------------------------------------------------------------
510// Internal scoring
511// ---------------------------------------------------------------------------
512
513/// Score a candidate target for ranking. Lower is better.
514pub(crate) fn score_target(
515    source: PixelDescriptor,
516    provenance: Provenance,
517    target: PixelDescriptor,
518    consumer_cost: ConversionCost,
519    intent: ConvertIntent,
520) -> u32 {
521    let our_cost = conversion_cost_with_provenance(source, target, provenance);
522    let total_effort = our_cost.effort as u32 + consumer_cost.effort as u32;
523    let total_loss =
524        our_cost.loss as u32 + consumer_cost.loss as u32 + suitability_loss(target, intent) as u32;
525    weighted_score(total_effort, total_loss, intent)
526}
527
528/// Find the best target from an iterator of (descriptor, consumer_cost) pairs.
529fn best_of(
530    source: PixelDescriptor,
531    provenance: Provenance,
532    options: impl Iterator<Item = (PixelDescriptor, ConversionCost)>,
533    intent: ConvertIntent,
534) -> Option<PixelDescriptor> {
535    let mut best: Option<(PixelDescriptor, u32)> = None;
536
537    for (target, consumer_cost) in options {
538        let score = score_target(source, provenance, target, consumer_cost, intent);
539        match best {
540            Some((_, best_score)) if score < best_score => best = Some((target, score)),
541            None => best = Some((target, score)),
542            _ => {}
543        }
544    }
545
546    best.map(|(desc, _)| desc)
547}
548
549/// Blend effort and loss into a single ranking score based on intent.
550///
551/// - `Fastest`: effort matters 4x more than loss
552/// - `LinearLight`/`Blend`: loss matters 4x more than effort
553/// - `Perceptual`: loss matters 3x more than effort
554pub(crate) fn weighted_score(effort: u32, loss: u32, intent: ConvertIntent) -> u32 {
555    match intent {
556        ConvertIntent::Fastest => effort * 4 + loss,
557        ConvertIntent::LinearLight | ConvertIntent::Blend => effort + loss * 4,
558        ConvertIntent::Perceptual => effort + loss * 3,
559    }
560}
561
562/// How unsuitable a target format is for the given intent.
563///
564/// This is a quality-of-operation penalty, not a conversion penalty.
565/// For example, u8 data processed with LinearLight resize produces
566/// gamma-incorrect results — that's a quality loss independent of
567/// how cheap the u8→u8 identity conversion is.
568pub(crate) fn suitability_loss(target: PixelDescriptor, intent: ConvertIntent) -> u16 {
569    match intent {
570        ConvertIntent::Fastest => 0,
571        ConvertIntent::LinearLight => linear_light_suitability(target),
572        ConvertIntent::Blend => {
573            let mut s = linear_light_suitability(target);
574            // Straight alpha requires per-pixel division during compositing.
575            // Calibrated: blending in straight alpha causes severe fringe artifacts
576            // at semi-transparent edges (measured ΔE=17.2, High bucket).
577            if target.layout().has_alpha() && target.alpha() == Some(AlphaMode::Straight) {
578                s += 200;
579            }
580            s
581        }
582        ConvertIntent::Perceptual => perceptual_suitability(target),
583    }
584}
585
586/// Suitability penalty for LinearLight operations (resize, blur).
587/// f32 Linear is ideal; any gamma-encoded format produces gamma
588/// darkening artifacts that dominate over quantization.
589///
590/// # Calibration notes (CIEDE2000 measurements)
591///
592/// **Non-linear (gamma-encoded) formats:**
593/// Bilinear resize in sRGB measures p95 ΔE ≈ 13.7 regardless of bit depth
594/// (u8=13.7, u16=13.7, f16=13.7, f32 sRGB=13.7).
595/// Gamma darkening is the dominant error — precision barely matters.
596///
597/// **Linear formats:**
598/// Only quantization matters. f32=0, f16=0.022, u8=0.213.
599#[allow(unreachable_patterns)] // non_exhaustive: future variants
600fn linear_light_suitability(target: PixelDescriptor) -> u16 {
601    if target.transfer() == TransferFunction::Linear {
602        // Linear space: only quantization error.
603        match target.channel_type() {
604            ChannelType::F32 => 0,
605            ChannelType::F16 => 5, // 10 mantissa bits, measured ΔE=0.022
606            ChannelType::U16 => 5, // 16 bits, negligible quantization
607            ChannelType::U8 => 40, // severe banding in darks, measured ΔE=0.213
608            _ => 50,
609        }
610    } else {
611        // Non-linear (sRGB, BT.709, PQ, HLG): gamma darkening dominates.
612        // All measure p95 ΔE ≈ 13.7 for resize regardless of precision.
613        120
614    }
615}
616
617/// Suitability penalty for perceptual operations (sharpening, color grading).
618/// sRGB-encoded data is ideal; Linear f32 is slightly off.
619fn perceptual_suitability(target: PixelDescriptor) -> u16 {
620    if target.channel_type() == ChannelType::F32 && target.transfer() == TransferFunction::Linear {
621        return 15;
622    }
623    if matches!(
624        target.transfer(),
625        TransferFunction::Pq | TransferFunction::Hlg
626    ) {
627        return 10;
628    }
629    0
630}
631
632// ---------------------------------------------------------------------------
633// Component cost functions (effort + loss)
634// ---------------------------------------------------------------------------
635
636/// Cost of transfer function conversion.
637fn transfer_cost(from: TransferFunction, to: TransferFunction) -> ConversionCost {
638    if from == to {
639        return ConversionCost::ZERO;
640    }
641    match (from, to) {
642        // Unknown → anything: relabeling, no actual math.
643        (TransferFunction::Unknown, _) | (_, TransferFunction::Unknown) => {
644            ConversionCost::new(1, 0)
645        }
646
647        // sRGB ↔ Linear: well-optimized EOTF/OETF, lossless in f32.
648        (TransferFunction::Srgb, TransferFunction::Linear)
649        | (TransferFunction::Linear, TransferFunction::Srgb) => ConversionCost::new(5, 0),
650
651        // BT.709 ↔ sRGB/Linear: slightly different curve, near-lossless.
652        (TransferFunction::Bt709, TransferFunction::Srgb)
653        | (TransferFunction::Srgb, TransferFunction::Bt709)
654        | (TransferFunction::Bt709, TransferFunction::Linear)
655        | (TransferFunction::Linear, TransferFunction::Bt709) => ConversionCost::new(8, 0),
656
657        // PQ/HLG ↔ anything else: expensive and lossy (range/gamut clipping).
658        _ => ConversionCost::new(80, 300),
659    }
660}
661
662/// Cost of channel depth conversion, considering the data's origin precision.
663///
664/// The `origin_depth` comes from [`Provenance`] and tells us the true
665/// precision of the data. This matters because:
666///
667/// - `f32 → u8` with origin `U8`: the data was u8 JPEG decoded to f32
668///   for processing. Round-trip back to u8 is lossless (±1 LSB).
669/// - `f32 → u8` with origin `F32`: the data has true f32 precision
670///   (e.g., EXR or HDR AVIF). Truncating to u8 destroys highlight detail.
671///
672/// **Rule:** If `target depth >= origin depth`, the narrowing has zero loss
673/// because the target can represent everything the original data contained.
674/// The effort cost is always based on the *current* depth conversion work.
675fn depth_cost(from: ChannelType, to: ChannelType, origin_depth: ChannelType) -> ConversionCost {
676    if from == to {
677        return ConversionCost::ZERO;
678    }
679
680    let effort = depth_effort(from, to);
681    let loss = depth_loss(to, origin_depth);
682
683    ConversionCost::new(effort, loss)
684}
685
686/// Computational effort for a depth conversion (independent of provenance).
687#[allow(unreachable_patterns)] // non_exhaustive: future variants
688fn depth_effort(from: ChannelType, to: ChannelType) -> u16 {
689    match (from, to) {
690        // Integer widen/narrow
691        (ChannelType::U8, ChannelType::U16) | (ChannelType::U16, ChannelType::U8) => 10,
692        // Float ↔ integer
693        (ChannelType::U16, ChannelType::F32) | (ChannelType::F32, ChannelType::U16) => 25,
694        (ChannelType::U8, ChannelType::F32) | (ChannelType::F32, ChannelType::U8) => 40,
695        // F16 ↔ F32 (hardware or fast table conversion)
696        (ChannelType::F16, ChannelType::F32) | (ChannelType::F32, ChannelType::F16) => 15,
697        // F16 ↔ integer (via F32 intermediate)
698        (ChannelType::F16, ChannelType::U8) | (ChannelType::U8, ChannelType::F16) => 30,
699        (ChannelType::F16, ChannelType::U16) | (ChannelType::U16, ChannelType::F16) => 25,
700        // remaining catch-all handles unknown future types
701        _ => 100,
702    }
703}
704
705/// Information loss when converting to `target_depth`, given the data's
706/// `origin_depth`. If the target can represent the origin precision,
707/// loss is zero.
708#[allow(unreachable_patterns)] // non_exhaustive: future variants
709fn depth_loss(target_depth: ChannelType, origin_depth: ChannelType) -> u16 {
710    let target_bits = channel_bits(target_depth);
711    let origin_bits = channel_bits(origin_depth);
712
713    if target_bits >= origin_bits {
714        // Target can hold everything the origin had — no loss.
715        return 0;
716    }
717
718    // Target has less precision than the origin — lossy.
719    //
720    // Calibrated from CIEDE2000 measurements (perceptual_loss.rs).
721    // In sRGB space, quantization to 8 bits produces ΔE < 0.5 (below JND)
722    // because sRGB OETF provides perceptually uniform quantization.
723    // The suitability_loss function handles the separate concern of
724    // operating in a lower-precision format (gamma darkening in u8, etc).
725    match (origin_depth, target_depth) {
726        (ChannelType::U16, ChannelType::U8) => 10, // measured ΔE=0.14, sub-JND
727        (ChannelType::F32, ChannelType::U8) => 10, // measured ΔE=0.14, sub-JND in sRGB
728        (ChannelType::F32, ChannelType::U16) => 5, // 23→16 mantissa bits, negligible
729        (ChannelType::F32, ChannelType::F16) => 20, // 23→10 mantissa bits, small loss
730        (ChannelType::F16, ChannelType::U8) => 8,  // measured ΔE=0.000 (f16 >8 bits precision)
731        (ChannelType::U16, ChannelType::F16) => 30, // 16→10 mantissa bits, moderate loss
732        _ => 50,
733    }
734}
735
736/// Nominal precision bits for a channel type (for ordering, not bit-exact).
737///
738/// F16 has 10 mantissa bits (~3.3 decimal digits) — between U8 (8 bits) and
739/// U16 (16 bits).
740#[allow(unreachable_patterns)] // non_exhaustive: future variants
741pub(crate) fn channel_bits(ct: ChannelType) -> u16 {
742    match ct {
743        ChannelType::U8 => 8,
744        ChannelType::F16 => 11, // 10 mantissa + 1 implicit
745        ChannelType::U16 => 16,
746        ChannelType::F32 => 32,
747        _ => 0,
748    }
749}
750
751/// Cost of color primaries (gamut) conversion.
752///
753/// Gamut hierarchy: BT.2020 ⊃ Display P3 ⊃ BT.709/sRGB.
754///
755/// Key principle: narrowing is only lossy if the data actually uses the
756/// wider gamut. Provenance tracks whether the data originally came from
757/// a narrower gamut (and hasn't been modified to use the wider one).
758fn primaries_cost(
759    from: ColorPrimaries,
760    to: ColorPrimaries,
761    origin: ColorPrimaries,
762) -> ConversionCost {
763    if from == to {
764        return ConversionCost::ZERO;
765    }
766
767    // Unknown ↔ anything: relabeling only, no actual math.
768    if matches!(from, ColorPrimaries::Unknown) || matches!(to, ColorPrimaries::Unknown) {
769        return ConversionCost::new(1, 0);
770    }
771
772    // Widening (e.g., sRGB → P3 → BT.2020): 3x3 matrix, lossless.
773    if to.contains(from) {
774        return ConversionCost::new(10, 0);
775    }
776
777    // Narrowing (e.g., BT.2020 → sRGB): check if origin fits in target.
778    // If the data originally came from sRGB and was placed in BT.2020
779    // without modifying colors, converting back to sRGB is near-lossless
780    // (only numerical precision of the 3x3 matrix round-trip).
781    if to.contains(origin) {
782        return ConversionCost::new(10, 5);
783    }
784
785    // True gamut clipping: data uses the wider gamut and target is narrower.
786    // Loss depends on how much wider the source is.
787    match (from, to) {
788        (ColorPrimaries::DisplayP3, ColorPrimaries::Bt709) => ConversionCost::new(15, 80),
789        (ColorPrimaries::Bt2020, ColorPrimaries::DisplayP3) => ConversionCost::new(15, 100),
790        (ColorPrimaries::Bt2020, ColorPrimaries::Bt709) => ConversionCost::new(15, 200),
791        _ => ConversionCost::new(15, 150),
792    }
793}
794
795/// Cost of layout conversion.
796fn layout_cost(from: ChannelLayout, to: ChannelLayout) -> ConversionCost {
797    if from == to {
798        return ConversionCost::ZERO;
799    }
800    match (from, to) {
801        // Swizzle: cheap, lossless.
802        (ChannelLayout::Bgra, ChannelLayout::Rgba) | (ChannelLayout::Rgba, ChannelLayout::Bgra) => {
803            ConversionCost::new(5, 0)
804        }
805
806        // Add alpha: cheap, lossless (fill opaque).
807        (ChannelLayout::Rgb, ChannelLayout::Rgba) | (ChannelLayout::Rgb, ChannelLayout::Bgra) => {
808            ConversionCost::new(10, 0)
809        }
810
811        // Drop alpha: cheap but lossy (alpha channel destroyed).
812        (ChannelLayout::Rgba, ChannelLayout::Rgb) | (ChannelLayout::Bgra, ChannelLayout::Rgb) => {
813            ConversionCost::new(15, 50)
814        }
815
816        // Gray → RGB: replicate, lossless.
817        (ChannelLayout::Gray, ChannelLayout::Rgb) => ConversionCost::new(8, 0),
818        (ChannelLayout::Gray, ChannelLayout::Rgba) | (ChannelLayout::Gray, ChannelLayout::Bgra) => {
819            ConversionCost::new(10, 0)
820        }
821
822        // Color → Gray: luma calculation, very lossy (color info destroyed).
823        (ChannelLayout::Rgb, ChannelLayout::Gray)
824        | (ChannelLayout::Rgba, ChannelLayout::Gray)
825        | (ChannelLayout::Bgra, ChannelLayout::Gray) => ConversionCost::new(30, 500),
826
827        // GrayAlpha → RGBA: replicate gray, lossless.
828        (ChannelLayout::GrayAlpha, ChannelLayout::Rgba)
829        | (ChannelLayout::GrayAlpha, ChannelLayout::Bgra) => ConversionCost::new(15, 0),
830
831        // RGBA → GrayAlpha: luma + drop color, very lossy.
832        (ChannelLayout::Rgba, ChannelLayout::GrayAlpha)
833        | (ChannelLayout::Bgra, ChannelLayout::GrayAlpha) => ConversionCost::new(30, 500),
834
835        // Gray ↔ GrayAlpha.
836        (ChannelLayout::Gray, ChannelLayout::GrayAlpha) => ConversionCost::new(8, 0),
837        (ChannelLayout::GrayAlpha, ChannelLayout::Gray) => ConversionCost::new(10, 50),
838
839        // GrayAlpha → Rgb: replicate + drop alpha.
840        (ChannelLayout::GrayAlpha, ChannelLayout::Rgb) => ConversionCost::new(12, 50),
841
842        // RGB ↔ Oklab: matrix + cube root, lossless at f32.
843        (ChannelLayout::Rgb, ChannelLayout::Oklab) | (ChannelLayout::Oklab, ChannelLayout::Rgb) => {
844            ConversionCost::new(80, 0)
845        }
846        (ChannelLayout::Rgba, ChannelLayout::OklabA)
847        | (ChannelLayout::OklabA, ChannelLayout::Rgba) => ConversionCost::new(80, 0),
848
849        // Oklab ↔ alpha variants.
850        (ChannelLayout::Oklab, ChannelLayout::OklabA) => ConversionCost::new(10, 0),
851        (ChannelLayout::OklabA, ChannelLayout::Oklab) => ConversionCost::new(15, 50),
852
853        // Cross-model with alpha changes.
854        (ChannelLayout::Rgb, ChannelLayout::OklabA) => ConversionCost::new(90, 0),
855        (ChannelLayout::OklabA, ChannelLayout::Rgb) => ConversionCost::new(90, 50),
856        (ChannelLayout::Oklab, ChannelLayout::Rgba) => ConversionCost::new(90, 0),
857        (ChannelLayout::Rgba, ChannelLayout::Oklab) => ConversionCost::new(90, 50),
858
859        _ => ConversionCost::new(100, 500),
860    }
861}
862
863/// Cost of alpha mode conversion.
864fn alpha_cost(
865    from_alpha: Option<AlphaMode>,
866    to_alpha: Option<AlphaMode>,
867    from_layout: ChannelLayout,
868    to_layout: ChannelLayout,
869) -> ConversionCost {
870    if !to_layout.has_alpha() || !from_layout.has_alpha() || from_alpha == to_alpha {
871        return ConversionCost::ZERO;
872    }
873    match (from_alpha, to_alpha) {
874        // Straight → Premul: per-pixel multiply, tiny rounding loss.
875        (Some(AlphaMode::Straight), Some(AlphaMode::Premultiplied)) => ConversionCost::new(20, 5),
876        // Premul → Straight: per-pixel divide, worse rounding at low alpha.
877        (Some(AlphaMode::Premultiplied), Some(AlphaMode::Straight)) => ConversionCost::new(25, 10),
878        _ => ConversionCost::ZERO,
879    }
880}
881
882// ---------------------------------------------------------------------------
883// Precision tier (used by ideal_format only)
884// ---------------------------------------------------------------------------
885
886#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
887enum PrecisionTier {
888    Sdr8 = 0,
889    Sdr16 = 1,
890    LinearF32 = 2,
891    Hdr = 3,
892}
893
894#[allow(unreachable_patterns)] // non_exhaustive: future variants
895fn precision_tier(desc: PixelDescriptor) -> PrecisionTier {
896    if matches!(
897        desc.transfer(),
898        TransferFunction::Pq | TransferFunction::Hlg
899    ) {
900        return PrecisionTier::Hdr;
901    }
902    match desc.channel_type() {
903        ChannelType::U8 => PrecisionTier::Sdr8,
904        ChannelType::U16 | ChannelType::F16 => PrecisionTier::Sdr16,
905        ChannelType::F32 => PrecisionTier::LinearF32,
906        _ => PrecisionTier::Sdr8,
907    }
908}
909
910// ---------------------------------------------------------------------------
911// Tests
912// ---------------------------------------------------------------------------
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917
918    #[test]
919    fn exact_match_wins() {
920        let src = PixelDescriptor::RGB8_SRGB;
921        let supported = &[PixelDescriptor::RGBA8_SRGB, PixelDescriptor::RGB8_SRGB];
922        assert_eq!(
923            best_match(src, supported, ConvertIntent::Fastest),
924            Some(PixelDescriptor::RGB8_SRGB)
925        );
926    }
927
928    #[test]
929    fn empty_list_returns_none() {
930        let src = PixelDescriptor::RGB8_SRGB;
931        assert_eq!(best_match(src, &[], ConvertIntent::Fastest), None);
932    }
933
934    #[test]
935    fn prefers_same_depth_over_cross_depth() {
936        let src = PixelDescriptor::RGB8_SRGB;
937        let supported = &[PixelDescriptor::RGBF32_LINEAR, PixelDescriptor::RGBA8_SRGB];
938        assert_eq!(
939            best_match(src, supported, ConvertIntent::Fastest),
940            Some(PixelDescriptor::RGBA8_SRGB)
941        );
942    }
943
944    #[test]
945    fn bgra_rgba_swizzle_is_cheap() {
946        let src = PixelDescriptor::BGRA8_SRGB;
947        let supported = &[PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBA8_SRGB];
948        assert_eq!(
949            best_match(src, supported, ConvertIntent::Fastest),
950            Some(PixelDescriptor::RGBA8_SRGB)
951        );
952    }
953
954    #[test]
955    fn gray_to_rgb_preferred_over_rgba() {
956        let src = PixelDescriptor::GRAY8_SRGB;
957        let supported = &[PixelDescriptor::RGBA8_SRGB, PixelDescriptor::RGB8_SRGB];
958        assert_eq!(
959            best_match(src, supported, ConvertIntent::Fastest),
960            Some(PixelDescriptor::RGB8_SRGB)
961        );
962    }
963
964    #[test]
965    fn transfer_only_diff_is_cheap() {
966        let src = PixelDescriptor::new(
967            ChannelType::U8,
968            ChannelLayout::Rgb,
969            None,
970            TransferFunction::Unknown,
971        );
972        let target = PixelDescriptor::RGB8_SRGB;
973        let supported = &[target, PixelDescriptor::RGBF32_LINEAR];
974        assert_eq!(
975            best_match(src, supported, ConvertIntent::Fastest),
976            Some(target)
977        );
978    }
979
980    // Two-axis cost tests.
981
982    #[test]
983    fn conversion_cost_identity_is_zero() {
984        let cost = conversion_cost(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGB8_SRGB);
985        assert_eq!(cost, ConversionCost::ZERO);
986    }
987
988    #[test]
989    fn widening_has_zero_loss() {
990        let cost = conversion_cost(PixelDescriptor::RGB8_SRGB, PixelDescriptor::RGBF32_LINEAR);
991        assert_eq!(cost.loss, 0);
992        assert!(cost.effort > 0);
993    }
994
995    #[test]
996    fn narrowing_has_nonzero_loss() {
997        let cost = conversion_cost(PixelDescriptor::RGBF32_LINEAR, PixelDescriptor::RGB8_SRGB);
998        assert!(cost.loss > 0, "f32→u8 should report data loss");
999        assert!(cost.effort > 0);
1000    }
1001
1002    #[test]
1003    fn consumer_override_shifts_preference() {
1004        // Source is f32 Linear. Without consumer cost, we'd need to convert to u8.
1005        // With a consumer that accepts f32 cheaply, we skip our conversion.
1006        let src = PixelDescriptor::RGBF32_LINEAR;
1007        let options = &[
1008            FormatOption::from(PixelDescriptor::RGB8_SRGB),
1009            FormatOption::with_cost(PixelDescriptor::RGBF32_LINEAR, ConversionCost::new(5, 0)),
1010        ];
1011        // Even Fastest should prefer the zero-conversion path with low consumer cost.
1012        assert_eq!(
1013            best_match_with(src, options, ConvertIntent::Fastest),
1014            Some(PixelDescriptor::RGBF32_LINEAR)
1015        );
1016    }
1017}