Files
lightcraft/crates/engine/src/files/tests.rs
T

1777 lines
101 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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<u8> {
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<u8> = (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<u16>,
/// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB.
color_space: Option<u16>,
}
/// 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<u8> {
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<u8> {
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<u8> = (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, &not_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<u8> = (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<u8> = (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<u8> = (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<u8> = (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<u16> = (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<f32> {
let l: Vec<f32> = (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::<f32>();
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<f32> = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect();
let na = pa.iter().map(|v| v * v).sum::<f32>().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<u8> {
let px: Vec<u8> = (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<u8> {
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::<f32>()
/ 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::<f32>() / 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<u8> = 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::<f64>();
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<usize>, 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<usize> = (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::<f64>() / img.data.len().max(1) as f64;
let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::<f64>() / 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(&copy) > 4.0 * g(&full), "the fixture's copy is much brighter: {} vs {}", g(&copy), 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<u8>| {
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::<f32>();
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<u8> {
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<u8> = (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<u8> = (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<u8> = (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<u8> = (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<u8> {
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);
}