Skip to main content

zenjxl_decoder/api/
decoder.rs

1// Copyright (c) the JPEG XL Project Authors. All rights reserved.
2//
3// Use of this source code is governed by a BSD-style
4// license that can be found in the LICENSE file.
5
6use super::{
7    JxlBasicInfo, JxlBitstreamInput, JxlColorProfile, JxlDecoderInner, JxlDecoderOptions,
8    JxlOutputBuffer, JxlPixelFormat, ProcessingResult,
9};
10#[cfg(test)]
11use crate::frame::Frame;
12use crate::{
13    api::JxlFrameHeader,
14    container::{frame_index::FrameIndexBox, gain_map::GainMapBundle},
15    error::Result,
16};
17use states::*;
18use std::marker::PhantomData;
19
20pub mod states {
21    pub trait JxlState {}
22    pub struct Initialized;
23    pub struct WithImageInfo;
24    pub struct WithFrameInfo;
25    impl JxlState for Initialized {}
26    impl JxlState for WithImageInfo {}
27    impl JxlState for WithFrameInfo {}
28}
29
30// Q: do we plan to add support for box decoding?
31// If we do, one way is to take a callback &[u8; 4] -> Box<dyn Write>.
32
33/// High level API using the typestate pattern to forbid invalid usage.
34pub struct JxlDecoder<State: JxlState> {
35    inner: Box<JxlDecoderInner>,
36    _state: PhantomData<State>,
37}
38
39#[cfg(test)]
40pub type FrameCallback = dyn FnMut(&Frame, usize) -> Result<()>;
41
42impl<S: JxlState> JxlDecoder<S> {
43    fn wrap_inner(inner: Box<JxlDecoderInner>) -> Self {
44        Self {
45            inner,
46            _state: PhantomData,
47        }
48    }
49
50    /// Sets a callback that processes all frames by calling `callback(frame, frame_index)`.
51    #[cfg(test)]
52    pub fn set_frame_callback(&mut self, callback: Box<FrameCallback>) {
53        self.inner.set_frame_callback(callback);
54    }
55
56    #[cfg(test)]
57    pub fn decoded_frames(&self) -> usize {
58        self.inner.decoded_frames()
59    }
60
61    /// Returns the reconstructed JPEG bytes if the file contained a JBRD box.
62    /// Call after decoding a frame. Returns `None` if no JBRD box was present
63    /// or the `jpeg` feature is not enabled.
64    #[cfg(feature = "jpeg")]
65    pub fn take_jpeg_reconstruction(&mut self) -> Option<Vec<u8>> {
66        self.inner.take_jpeg_reconstruction()
67    }
68
69    /// Returns the parsed frame index box, if the file contained one.
70    ///
71    /// The frame index box (`jxli`) is an optional part of the JXL container
72    /// format that provides a seek table for animated files, listing keyframe
73    /// byte offsets, timestamps, and frame counts.
74    pub fn frame_index(&self) -> Option<&FrameIndexBox> {
75        self.inner.frame_index()
76    }
77
78    /// Returns a reference to the parsed gain map bundle, if the file contained
79    /// a `jhgm` box (ISO 21496-1 HDR gain map).
80    ///
81    /// The gain map codestream is a bare JXL codestream that can be decoded
82    /// with the same decoder. The ISO 21496-1 metadata blob is stored as raw
83    /// bytes for the caller to parse.
84    pub fn gain_map(&self) -> Option<&GainMapBundle> {
85        self.inner.gain_map()
86    }
87
88    /// Takes the parsed gain map bundle, if the file contained a `jhgm` box.
89    /// After calling this, `gain_map()` will return `None`.
90    pub fn take_gain_map(&mut self) -> Option<GainMapBundle> {
91        self.inner.take_gain_map()
92    }
93
94    /// Returns the raw EXIF data from the `Exif` container box, if present.
95    ///
96    /// The 4-byte TIFF header offset prefix is stripped; this returns the raw
97    /// EXIF/TIFF bytes starting with the byte-order marker (`II` or `MM`).
98    /// Returns `None` for bare codestreams or files without an `Exif` box.
99    ///
100    /// Note: the `Exif` box may appear after the codestream in the container.
101    /// Call this after decoding at least one frame for the most complete results.
102    pub fn exif(&self) -> Option<&[u8]> {
103        self.inner.exif()
104    }
105
106    /// Takes the EXIF data, leaving `None` in its place.
107    pub fn take_exif(&mut self) -> Option<Vec<u8>> {
108        self.inner.take_exif()
109    }
110
111    /// Returns the raw XMP data from the `xml ` container box, if present.
112    ///
113    /// Returns `None` for bare codestreams or files without an `xml ` box.
114    ///
115    /// Note: the `xml ` box may appear after the codestream in the container.
116    /// Call this after decoding at least one frame for the most complete results.
117    pub fn xmp(&self) -> Option<&[u8]> {
118        self.inner.xmp()
119    }
120
121    /// Takes the XMP data, leaving `None` in its place.
122    pub fn take_xmp(&mut self) -> Option<Vec<u8>> {
123        self.inner.take_xmp()
124    }
125
126    /// Rewinds a decoder to the start of the file, allowing past frames to be displayed again.
127    pub fn rewind(mut self) -> JxlDecoder<Initialized> {
128        self.inner.rewind();
129        JxlDecoder::wrap_inner(self.inner)
130    }
131
132    fn map_inner_processing_result<SuccessState: JxlState>(
133        self,
134        inner_result: ProcessingResult<(), ()>,
135    ) -> ProcessingResult<JxlDecoder<SuccessState>, Self> {
136        match inner_result {
137            ProcessingResult::Complete { .. } => ProcessingResult::Complete {
138                result: JxlDecoder::wrap_inner(self.inner),
139            },
140            ProcessingResult::NeedsMoreInput { size_hint, .. } => {
141                ProcessingResult::NeedsMoreInput {
142                    size_hint,
143                    fallback: self,
144                }
145            }
146        }
147    }
148}
149
150impl JxlDecoder<Initialized> {
151    pub fn new(options: JxlDecoderOptions) -> Self {
152        Self::wrap_inner(Box::new(JxlDecoderInner::new(options)))
153    }
154
155    pub fn process(
156        mut self,
157        input: &mut impl JxlBitstreamInput,
158    ) -> Result<ProcessingResult<JxlDecoder<WithImageInfo>, Self>> {
159        let inner_result = self.inner.process(input, None)?;
160        Ok(self.map_inner_processing_result(inner_result))
161    }
162}
163
164impl JxlDecoder<WithImageInfo> {
165    // TODO(veluca): once frame skipping is implemented properly, expose that in the API.
166
167    /// Obtains the image's basic information.
168    pub fn basic_info(&self) -> &JxlBasicInfo {
169        self.inner.basic_info().unwrap()
170    }
171
172    /// Retrieves the file's color profile.
173    pub fn embedded_color_profile(&self) -> &JxlColorProfile {
174        self.inner.embedded_color_profile().unwrap()
175    }
176
177    /// Retrieves the current output color profile.
178    pub fn output_color_profile(&self) -> &JxlColorProfile {
179        self.inner.output_color_profile().unwrap()
180    }
181
182    /// Specifies the preferred color profile to be used for outputting data.
183    /// Same semantics as JxlDecoderSetOutputColorProfile.
184    pub fn set_output_color_profile(&mut self, profile: JxlColorProfile) -> Result<()> {
185        self.inner.set_output_color_profile(profile)
186    }
187
188    /// Retrieves the current pixel format for output buffers.
189    pub fn current_pixel_format(&self) -> &JxlPixelFormat {
190        self.inner.current_pixel_format().unwrap()
191    }
192
193    /// Specifies pixel format for output buffers.
194    ///
195    /// Setting this may also change output color profile in some cases, if the profile was not set
196    /// manually before.
197    pub fn set_pixel_format(&mut self, pixel_format: JxlPixelFormat) {
198        self.inner.set_pixel_format(pixel_format);
199    }
200
201    pub fn process(
202        mut self,
203        input: &mut impl JxlBitstreamInput,
204    ) -> Result<ProcessingResult<JxlDecoder<WithFrameInfo>, Self>> {
205        let inner_result = self.inner.process(input, None)?;
206        Ok(self.map_inner_processing_result(inner_result))
207    }
208
209    /// Draws all the pixels we have data for. This is useful for i.e. previewing LF frames.
210    ///
211    /// Note: see `process` for alignment requirements for the buffer data.
212    pub fn flush_pixels(&mut self, buffers: &mut [JxlOutputBuffer<'_>]) -> Result<()> {
213        self.inner.flush_pixels(buffers)
214    }
215
216    pub fn has_more_frames(&self) -> bool {
217        self.inner.has_more_frames()
218    }
219
220    #[cfg(test)]
221    pub(crate) fn set_use_simple_pipeline(&mut self, u: bool) {
222        self.inner.set_use_simple_pipeline(u);
223    }
224}
225
226impl JxlDecoder<WithFrameInfo> {
227    /// Skip the current frame.
228    pub fn skip_frame(
229        mut self,
230        input: &mut impl JxlBitstreamInput,
231    ) -> Result<ProcessingResult<JxlDecoder<WithImageInfo>, Self>> {
232        let inner_result = self.inner.process(input, None)?;
233        Ok(self.map_inner_processing_result(inner_result))
234    }
235
236    pub fn frame_header(&self) -> JxlFrameHeader {
237        self.inner.frame_header().unwrap()
238    }
239
240    /// Number of passes we have full data for.
241    pub fn num_completed_passes(&self) -> usize {
242        self.inner.num_completed_passes().unwrap()
243    }
244
245    /// Draws all the pixels we have data for.
246    ///
247    /// Note: see `process` for alignment requirements for the buffer data.
248    pub fn flush_pixels(&mut self, buffers: &mut [JxlOutputBuffer<'_>]) -> Result<()> {
249        self.inner.flush_pixels(buffers)
250    }
251
252    /// Guarantees to populate exactly the appropriate part of the buffers.
253    /// Wants one buffer for each non-ignored pixel type, i.e. color channels and each extra channel.
254    ///
255    /// Note: the data in `buffers` should have alignment requirements that are compatible with the
256    /// requested pixel format. This means that, if we are asking for 2-byte or 4-byte output (i.e.
257    /// u16/f16 and f32 respectively), each row in the provided buffers must be aligned to 2 or 4
258    /// bytes respectively. If that is not the case, the library may panic.
259    pub fn process<In: JxlBitstreamInput>(
260        mut self,
261        input: &mut In,
262        buffers: &mut [JxlOutputBuffer<'_>],
263    ) -> Result<ProcessingResult<JxlDecoder<WithImageInfo>, Self>> {
264        let inner_result = self.inner.process(input, Some(buffers))?;
265        Ok(self.map_inner_processing_result(inner_result))
266    }
267}
268
269#[cfg(test)]
270pub(crate) mod tests {
271    use super::*;
272    use crate::api::{JxlDataFormat, JxlDecoderOptions};
273    use crate::error::Error;
274    use crate::image::{Image, Rect};
275    use jxl_macros::for_each_test_file;
276    use std::path::Path;
277
278    #[test]
279    fn decode_small_chunks() {
280        arbtest::arbtest(|u| {
281            decode(
282                &std::fs::read("resources/test/green_queen_vardct_e3.jxl").unwrap(),
283                u.arbitrary::<u8>().unwrap() as usize + 1,
284                false,
285                false,
286                None,
287            )
288            .unwrap();
289            Ok(())
290        });
291    }
292
293    #[allow(clippy::type_complexity)]
294    pub fn decode(
295        mut input: &[u8],
296        chunk_size: usize,
297        use_simple_pipeline: bool,
298        do_flush: bool,
299        callback: Option<Box<dyn FnMut(&Frame, usize) -> Result<(), Error>>>,
300    ) -> Result<(usize, Vec<Vec<Image<f32>>>), Error> {
301        let mut options = JxlDecoderOptions::default();
302        // Correctness tests should not be constrained by memory limits.
303        // OOM/limit tests verify those separately.
304        options.limits.max_memory_bytes = None;
305        let mut initialized_decoder = JxlDecoder::<states::Initialized>::new(options);
306
307        if let Some(callback) = callback {
308            initialized_decoder.set_frame_callback(callback);
309        }
310
311        let mut chunk_input = &input[0..0];
312
313        macro_rules! advance_decoder {
314            ($decoder: ident $(, $extra_arg: expr)? $(; $flush_arg: expr)?) => {
315                loop {
316                    chunk_input =
317                        &input[..(chunk_input.len().saturating_add(chunk_size)).min(input.len())];
318                    let available_before = chunk_input.len();
319                    let process_result = $decoder.process(&mut chunk_input $(, $extra_arg)?);
320                    input = &input[(available_before - chunk_input.len())..];
321                    match process_result.unwrap() {
322                        ProcessingResult::Complete { result } => break result,
323                        ProcessingResult::NeedsMoreInput { fallback, .. } => {
324                            $(
325                                let mut fallback = fallback;
326                                if do_flush && !input.is_empty() {
327                                    fallback.flush_pixels($flush_arg)?;
328                                }
329                            )?
330                            if input.is_empty() {
331                                panic!("Unexpected end of input");
332                            }
333                            $decoder = fallback;
334                        }
335                    }
336                }
337            };
338        }
339
340        // Process until we have image info
341        let mut decoder_with_image_info = advance_decoder!(initialized_decoder);
342        decoder_with_image_info.set_use_simple_pipeline(use_simple_pipeline);
343
344        // Get basic info
345        let basic_info = decoder_with_image_info.basic_info().clone();
346        assert!(basic_info.bit_depth.bits_per_sample() > 0);
347
348        // Get image dimensions (after upsampling, which is the actual output size)
349        let (buffer_width, buffer_height) = basic_info.size;
350        assert!(buffer_width > 0);
351        assert!(buffer_height > 0);
352
353        // Explicitly request F32 pixel format (test helper returns Image<f32>)
354        let default_format = decoder_with_image_info.current_pixel_format();
355        let requested_format = JxlPixelFormat {
356            color_type: default_format.color_type,
357            color_data_format: Some(JxlDataFormat::f32()),
358            extra_channel_format: default_format
359                .extra_channel_format
360                .iter()
361                .map(|_| Some(JxlDataFormat::f32()))
362                .collect(),
363        };
364        decoder_with_image_info.set_pixel_format(requested_format);
365
366        // Get the configured pixel format
367        let pixel_format = decoder_with_image_info.current_pixel_format().clone();
368
369        let num_channels = pixel_format.color_type.samples_per_pixel();
370        assert!(num_channels > 0);
371
372        let mut frames = vec![];
373
374        loop {
375            // First channel is interleaved.
376            let mut buffers = vec![Image::new_with_value(
377                (buffer_width * num_channels, buffer_height),
378                f32::NAN,
379            )?];
380
381            for ecf in pixel_format.extra_channel_format.iter() {
382                if ecf.is_none() {
383                    continue;
384                }
385                buffers.push(Image::new_with_value(
386                    (buffer_width, buffer_height),
387                    f32::NAN,
388                )?);
389            }
390
391            let mut api_buffers: Vec<_> = buffers
392                .iter_mut()
393                .map(|b| {
394                    JxlOutputBuffer::from_image_rect_mut(
395                        b.get_rect_mut(Rect {
396                            origin: (0, 0),
397                            size: b.size(),
398                        })
399                        .into_raw(),
400                    )
401                })
402                .collect();
403
404            // Process until we have frame info
405            let mut decoder_with_frame_info =
406                advance_decoder!(decoder_with_image_info; &mut api_buffers);
407            decoder_with_image_info =
408                advance_decoder!(decoder_with_frame_info, &mut api_buffers; &mut api_buffers);
409
410            // All pixels should have been overwritten, so they should no longer be NaNs.
411            for buf in buffers.iter() {
412                let (xs, ys) = buf.size();
413                for y in 0..ys {
414                    let row = buf.row(y);
415                    for (x, v) in row.iter().enumerate() {
416                        assert!(!v.is_nan(), "NaN at {x} {y} (image size {xs}x{ys})");
417                    }
418                }
419            }
420
421            frames.push(buffers);
422
423            // Check if there are more frames
424            if !decoder_with_image_info.has_more_frames() {
425                let decoded_frames = decoder_with_image_info.decoded_frames();
426
427                // Ensure we decoded at least one frame
428                assert!(decoded_frames > 0, "No frames were decoded");
429
430                return Ok((decoded_frames, frames));
431            }
432        }
433    }
434
435    fn decode_test_file(path: &Path) -> Result<(), Error> {
436        decode(&std::fs::read(path)?, usize::MAX, false, false, None)?;
437        Ok(())
438    }
439
440    for_each_test_file!(decode_test_file);
441
442    fn decode_test_file_chunks(path: &Path) -> Result<(), Error> {
443        decode(&std::fs::read(path)?, 1, false, false, None)?;
444        Ok(())
445    }
446
447    for_each_test_file!(decode_test_file_chunks);
448
449    #[allow(dead_code)] // used by integration tests
450    fn compare_frames(
451        _path: &Path,
452        fc: usize,
453        f: &[Image<f32>],
454        sf: &[Image<f32>],
455    ) -> Result<(), Error> {
456        assert_eq!(
457            f.len(),
458            sf.len(),
459            "Frame {fc} has different channels counts",
460        );
461        for (c, (b, sb)) in f.iter().zip(sf.iter()).enumerate() {
462            assert_eq!(
463                b.size(),
464                sb.size(),
465                "Channel {c} in frame {fc} has different sizes",
466            );
467            let sz = b.size();
468            for y in 0..sz.1 {
469                for x in 0..sz.0 {
470                    assert_eq!(
471                        b.row(y)[x],
472                        sb.row(y)[x],
473                        "Pixels differ at position ({x}, {y}), channel {c}"
474                    );
475                }
476            }
477        }
478        Ok(())
479    }
480
481    /// Hash all pixel rows for memory-efficient comparison.
482    fn hash_frames(frames: &[Vec<Image<f32>>]) -> Vec<Vec<Vec<u64>>> {
483        use std::hash::{Hash, Hasher};
484        frames
485            .iter()
486            .map(|channels| {
487                channels
488                    .iter()
489                    .map(|img| {
490                        let (_, ys) = img.size();
491                        (0..ys)
492                            .map(|y| {
493                                let mut h = std::hash::DefaultHasher::new();
494                                for &v in img.row(y) {
495                                    v.to_bits().hash(&mut h);
496                                }
497                                h.finish()
498                            })
499                            .collect()
500                    })
501                    .collect()
502            })
503            .collect()
504    }
505
506    fn compare_pipelines(path: &Path) -> Result<(), Error> {
507        let file = std::fs::read(path)?;
508        let reference_frames = decode(&file, usize::MAX, true, false, None)?.1;
509        // Hash and drop reference pixels before second decode to halve peak
510        // memory. Critical for 32-bit targets where two full 4K decoded
511        // outputs + decoder state exceeds address space.
512        let reference_hashes = hash_frames(&reference_frames);
513        drop(reference_frames);
514        let frames = decode(&file, usize::MAX, false, false, None)?.1;
515        let frame_hashes = hash_frames(&frames);
516        assert_eq!(
517            reference_hashes,
518            frame_hashes,
519            "{}: pipeline outputs differ",
520            path.display()
521        );
522        Ok(())
523    }
524
525    for_each_test_file!(compare_pipelines);
526
527    fn compare_incremental(path: &Path) -> Result<(), Error> {
528        let file = std::fs::read(path).unwrap();
529        // One-shot decode — hash and drop before incremental decode.
530        let (_, one_shot_frames) = decode(&file, usize::MAX, false, false, None)?;
531        let reference_hashes = hash_frames(&one_shot_frames);
532        drop(one_shot_frames);
533        // Incremental decode with arbitrary flushes.
534        let (_, frames) = decode(&file, 123, false, true, None)?;
535        let frame_hashes = hash_frames(&frames);
536        assert_eq!(
537            reference_hashes,
538            frame_hashes,
539            "{}: incremental vs one-shot outputs differ",
540            path.display()
541        );
542
543        Ok(())
544    }
545
546    for_each_test_file!(compare_incremental);
547
548    #[test]
549    fn test_preview_size_none_for_regular_files() {
550        let file = std::fs::read("resources/test/basic.jxl").unwrap();
551        let options = JxlDecoderOptions::default();
552        let mut decoder = JxlDecoder::<states::Initialized>::new(options);
553        let mut input = file.as_slice();
554        let decoder = loop {
555            match decoder.process(&mut input).unwrap() {
556                ProcessingResult::Complete { result } => break result,
557                ProcessingResult::NeedsMoreInput { fallback, .. } => decoder = fallback,
558            }
559        };
560        assert!(decoder.basic_info().preview_size.is_none());
561    }
562
563    #[test]
564    fn test_preview_size_some_for_preview_files() {
565        let file = std::fs::read("resources/test/with_preview.jxl").unwrap();
566        let options = JxlDecoderOptions::default();
567        let mut decoder = JxlDecoder::<states::Initialized>::new(options);
568        let mut input = file.as_slice();
569        let decoder = loop {
570            match decoder.process(&mut input).unwrap() {
571                ProcessingResult::Complete { result } => break result,
572                ProcessingResult::NeedsMoreInput { fallback, .. } => decoder = fallback,
573            }
574        };
575        assert_eq!(decoder.basic_info().preview_size, Some((16, 16)));
576    }
577
578    #[test]
579    fn test_num_completed_passes() {
580        use crate::image::{Image, Rect};
581        let file = std::fs::read("resources/test/basic.jxl").unwrap();
582        let options = JxlDecoderOptions::default();
583        let mut decoder = JxlDecoder::<states::Initialized>::new(options);
584        let mut input = file.as_slice();
585        // Process until we have image info
586        let mut decoder_with_info = loop {
587            match decoder.process(&mut input).unwrap() {
588                ProcessingResult::Complete { result } => break result,
589                ProcessingResult::NeedsMoreInput { fallback, .. } => decoder = fallback,
590            }
591        };
592        let info = decoder_with_info.basic_info().clone();
593        let mut decoder_with_frame = loop {
594            match decoder_with_info.process(&mut input).unwrap() {
595                ProcessingResult::Complete { result } => break result,
596                ProcessingResult::NeedsMoreInput { fallback, .. } => {
597                    decoder_with_info = fallback;
598                }
599            }
600        };
601        // Before processing frame, passes should be 0
602        assert_eq!(decoder_with_frame.num_completed_passes(), 0);
603        // Process the frame
604        let mut output = Image::<f32>::new((info.size.0 * 3, info.size.1)).unwrap();
605        let rect = Rect {
606            size: output.size(),
607            origin: (0, 0),
608        };
609        let mut bufs = [JxlOutputBuffer::from_image_rect_mut(
610            output.get_rect_mut(rect).into_raw(),
611        )];
612        loop {
613            match decoder_with_frame.process(&mut input, &mut bufs).unwrap() {
614                ProcessingResult::Complete { .. } => break,
615                ProcessingResult::NeedsMoreInput { fallback, .. } => decoder_with_frame = fallback,
616            }
617        }
618    }
619
620    #[test]
621    fn test_set_pixel_format() {
622        use crate::api::{JxlColorType, JxlDataFormat, JxlPixelFormat};
623
624        let file = std::fs::read("resources/test/basic.jxl").unwrap();
625        let options = JxlDecoderOptions::default();
626        let mut decoder = JxlDecoder::<states::Initialized>::new(options);
627        let mut input = file.as_slice();
628        let mut decoder = loop {
629            match decoder.process(&mut input).unwrap() {
630                ProcessingResult::Complete { result } => break result,
631                ProcessingResult::NeedsMoreInput { fallback, .. } => decoder = fallback,
632            }
633        };
634        // Check default pixel format
635        let default_format = decoder.current_pixel_format().clone();
636        assert_eq!(default_format.color_type, JxlColorType::Rgb);
637
638        // Set a new pixel format
639        let new_format = JxlPixelFormat {
640            color_type: JxlColorType::Grayscale,
641            color_data_format: Some(JxlDataFormat::U8 { bit_depth: 8 }),
642            extra_channel_format: vec![],
643        };
644        decoder.set_pixel_format(new_format.clone());
645
646        // Verify it was set
647        assert_eq!(decoder.current_pixel_format(), &new_format);
648    }
649
650    #[test]
651    fn test_set_output_color_profile() {
652        use crate::api::JxlColorProfile;
653
654        let file = std::fs::read("resources/test/basic.jxl").unwrap();
655        let options = JxlDecoderOptions::default();
656        let mut decoder = JxlDecoder::<states::Initialized>::new(options);
657        let mut input = file.as_slice();
658        let mut decoder = loop {
659            match decoder.process(&mut input).unwrap() {
660                ProcessingResult::Complete { result } => break result,
661                ProcessingResult::NeedsMoreInput { fallback, .. } => decoder = fallback,
662            }
663        };
664
665        // Get the embedded profile and set it as output (should work)
666        let embedded = decoder.embedded_color_profile().clone();
667        let result = decoder.set_output_color_profile(embedded);
668        assert!(result.is_ok());
669
670        // Setting an ICC profile without CMS should fail
671        let icc_profile = JxlColorProfile::Icc(vec![0u8; 100]);
672        let result = decoder.set_output_color_profile(icc_profile);
673        assert!(result.is_err());
674    }
675
676    #[test]
677    fn test_default_output_tf_by_pixel_format() {
678        use crate::api::{JxlColorEncoding, JxlTransferFunction};
679
680        // Using test image with ICC profile to trigger default transfer function path
681        let file = std::fs::read("resources/test/lossy_with_icc.jxl").unwrap();
682        let options = JxlDecoderOptions::default();
683        let mut decoder = JxlDecoder::<states::Initialized>::new(options);
684        let mut input = file.as_slice();
685        let mut decoder = loop {
686            match decoder.process(&mut input).unwrap() {
687                ProcessingResult::Complete { result } => break result,
688                ProcessingResult::NeedsMoreInput { fallback, .. } => decoder = fallback,
689            }
690        };
691
692        // Output data format will default to F32, so output color profile will be linear sRGB
693        assert_eq!(
694            *decoder.output_color_profile().transfer_function().unwrap(),
695            JxlTransferFunction::Linear,
696        );
697
698        // Integer data format will set output color profile to sRGB
699        decoder.set_pixel_format(JxlPixelFormat::rgba8(0));
700        assert_eq!(
701            *decoder.output_color_profile().transfer_function().unwrap(),
702            JxlTransferFunction::SRGB,
703        );
704
705        decoder.set_pixel_format(JxlPixelFormat::rgba_f16(0));
706        assert_eq!(
707            *decoder.output_color_profile().transfer_function().unwrap(),
708            JxlTransferFunction::Linear,
709        );
710
711        decoder.set_pixel_format(JxlPixelFormat::rgba16(0));
712        assert_eq!(
713            *decoder.output_color_profile().transfer_function().unwrap(),
714            JxlTransferFunction::SRGB,
715        );
716
717        // Once output color profile is set by user, it will remain as is regardless of what pixel
718        // format is set
719        let profile = JxlColorProfile::Simple(JxlColorEncoding::srgb(false));
720        decoder.set_output_color_profile(profile.clone()).unwrap();
721        decoder.set_pixel_format(JxlPixelFormat::rgba_f16(0));
722        assert!(decoder.output_color_profile() == &profile);
723    }
724
725    #[test]
726    fn test_fill_opaque_alpha_both_pipelines() {
727        use crate::api::{JxlColorType, JxlDataFormat, JxlPixelFormat};
728        use crate::image::{Image, Rect};
729
730        // Use basic.jxl which has no alpha channel
731        let file = std::fs::read("resources/test/basic.jxl").unwrap();
732
733        // Request RGBA format even though image has no alpha
734        let rgba_format = JxlPixelFormat {
735            color_type: JxlColorType::Rgba,
736            color_data_format: Some(JxlDataFormat::f32()),
737            extra_channel_format: vec![],
738        };
739
740        // Test both pipelines (simple and low-memory)
741        for use_simple in [true, false] {
742            let options = JxlDecoderOptions::default();
743            let decoder = JxlDecoder::<states::Initialized>::new(options);
744            let mut input = file.as_slice();
745
746            // Advance to image info
747            macro_rules! advance_decoder {
748                ($decoder:expr) => {
749                    loop {
750                        match $decoder.process(&mut input).unwrap() {
751                            ProcessingResult::Complete { result } => break result,
752                            ProcessingResult::NeedsMoreInput { fallback, .. } => {
753                                if input.is_empty() {
754                                    panic!("Unexpected end of input");
755                                }
756                                $decoder = fallback;
757                            }
758                        }
759                    }
760                };
761                ($decoder:expr, $buffers:expr) => {
762                    loop {
763                        match $decoder.process(&mut input, $buffers).unwrap() {
764                            ProcessingResult::Complete { result } => break result,
765                            ProcessingResult::NeedsMoreInput { fallback, .. } => {
766                                if input.is_empty() {
767                                    panic!("Unexpected end of input");
768                                }
769                                $decoder = fallback;
770                            }
771                        }
772                    }
773                };
774            }
775
776            let mut decoder = decoder;
777            let mut decoder = advance_decoder!(decoder);
778            decoder.set_use_simple_pipeline(use_simple);
779
780            // Set RGBA format
781            decoder.set_pixel_format(rgba_format.clone());
782
783            let basic_info = decoder.basic_info().clone();
784            let (width, height) = basic_info.size;
785
786            // Advance to frame info
787            let mut decoder = advance_decoder!(decoder);
788
789            // Prepare buffer for RGBA (4 channels interleaved)
790            let mut color_buffer = Image::<f32>::new((width * 4, height)).unwrap();
791            let mut buffers: Vec<_> = vec![JxlOutputBuffer::from_image_rect_mut(
792                color_buffer
793                    .get_rect_mut(Rect {
794                        origin: (0, 0),
795                        size: (width * 4, height),
796                    })
797                    .into_raw(),
798            )];
799
800            // Decode frame
801            let _decoder = advance_decoder!(decoder, &mut buffers);
802
803            // Verify all alpha values are 1.0 (opaque)
804            for y in 0..height {
805                let row = color_buffer.row(y);
806                for x in 0..width {
807                    let alpha = row[x * 4 + 3];
808                    assert_eq!(
809                        alpha, 1.0,
810                        "Alpha at ({},{}) should be 1.0, got {} (use_simple={})",
811                        x, y, alpha, use_simple
812                    );
813                }
814            }
815        }
816    }
817
818    /// Test that premultiply_output=true produces premultiplied alpha output
819    /// from a source with straight (non-premultiplied) alpha.
820    #[test]
821    fn test_premultiply_output_straight_alpha() {
822        use crate::api::{JxlColorType, JxlDataFormat, JxlPixelFormat};
823
824        // Use alpha_nonpremultiplied.jxl which has straight alpha (alpha_associated=false)
825        let file =
826            std::fs::read("resources/test/conformance_test_images/alpha_nonpremultiplied.jxl")
827                .unwrap();
828
829        // Alpha is included in RGBA, so we set extra_channel_format to None
830        // to indicate no separate buffer for the alpha extra channel
831        let rgba_format = JxlPixelFormat {
832            color_type: JxlColorType::Rgba,
833            color_data_format: Some(JxlDataFormat::f32()),
834            extra_channel_format: vec![None],
835        };
836
837        // Test both pipelines
838        for use_simple in [true, false] {
839            let (straight_buffer, width, height) =
840                decode_with_format::<f32>(&file, &rgba_format, use_simple, false);
841            let (premul_buffer, _, _) =
842                decode_with_format::<f32>(&file, &rgba_format, use_simple, true);
843
844            // Verify premultiplied values: premul_rgb should equal straight_rgb * alpha
845            let mut found_semitransparent = false;
846            for y in 0..height {
847                let straight_row = straight_buffer.row(y);
848                let premul_row = premul_buffer.row(y);
849                for x in 0..width {
850                    let sr = straight_row[x * 4];
851                    let sg = straight_row[x * 4 + 1];
852                    let sb = straight_row[x * 4 + 2];
853                    let sa = straight_row[x * 4 + 3];
854
855                    let pr = premul_row[x * 4];
856                    let pg = premul_row[x * 4 + 1];
857                    let pb = premul_row[x * 4 + 2];
858                    let pa = premul_row[x * 4 + 3];
859
860                    // Alpha should be unchanged
861                    assert!(
862                        (sa - pa).abs() < 1e-5,
863                        "Alpha mismatch at ({},{}): straight={}, premul={} (use_simple={})",
864                        x,
865                        y,
866                        sa,
867                        pa,
868                        use_simple
869                    );
870
871                    // Check premultiplication: premul = straight * alpha
872                    let expected_r = sr * sa;
873                    let expected_g = sg * sa;
874                    let expected_b = sb * sa;
875
876                    // Allow 1% tolerance for precision differences between pipelines
877                    let tol = 0.01;
878                    assert!(
879                        (expected_r - pr).abs() < tol,
880                        "R mismatch at ({},{}): expected={}, got={} (use_simple={})",
881                        x,
882                        y,
883                        expected_r,
884                        pr,
885                        use_simple
886                    );
887                    assert!(
888                        (expected_g - pg).abs() < tol,
889                        "G mismatch at ({},{}): expected={}, got={} (use_simple={})",
890                        x,
891                        y,
892                        expected_g,
893                        pg,
894                        use_simple
895                    );
896                    assert!(
897                        (expected_b - pb).abs() < tol,
898                        "B mismatch at ({},{}): expected={}, got={} (use_simple={})",
899                        x,
900                        y,
901                        expected_b,
902                        pb,
903                        use_simple
904                    );
905
906                    if sa > 0.01 && sa < 0.99 {
907                        found_semitransparent = true;
908                    }
909                }
910            }
911
912            // Ensure the test image actually has some semi-transparent pixels
913            assert!(
914                found_semitransparent,
915                "Test image should have semi-transparent pixels (use_simple={})",
916                use_simple
917            );
918        }
919    }
920
921    /// Test that premultiply_output=true doesn't double-premultiply
922    /// when the source already has premultiplied alpha (alpha_associated=true).
923    #[test]
924    fn test_premultiply_output_already_premultiplied() {
925        use crate::api::{JxlColorType, JxlDataFormat, JxlPixelFormat};
926
927        // Use alpha_premultiplied.jxl which has alpha_associated=true
928        let file = std::fs::read("resources/test/conformance_test_images/alpha_premultiplied.jxl")
929            .unwrap();
930
931        // Alpha is included in RGBA, so we set extra_channel_format to None
932        let rgba_format = JxlPixelFormat {
933            color_type: JxlColorType::Rgba,
934            color_data_format: Some(JxlDataFormat::f32()),
935            extra_channel_format: vec![None],
936        };
937
938        // Test both pipelines
939        for use_simple in [true, false] {
940            let (without_flag_buffer, width, height) =
941                decode_with_format::<f32>(&file, &rgba_format, use_simple, false);
942            let (with_flag_buffer, _, _) =
943                decode_with_format::<f32>(&file, &rgba_format, use_simple, true);
944
945            // Both outputs should be identical since source is already premultiplied
946            // and we shouldn't double-premultiply
947            for y in 0..height {
948                let without_row = without_flag_buffer.row(y);
949                let with_row = with_flag_buffer.row(y);
950                for x in 0..width {
951                    for c in 0..4 {
952                        let without_val = without_row[x * 4 + c];
953                        let with_val = with_row[x * 4 + c];
954                        assert!(
955                            (without_val - with_val).abs() < 1e-5,
956                            "Mismatch at ({},{}) channel {}: without_flag={}, with_flag={} (use_simple={})",
957                            x,
958                            y,
959                            c,
960                            without_val,
961                            with_val,
962                            use_simple
963                        );
964                    }
965                }
966            }
967        }
968    }
969
970    /// Test that animations with reference frames work correctly.
971    /// This exercises the buffer index calculation fix where reference frame
972    /// save stages use indices beyond the API-provided buffer array.
973    #[test]
974    fn test_animation_with_reference_frames() {
975        use crate::api::{JxlColorType, JxlDataFormat, JxlPixelFormat};
976        use crate::image::{Image, Rect};
977
978        // Use animation_spline.jxl which has multiple frames with references
979        let file =
980            std::fs::read("resources/test/conformance_test_images/animation_spline.jxl").unwrap();
981
982        let options = JxlDecoderOptions::default();
983        let decoder = JxlDecoder::<states::Initialized>::new(options);
984        let mut input = file.as_slice();
985
986        // Advance to image info
987        let mut decoder = decoder;
988        let mut decoder = loop {
989            match decoder.process(&mut input).unwrap() {
990                ProcessingResult::Complete { result } => break result,
991                ProcessingResult::NeedsMoreInput { fallback, .. } => {
992                    decoder = fallback;
993                }
994            }
995        };
996
997        // Set RGB format with no extra channels
998        let rgb_format = JxlPixelFormat {
999            color_type: JxlColorType::Rgb,
1000            color_data_format: Some(JxlDataFormat::f32()),
1001            extra_channel_format: vec![],
1002        };
1003        decoder.set_pixel_format(rgb_format);
1004
1005        let basic_info = decoder.basic_info().clone();
1006        let (width, height) = basic_info.size;
1007
1008        let mut frame_count = 0;
1009
1010        // Decode all frames
1011        loop {
1012            // Advance to frame info
1013            let mut decoder_frame = loop {
1014                match decoder.process(&mut input).unwrap() {
1015                    ProcessingResult::Complete { result } => break result,
1016                    ProcessingResult::NeedsMoreInput { fallback, .. } => {
1017                        decoder = fallback;
1018                    }
1019                }
1020            };
1021
1022            // Prepare buffer for RGB (3 channels interleaved)
1023            let mut color_buffer = Image::<f32>::new((width * 3, height)).unwrap();
1024            let mut buffers: Vec<_> = vec![JxlOutputBuffer::from_image_rect_mut(
1025                color_buffer
1026                    .get_rect_mut(Rect {
1027                        origin: (0, 0),
1028                        size: (width * 3, height),
1029                    })
1030                    .into_raw(),
1031            )];
1032
1033            // Decode frame - this should not panic even though reference frame
1034            // save stages target buffer indices beyond buffers.len()
1035            decoder = loop {
1036                match decoder_frame.process(&mut input, &mut buffers).unwrap() {
1037                    ProcessingResult::Complete { result } => break result,
1038                    ProcessingResult::NeedsMoreInput { fallback, .. } => {
1039                        decoder_frame = fallback;
1040                    }
1041                }
1042            };
1043
1044            frame_count += 1;
1045
1046            // Check if there are more frames
1047            if !decoder.has_more_frames() {
1048                break;
1049            }
1050        }
1051
1052        // Verify we decoded multiple frames
1053        assert!(
1054            frame_count > 1,
1055            "Expected multiple frames in animation, got {}",
1056            frame_count
1057        );
1058    }
1059
1060    #[test]
1061    fn test_skip_frame_then_decode_next() {
1062        use crate::api::{JxlColorType, JxlDataFormat, JxlPixelFormat};
1063        use crate::image::{Image, Rect};
1064
1065        // Use animation_spline.jxl which has multiple frames
1066        let file =
1067            std::fs::read("resources/test/conformance_test_images/animation_spline.jxl").unwrap();
1068
1069        let options = JxlDecoderOptions::default();
1070        let decoder = JxlDecoder::<states::Initialized>::new(options);
1071        let mut input = file.as_slice();
1072
1073        // Advance to image info
1074        let mut decoder = decoder;
1075        let mut decoder = loop {
1076            match decoder.process(&mut input).unwrap() {
1077                ProcessingResult::Complete { result } => break result,
1078                ProcessingResult::NeedsMoreInput { fallback, .. } => {
1079                    decoder = fallback;
1080                }
1081            }
1082        };
1083
1084        // Set RGB format
1085        let rgb_format = JxlPixelFormat {
1086            color_type: JxlColorType::Rgb,
1087            color_data_format: Some(JxlDataFormat::f32()),
1088            extra_channel_format: vec![],
1089        };
1090        decoder.set_pixel_format(rgb_format);
1091
1092        let basic_info = decoder.basic_info().clone();
1093        let (width, height) = basic_info.size;
1094
1095        // Advance to frame info for first frame
1096        let mut decoder_frame = loop {
1097            match decoder.process(&mut input).unwrap() {
1098                ProcessingResult::Complete { result } => break result,
1099                ProcessingResult::NeedsMoreInput { fallback, .. } => {
1100                    decoder = fallback;
1101                }
1102            }
1103        };
1104
1105        // Skip the first frame (this is where the bug would leave stale frame state)
1106        let mut decoder = loop {
1107            match decoder_frame.skip_frame(&mut input).unwrap() {
1108                ProcessingResult::Complete { result } => break result,
1109                ProcessingResult::NeedsMoreInput { fallback, .. } => {
1110                    decoder_frame = fallback;
1111                }
1112            }
1113        };
1114
1115        assert!(
1116            decoder.has_more_frames(),
1117            "Animation should have more frames"
1118        );
1119
1120        // Advance to frame info for second frame
1121        // Without the fix, this would panic at assert!(self.frame.is_none())
1122        let mut decoder_frame = loop {
1123            match decoder.process(&mut input).unwrap() {
1124                ProcessingResult::Complete { result } => break result,
1125                ProcessingResult::NeedsMoreInput { fallback, .. } => {
1126                    decoder = fallback;
1127                }
1128            }
1129        };
1130
1131        // Decode the second frame to verify everything works
1132        let mut color_buffer = Image::<f32>::new((width * 3, height)).unwrap();
1133        let mut buffers: Vec<_> = vec![JxlOutputBuffer::from_image_rect_mut(
1134            color_buffer
1135                .get_rect_mut(Rect {
1136                    origin: (0, 0),
1137                    size: (width * 3, height),
1138                })
1139                .into_raw(),
1140        )];
1141
1142        let decoder = loop {
1143            match decoder_frame.process(&mut input, &mut buffers).unwrap() {
1144                ProcessingResult::Complete { result } => break result,
1145                ProcessingResult::NeedsMoreInput { fallback, .. } => {
1146                    decoder_frame = fallback;
1147                }
1148            }
1149        };
1150
1151        // If we got here without panicking, the fix works
1152        // Optionally verify we can continue with more frames
1153        let _ = decoder.has_more_frames();
1154    }
1155
1156    /// Test that u8 output matches f32 output within quantization tolerance.
1157    /// This test would catch bugs like the offset miscalculation in PR #586
1158    /// that caused black bars in u8 output.
1159    #[test]
1160    fn test_output_format_u8_matches_f32() {
1161        use crate::api::{JxlColorType, JxlDataFormat, JxlPixelFormat};
1162
1163        // Use bicycles.jxl - a larger image that exercises offset calculations
1164        let file = std::fs::read("resources/test/conformance_test_images/bicycles.jxl").unwrap();
1165
1166        // Test both RGB and BGRA to catch channel reordering bugs
1167        for (color_type, num_samples) in [(JxlColorType::Rgb, 3), (JxlColorType::Bgra, 4)] {
1168            let f32_format = JxlPixelFormat {
1169                color_type,
1170                color_data_format: Some(JxlDataFormat::f32()),
1171                extra_channel_format: vec![],
1172            };
1173            let u8_format = JxlPixelFormat {
1174                color_type,
1175                color_data_format: Some(JxlDataFormat::U8 { bit_depth: 8 }),
1176                extra_channel_format: vec![],
1177            };
1178
1179            // Test both pipelines
1180            for use_simple in [true, false] {
1181                let (f32_buffer, width, height) =
1182                    decode_with_format::<f32>(&file, &f32_format, use_simple, false);
1183                let (u8_buffer, _, _) =
1184                    decode_with_format::<u8>(&file, &u8_format, use_simple, false);
1185
1186                // Compare values: u8 / 255.0 should match f32
1187                // Tolerance: quantization error of ±0.5/255 ≈ 0.00196 plus small rounding
1188                let tolerance = 0.003;
1189                let mut max_error: f32 = 0.0;
1190
1191                for y in 0..height {
1192                    let f32_row = f32_buffer.row(y);
1193                    let u8_row = u8_buffer.row(y);
1194                    for x in 0..(width * num_samples) {
1195                        let f32_val = f32_row[x].clamp(0.0, 1.0);
1196                        let u8_val = u8_row[x] as f32 / 255.0;
1197                        let error = (f32_val - u8_val).abs();
1198                        max_error = max_error.max(error);
1199                        assert!(
1200                            error < tolerance,
1201                            "{:?} u8 mismatch at ({},{}): f32={}, u8={} (scaled={}), error={} (use_simple={})",
1202                            color_type,
1203                            x,
1204                            y,
1205                            f32_val,
1206                            u8_row[x],
1207                            u8_val,
1208                            error,
1209                            use_simple
1210                        );
1211                    }
1212                }
1213            }
1214        }
1215    }
1216
1217    /// Test that u16 output matches f32 output within quantization tolerance.
1218    #[test]
1219    fn test_output_format_u16_matches_f32() {
1220        use crate::api::{Endianness, JxlColorType, JxlDataFormat, JxlPixelFormat};
1221
1222        let file = std::fs::read("resources/test/conformance_test_images/bicycles.jxl").unwrap();
1223
1224        // Test both RGB and BGRA
1225        for (color_type, num_samples) in [(JxlColorType::Rgb, 3), (JxlColorType::Bgra, 4)] {
1226            let f32_format = JxlPixelFormat {
1227                color_type,
1228                color_data_format: Some(JxlDataFormat::f32()),
1229                extra_channel_format: vec![],
1230            };
1231            let u16_format = JxlPixelFormat {
1232                color_type,
1233                color_data_format: Some(JxlDataFormat::U16 {
1234                    endianness: Endianness::native(),
1235                    bit_depth: 16,
1236                }),
1237                extra_channel_format: vec![],
1238            };
1239
1240            for use_simple in [true, false] {
1241                let (f32_buffer, width, height) =
1242                    decode_with_format::<f32>(&file, &f32_format, use_simple, false);
1243                let (u16_buffer, _, _) =
1244                    decode_with_format::<u16>(&file, &u16_format, use_simple, false);
1245
1246                // Tolerance: quantization error of ±0.5/65535 plus small rounding
1247                let tolerance = 0.0001;
1248
1249                for y in 0..height {
1250                    let f32_row = f32_buffer.row(y);
1251                    let u16_row = u16_buffer.row(y);
1252                    for x in 0..(width * num_samples) {
1253                        let f32_val = f32_row[x].clamp(0.0, 1.0);
1254                        let u16_val = u16_row[x] as f32 / 65535.0;
1255                        let error = (f32_val - u16_val).abs();
1256                        assert!(
1257                            error < tolerance,
1258                            "{:?} u16 mismatch at ({},{}): f32={}, u16={} (scaled={}), error={} (use_simple={})",
1259                            color_type,
1260                            x,
1261                            y,
1262                            f32_val,
1263                            u16_row[x],
1264                            u16_val,
1265                            error,
1266                            use_simple
1267                        );
1268                    }
1269                }
1270            }
1271        }
1272    }
1273
1274    /// Test that f16 output matches f32 output within f16 precision tolerance.
1275    #[test]
1276    fn test_output_format_f16_matches_f32() {
1277        use crate::api::{Endianness, JxlColorType, JxlDataFormat, JxlPixelFormat};
1278        use crate::util::f16;
1279
1280        let file = std::fs::read("resources/test/conformance_test_images/bicycles.jxl").unwrap();
1281
1282        // Test both RGB and BGRA
1283        for (color_type, num_samples) in [(JxlColorType::Rgb, 3), (JxlColorType::Bgra, 4)] {
1284            let f32_format = JxlPixelFormat {
1285                color_type,
1286                color_data_format: Some(JxlDataFormat::f32()),
1287                extra_channel_format: vec![],
1288            };
1289            let f16_format = JxlPixelFormat {
1290                color_type,
1291                color_data_format: Some(JxlDataFormat::F16 {
1292                    endianness: Endianness::native(),
1293                }),
1294                extra_channel_format: vec![],
1295            };
1296
1297            for use_simple in [true, false] {
1298                let (f32_buffer, width, height) =
1299                    decode_with_format::<f32>(&file, &f32_format, use_simple, false);
1300                let (f16_buffer, _, _) =
1301                    decode_with_format::<f16>(&file, &f16_format, use_simple, false);
1302
1303                // f16 has about 3 decimal digits of precision
1304                // For values in [0,1], the relative error is about 0.001
1305                let tolerance = 0.002;
1306
1307                for y in 0..height {
1308                    let f32_row = f32_buffer.row(y);
1309                    let f16_row = f16_buffer.row(y);
1310                    for x in 0..(width * num_samples) {
1311                        let f32_val = f32_row[x];
1312                        let f16_val = f16_row[x].to_f32();
1313                        let error = (f32_val - f16_val).abs();
1314                        assert!(
1315                            error < tolerance,
1316                            "{:?} f16 mismatch at ({},{}): f32={}, f16={}, error={} (use_simple={})",
1317                            color_type,
1318                            x,
1319                            y,
1320                            f32_val,
1321                            f16_val,
1322                            error,
1323                            use_simple
1324                        );
1325                    }
1326                }
1327            }
1328        }
1329    }
1330
1331    /// Helper function to decode an image with a specific format.
1332    fn decode_with_format<T: crate::image::ImageDataType>(
1333        file: &[u8],
1334        pixel_format: &JxlPixelFormat,
1335        use_simple: bool,
1336        premultiply: bool,
1337    ) -> (Image<T>, usize, usize) {
1338        let options = JxlDecoderOptions {
1339            premultiply_output: premultiply,
1340            ..Default::default()
1341        };
1342        let mut decoder = JxlDecoder::<states::Initialized>::new(options);
1343        let mut input = file;
1344
1345        // Advance to image info
1346        let mut decoder = loop {
1347            match decoder.process(&mut input).unwrap() {
1348                ProcessingResult::Complete { result } => break result,
1349                ProcessingResult::NeedsMoreInput { fallback, .. } => {
1350                    if input.is_empty() {
1351                        panic!("Unexpected end of input");
1352                    }
1353                    decoder = fallback;
1354                }
1355            }
1356        };
1357        decoder.set_use_simple_pipeline(use_simple);
1358        decoder.set_pixel_format(pixel_format.clone());
1359
1360        let basic_info = decoder.basic_info().clone();
1361        let (width, height) = basic_info.size;
1362
1363        let num_samples = pixel_format.color_type.samples_per_pixel();
1364
1365        // Advance to frame info
1366        let decoder = loop {
1367            match decoder.process(&mut input).unwrap() {
1368                ProcessingResult::Complete { result } => break result,
1369                ProcessingResult::NeedsMoreInput { fallback, .. } => {
1370                    if input.is_empty() {
1371                        panic!("Unexpected end of input");
1372                    }
1373                    decoder = fallback;
1374                }
1375            }
1376        };
1377
1378        let mut buffer = Image::<T>::new((width * num_samples, height)).unwrap();
1379        let mut buffers: Vec<_> = vec![JxlOutputBuffer::from_image_rect_mut(
1380            buffer
1381                .get_rect_mut(Rect {
1382                    origin: (0, 0),
1383                    size: (width * num_samples, height),
1384                })
1385                .into_raw(),
1386        )];
1387
1388        // Decode
1389        let mut decoder = decoder;
1390        loop {
1391            match decoder.process(&mut input, &mut buffers).unwrap() {
1392                ProcessingResult::Complete { .. } => break,
1393                ProcessingResult::NeedsMoreInput { fallback, .. } => {
1394                    if input.is_empty() {
1395                        panic!("Unexpected end of input");
1396                    }
1397                    decoder = fallback;
1398                }
1399            }
1400        }
1401
1402        (buffer, width, height)
1403    }
1404
1405    /// Regression test for ClusterFuzz issue 5342436251336704
1406    /// Tests that malformed JXL files with overflow-inducing data don't panic
1407    #[test]
1408    fn test_fuzzer_smallbuffer_overflow() {
1409        use std::panic;
1410
1411        let data = include_bytes!("../../tests/testdata/fuzzer_smallbuffer_overflow.jxl");
1412
1413        // The test passes if it doesn't panic with "attempt to add with overflow"
1414        // It's OK if it returns an error or panics with "Unexpected end of input"
1415        let result = panic::catch_unwind(|| {
1416            let _ = decode(data, 1024, false, false, None);
1417        });
1418
1419        // If it panicked, make sure it wasn't an overflow panic
1420        if let Err(e) = result {
1421            let panic_msg = e
1422                .downcast_ref::<&str>()
1423                .map(|s| s.to_string())
1424                .or_else(|| e.downcast_ref::<String>().cloned())
1425                .unwrap_or_default();
1426            assert!(
1427                !panic_msg.contains("overflow"),
1428                "Unexpected overflow panic: {}",
1429                panic_msg
1430            );
1431        }
1432    }
1433
1434    /// Helper to wrap a bare codestream in a JXL container with a jxli frame index box.
1435    fn wrap_with_frame_index(
1436        codestream: &[u8],
1437        tnum: u32,
1438        tden: u32,
1439        entries: &[(u64, u64, u64)], // (OFF_delta, T, F)
1440    ) -> Vec<u8> {
1441        use crate::util::test::build_frame_index_content;
1442
1443        fn make_box(ty: &[u8; 4], content: &[u8]) -> Vec<u8> {
1444            let len = (8 + content.len()) as u32;
1445            let mut buf = Vec::new();
1446            buf.extend(len.to_be_bytes());
1447            buf.extend(ty);
1448            buf.extend(content);
1449            buf
1450        }
1451
1452        let jxli_content = build_frame_index_content(tnum, tden, entries);
1453
1454        // JXL signature box
1455        let sig = [
1456            0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a,
1457        ];
1458        // ftyp box
1459        let ftyp = make_box(b"ftyp", b"jxl \x00\x00\x00\x00jxl ");
1460        let jxli = make_box(b"jxli", &jxli_content);
1461        let jxlc = make_box(b"jxlc", codestream);
1462
1463        let mut container = Vec::new();
1464        container.extend(&sig);
1465        container.extend(&ftyp);
1466        container.extend(&jxli);
1467        container.extend(&jxlc);
1468        container
1469    }
1470
1471    #[test]
1472    fn test_frame_index_parsed_from_container() {
1473        // Read a bare animation codestream and wrap it in a container with a jxli box.
1474        let codestream =
1475            std::fs::read("resources/test/conformance_test_images/animation_icos4d_5.jxl").unwrap();
1476
1477        // Create synthetic frame index entries (delta offsets).
1478        // These are synthetic -- we don't know real frame offsets, but we can verify parsing.
1479        let entries = vec![
1480            (0u64, 100u64, 1u64), // Frame 0 at offset 0
1481            (500, 100, 1),        // Frame 1 at offset 500
1482            (600, 100, 1),        // Frame 2 at offset 1100
1483        ];
1484
1485        let container = wrap_with_frame_index(&codestream, 1, 1000, &entries);
1486
1487        // Decode with a large chunk size so the jxli box is fully consumed.
1488        let options = JxlDecoderOptions::default();
1489        let mut dec = JxlDecoder::<states::Initialized>::new(options);
1490        let mut input: &[u8] = &container;
1491        let dec = loop {
1492            match dec.process(&mut input).unwrap() {
1493                ProcessingResult::Complete { result } => break result,
1494                ProcessingResult::NeedsMoreInput { fallback, .. } => {
1495                    if input.is_empty() {
1496                        panic!("Unexpected end of input");
1497                    }
1498                    dec = fallback;
1499                }
1500            }
1501        };
1502
1503        // Check that frame index was parsed.
1504        let fi = dec.frame_index().expect("frame_index should be Some");
1505        assert_eq!(fi.num_frames(), 3);
1506        assert_eq!(fi.tnum, 1);
1507        assert_eq!(fi.tden.get(), 1000);
1508        // Verify absolute offsets (accumulated from deltas)
1509        assert_eq!(fi.entries[0].codestream_offset, 0);
1510        assert_eq!(fi.entries[1].codestream_offset, 500);
1511        assert_eq!(fi.entries[2].codestream_offset, 1100);
1512        assert_eq!(fi.entries[0].duration_ticks, 100);
1513        assert_eq!(fi.entries[2].frame_count, 1);
1514    }
1515
1516    #[test]
1517    fn test_frame_index_none_for_bare_codestream() {
1518        // A bare codestream has no container, so no frame index.
1519        let data =
1520            std::fs::read("resources/test/conformance_test_images/animation_icos4d_5.jxl").unwrap();
1521        let options = JxlDecoderOptions::default();
1522        let mut dec = JxlDecoder::<states::Initialized>::new(options);
1523        let mut input: &[u8] = &data;
1524        let dec = loop {
1525            match dec.process(&mut input).unwrap() {
1526                ProcessingResult::Complete { result } => break result,
1527                ProcessingResult::NeedsMoreInput { fallback, .. } => {
1528                    if input.is_empty() {
1529                        panic!("Unexpected end of input");
1530                    }
1531                    dec = fallback;
1532                }
1533            }
1534        };
1535        assert!(dec.frame_index().is_none());
1536    }
1537
1538    /// Regression test for Chromium ClusterFuzz issue 474401148.
1539    #[test]
1540    fn test_fuzzer_xyb_icc_no_panic() {
1541        use crate::api::ProcessingResult;
1542
1543        #[rustfmt::skip]
1544        let data: &[u8] = &[
1545            0xff, 0x0a, 0x01, 0x00, 0x00, 0x04, 0x00, 0x00,
1546            0x00, 0x00, 0x00, 0x00, 0x00, 0x11, 0x25, 0x00,
1547        ];
1548
1549        let opts = JxlDecoderOptions::default();
1550        let mut decoder = JxlDecoderInner::new(opts);
1551        let mut input = data;
1552
1553        if let Ok(ProcessingResult::Complete { .. }) = decoder.process(&mut input, None)
1554            && let Some(profile) = decoder.output_color_profile()
1555        {
1556            let _ = profile.try_as_icc();
1557        }
1558    }
1559
1560    #[test]
1561    fn test_pixel_limit_enforcement() {
1562        // Load a test image - green_queen is 256x256 = 65536 pixels
1563        let input = std::fs::read("resources/test/green_queen_vardct_e3.jxl").unwrap();
1564
1565        // Create options with a very restrictive pixel limit (smaller than the image)
1566        let mut options = JxlDecoderOptions::default();
1567        options.limits.max_pixels = Some(100); // Only 100 pixels allowed
1568
1569        let decoder = JxlDecoder::<states::Initialized>::new(options);
1570        let mut input_slice = &input[..];
1571
1572        // The decoder should fail when parsing the header with LimitExceeded error
1573        let result = decoder.process(&mut input_slice);
1574        match result {
1575            Err(err) => {
1576                assert!(
1577                    matches!(
1578                        err,
1579                        Error::LimitExceeded {
1580                            resource: "pixels",
1581                            ..
1582                        }
1583                    ),
1584                    "Expected LimitExceeded for pixels, got {:?}",
1585                    err
1586                );
1587            }
1588            Ok(ProcessingResult::NeedsMoreInput { .. }) => {
1589                panic!("Expected error, got needs more input");
1590            }
1591            Ok(ProcessingResult::Complete { .. }) => {
1592                panic!("Expected error, got success");
1593            }
1594        }
1595    }
1596
1597    #[test]
1598    fn test_restrictive_limits_preset() {
1599        // Verify the restrictive preset is reasonable
1600        let limits = crate::api::JxlDecoderLimits::restrictive();
1601        assert_eq!(limits.max_pixels, Some(100_000_000));
1602        assert_eq!(limits.max_extra_channels, Some(16));
1603        assert_eq!(limits.max_icc_size, Some(1 << 20));
1604        assert_eq!(limits.max_tree_size, Some(1 << 20));
1605        assert_eq!(limits.max_patches, Some(1 << 16));
1606        assert_eq!(limits.max_spline_points, Some(1 << 16));
1607        assert_eq!(limits.max_reference_frames, Some(2));
1608        assert_eq!(limits.max_memory_bytes, Some(1 << 30));
1609    }
1610
1611    #[test]
1612    fn test_extra_channel_metadata() {
1613        let file = std::fs::read("resources/test/extra_channels.jxl").unwrap();
1614        let options = JxlDecoderOptions::default();
1615        let mut decoder = JxlDecoder::<states::Initialized>::new(options);
1616        let mut input = file.as_slice();
1617        let decoder = loop {
1618            match decoder.process(&mut input).unwrap() {
1619                ProcessingResult::Complete { result } => break result,
1620                ProcessingResult::NeedsMoreInput { fallback, .. } => decoder = fallback,
1621            }
1622        };
1623        let info = decoder.basic_info();
1624        // extra_channels.jxl should have at least one extra channel
1625        assert!(
1626            !info.extra_channels.is_empty(),
1627            "expected at least one extra channel"
1628        );
1629
1630        // Verify all new fields are populated
1631        for ec in &info.extra_channels {
1632            // bits_per_sample should be a reasonable value
1633            assert!(
1634                ec.bits_per_sample > 0 && ec.bits_per_sample <= 32,
1635                "unexpected bits_per_sample: {}",
1636                ec.bits_per_sample
1637            );
1638            // dim_shift should be <= 3
1639            assert!(ec.dim_shift <= 3, "unexpected dim_shift: {}", ec.dim_shift);
1640        }
1641    }
1642
1643    #[test]
1644    fn test_extra_channel_alpha_with_new_fields() {
1645        use crate::headers::extra_channels::ExtraChannel;
1646
1647        // 3x3a has alpha
1648        let file = std::fs::read("resources/test/3x3a_srgb_lossless.jxl").unwrap();
1649        let options = JxlDecoderOptions::default();
1650        let mut decoder = JxlDecoder::<states::Initialized>::new(options);
1651        let mut input = file.as_slice();
1652        let decoder = loop {
1653            match decoder.process(&mut input).unwrap() {
1654                ProcessingResult::Complete { result } => break result,
1655                ProcessingResult::NeedsMoreInput { fallback, .. } => decoder = fallback,
1656            }
1657        };
1658        let info = decoder.basic_info();
1659        // Should have exactly one extra channel of type Alpha
1660        assert_eq!(info.extra_channels.len(), 1);
1661        let alpha = &info.extra_channels[0];
1662        assert_eq!(alpha.ec_type, ExtraChannel::Alpha);
1663        assert!(alpha.bits_per_sample > 0);
1664        // Default alpha channels typically have dim_shift 0 (full resolution)
1665        assert_eq!(alpha.dim_shift, 0);
1666    }
1667
1668    #[test]
1669    fn test_preview_metadata_in_basic_info() {
1670        // with_preview.jxl has a preview; basic.jxl does not
1671        let file = std::fs::read("resources/test/with_preview.jxl").unwrap();
1672        let options = JxlDecoderOptions::default();
1673        let mut decoder = JxlDecoder::<states::Initialized>::new(options);
1674        let mut input = file.as_slice();
1675        let decoder = loop {
1676            match decoder.process(&mut input).unwrap() {
1677                ProcessingResult::Complete { result } => break result,
1678                ProcessingResult::NeedsMoreInput { fallback, .. } => decoder = fallback,
1679            }
1680        };
1681        let info = decoder.basic_info();
1682        let (pw, ph) = info.preview_size.expect("expected preview_size");
1683        assert!(pw > 0 && ph > 0, "preview dimensions should be positive");
1684    }
1685
1686    #[test]
1687    fn test_stop_cancellation() {
1688        use almost_enough::Stopper;
1689        use enough::Stop;
1690
1691        let stop = Stopper::new();
1692        assert!(!stop.should_stop());
1693        stop.cancel();
1694        assert!(stop.should_stop());
1695        // Verify it integrates with our error type
1696        let result: crate::error::Result<()> = stop.check().map_err(Into::into);
1697        assert!(matches!(result, Err(crate::error::Error::Cancelled)));
1698    }
1699
1700    /// Regression for the preview-frame recovery option-propagation bug that
1701    /// was fixed upstream in libjxl/jxl-rs #743 (commit f1514f1).
1702    ///
1703    /// When the input file carries a preview frame, the codestream parser
1704    /// decodes the preview with `process_without_output=true`, then discovers
1705    /// the main frame is a separate frame and recreates the [`DecoderState`]
1706    /// in `codestream_parser::sections::handle_frame_finalized`. Before the
1707    /// port, that recreation path dropped several fields (`high_precision`,
1708    /// `premultiply_output`, `parallel`, `memory_tracker`,
1709    /// `embedded_color_profile`) back to their constructor defaults, silently
1710    /// reverting options set by the caller.
1711    ///
1712    /// The fix centralizes option propagation through
1713    /// `non_section::apply_decoder_options` so both the primary creation path
1714    /// and the preview-recovery path populate the same fields.
1715    ///
1716    /// The test fully decodes `with_preview.jxl` with non-default options, so
1717    /// the preview frame finalize path runs and the recreation branch is
1718    /// taken, then asserts every recreated field carries the configured
1719    /// option value rather than the `DecoderState::new` default.
1720    #[test]
1721    #[allow(clippy::field_reassign_with_default)]
1722    fn test_preview_recovery_preserves_decoder_options() {
1723        let data = std::fs::read("resources/test/with_preview.jxl")
1724            .expect("with_preview.jxl test fixture should exist");
1725
1726        // Flip every option the recovery path used to drop to a non-default
1727        // value (`render_spot_colors=false`, `high_precision=true`,
1728        // `premultiply_output=true`, `parallel=false`, restrictive
1729        // `max_memory_bytes`) so a successful decode with the buggy code
1730        // would visibly carry the wrong field values. `JxlDecoderOptions`
1731        // is `#[non_exhaustive]`, so a struct literal with
1732        // `..Default::default()` is not allowed.
1733        let mut options = JxlDecoderOptions::default();
1734        options.high_precision = true;
1735        options.premultiply_output = true;
1736        options.parallel = false;
1737        options.render_spot_colors = false;
1738        // Generous enough to actually decode the tiny test file but still
1739        // a finite limit so memory_tracker.has_limit() is true.
1740        options.limits.max_memory_bytes = Some(64 * 1024 * 1024);
1741
1742        let mut decoder = JxlDecoderInner::new(options);
1743        let mut input = data.as_slice();
1744
1745        // 1. Process up to image info.
1746        match decoder.process(&mut input, None) {
1747            Ok(ProcessingResult::Complete { .. }) => {}
1748            other => panic!("expected image-info Complete, got {other:?}"),
1749        }
1750        assert!(decoder.basic_info().is_some());
1751
1752        // 2. Process up to the (main) frame header. With the default
1753        //    `skip_preview=true`, the preview frame is fully decoded with
1754        //    `process_without_output=true`, then the recreate branch in
1755        //    `sections::handle_frame_finalized` runs and the decoder advances
1756        //    to the main frame. The main-frame `Frame::from_header_and_toc`
1757        //    consumes the recreated `DecoderState`, so by the time `process`
1758        //    returns here the recreated state lives inside the active Frame.
1759        match decoder.process(&mut input, None) {
1760            Ok(ProcessingResult::Complete { .. }) => {}
1761            other => panic!("expected frame-info Complete, got {other:?}"),
1762        }
1763        assert!(decoder.frame_header().is_some());
1764
1765        // Inspect the recreated state (now inside the main-frame Frame)
1766        // BEFORE the main frame finalizes and drops it.
1767        let state = decoder
1768            .decoder_state_for_test()
1769            .expect("decoder_state must exist inside the active main frame");
1770
1771        // Before the fix, all of the following assertions would fail when
1772        // the preview-recovery branch was taken: the recreated state reset
1773        // every knob below to its DecoderState::new() default.
1774        assert!(
1775            state.high_precision,
1776            "high_precision should survive preview-frame recovery"
1777        );
1778        assert!(
1779            state.premultiply_output,
1780            "premultiply_output should survive preview-frame recovery"
1781        );
1782        assert!(
1783            !state.parallel,
1784            "parallel=false should survive preview-frame recovery (was silently flipped back to DecoderState::new default)"
1785        );
1786        assert!(
1787            !state.render_spotcolors,
1788            "render_spotcolors=false should survive preview-frame recovery"
1789        );
1790        assert!(
1791            state.memory_tracker.has_limit(),
1792            "memory_tracker should carry the configured limit after preview-frame recovery, not revert to unlimited"
1793        );
1794        assert_eq!(
1795            state.memory_tracker.limit(),
1796            Some(64 * 1024 * 1024),
1797            "memory_tracker limit should equal configured max_memory_bytes"
1798        );
1799        assert!(
1800            state.embedded_color_profile.is_some(),
1801            "embedded_color_profile must be propagated so CMYK ICC and similar code paths work after preview recovery"
1802        );
1803        assert_eq!(
1804            state.limits.max_memory_bytes,
1805            Some(64 * 1024 * 1024),
1806            "limits.max_memory_bytes on DecoderState must match the configured options"
1807        );
1808    }
1809
1810    /// Chunk-drip stress test mirroring the Chrome-integration repro from
1811    /// libjxl/jxl-rs #743. We don't have the seek API (upstream #678) yet, but
1812    /// we can still exercise the same box-parser and codestream-parser state
1813    /// machines by feeding an animation file to `flush_pixels` in 1 KiB chunks
1814    /// and asserting the decoder never errors or panics on any chunk boundary.
1815    #[test]
1816    fn test_chunked_drip_decode_animation_newtons_cradle() {
1817        let data =
1818            std::fs::read("resources/test/conformance_test_images/animation_newtons_cradle.jxl")
1819                .expect("animation_newtons_cradle.jxl test fixture should exist");
1820
1821        let options = JxlDecoderOptions::default();
1822        let mut decoder = JxlDecoderInner::new(options);
1823        const CHUNK: usize = 1024;
1824        let mut fed = 0usize;
1825
1826        while fed < data.len() {
1827            let end = (fed + CHUNK).min(data.len());
1828            let mut chunk = &data[fed..end];
1829            let before = chunk.len();
1830            match decoder.process(&mut chunk, None) {
1831                Ok(_) => {}
1832                Err(e) => panic!("decoder errored on chunk [{fed}..{end}]: {e:?}"),
1833            }
1834            let consumed = before - chunk.len();
1835            fed += consumed;
1836            if consumed == 0 {
1837                // No progress on this chunk — advance to feed more bytes.
1838                fed = end;
1839            }
1840        }
1841    }
1842}