jpegli/encode/
encoder_types.rs

1//! Core types for the v2 encoder API.
2
3/// Quality/compression setting.
4///
5/// All variants map to internal quality through empirical lookup tables.
6/// Results vary by image - these are rough approximations, not guarantees.
7#[derive(Clone, Copy, Debug, PartialEq)]
8#[non_exhaustive]
9pub enum Quality {
10    /// Approximate jpegli quality scale (this is a fork, not exact jpegli).
11    /// Range: 0.0–100.0, where ~90 is visually lossless for most images.
12    ApproxJpegli(f32),
13
14    /// Approximate mozjpeg quality behavior.
15    /// Range: 0–100. Maps to quality producing similar file sizes.
16    ApproxMozjpeg(u8),
17
18    /// Approximate SSIMULACRA2 score target.
19    /// Range: 0–100 (higher = better). 90+ is roughly visually lossless.
20    ApproxSsim2(f32),
21
22    /// Approximate Butteraugli distance target.
23    /// Range: 0.0+ (lower = better). <1.0 excellent, <3.0 good.
24    ApproxButteraugli(f32),
25}
26
27impl Default for Quality {
28    fn default() -> Self {
29        Quality::ApproxJpegli(90.0)
30    }
31}
32
33impl From<f32> for Quality {
34    fn from(q: f32) -> Self {
35        Quality::ApproxJpegli(q)
36    }
37}
38
39impl From<u8> for Quality {
40    fn from(q: u8) -> Self {
41        Quality::ApproxJpegli(q as f32)
42    }
43}
44
45impl From<i32> for Quality {
46    fn from(q: i32) -> Self {
47        Quality::ApproxJpegli(q as f32)
48    }
49}
50
51impl Quality {
52    /// Convert to internal quality value (0.0-100.0 scale).
53    #[must_use]
54    pub fn to_internal(&self) -> f32 {
55        match self {
56            Quality::ApproxJpegli(q) => *q,
57            Quality::ApproxMozjpeg(q) => mozjpeg_to_internal(*q),
58            Quality::ApproxSsim2(score) => ssim2_to_internal(*score),
59            Quality::ApproxButteraugli(dist) => butteraugli_to_internal(*dist),
60        }
61    }
62
63    /// Create quality from a 0-100 scale value.
64    ///
65    /// This is equivalent to `Quality::ApproxJpegli(q)` or `Quality::from(q)`.
66    #[deprecated(
67        since = "0.5.0",
68        note = "Use Quality::from(f32) or Quality::ApproxJpegli(f32) instead"
69    )]
70    #[must_use]
71    pub fn from_quality(q: f32) -> Self {
72        Quality::ApproxJpegli(q)
73    }
74
75    /// Create quality from butteraugli distance (backward compatibility).
76    ///
77    /// This is equivalent to `Quality::ApproxButteraugli(d)`.
78    #[deprecated(since = "0.5.0", note = "Use Quality::ApproxButteraugli(f32) instead")]
79    #[must_use]
80    pub fn from_distance(d: f32) -> Self {
81        Quality::ApproxButteraugli(d)
82    }
83
84    /// Backward-compatible constructor matching old `Quality::Traditional(f32)`.
85    #[deprecated(since = "0.5.0", note = "Use Quality::ApproxJpegli(f32) instead")]
86    #[must_use]
87    #[allow(non_snake_case)] // Mimics old enum variant naming
88    pub fn Traditional(q: f32) -> Self {
89        Quality::ApproxJpegli(q)
90    }
91
92    /// Convert to butteraugli distance.
93    ///
94    /// Uses the exact same formula as C++ jpegli's `jpegli_quality_to_distance`.
95    #[must_use]
96    pub fn to_distance(&self) -> f32 {
97        // If already butteraugli distance, return it directly
98        if let Quality::ApproxButteraugli(d) = self {
99            return *d;
100        }
101        // Exact C++ jpegli formula from lib/jpegli/encode.cc:jpegli_quality_to_distance
102        let q = self.to_internal();
103        if q >= 100.0 {
104            0.01
105        } else if q >= 30.0 {
106            0.1 + (100.0 - q) * 0.09
107        } else {
108            // Quadratic for very low quality
109            53.0 / 3000.0 * q * q - 23.0 / 20.0 * q + 25.0
110        }
111    }
112}
113
114// Calibrated mozjpeg→jpegli quality mapping (4:4:4, DSSIM metric)
115// From corpus testing on CID22-512 and Kodak datasets
116const MOZJPEG_TO_JPEGLI: [(u8, u8); 10] = [
117    (30, 28),
118    (40, 37),
119    (50, 47),
120    (60, 55),
121    (70, 65),
122    (75, 71),
123    (80, 77),
124    (85, 83),
125    (90, 89),
126    (95, 94),
127];
128
129fn mozjpeg_to_internal(q: u8) -> f32 {
130    if q >= 100 {
131        return 100.0;
132    }
133    if q <= 30 {
134        // Extrapolate below table range
135        return (q as f32 / 30.0) * 28.0;
136    }
137
138    // Find bracketing entries and interpolate
139    let mut lower = (30u8, 28u8);
140    let mut upper = (95u8, 94u8);
141
142    for &(moz_q, jpegli_q) in &MOZJPEG_TO_JPEGLI {
143        if moz_q <= q && moz_q > lower.0 {
144            lower = (moz_q, jpegli_q);
145        }
146        if moz_q >= q && moz_q < upper.0 {
147            upper = (moz_q, jpegli_q);
148        }
149    }
150
151    if lower.0 == upper.0 {
152        return lower.1 as f32;
153    }
154
155    // Linear interpolation
156    let t = (q - lower.0) as f32 / (upper.0 - lower.0) as f32;
157    lower.1 as f32 + t * (upper.1 as f32 - lower.1 as f32)
158}
159
160// Calibrated SSIMULACRA2→jpegli quality mapping (4:4:4)
161// SSIM2 scores: higher is better, 100 = identical
162const SSIM2_TO_JPEGLI: [(u8, u8); 8] = [
163    (70, 55), // Low quality
164    (75, 65),
165    (80, 73),
166    (85, 80),
167    (88, 85),
168    (90, 88),
169    (93, 92),
170    (95, 95),
171];
172
173fn ssim2_to_internal(score: f32) -> f32 {
174    if score >= 100.0 {
175        return 100.0;
176    }
177    if score <= 70.0 {
178        return (score / 70.0) * 55.0;
179    }
180
181    let q = score as u8;
182    let mut lower = (70u8, 55u8);
183    let mut upper = (95u8, 95u8);
184
185    for &(ssim_score, jpegli_q) in &SSIM2_TO_JPEGLI {
186        if ssim_score <= q && ssim_score > lower.0 {
187            lower = (ssim_score, jpegli_q);
188        }
189        if ssim_score >= q && ssim_score < upper.0 {
190            upper = (ssim_score, jpegli_q);
191        }
192    }
193
194    if lower.0 == upper.0 {
195        return lower.1 as f32;
196    }
197
198    let t = (score - lower.0 as f32) / (upper.0 - lower.0) as f32;
199    lower.1 as f32 + t * (upper.1 as f32 - lower.1 as f32)
200}
201
202// Calibrated butteraugli→jpegli quality mapping
203// Butteraugli: lower is better, 0 = identical, <1 excellent, <3 good
204const BUTTERAUGLI_TO_JPEGLI: [(f32, f32); 7] = [
205    (0.3, 96.0),
206    (0.5, 93.0),
207    (1.0, 88.0),
208    (1.5, 82.0),
209    (2.0, 76.0),
210    (3.0, 68.0),
211    (5.0, 55.0),
212];
213
214fn butteraugli_to_internal(dist: f32) -> f32 {
215    if dist <= 0.0 {
216        return 100.0;
217    }
218    if dist <= 0.3 {
219        return 96.0 + (0.3 - dist) / 0.3 * 4.0;
220    }
221    if dist >= 5.0 {
222        return 55.0 - (dist - 5.0) * 3.0;
223    }
224
225    let mut lower = (0.3f32, 96.0f32);
226    let mut upper = (5.0f32, 55.0f32);
227
228    for &(ba_dist, jpegli_q) in &BUTTERAUGLI_TO_JPEGLI {
229        if ba_dist <= dist && ba_dist > lower.0 {
230            lower = (ba_dist, jpegli_q);
231        }
232        if ba_dist >= dist && ba_dist < upper.0 {
233            upper = (ba_dist, jpegli_q);
234        }
235    }
236
237    if (lower.0 - upper.0).abs() < 0.001 {
238        return lower.1;
239    }
240
241    let t = (dist - lower.0) / (upper.0 - lower.0);
242    lower.1 + t * (upper.1 - lower.1)
243}
244
245/// Quantization table configuration.
246#[derive(Clone, Debug)]
247#[non_exhaustive]
248#[derive(Default)]
249#[allow(clippy::large_enum_variant)] // Custom matrices are rarely used
250pub enum QuantTableConfig {
251    /// Jpegli's perceptual tables, scaled by Quality. (default)
252    #[default]
253    Perceptual,
254
255    /// Custom base matrices, scaled by Quality.
256    /// Provide f32 matrices (typically 1.0–255.0 range).
257    /// These are multiplied by the quality-derived scale factor.
258    CustomBase {
259        /// Luma (Y) base matrix - 64 coefficients in row-major order
260        luma: [f32; 64],
261        /// Blue chroma (Cb) base matrix - 64 coefficients in row-major order
262        cb: [f32; 64],
263        /// Red chroma (Cr) base matrix - 64 coefficients in row-major order
264        cr: [f32; 64],
265    },
266
267    /// Exact quantization tables. **Quality is ignored.**
268    /// Values should be in range 1-255 for baseline JPEG compatibility.
269    Exact {
270        /// Luma (Y) quantization table - 64 coefficients in row-major order
271        luma: [u16; 64],
272        /// Blue chroma (Cb) quantization table - 64 coefficients in row-major order
273        cb: [u16; 64],
274        /// Red chroma (Cr) quantization table - 64 coefficients in row-major order
275        cr: [u16; 64],
276    },
277}
278
279/// Zero-bias configuration for quantization.
280///
281/// Zero-bias controls how coefficients are rounded toward zero during quantization.
282/// Higher multipliers mean more aggressive zeroing of small coefficients, which
283/// improves compression but may reduce quality.
284///
285/// The jpegli defaults are perceptually optimized and vary by:
286/// - Quality level (blends between HQ and LQ tables based on butteraugli distance)
287/// - Component (Y, Cb, Cr have different optimal bias values)
288/// - Coefficient position (different DCT frequencies have different biases)
289#[derive(Clone, Debug, Default)]
290#[non_exhaustive]
291#[allow(clippy::large_enum_variant)] // Custom tables are rarely used
292pub enum ZeroBiasConfig {
293    /// Use jpegli's perceptual zero-bias tables (default).
294    /// These are quality-adaptive: they blend between HQ and LQ tables
295    /// based on the effective butteraugli distance.
296    #[default]
297    Perceptual,
298
299    /// Disable zero-bias (all multipliers = 0, all offsets = 0).
300    /// This matches standard JPEG behavior without adaptive quantization.
301    Disabled,
302
303    /// Custom zero-bias tables.
304    /// Each component has 64 multiplier values and 64 offset values.
305    Custom {
306        /// Luma (Y) zero-bias: (multipliers[64], offsets[64])
307        luma: ([f32; 64], [f32; 64]),
308        /// Blue chroma (Cb) zero-bias: (multipliers[64], offsets[64])
309        cb: ([f32; 64], [f32; 64]),
310        /// Red chroma (Cr) zero-bias: (multipliers[64], offsets[64])
311        cr: ([f32; 64], [f32; 64]),
312    },
313}
314
315impl QuantTableConfig {
316    /// Convert to internal CustomQuantMatrices format.
317    ///
318    /// Returns `None` for `Perceptual` (use defaults), or `Some` with the
319    /// appropriate internal representation.
320    #[must_use]
321    pub(crate) fn to_custom_matrices(&self) -> Option<crate::quant::CustomQuantMatrices> {
322        use crate::quant::CustomQuantMatrices;
323
324        match self {
325            QuantTableConfig::Perceptual => None,
326            QuantTableConfig::CustomBase { luma, cb, cr } => {
327                // Pack into 192-element array: Y[64], Cb[64], Cr[64]
328                let mut matrix = [0.0f32; 192];
329                matrix[..64].copy_from_slice(luma);
330                matrix[64..128].copy_from_slice(cb);
331                matrix[128..192].copy_from_slice(cr);
332                Some(CustomQuantMatrices::new().with_ycbcr(matrix))
333            }
334            QuantTableConfig::Exact { luma, cb, cr } => {
335                // Pack into 192-element array: Y[64], Cb[64], Cr[64]
336                let mut tables = [0u16; 192];
337                tables[..64].copy_from_slice(luma);
338                tables[64..128].copy_from_slice(cb);
339                tables[128..192].copy_from_slice(cr);
340                Some(CustomQuantMatrices::new().with_direct_tables(tables))
341            }
342        }
343    }
344}
345
346impl ZeroBiasConfig {
347    /// Convert to internal ZeroBiasParams for a specific component.
348    ///
349    /// # Arguments
350    /// * `component` - 0 for Y, 1 for Cb, 2 for Cr
351    /// * `distance` - Butteraugli distance (only used for `Perceptual` mode)
352    #[must_use]
353    pub(crate) fn to_zero_bias_params(
354        &self,
355        component: usize,
356        distance: f32,
357    ) -> crate::quant::ZeroBiasParams {
358        use crate::quant::ZeroBiasParams;
359
360        match self {
361            ZeroBiasConfig::Perceptual => ZeroBiasParams::for_ycbcr(distance, component),
362            ZeroBiasConfig::Disabled => ZeroBiasParams::default(),
363            ZeroBiasConfig::Custom { luma, cb, cr } => {
364                let (mul, offset) = match component {
365                    0 => luma,
366                    1 => cb,
367                    _ => cr,
368                };
369                ZeroBiasParams {
370                    mul: *mul,
371                    offset: *offset,
372                }
373            }
374        }
375    }
376}
377
378/// Output color space with bundled subsampling options.
379#[derive(Clone, Copy, Debug, PartialEq, Eq)]
380#[non_exhaustive]
381pub enum ColorMode {
382    /// Standard YCbCr with configurable chroma subsampling.
383    YCbCr { subsampling: ChromaSubsampling },
384
385    /// XYB perceptual color space (jpegli-specific).
386    /// Computed internally from linear RGB input.
387    Xyb { subsampling: XybSubsampling },
388
389    /// Single-channel grayscale.
390    Grayscale,
391}
392
393impl Default for ColorMode {
394    fn default() -> Self {
395        ColorMode::YCbCr {
396            subsampling: ChromaSubsampling::None, // 4:4:4 - no subsampling
397        }
398    }
399}
400
401/// YCbCr chroma subsampling (spatial resolution).
402#[derive(Clone, Copy, Debug, PartialEq, Eq)]
403#[non_exhaustive]
404pub enum ChromaSubsampling {
405    /// 4:4:4 - No subsampling (full chroma resolution, best quality, largest files)
406    None,
407    /// 4:2:2 - Half horizontal resolution
408    HalfHorizontal,
409    /// 4:2:0 - Quarter resolution (half each direction, most common)
410    Quarter,
411    /// 4:4:0 - Half vertical resolution
412    HalfVertical,
413}
414
415impl ChromaSubsampling {
416    /// Convert to the legacy Subsampling enum.
417    #[must_use]
418    pub fn to_legacy(&self) -> crate::types::Subsampling {
419        match self {
420            ChromaSubsampling::None => crate::types::Subsampling::S444,
421            ChromaSubsampling::HalfHorizontal => crate::types::Subsampling::S422,
422            ChromaSubsampling::Quarter => crate::types::Subsampling::S420,
423            ChromaSubsampling::HalfVertical => crate::types::Subsampling::S440,
424        }
425    }
426
427    /// Horizontal subsampling factor (1 or 2).
428    #[must_use]
429    pub const fn h_factor(&self) -> u8 {
430        match self {
431            ChromaSubsampling::None | ChromaSubsampling::HalfVertical => 1,
432            ChromaSubsampling::HalfHorizontal | ChromaSubsampling::Quarter => 2,
433        }
434    }
435
436    /// Vertical subsampling factor (1 or 2).
437    #[must_use]
438    pub const fn v_factor(&self) -> u8 {
439        match self {
440            ChromaSubsampling::None | ChromaSubsampling::HalfHorizontal => 1,
441            ChromaSubsampling::HalfVertical | ChromaSubsampling::Quarter => 2,
442        }
443    }
444}
445
446/// XYB component subsampling.
447///
448/// Unlike YCbCr where only luma is full, XYB keeps X and Y full
449/// even in subsampled mode - only B is reduced.
450#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
451#[non_exhaustive]
452pub enum XybSubsampling {
453    /// X, Y, B all at full resolution (1x1, 1x1, 1x1)
454    Full,
455    /// X, Y full, B at quarter resolution (1x1, 1x1, 2x2)
456    #[default]
457    BQuarter,
458}
459
460/// Chroma downsampling algorithm for RGB->YCbCr conversion.
461///
462/// **Only applies to RGB/RGBX input.** Ignored for grayscale, YCbCr, and planar input.
463#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
464#[non_exhaustive]
465pub enum DownsamplingMethod {
466    /// Simple box filter averaging (fast, matches C++ jpegli default)
467    #[default]
468    Box,
469    /// Gamma-aware averaging (better color accuracy at edges)
470    GammaAware,
471    /// Iterative optimization (SharpYUV-style, best quality, ~3x slower)
472    GammaAwareIterative,
473}
474
475impl DownsamplingMethod {
476    /// Convert to the legacy ChromaDownsampling enum.
477    #[must_use]
478    pub fn to_legacy(&self) -> crate::types::ChromaDownsampling {
479        match self {
480            DownsamplingMethod::Box => crate::types::ChromaDownsampling::Box,
481            DownsamplingMethod::GammaAware => crate::types::ChromaDownsampling::GammaAware,
482            DownsamplingMethod::GammaAwareIterative => {
483                crate::types::ChromaDownsampling::GammaAwareIterative
484            }
485        }
486    }
487}
488
489/// Pixel data layout for raw byte input.
490///
491/// Describes channel order, bit depth, and color space interpretation.
492/// Use with `encode_from_bytes()` when working with raw buffers.
493///
494/// For rgb crate types, use `encode_from_rgb()` which infers layout.
495#[derive(Clone, Copy, Debug, PartialEq, Eq)]
496#[non_exhaustive]
497pub enum PixelLayout {
498    // === 8-bit sRGB (gamma-encoded) ===
499    /// RGB, 3 bytes/pixel, sRGB gamma
500    Rgb8Srgb,
501    /// BGR, 3 bytes/pixel, sRGB gamma (Windows/GDI order)
502    Bgr8Srgb,
503    /// RGBX, 4 bytes/pixel, sRGB gamma (4th byte ignored)
504    Rgbx8Srgb,
505    /// BGRX, 4 bytes/pixel, sRGB gamma (4th byte ignored)
506    Bgrx8Srgb,
507    /// Grayscale, 1 byte/pixel, sRGB gamma
508    Gray8Srgb,
509
510    // === 16-bit linear ===
511    /// RGB, 6 bytes/pixel, linear light (0-65535)
512    Rgb16Linear,
513    /// RGBX, 8 bytes/pixel, linear light (4th channel ignored)
514    Rgbx16Linear,
515    /// Grayscale, 2 bytes/pixel, linear light
516    Gray16Linear,
517
518    // === 32-bit float linear ===
519    /// RGB, 12 bytes/pixel, linear light (0.0-1.0)
520    RgbF32Linear,
521    /// RGBX, 16 bytes/pixel, linear light (4th channel ignored)
522    RgbxF32Linear,
523    /// Grayscale, 4 bytes/pixel, linear light
524    GrayF32Linear,
525
526    // === Pre-converted YCbCr (skip RGB->YCbCr conversion) ===
527    /// YCbCr interleaved, 3 bytes/pixel, u8
528    YCbCr8,
529    /// YCbCr interleaved, 12 bytes/pixel, f32
530    YCbCrF32,
531}
532
533impl PixelLayout {
534    /// Bytes per pixel for this layout.
535    #[must_use]
536    pub const fn bytes_per_pixel(&self) -> usize {
537        match self {
538            Self::Gray8Srgb => 1,
539            Self::Gray16Linear => 2,
540            Self::Rgb8Srgb | Self::Bgr8Srgb | Self::YCbCr8 => 3,
541            Self::Rgbx8Srgb | Self::Bgrx8Srgb | Self::GrayF32Linear => 4,
542            Self::Rgb16Linear => 6,
543            Self::Rgbx16Linear => 8,
544            Self::RgbF32Linear | Self::YCbCrF32 => 12,
545            Self::RgbxF32Linear => 16,
546        }
547    }
548
549    /// Number of channels (including ignored channels).
550    #[must_use]
551    pub const fn channels(&self) -> usize {
552        match self {
553            Self::Gray8Srgb | Self::Gray16Linear | Self::GrayF32Linear => 1,
554            Self::Rgb8Srgb
555            | Self::Bgr8Srgb
556            | Self::Rgb16Linear
557            | Self::RgbF32Linear
558            | Self::YCbCr8
559            | Self::YCbCrF32 => 3,
560            Self::Rgbx8Srgb | Self::Bgrx8Srgb | Self::Rgbx16Linear | Self::RgbxF32Linear => 4,
561        }
562    }
563
564    /// Whether this is a grayscale format.
565    #[must_use]
566    pub const fn is_grayscale(&self) -> bool {
567        matches!(
568            self,
569            Self::Gray8Srgb | Self::Gray16Linear | Self::GrayF32Linear
570        )
571    }
572
573    /// Whether this is pre-converted YCbCr.
574    #[must_use]
575    pub const fn is_ycbcr(&self) -> bool {
576        matches!(self, Self::YCbCr8 | Self::YCbCrF32)
577    }
578
579    /// Whether this uses BGR channel order.
580    #[must_use]
581    pub const fn is_bgr(&self) -> bool {
582        matches!(self, Self::Bgr8Srgb | Self::Bgrx8Srgb)
583    }
584
585    /// Whether this is a float format (linear color space).
586    #[must_use]
587    pub const fn is_float(&self) -> bool {
588        matches!(
589            self,
590            Self::RgbF32Linear | Self::RgbxF32Linear | Self::GrayF32Linear | Self::YCbCrF32
591        )
592    }
593
594    /// Whether this is a 16-bit format (linear color space).
595    #[must_use]
596    pub const fn is_16bit(&self) -> bool {
597        matches!(
598            self,
599            Self::Rgb16Linear | Self::Rgbx16Linear | Self::Gray16Linear
600        )
601    }
602
603    /// Convert to legacy PixelFormat (best effort).
604    #[must_use]
605    pub fn to_legacy(&self) -> crate::types::PixelFormat {
606        match self {
607            Self::Rgb8Srgb => crate::types::PixelFormat::Rgb,
608            Self::Bgr8Srgb => crate::types::PixelFormat::Bgr,
609            Self::Rgbx8Srgb => crate::types::PixelFormat::Rgba,
610            Self::Bgrx8Srgb => crate::types::PixelFormat::Bgrx,
611            Self::Gray8Srgb => crate::types::PixelFormat::Gray,
612            Self::Rgb16Linear => crate::types::PixelFormat::Rgb16,
613            Self::Rgbx16Linear => crate::types::PixelFormat::Rgba16,
614            Self::Gray16Linear => crate::types::PixelFormat::Gray16,
615            Self::RgbF32Linear => crate::types::PixelFormat::RgbF32,
616            Self::RgbxF32Linear => crate::types::PixelFormat::RgbaF32,
617            Self::GrayF32Linear => crate::types::PixelFormat::GrayF32,
618            // YCbCr layouts don't have direct legacy equivalents
619            Self::YCbCr8 | Self::YCbCrF32 => crate::types::PixelFormat::Rgb,
620        }
621    }
622}
623
624/// Planar YCbCr data for a strip of rows.
625///
626/// Each plane has its own stride. All planes are f32.
627#[derive(Clone, Copy, Debug)]
628pub struct YCbCrPlanes<'a> {
629    pub y: &'a [f32],
630    pub y_stride: usize,
631    pub cb: &'a [f32],
632    pub cb_stride: usize,
633    pub cr: &'a [f32],
634    pub cr_stride: usize,
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    #[test]
642    fn test_quality_default() {
643        let q = Quality::default();
644        assert!(matches!(q, Quality::ApproxJpegli(90.0)));
645    }
646
647    #[test]
648    fn test_quality_from() {
649        let q: Quality = 85.0.into();
650        assert!(matches!(q, Quality::ApproxJpegli(85.0)));
651
652        let q: Quality = 75u8.into();
653        assert!(matches!(q, Quality::ApproxJpegli(75.0)));
654    }
655
656    #[test]
657    fn test_pixel_layout_bytes() {
658        assert_eq!(PixelLayout::Rgb8Srgb.bytes_per_pixel(), 3);
659        assert_eq!(PixelLayout::Rgbx8Srgb.bytes_per_pixel(), 4);
660        assert_eq!(PixelLayout::RgbF32Linear.bytes_per_pixel(), 12);
661        assert_eq!(PixelLayout::Gray8Srgb.bytes_per_pixel(), 1);
662    }
663
664    #[test]
665    fn test_chroma_subsampling_factors() {
666        assert_eq!(ChromaSubsampling::None.h_factor(), 1);
667        assert_eq!(ChromaSubsampling::None.v_factor(), 1);
668        assert_eq!(ChromaSubsampling::Quarter.h_factor(), 2);
669        assert_eq!(ChromaSubsampling::Quarter.v_factor(), 2);
670        assert_eq!(ChromaSubsampling::HalfHorizontal.h_factor(), 2);
671        assert_eq!(ChromaSubsampling::HalfHorizontal.v_factor(), 1);
672    }
673}
674
675// =============================================================================
676// Parallel Encoding Configuration
677// =============================================================================
678
679/// Parallel encoding strategy.
680///
681/// Controls how the encoder uses multiple threads for improved throughput.
682/// Parallel encoding uses JPEG restart markers to enable independent encoding
683/// of image segments, which are then concatenated.
684///
685/// # Restart Marker Behavior
686///
687/// Parallel encoding requires restart markers between segments. When enabled:
688/// - If `restart_interval` is 0 or too small, it will be **increased** to an
689///   optimal value based on thread count and image size
690/// - If `restart_interval` is already set to a reasonable value, it will be
691///   preserved (parallel encoding respects user-specified intervals)
692///
693/// Restart markers add ~2 bytes per interval but enable:
694/// - Parallel encoding/decoding
695/// - Error recovery in corrupted streams
696/// - Random access to image regions
697///
698/// # Performance
699///
700/// Parallel encoding is most beneficial for larger images (512x512+):
701/// - 2 threads: ~1.2-1.6x speedup
702/// - 4 threads: ~1.3-1.7x speedup
703/// - Diminishing returns beyond 4 threads for typical images
704///
705/// Small images (<256x256) may see no benefit or slight overhead.
706#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
707#[non_exhaustive]
708#[cfg(feature = "parallel")]
709pub enum ParallelEncoding {
710    /// Automatically configure parallel encoding.
711    ///
712    /// Uses available CPU cores and selects an optimal restart interval
713    /// based on image dimensions. The restart interval will be increased
714    /// if needed, but never decreased below the user-specified value.
715    #[default]
716    Auto,
717}