diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 82d4c57d6..d446d1801 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1149,1787 +1149,4 @@ impl crate::Session { } #[cfg(test)] -pub(crate) mod tests { - /// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the - /// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides - /// them; an exported `travel|Italy|Rome` comes back as itself. - #[test] - fn a_lens_without_a_name_is_shown_by_its_range() { - // a Nikon DSLR raw: no LensModel, the range from the maker note - let m = lightcraft_meta::Metadata { lens_spec: Some([18.0, 135.0, 3.5, 5.6]), ..Default::default() }; - assert_eq!(super::meta_of(&m).0.lens, "18.0-135.0 mm f/3.5-5.6"); - let named = lightcraft_meta::Metadata { - lens_model: Some("NIKKOR Z 24-70mm f/4 S".into()), - lens_spec: Some([24.0, 70.0, 4.0, 4.0]), - ..Default::default() - }; - assert_eq!(super::meta_of(&named).0.lens, "NIKKOR Z 24-70mm f/4 S"); - assert_eq!(super::meta_of(&lightcraft_meta::Metadata::default()).0.lens, ""); - } - - #[test] - fn imported_keywords_keep_their_hierarchy() { - let m = lightcraft_meta::Metadata { - keywords: vec!["Rome".into(), "Italy".into(), "travel".into(), "beach".into()], - hierarchical_keywords: vec!["travel|Italy|Rome".into()], - ..Default::default() - }; - assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]); - } - - #[test] - fn psd_probe_reads_embedded_capture_time_gps_and_title() { - let capture = lightcraft_meta::DateTime::parse_iso("2023-05-14T14:40:39").unwrap(); - let metadata = lightcraft_meta::Metadata { - capture_time: Some(capture), - gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), - title: Some("PSD metadata".into()), - ..Default::default() - }; - let bytes = crate::tests_xmp::synthetic_psd_with_metadata(&metadata); - let probe = probe_bytes("metadata.psd", &bytes).unwrap(); - assert_eq!(probe.captured.as_deref(), Some("2023-05-14T14:40:39")); - assert_eq!(probe.meta.gps, Some((45.0, 7.0))); - assert_eq!(probe.meta.title, "PSD metadata"); - } - - use super::*; - use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; - - /// Which white-balance scale a raw gets (issue #730): a file with matrices of its own is absolute from them; a - /// fitted-look file is absolute through its camera's measured matrices, which only read the as-shot - /// multipliers as Kelvin; any other raw stays relative (6500 / 0 = as shot). The catalog's decision from the - /// format and camera string agrees. - #[test] - fn white_balance_scale_follows_the_files_matrices_then_the_cameras() { - use lightcraft_raw::{ColorData, RawFormat}; - let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; - let mul = Some([2.2f32, 1.0, 1.6]); - let d65 = lightcraft_color::D65; - // a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe - let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3")); - assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some()); - let (t, tint) = a7m3.as_shot(d65); - assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}"); - assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3")); - // the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65) - let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3")); - assert!(bare.legacy_relative && bare.scale_white.is_none()); - assert_eq!(bare.as_shot(d65), { - let (t, tint) = xy_to_temp_tint(d65); - (t.round(), tint.round()) - }); - // a camera without measured matrices: relative, whatever the multipliers - let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2")); - assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none()); - assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0)); - assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2")); - // a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as - // 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65) - let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; - let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ - (2500.0, g(1336.0, 3672.0)), - (3200.0, g(1720.0, 2620.0)), - (4500.0, g(2256.0, 1880.0)), - (6000.0, g(2680.0, 1544.0)), - (8500.0, g(3136.0, 1300.0)), - ]); - let with = ColorData { kelvin_presets: presets, ..Default::default() }; - let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2")); - assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some()); - let (t, tint) = a99m2.as_shot(d65); - assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}"); - assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2")); - // a Canon body without matrices, through the presets its ColorData lists (PowerShot G1 X Mark III, a - // Daylight shot with the camera's white balance shifted towards blue: below Daylight's 5651 K) - let canon = lightcraft_raw::kelvin_presets::KelvinPresets::from_unordered([ - (5651.0, g(1418.0, 1357.0)), - (7505.0, g(1653.0, 1171.0)), - (6594.0, g(1533.0, 1259.0)), - (3174.0, g(963.0, 2016.0)), - (8529.0, g(1807.0, 1080.0)), - ]); - let with_canon = ColorData { kelvin_presets: canon, ..Default::default() }; - let g1x3 = WbScaleOf::new(RawFormat::Cr2, &with_canon, Some(g(1264.0, 1537.0)), &meta("Canon", "Canon PowerShot G1 X Mark III")); - assert!(!g1x3.relative && g1x3.scale_white.is_some()); - let (t, tint) = g1x3.as_shot(d65); - assert!((4300.0..5200.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}"); - assert!(!lightcraft_catalog::relative_wb_camera("CR2", "Canon Canon PowerShot G1 X Mark III")); - let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2")); - assert!(bare.legacy_relative && bare.scale_white.is_none()); - // presets in a file of a camera not listed: relative, as the catalog decides from the camera alone - let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1")); - assert!(other.relative && other.scale_white.is_none()); - let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default()); - assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0)); - // a file with matrices of its own (DNG): absolute from them, no matter whose camera - let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() }; - let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3")); - assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none()); - let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0); - assert_eq!(dng.as_shot(xy), (4200.0, 12.0)); - assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); - } - - /// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source - /// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative - /// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets - /// opened at 6113–7307 K / +115 where the ARWs show 3932–5268 K / +7. - #[test] - fn a_merged_dng_shows_the_white_balance_its_source_shows() { - use lightcraft_raw::{ColorData, RawFormat}; - let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; - let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; - let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ - (2500.0, g(1336.0, 3672.0)), - (3200.0, g(1720.0, 2620.0)), - (4500.0, g(2256.0, 1880.0)), - (6000.0, g(2680.0, 1544.0)), - (8500.0, g(3136.0, 1300.0)), - ]); - let with_presets = ColorData { kelvin_presets: presets, ..Default::default() }; - let cases = [ - (RawFormat::Arw, ColorData::default(), [2.5039f32, 1.0, 1.5078], meta("SONY", "ILCE-7M3")), - (RawFormat::Arw, with_presets, g(2500.0, 1668.0), meta("SONY", "ILCA-99M2")), - (RawFormat::Arw, ColorData::default(), [2.2, 1.0, 1.6], meta("SONY", "ILCE-7M2")), - (RawFormat::Rw2, ColorData::default(), [2.0, 1.0, 1.4], meta("Panasonic", "DC-G9")), - ]; - for (format, color, mul, meta) in cases { - let own = lightcraft_raw::color::as_shot_white_of(&color, Some(mul)); - let source = WbScaleOf::new(format, &color, Some(mul), &meta); - let t = lightcraft_raw::color::camera_transform_of(&color, own); - let dng = crate::camera_preview::dng_colour(&color, &source.dng_white(&color, own), &t, None).unwrap(); - let merged = WbScaleOf::new(RawFormat::Dng, &dng, None, &meta); - let shown = source.as_shot(own); - assert_eq!(merged.as_shot(lightcraft_raw::color::as_shot_white_of(&dng, None)), shown, "{:?}", meta.model); - } - } - - /// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need. - fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec { - let head = 160; - let mut f = vec![0u8; head]; - f.get_mut(..16).expect("head").copy_from_slice(b"FUJIFILMCCD-RAW "); - f.get_mut(84..88).expect("jpeg offset").copy_from_slice(&(head as u32).to_be_bytes()); - f.get_mut(88..92).expect("jpeg length").copy_from_slice(&(jpeg.len() as u32).to_be_bytes()); - f.extend_from_slice(jpeg); - f.extend(std::iter::repeat_n(0x33u8, tail)); - f - } - - /// A raw's preview is read from its header and its JPEG only. Every preview used to cost a - /// whole-file read, which on a 56 MB RAF over a network share is ten times the bytes and ten - /// times the wait (measured: 2.5 s local against 25.6 s over SMB, DSCF9492.RAF). The pixels the - /// quick path produces must equal the whole-file path's, exactly. - #[test] - fn preview_loading_reads_only_the_header_and_the_jpeg() { - let (w, h) = (48u32, 32u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 13 % 251) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - let file = raf_with_embedded_jpeg(&jpeg, 200_000); - let dir = crate::tests_xmp::temp_dir("preview-window"); - let path = dir.join("dscf0001.raf"); - std::fs::write(&path, &file).unwrap(); - let path = path.to_string_lossy().into_owned(); - let max_edge = 512; - - let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("it has a preview"); - assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded"); - assert!(read.header_path, "the header path is what answered"); - assert!(!read.planned, "a RAF pointer in the header needs no planner"); - assert_eq!(read.total, file.len() as u64); - assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}"); - assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len()); - - // The quick path and the whole-file path agree byte for byte. - let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview"); - let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); - assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); - assert_eq!(quick.data, whole.data, "byte for byte"); - - // A file with no locatable preview declines (the caller then reads it whole), a missing file errors. - let plain = dir.join("plain.jpg"); - std::fs::write(&plain, &jpeg).unwrap(); - assert!(preview_window(&plain.to_string_lossy(), max_edge).expect("a plain file reads").is_none()); - assert!(preview_window(&dir.join("nope.raf").to_string_lossy(), max_edge).is_err()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// What a fixture's IFD0 says about itself: the tag that makes `probe` call the file a raw, the - /// file's own orientation, and the EXIF colour space the container declares for its preview. - #[derive(Clone, Copy, Default)] - struct Fixture { - dng: bool, - sony: bool, - orientation: Option, - /// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB. - color_space: Option, - } - - /// A TIFF-family raw no header field points into: IFD0 is the JPEG-compressed preview strip (the - /// shape a DNG's `PreviewImage`, a CR2's or an ARW's preview takes), IFD1 a CFA "sensor" strip of - /// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than - /// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach. - fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec { - tiff_with_preview_strips(strip, &[], tail, what) - } - - /// [`tiff_with_preview_strip`] that also keeps the `smaller` JPEGs in sub-IFDs of IFD0, as an ARW keeps its - /// 1616 px preview beside the full-size one. - fn tiff_with_preview_strips(strip: &[u8], smaller: &[&[u8]], tail: usize, what: Fixture) -> Vec { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let mut preview = IfdBuilder::new(); - if what.dng { - preview.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); - } - if what.sony { - preview.set(t::MAKE, Value::Ascii("SONY".into())); - } - if let Some(o) = what.orientation { - preview.set(t::ORIENTATION, Value::Short(vec![o])); - } - if let Some(space) = what.color_space { - let mut exif = IfdBuilder::new(); - exif.set(0xA001, Value::Short(vec![space])); - if space == 0xFFFF { - let mut interop = IfdBuilder::new(); - interop.set(0x0001, Value::Undefined(b"R03".to_vec())); - exif.set_child(t::INTEROP_IFD, interop); - } - preview.set_child(t::EXIF_IFD, exif); - } - preview.set(t::COMPRESSION, Value::Short(vec![7])); - preview.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); - preview.set(t::IMAGE_WIDTH, Value::Long(vec![48])); - preview.set(t::IMAGE_LENGTH, Value::Long(vec![32])); - preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] }); - for jpeg in smaller { - let mut sub = IfdBuilder::new(); - sub.set(t::COMPRESSION, Value::Short(vec![7])); - sub.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); - sub.set(t::IMAGE_WIDTH, Value::Long(vec![48])); - sub.set(t::IMAGE_LENGTH, Value::Long(vec![32])); - sub.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![jpeg.to_vec()] }); - preview.add_sub_ifd(sub); - } - let mut raw = IfdBuilder::new(); - raw.set(t::COMPRESSION, Value::Short(vec![1])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000])); - raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] }); - TiffWriter::default().write(&[preview, raw]).expect("the fixture writes") - } - - /// A raw whose preview only its tags locate — a Canon CR3's JPEG track, a TIFF raw whose preview - /// tag sits past the prefix — is read in part too: `sparse::preview_plan` names the ranges - /// `embedded_preview` reads, so the preview strip is read and the sensor strip is not. The pixels - /// must equal the whole-file path's, exactly; a file the planner cannot describe must decline - /// rather than guess, so the caller keeps reading it whole. - #[test] - fn preview_loading_plans_the_ranges_it_needs() { - let (w, h) = (48u32, 32u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 29 % 241) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - let file = tiff_with_preview_strip(&jpeg, 400_000, Fixture { dng: true, ..Default::default() }); - let dir = crate::tests_xmp::temp_dir("preview-plan"); - let path = dir.join("preview.dng"); - std::fs::write(&path, &file).unwrap(); - let path = path.to_string_lossy().into_owned(); - let max_edge = 512; - - let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("the plan locates its preview"); - assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded"); - assert!(read.planned, "the planner is what answered"); - assert!(read.header_path, "and it is still a bounded read"); - assert_eq!(read.total, file.len() as u64); - assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?} of {} bytes", file.len()); - assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len()); - - // The plan path and the whole-file path agree byte for byte. - let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview"); - let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); - assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); - assert_eq!(quick.data, whole.data, "byte for byte"); - - // The same structure without the tag that makes it a raw: the planner declines, and the - // caller's whole-file read is what answers (no guessing at what a file might hold). - let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default()); - std::fs::write(&path, ¬_a_raw).unwrap(); - assert!(preview_window(&path, max_edge).expect("the file reads").is_none(), "an unknown container falls back"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A raw that keeps several JPEGs is shown, on the plan route as on the whole-file path, from the smallest one that - /// covers the box (`embedded_preview_covering`). The plan route used to hand on the largest whatever the size: an - /// A7R VI ARW's 9984 × 6656 JPEG for a 512 px thumbnail instead of its 1616 × 1080 one. - #[test] - fn preview_loading_plans_the_smallest_covering_jpeg() { - let jpeg = |w: u32, h: u32| { - let rgb: Vec = (0..w * h * 3).map(|i| (i * 31 % 247) as u8).collect(); - encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&rgb)), 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap() - }; - let (big, small) = (jpeg(96, 64), jpeg(48, 32)); - assert!(big.len() > small.len(), "the full-size JPEG is the largest"); - let file = tiff_with_preview_strips(&big, &[&small], 400_000, Fixture { dng: true, ..Default::default() }); - let dir = crate::tests_xmp::temp_dir("preview-plan-covering"); - let path = dir.join("two-previews.dng"); - std::fs::write(&path, &file).unwrap(); - let path = path.to_string_lossy().into_owned(); - - for (edge, want, which) in [(48, &small, "small"), (49, &big, "full-size"), (usize::MAX, &big, "full-size")] { - let (header, window, read) = preview_window(&path, edge).expect("the file reads").expect("the plan locates its preview"); - assert!(read.planned, "the planner is what answered"); - assert!(&window == want, "a {edge} px box takes the {which} JPEG"); - let quick = quick_image_srgb(&header, &window, read.adobe_rgb, edge).expect("the window holds a decodable preview"); - let whole = embedded_preview_srgb(&file, edge).expect("the whole file decodes too"); - assert_eq!( - (quick.width, quick.height, quick.data), - (whole.width, whole.height, whole.data), - "{edge} px: the plan and the whole file agree" - ); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// A file whose preview the quick path could not show the way the whole-file path does is left to - /// the whole file. Here the orientation tag sits in an IFD0 past the bounded header (its chunk is - /// bigger than that header) and the preview JPEG carries none of its own, so a window read would - /// come out unrotated where the whole-file path rotates it — the plan must decline instead of - /// handing on a wrong picture. - #[test] - fn preview_loading_declines_an_orientation_only_the_whole_file_shows() { - let (w, h) = (48u32, 32u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 17 % 239) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - // the strip (what the IFD's chunk covers) is padded past the plan's header, which is what puts - // IFD0 out of its reach; `embedded_preview` trims back to the JPEG's end-of-image - let mut strip = jpeg.clone(); - strip.extend(std::iter::repeat_n(0u8, PLAN_PREFIX as usize)); - let file = tiff_with_preview_strip(&strip, 400_000, Fixture { dng: true, orientation: Some(6), ..Default::default() }); - let dir = crate::tests_xmp::temp_dir("preview-plan-orientation"); - let path = dir.join("rotated.dng"); - std::fs::write(&path, &file).unwrap(); - let path = path.to_string_lossy().into_owned(); - - assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation"); - assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see"); - assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own"); - assert!(preview_window(&path, usize::MAX).expect("the file reads").is_none(), "the whole-file path keeps it"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A container can declare what its preview JPEG is in, and the whole-file path applies that - /// declaration (`decode_raw_preview`). The planned path reads it too and the quick decode applies - /// it, so a container that declares Adobe RGB (a Sony α1's ARW) is read in part like any other and - /// shows the same pixels as the whole-file path (issue #1440; it used to be read whole) — and one - /// that declares sRGB (what most bodies write) keeps the fast path as before. - #[test] - fn preview_loading_applies_a_container_colour_space() { - use lightcraft_raw::PreviewColorSpace; - let (w, h) = (48u32, 32u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - let dir = crate::tests_xmp::temp_dir("preview-plan-space"); - let path = dir.join("space.arw"); - let path = path.to_string_lossy().into_owned(); - - let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() }); - assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB"); - std::fs::write(&path, &adobe).unwrap(); - let (header, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("the plan answers for Adobe RGB too"); - assert!(read.planned && read.adobe_rgb, "{read:?}"); - assert!(read.fraction() < 0.5, "the sensor strip is not read: {read:?}"); - let quick = quick_image_srgb(&header, &window, read.adobe_rgb, 512).expect("the window decodes"); - let whole = embedded_preview_srgb(&adobe, 512).expect("the whole file decodes too"); - assert_eq!(quick.data, whole.data, "the space is applied as the whole-file path applies it"); - let plain = quick_image_srgb(&header, &window, false, 512).expect("the window decodes"); - assert_ne!(plain.data, whole.data, "and it changes the pixels (the fixture's colours are saturated enough)"); - - let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() }); - assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB"); - std::fs::write(&path, &srgb).unwrap(); - let (_, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes"); - assert!(read.planned && !read.adobe_rgb, "the plan still answers"); - assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG"); - } - - /// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as - /// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time. - #[test] - fn undated_files_take_the_file_date() { - let dir = std::env::temp_dir().join(format!("lightcraft-undated-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let rgb = vec![128u8; 16 * 16 * 3]; - let img = EncodeImage::new(16, 16, 3, Samples::U8(&rgb)); - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); - let path = dir.join("export.jpg"); - std::fs::write(&path, &jpeg).unwrap(); - let path = path.to_string_lossy().to_string(); - let mtime = std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); - let want = crate::import::civil(mtime as i64); - let (_, probe) = fs_hooks(); - let info = probe(&path).unwrap(); - assert_eq!(info.captured, None, "the file itself has no date"); - assert_eq!(info.file_modified.as_deref(), Some(want.as_str())); - let mut s = crate::Session::new().with_fs(); - let r = crate::import::import(&mut s, &[path], crate::import::ImportMode::Add).unwrap(); - let id = lightcraft_catalog::PhotoId(r.imported[0]); - assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some(want.as_str())); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: - /// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated - /// file or one that isn't an image. - #[test] - fn image_probe_reads_headers_like_the_decode_did() { - let (w, h) = (300u32, 200u32); - let rgb: Vec = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect(); - let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); - for o in [None, Some(1), Some(6), Some(3), Some(8)] { - let exif = o.map(lightcraft_codecs::exif::minimal_exif); - let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() }; - let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap(); - let png = lightcraft_codecs::encode_png(&img, &meta).unwrap(); - for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] { - let p = probe_bytes(name, bytes).unwrap(); - // what the probe computed from a decode before - let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap(); - let swap = Orientation::from_exif(d.orientation).swaps_axes(); - let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) }; - assert_eq!((p.width, p.height), want, "{name} {o:?}"); - assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}"); - assert_eq!(p.kind, MediaKind::Image); - assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" }); - assert_eq!(p.file_size, bytes.len() as u64); - assert!(p.content_hash.is_some()); - assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted"); - } - } - assert!(probe_bytes("x.jpg", b"not an image").is_err()); - } - - /// Issue #1212: a transparent PNG develops on white (what renders, previews, exports and contact sheets - /// start from), not on the black its transparent pixels store. - #[test] - fn transparent_png_develops_on_white() { - let rgba = [200, 40, 40, 255, 0, 0, 0, 0]; - let png = lightcraft_codecs::encode_png(&EncodeImage::new(2, 1, 4, Samples::U8(&rgba)), &EncodeMeta::default()).unwrap(); - let (img, _) = load_bytes(&png, 64).unwrap(); - assert!(img.get(0, 0)[0] > 0.3 && img.get(0, 0)[1] < 0.1, "{:?}", img.get(0, 0)); - assert!(img.get(1, 0).iter().all(|&c| (c - 1.0).abs() < 1e-4), "{:?}", img.get(1, 0)); - } - - /// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample - /// of a block on a grey surface must not become green speckles (the block's green used to take the - /// clip level, which highlight reconstruction then kept). - #[test] - fn coarse_binning_keeps_specular_points_neutral() { - let (w, h) = (256usize, 192usize); - // grey after the file's white balance (as-shot neutral 0.5 : 1 : 0.7), brighter to the right - let neutral = [0.5f32, 1.0, 0.7]; - let cfa = lightcraft_raw::Cfa::bayer("RGGB").unwrap(); - let mut data: Vec = (0..w * h) - .map(|i| { - let (x, y) = (i % w, i / w); - let v = (0.3 + 0.2 * x as f32 / w as f32) * neutral[cfa.color_at(x, y).min(2) as usize]; - (256.0 + v * (16000.0 - 256.0)).round() as u16 - }) - .collect(); - // one clipped green sample (RGGB: even row, odd column) in every other 8 × 8 block - for by in 0..h / 8 { - for bx in (by % 2..w / 8).step_by(2) { - data[(by * 8 + 4) * w + bx * 8 + 5] = 16000; - } - } - let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); - let raw = lightcraft_raw::decode(&bytes).unwrap(); - assert_eq!(bin_factor(&raw, 32), Some(8)); - let (img, _) = load_bytes(&bytes, 32).unwrap(); - assert_eq!((img.width, img.height), (32, 24)); - let worst = img.data.iter().map(|p| p[1] / ((p[0] + p[2]) / 2.0).max(1e-6)).fold(0.0f32, f32::max); - assert!(worst < 1.12, "green speckles: green {worst:.2}× the other channels"); - } - - #[test] - fn cached_denoise_uses_the_same_white_balance_profile_and_orientation_as_plain_raw() { - use lightcraft_denoise::product; - use lightcraft_raw::{DngWriteOptions, Method, Orientation, profile::HsvTable, write_dng}; - let dir = crate::tests_xmp::temp_dir("denoise-color-finish"); - let fixture = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&fixture).unwrap(); - raw.orientation = Orientation::Rotate90; - raw.color.baseline_exposure = 0.5; - raw.color.profile.look_table = - Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[23.0, 0.65, 0.8]; 2], srgb_value: false }); - let bytes = write_dng(&raw, &DngWriteOptions::default()).unwrap(); - let decoded = lightcraft_raw::decode(&bytes).unwrap(); - assert!(decoded.color.profile.look_table.is_some()); - // A cache containing the ordinary demosaic must undergo the identical downstream finish. - let camera_rgb = decoded.develop(Method::Ahd).unwrap(); - let key = "synthetic-color-finish"; - let path = dir.join("same.lcdn"); - product::write(&path, &camera_rgb, key).unwrap(); - let spec = DenoiseSpec { product: path, key: key.into(), make: None }; - let (ordinary, _) = load_bytes(&bytes, 2048).unwrap(); - let (plain, cached, _) = load_vec_pair(bytes, 2048, &spec).unwrap(); - assert_eq!(plain.data, ordinary.data, "requesting a twin must not change the ordinary path"); - let cached = cached.unwrap(); - assert_eq!((plain.width, plain.height), (24, 32)); - assert_eq!((cached.width, cached.height), (plain.width, plain.height)); - let worst = plain.data.iter().zip(&cached.data).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max); - assert!(worst < 0.002, "color finish differs beyond cache half-float rounding: {worst}"); - let _ = std::fs::remove_dir_all(dir); - } - - /// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG - /// mostly cancel, edges remain. - fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec { - let l: Vec = (0..w * h).map(|i| lum(i % w, i / w)).collect(); - let mut out = vec![0.0; w * h]; - for y in 0..h { - for x in 0..w { - let (x0, x1, y0, y1) = (x.saturating_sub(4), (x + 5).min(w), y.saturating_sub(4), (y + 5).min(h)); - let m = (y0..y1).flat_map(|yy| (x0..x1).map(move |xx| (xx, yy))).map(|(xx, yy)| l[yy * w + xx]).sum::(); - out[y * w + x] = l[y * w + x] - m / ((x1 - x0) * (y1 - y0)) as f32; - } - } - out - } - - /// The integer shift (within ±`r` px) of the `p × p` patch of `a` at `(x, y)` that best matches `b`, with its - /// normalised cross-correlation, or `None` when the patch has too little detail to tell. - fn patch_shift(a: &[f32], b: &[f32], w: usize, (x, y): (usize, usize), p: usize, r: i64) -> Option<((i64, i64), f32)> { - let pa: Vec = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect(); - let na = pa.iter().map(|v| v * v).sum::().sqrt(); - if na < 1e-3 * p as f32 { - return None; - } - let mut best = (f32::MIN, (0, 0)); - for dy in -r..=r { - for dx in -r..=r { - let (bx, by) = (x as i64 + dx, y as i64 + dy); - let (mut dot, mut nb) = (0.0f32, 0.0f32); - for i in 0..p { - for j in 0..p { - let v = b[(by as usize + i) * w + bx as usize + j]; - dot += pa[i * p + j] * v; - nb += v * v; - } - } - let c = dot / (na * nb.sqrt()).max(1e-12); - if c > best.0 { - best = (c, (dx, dy)); - } - } - } - Some((best.1, best.0)) - } - - #[test] - fn corpus_a7cr_warp_keeps_coordinates_after_orientation_and_resizing() { - use lightcraft_geom::{Orientation, Point}; - use lightcraft_pipeline::optics::Warp; - let root = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); - for name in ["arw-sony-a7cr-compressed.arw", "arw-sony-a7cr-lossless-l.arw"] { - let Ok(mut bytes) = std::fs::read(root.join("raw").join(name)) else { continue }; - let off = lightcraft_raw::probe_info(&bytes).unwrap(); - assert!(embedded_lens(&off).is_none()); - let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap(); - let at = tiff.find(0x7036).unwrap().offset as usize; - let mut enabled = Vec::new(); - tiff.order.put_u16(&mut enabled, 17); - bytes[at..at + 2].copy_from_slice(&enabled); - let mut raw = lightcraft_raw::probe_info(&bytes).unwrap(); - raw.orientation = Orientation::Normal; - let mut settings = lightcraft_develop::DevelopSettings::default(); - settings.optics.lens_profile = true; - let base_lens = embedded_lens(&raw).unwrap(); - let base = Warp::from_settings(9504.0, 6336.0, &settings, Some(&base_lens)); - for orientation in 1..=8 { - let o = Orientation::from_exif(orientation); - raw.orientation = o; - let lens = embedded_lens(&raw).unwrap(); - for edge in [1200.0, 2560.0, 9504.0] { - let scale = edge / 9504.0; - let (w, h) = if o.swaps_axes() { (6336.0 * scale, edge) } else { (edge, 6336.0 * scale) }; - let oriented = Warp::from_settings(w, h, &settings, Some(&lens)); - for (x, y) in [(0.05, 0.1), (0.5, 0.5), (0.9, 0.8)] { - let point = Point::new(x * 9504.0, y * 6336.0); - let source = base.corrected_to_source(point, 1); - let (px, py) = o.map(point.x, point.y, 9504.0, 6336.0); - let (sx, sy) = o.map(source.x, source.y, 9504.0, 6336.0); - let got = oriented.corrected_to_source(Point::new(px * scale, py * scale), 1); - assert!((got.x - sx * scale).hypot(got.y - sy * scale) < 1e-8, "{name} orientation {orientation} edge {edge}"); - } - } - } - } - } - - /// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the - /// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile - /// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide - /// lenses is far off. A file shot with the camera's correction off (DMC-GH1) carries no correction. - #[test] - fn corpus_rw2_distortion_matches_the_camera_jpeg() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else { - lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); - return; - }; - assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply"); - assert_eq!(load_bytes(&gh1, 320).unwrap().1.lens, None); - - // (file, how far off (px) the uncorrected corners are at the least, if they match within ±R at all) - let files = [ - ("rw2-panasonic-fz1000m2-4x3.rw2", 8), // 9.1 mm, ~13 % at the corners - ("rwl-leica-dlux7.rwl", 8), // 10.9 mm, ~13 % - ("rw2-panasonic-gx80.rw2", 8), // 14 mm, ~8 % - ("rw2-panasonic-gh6.rw2", 3), // 20 mm, ~1.6 % - ("rw2-panasonic-s9.rw2", 3), // 45 mm, 3 % zoom after the correction - ("rw2-panasonic-g9.rw2", 0), // 40 mm, ~0.5 % - ]; - const EDGE: usize = 640; - const P: usize = 64; - const R: i64 = 12; - for (name, off_at_least) in files { - let bytes = std::fs::read(dir.join(name)).unwrap(); - let probed = probe_bytes(name, &bytes).unwrap(); - assert!(probed.embedded_lens.is_some_and(|l| l.warp.is_some()), "{name}: no distortion correction read"); - let (src, info) = load_bytes(&bytes, 2 * EDGE).unwrap(); - assert_eq!(info.lens, probed.embedded_lens, "{name}: probe and decode disagree"); - // Convert to DNG keeps it: the same correction, as the DNG's own `WarpRectilinear` - let dng = lightcraft_raw::write_dng(&lightcraft_raw::decode(&bytes).unwrap(), &Default::default()).unwrap(); - assert_eq!(probe_bytes("x.dng", &dng).unwrap().embedded_lens, probed.embedded_lens, "{name}: lost in the DNG"); - let mut s = lightcraft_develop::DevelopSettings::default(); - let render = |s: &lightcraft_develop::DevelopSettings| { - lightcraft_pipeline::render(&src, &info, s, &lightcraft_pipeline::RenderRequest::fit(EDGE, EDGE)).image - }; - let before = render(&s); - s.optics.lens_profile = true; - let after = render(&s); - let (w, h) = (after.width, after.height); - assert_eq!((before.width, before.height), (w, h)); - // the camera JPEG shows the whole sensor; a render in an in-camera aspect ratio is its centre - let (cam, _) = load_embedded_preview(&bytes, 4 * EDGE).unwrap(); - let (cw, ch) = (cam.width as f64, cam.height as f64); - let (sw, sh) = if cw / ch > w as f64 / h as f64 { (ch * w as f64 / h as f64, ch) } else { (cw, cw * h as f64 / w as f64) }; - let cam = cam.crop(((cw - sw) / 2.0).round() as usize, ((ch - sh) / 2.0).round() as usize, sw.round() as usize, sh.round() as usize); - let cam = lightcraft_raster::resample::resize(&cam, w, h, Filter::Mitchell); - let lin = |v: u8| (v as f32 / 255.0).powf(2.2); - let lum8 = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| { - let p = im.data[y * im.width + x]; - 0.3 * lin(p[0]) + 0.6 * lin(p[1]) + 0.1 * lin(p[2]) - }; - let reference = detail(w, h, |x, y| { - let p = cam.data[y * w + x]; - 0.3 * p[0] + 0.6 * p[1] + 0.1 * p[2] - }); - let (db, da) = (detail(w, h, |x, y| lum8(&before, x, y)), detail(w, h, |x, y| lum8(&after, x, y))); - let m = R as usize + 4; - let corners = [(m, m), (w - m - P, m), (m, h - m - P), (w - m - P, h - m - P)]; - let mut measured = 0; - for c in corners { - let Some(((dx, dy), ca)) = patch_shift(&da, &reference, w, c, P, R) else { continue }; - measured += 1; - assert!(dx.abs() <= 2 && dy.abs() <= 2 && ca >= 0.5, "{name}: corrected corner {c:?} off by ({dx}, {dy}) px, ncc {ca}"); - if off_at_least > 0 - && let Some(((bx, by), cb)) = patch_shift(&db, &reference, w, c, P, R) - { - let off = bx.abs().max(by.abs()) >= off_at_least || cb < 0.3; - assert!(off, "{name}: uncorrected corner {c:?} already within ({bx}, {by}) px, ncc {cb}"); - } - } - assert!(measured >= 2, "{name}: only {measured} corners with detail"); - } - } - - /// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box. - pub(crate) fn cr3_with_preview(w: u32, h: u32) -> Vec { - let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); - let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec(); - b.extend_from_slice(&((24 + jpeg.len()) as u32).to_be_bytes()); - b.extend_from_slice(b"PRVW\0\0\0\0\0\x01"); - b.extend_from_slice(&(w as u16).to_be_bytes()); - b.extend_from_slice(&(h as u16).to_be_bytes()); - b.extend_from_slice(b"\0\x01"); - b.extend_from_slice(&(jpeg.len() as u32).to_be_bytes()); - b.extend_from_slice(&jpeg); - b - } - - /// [`cr3_with_preview`] plus a full-size CRX raw track whose `CMP1` coding header is garbage: - /// the container parses, the raw data doesn't (`RawError::Corrupt`, not `Unsupported`). - fn cr3_with_corrupt_raw(w: u32, h: u32) -> Vec { - let bx = |kind: &[u8; 4], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat(); - let mut craw = vec![0u8; 82]; - craw[24..26].copy_from_slice(&(w as u16).to_be_bytes()); - craw[26..28].copy_from_slice(&(h as u16).to_be_bytes()); - craw.extend(bx(b"CMP1", &[0xff; 4])); - let mut stsd = vec![0, 0, 0, 0, 0, 0, 0, 1]; - stsd.extend(bx(b"CRAW", &craw)); - let stsz = [0u32, 16, 1].map(u32::to_be_bytes).concat(); - let co64 = [&[0u8, 0, 0, 0, 0, 0, 0, 1][..], &0u64.to_be_bytes()].concat(); - let stbl = [bx(b"stsd", &stsd), bx(b"stsz", &stsz), bx(b"co64", &co64)].concat(); - let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))); - let preview = cr3_with_preview(w, h); - [&preview[..24], &bx(b"moov", &trak), &preview[24..]].concat() - } - - /// A CR3 whose raw data fails to decode, for any reason, still opens from its embedded JPEG: - /// CR3s did before the CRX decoder, which is verified on few bodies (PR #279 review). - #[test] - fn corrupt_cr3_falls_back_to_embedded_preview() { - let b = cr3_with_corrupt_raw(48, 32); - let err = lightcraft_raw::decode(&b).expect_err("the fixture's raw data is corrupt"); - assert!(!matches!(err, lightcraft_raw::RawError::Unsupported(_)), "{err}"); - let p = probe_bytes("x.cr3", &b).unwrap(); - assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); - assert!(p.preview_only.is_some_and(|why| why.contains("CR3"))); - let (img, src) = load_bytes(&b, 24).unwrap(); - assert_eq!((img.width, img.height), (24, 16)); - assert!(!src.raw); - // other formats still report a corrupt file as an error - let mut dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - dng.truncate(dng.len() / 2); - assert!(load_bytes(&dng, 24).is_err()); - } - - /// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is - /// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it. - #[test] - fn dng_profile_look_is_applied() { - use lightcraft_raw::profile::{HsvTable, ProfileLook, ToneCurve}; - let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&plain).unwrap(); - let sat = |img: &Rgb32f| { - img.data.iter().map(|p| (p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2])) / p[0].max(p[1]).max(p[2]).max(1e-6)).sum::() - / img.data.len() as f32 - }; - let (before, info) = load_bytes(&plain, 64).unwrap(); - assert!(sat(&before) > 0.05, "the synthetic scene is coloured"); - assert!(info.camera_tone.is_none()); - // a map that removes all saturation, and a tone curve - let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false }; - raw.color.profile = ProfileLook { - hue_sat_map: [Some(grey), None], - look_table: None, - tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]), - gain_table_map: None, - }; - let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - let (after, info) = load_bytes(&with, 64).unwrap(); - assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after)); - // a saturation-only map keeps brightness roughly (HSV value is kept in ProPhoto RGB) - let mean = |img: &Rgb32f| img.data.iter().map(|p| p[0].max(p[1]).max(p[2])).sum::() / img.data.len() as f32; - assert!((0.8..1.1).contains(&(mean(&after) / mean(&before))), "{} vs {}", mean(&after), mean(&before)); - let tone = info.camera_tone.expect("the DNG tone curve becomes the camera tone"); - assert!((tone.apply(0.18) - 0.3).abs() < 0.01, "{}", tone.apply(0.18)); - // a look table alone changes the render too (applied after exposure) - raw.color.profile = ProfileLook { - look_table: Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 1.0, 0.5]; 2], srgb_value: false }), - ..Default::default() - }; - let (dim, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); - assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before)); - } - - /// Corrections used while fitting a DNG's preview must not be baked into its editable source. - /// The pipeline applies them once from SourceInfo, and a file-defined tone stays authoritative. - #[test] - fn dng_preview_corrections_leave_the_render_source_uncorrected() { - let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&plain).unwrap(); - raw.color.profile.tone_curve = lightcraft_raw::profile::ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); - let before = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - raw.opcodes.list3 = vec![lightcraft_raw::Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; - let corrected = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - let (before, expected) = load_bytes(&before, 64).unwrap(); - let (after, info) = load_bytes(&corrected, 64).unwrap(); - assert_eq!(before.data, after.data, "lens correction belongs to the pipeline"); - assert!(info.lens.as_ref().is_some_and(|l| l.vignette.is_some())); - assert_eq!(info.camera_tone, expected.camera_tone, "the file's own tone is preserved"); - } - - /// A camera JPEG made from the corrected scene teaches one tone to the DNG's existing fit. - /// The source pixels stay uncorrected, including when the file is rotated. - #[test] - fn dng_lens_corrected_preview_teaches_one_tone_without_baking_in_the_lens() { - use lightcraft_color::{REC2020, SRGB, luminance_2020, transfer::linear_to_srgb}; - use lightcraft_raw::{Opcode, Orientation}; - let (w, h) = (384usize, 256usize); - let data = (0..w * h) - .map(|i| { - let (x, y) = ((i % w) as f32, (i / w) as f32); - let v = 0.02 + 0.2 * (0.5 + 0.5 * (x * 0.045).sin() * (y * 0.06).cos()) + 0.1 * x / w as f32; - let colour = match (i / w % 2, i % w % 2) { - (0, 0) => 1.2, - (1, 1) => 0.8, - _ => 1.0, - }; - 256 + (v * colour * 15744.0) as u16 - }) - .collect(); - let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); - let mut raw = lightcraft_raw::decode(&bytes).unwrap(); - raw.color = lightcraft_raw::ColorData { - illuminant: [21, 0], - color_matrix: [Some(REC2020.from_xyz()), None], - as_shot_neutral: Some([1.0; 3]), - ..Default::default() - }; - let plain = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - raw.opcodes.list3 = vec![ - Opcode::FixVignetteRadial { k: [0.8, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }, - Opcode::WarpRectilinear { planes: vec![[0.7, 0.2, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }, - ]; - let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let corrected = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); - let to_srgb = REC2020.to_space(&SRGB); - let pixels: Vec = corrected - .data - .iter() - .flat_map(|p| { - let p = t.matrix.apply_f32(std::array::from_fn(|i| p[i] * t.wb[i])); - let y = luminance_2020(p).max(1e-6); - let gain = (1.0 - (-3.0 * y).exp()) / y; - to_srgb.apply_f32(p.map(|v| v * gain)).map(|v| (255.0 * linear_to_srgb(v).clamp(0.0, 1.0)).round() as u8) - }) - .collect(); - let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); - let jpeg = lightcraft_codecs::encode_jpeg(&image, 98, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); - for orientation in [Orientation::Normal, Orientation::Rotate90] { - raw.orientation = orientation; - let mut file = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - // Append a preview IFD to the little-endian DNG written above. - let root = u32::from_le_bytes(file[4..8].try_into().unwrap()) as usize; - let count = u16::from_le_bytes(file[root..root + 2].try_into().unwrap()) as usize; - let next = root + 2 + 12 * count; - let start = file.len() as u32; - file[next..next + 4].copy_from_slice(&start.to_le_bytes()); - let jpeg_at = start + 2 + 4 * 12 + 4; - file.extend(4u16.to_le_bytes()); - for (tag, value) in [(256u16, w as u32), (257, h as u32), (0x201, jpeg_at), (0x202, jpeg.len() as u32)] { - file.extend(tag.to_le_bytes()); - file.extend(4u16.to_le_bytes()); - file.extend(1u32.to_le_bytes()); - file.extend(value.to_le_bytes()); - } - file.extend(0u32.to_le_bytes()); - file.extend(&jpeg); - let (source, info) = load_bytes(&file, 96).unwrap(); - let tone = info.camera_tone.expect("one DNG look fitted to the lens-corrected preview"); - let target = 1.0 - (-3.0f32 * 0.18).exp(); - assert!((tone.apply(0.18) - target).abs() < 0.04, "one tone: {} vs {target}", tone.apply(0.18)); - let expected = load_bytes(&plain, 96).unwrap().0.into_oriented(orientation); - assert_eq!(source.data, expected.data, "the editable source is not lens-corrected"); - } - } - - /// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's - /// own white-balanced neutral comes out yellow (blue 12 % low). - fn yellowing_look() -> lightcraft_color::Mat3 { - lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]]) - } - - /// A fitted camera look applied in lanes, on rows that aren't a whole number of them, gives the bits of the - /// pixel-by-pixel path, awkward pixels (black, negative, huge, NaN, infinite) included. - #[test] - fn camera_look_in_lanes_matches_pixel_by_pixel() { - let data = (0..72 * 5 * 5).map(|i| [((i * 37) % 61) as f32 - 30.0, 0.6 + ((i * 11) % 13) as f32 / 13.0, 1.0]).collect(); - let table = lightcraft_raw::profile::HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data, srgb_value: true }; - let hue_sat = crate::camera_preview::HueSat::new(&table).unwrap(); - let colour = CameraColourModel { wb: [2.1, 1.0, 1.5], matrix: yellowing_look(), gain: 1.3, tables: None, hue_sat: Some(&hue_sat) }; - let special = [0.0, -0.0, -0.01, 1e-30, 0.3, 1.0, 5.0, 1e30, f32::INFINITY, f32::NAN]; - let img = Rgb32f::from_fn(37, 5, |x, y| { - let k = y * 37 + x; - if k % 5 == 0 { - [special[k % 10], special[k / 10 % 10], special[k / 100 % 10]] - } else { - [(x as f32 * 0.37).sin().abs(), ((x + y) as f32 * 0.11).cos().abs(), y as f32 / 4.0 - 0.05] - } - }); - let mut lanes = img.clone(); - colour.apply(&mut lanes); - let m = colour.matrix.to_f32(); - for (p, got) in img.data.iter().zip(&lanes.data) { - assert_eq!(got.map(f32::to_bits), colour.pixel(&m, *p).map(f32::to_bits), "{p:?}"); - } - } - - #[test] - fn clipped_white_renders_neutral_through_the_colour_model() { - use lightcraft_color::{LUMA_2020, Mat3}; - let look = yellowing_look(); - let white = clipped_white(&look); - let out = look.apply(white.map(f64::from)); - assert!(out.iter().all(|v| (v - out[1]).abs() < 1e-5), "{white:?} renders {out:?}"); - let luma = |c: [f64; 3]| (0..3).map(|i| c[i] * f64::from(LUMA_2020[i])).sum::(); - assert!((luma(out) - luma(look.apply([1.0; 3]))).abs() < 1e-5, "as bright as the camera's neutral"); - assert_eq!(clipped_white(&Mat3::IDENTITY), [1.0; 3]); - // a model that can't be inverted, or only just, keeps the camera's neutral - assert_eq!(clipped_white(&Mat3([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); - assert_eq!(clipped_white(&Mat3([[1.0, 1.0, 0.0], [1.0, 1.0 + 1e-8, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); - // so does one whose white would overflow the rebuilt highlights (2.5e38 in red here) - assert_eq!(clipped_white(&Mat3([[3e-39, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); - // or move the camera's neutral implausibly far - assert_eq!(clipped_white(&Mat3([[0.1, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); - // what it answers renders neutral through the f32 matrix, and fully clipped highlights stay finite - for model in [look, Mat3([[1.9, -0.7, -0.2], [-0.3, 1.4, -0.1], [0.0, -0.4, 1.4]])] { - let w = clipped_white(&model); - let m = model.to_f32(); - let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * w[0] + m[i][1] * w[1] + m[i][2] * w[2]); - assert!(out.iter().all(|v| (v - out[1]).abs() <= 1e-3 * out[1]), "{w:?} renders {out:?}"); - let colour = CameraColourModel { wb: [2.5, 1.0, 1.6], matrix: model, gain: 1.0, tables: None, hue_sat: None }; - let mut img = Rgb32f::filled(4, 4, [1.0; 3]); - colour.rebuild_highlights(&mut img); - colour.apply(&mut img); - assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", img.get(0, 0)); - } - } - - /// Issue #523: fully clipped highlights are neutral in the white-balanced camera RGB, but a colour model that - /// doesn't map that neutral to neutral tinted them. The tone map's roll-off to white hid the tint at Neutral; - /// Highlights -100, or the issue's edit (Exposure -0.88, Highlights -75…), showed it. - #[test] - fn clipped_highlights_stay_neutral_when_darkened() { - use lightcraft_pipeline::{RenderRequest, render}; - let wb = [2.4f32, 1.0, 1.6]; - let colour = CameraColourModel { wb, matrix: yellowing_look(), gain: 1.0, tables: None, hue_sat: None }; - // a grey wall (camera neutral) with a fully clipped light and a light clipped in green only - let mut img = Rgb32f::from_fn(96, 64, |x, y| { - let d = |cx: f32| ((x as f32 - cx).powi(2) + (y as f32 - 32.0).powi(2)).sqrt(); - if d(28.0) < 14.0 { - [1.0; 3] - } else if d(70.0) < 12.0 { - [0.6 / wb[0], 1.0, 0.7 / wb[2]] - } else { - [0.3 / wb[0], 0.3, 0.3 / wb[2]] - } - }); - colour.rebuild_highlights(&mut img); - colour.apply(&mut img); - let info = SourceInfo { raw: true, ..Default::default() }; - let mut issue = lightcraft_develop::DevelopSettings::default(); - let l = &mut issue.light; - (l.exposure, l.contrast, l.highlights, l.shadows, l.whites, l.blacks) = (-0.88, 5.0, -75.0, 52.0, 6.0, -37.0); - let mut h100 = lightcraft_develop::DevelopSettings::default(); - h100.light.highlights = -100.0; - for (name, s) in [("Highlights -100", h100), ("#523's edit", issue)] { - let out = render(&img, &info, &s, &RenderRequest::fit(96, 64)).image; - let p = out.get(28, 32); - assert!(p[1] < 250, "{name}: the clipped light is darkened ({p:?})"); - let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]); - assert!(spread <= 1, "{name}: clipped light {p:?} is not neutral"); - } - } - - /// A smooth pink gradient (`[0.9k, k, 0.8k]` in the sensor, k from 0.8 to 1.35, white balance `[2.5, 1, 1.6]`) - /// through the point where green clips, 1600 px long, in `rows` of a 1600 × `height` picture that is otherwise - /// near black, rebuilt and rendered at Exposure -2. Returns the rendered colour of the middle row's last - /// unclipped pixel and the next two, and the largest step between neighbouring pixels along that row. - fn darkened_ramp(rows: std::ops::Range, height: usize) -> ([[u8; 4]; 3], u8) { - use lightcraft_pipeline::{RenderRequest, render}; - let wb = [2.5f32, 1.0, 1.6]; - let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; - let w = 1600usize; - let k = |x: usize| 0.8 + 0.55 * x as f32 / (w - 1) as f32; - let sensor = - Rgb32f::from_fn(w, height, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); - let y = (rows.start + rows.end) / 2; - let first = (0..w).find(|&x| sensor.get(x, y)[1] >= HIGHLIGHT_CLIP).unwrap(); - let mut img = sensor.clone(); - colour.rebuild_highlights(&mut img); - colour.apply(&mut img); - let mut s = lightcraft_develop::DevelopSettings::default(); - s.light.exposure = -2.0; - let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, height)).image; - let step = |x: usize| { - let (a, b) = (out.get(x - 1, y), out.get(x, y)); - (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) - }; - ([out.get(first - 1, y), out.get(first, y), out.get(first + 1, y)], (1..w).map(step).max().unwrap_or(0)) - } - - /// The darkened pink gradient carries on across the point where green clips: before the fix the fade towards - /// the clipped neutral, driven by how close the other channels were to their clip, made a step of 37 levels - /// there. - #[test] - fn rendered_colour_is_continuous_where_the_first_channel_clips() { - let ([a, b, c], worst) = darkened_ramp(0..16, 16); - let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); - assert!(step(a, b) <= 1 && step(b, c) <= 1, "at the first clip: {a:?} {b:?} {c:?}"); - assert!(worst <= 3, "largest step between neighbouring pixels: {worst} levels"); - } - - /// The same gradient as a thin bright line on near black: every pixel of it is next to a strong edge, so there - /// is no reliable colour around it. It still carries on across the first clip, however thin the line and however - /// much dark background there is (a 4-row line on 32 rows used to jump from [225, 126, 153] to [198, 177, 163]; - /// on 512 rows, from [225, 126, 153] to [218, 145, 151]). - #[test] - fn rendered_thin_line_is_continuous_where_the_first_channel_clips() { - let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); - for height in [32usize, 128, 512, 1024] { - for width in [1usize, 2, 4, 16] { - let top = (height - width) / 2; - let ([a, b, c], worst) = darkened_ramp(top..top + width, height); - let case = format!("{width} rows of {height}"); - assert!(step(a, b) <= 1 && step(b, c) <= 1, "{case}, at the first clip: {a:?} {b:?} {c:?}"); - assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); - } - } - } - - /// A band clipped in green whose red rises through its own clip from left to right (sensor `[0.985 + 0.01 t, 1, - /// 0.8]`, t from 0 to 1 across 320 px), `band` rows at the bottom of the picture, below `strip` rows of a pale - /// surface just under the clip (`[0.4, 0.95, 0.8]`: the band's rim, of another colour) and above those `top` rows - /// of a strongly coloured reliable surface (`[0.8, 0.35, 0.3]`). Rebuilt and rendered at Exposure -2: the - /// rendered colour on either side of where red clips on the band's middle row, and the largest step between - /// neighbouring pixels along that row. - fn second_clip(top: usize, strip: usize, band: usize) -> ([[u8; 4]; 2], u8) { - use lightcraft_pipeline::{RenderRequest, render}; - let wb = [2.5f32, 1.0, 1.6]; - let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; - let (w, h) = (320usize, top + strip + band); - let sensor = Rgb32f::from_fn(w, h, |x, y| { - if y < top { - [0.8, 0.35, 0.3] - } else if y < top + strip { - [0.4, 0.95, 0.8] - } else { - [0.985 + 0.01 * x as f32 / (w - 1) as f32, 1.0, 0.8] - } - }); - let y = top + strip + band / 2; - let at = (0..w).find(|&x| sensor.get(x, y)[0] >= HIGHLIGHT_CLIP).unwrap(); - let mut img = sensor.clone(); - colour.rebuild_highlights(&mut img); - colour.apply(&mut img); - let mut s = lightcraft_develop::DevelopSettings::default(); - s.light.exposure = -2.0; - let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h)).image; - let step = |x: usize| { - let (a, b) = (out.get(x - 1, y), out.get(x, y)); - (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) - }; - ([out.get(at - 1, y), out.get(at, y)], (1..w).map(step).max().unwrap_or(0)) - } - - /// Where a second channel clips within reach of the surface's own rim, the rebuilt colour carries on too. The - /// rim's comparison with the pixel used to drop each clipped channel, so a rim far from the pixel in red suddenly - /// matched it once red clipped (80 rows of colour, 16 of rim and 32 of band: [237, 124, 153] then - /// [205, 181, 168]); and a channel about to clip counted as an uncapped floor that was capped once it clipped - /// (without the coloured surface: [198, 195, 178] then [205, 181, 168]). - #[test] - fn rendered_colour_is_continuous_where_a_second_channel_clips() { - for (top, strip, band) in [(80, 16, 32), (0, 16, 32), (80, 16, 4), (200, 4, 8), (16, 64, 16), (8, 32, 24)] { - let ([a, b], worst) = second_clip(top, strip, band); - let case = format!("{top} rows of colour, {strip} of rim, {band} of band"); - let step = (0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0); - assert!(step <= 1, "{case}: {a:?} then {b:?} where red clips"); - assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); - } - } - - /// Issue #523 on a public ILCE-7RM4 raw (raw.pixls.us, CC0; skipped without the corpus): a dusk sky clipped in - /// green behind a poplar. Its rebuilt colour used to come from the tree's foliage and lens fringes, a green halo - /// on one side of the tree and a magenta one on the other (Highlights -100 or the issue's edit show them). - #[test] - fn corpus_clipped_sky_beside_a_tree_has_no_coloured_halo() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw"); - let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else { - lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); - return; - }; - let (img, _) = load_bytes(&bytes, 1600).unwrap(); - let (w, h) = (img.width, img.height); - let lum = |p: [f32; 3]| lightcraft_color::luminance_2020(p); - let chroma = |p: [f32; 3]| { - let s = (p[0] + p[1] + p[2]).max(1e-6); - [p[0] / s, p[2] / s] - }; - // the poplar stands at about 30 % of the width, its top 25 to 34 % down the frame - let px = |f: f32, n: usize| (f * n as f32) as usize; - let (mut near, mut far) = ([[0f64; 2]; 2], [[0f64; 2]; 2]); - let mut rows = 0; - for y in px(0.24, h)..px(0.34, h) { - let row = &img.data[y * w..(y + 1) * w]; - let sky = lum(row[px(0.2, w)]); - let dark: Vec = (px(0.25, w)..px(0.35, w)).filter(|&x| lum(row[x]) < 0.5 * sky).collect(); - let (Some(&left), Some(&right)) = (dark.first(), dark.last()) else { continue }; - let k = w as f32 / 6000.0; - let mean = |a: usize, b: usize| { - let n = (b - a) as f64; - (a..b).map(|x| chroma(row[x])).fold([0f64; 2], |s, c| [s[0] + c[0] as f64 / n, s[1] + c[1] as f64 / n]) - }; - let (n0, n1, f0, f1) = (px(8.0 / 6000.0, w).max(2), px(40.0 / 6000.0, w), (200.0 * k) as usize, (320.0 * k) as usize); - for (side, (n, f)) in - [(mean(left - n1, left - n0), mean(left - f1, left - f0)), (mean(right + n0, right + n1), mean(right + f0, right + f1))] - .into_iter() - .enumerate() - { - for c in 0..2 { - near[side][c] += n[c]; - far[side][c] += f[c]; - } - } - rows += 1; - } - assert!(rows > 50, "the tree was not found ({rows} rows)"); - for side in 0..2 { - let d = (0..2).map(|c| ((near[side][c] - far[side][c]) / rows as f64).abs()).fold(0.0, f64::max); - assert!(d < 0.004, "{} of the tree the clipped sky's chromaticity is off by {d:.4}", ["left", "right"][side]); - } - } - - /// A Lightroom merge (a LinearRaw DNG keeping a reduced copy of its raw image): its grid thumbnail develops the - /// copy, but an export never does, whatever its size, and not from the thumbnail's source in memory either. The - /// copy's samples are much brighter than the image's here, so a picture says which one it came from. - #[test] - fn exports_never_develop_a_dngs_reduced_copy() { - use lightcraft_tiff::tags::{self as t, photometric}; - use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; - use serde_json::json; - let linear = |subfile: u32, w: u32, h: u32, sample: u16| { - let mut ifd = IfdBuilder::new(); - ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); - ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); - ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![sample.to_le_bytes().repeat((w * h * 3) as usize)] }); - ifd - }; - // (the copy must cover a thumbnail source's 512 px) - let (w, h) = (1200u32, 600u32); - let mut main = linear(0, w, h, 1500); - main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); - main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); - ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); - ifd0.set(t::MAKE, Value::Ascii("DJI".into())); - ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); - ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![4])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![2])); - ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); - ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd0.set(t::COMPRESSION, Value::Short(vec![1])); - ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![128; 24]] }); - ifd0.add_sub_ifd(main); - ifd0.add_sub_ifd(linear(1, w / 2, h / 2, 24000)); - let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); - let g = |img: &Rgb32f| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; - let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; - - // the loaders: only the thumbnail's develops the copy, and only when it covers the size - let (copy, _, used) = load_vec_reduced(bytes.clone(), 512).unwrap(); - assert!(used, "a thumbnail develops the copy"); - assert!(!load_vec_reduced(bytes.clone(), 1024).unwrap().2, "no copy covers 1024 px"); - let (full, _) = load_vec(bytes.clone(), 512).unwrap(); - assert!(g(©) > 4.0 * g(&full), "the fixture's copy is much brighter: {} vs {}", g(©), g(&full)); - - let dir = std::env::temp_dir().join(format!("lc-reduced-export-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("merge-pano.dng"); - std::fs::write(&path, &bytes).unwrap(); - let mut s = crate::Session::new().with_fs(); - let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); - let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); - let (space, depth) = (lightcraft_pipeline::OutputSpace::Srgb, lightcraft_pipeline::OutputDepth::U8); - // an export at a thumbnail's size, before any thumbnail - assert_eq!(s.export_job(id, 64, 64, space, depth).unwrap().level, crate::media::SourceLevel::Thumb); - let export = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); - // the thumbnail develops the copy and its source stays in memory… - s.media.clear_sources(); - let thumb = s.thumb_job(id, 64).unwrap().run(); - assert!(thumb.loaded.as_ref().is_some_and(|l| l.reduced)); - let thumb_mean = mean(&thumb.rendered.as_ref().unwrap().image); - s.accept(&thumb); - assert!(thumb_mean > export + 20.0, "the thumbnail shows the copy: {thumb_mean} vs {export}"); - // …and an export after it still reads the full image - let job = s.export_job(id, 64, 64, space, depth).unwrap(); - assert!(matches!(&job.source, crate::media::SourceRef::File { reduced: None, .. }), "the file, not the copy"); - let again = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); - assert!((again - export).abs() < 1.0, "{again} vs {export}"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right - /// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky - /// heuristic would never look) is used; a matte with an unknown name is ignored. - #[test] - fn dng_sky_matte_drives_the_sky_mask() { - use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; - use lightcraft_pipeline::{RenderRequest, render}; - use lightcraft_tiff::tags::{self as t, photometric}; - use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; - let (w, h) = (16u32, 8u32); - let image = |ifd: &mut IfdBuilder, w: u32, h: u32, cpp: u16, bits: u16, data: Vec| { - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; cpp as usize])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![cpp])); - ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![data] }); - }; - let mut raw = IfdBuilder::new(); - raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); - image(&mut raw, w, h, 3, 16, 12000u16.to_le_bytes().repeat((w * h * 3) as usize)); - let matte = |name: &str, right: bool| { - let mut m = IfdBuilder::new(); - m.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![t::SUBFILE_SEMANTIC_MASK])); - m.set(t::PHOTOMETRIC, Value::Short(vec![photometric::MASK])); - m.set(t::SEMANTIC_NAME, Value::Ascii(name.into())); - image(&mut m, w / 2, h / 2, 1, 8, (0..w / 2 * h / 2).map(|i| if (i % (w / 2) >= w / 4) == right { 255 } else { 0 }).collect()); - m - }; - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); - ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0])); - ifd0.set(t::MAKE, Value::Ascii("Apple".into())); - ifd0.set(t::ORIENTATION, Value::Short(vec![6])); - ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); - ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); - image(&mut ifd0, 4, 2, 3, 8, vec![128; 24]); - ifd0.add_sub_ifd(raw); - ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticskymatte", true)); - ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticsomethingelse", false)); - let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); - - let (img, info) = load_bytes(&bytes, 64).unwrap(); - assert_eq!((img.width, img.height), (8, 16)); - assert!(info.mattes.is_some()); - let sky = Mask { - components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }], - adjust: LocalAdjustments { exposure: -3.0, ..Default::default() }, - ..Default::default() - }; - let s = DevelopSettings { masks: vec![sky], ..Default::default() }; - let req = RenderRequest::fit(8, 16); - let green = |s: &DevelopSettings, info: &SourceInfo, y: usize| render(&img, info, s, &req).image.get(4, y)[1] as i32; - let plain = DevelopSettings::default(); - assert_eq!(green(&s, &info, 2), green(&plain, &info, 2), "no sky at the top"); - assert!(green(&s, &info, 13) + 40 < green(&plain, &info, 13), "the sky at the bottom is darkened"); - // without the matte the heuristic looks at the top of the frame instead - let heuristic = SourceInfo { mattes: None, ..info.clone() }; - assert!(green(&s, &heuristic, 2) < green(&plain, &heuristic, 2)); - assert_eq!(green(&s, &heuristic, 13), green(&plain, &heuristic, 13)); - } - - /// A DNG's white balance is re-evaluated through its own colour model: the decoder hands the - /// pipeline the file's colour tags (without the profile look, applied at load) and the white - /// the pixels were developed for, and a custom white balance renders a neutral differently - /// from the Bradford adaptation it replaces. - #[test] - fn dng_white_balance_uses_the_camera_colour_model() { - use lightcraft_pipeline::{RenderRequest, render}; - let dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let raw = lightcraft_raw::decode(&dng).unwrap(); - let (img, info) = load_bytes(&dng, 64).unwrap(); - let cc = info.camera_color.as_ref().expect("a DNG with a colour matrix"); - assert_eq!(cc.developed_for, lightcraft_raw::color::as_shot_white_xy(&raw)); - assert!(cc.tags.profile.is_empty()); - assert_eq!(cc.tags.color_matrix, raw.color.color_matrix); - let mut s = lightcraft_develop::DevelopSettings::default(); - (s.wb.mode, s.wb.temp, s.wb.tint) = (lightcraft_develop::WbMode::Custom, 3000.0, 20.0); - let req = RenderRequest::fit(32, 32); - let camera = render(&img, &info, &s, &req).image; - let adapted = render(&img, &SourceInfo { camera_color: None, ..info.clone() }, &s, &req).image; - assert_ne!(camera.data, adapted.data); - } - - /// Apple ProRAW: like Lightroom Classic, the default render ignores the file's - /// `ProfileGainTableMap` (Apple's local tone mapping); a map that would double every pixel - /// changes nothing. - #[test] - fn dng_gain_table_map_is_not_rendered() { - use lightcraft_raw::gaintable::GainTableMap; - let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&plain).unwrap(); - let (before, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); - raw.color.profile.gain_table_map = Some(GainTableMap { - points_v: 1, - points_h: 1, - points_n: 1, - spacing_v: 1.0, - spacing_h: 1.0, - origin_v: 0.0, - origin_h: 0.0, - weights: [0.2, 0.2, 0.2, 0.2, 0.2], - gamma: 1.0, - gains: vec![2.0], - }); - let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - assert!(lightcraft_raw::decode(&with_map).unwrap().color.profile.gain_table_map.is_some(), "the map is kept"); - let (after, _) = load_bytes(&with_map, 64).unwrap(); - assert_eq!(before.data, after.data); - } - - /// The Profile option "Camera local tone mapping" renders the map by position (in the raw's - /// active area, whatever the photo's orientation), and only when it's on. - #[test] - fn dng_gain_table_map_renders_when_asked() { - use lightcraft_pipeline::{RenderRequest, render}; - use lightcraft_raw::gaintable::GainTableMap; - let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); - let mut raw = lightcraft_raw::decode(&plain).unwrap(); - // ×1 at the active area's left edge rising to ×4 at its right edge, whatever the colour - raw.color.profile.gain_table_map = Some(GainTableMap { - points_v: 1, - points_h: 2, - points_n: 1, - spacing_v: 1.0, - spacing_h: 1.0, - origin_v: 0.0, - origin_h: 0.0, - weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0], - gamma: 1.0, - gains: vec![1.0, 4.0], - }); - let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); - let (img, info) = load_bytes(&with_map, 64).unwrap(); - let (_, plain_info) = load_bytes(&plain, 64).unwrap(); - assert!(info.local_tone.is_some() && plain_info.local_tone.is_none()); - let req = RenderRequest::fit(64, 64); - let mut s = lightcraft_develop::DevelopSettings::default(); - // (darker, so that ×4 never reaches white) - s.light.exposure = -3.0; - let off = render(&img, &info, &s, &req).image; - assert_eq!(off.data, render(&img, &plain_info, &s, &req).image.data, "off: the map changes nothing"); - s.profile.camera_local_tone = true; - assert!(s.to_json().to_string().contains("camera_local_tone"), "the option is part of the settings (and their hash)"); - let on = render(&img, &info, &s, &req).image; - // brightness gained, per column: none at the left edge, most at the right - let column = |im: &lightcraft_raster::Rgba8, x: usize| (0..im.height).map(|y| im.data[y * im.width + x][1] as f32).sum::(); - let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); - let last = on.width - 1; - assert!((gain(0) - 1.0).abs() < 0.08, "left ×{}", gain(0)); - assert!(gain(last) > 1.4 && gain(last) > gain(last / 2) && gain(last / 2) > gain(0), "{} {} {}", gain(0), gain(last / 2), gain(last)); - // the photo turned upside down: the map follows the raw, so the brightened side swaps - s.orientation = lightcraft_geom::Orientation::Rotate180; - s.profile.camera_local_tone = false; - let off = render(&img, &info, &s, &req).image; - s.profile.camera_local_tone = true; - let on = render(&img, &info, &s, &req).image; - let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); - assert!(gain(0) > 1.4 && (gain(last) - 1.0).abs() < 0.08, "rotated: {} … {}", gain(0), gain(last)); - // a rendered (non-raw) source never gets it - let jpeg = SourceInfo { raw: false, ..info.clone() }; - assert!(!lightcraft_pipeline::local_tone::enabled(&jpeg, &s)); - } - - #[test] - fn unsupported_raw_falls_back_to_embedded_preview() { - let b = cr3_with_preview(48, 32); - let p = probe_bytes("x.cr3", &b).unwrap(); - assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); - let (img, src) = load_bytes(&b, 24).unwrap(); - assert_eq!((img.width, img.height), (24, 16)); - assert!(!src.raw); - // no preview at all: a clear error, not a panic - assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err()); - } - - /// Issue #10: a raw variant we can't decode imports as "preview only" with the decoder's - /// reason, is rendered as the rendered JPEG it is (not as raw), says so to agents, survives a - /// library reopen, and Reload / Relink clear it once the file decodes (undoably). - #[test] - fn unsupported_raw_imports_as_preview_only_and_survives_reopen() { - use serde_json::json; - let dir = std::env::temp_dir().join(format!("lc-preview-only-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let (lib, files) = (dir.join("lib"), dir.join("files")); - std::fs::create_dir_all(&files).unwrap(); - let path = files.join("DSC_0001.cr3"); - std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); - let mut s = crate::Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - // the import review already says so - let scan = s.execute("library.importPreview", &json!({"paths": [path.to_string_lossy()]})).unwrap(); - assert!(scan["candidates"][0]["previewOnly"].as_str().is_some_and(|w| !w.is_empty()), "{scan}"); - let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); - let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); - let p = s.catalog.photo(id).unwrap().clone(); - let why = p.preview_only.clone().expect("marked preview only"); - assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}"); - // stamped with the version that decided it, so a later version rechecks it (#662) - assert_eq!(p.preview_checked, lightcraft_catalog::APP_VERSION); - assert!(!p.preview_only_stale()); - assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)"); - assert!(!p.develops_raw()); - // rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults - assert_eq!(SourceInfo { native_long: None, ..crate::media::source_info(&p) }, SourceInfo::default()); - assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default()); - assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok()); - // agents see it - let q = s.execute("catalog.query", &json!({"filter": {}})).unwrap(); - assert_eq!(q["photos"][0]["previewOnly"], json!(why), "{q}"); - let i = s.execute("photo.inspect", &json!({"id": id.0})).unwrap(); - assert_eq!(i["preview_only"], json!(why), "{i}"); - // a library reopen keeps it - s.persist().unwrap(); - drop(s); - let mut s = crate::Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); - assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); - // the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back - std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); - let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap(); - assert_eq!(r["reloaded"], json!([id.0]), "{r}"); - assert!(s.catalog.photo(id).unwrap().develops_raw()); - assert_eq!(s.catalog.photo(id).unwrap().preview_checked, "", "the stamp goes with the flag"); - assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); - assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); - // relinking to a file that decodes clears it too - let dng = files.join("DSC_0001.dng"); - std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); - s.execute("photo.relink", &json!({"id": id.0, "path": dng.to_string_lossy()})).unwrap(); - assert_eq!(s.catalog.photo(id).unwrap().preview_only, None); - drop(s); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A raw whose TIFF shell holds only a small RGB image (IFD0) next to a private block, as Exif - /// declares a picture 25x its size. - fn tiff_shell(w: u32, h: u32, shell: bool) -> Vec { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); - ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); - ifd0.set(t::COMPRESSION, Value::Short(vec![1])); - let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - ifd0.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![px] }); - ifd0.set_child( - t::EXIF_IFD, - IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w * 25])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h * 25])), - ); - let mut b = TiffWriter::default().write(&[ifd0]).unwrap(); - if shell { - b.extend(std::iter::repeat_n(0x5au8, (w * h * 625 / 8) as usize + 100)); - } - b - } - - /// A TIFF raw whose only image is a small thumbnail used to open as a plain image of that size - /// ("a 150 MP file renders 296x220"), with nothing saying so. Now it is recognised as a raw we can't - /// decode: preview only, with the reason, still showing that image. - #[test] - fn thumbnail_only_tiff_raw_is_preview_only_not_a_plain_image() { - let b = tiff_shell(24, 16, true); - let p = probe_bytes("IMG_0001.IIQ", &b).unwrap(); - assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "IIQ", 24, 16)); - let why = p.preview_only.expect("marked preview only"); - assert!(why.contains("600x400") && why.contains("24x16"), "{why}"); - let (img, src) = load_bytes(&b, 12).unwrap(); - assert_eq!((img.width, img.height, src.raw), (12, 8, false)); - assert!(embedded_preview_srgb(&b, 12).is_some()); - // the same pixels without the private block are a small ordinary image - let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap(); - assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16)); - assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none()); - - // TIFFs preserving camera Make tags (SONY, NIKON, etc.) are ordinary images, not raws (#281) - for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![24])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![16])); - ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); - ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); - ifd0.set(t::COMPRESSION, Value::Short(vec![1])); - ifd0.set(t::MAKE, Value::Ascii(make.into())); - let px: Vec = (0..24 * 16).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - ifd0.set_image(ImageData::Strips { rows_per_strip: 16, strips: vec![px] }); - let bytes = TiffWriter::default().write(&[ifd0]).unwrap(); - let p = probe_bytes("photo.tif", &bytes).unwrap(); - assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Image, None, 24, 16), "make: {make}"); - let (img, src) = load_bytes(&bytes, 24).unwrap(); - assert_eq!((img.width, img.height, src.raw), (24, 16, false), "make: {make}"); - } - } - - /// Recognised raw containers we can't decode (an MRW whose block header is damaged here; its preview's first - /// byte is overwritten, as in real MRW files) import as preview only with the JPEG's size, instead of failing. - #[test] - fn undecodable_container_with_a_preview_imports_as_preview_only() { - let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - let mut jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); - jpeg[0] = 0x02; - let mut f = b"\0MRM\0\x01\0\0".to_vec(); - f.extend(std::iter::repeat_n(0x11u8, 300)); - f.extend_from_slice(&jpeg); - f.extend(std::iter::repeat_n(0x22u8, 300)); - let p = probe_bytes("A.MRW", &f).unwrap(); - assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MRW", 40, 30)); - assert!(p.preview_only.is_some_and(|w| w.contains("Mrw"))); - let (img, src) = load_bytes(&f, 20).unwrap(); - assert_eq!((img.width, img.height, src.raw), (20, 15, false)); - // without a JPEG: a clear error - let e = probe_bytes("A.MRW", b"\0MRM\0\x01\0\0 nothing here").unwrap_err(); - assert!(e.contains("without an embedded preview"), "{e}"); - } - - /// Issue #1275: an uncompressed Leaf MOS (IFD0 is the 16-bit mosaic, no Make tag; Leaf's data block `0x8606` - /// holds the preview) imports as a raw shown from its preview, not as a greyscale image of the mosaic. - #[test] - fn uncompressed_leaf_mos_imports_as_a_preview_only_raw() { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); - let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); - let mos = |leaf_data: bool| { - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![16])); - ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![12])); - ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - ifd0.set(t::PHOTOMETRIC, Value::Short(vec![1])); - ifd0.set(t::COMPRESSION, Value::Short(vec![1])); - ifd0.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x30; 16 * 12 * 2]] }); - if leaf_data { - ifd0.set(0x8606, Value::Undefined([&b"PKTS\0\0\0\x01"[..], &jpeg].concat())); - } - TiffWriter::default().write(&[ifd0]).unwrap() - }; - let p = probe_bytes("aptus22.mos", &mos(true)).unwrap(); - assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MOS", 40, 30)); - assert!(p.preview_only.is_some_and(|why| !why.is_empty())); - let (img, src) = load_bytes(&mos(true), 20).unwrap(); - assert_eq!((img.width, img.height, src.raw), (20, 15, false)); - // the same grey image without Leaf's block is an ordinary 16-bit TIFF - let plain = probe_bytes("grey.tif", &mos(false)).unwrap(); - assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 16, 12)); - } - - /// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its - /// headers; loading it says what is wrong instead of naming a bare TIFF compression number. - #[test] - fn gopro_vc5_dng_imports_and_fails_to_load_with_a_clear_reason() { - use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; - let mut ifd = IfdBuilder::new(); - ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32])); - ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16])); - ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803])); - ifd.set(t::COMPRESSION, Value::Short(vec![9])); - ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); - ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); - ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); - ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] }); - let f = TiffWriter::default().write(&[ifd]).unwrap(); - let p = probe_bytes("GOPR0001.GPR", &f).unwrap(); - assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Raw, None, 32, 16)); - let e = load_bytes(&f, 16).unwrap_err(); - assert!(e.contains("GoPro VC-5") && e.contains("not decoded yet"), "{e}"); - } - - /// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview; - /// CR3 keeps its own wording. - #[test] - fn decode_failures_of_recognised_raws_may_fall_back_to_the_preview() { - use lightcraft_raw::RawError; - let cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01"; - assert_eq!(preview_reason(b"x", RawError::Unsupported("x".into())), Ok("x".to_string())); - assert!(preview_reason(b"x", RawError::Corrupt("bad strip".into())).is_ok_and(|w| w.contains("bad strip"))); - assert!(preview_reason(b"x", RawError::Limit("too big")).is_ok()); - assert!(preview_reason(b"x", RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(256))).is_ok()); - assert!(preview_reason(b"x", RawError::NotRaw).is_err()); - assert!(preview_reason(cr3, RawError::Corrupt("bad".into())).is_ok_and(|w| w.starts_with("CR3 "))); - } - - #[test] - fn quick_jobs_show_embedded_previews_then_cached_renders() { - use crate::media::QuickSource; - let dir = std::env::temp_dir().join(format!("lc-quick-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("a.cr3"); - std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); - let mut s = crate::Session::new().with_fs(); - let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); - let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); - // loupe: an unedited raw opens on its embedded preview… - let q = s.quick_view_job(id, 1600, true).unwrap().run(); - assert_eq!(q.quick, Some(QuickSource::Embedded)); - let img = q.rendered.unwrap().image; - assert_eq!((img.width, img.height), (96, 64)); - // …and once the loupe has rendered it, on that render (any size) - let full = s.loupe_job(id, 48, 32, true).unwrap().run(); - assert!(full.rendered.is_ok() && full.quick.is_none()); - let q = s.quick_view_job(id, 1600, true).unwrap().run(); - assert_eq!(q.quick, Some(QuickSource::Cached)); - assert_eq!(q.rendered.unwrap().image.width, 48); - // grid: embedded first, then the real thumbnail (final, with the thumbnail job's key) - let job = s.thumb_job(id, 128).unwrap(); - let q = s.quick_thumb_job(&job).unwrap().run(); - assert_eq!((q.quick, q.key), (Some(QuickSource::Embedded), job.key)); - drop(job.clone().run()); - assert_eq!(s.quick_thumb_job(&job).unwrap().run().quick, Some(QuickSource::Cached)); - // an edited raw: no embedded stand-in (it wouldn't show the edit) - s.execute("library.select", &serde_json::json!({"ids": [id.0]})).unwrap(); - s.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": 1.0})).unwrap(); - let job = s.thumb_job(id, 128).unwrap(); - assert!(s.quick_thumb_job(&job).is_none()); - assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small)); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Issue #1448: the grid renders nothing behind an unedited raw's quick thumbnail, so when the - /// embedded preview can't be read (and nothing is cached) the quick job itself must render the - /// raw, or the cell stays empty for good ("no quick preview"). - #[test] - fn a_quick_thumbnail_without_an_embedded_preview_renders_the_raw() { - use crate::media::QuickSource; - let dir = std::env::temp_dir().join(format!("lc-quick-none-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("a.cr3"); - std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); - let mut s = crate::Session::new().with_fs(); - let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); - let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); - s.media.preview_loader = Some(std::sync::Arc::new(|_: &str, _| None)); - let job = s.thumb_job(id, 128).unwrap(); - let q = s.quick_thumb_job(&job).unwrap().run(); - assert_eq!((q.quick, q.key), (Some(QuickSource::Small), job.key)); - assert!(q.rendered.is_ok(), "{:?}", q.rendered.err()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/it/vendor_robust.rs` - /// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037). - fn sony_arw(model: &str) -> Vec { - use lightcraft_tiff::tags::{self as t, photometric}; - use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; - // 3:2, the aspect Sony's lens opcodes are limited to - let (w, h) = (72u32, 48u32); - let mut raw = IfdBuilder::new(); - raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); - raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14])); - raw.set(t::COMPRESSION, Value::Short(vec![1])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); - raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); - raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); - raw.set(0x7037, Value::SShort(vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479])); - raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![2000u16.to_le_bytes().repeat((w * h) as usize)] }); - let mut ifd0 = IfdBuilder::new(); - ifd0.set(t::MAKE, Value::Ascii("SONY".into())); - ifd0.set(t::MODEL, Value::Ascii(model.into())); - ifd0.add_sub_ifd(raw); - TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() - } - - /// Sony's embedded distortion correction is on by default: an ARW of a verified body imports with "Enable Lens - /// Corrections" on and still counts as unedited; another body's tables aren't read, so its correction is off. - #[test] - fn arw_with_lens_data_imports_with_profile_corrections_on() { - let dir = std::env::temp_dir().join(format!("lc-arw-lens-default-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let mut s = crate::Session::new().with_fs(); - for (model, on) in [("ILCE-7RM6", true), ("ILCE-7M4", false)] { - let path = dir.join(format!("{model}.arw")); - std::fs::write(&path, sony_arw(model)).unwrap(); - let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); - let p = s.catalog.photo(lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap())).unwrap(); - assert_eq!((p.format.as_str(), p.embedded_lens.is_some(), p.develop.optics.lens_profile), ("ARW", on, on), "{model}"); - assert!(!p.is_edited(), "{model}: as imported"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// RW2 and DNG keep their default: the correction is on when the file carries one (RW2 distortion, a DNG's - /// opcodes), off when it doesn't. - #[test] - fn rw2_and_dng_defaults_unchanged() { - use lightcraft_catalog::{Photo, PhotoId, Source}; - let s = crate::Session::new(); - let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), ..Default::default() }; - for format in ["RW2", "DNG"] { - for embedded in [None, Some(lens)] { - let name = format!("x.{format}"); - let mut p = Photo::new(PhotoId(1), Source::File { path: format!("/{name}") }, &name, format, 64, 48, ""); - p.kind = MediaKind::Raw; - p.embedded_lens = embedded; - crate::import::apply_import_defaults(&s, &mut p); - assert_eq!(p.develop.optics.lens_profile, embedded.is_some(), "{format} {embedded:?}"); - } - } - } - - /// Stored settings stay as they are when the library opens again: an ARW catalogued before its lens data was read - /// (none, correction off) keeps the correction off and counts as unedited, and one whose user switched the - /// correction off keeps it off. - #[test] - fn existing_photo_settings_are_not_rewritten() { - use lightcraft_catalog::{Op, PhotoId}; - let dir = std::env::temp_dir().join(format!("lc-arw-lens-existing-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let (lib, files) = (dir.join("lib"), dir.join("files")); - std::fs::create_dir_all(&files).unwrap(); - let mut s = crate::Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - let import = |s: &mut crate::Session, name: &str| { - let path = files.join(name); - // trailing bytes of its own: not a duplicate of the other file - std::fs::write(&path, [sony_arw("ILCE-7RM6"), name.as_bytes().to_vec()].concat()).unwrap(); - let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); - PhotoId(r["imported"][0].as_u64().unwrap()) - }; - let old = import(&mut s, "old.arw"); - // as a release that didn't read the Sony tables catalogued it - let mut d = (*s.catalog.photo(old).unwrap().develop).clone(); - d.optics.lens_profile = false; - let ops = vec![ - Op::SetEmbeddedLens { id: old, lens: None }, - Op::SetDevelop { id: old, settings: std::sync::Arc::new(d), label: "x".into(), edited: None }, - ]; - s.commit("x", Op::Batch { ops }).unwrap(); - let off = import(&mut s, "off.arw"); - s.execute("develop.merge", &serde_json::json!({"settings": {"optics": {"lens_profile": false}}})).unwrap(); - let before: Vec<_> = [old, off].iter().map(|id| s.catalog.photo(*id).unwrap().clone()).collect(); - assert!(before.iter().all(|p| !p.develop.optics.lens_profile)); - s.persist().unwrap(); - drop(s); - let mut s = crate::Session::new().with_fs(); - s.open_library(&lib, false).unwrap(); - for b in &before { - let p = s.catalog.photo(b.id).unwrap(); - assert_eq!((&p.develop, &p.embedded_lens), (&b.develop, &b.embedded_lens), "{}", p.file_name); - } - assert!(!s.catalog.photo(old).unwrap().is_edited(), "still as imported"); - drop(s); - let _ = std::fs::remove_dir_all(&dir); - } -} +pub(crate) mod tests; diff --git a/crates/engine/src/files/tests.rs b/crates/engine/src/files/tests.rs new file mode 100644 index 000000000..d266ad35a --- /dev/null +++ b/crates/engine/src/files/tests.rs @@ -0,0 +1,1782 @@ +/// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the +/// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides +/// them; an exported `travel|Italy|Rome` comes back as itself. +#[test] +fn a_lens_without_a_name_is_shown_by_its_range() { + // a Nikon DSLR raw: no LensModel, the range from the maker note + let m = lightcraft_meta::Metadata { lens_spec: Some([18.0, 135.0, 3.5, 5.6]), ..Default::default() }; + assert_eq!(super::meta_of(&m).0.lens, "18.0-135.0 mm f/3.5-5.6"); + let named = lightcraft_meta::Metadata { + lens_model: Some("NIKKOR Z 24-70mm f/4 S".into()), + lens_spec: Some([24.0, 70.0, 4.0, 4.0]), + ..Default::default() + }; + assert_eq!(super::meta_of(&named).0.lens, "NIKKOR Z 24-70mm f/4 S"); + assert_eq!(super::meta_of(&lightcraft_meta::Metadata::default()).0.lens, ""); +} + +#[test] +fn imported_keywords_keep_their_hierarchy() { + let m = lightcraft_meta::Metadata { + keywords: vec!["Rome".into(), "Italy".into(), "travel".into(), "beach".into()], + hierarchical_keywords: vec!["travel|Italy|Rome".into()], + ..Default::default() + }; + assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]); +} + +#[test] +fn psd_probe_reads_embedded_capture_time_gps_and_title() { + let capture = lightcraft_meta::DateTime::parse_iso("2023-05-14T14:40:39").unwrap(); + let metadata = lightcraft_meta::Metadata { + capture_time: Some(capture), + gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), + title: Some("PSD metadata".into()), + ..Default::default() + }; + let bytes = crate::tests_xmp::synthetic_psd_with_metadata(&metadata); + let probe = probe_bytes("metadata.psd", &bytes).unwrap(); + assert_eq!(probe.captured.as_deref(), Some("2023-05-14T14:40:39")); + assert_eq!(probe.meta.gps, Some((45.0, 7.0))); + assert_eq!(probe.meta.title, "PSD metadata"); +} + +use super::*; +use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + +/// Which white-balance scale a raw gets (issue #730): a file with matrices of its own is absolute from them; a +/// fitted-look file is absolute through its camera's measured matrices, which only read the as-shot +/// multipliers as Kelvin; any other raw stays relative (6500 / 0 = as shot). The catalog's decision from the +/// format and camera string agrees. +#[test] +fn white_balance_scale_follows_the_files_matrices_then_the_cameras() { + use lightcraft_raw::{ColorData, RawFormat}; + let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + let mul = Some([2.2f32, 1.0, 1.6]); + let d65 = lightcraft_color::D65; + // a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe + let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3")); + assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some()); + let (t, tint) = a7m3.as_shot(d65); + assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3")); + // the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65) + let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3")); + assert!(bare.legacy_relative && bare.scale_white.is_none()); + assert_eq!(bare.as_shot(d65), { + let (t, tint) = xy_to_temp_tint(d65); + (t.round(), tint.round()) + }); + // a camera without measured matrices: relative, whatever the multipliers + let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2")); + assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none()); + assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0)); + assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2")); + // a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as + // 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65) + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]); + let with = ColorData { kelvin_presets: presets, ..Default::default() }; + let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2")); + assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some()); + let (t, tint) = a99m2.as_shot(d65); + assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2")); + // a Canon body without matrices, through the presets its ColorData lists (PowerShot G1 X Mark III, a + // Daylight shot with the camera's white balance shifted towards blue: below Daylight's 5651 K) + let canon = lightcraft_raw::kelvin_presets::KelvinPresets::from_unordered([ + (5651.0, g(1418.0, 1357.0)), + (7505.0, g(1653.0, 1171.0)), + (6594.0, g(1533.0, 1259.0)), + (3174.0, g(963.0, 2016.0)), + (8529.0, g(1807.0, 1080.0)), + ]); + let with_canon = ColorData { kelvin_presets: canon, ..Default::default() }; + let g1x3 = WbScaleOf::new(RawFormat::Cr2, &with_canon, Some(g(1264.0, 1537.0)), &meta("Canon", "Canon PowerShot G1 X Mark III")); + assert!(!g1x3.relative && g1x3.scale_white.is_some()); + let (t, tint) = g1x3.as_shot(d65); + assert!((4300.0..5200.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("CR2", "Canon Canon PowerShot G1 X Mark III")); + let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2")); + assert!(bare.legacy_relative && bare.scale_white.is_none()); + // presets in a file of a camera not listed: relative, as the catalog decides from the camera alone + let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1")); + assert!(other.relative && other.scale_white.is_none()); + let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default()); + assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0)); + // a file with matrices of its own (DNG): absolute from them, no matter whose camera + let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() }; + let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3")); + assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none()); + let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0); + assert_eq!(dng.as_shot(xy), (4200.0, 12.0)); + assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); +} + +/// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source +/// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative +/// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets +/// opened at 6113–7307 K / +115 where the ARWs show 3932–5268 K / +7. +#[test] +fn a_merged_dng_shows_the_white_balance_its_source_shows() { + use lightcraft_raw::{ColorData, RawFormat}; + let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]); + let with_presets = ColorData { kelvin_presets: presets, ..Default::default() }; + let cases = [ + (RawFormat::Arw, ColorData::default(), [2.5039f32, 1.0, 1.5078], meta("SONY", "ILCE-7M3")), + (RawFormat::Arw, with_presets, g(2500.0, 1668.0), meta("SONY", "ILCA-99M2")), + (RawFormat::Arw, ColorData::default(), [2.2, 1.0, 1.6], meta("SONY", "ILCE-7M2")), + (RawFormat::Rw2, ColorData::default(), [2.0, 1.0, 1.4], meta("Panasonic", "DC-G9")), + ]; + for (format, color, mul, meta) in cases { + let own = lightcraft_raw::color::as_shot_white_of(&color, Some(mul)); + let source = WbScaleOf::new(format, &color, Some(mul), &meta); + let t = lightcraft_raw::color::camera_transform_of(&color, own); + let dng = crate::camera_preview::dng_colour(&color, &source.dng_white(&color, own), &t, None).unwrap(); + let merged = WbScaleOf::new(RawFormat::Dng, &dng, None, &meta); + let shown = source.as_shot(own); + assert_eq!(merged.as_shot(lightcraft_raw::color::as_shot_white_of(&dng, None)), shown, "{:?}", meta.model); + } +} + +/// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need. +fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec { + let head = 160; + let mut f = vec![0u8; head]; + f.get_mut(..16).expect("head").copy_from_slice(b"FUJIFILMCCD-RAW "); + f.get_mut(84..88).expect("jpeg offset").copy_from_slice(&(head as u32).to_be_bytes()); + f.get_mut(88..92).expect("jpeg length").copy_from_slice(&(jpeg.len() as u32).to_be_bytes()); + f.extend_from_slice(jpeg); + f.extend(std::iter::repeat_n(0x33u8, tail)); + f +} + +/// A raw's preview is read from its header and its JPEG only. Every preview used to cost a +/// whole-file read, which on a 56 MB RAF over a network share is ten times the bytes and ten +/// times the wait (measured: 2.5 s local against 25.6 s over SMB, DSCF9492.RAF). The pixels the +/// quick path produces must equal the whole-file path's, exactly. +#[test] +fn preview_loading_reads_only_the_header_and_the_jpeg() { + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 13 % 251) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let file = raf_with_embedded_jpeg(&jpeg, 200_000); + let dir = crate::tests_xmp::temp_dir("preview-window"); + let path = dir.join("dscf0001.raf"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + let max_edge = 512; + + let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("it has a preview"); + assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded"); + assert!(read.header_path, "the header path is what answered"); + assert!(!read.planned, "a RAF pointer in the header needs no planner"); + assert_eq!(read.total, file.len() as u64); + assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}"); + assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len()); + + // The quick path and the whole-file path agree byte for byte. + let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview"); + let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); + assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); + assert_eq!(quick.data, whole.data, "byte for byte"); + + // A file with no locatable preview declines (the caller then reads it whole), a missing file errors. + let plain = dir.join("plain.jpg"); + std::fs::write(&plain, &jpeg).unwrap(); + assert!(preview_window(&plain.to_string_lossy(), max_edge).expect("a plain file reads").is_none()); + assert!(preview_window(&dir.join("nope.raf").to_string_lossy(), max_edge).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// What a fixture's IFD0 says about itself: the tag that makes `probe` call the file a raw, the +/// file's own orientation, and the EXIF colour space the container declares for its preview. +#[derive(Clone, Copy, Default)] +struct Fixture { + dng: bool, + sony: bool, + orientation: Option, + /// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB. + color_space: Option, +} + +/// A TIFF-family raw no header field points into: IFD0 is the JPEG-compressed preview strip (the +/// shape a DNG's `PreviewImage`, a CR2's or an ARW's preview takes), IFD1 a CFA "sensor" strip of +/// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than +/// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach. +fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec { + tiff_with_preview_strips(strip, &[], tail, what) +} + +/// [`tiff_with_preview_strip`] that also keeps the `smaller` JPEGs in sub-IFDs of IFD0, as an ARW keeps its +/// 1616 px preview beside the full-size one. +fn tiff_with_preview_strips(strip: &[u8], smaller: &[&[u8]], tail: usize, what: Fixture) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut preview = IfdBuilder::new(); + if what.dng { + preview.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + } + if what.sony { + preview.set(t::MAKE, Value::Ascii("SONY".into())); + } + if let Some(o) = what.orientation { + preview.set(t::ORIENTATION, Value::Short(vec![o])); + } + if let Some(space) = what.color_space { + let mut exif = IfdBuilder::new(); + exif.set(0xA001, Value::Short(vec![space])); + if space == 0xFFFF { + let mut interop = IfdBuilder::new(); + interop.set(0x0001, Value::Undefined(b"R03".to_vec())); + exif.set_child(t::INTEROP_IFD, interop); + } + preview.set_child(t::EXIF_IFD, exif); + } + preview.set(t::COMPRESSION, Value::Short(vec![7])); + preview.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); + preview.set(t::IMAGE_WIDTH, Value::Long(vec![48])); + preview.set(t::IMAGE_LENGTH, Value::Long(vec![32])); + preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] }); + for jpeg in smaller { + let mut sub = IfdBuilder::new(); + sub.set(t::COMPRESSION, Value::Short(vec![7])); + sub.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); + sub.set(t::IMAGE_WIDTH, Value::Long(vec![48])); + sub.set(t::IMAGE_LENGTH, Value::Long(vec![32])); + sub.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![jpeg.to_vec()] }); + preview.add_sub_ifd(sub); + } + let mut raw = IfdBuilder::new(); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000])); + raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] }); + TiffWriter::default().write(&[preview, raw]).expect("the fixture writes") +} + +/// A raw whose preview only its tags locate — a Canon CR3's JPEG track, a TIFF raw whose preview +/// tag sits past the prefix — is read in part too: `sparse::preview_plan` names the ranges +/// `embedded_preview` reads, so the preview strip is read and the sensor strip is not. The pixels +/// must equal the whole-file path's, exactly; a file the planner cannot describe must decline +/// rather than guess, so the caller keeps reading it whole. +#[test] +fn preview_loading_plans_the_ranges_it_needs() { + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 29 % 241) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let file = tiff_with_preview_strip(&jpeg, 400_000, Fixture { dng: true, ..Default::default() }); + let dir = crate::tests_xmp::temp_dir("preview-plan"); + let path = dir.join("preview.dng"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + let max_edge = 512; + + let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("the plan locates its preview"); + assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded"); + assert!(read.planned, "the planner is what answered"); + assert!(read.header_path, "and it is still a bounded read"); + assert_eq!(read.total, file.len() as u64); + assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?} of {} bytes", file.len()); + assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len()); + + // The plan path and the whole-file path agree byte for byte. + let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview"); + let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); + assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); + assert_eq!(quick.data, whole.data, "byte for byte"); + + // The same structure without the tag that makes it a raw: the planner declines, and the + // caller's whole-file read is what answers (no guessing at what a file might hold). + let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default()); + std::fs::write(&path, ¬_a_raw).unwrap(); + assert!(preview_window(&path, max_edge).expect("the file reads").is_none(), "an unknown container falls back"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A raw that keeps several JPEGs is shown, on the plan route as on the whole-file path, from the smallest one that +/// covers the box (`embedded_preview_covering`). The plan route used to hand on the largest whatever the size: an +/// A7R VI ARW's 9984 × 6656 JPEG for a 512 px thumbnail instead of its 1616 × 1080 one. +#[test] +fn preview_loading_plans_the_smallest_covering_jpeg() { + let jpeg = |w: u32, h: u32| { + let rgb: Vec = (0..w * h * 3).map(|i| (i * 31 % 247) as u8).collect(); + encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&rgb)), 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap() + }; + let (big, small) = (jpeg(96, 64), jpeg(48, 32)); + assert!(big.len() > small.len(), "the full-size JPEG is the largest"); + let file = tiff_with_preview_strips(&big, &[&small], 400_000, Fixture { dng: true, ..Default::default() }); + let dir = crate::tests_xmp::temp_dir("preview-plan-covering"); + let path = dir.join("two-previews.dng"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + + for (edge, want, which) in [(48, &small, "small"), (49, &big, "full-size"), (usize::MAX, &big, "full-size")] { + let (header, window, read) = preview_window(&path, edge).expect("the file reads").expect("the plan locates its preview"); + assert!(read.planned, "the planner is what answered"); + assert!(&window == want, "a {edge} px box takes the {which} JPEG"); + let quick = quick_image_srgb(&header, &window, read.adobe_rgb, edge).expect("the window holds a decodable preview"); + let whole = embedded_preview_srgb(&file, edge).expect("the whole file decodes too"); + assert_eq!( + (quick.width, quick.height, quick.data), + (whole.width, whole.height, whole.data), + "{edge} px: the plan and the whole file agree" + ); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// A file whose preview the quick path could not show the way the whole-file path does is left to +/// the whole file. Here the orientation tag sits in an IFD0 past the bounded header (its chunk is +/// bigger than that header) and the preview JPEG carries none of its own, so a window read would +/// come out unrotated where the whole-file path rotates it — the plan must decline instead of +/// handing on a wrong picture. +#[test] +fn preview_loading_declines_an_orientation_only_the_whole_file_shows() { + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 17 % 239) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + // the strip (what the IFD's chunk covers) is padded past the plan's header, which is what puts + // IFD0 out of its reach; `embedded_preview` trims back to the JPEG's end-of-image + let mut strip = jpeg.clone(); + strip.extend(std::iter::repeat_n(0u8, PLAN_PREFIX as usize)); + let file = tiff_with_preview_strip(&strip, 400_000, Fixture { dng: true, orientation: Some(6), ..Default::default() }); + let dir = crate::tests_xmp::temp_dir("preview-plan-orientation"); + let path = dir.join("rotated.dng"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + + assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation"); + assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see"); + assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own"); + assert!(preview_window(&path, usize::MAX).expect("the file reads").is_none(), "the whole-file path keeps it"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A container can declare what its preview JPEG is in, and the whole-file path applies that +/// declaration (`decode_raw_preview`). The planned path reads it too and the quick decode applies +/// it, so a container that declares Adobe RGB (a Sony α1's ARW) is read in part like any other and +/// shows the same pixels as the whole-file path (issue #1440; it used to be read whole) — and one +/// that declares sRGB (what most bodies write) keeps the fast path as before. +#[test] +fn preview_loading_applies_a_container_colour_space() { + use lightcraft_raw::PreviewColorSpace; + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let dir = crate::tests_xmp::temp_dir("preview-plan-space"); + let path = dir.join("space.arw"); + let path = path.to_string_lossy().into_owned(); + + let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() }); + assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB"); + std::fs::write(&path, &adobe).unwrap(); + let (header, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("the plan answers for Adobe RGB too"); + assert!(read.planned && read.adobe_rgb, "{read:?}"); + assert!(read.fraction() < 0.5, "the sensor strip is not read: {read:?}"); + let quick = quick_image_srgb(&header, &window, read.adobe_rgb, 512).expect("the window decodes"); + let whole = embedded_preview_srgb(&adobe, 512).expect("the whole file decodes too"); + assert_eq!(quick.data, whole.data, "the space is applied as the whole-file path applies it"); + let plain = quick_image_srgb(&header, &window, false, 512).expect("the window decodes"); + assert_ne!(plain.data, whole.data, "and it changes the pixels (the fixture's colours are saturated enough)"); + + let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() }); + assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB"); + std::fs::write(&path, &srgb).unwrap(); + let (_, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes"); + assert!(read.planned && !read.adobe_rgb, "the plan still answers"); + assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG"); +} + +/// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as +/// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time. +#[test] +fn undated_files_take_the_file_date() { + let dir = std::env::temp_dir().join(format!("lightcraft-undated-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let rgb = vec![128u8; 16 * 16 * 3]; + let img = EncodeImage::new(16, 16, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let path = dir.join("export.jpg"); + std::fs::write(&path, &jpeg).unwrap(); + let path = path.to_string_lossy().to_string(); + let mtime = std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let want = crate::import::civil(mtime as i64); + let (_, probe) = fs_hooks(); + let info = probe(&path).unwrap(); + assert_eq!(info.captured, None, "the file itself has no date"); + assert_eq!(info.file_modified.as_deref(), Some(want.as_str())); + let mut s = crate::Session::new().with_fs(); + let r = crate::import::import(&mut s, &[path], crate::import::ImportMode::Add).unwrap(); + let id = lightcraft_catalog::PhotoId(r.imported[0]); + assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some(want.as_str())); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: +/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated +/// file or one that isn't an image. +#[test] +fn image_probe_reads_headers_like_the_decode_did() { + let (w, h) = (300u32, 200u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + for o in [None, Some(1), Some(6), Some(3), Some(8)] { + let exif = o.map(lightcraft_codecs::exif::minimal_exif); + let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() }; + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap(); + let png = lightcraft_codecs::encode_png(&img, &meta).unwrap(); + for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] { + let p = probe_bytes(name, bytes).unwrap(); + // what the probe computed from a decode before + let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap(); + let swap = Orientation::from_exif(d.orientation).swaps_axes(); + let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) }; + assert_eq!((p.width, p.height), want, "{name} {o:?}"); + assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}"); + assert_eq!(p.kind, MediaKind::Image); + assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" }); + assert_eq!(p.file_size, bytes.len() as u64); + assert!(p.content_hash.is_some()); + assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted"); + } + } + assert!(probe_bytes("x.jpg", b"not an image").is_err()); +} + +/// Issue #1212: a transparent PNG develops on white (what renders, previews, exports and contact sheets +/// start from), not on the black its transparent pixels store. +#[test] +fn transparent_png_develops_on_white() { + let rgba = [200, 40, 40, 255, 0, 0, 0, 0]; + let png = lightcraft_codecs::encode_png(&EncodeImage::new(2, 1, 4, Samples::U8(&rgba)), &EncodeMeta::default()).unwrap(); + let (img, _) = load_bytes(&png, 64).unwrap(); + assert!(img.get(0, 0)[0] > 0.3 && img.get(0, 0)[1] < 0.1, "{:?}", img.get(0, 0)); + assert!(img.get(1, 0).iter().all(|&c| (c - 1.0).abs() < 1e-4), "{:?}", img.get(1, 0)); +} + +/// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample +/// of a block on a grey surface must not become green speckles (the block's green used to take the +/// clip level, which highlight reconstruction then kept). +#[test] +fn coarse_binning_keeps_specular_points_neutral() { + let (w, h) = (256usize, 192usize); + // grey after the file's white balance (as-shot neutral 0.5 : 1 : 0.7), brighter to the right + let neutral = [0.5f32, 1.0, 0.7]; + let cfa = lightcraft_raw::Cfa::bayer("RGGB").unwrap(); + let mut data: Vec = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let v = (0.3 + 0.2 * x as f32 / w as f32) * neutral[cfa.color_at(x, y).min(2) as usize]; + (256.0 + v * (16000.0 - 256.0)).round() as u16 + }) + .collect(); + // one clipped green sample (RGGB: even row, odd column) in every other 8 × 8 block + for by in 0..h / 8 { + for bx in (by % 2..w / 8).step_by(2) { + data[(by * 8 + 4) * w + bx * 8 + 5] = 16000; + } + } + let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!(bin_factor(&raw, 32), Some(8)); + let (img, _) = load_bytes(&bytes, 32).unwrap(); + assert_eq!((img.width, img.height), (32, 24)); + let worst = img.data.iter().map(|p| p[1] / ((p[0] + p[2]) / 2.0).max(1e-6)).fold(0.0f32, f32::max); + assert!(worst < 1.12, "green speckles: green {worst:.2}× the other channels"); +} + +#[test] +fn cached_denoise_uses_the_same_white_balance_profile_and_orientation_as_plain_raw() { + use lightcraft_denoise::product; + use lightcraft_raw::{DngWriteOptions, Method, Orientation, profile::HsvTable, write_dng}; + let dir = crate::tests_xmp::temp_dir("denoise-color-finish"); + let fixture = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&fixture).unwrap(); + raw.orientation = Orientation::Rotate90; + raw.color.baseline_exposure = 0.5; + raw.color.profile.look_table = + Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[23.0, 0.65, 0.8]; 2], srgb_value: false }); + let bytes = write_dng(&raw, &DngWriteOptions::default()).unwrap(); + let decoded = lightcraft_raw::decode(&bytes).unwrap(); + assert!(decoded.color.profile.look_table.is_some()); + // A cache containing the ordinary demosaic must undergo the identical downstream finish. + let camera_rgb = decoded.develop(Method::Ahd).unwrap(); + let key = "synthetic-color-finish"; + let path = dir.join("same.lcdn"); + product::write(&path, &camera_rgb, key).unwrap(); + let spec = DenoiseSpec { product: path, key: key.into(), make: None }; + let (ordinary, _) = load_bytes(&bytes, 2048).unwrap(); + let (plain, cached, _) = load_vec_pair(bytes, 2048, &spec).unwrap(); + assert_eq!(plain.data, ordinary.data, "requesting a twin must not change the ordinary path"); + let cached = cached.unwrap(); + assert_eq!((plain.width, plain.height), (24, 32)); + assert_eq!((cached.width, cached.height), (plain.width, plain.height)); + let worst = plain.data.iter().zip(&cached.data).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max); + assert!(worst < 0.002, "color finish differs beyond cache half-float rounding: {worst}"); + let _ = std::fs::remove_dir_all(dir); +} + +/// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG +/// mostly cancel, edges remain. +fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec { + let l: Vec = (0..w * h).map(|i| lum(i % w, i / w)).collect(); + let mut out = vec![0.0; w * h]; + for y in 0..h { + for x in 0..w { + let (x0, x1, y0, y1) = (x.saturating_sub(4), (x + 5).min(w), y.saturating_sub(4), (y + 5).min(h)); + let m = (y0..y1).flat_map(|yy| (x0..x1).map(move |xx| (xx, yy))).map(|(xx, yy)| l[yy * w + xx]).sum::(); + out[y * w + x] = l[y * w + x] - m / ((x1 - x0) * (y1 - y0)) as f32; + } + } + out +} + +/// The integer shift (within ±`r` px) of the `p × p` patch of `a` at `(x, y)` that best matches `b`, with its +/// normalised cross-correlation, or `None` when the patch has too little detail to tell. +fn patch_shift(a: &[f32], b: &[f32], w: usize, (x, y): (usize, usize), p: usize, r: i64) -> Option<((i64, i64), f32)> { + let pa: Vec = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect(); + let na = pa.iter().map(|v| v * v).sum::().sqrt(); + if na < 1e-3 * p as f32 { + return None; + } + let mut best = (f32::MIN, (0, 0)); + for dy in -r..=r { + for dx in -r..=r { + let (bx, by) = (x as i64 + dx, y as i64 + dy); + let (mut dot, mut nb) = (0.0f32, 0.0f32); + for i in 0..p { + for j in 0..p { + let v = b[(by as usize + i) * w + bx as usize + j]; + dot += pa[i * p + j] * v; + nb += v * v; + } + } + let c = dot / (na * nb.sqrt()).max(1e-12); + if c > best.0 { + best = (c, (dx, dy)); + } + } + } + Some((best.1, best.0)) +} + +#[test] +fn corpus_a7cr_warp_keeps_coordinates_after_orientation_and_resizing() { + use lightcraft_geom::{Orientation, Point}; + use lightcraft_pipeline::optics::Warp; + let root = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); + for name in ["arw-sony-a7cr-compressed.arw", "arw-sony-a7cr-lossless-l.arw"] { + let Ok(mut bytes) = std::fs::read(root.join("raw").join(name)) else { continue }; + let off = lightcraft_raw::probe_info(&bytes).unwrap(); + assert!(embedded_lens(&off).is_none()); + let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap(); + let at = tiff.find(0x7036).unwrap().offset as usize; + let mut enabled = Vec::new(); + tiff.order.put_u16(&mut enabled, 17); + bytes[at..at + 2].copy_from_slice(&enabled); + let mut raw = lightcraft_raw::probe_info(&bytes).unwrap(); + raw.orientation = Orientation::Normal; + let mut settings = lightcraft_develop::DevelopSettings::default(); + settings.optics.lens_profile = true; + let base_lens = embedded_lens(&raw).unwrap(); + let base = Warp::from_settings(9504.0, 6336.0, &settings, Some(&base_lens)); + for orientation in 1..=8 { + let o = Orientation::from_exif(orientation); + raw.orientation = o; + let lens = embedded_lens(&raw).unwrap(); + for edge in [1200.0, 2560.0, 9504.0] { + let scale = edge / 9504.0; + let (w, h) = if o.swaps_axes() { (6336.0 * scale, edge) } else { (edge, 6336.0 * scale) }; + let oriented = Warp::from_settings(w, h, &settings, Some(&lens)); + for (x, y) in [(0.05, 0.1), (0.5, 0.5), (0.9, 0.8)] { + let point = Point::new(x * 9504.0, y * 6336.0); + let source = base.corrected_to_source(point, 1); + let (px, py) = o.map(point.x, point.y, 9504.0, 6336.0); + let (sx, sy) = o.map(source.x, source.y, 9504.0, 6336.0); + let got = oriented.corrected_to_source(Point::new(px * scale, py * scale), 1); + assert!((got.x - sx * scale).hypot(got.y - sy * scale) < 1e-8, "{name} orientation {orientation} edge {edge}"); + } + } + } + } +} + +/// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the +/// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile +/// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide +/// lenses is far off. A file shot with the camera's correction off (DMC-GH1) carries no correction. +#[test] +fn corpus_rw2_distortion_matches_the_camera_jpeg() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else { + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); + return; + }; + assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply"); + assert_eq!(load_bytes(&gh1, 320).unwrap().1.lens, None); + + // (file, how far off (px) the uncorrected corners are at the least, if they match within ±R at all) + let files = [ + ("rw2-panasonic-fz1000m2-4x3.rw2", 8), // 9.1 mm, ~13 % at the corners + ("rwl-leica-dlux7.rwl", 8), // 10.9 mm, ~13 % + ("rw2-panasonic-gx80.rw2", 8), // 14 mm, ~8 % + ("rw2-panasonic-gh6.rw2", 3), // 20 mm, ~1.6 % + ("rw2-panasonic-s9.rw2", 3), // 45 mm, 3 % zoom after the correction + ("rw2-panasonic-g9.rw2", 0), // 40 mm, ~0.5 % + ]; + const EDGE: usize = 640; + const P: usize = 64; + const R: i64 = 12; + for (name, off_at_least) in files { + let bytes = std::fs::read(dir.join(name)).unwrap(); + let probed = probe_bytes(name, &bytes).unwrap(); + assert!(probed.embedded_lens.is_some_and(|l| l.warp.is_some()), "{name}: no distortion correction read"); + let (src, info) = load_bytes(&bytes, 2 * EDGE).unwrap(); + assert_eq!(info.lens, probed.embedded_lens, "{name}: probe and decode disagree"); + // Convert to DNG keeps it: the same correction, as the DNG's own `WarpRectilinear` + let dng = lightcraft_raw::write_dng(&lightcraft_raw::decode(&bytes).unwrap(), &Default::default()).unwrap(); + assert_eq!(probe_bytes("x.dng", &dng).unwrap().embedded_lens, probed.embedded_lens, "{name}: lost in the DNG"); + let mut s = lightcraft_develop::DevelopSettings::default(); + let render = |s: &lightcraft_develop::DevelopSettings| { + lightcraft_pipeline::render(&src, &info, s, &lightcraft_pipeline::RenderRequest::fit(EDGE, EDGE)).image + }; + let before = render(&s); + s.optics.lens_profile = true; + let after = render(&s); + let (w, h) = (after.width, after.height); + assert_eq!((before.width, before.height), (w, h)); + // the camera JPEG shows the whole sensor; a render in an in-camera aspect ratio is its centre + let (cam, _) = load_embedded_preview(&bytes, 4 * EDGE).unwrap(); + let (cw, ch) = (cam.width as f64, cam.height as f64); + let (sw, sh) = if cw / ch > w as f64 / h as f64 { (ch * w as f64 / h as f64, ch) } else { (cw, cw * h as f64 / w as f64) }; + let cam = cam.crop(((cw - sw) / 2.0).round() as usize, ((ch - sh) / 2.0).round() as usize, sw.round() as usize, sh.round() as usize); + let cam = lightcraft_raster::resample::resize(&cam, w, h, Filter::Mitchell); + let lin = |v: u8| (v as f32 / 255.0).powf(2.2); + let lum8 = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| { + let p = im.data[y * im.width + x]; + 0.3 * lin(p[0]) + 0.6 * lin(p[1]) + 0.1 * lin(p[2]) + }; + let reference = detail(w, h, |x, y| { + let p = cam.data[y * w + x]; + 0.3 * p[0] + 0.6 * p[1] + 0.1 * p[2] + }); + let (db, da) = (detail(w, h, |x, y| lum8(&before, x, y)), detail(w, h, |x, y| lum8(&after, x, y))); + let m = R as usize + 4; + let corners = [(m, m), (w - m - P, m), (m, h - m - P), (w - m - P, h - m - P)]; + let mut measured = 0; + for c in corners { + let Some(((dx, dy), ca)) = patch_shift(&da, &reference, w, c, P, R) else { continue }; + measured += 1; + assert!(dx.abs() <= 2 && dy.abs() <= 2 && ca >= 0.5, "{name}: corrected corner {c:?} off by ({dx}, {dy}) px, ncc {ca}"); + if off_at_least > 0 + && let Some(((bx, by), cb)) = patch_shift(&db, &reference, w, c, P, R) + { + let off = bx.abs().max(by.abs()) >= off_at_least || cb < 0.3; + assert!(off, "{name}: uncorrected corner {c:?} already within ({bx}, {by}) px, ncc {cb}"); + } + } + assert!(measured >= 2, "{name}: only {measured} corners with detail"); + } +} + +/// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box. +pub(crate) fn cr3_with_preview(w: u32, h: u32) -> Vec { + let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec(); + b.extend_from_slice(&((24 + jpeg.len()) as u32).to_be_bytes()); + b.extend_from_slice(b"PRVW\0\0\0\0\0\x01"); + b.extend_from_slice(&(w as u16).to_be_bytes()); + b.extend_from_slice(&(h as u16).to_be_bytes()); + b.extend_from_slice(b"\0\x01"); + b.extend_from_slice(&(jpeg.len() as u32).to_be_bytes()); + b.extend_from_slice(&jpeg); + b +} + +/// [`cr3_with_preview`] plus a full-size CRX raw track whose `CMP1` coding header is garbage: +/// the container parses, the raw data doesn't (`RawError::Corrupt`, not `Unsupported`). +fn cr3_with_corrupt_raw(w: u32, h: u32) -> Vec { + let bx = |kind: &[u8; 4], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat(); + let mut craw = vec![0u8; 82]; + craw[24..26].copy_from_slice(&(w as u16).to_be_bytes()); + craw[26..28].copy_from_slice(&(h as u16).to_be_bytes()); + craw.extend(bx(b"CMP1", &[0xff; 4])); + let mut stsd = vec![0, 0, 0, 0, 0, 0, 0, 1]; + stsd.extend(bx(b"CRAW", &craw)); + let stsz = [0u32, 16, 1].map(u32::to_be_bytes).concat(); + let co64 = [&[0u8, 0, 0, 0, 0, 0, 0, 1][..], &0u64.to_be_bytes()].concat(); + let stbl = [bx(b"stsd", &stsd), bx(b"stsz", &stsz), bx(b"co64", &co64)].concat(); + let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))); + let preview = cr3_with_preview(w, h); + [&preview[..24], &bx(b"moov", &trak), &preview[24..]].concat() +} + +/// A CR3 whose raw data fails to decode, for any reason, still opens from its embedded JPEG: +/// CR3s did before the CRX decoder, which is verified on few bodies (PR #279 review). +#[test] +fn corrupt_cr3_falls_back_to_embedded_preview() { + let b = cr3_with_corrupt_raw(48, 32); + let err = lightcraft_raw::decode(&b).expect_err("the fixture's raw data is corrupt"); + assert!(!matches!(err, lightcraft_raw::RawError::Unsupported(_)), "{err}"); + let p = probe_bytes("x.cr3", &b).unwrap(); + assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); + assert!(p.preview_only.is_some_and(|why| why.contains("CR3"))); + let (img, src) = load_bytes(&b, 24).unwrap(); + assert_eq!((img.width, img.height), (24, 16)); + assert!(!src.raw); + // other formats still report a corrupt file as an error + let mut dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + dng.truncate(dng.len() / 2); + assert!(load_bytes(&dng, 24).is_err()); +} + +/// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is +/// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it. +#[test] +fn dng_profile_look_is_applied() { + use lightcraft_raw::profile::{HsvTable, ProfileLook, ToneCurve}; + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + let sat = |img: &Rgb32f| { + img.data.iter().map(|p| (p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2])) / p[0].max(p[1]).max(p[2]).max(1e-6)).sum::() + / img.data.len() as f32 + }; + let (before, info) = load_bytes(&plain, 64).unwrap(); + assert!(sat(&before) > 0.05, "the synthetic scene is coloured"); + assert!(info.camera_tone.is_none()); + // a map that removes all saturation, and a tone curve + let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false }; + raw.color.profile = ProfileLook { + hue_sat_map: [Some(grey), None], + look_table: None, + tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]), + gain_table_map: None, + }; + let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (after, info) = load_bytes(&with, 64).unwrap(); + assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after)); + // a saturation-only map keeps brightness roughly (HSV value is kept in ProPhoto RGB) + let mean = |img: &Rgb32f| img.data.iter().map(|p| p[0].max(p[1]).max(p[2])).sum::() / img.data.len() as f32; + assert!((0.8..1.1).contains(&(mean(&after) / mean(&before))), "{} vs {}", mean(&after), mean(&before)); + let tone = info.camera_tone.expect("the DNG tone curve becomes the camera tone"); + assert!((tone.apply(0.18) - 0.3).abs() < 0.01, "{}", tone.apply(0.18)); + // a look table alone changes the render too (applied after exposure) + raw.color.profile = ProfileLook { + look_table: Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 1.0, 0.5]; 2], srgb_value: false }), + ..Default::default() + }; + let (dim, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); + assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before)); +} + +/// Corrections used while fitting a DNG's preview must not be baked into its editable source. +/// The pipeline applies them once from SourceInfo, and a file-defined tone stays authoritative. +#[test] +fn dng_preview_corrections_leave_the_render_source_uncorrected() { + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + raw.color.profile.tone_curve = lightcraft_raw::profile::ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); + let before = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + raw.opcodes.list3 = vec![lightcraft_raw::Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; + let corrected = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (before, expected) = load_bytes(&before, 64).unwrap(); + let (after, info) = load_bytes(&corrected, 64).unwrap(); + assert_eq!(before.data, after.data, "lens correction belongs to the pipeline"); + assert!(info.lens.as_ref().is_some_and(|l| l.vignette.is_some())); + assert_eq!(info.camera_tone, expected.camera_tone, "the file's own tone is preserved"); +} + +/// A camera JPEG made from the corrected scene teaches one tone to the DNG's existing fit. +/// The source pixels stay uncorrected, including when the file is rotated. +#[test] +fn dng_lens_corrected_preview_teaches_one_tone_without_baking_in_the_lens() { + use lightcraft_color::{REC2020, SRGB, luminance_2020, transfer::linear_to_srgb}; + use lightcraft_raw::{Opcode, Orientation}; + let (w, h) = (384usize, 256usize); + let data = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let v = 0.02 + 0.2 * (0.5 + 0.5 * (x * 0.045).sin() * (y * 0.06).cos()) + 0.1 * x / w as f32; + let colour = match (i / w % 2, i % w % 2) { + (0, 0) => 1.2, + (1, 1) => 0.8, + _ => 1.0, + }; + 256 + (v * colour * 15744.0) as u16 + }) + .collect(); + let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.color = lightcraft_raw::ColorData { + illuminant: [21, 0], + color_matrix: [Some(REC2020.from_xyz()), None], + as_shot_neutral: Some([1.0; 3]), + ..Default::default() + }; + let plain = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + raw.opcodes.list3 = vec![ + Opcode::FixVignetteRadial { k: [0.8, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }, + Opcode::WarpRectilinear { planes: vec![[0.7, 0.2, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }, + ]; + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let corrected = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + let to_srgb = REC2020.to_space(&SRGB); + let pixels: Vec = corrected + .data + .iter() + .flat_map(|p| { + let p = t.matrix.apply_f32(std::array::from_fn(|i| p[i] * t.wb[i])); + let y = luminance_2020(p).max(1e-6); + let gain = (1.0 - (-3.0 * y).exp()) / y; + to_srgb.apply_f32(p.map(|v| v * gain)).map(|v| (255.0 * linear_to_srgb(v).clamp(0.0, 1.0)).round() as u8) + }) + .collect(); + let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels)); + let jpeg = lightcraft_codecs::encode_jpeg(&image, 98, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); + for orientation in [Orientation::Normal, Orientation::Rotate90] { + raw.orientation = orientation; + let mut file = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + // Append a preview IFD to the little-endian DNG written above. + let root = u32::from_le_bytes(file[4..8].try_into().unwrap()) as usize; + let count = u16::from_le_bytes(file[root..root + 2].try_into().unwrap()) as usize; + let next = root + 2 + 12 * count; + let start = file.len() as u32; + file[next..next + 4].copy_from_slice(&start.to_le_bytes()); + let jpeg_at = start + 2 + 4 * 12 + 4; + file.extend(4u16.to_le_bytes()); + for (tag, value) in [(256u16, w as u32), (257, h as u32), (0x201, jpeg_at), (0x202, jpeg.len() as u32)] { + file.extend(tag.to_le_bytes()); + file.extend(4u16.to_le_bytes()); + file.extend(1u32.to_le_bytes()); + file.extend(value.to_le_bytes()); + } + file.extend(0u32.to_le_bytes()); + file.extend(&jpeg); + let (source, info) = load_bytes(&file, 96).unwrap(); + let tone = info.camera_tone.expect("one DNG look fitted to the lens-corrected preview"); + let target = 1.0 - (-3.0f32 * 0.18).exp(); + assert!((tone.apply(0.18) - target).abs() < 0.04, "one tone: {} vs {target}", tone.apply(0.18)); + let expected = load_bytes(&plain, 96).unwrap().0.into_oriented(orientation); + assert_eq!(source.data, expected.data, "the editable source is not lens-corrected"); + } +} + +/// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's +/// own white-balanced neutral comes out yellow (blue 12 % low). +fn yellowing_look() -> lightcraft_color::Mat3 { + lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]]) +} + +/// A fitted camera look applied in lanes, on rows that aren't a whole number of them, gives the bits of the +/// pixel-by-pixel path, awkward pixels (black, negative, huge, NaN, infinite) included. +#[test] +fn camera_look_in_lanes_matches_pixel_by_pixel() { + let data = (0..72 * 5 * 5).map(|i| [((i * 37) % 61) as f32 - 30.0, 0.6 + ((i * 11) % 13) as f32 / 13.0, 1.0]).collect(); + let table = lightcraft_raw::profile::HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data, srgb_value: true }; + let hue_sat = crate::camera_preview::HueSat::new(&table).unwrap(); + let colour = CameraColourModel { wb: [2.1, 1.0, 1.5], matrix: yellowing_look(), gain: 1.3, tables: None, hue_sat: Some(&hue_sat) }; + let special = [0.0, -0.0, -0.01, 1e-30, 0.3, 1.0, 5.0, 1e30, f32::INFINITY, f32::NAN]; + let img = Rgb32f::from_fn(37, 5, |x, y| { + let k = y * 37 + x; + if k % 5 == 0 { + [special[k % 10], special[k / 10 % 10], special[k / 100 % 10]] + } else { + [(x as f32 * 0.37).sin().abs(), ((x + y) as f32 * 0.11).cos().abs(), y as f32 / 4.0 - 0.05] + } + }); + let mut lanes = img.clone(); + colour.apply(&mut lanes); + let m = colour.matrix.to_f32(); + for (p, got) in img.data.iter().zip(&lanes.data) { + assert_eq!(got.map(f32::to_bits), colour.pixel(&m, *p).map(f32::to_bits), "{p:?}"); + } +} + +#[test] +fn clipped_white_renders_neutral_through_the_colour_model() { + use lightcraft_color::{LUMA_2020, Mat3}; + let look = yellowing_look(); + let white = clipped_white(&look); + let out = look.apply(white.map(f64::from)); + assert!(out.iter().all(|v| (v - out[1]).abs() < 1e-5), "{white:?} renders {out:?}"); + let luma = |c: [f64; 3]| (0..3).map(|i| c[i] * f64::from(LUMA_2020[i])).sum::(); + assert!((luma(out) - luma(look.apply([1.0; 3]))).abs() < 1e-5, "as bright as the camera's neutral"); + assert_eq!(clipped_white(&Mat3::IDENTITY), [1.0; 3]); + // a model that can't be inverted, or only just, keeps the camera's neutral + assert_eq!(clipped_white(&Mat3([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + assert_eq!(clipped_white(&Mat3([[1.0, 1.0, 0.0], [1.0, 1.0 + 1e-8, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + // so does one whose white would overflow the rebuilt highlights (2.5e38 in red here) + assert_eq!(clipped_white(&Mat3([[3e-39, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + // or move the camera's neutral implausibly far + assert_eq!(clipped_white(&Mat3([[0.1, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + // what it answers renders neutral through the f32 matrix, and fully clipped highlights stay finite + for model in [look, Mat3([[1.9, -0.7, -0.2], [-0.3, 1.4, -0.1], [0.0, -0.4, 1.4]])] { + let w = clipped_white(&model); + let m = model.to_f32(); + let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * w[0] + m[i][1] * w[1] + m[i][2] * w[2]); + assert!(out.iter().all(|v| (v - out[1]).abs() <= 1e-3 * out[1]), "{w:?} renders {out:?}"); + let colour = CameraColourModel { wb: [2.5, 1.0, 1.6], matrix: model, gain: 1.0, tables: None, hue_sat: None }; + let mut img = Rgb32f::filled(4, 4, [1.0; 3]); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", img.get(0, 0)); + } +} + +/// Issue #523: fully clipped highlights are neutral in the white-balanced camera RGB, but a colour model that +/// doesn't map that neutral to neutral tinted them. The tone map's roll-off to white hid the tint at Neutral; +/// Highlights -100, or the issue's edit (Exposure -0.88, Highlights -75…), showed it. +#[test] +fn clipped_highlights_stay_neutral_when_darkened() { + use lightcraft_pipeline::{RenderRequest, render}; + let wb = [2.4f32, 1.0, 1.6]; + let colour = CameraColourModel { wb, matrix: yellowing_look(), gain: 1.0, tables: None, hue_sat: None }; + // a grey wall (camera neutral) with a fully clipped light and a light clipped in green only + let mut img = Rgb32f::from_fn(96, 64, |x, y| { + let d = |cx: f32| ((x as f32 - cx).powi(2) + (y as f32 - 32.0).powi(2)).sqrt(); + if d(28.0) < 14.0 { + [1.0; 3] + } else if d(70.0) < 12.0 { + [0.6 / wb[0], 1.0, 0.7 / wb[2]] + } else { + [0.3 / wb[0], 0.3, 0.3 / wb[2]] + } + }); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + let info = SourceInfo { raw: true, ..Default::default() }; + let mut issue = lightcraft_develop::DevelopSettings::default(); + let l = &mut issue.light; + (l.exposure, l.contrast, l.highlights, l.shadows, l.whites, l.blacks) = (-0.88, 5.0, -75.0, 52.0, 6.0, -37.0); + let mut h100 = lightcraft_develop::DevelopSettings::default(); + h100.light.highlights = -100.0; + for (name, s) in [("Highlights -100", h100), ("#523's edit", issue)] { + let out = render(&img, &info, &s, &RenderRequest::fit(96, 64)).image; + let p = out.get(28, 32); + assert!(p[1] < 250, "{name}: the clipped light is darkened ({p:?})"); + let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]); + assert!(spread <= 1, "{name}: clipped light {p:?} is not neutral"); + } +} + +/// A smooth pink gradient (`[0.9k, k, 0.8k]` in the sensor, k from 0.8 to 1.35, white balance `[2.5, 1, 1.6]`) +/// through the point where green clips, 1600 px long, in `rows` of a 1600 × `height` picture that is otherwise +/// near black, rebuilt and rendered at Exposure -2. Returns the rendered colour of the middle row's last +/// unclipped pixel and the next two, and the largest step between neighbouring pixels along that row. +fn darkened_ramp(rows: std::ops::Range, height: usize) -> ([[u8; 4]; 3], u8) { + use lightcraft_pipeline::{RenderRequest, render}; + let wb = [2.5f32, 1.0, 1.6]; + let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; + let w = 1600usize; + let k = |x: usize| 0.8 + 0.55 * x as f32 / (w - 1) as f32; + let sensor = + Rgb32f::from_fn(w, height, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); + let y = (rows.start + rows.end) / 2; + let first = (0..w).find(|&x| sensor.get(x, y)[1] >= HIGHLIGHT_CLIP).unwrap(); + let mut img = sensor.clone(); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + let mut s = lightcraft_develop::DevelopSettings::default(); + s.light.exposure = -2.0; + let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, height)).image; + let step = |x: usize| { + let (a, b) = (out.get(x - 1, y), out.get(x, y)); + (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) + }; + ([out.get(first - 1, y), out.get(first, y), out.get(first + 1, y)], (1..w).map(step).max().unwrap_or(0)) +} + +/// The darkened pink gradient carries on across the point where green clips: before the fix the fade towards +/// the clipped neutral, driven by how close the other channels were to their clip, made a step of 37 levels +/// there. +#[test] +fn rendered_colour_is_continuous_where_the_first_channel_clips() { + let ([a, b, c], worst) = darkened_ramp(0..16, 16); + let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); + assert!(step(a, b) <= 1 && step(b, c) <= 1, "at the first clip: {a:?} {b:?} {c:?}"); + assert!(worst <= 3, "largest step between neighbouring pixels: {worst} levels"); +} + +/// The same gradient as a thin bright line on near black: every pixel of it is next to a strong edge, so there +/// is no reliable colour around it. It still carries on across the first clip, however thin the line and however +/// much dark background there is (a 4-row line on 32 rows used to jump from [225, 126, 153] to [198, 177, 163]; +/// on 512 rows, from [225, 126, 153] to [218, 145, 151]). +#[test] +fn rendered_thin_line_is_continuous_where_the_first_channel_clips() { + let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); + for height in [32usize, 128, 512, 1024] { + for width in [1usize, 2, 4, 16] { + let top = (height - width) / 2; + let ([a, b, c], worst) = darkened_ramp(top..top + width, height); + let case = format!("{width} rows of {height}"); + assert!(step(a, b) <= 1 && step(b, c) <= 1, "{case}, at the first clip: {a:?} {b:?} {c:?}"); + assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); + } + } +} + +/// A band clipped in green whose red rises through its own clip from left to right (sensor `[0.985 + 0.01 t, 1, +/// 0.8]`, t from 0 to 1 across 320 px), `band` rows at the bottom of the picture, below `strip` rows of a pale +/// surface just under the clip (`[0.4, 0.95, 0.8]`: the band's rim, of another colour) and above those `top` rows +/// of a strongly coloured reliable surface (`[0.8, 0.35, 0.3]`). Rebuilt and rendered at Exposure -2: the +/// rendered colour on either side of where red clips on the band's middle row, and the largest step between +/// neighbouring pixels along that row. +fn second_clip(top: usize, strip: usize, band: usize) -> ([[u8; 4]; 2], u8) { + use lightcraft_pipeline::{RenderRequest, render}; + let wb = [2.5f32, 1.0, 1.6]; + let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; + let (w, h) = (320usize, top + strip + band); + let sensor = Rgb32f::from_fn(w, h, |x, y| { + if y < top { + [0.8, 0.35, 0.3] + } else if y < top + strip { + [0.4, 0.95, 0.8] + } else { + [0.985 + 0.01 * x as f32 / (w - 1) as f32, 1.0, 0.8] + } + }); + let y = top + strip + band / 2; + let at = (0..w).find(|&x| sensor.get(x, y)[0] >= HIGHLIGHT_CLIP).unwrap(); + let mut img = sensor.clone(); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + let mut s = lightcraft_develop::DevelopSettings::default(); + s.light.exposure = -2.0; + let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h)).image; + let step = |x: usize| { + let (a, b) = (out.get(x - 1, y), out.get(x, y)); + (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) + }; + ([out.get(at - 1, y), out.get(at, y)], (1..w).map(step).max().unwrap_or(0)) +} + +/// Where a second channel clips within reach of the surface's own rim, the rebuilt colour carries on too. The +/// rim's comparison with the pixel used to drop each clipped channel, so a rim far from the pixel in red suddenly +/// matched it once red clipped (80 rows of colour, 16 of rim and 32 of band: [237, 124, 153] then +/// [205, 181, 168]); and a channel about to clip counted as an uncapped floor that was capped once it clipped +/// (without the coloured surface: [198, 195, 178] then [205, 181, 168]). +#[test] +fn rendered_colour_is_continuous_where_a_second_channel_clips() { + for (top, strip, band) in [(80, 16, 32), (0, 16, 32), (80, 16, 4), (200, 4, 8), (16, 64, 16), (8, 32, 24)] { + let ([a, b], worst) = second_clip(top, strip, band); + let case = format!("{top} rows of colour, {strip} of rim, {band} of band"); + let step = (0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0); + assert!(step <= 1, "{case}: {a:?} then {b:?} where red clips"); + assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); + } +} + +/// Issue #523 on a public ILCE-7RM4 raw (raw.pixls.us, CC0; skipped without the corpus): a dusk sky clipped in +/// green behind a poplar. Its rebuilt colour used to come from the tree's foliage and lens fringes, a green halo +/// on one side of the tree and a magenta one on the other (Highlights -100 or the issue's edit show them). +#[test] +fn corpus_clipped_sky_beside_a_tree_has_no_coloured_halo() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else { + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); + return; + }; + let (img, _) = load_bytes(&bytes, 1600).unwrap(); + let (w, h) = (img.width, img.height); + let lum = |p: [f32; 3]| lightcraft_color::luminance_2020(p); + let chroma = |p: [f32; 3]| { + let s = (p[0] + p[1] + p[2]).max(1e-6); + [p[0] / s, p[2] / s] + }; + // the poplar stands at about 30 % of the width, its top 25 to 34 % down the frame + let px = |f: f32, n: usize| (f * n as f32) as usize; + let (mut near, mut far) = ([[0f64; 2]; 2], [[0f64; 2]; 2]); + let mut rows = 0; + for y in px(0.24, h)..px(0.34, h) { + let row = &img.data[y * w..(y + 1) * w]; + let sky = lum(row[px(0.2, w)]); + let dark: Vec = (px(0.25, w)..px(0.35, w)).filter(|&x| lum(row[x]) < 0.5 * sky).collect(); + let (Some(&left), Some(&right)) = (dark.first(), dark.last()) else { continue }; + let k = w as f32 / 6000.0; + let mean = |a: usize, b: usize| { + let n = (b - a) as f64; + (a..b).map(|x| chroma(row[x])).fold([0f64; 2], |s, c| [s[0] + c[0] as f64 / n, s[1] + c[1] as f64 / n]) + }; + let (n0, n1, f0, f1) = (px(8.0 / 6000.0, w).max(2), px(40.0 / 6000.0, w), (200.0 * k) as usize, (320.0 * k) as usize); + for (side, (n, f)) in + [(mean(left - n1, left - n0), mean(left - f1, left - f0)), (mean(right + n0, right + n1), mean(right + f0, right + f1))] + .into_iter() + .enumerate() + { + for c in 0..2 { + near[side][c] += n[c]; + far[side][c] += f[c]; + } + } + rows += 1; + } + assert!(rows > 50, "the tree was not found ({rows} rows)"); + for side in 0..2 { + let d = (0..2).map(|c| ((near[side][c] - far[side][c]) / rows as f64).abs()).fold(0.0, f64::max); + assert!(d < 0.004, "{} of the tree the clipped sky's chromaticity is off by {d:.4}", ["left", "right"][side]); + } +} + +/// A Lightroom merge (a LinearRaw DNG keeping a reduced copy of its raw image): its grid thumbnail develops the +/// copy, but an export never does, whatever its size, and not from the thumbnail's source in memory either. The +/// copy's samples are much brighter than the image's here, so a picture says which one it came from. +#[test] +fn exports_never_develop_a_dngs_reduced_copy() { + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + use serde_json::json; + let linear = |subfile: u32, w: u32, h: u32, sample: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![sample.to_le_bytes().repeat((w * h * 3) as usize)] }); + ifd + }; + // (the copy must cover a thumbnail source's 512 px) + let (w, h) = (1200u32, 600u32); + let mut main = linear(0, w, h, 1500); + main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); + main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); + ifd0.set(t::MAKE, Value::Ascii("DJI".into())); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![128; 24]] }); + ifd0.add_sub_ifd(main); + ifd0.add_sub_ifd(linear(1, w / 2, h / 2, 24000)); + let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + let g = |img: &Rgb32f| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; + let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; + + // the loaders: only the thumbnail's develops the copy, and only when it covers the size + let (copy, _, used) = load_vec_reduced(bytes.clone(), 512).unwrap(); + assert!(used, "a thumbnail develops the copy"); + assert!(!load_vec_reduced(bytes.clone(), 1024).unwrap().2, "no copy covers 1024 px"); + let (full, _) = load_vec(bytes.clone(), 512).unwrap(); + assert!(g(©) > 4.0 * g(&full), "the fixture's copy is much brighter: {} vs {}", g(©), g(&full)); + + let dir = std::env::temp_dir().join(format!("lc-reduced-export-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("merge-pano.dng"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = crate::Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + let (space, depth) = (lightcraft_pipeline::OutputSpace::Srgb, lightcraft_pipeline::OutputDepth::U8); + // an export at a thumbnail's size, before any thumbnail + assert_eq!(s.export_job(id, 64, 64, space, depth).unwrap().level, crate::media::SourceLevel::Thumb); + let export = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); + // the thumbnail develops the copy and its source stays in memory… + s.media.clear_sources(); + let thumb = s.thumb_job(id, 64).unwrap().run(); + assert!(thumb.loaded.as_ref().is_some_and(|l| l.reduced)); + let thumb_mean = mean(&thumb.rendered.as_ref().unwrap().image); + s.accept(&thumb); + assert!(thumb_mean > export + 20.0, "the thumbnail shows the copy: {thumb_mean} vs {export}"); + // …and an export after it still reads the full image + let job = s.export_job(id, 64, 64, space, depth).unwrap(); + assert!(matches!(&job.source, crate::media::SourceRef::File { reduced: None, .. }), "the file, not the copy"); + let again = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); + assert!((again - export).abs() < 1.0, "{again} vs {export}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right +/// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky +/// heuristic would never look) is used; a matte with an unknown name is ignored. +#[test] +fn dng_sky_matte_drives_the_sky_mask() { + use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_pipeline::{RenderRequest, render}; + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + let (w, h) = (16u32, 8u32); + let image = |ifd: &mut IfdBuilder, w: u32, h: u32, cpp: u16, bits: u16, data: Vec| { + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; cpp as usize])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![cpp])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![data] }); + }; + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); + image(&mut raw, w, h, 3, 16, 12000u16.to_le_bytes().repeat((w * h * 3) as usize)); + let matte = |name: &str, right: bool| { + let mut m = IfdBuilder::new(); + m.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![t::SUBFILE_SEMANTIC_MASK])); + m.set(t::PHOTOMETRIC, Value::Short(vec![photometric::MASK])); + m.set(t::SEMANTIC_NAME, Value::Ascii(name.into())); + image(&mut m, w / 2, h / 2, 1, 8, (0..w / 2 * h / 2).map(|i| if (i % (w / 2) >= w / 4) == right { 255 } else { 0 }).collect()); + m + }; + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0])); + ifd0.set(t::MAKE, Value::Ascii("Apple".into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![6])); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); + image(&mut ifd0, 4, 2, 3, 8, vec![128; 24]); + ifd0.add_sub_ifd(raw); + ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticskymatte", true)); + ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticsomethingelse", false)); + let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + + let (img, info) = load_bytes(&bytes, 64).unwrap(); + assert_eq!((img.width, img.height), (8, 16)); + assert!(info.mattes.is_some()); + let sky = Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }], + adjust: LocalAdjustments { exposure: -3.0, ..Default::default() }, + ..Default::default() + }; + let s = DevelopSettings { masks: vec![sky], ..Default::default() }; + let req = RenderRequest::fit(8, 16); + let green = |s: &DevelopSettings, info: &SourceInfo, y: usize| render(&img, info, s, &req).image.get(4, y)[1] as i32; + let plain = DevelopSettings::default(); + assert_eq!(green(&s, &info, 2), green(&plain, &info, 2), "no sky at the top"); + assert!(green(&s, &info, 13) + 40 < green(&plain, &info, 13), "the sky at the bottom is darkened"); + // without the matte the heuristic looks at the top of the frame instead + let heuristic = SourceInfo { mattes: None, ..info.clone() }; + assert!(green(&s, &heuristic, 2) < green(&plain, &heuristic, 2)); + assert_eq!(green(&s, &heuristic, 13), green(&plain, &heuristic, 13)); +} + +/// A DNG's white balance is re-evaluated through its own colour model: the decoder hands the +/// pipeline the file's colour tags (without the profile look, applied at load) and the white +/// the pixels were developed for, and a custom white balance renders a neutral differently +/// from the Bradford adaptation it replaces. +#[test] +fn dng_white_balance_uses_the_camera_colour_model() { + use lightcraft_pipeline::{RenderRequest, render}; + let dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let raw = lightcraft_raw::decode(&dng).unwrap(); + let (img, info) = load_bytes(&dng, 64).unwrap(); + let cc = info.camera_color.as_ref().expect("a DNG with a colour matrix"); + assert_eq!(cc.developed_for, lightcraft_raw::color::as_shot_white_xy(&raw)); + assert!(cc.tags.profile.is_empty()); + assert_eq!(cc.tags.color_matrix, raw.color.color_matrix); + let mut s = lightcraft_develop::DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.tint) = (lightcraft_develop::WbMode::Custom, 3000.0, 20.0); + let req = RenderRequest::fit(32, 32); + let camera = render(&img, &info, &s, &req).image; + let adapted = render(&img, &SourceInfo { camera_color: None, ..info.clone() }, &s, &req).image; + assert_ne!(camera.data, adapted.data); +} + +/// Apple ProRAW: like Lightroom Classic, the default render ignores the file's +/// `ProfileGainTableMap` (Apple's local tone mapping); a map that would double every pixel +/// changes nothing. +#[test] +fn dng_gain_table_map_is_not_rendered() { + use lightcraft_raw::gaintable::GainTableMap; + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + let (before, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap(); + raw.color.profile.gain_table_map = Some(GainTableMap { + points_v: 1, + points_h: 1, + points_n: 1, + spacing_v: 1.0, + spacing_h: 1.0, + origin_v: 0.0, + origin_h: 0.0, + weights: [0.2, 0.2, 0.2, 0.2, 0.2], + gamma: 1.0, + gains: vec![2.0], + }); + let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + assert!(lightcraft_raw::decode(&with_map).unwrap().color.profile.gain_table_map.is_some(), "the map is kept"); + let (after, _) = load_bytes(&with_map, 64).unwrap(); + assert_eq!(before.data, after.data); +} + +/// The Profile option "Camera local tone mapping" renders the map by position (in the raw's +/// active area, whatever the photo's orientation), and only when it's on. +#[test] +fn dng_gain_table_map_renders_when_asked() { + use lightcraft_pipeline::{RenderRequest, render}; + use lightcraft_raw::gaintable::GainTableMap; + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + // ×1 at the active area's left edge rising to ×4 at its right edge, whatever the colour + raw.color.profile.gain_table_map = Some(GainTableMap { + points_v: 1, + points_h: 2, + points_n: 1, + spacing_v: 1.0, + spacing_h: 1.0, + origin_v: 0.0, + origin_h: 0.0, + weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0], + gamma: 1.0, + gains: vec![1.0, 4.0], + }); + let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (img, info) = load_bytes(&with_map, 64).unwrap(); + let (_, plain_info) = load_bytes(&plain, 64).unwrap(); + assert!(info.local_tone.is_some() && plain_info.local_tone.is_none()); + let req = RenderRequest::fit(64, 64); + let mut s = lightcraft_develop::DevelopSettings::default(); + // (darker, so that ×4 never reaches white) + s.light.exposure = -3.0; + let off = render(&img, &info, &s, &req).image; + assert_eq!(off.data, render(&img, &plain_info, &s, &req).image.data, "off: the map changes nothing"); + s.profile.camera_local_tone = true; + assert!(s.to_json().to_string().contains("camera_local_tone"), "the option is part of the settings (and their hash)"); + let on = render(&img, &info, &s, &req).image; + // brightness gained, per column: none at the left edge, most at the right + let column = |im: &lightcraft_raster::Rgba8, x: usize| (0..im.height).map(|y| im.data[y * im.width + x][1] as f32).sum::(); + let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); + let last = on.width - 1; + assert!((gain(0) - 1.0).abs() < 0.08, "left ×{}", gain(0)); + assert!(gain(last) > 1.4 && gain(last) > gain(last / 2) && gain(last / 2) > gain(0), "{} {} {}", gain(0), gain(last / 2), gain(last)); + // the photo turned upside down: the map follows the raw, so the brightened side swaps + s.orientation = lightcraft_geom::Orientation::Rotate180; + s.profile.camera_local_tone = false; + let off = render(&img, &info, &s, &req).image; + s.profile.camera_local_tone = true; + let on = render(&img, &info, &s, &req).image; + let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0); + assert!(gain(0) > 1.4 && (gain(last) - 1.0).abs() < 0.08, "rotated: {} … {}", gain(0), gain(last)); + // a rendered (non-raw) source never gets it + let jpeg = SourceInfo { raw: false, ..info.clone() }; + assert!(!lightcraft_pipeline::local_tone::enabled(&jpeg, &s)); +} + +#[test] +fn unsupported_raw_falls_back_to_embedded_preview() { + let b = cr3_with_preview(48, 32); + let p = probe_bytes("x.cr3", &b).unwrap(); + assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); + let (img, src) = load_bytes(&b, 24).unwrap(); + assert_eq!((img.width, img.height), (24, 16)); + assert!(!src.raw); + // no preview at all: a clear error, not a panic + assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err()); +} + +/// Issue #10: a raw variant we can't decode imports as "preview only" with the decoder's +/// reason, is rendered as the rendered JPEG it is (not as raw), says so to agents, survives a +/// library reopen, and Reload / Relink clear it once the file decodes (undoably). +#[test] +fn unsupported_raw_imports_as_preview_only_and_survives_reopen() { + use serde_json::json; + let dir = std::env::temp_dir().join(format!("lc-preview-only-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (lib, files) = (dir.join("lib"), dir.join("files")); + std::fs::create_dir_all(&files).unwrap(); + let path = files.join("DSC_0001.cr3"); + std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + // the import review already says so + let scan = s.execute("library.importPreview", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert!(scan["candidates"][0]["previewOnly"].as_str().is_some_and(|w| !w.is_empty()), "{scan}"); + let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + let p = s.catalog.photo(id).unwrap().clone(); + let why = p.preview_only.clone().expect("marked preview only"); + assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}"); + // stamped with the version that decided it, so a later version rechecks it (#662) + assert_eq!(p.preview_checked, lightcraft_catalog::APP_VERSION); + assert!(!p.preview_only_stale()); + assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)"); + assert!(!p.develops_raw()); + // rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults + assert_eq!(SourceInfo { native_long: None, ..crate::media::source_info(&p) }, SourceInfo::default()); + assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default()); + assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok()); + // agents see it + let q = s.execute("catalog.query", &json!({"filter": {}})).unwrap(); + assert_eq!(q["photos"][0]["previewOnly"], json!(why), "{q}"); + let i = s.execute("photo.inspect", &json!({"id": id.0})).unwrap(); + assert_eq!(i["preview_only"], json!(why), "{i}"); + // a library reopen keeps it + s.persist().unwrap(); + drop(s); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); + // the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back + std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); + let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["reloaded"], json!([id.0]), "{r}"); + assert!(s.catalog.photo(id).unwrap().develops_raw()); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, "", "the stamp goes with the flag"); + assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); + // relinking to a file that decodes clears it too + let dng = files.join("DSC_0001.dng"); + std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); + s.execute("photo.relink", &json!({"id": id.0, "path": dng.to_string_lossy()})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().preview_only, None); + drop(s); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A raw whose TIFF shell holds only a small RGB image (IFD0) next to a private block, as Exif +/// declares a picture 25x its size. +fn tiff_shell(w: u32, h: u32, shell: bool) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + ifd0.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![px] }); + ifd0.set_child( + t::EXIF_IFD, + IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w * 25])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h * 25])), + ); + let mut b = TiffWriter::default().write(&[ifd0]).unwrap(); + if shell { + b.extend(std::iter::repeat_n(0x5au8, (w * h * 625 / 8) as usize + 100)); + } + b +} + +/// A TIFF raw whose only image is a small thumbnail used to open as a plain image of that size +/// ("a 150 MP file renders 296x220"), with nothing saying so. Now it is recognised as a raw we can't +/// decode: preview only, with the reason, still showing that image. +#[test] +fn thumbnail_only_tiff_raw_is_preview_only_not_a_plain_image() { + let b = tiff_shell(24, 16, true); + let p = probe_bytes("IMG_0001.IIQ", &b).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "IIQ", 24, 16)); + let why = p.preview_only.expect("marked preview only"); + assert!(why.contains("600x400") && why.contains("24x16"), "{why}"); + let (img, src) = load_bytes(&b, 12).unwrap(); + assert_eq!((img.width, img.height, src.raw), (12, 8, false)); + assert!(embedded_preview_srgb(&b, 12).is_some()); + // the same pixels without the private block are a small ordinary image + let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap(); + assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16)); + assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none()); + + // TIFFs preserving camera Make tags (SONY, NIKON, etc.) are ordinary images, not raws (#281) + for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![24])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![16])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + let px: Vec = (0..24 * 16).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + ifd0.set_image(ImageData::Strips { rows_per_strip: 16, strips: vec![px] }); + let bytes = TiffWriter::default().write(&[ifd0]).unwrap(); + let p = probe_bytes("photo.tif", &bytes).unwrap(); + assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Image, None, 24, 16), "make: {make}"); + let (img, src) = load_bytes(&bytes, 24).unwrap(); + assert_eq!((img.width, img.height, src.raw), (24, 16, false), "make: {make}"); + } +} + +/// Recognised raw containers we can't decode (an MRW whose block header is damaged here; its preview's first +/// byte is overwritten, as in real MRW files) import as preview only with the JPEG's size, instead of failing. +#[test] +fn undecodable_container_with_a_preview_imports_as_preview_only() { + let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + let mut jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + jpeg[0] = 0x02; + let mut f = b"\0MRM\0\x01\0\0".to_vec(); + f.extend(std::iter::repeat_n(0x11u8, 300)); + f.extend_from_slice(&jpeg); + f.extend(std::iter::repeat_n(0x22u8, 300)); + let p = probe_bytes("A.MRW", &f).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MRW", 40, 30)); + assert!(p.preview_only.is_some_and(|w| w.contains("Mrw"))); + let (img, src) = load_bytes(&f, 20).unwrap(); + assert_eq!((img.width, img.height, src.raw), (20, 15, false)); + // without a JPEG: a clear error + let e = probe_bytes("A.MRW", b"\0MRM\0\x01\0\0 nothing here").unwrap_err(); + assert!(e.contains("without an embedded preview"), "{e}"); +} + +/// Issue #1275: an uncompressed Leaf MOS (IFD0 is the 16-bit mosaic, no Make tag; Leaf's data block `0x8606` +/// holds the preview) imports as a raw shown from its preview, not as a greyscale image of the mosaic. +#[test] +fn uncompressed_leaf_mos_imports_as_a_preview_only_raw() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let mos = |leaf_data: bool| { + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![16])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![12])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![1])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x30; 16 * 12 * 2]] }); + if leaf_data { + ifd0.set(0x8606, Value::Undefined([&b"PKTS\0\0\0\x01"[..], &jpeg].concat())); + } + TiffWriter::default().write(&[ifd0]).unwrap() + }; + let p = probe_bytes("aptus22.mos", &mos(true)).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MOS", 40, 30)); + assert!(p.preview_only.is_some_and(|why| !why.is_empty())); + let (img, src) = load_bytes(&mos(true), 20).unwrap(); + assert_eq!((img.width, img.height, src.raw), (20, 15, false)); + // the same grey image without Leaf's block is an ordinary 16-bit TIFF + let plain = probe_bytes("grey.tif", &mos(false)).unwrap(); + assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 16, 12)); +} + +/// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its +/// headers; loading it says what is wrong instead of naming a bare TIFF compression number. +#[test] +fn gopro_vc5_dng_imports_and_fails_to_load_with_a_clear_reason() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803])); + ifd.set(t::COMPRESSION, Value::Short(vec![9])); + ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] }); + let f = TiffWriter::default().write(&[ifd]).unwrap(); + let p = probe_bytes("GOPR0001.GPR", &f).unwrap(); + assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Raw, None, 32, 16)); + let e = load_bytes(&f, 16).unwrap_err(); + assert!(e.contains("GoPro VC-5") && e.contains("not decoded yet"), "{e}"); +} + +/// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview; +/// CR3 keeps its own wording. +#[test] +fn decode_failures_of_recognised_raws_may_fall_back_to_the_preview() { + use lightcraft_raw::RawError; + let cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01"; + assert_eq!(preview_reason(b"x", RawError::Unsupported("x".into())), Ok("x".to_string())); + assert!(preview_reason(b"x", RawError::Corrupt("bad strip".into())).is_ok_and(|w| w.contains("bad strip"))); + assert!(preview_reason(b"x", RawError::Limit("too big")).is_ok()); + assert!(preview_reason(b"x", RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(256))).is_ok()); + assert!(preview_reason(b"x", RawError::NotRaw).is_err()); + assert!(preview_reason(cr3, RawError::Corrupt("bad".into())).is_ok_and(|w| w.starts_with("CR3 "))); +} + +#[test] +fn quick_jobs_show_embedded_previews_then_cached_renders() { + use crate::media::QuickSource; + let dir = std::env::temp_dir().join(format!("lc-quick-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("a.cr3"); + std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); + let mut s = crate::Session::new().with_fs(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + // loupe: an unedited raw opens on its embedded preview… + let q = s.quick_view_job(id, 1600, true).unwrap().run(); + assert_eq!(q.quick, Some(QuickSource::Embedded)); + let img = q.rendered.unwrap().image; + assert_eq!((img.width, img.height), (96, 64)); + // …and once the loupe has rendered it, on that render (any size) + let full = s.loupe_job(id, 48, 32, true).unwrap().run(); + assert!(full.rendered.is_ok() && full.quick.is_none()); + let q = s.quick_view_job(id, 1600, true).unwrap().run(); + assert_eq!(q.quick, Some(QuickSource::Cached)); + assert_eq!(q.rendered.unwrap().image.width, 48); + // grid: embedded first, then the real thumbnail (final, with the thumbnail job's key) + let job = s.thumb_job(id, 128).unwrap(); + let q = s.quick_thumb_job(&job).unwrap().run(); + assert_eq!((q.quick, q.key), (Some(QuickSource::Embedded), job.key)); + drop(job.clone().run()); + assert_eq!(s.quick_thumb_job(&job).unwrap().run().quick, Some(QuickSource::Cached)); + // an edited raw: no embedded stand-in (it wouldn't show the edit) + s.execute("library.select", &serde_json::json!({"ids": [id.0]})).unwrap(); + s.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": 1.0})).unwrap(); + let job = s.thumb_job(id, 128).unwrap(); + assert!(s.quick_thumb_job(&job).is_none()); + assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #1448: the grid renders nothing behind an unedited raw's quick thumbnail, so when the +/// embedded preview can't be read (and nothing is cached) the quick job itself must render the +/// raw, or the cell stays empty for good ("no quick preview"). +#[test] +fn a_quick_thumbnail_without_an_embedded_preview_renders_the_raw() { + use crate::media::QuickSource; + let dir = std::env::temp_dir().join(format!("lc-quick-none-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("a.cr3"); + std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); + let mut s = crate::Session::new().with_fs(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + s.media.preview_loader = Some(std::sync::Arc::new(|_: &str, _| None)); + let job = s.thumb_job(id, 128).unwrap(); + let q = s.quick_thumb_job(&job).unwrap().run(); + assert_eq!((q.quick, q.key), (Some(QuickSource::Small), job.key)); + assert!(q.rendered.is_ok(), "{:?}", q.rendered.err()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/it/vendor_robust.rs` +/// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037). +fn sony_arw(model: &str) -> Vec { + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + // 3:2, the aspect Sony's lens opcodes are limited to + let (w, h) = (72u32, 48u32); + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set(0x7037, Value::SShort(vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![2000u16.to_le_bytes().repeat((w * h) as usize)] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("SONY".into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() +} + +/// Sony's embedded distortion correction is on by default: an ARW of a verified body imports with "Enable Lens +/// Corrections" on and still counts as unedited; another body's tables aren't read, so its correction is off. +#[test] +fn arw_with_lens_data_imports_with_profile_corrections_on() { + let dir = std::env::temp_dir().join(format!("lc-arw-lens-default-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = crate::Session::new().with_fs(); + for (model, on) in [("ILCE-7RM6", true), ("ILCE-7M4", false)] { + let path = dir.join(format!("{model}.arw")); + std::fs::write(&path, sony_arw(model)).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap())).unwrap(); + assert_eq!((p.format.as_str(), p.embedded_lens.is_some(), p.develop.optics.lens_profile), ("ARW", on, on), "{model}"); + assert!(!p.is_edited(), "{model}: as imported"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// RW2 and DNG keep their default: the correction is on when the file carries one (RW2 distortion, a DNG's +/// opcodes), off when it doesn't. +#[test] +fn rw2_and_dng_defaults_unchanged() { + use lightcraft_catalog::{Photo, PhotoId, Source}; + let s = crate::Session::new(); + let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), ..Default::default() }; + for format in ["RW2", "DNG"] { + for embedded in [None, Some(lens)] { + let name = format!("x.{format}"); + let mut p = Photo::new(PhotoId(1), Source::File { path: format!("/{name}") }, &name, format, 64, 48, ""); + p.kind = MediaKind::Raw; + p.embedded_lens = embedded; + crate::import::apply_import_defaults(&s, &mut p); + assert_eq!(p.develop.optics.lens_profile, embedded.is_some(), "{format} {embedded:?}"); + } + } +} + +/// Stored settings stay as they are when the library opens again: an ARW catalogued before its lens data was read +/// (none, correction off) keeps the correction off and counts as unedited, and one whose user switched the +/// correction off keeps it off. +#[test] +fn existing_photo_settings_are_not_rewritten() { + use lightcraft_catalog::{Op, PhotoId}; + let dir = std::env::temp_dir().join(format!("lc-arw-lens-existing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (lib, files) = (dir.join("lib"), dir.join("files")); + std::fs::create_dir_all(&files).unwrap(); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let import = |s: &mut crate::Session, name: &str| { + let path = files.join(name); + // trailing bytes of its own: not a duplicate of the other file + std::fs::write(&path, [sony_arw("ILCE-7RM6"), name.as_bytes().to_vec()].concat()).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + PhotoId(r["imported"][0].as_u64().unwrap()) + }; + let old = import(&mut s, "old.arw"); + // as a release that didn't read the Sony tables catalogued it + let mut d = (*s.catalog.photo(old).unwrap().develop).clone(); + d.optics.lens_profile = false; + let ops = vec![ + Op::SetEmbeddedLens { id: old, lens: None }, + Op::SetDevelop { id: old, settings: std::sync::Arc::new(d), label: "x".into(), edited: None }, + ]; + s.commit("x", Op::Batch { ops }).unwrap(); + let off = import(&mut s, "off.arw"); + s.execute("develop.merge", &serde_json::json!({"settings": {"optics": {"lens_profile": false}}})).unwrap(); + let before: Vec<_> = [old, off].iter().map(|id| s.catalog.photo(*id).unwrap().clone()).collect(); + assert!(before.iter().all(|p| !p.develop.optics.lens_profile)); + s.persist().unwrap(); + drop(s); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + for b in &before { + let p = s.catalog.photo(b.id).unwrap(); + assert_eq!((&p.develop, &p.embedded_lens), (&b.develop, &b.embedded_lens), "{}", p.file_name); + } + assert!(!s.catalog.photo(old).unwrap().is_edited(), "still as imported"); + drop(s); + let _ = std::fs::remove_dir_all(&dir); +}