Files
robmdunnandClaude Opus 5.5 afbc0f9744 LR-EXP-TYPE: JPEG XL export (#247)
Export as JPEG XL from the dialog, CLI, MCP and control channel (format jxl): lossy with
quality or lossless, 8/16-bit, effort fast/normal, progressive, EXIF/XMP boxes; HDR edits
as 16-bit Rec. 2020 PQ. Encoder: jixel (pure Rust, BSD-3-Clause OR Apache-2.0, native
only: it spawns its own threads), wrapped in catch_unwind. Lossy files declare an enum
encoding (sRGB / P3 / Rec. 2020; Adobe RGB and ProPhoto written as Rec. 2020); lossless
files embed the ICC profile.

Decoder fix: jxl-oxide desaturated colours outside sRGB when rendering XYB images to
wider primaries; lossy wide-gamut JXL now renders to linear sRGB primaries and is
converted to Rec. 2020 here. jixel's slowest effort is left out: jxl-oxide misdecodes
its (valid) files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:19:30 -04:00

98 lines
4.1 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Benchmark-ish, ignored by default. Run with:
//! `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocapture`
use lightcraft_codecs::*;
use std::time::Instant;
fn synthetic(w: usize, h: usize) -> Vec<u8> {
// Smooth gradients + deterministic noise, roughly photo-like entropy.
let mut s: u32 = 0x1234_5678;
let mut v = Vec::with_capacity(w * h * 3);
for y in 0..h {
for x in 0..w {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
let n = (s & 15) as i32 - 8;
let r = (x * 255 / w) as i32 + n;
let g = (y * 255 / h) as i32 + n;
let b = (((x / 37) ^ (y / 29)) & 0xFF) as i32 / 2 + 64 + n;
v.extend_from_slice(&[r.clamp(0, 255) as u8, g.clamp(0, 255) as u8, b.clamp(0, 255) as u8]);
}
}
v
}
fn time<T>(label: &str, mp: f64, reps: usize, mut f: impl FnMut() -> T) -> T {
let mut out = f();
let t = Instant::now();
for _ in 0..reps {
out = f();
}
let dt = t.elapsed().as_secs_f64() / reps as f64;
if mp > 0.0 {
println!("{label:<48} {:>8.2} ms {:>7.1} MP/s", dt * 1e3, mp / dt);
} else {
println!("{label:<48} {:>8.2} ms", dt * 1e3);
}
out
}
#[test]
#[ignore]
fn bench_24mp_jpeg() {
let (w, h) = (6000usize, 4000usize);
let mp = (w * h) as f64 / 1e6;
let px = synthetic(w, h);
let img = EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&px));
let plain =
time("encode JPEG q90 4:2:0 (jpeg-encoder)", mp, 1, || encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap());
println!(" size {:.1} MB", plain.len() as f64 / 1e6);
time("decode full → linear Rgb32f", mp, 3, || decode(&plain, DecodeOptions::default()).unwrap());
time("decode fit 2048 (DCT 1/2 + resample)", mp, 3, || decode(&plain, DecodeOptions::fit(2048, 2048)).unwrap());
time("decode_thumbnail 256 (DCT 1/8, no preview)", mp, 5, || decode_thumbnail(&plain, 256).unwrap());
// Camera-style file: 320×213 EXIF thumbnail covers a 256 px request.
let small: Vec<u8> = synthetic(320, 213);
let thumb = encode_jpeg(&EncodeImage::new(320, 213, 3, Samples::U8(&small)), 80, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
let exif = exif_with_thumbnail(&thumb);
let cam = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta { exif: Some(&exif), ..Default::default() }).unwrap();
let t = time("decode_thumbnail 256 (EXIF preview)", mp, 20, || decode_thumbnail(&cam, 256).unwrap());
assert_eq!(t.source, ThumbnailSource::ExifThumbnail);
let d = decode(&plain, DecodeOptions::default()).unwrap();
time("to_working (sRGB → Rec.2020 matrix)", mp, 3, || to_working(&d));
let rgba = d.to_srgb8();
time("encode PNG 8-bit", mp, 1, || encode_png(&EncodeImage::rgba8(&rgba), &EncodeMeta::default()).unwrap());
time("encode TIFF 8-bit deflate", mp, 1, || encode_tiff(&EncodeImage::rgba8(&rgba), TiffCompression::Deflate, &EncodeMeta::default()).unwrap());
#[cfg(feature = "jxl")]
for (label, o) in [
("encode JPEG XL q90 effort fast", JxlOptions { effort: JxlEffort::Fast, ..Default::default() }),
("encode JPEG XL q90 effort normal", JxlOptions::default()),
("encode JPEG XL lossless", JxlOptions { lossless: true, ..Default::default() }),
] {
let b = time(label, mp, 1, || encode_jxl(&img, &o, &EncodeMeta::default()).unwrap());
println!(" size {:.1} MB", b.len() as f64 / 1e6);
}
}
fn exif_with_thumbnail(jpeg: &[u8]) -> Vec<u8> {
let mut v = b"II*\0".to_vec();
v.extend_from_slice(&8u32.to_le_bytes());
v.extend_from_slice(&0u16.to_le_bytes());
v.extend_from_slice(&14u32.to_le_bytes());
// IFD1 at 14: 2 entries, data at 44
v.extend_from_slice(&2u16.to_le_bytes());
for (tag, val) in [(0x0201u16, 44u32), (0x0202, jpeg.len() as u32)] {
v.extend_from_slice(&tag.to_le_bytes());
v.extend_from_slice(&4u16.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
v.extend_from_slice(&val.to_le_bytes());
}
v.extend_from_slice(&0u32.to_le_bytes());
assert_eq!(v.len(), 44);
v.extend_from_slice(jpeg);
v
}