Skip to main content

jxl_encoder/
api.rs

1// Copyright (c) Imazen LLC and the JPEG XL Project Authors.
2// Algorithms and constants derived from libjxl (BSD-3-Clause).
3// Licensed under AGPL-3.0-or-later. Commercial licenses at https://www.imazen.io/pricing
4
5//! Three-layer public API: Config → Request → Encoder.
6//!
7//! ```rust,no_run
8//! use jxl_encoder::{LosslessConfig, LossyConfig, PixelLayout};
9//!
10//! # let pixels = vec![0u8; 800 * 600 * 3];
11//! // Simple — one line, no request visible
12//! let jxl = LossyConfig::new(1.0)
13//!     .encode(&pixels, 800, 600, PixelLayout::Rgb8)?;
14//!
15//! // Full control — request layer for metadata, limits, cancellation
16//! let jxl = LosslessConfig::new()
17//!     .encode_request(800, 600, PixelLayout::Rgb8)
18//!     .encode(&pixels)?;
19//! # Ok::<_, jxl_encoder::At<jxl_encoder::EncodeError>>(())
20//! ```
21
22pub use crate::entropy_coding::Lz77Method;
23pub use enough::{Stop, Unstoppable};
24pub use whereat::{At, ResultAtExt, at};
25
26// ── Error type ──────────────────────────────────────────────────────────────
27
28/// Encode error type.
29#[derive(Debug)]
30#[non_exhaustive]
31pub enum EncodeError {
32    /// Input validation failed (wrong buffer size, zero dimensions, etc.).
33    InvalidInput { message: String },
34    /// Config validation failed (contradictory options, out-of-range values).
35    InvalidConfig { message: String },
36    /// Pixel layout not supported for this config/mode.
37    UnsupportedPixelLayout(PixelLayout),
38    /// A configured limit was exceeded.
39    LimitExceeded { message: String },
40    /// Encoding was cancelled via [`Stop`].
41    Cancelled,
42    /// Allocation failure.
43    Oom(std::collections::TryReserveError),
44    /// I/O error.
45    #[cfg(feature = "std")]
46    Io(std::io::Error),
47    /// Internal encoder error (should not happen — file a bug).
48    Internal { message: String },
49}
50
51impl core::fmt::Display for EncodeError {
52    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
53        match self {
54            Self::InvalidInput { message } => write!(f, "invalid input: {message}"),
55            Self::InvalidConfig { message } => write!(f, "invalid config: {message}"),
56            Self::UnsupportedPixelLayout(layout) => {
57                write!(f, "unsupported pixel layout: {layout:?}")
58            }
59            Self::LimitExceeded { message } => write!(f, "limit exceeded: {message}"),
60            Self::Cancelled => write!(f, "encoding cancelled"),
61            Self::Oom(e) => write!(f, "out of memory: {e}"),
62            #[cfg(feature = "std")]
63            Self::Io(e) => write!(f, "I/O error: {e}"),
64            Self::Internal { message } => write!(f, "internal error: {message}"),
65        }
66    }
67}
68
69impl core::error::Error for EncodeError {
70    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
71        match self {
72            Self::Oom(e) => Some(e),
73            #[cfg(feature = "std")]
74            Self::Io(e) => Some(e),
75            _ => None,
76        }
77    }
78}
79
80impl From<crate::error::Error> for EncodeError {
81    fn from(e: crate::error::Error) -> Self {
82        match e {
83            crate::error::Error::InvalidImageDimensions(w, h) => Self::InvalidInput {
84                message: format!("invalid dimensions: {w}x{h}"),
85            },
86            crate::error::Error::ImageTooLarge(w, h, mw, mh) => Self::LimitExceeded {
87                message: format!("image {w}x{h} exceeds max {mw}x{mh}"),
88            },
89            crate::error::Error::DimensionOverflow {
90                width,
91                height,
92                channels,
93            } => Self::InvalidInput {
94                message: format!("dimension overflow: {width}x{height}x{channels} exceeds usize"),
95            },
96            crate::error::Error::InvalidInput(msg) => Self::InvalidInput { message: msg },
97            crate::error::Error::OutOfMemory(e) => Self::Oom(e),
98            #[cfg(feature = "std")]
99            crate::error::Error::IoError(e) => Self::Io(e),
100            crate::error::Error::Cancelled => Self::Cancelled,
101            other => Self::Internal {
102                message: format!("{other}"),
103            },
104        }
105    }
106}
107
108#[cfg(feature = "std")]
109impl From<std::io::Error> for EncodeError {
110    fn from(e: std::io::Error) -> Self {
111        Self::Io(e)
112    }
113}
114
115impl From<enough::StopReason> for EncodeError {
116    fn from(_: enough::StopReason) -> Self {
117        Self::Cancelled
118    }
119}
120
121/// Result type for encoding operations.
122///
123/// Errors carry location traces via [`whereat::At`] for lightweight
124/// production-safe error tracking without debuginfo or backtraces.
125pub type Result<T> = core::result::Result<T, At<EncodeError>>;
126
127// ── EncodeResult / EncodeStats ──────────────────────────────────────────────
128
129/// Result of an encode operation. Holds encoded data and metrics.
130///
131/// After `encode()`, `data()` returns the JXL bytes. After `encode_into()`
132/// or `encode_to()`, `data()` returns `None` (data already delivered).
133/// Use `take_data()` to move the vec out without cloning.
134#[derive(Clone, Debug)]
135pub struct EncodeResult {
136    data: Option<Vec<u8>>,
137    stats: EncodeStats,
138}
139
140impl EncodeResult {
141    /// Encoded JXL bytes (borrowing). None if data was written elsewhere.
142    pub fn data(&self) -> Option<&[u8]> {
143        self.data.as_deref()
144    }
145
146    /// Take the owned data vec, leaving None in its place.
147    pub fn take_data(&mut self) -> Option<Vec<u8>> {
148        self.data.take()
149    }
150
151    /// Encode metrics.
152    pub fn stats(&self) -> &EncodeStats {
153        &self.stats
154    }
155}
156
157/// Encode metrics collected during encoding.
158#[derive(Clone, Debug, Default)]
159#[non_exhaustive]
160pub struct EncodeStats {
161    codestream_size: usize,
162    output_size: usize,
163    mode: EncodeMode,
164    /// Index = raw strategy code (0..19), value = first-block count.
165    strategy_counts: [u32; 19],
166    gaborish: bool,
167    ans: bool,
168    butteraugli_iters: u32,
169    pixel_domain_loss: bool,
170}
171
172impl EncodeStats {
173    /// Size of the JXL codestream in bytes (before container wrapping).
174    pub fn codestream_size(&self) -> usize {
175        self.codestream_size
176    }
177
178    /// Size of the final output in bytes (after container wrapping, if any).
179    pub fn output_size(&self) -> usize {
180        self.output_size
181    }
182
183    /// Whether the encode was lossy or lossless.
184    pub fn mode(&self) -> EncodeMode {
185        self.mode
186    }
187
188    /// Per-strategy first-block counts, indexed by raw strategy code (0..19).
189    pub fn strategy_counts(&self) -> &[u32; 19] {
190        &self.strategy_counts
191    }
192
193    /// Whether gaborish pre-filtering was enabled.
194    pub fn gaborish(&self) -> bool {
195        self.gaborish
196    }
197
198    /// Whether ANS entropy coding was used.
199    pub fn ans(&self) -> bool {
200        self.ans
201    }
202
203    /// Number of butteraugli quantization loop iterations performed.
204    pub fn butteraugli_iters(&self) -> u32 {
205        self.butteraugli_iters
206    }
207
208    /// Whether pixel-domain loss was enabled.
209    pub fn pixel_domain_loss(&self) -> bool {
210        self.pixel_domain_loss
211    }
212}
213
214/// Encoding mode.
215#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
216pub enum EncodeMode {
217    /// Lossy (VarDCT) encoding.
218    #[default]
219    Lossy,
220    /// Lossless (modular) encoding.
221    Lossless,
222}
223
224// ── PixelLayout ─────────────────────────────────────────────────────────────
225
226/// Describes the pixel format of input data.
227#[derive(Clone, Copy, Debug, PartialEq, Eq)]
228#[non_exhaustive]
229pub enum PixelLayout {
230    /// 8-bit sRGB, 3 bytes per pixel (R, G, B).
231    Rgb8,
232    /// 8-bit sRGB + alpha, 4 bytes per pixel (R, G, B, A).
233    Rgba8,
234    /// 8-bit sRGB in BGR order, 3 bytes per pixel (B, G, R).
235    Bgr8,
236    /// 8-bit sRGB in BGRA order, 4 bytes per pixel (B, G, R, A).
237    Bgra8,
238    /// 8-bit grayscale, 1 byte per pixel.
239    Gray8,
240    /// 8-bit grayscale + alpha, 2 bytes per pixel.
241    GrayAlpha8,
242    /// 16-bit sRGB, 6 bytes per pixel (R, G, B) — native-endian u16.
243    Rgb16,
244    /// 16-bit sRGB + alpha, 8 bytes per pixel (R, G, B, A) — native-endian u16.
245    Rgba16,
246    /// 16-bit grayscale, 2 bytes per pixel — native-endian u16.
247    Gray16,
248    /// 16-bit grayscale + alpha, 4 bytes per pixel — native-endian u16.
249    GrayAlpha16,
250    /// Linear f32 RGB, 12 bytes per pixel. Skips sRGB→linear conversion.
251    RgbLinearF32,
252    /// Linear f32 RGBA, 16 bytes per pixel. Skips sRGB→linear conversion.
253    RgbaLinearF32,
254    /// Linear f32 grayscale, 4 bytes per pixel.
255    GrayLinearF32,
256    /// Linear f32 grayscale + alpha, 8 bytes per pixel.
257    GrayAlphaLinearF32,
258}
259
260impl PixelLayout {
261    /// Bytes per pixel for this layout.
262    pub const fn bytes_per_pixel(self) -> usize {
263        match self {
264            Self::Rgb8 | Self::Bgr8 => 3,
265            Self::Rgba8 | Self::Bgra8 => 4,
266            Self::Gray8 => 1,
267            Self::GrayAlpha8 => 2,
268            Self::Rgb16 => 6,
269            Self::Rgba16 => 8,
270            Self::Gray16 => 2,
271            Self::GrayAlpha16 => 4,
272            Self::RgbLinearF32 => 12,
273            Self::RgbaLinearF32 => 16,
274            Self::GrayLinearF32 => 4,
275            Self::GrayAlphaLinearF32 => 8,
276        }
277    }
278
279    /// Whether this layout uses linear (not gamma-encoded) values.
280    pub const fn is_linear(self) -> bool {
281        matches!(
282            self,
283            Self::RgbLinearF32
284                | Self::RgbaLinearF32
285                | Self::GrayLinearF32
286                | Self::GrayAlphaLinearF32
287        )
288    }
289
290    /// Whether this layout uses 16-bit samples.
291    pub const fn is_16bit(self) -> bool {
292        matches!(
293            self,
294            Self::Rgb16 | Self::Rgba16 | Self::Gray16 | Self::GrayAlpha16
295        )
296    }
297
298    /// Whether this layout uses f32 samples.
299    pub const fn is_f32(self) -> bool {
300        matches!(
301            self,
302            Self::RgbLinearF32
303                | Self::RgbaLinearF32
304                | Self::GrayLinearF32
305                | Self::GrayAlphaLinearF32
306        )
307    }
308
309    /// Whether this layout includes an alpha channel.
310    pub const fn has_alpha(self) -> bool {
311        matches!(
312            self,
313            Self::Rgba8
314                | Self::Bgra8
315                | Self::GrayAlpha8
316                | Self::Rgba16
317                | Self::GrayAlpha16
318                | Self::RgbaLinearF32
319                | Self::GrayAlphaLinearF32
320        )
321    }
322
323    /// Whether this layout is grayscale.
324    pub const fn is_grayscale(self) -> bool {
325        matches!(
326            self,
327            Self::Gray8
328                | Self::GrayAlpha8
329                | Self::Gray16
330                | Self::GrayAlpha16
331                | Self::GrayLinearF32
332                | Self::GrayAlphaLinearF32
333        )
334    }
335}
336
337// ── Quality ─────────────────────────────────────────────────────────────────
338
339/// Quality specification for lossy encoding.
340#[derive(Clone, Copy, Debug)]
341#[non_exhaustive]
342pub enum Quality {
343    /// Butteraugli distance (1.0 = high quality, lower = better).
344    Distance(f32),
345    /// Percentage scale (0–100, 100 = mathematically lossless, invalid for lossy).
346    Percent(u32),
347}
348
349impl Quality {
350    /// Convert to butteraugli distance.
351    fn to_distance(self) -> core::result::Result<f32, EncodeError> {
352        match self {
353            Self::Distance(d) => {
354                if d <= 0.0 {
355                    return Err(EncodeError::InvalidConfig {
356                        message: format!("lossy distance must be > 0.0, got {d}"),
357                    });
358                }
359                Ok(d)
360            }
361            Self::Percent(q) => {
362                if q >= 100 {
363                    return Err(EncodeError::InvalidConfig {
364                        message: "quality 100 is lossless; use LosslessConfig instead".into(),
365                    });
366                }
367                Ok(percent_to_distance(q))
368            }
369        }
370    }
371}
372
373fn percent_to_distance(quality: u32) -> f32 {
374    if quality >= 100 {
375        0.0
376    } else if quality >= 90 {
377        (100 - quality) as f32 / 10.0
378    } else if quality >= 70 {
379        1.0 + (90 - quality) as f32 / 20.0
380    } else {
381        2.0 + (70 - quality) as f32 / 10.0
382    }
383}
384
385/// Convert quality on 0–100 scale to JXL butteraugli distance.
386///
387/// Matches the jxl-encoder's own `percent_to_distance` piecewise mapping:
388/// - 90–100 → distance 0.0–1.0  (perceptually lossless zone)
389/// - 70–90  → distance 1.0–2.0  (high quality)
390/// - 0–70   → distance 2.0–9.0  (lower quality)
391#[must_use]
392pub fn quality_to_distance(quality: f32) -> f32 {
393    let q = quality.clamp(0.0, 100.0);
394    if q >= 100.0 {
395        0.0
396    } else if q >= 90.0 {
397        (100.0 - q) / 10.0
398    } else if q >= 70.0 {
399        1.0 + (90.0 - q) / 20.0
400    } else {
401        2.0 + (70.0 - q) / 10.0
402    }
403}
404
405/// Map generic quality (libjpeg-turbo scale) to JXL native quality.
406///
407/// Calibrated on CID22-512 corpus (209 images) to produce the same median
408/// SSIMULACRA2 as libjpeg-turbo at each quality level. The native quality
409/// is then mapped to Butteraugli distance by [`quality_to_distance`].
410#[must_use]
411pub fn calibrated_jxl_quality(generic_q: f32) -> f32 {
412    let clamped = generic_q.clamp(0.0, 100.0);
413    const TABLE: &[(f32, f32)] = &[
414        (5.0, 5.0),
415        (10.0, 5.0),
416        (15.0, 5.0),
417        (20.0, 5.0),
418        (25.0, 9.3),
419        (30.0, 22.7),
420        (35.0, 33.0),
421        (40.0, 38.8),
422        (45.0, 43.8),
423        (50.0, 48.5),
424        (55.0, 51.9),
425        (60.0, 55.1),
426        (65.0, 58.0),
427        (70.0, 61.3),
428        (72.0, 63.2),
429        (75.0, 65.5),
430        (78.0, 67.9),
431        (80.0, 69.1),
432        (82.0, 71.8),
433        (85.0, 76.1),
434        (87.0, 79.3),
435        (90.0, 84.2),
436        (92.0, 86.9),
437        (95.0, 91.2),
438        (97.0, 92.8),
439        (99.0, 93.8),
440    ];
441    interp_quality(TABLE, clamped)
442}
443
444/// Piecewise linear interpolation with clamping at table bounds.
445fn interp_quality(table: &[(f32, f32)], x: f32) -> f32 {
446    if x <= table[0].0 {
447        return table[0].1;
448    }
449    if x >= table[table.len() - 1].0 {
450        return table[table.len() - 1].1;
451    }
452    for i in 1..table.len() {
453        if x <= table[i].0 {
454            let (x0, y0) = table[i - 1];
455            let (x1, y1) = table[i];
456            let t = (x - x0) / (x1 - x0);
457            return y0 + t * (y1 - y0);
458        }
459    }
460    table[table.len() - 1].1
461}
462
463// ── Supporting types ────────────────────────────────────────────────────────
464
465/// Image metadata (ICC, EXIF, XMP, tone mapping) to embed in the JXL file.
466#[derive(Clone, Debug, Default)]
467pub struct ImageMetadata<'a> {
468    icc_profile: Option<&'a [u8]>,
469    exif: Option<&'a [u8]>,
470    xmp: Option<&'a [u8]>,
471    /// Peak display luminance in nits (cd/m²). `None` uses the JXL default (255.0 = SDR).
472    intensity_target: Option<f32>,
473    /// Minimum display luminance in nits. `None` uses the JXL default (0.0).
474    min_nits: Option<f32>,
475    /// Intrinsic display size `(width, height)`, if different from coded dimensions.
476    intrinsic_size: Option<(u32, u32)>,
477}
478
479impl<'a> ImageMetadata<'a> {
480    /// Create empty metadata.
481    pub fn new() -> Self {
482        Self::default()
483    }
484
485    /// Attach an ICC color profile.
486    pub fn with_icc_profile(mut self, data: &'a [u8]) -> Self {
487        self.icc_profile = Some(data);
488        self
489    }
490
491    /// Attach EXIF data.
492    pub fn with_exif(mut self, data: &'a [u8]) -> Self {
493        self.exif = Some(data);
494        self
495    }
496
497    /// Attach XMP data.
498    pub fn with_xmp(mut self, data: &'a [u8]) -> Self {
499        self.xmp = Some(data);
500        self
501    }
502
503    /// Get the ICC color profile, if set.
504    pub fn icc_profile(&self) -> Option<&[u8]> {
505        self.icc_profile
506    }
507
508    /// Get the EXIF data, if set.
509    pub fn exif(&self) -> Option<&[u8]> {
510        self.exif
511    }
512
513    /// Get the XMP data, if set.
514    pub fn xmp(&self) -> Option<&[u8]> {
515        self.xmp
516    }
517
518    /// Set the peak display luminance in nits (cd/m²) for HDR content.
519    ///
520    /// Written to the JXL codestream `ToneMapping.intensity_target` field.
521    /// Default is 255.0 (SDR). Set to e.g. 4000.0 or 10000.0 for HDR.
522    pub fn with_intensity_target(mut self, nits: f32) -> Self {
523        self.intensity_target = Some(nits);
524        self
525    }
526
527    /// Set the minimum display luminance in nits.
528    ///
529    /// Written to the JXL codestream `ToneMapping.min_nits` field.
530    /// Default is 0.0.
531    pub fn with_min_nits(mut self, nits: f32) -> Self {
532        self.min_nits = Some(nits);
533        self
534    }
535
536    /// Get the intensity target, if set.
537    pub fn intensity_target(&self) -> Option<f32> {
538        self.intensity_target
539    }
540
541    /// Get the min nits, if set.
542    pub fn min_nits(&self) -> Option<f32> {
543        self.min_nits
544    }
545
546    /// Set the intrinsic display size.
547    ///
548    /// When set, the image should be rendered at this `(width, height)` rather
549    /// than the coded dimensions. Written to the JXL codestream `intrinsic_size` field.
550    pub fn with_intrinsic_size(mut self, width: u32, height: u32) -> Self {
551        self.intrinsic_size = Some((width, height));
552        self
553    }
554
555    /// Get the intrinsic size, if set.
556    pub fn intrinsic_size(&self) -> Option<(u32, u32)> {
557        self.intrinsic_size
558    }
559}
560
561/// Resource limits for encoding.
562#[derive(Clone, Debug, Default)]
563pub struct Limits {
564    max_width: Option<u64>,
565    max_height: Option<u64>,
566    max_pixels: Option<u64>,
567    max_memory_bytes: Option<u64>,
568}
569
570impl Limits {
571    /// Create limits with no restrictions (all `None`).
572    pub fn new() -> Self {
573        Self::default()
574    }
575
576    /// Set maximum image width.
577    pub fn with_max_width(mut self, w: u64) -> Self {
578        self.max_width = Some(w);
579        self
580    }
581
582    /// Set maximum image height.
583    pub fn with_max_height(mut self, h: u64) -> Self {
584        self.max_height = Some(h);
585        self
586    }
587
588    /// Set maximum total pixels (width × height).
589    pub fn with_max_pixels(mut self, p: u64) -> Self {
590        self.max_pixels = Some(p);
591        self
592    }
593
594    /// Set maximum memory bytes the encoder may allocate.
595    pub fn with_max_memory_bytes(mut self, bytes: u64) -> Self {
596        self.max_memory_bytes = Some(bytes);
597        self
598    }
599
600    /// Get maximum width, if set.
601    pub fn max_width(&self) -> Option<u64> {
602        self.max_width
603    }
604
605    /// Get maximum height, if set.
606    pub fn max_height(&self) -> Option<u64> {
607        self.max_height
608    }
609
610    /// Get maximum pixels, if set.
611    pub fn max_pixels(&self) -> Option<u64> {
612        self.max_pixels
613    }
614
615    /// Get maximum memory bytes, if set.
616    pub fn max_memory_bytes(&self) -> Option<u64> {
617        self.max_memory_bytes
618    }
619}
620
621// ── Animation ──────────────────────────────────────────────────────────────
622
623/// Animation timing parameters.
624#[derive(Clone, Debug)]
625pub struct AnimationParams {
626    /// Ticks per second numerator (default 100 = 10ms precision).
627    pub tps_numerator: u32,
628    /// Ticks per second denominator (default 1).
629    pub tps_denominator: u32,
630    /// Number of loops: 0 = infinite (default), >0 = play N times.
631    pub num_loops: u32,
632}
633
634impl Default for AnimationParams {
635    fn default() -> Self {
636        Self {
637            tps_numerator: 100,
638            tps_denominator: 1,
639            num_loops: 0,
640        }
641    }
642}
643
644/// A single frame in an animation sequence.
645pub struct AnimationFrame<'a> {
646    /// Raw pixel data (must match width/height/layout from the encode call).
647    pub pixels: &'a [u8],
648    /// Duration of this frame in ticks (tps_numerator/tps_denominator seconds per tick).
649    pub duration: u32,
650}
651
652// ── LosslessConfig ──────────────────────────────────────────────────────────
653
654/// Lossless (modular) encoding configuration.
655///
656/// Has a sensible `Default` — lossless has no quality ambiguity.
657#[derive(Clone, Debug)]
658pub struct LosslessConfig {
659    effort: u8,
660    mode: EncoderMode,
661    use_ans: bool,
662    squeeze: bool,
663    tree_learning: bool,
664    lz77: bool,
665    lz77_method: Lz77Method,
666    patches: bool,
667    lossy_palette: bool,
668    threads: usize,
669    /// Sweep / picker hook: when set, replaces the effort+mode-derived
670    /// `EffortProfile` everywhere the encoder asks for one. See
671    /// [`Self::with_effort_profile_override`].
672    profile_override: Option<crate::effort::EffortProfile>,
673}
674
675impl Default for LosslessConfig {
676    fn default() -> Self {
677        Self::with_effort_level(7)
678    }
679}
680
681impl LosslessConfig {
682    fn with_effort_level(effort: u8) -> Self {
683        let profile = crate::effort::EffortProfile::lossless(effort, EncoderMode::Reference);
684        Self {
685            effort: profile.effort,
686            mode: EncoderMode::Reference,
687            use_ans: profile.use_ans,
688            tree_learning: profile.tree_learning,
689            squeeze: false, // squeeze hurts even with tree learning (14-62% larger on both photos and screenshots)
690            lz77: profile.lz77,
691            lz77_method: profile.lz77_method,
692            patches: profile.patches,
693            lossy_palette: false,
694            threads: 0,
695            profile_override: None,
696        }
697    }
698
699    /// Resolve the effective [`EffortProfile`]: the override if set,
700    /// otherwise the standard profile derived from effort + mode.
701    pub(crate) fn effective_profile(&self) -> crate::effort::EffortProfile {
702        self.profile_override
703            .clone()
704            .unwrap_or_else(|| crate::effort::EffortProfile::lossless(self.effort, self.mode))
705    }
706
707    /// Apply picker / sweep override knobs scoped to the **lossless
708    /// (modular)** encode path.
709    ///
710    /// Each `Some(_)` field on the supplied
711    /// [`crate::effort::LosslessInternalParams`] overrides the corresponding
712    /// effort-derived default; `None` fields keep the default. Per-knob
713    /// public setters (`with_lz77_method`, `with_squeeze`, …) called after
714    /// this still take precedence on the few knobs they cover.
715    ///
716    /// The type system enforces mode-correctness: lossy-only knobs
717    /// (AC strategy gates, CfL, cost-model constants) live on
718    /// [`crate::effort::LossyInternalParams`] and cannot be passed here.
719    ///
720    /// **Requires the `__expert` cargo feature.**
721    /// Not stable; the underlying field set may grow additively between
722    /// minor versions.
723    #[cfg(feature = "__expert")]
724    #[doc(hidden)]
725    pub fn with_internal_params(mut self, params: crate::effort::LosslessInternalParams) -> Self {
726        let mut profile = crate::effort::EffortProfile::lossless(self.effort, self.mode);
727        params.apply_to(&mut profile);
728        self.profile_override = Some(profile);
729        self
730    }
731
732    /// Create a new lossless config with defaults (effort 7).
733    pub fn new() -> Self {
734        Self::default()
735    }
736
737    /// Set effort level (1–10). Higher effort = slower, better compression.
738    ///
739    /// This adjusts all effort-dependent defaults:
740    /// - **e1–3**: Huffman encoding
741    /// - **e4–6**: + ANS entropy coding
742    /// - **e7**: + content-adaptive tree learning, LZ77 RLE
743    /// - **e8**: + LZ77 greedy hash chain
744    /// - **e9–10**: + LZ77 optimal (Viterbi DP)
745    ///
746    /// Individual `with_*()` calls after `with_effort()` override these defaults.
747    pub fn with_effort(self, effort: u8) -> Self {
748        let mut new = Self::with_effort_level(effort);
749        // Preserve settings that aren't effort-derived
750        new.mode = self.mode;
751        new.squeeze = self.squeeze;
752        new.profile_override = self.profile_override;
753        new
754    }
755
756    /// Set encoder mode (default: [`EncoderMode::Reference`]).
757    ///
758    /// `Reference` matches libjxl's algorithm choices for comparable output.
759    /// `Experimental` enables encoder-specific improvements.
760    pub fn with_mode(mut self, mode: EncoderMode) -> Self {
761        self.mode = mode;
762        self
763    }
764
765    /// Current encoder mode.
766    pub fn mode(&self) -> EncoderMode {
767        self.mode
768    }
769
770    /// Enable/disable patches (dictionary-based repeated pattern detection).
771    /// Default: true at effort >= 5. Huge wins on screenshots, zero cost on photos.
772    pub fn with_patches(mut self, enable: bool) -> Self {
773        self.patches = enable;
774        self
775    }
776
777    /// Enable/disable ANS entropy coding (default: true).
778    pub fn with_ans(mut self, enable: bool) -> Self {
779        self.use_ans = enable;
780        self
781    }
782
783    /// Enable/disable squeeze (Haar wavelet) transform (default: false).
784    ///
785    /// Squeeze is disabled by default because tree learning provides better
786    /// compression on both photos and screenshots. Squeeze can still be
787    /// enabled via `.with_squeeze(true)` for experimentation.
788    pub fn with_squeeze(mut self, enable: bool) -> Self {
789        self.squeeze = enable;
790        self
791    }
792
793    /// Enable/disable content-adaptive tree learning (default: false).
794    pub fn with_tree_learning(mut self, enable: bool) -> Self {
795        self.tree_learning = enable;
796        self
797    }
798
799    /// Enable/disable LZ77 backward references (default: false).
800    pub fn with_lz77(mut self, enable: bool) -> Self {
801        self.lz77 = enable;
802        self
803    }
804
805    /// Set LZ77 method (default: Greedy). Only effective when LZ77 is enabled.
806    pub fn with_lz77_method(mut self, method: Lz77Method) -> Self {
807        self.lz77_method = method;
808        self
809    }
810
811    /// Enable/disable lossy delta palette (default: false).
812    ///
813    /// When enabled, uses quantized palette with delta entries and error diffusion
814    /// for near-lossless encoding. This is NOT pixel-exact — it trades some color
815    /// accuracy for significantly smaller files on images with many colors.
816    /// Matching libjxl's modular lossy palette mode.
817    pub fn with_lossy_palette(mut self, enable: bool) -> Self {
818        self.lossy_palette = enable;
819        self
820    }
821
822    /// Set thread count for parallel encoding.
823    ///
824    /// - `0` (default): use the ambient rayon pool. The caller can control
825    ///   thread count by wrapping the encode call in `pool.install(|| ...)`.
826    /// - `1`: force sequential encoding (no rayon).
827    /// - `N >= 2`: create a dedicated N-thread pool for this encode.
828    ///
829    /// Requires the `parallel` feature. When `parallel` is not enabled,
830    /// this value is ignored and encoding is always sequential.
831    pub fn with_threads(mut self, threads: usize) -> Self {
832        self.threads = threads;
833        self
834    }
835
836    // ── Getters ───────────────────────────────────────────────────────
837
838    /// Current effort level.
839    pub fn effort(&self) -> u8 {
840        self.effort
841    }
842
843    /// Whether ANS entropy coding is enabled.
844    pub fn ans(&self) -> bool {
845        self.use_ans
846    }
847
848    /// Whether squeeze (Haar wavelet) transform is enabled.
849    pub fn squeeze(&self) -> bool {
850        self.squeeze
851    }
852
853    /// Whether content-adaptive tree learning is enabled.
854    pub fn tree_learning(&self) -> bool {
855        self.tree_learning
856    }
857
858    /// Whether LZ77 backward references are enabled.
859    pub fn lz77(&self) -> bool {
860        self.lz77
861    }
862
863    /// Current LZ77 method.
864    pub fn lz77_method(&self) -> Lz77Method {
865        self.lz77_method
866    }
867
868    /// Whether patches (dictionary-based repeated pattern detection) are enabled.
869    pub fn patches(&self) -> bool {
870        self.patches
871    }
872
873    /// Whether lossy delta palette is enabled.
874    pub fn lossy_palette(&self) -> bool {
875        self.lossy_palette
876    }
877
878    /// Thread count (0 = auto, 1 = sequential).
879    pub fn threads(&self) -> usize {
880        self.threads
881    }
882
883    /// Borrow the resolved `EffortProfile` override, if any. Internal hook
884    /// used by [`crate::validation`].
885    #[cfg(feature = "__expert")]
886    pub(crate) fn profile_override_ref(&self) -> Option<&crate::effort::EffortProfile> {
887        self.profile_override.as_ref()
888    }
889
890    // ── Request / fluent encode ─────────────────────────────────────
891
892    /// Create an encode request for an image with this config.
893    ///
894    /// Use this when you need to attach metadata, limits, or cancellation.
895    pub fn encode_request(
896        &self,
897        width: u32,
898        height: u32,
899        layout: PixelLayout,
900    ) -> EncodeRequest<'_> {
901        EncodeRequest {
902            config: ConfigRef::Lossless(self),
903            width,
904            height,
905            layout,
906            metadata: None,
907            limits: None,
908            stop: None,
909            source_gamma: None,
910            color_encoding: None,
911        }
912    }
913
914    /// Encode pixels directly with this config. Shortcut for simple cases.
915    ///
916    /// ```rust,no_run
917    /// # let pixels = vec![0u8; 100 * 100 * 3];
918    /// let jxl = jxl_encoder::LosslessConfig::new()
919    ///     .encode(&pixels, 100, 100, jxl_encoder::PixelLayout::Rgb8)?;
920    /// # Ok::<_, jxl_encoder::At<jxl_encoder::EncodeError>>(())
921    /// ```
922    #[track_caller]
923    pub fn encode(
924        &self,
925        pixels: &[u8],
926        width: u32,
927        height: u32,
928        layout: PixelLayout,
929    ) -> Result<Vec<u8>> {
930        self.encode_request(width, height, layout).encode(pixels)
931    }
932
933    /// Encode pixels, appending to an existing buffer.
934    #[track_caller]
935    pub fn encode_into(
936        &self,
937        pixels: &[u8],
938        width: u32,
939        height: u32,
940        layout: PixelLayout,
941        out: &mut Vec<u8>,
942    ) -> Result<()> {
943        self.encode_request(width, height, layout)
944            .encode_into(pixels, out)
945            .map(|_| ())
946    }
947
948    /// Encode a multi-frame animation as a lossless JXL.
949    ///
950    /// Each frame must have the same dimensions and pixel layout.
951    /// Returns the complete JXL codestream bytes.
952    #[track_caller]
953    pub fn encode_animation(
954        &self,
955        width: u32,
956        height: u32,
957        layout: PixelLayout,
958        animation: &AnimationParams,
959        frames: &[AnimationFrame<'_>],
960    ) -> Result<Vec<u8>> {
961        encode_animation_lossless(self, width, height, layout, animation, frames).map_err(at)
962    }
963}
964
965// ── EncoderMode ──────────────────────────────────────────────────────────────
966
967/// Controls whether the encoder matches libjxl's algorithm choices or uses
968/// its own improvements.
969///
970/// Both modes produce valid JPEG XL bitstreams decodable by any conformant
971/// decoder. The difference is in *encoder-side* decisions: strategy selection
972/// heuristics, cost models, entropy coding parameters, tree learning, etc.
973#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
974pub enum EncoderMode {
975    /// Match libjxl's algorithm choices at the configured effort level.
976    ///
977    /// Output is statistically equivalent to `cjxl` at the same effort and
978    /// distance — same RD curve within measurement noise. Use this when
979    /// comparing against libjxl or when reproducibility matters.
980    #[default]
981    Reference,
982
983    /// Use encoder-specific improvements and research features.
984    ///
985    /// May produce better rate-distortion performance than libjxl at the
986    /// same effort level, but output will differ. Use this for production
987    /// encoding where quality per byte is the goal.
988    Experimental,
989}
990
991// ── ProgressiveMode ──────────────────────────────────────────────────────────
992
993/// Progressive encoding mode for VarDCT.
994///
995/// Progressive encoding splits AC coefficients across multiple passes by
996/// reducing precision. Decoders can render a coarse preview after early passes,
997/// improving user experience for web delivery.
998///
999/// The shift mechanism works by right-shifting quantized coefficients before
1000/// encoding in early passes. The decoder left-shifts and accumulates, so the
1001/// final result is exact (lossless reconstruction of the quantized coefficients).
1002#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1003pub enum ProgressiveMode {
1004    /// Single pass (default). No progressive rendering.
1005    #[default]
1006    Single,
1007    /// 2-pass quantized progressive.
1008    ///
1009    /// - Pass 0: All AC coefficients right-shifted by 1 bit (coarse)
1010    /// - Pass 1: Residual at full precision
1011    ///
1012    /// Provides quick 2x-downsampled preview, then full quality refinement.
1013    QuantizedAcFullAc,
1014    /// 3-pass progressive (DC/VLF → LF → Full AC).
1015    ///
1016    /// - Pass 0: All AC coefficients right-shifted by 2 bits (very coarse, 8x downsample hint)
1017    /// - Pass 1: Residual right-shifted by 1 bit (medium, 4x downsample hint)
1018    /// - Pass 2: Final residual at full precision
1019    ///
1020    /// Provides staged refinement: blurry preview → sharper → final.
1021    DcVlfLfAc,
1022}
1023
1024// ── LossyConfig ─────────────────────────────────────────────────────────────
1025
1026/// Lossy (VarDCT) encoding configuration.
1027///
1028/// No `Default` — distance/quality is a required choice.
1029#[derive(Clone, Debug)]
1030pub struct LossyConfig {
1031    distance: f32,
1032    effort: u8,
1033    mode: EncoderMode,
1034    use_ans: bool,
1035    gaborish: bool,
1036    noise: bool,
1037    denoise: bool,
1038    error_diffusion: bool,
1039    pixel_domain_loss: bool,
1040    lz77: bool,
1041    lz77_method: Lz77Method,
1042    force_strategy: Option<u8>,
1043    max_strategy_size: Option<u8>,
1044    patches: bool,
1045    splines: Option<Vec<crate::vardct::splines::Spline>>,
1046    progressive: ProgressiveMode,
1047    lf_frame: bool,
1048    #[cfg(feature = "butteraugli-loop")]
1049    butteraugli_iters: u32,
1050    #[cfg(feature = "butteraugli-loop")]
1051    butteraugli_iters_explicit: bool,
1052    #[cfg(feature = "ssim2-loop")]
1053    ssim2_iters: u32,
1054    #[cfg(feature = "zensim-loop")]
1055    zensim_iters: u32,
1056    threads: usize,
1057    /// Sweep / picker hook: when set, replaces the effort+mode-derived
1058    /// `EffortProfile` everywhere the encoder asks for one. See
1059    /// [`Self::with_effort_profile_override`].
1060    profile_override: Option<crate::effort::EffortProfile>,
1061}
1062
1063impl LossyConfig {
1064    /// Create with butteraugli distance (1.0 = high quality). Default effort 7.
1065    pub fn new(distance: f32) -> Self {
1066        Self::new_with_effort(distance, 7)
1067    }
1068
1069    fn new_with_effort(distance: f32, effort: u8) -> Self {
1070        let profile = crate::effort::EffortProfile::lossy(effort, EncoderMode::Reference);
1071        Self {
1072            distance,
1073            effort: profile.effort,
1074            mode: EncoderMode::Reference,
1075            use_ans: profile.use_ans,
1076            gaborish: profile.gaborish,
1077            noise: false,
1078            denoise: false,
1079            error_diffusion: profile.error_diffusion,
1080            pixel_domain_loss: profile.pixel_domain_loss,
1081            lz77: profile.lz77,
1082            lz77_method: profile.lz77_method,
1083            force_strategy: None,
1084            max_strategy_size: None,
1085            patches: profile.patches,
1086            splines: None,
1087            progressive: ProgressiveMode::Single,
1088            lf_frame: false,
1089            #[cfg(feature = "butteraugli-loop")]
1090            butteraugli_iters: profile.butteraugli_iters,
1091            #[cfg(feature = "butteraugli-loop")]
1092            butteraugli_iters_explicit: false,
1093            #[cfg(feature = "ssim2-loop")]
1094            ssim2_iters: 0,
1095            #[cfg(feature = "zensim-loop")]
1096            zensim_iters: 0,
1097            threads: 0,
1098            profile_override: None,
1099        }
1100    }
1101
1102    /// Resolve the effective [`EffortProfile`]: the override if set,
1103    /// otherwise the standard profile derived from effort + mode.
1104    pub(crate) fn effective_profile(&self) -> crate::effort::EffortProfile {
1105        self.profile_override
1106            .clone()
1107            .unwrap_or_else(|| crate::effort::EffortProfile::lossy(self.effort, self.mode))
1108    }
1109
1110    /// Apply picker / sweep override knobs scoped to the **lossy (VarDCT)**
1111    /// encode path.
1112    ///
1113    /// Each `Some(_)` field on the supplied
1114    /// [`crate::effort::LossyInternalParams`] overrides the corresponding
1115    /// effort-derived default; `None` fields keep the default. Per-knob
1116    /// public setters (`with_butteraugli_iters`, `with_gaborish`, …) called
1117    /// after this still take precedence on the few knobs they cover.
1118    ///
1119    /// The type system enforces mode-correctness: modular-only knobs
1120    /// (RCT search, WP parameter scan, tree-learning shape) live on
1121    /// [`crate::effort::LosslessInternalParams`] and cannot be passed here.
1122    ///
1123    /// **Requires the `__expert` cargo feature.**
1124    /// Not stable; the underlying field set may grow additively between
1125    /// minor versions.
1126    #[cfg(feature = "__expert")]
1127    #[doc(hidden)]
1128    pub fn with_internal_params(mut self, params: crate::effort::LossyInternalParams) -> Self {
1129        let mut profile = crate::effort::EffortProfile::lossy(self.effort, self.mode);
1130        params.apply_to(&mut profile);
1131        self.profile_override = Some(profile);
1132        self
1133    }
1134
1135    /// Create from a [`Quality`] specification.
1136    pub fn from_quality(quality: Quality) -> core::result::Result<Self, EncodeError> {
1137        let distance = quality.to_distance()?;
1138        Ok(Self::new(distance))
1139    }
1140
1141    /// Set effort level (1–10). Higher effort = slower, better compression.
1142    ///
1143    /// This adjusts all effort-dependent defaults:
1144    /// - **e1–3**: DCT8 only, Huffman, no gaborish/patches/butteraugli
1145    /// - **e4**: + ANS entropy coding, custom coefficient orders
1146    /// - **e5**: + gaborish, pixel-domain loss, AC strategy search, AdjustQuantBlockAC
1147    /// - **e6**: + DCT4x8/AFV strategies, non-aligned eval, EPF dynamic sharpness
1148    /// - **e7**: + patches, error diffusion, CfL two-pass, LZ77 RLE, DCT64 strategies
1149    /// - **e8**: + butteraugli loop (2 iters), LZ77 greedy, WP param search (2 modes)
1150    /// - **e9–10**: + LZ77 optimal (Viterbi DP), 4 butteraugli iters, WP search (5 modes)
1151    ///
1152    /// Individual `with_*()` calls after `with_effort()` override these defaults.
1153    pub fn with_effort(self, effort: u8) -> Self {
1154        let mut new = Self::new_with_effort(self.distance, effort);
1155        // Preserve settings that are never effort-derived (always opt-in)
1156        new.mode = self.mode;
1157        new.noise = self.noise;
1158        new.denoise = self.denoise;
1159        new.force_strategy = self.force_strategy;
1160        new.max_strategy_size = self.max_strategy_size;
1161        new.splines = self.splines;
1162        new.progressive = self.progressive;
1163        // Preserve explicit butteraugli override
1164        #[cfg(feature = "butteraugli-loop")]
1165        if self.butteraugli_iters_explicit {
1166            new.butteraugli_iters = self.butteraugli_iters;
1167            new.butteraugli_iters_explicit = true;
1168        }
1169        #[cfg(feature = "ssim2-loop")]
1170        {
1171            new.ssim2_iters = self.ssim2_iters;
1172        }
1173        #[cfg(feature = "zensim-loop")]
1174        {
1175            new.zensim_iters = self.zensim_iters;
1176        }
1177        new.profile_override = self.profile_override;
1178        new
1179    }
1180
1181    /// Set encoder mode (default: [`EncoderMode::Reference`]).
1182    ///
1183    /// `Reference` matches libjxl's algorithm choices for comparable output.
1184    /// `Experimental` enables encoder-specific improvements.
1185    pub fn with_mode(mut self, mode: EncoderMode) -> Self {
1186        self.mode = mode;
1187        self
1188    }
1189
1190    /// Current encoder mode.
1191    pub fn mode(&self) -> EncoderMode {
1192        self.mode
1193    }
1194
1195    /// Enable/disable ANS entropy coding (default: true).
1196    pub fn with_ans(mut self, enable: bool) -> Self {
1197        self.use_ans = enable;
1198        self
1199    }
1200
1201    /// Enable/disable gaborish inverse pre-filter (default: true).
1202    pub fn with_gaborish(mut self, enable: bool) -> Self {
1203        self.gaborish = enable;
1204        self
1205    }
1206
1207    /// Enable/disable noise synthesis (default: false).
1208    pub fn with_noise(mut self, enable: bool) -> Self {
1209        self.noise = enable;
1210        self
1211    }
1212
1213    /// Enable/disable Wiener denoising pre-filter (default: false). Implies noise.
1214    pub fn with_denoise(mut self, enable: bool) -> Self {
1215        self.denoise = enable;
1216        if enable {
1217            self.noise = true;
1218        }
1219        self
1220    }
1221
1222    /// Enable/disable error diffusion in AC quantization (default: false).
1223    ///
1224    /// Error diffusion propagates 1/4 of the quantization error to the next
1225    /// coefficient in zigzag order. Note: libjxl's `QuantizeBlockAC` accepts
1226    /// this parameter but never references it — the feature is effectively a
1227    /// no-op in the reference encoder. Our implementation actually performs
1228    /// the diffusion, which can hurt quality on certain content (bright features
1229    /// in dark regions), especially when combined with gaborish.
1230    pub fn with_error_diffusion(mut self, enable: bool) -> Self {
1231        self.error_diffusion = enable;
1232        self
1233    }
1234
1235    /// Enable/disable pixel-domain loss in strategy selection (default: true).
1236    pub fn with_pixel_domain_loss(mut self, enable: bool) -> Self {
1237        self.pixel_domain_loss = enable;
1238        self
1239    }
1240
1241    /// Enable/disable LZ77 backward references (default: false).
1242    pub fn with_lz77(mut self, enable: bool) -> Self {
1243        self.lz77 = enable;
1244        self
1245    }
1246
1247    /// Set LZ77 method (default: Greedy).
1248    pub fn with_lz77_method(mut self, method: Lz77Method) -> Self {
1249        self.lz77_method = method;
1250        self
1251    }
1252
1253    /// Force a specific AC strategy for all blocks. `None` for auto-selection.
1254    pub fn with_force_strategy(mut self, strategy: Option<u8>) -> Self {
1255        self.force_strategy = strategy;
1256        self
1257    }
1258
1259    /// Limit the maximum AC strategy transform size.
1260    ///
1261    /// Controls the largest DCT transform the encoder will consider:
1262    /// - `8`: Only 8×8-class transforms (DCT8, DCT4x4, DCT4x8, AFV, IDENTITY, DCT2x2)
1263    /// - `16`: Up to 16×16 (adds DCT16x16, DCT16x8, DCT8x16)
1264    /// - `32`: Up to 32×32 (adds DCT32x32, DCT32x16, DCT16x32)
1265    /// - `64`: No restriction (adds DCT64x64, DCT64x32, DCT32x64) — the default
1266    ///
1267    /// `None` means no restriction (same as `64`). Values are clamped to the
1268    /// nearest valid size.
1269    pub fn with_max_strategy_size(mut self, size: Option<u8>) -> Self {
1270        self.max_strategy_size = size;
1271        self
1272    }
1273
1274    /// Enable/disable patches (dictionary-based repeated pattern detection).
1275    /// Default: true. Huge wins on screenshots, zero cost on photos.
1276    pub fn with_patches(mut self, enable: bool) -> Self {
1277        self.patches = enable;
1278        self
1279    }
1280
1281    /// Set manual splines to overlay on the image.
1282    ///
1283    /// Splines are Gaussian-blurred parametric curves overlaid additively.
1284    /// They encode thin features (power lines, horizons) efficiently.
1285    /// The encoder subtracts splines from XYB before VarDCT; the decoder
1286    /// adds them back after reconstruction. Default: `None`.
1287    pub fn with_splines(mut self, splines: Vec<crate::vardct::splines::Spline>) -> Self {
1288        self.splines = Some(splines);
1289        self
1290    }
1291
1292    /// Set progressive encoding mode (default: Single = no progressive).
1293    ///
1294    /// Progressive encoding splits AC coefficients across multiple passes,
1295    /// allowing decoders to render coarse previews before the full file is received.
1296    pub fn with_progressive(mut self, mode: ProgressiveMode) -> Self {
1297        self.progressive = mode;
1298        self
1299    }
1300
1301    /// Enable LfFrame (separate DC frame).
1302    ///
1303    /// When true, DC coefficients are encoded as a separate modular frame
1304    /// before the main VarDCT frame, matching libjxl's `progressive_dc >= 1`.
1305    pub fn with_lf_frame(mut self, enable: bool) -> Self {
1306        self.lf_frame = enable;
1307        self
1308    }
1309
1310    /// Set butteraugli quantization loop iterations explicitly.
1311    ///
1312    /// Overrides the automatic effort-based default (effort 7: 0, effort 8: 2, effort 9+: 4).
1313    /// Requires the `butteraugli-loop` feature.
1314    #[cfg(feature = "butteraugli-loop")]
1315    pub fn with_butteraugli_iters(mut self, n: u32) -> Self {
1316        self.butteraugli_iters = n;
1317        self.butteraugli_iters_explicit = true;
1318        self
1319    }
1320
1321    /// Set SSIM2 quantization loop iterations.
1322    ///
1323    /// Alternative to butteraugli loop: uses per-block linear RGB RMSE + full-image SSIM2.
1324    /// Requires the `ssim2-loop` feature.
1325    #[cfg(feature = "ssim2-loop")]
1326    pub fn with_ssim2_iters(mut self, n: u32) -> Self {
1327        self.ssim2_iters = n;
1328        self
1329    }
1330
1331    /// Set zensim quantization loop iterations.
1332    ///
1333    /// Alternative to butteraugli loop: uses zensim's psychovisual metric for
1334    /// both global quality tracking and per-pixel spatial error map (diffmap in XYB space).
1335    /// Also refines AC strategy by splitting large transforms with high perceptual error.
1336    /// Can stack with butteraugli loop (butteraugli runs first, then zensim fine-tunes).
1337    /// Requires the `zensim-loop` feature.
1338    #[cfg(feature = "zensim-loop")]
1339    pub fn with_zensim_iters(mut self, n: u32) -> Self {
1340        self.zensim_iters = n;
1341        self
1342    }
1343
1344    /// Set thread count for parallel encoding.
1345    ///
1346    /// - `0` (default): use the ambient rayon pool. The caller can control
1347    ///   thread count by wrapping the encode call in `pool.install(|| ...)`.
1348    /// - `1`: force sequential encoding (no rayon).
1349    /// - `N >= 2`: create a dedicated N-thread pool for this encode.
1350    ///
1351    /// Requires the `parallel` feature. When `parallel` is not enabled,
1352    /// this value is ignored and encoding is always sequential.
1353    pub fn with_threads(mut self, threads: usize) -> Self {
1354        self.threads = threads;
1355        self
1356    }
1357
1358    // ── Getters ───────────────────────────────────────────────────────
1359
1360    /// Current butteraugli distance.
1361    pub fn distance(&self) -> f32 {
1362        self.distance
1363    }
1364
1365    /// Current effort level.
1366    pub fn effort(&self) -> u8 {
1367        self.effort
1368    }
1369
1370    /// Whether ANS entropy coding is enabled.
1371    pub fn ans(&self) -> bool {
1372        self.use_ans
1373    }
1374
1375    /// Whether gaborish inverse pre-filter is enabled.
1376    pub fn gaborish(&self) -> bool {
1377        self.gaborish
1378    }
1379
1380    /// Whether noise synthesis is enabled.
1381    pub fn noise(&self) -> bool {
1382        self.noise
1383    }
1384
1385    /// Whether Wiener denoising pre-filter is enabled.
1386    pub fn denoise(&self) -> bool {
1387        self.denoise
1388    }
1389
1390    /// Whether error diffusion in AC quantization is enabled.
1391    pub fn error_diffusion(&self) -> bool {
1392        self.error_diffusion
1393    }
1394
1395    /// Whether pixel-domain loss is enabled.
1396    pub fn pixel_domain_loss(&self) -> bool {
1397        self.pixel_domain_loss
1398    }
1399
1400    /// Whether LZ77 backward references are enabled.
1401    pub fn lz77(&self) -> bool {
1402        self.lz77
1403    }
1404
1405    /// Current LZ77 method.
1406    pub fn lz77_method(&self) -> Lz77Method {
1407        self.lz77_method
1408    }
1409
1410    /// Forced AC strategy, if any.
1411    pub fn force_strategy(&self) -> Option<u8> {
1412        self.force_strategy
1413    }
1414
1415    /// Maximum AC strategy transform size, if set.
1416    pub fn max_strategy_size(&self) -> Option<u8> {
1417        self.max_strategy_size
1418    }
1419
1420    /// Current progressive mode.
1421    pub fn progressive(&self) -> ProgressiveMode {
1422        self.progressive
1423    }
1424
1425    /// Whether LfFrame (separate DC frame) is enabled.
1426    pub fn lf_frame(&self) -> bool {
1427        self.lf_frame
1428    }
1429
1430    /// Butteraugli quantization loop iterations.
1431    #[cfg(feature = "butteraugli-loop")]
1432    pub fn butteraugli_iters(&self) -> u32 {
1433        self.butteraugli_iters
1434    }
1435
1436    /// SSIM2 quantization loop iterations (internal accessor for validation).
1437    #[cfg(feature = "ssim2-loop")]
1438    pub(crate) fn ssim2_iters_value(&self) -> u32 {
1439        self.ssim2_iters
1440    }
1441
1442    /// zensim quantization loop iterations (internal accessor for validation).
1443    #[cfg(feature = "zensim-loop")]
1444    pub(crate) fn zensim_iters_value(&self) -> u32 {
1445        self.zensim_iters
1446    }
1447
1448    /// Borrow the resolved `EffortProfile` override, if any. Internal hook
1449    /// used by [`crate::validation`].
1450    #[cfg(feature = "__expert")]
1451    pub(crate) fn profile_override_ref(&self) -> Option<&crate::effort::EffortProfile> {
1452        self.profile_override.as_ref()
1453    }
1454
1455    /// Thread count (0 = auto, 1 = sequential).
1456    pub fn threads(&self) -> usize {
1457        self.threads
1458    }
1459
1460    // ── Request / fluent encode ─────────────────────────────────────
1461
1462    /// Create an encode request for an image with this config.
1463    ///
1464    /// Use this when you need to attach metadata, limits, or cancellation.
1465    pub fn encode_request(
1466        &self,
1467        width: u32,
1468        height: u32,
1469        layout: PixelLayout,
1470    ) -> EncodeRequest<'_> {
1471        EncodeRequest {
1472            config: ConfigRef::Lossy(self),
1473            width,
1474            height,
1475            layout,
1476            metadata: None,
1477            limits: None,
1478            stop: None,
1479            source_gamma: None,
1480            color_encoding: None,
1481        }
1482    }
1483
1484    /// Encode pixels directly with this config. Shortcut for simple cases.
1485    ///
1486    /// ```rust,no_run
1487    /// # let pixels = vec![0u8; 100 * 100 * 3];
1488    /// let jxl = jxl_encoder::LossyConfig::new(1.0)
1489    ///     .encode(&pixels, 100, 100, jxl_encoder::PixelLayout::Rgb8)?;
1490    /// # Ok::<_, jxl_encoder::At<jxl_encoder::EncodeError>>(())
1491    /// ```
1492    #[track_caller]
1493    pub fn encode(
1494        &self,
1495        pixels: &[u8],
1496        width: u32,
1497        height: u32,
1498        layout: PixelLayout,
1499    ) -> Result<Vec<u8>> {
1500        self.encode_request(width, height, layout).encode(pixels)
1501    }
1502
1503    /// Encode pixels, appending to an existing buffer.
1504    #[track_caller]
1505    pub fn encode_into(
1506        &self,
1507        pixels: &[u8],
1508        width: u32,
1509        height: u32,
1510        layout: PixelLayout,
1511        out: &mut Vec<u8>,
1512    ) -> Result<()> {
1513        self.encode_request(width, height, layout)
1514            .encode_into(pixels, out)
1515            .map(|_| ())
1516    }
1517
1518    /// Encode a multi-frame animation as a lossy JXL.
1519    ///
1520    /// Each frame must have the same dimensions and pixel layout.
1521    /// Returns the complete JXL codestream bytes.
1522    #[track_caller]
1523    pub fn encode_animation(
1524        &self,
1525        width: u32,
1526        height: u32,
1527        layout: PixelLayout,
1528        animation: &AnimationParams,
1529        frames: &[AnimationFrame<'_>],
1530    ) -> Result<Vec<u8>> {
1531        encode_animation_lossy(self, width, height, layout, animation, frames).map_err(at)
1532    }
1533}
1534
1535// ── EncodeRequest ───────────────────────────────────────────────────────────
1536
1537/// Internal config reference (lossy or lossless).
1538#[derive(Clone, Copy, Debug)]
1539enum ConfigRef<'a> {
1540    Lossless(&'a LosslessConfig),
1541    Lossy(&'a LossyConfig),
1542}
1543
1544/// An encoding request — binds config + image dimensions + pixel layout.
1545///
1546/// Created via [`LosslessConfig::encode_request`] or [`LossyConfig::encode_request`].
1547pub struct EncodeRequest<'a> {
1548    config: ConfigRef<'a>,
1549    width: u32,
1550    height: u32,
1551    layout: PixelLayout,
1552    metadata: Option<&'a ImageMetadata<'a>>,
1553    limits: Option<&'a Limits>,
1554    stop: Option<&'a dyn Stop>,
1555    source_gamma: Option<f32>,
1556    color_encoding: Option<crate::headers::color_encoding::ColorEncoding>,
1557}
1558
1559impl<'a> EncodeRequest<'a> {
1560    /// Attach image metadata (ICC, EXIF, XMP).
1561    pub fn with_metadata(mut self, meta: &'a ImageMetadata<'a>) -> Self {
1562        self.metadata = Some(meta);
1563        self
1564    }
1565
1566    /// Attach resource limits.
1567    pub fn with_limits(mut self, limits: &'a Limits) -> Self {
1568        self.limits = Some(limits);
1569        self
1570    }
1571
1572    /// Attach a cooperative cancellation token.
1573    ///
1574    /// The encoder will check this periodically and return
1575    /// [`EncodeError::Cancelled`] if stopped.
1576    pub fn with_stop(mut self, stop: &'a dyn Stop) -> Self {
1577        self.stop = Some(stop);
1578        self
1579    }
1580
1581    /// Specify that source pixels use a custom gamma transfer function.
1582    ///
1583    /// When set, the encoder linearizes u8/u16 pixels with `pixel ^ (1/gamma)`
1584    /// instead of the sRGB transfer function, and writes `have_gamma=true` in
1585    /// the JXL header. This matches cjxl's behavior for PNGs with gAMA chunks.
1586    ///
1587    /// Example: `0.45455` for standard gamma 2.2 encoding (gAMA=45455).
1588    pub fn with_source_gamma(mut self, gamma: f32) -> Self {
1589        self.source_gamma = Some(gamma);
1590        self
1591    }
1592
1593    /// Override the color encoding written to the JXL header.
1594    ///
1595    /// When set, this color encoding is used instead of the default (sRGB for
1596    /// u8/u16, linear sRGB for f32) or any gamma derived from
1597    /// [`with_source_gamma`](Self::with_source_gamma).
1598    ///
1599    /// Use this for HDR content (PQ, HLG) or non-sRGB primaries (BT.2020, Display P3).
1600    ///
1601    /// Note: this only affects the signaled color encoding in the JXL header.
1602    /// Pixel linearization for lossy encoding is still controlled by
1603    /// `with_source_gamma()`. For float input, pixels are assumed already linear.
1604    pub fn with_color_encoding(
1605        mut self,
1606        ce: crate::headers::color_encoding::ColorEncoding,
1607    ) -> Self {
1608        self.color_encoding = Some(ce);
1609        self
1610    }
1611
1612    /// Encode pixels and return the JXL bytes.
1613    #[track_caller]
1614    pub fn encode(self, pixels: &[u8]) -> Result<Vec<u8>> {
1615        self.encode_inner(pixels)
1616            .map(|mut r| r.take_data().unwrap())
1617            .map_err(at)
1618    }
1619
1620    /// Encode pixels and return the JXL bytes together with [`EncodeStats`].
1621    #[track_caller]
1622    pub fn encode_with_stats(self, pixels: &[u8]) -> Result<EncodeResult> {
1623        self.encode_inner(pixels).map_err(at)
1624    }
1625
1626    /// Encode pixels, appending to an existing buffer. Returns metrics.
1627    #[track_caller]
1628    pub fn encode_into(self, pixels: &[u8], out: &mut Vec<u8>) -> Result<EncodeResult> {
1629        let mut result = self.encode_inner(pixels).map_err(at)?;
1630        if let Some(data) = result.data.take() {
1631            out.extend_from_slice(&data);
1632        }
1633        Ok(result)
1634    }
1635
1636    /// Encode pixels, writing to a `std::io::Write` destination. Returns metrics.
1637    #[cfg(feature = "std")]
1638    #[track_caller]
1639    pub fn encode_to(self, pixels: &[u8], mut dest: impl std::io::Write) -> Result<EncodeResult> {
1640        let mut result = self.encode_inner(pixels).map_err(at)?;
1641        if let Some(data) = result.data.take() {
1642            dest.write_all(&data)
1643                .map_err(|e| at(EncodeError::from(e)))?;
1644        }
1645        Ok(result)
1646    }
1647
1648    fn encode_inner(&self, pixels: &[u8]) -> core::result::Result<EncodeResult, EncodeError> {
1649        self.validate_pixels(pixels)?;
1650        self.check_limits()?;
1651
1652        let threads = match self.config {
1653            ConfigRef::Lossless(cfg) => cfg.threads,
1654            ConfigRef::Lossy(cfg) => cfg.threads,
1655        };
1656
1657        let (codestream, mut stats) = run_with_threads(threads, || match self.config {
1658            ConfigRef::Lossless(cfg) => self.encode_lossless(cfg, pixels),
1659            ConfigRef::Lossy(cfg) => self.encode_lossy(cfg, pixels),
1660        })?;
1661
1662        stats.codestream_size = codestream.len();
1663
1664        // Wrap in container if metadata (EXIF/XMP) is present
1665        let output = if let Some(meta) = self.metadata
1666            && (meta.exif.is_some() || meta.xmp.is_some())
1667        {
1668            crate::container::wrap_in_container(&codestream, meta.exif, meta.xmp)
1669        } else {
1670            codestream
1671        };
1672
1673        stats.output_size = output.len();
1674
1675        Ok(EncodeResult {
1676            data: Some(output),
1677            stats,
1678        })
1679    }
1680
1681    fn validate_pixels(&self, pixels: &[u8]) -> core::result::Result<(), EncodeError> {
1682        let w = self.width as usize;
1683        let h = self.height as usize;
1684        if w == 0 || h == 0 {
1685            return Err(EncodeError::InvalidInput {
1686                message: format!("zero dimensions: {w}x{h}"),
1687            });
1688        }
1689        // JXL spec limits each dimension to 2^30.
1690        const MAX_JXL_DIM: u32 = 1 << 30;
1691        if self.width > MAX_JXL_DIM || self.height > MAX_JXL_DIM {
1692            return Err(EncodeError::LimitExceeded {
1693                message: format!(
1694                    "image {}x{} exceeds JXL spec maximum of {MAX_JXL_DIM} per dimension",
1695                    self.width, self.height
1696                ),
1697            });
1698        }
1699        let expected = w
1700            .checked_mul(h)
1701            .and_then(|n| n.checked_mul(self.layout.bytes_per_pixel()));
1702        match expected {
1703            Some(expected) if pixels.len() == expected => Ok(()),
1704            Some(expected) => Err(EncodeError::InvalidInput {
1705                message: format!(
1706                    "pixel buffer size mismatch: expected {expected} bytes for {w}x{h} {:?}, got {}",
1707                    self.layout,
1708                    pixels.len()
1709                ),
1710            }),
1711            None => Err(EncodeError::InvalidInput {
1712                message: "image dimensions overflow".into(),
1713            }),
1714        }
1715    }
1716
1717    fn check_limits(&self) -> core::result::Result<(), EncodeError> {
1718        let Some(limits) = self.limits else {
1719            return Ok(());
1720        };
1721        let w = self.width as u64;
1722        let h = self.height as u64;
1723        if let Some(max_w) = limits.max_width
1724            && w > max_w
1725        {
1726            return Err(EncodeError::LimitExceeded {
1727                message: format!("width {w} > max {max_w}"),
1728            });
1729        }
1730        if let Some(max_h) = limits.max_height
1731            && h > max_h
1732        {
1733            return Err(EncodeError::LimitExceeded {
1734                message: format!("height {h} > max {max_h}"),
1735            });
1736        }
1737        if let Some(max_px) = limits.max_pixels
1738            && w * h > max_px
1739        {
1740            return Err(EncodeError::LimitExceeded {
1741                message: format!("pixels {}x{} = {} > max {max_px}", w, h, w * h),
1742            });
1743        }
1744        if let Some(max_mem) = limits.max_memory_bytes {
1745            // Conservative estimate: ~40 bytes per pixel covers XYB (3×f32=12),
1746            // quantization fields, strategy maps, and entropy coding buffers.
1747            let estimated = w.saturating_mul(h).saturating_mul(40);
1748            if estimated > max_mem {
1749                return Err(EncodeError::LimitExceeded {
1750                    message: format!(
1751                        "estimated memory {estimated} bytes > max {max_mem} bytes \
1752                         (for {w}x{h} image)"
1753                    ),
1754                });
1755            }
1756        }
1757        Ok(())
1758    }
1759
1760    // ── Lossless path ───────────────────────────────────────────────────
1761
1762    fn encode_lossless(
1763        &self,
1764        cfg: &LosslessConfig,
1765        pixels: &[u8],
1766    ) -> core::result::Result<(Vec<u8>, EncodeStats), EncodeError> {
1767        use crate::bit_writer::BitWriter;
1768        use crate::headers::color_encoding::ColorSpace;
1769        use crate::headers::{ColorEncoding, FileHeader};
1770        use crate::modular::channel::ModularImage;
1771        use crate::modular::frame::{FrameEncoder, FrameEncoderOptions};
1772
1773        let w = self.width as usize;
1774        let h = self.height as usize;
1775
1776        // Normalize pixels to RGB8 for detection if needed (BGR swap)
1777        let rgb_pixels;
1778        let detection_pixels: &[u8] = match self.layout {
1779            PixelLayout::Bgr8 => {
1780                rgb_pixels = bgr_to_rgb(pixels, 3);
1781                &rgb_pixels
1782            }
1783            PixelLayout::Bgra8 => {
1784                rgb_pixels = bgr_to_rgb(pixels, 4);
1785                &rgb_pixels
1786            }
1787            _ => {
1788                rgb_pixels = Vec::new();
1789                let _ = &rgb_pixels;
1790                pixels
1791            }
1792        };
1793
1794        // Build ModularImage from pixel layout
1795        let mut image = match self.layout {
1796            PixelLayout::Rgb8 => ModularImage::from_rgb8(pixels, w, h),
1797            PixelLayout::Rgba8 => ModularImage::from_rgba8(pixels, w, h),
1798            PixelLayout::Bgr8 => ModularImage::from_rgb8(&bgr_to_rgb(pixels, 3), w, h),
1799            PixelLayout::Bgra8 => ModularImage::from_rgba8(&bgr_to_rgb(pixels, 4), w, h),
1800            PixelLayout::Gray8 => ModularImage::from_gray8(pixels, w, h),
1801            PixelLayout::GrayAlpha8 => ModularImage::from_grayalpha8(pixels, w, h),
1802            PixelLayout::Rgb16 => ModularImage::from_rgb16_native(pixels, w, h),
1803            PixelLayout::Rgba16 => ModularImage::from_rgba16_native(pixels, w, h),
1804            PixelLayout::Gray16 => ModularImage::from_gray16_native(pixels, w, h),
1805            PixelLayout::GrayAlpha16 => ModularImage::from_grayalpha16_native(pixels, w, h),
1806            other => return Err(EncodeError::UnsupportedPixelLayout(other)),
1807        }
1808        .map_err(EncodeError::from)?;
1809
1810        // Detect patches for lossless mode (RGB 8-bit only, non-grayscale)
1811        let num_channels = self.layout.bytes_per_pixel();
1812        let can_use_patches =
1813            cfg.patches && !image.is_grayscale && image.bit_depth <= 8 && num_channels >= 3;
1814        let patches_data = if can_use_patches {
1815            crate::vardct::patches::find_and_build_lossless(
1816                detection_pixels,
1817                w,
1818                h,
1819                num_channels,
1820                image.bit_depth,
1821            )
1822        } else {
1823            None
1824        };
1825
1826        // Build file header
1827        let mut file_header = if image.is_grayscale {
1828            FileHeader::new_gray(self.width, self.height)
1829        } else if image.has_alpha {
1830            FileHeader::new_rgba(self.width, self.height)
1831        } else {
1832            FileHeader::new_rgb(self.width, self.height)
1833        };
1834        if image.bit_depth == 16 {
1835            file_header.metadata.bit_depth = crate::headers::file_header::BitDepth::uint16();
1836            for ec in &mut file_header.metadata.extra_channels {
1837                ec.bit_depth = crate::headers::file_header::BitDepth::uint16();
1838            }
1839        }
1840        if let Some(meta) = self.metadata {
1841            if meta.icc_profile.is_some() {
1842                file_header.metadata.color_encoding.want_icc = true;
1843            }
1844            if let Some(it) = meta.intensity_target {
1845                file_header.metadata.intensity_target = it;
1846            }
1847            if let Some(mn) = meta.min_nits {
1848                file_header.metadata.min_nits = mn;
1849            }
1850            if let Some((w, h)) = meta.intrinsic_size {
1851                file_header.metadata.have_intrinsic_size = true;
1852                file_header.metadata.intrinsic_width = w;
1853                file_header.metadata.intrinsic_height = h;
1854            }
1855        }
1856
1857        // Write codestream
1858        let mut writer = BitWriter::new();
1859        file_header.write(&mut writer).map_err(EncodeError::from)?;
1860        if let Some(meta) = self.metadata
1861            && let Some(icc) = meta.icc_profile
1862        {
1863            crate::icc::write_icc(icc, &mut writer).map_err(EncodeError::from)?;
1864        }
1865        writer.zero_pad_to_byte();
1866
1867        // Write reference frame and subtract patches from image if detected
1868        if let Some(ref pd) = patches_data {
1869            let lossless_profile = cfg.effective_profile();
1870            crate::vardct::patches::encode_reference_frame_rgb(
1871                pd,
1872                image.bit_depth,
1873                cfg.use_ans,
1874                lossless_profile.patch_ref_tree_learning,
1875                &mut writer,
1876            )
1877            .map_err(EncodeError::from)?;
1878            writer.zero_pad_to_byte();
1879            let bd = image.bit_depth;
1880            crate::vardct::patches::subtract_patches_modular(&mut image, pd, bd);
1881        }
1882
1883        // Encode frame
1884        let use_tree_learning = cfg.tree_learning;
1885        let frame_encoder = FrameEncoder::new(
1886            w,
1887            h,
1888            FrameEncoderOptions {
1889                use_modular: true,
1890                effort: cfg.effort,
1891                use_ans: cfg.use_ans,
1892                use_tree_learning,
1893                use_squeeze: cfg.squeeze,
1894                enable_lz77: cfg.lz77,
1895                lz77_method: cfg.lz77_method,
1896                lossy_palette: cfg.lossy_palette,
1897                encoder_mode: cfg.mode,
1898                profile: cfg.effective_profile(),
1899                have_animation: false,
1900                duration: 0,
1901                is_last: true,
1902                crop: None,
1903                skip_rct: false,
1904            },
1905        );
1906        let color_encoding = if let Some(ce) = self.color_encoding.clone() {
1907            // Explicit color encoding overrides source_gamma and defaults.
1908            // Adjust for grayscale if needed.
1909            if image.is_grayscale && ce.color_space != ColorSpace::Gray {
1910                ColorEncoding {
1911                    color_space: ColorSpace::Gray,
1912                    ..ce
1913                }
1914            } else {
1915                ce
1916            }
1917        } else if let Some(gamma) = self.source_gamma {
1918            if image.is_grayscale {
1919                ColorEncoding::gray_with_gamma(gamma)
1920            } else {
1921                ColorEncoding::with_gamma(gamma)
1922            }
1923        } else if image.is_grayscale {
1924            ColorEncoding::gray()
1925        } else {
1926            ColorEncoding::srgb()
1927        };
1928        frame_encoder
1929            .encode_modular_with_patches(
1930                &image,
1931                &color_encoding,
1932                &mut writer,
1933                patches_data.as_ref(),
1934            )
1935            .map_err(EncodeError::from)?;
1936
1937        let stats = EncodeStats {
1938            mode: EncodeMode::Lossless,
1939            ans: cfg.use_ans,
1940            ..Default::default()
1941        };
1942        Ok((writer.finish_with_padding(), stats))
1943    }
1944
1945    // ── Lossy path ──────────────────────────────────────────────────────
1946
1947    fn encode_lossy(
1948        &self,
1949        cfg: &LossyConfig,
1950        pixels: &[u8],
1951    ) -> core::result::Result<(Vec<u8>, EncodeStats), EncodeError> {
1952        let w = self.width as usize;
1953        let h = self.height as usize;
1954
1955        // Build linear f32 RGB and extract alpha from input layout.
1956        // Grayscale layouts are expanded to RGB (R=G=B) for VarDCT encoding.
1957        // When source_gamma is set, use gamma linearization instead of sRGB TF.
1958        let gamma = self.source_gamma;
1959        let (linear_rgb, alpha, bit_depth_16) = match self.layout {
1960            PixelLayout::Rgb8 => {
1961                let linear = if let Some(g) = gamma {
1962                    gamma_u8_to_linear_f32(pixels, 3, g)
1963                } else {
1964                    srgb_u8_to_linear_f32(pixels, 3)
1965                };
1966                (linear, None, false)
1967            }
1968            PixelLayout::Bgr8 => {
1969                let rgb = bgr_to_rgb(pixels, 3);
1970                let linear = if let Some(g) = gamma {
1971                    gamma_u8_to_linear_f32(&rgb, 3, g)
1972                } else {
1973                    srgb_u8_to_linear_f32(&rgb, 3)
1974                };
1975                (linear, None, false)
1976            }
1977            PixelLayout::Rgba8 => {
1978                let rgb = if let Some(g) = gamma {
1979                    gamma_u8_to_linear_f32(pixels, 4, g)
1980                } else {
1981                    srgb_u8_to_linear_f32(pixels, 4)
1982                };
1983                let alpha = extract_alpha(pixels, 4, 3);
1984                (rgb, Some(alpha), false)
1985            }
1986            PixelLayout::Bgra8 => {
1987                let swapped = bgr_to_rgb(pixels, 4);
1988                let rgb = if let Some(g) = gamma {
1989                    gamma_u8_to_linear_f32(&swapped, 4, g)
1990                } else {
1991                    srgb_u8_to_linear_f32(&swapped, 4)
1992                };
1993                let alpha = extract_alpha(pixels, 4, 3);
1994                (rgb, Some(alpha), false)
1995            }
1996            PixelLayout::Gray8 => {
1997                let rgb = if let Some(g) = gamma {
1998                    gamma_gray_u8_to_linear_f32_rgb(pixels, 1, g)
1999                } else {
2000                    gray_u8_to_linear_f32_rgb(pixels, 1)
2001                };
2002                (rgb, None, false)
2003            }
2004            PixelLayout::GrayAlpha8 => {
2005                let rgb = if let Some(g) = gamma {
2006                    gamma_gray_u8_to_linear_f32_rgb(pixels, 2, g)
2007                } else {
2008                    gray_u8_to_linear_f32_rgb(pixels, 2)
2009                };
2010                let alpha = extract_alpha(pixels, 2, 1);
2011                (rgb, Some(alpha), false)
2012            }
2013            PixelLayout::Rgb16 => {
2014                let linear = if let Some(g) = gamma {
2015                    gamma_u16_to_linear_f32(pixels, 3, g)
2016                } else {
2017                    srgb_u16_to_linear_f32(pixels, 3)
2018                };
2019                (linear, None, true)
2020            }
2021            PixelLayout::Rgba16 => {
2022                let rgb = if let Some(g) = gamma {
2023                    gamma_u16_to_linear_f32(pixels, 4, g)
2024                } else {
2025                    srgb_u16_to_linear_f32(pixels, 4)
2026                };
2027                let alpha = extract_alpha_u16(pixels, 4, 3);
2028                (rgb, Some(alpha), true)
2029            }
2030            PixelLayout::Gray16 => {
2031                let rgb = if let Some(g) = gamma {
2032                    gamma_gray_u16_to_linear_f32_rgb(pixels, 1, g)
2033                } else {
2034                    gray_u16_to_linear_f32_rgb(pixels, 1)
2035                };
2036                (rgb, None, true)
2037            }
2038            PixelLayout::GrayAlpha16 => {
2039                let rgb = if let Some(g) = gamma {
2040                    gamma_gray_u16_to_linear_f32_rgb(pixels, 2, g)
2041                } else {
2042                    gray_u16_to_linear_f32_rgb(pixels, 2)
2043                };
2044                let alpha = extract_alpha_u16(pixels, 2, 1);
2045                (rgb, Some(alpha), true)
2046            }
2047            PixelLayout::RgbLinearF32 => {
2048                let floats: &[f32] = bytemuck::cast_slice(pixels);
2049                (floats.to_vec(), None, false)
2050            }
2051            PixelLayout::RgbaLinearF32 => {
2052                let floats: &[f32] = bytemuck::cast_slice(pixels);
2053                let rgb: Vec<f32> = floats
2054                    .chunks(4)
2055                    .flat_map(|px| [px[0], px[1], px[2]])
2056                    .collect();
2057                let alpha = extract_alpha_f32(floats, 4, 3);
2058                (rgb, Some(alpha), false)
2059            }
2060            PixelLayout::GrayLinearF32 => {
2061                let floats: &[f32] = bytemuck::cast_slice(pixels);
2062                (gray_f32_to_linear_f32_rgb(floats, 1), None, false)
2063            }
2064            PixelLayout::GrayAlphaLinearF32 => {
2065                let floats: &[f32] = bytemuck::cast_slice(pixels);
2066                let rgb = gray_f32_to_linear_f32_rgb(floats, 2);
2067                let alpha = extract_alpha_f32(floats, 2, 1);
2068                (rgb, Some(alpha), false)
2069            }
2070        };
2071
2072        let mut profile = cfg.effective_profile();
2073
2074        // Apply max_strategy_size to profile flags
2075        if let Some(max_size) = cfg.max_strategy_size {
2076            if max_size < 16 {
2077                profile.try_dct16 = false;
2078            }
2079            if max_size < 32 {
2080                profile.try_dct32 = false;
2081            }
2082            if max_size < 64 {
2083                profile.try_dct64 = false;
2084            }
2085        }
2086
2087        let mut enc = crate::vardct::VarDctEncoder::new(cfg.distance);
2088        enc.effort = cfg.effort;
2089        enc.profile = profile;
2090        enc.use_ans = cfg.use_ans;
2091        enc.optimize_codes = enc.profile.optimize_codes;
2092        enc.custom_orders = enc.profile.custom_orders;
2093        enc.ac_strategy_enabled = enc.profile.ac_strategy_enabled;
2094        enc.enable_noise = cfg.noise;
2095        enc.enable_denoise = cfg.denoise;
2096        // libjxl gates gaborish at distance > 0.5 (enc_frame.cc:281)
2097        enc.enable_gaborish = cfg.gaborish && cfg.distance > 0.5;
2098        enc.error_diffusion = cfg.error_diffusion;
2099        enc.pixel_domain_loss = cfg.pixel_domain_loss;
2100        enc.enable_lz77 = cfg.lz77;
2101        enc.lz77_method = cfg.lz77_method;
2102        enc.force_strategy = cfg.force_strategy;
2103        enc.enable_patches = cfg.patches;
2104        enc.encoder_mode = cfg.mode;
2105        enc.splines = cfg.splines.clone();
2106        enc.is_grayscale = self.layout.is_grayscale();
2107        enc.progressive = cfg.progressive;
2108        enc.use_lf_frame = cfg.lf_frame;
2109        #[cfg(feature = "butteraugli-loop")]
2110        {
2111            enc.butteraugli_iters = cfg.butteraugli_iters;
2112        }
2113        #[cfg(feature = "ssim2-loop")]
2114        {
2115            enc.ssim2_iters = cfg.ssim2_iters;
2116        }
2117        #[cfg(feature = "zensim-loop")]
2118        {
2119            enc.zensim_iters = cfg.zensim_iters;
2120        }
2121
2122        enc.bit_depth_16 = bit_depth_16;
2123        enc.source_gamma = self.source_gamma;
2124        enc.color_encoding = self.color_encoding.clone();
2125
2126        // Tone mapping and intrinsic size from metadata
2127        if let Some(meta) = self.metadata {
2128            if let Some(it) = meta.intensity_target {
2129                enc.intensity_target = it;
2130            }
2131            if let Some(mn) = meta.min_nits {
2132                enc.min_nits = mn;
2133            }
2134            if meta.intrinsic_size.is_some() {
2135                enc.intrinsic_size = meta.intrinsic_size;
2136            }
2137        }
2138
2139        // ICC profile from metadata
2140        if let Some(meta) = self.metadata
2141            && let Some(icc) = meta.icc_profile
2142        {
2143            enc.icc_profile = Some(icc.to_vec());
2144        }
2145
2146        let output = enc
2147            .encode(w, h, &linear_rgb, alpha.as_deref())
2148            .map_err(EncodeError::from)?;
2149
2150        #[cfg(feature = "butteraugli-loop")]
2151        let butteraugli_iters_actual = cfg.butteraugli_iters;
2152        #[cfg(not(feature = "butteraugli-loop"))]
2153        let butteraugli_iters_actual = 0u32;
2154
2155        let stats = EncodeStats {
2156            mode: EncodeMode::Lossy,
2157            strategy_counts: output.strategy_counts,
2158            gaborish: cfg.gaborish,
2159            ans: cfg.use_ans,
2160            butteraugli_iters: butteraugli_iters_actual,
2161            pixel_domain_loss: cfg.pixel_domain_loss,
2162            ..Default::default()
2163        };
2164        Ok((output.data, stats))
2165    }
2166}
2167
2168// ── Streaming Encoders ──────────────────────────────────────────────────────
2169
2170/// Streaming lossy (VarDCT) encoder.
2171///
2172/// Accepts pixel rows incrementally via [`push_rows`](Self::push_rows), then
2173/// encodes on [`finish`](Self::finish). This allows callers to free source pixel
2174/// buffers as rows are pushed, rather than materializing the entire image in
2175/// memory before encoding.
2176///
2177/// ```rust,no_run
2178/// use jxl_encoder::{LossyConfig, PixelLayout};
2179///
2180/// let mut enc = LossyConfig::new(1.0)
2181///     .encoder(800, 600, PixelLayout::Rgb8)?;
2182///
2183/// // Push rows from a streaming source (e.g. PNG decoder)
2184/// # let row_bytes = 800 * 3;
2185/// # let source_rows = vec![0u8; row_bytes * 600];
2186/// for chunk in source_rows.chunks(row_bytes * 100) {
2187///     enc.push_rows(chunk, 100)?;
2188/// }
2189///
2190/// let jxl_bytes = enc.finish()?;
2191/// # Ok::<_, jxl_encoder::At<jxl_encoder::EncodeError>>(())
2192/// ```
2193pub struct LossyEncoder {
2194    cfg: LossyConfig,
2195    width: u32,
2196    height: u32,
2197    layout: PixelLayout,
2198    rows_pushed: u32,
2199    linear_rgb: Vec<f32>,
2200    alpha: Option<Vec<u8>>,
2201    bit_depth_16: bool,
2202    icc_profile: Option<Vec<u8>>,
2203    exif: Option<Vec<u8>>,
2204    xmp: Option<Vec<u8>>,
2205    source_gamma: Option<f32>,
2206    color_encoding: Option<crate::headers::color_encoding::ColorEncoding>,
2207    intensity_target: f32,
2208    min_nits: f32,
2209    intrinsic_size: Option<(u32, u32)>,
2210}
2211
2212impl LossyEncoder {
2213    /// Attach an ICC color profile.
2214    pub fn with_icc_profile(mut self, data: &[u8]) -> Self {
2215        self.icc_profile = Some(data.to_vec());
2216        self
2217    }
2218
2219    /// Attach EXIF data.
2220    pub fn with_exif(mut self, data: &[u8]) -> Self {
2221        self.exif = Some(data.to_vec());
2222        self
2223    }
2224
2225    /// Attach XMP data.
2226    pub fn with_xmp(mut self, data: &[u8]) -> Self {
2227        self.xmp = Some(data.to_vec());
2228        self
2229    }
2230
2231    /// Specify that source pixels use a custom gamma transfer function.
2232    pub fn with_source_gamma(mut self, gamma: f32) -> Self {
2233        self.source_gamma = Some(gamma);
2234        self
2235    }
2236
2237    /// Override the color encoding written to the JXL header.
2238    pub fn with_color_encoding(
2239        mut self,
2240        ce: crate::headers::color_encoding::ColorEncoding,
2241    ) -> Self {
2242        self.color_encoding = Some(ce);
2243        self
2244    }
2245
2246    /// Set the peak display luminance in nits for HDR content.
2247    pub fn with_intensity_target(mut self, nits: f32) -> Self {
2248        self.intensity_target = nits;
2249        self
2250    }
2251
2252    /// Set the minimum display luminance in nits.
2253    pub fn with_min_nits(mut self, nits: f32) -> Self {
2254        self.min_nits = nits;
2255        self
2256    }
2257
2258    /// Set the intrinsic display size.
2259    pub fn with_intrinsic_size(mut self, width: u32, height: u32) -> Self {
2260        self.intrinsic_size = Some((width, height));
2261        self
2262    }
2263
2264    /// Number of rows pushed so far.
2265    pub fn rows_pushed(&self) -> u32 {
2266        self.rows_pushed
2267    }
2268
2269    /// Total expected height.
2270    pub fn height(&self) -> u32 {
2271        self.height
2272    }
2273
2274    /// Push pixel rows into the encoder.
2275    ///
2276    /// `pixels` must contain exactly `width * num_rows * bytes_per_pixel` bytes.
2277    /// Rows are converted to the internal linear f32 format immediately, so the
2278    /// caller can free the source buffer after this call returns.
2279    #[track_caller]
2280    pub fn push_rows(&mut self, pixels: &[u8], num_rows: u32) -> Result<()> {
2281        self.push_rows_inner(pixels, num_rows).map_err(at)
2282    }
2283
2284    fn push_rows_inner(
2285        &mut self,
2286        pixels: &[u8],
2287        num_rows: u32,
2288    ) -> core::result::Result<(), EncodeError> {
2289        if num_rows == 0 {
2290            return Ok(());
2291        }
2292        let remaining = self.height - self.rows_pushed;
2293        if num_rows > remaining {
2294            return Err(EncodeError::InvalidInput {
2295                message: format!(
2296                    "push_rows: {num_rows} rows would exceed image height \
2297                     ({} pushed + {num_rows} > {})",
2298                    self.rows_pushed, self.height
2299                ),
2300            });
2301        }
2302        let w = self.width as usize;
2303        let n = num_rows as usize;
2304        let expected = w
2305            .checked_mul(n)
2306            .and_then(|wn| wn.checked_mul(self.layout.bytes_per_pixel()));
2307        match expected {
2308            Some(expected) if pixels.len() == expected => {}
2309            Some(expected) => {
2310                return Err(EncodeError::InvalidInput {
2311                    message: format!(
2312                        "push_rows: expected {expected} bytes for {w}x{n} {:?}, got {}",
2313                        self.layout,
2314                        pixels.len()
2315                    ),
2316                });
2317            }
2318            None => {
2319                return Err(EncodeError::InvalidInput {
2320                    message: "push_rows: row dimensions overflow".into(),
2321                });
2322            }
2323        }
2324
2325        let gamma = self.source_gamma;
2326
2327        // Convert and append linear RGB
2328        let new_linear: Vec<f32> = match self.layout {
2329            PixelLayout::Rgb8 => {
2330                if let Some(g) = gamma {
2331                    gamma_u8_to_linear_f32(pixels, 3, g)
2332                } else {
2333                    srgb_u8_to_linear_f32(pixels, 3)
2334                }
2335            }
2336            PixelLayout::Bgr8 => {
2337                let rgb = bgr_to_rgb(pixels, 3);
2338                if let Some(g) = gamma {
2339                    gamma_u8_to_linear_f32(&rgb, 3, g)
2340                } else {
2341                    srgb_u8_to_linear_f32(&rgb, 3)
2342                }
2343            }
2344            PixelLayout::Rgba8 => {
2345                if let Some(g) = gamma {
2346                    gamma_u8_to_linear_f32(pixels, 4, g)
2347                } else {
2348                    srgb_u8_to_linear_f32(pixels, 4)
2349                }
2350            }
2351            PixelLayout::Bgra8 => {
2352                let swapped = bgr_to_rgb(pixels, 4);
2353                if let Some(g) = gamma {
2354                    gamma_u8_to_linear_f32(&swapped, 4, g)
2355                } else {
2356                    srgb_u8_to_linear_f32(&swapped, 4)
2357                }
2358            }
2359            PixelLayout::Gray8 => {
2360                if let Some(g) = gamma {
2361                    gamma_gray_u8_to_linear_f32_rgb(pixels, 1, g)
2362                } else {
2363                    gray_u8_to_linear_f32_rgb(pixels, 1)
2364                }
2365            }
2366            PixelLayout::GrayAlpha8 => {
2367                if let Some(g) = gamma {
2368                    gamma_gray_u8_to_linear_f32_rgb(pixels, 2, g)
2369                } else {
2370                    gray_u8_to_linear_f32_rgb(pixels, 2)
2371                }
2372            }
2373            PixelLayout::Rgb16 => {
2374                if let Some(g) = gamma {
2375                    gamma_u16_to_linear_f32(pixels, 3, g)
2376                } else {
2377                    srgb_u16_to_linear_f32(pixels, 3)
2378                }
2379            }
2380            PixelLayout::Rgba16 => {
2381                if let Some(g) = gamma {
2382                    gamma_u16_to_linear_f32(pixels, 4, g)
2383                } else {
2384                    srgb_u16_to_linear_f32(pixels, 4)
2385                }
2386            }
2387            PixelLayout::Gray16 => {
2388                if let Some(g) = gamma {
2389                    gamma_gray_u16_to_linear_f32_rgb(pixels, 1, g)
2390                } else {
2391                    gray_u16_to_linear_f32_rgb(pixels, 1)
2392                }
2393            }
2394            PixelLayout::GrayAlpha16 => {
2395                if let Some(g) = gamma {
2396                    gamma_gray_u16_to_linear_f32_rgb(pixels, 2, g)
2397                } else {
2398                    gray_u16_to_linear_f32_rgb(pixels, 2)
2399                }
2400            }
2401            PixelLayout::RgbLinearF32 => {
2402                let floats: &[f32] = bytemuck::cast_slice(pixels);
2403                floats.to_vec()
2404            }
2405            PixelLayout::RgbaLinearF32 => {
2406                let floats: &[f32] = bytemuck::cast_slice(pixels);
2407                floats
2408                    .chunks(4)
2409                    .flat_map(|px| [px[0], px[1], px[2]])
2410                    .collect()
2411            }
2412            PixelLayout::GrayLinearF32 => {
2413                let floats: &[f32] = bytemuck::cast_slice(pixels);
2414                gray_f32_to_linear_f32_rgb(floats, 1)
2415            }
2416            PixelLayout::GrayAlphaLinearF32 => {
2417                let floats: &[f32] = bytemuck::cast_slice(pixels);
2418                gray_f32_to_linear_f32_rgb(floats, 2)
2419            }
2420        };
2421        self.linear_rgb.extend_from_slice(&new_linear);
2422
2423        // Extract and append alpha
2424        match self.layout {
2425            PixelLayout::Rgba8 | PixelLayout::Bgra8 => {
2426                let new_alpha = extract_alpha(pixels, 4, 3);
2427                self.alpha
2428                    .get_or_insert_with(Vec::new)
2429                    .extend_from_slice(&new_alpha);
2430            }
2431            PixelLayout::GrayAlpha8 => {
2432                let new_alpha = extract_alpha(pixels, 2, 1);
2433                self.alpha
2434                    .get_or_insert_with(Vec::new)
2435                    .extend_from_slice(&new_alpha);
2436            }
2437            PixelLayout::Rgba16 => {
2438                let new_alpha = extract_alpha_u16(pixels, 4, 3);
2439                self.alpha
2440                    .get_or_insert_with(Vec::new)
2441                    .extend_from_slice(&new_alpha);
2442            }
2443            PixelLayout::GrayAlpha16 => {
2444                let new_alpha = extract_alpha_u16(pixels, 2, 1);
2445                self.alpha
2446                    .get_or_insert_with(Vec::new)
2447                    .extend_from_slice(&new_alpha);
2448            }
2449            PixelLayout::RgbaLinearF32 => {
2450                let floats: &[f32] = bytemuck::cast_slice(pixels);
2451                let new_alpha = extract_alpha_f32(floats, 4, 3);
2452                self.alpha
2453                    .get_or_insert_with(Vec::new)
2454                    .extend_from_slice(&new_alpha);
2455            }
2456            PixelLayout::GrayAlphaLinearF32 => {
2457                let floats: &[f32] = bytemuck::cast_slice(pixels);
2458                let new_alpha = extract_alpha_f32(floats, 2, 1);
2459                self.alpha
2460                    .get_or_insert_with(Vec::new)
2461                    .extend_from_slice(&new_alpha);
2462            }
2463            _ => {}
2464        }
2465
2466        self.rows_pushed += num_rows;
2467        Ok(())
2468    }
2469
2470    /// Encode the accumulated pixels and return the JXL bytes.
2471    ///
2472    /// All rows must have been pushed via [`push_rows`](Self::push_rows) before
2473    /// calling this. Returns an error if the image is incomplete.
2474    #[track_caller]
2475    pub fn finish(self) -> Result<Vec<u8>> {
2476        self.finish_inner()
2477            .map(|mut r| r.take_data().unwrap())
2478            .map_err(at)
2479    }
2480
2481    /// Encode and return JXL bytes together with [`EncodeStats`].
2482    #[track_caller]
2483    pub fn finish_with_stats(self) -> Result<EncodeResult> {
2484        self.finish_inner().map_err(at)
2485    }
2486
2487    /// Encode, appending to an existing buffer.
2488    #[track_caller]
2489    pub fn finish_into(self, out: &mut Vec<u8>) -> Result<EncodeResult> {
2490        let mut result = self.finish_inner().map_err(at)?;
2491        if let Some(data) = result.data.take() {
2492            out.extend_from_slice(&data);
2493        }
2494        Ok(result)
2495    }
2496
2497    /// Encode, writing to a `std::io::Write` destination.
2498    #[cfg(feature = "std")]
2499    #[track_caller]
2500    pub fn finish_to(self, mut dest: impl std::io::Write) -> Result<EncodeResult> {
2501        let mut result = self.finish_inner().map_err(at)?;
2502        if let Some(data) = result.data.take() {
2503            dest.write_all(&data)
2504                .map_err(|e| at(EncodeError::from(e)))?;
2505        }
2506        Ok(result)
2507    }
2508
2509    fn finish_inner(self) -> core::result::Result<EncodeResult, EncodeError> {
2510        if self.rows_pushed != self.height {
2511            return Err(EncodeError::InvalidInput {
2512                message: format!(
2513                    "incomplete image: {} of {} rows pushed",
2514                    self.rows_pushed, self.height
2515                ),
2516            });
2517        }
2518
2519        let cfg = &self.cfg;
2520        let w = self.width as usize;
2521        let h = self.height as usize;
2522        let linear_rgb = self.linear_rgb;
2523        let alpha = self.alpha;
2524
2525        let (codestream, mut stats) = run_with_threads(cfg.threads, || {
2526            let mut profile = cfg.effective_profile();
2527            if let Some(max_size) = cfg.max_strategy_size {
2528                if max_size < 16 {
2529                    profile.try_dct16 = false;
2530                }
2531                if max_size < 32 {
2532                    profile.try_dct32 = false;
2533                }
2534                if max_size < 64 {
2535                    profile.try_dct64 = false;
2536                }
2537            }
2538
2539            let mut enc = crate::vardct::VarDctEncoder::new(cfg.distance);
2540            enc.effort = cfg.effort;
2541            enc.profile = profile;
2542            enc.use_ans = cfg.use_ans;
2543            enc.optimize_codes = enc.profile.optimize_codes;
2544            enc.custom_orders = enc.profile.custom_orders;
2545            enc.ac_strategy_enabled = enc.profile.ac_strategy_enabled;
2546            enc.enable_noise = cfg.noise;
2547            enc.enable_denoise = cfg.denoise;
2548            enc.enable_gaborish = cfg.gaborish && cfg.distance > 0.5;
2549            enc.error_diffusion = cfg.error_diffusion;
2550            enc.pixel_domain_loss = cfg.pixel_domain_loss;
2551            enc.enable_lz77 = cfg.lz77;
2552            enc.lz77_method = cfg.lz77_method;
2553            enc.force_strategy = cfg.force_strategy;
2554            enc.enable_patches = cfg.patches;
2555            enc.encoder_mode = cfg.mode;
2556            enc.splines = cfg.splines.clone();
2557            enc.is_grayscale = self.layout.is_grayscale();
2558            enc.progressive = cfg.progressive;
2559            enc.use_lf_frame = cfg.lf_frame;
2560            #[cfg(feature = "butteraugli-loop")]
2561            {
2562                enc.butteraugli_iters = cfg.butteraugli_iters;
2563            }
2564            enc.bit_depth_16 = self.bit_depth_16;
2565            enc.source_gamma = self.source_gamma;
2566            enc.color_encoding = self.color_encoding.clone();
2567            enc.intensity_target = self.intensity_target;
2568            enc.min_nits = self.min_nits;
2569            enc.intrinsic_size = self.intrinsic_size;
2570            if let Some(ref icc) = self.icc_profile {
2571                enc.icc_profile = Some(icc.clone());
2572            }
2573
2574            let output = enc
2575                .encode(w, h, &linear_rgb, alpha.as_deref())
2576                .map_err(EncodeError::from)?;
2577
2578            #[cfg(feature = "butteraugli-loop")]
2579            let butteraugli_iters_actual = cfg.butteraugli_iters;
2580            #[cfg(not(feature = "butteraugli-loop"))]
2581            let butteraugli_iters_actual = 0u32;
2582
2583            let stats = EncodeStats {
2584                mode: EncodeMode::Lossy,
2585                strategy_counts: output.strategy_counts,
2586                gaborish: cfg.gaborish,
2587                ans: cfg.use_ans,
2588                butteraugli_iters: butteraugli_iters_actual,
2589                pixel_domain_loss: cfg.pixel_domain_loss,
2590                ..Default::default()
2591            };
2592            Ok::<_, EncodeError>((output.data, stats))
2593        })?;
2594
2595        stats.codestream_size = codestream.len();
2596
2597        let output = if self.exif.is_some() || self.xmp.is_some() {
2598            crate::container::wrap_in_container(
2599                &codestream,
2600                self.exif.as_deref(),
2601                self.xmp.as_deref(),
2602            )
2603        } else {
2604            codestream
2605        };
2606
2607        stats.output_size = output.len();
2608        Ok(EncodeResult {
2609            data: Some(output),
2610            stats,
2611        })
2612    }
2613}
2614
2615impl LossyConfig {
2616    /// Create a streaming encoder for incremental row input.
2617    ///
2618    /// Pixels are converted to the internal format as rows are pushed via
2619    /// [`LossyEncoder::push_rows`], allowing callers to free source buffers
2620    /// incrementally rather than materializing the entire image.
2621    #[track_caller]
2622    pub fn encoder(&self, width: u32, height: u32, layout: PixelLayout) -> Result<LossyEncoder> {
2623        if width == 0 || height == 0 {
2624            return Err(at(EncodeError::InvalidInput {
2625                message: format!("zero dimensions: {width}x{height}"),
2626            }));
2627        }
2628        let w = width as usize;
2629        let h = height as usize;
2630        let rgb_capacity = w.checked_mul(h).and_then(|n| n.checked_mul(3));
2631        let Some(rgb_capacity) = rgb_capacity else {
2632            return Err(at(EncodeError::InvalidInput {
2633                message: "image dimensions overflow".into(),
2634            }));
2635        };
2636
2637        let bit_depth_16 = layout.is_16bit();
2638        let has_alpha = layout.has_alpha();
2639        let alpha = if has_alpha {
2640            let mut v = Vec::new();
2641            v.try_reserve(w * h)
2642                .map_err(|e| at(EncodeError::from(crate::error::Error::from(e))))?;
2643            Some(v)
2644        } else {
2645            None
2646        };
2647
2648        let mut linear_rgb = Vec::new();
2649        linear_rgb
2650            .try_reserve(rgb_capacity)
2651            .map_err(|e| at(EncodeError::from(crate::error::Error::from(e))))?;
2652
2653        Ok(LossyEncoder {
2654            cfg: self.clone(),
2655            width,
2656            height,
2657            layout,
2658            rows_pushed: 0,
2659            linear_rgb,
2660            alpha,
2661            bit_depth_16,
2662            icc_profile: None,
2663            exif: None,
2664            xmp: None,
2665            source_gamma: None,
2666            color_encoding: None,
2667            intensity_target: 255.0,
2668            min_nits: 0.0,
2669            intrinsic_size: None,
2670        })
2671    }
2672}
2673
2674/// Streaming lossless (modular) encoder.
2675///
2676/// Accepts pixel rows incrementally via [`push_rows`](Self::push_rows), then
2677/// encodes on [`finish`](Self::finish). This allows callers to free source pixel
2678/// buffers as rows are pushed, rather than materializing the entire image in
2679/// memory before encoding.
2680///
2681/// ```rust,no_run
2682/// use jxl_encoder::{LosslessConfig, PixelLayout};
2683///
2684/// let mut enc = LosslessConfig::new()
2685///     .encoder(800, 600, PixelLayout::Rgb8)?;
2686///
2687/// # let row_bytes = 800 * 3;
2688/// # let source_rows = vec![0u8; row_bytes * 600];
2689/// for chunk in source_rows.chunks(row_bytes * 100) {
2690///     enc.push_rows(chunk, 100)?;
2691/// }
2692///
2693/// let jxl_bytes = enc.finish()?;
2694/// # Ok::<_, jxl_encoder::At<jxl_encoder::EncodeError>>(())
2695/// ```
2696pub struct LosslessEncoder {
2697    cfg: LosslessConfig,
2698    width: u32,
2699    height: u32,
2700    layout: PixelLayout,
2701    rows_pushed: u32,
2702    channels: Vec<crate::modular::channel::Channel>,
2703    num_source_channels: usize,
2704    bit_depth: u32,
2705    is_grayscale: bool,
2706    has_alpha: bool,
2707    icc_profile: Option<Vec<u8>>,
2708    exif: Option<Vec<u8>>,
2709    xmp: Option<Vec<u8>>,
2710    source_gamma: Option<f32>,
2711    color_encoding: Option<crate::headers::color_encoding::ColorEncoding>,
2712    intensity_target: f32,
2713    min_nits: f32,
2714    intrinsic_size: Option<(u32, u32)>,
2715}
2716
2717impl LosslessEncoder {
2718    /// Attach an ICC color profile.
2719    pub fn with_icc_profile(mut self, data: &[u8]) -> Self {
2720        self.icc_profile = Some(data.to_vec());
2721        self
2722    }
2723
2724    /// Attach EXIF data.
2725    pub fn with_exif(mut self, data: &[u8]) -> Self {
2726        self.exif = Some(data.to_vec());
2727        self
2728    }
2729
2730    /// Attach XMP data.
2731    pub fn with_xmp(mut self, data: &[u8]) -> Self {
2732        self.xmp = Some(data.to_vec());
2733        self
2734    }
2735
2736    /// Specify that source pixels use a custom gamma transfer function.
2737    pub fn with_source_gamma(mut self, gamma: f32) -> Self {
2738        self.source_gamma = Some(gamma);
2739        self
2740    }
2741
2742    /// Override the color encoding written to the JXL header.
2743    pub fn with_color_encoding(
2744        mut self,
2745        ce: crate::headers::color_encoding::ColorEncoding,
2746    ) -> Self {
2747        self.color_encoding = Some(ce);
2748        self
2749    }
2750
2751    /// Set the peak display luminance in nits for HDR content.
2752    pub fn with_intensity_target(mut self, nits: f32) -> Self {
2753        self.intensity_target = nits;
2754        self
2755    }
2756
2757    /// Set the minimum display luminance in nits.
2758    pub fn with_min_nits(mut self, nits: f32) -> Self {
2759        self.min_nits = nits;
2760        self
2761    }
2762
2763    /// Set the intrinsic display size.
2764    pub fn with_intrinsic_size(mut self, width: u32, height: u32) -> Self {
2765        self.intrinsic_size = Some((width, height));
2766        self
2767    }
2768
2769    /// Number of rows pushed so far.
2770    pub fn rows_pushed(&self) -> u32 {
2771        self.rows_pushed
2772    }
2773
2774    /// Total expected height.
2775    pub fn height(&self) -> u32 {
2776        self.height
2777    }
2778
2779    /// Push pixel rows into the encoder.
2780    ///
2781    /// `pixels` must contain exactly `width * num_rows * bytes_per_pixel` bytes.
2782    /// Rows are deinterleaved into per-channel planes immediately, so the caller
2783    /// can free the source buffer after this call returns.
2784    #[track_caller]
2785    pub fn push_rows(&mut self, pixels: &[u8], num_rows: u32) -> Result<()> {
2786        self.push_rows_inner(pixels, num_rows).map_err(at)
2787    }
2788
2789    fn push_rows_inner(
2790        &mut self,
2791        pixels: &[u8],
2792        num_rows: u32,
2793    ) -> core::result::Result<(), EncodeError> {
2794        if num_rows == 0 {
2795            return Ok(());
2796        }
2797        let remaining = self.height - self.rows_pushed;
2798        if num_rows > remaining {
2799            return Err(EncodeError::InvalidInput {
2800                message: format!(
2801                    "push_rows: {num_rows} rows would exceed image height \
2802                     ({} pushed + {num_rows} > {})",
2803                    self.rows_pushed, self.height
2804                ),
2805            });
2806        }
2807        let w = self.width as usize;
2808        let n = num_rows as usize;
2809        let bpp = self.layout.bytes_per_pixel();
2810        let expected = w.checked_mul(n).and_then(|wn| wn.checked_mul(bpp));
2811        match expected {
2812            Some(expected) if pixels.len() == expected => {}
2813            Some(expected) => {
2814                return Err(EncodeError::InvalidInput {
2815                    message: format!(
2816                        "push_rows: expected {expected} bytes for {w}x{n} {:?}, got {}",
2817                        self.layout,
2818                        pixels.len()
2819                    ),
2820                });
2821            }
2822            None => {
2823                return Err(EncodeError::InvalidInput {
2824                    message: "push_rows: row dimensions overflow".into(),
2825                });
2826            }
2827        }
2828
2829        let y_start = self.rows_pushed as usize;
2830        let nc = self.num_source_channels;
2831
2832        match self.layout {
2833            PixelLayout::Rgb8 | PixelLayout::Bgr8 => {
2834                let is_bgr = matches!(self.layout, PixelLayout::Bgr8);
2835                for y in 0..n {
2836                    let row_offset = y * w * 3;
2837                    let dst_y = y_start + y;
2838                    for x in 0..w {
2839                        let src = row_offset + x * 3;
2840                        let (r, g, b) = if is_bgr {
2841                            (pixels[src + 2], pixels[src + 1], pixels[src])
2842                        } else {
2843                            (pixels[src], pixels[src + 1], pixels[src + 2])
2844                        };
2845                        self.channels[0].set(x, dst_y, r as i32);
2846                        self.channels[1].set(x, dst_y, g as i32);
2847                        self.channels[2].set(x, dst_y, b as i32);
2848                    }
2849                }
2850            }
2851            PixelLayout::Rgba8 | PixelLayout::Bgra8 => {
2852                let is_bgr = matches!(self.layout, PixelLayout::Bgra8);
2853                for y in 0..n {
2854                    let row_offset = y * w * 4;
2855                    let dst_y = y_start + y;
2856                    for x in 0..w {
2857                        let src = row_offset + x * 4;
2858                        let (r, g, b) = if is_bgr {
2859                            (pixels[src + 2], pixels[src + 1], pixels[src])
2860                        } else {
2861                            (pixels[src], pixels[src + 1], pixels[src + 2])
2862                        };
2863                        self.channels[0].set(x, dst_y, r as i32);
2864                        self.channels[1].set(x, dst_y, g as i32);
2865                        self.channels[2].set(x, dst_y, b as i32);
2866                        self.channels[3].set(x, dst_y, pixels[src + 3] as i32);
2867                    }
2868                }
2869            }
2870            PixelLayout::Gray8 => {
2871                for y in 0..n {
2872                    let row_offset = y * w;
2873                    let dst_y = y_start + y;
2874                    for x in 0..w {
2875                        self.channels[0].set(x, dst_y, pixels[row_offset + x] as i32);
2876                    }
2877                }
2878            }
2879            PixelLayout::GrayAlpha8 => {
2880                for y in 0..n {
2881                    let row_offset = y * w * 2;
2882                    let dst_y = y_start + y;
2883                    for x in 0..w {
2884                        let src = row_offset + x * 2;
2885                        self.channels[0].set(x, dst_y, pixels[src] as i32);
2886                        self.channels[1].set(x, dst_y, pixels[src + 1] as i32);
2887                    }
2888                }
2889            }
2890            PixelLayout::Rgb16
2891            | PixelLayout::Rgba16
2892            | PixelLayout::Gray16
2893            | PixelLayout::GrayAlpha16 => {
2894                let pixels_u16: &[u16] = bytemuck::cast_slice(pixels);
2895                for y in 0..n {
2896                    let row_offset = y * w * nc;
2897                    let dst_y = y_start + y;
2898                    for x in 0..w {
2899                        let src = row_offset + x * nc;
2900                        for c in 0..nc {
2901                            self.channels[c].set(x, dst_y, pixels_u16[src + c] as i32);
2902                        }
2903                    }
2904                }
2905            }
2906            _ => {
2907                return Err(EncodeError::UnsupportedPixelLayout(self.layout));
2908            }
2909        }
2910
2911        self.rows_pushed += num_rows;
2912        Ok(())
2913    }
2914
2915    /// Encode the accumulated pixels and return the JXL bytes.
2916    ///
2917    /// All rows must have been pushed via [`push_rows`](Self::push_rows) before
2918    /// calling this. Returns an error if the image is incomplete.
2919    #[track_caller]
2920    pub fn finish(self) -> Result<Vec<u8>> {
2921        self.finish_inner()
2922            .map(|mut r| r.take_data().unwrap())
2923            .map_err(at)
2924    }
2925
2926    /// Encode and return JXL bytes together with [`EncodeStats`].
2927    #[track_caller]
2928    pub fn finish_with_stats(self) -> Result<EncodeResult> {
2929        self.finish_inner().map_err(at)
2930    }
2931
2932    /// Encode, appending to an existing buffer.
2933    #[track_caller]
2934    pub fn finish_into(self, out: &mut Vec<u8>) -> Result<EncodeResult> {
2935        let mut result = self.finish_inner().map_err(at)?;
2936        if let Some(data) = result.data.take() {
2937            out.extend_from_slice(&data);
2938        }
2939        Ok(result)
2940    }
2941
2942    /// Encode, writing to a `std::io::Write` destination.
2943    #[cfg(feature = "std")]
2944    #[track_caller]
2945    pub fn finish_to(self, mut dest: impl std::io::Write) -> Result<EncodeResult> {
2946        let mut result = self.finish_inner().map_err(at)?;
2947        if let Some(data) = result.data.take() {
2948            dest.write_all(&data)
2949                .map_err(|e| at(EncodeError::from(e)))?;
2950        }
2951        Ok(result)
2952    }
2953
2954    fn finish_inner(self) -> core::result::Result<EncodeResult, EncodeError> {
2955        use crate::bit_writer::BitWriter;
2956        use crate::headers::color_encoding::ColorSpace;
2957        use crate::headers::{ColorEncoding, FileHeader};
2958        use crate::modular::channel::ModularImage;
2959        use crate::modular::frame::{FrameEncoder, FrameEncoderOptions};
2960
2961        if self.rows_pushed != self.height {
2962            return Err(EncodeError::InvalidInput {
2963                message: format!(
2964                    "incomplete image: {} of {} rows pushed",
2965                    self.rows_pushed, self.height
2966                ),
2967            });
2968        }
2969
2970        let cfg = &self.cfg;
2971        let w = self.width as usize;
2972        let h = self.height as usize;
2973
2974        let mut image = ModularImage {
2975            channels: self.channels,
2976            bit_depth: self.bit_depth,
2977            is_grayscale: self.is_grayscale,
2978            has_alpha: self.has_alpha,
2979        };
2980
2981        let (codestream, mut stats) = run_with_threads(cfg.threads, || {
2982            // Reconstruct interleaved pixels for patch detection (8-bit RGB only)
2983            let num_channels = self.layout.bytes_per_pixel();
2984            let can_use_patches =
2985                cfg.patches && !image.is_grayscale && image.bit_depth <= 8 && num_channels >= 3;
2986            let patches_data = if can_use_patches {
2987                let mut detection_pixels = vec![0u8; w * h * num_channels];
2988                let nc = core::cmp::min(num_channels, image.channels.len());
2989                for y in 0..h {
2990                    for x in 0..w {
2991                        for c in 0..nc {
2992                            detection_pixels[(y * w + x) * num_channels + c] =
2993                                image.channels[c].get(x, y) as u8;
2994                        }
2995                        // Fill remaining channels (alpha) from the image
2996                        for c in nc..num_channels {
2997                            if c < image.channels.len() {
2998                                detection_pixels[(y * w + x) * num_channels + c] =
2999                                    image.channels[c].get(x, y) as u8;
3000                            }
3001                        }
3002                    }
3003                }
3004                crate::vardct::patches::find_and_build_lossless(
3005                    &detection_pixels,
3006                    w,
3007                    h,
3008                    num_channels,
3009                    image.bit_depth,
3010                )
3011            } else {
3012                None
3013            };
3014
3015            // Build file header
3016            let mut file_header = if image.is_grayscale {
3017                FileHeader::new_gray(self.width, self.height)
3018            } else if image.has_alpha {
3019                FileHeader::new_rgba(self.width, self.height)
3020            } else {
3021                FileHeader::new_rgb(self.width, self.height)
3022            };
3023            if image.bit_depth == 16 {
3024                file_header.metadata.bit_depth = crate::headers::file_header::BitDepth::uint16();
3025                for ec in &mut file_header.metadata.extra_channels {
3026                    ec.bit_depth = crate::headers::file_header::BitDepth::uint16();
3027                }
3028            }
3029            if self.icc_profile.is_some() {
3030                file_header.metadata.color_encoding.want_icc = true;
3031            }
3032            file_header.metadata.intensity_target = self.intensity_target;
3033            file_header.metadata.min_nits = self.min_nits;
3034            if let Some((w, h)) = self.intrinsic_size {
3035                file_header.metadata.have_intrinsic_size = true;
3036                file_header.metadata.intrinsic_width = w;
3037                file_header.metadata.intrinsic_height = h;
3038            }
3039
3040            let mut writer = BitWriter::new();
3041            file_header.write(&mut writer).map_err(EncodeError::from)?;
3042            if let Some(ref icc) = self.icc_profile {
3043                crate::icc::write_icc(icc, &mut writer).map_err(EncodeError::from)?;
3044            }
3045            writer.zero_pad_to_byte();
3046
3047            // Write reference frame and subtract patches
3048            if let Some(ref pd) = patches_data {
3049                let lossless_profile = cfg.effective_profile();
3050                crate::vardct::patches::encode_reference_frame_rgb(
3051                    pd,
3052                    image.bit_depth,
3053                    cfg.use_ans,
3054                    lossless_profile.patch_ref_tree_learning,
3055                    &mut writer,
3056                )
3057                .map_err(EncodeError::from)?;
3058                writer.zero_pad_to_byte();
3059                let bd = image.bit_depth;
3060                crate::vardct::patches::subtract_patches_modular(&mut image, pd, bd);
3061            }
3062
3063            // Encode frame
3064            let frame_encoder = FrameEncoder::new(
3065                w,
3066                h,
3067                FrameEncoderOptions {
3068                    use_modular: true,
3069                    effort: cfg.effort,
3070                    use_ans: cfg.use_ans,
3071                    use_tree_learning: cfg.tree_learning,
3072                    use_squeeze: cfg.squeeze,
3073                    enable_lz77: cfg.lz77,
3074                    lz77_method: cfg.lz77_method,
3075                    lossy_palette: cfg.lossy_palette,
3076                    encoder_mode: cfg.mode,
3077                    profile: cfg.effective_profile(),
3078                    have_animation: false,
3079                    duration: 0,
3080                    is_last: true,
3081                    crop: None,
3082                    skip_rct: false,
3083                },
3084            );
3085            let color_encoding = if let Some(ce) = self.color_encoding.clone() {
3086                if image.is_grayscale && ce.color_space != ColorSpace::Gray {
3087                    ColorEncoding {
3088                        color_space: ColorSpace::Gray,
3089                        ..ce
3090                    }
3091                } else {
3092                    ce
3093                }
3094            } else if let Some(gamma) = self.source_gamma {
3095                if image.is_grayscale {
3096                    ColorEncoding::gray_with_gamma(gamma)
3097                } else {
3098                    ColorEncoding::with_gamma(gamma)
3099                }
3100            } else if image.is_grayscale {
3101                ColorEncoding::gray()
3102            } else {
3103                ColorEncoding::srgb()
3104            };
3105            frame_encoder
3106                .encode_modular_with_patches(
3107                    &image,
3108                    &color_encoding,
3109                    &mut writer,
3110                    patches_data.as_ref(),
3111                )
3112                .map_err(EncodeError::from)?;
3113
3114            let stats = EncodeStats {
3115                mode: EncodeMode::Lossless,
3116                ans: cfg.use_ans,
3117                ..Default::default()
3118            };
3119            Ok::<_, EncodeError>((writer.finish_with_padding(), stats))
3120        })?;
3121
3122        stats.codestream_size = codestream.len();
3123
3124        let output = if self.exif.is_some() || self.xmp.is_some() {
3125            crate::container::wrap_in_container(
3126                &codestream,
3127                self.exif.as_deref(),
3128                self.xmp.as_deref(),
3129            )
3130        } else {
3131            codestream
3132        };
3133
3134        stats.output_size = output.len();
3135        Ok(EncodeResult {
3136            data: Some(output),
3137            stats,
3138        })
3139    }
3140}
3141
3142impl LosslessConfig {
3143    /// Create a streaming encoder for incremental row input.
3144    ///
3145    /// Per-channel planes are pre-allocated and filled as rows are pushed via
3146    /// [`LosslessEncoder::push_rows`], allowing callers to free source buffers
3147    /// incrementally rather than materializing the entire image.
3148    #[track_caller]
3149    pub fn encoder(&self, width: u32, height: u32, layout: PixelLayout) -> Result<LosslessEncoder> {
3150        use crate::modular::channel::Channel;
3151
3152        if width == 0 || height == 0 {
3153            return Err(at(EncodeError::InvalidInput {
3154                message: format!("zero dimensions: {width}x{height}"),
3155            }));
3156        }
3157
3158        let w = width as usize;
3159        let h = height as usize;
3160
3161        let (num_channels, bit_depth, is_grayscale, has_alpha) = match layout {
3162            PixelLayout::Rgb8 | PixelLayout::Bgr8 => (3, 8u32, false, false),
3163            PixelLayout::Rgba8 | PixelLayout::Bgra8 => (4, 8, false, true),
3164            PixelLayout::Gray8 => (1, 8, true, false),
3165            PixelLayout::GrayAlpha8 => (2, 8, true, true),
3166            PixelLayout::Rgb16 => (3, 16, false, false),
3167            PixelLayout::Rgba16 => (4, 16, false, true),
3168            PixelLayout::Gray16 => (1, 16, true, false),
3169            PixelLayout::GrayAlpha16 => (2, 16, true, true),
3170            other => return Err(at(EncodeError::UnsupportedPixelLayout(other))),
3171        };
3172
3173        let mut channels = Vec::with_capacity(num_channels);
3174        for _ in 0..num_channels {
3175            channels.push(Channel::new(w, h).map_err(|e| at(EncodeError::from(e)))?);
3176        }
3177
3178        Ok(LosslessEncoder {
3179            cfg: self.clone(),
3180            width,
3181            height,
3182            layout,
3183            rows_pushed: 0,
3184            channels,
3185            num_source_channels: num_channels,
3186            bit_depth,
3187            is_grayscale,
3188            has_alpha,
3189            icc_profile: None,
3190            exif: None,
3191            xmp: None,
3192            source_gamma: None,
3193            color_encoding: None,
3194            intensity_target: 255.0,
3195            min_nits: 0.0,
3196            intrinsic_size: None,
3197        })
3198    }
3199}
3200
3201// ── Thread pool helper ──────────────────────────────────────────────────────
3202
3203/// Run a closure inside a rayon thread pool when the `parallel` feature
3204/// is enabled and `threads > 1`. Otherwise, just call the closure directly.
3205///
3206/// - `threads == 0`: use the ambient rayon pool (caller controls via
3207///   `pool.install()` or the global default).
3208/// - `threads == 1`: sequential — call `f()` on the current thread.
3209/// - `threads >= 2`: create a dedicated pool with that many threads.
3210#[cfg(feature = "parallel")]
3211fn run_with_threads<T>(threads: usize, f: impl FnOnce() -> T + Send) -> T
3212where
3213    T: Send,
3214{
3215    if threads <= 1 {
3216        return f();
3217    }
3218    match rayon::ThreadPoolBuilder::new().num_threads(threads).build() {
3219        Ok(pool) => pool.install(f),
3220        Err(_) => f(),
3221    }
3222}
3223
3224#[cfg(not(feature = "parallel"))]
3225fn run_with_threads<T>(_threads: usize, f: impl FnOnce() -> T) -> T {
3226    f()
3227}
3228
3229// ── Animation encode implementations ────────────────────────────────────────
3230
3231fn validate_animation_input(
3232    width: u32,
3233    height: u32,
3234    layout: PixelLayout,
3235    frames: &[AnimationFrame<'_>],
3236) -> core::result::Result<(), EncodeError> {
3237    if width == 0 || height == 0 {
3238        return Err(EncodeError::InvalidInput {
3239            message: format!("zero dimensions: {width}x{height}"),
3240        });
3241    }
3242    if frames.is_empty() {
3243        return Err(EncodeError::InvalidInput {
3244            message: "animation requires at least one frame".into(),
3245        });
3246    }
3247    let expected_size = (width as usize)
3248        .checked_mul(height as usize)
3249        .and_then(|n| n.checked_mul(layout.bytes_per_pixel()))
3250        .ok_or_else(|| EncodeError::InvalidInput {
3251            message: "image dimensions overflow".into(),
3252        })?;
3253    for (i, frame) in frames.iter().enumerate() {
3254        if frame.pixels.len() != expected_size {
3255            return Err(EncodeError::InvalidInput {
3256                message: format!(
3257                    "frame {} pixel buffer size mismatch: expected {expected_size}, got {}",
3258                    i,
3259                    frame.pixels.len()
3260                ),
3261            });
3262        }
3263    }
3264    Ok(())
3265}
3266
3267fn encode_animation_lossless(
3268    cfg: &LosslessConfig,
3269    width: u32,
3270    height: u32,
3271    layout: PixelLayout,
3272    animation: &AnimationParams,
3273    frames: &[AnimationFrame<'_>],
3274) -> core::result::Result<Vec<u8>, EncodeError> {
3275    use crate::bit_writer::BitWriter;
3276    use crate::headers::file_header::AnimationHeader;
3277    use crate::headers::{ColorEncoding, FileHeader};
3278    use crate::modular::channel::ModularImage;
3279    use crate::modular::frame::{FrameEncoder, FrameEncoderOptions};
3280
3281    validate_animation_input(width, height, layout, frames)?;
3282
3283    let w = width as usize;
3284    let h = height as usize;
3285    let num_frames = frames.len();
3286
3287    // Build file header with animation
3288    let sample_image = match layout {
3289        PixelLayout::Rgb8 => ModularImage::from_rgb8(frames[0].pixels, w, h),
3290        PixelLayout::Rgba8 => ModularImage::from_rgba8(frames[0].pixels, w, h),
3291        PixelLayout::Bgr8 => ModularImage::from_rgb8(&bgr_to_rgb(frames[0].pixels, 3), w, h),
3292        PixelLayout::Bgra8 => ModularImage::from_rgba8(&bgr_to_rgb(frames[0].pixels, 4), w, h),
3293        PixelLayout::Gray8 => ModularImage::from_gray8(frames[0].pixels, w, h),
3294        PixelLayout::GrayAlpha8 => ModularImage::from_grayalpha8(frames[0].pixels, w, h),
3295        PixelLayout::Rgb16 => ModularImage::from_rgb16_native(frames[0].pixels, w, h),
3296        PixelLayout::Rgba16 => ModularImage::from_rgba16_native(frames[0].pixels, w, h),
3297        PixelLayout::Gray16 => ModularImage::from_gray16_native(frames[0].pixels, w, h),
3298        PixelLayout::GrayAlpha16 => ModularImage::from_grayalpha16_native(frames[0].pixels, w, h),
3299        other => return Err(EncodeError::UnsupportedPixelLayout(other)),
3300    }
3301    .map_err(EncodeError::from)?;
3302
3303    let mut file_header = if sample_image.is_grayscale {
3304        FileHeader::new_gray(width, height)
3305    } else if sample_image.has_alpha {
3306        FileHeader::new_rgba(width, height)
3307    } else {
3308        FileHeader::new_rgb(width, height)
3309    };
3310    if sample_image.bit_depth == 16 {
3311        file_header.metadata.bit_depth = crate::headers::file_header::BitDepth::uint16();
3312        for ec in &mut file_header.metadata.extra_channels {
3313            ec.bit_depth = crate::headers::file_header::BitDepth::uint16();
3314        }
3315    }
3316    file_header.metadata.animation = Some(AnimationHeader {
3317        tps_numerator: animation.tps_numerator,
3318        tps_denominator: animation.tps_denominator,
3319        num_loops: animation.num_loops,
3320        have_timecodes: false,
3321    });
3322
3323    // Write file header
3324    let mut writer = BitWriter::new();
3325    file_header.write(&mut writer).map_err(EncodeError::from)?;
3326    writer.zero_pad_to_byte();
3327
3328    // Encode each frame with crop detection
3329    let color_encoding = ColorEncoding::srgb();
3330    let bpp = layout.bytes_per_pixel();
3331    let mut prev_pixels: Option<&[u8]> = None;
3332
3333    for (i, frame) in frames.iter().enumerate() {
3334        // Detect crop: compare current frame against previous.
3335        // Only use crop when it's smaller than the full frame.
3336        let crop = if let Some(prev) = prev_pixels {
3337            match detect_frame_crop(prev, frame.pixels, w, h, bpp, false) {
3338                Some(crop) if (crop.width as usize) < w || (crop.height as usize) < h => Some(crop),
3339                Some(_) => None, // Crop covers full frame — no benefit
3340                None => {
3341                    // Frames are identical — emit a minimal 1x1 crop to preserve canvas
3342                    Some(FrameCrop {
3343                        x0: 0,
3344                        y0: 0,
3345                        width: 1,
3346                        height: 1,
3347                    })
3348                }
3349            }
3350        } else {
3351            None // Frame 0: always full frame
3352        };
3353
3354        // Build ModularImage from the appropriate pixel region
3355        let (frame_w, frame_h, frame_pixels_owned);
3356        let frame_pixels: &[u8] = if let Some(ref crop) = crop {
3357            frame_w = crop.width as usize;
3358            frame_h = crop.height as usize;
3359            frame_pixels_owned = extract_pixel_crop(frame.pixels, w, crop, bpp);
3360            &frame_pixels_owned
3361        } else {
3362            frame_w = w;
3363            frame_h = h;
3364            frame_pixels_owned = Vec::new();
3365            let _ = &frame_pixels_owned; // suppress unused warning
3366            frame.pixels
3367        };
3368
3369        let image = match layout {
3370            PixelLayout::Rgb8 => ModularImage::from_rgb8(frame_pixels, frame_w, frame_h),
3371            PixelLayout::Rgba8 => ModularImage::from_rgba8(frame_pixels, frame_w, frame_h),
3372            PixelLayout::Bgr8 => {
3373                ModularImage::from_rgb8(&bgr_to_rgb(frame_pixels, 3), frame_w, frame_h)
3374            }
3375            PixelLayout::Bgra8 => {
3376                ModularImage::from_rgba8(&bgr_to_rgb(frame_pixels, 4), frame_w, frame_h)
3377            }
3378            PixelLayout::Gray8 => ModularImage::from_gray8(frame_pixels, frame_w, frame_h),
3379            PixelLayout::GrayAlpha8 => {
3380                ModularImage::from_grayalpha8(frame_pixels, frame_w, frame_h)
3381            }
3382            PixelLayout::Rgb16 => ModularImage::from_rgb16_native(frame_pixels, frame_w, frame_h),
3383            PixelLayout::Rgba16 => ModularImage::from_rgba16_native(frame_pixels, frame_w, frame_h),
3384            PixelLayout::Gray16 => ModularImage::from_gray16_native(frame_pixels, frame_w, frame_h),
3385            PixelLayout::GrayAlpha16 => {
3386                ModularImage::from_grayalpha16_native(frame_pixels, frame_w, frame_h)
3387            }
3388            other => return Err(EncodeError::UnsupportedPixelLayout(other)),
3389        }
3390        .map_err(EncodeError::from)?;
3391
3392        let use_tree_learning = cfg.tree_learning;
3393        let frame_encoder = FrameEncoder::new(
3394            frame_w,
3395            frame_h,
3396            FrameEncoderOptions {
3397                use_modular: true,
3398                effort: cfg.effort,
3399                use_ans: cfg.use_ans,
3400                use_tree_learning,
3401                use_squeeze: cfg.squeeze,
3402                enable_lz77: cfg.lz77,
3403                lz77_method: cfg.lz77_method,
3404                lossy_palette: cfg.lossy_palette,
3405                encoder_mode: cfg.mode,
3406                profile: cfg.effective_profile(),
3407                have_animation: true,
3408                duration: frame.duration,
3409                is_last: i == num_frames - 1,
3410                crop,
3411                skip_rct: false,
3412            },
3413        );
3414        frame_encoder
3415            .encode_modular(&image, &color_encoding, &mut writer)
3416            .map_err(EncodeError::from)?;
3417
3418        prev_pixels = Some(frame.pixels);
3419    }
3420
3421    Ok(writer.finish_with_padding())
3422}
3423
3424fn encode_animation_lossy(
3425    cfg: &LossyConfig,
3426    width: u32,
3427    height: u32,
3428    layout: PixelLayout,
3429    animation: &AnimationParams,
3430    frames: &[AnimationFrame<'_>],
3431) -> core::result::Result<Vec<u8>, EncodeError> {
3432    use crate::bit_writer::BitWriter;
3433    use crate::headers::file_header::AnimationHeader;
3434    use crate::headers::frame_header::FrameOptions;
3435
3436    validate_animation_input(width, height, layout, frames)?;
3437
3438    let w = width as usize;
3439    let h = height as usize;
3440    let num_frames = frames.len();
3441
3442    // Set up VarDCT encoder
3443    let mut profile = cfg.effective_profile();
3444
3445    // Apply max_strategy_size to profile flags
3446    if let Some(max_size) = cfg.max_strategy_size {
3447        if max_size < 16 {
3448            profile.try_dct16 = false;
3449        }
3450        if max_size < 32 {
3451            profile.try_dct32 = false;
3452        }
3453        if max_size < 64 {
3454            profile.try_dct64 = false;
3455        }
3456    }
3457
3458    let mut enc = crate::vardct::VarDctEncoder::new(cfg.distance);
3459    enc.effort = cfg.effort;
3460    enc.profile = profile;
3461    enc.use_ans = cfg.use_ans;
3462    enc.optimize_codes = enc.profile.optimize_codes;
3463    enc.custom_orders = enc.profile.custom_orders;
3464    enc.ac_strategy_enabled = enc.profile.ac_strategy_enabled;
3465    enc.enable_noise = cfg.noise;
3466    enc.enable_denoise = cfg.denoise;
3467    // libjxl gates gaborish at distance > 0.5 (enc_frame.cc:281)
3468    enc.enable_gaborish = cfg.gaborish && cfg.distance > 0.5;
3469    enc.error_diffusion = cfg.error_diffusion;
3470    enc.pixel_domain_loss = cfg.pixel_domain_loss;
3471    enc.enable_lz77 = cfg.lz77;
3472    enc.lz77_method = cfg.lz77_method;
3473    enc.force_strategy = cfg.force_strategy;
3474    enc.progressive = cfg.progressive;
3475    enc.use_lf_frame = cfg.lf_frame;
3476    #[cfg(feature = "butteraugli-loop")]
3477    {
3478        enc.butteraugli_iters = cfg.butteraugli_iters;
3479    }
3480    #[cfg(feature = "ssim2-loop")]
3481    {
3482        enc.ssim2_iters = cfg.ssim2_iters;
3483    }
3484    #[cfg(feature = "zensim-loop")]
3485    {
3486        enc.zensim_iters = cfg.zensim_iters;
3487    }
3488
3489    // Detect alpha and 16-bit from layout
3490    let has_alpha = layout.has_alpha();
3491    let bit_depth_16 = matches!(layout, PixelLayout::Rgb16 | PixelLayout::Rgba16);
3492    enc.bit_depth_16 = bit_depth_16;
3493
3494    // Build file header from VarDCT encoder (sets xyb_encoded, rendering_intent, etc.)
3495    // then add animation metadata
3496    let mut file_header = enc.build_file_header(w, h, has_alpha);
3497    file_header.metadata.animation = Some(AnimationHeader {
3498        tps_numerator: animation.tps_numerator,
3499        tps_denominator: animation.tps_denominator,
3500        num_loops: animation.num_loops,
3501        have_timecodes: false,
3502    });
3503
3504    let mut writer = BitWriter::with_capacity(w * h * 4);
3505    file_header.write(&mut writer).map_err(EncodeError::from)?;
3506    if let Some(ref icc) = enc.icc_profile {
3507        crate::icc::write_icc(icc, &mut writer).map_err(EncodeError::from)?;
3508    }
3509    writer.zero_pad_to_byte();
3510
3511    // Encode each frame with crop detection
3512    let bpp = layout.bytes_per_pixel();
3513    let mut prev_pixels: Option<&[u8]> = None;
3514
3515    for (i, frame) in frames.iter().enumerate() {
3516        // Detect crop on raw input pixels (before linear conversion).
3517        // Only use crop when it's smaller than the full frame.
3518        let crop = if let Some(prev) = prev_pixels {
3519            match detect_frame_crop(prev, frame.pixels, w, h, bpp, true) {
3520                Some(crop) if (crop.width as usize) < w || (crop.height as usize) < h => Some(crop),
3521                Some(_) => None, // Crop covers full frame — no benefit
3522                None => {
3523                    // Frames identical — emit minimal 8x8 crop (VarDCT minimum)
3524                    Some(FrameCrop {
3525                        x0: 0,
3526                        y0: 0,
3527                        width: 8.min(width),
3528                        height: 8.min(height),
3529                    })
3530                }
3531            }
3532        } else {
3533            None // Frame 0: always full frame
3534        };
3535
3536        // Extract crop region from raw pixels, then convert to linear
3537        let (frame_w, frame_h) = if let Some(ref crop) = crop {
3538            (crop.width as usize, crop.height as usize)
3539        } else {
3540            (w, h)
3541        };
3542
3543        let crop_pixels_owned;
3544        let src_pixels: &[u8] = if let Some(ref crop) = crop {
3545            crop_pixels_owned = extract_pixel_crop(frame.pixels, w, crop, bpp);
3546            &crop_pixels_owned
3547        } else {
3548            crop_pixels_owned = Vec::new();
3549            let _ = &crop_pixels_owned;
3550            frame.pixels
3551        };
3552
3553        let (linear_rgb, alpha) = match layout {
3554            PixelLayout::Rgb8 => (srgb_u8_to_linear_f32(src_pixels, 3), None),
3555            PixelLayout::Bgr8 => (srgb_u8_to_linear_f32(&bgr_to_rgb(src_pixels, 3), 3), None),
3556            PixelLayout::Rgba8 => {
3557                let rgb = srgb_u8_to_linear_f32(src_pixels, 4);
3558                let alpha = extract_alpha(src_pixels, 4, 3);
3559                (rgb, Some(alpha))
3560            }
3561            PixelLayout::Bgra8 => {
3562                let swapped = bgr_to_rgb(src_pixels, 4);
3563                let rgb = srgb_u8_to_linear_f32(&swapped, 4);
3564                let alpha = extract_alpha(src_pixels, 4, 3);
3565                (rgb, Some(alpha))
3566            }
3567            PixelLayout::Gray8 => (gray_u8_to_linear_f32_rgb(src_pixels, 1), None),
3568            PixelLayout::GrayAlpha8 => {
3569                let rgb = gray_u8_to_linear_f32_rgb(src_pixels, 2);
3570                let alpha = extract_alpha(src_pixels, 2, 1);
3571                (rgb, Some(alpha))
3572            }
3573            PixelLayout::Rgb16 => (srgb_u16_to_linear_f32(src_pixels, 3), None),
3574            PixelLayout::Rgba16 => {
3575                let rgb = srgb_u16_to_linear_f32(src_pixels, 4);
3576                let alpha = extract_alpha_u16(src_pixels, 4, 3);
3577                (rgb, Some(alpha))
3578            }
3579            PixelLayout::Gray16 => (gray_u16_to_linear_f32_rgb(src_pixels, 1), None),
3580            PixelLayout::GrayAlpha16 => {
3581                let rgb = gray_u16_to_linear_f32_rgb(src_pixels, 2);
3582                let alpha = extract_alpha_u16(src_pixels, 2, 1);
3583                (rgb, Some(alpha))
3584            }
3585            PixelLayout::RgbLinearF32 => {
3586                let floats: &[f32] = bytemuck::cast_slice(src_pixels);
3587                (floats.to_vec(), None)
3588            }
3589            PixelLayout::RgbaLinearF32 => {
3590                let floats: &[f32] = bytemuck::cast_slice(src_pixels);
3591                let rgb: Vec<f32> = floats
3592                    .chunks(4)
3593                    .flat_map(|px| [px[0], px[1], px[2]])
3594                    .collect();
3595                let alpha = extract_alpha_f32(floats, 4, 3);
3596                (rgb, Some(alpha))
3597            }
3598            PixelLayout::GrayLinearF32 => {
3599                let floats: &[f32] = bytemuck::cast_slice(src_pixels);
3600                (gray_f32_to_linear_f32_rgb(floats, 1), None)
3601            }
3602            PixelLayout::GrayAlphaLinearF32 => {
3603                let floats: &[f32] = bytemuck::cast_slice(src_pixels);
3604                let rgb = gray_f32_to_linear_f32_rgb(floats, 2);
3605                let alpha = extract_alpha_f32(floats, 2, 1);
3606                (rgb, Some(alpha))
3607            }
3608        };
3609
3610        let frame_options = FrameOptions {
3611            have_animation: true,
3612            have_timecodes: false,
3613            duration: frame.duration,
3614            is_last: i == num_frames - 1,
3615            crop,
3616        };
3617
3618        enc.encode_frame_to_writer(
3619            frame_w,
3620            frame_h,
3621            &linear_rgb,
3622            alpha.as_deref(),
3623            &frame_options,
3624            &mut writer,
3625        )
3626        .map_err(EncodeError::from)?;
3627
3628        prev_pixels = Some(frame.pixels);
3629    }
3630
3631    Ok(writer.finish_with_padding())
3632}
3633
3634// ── Animation frame crop detection ──────────────────────────────────────────
3635
3636use crate::headers::frame_header::FrameCrop;
3637
3638/// Detects the minimal bounding rectangle that differs between two frames.
3639///
3640/// Compares `prev` and `curr` byte-by-byte. Returns `Some(FrameCrop)` with the
3641/// tight bounding box of changed pixels, or `None` if the frames are identical.
3642///
3643/// When `align_to_8x8` is true (for VarDCT), the crop is expanded outward to
3644/// 8x8 block boundaries for better compression.
3645fn detect_frame_crop(
3646    prev: &[u8],
3647    curr: &[u8],
3648    width: usize,
3649    height: usize,
3650    bytes_per_pixel: usize,
3651    align_to_8x8: bool,
3652) -> Option<FrameCrop> {
3653    let stride = width * bytes_per_pixel;
3654    debug_assert_eq!(prev.len(), height * stride);
3655    debug_assert_eq!(curr.len(), height * stride);
3656
3657    // Find top (first row with a difference)
3658    let mut top = height;
3659    let mut bottom = 0;
3660    let mut left = width;
3661    let mut right = 0;
3662
3663    for y in 0..height {
3664        let row_start = y * stride;
3665        let prev_row = &prev[row_start..row_start + stride];
3666        let curr_row = &curr[row_start..row_start + stride];
3667
3668        // Fast row comparison via u64 chunks — lets the compiler auto-vectorize
3669        let (prev_prefix, prev_u64, prev_suffix) = bytemuck::pod_align_to::<u8, u64>(prev_row);
3670        let (curr_prefix, curr_u64, curr_suffix) = bytemuck::pod_align_to::<u8, u64>(curr_row);
3671        if prev_prefix == curr_prefix && prev_u64 == curr_u64 && prev_suffix == curr_suffix {
3672            continue;
3673        }
3674
3675        // This row has differences — find leftmost and rightmost changed pixel
3676        if top == height {
3677            top = y;
3678        }
3679        bottom = y;
3680
3681        // Scan from left to find first differing pixel
3682        for x in 0..width {
3683            let px_start = x * bytes_per_pixel;
3684            if prev_row[px_start..px_start + bytes_per_pixel]
3685                != curr_row[px_start..px_start + bytes_per_pixel]
3686            {
3687                left = left.min(x);
3688                break;
3689            }
3690        }
3691        // Scan from right to find last differing pixel
3692        for x in (0..width).rev() {
3693            let px_start = x * bytes_per_pixel;
3694            if prev_row[px_start..px_start + bytes_per_pixel]
3695                != curr_row[px_start..px_start + bytes_per_pixel]
3696            {
3697                right = right.max(x);
3698                break;
3699            }
3700        }
3701    }
3702
3703    if top == height {
3704        // Frames are identical
3705        return None;
3706    }
3707
3708    // Convert to crop rectangle (inclusive → exclusive for width/height)
3709    let mut crop_x = left as i32;
3710    let mut crop_y = top as i32;
3711    let mut crop_w = (right - left + 1) as u32;
3712    let mut crop_h = (bottom - top + 1) as u32;
3713
3714    if align_to_8x8 {
3715        // Expand to 8x8 block boundaries
3716        let aligned_x = (crop_x / 8) * 8;
3717        let aligned_y = (crop_y / 8) * 8;
3718        let end_x = (crop_x as u32 + crop_w).div_ceil(8) * 8;
3719        let end_y = (crop_y as u32 + crop_h).div_ceil(8) * 8;
3720        crop_x = aligned_x;
3721        crop_y = aligned_y;
3722        crop_w = end_x.min(width as u32) - aligned_x as u32;
3723        crop_h = end_y.min(height as u32) - aligned_y as u32;
3724    }
3725
3726    Some(FrameCrop {
3727        x0: crop_x,
3728        y0: crop_y,
3729        width: crop_w,
3730        height: crop_h,
3731    })
3732}
3733
3734/// Extracts a rectangular crop region from a pixel buffer.
3735///
3736/// `bytes_per_pixel` is the number of bytes per pixel (e.g., 3 for RGB, 4 for RGBA).
3737fn extract_pixel_crop(
3738    pixels: &[u8],
3739    full_width: usize,
3740    crop: &FrameCrop,
3741    bytes_per_pixel: usize,
3742) -> Vec<u8> {
3743    let cx = crop.x0 as usize;
3744    let cy = crop.y0 as usize;
3745    let cw = crop.width as usize;
3746    let ch = crop.height as usize;
3747    let stride = full_width * bytes_per_pixel;
3748
3749    let mut out = Vec::with_capacity(cw * ch * bytes_per_pixel);
3750    for y in cy..cy + ch {
3751        let row_start = y * stride + cx * bytes_per_pixel;
3752        out.extend_from_slice(&pixels[row_start..row_start + cw * bytes_per_pixel]);
3753    }
3754    out
3755}
3756
3757// ── Pixel conversion helpers ────────────────────────────────────────────────
3758
3759/// Pre-computed sRGB u8 → linear f32 lookup table (256 entries).
3760/// Eliminates per-pixel `powf(2.4)` calls for the common 8-bit path.
3761const SRGB_U8_TO_LINEAR: [f32; 256] = {
3762    let mut table = [0.0f32; 256];
3763    let mut i = 0u16;
3764    while i < 256 {
3765        let c = i as f64 / 255.0;
3766        // Use f64 for accuracy during const eval, then truncate to f32.
3767        // powf is not const, so we use exp(2.4 * ln(x)) via a manual series.
3768        // For const context, we precompute using the piecewise sRGB TF.
3769        table[i as usize] = if c <= 0.04045 {
3770            (c / 12.92) as f32
3771        } else {
3772            // ((c + 0.055) / 1.055)^2.4
3773            // = exp(2.4 * ln((c + 0.055) / 1.055))
3774            // Approximate via repeated squaring: x^2.4 = x^2 * x^0.4
3775            // x^0.4 = (x^0.5)^0.8 = ((x^0.5)^0.5)^... too complex for const.
3776            // Instead, use the identity: x^2.4 = (x^12)^(1/5)
3777            // and compute fifth root via Newton's method in f64.
3778            let base = (c + 0.055) / 1.055;
3779            // x^12 = ((x^2)^2)^3
3780            let x2 = base * base;
3781            let x4 = x2 * x2;
3782            let x8 = x4 * x4;
3783            let x12 = x8 * x4;
3784            // Fifth root of x^12 = x^(12/5) = x^2.4
3785            // Newton: y_{n+1} = y_n - (y_n^5 - x12) / (5 * y_n^4)
3786            //       = (4*y_n + x12/y_n^4) / 5
3787            let mut y = base * base; // initial guess ~x^2
3788            // 8 iterations of Newton's method for fifth root (converges in ~6 for f64)
3789            let mut iter = 0;
3790            while iter < 8 {
3791                let y2 = y * y;
3792                let y4 = y2 * y2;
3793                y = (4.0 * y + x12 / y4) / 5.0;
3794                iter += 1;
3795            }
3796            y as f32
3797        };
3798        i += 1;
3799    }
3800    table
3801};
3802
3803/// sRGB u8 → linear f32 via LUT.
3804#[inline]
3805fn srgb_to_linear(c: u8) -> f32 {
3806    SRGB_U8_TO_LINEAR[c as usize]
3807}
3808
3809fn srgb_u8_to_linear_f32(data: &[u8], channels: usize) -> Vec<f32> {
3810    let num_pixels = data.len() / channels;
3811    let mut out = vec![0.0f32; num_pixels * 3];
3812    let lut = &SRGB_U8_TO_LINEAR;
3813    // zip chunks to eliminate output bounds checks; u8 index into [f32; 256] is always in bounds
3814    for (px, rgb) in data.chunks_exact(channels).zip(out.chunks_exact_mut(3)) {
3815        rgb[0] = lut[px[0] as usize];
3816        rgb[1] = lut[px[1] as usize];
3817        rgb[2] = lut[px[2] as usize];
3818    }
3819    out
3820}
3821
3822/// sRGB u16 → linear f32 (IEC 61966-2-1).
3823fn srgb_u16_to_linear_f32(data: &[u8], channels: usize) -> Vec<f32> {
3824    let pixels: &[u16] = bytemuck::cast_slice(data);
3825    pixels
3826        .chunks(channels)
3827        .flat_map(|px| {
3828            [
3829                srgb_to_linear_f(px[0] as f32 / 65535.0),
3830                srgb_to_linear_f(px[1] as f32 / 65535.0),
3831                srgb_to_linear_f(px[2] as f32 / 65535.0),
3832            ]
3833        })
3834        .collect()
3835}
3836
3837/// sRGB transfer function: normalized float [0,1] → linear float.
3838#[inline]
3839fn srgb_to_linear_f(c: f32) -> f32 {
3840    if c <= 0.04045 {
3841        c / 12.92
3842    } else {
3843        jxl_simd::fast_powf((c + 0.055) / 1.055, 2.4)
3844    }
3845}
3846
3847/// Gamma u8 → linear f32 RGB. `linear = (encoded/255)^(1/gamma)`
3848fn gamma_u8_to_linear_f32(data: &[u8], channels: usize, gamma: f32) -> Vec<f32> {
3849    // Build 256-entry LUT for u8 values (avoids per-pixel powf)
3850    let inv_gamma = 1.0 / gamma;
3851    let lut: [f32; 256] =
3852        core::array::from_fn(|i| jxl_simd::fast_powf(i as f32 / 255.0, inv_gamma));
3853    data.chunks(channels)
3854        .flat_map(|px| {
3855            [
3856                lut[px[0] as usize],
3857                lut[px[1] as usize],
3858                lut[px[2] as usize],
3859            ]
3860        })
3861        .collect()
3862}
3863
3864/// Gamma u16 → linear f32 RGB. `linear = (encoded/65535)^(1/gamma)`
3865fn gamma_u16_to_linear_f32(data: &[u8], channels: usize, gamma: f32) -> Vec<f32> {
3866    let inv_gamma = 1.0 / gamma;
3867    let pixels: &[u16] = bytemuck::cast_slice(data);
3868    pixels
3869        .chunks(channels)
3870        .flat_map(|px| {
3871            [
3872                jxl_simd::fast_powf(px[0] as f32 / 65535.0, inv_gamma),
3873                jxl_simd::fast_powf(px[1] as f32 / 65535.0, inv_gamma),
3874                jxl_simd::fast_powf(px[2] as f32 / 65535.0, inv_gamma),
3875            ]
3876        })
3877        .collect()
3878}
3879
3880/// Gamma u8 grayscale → linear f32 RGB (gray→R=G=B). `linear = (encoded/255)^(1/gamma)`
3881fn gamma_gray_u8_to_linear_f32_rgb(data: &[u8], stride: usize, gamma: f32) -> Vec<f32> {
3882    let inv_gamma = 1.0 / gamma;
3883    let lut: [f32; 256] =
3884        core::array::from_fn(|i| jxl_simd::fast_powf(i as f32 / 255.0, inv_gamma));
3885    data.chunks(stride)
3886        .flat_map(|px| {
3887            let v = lut[px[0] as usize];
3888            [v, v, v]
3889        })
3890        .collect()
3891}
3892
3893/// Gamma u16 grayscale → linear f32 RGB (gray→R=G=B). `linear = (encoded/65535)^(1/gamma)`
3894fn gamma_gray_u16_to_linear_f32_rgb(data: &[u8], stride: usize, gamma: f32) -> Vec<f32> {
3895    let inv_gamma = 1.0 / gamma;
3896    let pixels: &[u16] = bytemuck::cast_slice(data);
3897    pixels
3898        .chunks(stride)
3899        .flat_map(|px| {
3900            let v = jxl_simd::fast_powf(px[0] as f32 / 65535.0, inv_gamma);
3901            [v, v, v]
3902        })
3903        .collect()
3904}
3905
3906/// Extract alpha channel from interleaved 16-bit pixel data as u8 (quantized).
3907fn extract_alpha_u16(data: &[u8], stride: usize, alpha_offset: usize) -> Vec<u8> {
3908    let pixels: &[u16] = bytemuck::cast_slice(data);
3909    pixels
3910        .chunks(stride)
3911        .map(|px| (px[alpha_offset] >> 8) as u8)
3912        .collect()
3913}
3914
3915/// Swap B and R channels in-place equivalent: BGR(A) → RGB(A).
3916fn bgr_to_rgb(data: &[u8], stride: usize) -> Vec<u8> {
3917    let mut out = data.to_vec();
3918    for chunk in out.chunks_mut(stride) {
3919        chunk.swap(0, 2);
3920    }
3921    out
3922}
3923
3924/// Extract a single channel from interleaved pixel data.
3925fn extract_alpha(data: &[u8], stride: usize, alpha_offset: usize) -> Vec<u8> {
3926    data.chunks(stride).map(|px| px[alpha_offset]).collect()
3927}
3928
3929/// Extract alpha from interleaved f32 pixel data, converting to u8 (0..255).
3930fn extract_alpha_f32(data: &[f32], stride: usize, alpha_offset: usize) -> Vec<u8> {
3931    data.chunks(stride)
3932        .map(|px| (px[alpha_offset].clamp(0.0, 1.0) * 255.0 + 0.5) as u8)
3933        .collect()
3934}
3935
3936/// Expand 8-bit sRGB grayscale to linear f32 RGB (gray→R=G=B).
3937fn gray_u8_to_linear_f32_rgb(data: &[u8], stride: usize) -> Vec<f32> {
3938    data.chunks(stride)
3939        .flat_map(|px| {
3940            let v = srgb_to_linear(px[0]);
3941            [v, v, v]
3942        })
3943        .collect()
3944}
3945
3946/// Expand 16-bit sRGB grayscale to linear f32 RGB (gray→R=G=B).
3947fn gray_u16_to_linear_f32_rgb(data: &[u8], stride: usize) -> Vec<f32> {
3948    let pixels: &[u16] = bytemuck::cast_slice(data);
3949    pixels
3950        .chunks(stride)
3951        .flat_map(|px| {
3952            let v = srgb_to_linear_f(px[0] as f32 / 65535.0);
3953            [v, v, v]
3954        })
3955        .collect()
3956}
3957
3958/// Expand linear f32 grayscale to linear f32 RGB (gray→R=G=B).
3959fn gray_f32_to_linear_f32_rgb(data: &[f32], stride: usize) -> Vec<f32> {
3960    data.chunks(stride)
3961        .flat_map(|px| {
3962            let v = px[0];
3963            [v, v, v]
3964        })
3965        .collect()
3966}
3967
3968// ── Tests ───────────────────────────────────────────────────────────────────
3969
3970#[cfg(test)]
3971mod tests {
3972    use super::*;
3973
3974    #[test]
3975    fn test_lossless_config_builder_and_getters() {
3976        let cfg = LosslessConfig::new()
3977            .with_effort(5)
3978            .with_ans(false)
3979            .with_squeeze(true)
3980            .with_tree_learning(true);
3981        assert_eq!(cfg.effort(), 5);
3982        assert!(!cfg.ans());
3983        assert!(cfg.squeeze());
3984        assert!(cfg.tree_learning());
3985    }
3986
3987    #[test]
3988    fn test_lossy_config_builder_and_getters() {
3989        let cfg = LossyConfig::new(2.0)
3990            .with_effort(3)
3991            .with_gaborish(false)
3992            .with_noise(true);
3993        assert_eq!(cfg.distance(), 2.0);
3994        assert_eq!(cfg.effort(), 3);
3995        assert!(!cfg.gaborish());
3996        assert!(cfg.noise());
3997    }
3998
3999    #[test]
4000    fn test_pixel_layout_helpers() {
4001        assert_eq!(PixelLayout::Rgb8.bytes_per_pixel(), 3);
4002        assert_eq!(PixelLayout::Rgba8.bytes_per_pixel(), 4);
4003        assert_eq!(PixelLayout::Bgr8.bytes_per_pixel(), 3);
4004        assert_eq!(PixelLayout::Bgra8.bytes_per_pixel(), 4);
4005        assert_eq!(PixelLayout::Gray8.bytes_per_pixel(), 1);
4006        assert_eq!(PixelLayout::GrayAlpha8.bytes_per_pixel(), 2);
4007        assert_eq!(PixelLayout::Rgb16.bytes_per_pixel(), 6);
4008        assert_eq!(PixelLayout::Rgba16.bytes_per_pixel(), 8);
4009        assert_eq!(PixelLayout::Gray16.bytes_per_pixel(), 2);
4010        assert_eq!(PixelLayout::GrayAlpha16.bytes_per_pixel(), 4);
4011        assert_eq!(PixelLayout::RgbLinearF32.bytes_per_pixel(), 12);
4012        assert_eq!(PixelLayout::RgbaLinearF32.bytes_per_pixel(), 16);
4013        assert_eq!(PixelLayout::GrayLinearF32.bytes_per_pixel(), 4);
4014        assert_eq!(PixelLayout::GrayAlphaLinearF32.bytes_per_pixel(), 8);
4015        // Linear
4016        assert!(!PixelLayout::Rgb8.is_linear());
4017        assert!(PixelLayout::RgbLinearF32.is_linear());
4018        assert!(PixelLayout::RgbaLinearF32.is_linear());
4019        assert!(PixelLayout::GrayLinearF32.is_linear());
4020        assert!(PixelLayout::GrayAlphaLinearF32.is_linear());
4021        assert!(!PixelLayout::Rgb16.is_linear());
4022        // Alpha
4023        assert!(!PixelLayout::Rgb8.has_alpha());
4024        assert!(PixelLayout::Rgba8.has_alpha());
4025        assert!(PixelLayout::Bgra8.has_alpha());
4026        assert!(PixelLayout::GrayAlpha8.has_alpha());
4027        assert!(PixelLayout::Rgba16.has_alpha());
4028        assert!(PixelLayout::GrayAlpha16.has_alpha());
4029        assert!(PixelLayout::RgbaLinearF32.has_alpha());
4030        assert!(PixelLayout::GrayAlphaLinearF32.has_alpha());
4031        assert!(!PixelLayout::Rgb16.has_alpha());
4032        assert!(!PixelLayout::RgbLinearF32.has_alpha());
4033        // 16-bit
4034        assert!(PixelLayout::Rgb16.is_16bit());
4035        assert!(PixelLayout::Rgba16.is_16bit());
4036        assert!(PixelLayout::Gray16.is_16bit());
4037        assert!(PixelLayout::GrayAlpha16.is_16bit());
4038        assert!(!PixelLayout::Rgb8.is_16bit());
4039        assert!(!PixelLayout::RgbLinearF32.is_16bit());
4040        // f32
4041        assert!(PixelLayout::RgbLinearF32.is_f32());
4042        assert!(PixelLayout::RgbaLinearF32.is_f32());
4043        assert!(PixelLayout::GrayLinearF32.is_f32());
4044        assert!(PixelLayout::GrayAlphaLinearF32.is_f32());
4045        assert!(!PixelLayout::Rgb8.is_f32());
4046        assert!(!PixelLayout::Rgb16.is_f32());
4047        // Grayscale
4048        assert!(PixelLayout::Gray8.is_grayscale());
4049        assert!(PixelLayout::GrayAlpha8.is_grayscale());
4050        assert!(PixelLayout::Gray16.is_grayscale());
4051        assert!(PixelLayout::GrayAlpha16.is_grayscale());
4052        assert!(PixelLayout::GrayLinearF32.is_grayscale());
4053        assert!(PixelLayout::GrayAlphaLinearF32.is_grayscale());
4054        assert!(!PixelLayout::Rgb16.is_grayscale());
4055        assert!(!PixelLayout::RgbLinearF32.is_grayscale());
4056    }
4057
4058    #[test]
4059    fn test_quality_to_distance() {
4060        assert!(Quality::Distance(1.0).to_distance().unwrap() == 1.0);
4061        assert!(Quality::Distance(-1.0).to_distance().is_err());
4062        assert!(Quality::Percent(100).to_distance().is_err()); // lossless invalid for lossy
4063        assert!(Quality::Percent(90).to_distance().unwrap() == 1.0);
4064    }
4065
4066    #[test]
4067    fn test_pixel_validation() {
4068        let cfg = LosslessConfig::new();
4069        let req = cfg.encode_request(2, 2, PixelLayout::Rgb8);
4070        assert!(req.validate_pixels(&[0u8; 12]).is_ok());
4071    }
4072
4073    #[test]
4074    fn test_pixel_validation_wrong_size() {
4075        let cfg = LosslessConfig::new();
4076        let req = cfg.encode_request(2, 2, PixelLayout::Rgb8);
4077        assert!(req.validate_pixels(&[0u8; 11]).is_err());
4078    }
4079
4080    #[test]
4081    fn test_limits_check() {
4082        let limits = Limits::new().with_max_width(100);
4083        let cfg = LosslessConfig::new();
4084        let req = cfg
4085            .encode_request(200, 100, PixelLayout::Rgb8)
4086            .with_limits(&limits);
4087        assert!(req.check_limits().is_err());
4088    }
4089
4090    #[test]
4091    fn test_lossless_encode_rgb8_small() {
4092        // 4x4 red image
4093        let pixels = [255u8, 0, 0].repeat(16);
4094        let result = LosslessConfig::new()
4095            .encode_request(4, 4, PixelLayout::Rgb8)
4096            .encode(&pixels);
4097        assert!(result.is_ok());
4098        let jxl = result.unwrap();
4099        assert_eq!(&jxl[..2], &[0xFF, 0x0A]); // JXL signature
4100    }
4101
4102    #[test]
4103    fn test_lossy_encode_rgb8_small() {
4104        // 8x8 gradient
4105        let mut pixels = Vec::with_capacity(8 * 8 * 3);
4106        for y in 0..8u8 {
4107            for x in 0..8u8 {
4108                pixels.push(x * 32);
4109                pixels.push(y * 32);
4110                pixels.push(128);
4111            }
4112        }
4113        let result = LossyConfig::new(2.0)
4114            .with_gaborish(false)
4115            .encode_request(8, 8, PixelLayout::Rgb8)
4116            .encode(&pixels);
4117        assert!(result.is_ok());
4118        let jxl = result.unwrap();
4119        assert_eq!(&jxl[..2], &[0xFF, 0x0A]);
4120    }
4121
4122    #[test]
4123    fn test_fluent_lossless() {
4124        let pixels = vec![128u8; 4 * 4 * 3];
4125        let result = LosslessConfig::new().encode(&pixels, 4, 4, PixelLayout::Rgb8);
4126        assert!(result.is_ok());
4127    }
4128
4129    #[test]
4130    fn test_lossy_gray8() {
4131        // Grayscale input → RGB expansion → VarDCT (XYB)
4132        let pixels = vec![128u8; 8 * 8];
4133        let result = LossyConfig::new(2.0)
4134            .with_gaborish(false)
4135            .encode_request(8, 8, PixelLayout::Gray8)
4136            .encode(&pixels);
4137        assert!(result.is_ok(), "lossy Gray8 should encode: {result:?}");
4138    }
4139
4140    #[test]
4141    fn test_lossy_gray_alpha8() {
4142        let pixels: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 255]).collect();
4143        let result = LossyConfig::new(2.0)
4144            .with_gaborish(false)
4145            .encode_request(8, 8, PixelLayout::GrayAlpha8)
4146            .encode(&pixels);
4147        assert!(result.is_ok(), "lossy GrayAlpha8 should encode: {result:?}");
4148    }
4149
4150    #[test]
4151    fn test_lossy_gray16() {
4152        let pixels_u16: Vec<u16> = (0..8 * 8).map(|_| 32768u16).collect();
4153        let pixels: &[u8] = bytemuck::cast_slice(&pixels_u16);
4154        let result = LossyConfig::new(2.0)
4155            .with_gaborish(false)
4156            .encode_request(8, 8, PixelLayout::Gray16)
4157            .encode(pixels);
4158        assert!(result.is_ok(), "lossy Gray16 should encode: {result:?}");
4159    }
4160
4161    #[test]
4162    fn test_lossy_rgba_linear_f32() {
4163        let pixels_f32: Vec<f32> = (0..8 * 8).flat_map(|_| [0.5f32, 0.3, 0.7, 1.0]).collect();
4164        let pixels: &[u8] = bytemuck::cast_slice(&pixels_f32);
4165        let result = LossyConfig::new(2.0)
4166            .with_gaborish(false)
4167            .encode_request(8, 8, PixelLayout::RgbaLinearF32)
4168            .encode(pixels);
4169        assert!(
4170            result.is_ok(),
4171            "lossy RgbaLinearF32 should encode: {result:?}"
4172        );
4173    }
4174
4175    #[test]
4176    fn test_lossy_gray_linear_f32() {
4177        let pixels_f32: Vec<f32> = (0..8 * 8).map(|_| 0.5f32).collect();
4178        let pixels: &[u8] = bytemuck::cast_slice(&pixels_f32);
4179        let result = LossyConfig::new(2.0)
4180            .with_gaborish(false)
4181            .encode_request(8, 8, PixelLayout::GrayLinearF32)
4182            .encode(pixels);
4183        assert!(
4184            result.is_ok(),
4185            "lossy GrayLinearF32 should encode: {result:?}"
4186        );
4187    }
4188
4189    #[test]
4190    fn test_lossless_grayalpha8() {
4191        let pixels: Vec<u8> = (0..8 * 8).flat_map(|_| [200u8, 255]).collect();
4192        let result = LosslessConfig::new().encode(&pixels, 8, 8, PixelLayout::GrayAlpha8);
4193        assert!(
4194            result.is_ok(),
4195            "lossless GrayAlpha8 should encode: {result:?}"
4196        );
4197    }
4198
4199    #[test]
4200    fn test_lossless_grayalpha16() {
4201        let pixels_u16: Vec<u16> = (0..8 * 8).flat_map(|_| [32768u16, 65535]).collect();
4202        let pixels: &[u8] = bytemuck::cast_slice(&pixels_u16);
4203        let result = LosslessConfig::new().encode(pixels, 8, 8, PixelLayout::GrayAlpha16);
4204        assert!(
4205            result.is_ok(),
4206            "lossless GrayAlpha16 should encode: {result:?}"
4207        );
4208    }
4209
4210    #[test]
4211    fn test_bgra_lossless() {
4212        // 4x4 red image in BGRA (B=0, G=0, R=255, A=255)
4213        let pixels = [0u8, 0, 255, 255].repeat(16);
4214        let result = LosslessConfig::new().encode(&pixels, 4, 4, PixelLayout::Bgra8);
4215        assert!(result.is_ok());
4216        let jxl = result.unwrap();
4217        assert_eq!(&jxl[..2], &[0xFF, 0x0A]);
4218    }
4219
4220    #[test]
4221    fn test_lossy_alpha_encodes() {
4222        // Lossy+alpha: VarDCT RGB + modular alpha extra channel
4223        let pixels = [255u8, 0, 0, 255].repeat(64);
4224        let result =
4225            LossyConfig::new(2.0)
4226                .with_gaborish(false)
4227                .encode(&pixels, 8, 8, PixelLayout::Bgra8);
4228        assert!(
4229            result.is_ok(),
4230            "BGRA lossy encode failed: {:?}",
4231            result.err()
4232        );
4233
4234        let result2 = LossyConfig::new(2.0).encode(&pixels, 8, 8, PixelLayout::Rgba8);
4235        assert!(
4236            result2.is_ok(),
4237            "RGBA lossy encode failed: {:?}",
4238            result2.err()
4239        );
4240    }
4241
4242    #[test]
4243    fn test_stop_cancellation() {
4244        use enough::Unstoppable;
4245        // Unstoppable should not cancel
4246        let pixels = vec![128u8; 4 * 4 * 3];
4247        let cfg = LosslessConfig::new();
4248        let result = cfg
4249            .encode_request(4, 4, PixelLayout::Rgb8)
4250            .with_stop(&Unstoppable)
4251            .encode(&pixels);
4252        assert!(result.is_ok());
4253    }
4254
4255    #[test]
4256    fn test_lossy_palette_encode() {
4257        // 16x16 RGB image with 4 colors + slight noise
4258        let colors = [[255u8, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
4259        let mut pixels = Vec::with_capacity(16 * 16 * 3);
4260        for y in 0..16u8 {
4261            for x in 0..16u8 {
4262                let ci = ((y / 4) * 4 + x / 4) as usize % 4;
4263                let noise = ((x.wrapping_mul(7).wrapping_add(y.wrapping_mul(13))) % 5) as i16 - 2;
4264                for &channel in &colors[ci][..3] {
4265                    let v = (channel as i16 + noise).clamp(0, 255) as u8;
4266                    pixels.push(v);
4267                }
4268            }
4269        }
4270        let cfg = LosslessConfig::new()
4271            .with_lossy_palette(true)
4272            .with_ans(true);
4273        let result = cfg.encode(&pixels, 16, 16, PixelLayout::Rgb8);
4274        assert!(
4275            result.is_ok(),
4276            "lossy palette encode failed: {:?}",
4277            result.err()
4278        );
4279        let jxl = result.unwrap();
4280        assert_eq!(&jxl[..2], &[0xFF, 0x0A], "JXL signature");
4281
4282        // Verify jxl-oxide can parse and decode it
4283        let cursor = std::io::Cursor::new(&jxl);
4284        let reader = std::io::BufReader::new(cursor);
4285        let image = jxl_oxide::JxlImage::builder()
4286            .read(reader)
4287            .expect("jxl-oxide parse");
4288        assert!(
4289            image.width() > 0,
4290            "decoded image should have non-zero width"
4291        );
4292    }
4293
4294    #[test]
4295    fn test_lossy_palette_multi_group() {
4296        // 300x300 RGB image with ~20 dominant colors + noise (>256x256 = multi-group)
4297        let colors = [
4298            [255u8, 0, 0],
4299            [0, 255, 0],
4300            [0, 0, 255],
4301            [255, 255, 0],
4302            [255, 0, 255],
4303            [0, 255, 255],
4304            [128, 128, 128],
4305            [64, 64, 64],
4306        ];
4307        let mut pixels = Vec::with_capacity(300 * 300 * 3);
4308        for y in 0..300u32 {
4309            for x in 0..300u32 {
4310                let ci = ((y / 40) * 8 + x / 40) as usize % colors.len();
4311                let noise = ((x.wrapping_mul(7).wrapping_add(y.wrapping_mul(13))) % 7) as i16 - 3;
4312                for &channel in &colors[ci][..3] {
4313                    let v = (channel as i16 + noise).clamp(0, 255) as u8;
4314                    pixels.push(v);
4315                }
4316            }
4317        }
4318
4319        // Encode with lossy palette + ANS (multi-group)
4320        let cfg = LosslessConfig::new()
4321            .with_lossy_palette(true)
4322            .with_ans(true);
4323        let jxl = cfg
4324            .encode(&pixels, 300, 300, PixelLayout::Rgb8)
4325            .expect("lossy palette multi-group encode");
4326        assert_eq!(&jxl[..2], &[0xFF, 0x0A], "JXL signature");
4327        assert!(jxl.len() < 300 * 300 * 3, "should compress");
4328
4329        // Save to disk for inspection
4330        let out = crate::test_helpers::output_dir("lossy_palette");
4331        let jxl_out = out.join("lossy_palette_multi.jxl");
4332        let png_out = out.join("lossy_palette_multi.png");
4333        std::fs::write(&jxl_out, &jxl).ok();
4334        eprintln!(
4335            "LOSSY_PALETTE_MULTI test: encoded {} bytes ({}x{})",
4336            jxl.len(),
4337            300,
4338            300
4339        );
4340
4341        // Try djxl decode first for better error messages
4342        let djxl_result = std::process::Command::new("djxl")
4343            .args([jxl_out.to_str().unwrap(), png_out.to_str().unwrap()])
4344            .output();
4345        if let Ok(output) = djxl_result {
4346            eprintln!(
4347                "djxl: status={}, stderr={}",
4348                output.status,
4349                String::from_utf8_lossy(&output.stderr)
4350            );
4351        }
4352
4353        // Verify jxl-rs can decode it
4354        let decoded = crate::test_helpers::decode_with_jxl_rs(&jxl).expect("jxl-rs decode failed");
4355        assert_eq!(decoded.width, 300);
4356        assert_eq!(decoded.height, 300);
4357        assert_eq!(decoded.channels, 3);
4358
4359        // Verify lossy quality: each pixel should be within 50 of original (delta palette error)
4360        // decoded.pixels is f32 in [0.0, 1.0] — convert to u8 for comparison
4361        let mut max_error = 0i32;
4362        let mut error_pos = (0, 0, 0);
4363        for (i, (&orig, &dec)) in pixels.iter().zip(decoded.pixels.iter()).enumerate() {
4364            let dec_u8 = (dec * 255.0).round().clamp(0.0, 255.0) as u8;
4365            let diff = (orig as i32 - dec_u8 as i32).abs();
4366            if diff > max_error {
4367                max_error = diff;
4368                let pixel = i / 3;
4369                error_pos = (pixel % 300, pixel / 300, i % 3);
4370            }
4371        }
4372        let err_idx = error_pos.1 * 300 * 3 + error_pos.0 * 3 + error_pos.2;
4373        let dec_u8 = (decoded.pixels[err_idx] * 255.0).round().clamp(0.0, 255.0) as u8;
4374        eprintln!(
4375            "max_error={} at ({},{}) ch={}, orig={} decoded={}",
4376            max_error, error_pos.0, error_pos.1, error_pos.2, pixels[err_idx], dec_u8,
4377        );
4378        assert!(
4379            max_error <= 80,
4380            "lossy palette max error {} too large (expected <= 80)",
4381            max_error
4382        );
4383    }
4384
4385    #[test]
4386    fn test_palette_256_colors_regression() {
4387        // Regression test for palette+ANS checksum mismatch with many unique colors.
4388        // Root cause was u2S bit width bug in write_palette_transform (fixed Feb 17, 2026):
4389        // nb_colors selectors 1-2 used 11/14 bits instead of 10/12 bits. Triggered when
4390        // nb_colors >= 256 (selector 1). Two test cases:
4391        //
4392        // 1. 32x32 with 256 unique colors via standard API (passes 50% heuristic)
4393        // 2. 16x16 with 256 unique colors via internal API (bypasses heuristic)
4394        use crate::modular::channel::{Channel, ModularImage};
4395        use crate::modular::encode::write_modular_stream_with_palette;
4396
4397        // Test 1: 32x32 through standard API (256 colors, each used 4x)
4398        let mut pixels = Vec::with_capacity(32 * 32 * 3);
4399        for i in 0..1024u32 {
4400            let idx = (i / 4) as u8;
4401            pixels.push(idx);
4402            pixels.push(((idx as u32 * 7 + 13) & 0xFF) as u8);
4403            pixels.push(((idx as u32 * 31 + 97) & 0xFF) as u8);
4404        }
4405        let cfg = LosslessConfig::new().with_ans(true);
4406        let jxl = cfg
4407            .encode(&pixels, 32, 32, PixelLayout::Rgb8)
4408            .expect("palette 256-colors encode");
4409        let decoded = crate::test_helpers::decode_with_jxl_rs(&jxl).expect("jxl-rs decode failed");
4410        for (i, (&orig, &dec)) in pixels.iter().zip(decoded.pixels.iter()).enumerate() {
4411            let dec_u8 = (dec * 255.0).round().clamp(0.0, 255.0) as u8;
4412            assert_eq!(
4413                orig, dec_u8,
4414                "32x32: mismatch at byte {}: orig={} decoded={}",
4415                i, orig, dec_u8
4416            );
4417        }
4418
4419        // Test 2: 16x16 via internal API (bypasses 50% heuristic)
4420        let mut channels = Vec::new();
4421        for c in 0..3 {
4422            let mut ch = Channel::new(16, 16).unwrap();
4423            for y in 0..16 {
4424                for x in 0..16 {
4425                    let idx = y * 16 + x;
4426                    let val = match c {
4427                        0 => idx as i32,
4428                        1 => ((idx * 3 + 17) & 0xFF) as i32,
4429                        2 => (255 - idx) as i32,
4430                        _ => 0,
4431                    };
4432                    ch.set(x, y, val);
4433                }
4434            }
4435            channels.push(ch);
4436        }
4437        let image = ModularImage {
4438            channels,
4439            bit_depth: 8,
4440            is_grayscale: false,
4441            has_alpha: false,
4442        };
4443        let mut writer = crate::bit_writer::BitWriter::new();
4444        write_modular_stream_with_palette(&image, &mut writer, true, 0, 3)
4445            .expect("palette encode with 256 unique colors must not fail");
4446    }
4447
4448    #[test]
4449    fn test_16bit_tree_learning() {
4450        // Test multiple 16-bit scenarios that previously failed
4451        for &(w, h, layout, label) in &[
4452            (32u32, 32u32, PixelLayout::Rgb16, "32x32 RGB16"),
4453            (8, 8, PixelLayout::Rgba16, "8x8 RGBA16"),
4454            (8, 8, PixelLayout::Rgb16, "8x8 RGB16"),
4455            (16, 16, PixelLayout::Gray16, "16x16 Gray16"),
4456        ] {
4457            let nc = layout.bytes_per_pixel()
4458                / if layout.is_16bit() {
4459                    2
4460                } else if layout.is_f32() {
4461                    4
4462                } else {
4463                    1
4464                };
4465            let mut pixels = vec![0u16; (w * h) as usize * nc];
4466            for y in 0..h {
4467                for x in 0..w {
4468                    let idx = ((y * w + x) as usize) * nc;
4469                    pixels[idx] = (x * 2048) as u16;
4470                    if nc >= 2 {
4471                        pixels[idx + 1] = (y * 2048) as u16;
4472                    }
4473                    if nc >= 3 {
4474                        pixels[idx + 2] = ((x + y) * 1024) as u16;
4475                    }
4476                    if nc >= 4 {
4477                        pixels[idx + 3] = 65535; // opaque alpha
4478                    }
4479                }
4480            }
4481            let bytes: Vec<u8> = pixels.iter().flat_map(|v| v.to_ne_bytes()).collect();
4482
4483            let cfg = LosslessConfig::new().with_effort(7).with_ans(true);
4484            let jxl = cfg
4485                .encode(&bytes, w, h, layout)
4486                .unwrap_or_else(|e| panic!("{}: encode failed: {}", label, e));
4487
4488            let decoded = crate::test_helpers::decode_with_jxl_rs(&jxl)
4489                .unwrap_or_else(|e| panic!("{}: jxl-rs decode failed: {}", label, e));
4490            assert_eq!(decoded.width, w as usize, "{}: width", label);
4491            assert_eq!(decoded.height, h as usize, "{}: height", label);
4492
4493            let scale = 65535.0;
4494            let mut mismatches = 0;
4495            for (i, (&orig, &dec_f)) in pixels.iter().zip(decoded.pixels.iter()).enumerate() {
4496                let dec = (dec_f * scale).round().clamp(0.0, scale) as u16;
4497                if orig != dec && mismatches < 3 {
4498                    eprintln!("{}: mismatch[{}]: orig={} dec={}", label, i, orig, dec);
4499                    mismatches += 1;
4500                }
4501            }
4502            assert_eq!(mismatches, 0, "{}: {} mismatches", label, mismatches);
4503            eprintln!("{}: PASS ({} bytes)", label, jxl.len());
4504        }
4505    }
4506
4507    #[test]
4508    fn test_srgb_lut_matches_powf() {
4509        for i in 0u16..256 {
4510            let lut_val = SRGB_U8_TO_LINEAR[i as usize];
4511            let fast_val = srgb_to_linear_f(i as f32 / 255.0);
4512            let diff = (lut_val - fast_val).abs();
4513            // LUT uses f64 exact powf, srgb_to_linear_f uses fast_powf (~3e-5 relative error)
4514            let tol = fast_val.abs() * 5e-5 + 1e-7;
4515            assert!(
4516                diff <= tol,
4517                "sRGB LUT mismatch at {i}: LUT={lut_val}, fast={fast_val}, diff={diff}"
4518            );
4519        }
4520    }
4521
4522    #[test]
4523    fn test_quality_to_distance_f32_mapping() {
4524        // Verify the piecewise mapping at key points.
4525        assert_eq!(quality_to_distance(100.0), 0.0);
4526        assert_eq!(quality_to_distance(90.0), 1.0); // visually lossless
4527        assert_eq!(quality_to_distance(80.0), 1.5);
4528        assert_eq!(quality_to_distance(70.0), 2.0);
4529        assert_eq!(quality_to_distance(50.0), 4.0);
4530        assert_eq!(quality_to_distance(0.0), 9.0);
4531        // Clamped above 100
4532        assert_eq!(quality_to_distance(110.0), 0.0);
4533    }
4534
4535    #[test]
4536    fn test_calibrated_jxl_quality() {
4537        // Boundary: below table minimum clamps to first entry's output.
4538        assert_eq!(calibrated_jxl_quality(0.0), 5.0);
4539        // Boundary: above table maximum clamps to last entry's output.
4540        assert_eq!(calibrated_jxl_quality(100.0), 93.8);
4541        // Exact table entry.
4542        assert_eq!(calibrated_jxl_quality(90.0), 84.2);
4543        // Interpolated mid-point between (50, 48.5) and (55, 51.9).
4544        let mid = calibrated_jxl_quality(52.5);
4545        let expected = 48.5 + 0.5 * (51.9 - 48.5);
4546        assert!(
4547            (mid - expected).abs() < 0.01,
4548            "expected {expected}, got {mid}"
4549        );
4550    }
4551
4552    #[test]
4553    fn test_interp_quality_edge_cases() {
4554        let table = &[(10.0f32, 20.0f32), (20.0, 40.0), (30.0, 60.0)];
4555        // Below table
4556        assert_eq!(interp_quality(table, 5.0), 20.0);
4557        // Above table
4558        assert_eq!(interp_quality(table, 35.0), 60.0);
4559        // Exact match
4560        assert_eq!(interp_quality(table, 20.0), 40.0);
4561        // Midpoint
4562        assert!((interp_quality(table, 15.0) - 30.0).abs() < 0.001);
4563    }
4564
4565    // -----------------------------------------------------------------
4566    // Internal-params override (__expert) — segmented Lossy / Lossless
4567    // -----------------------------------------------------------------
4568
4569    #[cfg(feature = "__expert")]
4570    mod internal_params {
4571        use super::*;
4572        use crate::effort::{LosslessInternalParams, LossyInternalParams};
4573
4574        // Pseudo-random RGB image — large enough + complex enough to exercise
4575        // RCT search, WP, and tree-learning splits so different param
4576        // settings produce different bitstreams.
4577        fn pseudo_random_rgb8(w: u32, h: u32) -> Vec<u8> {
4578            let mut out = Vec::with_capacity((w * h * 3) as usize);
4579            let mut state: u32 = 0xDEAD_BEEF;
4580            for _ in 0..(w * h) {
4581                let r = state.wrapping_mul(1664525).wrapping_add(1013904223);
4582                state = r;
4583                let g = state.wrapping_mul(1664525).wrapping_add(1013904223);
4584                state = g;
4585                let b = state.wrapping_mul(1664525).wrapping_add(1013904223);
4586                state = b;
4587                out.push((r >> 24) as u8);
4588                out.push((g >> 24) as u8);
4589                out.push((b >> 24) as u8);
4590            }
4591            out
4592        }
4593
4594        #[test]
4595        fn lossless_internal_params_changes_bitstream() {
4596            // Tighten tree learning + skip RCT search to push bytes off the
4597            // e7 default.
4598            let params = LosslessInternalParams {
4599                tree_max_buckets: Some(16),
4600                tree_num_properties: Some(3),
4601                nb_rcts_to_try: Some(0),
4602                ..Default::default()
4603            };
4604
4605            let cfg_override = LosslessConfig::new()
4606                .with_effort(7)
4607                .with_internal_params(params)
4608                .with_threads(1);
4609            let cfg_default = LosslessConfig::new().with_effort(7).with_threads(1);
4610
4611            let pixels = pseudo_random_rgb8(64, 64);
4612            let bytes_a = cfg_override
4613                .encode(&pixels, 64, 64, PixelLayout::Rgb8)
4614                .expect("override encode");
4615            let bytes_b = cfg_default
4616                .encode(&pixels, 64, 64, PixelLayout::Rgb8)
4617                .expect("default encode");
4618
4619            assert_eq!(&bytes_a[..2], &crate::JXL_SIGNATURE);
4620            assert_eq!(&bytes_b[..2], &crate::JXL_SIGNATURE);
4621            assert_ne!(
4622                bytes_a, bytes_b,
4623                "internal_params override should produce different bitstream"
4624            );
4625        }
4626
4627        #[test]
4628        fn lossy_internal_params_changes_bitstream() {
4629            let mut entropy = crate::effort::EntropyMulTable::reference();
4630            entropy.dct8 = 0.95;
4631            let params = LossyInternalParams {
4632                try_dct16: Some(false),
4633                try_dct32: Some(false),
4634                try_dct64: Some(false),
4635                try_dct4x8_afv: Some(false),
4636                k_info_loss_mul_base: Some(1.5),
4637                entropy_mul_table: Some(entropy),
4638                ..Default::default()
4639            };
4640
4641            let cfg_override = LossyConfig::new(2.0)
4642                .with_effort(7)
4643                .with_internal_params(params)
4644                .with_threads(1);
4645            let cfg_default = LossyConfig::new(2.0).with_effort(7).with_threads(1);
4646
4647            let pixels = pseudo_random_rgb8(64, 64);
4648            let bytes_a = cfg_override
4649                .encode(&pixels, 64, 64, PixelLayout::Rgb8)
4650                .expect("override encode");
4651            let bytes_b = cfg_default
4652                .encode(&pixels, 64, 64, PixelLayout::Rgb8)
4653                .expect("default encode");
4654
4655            assert_eq!(&bytes_a[..2], &crate::JXL_SIGNATURE);
4656            assert_eq!(&bytes_b[..2], &crate::JXL_SIGNATURE);
4657            assert_ne!(
4658                bytes_a, bytes_b,
4659                "internal_params override should produce different bitstream"
4660            );
4661        }
4662
4663        #[test]
4664        fn lossless_internal_params_persist_across_with_effort() {
4665            // Override applied before with_effort should still take effect
4666            // (with_effort preserves profile_override).
4667            let params = LosslessInternalParams {
4668                tree_max_buckets: Some(16),
4669                ..Default::default()
4670            };
4671
4672            let cfg = LosslessConfig::new()
4673                .with_internal_params(params)
4674                .with_effort(9) // should NOT clobber the override
4675                .with_threads(1);
4676
4677            let pixels = pseudo_random_rgb8(64, 64);
4678            let bytes_with_override = cfg
4679                .encode(&pixels, 64, 64, PixelLayout::Rgb8)
4680                .expect("encode");
4681            let bytes_e9_plain = LosslessConfig::new()
4682                .with_effort(9)
4683                .with_threads(1)
4684                .encode(&pixels, 64, 64, PixelLayout::Rgb8)
4685                .expect("encode");
4686
4687            assert_ne!(
4688                bytes_with_override, bytes_e9_plain,
4689                "override should persist across with_effort()"
4690            );
4691        }
4692    }
4693}