From 39b56219f0f0564da2d5566dd8d2dc2f96553bf4 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Wed, 28 Oct 2026 13:45:46 -0400 Subject: [PATCH] raw: read Canon CR3/CR2 preview colour space from the maker note (#2654) The default camera look is fitted to the raw's embedded JPEG, decoded in the colour space embedded_preview_color_space reports. That function parsed the file as TIFF, which fails on a CR3 (ISO BMFF), so a CR3 shot in Adobe RGB was read as sRGB and the look was fitted to a desaturated target. A CR2 shot in Adobe RGB parses, but its EXIF says ColorSpace Uncalibrated with InteropIndex "R98" rather than Adobe RGB's "R03", so it was read as sRGB too. Read the Canon maker-note ColorSpace (0x00b4: 1 = sRGB, 2 = Adobe RGB): for a CR3 from its CMT3 block, else an EXIF ColorSpace of sRGB from CMT2; for a CR2 from the EXIF maker note, before the EXIF fallback. LOOK_VERSION (13) and RENDER_CACHE_VERSION (71) are bumped so cached smart previews and thumbnails are rebuilt with the new look. Smart previews of preview-only raws now follow LOOK_VERSION too, since their proxy is the embedded JPEG read in the declared colour space. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM --- crates/engine/src/camera_preview.rs | 3 +- crates/engine/src/cmd/previews.rs | 22 +++++++++- crates/engine/src/media/source.rs | 2 +- crates/raw/src/preview.rs | 39 +++++++++++++++-- crates/raw/src/preview/tests.rs | 66 +++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 7 deletions(-) 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];