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    // libjxl runs FindBestQuantization (butteraugli loop) at all efforts <= kKitten (e8).
653    // Default is 2 iterations, tortoise (e9+) gets 4.
654    match effort {
655        0..=4 => 0,
656        5..=8 => 2,
657        _ => 4,
658    }
659}
660
661/// Lossy (VarDCT) encoding configuration.
662///
663/// No `Default` — distance/quality is a required choice.
664#[derive(Clone, Debug)]
665pub struct LossyConfig {
666    distance: f32,
667    effort: u8,
668    use_ans: bool,
669    gaborish: bool,
670    noise: bool,
671    denoise: bool,
672    error_diffusion: bool,
673    pixel_domain_loss: bool,
674    lz77: bool,
675    lz77_method: Lz77Method,
676    force_strategy: Option<u8>,
677    #[cfg(feature = "butteraugli-loop")]
678    butteraugli_iters: u32,
679    #[cfg(feature = "butteraugli-loop")]
680    butteraugli_iters_explicit: bool,
681}
682
683impl LossyConfig {
684    /// Create with butteraugli distance (1.0 = high quality).
685    pub fn new(distance: f32) -> Self {
686        let effort = 7;
687        Self {
688            distance,
689            effort,
690            use_ans: true,
691            gaborish: true,
692            noise: false,
693            denoise: false,
694            error_diffusion: true,
695            pixel_domain_loss: true,
696            lz77: false,
697            lz77_method: Lz77Method::Greedy,
698            force_strategy: None,
699            #[cfg(feature = "butteraugli-loop")]
700            butteraugli_iters: butteraugli_iters_for_effort(effort),
701            #[cfg(feature = "butteraugli-loop")]
702            butteraugli_iters_explicit: false,
703        }
704    }
705
706    /// Create from a [`Quality`] specification.
707    pub fn from_quality(quality: Quality) -> core::result::Result<Self, EncodeError> {
708        let distance = quality.to_distance()?;
709        Ok(Self::new(distance))
710    }
711
712    /// Set effort level (1–10).
713    ///
714    /// Also adjusts butteraugli iterations to match libjxl's effort gating,
715    /// unless [`with_butteraugli_iters`](Self::with_butteraugli_iters) was called explicitly.
716    pub fn with_effort(mut self, effort: u8) -> Self {
717        self.effort = effort;
718        #[cfg(feature = "butteraugli-loop")]
719        if !self.butteraugli_iters_explicit {
720            self.butteraugli_iters = butteraugli_iters_for_effort(effort);
721        }
722        self
723    }
724
725    /// Enable/disable ANS entropy coding (default: true).
726    pub fn with_ans(mut self, enable: bool) -> Self {
727        self.use_ans = enable;
728        self
729    }
730
731    /// Enable/disable gaborish inverse pre-filter (default: true).
732    pub fn with_gaborish(mut self, enable: bool) -> Self {
733        self.gaborish = enable;
734        self
735    }
736
737    /// Enable/disable noise synthesis (default: false).
738    pub fn with_noise(mut self, enable: bool) -> Self {
739        self.noise = enable;
740        self
741    }
742
743    /// Enable/disable Wiener denoising pre-filter (default: false). Implies noise.
744    pub fn with_denoise(mut self, enable: bool) -> Self {
745        self.denoise = enable;
746        if enable {
747            self.noise = true;
748        }
749        self
750    }
751
752    /// Enable/disable error diffusion in AC quantization (default: true).
753    pub fn with_error_diffusion(mut self, enable: bool) -> Self {
754        self.error_diffusion = enable;
755        self
756    }
757
758    /// Enable/disable pixel-domain loss in strategy selection (default: true).
759    pub fn with_pixel_domain_loss(mut self, enable: bool) -> Self {
760        self.pixel_domain_loss = enable;
761        self
762    }
763
764    /// Enable/disable LZ77 backward references (default: false).
765    pub fn with_lz77(mut self, enable: bool) -> Self {
766        self.lz77 = enable;
767        self
768    }
769
770    /// Set LZ77 method (default: Greedy).
771    pub fn with_lz77_method(mut self, method: Lz77Method) -> Self {
772        self.lz77_method = method;
773        self
774    }
775
776    /// Force a specific AC strategy for all blocks. `None` for auto-selection.
777    pub fn with_force_strategy(mut self, strategy: Option<u8>) -> Self {
778        self.force_strategy = strategy;
779        self
780    }
781
782    /// Set butteraugli quantization loop iterations explicitly.
783    ///
784    /// Overrides the automatic effort-based default (effort 7: 0, effort 8: 2, effort 9+: 4).
785    /// Requires the `butteraugli-loop` feature.
786    #[cfg(feature = "butteraugli-loop")]
787    pub fn with_butteraugli_iters(mut self, n: u32) -> Self {
788        self.butteraugli_iters = n;
789        self.butteraugli_iters_explicit = true;
790        self
791    }
792
793    // ── Getters ───────────────────────────────────────────────────────
794
795    /// Current butteraugli distance.
796    pub fn distance(&self) -> f32 {
797        self.distance
798    }
799
800    /// Current effort level.
801    pub fn effort(&self) -> u8 {
802        self.effort
803    }
804
805    /// Whether ANS entropy coding is enabled.
806    pub fn ans(&self) -> bool {
807        self.use_ans
808    }
809
810    /// Whether gaborish inverse pre-filter is enabled.
811    pub fn gaborish(&self) -> bool {
812        self.gaborish
813    }
814
815    /// Whether noise synthesis is enabled.
816    pub fn noise(&self) -> bool {
817        self.noise
818    }
819
820    /// Whether Wiener denoising pre-filter is enabled.
821    pub fn denoise(&self) -> bool {
822        self.denoise
823    }
824
825    /// Whether error diffusion in AC quantization is enabled.
826    pub fn error_diffusion(&self) -> bool {
827        self.error_diffusion
828    }
829
830    /// Whether pixel-domain loss is enabled.
831    pub fn pixel_domain_loss(&self) -> bool {
832        self.pixel_domain_loss
833    }
834
835    /// Whether LZ77 backward references are enabled.
836    pub fn lz77(&self) -> bool {
837        self.lz77
838    }
839
840    /// Current LZ77 method.
841    pub fn lz77_method(&self) -> Lz77Method {
842        self.lz77_method
843    }
844
845    /// Forced AC strategy, if any.
846    pub fn force_strategy(&self) -> Option<u8> {
847        self.force_strategy
848    }
849
850    /// Butteraugli quantization loop iterations.
851    #[cfg(feature = "butteraugli-loop")]
852    pub fn butteraugli_iters(&self) -> u32 {
853        self.butteraugli_iters
854    }
855
856    // ── Request / fluent encode ─────────────────────────────────────
857
858    /// Create an encode request for an image with this config.
859    ///
860    /// Use this when you need to attach metadata, limits, or cancellation.
861    pub fn encode_request(
862        &self,
863        width: u32,
864        height: u32,
865        layout: PixelLayout,
866    ) -> EncodeRequest<'_> {
867        EncodeRequest {
868            config: ConfigRef::Lossy(self),
869            width,
870            height,
871            layout,
872            metadata: None,
873            limits: None,
874            stop: None,
875        }
876    }
877
878    /// Encode pixels directly with this config. Shortcut for simple cases.
879    ///
880    /// ```rust,no_run
881    /// # let pixels = vec![0u8; 100 * 100 * 3];
882    /// let jxl = jxl_encoder::LossyConfig::new(1.0)
883    ///     .encode(&pixels, 100, 100, jxl_encoder::PixelLayout::Rgb8)?;
884    /// # Ok::<_, jxl_encoder::At<jxl_encoder::EncodeError>>(())
885    /// ```
886    #[track_caller]
887    pub fn encode(
888        &self,
889        pixels: &[u8],
890        width: u32,
891        height: u32,
892        layout: PixelLayout,
893    ) -> Result<Vec<u8>> {
894        self.encode_request(width, height, layout).encode(pixels)
895    }
896
897    /// Encode pixels, appending to an existing buffer.
898    #[track_caller]
899    pub fn encode_into(
900        &self,
901        pixels: &[u8],
902        width: u32,
903        height: u32,
904        layout: PixelLayout,
905        out: &mut Vec<u8>,
906    ) -> Result<()> {
907        self.encode_request(width, height, layout)
908            .encode_into(pixels, out)
909            .map(|_| ())
910    }
911
912    /// Encode a multi-frame animation as a lossy JXL.
913    ///
914    /// Each frame must have the same dimensions and pixel layout.
915    /// Returns the complete JXL codestream bytes.
916    #[track_caller]
917    pub fn encode_animation(
918        &self,
919        width: u32,
920        height: u32,
921        layout: PixelLayout,
922        animation: &AnimationParams,
923        frames: &[AnimationFrame<'_>],
924    ) -> Result<Vec<u8>> {
925        encode_animation_lossy(self, width, height, layout, animation, frames).map_err(at)
926    }
927}
928
929// ── EncodeRequest ───────────────────────────────────────────────────────────
930
931/// Internal config reference (lossy or lossless).
932#[derive(Clone, Copy, Debug)]
933enum ConfigRef<'a> {
934    Lossless(&'a LosslessConfig),
935    Lossy(&'a LossyConfig),
936}
937
938/// An encoding request — binds config + image dimensions + pixel layout.
939///
940/// Created via [`LosslessConfig::encode_request`] or [`LossyConfig::encode_request`].
941pub struct EncodeRequest<'a> {
942    config: ConfigRef<'a>,
943    width: u32,
944    height: u32,
945    layout: PixelLayout,
946    metadata: Option<&'a ImageMetadata<'a>>,
947    limits: Option<&'a Limits>,
948    stop: Option<&'a dyn Stop>,
949}
950
951impl<'a> EncodeRequest<'a> {
952    /// Attach image metadata (ICC, EXIF, XMP).
953    pub fn with_metadata(mut self, meta: &'a ImageMetadata<'a>) -> Self {
954        self.metadata = Some(meta);
955        self
956    }
957
958    /// Attach resource limits.
959    pub fn with_limits(mut self, limits: &'a Limits) -> Self {
960        self.limits = Some(limits);
961        self
962    }
963
964    /// Attach a cooperative cancellation token.
965    ///
966    /// The encoder will check this periodically and return
967    /// [`EncodeError::Cancelled`] if stopped.
968    pub fn with_stop(mut self, stop: &'a dyn Stop) -> Self {
969        self.stop = Some(stop);
970        self
971    }
972
973    /// Encode pixels and return the JXL bytes.
974    #[track_caller]
975    pub fn encode(self, pixels: &[u8]) -> Result<Vec<u8>> {
976        self.encode_inner(pixels)
977            .map(|mut r| r.take_data().unwrap())
978            .map_err(at)
979    }
980
981    /// Encode pixels and return the JXL bytes together with [`EncodeStats`].
982    #[track_caller]
983    pub fn encode_with_stats(self, pixels: &[u8]) -> Result<EncodeResult> {
984        self.encode_inner(pixels).map_err(at)
985    }
986
987    /// Encode pixels, appending to an existing buffer. Returns metrics.
988    #[track_caller]
989    pub fn encode_into(self, pixels: &[u8], out: &mut Vec<u8>) -> Result<EncodeResult> {
990        let mut result = self.encode_inner(pixels).map_err(at)?;
991        if let Some(data) = result.data.take() {
992            out.extend_from_slice(&data);
993        }
994        Ok(result)
995    }
996
997    /// Encode pixels, writing to a `std::io::Write` destination. Returns metrics.
998    #[cfg(feature = "std")]
999    #[track_caller]
1000    pub fn encode_to(self, pixels: &[u8], mut dest: impl std::io::Write) -> Result<EncodeResult> {
1001        let mut result = self.encode_inner(pixels).map_err(at)?;
1002        if let Some(data) = result.data.take() {
1003            dest.write_all(&data)
1004                .map_err(|e| at(EncodeError::from(e)))?;
1005        }
1006        Ok(result)
1007    }
1008
1009    fn encode_inner(&self, pixels: &[u8]) -> core::result::Result<EncodeResult, EncodeError> {
1010        self.validate_pixels(pixels)?;
1011        self.check_limits()?;
1012
1013        let (codestream, mut stats) = match self.config {
1014            ConfigRef::Lossless(cfg) => self.encode_lossless(cfg, pixels),
1015            ConfigRef::Lossy(cfg) => self.encode_lossy(cfg, pixels),
1016        }?;
1017
1018        stats.codestream_size = codestream.len();
1019
1020        // Wrap in container if metadata (EXIF/XMP) is present
1021        let output = if let Some(meta) = self.metadata
1022            && (meta.exif.is_some() || meta.xmp.is_some())
1023        {
1024            crate::container::wrap_in_container(&codestream, meta.exif, meta.xmp)
1025        } else {
1026            codestream
1027        };
1028
1029        stats.output_size = output.len();
1030
1031        Ok(EncodeResult {
1032            data: Some(output),
1033            stats,
1034        })
1035    }
1036
1037    fn validate_pixels(&self, pixels: &[u8]) -> core::result::Result<(), EncodeError> {
1038        let w = self.width as usize;
1039        let h = self.height as usize;
1040        if w == 0 || h == 0 {
1041            return Err(EncodeError::InvalidInput {
1042                message: format!("zero dimensions: {w}x{h}"),
1043            });
1044        }
1045        let expected = w
1046            .checked_mul(h)
1047            .and_then(|n| n.checked_mul(self.layout.bytes_per_pixel()));
1048        match expected {
1049            Some(expected) if pixels.len() == expected => Ok(()),
1050            Some(expected) => Err(EncodeError::InvalidInput {
1051                message: format!(
1052                    "pixel buffer size mismatch: expected {expected} bytes for {w}x{h} {:?}, got {}",
1053                    self.layout,
1054                    pixels.len()
1055                ),
1056            }),
1057            None => Err(EncodeError::InvalidInput {
1058                message: "image dimensions overflow".into(),
1059            }),
1060        }
1061    }
1062
1063    fn check_limits(&self) -> core::result::Result<(), EncodeError> {
1064        let Some(limits) = self.limits else {
1065            return Ok(());
1066        };
1067        let w = self.width as u64;
1068        let h = self.height as u64;
1069        if let Some(max_w) = limits.max_width
1070            && w > max_w
1071        {
1072            return Err(EncodeError::LimitExceeded {
1073                message: format!("width {w} > max {max_w}"),
1074            });
1075        }
1076        if let Some(max_h) = limits.max_height
1077            && h > max_h
1078        {
1079            return Err(EncodeError::LimitExceeded {
1080                message: format!("height {h} > max {max_h}"),
1081            });
1082        }
1083        if let Some(max_px) = limits.max_pixels
1084            && w * h > max_px
1085        {
1086            return Err(EncodeError::LimitExceeded {
1087                message: format!("pixels {}x{} = {} > max {max_px}", w, h, w * h),
1088            });
1089        }
1090        Ok(())
1091    }
1092
1093    // ── Lossless path ───────────────────────────────────────────────────
1094
1095    fn encode_lossless(
1096        &self,
1097        cfg: &LosslessConfig,
1098        pixels: &[u8],
1099    ) -> core::result::Result<(Vec<u8>, EncodeStats), EncodeError> {
1100        use crate::bit_writer::BitWriter;
1101        use crate::headers::{ColorEncoding, FileHeader};
1102        use crate::modular::channel::ModularImage;
1103        use crate::modular::frame::{FrameEncoder, FrameEncoderOptions};
1104
1105        let w = self.width as usize;
1106        let h = self.height as usize;
1107
1108        // Build ModularImage from pixel layout
1109        let image = match self.layout {
1110            PixelLayout::Rgb8 => ModularImage::from_rgb8(pixels, w, h),
1111            PixelLayout::Rgba8 => ModularImage::from_rgba8(pixels, w, h),
1112            PixelLayout::Bgr8 => ModularImage::from_rgb8(&bgr_to_rgb(pixels, 3), w, h),
1113            PixelLayout::Bgra8 => ModularImage::from_rgba8(&bgr_to_rgb(pixels, 4), w, h),
1114            PixelLayout::Gray8 => ModularImage::from_gray8(pixels, w, h),
1115            PixelLayout::Rgb16 => ModularImage::from_rgb16_native(pixels, w, h),
1116            PixelLayout::Rgba16 => ModularImage::from_rgba16_native(pixels, w, h),
1117            PixelLayout::Gray16 => ModularImage::from_gray16_native(pixels, w, h),
1118            other => return Err(EncodeError::UnsupportedPixelLayout(other)),
1119        }
1120        .map_err(EncodeError::from)?;
1121
1122        // Build file header
1123        let mut file_header = if image.is_grayscale {
1124            FileHeader::new_gray(self.width, self.height)
1125        } else if image.has_alpha {
1126            FileHeader::new_rgba(self.width, self.height)
1127        } else {
1128            FileHeader::new_rgb(self.width, self.height)
1129        };
1130        if image.bit_depth == 16 {
1131            file_header.metadata.bit_depth = crate::headers::file_header::BitDepth::uint16();
1132            for ec in &mut file_header.metadata.extra_channels {
1133                ec.bit_depth = crate::headers::file_header::BitDepth::uint16();
1134            }
1135        }
1136        if let Some(meta) = self.metadata
1137            && meta.icc_profile.is_some()
1138        {
1139            file_header.metadata.color_encoding.want_icc = true;
1140        }
1141
1142        // Write codestream
1143        let mut writer = BitWriter::new();
1144        file_header.write(&mut writer).map_err(EncodeError::from)?;
1145        if let Some(meta) = self.metadata
1146            && let Some(icc) = meta.icc_profile
1147        {
1148            crate::icc::write_icc(icc, &mut writer).map_err(EncodeError::from)?;
1149        }
1150        writer.zero_pad_to_byte();
1151
1152        // Encode frame
1153        let frame_encoder = FrameEncoder::new(
1154            w,
1155            h,
1156            FrameEncoderOptions {
1157                use_modular: true,
1158                effort: cfg.effort,
1159                use_ans: cfg.use_ans,
1160                use_tree_learning: cfg.tree_learning,
1161                use_squeeze: cfg.squeeze,
1162                have_animation: false,
1163                duration: 0,
1164                is_last: true,
1165                crop: None,
1166            },
1167        );
1168        let color_encoding = ColorEncoding::srgb();
1169        frame_encoder
1170            .encode_modular(&image, &color_encoding, &mut writer)
1171            .map_err(EncodeError::from)?;
1172
1173        let stats = EncodeStats {
1174            mode: EncodeMode::Lossless,
1175            ans: cfg.use_ans,
1176            ..Default::default()
1177        };
1178        Ok((writer.finish_with_padding(), stats))
1179    }
1180
1181    // ── Lossy path ──────────────────────────────────────────────────────
1182
1183    fn encode_lossy(
1184        &self,
1185        cfg: &LossyConfig,
1186        pixels: &[u8],
1187    ) -> core::result::Result<(Vec<u8>, EncodeStats), EncodeError> {
1188        let w = self.width as usize;
1189        let h = self.height as usize;
1190
1191        // Build linear f32 RGB and extract alpha from input layout
1192        let (linear_rgb, alpha, bit_depth_16) = match self.layout {
1193            PixelLayout::Rgb8 => (srgb_u8_to_linear_f32(pixels, 3), None, false),
1194            PixelLayout::Bgr8 => (
1195                srgb_u8_to_linear_f32(&bgr_to_rgb(pixels, 3), 3),
1196                None,
1197                false,
1198            ),
1199            PixelLayout::Rgba8 => {
1200                let rgb = srgb_u8_to_linear_f32(pixels, 4);
1201                let alpha = extract_alpha(pixels, 4, 3);
1202                (rgb, Some(alpha), false)
1203            }
1204            PixelLayout::Bgra8 => {
1205                let swapped = bgr_to_rgb(pixels, 4);
1206                let rgb = srgb_u8_to_linear_f32(&swapped, 4);
1207                let alpha = extract_alpha(pixels, 4, 3);
1208                (rgb, Some(alpha), false)
1209            }
1210            PixelLayout::Rgb16 => (srgb_u16_to_linear_f32(pixels, 3), None, true),
1211            PixelLayout::Rgba16 => {
1212                let rgb = srgb_u16_to_linear_f32(pixels, 4);
1213                let alpha = extract_alpha_u16(pixels, 4, 3);
1214                (rgb, Some(alpha), true)
1215            }
1216            PixelLayout::RgbLinearF32 => {
1217                let floats: &[f32] = bytemuck::cast_slice(pixels);
1218                (floats.to_vec(), None, false)
1219            }
1220            PixelLayout::Gray8 | PixelLayout::GrayAlpha8 | PixelLayout::Gray16 => {
1221                return Err(EncodeError::UnsupportedPixelLayout(self.layout));
1222            }
1223        };
1224
1225        let mut tiny = crate::vardct::VarDctEncoder::new(cfg.distance);
1226        tiny.use_ans = cfg.use_ans;
1227        tiny.optimize_codes = true;
1228        tiny.custom_orders = true;
1229        tiny.enable_noise = cfg.noise;
1230        tiny.enable_denoise = cfg.denoise;
1231        tiny.enable_gaborish = cfg.gaborish;
1232        tiny.error_diffusion = cfg.error_diffusion;
1233        tiny.pixel_domain_loss = cfg.pixel_domain_loss;
1234        tiny.enable_lz77 = cfg.lz77;
1235        tiny.lz77_method = cfg.lz77_method;
1236        tiny.force_strategy = cfg.force_strategy;
1237        #[cfg(feature = "butteraugli-loop")]
1238        {
1239            tiny.butteraugli_iters = cfg.butteraugli_iters;
1240        }
1241
1242        tiny.bit_depth_16 = bit_depth_16;
1243
1244        // ICC profile from metadata
1245        if let Some(meta) = self.metadata
1246            && let Some(icc) = meta.icc_profile
1247        {
1248            tiny.icc_profile = Some(icc.to_vec());
1249        }
1250
1251        let output = tiny
1252            .encode(w, h, &linear_rgb, alpha.as_deref())
1253            .map_err(EncodeError::from)?;
1254
1255        #[cfg(feature = "butteraugli-loop")]
1256        let butteraugli_iters_actual = cfg.butteraugli_iters;
1257        #[cfg(not(feature = "butteraugli-loop"))]
1258        let butteraugli_iters_actual = 0u32;
1259
1260        let stats = EncodeStats {
1261            mode: EncodeMode::Lossy,
1262            strategy_counts: output.strategy_counts,
1263            gaborish: cfg.gaborish,
1264            ans: cfg.use_ans,
1265            butteraugli_iters: butteraugli_iters_actual,
1266            pixel_domain_loss: cfg.pixel_domain_loss,
1267            ..Default::default()
1268        };
1269        Ok((output.data, stats))
1270    }
1271}
1272
1273// ── Animation encode implementations ────────────────────────────────────────
1274
1275fn validate_animation_input(
1276    width: u32,
1277    height: u32,
1278    layout: PixelLayout,
1279    frames: &[AnimationFrame<'_>],
1280) -> core::result::Result<(), EncodeError> {
1281    if width == 0 || height == 0 {
1282        return Err(EncodeError::InvalidInput {
1283            message: format!("zero dimensions: {width}x{height}"),
1284        });
1285    }
1286    if frames.is_empty() {
1287        return Err(EncodeError::InvalidInput {
1288            message: "animation requires at least one frame".into(),
1289        });
1290    }
1291    let expected_size = (width as usize)
1292        .checked_mul(height as usize)
1293        .and_then(|n| n.checked_mul(layout.bytes_per_pixel()))
1294        .ok_or_else(|| EncodeError::InvalidInput {
1295            message: "image dimensions overflow".into(),
1296        })?;
1297    for (i, frame) in frames.iter().enumerate() {
1298        if frame.pixels.len() != expected_size {
1299            return Err(EncodeError::InvalidInput {
1300                message: format!(
1301                    "frame {} pixel buffer size mismatch: expected {expected_size}, got {}",
1302                    i,
1303                    frame.pixels.len()
1304                ),
1305            });
1306        }
1307    }
1308    Ok(())
1309}
1310
1311fn encode_animation_lossless(
1312    cfg: &LosslessConfig,
1313    width: u32,
1314    height: u32,
1315    layout: PixelLayout,
1316    animation: &AnimationParams,
1317    frames: &[AnimationFrame<'_>],
1318) -> core::result::Result<Vec<u8>, EncodeError> {
1319    use crate::bit_writer::BitWriter;
1320    use crate::headers::file_header::AnimationHeader;
1321    use crate::headers::{ColorEncoding, FileHeader};
1322    use crate::modular::channel::ModularImage;
1323    use crate::modular::frame::{FrameEncoder, FrameEncoderOptions};
1324
1325    validate_animation_input(width, height, layout, frames)?;
1326
1327    let w = width as usize;
1328    let h = height as usize;
1329    let num_frames = frames.len();
1330
1331    // Build file header with animation
1332    let sample_image = match layout {
1333        PixelLayout::Rgb8 => ModularImage::from_rgb8(frames[0].pixels, w, h),
1334        PixelLayout::Rgba8 => ModularImage::from_rgba8(frames[0].pixels, w, h),
1335        PixelLayout::Bgr8 => ModularImage::from_rgb8(&bgr_to_rgb(frames[0].pixels, 3), w, h),
1336        PixelLayout::Bgra8 => ModularImage::from_rgba8(&bgr_to_rgb(frames[0].pixels, 4), w, h),
1337        PixelLayout::Gray8 => ModularImage::from_gray8(frames[0].pixels, w, h),
1338        PixelLayout::Rgb16 => ModularImage::from_rgb16_native(frames[0].pixels, w, h),
1339        PixelLayout::Rgba16 => ModularImage::from_rgba16_native(frames[0].pixels, w, h),
1340        PixelLayout::Gray16 => ModularImage::from_gray16_native(frames[0].pixels, w, h),
1341        other => return Err(EncodeError::UnsupportedPixelLayout(other)),
1342    }
1343    .map_err(EncodeError::from)?;
1344
1345    let mut file_header = if sample_image.is_grayscale {
1346        FileHeader::new_gray(width, height)
1347    } else if sample_image.has_alpha {
1348        FileHeader::new_rgba(width, height)
1349    } else {
1350        FileHeader::new_rgb(width, height)
1351    };
1352    if sample_image.bit_depth == 16 {
1353        file_header.metadata.bit_depth = crate::headers::file_header::BitDepth::uint16();
1354        for ec in &mut file_header.metadata.extra_channels {
1355            ec.bit_depth = crate::headers::file_header::BitDepth::uint16();
1356        }
1357    }
1358    file_header.metadata.animation = Some(AnimationHeader {
1359        tps_numerator: animation.tps_numerator,
1360        tps_denominator: animation.tps_denominator,
1361        num_loops: animation.num_loops,
1362        have_timecodes: false,
1363    });
1364
1365    // Write file header
1366    let mut writer = BitWriter::new();
1367    file_header.write(&mut writer).map_err(EncodeError::from)?;
1368    writer.zero_pad_to_byte();
1369
1370    // Encode each frame with crop detection
1371    let color_encoding = ColorEncoding::srgb();
1372    let bpp = layout.bytes_per_pixel();
1373    let mut prev_pixels: Option<&[u8]> = None;
1374
1375    for (i, frame) in frames.iter().enumerate() {
1376        // Detect crop: compare current frame against previous.
1377        // Only use crop when it's smaller than the full frame.
1378        let crop = if let Some(prev) = prev_pixels {
1379            match detect_frame_crop(prev, frame.pixels, w, h, bpp, false) {
1380                Some(crop) if (crop.width as usize) < w || (crop.height as usize) < h => Some(crop),
1381                Some(_) => None, // Crop covers full frame — no benefit
1382                None => {
1383                    // Frames are identical — emit a minimal 1x1 crop to preserve canvas
1384                    Some(FrameCrop {
1385                        x0: 0,
1386                        y0: 0,
1387                        width: 1,
1388                        height: 1,
1389                    })
1390                }
1391            }
1392        } else {
1393            None // Frame 0: always full frame
1394        };
1395
1396        // Build ModularImage from the appropriate pixel region
1397        let (frame_w, frame_h, frame_pixels_owned);
1398        let frame_pixels: &[u8] = if let Some(ref crop) = crop {
1399            frame_w = crop.width as usize;
1400            frame_h = crop.height as usize;
1401            frame_pixels_owned = extract_pixel_crop(frame.pixels, w, crop, bpp);
1402            &frame_pixels_owned
1403        } else {
1404            frame_w = w;
1405            frame_h = h;
1406            frame_pixels_owned = Vec::new();
1407            let _ = &frame_pixels_owned; // suppress unused warning
1408            frame.pixels
1409        };
1410
1411        let image = match layout {
1412            PixelLayout::Rgb8 => ModularImage::from_rgb8(frame_pixels, frame_w, frame_h),
1413            PixelLayout::Rgba8 => ModularImage::from_rgba8(frame_pixels, frame_w, frame_h),
1414            PixelLayout::Bgr8 => {
1415                ModularImage::from_rgb8(&bgr_to_rgb(frame_pixels, 3), frame_w, frame_h)
1416            }
1417            PixelLayout::Bgra8 => {
1418                ModularImage::from_rgba8(&bgr_to_rgb(frame_pixels, 4), frame_w, frame_h)
1419            }
1420            PixelLayout::Gray8 => ModularImage::from_gray8(frame_pixels, frame_w, frame_h),
1421            PixelLayout::Rgb16 => ModularImage::from_rgb16_native(frame_pixels, frame_w, frame_h),
1422            PixelLayout::Rgba16 => ModularImage::from_rgba16_native(frame_pixels, frame_w, frame_h),
1423            PixelLayout::Gray16 => ModularImage::from_gray16_native(frame_pixels, frame_w, frame_h),
1424            other => return Err(EncodeError::UnsupportedPixelLayout(other)),
1425        }
1426        .map_err(EncodeError::from)?;
1427
1428        let frame_encoder = FrameEncoder::new(
1429            frame_w,
1430            frame_h,
1431            FrameEncoderOptions {
1432                use_modular: true,
1433                effort: cfg.effort,
1434                use_ans: cfg.use_ans,
1435                use_tree_learning: cfg.tree_learning,
1436                use_squeeze: cfg.squeeze,
1437                have_animation: true,
1438                duration: frame.duration,
1439                is_last: i == num_frames - 1,
1440                crop,
1441            },
1442        );
1443        frame_encoder
1444            .encode_modular(&image, &color_encoding, &mut writer)
1445            .map_err(EncodeError::from)?;
1446
1447        prev_pixels = Some(frame.pixels);
1448    }
1449
1450    Ok(writer.finish_with_padding())
1451}
1452
1453fn encode_animation_lossy(
1454    cfg: &LossyConfig,
1455    width: u32,
1456    height: u32,
1457    layout: PixelLayout,
1458    animation: &AnimationParams,
1459    frames: &[AnimationFrame<'_>],
1460) -> core::result::Result<Vec<u8>, EncodeError> {
1461    use crate::bit_writer::BitWriter;
1462    use crate::headers::file_header::AnimationHeader;
1463    use crate::headers::frame_header::FrameOptions;
1464
1465    validate_animation_input(width, height, layout, frames)?;
1466
1467    let w = width as usize;
1468    let h = height as usize;
1469    let num_frames = frames.len();
1470
1471    // Set up VarDCT encoder
1472    let mut tiny = crate::vardct::VarDctEncoder::new(cfg.distance);
1473    tiny.use_ans = cfg.use_ans;
1474    tiny.optimize_codes = true;
1475    tiny.custom_orders = true;
1476    tiny.enable_noise = cfg.noise;
1477    tiny.enable_denoise = cfg.denoise;
1478    tiny.enable_gaborish = cfg.gaborish;
1479    tiny.error_diffusion = cfg.error_diffusion;
1480    tiny.pixel_domain_loss = cfg.pixel_domain_loss;
1481    tiny.enable_lz77 = cfg.lz77;
1482    tiny.lz77_method = cfg.lz77_method;
1483    tiny.force_strategy = cfg.force_strategy;
1484    #[cfg(feature = "butteraugli-loop")]
1485    {
1486        tiny.butteraugli_iters = cfg.butteraugli_iters;
1487    }
1488
1489    // Detect alpha and 16-bit from layout
1490    let has_alpha = layout.has_alpha();
1491    let bit_depth_16 = matches!(layout, PixelLayout::Rgb16 | PixelLayout::Rgba16);
1492    tiny.bit_depth_16 = bit_depth_16;
1493
1494    // Build file header from VarDCT encoder (sets xyb_encoded, rendering_intent, etc.)
1495    // then add animation metadata
1496    let mut file_header = tiny.build_file_header(w, h, has_alpha);
1497    file_header.metadata.animation = Some(AnimationHeader {
1498        tps_numerator: animation.tps_numerator,
1499        tps_denominator: animation.tps_denominator,
1500        num_loops: animation.num_loops,
1501        have_timecodes: false,
1502    });
1503
1504    let mut writer = BitWriter::with_capacity(w * h * 4);
1505    file_header.write(&mut writer).map_err(EncodeError::from)?;
1506    if let Some(ref icc) = tiny.icc_profile {
1507        crate::icc::write_icc(icc, &mut writer).map_err(EncodeError::from)?;
1508    }
1509    writer.zero_pad_to_byte();
1510
1511    // Encode each frame with crop detection
1512    let bpp = layout.bytes_per_pixel();
1513    let mut prev_pixels: Option<&[u8]> = None;
1514
1515    for (i, frame) in frames.iter().enumerate() {
1516        // Detect crop on raw input pixels (before linear conversion).
1517        // Only use crop when it's smaller than the full frame.
1518        let crop = if let Some(prev) = prev_pixels {
1519            match detect_frame_crop(prev, frame.pixels, w, h, bpp, true) {
1520                Some(crop) if (crop.width as usize) < w || (crop.height as usize) < h => Some(crop),
1521                Some(_) => None, // Crop covers full frame — no benefit
1522                None => {
1523                    // Frames identical — emit minimal 8x8 crop (VarDCT minimum)
1524                    Some(FrameCrop {
1525                        x0: 0,
1526                        y0: 0,
1527                        width: 8.min(width),
1528                        height: 8.min(height),
1529                    })
1530                }
1531            }
1532        } else {
1533            None // Frame 0: always full frame
1534        };
1535
1536        // Extract crop region from raw pixels, then convert to linear
1537        let (frame_w, frame_h) = if let Some(ref crop) = crop {
1538            (crop.width as usize, crop.height as usize)
1539        } else {
1540            (w, h)
1541        };
1542
1543        let crop_pixels_owned;
1544        let src_pixels: &[u8] = if let Some(ref crop) = crop {
1545            crop_pixels_owned = extract_pixel_crop(frame.pixels, w, crop, bpp);
1546            &crop_pixels_owned
1547        } else {
1548            crop_pixels_owned = Vec::new();
1549            let _ = &crop_pixels_owned;
1550            frame.pixels
1551        };
1552
1553        let (linear_rgb, alpha) = match layout {
1554            PixelLayout::Rgb8 => (srgb_u8_to_linear_f32(src_pixels, 3), None),
1555            PixelLayout::Bgr8 => (srgb_u8_to_linear_f32(&bgr_to_rgb(src_pixels, 3), 3), None),
1556            PixelLayout::Rgba8 => {
1557                let rgb = srgb_u8_to_linear_f32(src_pixels, 4);
1558                let alpha = extract_alpha(src_pixels, 4, 3);
1559                (rgb, Some(alpha))
1560            }
1561            PixelLayout::Bgra8 => {
1562                let swapped = bgr_to_rgb(src_pixels, 4);
1563                let rgb = srgb_u8_to_linear_f32(&swapped, 4);
1564                let alpha = extract_alpha(src_pixels, 4, 3);
1565                (rgb, Some(alpha))
1566            }
1567            PixelLayout::Rgb16 => (srgb_u16_to_linear_f32(src_pixels, 3), None),
1568            PixelLayout::Rgba16 => {
1569                let rgb = srgb_u16_to_linear_f32(src_pixels, 4);
1570                let alpha = extract_alpha_u16(src_pixels, 4, 3);
1571                (rgb, Some(alpha))
1572            }
1573            PixelLayout::RgbLinearF32 => {
1574                let floats: &[f32] = bytemuck::cast_slice(src_pixels);
1575                (floats.to_vec(), None)
1576            }
1577            PixelLayout::Gray8 | PixelLayout::GrayAlpha8 | PixelLayout::Gray16 => {
1578                return Err(EncodeError::UnsupportedPixelLayout(layout));
1579            }
1580        };
1581
1582        let frame_options = FrameOptions {
1583            have_animation: true,
1584            have_timecodes: false,
1585            duration: frame.duration,
1586            is_last: i == num_frames - 1,
1587            crop,
1588        };
1589
1590        tiny.encode_frame_to_writer(
1591            frame_w,
1592            frame_h,
1593            &linear_rgb,
1594            alpha.as_deref(),
1595            &frame_options,
1596            &mut writer,
1597        )
1598        .map_err(EncodeError::from)?;
1599
1600        prev_pixels = Some(frame.pixels);
1601    }
1602
1603    Ok(writer.finish_with_padding())
1604}
1605
1606// ── Animation frame crop detection ──────────────────────────────────────────
1607
1608use crate::headers::frame_header::FrameCrop;
1609
1610/// Detects the minimal bounding rectangle that differs between two frames.
1611///
1612/// Compares `prev` and `curr` byte-by-byte. Returns `Some(FrameCrop)` with the
1613/// tight bounding box of changed pixels, or `None` if the frames are identical.
1614///
1615/// When `align_to_8x8` is true (for VarDCT), the crop is expanded outward to
1616/// 8x8 block boundaries for better compression.
1617fn detect_frame_crop(
1618    prev: &[u8],
1619    curr: &[u8],
1620    width: usize,
1621    height: usize,
1622    bytes_per_pixel: usize,
1623    align_to_8x8: bool,
1624) -> Option<FrameCrop> {
1625    let stride = width * bytes_per_pixel;
1626    debug_assert_eq!(prev.len(), height * stride);
1627    debug_assert_eq!(curr.len(), height * stride);
1628
1629    // Find top (first row with a difference)
1630    let mut top = height;
1631    let mut bottom = 0;
1632    let mut left = width;
1633    let mut right = 0;
1634
1635    for y in 0..height {
1636        let row_start = y * stride;
1637        let prev_row = &prev[row_start..row_start + stride];
1638        let curr_row = &curr[row_start..row_start + stride];
1639
1640        // Fast row comparison via u64 chunks — lets the compiler auto-vectorize
1641        let (prev_prefix, prev_u64, prev_suffix) = bytemuck::pod_align_to::<u8, u64>(prev_row);
1642        let (curr_prefix, curr_u64, curr_suffix) = bytemuck::pod_align_to::<u8, u64>(curr_row);
1643        if prev_prefix == curr_prefix && prev_u64 == curr_u64 && prev_suffix == curr_suffix {
1644            continue;
1645        }
1646
1647        // This row has differences — find leftmost and rightmost changed pixel
1648        if top == height {
1649            top = y;
1650        }
1651        bottom = y;
1652
1653        // Scan from left to find first differing pixel
1654        for x in 0..width {
1655            let px_start = x * bytes_per_pixel;
1656            if prev_row[px_start..px_start + bytes_per_pixel]
1657                != curr_row[px_start..px_start + bytes_per_pixel]
1658            {
1659                left = left.min(x);
1660                break;
1661            }
1662        }
1663        // Scan from right to find last differing pixel
1664        for x in (0..width).rev() {
1665            let px_start = x * bytes_per_pixel;
1666            if prev_row[px_start..px_start + bytes_per_pixel]
1667                != curr_row[px_start..px_start + bytes_per_pixel]
1668            {
1669                right = right.max(x);
1670                break;
1671            }
1672        }
1673    }
1674
1675    if top == height {
1676        // Frames are identical
1677        return None;
1678    }
1679
1680    // Convert to crop rectangle (inclusive → exclusive for width/height)
1681    let mut crop_x = left as i32;
1682    let mut crop_y = top as i32;
1683    let mut crop_w = (right - left + 1) as u32;
1684    let mut crop_h = (bottom - top + 1) as u32;
1685
1686    if align_to_8x8 {
1687        // Expand to 8x8 block boundaries
1688        let aligned_x = (crop_x / 8) * 8;
1689        let aligned_y = (crop_y / 8) * 8;
1690        let end_x = (crop_x as u32 + crop_w).div_ceil(8) * 8;
1691        let end_y = (crop_y as u32 + crop_h).div_ceil(8) * 8;
1692        crop_x = aligned_x;
1693        crop_y = aligned_y;
1694        crop_w = end_x.min(width as u32) - aligned_x as u32;
1695        crop_h = end_y.min(height as u32) - aligned_y as u32;
1696    }
1697
1698    Some(FrameCrop {
1699        x0: crop_x,
1700        y0: crop_y,
1701        width: crop_w,
1702        height: crop_h,
1703    })
1704}
1705
1706/// Extracts a rectangular crop region from a pixel buffer.
1707///
1708/// `bytes_per_pixel` is the number of bytes per pixel (e.g., 3 for RGB, 4 for RGBA).
1709fn extract_pixel_crop(
1710    pixels: &[u8],
1711    full_width: usize,
1712    crop: &FrameCrop,
1713    bytes_per_pixel: usize,
1714) -> Vec<u8> {
1715    let cx = crop.x0 as usize;
1716    let cy = crop.y0 as usize;
1717    let cw = crop.width as usize;
1718    let ch = crop.height as usize;
1719    let stride = full_width * bytes_per_pixel;
1720
1721    let mut out = Vec::with_capacity(cw * ch * bytes_per_pixel);
1722    for y in cy..cy + ch {
1723        let row_start = y * stride + cx * bytes_per_pixel;
1724        out.extend_from_slice(&pixels[row_start..row_start + cw * bytes_per_pixel]);
1725    }
1726    out
1727}
1728
1729// ── Pixel conversion helpers ────────────────────────────────────────────────
1730
1731/// sRGB u8 → linear f32 (IEC 61966-2-1).
1732#[inline]
1733fn srgb_to_linear(c: u8) -> f32 {
1734    srgb_to_linear_f(c as f32 / 255.0)
1735}
1736
1737fn srgb_u8_to_linear_f32(data: &[u8], channels: usize) -> Vec<f32> {
1738    data.chunks(channels)
1739        .flat_map(|px| {
1740            [
1741                srgb_to_linear(px[0]),
1742                srgb_to_linear(px[1]),
1743                srgb_to_linear(px[2]),
1744            ]
1745        })
1746        .collect()
1747}
1748
1749/// sRGB u16 → linear f32 (IEC 61966-2-1).
1750fn srgb_u16_to_linear_f32(data: &[u8], channels: usize) -> Vec<f32> {
1751    let pixels: &[u16] = bytemuck::cast_slice(data);
1752    pixels
1753        .chunks(channels)
1754        .flat_map(|px| {
1755            [
1756                srgb_to_linear_f(px[0] as f32 / 65535.0),
1757                srgb_to_linear_f(px[1] as f32 / 65535.0),
1758                srgb_to_linear_f(px[2] as f32 / 65535.0),
1759            ]
1760        })
1761        .collect()
1762}
1763
1764/// sRGB transfer function: normalized float [0,1] → linear float.
1765#[inline]
1766fn srgb_to_linear_f(c: f32) -> f32 {
1767    if c <= 0.04045 {
1768        c / 12.92
1769    } else {
1770        ((c + 0.055) / 1.055).powf(2.4)
1771    }
1772}
1773
1774/// Extract alpha channel from interleaved 16-bit pixel data as u8 (quantized).
1775fn extract_alpha_u16(data: &[u8], stride: usize, alpha_offset: usize) -> Vec<u8> {
1776    let pixels: &[u16] = bytemuck::cast_slice(data);
1777    pixels
1778        .chunks(stride)
1779        .map(|px| (px[alpha_offset] >> 8) as u8)
1780        .collect()
1781}
1782
1783/// Swap B and R channels in-place equivalent: BGR(A) → RGB(A).
1784fn bgr_to_rgb(data: &[u8], stride: usize) -> Vec<u8> {
1785    let mut out = data.to_vec();
1786    for chunk in out.chunks_mut(stride) {
1787        chunk.swap(0, 2);
1788    }
1789    out
1790}
1791
1792/// Extract a single channel from interleaved pixel data.
1793fn extract_alpha(data: &[u8], stride: usize, alpha_offset: usize) -> Vec<u8> {
1794    data.chunks(stride).map(|px| px[alpha_offset]).collect()
1795}
1796
1797// ── Tests ───────────────────────────────────────────────────────────────────
1798
1799#[cfg(test)]
1800mod tests {
1801    use super::*;
1802
1803    #[test]
1804    fn test_lossless_config_builder_and_getters() {
1805        let cfg = LosslessConfig::new()
1806            .with_effort(5)
1807            .with_ans(false)
1808            .with_squeeze(true)
1809            .with_tree_learning(true);
1810        assert_eq!(cfg.effort(), 5);
1811        assert!(!cfg.ans());
1812        assert!(cfg.squeeze());
1813        assert!(cfg.tree_learning());
1814    }
1815
1816    #[test]
1817    fn test_lossy_config_builder_and_getters() {
1818        let cfg = LossyConfig::new(2.0)
1819            .with_effort(3)
1820            .with_gaborish(false)
1821            .with_noise(true);
1822        assert_eq!(cfg.distance(), 2.0);
1823        assert_eq!(cfg.effort(), 3);
1824        assert!(!cfg.gaborish());
1825        assert!(cfg.noise());
1826    }
1827
1828    #[test]
1829    fn test_pixel_layout_helpers() {
1830        assert_eq!(PixelLayout::Rgb8.bytes_per_pixel(), 3);
1831        assert_eq!(PixelLayout::Rgba8.bytes_per_pixel(), 4);
1832        assert_eq!(PixelLayout::Bgr8.bytes_per_pixel(), 3);
1833        assert_eq!(PixelLayout::Bgra8.bytes_per_pixel(), 4);
1834        assert_eq!(PixelLayout::Gray8.bytes_per_pixel(), 1);
1835        assert_eq!(PixelLayout::Rgb16.bytes_per_pixel(), 6);
1836        assert_eq!(PixelLayout::Rgba16.bytes_per_pixel(), 8);
1837        assert_eq!(PixelLayout::Gray16.bytes_per_pixel(), 2);
1838        assert!(!PixelLayout::Rgb8.is_linear());
1839        assert!(PixelLayout::RgbLinearF32.is_linear());
1840        assert!(!PixelLayout::Rgb16.is_linear());
1841        assert!(!PixelLayout::Rgb8.has_alpha());
1842        assert!(PixelLayout::Rgba8.has_alpha());
1843        assert!(PixelLayout::Bgra8.has_alpha());
1844        assert!(PixelLayout::GrayAlpha8.has_alpha());
1845        assert!(PixelLayout::Rgba16.has_alpha());
1846        assert!(!PixelLayout::Rgb16.has_alpha());
1847        assert!(PixelLayout::Rgb16.is_16bit());
1848        assert!(PixelLayout::Rgba16.is_16bit());
1849        assert!(PixelLayout::Gray16.is_16bit());
1850        assert!(!PixelLayout::Rgb8.is_16bit());
1851        assert!(PixelLayout::Gray8.is_grayscale());
1852        assert!(PixelLayout::Gray16.is_grayscale());
1853        assert!(!PixelLayout::Rgb16.is_grayscale());
1854    }
1855
1856    #[test]
1857    fn test_quality_to_distance() {
1858        assert!(Quality::Distance(1.0).to_distance().unwrap() == 1.0);
1859        assert!(Quality::Distance(-1.0).to_distance().is_err());
1860        assert!(Quality::Percent(100).to_distance().is_err()); // lossless invalid for lossy
1861        assert!(Quality::Percent(90).to_distance().unwrap() == 1.0);
1862    }
1863
1864    #[test]
1865    fn test_pixel_validation() {
1866        let cfg = LosslessConfig::new();
1867        let req = cfg.encode_request(2, 2, PixelLayout::Rgb8);
1868        assert!(req.validate_pixels(&[0u8; 12]).is_ok());
1869    }
1870
1871    #[test]
1872    fn test_pixel_validation_wrong_size() {
1873        let cfg = LosslessConfig::new();
1874        let req = cfg.encode_request(2, 2, PixelLayout::Rgb8);
1875        assert!(req.validate_pixels(&[0u8; 11]).is_err());
1876    }
1877
1878    #[test]
1879    fn test_limits_check() {
1880        let limits = Limits::new().with_max_width(100);
1881        let cfg = LosslessConfig::new();
1882        let req = cfg
1883            .encode_request(200, 100, PixelLayout::Rgb8)
1884            .with_limits(&limits);
1885        assert!(req.check_limits().is_err());
1886    }
1887
1888    #[test]
1889    fn test_lossless_encode_rgb8_small() {
1890        // 4x4 red image
1891        let pixels = [255u8, 0, 0].repeat(16);
1892        let result = LosslessConfig::new()
1893            .encode_request(4, 4, PixelLayout::Rgb8)
1894            .encode(&pixels);
1895        assert!(result.is_ok());
1896        let jxl = result.unwrap();
1897        assert_eq!(&jxl[..2], &[0xFF, 0x0A]); // JXL signature
1898    }
1899
1900    #[test]
1901    fn test_lossy_encode_rgb8_small() {
1902        // 8x8 gradient
1903        let mut pixels = Vec::with_capacity(8 * 8 * 3);
1904        for y in 0..8u8 {
1905            for x in 0..8u8 {
1906                pixels.push(x * 32);
1907                pixels.push(y * 32);
1908                pixels.push(128);
1909            }
1910        }
1911        let result = LossyConfig::new(2.0)
1912            .with_gaborish(false)
1913            .encode_request(8, 8, PixelLayout::Rgb8)
1914            .encode(&pixels);
1915        assert!(result.is_ok());
1916        let jxl = result.unwrap();
1917        assert_eq!(&jxl[..2], &[0xFF, 0x0A]);
1918    }
1919
1920    #[test]
1921    fn test_fluent_lossless() {
1922        let pixels = vec![128u8; 4 * 4 * 3];
1923        let result = LosslessConfig::new().encode(&pixels, 4, 4, PixelLayout::Rgb8);
1924        assert!(result.is_ok());
1925    }
1926
1927    #[test]
1928    fn test_lossy_unsupported_gray() {
1929        let pixels = vec![128u8; 8 * 8];
1930        let result = LossyConfig::new(1.0)
1931            .encode_request(8, 8, PixelLayout::Gray8)
1932            .encode(&pixels);
1933        assert!(matches!(
1934            result.as_ref().map_err(|e| e.error()),
1935            Err(EncodeError::UnsupportedPixelLayout(_))
1936        ));
1937    }
1938
1939    #[test]
1940    fn test_bgra_lossless() {
1941        // 4x4 red image in BGRA (B=0, G=0, R=255, A=255)
1942        let pixels = [0u8, 0, 255, 255].repeat(16);
1943        let result = LosslessConfig::new().encode(&pixels, 4, 4, PixelLayout::Bgra8);
1944        assert!(result.is_ok());
1945        let jxl = result.unwrap();
1946        assert_eq!(&jxl[..2], &[0xFF, 0x0A]);
1947    }
1948
1949    #[test]
1950    fn test_lossy_alpha_encodes() {
1951        // Lossy+alpha: VarDCT RGB + modular alpha extra channel
1952        let pixels = [255u8, 0, 0, 255].repeat(64);
1953        let result =
1954            LossyConfig::new(2.0)
1955                .with_gaborish(false)
1956                .encode(&pixels, 8, 8, PixelLayout::Bgra8);
1957        assert!(
1958            result.is_ok(),
1959            "BGRA lossy encode failed: {:?}",
1960            result.err()
1961        );
1962
1963        let result2 = LossyConfig::new(2.0).encode(&pixels, 8, 8, PixelLayout::Rgba8);
1964        assert!(
1965            result2.is_ok(),
1966            "RGBA lossy encode failed: {:?}",
1967            result2.err()
1968        );
1969    }
1970
1971    #[test]
1972    fn test_stop_cancellation() {
1973        use enough::Unstoppable;
1974        // Unstoppable should not cancel
1975        let pixels = vec![128u8; 4 * 4 * 3];
1976        let cfg = LosslessConfig::new();
1977        let result = cfg
1978            .encode_request(4, 4, PixelLayout::Rgb8)
1979            .with_stop(&Unstoppable)
1980            .encode(&pixels);
1981        assert!(result.is_ok());
1982    }
1983}