From 8a3489da6b7670785fb00607ba91270552c4a8d1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 20:51:15 -0400 Subject: [PATCH] =?UTF-8?q?LR-IMP-FORMATS:=20decode=20lossy=20(DCT=20JPEG)?= =?UTF-8?q?=20DNG=20=E2=80=94=20Smart=20Previews=20and=20lossy=20DNG=20exp?= =?UTF-8?q?orts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DNG compression 34892: each tile / strip is a baseline JPEG of 8-bit samples (decoded with zune-jpeg, pure Rust), then the usual linearization table, levels and crop apply. Per the DNG specification. Lossy DNGs (Smart Previews, "lossy compression" DNG exports, many phone DNGs) now open as raw files instead of falling back to their embedded preview. Unit test with synthetic JPEG tiles (edge tile cut short); the corpus sample decodes (5760×3840 in 144 ms). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 ++ crates/raw/Cargo.toml | 3 ++ crates/raw/src/dng.rs | 3 -- crates/raw/src/lib.rs | 2 +- crates/raw/src/tiffraw.rs | 77 ++++++++++++++++++++++++++++++++++++++ crates/raw/tests/corpus.rs | 1 - crates/raw/tests/dng.rs | 3 +- docs/parity.md | 2 +- 8 files changed, 87 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26fbef7f..7cbc385c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2294,6 +2294,7 @@ dependencies = [ name = "lightcraft-raw" version = "0.1.0" dependencies = [ + "jpeg-encoder", "lightcraft-color", "lightcraft-geom", "lightcraft-meta", @@ -2304,6 +2305,8 @@ dependencies = [ "rayon", "serde", "thiserror 2.0.21", + "zune-core", + "zune-jpeg", ] [[package]] diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index 512258bf..ec7c7f7d 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -18,6 +18,9 @@ serde = { workspace = true } thiserror = { workspace = true } rayon = { workspace = true } miniz_oxide = { workspace = true } +zune-jpeg = { workspace = true } +zune-core = { workspace = true } [dev-dependencies] +jpeg-encoder = { workspace = true } proptest = { workspace = true } diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 2b0db2bb..a7ef1987 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -57,9 +57,6 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { if !(1..=4).contains(&cpp) { return Err(RawError::Unsupported(format!("{cpp} samples per pixel"))); } - if info.compression == t::compression::LOSSY_JPEG { - return Err(RawError::Unsupported("lossy (DCT) JPEG DNG".into())); - } if info.compression == t::compression::JPEG_XL { return Err(RawError::Unsupported("JPEG XL DNG".into())); } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 01c9fd2c..be7e0fdc 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -10,7 +10,7 @@ //! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance) //! and produces camera → linear Rec.2020 D65 matrices. //! -//! Formats: DNG (uncompressed, lossless JPEG, Deflate incl. floating point, tiled/stripped, CFA and LinearRaw), +//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw), //! Canon CR2, Nikon NEF/NRW (uncompressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer //! and X-Trans), Panasonic RW2 (packed 12/14-bit), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed). //! [`embedded_preview`] covers all of them plus CR3. Variants we can't decode yet (Nikon Huffman NEF, Panasonic diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 7da80403..870a6164 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -150,6 +150,19 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack v.resize(n, 0); Ok(ChunkPx::U16(v)) } + comp::LOSSY_JPEG => { + // lossy DNG: each chunk is a baseline (DCT) JPEG of 8-bit samples + let (px, jw, jh, jc) = lossy_jpeg(src, cpp)?; + let mut v = vec![0u16; n]; + for y in 0..ch.min(jh) { + for x in 0..cw.min(jw) { + for k in 0..cpp { + v[(y * cw + x) * cpp + k] = px[(y * jw + x) * jc + k.min(jc - 1)] as u16; + } + } + } + Ok(ChunkPx::U16(v)) + } comp::ADOBE_DEFLATE | comp::DEFLATE => { let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 }; let row_bytes = if float { cw * cpp * bytes_per } else { (cw * cpp * bits as usize).div_ceil(8) }; @@ -160,6 +173,24 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack } } +/// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels). +fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec, usize, usize, usize)> { + use zune_core::bytestream::ZCursor; + use zune_core::colorspace::ColorSpace; + use zune_core::options::DecoderOptions; + let cs = if cpp == 1 { ColorSpace::Luma } else { ColorSpace::RGB }; + let opts = DecoderOptions::default().set_max_width(1 << 16).set_max_height(1 << 16).set_strict_mode(false).jpeg_set_out_colorspace(cs); + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), opts); + let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?; + let info = d.info().ok_or_else(|| RawError::Corrupt("lossy JPEG tile without a header".into()))?; + let (w, h) = (info.width as usize, info.height as usize); + let c = if cpp == 1 { 1 } else { 3 }; + if w == 0 || h == 0 || px.len() < w * h * c { + return Err(RawError::Corrupt("lossy JPEG tile: short output".into())); + } + Ok((px, w, h, c)) +} + #[allow(clippy::too_many_arguments)] fn unpack_chunk( src: &[u8], @@ -248,3 +279,49 @@ fn unpack_chunk( } Ok(ChunkPx::U16(out)) } + +#[cfg(test)] +mod lossy_tests { + use super::*; + use lightcraft_tiff::image::Layout; + + /// A lossy-DNG-style image: two 16×8 tiles, each a baseline JPEG, the right one cut short + /// by the image edge. + #[test] + fn lossy_jpeg_tiles_decode() { + let (tw, th) = (16usize, 8usize); + let tile = |shade: u8| { + let px: Vec = (0..tw * th).flat_map(|i| [shade, (i % tw * 15) as u8, 200]).collect(); + let mut out = Vec::new(); + jpeg_encoder::Encoder::new(&mut out, 100).encode(&px, tw as u16, th as u16, jpeg_encoder::ColorType::Rgb).unwrap(); + out + }; + let (a, b) = (tile(40), tile(220)); + let mut file = vec![0u8; 8]; + let oa = file.len() as u64; + file.extend_from_slice(&a); + let ob = file.len() as u64; + file.extend_from_slice(&b); + let info = ImageInfo { + width: 24, + height: 8, + bits_per_sample: vec![8, 8, 8], + samples_per_pixel: 3, + compression: comp::LOSSY_JPEG, + photometric: 34892, + planar: 1, + predictor: 1, + sample_format: 1, + new_subfile_type: 0, + layout: Layout::Tiles { tile_width: tw as u32, tile_height: th as u32 }, + offsets: vec![oa, ob], + byte_counts: vec![a.len() as u64, b.len() as u64], + }; + let RawData::U16(v) = read_image(&file, &info, ByteOrder::Little, Packing::Msb).unwrap() else { panic!("integer samples") }; + assert_eq!(v.len(), 24 * 8 * 3); + let px = |x: usize, y: usize| &v[(y * 24 + x) * 3..(y * 24 + x) * 3 + 3]; + assert!((px(2, 3)[0] as i32 - 40).abs() <= 3 && (px(2, 3)[2] as i32 - 200).abs() <= 3, "{:?}", px(2, 3)); + assert!((px(20, 3)[0] as i32 - 220).abs() <= 3, "second tile: {:?}", px(20, 3)); + assert!((px(10, 5)[1] as i32 - 150).abs() <= 6, "green ramp: {:?}", px(10, 5)); + } +} diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index af47b84a..b8a5535c 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -24,7 +24,6 @@ const KNOWN_UNSUPPORTED: &[&str] = &[ "nef-nikon-d5100-lossless", // Nikon Huffman NEF "nef-nikon-d7000-lossy", // " "sraw", // Canon sRAW / mRAW - "-lossy.dng", // lossy (DCT) DNG ]; #[test] diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index c7e77ba0..f670401b 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -453,7 +453,8 @@ fn rejects_unsupported_and_broken() { let mut raw = base_ifd(w, h, 8, 1, true); raw.set(t::COMPRESSION, Value::Short(vec![compression::LOSSY_JPEG])); raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![vec![0; 10]] }); - assert!(matches!(decode(&dng(raw, ByteOrder::Little)), Err(lightcraft_raw::RawError::Unsupported(_)))); + // lossy JPEG DNGs decode now; a strip that isn't a JPEG is reported as corrupt + assert!(matches!(decode(&dng(raw, ByteOrder::Little)), Err(lightcraft_raw::RawError::Corrupt(_)))); let mut raw = base_ifd(w, h, 16, 1, true); raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 3, 1])); raw.set(t::COMPRESSION, Value::Short(vec![1])); diff --git a/docs/parity.md b/docs/parity.md index 26a028b5..348004be 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -109,7 +109,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-PROFILES | Import profiles & presets | P1 | 🟡 | `cmd:file.importPresets`, `cmd:preset.import`, `crates/engine/src/preset_import.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), .zip bundles, folders (folder → group), drag & drop; masks (gradients, radial, brush, subject/sky, luminance/depth range) carried over; unmapped settings reported; no profile import (Adobe profile formats deliberately unsupported) | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; edits go to XMP sidecars; importing promotes them | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG, CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | ⬜ | | | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ⬜ | `crates/raw/src/dngwrite.rs` | DNG writer exists, not wired to import |