Skip to main content

jxl_encoder/
test_helpers.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//! Test helpers to prevent false positives and verify what tests actually do.
6//!
7//! Path helper functions (`corpus_dir`, `djxl_path`, `cjxl_path`, `jxl_cli_path`,
8//! `output_dir`, `output_dir_for`) are always available and read environment variables
9//! with sensible defaults.
10//!
11//! Decode/roundtrip helpers require test dependencies and are only available
12//! under `#[cfg(test)]`.
13//!
14//! IMPORTANT: Use jxl-rs as the PRIMARY decoder for all roundtrip tests.
15//! jxl-oxide is only a secondary/fallback decoder.
16
17/// Returns the path to the codec corpus directory, if available.
18///
19/// Uses `CODEC_CORPUS_DIR` env var, falling back to `/home/lilith/work/codec-corpus`.
20/// Returns `None` if the directory does not exist (e.g., in CI).
21pub fn try_corpus_dir() -> Option<std::path::PathBuf> {
22    let dir = std::path::PathBuf::from(
23        std::env::var("CODEC_CORPUS_DIR")
24            .unwrap_or_else(|_| "/home/lilith/work/codec-corpus".into()),
25    );
26    dir.is_dir().then_some(dir)
27}
28
29/// Returns the path to the codec corpus directory.
30///
31/// Uses `CODEC_CORPUS_DIR` env var, falling back to `/home/lilith/work/codec-corpus`.
32/// Panics if the directory does not exist.
33pub fn corpus_dir() -> std::path::PathBuf {
34    try_corpus_dir().unwrap_or_else(|| {
35        panic!(
36            "Codec corpus not found. Set CODEC_CORPUS_DIR env var or install codec-corpus crate."
37        )
38    })
39}
40
41/// Skip the current test if the codec corpus is not available.
42///
43/// Use at the top of any test that requires external corpus files.
44/// In CI (no corpus), the test will pass silently instead of failing.
45#[macro_export]
46macro_rules! skip_without_corpus {
47    () => {
48        if $crate::test_helpers::try_corpus_dir().is_none() {
49            eprintln!("SKIPPED: codec corpus not available");
50            return;
51        }
52    };
53}
54
55/// Skip the current test if the given external binary is not available.
56#[macro_export]
57macro_rules! skip_without_binary {
58    ($path:expr) => {
59        if !std::path::Path::new(&$path).exists() {
60            eprintln!("SKIPPED: {} not available", $path);
61            return;
62        }
63    };
64}
65
66/// Returns the path to the djxl binary.
67///
68/// Uses `DJXL_PATH` env var, falling back to the libjxl build directory.
69pub fn djxl_path() -> String {
70    std::env::var("DJXL_PATH")
71        .unwrap_or_else(|_| "/home/lilith/work/jxl-efforts/libjxl/build/tools/djxl".into())
72}
73
74/// Returns the path to the cjxl binary.
75///
76/// Uses `CJXL_PATH` env var, falling back to the libjxl build directory.
77pub fn cjxl_path() -> String {
78    std::env::var("CJXL_PATH")
79        .unwrap_or_else(|_| "/home/lilith/work/jxl-efforts/libjxl/build/tools/cjxl".into())
80}
81
82/// Returns the path to the jxl_cli binary (jxl-rs decoder).
83///
84/// Uses `JXL_CLI_PATH` env var, falling back to the jxl-rs build directory.
85pub fn jxl_cli_path() -> String {
86    std::env::var("JXL_CLI_PATH")
87        .unwrap_or_else(|_| "/home/lilith/work/jxl-rs/target/release/jxl_cli".into())
88}
89
90/// Returns a test output directory, creating it if needed.
91///
92/// Uses `JXL_ENCODER_OUTPUT_DIR` env var as the base, falling back to
93/// `/mnt/v/output/jxl-encoder-rs`. Appends the given subdir and creates
94/// the full path.
95///
96/// Falls back to `$TMPDIR/jxl-encoder-rs/{subdir}` when the preferred
97/// path is unavailable (CI, Docker, other machines).
98pub fn output_dir(subdir: &str) -> std::path::PathBuf {
99    let base = std::path::PathBuf::from(
100        std::env::var("JXL_ENCODER_OUTPUT_DIR")
101            .unwrap_or_else(|_| "/mnt/v/output/jxl-encoder-rs".into()),
102    );
103    let dir = base.join(subdir);
104    if std::fs::create_dir_all(&dir).is_ok() {
105        return dir;
106    }
107    let fallback = std::env::temp_dir().join(format!("jxl-encoder-rs/{subdir}"));
108    let _ = std::fs::create_dir_all(&fallback);
109    fallback
110}
111
112/// Returns an output directory for non-standard project subdirs.
113///
114/// Like `output_dir`, but uses the parent of `JXL_ENCODER_OUTPUT_DIR`
115/// (or `/mnt/v/output`) as the base, then appends the given project/subdir.
116/// Use for output paths like `/mnt/v/output/jpeg-reencoding/...` or
117/// `/mnt/v/output/jxl-encoder/...`.
118pub fn output_dir_for(project: &str, subdir: &str) -> std::path::PathBuf {
119    let base = match std::env::var("JXL_ENCODER_OUTPUT_DIR") {
120        Ok(dir) => {
121            // Go up one level from jxl-encoder-rs to the shared output root
122            let p = std::path::PathBuf::from(dir);
123            p.parent().unwrap_or(&p).to_path_buf()
124        }
125        Err(_) => std::path::PathBuf::from("/mnt/v/output"),
126    };
127    let dir = base.join(project).join(subdir);
128    if std::fs::create_dir_all(&dir).is_ok() {
129        return dir;
130    }
131    let fallback = std::env::temp_dir().join(format!("{project}/{subdir}"));
132    let _ = std::fs::create_dir_all(&fallback);
133    fallback
134}
135
136// --- Everything below requires test (dev) dependencies ---
137
138#[cfg(test)]
139use crate::error::Result;
140
141/// Encoding mode in JXL
142#[cfg(test)]
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum EncodingMode {
145    VarDct,  // encoding=0 (lossy)
146    Modular, // encoding=1 (lossless)
147}
148
149/// Decoded image result from jxl-rs
150#[cfg(test)]
151pub struct DecodedImage {
152    /// Width in pixels
153    pub width: usize,
154    /// Height in pixels
155    pub height: usize,
156    /// Number of color channels (3 for RGB, 4 for RGBA)
157    pub channels: usize,
158    /// Pixel data as interleaved f32 values [R, G, B, ...] row by row
159    pub pixels: Vec<f32>,
160}
161
162#[cfg(test)]
163impl DecodedImage {
164    /// Get pixel value at (x, y) for channel c
165    pub fn get(&self, x: usize, y: usize, c: usize) -> f32 {
166        let idx = (y * self.width + x) * self.channels + c;
167        self.pixels[idx]
168    }
169
170    /// Get RGB pixel as (r, g, b) scaled to 0-255
171    pub fn get_rgb_u8(&self, x: usize, y: usize) -> (u8, u8, u8) {
172        let r = (self.get(x, y, 0) * 255.0).clamp(0.0, 255.0) as u8;
173        let g = (self.get(x, y, 1) * 255.0).clamp(0.0, 255.0) as u8;
174        let b = (self.get(x, y, 2) * 255.0).clamp(0.0, 255.0) as u8;
175        (r, g, b)
176    }
177}
178
179/// Decode JXL data using jxl-rs (PRIMARY decoder for single-group images).
180///
181/// WARNING: jxl-rs has a multi-group VarDCT decoder bug (same as jxl-oxide).
182/// For images >256x256, use decode_with_djxl() instead.
183///
184/// Returns decoded image with f32 pixel values.
185#[cfg(test)]
186pub fn decode_with_jxl_rs(data: &[u8]) -> Result<DecodedImage> {
187    use jxl::api::states::Initialized;
188    use jxl::api::{
189        JxlDataFormat, JxlDecoder, JxlDecoderOptions, JxlOutputBuffer, JxlPixelFormat,
190        ProcessingResult,
191    };
192    use jxl::image::{Image, Rect};
193
194    let options = JxlDecoderOptions::default();
195    let mut decoder: JxlDecoder<Initialized> = JxlDecoder::new(options);
196    let mut input = data;
197
198    // Process until we have image info
199    let mut decoder = loop {
200        match decoder
201            .process(&mut input)
202            .map_err(|e| crate::error::Error::InvalidInput(format!("jxl-rs init error: {:?}", e)))?
203        {
204            ProcessingResult::Complete { result } => break result,
205            ProcessingResult::NeedsMoreInput { fallback, .. } => {
206                if input.is_empty() {
207                    return Err(crate::error::Error::InvalidInput(
208                        "jxl-rs: unexpected end of input during header parsing".to_string(),
209                    ));
210                }
211                decoder = fallback;
212            }
213        }
214    };
215
216    // Get basic info
217    let basic_info = decoder.basic_info().clone();
218    let (width, height) = basic_info.size;
219
220    // Request f32 format for color + extra channels
221    let default_format = decoder.current_pixel_format();
222    let num_channels = default_format.color_type.samples_per_pixel();
223    let num_extra = default_format.extra_channel_format.len();
224
225    let requested_format = JxlPixelFormat {
226        color_type: default_format.color_type,
227        color_data_format: Some(JxlDataFormat::f32()),
228        extra_channel_format: default_format
229            .extra_channel_format
230            .iter()
231            .map(|_| Some(JxlDataFormat::f32()))
232            .collect(),
233    };
234    decoder.set_pixel_format(requested_format);
235
236    // Process until we have frame info
237    let mut decoder = loop {
238        match decoder.process(&mut input).map_err(|e| {
239            crate::error::Error::InvalidInput(format!("jxl-rs frame error: {:?}", e))
240        })? {
241            ProcessingResult::Complete { result } => break result,
242            ProcessingResult::NeedsMoreInput { fallback, .. } => {
243                if input.is_empty() {
244                    return Err(crate::error::Error::InvalidInput(
245                        "jxl-rs: unexpected end of input during frame parsing".to_string(),
246                    ));
247                }
248                decoder = fallback;
249            }
250        }
251    };
252
253    // Create output buffers (color + optional extra channels like alpha)
254    let mut color_buffer = Image::<f32>::new((width * num_channels, height)).map_err(|e| {
255        crate::error::Error::InvalidInput(format!("jxl-rs buffer alloc error: {:?}", e))
256    })?;
257
258    let mut extra_buffers: Vec<Image<f32>> = (0..num_extra)
259        .map(|_| {
260            Image::<f32>::new((width, height)).map_err(|e| {
261                crate::error::Error::InvalidInput(format!(
262                    "jxl-rs extra buffer alloc error: {:?}",
263                    e
264                ))
265            })
266        })
267        .collect::<Result<Vec<_>>>()?;
268
269    let mut buffers: Vec<_> = vec![JxlOutputBuffer::from_image_rect_mut(
270        color_buffer
271            .get_rect_mut(Rect {
272                origin: (0, 0),
273                size: (width * num_channels, height),
274            })
275            .into_raw(),
276    )];
277    for eb in &mut extra_buffers {
278        buffers.push(JxlOutputBuffer::from_image_rect_mut(
279            eb.get_rect_mut(Rect {
280                origin: (0, 0),
281                size: (width, height),
282            })
283            .into_raw(),
284        ));
285    }
286
287    // Decode frame
288    loop {
289        match decoder.process(&mut input, &mut buffers).map_err(|e| {
290            crate::error::Error::InvalidInput(format!("jxl-rs decode error: {:?}", e))
291        })? {
292            ProcessingResult::Complete { .. } => break,
293            ProcessingResult::NeedsMoreInput { fallback, .. } => {
294                if input.is_empty() {
295                    return Err(crate::error::Error::InvalidInput(
296                        "jxl-rs: unexpected end of input during decode".to_string(),
297                    ));
298                }
299                decoder = fallback;
300            }
301        }
302    }
303
304    // Extract pixels: interleave color + extra channels
305    let total_channels = num_channels + num_extra;
306    let mut pixels = Vec::with_capacity(width * height * total_channels);
307    for y in 0..height {
308        let color_row = color_buffer.row(y);
309        if num_extra == 0 {
310            pixels.extend_from_slice(color_row);
311        } else {
312            // Interleave: for each pixel, emit color channels then extra channels
313            let extra_rows: Vec<&[f32]> = extra_buffers.iter().map(|eb| eb.row(y)).collect();
314            for x in 0..width {
315                for c in 0..num_channels {
316                    pixels.push(color_row[x * num_channels + c]);
317                }
318                for (ec, extra_row) in extra_rows.iter().enumerate() {
319                    let _ = ec;
320                    pixels.push(extra_row[x]);
321                }
322            }
323        }
324    }
325
326    Ok(DecodedImage {
327        width,
328        height,
329        channels: total_channels,
330        pixels,
331    })
332}
333
334/// Decode JXL data using djxl (libjxl reference decoder).
335///
336/// This is the GOLD STANDARD decoder. Use for multi-group VarDCT images
337/// since both jxl-rs and jxl-oxide have multi-group decoder bugs.
338///
339/// Requires djxl binary (set `DJXL_PATH` env var or use default libjxl build path).
340#[cfg(test)]
341pub fn decode_with_djxl(data: &[u8]) -> Result<DecodedImage> {
342    use std::process::Command;
343
344    // Use unique temp file names: PID + thread ID + monotonic counter to avoid
345    // race conditions when tests run in parallel threads within the same process.
346    use core::sync::atomic::{AtomicU64, Ordering};
347    static COUNTER: AtomicU64 = AtomicU64::new(0);
348    let id = COUNTER.fetch_add(1, Ordering::Relaxed);
349    let temp_dir = std::env::temp_dir();
350    let temp_jxl = temp_dir
351        .join(format!("decode_test_djxl_{id}.jxl"))
352        .to_string_lossy()
353        .into_owned();
354    let temp_png = temp_dir
355        .join(format!("decode_test_djxl_{id}.png"))
356        .to_string_lossy()
357        .into_owned();
358
359    std::fs::write(&temp_jxl, data).map_err(|e| {
360        crate::error::Error::InvalidInput(format!("Failed to write temp file: {:?}", e))
361    })?;
362
363    // Run djxl
364    let djxl = djxl_path();
365    let output = Command::new(&djxl)
366        .args([&temp_jxl, &temp_png])
367        .output()
368        .map_err(|e| crate::error::Error::InvalidInput(format!("Failed to run djxl: {:?}", e)))?;
369
370    if !output.status.success() {
371        let _ = std::fs::remove_file(&temp_jxl);
372        return Err(crate::error::Error::InvalidInput(format!(
373            "djxl failed: {}",
374            String::from_utf8_lossy(&output.stderr)
375        )));
376    }
377
378    // Load PNG with image crate
379    let img = image::open(&temp_png).map_err(|e| {
380        let _ = std::fs::remove_file(&temp_jxl);
381        let _ = std::fs::remove_file(&temp_png);
382        crate::error::Error::InvalidInput(format!("Failed to load decoded PNG: {:?}", e))
383    })?;
384    let rgb = img.to_rgb8();
385
386    let width = rgb.width() as usize;
387    let height = rgb.height() as usize;
388
389    // Convert u8 to f32
390    let pixels: Vec<f32> = rgb.as_raw().iter().map(|&v| v as f32 / 255.0).collect();
391
392    // Debug: check the actual pixel values we're returning
393    eprintln!(
394        "DEBUG decode_with_djxl: {}x{}, first 9 u8 raw: {:?}",
395        width,
396        height,
397        rgb.as_raw().iter().take(9).copied().collect::<Vec<_>>()
398    );
399
400    // Cleanup temp files
401    let _ = std::fs::remove_file(&temp_jxl);
402    let _ = std::fs::remove_file(&temp_png);
403
404    Ok(DecodedImage {
405        width,
406        height,
407        channels: 3,
408        pixels,
409    })
410}
411
412/// Decode JXL data using jxl-oxide (SECONDARY decoder).
413///
414/// WARNING: jxl-oxide has a multi-group VarDCT decoder bug.
415/// For images >256x256, use decode_with_djxl() instead.
416#[cfg(test)]
417pub fn decode_with_jxl_oxide(data: &[u8]) -> Result<DecodedImage> {
418    let mut image = jxl_oxide::JxlImage::builder()
419        .read(std::io::Cursor::new(data))
420        .map_err(|e| {
421            crate::error::Error::InvalidInput(format!("jxl-oxide decode failed: {:?}", e))
422        })?;
423
424    // Request linear sRGB output so decoded pixels are in linear RGB space,
425    // matching our encoder's internal representation.
426    image.request_color_encoding(jxl_oxide::EnumColourEncoding::srgb_linear(
427        jxl_oxide::RenderingIntent::Relative,
428    ));
429
430    let width = image.width() as usize;
431    let height = image.height() as usize;
432    let channels = image.pixel_format().channels();
433
434    // Render to get actual pixels (linear f32)
435    let render = image.render_frame(0).map_err(|e| {
436        crate::error::Error::InvalidInput(format!("jxl-oxide render failed: {:?}", e))
437    })?;
438
439    // Get pixel data as f32 (interleaved)
440    let framebuffer = render.image_all_channels();
441    let buf = framebuffer.buf();
442
443    // buf is already interleaved [R,G,B,R,G,B,...] for all pixels
444    let pixels = buf.to_vec();
445
446    Ok(DecodedImage {
447        width,
448        height,
449        channels,
450        pixels,
451    })
452}
453
454/// Parse the encoding mode from a JXL bitstream.
455/// Returns None if unable to parse (ambiguous or invalid).
456#[cfg(test)]
457pub fn parse_encoding_mode(data: &[u8]) -> Option<EncodingMode> {
458    if data.len() < 10 {
459        return None;
460    }
461
462    // Read bit at position (LSB-first)
463    fn read_bit(data: &[u8], bit_pos: usize) -> Option<u8> {
464        let byte_idx = bit_pos / 8;
465        let bit_idx = bit_pos % 8;
466        if byte_idx >= data.len() {
467            return None;
468        }
469        Some((data[byte_idx] >> bit_idx) & 1)
470    }
471
472    // Frame header typically starts around bit 38-60 depending on size header
473    // Look for all_default=0 followed by frame_type (2 bits) and encoding (1 bit)
474    // Start at 38 to skip file header metadata (which can have spurious zeros)
475    // The frame header position varies by file header size and the bit parsing
476    // is fragile. Since VarDctEncoder always produces VarDCT (verified in source)
477    // and the real test is that decoders work, we use a simpler heuristic:
478    // Just check if the file decodes and trust the encoding type based on API used.
479    //
480    // For robustness, search byte-aligned positions for the frame header pattern.
481    for start_byte in 4..25 {
482        let start_bit = start_byte * 8;
483        let all_default = read_bit(data, start_bit)?;
484        if all_default == 0 {
485            // all_default=0, so frame_type (2 bits) and encoding (1 bit) follow
486            let frame_type_0 = read_bit(data, start_bit + 1)?;
487            let frame_type_1 = read_bit(data, start_bit + 2)?;
488            let encoding_bit = read_bit(data, start_bit + 3)?;
489            // For a valid frame: frame_type should be 0 (regular frame)
490            if frame_type_0 == 0 && frame_type_1 == 0 {
491                return Some(match encoding_bit {
492                    0 => EncodingMode::VarDct,
493                    1 => EncodingMode::Modular,
494                    _ => unreachable!(),
495                });
496            }
497        }
498    }
499
500    None
501}
502
503/// Assert that encoded data uses the expected encoding mode.
504/// Panics with a clear message if the mode doesn't match.
505#[cfg(test)]
506pub fn assert_encoding_mode(data: &[u8], expected: EncodingMode, test_name: &str) {
507    let actual = parse_encoding_mode(data).unwrap_or_else(|| {
508        panic!(
509            "{}: Could not parse encoding mode from bitstream",
510            test_name
511        )
512    });
513
514    assert_eq!(
515        actual, expected,
516        "{}: Expected {:?} but got {:?}. This test is not testing what it claims!",
517        test_name, expected, actual
518    );
519}
520
521/// Standard roundtrip test for lossless encoding.
522/// Encodes with Modular, verifies encoding mode, then decodes with jxl-rs (primary).
523#[cfg(test)]
524pub fn test_lossless_roundtrip(
525    data: &[u8],
526    width: usize,
527    height: usize,
528    test_name: &str,
529) -> Result<()> {
530    let encoded = crate::LosslessConfig::new()
531        .encode(data, width as u32, height as u32, crate::PixelLayout::Rgb8)
532        .map_err(|e| crate::error::Error::InvalidInput(format!("{e}")))?;
533
534    // VERIFY we actually used Modular encoding
535    assert_encoding_mode(&encoded, EncodingMode::Modular, test_name);
536
537    // Decode with jxl-rs (PRIMARY decoder)
538    let decoded = decode_with_jxl_rs(&encoded)?;
539    assert_eq!(decoded.width, width, "{}: width mismatch", test_name);
540    assert_eq!(decoded.height, height, "{}: height mismatch", test_name);
541
542    Ok(())
543}
544
545/// Standard roundtrip test for lossy VarDCT encoding.
546/// Encodes with VarDCT, verifies encoding mode, then decodes with jxl-rs (primary).
547#[cfg(test)]
548pub fn test_lossy_roundtrip(
549    data: &[u8],
550    width: usize,
551    height: usize,
552    distance: f32,
553    test_name: &str,
554) -> Result<()> {
555    let encoded = crate::LossyConfig::new(distance)
556        .encode(data, width as u32, height as u32, crate::PixelLayout::Rgb8)
557        .map_err(|e| crate::error::Error::InvalidInput(format!("{e}")))?;
558
559    // Save for debugging
560    let debug_path = std::env::temp_dir().join(format!("{}.jxl", test_name));
561    std::fs::write(&debug_path, &encoded).ok();
562    eprintln!(
563        "DEBUG: Saved {} bytes to {}",
564        encoded.len(),
565        debug_path.display()
566    );
567
568    // VERIFY we actually used VarDCT encoding
569    assert_encoding_mode(&encoded, EncodingMode::VarDct, test_name);
570
571    // Decode with jxl-rs (PRIMARY decoder)
572    eprintln!("DEBUG: Decoding with jxl-rs (primary)...");
573    let decoded = decode_with_jxl_rs(&encoded)?;
574    assert_eq!(decoded.width, width, "{}: width mismatch", test_name);
575    assert_eq!(decoded.height, height, "{}: height mismatch", test_name);
576
577    Ok(())
578}
579
580/// Lossy roundtrip test with quality verification using SSIMULACRA2.
581/// Returns SSIM2 score (higher is better, typically 50+ is acceptable).
582#[cfg(test)]
583pub fn test_lossy_roundtrip_with_quality(
584    data: &[u8],
585    width: usize,
586    height: usize,
587    distance: f32,
588    test_name: &str,
589) -> Result<f64> {
590    let encoded = crate::LossyConfig::new(distance)
591        .encode(data, width as u32, height as u32, crate::PixelLayout::Rgb8)
592        .map_err(|e| crate::error::Error::InvalidInput(format!("{e}")))?;
593
594    // Save for debugging
595    let debug_path = std::env::temp_dir().join(format!("{}.jxl", test_name));
596    std::fs::write(&debug_path, &encoded).ok();
597
598    // VERIFY we actually used VarDCT encoding
599    assert_encoding_mode(&encoded, EncodingMode::VarDct, test_name);
600
601    // Decode with jxl-rs (PRIMARY decoder)
602    let decoded = decode_with_jxl_rs(&encoded)?;
603    assert_eq!(decoded.width, width, "{}: width mismatch", test_name);
604    assert_eq!(decoded.height, height, "{}: height mismatch", test_name);
605
606    // Calculate SSIMULACRA2 score
607    let ssim2 = calculate_ssim2(data, &decoded, width, height);
608
609    eprintln!(
610        "{}: encoded {} bytes, SSIM2={:.2}",
611        test_name,
612        encoded.len(),
613        ssim2
614    );
615
616    Ok(ssim2)
617}
618
619/// Calculate SSIMULACRA2 score between original RGB8 data and decoded image.
620/// Returns score where 100 = identical, 90+ = imperceptible, <50 = significant degradation.
621#[cfg(test)]
622pub fn calculate_ssim2(
623    original: &[u8],
624    decoded: &DecodedImage,
625    width: usize,
626    height: usize,
627) -> f64 {
628    use fast_ssim2::compute_ssimulacra2;
629    use imgref::ImgVec;
630
631    // Convert original to [u8; 3] array format
632    let original_rgb: Vec<[u8; 3]> = original
633        .chunks_exact(3)
634        .map(|rgb| [rgb[0], rgb[1], rgb[2]])
635        .collect();
636
637    // Convert decoded f32 back to u8 for comparison
638    // (decoded is already in sRGB, just scale to 0-255)
639    let decoded_rgb: Vec<[u8; 3]> = (0..height)
640        .flat_map(|y| {
641            (0..width).map(move |x| {
642                let r = (decoded.get(x, y, 0) * 255.0).clamp(0.0, 255.0) as u8;
643                let g = (decoded.get(x, y, 1) * 255.0).clamp(0.0, 255.0) as u8;
644                let b = (decoded.get(x, y, 2) * 255.0).clamp(0.0, 255.0) as u8;
645                [r, g, b]
646            })
647        })
648        .collect();
649
650    let src = ImgVec::new(original_rgb, width, height);
651    let dst = ImgVec::new(decoded_rgb, width, height);
652
653    // compute_ssimulacra2 handles sRGB->linear conversion internally
654    compute_ssimulacra2(src.as_ref(), dst.as_ref()).unwrap_or(0.0)
655}
656
657/// Return a test output directory, creating it if possible.
658///
659/// Uses `JXL_ENCODER_OUTPUT_DIR` env var as base (default: `/mnt/v/output/jxl-encoder-rs`).
660/// Falls back to `$TMPDIR/jxl-encoder-rs/{subdir}` when that path is unavailable
661/// (CI, Docker, other machines).
662pub fn test_output_dir(subdir: &str) -> std::path::PathBuf {
663    output_dir(subdir)
664}
665
666/// Write test output to the best available directory. Never panics.
667pub fn save_test_output(subdir: &str, filename: &str, data: &[u8]) {
668    let dir = test_output_dir(subdir);
669    let path = dir.join(filename);
670    match std::fs::write(&path, data) {
671        Ok(()) => eprintln!("Saved {} bytes to {}", data.len(), path.display()),
672        Err(e) => eprintln!("Could not save to {} ({})", path.display(), e),
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn test_parse_encoding_mode() {
682        // This test verifies the parser itself works correctly
683        // We'll generate known bitstreams and verify parsing
684
685        // For now, just ensure it doesn't panic on various inputs
686        let _ = parse_encoding_mode(&[]);
687        let _ = parse_encoding_mode(&[0xFF, 0x0A]);
688        let _ = parse_encoding_mode(&[0; 100]);
689    }
690}