diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index d47c135a..767ed249 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 8b45ff51..c2485b2a 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 = 50; +pub const RENDER_CACHE_VERSION: u64 = 51; /// 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 f97a48f3..340f752a 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -279,6 +279,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);