diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 29e99946a..e21c5d2de 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -125,6 +125,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 1fc61f06c..2cb4ba82f 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1710,7 +1710,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 db33b82df..da2c38d7d 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -951,6 +951,56 @@ fn dng_export_compression_choices() { assert!(sizes[3].1 < sizes[0].1 / 2, "lossy is much smaller than lossless: {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 57b617056..5d689b833 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -391,7 +391,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 |