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::InvalidInput(msg) => Self::InvalidInput { message: msg },
90            crate::error::Error::OutOfMemory(e) => Self::Oom(e),
91            #[cfg(feature = "std")]
92            crate::error::Error::IoError(e) => Self::Io(e),
93            crate::error::Error::Cancelled => Self::Cancelled,
94            other => Self::Internal {
95                message: format!("{other}"),
96            },
97        }
98    }
99}
100
101#[cfg(feature = "std")]
102impl From<std::io::Error> for EncodeError {
103    fn from(e: std::io::Error) -> Self {
104        Self::Io(e)
105    }
106}
107
108impl From<enough::StopReason> for EncodeError {
109    fn from(_: enough::StopReason) -> Self {
110        Self::Cancelled
111    }
112}
113
114/// Result type for encoding operations.
115///
116/// Errors carry location traces via [`whereat::At`] for lightweight
117/// production-safe error tracking without debuginfo or backtraces.
118pub type Result<T> = core::result::Result<T, At<EncodeError>>;
119
120// ── EncodeResult / EncodeStats ──────────────────────────────────────────────
121
122/// Result of an encode operation. Holds encoded data and metrics.
123///
124/// After `encode()`, `data()` returns the JXL bytes. After `encode_into()`
125/// or `encode_to()`, `data()` returns `None` (data already delivered).
126/// Use `take_data()` to move the vec out without cloning.
127#[derive(Clone, Debug)]
128pub struct EncodeResult {
129    data: Option<Vec<u8>>,
130    stats: EncodeStats,
131}
132
133impl EncodeResult {
134    /// Encoded JXL bytes (borrowing). None if data was written elsewhere.
135    pub fn data(&self) -> Option<&[u8]> {
136        self.data.as_deref()
137    }
138
139    /// Take the owned data vec, leaving None in its place.
140    pub fn take_data(&mut self) -> Option<Vec<u8>> {
141        self.data.take()
142    }
143
144    /// Encode metrics.
145    pub fn stats(&self) -> &EncodeStats {
146        &self.stats
147    }
148}
149
150/// Encode metrics collected during encoding.
151#[derive(Clone, Debug, Default)]
152#[non_exhaustive]
153pub struct EncodeStats {
154    codestream_size: usize,
155    output_size: usize,
156    mode: EncodeMode,
157    /// Index = raw strategy code (0..19), value = first-block count.
158    strategy_counts: [u32; 19],
159    gaborish: bool,
160    ans: bool,
161    butteraugli_iters: u32,
162    pixel_domain_loss: bool,
163}
164
165impl EncodeStats {
166    /// Size of the JXL codestream in bytes (before container wrapping).
167    pub fn codestream_size(&self) -> usize {
168        self.codestream_size
169    }
170
171    /// Size of the final output in bytes (after container wrapping, if any).
172    pub fn output_size(&self) -> usize {
173        self.output_size
174    }
175
176    /// Whether the encode was lossy or lossless.
177    pub fn mode(&self) -> EncodeMode {
178        self.mode
179    }
180
181    /// Per-strategy first-block counts, indexed by raw strategy code (0..19).
182    pub fn strategy_counts(&self) -> &[u32; 19] {
183        &self.strategy_counts
184    }
185
186    /// Whether gaborish pre-filtering was enabled.
187    pub fn gaborish(&self) -> bool {
188        self.gaborish
189    }
190
191    /// Whether ANS entropy coding was used.
192    pub fn ans(&self) -> bool {
193        self.ans
194    }
195
196    /// Number of butteraugli quantization loop iterations performed.
197    pub fn butteraugli_iters(&self) -> u32 {
198        self.butteraugli_iters
199    }
200
201    /// Whether pixel-domain loss was enabled.
202    pub fn pixel_domain_loss(&self) -> bool {
203        self.pixel_domain_loss
204    }
205}
206
207/// Encoding mode.
208#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
209pub enum EncodeMode {
210    /// Lossy (VarDCT) encoding.
211    #[default]
212    Lossy,
213    /// Lossless (modular) encoding.
214    Lossless,
215}
216
217// ── PixelLayout ─────────────────────────────────────────────────────────────
218
219/// Describes the pixel format of input data.
220#[derive(Clone, Copy, Debug, PartialEq, Eq)]
221#[non_exhaustive]
222pub enum PixelLayout {
223    /// 8-bit sRGB, 3 bytes per pixel (R, G, B).
224    Rgb8,
225    /// 8-bit sRGB + alpha, 4 bytes per pixel (R, G, B, A).
226    Rgba8,
227    /// 8-bit sRGB in BGR order, 3 bytes per pixel (B, G, R).
228    Bgr8,
229    /// 8-bit sRGB in BGRA order, 4 bytes per pixel (B, G, R, A).
230    Bgra8,
231    /// 8-bit grayscale, 1 byte per pixel.
232    Gray8,
233    /// 8-bit grayscale + alpha, 2 bytes per pixel.
234    GrayAlpha8,
235    /// 16-bit sRGB, 6 bytes per pixel (R, G, B) — native-endian u16.
236    Rgb16,
237    /// 16-bit sRGB + alpha, 8 bytes per pixel (R, G, B, A) — native-endian u16.
238    Rgba16,
239    /// 16-bit grayscale, 2 bytes per pixel — native-endian u16.
240    Gray16,
241    /// Linear f32 RGB, 12 bytes per pixel. Skips sRGB→linear conversion.
242    RgbLinearF32,
243}
244
245impl PixelLayout {
246    /// Bytes per pixel for this layout.
247    pub const fn bytes_per_pixel(self) -> usize {
248        match self {
249            Self::Rgb8 | Self::Bgr8 => 3,
250            Self::Rgba8 | Self::Bgra8 => 4,
251            Self::Gray8 => 1,
252            Self::GrayAlpha8 => 2,
253            Self::Rgb16 => 6,
254            Self::Rgba16 => 8,
255            Self::Gray16 => 2,
256            Self::RgbLinearF32 => 12,
257        }
258    }
259
260    /// Whether this layout uses linear (not gamma-encoded) values.
261    pub const fn is_linear(self) -> bool {
262        matches!(self, Self::RgbLinearF32)
263    }
264
265    /// Whether this layout uses 16-bit samples.
266    pub const fn is_16bit(self) -> bool {
267        matches!(self, Self::Rgb16 | Self::Rgba16 | Self::Gray16)
268    }
269
270    /// Whether this layout includes an alpha channel.
271    pub const fn has_alpha(self) -> bool {
272        matches!(
273            self,
274            Self::Rgba8 | Self::Bgra8 | Self::GrayAlpha8 | Self::Rgba16
275        )
276    }
277
278    /// Whether this layout is grayscale.
279    pub const fn is_grayscale(self) -> bool {
280        matches!(self, Self::Gray8 | Self::GrayAlpha8 | Self::Gray16)
281    }
282}
283
284// ── Quality ─────────────────────────────────────────────────────────────────
285
286/// Quality specification for lossy encoding.
287#[derive(Clone, Copy, Debug)]
288#[non_exhaustive]
289pub enum Quality {
290    /// Butteraugli distance (1.0 = high quality, lower = better).
291    Distance(f32),
292    /// Percentage scale (0–100, 100 = mathematically lossless, invalid for lossy).
293    Percent(u32),
294}
295
296impl Quality {
297    /// Convert to butteraugli distance.
298    fn to_distance(self) -> core::result::Result<f32, EncodeError> {
299        match self {
300            Self::Distance(d) => {
301                if d <= 0.0 {
302                    return Err(EncodeError::InvalidConfig {
303                        message: format!("lossy distance must be > 0.0, got {d}"),
304                    });
305                }
306                Ok(d)
307            }
308            Self::Percent(q) => {
309                if q >= 100 {
310                    return Err(EncodeError::InvalidConfig {
311                        message: "quality 100 is lossless; use LosslessConfig instead".into(),
312                    });
313                }
314                Ok(percent_to_distance(q))
315            }
316        }
317    }
318}
319
320fn percent_to_distance(quality: u32) -> f32 {
321    if quality >= 100 {
322        0.0
323    } else if quality >= 90 {
324        (100 - quality) as f32 / 10.0
325    } else if quality >= 70 {
326        1.0 + (90 - quality) as f32 / 20.0
327    } else {
328        2.0 + (70 - quality) as f32 / 10.0
329    }
330}
331
332// ── Supporting types ────────────────────────────────────────────────────────
333
334/// Image metadata (ICC, EXIF, XMP) to embed in the JXL file.
335#[derive(Clone, Debug, Default)]
336pub struct ImageMetadata<'a> {
337    icc_profile: Option<&'a [u8]>,
338    exif: Option<&'a [u8]>,
339    xmp: Option<&'a [u8]>,
340}
341
342impl<'a> ImageMetadata<'a> {
343    /// Create empty metadata.
344    pub fn new() -> Self {
345        Self::default()
346    }
347
348    /// Attach an ICC color profile.
349    pub fn with_icc_profile(mut self, data: &'a [u8]) -> Self {
350        self.icc_profile = Some(data);
351        self
352    }
353
354    /// Attach EXIF data.
355    pub fn with_exif(mut self, data: &'a [u8]) -> Self {
356        self.exif = Some(data);
357        self
358    }
359
360    /// Attach XMP data.
361    pub fn with_xmp(mut self, data: &'a [u8]) -> Self {
362        self.xmp = Some(data);
363        self
364    }
365
366    /// Get the ICC color profile, if set.
367    pub fn icc_profile(&self) -> Option<&[u8]> {
368        self.icc_profile
369    }
370
371    /// Get the EXIF data, if set.
372    pub fn exif(&self) -> Option<&[u8]> {
373        self.exif
374    }
375
376    /// Get the XMP data, if set.
377    pub fn xmp(&self) -> Option<&[u8]> {
378        self.xmp
379    }
380}
381
382/// Resource limits for encoding.
383#[derive(Clone, Debug, Default)]
384pub struct Limits {
385    max_width: Option<u64>,
386    max_height: Option<u64>,
387    max_pixels: Option<u64>,
388    max_memory_bytes: Option<u64>,
389}
390
391impl Limits {
392    /// Create limits with no restrictions (all `None`).
393    pub fn new() -> Self {
394        Self::default()
395    }
396
397    /// Set maximum image width.
398    pub fn with_max_width(mut self, w: u64) -> Self {
399        self.max_width = Some(w);
400        self
401    }
402
403    /// Set maximum image height.
404    pub fn with_max_height(mut self, h: u64) -> Self {
405        self.max_height = Some(h);
406        self
407    }
408
409    /// Set maximum total pixels (width × height).
410    pub fn with_max_pixels(mut self, p: u64) -> Self {
411        self.max_pixels = Some(p);
412        self
413    }
414
415    /// Set maximum memory bytes the encoder may allocate.
416    pub fn with_max_memory_bytes(mut self, bytes: u64) -> Self {
417        self.max_memory_bytes = Some(bytes);
418        self
419    }
420
421    /// Get maximum width, if set.
422    pub fn max_width(&self) -> Option<u64> {
423        self.max_width
424    }
425
426    /// Get maximum height, if set.
427    pub fn max_height(&self) -> Option<u64> {
428        self.max_height
429    }
430
431    /// Get maximum pixels, if set.
432    pub fn max_pixels(&self) -> Option<u64> {
433        self.max_pixels
434    }
435
436    /// Get maximum memory bytes, if set.
437    pub fn max_memory_bytes(&self) -> Option<u64> {
438        self.max_memory_bytes
439    }
440}
441
442// ── Animation ──────────────────────────────────────────────────────────────
443
444/// Animation timing parameters.
445#[derive(Clone, Debug)]
446pub struct AnimationParams {
447    /// Ticks per second numerator (default 100 = 10ms precision).
448    pub tps_numerator: u32,
449    /// Ticks per second denominator (default 1).
450    pub tps_denominator: u32,
451    /// Number of loops: 0 = infinite (default), >0 = play N times.
452    pub num_loops: u32,
453}
454
455impl Default for AnimationParams {
456    fn default() -> Self {
457        Self {
458            tps_numerator: 100,
459            tps_denominator: 1,
460            num_loops: 0,
461        }
462    }
463}
464
465/// A single frame in an animation sequence.
466pub struct AnimationFrame<'a> {
467    /// Raw pixel data (must match width/height/layout from the encode call).
468    pub pixels: &'a [u8],
469    /// Duration of this frame in ticks (tps_numerator/tps_denominator seconds per tick).
470    pub duration: u32,
471}
472
473// ── LosslessConfig ──────────────────────────────────────────────────────────
474
475/// Lossless (modular) encoding configuration.
476///
477/// Has a sensible `Default` — lossless has no quality ambiguity.
478#[derive(Clone, Debug)]
479pub struct LosslessConfig {
480    effort: u8,
481    use_ans: bool,
482    squeeze: bool,
483    tree_learning: bool,
484    lz77: bool,
485    lz77_method: Lz77Method,
486}
487
488impl Default for LosslessConfig {
489    fn default() -> Self {
490        Self {
491            effort: 7,
492            use_ans: true,
493            squeeze: false,
494            tree_learning: false,
495            lz77: false,
496            lz77_method: Lz77Method::Greedy,
497        }
498    }
499}
500
501impl LosslessConfig {
502    /// Create a new lossless config with defaults.
503    pub fn new() -> Self {
504        Self::default()
505    }
506
507    /// Set effort level (1–10). Higher = slower, better compression.
508    pub fn with_effort(mut self, effort: u8) -> Self {
509        self.effort = effort;
510        self
511    }
512
513    /// Enable/disable ANS entropy coding (default: true).
514    pub fn with_ans(mut self, enable: bool) -> Self {
515        self.use_ans = enable;
516        self
517    }
518
519    /// Enable/disable squeeze (Haar wavelet) transform (default: false).
520    pub fn with_squeeze(mut self, enable: bool) -> Self {
521        self.squeeze = enable;
522        self
523    }
524
525    /// Enable/disable content-adaptive tree learning (default: false).
526    pub fn with_tree_learning(mut self, enable: bool) -> Self {
527        self.tree_learning = enable;
528        self
529    }
530
531    /// Enable/disable LZ77 backward references (default: false).
532    pub fn with_lz77(mut self, enable: bool) -> Self {
533        self.lz77 = enable;
534        self
535    }
536
537    /// Set LZ77 method (default: Greedy). Only effective when LZ77 is enabled.
538    pub fn with_lz77_method(mut self, method: Lz77Method) -> Self {
539        self.lz77_method = method;
540        self
541    }
542
543    // ── Getters ───────────────────────────────────────────────────────
544
545    /// Current effort level.
546    pub fn effort(&self) -> u8 {
547        self.effort
548    }
549
550    /// Whether ANS entropy coding is enabled.
551    pub fn ans(&self) -> bool {
552        self.use_ans
553    }
554
555    /// Whether squeeze (Haar wavelet) transform is enabled.
556    pub fn squeeze(&self) -> bool {
557        self.squeeze
558    }
559
560    /// Whether content-adaptive tree learning is enabled.
561    pub fn tree_learning(&self) -> bool {
562        self.tree_learning
563    }
564
565    /// Whether LZ77 backward references are enabled.
566    pub fn lz77(&self) -> bool {
567        self.lz77
568    }
569
570    /// Current LZ77 method.
571    pub fn lz77_method(&self) -> Lz77Method {
572        self.lz77_method
573    }
574
575    // ── Request / fluent encode ─────────────────────────────────────
576
577    /// Create an encode request for an image with this config.
578    ///
579    /// Use this when you need to attach metadata, limits, or cancellation.
580    pub fn encode_request(
581        &self,
582        width: u32,
583        height: u32,
584        layout: PixelLayout,
585    ) -> EncodeRequest<'_> {
586        EncodeRequest {
587            config: ConfigRef::Lossless(self),
588            width,
589            height,
590            layout,
591            metadata: None,
592            limits: None,
593            stop: None,
594        }
595    }
596
597    /// Encode pixels directly with this config. Shortcut for simple cases.
598    ///
599    /// ```rust,no_run
600    /// # let pixels = vec![0u8; 100 * 100 * 3];
601    /// let jxl = jxl_encoder::LosslessConfig::new()
602    ///     .encode(&pixels, 100, 100, jxl_encoder::PixelLayout::Rgb8)?;
603    /// # Ok::<_, jxl_encoder::At<jxl_encoder::EncodeError>>(())
604    /// ```
605    #[track_caller]
606    pub fn encode(
607        &self,
608        pixels: &[u8],
609        width: u32,
610        height: u32,
611        layout: PixelLayout,
612    ) -> Result<Vec<u8>> {
613        self.encode_request(width, height, layout).encode(pixels)
614    }
615
616    /// Encode pixels, appending to an existing buffer.
617    #[track_caller]
618    pub fn encode_into(
619        &self,
620        pixels: &[u8],
621        width: u32,
622        height: u32,
623        layout: PixelLayout,
624        out: &mut Vec<u8>,
625    ) -> Result<()> {
626        self.encode_request(width, height, layout)
627            .encode_into(pixels, out)
628            .map(|_| ())
629    }
630
631    /// Encode a multi-frame animation as a lossless JXL.
632    ///
633    /// Each frame must have the same dimensions and pixel layout.
634    /// Returns the complete JXL codestream bytes.
635    #[track_caller]
636    pub fn encode_animation(
637        &self,
638        width: u32,
639        height: u32,
640        layout: PixelLayout,
641        animation: &AnimationParams,
642        frames: &[AnimationFrame<'_>],
643    ) -> Result<Vec<u8>> {
644        encode_animation_lossless(self, width, height, layout, animation, frames).map_err(at)
645    }
646}
647
648// ── LossyConfig ─────────────────────────────────────────────────────────────
649
650#[cfg(feature = "butteraugli-loop")]
651fn butteraugli_iters_for_effort(effort: u8) -> u32 {
652    match effort {
653        0..=7 => 0,
654        8 => 2,
655        _ => 4,
656    }
657}
658
659/// Lossy (VarDCT) encoding configuration.
660///
661/// No `Default` — distance/quality is a required choice.
662#[derive(Clone, Debug)]
663pub struct LossyConfig {
664    distance: f32,
665    effort: u8,
666    use_ans: bool,
667    gaborish: bool,
668    noise: bool,
669    denoise: bool,
670    error_diffusion: bool,
671    pixel_domain_loss: bool,
672    lz77: bool,
673    lz77_method: Lz77Method,
674    force_strategy: Option<u8>,
675    #[cfg(feature = "butteraugli-loop")]
676    butteraugli_iters: u32,
677    #[cfg(feature = "butteraugli-loop")]
678    butteraugli_iters_explicit: bool,
679}
680
681impl LossyConfig {
682    /// Create with butteraugli distance (1.0 = high quality).
683    pub fn new(distance: f32) -> Self {
684        let effort = 7;
685        Self {
686            distance,
687            effort,
688            use_ans: true,
689            gaborish: true,
690            noise: false,
691            denoise: false,
692            error_diffusion: true,
693            pixel_domain_loss: true,
694            lz77: false,
695            lz77_method: Lz77Method::Greedy,
696            force_strategy: None,
697            #[cfg(feature = "butteraugli-loop")]
698            butteraugli_iters: butteraugli_iters_for_effort(effort),
699            #[cfg(feature = "butteraugli-loop")]
700            butteraugli_iters_explicit: false,
701        }
702    }
703
704    /// Create from a [`Quality`] specification.
705    pub fn from_quality(quality: Quality) -> core::result::Result<Self, EncodeError> {
706        let distance = quality.to_distance()?;
707        Ok(Self::new(distance))
708    }
709
710    /// Set effort level (1–10).
711    ///
712    /// Also adjusts butteraugli iterations to match libjxl's effort gating,
713    /// unless [`with_butteraugli_iters`](Self::with_butteraugli_iters) was called explicitly.
714    pub fn with_effort(mut self, effort: u8) -> Self {
715        self.effort = effort;
716        #[cfg(feature = "butteraugli-loop")]
717        if !self.butteraugli_iters_explicit {
718            self.butteraugli_iters = butteraugli_iters_for_effort(effort);
719        }
720        self
721    }
722
723    /// Enable/disable ANS entropy coding (default: true).
724    pub fn with_ans(mut self, enable: bool) -> Self {
725        self.use_ans = enable;
726        self
727    }
728
729    /// Enable/disable gaborish inverse pre-filter (default: true).
730    pub fn with_gaborish(mut self, enable: bool) -> Self {
731        self.gaborish = enable;
732        self
733    }
734
735    /// Enable/disable noise synthesis (default: false).
736    pub fn with_noise(mut self, enable: bool) -> Self {
737        self.noise = enable;
738        self
739    }
740
741    /// Enable/disable Wiener denoising pre-filter (default: false). Implies noise.
742    pub fn with_denoise(mut self, enable: bool) -> Self {
743        self.denoise = enable;
744        if enable {
745            self.noise = true;
746        }
747        self
748    }
749
750    /// Enable/disable error diffusion in AC quantization (default: true).
751    pub fn with_error_diffusion(mut self, enable: bool) -> Self {
752        self.error_diffusion = enable;
753        self
754    }
755
756    /// Enable/disable pixel-domain loss in strategy selection (default: true).
757    pub fn with_pixel_domain_loss(mut self, enable: bool) -> Self {
758        self.pixel_domain_loss = enable;
759        self
760    }
761
762    /// Enable/disable LZ77 backward references (default: false).
763    pub fn with_lz77(mut self, enable: bool) -> Self {
764        self.lz77 = enable;
765        self
766    }
767
768    /// Set LZ77 method (default: Greedy).
769    pub fn with_lz77_method(mut self, method: Lz77Method) -> Self {
770        self.lz77_method = method;
771        self
772    }
773
774    /// Force a specific AC strategy for all blocks. `None` for auto-selection.
775    pub fn with_force_strategy(mut self, strategy: Option<u8>) -> Self {
776        self.force_strategy = strategy;
777        self
778    }
779
780    /// Set butteraugli quantization loop iterations explicitly.
781    ///
782    /// Overrides the automatic effort-based default (effort 7: 0, effort 8: 2, effort 9+: 4).
783    /// Requires the `butteraugli-loop` feature.
784    #[cfg(feature = "butteraugli-loop")]
785    pub fn with_butteraugli_iters(mut self, n: u32) -> Self {
786        self.butteraugli_iters = n;
787        self.butteraugli_iters_explicit = true;
788        self
789    }
790
791    // ── Getters ───────────────────────────────────────────────────────
792
793    /// Current butteraugli distance.
794    pub fn distance(&self) -> f32 {
795        self.distance
796    }
797
798    /// Current effort level.
799    pub fn effort(&self) -> u8 {
800        self.effort
801    }
802
803    /// Whether ANS entropy coding is enabled.
804    pub fn ans(&self) -> bool {
805        self.use_ans
806    }
807
808    /// Whether gaborish inverse pre-filter is enabled.
809    pub fn gaborish(&self) -> bool {
810        self.gaborish
811    }
812
813    /// Whether noise synthesis is enabled.
814    pub fn noise(&self) -> bool {
815        self.noise
816    }
817
818    /// Whether Wiener denoising pre-filter is enabled.
819    pub fn denoise(&self) -> bool {
820        self.denoise
821    }
822
823    /// Whether error diffusion in AC quantization is enabled.
824    pub fn error_diffusion(&self) -> bool {
825        self.error_diffusion
826    }
827
828    /// Whether pixel-domain loss is enabled.
829    pub fn pixel_domain_loss(&self) -> bool {
830        self.pixel_domain_loss
831    }
832
833    /// Whether LZ77 backward references are enabled.
834    pub fn lz77(&self) -> bool {
835        self.lz77
836    }
837
838    /// Current LZ77 method.
839    pub fn lz77_method(&self) -> Lz77Method {
840        self.lz77_method
841    }
842
843    /// Forced AC strategy, if any.
844    pub fn force_strategy(&self) -> Option<u8> {
845        self.force_strategy
846    }
847
848    /// Butteraugli quantization loop iterations.
849    #[cfg(feature = "butteraugli-loop")]
850    pub fn butteraugli_iters(&self) -> u32 {
851        self.butteraugli_iters
852    }
853
854    // ── Request / fluent encode ─────────────────────────────────────
855
856    /// Create an encode request for an image with this config.
857    ///
858    /// Use this when you need to attach metadata, limits, or cancellation.
859    pub fn encode_request(
860        &self,
861        width: u32,
862        height: u32,
863        layout: PixelLayout,
864    ) -> EncodeRequest<'_> {
865        EncodeRequest {
866            config: ConfigRef::Lossy(self),
867            width,
868            height,
869            layout,
870            metadata: None,
871            limits: None,
872            stop: None,
873        }
874    }
875
876    /// Encode pixels directly with this config. Shortcut for simple cases.
877    ///
878    /// ```rust,no_run
879    /// # let pixels = vec![0u8; 100 * 100 * 3];
880    /// let jxl = jxl_encoder::LossyConfig::new(1.0)
881    ///     .encode(&pixels, 100, 100, jxl_encoder::PixelLayout::Rgb8)?;
882    /// # Ok::<_, jxl_encoder::At<jxl_encoder::EncodeError>>(())
883    /// ```
884    #[track_caller]
885    pub fn encode(
886        &self,
887        pixels: &[u8],
888        width: u32,
889        height: u32,
890        layout: PixelLayout,
891    ) -> Result<Vec<u8>> {
892        self.encode_request(width, height, layout).encode(pixels)
893    }
894
895    /// Encode pixels, appending to an existing buffer.
896    #[track_caller]
897    pub fn encode_into(
898        &self,
899        pixels: &[u8],
900        width: u32,
901        height: u32,
902        layout: PixelLayout,
903        out: &mut Vec<u8>,
904    ) -> Result<()> {
905        self.encode_request(width, height, layout)
906            .encode_into(pixels, out)
907            .map(|_| ())
908    }
909
910    /// Encode a multi-frame animation as a lossy JXL.
911    ///
912    /// Each frame must have the same dimensions and pixel layout.
913    /// Returns the complete JXL codestream bytes.
914    #[track_caller]
915    pub fn encode_animation(
916        &self,
917        width: u32,
918        height: u32,
919        layout: PixelLayout,
920        animation: &AnimationParams,
921        frames: &[AnimationFrame<'_>],
922    ) -> Result<Vec<u8>> {
923        encode_animation_lossy(self, width, height, layout, animation, frames).map_err(at)
924    }
925}
926
927// ── EncodeRequest ───────────────────────────────────────────────────────────
928
929/// Internal config reference (lossy or lossless).
930#[derive(Clone, Copy, Debug)]
931enum ConfigRef<'a> {
932    Lossless(&'a LosslessConfig),
933    Lossy(&'a LossyConfig),
934}
935
936/// An encoding request — binds config + image dimensions + pixel layout.
937///
938/// Created via [`LosslessConfig::encode_request`] or [`LossyConfig::encode_request`].
939pub struct EncodeRequest<'a> {
940    config: ConfigRef<'a>,
941    width: u32,
942    height: u32,
943    layout: PixelLayout,
944    metadata: Option<&'a ImageMetadata<'a>>,
945    limits: Option<&'a Limits>,
946    stop: Option<&'a dyn Stop>,
947}
948
949impl<'a> EncodeRequest<'a> {
950    /// Attach image metadata (ICC, EXIF, XMP).
951    pub fn with_metadata(mut self, meta: &'a ImageMetadata<'a>) -> Self {
952        self.metadata = Some(meta);
953        self
954    }
955
956    /// Attach resource limits.
957    pub fn with_limits(mut self, limits: &'a Limits) -> Self {
958        self.limits = Some(limits);
959        self
960    }
961
962    /// Attach a cooperative cancellation token.
963    ///
964    /// The encoder will check this periodically and return
965    /// [`EncodeError::Cancelled`] if stopped.
966    pub fn with_stop(mut self, stop: &'a dyn Stop) -> Self {
967        self.stop = Some(stop);
968        self
969    }
970
971    /// Encode pixels and return the JXL bytes.
972    #[track_caller]
973    pub fn encode(self, pixels: &[u8]) -> Result<Vec<u8>> {
974        self.encode_inner(pixels)
975            .map(|mut r| r.take_data().unwrap())
976            .map_err(at)
977    }
978
979    /// Encode pixels and return the JXL bytes together with [`EncodeStats`].
980    #[track_caller]
981    pub fn encode_with_stats(self, pixels: &[u8]) -> Result<EncodeResult> {
982        self.encode_inner(pixels).map_err(at)
983    }
984
985    /// Encode pixels, appending to an existing buffer. Returns metrics.
986    #[track_caller]
987    pub fn encode_into(self, pixels: &[u8], out: &mut Vec<u8>) -> Result<EncodeResult> {
988        let mut result = self.encode_inner(pixels).map_err(at)?;
989        if let Some(data) = result.data.take() {
990            out.extend_from_slice(&data);
991        }
992        Ok(result)
993    }
994
995    /// Encode pixels, writing to a `std::io::Write` destination. Returns metrics.
996    #[cfg(feature = "std")]
997    #[track_caller]
998    pub fn encode_to(self, pixels: &[u8], mut dest: impl std::io::Write) -> Result<EncodeResult> {
999        let mut result = self.encode_inner(pixels).map_err(at)?;
1000        if let Some(data) = result.data.take() {
1001            dest.write_all(&data)
1002                .map_err(|e| at(EncodeError::from(e)))?;
1003        }
1004        Ok(result)
1005    }
1006
1007    fn encode_inner(&self, pixels: &[u8]) -> core::result::Result<EncodeResult, EncodeError> {
1008        self.validate_pixels(pixels)?;
1009        self.check_limits()?;
1010
1011        let (codestream, mut stats) = match self.config {
1012            ConfigRef::Lossless(cfg) => self.encode_lossless(cfg, pixels),
1013            ConfigRef::Lossy(cfg) => self.encode_lossy(cfg, pixels),
1014        }?;
1015
1016        stats.codestream_size = codestream.len();
1017
1018        // Wrap in container if metadata (EXIF/XMP) is present
1019        let output = if let Some(meta) = self.metadata
1020            && (meta.exif.is_some() || meta.xmp.is_some())
1021        {
1022            crate::container::wrap_in_container(&codestream, meta.exif, meta.xmp)
1023        } else {
1024            codestream
1025        };
1026
1027        stats.output_size = output.len();
1028
1029        Ok(EncodeResult {
1030            data: Some(output),
1031            stats,
1032        })
1033    }
1034
1035    fn validate_pixels(&self, pixels: &[u8]) -> core::result::Result<(), EncodeError> {
1036        let w = self.width as usize;
1037        let h = self.height as usize;
1038        if w == 0 || h == 0 {
1039            return Err(EncodeError::InvalidInput {
1040                message: format!("zero dimensions: {w}x{h}"),
1041            });
1042        }
1043        let expected = w
1044            .checked_mul(h)
1045            .and_then(|n| n.checked_mul(self.layout.bytes_per_pixel()));
1046        match expected {
1047            Some(expected) if pixels.len() == expected => Ok(()),
1048            Some(expected) => Err(EncodeError::InvalidInput {
1049                message: format!(
1050                    "pixel buffer size mismatch: expected {expected} bytes for {w}x{h} {:?}, got {}",
1051                    self.layout,
1052                    pixels.len()
1053                ),
1054            }),
1055            None => Err(EncodeError::InvalidInput {
1056                message: "image dimensions overflow".into(),
1057            }),
1058        }
1059    }
1060
1061    fn check_limits(&self) -> core::result::Result<(), EncodeError> {
1062        let Some(limits) = self.limits else {
1063            return Ok(());
1064        };
1065        let w = self.width as u64;
1066        let h = self.height as u64;
1067        if let Some(max_w) = limits.max_width
1068            && w > max_w
1069        {
1070            return Err(EncodeError::LimitExceeded {
1071                message: format!("width {w} > max {max_w}"),
1072            });
1073        }
1074        if let Some(max_h) = limits.max_height
1075            && h > max_h
1076        {
1077            return Err(EncodeError::LimitExceeded {
1078                message: format!("height {h} > max {max_h}"),
1079            });
1080        }
1081        if let Some(max_px) = limits.max_pixels
1082            && w * h > max_px
1083        {
1084            return Err(EncodeError::LimitExceeded {
1085                message: format!("pixels {}x{} = {} > max {max_px}", w, h, w * h),
1086            });
1087        }
1088        Ok(())
1089    }
1090
1091    // ── Lossless path ───────────────────────────────────────────────────
1092
1093    fn encode_lossless(
1094        &self,
1095        cfg: &LosslessConfig,
1096        pixels: &[u8],
1097    ) -> core::result::Result<(Vec<u8>, EncodeStats), EncodeError> {
1098        use crate::bit_writer::BitWriter;
1099        use crate::headers::{ColorEncoding, FileHeader};
1100        use crate::modular::channel::ModularImage;
1101        use crate::modular::frame::{FrameEncoder, FrameEncoderOptions};
1102
1103        let w = self.width as usize;
1104        let h = self.height as usize;
1105
1106        // Build ModularImage from pixel layout
1107        let image = match self.layout {
1108            PixelLayout::Rgb8 => ModularImage::from_rgb8(pixels, w, h),
1109            PixelLayout::Rgba8 => ModularImage::from_rgba8(pixels, w, h),
1110            PixelLayout::Bgr8 => ModularImage::from_rgb8(&bgr_to_rgb(pixels, 3), w, h),
1111            PixelLayout::Bgra8 => ModularImage::from_rgba8(&bgr_to_rgb(pixels, 4), w, h),
1112            PixelLayout::Gray8 => ModularImage::from_gray8(pixels, w, h),
1113            PixelLayout::Rgb16 => ModularImage::from_rgb16_native(pixels, w, h),
1114            PixelLayout::Rgba16 => ModularImage::from_rgba16_native(pixels, w, h),
1115            PixelLayout::Gray16 => ModularImage::from_gray16_native(pixels, w, h),
1116            other => return Err(EncodeError::UnsupportedPixelLayout(other)),
1117        }
1118        .map_err(EncodeError::from)?;
1119
1120        // Build file header
1121        let mut file_header = if image.is_grayscale {
1122            FileHeader::new_gray(self.width, self.height)
1123        } else if image.has_alpha {
1124            FileHeader::new_rgba(self.width, self.height)
1125        } else {
1126            FileHeader::new_rgb(self.width, self.height)
1127        };
1128        if image.bit_depth == 16 {
1129            file_header.metadata.bit_depth = crate::headers::file_header::BitDepth::uint16();
1130            for ec in &mut file_header.metadata.extra_channels {
1131                ec.bit_depth = crate::headers::file_header::BitDepth::uint16();
1132            }
1133        }
1134        if let Some(meta) = self.metadata
1135            && meta.icc_profile.is_some()
1136        {
1137            file_header.metadata.color_encoding.want_icc = true;
1138        }
1139
1140        // Write codestream
1141        let mut writer = BitWriter::new();
1142        file_header.write(&mut writer).map_err(EncodeError::from)?;
1143        if let Some(meta) = self.metadata
1144            && let Some(icc) = meta.icc_profile
1145        {
1146            crate::icc::write_icc(icc, &mut writer).map_err(EncodeError::from)?;
1147        }
1148        writer.zero_pad_to_byte();
1149
1150        // Encode frame
1151        let frame_encoder = FrameEncoder::new(
1152            w,
1153            h,
1154            FrameEncoderOptions {
1155                use_modular: true,
1156                effort: cfg.effort,
1157                use_ans: cfg.use_ans,
1158                use_tree_learning: cfg.tree_learning,
1159                use_squeeze: cfg.squeeze,
1160                have_animation: false,
1161                duration: 0,
1162                is_last: true,
1163                crop: None,
1164            },
1165        );
1166        let color_encoding = ColorEncoding::srgb();
1167        frame_encoder
1168            .encode_modular(&image, &color_encoding, &mut writer)
1169            .map_err(EncodeError::from)?;
1170
1171        let stats = EncodeStats {
1172            mode: EncodeMode::Lossless,
1173            ans: cfg.use_ans,
1174            ..Default::default()
1175        };
1176        Ok((writer.finish_with_padding(), stats))
1177    }
1178
1179    // ── Lossy path ──────────────────────────────────────────────────────
1180
1181    fn encode_lossy(
1182        &self,
1183        cfg: &LossyConfig,
1184        pixels: &[u8],
1185    ) -> core::result::Result<(Vec<u8>, EncodeStats), EncodeError> {
1186        let w = self.width as usize;
1187        let h = self.height as usize;
1188
1189        // Build linear f32 RGB and extract alpha from input layout
1190        let (linear_rgb, alpha, bit_depth_16) = match self.layout {
1191            PixelLayout::Rgb8 => (srgb_u8_to_linear_f32(pixels, 3), None, false),
1192            PixelLayout::Bgr8 => (
1193                srgb_u8_to_linear_f32(&bgr_to_rgb(pixels, 3), 3),
1194                None,
1195                false,
1196            ),
1197            PixelLayout::Rgba8 => {
1198                let rgb = srgb_u8_to_linear_f32(pixels, 4);
1199                let alpha = extract_alpha(pixels, 4, 3);
1200                (rgb, Some(alpha), false)
1201            }
1202            PixelLayout::Bgra8 => {
1203                let swapped = bgr_to_rgb(pixels, 4);
1204                let rgb = srgb_u8_to_linear_f32(&swapped, 4);
1205                let alpha = extract_alpha(pixels, 4, 3);
1206                (rgb, Some(alpha), false)
1207            }
1208            PixelLayout::Rgb16 => (srgb_u16_to_linear_f32(pixels, 3), None, true),
1209            PixelLayout::Rgba16 => {
1210                let rgb = srgb_u16_to_linear_f32(pixels, 4);
1211                let alpha = extract_alpha_u16(pixels, 4, 3);
1212                (rgb, Some(alpha), true)
1213            }
1214            PixelLayout::RgbLinearF32 => {
1215                let floats: &[f32] = bytemuck::cast_slice(pixels);
1216                (floats.to_vec(), None, false)
1217            }
1218            PixelLayout::Gray8 | PixelLayout::GrayAlpha8 | PixelLayout::Gray16 => {
1219                return Err(EncodeError::UnsupportedPixelLayout(self.layout));
1220            }
1221        };
1222
1223        let mut tiny = crate::vardct::VarDctEncoder::new(cfg.distance);
1224        tiny.use_ans = cfg.use_ans;
1225        tiny.optimize_codes = true;
1226        tiny.custom_orders = true;
1227        tiny.enable_noise = cfg.noise;
1228        tiny.enable_denoise = cfg.denoise;
1229        tiny.enable_gaborish = cfg.gaborish;
1230        tiny.error_diffusion = cfg.error_diffusion;
1231        tiny.pixel_domain_loss = cfg.pixel_domain_loss;
1232        tiny.enable_lz77 = cfg.lz77;
1233        tiny.lz77_method = cfg.lz77_method;
1234        tiny.force_strategy = cfg.force_strategy;
1235        #[cfg(feature = "butteraugli-loop")]
1236        {
1237            tiny.butteraugli_iters = cfg.butteraugli_iters;
1238        }
1239
1240        tiny.bit_depth_16 = bit_depth_16;
1241
1242        // ICC profile from metadata
1243        if let Some(meta) = self.metadata
1244            && let Some(icc) = meta.icc_profile
1245        {
1246            tiny.icc_profile = Some(icc.to_vec());
1247        }
1248
1249        let output = tiny
1250            .encode(w, h, &linear_rgb, alpha.as_deref())
1251            .map_err(EncodeError::from)?;
1252
1253        #[cfg(feature = "butteraugli-loop")]
1254        let butteraugli_iters_actual = cfg.butteraugli_iters;
1255        #[cfg(not(feature = "butteraugli-loop"))]
1256        let butteraugli_iters_actual = 0u32;
1257
1258        let stats = EncodeStats {
1259            mode: EncodeMode::Lossy,
1260            strategy_counts: output.strategy_counts,
1261            gaborish: cfg.gaborish,
1262            ans: cfg.use_ans,
1263            butteraugli_iters: butteraugli_iters_actual,
1264            pixel_domain_loss: cfg.pixel_domain_loss,
1265            ..Default::default()
1266        };
1267        Ok((output.data, stats))
1268    }
1269}
1270
1271// ── Animation encode implementations ────────────────────────────────────────
1272
1273fn validate_animation_input(
1274    width: u32,
1275    height: u32,
1276    layout: PixelLayout,
1277    frames: &[AnimationFrame<'_>],
1278) -> core::result::Result<(), EncodeError> {
1279    if width == 0 || height == 0 {
1280        return Err(EncodeError::InvalidInput {
1281            message: format!("zero dimensions: {width}x{height}"),
1282        });
1283    }
1284    if frames.is_empty() {
1285        return Err(EncodeError::InvalidInput {
1286            message: "animation requires at least one frame".into(),
1287        });
1288    }
1289    let expected_size = (width as usize)
1290        .checked_mul(height as usize)
1291        .and_then(|n| n.checked_mul(layout.bytes_per_pixel()))
1292        .ok_or_else(|| EncodeError::InvalidInput {
1293            message: "image dimensions overflow".into(),
1294        })?;
1295    for (i, frame) in frames.iter().enumerate() {
1296        if frame.pixels.len() != expected_size {
1297            return Err(EncodeError::InvalidInput {
1298                message: format!(
1299                    "frame {} pixel buffer size mismatch: expected {expected_size}, got {}",
1300                    i,
1301                    frame.pixels.len()
1302                ),
1303            });
1304        }
1305    }
1306    Ok(())
1307}
1308
1309fn encode_animation_lossless(
1310    cfg: &LosslessConfig,
1311    width: u32,
1312    height: u32,
1313    layout: PixelLayout,
1314    animation: &AnimationParams,
1315    frames: &[AnimationFrame<'_>],
1316) -> core::result::Result<Vec<u8>, EncodeError> {
1317    use crate::bit_writer::BitWriter;
1318    use crate::headers::file_header::AnimationHeader;
1319    use crate::headers::{ColorEncoding, FileHeader};
1320    use crate::modular::channel::ModularImage;
1321    use crate::modular::frame::{FrameEncoder, FrameEncoderOptions};
1322
1323    validate_animation_input(width, height, layout, frames)?;
1324
1325    let w = width as usize;
1326    let h = height as usize;
1327    let num_frames = frames.len();
1328
1329    // Build file header with animation
1330    let sample_image = match layout {
1331        PixelLayout::Rgb8 => ModularImage::from_rgb8(frames[0].pixels, w, h),
1332        PixelLayout::Rgba8 => ModularImage::from_rgba8(frames[0].pixels, w, h),
1333        PixelLayout::Bgr8 => ModularImage::from_rgb8(&bgr_to_rgb(frames[0].pixels, 3), w, h),
1334        PixelLayout::Bgra8 => ModularImage::from_rgba8(&bgr_to_rgb(frames[0].pixels, 4), w, h),
1335        PixelLayout::Gray8 => ModularImage::from_gray8(frames[0].pixels, w, h),
1336        PixelLayout::Rgb16 => ModularImage::from_rgb16_native(frames[0].pixels, w, h),
1337        PixelLayout::Rgba16 => ModularImage::from_rgba16_native(frames[0].pixels, w, h),
1338        PixelLayout::Gray16 => ModularImage::from_gray16_native(frames[0].pixels, w, h),
1339        other => return Err(EncodeError::UnsupportedPixelLayout(other)),
1340    }
1341    .map_err(EncodeError::from)?;
1342
1343    let mut file_header = if sample_image.is_grayscale {
1344        FileHeader::new_gray(width, height)
1345    } else if sample_image.has_alpha {
1346        FileHeader::new_rgba(width, height)
1347    } else {
1348        FileHeader::new_rgb(width, height)
1349    };
1350    if sample_image.bit_depth == 16 {
1351        file_header.metadata.bit_depth = crate::headers::file_header::BitDepth::uint16();
1352        for ec in &mut file_header.metadata.extra_channels {
1353            ec.bit_depth = crate::headers::file_header::BitDepth::uint16();
1354        }
1355    }
1356    file_header.metadata.animation = Some(AnimationHeader {
1357        tps_numerator: animation.tps_numerator,
1358        tps_denominator: animation.tps_denominator,
1359        num_loops: animation.num_loops,
1360        have_timecodes: false,
1361    });
1362
1363    // Write file header
1364    let mut writer = BitWriter::new();
1365    file_header.write(&mut writer).map_err(EncodeError::from)?;
1366    writer.zero_pad_to_byte();
1367
1368    // Encode each frame with crop detection
1369    let color_encoding = ColorEncoding::srgb();
1370    let bpp = layout.bytes_per_pixel();
1371    let mut prev_pixels: Option<&[u8]> = None;
1372
1373    for (i, frame) in frames.iter().enumerate() {
1374        // Detect crop: compare current frame against previous.
1375        // Only use crop when it's smaller than the full frame.
1376        let crop = if let Some(prev) = prev_pixels {
1377            match detect_frame_crop(prev, frame.pixels, w, h, bpp, false) {
1378                Some(crop) if (crop.width as usize) < w || (crop.height as usize) < h => Some(crop),
1379                Some(_) => None, // Crop covers full frame — no benefit
1380                None => {
1381                    // Frames are identical — emit a minimal 1x1 crop to preserve canvas
1382                    Some(FrameCrop {
1383                        x0: 0,
1384                        y0: 0,
1385                        width: 1,
1386                        height: 1,
1387                    })
1388                }
1389            }
1390        } else {
1391            None // Frame 0: always full frame
1392        };
1393
1394        // Build ModularImage from the appropriate pixel region
1395        let (frame_w, frame_h, frame_pixels_owned);
1396        let frame_pixels: &[u8] = if let Some(ref crop) = crop {
1397            frame_w = crop.width as usize;
1398            frame_h = crop.height as usize;
1399            frame_pixels_owned = extract_pixel_crop(frame.pixels, w, crop, bpp);
1400            &frame_pixels_owned
1401        } else {
1402            frame_w = w;
1403            frame_h = h;
1404            frame_pixels_owned = Vec::new();
1405            let _ = &frame_pixels_owned; // suppress unused warning
1406            frame.pixels
1407        };
1408
1409        let image = match layout {
1410            PixelLayout::Rgb8 => ModularImage::from_rgb8(frame_pixels, frame_w, frame_h),
1411            PixelLayout::Rgba8 => ModularImage::from_rgba8(frame_pixels, frame_w, frame_h),
1412            PixelLayout::Bgr8 => {
1413                ModularImage::from_rgb8(&bgr_to_rgb(frame_pixels, 3), frame_w, frame_h)
1414            }
1415            PixelLayout::Bgra8 => {
1416                ModularImage::from_rgba8(&bgr_to_rgb(frame_pixels, 4), frame_w, frame_h)
1417            }
1418            PixelLayout::Gray8 => ModularImage::from_gray8(frame_pixels, frame_w, frame_h),
1419            PixelLayout::Rgb16 => ModularImage::from_rgb16_native(frame_pixels, frame_w, frame_h),
1420            PixelLayout::Rgba16 => ModularImage::from_rgba16_native(frame_pixels, frame_w, frame_h),
1421            PixelLayout::Gray16 => ModularImage::from_gray16_native(frame_pixels, frame_w, frame_h),
1422            other => return Err(EncodeError::UnsupportedPixelLayout(other)),
1423        }
1424        .map_err(EncodeError::from)?;
1425
1426        let frame_encoder = FrameEncoder::new(
1427            frame_w,
1428            frame_h,
1429            FrameEncoderOptions {
1430                use_modular: true,
1431                effort: cfg.effort,
1432                use_ans: cfg.use_ans,
1433                use_tree_learning: cfg.tree_learning,
1434                use_squeeze: cfg.squeeze,
1435                have_animation: true,
1436                duration: frame.duration,
1437                is_last: i == num_frames - 1,
1438                crop,
1439            },
1440        );
1441        frame_encoder
1442            .encode_modular(&image, &color_encoding, &mut writer)
1443            .map_err(EncodeError::from)?;
1444
1445        prev_pixels = Some(frame.pixels);
1446    }
1447
1448    Ok(writer.finish_with_padding())
1449}
1450
1451fn encode_animation_lossy(
1452    cfg: &LossyConfig,
1453    width: u32,
1454    height: u32,
1455    layout: PixelLayout,
1456    animation: &AnimationParams,
1457    frames: &[AnimationFrame<'_>],
1458) -> core::result::Result<Vec<u8>, EncodeError> {
1459    use crate::bit_writer::BitWriter;
1460    use crate::headers::file_header::AnimationHeader;
1461    use crate::headers::frame_header::FrameOptions;
1462
1463    validate_animation_input(width, height, layout, frames)?;
1464
1465    let w = width as usize;
1466    let h = height as usize;
1467    let num_frames = frames.len();
1468
1469    // Set up VarDCT encoder
1470    let mut tiny = crate::vardct::VarDctEncoder::new(cfg.distance);
1471    tiny.use_ans = cfg.use_ans;
1472    tiny.optimize_codes = true;
1473    tiny.custom_orders = true;
1474    tiny.enable_noise = cfg.noise;
1475    tiny.enable_denoise = cfg.denoise;
1476    tiny.enable_gaborish = cfg.gaborish;
1477    tiny.error_diffusion = cfg.error_diffusion;
1478    tiny.pixel_domain_loss = cfg.pixel_domain_loss;
1479    tiny.enable_lz77 = cfg.lz77;
1480    tiny.lz77_method = cfg.lz77_method;
1481    tiny.force_strategy = cfg.force_strategy;
1482    #[cfg(feature = "butteraugli-loop")]
1483    {
1484        tiny.butteraugli_iters = cfg.butteraugli_iters;
1485    }
1486
1487    // Detect alpha and 16-bit from layout
1488    let has_alpha = layout.has_alpha();
1489    let bit_depth_16 = matches!(layout, PixelLayout::Rgb16 | PixelLayout::Rgba16);
1490    tiny.bit_depth_16 = bit_depth_16;
1491
1492    // Build file header from VarDCT encoder (sets xyb_encoded, rendering_intent, etc.)
1493    // then add animation metadata
1494    let mut file_header = tiny.build_file_header(w, h, has_alpha);
1495    file_header.metadata.animation = Some(AnimationHeader {
1496        tps_numerator: animation.tps_numerator,
1497        tps_denominator: animation.tps_denominator,
1498        num_loops: animation.num_loops,
1499        have_timecodes: false,
1500    });
1501
1502    let mut writer = BitWriter::with_capacity(w * h * 4);
1503    file_header.write(&mut writer).map_err(EncodeError::from)?;
1504    if let Some(ref icc) = tiny.icc_profile {
1505        crate::icc::write_icc(icc, &mut writer).map_err(EncodeError::from)?;
1506    }
1507    writer.zero_pad_to_byte();
1508
1509    // Encode each frame with crop detection
1510    let bpp = layout.bytes_per_pixel();
1511    let mut prev_pixels: Option<&[u8]> = None;
1512
1513    for (i, frame) in frames.iter().enumerate() {
1514        // Detect crop on raw input pixels (before linear conversion).
1515        // Only use crop when it's smaller than the full frame.
1516        let crop = if let Some(prev) = prev_pixels {
1517            match detect_frame_crop(prev, frame.pixels, w, h, bpp, true) {
1518                Some(crop) if (crop.width as usize) < w || (crop.height as usize) < h => Some(crop),
1519                Some(_) => None, // Crop covers full frame — no benefit
1520                None => {
1521                    // Frames identical — emit minimal 8x8 crop (VarDCT minimum)
1522                    Some(FrameCrop {
1523                        x0: 0,
1524                        y0: 0,
1525                        width: 8.min(width),
1526                        height: 8.min(height),
1527                    })
1528                }
1529            }
1530        } else {
1531            None // Frame 0: always full frame
1532        };
1533
1534        // Extract crop region from raw pixels, then convert to linear
1535        let (frame_w, frame_h) = if let Some(ref crop) = crop {
1536            (crop.width as usize, crop.height as usize)
1537        } else {
1538            (w, h)
1539        };
1540
1541        let crop_pixels_owned;
1542        let src_pixels: &[u8] = if let Some(ref crop) = crop {
1543            crop_pixels_owned = extract_pixel_crop(frame.pixels, w, crop, bpp);
1544            &crop_pixels_owned
1545        } else {
1546            crop_pixels_owned = Vec::new();
1547            let _ = &crop_pixels_owned;
1548            frame.pixels
1549        };
1550
1551        let (linear_rgb, alpha) = match layout {
1552            PixelLayout::Rgb8 => (srgb_u8_to_linear_f32(src_pixels, 3), None),
1553            PixelLayout::Bgr8 => (srgb_u8_to_linear_f32(&bgr_to_rgb(src_pixels, 3), 3), None),
1554            PixelLayout::Rgba8 => {
1555                let rgb = srgb_u8_to_linear_f32(src_pixels, 4);
1556                let alpha = extract_alpha(src_pixels, 4, 3);
1557                (rgb, Some(alpha))
1558            }
1559            PixelLayout::Bgra8 => {
1560                let swapped = bgr_to_rgb(src_pixels, 4);
1561                let rgb = srgb_u8_to_linear_f32(&swapped, 4);
1562                let alpha = extract_alpha(src_pixels, 4, 3);
1563                (rgb, Some(alpha))
1564            }
1565            PixelLayout::Rgb16 => (srgb_u16_to_linear_f32(src_pixels, 3), None),
1566            PixelLayout::Rgba16 => {
1567                let rgb = srgb_u16_to_linear_f32(src_pixels, 4);
1568                let alpha = extract_alpha_u16(src_pixels, 4, 3);
1569                (rgb, Some(alpha))
1570            }
1571            PixelLayout::RgbLinearF32 => {
1572                let floats: &[f32] = bytemuck::cast_slice(src_pixels);
1573                (floats.to_vec(), None)
1574            }
1575            PixelLayout::Gray8 | PixelLayout::GrayAlpha8 | PixelLayout::Gray16 => {
1576                return Err(EncodeError::UnsupportedPixelLayout(layout));
1577            }
1578        };
1579
1580        let frame_options = FrameOptions {
1581            have_animation: true,
1582            have_timecodes: false,
1583            duration: frame.duration,
1584            is_last: i == num_frames - 1,
1585            crop,
1586        };
1587
1588        tiny.encode_frame_to_writer(
1589            frame_w,
1590            frame_h,
1591            &linear_rgb,
1592            alpha.as_deref(),
1593            &frame_options,
1594            &mut writer,
1595        )
1596        .map_err(EncodeError::from)?;
1597
1598        prev_pixels = Some(frame.pixels);
1599    }
1600
1601    Ok(writer.finish_with_padding())
1602}
1603
1604// ── Animation frame crop detection ──────────────────────────────────────────
1605
1606use crate::headers::frame_header::FrameCrop;
1607
1608/// Detects the minimal bounding rectangle that differs between two frames.
1609///
1610/// Compares `prev` and `curr` byte-by-byte. Returns `Some(FrameCrop)` with the
1611/// tight bounding box of changed pixels, or `None` if the frames are identical.
1612///
1613/// When `align_to_8x8` is true (for VarDCT), the crop is expanded outward to
1614/// 8x8 block boundaries for better compression.
1615fn detect_frame_crop(
1616    prev: &[u8],
1617    curr: &[u8],
1618    width: usize,
1619    height: usize,
1620    bytes_per_pixel: usize,
1621    align_to_8x8: bool,
1622) -> Option<FrameCrop> {
1623    let stride = width * bytes_per_pixel;
1624    debug_assert_eq!(prev.len(), height * stride);
1625    debug_assert_eq!(curr.len(), height * stride);
1626
1627    // Find top (first row with a difference)
1628    let mut top = height;
1629    let mut bottom = 0;
1630    let mut left = width;
1631    let mut right = 0;
1632
1633    for y in 0..height {
1634        let row_start = y * stride;
1635        let prev_row = &prev[row_start..row_start + stride];
1636        let curr_row = &curr[row_start..row_start + stride];
1637
1638        // Fast row comparison via u64 chunks — lets the compiler auto-vectorize
1639        let (prev_prefix, prev_u64, prev_suffix) = bytemuck::pod_align_to::<u8, u64>(prev_row);
1640        let (curr_prefix, curr_u64, curr_suffix) = bytemuck::pod_align_to::<u8, u64>(curr_row);
1641        if prev_prefix == curr_prefix && prev_u64 == curr_u64 && prev_suffix == curr_suffix {
1642            continue;
1643        }
1644
1645        // This row has differences — find leftmost and rightmost changed pixel
1646        if top == height {
1647            top = y;
1648        }
1649        bottom = y;
1650
1651        // Scan from left to find first differing pixel
1652        for x in 0..width {
1653            let px_start = x * bytes_per_pixel;
1654            if prev_row[px_start..px_start + bytes_per_pixel]
1655                != curr_row[px_start..px_start + bytes_per_pixel]
1656            {
1657                left = left.min(x);
1658                break;
1659            }
1660        }
1661        // Scan from right to find last differing pixel
1662        for x in (0..width).rev() {
1663            let px_start = x * bytes_per_pixel;
1664            if prev_row[px_start..px_start + bytes_per_pixel]
1665                != curr_row[px_start..px_start + bytes_per_pixel]
1666            {
1667                right = right.max(x);
1668                break;
1669            }
1670        }
1671    }
1672
1673    if top == height {
1674        // Frames are identical
1675        return None;
1676    }
1677
1678    // Convert to crop rectangle (inclusive → exclusive for width/height)
1679    let mut crop_x = left as i32;
1680    let mut crop_y = top as i32;
1681    let mut crop_w = (right - left + 1) as u32;
1682    let mut crop_h = (bottom - top + 1) as u32;
1683
1684    if align_to_8x8 {
1685        // Expand to 8x8 block boundaries
1686        let aligned_x = (crop_x / 8) * 8;
1687        let aligned_y = (crop_y / 8) * 8;
1688        let end_x = (crop_x as u32 + crop_w).div_ceil(8) * 8;
1689        let end_y = (crop_y as u32 + crop_h).div_ceil(8) * 8;
1690        crop_x = aligned_x;
1691        crop_y = aligned_y;
1692        crop_w = end_x.min(width as u32) - aligned_x as u32;
1693        crop_h = end_y.min(height as u32) - aligned_y as u32;
1694    }
1695
1696    Some(FrameCrop {
1697        x0: crop_x,
1698        y0: crop_y,
1699        width: crop_w,
1700        height: crop_h,
1701    })
1702}
1703
1704/// Extracts a rectangular crop region from a pixel buffer.
1705///
1706/// `bytes_per_pixel` is the number of bytes per pixel (e.g., 3 for RGB, 4 for RGBA).
1707fn extract_pixel_crop(
1708    pixels: &[u8],
1709    full_width: usize,
1710    crop: &FrameCrop,
1711    bytes_per_pixel: usize,
1712) -> Vec<u8> {
1713    let cx = crop.x0 as usize;
1714    let cy = crop.y0 as usize;
1715    let cw = crop.width as usize;
1716    let ch = crop.height as usize;
1717    let stride = full_width * bytes_per_pixel;
1718
1719    let mut out = Vec::with_capacity(cw * ch * bytes_per_pixel);
1720    for y in cy..cy + ch {
1721        let row_start = y * stride + cx * bytes_per_pixel;
1722        out.extend_from_slice(&pixels[row_start..row_start + cw * bytes_per_pixel]);
1723    }
1724    out
1725}
1726
1727// ── Pixel conversion helpers ────────────────────────────────────────────────
1728
1729/// sRGB u8 → linear f32 (IEC 61966-2-1).
1730#[inline]
1731fn srgb_to_linear(c: u8) -> f32 {
1732    srgb_to_linear_f(c as f32 / 255.0)
1733}
1734
1735fn srgb_u8_to_linear_f32(data: &[u8], channels: usize) -> Vec<f32> {
1736    data.chunks(channels)
1737        .flat_map(|px| {
1738            [
1739                srgb_to_linear(px[0]),
1740                srgb_to_linear(px[1]),
1741                srgb_to_linear(px[2]),
1742            ]
1743        })
1744        .collect()
1745}
1746
1747/// sRGB u16 → linear f32 (IEC 61966-2-1).
1748fn srgb_u16_to_linear_f32(data: &[u8], channels: usize) -> Vec<f32> {
1749    let pixels: &[u16] = bytemuck::cast_slice(data);
1750    pixels
1751        .chunks(channels)
1752        .flat_map(|px| {
1753            [
1754                srgb_to_linear_f(px[0] as f32 / 65535.0),
1755                srgb_to_linear_f(px[1] as f32 / 65535.0),
1756                srgb_to_linear_f(px[2] as f32 / 65535.0),
1757            ]
1758        })
1759        .collect()
1760}
1761
1762/// sRGB transfer function: normalized float [0,1] → linear float.
1763#[inline]
1764fn srgb_to_linear_f(c: f32) -> f32 {
1765    if c <= 0.04045 {
1766        c / 12.92
1767    } else {
1768        ((c + 0.055) / 1.055).powf(2.4)
1769    }
1770}
1771
1772/// Extract alpha channel from interleaved 16-bit pixel data as u8 (quantized).
1773fn extract_alpha_u16(data: &[u8], stride: usize, alpha_offset: usize) -> Vec<u8> {
1774    let pixels: &[u16] = bytemuck::cast_slice(data);
1775    pixels
1776        .chunks(stride)
1777        .map(|px| (px[alpha_offset] >> 8) as u8)
1778        .collect()
1779}
1780
1781/// Swap B and R channels in-place equivalent: BGR(A) → RGB(A).
1782fn bgr_to_rgb(data: &[u8], stride: usize) -> Vec<u8> {
1783    let mut out = data.to_vec();
1784    for chunk in out.chunks_mut(stride) {
1785        chunk.swap(0, 2);
1786    }
1787    out
1788}
1789
1790/// Extract a single channel from interleaved pixel data.
1791fn extract_alpha(data: &[u8], stride: usize, alpha_offset: usize) -> Vec<u8> {
1792    data.chunks(stride).map(|px| px[alpha_offset]).collect()
1793}
1794
1795// ── Tests ───────────────────────────────────────────────────────────────────
1796
1797#[cfg(test)]
1798mod tests {
1799    use super::*;
1800
1801    #[test]
1802    fn test_lossless_config_builder_and_getters() {
1803        let cfg = LosslessConfig::new()
1804            .with_effort(5)
1805            .with_ans(false)
1806            .with_squeeze(true)
1807            .with_tree_learning(true);
1808        assert_eq!(cfg.effort(), 5);
1809        assert!(!cfg.ans());
1810        assert!(cfg.squeeze());
1811        assert!(cfg.tree_learning());
1812    }
1813
1814    #[test]
1815    fn test_lossy_config_builder_and_getters() {
1816        let cfg = LossyConfig::new(2.0)
1817            .with_effort(3)
1818            .with_gaborish(false)
1819            .with_noise(true);
1820        assert_eq!(cfg.distance(), 2.0);
1821        assert_eq!(cfg.effort(), 3);
1822        assert!(!cfg.gaborish());
1823        assert!(cfg.noise());
1824    }
1825
1826    #[test]
1827    fn test_pixel_layout_helpers() {
1828        assert_eq!(PixelLayout::Rgb8.bytes_per_pixel(), 3);
1829        assert_eq!(PixelLayout::Rgba8.bytes_per_pixel(), 4);
1830        assert_eq!(PixelLayout::Bgr8.bytes_per_pixel(), 3);
1831        assert_eq!(PixelLayout::Bgra8.bytes_per_pixel(), 4);
1832        assert_eq!(PixelLayout::Gray8.bytes_per_pixel(), 1);
1833        assert_eq!(PixelLayout::Rgb16.bytes_per_pixel(), 6);
1834        assert_eq!(PixelLayout::Rgba16.bytes_per_pixel(), 8);
1835        assert_eq!(PixelLayout::Gray16.bytes_per_pixel(), 2);
1836        assert!(!PixelLayout::Rgb8.is_linear());
1837        assert!(PixelLayout::RgbLinearF32.is_linear());
1838        assert!(!PixelLayout::Rgb16.is_linear());
1839        assert!(!PixelLayout::Rgb8.has_alpha());
1840        assert!(PixelLayout::Rgba8.has_alpha());
1841        assert!(PixelLayout::Bgra8.has_alpha());
1842        assert!(PixelLayout::GrayAlpha8.has_alpha());
1843        assert!(PixelLayout::Rgba16.has_alpha());
1844        assert!(!PixelLayout::Rgb16.has_alpha());
1845        assert!(PixelLayout::Rgb16.is_16bit());
1846        assert!(PixelLayout::Rgba16.is_16bit());
1847        assert!(PixelLayout::Gray16.is_16bit());
1848        assert!(!PixelLayout::Rgb8.is_16bit());
1849        assert!(PixelLayout::Gray8.is_grayscale());
1850        assert!(PixelLayout::Gray16.is_grayscale());
1851        assert!(!PixelLayout::Rgb16.is_grayscale());
1852    }
1853
1854    #[test]
1855    fn test_quality_to_distance() {
1856        assert!(Quality::Distance(1.0).to_distance().unwrap() == 1.0);
1857        assert!(Quality::Distance(-1.0).to_distance().is_err());
1858        assert!(Quality::Percent(100).to_distance().is_err()); // lossless invalid for lossy
1859        assert!(Quality::Percent(90).to_distance().unwrap() == 1.0);
1860    }
1861
1862    #[test]
1863    fn test_pixel_validation() {
1864        let cfg = LosslessConfig::new();
1865        let req = cfg.encode_request(2, 2, PixelLayout::Rgb8);
1866        assert!(req.validate_pixels(&[0u8; 12]).is_ok());
1867    }
1868
1869    #[test]
1870    fn test_pixel_validation_wrong_size() {
1871        let cfg = LosslessConfig::new();
1872        let req = cfg.encode_request(2, 2, PixelLayout::Rgb8);
1873        assert!(req.validate_pixels(&[0u8; 11]).is_err());
1874    }
1875
1876    #[test]
1877    fn test_limits_check() {
1878        let limits = Limits::new().with_max_width(100);
1879        let cfg = LosslessConfig::new();
1880        let req = cfg
1881            .encode_request(200, 100, PixelLayout::Rgb8)
1882            .with_limits(&limits);
1883        assert!(req.check_limits().is_err());
1884    }
1885
1886    #[test]
1887    fn test_lossless_encode_rgb8_small() {
1888        // 4x4 red image
1889        let pixels = [255u8, 0, 0].repeat(16);
1890        let result = LosslessConfig::new()
1891            .encode_request(4, 4, PixelLayout::Rgb8)
1892            .encode(&pixels);
1893        assert!(result.is_ok());
1894        let jxl = result.unwrap();
1895        assert_eq!(&jxl[..2], &[0xFF, 0x0A]); // JXL signature
1896    }
1897
1898    #[test]
1899    fn test_lossy_encode_rgb8_small() {
1900        // 8x8 gradient
1901        let mut pixels = Vec::with_capacity(8 * 8 * 3);
1902        for y in 0..8u8 {
1903            for x in 0..8u8 {
1904                pixels.push(x * 32);
1905                pixels.push(y * 32);
1906                pixels.push(128);
1907            }
1908        }
1909        let result = LossyConfig::new(2.0)
1910            .with_gaborish(false)
1911            .encode_request(8, 8, PixelLayout::Rgb8)
1912            .encode(&pixels);
1913        assert!(result.is_ok());
1914        let jxl = result.unwrap();
1915        assert_eq!(&jxl[..2], &[0xFF, 0x0A]);
1916    }
1917
1918    #[test]
1919    fn test_fluent_lossless() {
1920        let pixels = vec![128u8; 4 * 4 * 3];
1921        let result = LosslessConfig::new().encode(&pixels, 4, 4, PixelLayout::Rgb8);
1922        assert!(result.is_ok());
1923    }
1924
1925    #[test]
1926    fn test_lossy_unsupported_gray() {
1927        let pixels = vec![128u8; 8 * 8];
1928        let result = LossyConfig::new(1.0)
1929            .encode_request(8, 8, PixelLayout::Gray8)
1930            .encode(&pixels);
1931        assert!(matches!(
1932            result.as_ref().map_err(|e| e.error()),
1933            Err(EncodeError::UnsupportedPixelLayout(_))
1934        ));
1935    }
1936
1937    #[test]
1938    fn test_bgra_lossless() {
1939        // 4x4 red image in BGRA (B=0, G=0, R=255, A=255)
1940        let pixels = [0u8, 0, 255, 255].repeat(16);
1941        let result = LosslessConfig::new().encode(&pixels, 4, 4, PixelLayout::Bgra8);
1942        assert!(result.is_ok());
1943        let jxl = result.unwrap();
1944        assert_eq!(&jxl[..2], &[0xFF, 0x0A]);
1945    }
1946
1947    #[test]
1948    fn test_lossy_alpha_encodes() {
1949        // Lossy+alpha: VarDCT RGB + modular alpha extra channel
1950        let pixels = [255u8, 0, 0, 255].repeat(64);
1951        let result =
1952            LossyConfig::new(2.0)
1953                .with_gaborish(false)
1954                .encode(&pixels, 8, 8, PixelLayout::Bgra8);
1955        assert!(
1956            result.is_ok(),
1957            "BGRA lossy encode failed: {:?}",
1958            result.err()
1959        );
1960
1961        let result2 = LossyConfig::new(2.0).encode(&pixels, 8, 8, PixelLayout::Rgba8);
1962        assert!(
1963            result2.is_ok(),
1964            "RGBA lossy encode failed: {:?}",
1965            result2.err()
1966        );
1967    }
1968
1969    #[test]
1970    fn test_stop_cancellation() {
1971        use enough::Unstoppable;
1972        // Unstoppable should not cancel
1973        let pixels = vec![128u8; 4 * 4 * 3];
1974        let cfg = LosslessConfig::new();
1975        let result = cfg
1976            .encode_request(4, 4, PixelLayout::Rgb8)
1977            .with_stop(&Unstoppable)
1978            .encode(&pixels);
1979        assert!(result.is_ok());
1980    }
1981}