diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 1fc172548..445374eb5 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -79,7 +79,8 @@ use tone::{HighlightCheck, ToneFit, fit_tone, fit_tone_conditional, highlights}; /// a tone-only fallback for near-black scenes with too few colour pairs (#1587); DNG tone fitting uses a /// lens-corrected proxy when the file carries lens opcodes; validated A7CR distortion, including the lens data /// stored with each smart preview. -pub const LOOK_VERSION: u32 = 12; +/// 13: the Canon CR3/CR2 camera JPEG read in the colour space of the Canon maker note (#2654). +pub const LOOK_VERSION: u32 = 13; #[derive(Clone, Debug)] pub(crate) struct CameraLook { diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index e1b33cc71..dc1f2c864 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -320,9 +320,13 @@ fn build(s: &mut Session, p: &Value) -> Result { type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option, bool); /// DNG tone fits also go stale, although DNGs keep their own matrix and Kelvin white-balance scale. +/// So do preview-only raws: their proxy is the embedded JPEG, read in the colour space the raw +/// declares, which a look version bump can change too (#2654). #[cfg(not(target_arch = "wasm32"))] fn preview_fitted_look(photo: &lightcraft_catalog::Photo) -> bool { - photo.fitted_look() || (photo.develops_raw() && photo.format.eq_ignore_ascii_case("DNG")) + photo.fitted_look() + || (photo.develops_raw() && photo.format.eq_ignore_ascii_case("DNG")) + || (photo.kind == lightcraft_catalog::MediaKind::Raw && photo.preview_only.is_some()) } /// Held while one proxy is checked and (re)written, so Build Smart Previews and the opening look @@ -691,6 +695,22 @@ mod tests { use crate::Session; use crate::media::SourceRef; + // Given a preview-only raw, its proxy goes stale with the look version: the embedded JPEG's colour + // space can change with it (#2654); a rendered photo's proxy does not + #[test] + fn preview_only_raws_follow_the_look_version() { + use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source}; + let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.cr3".into() }, "x.cr3", "CR3", 6000, 4000, "2026-10-23T00:00:00"); + ph.kind = MediaKind::Raw; + assert!(super::preview_fitted_look(&ph), "a developed CR3"); + ph.set_preview_only(Some("unsupported".into())); + assert!(super::preview_fitted_look(&ph), "a preview-only CR3"); + ph.kind = MediaKind::Image; + ph.format = "JPG".into(); + ph.set_preview_only(None); + assert!(!super::preview_fitted_look(&ph), "a JPEG"); + } + // Given a photo's preview jobs, the grid thumbnail is made from the source the view decoded, not // by reading the file again (here: a thumbnail job pointing at a file that is not there) #[test] diff --git a/crates/engine/src/media/source.rs b/crates/engine/src/media/source.rs index 8f6a9c28c..8490d2ee2 100644 --- a/crates/engine/src/media/source.rs +++ b/crates/engine/src/media/source.rs @@ -1,7 +1,7 @@ use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 70; +pub const RENDER_CACHE_VERSION: u64 = 71; /// 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 2047174da..89a5ea48a 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -16,12 +16,13 @@ pub enum PreviewColorSpace { /// 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 +/// tag documentation); for a Canon CR2 or CR3, the Canon maker-note ColorSpace (0x00b4: 1 = sRGB, 2 = Adobe RGB); +/// 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 Ok(tiff) = Tiff::parse(bytes) else { return cr3_color_space(bytes) }; let make = tiff.ifds.first()?.string(t::MAKE).unwrap_or_default(); let exif = tiff.exif()?; if make.to_ascii_uppercase().starts_with("NIKON") @@ -33,6 +34,13 @@ pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { if tiff.ifds.first()?.contains(t::DNG_VERSION) { return None; } + if make.to_ascii_uppercase().starts_with("CANON") + && let Some(e) = exif.get(t::MAKER_NOTE) + && let Some(mn) = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make) + && let Some(space) = canon_color_space(&mn.ifd) + { + return Some(space); + } match exif.u16(0xA001)? { 1 => Some(PreviewColorSpace::Srgb), 0xFFFF if exif.interop.as_deref()?.bytes(0x0001)?.starts_with(b"R03") => Some(PreviewColorSpace::AdobeRgb), @@ -40,6 +48,29 @@ pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { } } +/// A CR3 keeps its EXIF and maker note in `CMT2`/`CMT3` TIFF blocks and its JPEGs carry no EXIF; a body shot in +/// Adobe RGB writes ColorSpace Uncalibrated (0xFFFF) with no Interoperability IFD (issue #2654), so the Canon +/// maker note says which, else an EXIF ColorSpace of sRGB is taken. +fn cr3_color_space(bytes: &[u8]) -> Option { + let cr3 = lightcraft_meta::cr3::parse_cr3(bytes)?; + let first = |i: usize| cr3.cmt[i].and_then(|b| Tiff::parse(b).ok()).and_then(|t| t.ifds.into_iter().next()); + if let Some(space) = first(2).and_then(|mn| canon_color_space(&mn)) { + return Some(space); + } + (first(1)?.u16(0xA001)? == 1).then_some(PreviewColorSpace::Srgb) +} + +/// The Canon maker-note ColorSpace (0x00b4: 1 = sRGB, 2 = Adobe RGB, per ExifTool's published Canon tag names). +/// A Canon body shot in Adobe RGB writes EXIF ColorSpace Uncalibrated (0xFFFF) with InteropIndex "R98" in a CR2 +/// and no Interoperability IFD in a CR3, so the EXIF alone does not say Adobe RGB (issue #2654). +fn canon_color_space(note: &Ifd) -> Option { + match note.u16(0x00b4)? { + 1 => Some(PreviewColorSpace::Srgb), + 2 => 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)?; diff --git a/crates/raw/src/preview/tests.rs b/crates/raw/src/preview/tests.rs index 2ee098414..80e3fdb52 100644 --- a/crates/raw/src/preview/tests.rs +++ b/crates/raw/src/preview/tests.rs @@ -282,6 +282,72 @@ fn cr3_prefers_the_full_size_jpeg_track() { assert_eq!(embedded_preview(&file).unwrap(), small); } +/// A minimal CR3 whose Canon box holds `CMT2` (EXIF ColorSpace `exif`) and `CMT3` (maker-note ColorSpace `note`). +fn cr3_with_color_space(exif: Option, note: Option) -> Vec { + let bx = |kind: &[u8; 4], body: &[u8]| -> Vec { [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat() }; + let block = |tag: u16, v: Option| { + let ifd = + v.map_or_else(|| IfdBuilder::new().with(t::ISO_SPEED, Value::Short(vec![100])), |v| IfdBuilder::new().with(tag, Value::Short(vec![v]))); + TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[ifd]).unwrap() + }; + let cmt1 = + TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[IfdBuilder::new().with(t::MAKE, Value::Ascii("Canon".into()))]).unwrap(); + let canon_uuid = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; + let boxes = [bx(b"CMT1", &cmt1), bx(b"CMT2", &block(0xA001, exif)), bx(b"CMT3", &block(0x00b4, note))].concat(); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &bx(b"uuid", &[&canon_uuid[..], &boxes].concat()))); + file +} + +/// A CR3 (not TIFF-based) says its preview's colour space in the Canon maker note (`CMT3` 0x00b4), else an +/// EXIF ColorSpace of sRGB in `CMT2`: an EOS R shot in Adobe RGB writes Uncalibrated with no interop (issue #2654). +#[test] +fn cr3_preview_colour_space_comes_from_the_canon_maker_note() { + use PreviewColorSpace::{AdobeRgb, Srgb}; + let space = |exif, note| embedded_preview_color_space(&cr3_with_color_space(exif, note)); + assert_eq!(space(Some(0xFFFF), Some(2)), Some(AdobeRgb)); + assert_eq!(space(Some(1), Some(1)), Some(Srgb)); + assert_eq!(space(Some(1), Some(2)), Some(AdobeRgb), "the maker note wins"); + assert_eq!(space(Some(1), None), Some(Srgb), "no maker-note value: the EXIF sRGB"); + assert_eq!(space(Some(0xFFFF), Some(0xFFFF)), None, "neither says"); + assert_eq!(space(Some(0xFFFF), None), None); + assert_eq!(space(None, None), None); + let f = cr3_with_color_space(Some(0xFFFF), Some(2)); + for n in 0..f.len() { + let _ = embedded_preview_color_space(&f[..n]); + } +} + +/// A CR2 shot in Adobe RGB writes EXIF ColorSpace Uncalibrated with InteropIndex "R98", not "R03": the Canon +/// maker note (0x00b4) says Adobe RGB (issue #2654). Another make's note is not read for it. +#[test] +fn cr2_preview_colour_space_comes_from_the_canon_maker_note() { + use PreviewColorSpace::{AdobeRgb, Srgb}; + let cr2 = |make: &str, exif_space: u16, note: Option| { + let mut exif = IfdBuilder::new(); + exif.set(0xA001, Value::Short(vec![exif_space])); + exif.set_child(t::INTEROP_IFD, IfdBuilder::new().with(0x0001, Value::Undefined(b"R98".to_vec()))); + if let Some(v) = note { + // a plain one-entry IFD, its SHORT value inline + let mut mn = 1u16.to_le_bytes().to_vec(); + mn.extend([0x00b4u16.to_le_bytes(), 3u16.to_le_bytes()].concat()); + mn.extend(1u32.to_le_bytes()); + mn.extend([v.to_le_bytes(), [0, 0]].concat()); + mn.extend(0u32.to_le_bytes()); + exif.set(t::MAKER_NOTE, Value::Undefined(mn)); + } + let mut ifd0 = IfdBuilder::new().with(t::MAKE, Value::Ascii(make.into())); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[ifd0]).unwrap() + }; + assert_eq!(embedded_preview_color_space(&cr2("Canon", 0xFFFF, Some(2))), Some(AdobeRgb)); + assert_eq!(embedded_preview_color_space(&cr2("Canon", 1, Some(1))), Some(Srgb)); + assert_eq!(embedded_preview_color_space(&cr2("Canon", 1, None)), Some(Srgb), "no maker note: the EXIF"); + assert_eq!(embedded_preview_color_space(&cr2("Canon", 0xFFFF, None)), None, "R98 is not Adobe RGB's R03"); + assert_eq!(embedded_preview_color_space(&cr2("Canon", 0xFFFF, Some(0xFFFF))), None); + assert_eq!(embedded_preview_color_space(&cr2("Leica", 0xFFFF, Some(2))), None, "only a Canon note"); +} + /// A structurally complete DCT JPEG (no real image data): `nc` components, `n` entropy bytes. fn scan_jpeg(nc: u8, n: usize) -> Vec { let mut j = vec![0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00];