mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 14:07:34 -06:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e2d18b5fb9
commit
39b56219f0
@@ -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 {
|
||||
|
||||
@@ -320,9 +320,13 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>, 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]
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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<PreviewColorSpace> {
|
||||
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<PreviewColorSpace> {
|
||||
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<PreviewColorSpace> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<PreviewColorSpace> {
|
||||
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<PreviewColorSpace> {
|
||||
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<PreviewColorSpace> {
|
||||
let e = exif.get(t::MAKER_NOTE)?;
|
||||
let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, make)?;
|
||||
|
||||
@@ -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<u16>, note: Option<u16>) -> Vec<u8> {
|
||||
let bx = |kind: &[u8; 4], body: &[u8]| -> Vec<u8> { [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat() };
|
||||
let block = |tag: u16, v: Option<u16>| {
|
||||
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<u16>| {
|
||||
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<u8> {
|
||||
let mut j = vec![0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00];
|
||||
|
||||
Reference in New Issue
Block a user