From f032d6e389caca82140769aa698308a255de0c61 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 10 Oct 2026 20:06:50 -0400 Subject: [PATCH] LR-PROF-CAMERACOLOR: read an untagged camera JPEG in the colour space the raw's EXIF states, for every make (#778) A Sony body shot in Adobe RGB writes ColorSpace Uncalibrated with InteropIndex R03 in the raw's EXIF and leaves the embedded JPEG untagged, so the look was fitted against the JPEG decoded as sRGB. The EXIF fallback now applies to all makes (the Nikon maker note still wins); DNGs are skipped, since their previews come from the converter. --- crates/engine/src/camera_preview.rs | 5 ++-- crates/engine/src/media.rs | 2 +- crates/raw/src/preview.rs | 31 ++++++++++++++++++----- crates/raw/src/vendor/nef.rs | 39 +++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index e14b1bd6..2c4d0156 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -22,8 +22,9 @@ use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTa /// that leave the fitted look alone. `RENDER_CACHE_VERSION` covers renders; this covers proxies. /// /// 1: the fit as of #499's follow-up; 2: Sony DRO (tone curve lowered to Sony's curve without DRO) -/// and the ILCE-7CR profile (#528, #583, #568, #616). -pub const LOOK_VERSION: u32 = 2; +/// and the ILCE-7CR profile (#528, #583, #568, #616); 3: the camera JPEG read in the colour space the raw's EXIF +/// states, for every make (#778). +pub const LOOK_VERSION: u32 = 3; #[derive(Clone, Debug)] pub(crate) struct CameraLook { diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6d5b548f..d863060e 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 42; +pub const RENDER_CACHE_VERSION: u64 = 43; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index d34fb4c6..8cf518fd 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -14,16 +14,35 @@ pub enum PreviewColorSpace { AdobeRgb, } -/// Nikon maker-note ColorSpace (0x001e: 1 = sRGB, 2 = Adobe RGB), per Nikon tag documentation. -/// This is a fallback only: a JPEG's own ICC/EXIF colour declaration takes precedence. +/// The colour space the raw says its embedded JPEG is in, for a JPEG that declares none itself (no ICC profile, +/// no EXIF), first available first: the Nikon maker-note ColorSpace (0x001e: 1 = sRGB, 2 = Adobe RGB, per Nikon +/// tag documentation); the raw's own EXIF ColorSpace (0xA001: 1 = sRGB), or Uncalibrated (0xFFFF) with the +/// Interoperability IFD's InteropIndex "R03", the DCF option file for Adobe RGB (as a Sony body shot in Adobe RGB +/// writes, issue #778; per the Exif and DCF specifications), not for a DNG. This is a fallback only: a JPEG's own ICC/EXIF colour +/// declaration takes precedence. pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { let tiff = Tiff::parse(bytes).ok()?; - let make = tiff.ifds.first()?.string(t::MAKE)?; - if !make.to_ascii_uppercase().starts_with("NIKON") { + let make = tiff.ifds.first()?.string(t::MAKE).unwrap_or_default(); + let exif = tiff.exif()?; + if make.to_ascii_uppercase().starts_with("NIKON") + && let Some(space) = nikon_color_space(bytes, &tiff, exif, &make) + { + return Some(space); + } + // a DNG's EXIF describes the original capture; its previews are rendered by the converter + if tiff.ifds.first()?.contains(t::DNG_VERSION) { return None; } - let e = tiff.exif()?.get(t::MAKER_NOTE)?; - let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?; + match exif.u16(0xA001)? { + 1 => Some(PreviewColorSpace::Srgb), + 0xFFFF if exif.interop.as_deref()?.bytes(0x0001)?.starts_with(b"R03") => Some(PreviewColorSpace::AdobeRgb), + _ => None, + } +} + +fn nikon_color_space(bytes: &[u8], tiff: &Tiff, exif: &Ifd, make: &str) -> Option { + let e = exif.get(t::MAKER_NOTE)?; + let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, make)?; match mn.ifd.u16(0x001e)? { 1 => Some(PreviewColorSpace::Srgb), 2 => Some(PreviewColorSpace::AdobeRgb), diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index c7b99e93..3177c684 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -251,6 +251,45 @@ mod tests { assert_eq!(crate::embedded_preview_color_space(b"not a TIFF"), None); } + /// Issue #778: any make's raw falls back to its EXIF ColorSpace, with Uncalibrated + InteropIndex "R03" read as + /// Adobe RGB; the Nikon note wins; a DNG is left alone. + #[test] + fn preview_color_space_falls_back_to_the_exif_color_space() { + let file = |make: &str, cs: u16, interop: Option<&str>, note: Option, dng: bool| { + let mut exif = IfdBuilder::new(); + exif.set(0xA001, Value::Short(vec![cs])); + if let Some(i) = interop { + let mut io = IfdBuilder::new(); + io.set(0x0001, Value::Ascii(i.into())); + exif.set_child(t::INTEROP_IFD, io); + } + if let Some(v) = note { + let mut mn = IfdBuilder::new(); + mn.set(0x001e, Value::Short(vec![v])); + let mut bytes = b"Nikon".to_vec(); + bytes.extend(TiffWriter::new(ByteOrder::Little, false).write(&[mn]).unwrap()); + exif.set(t::MAKER_NOTE, Value::Undefined(bytes)); + } + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + if dng { + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + } + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + }; + use crate::PreviewColorSpace::{AdobeRgb, Srgb}; + let f = crate::embedded_preview_color_space; + assert_eq!(f(&file("SONY", 0xFFFF, Some("R03"), None, false)), Some(AdobeRgb)); + assert_eq!(f(&file("SONY", 1, Some("R98"), None, false)), Some(Srgb)); + assert_eq!(f(&file("SONY", 0xFFFF, Some("R98"), None, false)), None); + assert_eq!(f(&file("SONY", 0xFFFF, None, None, false)), None); + assert_eq!(f(&file("Canon", 0xFFFF, Some("R03"), None, false)), Some(AdobeRgb)); + assert_eq!(f(&file("NIKON CORPORATION", 1, Some("R98"), Some(2), false)), Some(AdobeRgb)); + assert_eq!(f(&file("NIKON CORPORATION", 0xFFFF, Some("R03"), None, false)), Some(AdobeRgb)); + assert_eq!(f(&file("Canon", 0xFFFF, Some("R03"), None, true)), None); + } + #[test] fn nikon_crop_area_preserves_sensor_origin_and_rejects_invalid_rectangles() { let (w, h) = (32, 24);