From 1aaafb62181e256c7174ebaff3db33b916fce9dc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 00:27:45 -0400 Subject: [PATCH] LR-EXP-DNGOPT: DNG export compression (lossless / ZIP / none) ExportOptions.dng_compression (`dngCompression`: lossless (default) | deflate | uncompressed), in the export dialog when the format is DNG. Corpus-gated test: all three decode, uncompressed is the biggest. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/export.rs | 31 +++++++++++++++++++++++----- crates/engine/src/tests_xmp.rs | 30 +++++++++++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 4 ++++ docs/parity.md | 8 +++---- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 7b926193b..f4c05cf9e 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -445,6 +445,8 @@ pub struct ExportOptions { pub conflict: Conflict, /// TIFF compression. pub tiff_compression: TiffCompression, + /// DNG export: how the raw data is stored. + pub dng_compression: DngCompression, pub metadata: MetadataPolicy, /// Strip GPS / location even when the policy would include it. pub remove_location: bool, @@ -472,6 +474,7 @@ impl Default for ExportOptions { subfolder: String::new(), conflict: Conflict::Unique, tiff_compression: TiffCompression::Deflate, + dng_compression: DngCompression::Lossless, metadata: MetadataPolicy::All, remove_location: false, watermark: None, @@ -513,6 +516,7 @@ impl ExportOptions { Some("zip" | "deflate") => TiffCompression::Deflate, _ => d.tiff_compression, }, + dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression), metadata: enm(p, "metadata").unwrap_or(d.metadata), remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location), watermark: match p.get("watermark") { @@ -944,7 +948,7 @@ enum Work { path: String, read: Option, packet: String, - dng: bool, + dng: Option, label: String, size: (usize, usize), }, @@ -972,7 +976,7 @@ pub fn prepare_export( path: path.clone(), read: session.media.file_bytes.clone(), packet: crate::sidecar::sidecar_packet(p, &session.catalog), - dng: o.format == ExportFormat::Dng, + dng: (o.format == ExportFormat::Dng).then_some(o.dng_compression), label: p.file_name.clone(), size: (p.width as usize, p.height as usize), } @@ -995,15 +999,20 @@ impl PreparedExport { Some(r) => r(&path)?, None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?, }; - if !dng { + let Some(dng) = dng else { return Ok(Exported { file_name, bytes, width: size.0, height: size.1, sidecars: vec![("xmp", packet.into_bytes())] }); - } + }; if lightcraft_raw::probe(&bytes).is_none() { return Err(format!("{label}: DNG export needs a raw photo")); } let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?; drop(bytes); - let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), ..Default::default() }) + let compression = match dng { + DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 }, + DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false }, + DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed, + }; + let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() }) .map_err(|e| e.to_string())?; Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new() }) } @@ -1011,6 +1020,18 @@ impl PreparedExport { } } +/// How an exported DNG stores its raw data. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DngCompression { + /// Lossless JPEG (the usual DNG compression). + #[default] + Lossless, + /// Deflate (zip) tiles with prediction. + Deflate, + Uncompressed, +} + /// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy /// applied), or one exact file path (single photo; overwritten). #[derive(Clone, Debug, Default)] diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 1375655de..2f00a3152 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -415,3 +415,33 @@ fn gps_typed_in_is_saved_to_xmp() { s.execute("photo.setMeta", &json!({"gps": null})).unwrap(); assert!(s.catalog.photo(id).unwrap().meta.gps.is_none()); } + +/// DNG export compression: lossless, zip and none all decode; none is the biggest. +#[test] +fn dng_export_compression_choices() { + let corpus = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(PathBuf::from) + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/nef-nikon-d5100-uncompressed.nef"); + if !corpus.exists() { + eprintln!("skip: {} absent", corpus.display()); + return; + } + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [corpus.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let mut sizes = Vec::new(); + for c in ["lossless", "deflate", "uncompressed"] { + let o = crate::export::ExportOptions::from_json(&json!({"format": "dng", "dngCompression": c})); + let mut out = Vec::new(); + let mut write = |_: &str, b: &[u8]| { + out = b.to_vec(); + Ok(()) + }; + crate::export::export_batch(&mut s, &[id], &o, &crate::export::Destination { dir: "x".into(), exact: None }, &mut write, &|_| false).unwrap(); + let raw = lightcraft_raw::decode(&out).unwrap_or_else(|e| panic!("{c}: {e}")); + assert!(raw.width > 1000, "{c}"); + sizes.push((c, out.len())); + } + assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}"); +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d20584361..20ad7d527 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -559,6 +559,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { use lightcraft_engine::export::TiffCompression as Z; choices(ui, "Compression", "exportTiffCompression", &[(Z::None, "None"), (Z::Lzw, "LZW"), (Z::Deflate, "ZIP")], &mut opts.tiff_compression); } + if opts.format == F::Dng { + use lightcraft_engine::export::DngCompression as Z; + choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None")], &mut opts.dng_compression); + } field(ui, "File name", |ui| { ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq} · {date}").desired_width(f32::INFINITY)) }); diff --git a/docs/parity.md b/docs/parity.md index 50f7bd004..ad097f8f4 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -39,7 +39,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | | Q. HDR (HDR) | 0 | 0 | 5 | 0 | — | — | | R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) | -| S. Export (EXP) | 15 | 0 | 3 | 0 | 7/7 (100%) | 7/7 (100%) | +| S. Export (EXP) | 15 | 1 | 2 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | @@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 79 | 2 | 4 | 8 | 46/47 (98%) | 22/23 (96%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 22 | 10 | 47 | 9 | — | 18/21 (86%) | -| **Total** | 370 | 25 | 105 | 37 | 193/198 (97%) | 132/144 (92%) | +| **Total** | 370 | 26 | 104 | 37 | 193/198 (97%) | 132/144 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **76.5%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.4% of 144 · P2 32.3% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **76.6%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.4% of 144 · P2 32.6% of 158. ## Top gaps @@ -420,7 +420,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`: `{name}`, `{seq}`, `{date}`; `startNumber`) | free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker) | folder picker on the desktop; existing files get `-2`, `-3`… by default | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | -| LR-EXP-DNGOPT | DNG options | P2 | ⬜ | | | +| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`) | file copied byte for byte, sidecar named after the output | | LR-EXP-PHOTOS | Export to the system photo library | P2 | ⬜ | | | | LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs` | a 16-bit TIFF `-Edit` copy with the edits (Adobe RGB / ProPhoto / P3 / sRGB) next to the original, added stacked on top of it and opened in the editor set in Settings ▸ General (or the system default) |