1pub 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
29pub 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#[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#[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
66pub 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
74pub 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
82pub 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
90pub 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
112pub 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 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#[cfg(test)]
139use crate::error::Result;
140
141#[cfg(test)]
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum EncodingMode {
145 VarDct, Modular, }
148
149#[cfg(test)]
151pub struct DecodedImage {
152 pub width: usize,
154 pub height: usize,
156 pub channels: usize,
158 pub pixels: Vec<f32>,
160}
161
162#[cfg(test)]
163impl DecodedImage {
164 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 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#[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 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 let basic_info = decoder.basic_info().clone();
218 let (width, height) = basic_info.size;
219
220 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 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 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 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 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 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#[cfg(test)]
341pub fn decode_with_djxl(data: &[u8]) -> Result<DecodedImage> {
342 use std::process::Command;
343
344 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 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 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 let pixels: Vec<f32> = rgb.as_raw().iter().map(|&v| v as f32 / 255.0).collect();
391
392 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 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#[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 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 let render = image.render_frame(0).map_err(|e| {
436 crate::error::Error::InvalidInput(format!("jxl-oxide render failed: {:?}", e))
437 })?;
438
439 let framebuffer = render.image_all_channels();
441 let buf = framebuffer.buf();
442
443 let pixels = buf.to_vec();
445
446 Ok(DecodedImage {
447 width,
448 height,
449 channels,
450 pixels,
451 })
452}
453
454#[cfg(test)]
457pub fn parse_encoding_mode(data: &[u8]) -> Option<EncodingMode> {
458 if data.len() < 10 {
459 return None;
460 }
461
462 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 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 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 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#[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#[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 assert_encoding_mode(&encoded, EncodingMode::Modular, test_name);
536
537 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#[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 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 assert_encoding_mode(&encoded, EncodingMode::VarDct, test_name);
570
571 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#[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 let debug_path = std::env::temp_dir().join(format!("{}.jxl", test_name));
596 std::fs::write(&debug_path, &encoded).ok();
597
598 assert_encoding_mode(&encoded, EncodingMode::VarDct, test_name);
600
601 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 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#[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 let original_rgb: Vec<[u8; 3]> = original
633 .chunks_exact(3)
634 .map(|rgb| [rgb[0], rgb[1], rgb[2]])
635 .collect();
636
637 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(src.as_ref(), dst.as_ref()).unwrap_or(0.0)
655}
656
657pub fn test_output_dir(subdir: &str) -> std::path::PathBuf {
663 output_dir(subdir)
664}
665
666pub 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 let _ = parse_encoding_mode(&[]);
687 let _ = parse_encoding_mode(&[0xFF, 0x0A]);
688 let _ = parse_encoding_mode(&[0; 100]);
689 }
690}