From 17e05759b4c5c50a79a46c2f90c9cfc687375079 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 18:08:53 +0200 Subject: [PATCH] DNG export and Convert / Copy as DNG write the colour a raw without matrices opens with (fixes #1745) --- crates/engine/src/camera_preview.rs | 17 ++++++++++ crates/engine/src/cmd/convert.rs | 3 +- crates/engine/src/export.rs | 3 +- crates/engine/src/tests_xmp.rs | 50 +++++++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index c4824997c..1f3598a99 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -123,6 +123,23 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option, path Some(r) => r(path)?, None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?, }; - let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?; + let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?; + crate::camera_preview::with_starting_colour(&mut raw, &bytes); drop(bytes); let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() }) .map_err(|e| e.to_string())?; diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 403cb71f6..8309caf7e 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1785,7 +1785,8 @@ impl PreparedExport { 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}"))?; + let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?; + crate::camera_preview::with_starting_colour(&mut raw, &bytes); drop(bytes); let compression = match dng { DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 }, diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 58d0d7a14..280b3d053 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -950,6 +950,56 @@ fn dng_export_compression_choices() { assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}"); } +/// Issue #1745: the DNG of a raw without colour matrices of its own (a Nikon Z 9 NEF) had no `ColorMatrix1`, and +/// other raw converters refused it. Export as DNG and Convert / Copy as DNG now write the colour the raw opens with: +/// the measured matrices of a camera that has them (Z f), the neutral fallback of one that doesn't (Z 9, whose +/// look is fitted to its JPEG when there is one). +#[test] +fn dngs_of_raws_without_colour_matrices_carry_the_colour_they_open_with() { + use lightcraft_raw::color::{as_shot_white_xy, camera_transform, has_matrix}; + let dir = temp_dir("dng-colour"); + let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]]; + let render = |t: &lightcraft_raw::color::CameraTransform, c: [f64; 3]| { + t.matrix.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2()) + }; + for (model, measured) in [("NIKON Z f", true), ("NIKON Z 9", false)] { + let metadata = lightcraft_meta::Metadata { make: Some("NIKON CORPORATION".into()), model: Some(model.into()), ..Default::default() }; + let mut raw = lightcraft_raw::decode(&synthetic_dng_with(None, metadata)).unwrap(); + // a vendor raw's colour: a white balance, no matrices + raw.color = lightcraft_raw::ColorData { as_shot_neutral: Some([0.5, 1.0, 0.7]), ..Default::default() }; + let bytes = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let path = dir.join(format!("{model}.dng")); + std::fs::write(&path, &bytes).unwrap(); + let mut opened = lightcraft_raw::decode(&bytes).unwrap(); + assert!(!has_matrix(&opened.color)); + let (_, want, look) = crate::camera_preview::starting_colour(&mut opened, &bytes); + assert_eq!((want.matrix_is_fallback, look.is_none()), (!measured, true), "{model}"); + + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let o = crate::export::ExportOptions::from_json(&json!({"format": "dng"})); + let mut exported = Vec::new(); + let mut write = |_: &str, b: &[u8]| { + exported = 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 converted = crate::cmd::convert::write_dng_with(None, &path.to_string_lossy(), String::new()).unwrap(); + for (how, dng) in [("export", exported), ("convert", std::fs::read(&converted).unwrap())] { + let back = lightcraft_raw::decode(&dng).unwrap(); + assert!(back.color.color_matrix[0].is_some() && back.color.illuminant[0] != 0, "{model} {how}: {:?}", back.color); + let got = camera_transform(&back, as_shot_white_xy(&back)); + for c in samples { + let (a, b) = (render(&want, c), render(&got, c)); + assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3), "{model} {how} {c:?}: {a:?} vs {b:?}"); + } + } + std::fs::remove_file(&converted).unwrap(); + } + let _ = std::fs::remove_dir_all(&dir); +} + fn write_jpeg(path: &Path, capture: Option<&str>) { let (w, h) = (32usize, 24usize); let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 7) as u8, (i / w * 9) as u8, 90, 255]).collect(); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..4d6d1172a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -385,7 +385,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | -| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build | +| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU |