From 3e42f2e05c5b2779e381f4117e14f53aa34d1b2f Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 16:33:54 -0500 Subject: [PATCH 01/52] feat(testkit): #1037 tests record why they skip libtest has no skipped status and hides the output of passing tests, so a test that returns early without a GPU, a corpus file, a craft-fonts face or a model passed silently. `lightcraft-testkit` (the testkit slot the layering table already reserves, dev-dependency only, std only) adds `skip!(Need, "reason")`: it prints the note as before and, when LIGHTCRAFT_SKIP_LOG names a file, appends one JSON line (need, reason, test, binary, file:line). Plain `cargo test` is unchanged. The 61 existing skip sites use it. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 ++ Cargo.toml | 1 + apps/lightcraft/Cargo.toml | 4 + apps/lightcraft/src/window_gpu.rs | 4 +- crates/codecs/Cargo.toml | 2 + crates/codecs/tests/corpus.rs | 8 +- crates/engine/Cargo.toml | 2 + crates/engine/src/camera_preview.rs | 18 +- crates/engine/src/cmd/convert.rs | 2 +- crates/engine/src/export.rs | 8 +- crates/engine/src/files.rs | 4 +- crates/engine/src/tests_face_models.rs | 2 +- crates/engine/src/tests_face_recognize.rs | 2 +- crates/engine/src/tests_xmp.rs | 4 +- crates/engine/tests/preview_read.rs | 2 +- crates/faces/Cargo.toml | 2 + crates/faces/src/yunet.rs | 2 +- crates/gpu/Cargo.toml | 2 + crates/gpu/src/nn.rs | 2 +- crates/gpu/tests/equivalence.rs | 2 +- crates/gpu/tests/fallback.rs | 2 +- crates/gpu/tests/memory.rs | 2 +- crates/gpu/tests/shutdown.rs | 3 +- crates/raw/Cargo.toml | 2 + crates/raw/tests/corpus.rs | 42 ++--- crates/raw/tests/cr3_corpus.rs | 4 +- crates/raw/tests/sparse_corpus.rs | 2 +- crates/segment/Cargo.toml | 4 + crates/segment/tests/reference.rs | 2 +- crates/testkit/Cargo.toml | 12 ++ crates/testkit/src/lib.rs | 190 ++++++++++++++++++++++ crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/i18n.rs | 4 +- crates/ui-egui/src/theme.rs | 2 +- 34 files changed, 297 insertions(+), 61 deletions(-) create mode 100644 crates/testkit/Cargo.toml create mode 100644 crates/testkit/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 5f2609a5b..43d5f119c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3081,6 +3081,7 @@ dependencies = [ "lightcraft-engine", "lightcraft-raster", "lightcraft-sysmem", + "lightcraft-testkit", "lightcraft-ui-egui", "log", "muda", @@ -3144,6 +3145,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-heif", "lightcraft-raster", + "lightcraft-testkit", "moxcms 0.9.1", "png", "proptest", @@ -3239,6 +3241,7 @@ dependencies = [ "lightcraft-raw", "lightcraft-scenes", "lightcraft-segment", + "lightcraft-testkit", "lightcraft-tiff", "log", "miniz_oxide 0.8.9", @@ -3254,6 +3257,7 @@ dependencies = [ name = "lightcraft-faces" version = "0.6.0" dependencies = [ + "lightcraft-testkit", "matrixmultiply", "ndarray", "proptest", @@ -3304,6 +3308,7 @@ dependencies = [ "lightcraft-raster", "lightcraft-raw", "lightcraft-scenes", + "lightcraft-testkit", "log", "pollster 1.0.1", "wgpu", @@ -3424,6 +3429,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-meta", "lightcraft-raster", + "lightcraft-testkit", "lightcraft-tiff", "miniz_oxide 0.8.9", "proptest", @@ -3454,6 +3460,7 @@ dependencies = [ "candle-core", "candle-nn", "lightcraft-fetch", + "lightcraft-testkit", "log", "serde", "serde_json", @@ -3464,6 +3471,10 @@ dependencies = [ name = "lightcraft-sysmem" version = "0.6.0" +[[package]] +name = "lightcraft-testkit" +version = "0.6.0" + [[package]] name = "lightcraft-tiff" version = "0.6.0" @@ -3493,6 +3504,7 @@ dependencies = [ "lightcraft-pipeline", "lightcraft-preview", "lightcraft-raster", + "lightcraft-testkit", "log", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 7163c757c..feaee1d54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ lightcraft-fetch = { path = "crates/fetch" } lightcraft-heif = { path = "crates/heif" } lightcraft-segment = { path = "crates/segment" } lightcraft-geocode = { path = "crates/geocode" } +lightcraft-testkit = { path = "crates/testkit" } # foundation thiserror = "2" diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index bbe495c7d..26122c3e6 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -48,3 +48,7 @@ muda = { workspace = true } objc2 = { workspace = true } objc2-app-kit = { workspace = true } objc2-foundation = { workspace = true } + +[dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs index 66e5a78d7..1f84ec477 100644 --- a/apps/lightcraft/src/window_gpu.rs +++ b/apps/lightcraft/src/window_gpu.rs @@ -278,11 +278,11 @@ mod tests { ..wgpu::InstanceDescriptor::new_without_display_handle() }); let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else { - eprintln!("no GPU adapter: skipped"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); return; }; let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else { - eprintln!("no GPU device: skipped"); + lightcraft_testkit::skip!(Gpu, "no GPU device"); return; }; install_error_handler(&device); diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 166fbf4dc..1ae016bc8 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -48,6 +48,8 @@ heif = ["dep:lightcraft-heif"] avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } # JPEG XL tests read encoded samples without our colour conversion jxl-oxide = { workspace = true } proptest = { workspace = true } diff --git a/crates/codecs/tests/corpus.rs b/crates/codecs/tests/corpus.rs index d3b2ceba3..9a86bc5e2 100644 --- a/crates/codecs/tests/corpus.rs +++ b/crates/codecs/tests/corpus.rs @@ -31,7 +31,7 @@ fn files(dir: &Path) -> Vec { fn corpus_images_decode() { let dir = corpus_root().join("images"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } let (mut ok, mut unsupported) = (0, 0); @@ -57,7 +57,7 @@ fn corpus_images_decode() { fn corpus_pngsuite() { let dir = corpus_root().join("pngsuite"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } let mut n = 0; @@ -83,7 +83,7 @@ fn corpus_pngsuite() { fn corpus_raw_is_routed() { let dir = corpus_root().join("raw"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } for p in files(&dir) { @@ -103,7 +103,7 @@ fn system_icc_profiles() { use lightcraft_codecs::icc::{IccColorModel, IccKind, parse}; let dir = Path::new("/System/Library/ColorSync/Profiles"); if !dir.is_dir() { - eprintln!("skip: no system ICC profiles"); + lightcraft_testkit::skip!(System, "no system ICC profiles"); return; } let expect = [ diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 2229c42a9..203135867 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -51,6 +51,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } # HDR JPEG XL tests read the file's colour encoding jxl-oxide = { workspace = true } # Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on). diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..758714a75 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1828,7 +1828,7 @@ mod tests { .join("raw"); for name in ["arw-sony-a7m4-14bit.arw", "cr2-canon-5d3.cr2", "nef-nikon-d5100-lossless.nef", "cr2-canon-7d.cr2", "dng-canon-5d3-16bit.dng"] { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let mut raw = lightcraft_raw::decode(&bytes).unwrap(); @@ -1929,13 +1929,13 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/cr3-canon-r100-raw.cr3"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = match lightcraft_raw::decode(&bytes) { Ok(raw) => raw, Err(lightcraft_raw::RawError::Unsupported(why)) => { - eprintln!("skip CR3 sensor decoder: {why}"); + lightcraft_testkit::skip!(Feature, "CR3 sensor decoder: {why}"); return; } Err(error) => panic!("CR3 corpus failed to decode: {error}"), @@ -1964,7 +1964,7 @@ mod tests { let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; } Err(e) => panic!("{}: {e}", path.display()), @@ -1995,7 +1995,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/nef-nikon-d7500-lossless14.nef"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2015,7 +2015,7 @@ mod tests { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -2036,7 +2036,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/rw2-panasonic-fz1000m2-4x3.rw2"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2055,7 +2055,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/arw-sony-a7rm2-12bit-uncompressed.arw"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2213,7 +2213,7 @@ mod tests { let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); continue; } Err(e) => panic!("{}: {e}", path.display()), diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 4d7f13aeb..1eaec749d 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -494,7 +494,7 @@ mod tests { .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); let f = root.join("raw/rw2-panasonic-gx80.rw2"); if !f.exists() { - eprintln!("skip: {} absent", f.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", f.display()); return; } let mut s = Session::new().with_fs(); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index f356d6e4d..4b91e5b78 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -2414,7 +2414,7 @@ mod tests { fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() { use ab_glyph::Font; let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else { - eprintln!("skipped: built without BIZ UDMincho from craft-fonts"); + lightcraft_testkit::skip!(Fonts, "built without BIZ UDMincho from craft-fonts"); return; }; let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap(); @@ -2486,7 +2486,7 @@ mod tests { compare(composed, decomposed, crate::fonts::CRAFT_FONTS); } } else { - eprintln!("skipped Japanese comparisons: built without BIZ UDMincho from craft-fonts"); + lightcraft_testkit::skip!(Fonts, "Japanese comparisons: built without BIZ UDMincho from craft-fonts"); } } @@ -2510,7 +2510,7 @@ mod tests { use ab_glyph::Font; let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS); if fonts.len() < 2 { - eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); + lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); return; } for c in "日本語の文字".chars() { @@ -3090,7 +3090,7 @@ mod tests { #[test] fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason()); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); return; } let mut s = crate::Session::with_demo(); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff77..2ab7c21f0 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1133,7 +1133,7 @@ mod tests { .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 { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply"); @@ -1484,7 +1484,7 @@ mod tests { .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 { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let (img, _) = load_bytes(&bytes, 1600).unwrap(); diff --git a/crates/engine/src/tests_face_models.rs b/crates/engine/src/tests_face_models.rs index f1c27c849..49532c471 100644 --- a/crates/engine/src/tests_face_models.rs +++ b/crates/engine/src/tests_face_models.rs @@ -179,7 +179,7 @@ fn hostile_ids_unsupported_models_and_odd_folders_are_handled() { /// Settings > Faces downloads; nothing is committed). Without it those tests say so and pass. fn demo_with_yunet() -> Option { let Some(file) = std::env::var_os("LC_YUNET_MODEL") else { - eprintln!("LC_YUNET_MODEL is not set: skipping"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set"); return None; }; let dir = temp("yunet"); diff --git a/crates/engine/src/tests_face_recognize.rs b/crates/engine/src/tests_face_recognize.rs index aabcb03c3..5aab8f9a4 100644 --- a/crates/engine/src/tests_face_recognize.rs +++ b/crates/engine/src/tests_face_recognize.rs @@ -34,7 +34,7 @@ pub(crate) fn setup(d: &std::path::Path, dim: u64) -> (Session, String) { /// committed) and says whether it did: the parts of a test that need to find faces run only then. pub(crate) fn install_yunet(s: &mut Session) -> bool { let Some(file) = std::env::var_os("LC_YUNET_MODEL") else { - eprintln!("LC_YUNET_MODEL is not set: skipping the face-finding part"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set, so the face-finding part is not checked"); return false; }; let r = s.execute("faces.models.install", &json!({"path": file.to_string_lossy(), "acknowledged": true})); diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index c768078ce..9c2b40849 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -581,7 +581,7 @@ fn convert_raw_to_dng() { let r = s.execute("photo.convertToDng", &json!({})).unwrap(); assert_eq!((r["converted"].as_array().unwrap().len(), r["skipped"].as_array().unwrap().len()), (0, 1)); if !corpus.exists() { - eprintln!("skip: {} absent", corpus.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display()); return; } let nef = dir.join("shot.nef"); @@ -843,7 +843,7 @@ fn dng_export_compression_choices() { .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/nef-nikon-d5100-uncompressed.nef"); if !corpus.exists() { - eprintln!("skip: {} absent", corpus.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display()); return; } let mut s = Session::new().with_fs(); diff --git a/crates/engine/tests/preview_read.rs b/crates/engine/tests/preview_read.rs index 11ffec44e..34aae9591 100644 --- a/crates/engine/tests/preview_read.rs +++ b/crates/engine/tests/preview_read.rs @@ -13,7 +13,7 @@ use std::time::Instant; #[ignore = "needs LC_PREVIEW_RAW pointing at a real raw file"] fn a_preview_reads_only_the_header_and_the_preview() { let Ok(path) = std::env::var("LC_PREVIEW_RAW") else { - eprintln!("skipped: set LC_PREVIEW_RAW="); + lightcraft_testkit::skip!(Input, "set LC_PREVIEW_RAW="); return; }; let file_len = std::fs::metadata(&path).expect("the file exists").len(); diff --git a/crates/faces/Cargo.toml b/crates/faces/Cargo.toml index 112967cee..78ea0b798 100644 --- a/crates/faces/Cargo.toml +++ b/crates/faces/Cargo.toml @@ -22,4 +22,6 @@ sha2 = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } proptest = { workspace = true } diff --git a/crates/faces/src/yunet.rs b/crates/faces/src/yunet.rs index b77f318bf..56f5d6474 100644 --- a/crates/faces/src/yunet.rs +++ b/crates/faces/src/yunet.rs @@ -263,7 +263,7 @@ mod tests { fn detector() -> Option { let Some(bytes) = model() else { - eprintln!("LC_YUNET_MODEL is not set: skipping"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set"); return None; }; Some(Detector::new(&bytes).unwrap()) diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index dc348ee88..5547c548d 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -29,5 +29,7 @@ default = [] denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } lightcraft-scenes = { workspace = true } lightcraft-raw = { workspace = true } diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 4f078ff93..5872bfaa9 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -907,7 +907,7 @@ mod tests { match runner(net, tile) { Ok(r) => Some(r), Err(e) => { - eprintln!("skipped: no GPU runner here ({e})"); + lightcraft_testkit::skip!(Gpu, "no GPU runner here ({e})"); None } } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 96771a971..16a9263c1 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -19,7 +19,7 @@ const MAX_LSB: u8 = 3; fn gpu() -> bool { let ok = lightcraft_gpu::available(); if !ok { - eprintln!("skipped: no GPU adapter"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); } ok } diff --git a/crates/gpu/tests/fallback.rs b/crates/gpu/tests/fallback.rs index fe2ecad5e..05347b446 100644 --- a/crates/gpu/tests/fallback.rs +++ b/crates/gpu/tests/fallback.rs @@ -38,7 +38,7 @@ fn masked(s: &mut DevelopSettings, k: usize) { #[test] fn failed_gpu_renders_fall_back_with_a_reason() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason()); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); return; } assert_eq!(lightcraft_gpu::unavailable_reason(), None); diff --git a/crates/gpu/tests/memory.rs b/crates/gpu/tests/memory.rs index 2ef958cb1..25784f001 100644 --- a/crates/gpu/tests/memory.rs +++ b/crates/gpu/tests/memory.rs @@ -9,7 +9,7 @@ use lightcraft_pipeline::{RenderRequest, SourceInfo, StageCache}; #[test] fn stages_are_counted_and_the_pool_is_bounded_and_trimmed() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); return; } let limit = 64u64 << 20; diff --git a/crates/gpu/tests/shutdown.rs b/crates/gpu/tests/shutdown.rs index b827a0711..a8d5cbd23 100644 --- a/crates/gpu/tests/shutdown.rs +++ b/crates/gpu/tests/shutdown.rs @@ -21,7 +21,8 @@ fn nothing_renders_on_the_gpu_once_the_process_is_ending() { let req = RenderRequest::fit(w, h); let has_gpu = lightcraft_gpu::available(); if !has_gpu { - eprintln!("no GPU adapter ({:?}): only the CPU side is checked", lightcraft_gpu::unavailable_reason()); + let why = lightcraft_gpu::unavailable_reason().unwrap_or_default(); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({why}), so only the CPU side is checked"); } assert!(!lightcraft_gpu::shutting_down()); assert!(lightcraft_gpu::wait_idle(Duration::ZERO), "nothing is in flight yet"); diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index 2ca9ef246..84f9fa91a 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -28,6 +28,8 @@ default = ["jxl"] jxl = ["dep:jxl-oxide"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } jpeg-encoder = { workspace = true } proptest = { workspace = true } zune-jpegxl = { workspace = true } diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 5ca4777b7..cf5bc5290 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -25,7 +25,7 @@ const KNOWN_UNSUPPORTED: &[&str] = &[ fn corpus_raw_decodes() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut paths: Vec = rd.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect(); @@ -159,7 +159,7 @@ fn corpus_cr2_cfa_patterns() { let mut seen = 0; for (name, want) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -189,7 +189,7 @@ fn corpus_cr2_as_shot_white_balance() { "cr2-canon-80d.cr2", ] { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let wb = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")).wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot WB")); @@ -206,7 +206,7 @@ fn corpus_nef_compressed_matches_uncompressed() { let dir = corpus_root().join("raw"); let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d5100-lossless.nef")), std::fs::read(dir.join("nef-nikon-d5100-uncompressed.nef"))) else { - eprintln!("skip: D5100 NEF pair absent"); + lightcraft_testkit::skip!(Corpus, "D5100 NEF pair absent"); return; }; let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap()); @@ -231,7 +231,7 @@ fn corpus_nef_compressed_matches_uncompressed() { fn corpus_nef_high_efficiency_is_named() { let dir = corpus_root().join("raw"); let (Ok(he), Ok(lossless)) = (std::fs::read(dir.join("nef-nikon-zf-he.nef")), std::fs::read(dir.join("nef-nikon-zf-lossless.nef"))) else { - eprintln!("skip: Z f samples absent"); + lightcraft_testkit::skip!(Corpus, "Z f samples absent"); return; }; for result in [decode(&he).map(|_| ()), probe_info(&he).map(|_| ())] { @@ -248,7 +248,7 @@ fn corpus_nef_12_bit_black_level_matches_14_bit() { let dir = corpus_root().join("raw"); let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d7500-lossless12.nef")), std::fs::read(dir.join("nef-nikon-d7500-lossless14.nef"))) else { - eprintln!("skip: D7500 NEF pair absent"); + lightcraft_testkit::skip!(Corpus, "D7500 NEF pair absent"); return; }; let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap()); @@ -278,7 +278,7 @@ fn corpus_nef_12_bit_black_level_matches_14_bit() { fn corpus_dngs_carry_profile_looks() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut seen = 0; @@ -327,7 +327,7 @@ fn corpus_dngs_carry_profile_looks() { fn corpus_proraw_sky_matte() { let path = corpus_root().join("raw/dng-apple-iphone12pro-proraw.dng"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let t0 = Instant::now(); @@ -365,7 +365,7 @@ fn corpus_sony_pre2017_colour_metadata() { let mut seen = 0; for (name, black, [r, b], (cw, ch)) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -397,7 +397,7 @@ fn corpus_sony_a7cr_codings() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -464,7 +464,7 @@ fn corpus_sony_a7cm2_codings() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -509,7 +509,7 @@ fn corpus_sony_black_levels_sit_at_the_data_floor() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -567,7 +567,7 @@ fn corpus_sony_sr2_white_balance() { let mut seen = 0; for (name, [r, b]) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -585,7 +585,7 @@ fn corpus_sony_sr2_white_balance() { fn corpus_sony_a900_packed12() { let name = "arw-sony-a900-packed12.arw"; let Ok(bytes) = std::fs::read(corpus_root().join("raw").join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); return; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -607,7 +607,7 @@ fn corpus_sony_16x9_crops() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -727,7 +727,7 @@ fn corpus_panasonic_encodings() { let mut seen = 0; for (name, bits, black, (cw, ch), cfa) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -777,7 +777,7 @@ fn corpus_orf_cfa_patterns() { let mut seen = 0; for (name, cfa) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -812,7 +812,7 @@ fn corpus_samsung_srw() { let mut seen = 0; for (name, bits, active, crop, cfa, whole) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -941,7 +941,7 @@ fn corpus_sony_arw6_samples() { fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut checked = 0; @@ -1004,7 +1004,7 @@ fn sony_embedded_distortion_is_limited_to_validated_models() { ] { let path = root.join(name); if !path.exists() { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); continue; } let bytes = std::fs::read(&path).unwrap(); @@ -1026,7 +1026,7 @@ fn corpus_kodak_dcs520c() { let name = "2573_Kodak - DCS520C - 12bit (3_2).TIF"; let candidates = [corpus_root().join("raw").join("kodak-dcs520c.tif"), Path::new("P:/raw-pixls/Kodak/DCS520C").join(name)]; let Some(bytes) = candidates.iter().find_map(|p| std::fs::read(p).ok()) else { - eprintln!("skip: DCS520C sample absent"); + lightcraft_testkit::skip!(Corpus, "DCS520C sample absent"); return; }; assert_eq!(probe(&bytes), Some(RawFormat::KodakDcs)); diff --git a/crates/raw/tests/cr3_corpus.rs b/crates/raw/tests/cr3_corpus.rs index ebf669307..06f7150a8 100644 --- a/crates/raw/tests/cr3_corpus.rs +++ b/crates/raw/tests/cr3_corpus.rs @@ -22,7 +22,7 @@ fn check(name: &str, hash: u64, sensor: (usize, usize), developed: (usize, usize fn check_against(name: &str, hash: u64, sensor: (usize, usize), developed: Option<(usize, usize)>, reference: &str) { let path = corpus().join("raw").join(name); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -118,7 +118,7 @@ fn canon_m50_craw_crop_is_the_shot_aspect() { // which is (12, 12) inside the valid sensor area. let path = corpus().join("raw").join("cr3-canon-m50-craw.cr3"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = decode(&bytes).unwrap(); diff --git a/crates/raw/tests/sparse_corpus.rs b/crates/raw/tests/sparse_corpus.rs index 3ab735299..4d9112bfb 100644 --- a/crates/raw/tests/sparse_corpus.rs +++ b/crates/raw/tests/sparse_corpus.rs @@ -58,7 +58,7 @@ fn partial_reads_describe_raws_as_whole_files_do() { .filter(|p| p.is_file() && !p.extension().is_some_and(|e| e == "part" || e == "txt" || e == "md")) .collect(); if paths.is_empty() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } paths.sort(); diff --git a/crates/segment/Cargo.toml b/crates/segment/Cargo.toml index 11dff5c77..b4614a81d 100644 --- a/crates/segment/Cargo.toml +++ b/crates/segment/Cargo.toml @@ -23,3 +23,7 @@ lightcraft-fetch = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] candle-core = { workspace = true, features = ["metal"] } candle-nn = { workspace = true, features = ["metal"] } + +[dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } diff --git a/crates/segment/tests/reference.rs b/crates/segment/tests/reference.rs index a73fcaa7c..315d36cd1 100644 --- a/crates/segment/tests/reference.rs +++ b/crates/segment/tests/reference.rs @@ -38,7 +38,7 @@ fn mask_disagreement(a: &[f32], b: &[f32]) -> f32 { #[test] fn matches_reference() { let Some((dir, r)) = setup() else { - eprintln!("skipped: set LIGHTCRAFT_SAM3_DIR and LIGHTCRAFT_SAM3_REF"); + lightcraft_testkit::skip!(Model, "set LIGHTCRAFT_SAM3_DIR and LIGHTCRAFT_SAM3_REF"); return; }; let mut model = Sam3::load(&dir).unwrap(); diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml new file mode 100644 index 000000000..cefa1ef5c --- /dev/null +++ b/crates/testkit/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lightcraft-testkit" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft test tooling (dev-dependency only): a test that can't run here records why, so `cargo xtask ci` reports it" +publish = false + +[lints] +workspace = true + +[dependencies] diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs new file mode 100644 index 000000000..7a2322245 --- /dev/null +++ b/crates/testkit/src/lib.rs @@ -0,0 +1,190 @@ +//! Test tooling shared by the workspace's tests. A dev-dependency only: `cargo xtask layers` +//! refuses it as a normal dependency. Std only, so every crate can use it at no build cost. +//! +//! **Skips.** libtest has no "skipped" status and hides the output of passing tests, so a test that +//! needs a GPU, a corpus file, the craft-fonts faces or a model, and returns early without them, +//! passes silently: "all steps passed" could mean the GPU tests never ran. Such a test calls +//! [`skip!`] before it returns: +//! +//! ```no_run +//! # let gpu_available = false; +//! if !gpu_available { +//! lightcraft_testkit::skip!(Gpu, "no GPU adapter"); +//! return; +//! } +//! ``` +//! +//! It prints the note to stderr as before and, when the [`SKIP_LOG_ENV`] variable names a file +//! (`cargo xtask ci` sets it), appends one JSON line there: +//! `{"need":"gpu","reason":"no GPU adapter","test":"camera_tone","binary":"equivalence","location":"crates/gpu/tests/equivalence.rs:22"}`. +//! `cargo xtask ci` lists the skips by need and reason in its summary and in +//! `target/ci/report.json`. A test that runs part of its checks (e.g. only the CPU half) records +//! the part it could not run the same way. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use std::fmt::Write as _; +use std::io::Write as _; +use std::path::Path; + +/// The environment variable naming the file skips are appended to (JSON lines). +pub const SKIP_LOG_ENV: &str = "LIGHTCRAFT_SKIP_LOG"; + +/// What a skipped test needed and didn't find. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Need { + /// A usable GPU adapter (`lightcraft_gpu::available()`). + Gpu, + /// A file from the gitignored `corpus/` (`cargo xtask corpus --download`) or a private corpus. + Corpus, + /// Faces from craft-fonts, embedded only in builds with `CRAFT_FONTS_DIR`. + Fonts, + /// A model file named by an environment variable (SAM 3, YuNet, …). + Model, + /// An input file named by an environment variable (opt-in tests). + Input, + /// Something the operating system provides (system ICC profiles, …). + System, + /// Support this build doesn't have yet (a decoder that refuses a file variant, an optional + /// cargo feature that is off). + Feature, +} + +impl Need { + /// The name in the skip log and the CI summary. + pub fn as_str(self) -> &'static str { + match self { + Need::Gpu => "gpu", + Need::Corpus => "corpus", + Need::Fonts => "fonts", + Need::Model => "model", + Need::Input => "input", + Need::System => "system", + Need::Feature => "feature", + } + } +} + +/// Record that this test (or part of it) can't run here: `skip!(Gpu, "no GPU adapter ({why})")`. +/// The first argument names a [`Need`] variant; the rest is a `format!` message. Doesn't return +/// from the test: follow it with `return` (or `continue`) as the test needs. +#[macro_export] +macro_rules! skip { + ($need:ident, $($arg:tt)+) => { + $crate::record($crate::Need::$need, &::std::format!($($arg)+), ::std::file!(), ::std::line!()) + }; +} + +/// What [`skip!`] calls: print the note and append it to the skip log, if one is set. Never fails +/// the test: a log that can't be written loses the entry, not the run. +pub fn record(need: Need, reason: &str, file: &str, line: u32) { + eprintln!("skipped ({}): {reason}", need.as_str()); + let log = std::env::var_os(SKIP_LOG_ENV).filter(|p| !p.is_empty()); + if let Some(log) = log { + let thread = std::thread::current(); + let binary = std::env::current_exe().ok().and_then(|p| p.file_stem().map(|s| binary_name(&s.to_string_lossy()).to_owned())); + let entry = entry(need, reason, thread.name().unwrap_or(""), binary.as_deref().unwrap_or(""), file, line); + append(Path::new(&log), &entry); + } +} + +/// Append one line in a single write, so lines from parallel tests don't interleave. +fn append(path: &Path, line: &str) { + let file = std::fs::OpenOptions::new().create(true).append(true).open(path); + if let Err(e) = file.and_then(|mut f| f.write_all(line.as_bytes())) { + eprintln!("{SKIP_LOG_ENV}: can't append to {}: {e}", path.display()); + } +} + +/// A test binary's name without the hash cargo appends (`equivalence-0123abcd` → `equivalence`). +fn binary_name(stem: &str) -> &str { + match stem.rsplit_once('-') { + Some((name, hash)) if !name.is_empty() && hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()) => name, + _ => stem, + } +} + +/// One skip-log line (JSON, newline-terminated). +fn entry(need: Need, reason: &str, test: &str, binary: &str, file: &str, line: u32) -> String { + let mut s = String::from("{"); + for (i, (key, value)) in [("need", need.as_str()), ("reason", reason), ("test", test), ("binary", binary)].into_iter().enumerate() { + if i > 0 { + s.push(','); + } + let _ = write!(s, "\"{key}\":\"{}\"", escape(value)); + } + let _ = writeln!(s, ",\"location\":\"{}:{line}\"}}", escape(&file.replace('\\', "/"))); + s +} + +/// A JSON string's contents. +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if u32::from(c) < 0x20 => { + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_entry_is_one_json_line_with_its_text_escaped() { + let line = entry(Need::Corpus, "skip: C:\\corpus\\raw \"absent\"\n\u{1}", "corpus::decodes", "corpus", "crates\\raw\\tests\\corpus.rs", 28); + assert!(line.ends_with('\n')); + assert_eq!(line.matches('\n').count(), 1, "{line}"); + assert_eq!( + line, + "{\"need\":\"corpus\",\"reason\":\"skip: C:\\\\corpus\\\\raw \\\"absent\\\"\\n\\u0001\",\"test\":\"corpus::decodes\",\ + \"binary\":\"corpus\",\"location\":\"crates/raw/tests/corpus.rs:28\"}\n" + ); + } + + #[test] + fn binary_names_lose_cargos_hash_only() { + assert_eq!(binary_name("equivalence-0f3a9c2b1d4e5f60"), "equivalence"); + assert_eq!(binary_name("lightcraft_engine-0f3a9c2b1d4e5f60"), "lightcraft_engine"); + assert_eq!(binary_name("sparse_corpus"), "sparse_corpus"); + assert_eq!(binary_name("cr3-corpus"), "cr3-corpus"); + assert_eq!(binary_name("-0f3a9c2b1d4e5f60"), "-0f3a9c2b1d4e5f60"); + assert_eq!(binary_name("tool-cafe"), "tool-cafe"); + } + + #[test] + fn appended_lines_accumulate() { + let dir = std::env::temp_dir().join(format!("lightcraft-testkit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let log = dir.join("skips.jsonl"); + let _ = std::fs::remove_file(&log); + let threads: Vec<_> = (0..8) + .map(|i| { + let log = log.clone(); + std::thread::spawn(move || append(&log, &entry(Need::Gpu, &format!("no GPU {i}"), "t", "b", "f.rs", i))) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + let text = std::fs::read_to_string(&log).unwrap(); + assert_eq!(text.lines().count(), 8); + assert!(text.lines().all(|l| l.starts_with("{\"need\":\"gpu\"") && l.ends_with('}'))); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_missing_log_directory_does_not_fail_the_test() { + append(Path::new("/nonexistent-lightcraft-dir/skips.jsonl"), "{}\n"); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 74bc80058..38322d834 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -27,6 +27,8 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } lightcraft-denoise = { workspace = true, features = ["runtime"] } lightcraft-codecs = { workspace = true } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 109accfa9..c040492a3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -597,7 +597,7 @@ mod tests { lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect(); faces.dedup(); if faces.is_empty() { - eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); continue; } ctx.fonts_mut(|fonts| { @@ -1200,7 +1200,7 @@ mod tests { let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")]; for (language, sample) in samples { if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) { - eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); continue; } set_language(language); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a87a196ae..30a9823d1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -415,7 +415,7 @@ mod tests { #[test] fn thai_face_is_a_fallback_in_every_ui_family_when_craft_fonts_carry_one() { let Some(thai) = lightcraft_engine::CRAFT_FONTS.iter().find(|f| f.covers("Thai")) else { - eprintln!("no Thai face in craft-fonts: nothing to check"); + lightcraft_testkit::skip!(Fonts, "no Thai face in craft-fonts"); return; }; set_language(Locale::En); From 82045f4d2cd0c99b3763c406a10cd9caf9295e20 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 16:33:54 -0500 Subject: [PATCH 02/52] feat(xtask): #1037 ci reports the machine, GPU adapters and skipped tests `cargo xtask ci` points each test step at target/ci/skips-.jsonl, then probes the machine: OS, CPUs, RAM, toolchain, corpus and craft-fonts, and the GPU through the ignored `gpu_probe` test of lightcraft-gpu (run right after the workspace tests with --workspace, so it reuses their build; xtask stays without wgpu). The run ends with the skips grouped by need and reason and writes target/ci/report.json (schema 1: commit, dirty flag, machine, capabilities, step results and times, skips) and target/ci/summary.md for a PR (`--summary-md` prints it). `--hardware` also runs the lightcraft-gpu tests once per backend that has a hardware adapter. Nothing is uploaded; reasons are cut free of the workspace root and home directory. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/tests/gpu_probe.rs | 87 +++++ xtask/src/ci_report.rs | 599 ++++++++++++++++++++++++++++++++++ xtask/src/main.rs | 194 +++++++++-- 3 files changed, 849 insertions(+), 31 deletions(-) create mode 100644 crates/gpu/tests/gpu_probe.rs create mode 100644 xtask/src/ci_report.rs diff --git a/crates/gpu/tests/gpu_probe.rs b/crates/gpu/tests/gpu_probe.rs new file mode 100644 index 000000000..e006fbc80 --- /dev/null +++ b/crates/gpu/tests/gpu_probe.rs @@ -0,0 +1,87 @@ +//! What GPU this machine offers the develop pipeline, as one JSON line: the capability probe of +//! `cargo xtask ci`, which is std + serde_json and has no wgpu of its own. Ignored in normal runs; +//! xtask runs it after the workspace tests, so it costs no extra build: +//! `cargo test --workspace --test gpu_probe -- --ignored --nocapture`. +//! +//! - `LIGHTCRAFT_GPU_PROBE` unset: the compute device the pipeline picks (`available()`, +//! `adapter_name()`, `unavailable_reason()`), the backends it may use and the backends this build +//! contains. +//! - `LIGHTCRAFT_GPU_PROBE=` (`vulkan`, `metal`, `dx12`, `gl`): every adapter that +//! backend lists, software ones (lavapipe, llvmpipe, WARP) included. One backend per process, so +//! a driver that crashes while it is enumerated (issue #136) costs only its own line. +//! +//! Only reports: which adapters the pipeline accepts is `ctx.rs`'s policy. +#![cfg(not(target_arch = "wasm32"))] + +use lightcraft_gpu::backend::{self, Backends}; + +const NAMES: [(&str, Backends); 4] = [("vulkan", Backends::VULKAN), ("metal", Backends::METAL), ("dx12", Backends::DX12), ("gl", Backends::GL)]; + +#[test] +#[ignore = "the capability probe of `cargo xtask ci`"] +fn gpu_probe() { + let line = match std::env::var("LIGHTCRAFT_GPU_PROBE").ok().filter(|b| !b.is_empty()) { + None => pipeline(), + Some(name) => adapters(&name), + }; + // its own line, prefixed: libtest may have printed `test gpu_probe ... ` on this one + println!("\ngpu_probe: {line}"); +} + +fn names(set: Backends) -> String { + let list: Vec = NAMES.iter().filter(|(_, b)| set.contains(*b)).map(|(n, _)| json_str(n)).collect(); + format!("[{}]", list.join(",")) +} + +fn pipeline() -> String { + let available = lightcraft_gpu::available(); + let opt = |s: Option| s.map_or_else(|| "null".to_owned(), |s| json_str(&s)); + let compiled = wgpu::Instance::enabled_backend_features(); + format!( + "{{\"pipeline\":{{\"available\":{available},\"adapter\":{},\"reason\":{},\"backends\":{},\"compiled\":{}}}}}", + opt(lightcraft_gpu::adapter_name()), + opt(lightcraft_gpu::unavailable_reason()), + names(backend::compute_backends().unwrap_or(Backends::empty()) & compiled), + names(compiled), + ) +} + +fn adapters(name: &str) -> String { + let (_, set) = NAMES.iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("unknown backend `{name}` (vulkan, metal, dx12, gl)")); + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + desc.backends = *set; + desc.backend_options = backend::backend_options(); + desc.flags = backend::instance_flags_from_env(desc.flags); + let instance = wgpu::Instance::new(desc); + let adapters: Vec = pollster::block_on(instance.enumerate_adapters(*set)) + .iter() + .map(|a| { + let i = a.get_info(); + format!( + "{{\"name\":{},\"type\":{},\"vendor\":\"0x{:04x}\",\"device\":\"0x{:04x}\",\"driver\":{},\"driver_info\":{}}}", + json_str(&i.name), + json_str(&format!("{:?}", i.device_type)), + i.vendor, + i.device, + json_str(&i.driver), + json_str(&i.driver_info), + ) + }) + .collect(); + format!("{{\"backend\":{},\"adapters\":[{}]}}", json_str(name), adapters.join(",")) +} + +/// A JSON string literal. +fn json_str(s: &str) -> String { + let mut out = String::from("\""); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c if u32::from(c) < 0x20 => out.push_str(&format!("\\u{:04x}", u32::from(c))), + c => out.push(c), + } + } + out.push('"'); + out +} diff --git a/xtask/src/ci_report.rs b/xtask/src/ci_report.rs new file mode 100644 index 000000000..dfbd9f5f9 --- /dev/null +++ b/xtask/src/ci_report.rs @@ -0,0 +1,599 @@ +//! What a `cargo xtask ci` run covered on this machine: the capability probe (OS, CPUs, RAM, GPU +//! adapters per backend, corpus, craft-fonts), each step's result and time, and the tests that +//! skipped for want of hardware or data. Written to `target/ci/report.json` and +//! `target/ci/summary.md` (a block to paste into a PR) and printed after the CI summary. +//! +//! Tests record skips with `lightcraft_testkit::skip!`; each test step points +//! `LIGHTCRAFT_SKIP_LOG` at `target/ci/skips-.jsonl`. The GPU adapters come from the ignored +//! `gpu_probe` test of `lightcraft-gpu` (xtask has no wgpu). Nothing here is sent anywhere. +//! +//! The report holds no host name, user name or absolute path: the workspace root and the home +//! directory are cut from skip reasons. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{Value, json}; + +/// Bumped when a field changes meaning. +pub const SCHEMA: u64 = 1; + +/// The environment variable `lightcraft-testkit` appends skips to. +pub const SKIP_LOG_ENV: &str = "LIGHTCRAFT_SKIP_LOG"; + +/// `target/ci` under the workspace root (not `CARGO_TARGET_DIR`: one place to look, like +/// `target/bench`). +pub fn dir(root: &Path) -> PathBuf { + root.join("target").join("ci") +} + +/// Create `target/ci` and remove the previous run's skip logs, report and summary. +pub fn prepare(root: &Path) -> Result { + let dir = dir(root); + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for entry in std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if (name.starts_with("skips-") && name.ends_with(".jsonl")) || name == "report.json" || name == "summary.md" { + let _ = std::fs::remove_file(entry.path()); + } + } + Ok(dir) +} + +/// The skip log of one step. +pub fn skip_log(root: &Path, step: &str) -> PathBuf { + let safe: String = step.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect(); + dir(root).join(format!("skips-{safe}.jsonl")) +} + +fn output(program: &str, args: &[&str], cwd: Option<&Path>) -> Option { + let mut c = Command::new(program); + c.args(args); + if let Some(cwd) = cwd { + c.current_dir(cwd); + } + let out = c.output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()) +} + +/// Commit, branch and whether the work tree has uncommitted changes. +pub fn git(root: &Path) -> Value { + let commit = output("git", &["rev-parse", "HEAD"], Some(root)); + let branch = output("git", &["rev-parse", "--abbrev-ref", "HEAD"], Some(root)); + let dirty = output("git", &["status", "--porcelain"], Some(root)).map(|s| !s.is_empty()); + json!({"commit": commit, "branch": branch, "dirty": dirty}) +} + +/// OS, version, architecture, CPUs and RAM. +pub fn machine(ram_total_gb: Option, ram_available_mb: Option) -> Value { + json!({ + "os": std::env::consts::OS, + "os_version": os_version(), + "arch": std::env::consts::ARCH, + "cpus": std::thread::available_parallelism().map(|n| n.get()).ok(), + "ram_total_gb": ram_total_gb, + "ram_available_mb": ram_available_mb, + }) +} + +fn os_version() -> Option { + if cfg!(target_os = "linux") { + let release = std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|s| s.lines().find_map(|l| l.strip_prefix("PRETTY_NAME=")).map(|v| v.trim_matches('"').to_owned())); + let kernel = output("uname", &["-r"], None); + match (release, kernel) { + (Some(r), Some(k)) => Some(format!("{r}, kernel {k}")), + (r, k) => r.or(k), + } + } else if cfg!(target_os = "macos") { + output("sw_vers", &["-productVersion"], None).map(|v| format!("macOS {v}")) + } else if cfg!(windows) { + output("cmd", &["/C", "ver"], None) + } else { + output("uname", &["-sr"], None) + } +} + +/// `rustc -V` of the toolchain cargo uses. +pub fn toolchain() -> Option { + output(&std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()), &["-V"], None) +} + +/// Test data present: how many of the pinned raw samples, other corpus files, craft-fonts. +pub fn data(root: &Path, pinned_raw: &[&str]) -> Value { + let corpus = std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| root.join("corpus")); + let count = |sub: &str| -> usize { std::fs::read_dir(corpus.join(sub)).map(|d| d.flatten().filter(|e| e.path().is_file()).count()).unwrap_or(0) }; + let pinned = pinned_raw.iter().filter(|name| corpus.join("raw").join(name).is_file()).count(); + let fonts = std::env::var_os("CRAFT_FONTS_DIR").filter(|d| !d.is_empty()).map(PathBuf::from); + json!({ + "corpus": { + "custom_dir": std::env::var_os("LIGHTCRAFT_CORPUS").is_some(), + "raw_pinned": pinned, + "raw_pinned_total": pinned_raw.len(), + "raw_files": count("raw"), + "images": count("images"), + "pngsuite": count("pngsuite"), + }, + "craft_fonts": { + "set": fonts.is_some(), + // crates/engine/build.rs embeds the fonts of `fonts/manifest.txt` (a relative dir may mean the root) + "found": fonts.as_deref().is_some_and(|d| [d.to_path_buf(), root.join(d)].iter().any(|d| d.join("fonts/manifest.txt").is_file())), + }, + }) +} + +/// Backends wgpu can probe, in the order reports list them. +const BACKENDS: [&str; 4] = ["vulkan", "metal", "dx12", "gl"]; + +/// Run `gpu_probe` (the pipeline's device), then once per backend: the backends the pipeline may +/// use, or with `all` every backend in the build (`--hardware`; on Windows that loads the Vulkan +/// driver, which the app avoids by default, issue #136). +pub fn gpu(mut probe: impl FnMut(Option<&str>) -> Result, all: bool) -> Value { + let pipeline = match probe(None).and_then(|s| first_json(&s)) { + Ok(v) => v.get("pipeline").cloned().unwrap_or(Value::Null), + Err(e) => return json!({"error": e}), + }; + let names = |key: &str| -> Vec { + pipeline[key].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default() + }; + let (compiled, used) = (names("compiled"), names("backends")); + let mut backends = Vec::new(); + for b in BACKENDS.iter().filter(|b| compiled.iter().any(|c| c == *b) && (all || used.iter().any(|u| u == *b))) { + backends.push(match probe(Some(b)).and_then(|s| first_json(&s)) { + Ok(v) => v, + Err(e) => json!({"backend": b, "adapters": [], "error": e}), + }); + } + json!({"pipeline": pipeline, "backends": backends}) +} + +/// The JSON of the `gpu_probe: {…}` line in the probe's output (libtest prints around it). +fn first_json(s: &str) -> Result { + let line = s.lines().find_map(|l| l.trim().strip_prefix("gpu_probe: ")).ok_or_else(|| format!("gpu_probe printed no result: {s:?}"))?; + serde_json::from_str(line).map_err(|e| format!("gpu_probe: bad JSON ({e}): {line}")) +} + +/// Backends with at least one hardware (not software) adapter: `--hardware` runs the GPU tests +/// on each. +pub fn hardware_backends(gpu: &Value) -> Vec<(String, String)> { + let mut out = Vec::new(); + for b in gpu["backends"].as_array().into_iter().flatten() { + let Some(name) = b["backend"].as_str() else { continue }; + let hw = b["adapters"].as_array().into_iter().flatten().find(|a| a["type"].as_str().is_some_and(|t| t != "Cpu")); + if let Some(a) = hw { + out.push((name.to_owned(), a["name"].as_str().unwrap_or("?").to_owned())); + } + } + out +} + +/// One test that skipped, possibly in several steps. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Skip { + pub need: String, + pub reason: String, + pub test: String, + pub binary: String, + pub location: String, + pub steps: Vec, +} + +/// The skips of every `skips-.jsonl` in `dir`, deduplicated (the HEIF step reruns some test +/// binaries), sorted by need, then reason. Unreadable lines are dropped. +pub fn read_skips(dir: &Path, root: &Path) -> Vec { + let mut by_key: BTreeMap<(String, String, String, String, String), Vec> = BTreeMap::new(); + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut files: Vec = entries.flatten().map(|e| e.path()).collect(); + files.sort(); + for path in files { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let Some(step) = name.strip_prefix("skips-").and_then(|n| n.strip_suffix(".jsonl")) else { continue }; + let Ok(text) = std::fs::read_to_string(&path) else { continue }; + for line in text.lines() { + let Ok(v) = serde_json::from_str::(line) else { continue }; + let field = |k: &str| v[k].as_str().unwrap_or("").to_owned(); + let key = (field("need"), sanitize(&field("reason"), root), field("test"), field("binary"), field("location")); + let steps = by_key.entry(key).or_default(); + if !steps.iter().any(|s| s == step) { + steps.push(step.to_owned()); + } + } + } + by_key.into_iter().map(|((need, reason, test, binary, location), steps)| Skip { need, reason, test, binary, location, steps }).collect() +} + +/// Cut the workspace root and the home directory from `s`, and fold `dir/../` in paths. +pub fn sanitize(s: &str, root: &Path) -> String { + let mut out = s.to_owned(); + let root = root.display().to_string(); + if !root.is_empty() { + for sep in ['/', '\\'] { + out = out.replace(&format!("{root}{sep}"), ""); + } + out = out.replace(&root, "."); + } + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(|h| h.to_string_lossy().into_owned()); + if let Some(home) = home.filter(|h| h.len() > 1) { + out = out.replace(&home, "~"); + } + out.split(' ').map(fold_parent_dirs).collect::>().join(" ") +} + +/// `crates/raw/../../corpus/raw` → `corpus/raw` (lexically; leading `..` stay). +fn fold_parent_dirs(token: &str) -> String { + if !token.contains("/../") && !token.contains("\\..\\") { + return token.to_owned(); + } + let sep = if token.contains("/../") { '/' } else { '\\' }; + let mut parts: Vec<&str> = Vec::new(); + for part in token.split(sep) { + if part == ".." && parts.last().is_some_and(|p| *p != ".." && !p.is_empty()) { + parts.pop(); + } else { + parts.push(part); + } + } + parts.join(&sep.to_string()) +} + +pub fn skips_json(skips: &[Skip]) -> Value { + Value::Array( + skips + .iter() + .map(|s| json!({"need": s.need, "reason": s.reason, "test": s.test, "binary": s.binary, "location": s.location, "steps": s.steps})) + .collect(), + ) +} + +/// What an empty skip list means: no test skipped, or the tests never ran. +fn no_skips(report: &Value) -> &'static str { + let test_ran = report["steps"].as_array().into_iter().flatten().any(|s| s["name"] == "test" && s["status"] != "not run"); + if test_ran { "none recorded" } else { "unknown, the test step did not run" } +} + +fn skips_from(report: &Value) -> Vec { + report["skips"] + .as_array() + .into_iter() + .flatten() + .map(|v| { + let f = |k: &str| v[k].as_str().unwrap_or("").to_owned(); + let steps = v["steps"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_default(); + Skip { need: f("need"), reason: f("reason"), test: f("test"), binary: f("binary"), location: f("location"), steps } + }) + .collect() +} + +/// Skip counts per need, most first: `[("corpus", 40), ("gpu", 5)]`. +pub fn counts_by_need(skips: &[Skip]) -> Vec<(String, usize)> { + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + for s in skips { + *counts.entry(&s.need).or_default() += 1; + } + let mut v: Vec<(String, usize)> = counts.into_iter().map(|(k, n)| (k.to_owned(), n)).collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + v +} + +/// What gets the skipped tests of `need` running. +fn hint(need: &str) -> &'static str { + match need { + "gpu" => "a hardware GPU adapter the pipeline accepts", + "corpus" => "the corpus (`cargo xtask corpus --download`; some files are private)", + "fonts" => "craft-fonts (`CRAFT_FONTS_DIR`, see the README)", + "model" => "the model files the reasons name", + "input" => "the input files the reasons name", + "system" => "what the operating system provides", + "feature" => "support this build doesn't have yet", + _ => "what the reasons name", + } +} + +/// The skips of `need`, grouped by reason, largest group first. +fn by_reason<'a>(skips: &'a [Skip], need: &str) -> Vec<(&'a str, Vec<&'a Skip>)> { + let mut groups: BTreeMap<&str, Vec<&Skip>> = BTreeMap::new(); + for k in skips.iter().filter(|k| k.need == need) { + groups.entry(&k.reason).or_default().push(k); + } + let mut groups: Vec<(&str, Vec<&Skip>)> = groups.into_iter().collect(); + groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(b.0))); + groups +} + +pub fn seconds(s: f64) -> String { + if s < 60.0 { format!("{s:.1} s") } else { format!("{}m {:02}s", (s / 60.0).floor() as u64, (s % 60.0).floor() as u64) } +} + +fn test_name(s: &Skip) -> String { + if s.test.is_empty() || s.test == "main" { format!("{} ({})", s.binary, s.location) } else { format!("{}::{}", s.binary, s.test) } +} + +fn adapter_line(a: &Value) -> String { + let name = a["name"].as_str().unwrap_or("?"); + let kind = a["type"].as_str().unwrap_or("?"); + let driver = [a["driver"].as_str().unwrap_or(""), a["driver_info"].as_str().unwrap_or("")] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join(" "); + if driver.is_empty() { format!("{name} ({kind})") } else { format!("{name} ({kind}; {driver})") } +} + +/// "the pipeline's device" line. +fn gpu_line(report: &Value) -> String { + let gpu = &report["capabilities"]["gpu"]; + if let Some(e) = gpu["error"].as_str() { + return format!("probe failed: {e}"); + } + let p = &gpu["pipeline"]; + match (p["available"].as_bool(), p["adapter"].as_str(), p["reason"].as_str()) { + (Some(true), Some(a), _) => a.to_owned(), + (_, _, Some(r)) => format!("none ({r})"), + _ => "not probed".to_owned(), + } +} + +fn adapters_lines(report: &Value) -> Vec { + let mut out = Vec::new(); + for b in report["capabilities"]["gpu"]["backends"].as_array().into_iter().flatten() { + let name = b["backend"].as_str().unwrap_or("?"); + let list: Vec = b["adapters"].as_array().into_iter().flatten().map(adapter_line).collect(); + let text = match (b["error"].as_str(), list.is_empty()) { + (Some(e), _) => format!("probe failed: {e}"), + (None, true) => "no adapter".to_owned(), + (None, false) => list.join(", "), + }; + out.push(format!("{name}: {text}")); + } + out +} + +fn machine_line(report: &Value) -> String { + let m = &report["machine"]; + let mut s = format!("{} {}", m["os"].as_str().unwrap_or("?"), m["arch"].as_str().unwrap_or("?")); + if let Some(v) = m["os_version"].as_str() { + let _ = write!(s, " ({v})"); + } + if let Some(n) = m["cpus"].as_u64() { + let _ = write!(s, ", {n} CPUs"); + } + if let Some(gb) = m["ram_total_gb"].as_u64() { + let _ = write!(s, ", {gb} GB RAM"); + } + if let Some(mb) = m["ram_available_mb"].as_u64() { + let _ = write!(s, " ({:.1} GB available)", mb as f64 / 1024.0); + } + s +} + +fn data_line(report: &Value) -> String { + let d = &report["capabilities"]["data"]; + let c = &d["corpus"]; + let n = |k: &str| c[k].as_u64().unwrap_or(0); + let fonts = match (d["craft_fonts"]["set"].as_bool(), d["craft_fonts"]["found"].as_bool()) { + (Some(true), Some(true)) => "CRAFT_FONTS_DIR set", + (Some(true), _) => "CRAFT_FONTS_DIR set but not found", + _ => "no CRAFT_FONTS_DIR", + }; + format!("corpus: {} of {} pinned raws, {} images, {} PngSuite files; {fonts}", n("raw_pinned"), n("raw_pinned_total"), n("images"), n("pngsuite")) +} + +fn commit_line(report: &Value) -> String { + let g = &report["git"]; + let commit = g["commit"].as_str().map_or("unknown commit".to_owned(), |c| c.chars().take(10).collect()); + let state = match g["dirty"].as_bool() { + Some(true) => "with uncommitted changes", + Some(false) => "clean", + None => "state unknown", + }; + format!("{commit} ({state})") +} + +/// The lines printed after the CI summary. +pub fn summary_text(report: &Value) -> String { + let mut s = String::new(); + let _ = writeln!(s, "\nMachine: {}", machine_line(report)); + let _ = writeln!(s, "GPU for the pipeline: {}", gpu_line(report)); + for line in adapters_lines(report) { + let _ = writeln!(s, " adapters on {line}"); + } + let _ = writeln!(s, "Data: {}", data_line(report)); + for h in report["hardware"].as_array().into_iter().flatten() { + let _ = writeln!( + s, + "GPU tests on {} ({}): {}", + h["backend"].as_str().unwrap_or("?"), + h["adapter"].as_str().unwrap_or("?"), + h["status"].as_str().unwrap_or("?") + ); + } + let skips = skips_from(report); + if skips.is_empty() { + let _ = writeln!(s, "Skipped tests: {}", no_skips(report)); + } else { + let counts: Vec = counts_by_need(&skips).iter().map(|(k, n)| format!("{k} {n}")).collect(); + let _ = writeln!(s, "Skipped tests: {} passed without running their checks ({}):", skips.len(), counts.join(", ")); + for (need, n) in counts_by_need(&skips) { + let _ = writeln!(s, " {need} ({n}): needs {}", hint(&need)); + let groups = by_reason(&skips, &need); + const SHOWN: usize = 4; + for (reason, tests) in groups.iter().take(SHOWN) { + let first = tests.first().map(|t| test_name(t)).unwrap_or_default(); + let more = if tests.len() > 1 { ", …" } else { "" }; + let _ = writeln!(s, " {:>3}× {reason} ({first}{more})", tests.len()); + } + if groups.len() > SHOWN { + let rest: usize = groups.iter().skip(SHOWN).map(|(_, t)| t.len()).sum(); + let _ = writeln!(s, " {rest:>3}× {} other reasons (target/ci/report.json)", groups.len() - SHOWN); + } + } + } + s +} + +fn cell(s: &str) -> String { + s.replace('|', "\\|").replace('\n', " ") +} + +/// A Markdown block for a PR description or comment. +pub fn summary_md(report: &Value) -> String { + let mut s = String::new(); + let result = report["result"].as_str().unwrap_or("?"); + let command = report["command"].as_str().unwrap_or("cargo xtask ci"); + let _ = writeln!(s, ""); + let _ = writeln!(s, "**`{command}`: {result}** at {}\n", commit_line(report)); + let _ = writeln!(s, "| | |\n|---|---|"); + let _ = writeln!(s, "| Machine | {} |", cell(&machine_line(report))); + if let Some(t) = report["toolchain"].as_str() { + let _ = writeln!(s, "| Rust | {} |", cell(t)); + } + let _ = writeln!(s, "| GPU for the pipeline | {} |", cell(&gpu_line(report))); + let adapters = adapters_lines(report); + if !adapters.is_empty() { + let _ = writeln!(s, "| Adapters | {} |", cell(&adapters.join("; "))); + } + let _ = writeln!(s, "| Data | {} |", cell(&data_line(report))); + let _ = writeln!(s, "\n| Step | Result | Time |\n|---|---|---|"); + for step in report["steps"].as_array().into_iter().flatten() { + let time = step["seconds"].as_f64().map(seconds).unwrap_or_default(); + let _ = writeln!(s, "| {} | {} | {time} |", cell(step["name"].as_str().unwrap_or("?")), step["status"].as_str().unwrap_or("?")); + } + for h in report["hardware"].as_array().into_iter().flatten() { + let time = h["seconds"].as_f64().map(seconds).unwrap_or_default(); + let name = format!("GPU tests on {} ({})", h["backend"].as_str().unwrap_or("?"), h["adapter"].as_str().unwrap_or("?")); + let _ = writeln!(s, "| {} | {} | {time} |", cell(&name), h["status"].as_str().unwrap_or("?")); + } + let skips = skips_from(report); + if skips.is_empty() { + let _ = writeln!(s, "\nSkipped tests: {}.", no_skips(report)); + } else { + let counts: Vec = counts_by_need(&skips).iter().map(|(k, n)| format!("{k} {n}")).collect(); + let _ = writeln!(s, "\n
{} tests skipped ({})\n", skips.len(), counts.join(", ")); + let _ = writeln!(s, "| Need | Tests | Reason | For example |\n|---|---|---|---|"); + for (need, _) in counts_by_need(&skips) { + let groups = by_reason(&skips, &need); + const SHOWN: usize = 12; + for (reason, tests) in groups.iter().take(SHOWN) { + let first = tests.first().map(|t| test_name(t)).unwrap_or_default(); + let _ = writeln!(s, "| {} | {} | {} | `{}` |", cell(&need), tests.len(), cell(reason), cell(&first)); + } + if groups.len() > SHOWN { + let rest: usize = groups.iter().skip(SHOWN).map(|(_, t)| t.len()).sum(); + let _ = writeln!(s, "| {} | {rest} | {} other reasons | |", cell(&need), groups.len() - SHOWN); + } + } + let _ = writeln!(s, "\n
"); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("xtask-ci-report-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn skips_are_read_per_step_deduplicated_and_cut_free_of_the_root() { + let root = Path::new("/work/lightcraft"); + let dir = tmp("skips"); + let gpu = + r#"{"need":"gpu","reason":"no GPU adapter","test":"camera_tone","binary":"equivalence","location":"crates/gpu/tests/equivalence.rs:22"}"#; + let corpus = r#"{"need":"corpus","reason":"skip /work/lightcraft/crates/raw/../../corpus/raw absent","test":"decodes","binary":"corpus","location":"crates/raw/tests/corpus.rs:28"}"#; + std::fs::write(dir.join("skips-heif.jsonl"), format!("{corpus}\n")).unwrap(); + std::fs::write(dir.join("skips-test.jsonl"), format!("{gpu}\n{corpus}\nnot json\n")).unwrap(); + std::fs::write(dir.join("other.jsonl"), format!("{gpu}\n")).unwrap(); + let skips = read_skips(&dir, root); + assert_eq!(skips.len(), 2, "{skips:?}"); + assert_eq!(skips[0].need, "corpus"); + assert_eq!(skips[0].reason, "skip corpus/raw absent"); + assert_eq!(skips[0].steps, ["heif", "test"]); + assert_eq!(skips[1].steps, ["test"]); + assert_eq!(counts_by_need(&skips), [("corpus".to_owned(), 1), ("gpu".to_owned(), 1)]); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn prepare_clears_the_last_runs_files_only() { + let root = tmp("prepare"); + let dir = dir(&root); + std::fs::create_dir_all(&dir).unwrap(); + for f in ["skips-test.jsonl", "report.json", "summary.md", "keep.txt"] { + std::fs::write(dir.join(f), "x").unwrap(); + } + prepare(&root).unwrap(); + let left: Vec = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(left, ["keep.txt"]); + assert_eq!(skip_log(&root, "gpu:dx12").file_name().unwrap(), "skips-gpu-dx12.jsonl"); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn parent_dirs_fold_in_path_tokens_only() { + assert_eq!(fold_parent_dirs("crates/raw/../../corpus/raw"), "corpus/raw"); + assert_eq!(fold_parent_dirs("../corpus/raw"), "../corpus/raw"); + assert_eq!(fold_parent_dirs("C:\\w\\crates\\raw\\..\\..\\corpus"), "C:\\w\\corpus"); + assert_eq!(fold_parent_dirs("absent"), "absent"); + } + + #[test] + fn the_probe_asks_the_pipelines_backends_or_all_with_hardware() { + let pipeline = r#"{"pipeline":{"available":false,"adapter":null,"reason":"software adapter (llvmpipe) skipped","backends":["vulkan","dx12"],"compiled":["vulkan","dx12","gl"]}}"#; + let asked = std::cell::RefCell::new(Vec::new()); + let probe = |b: Option<&str>| -> Result { + asked.borrow_mut().push(b.map(str::to_owned)); + Ok(match b { + None => format!("running 1 test\ntest gpu_probe ... \ngpu_probe: {pipeline}\nok\n"), + Some("vulkan") => r#"gpu_probe: {"backend":"vulkan","adapters":[{"name":"llvmpipe","type":"Cpu","driver":"llvmpipe","driver_info":"Mesa"}]}"#.to_owned(), + Some("dx12") => r#"gpu_probe: {"backend":"dx12","adapters":[{"name":"Intel(R) UHD Graphics 620","type":"IntegratedGpu","driver":"","driver_info":"31.0.101"}]}"#.to_owned(), + Some(_) => return Err("crashed".into()), + }) + }; + let gpu_default = gpu(probe, false); + assert_eq!(*asked.borrow(), [None, Some("vulkan".to_owned()), Some("dx12".to_owned())]); + assert_eq!(hardware_backends(&gpu_default), [("dx12".to_owned(), "Intel(R) UHD Graphics 620".to_owned())]); + asked.borrow_mut().clear(); + let gpu_all = gpu(probe, true); + assert_eq!(asked.borrow().len(), 4, "gl too"); + assert_eq!(gpu_all["backends"][2]["error"], "crashed"); + + let report = json!({ + "command": "cargo xtask ci", + "result": "pass", + "git": {"commit": "0123456789abcdef", "dirty": false}, + "machine": {"os": "windows", "arch": "x86_64", "cpus": 8, "ram_total_gb": 16}, + "capabilities": {"gpu": gpu_all, "data": {"corpus": {"raw_pinned": 3, "raw_pinned_total": 30}, "craft_fonts": {"set": false}}}, + "steps": [{"name": "fmt", "status": "ok", "seconds": 1.25}, {"name": "test", "status": "ok", "seconds": 125.0}], + "skips": [{"need": "gpu", "reason": "no GPU | adapter", "test": "memory", "binary": "memory", "location": "x.rs:1", "steps": ["test"]}], + }); + let text = summary_text(&report); + assert!(text.contains("GPU for the pipeline: none (software adapter (llvmpipe) skipped)"), "{text}"); + assert!(text.contains("adapters on dx12: Intel(R) UHD Graphics 620 (IntegratedGpu; 31.0.101)"), "{text}"); + assert!(text.contains("adapters on gl: probe failed: crashed"), "{text}"); + assert!(text.contains("Skipped tests: 1 passed without running their checks (gpu 1)"), "{text}"); + assert!(text.contains(" gpu (1): needs a hardware GPU adapter"), "{text}"); + assert!(text.contains(" 1× no GPU | adapter (memory::memory)"), "{text}"); + assert!(text.contains("corpus: 3 of 30 pinned raws"), "{text}"); + let md = summary_md(&report); + assert!(md.contains("**`cargo xtask ci`: pass** at 0123456789 (clean)"), "{md}"); + assert!(md.contains("| test | ok | 2m 05s |"), "{md}"); + assert!(md.contains("| gpu | 1 | no GPU \\| adapter | `memory::memory` |"), "{md}"); + assert!(md.contains("1 tests skipped (gpu 1)"), "{md}"); + } + + #[test] + fn no_skips_is_not_claimed_when_the_tests_never_ran() { + let stopped = json!({"steps": [{"name": "clippy", "status": "fail"}, {"name": "test", "status": "not run"}], "skips": []}); + assert!(summary_text(&stopped).contains("Skipped tests: unknown, the test step did not run")); + let ran = json!({"steps": [{"name": "test", "status": "ok"}], "skips": []}); + assert!(summary_md(&ran).contains("Skipped tests: none recorded.")); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 08ef435c5..0bc3631ba 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -5,6 +5,7 @@ mod assets; mod bench; +mod ci_report; mod ico; mod layers; mod parity; @@ -33,7 +34,11 @@ commands: web [--serve [port]] [--dev] build the browser app (apps/lightcraft-web) into /web-site/; --serve serves it on http://127.0.0.1: (default 8080) - ci fmt --check, clippy -D warnings, heif, test, parity refs, layers, assets, wasm (stops at first failure) + ci [--hardware] [--summary-md] + fmt --check, clippy -D warnings, heif, test, GPU probe, parity refs, layers, assets, wasm + (stops at first failure); writes target/ci/report.json (machine, GPU adapters, data, + steps, skipped tests) and target/ci/summary.md; --hardware also runs the GPU tests on + every backend with a hardware adapter; --summary-md prints the Markdown summary corpus [--download] show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/ and checks their sha256 stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) @@ -51,7 +56,7 @@ fn main() -> ExitCode { Some("parity") => parity::run(&root(), rest.contains(&"--write")), Some("wasm") => cmd_wasm(), Some("web") => web::run(&rest), - Some("ci") => cmd_ci(), + Some("ci") => cmd_ci(&rest), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), Some("-h" | "--help" | "help") | None => { @@ -240,9 +245,38 @@ fn cmd_wasm() -> Result<(), String> { if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) } } -fn cmd_ci() -> Result<(), String> { - type Step = (&'static str, Box Result<(), String>>); - let steps: Vec = vec![ +fn cmd_ci(args: &[&str]) -> Result<(), String> { + let hardware = args.contains(&"--hardware"); + let print_md = args.contains(&"--summary-md"); + if let Some(other) = args.iter().find(|a| !matches!(**a, "--hardware" | "--summary-md")) { + return Err(format!("ci: unknown option `{other}`\n\n{USAGE}")); + } + let root = root(); + let report_dir = ci_report::prepare(&root)?; + let started = std::time::Instant::now(); + let started_unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + // test runs append the skips their tests record (`lightcraft_testkit::skip!`) to target/ci/skips-.jsonl + let test = |cargo_args: &[&str], step: &str| { + let mut c = cargo(); + c.args(cargo_args).env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, step)); + run(c, &format!("cargo {}", cargo_args.join(" "))) + }; + let gpu = std::cell::RefCell::new(serde_json::Value::Null); + let hardware_runs = std::cell::RefCell::new(Vec::new()); + // What this machine offers the GPU tests, once, after the workspace tests (whose build the + // probe reuses). Never fails the run: the report says what went wrong. + let probe = || { + if gpu.borrow().is_null() { + eprintln!("\n=== ci: GPU probe ==="); + let t = std::time::Instant::now(); + let mut found = ci_report::gpu(probe_gpu, hardware); + found["seconds"] = serde_json::json!(t.elapsed().as_secs_f64()); + *gpu.borrow_mut() = found; + } + }; + + type Step<'a> = (&'static str, Box Result<(), String> + 'a>); + let mut steps: Vec = vec![ ( "fmt", Box::new(|| { @@ -266,46 +300,144 @@ fn cmd_ci() -> Result<(), String> { let mut c = cargo(); c.args(["clippy", "-p", "lightcraft-codecs", "--features", "heif", "--all-targets", "--", "-D", "warnings"]); run(c, "cargo clippy -p lightcraft-codecs --features heif --all-targets -- -D warnings")?; - let mut c = cargo(); - c.args(["test", "-p", "lightcraft-codecs", "-p", "lightcraft-heif", "--features", "lightcraft-codecs/heif"]); - run(c, "cargo test -p lightcraft-codecs -p lightcraft-heif --features lightcraft-codecs/heif")?; + test(&["test", "-p", "lightcraft-codecs", "-p", "lightcraft-heif", "--features", "lightcraft-codecs/heif"], "heif")?; // the engine's HEIC import test, with the decoder (the workspace run checks the error) - let mut c = cargo(); - c.args(["test", "-p", "lightcraft-engine", "--lib", "--features", "lightcraft-codecs/heif", "heic"]); - run(c, "cargo test -p lightcraft-engine --lib --features lightcraft-codecs/heif heic") + test(&["test", "-p", "lightcraft-engine", "--lib", "--features", "lightcraft-codecs/heif", "heic"], "heif") }), ), - ( - "test", - Box::new(|| { - let mut c = cargo(); - c.args(["test", "--workspace"]); - run(c, "cargo test --workspace") - }), - ), - ("parity", Box::new(|| parity::run(&root(), false))), - ("layers", Box::new(cmd_layers)), - ("assets", Box::new(|| assets::run(&root()))), - ("wasm", Box::new(cmd_wasm)), + ("test", Box::new(|| test(&["test", "--workspace"], "test"))), ]; - let mut done = Vec::new(); + if hardware { + steps.push(( + "gpu-backends", + Box::new(|| { + // the GPU tests once per backend with a hardware adapter (`LIGHTCRAFT_GPU_BACKEND`) + probe(); + let backends = ci_report::hardware_backends(&gpu.borrow()); + if backends.is_empty() { + eprintln!("no hardware GPU adapter on any backend: nothing to run"); + } + let mut failed = Vec::new(); + for (backend, adapter) in backends { + let step = format!("gpu-{backend}"); + let mut c = cargo(); + c.args(["test", "-p", "lightcraft-gpu", "--features", "denoise"]) + .env("LIGHTCRAFT_GPU_BACKEND", &backend) + .env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, &step)); + let t = std::time::Instant::now(); + let ok = run(c, &format!("LIGHTCRAFT_GPU_BACKEND={backend} cargo test -p lightcraft-gpu --features denoise")).is_ok(); + hardware_runs.borrow_mut().push(serde_json::json!({ + "backend": backend, + "adapter": adapter, + "status": if ok { "ok" } else { "fail" }, + "seconds": t.elapsed().as_secs_f64(), + })); + if !ok { + failed.push(backend); + } + } + if failed.is_empty() { Ok(()) } else { Err(format!("the GPU tests failed on {}", failed.join(", "))) } + }), + )); + } + steps.push(("parity", Box::new(|| parity::run(&root, false)))); + steps.push(("layers", Box::new(cmd_layers))); + steps.push(("assets", Box::new(|| assets::run(&root)))); + steps.push(("wasm", Box::new(cmd_wasm))); + + let mut results = Vec::new(); + let mut failure = None; for (name, f) in &steps { eprintln!("\n=== ci: {name} ==="); - if let Err(e) = f() { + let t = std::time::Instant::now(); + let r = f(); + let status = if r.is_ok() { "ok" } else { "fail" }; + results.push(serde_json::json!({"name": name, "status": status, "seconds": t.elapsed().as_secs_f64()})); + if *name == "test" { + // right after the workspace tests, before parity and wasm build other feature sets + probe(); + } + if let Err(e) = r { + failure = Some((*name, e)); + break; + } + } + if gpu.borrow().is_null() { + *gpu.borrow_mut() = serde_json::json!({"error": "not probed: the test step did not run"}); + } + let done: Vec<&str> = steps.iter().take(results.len()).map(|(n, _)| *n).collect(); + match &failure { + Some((name, e)) => { println!("\nCI summary:"); - for d in &done { + for d in done.iter().filter(|d| *d != name) { println!(" ok {d}"); } println!(" FAIL {name}: {e}"); - for (n, _) in steps.iter().skip(done.len() + 1) { + for (n, _) in steps.iter().skip(results.len()) { println!(" skip {n}"); } - return Err(format!("ci failed at `{name}`")); } - done.push(*name); + None => println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", ")), } - println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", ")); - Ok(()) + for (n, _) in steps.iter().skip(results.len()) { + results.push(serde_json::json!({"name": n, "status": "not run"})); + } + + let skips = ci_report::read_skips(&report_dir, &root); + let ram_mb = available_ram_mb(); + let report = serde_json::json!({ + "schema": ci_report::SCHEMA, + "command": std::iter::once("cargo xtask ci").chain(args.iter().copied()).collect::>().join(" "), + "result": if failure.is_some() { "fail" } else { "pass" }, + "failed_step": failure.as_ref().map(|(n, _)| *n), + "started_unix": started_unix, + "seconds": started.elapsed().as_secs_f64(), + "git": ci_report::git(&root), + "machine": ci_report::machine(total_ram_gb(), ram_mb), + "toolchain": ci_report::toolchain(), + "capabilities": {"gpu": gpu.take(), "data": ci_report::data(&root, &RAW_SAMPLES.iter().map(|s| s.0).collect::>())}, + "steps": results, + "hardware": hardware_runs.take(), + "skips": ci_report::skips_json(&skips), + }); + print!("{}", ci_report::summary_text(&report)); + let md = ci_report::summary_md(&report); + let written = serde_json::to_string_pretty(&report) + .map_err(|e| e.to_string()) + .and_then(|json| std::fs::write(report_dir.join("report.json"), json + "\n").map_err(|e| e.to_string())) + .and_then(|()| std::fs::write(report_dir.join("summary.md"), &md).map_err(|e| e.to_string())); + match written { + Ok(()) => println!("Report: target/ci/report.json; to paste into a PR: target/ci/summary.md (or run with --summary-md)"), + Err(e) => eprintln!("ci: could not write the report to {}: {e}", report_dir.display()), + } + if print_md { + println!("\n{md}"); + } + match failure { + Some((name, _)) => Err(format!("ci failed at `{name}`")), + None => Ok(()), + } +} + +/// Run the ignored `gpu_probe` test of lightcraft-gpu: the pipeline's device (`None`) or one +/// backend's adapters. `--workspace`, like the test step, so its build is reused. Its stdout, or why +/// it failed (a driver that crashes while it is enumerated, issue #136, ends up here, not in xtask). +fn probe_gpu(backend: Option<&str>) -> Result { + let mut c = cargo(); + c.args(["test", "-q", "--workspace", "--test", "gpu_probe", "--", "--ignored", "--exact", "gpu_probe", "--nocapture"]); + c.env("LIGHTCRAFT_GPU_PROBE", backend.unwrap_or("")); + let out = c.output().map_err(|e| format!("gpu_probe: {e}"))?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + let tail: Vec<&str> = err.lines().rev().filter(|l| !l.trim().is_empty()).take(3).collect(); + return Err(format!( + "gpu_probe {}exited with {}: {}", + backend.map(|b| format!("{b} ")).unwrap_or_default(), + out.status, + tail.into_iter().rev().collect::>().join(" / ") + )); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } /// CC0 raw samples (raw.pixls.us). Small, representative set; extend freely (CC0 only). From dbf2dcbfbd3cd75e901ce2ed6b2871397c3e7811 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 16:33:54 -0500 Subject: [PATCH 03/52] docs: #1037 visible skips and the ci report Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +++++- CLAUDE.md | 6 +++++- README.md | 7 +++++++ docs/architecture.md | 15 +++++++++++++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8cd199ff6..3d615d00c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,11 +125,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md` with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe ≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG. - Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`. +- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")` + before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and + data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR). ## Map of the code `geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine` → `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo -photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` + +photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers +(dev-dependency only). `flake.nix` + `nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not in CI: it may lag behind the workspace; see README → Quick start. diff --git a/CLAUDE.md b/CLAUDE.md index 4a736f12a..5596bb95f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,11 +132,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md` with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe ≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG. - Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`. +- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")` + before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and + data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR). ## Map of the code `geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine` → `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo -photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` + +photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers +(dev-dependency only). `flake.nix` + `nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not in CI: it may lag behind the workspace; see README → Quick start. diff --git a/README.md b/README.md index 8884f2bd3..6f9ff1cba 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,13 @@ per CPU, otherwise exhaust RAM before any test runs. GPU coverage is unchanged. `CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS` and `RUST_TEST_THREADS` settings override these defaults. Ordinary development commands keep their own settings. +Tests that need a GPU, the corpus, the craft-fonts faces or a model pass without checking anything when +those are missing (libtest has no "skipped" status), so `cargo xtask ci` lists them: tests record a skip +with `lightcraft_testkit::skip!`, and the run ends with what the machine offered (OS, CPUs, RAM, GPU +adapters per backend, corpus, fonts) and every skip by reason. It writes `target/ci/report.json` and +`target/ci/summary.md`, a block to paste into a PR (`--summary-md` prints it); `--hardware` also runs the +GPU tests on every backend that has a hardware adapter. Nothing is uploaded. + **Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it): ```sh diff --git a/docs/architecture.md b/docs/architecture.md index c50066a10..7d1b7a3ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code; the full design notes stay in the local `plan/architecture.md`) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`cargo xtask ci` reports the machine and skipped tests; `testkit`) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft is built today. The longer design document (`plan/architecture.md`) is local-only (`plan/` is gitignored); this page is the committed, current summary. Sizes are Rust lines counted @@ -19,7 +19,7 @@ L5 depends on egui, eframe, winit or rfd, and that each crate only uses lower la | L3 | `catalog` (11.0k), `gpu` (5.1k), `preview` (1.3k), `merge` (3.8k), `segment` (2.3k) | Library model and persistence; wgpu compute twin of the pipeline; thumbnail/preview cache and job pool; HDR and panorama merge; SAM 3 segmentation | | L4 | `engine` (53.2k) | Session, command registry (everything is a command), history, render and export jobs, import, Lightroom catalog and preset import, faces index, view-models | | L5 | `ui-egui` (46.9k), `mcp` (2.3k) | The Lightroom-style egui interface (swappable); the MCP server | -| L6 | `apps/lightcraft` (2.8k), `apps/lightcraft-cli` (1.5k), `apps/lightcraft-web` (3.4k), `xtask` (2.7k) | Desktop app (eframe + wgpu, native menus, control server), CLI (render, commands, calibrate, snapshot, MCP), WASM build, tooling | +| L6 | `apps/lightcraft` (2.8k), `apps/lightcraft-cli` (1.5k), `apps/lightcraft-web` (3.4k), `xtask` (3.4k), `testkit` (0.2k, dev-dependency only) | Desktop app (eframe + wgpu, native menus, control server), CLI (render, commands, calibrate, snapshot, MCP), WASM build, tooling; test helpers (skips the CI report lists) | ## Data model @@ -63,8 +63,19 @@ dispatch by id, and headless snapshots render the UI without a window. `assets` (every asset attributed, no Adobe formats) and the WASM build. Other workflows build and test FreeBSD and Windows ARM64, lint packaging, and build releases. +`ci` also probes the machine: OS, CPUs, RAM, the GPU adapters of each backend (from the ignored +`gpu_probe` test of `lightcraft-gpu`, run after the workspace tests so it reuses their build; xtask +has no wgpu), the corpus and `CRAFT_FONTS_DIR`. Tests +that need what is missing call `lightcraft_testkit::skip!(need, reason)`, which appends a JSON line to +the file `LIGHTCRAFT_SKIP_LOG` names. The run ends with the machine and the skips by need and reason, +and writes `target/ci/report.json` (commit, dirty flag, machine, capabilities, each step's result and +time, skips) and `target/ci/summary.md` (Markdown for a PR; `--summary-md` prints it). The report holds +no host name, user name or absolute path, and nothing is uploaded. `--hardware` adds a step that runs +the `lightcraft-gpu` tests once per backend with a hardware adapter (`LIGHTCRAFT_GPU_BACKEND`). + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Quality gates: the CI machine probe, visible skips (`testkit`) and `target/ci/report.json` | | 2026-10-10 | major | Created from the code (crate sizes, layers, data model, rendering, control, gates) | From 615d8a365e510e3b25e380ceb9fad8e791e79863 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 16:48:16 -0500 Subject: [PATCH 04/52] perf(catalog): #1136 index stacks and album membership, sort keys once per photo stack_of, validate_stack and regular-album membership looked a photo up by scanning every stack or the album's list, so an "In a Stack" or "Album" smart album, or a query filtered to an album, cost photos x list length (2.7 s and 0.6-0.8 s at 100k photos). Derived indexes (never saved) answer them now: built on first use, kept current by the six ops that change stacks or album photos in apply_inner, so undo, failed-batch rollback and journal replay follow. A file with a photo in two stacks falls back to the scan. query() works out each sort key once (File Name was lower-cased twice per comparison) and tests `only` as a set; smart-album passes prepare the filter once; rules name flag, label and kind without formatting per photo. A property test checks the indexes against a scan through random op sequences, rollbacks, snapshots, replay, clones and undo. Co-Authored-By: Claude Opus 5.5 --- .../proptest-regressions/tests_index.txt | 8 + crates/catalog/src/index.rs | 101 ++++++++ crates/catalog/src/lib.rs | 44 +++- crates/catalog/src/query.rs | 71 ++++-- crates/catalog/src/rules.rs | 37 ++- crates/catalog/src/stacks.rs | 4 +- crates/catalog/src/tests_index.rs | 222 ++++++++++++++++++ crates/catalog/src/tests_scale.rs | 92 ++++++++ 8 files changed, 541 insertions(+), 38 deletions(-) create mode 100644 crates/catalog/proptest-regressions/tests_index.txt create mode 100644 crates/catalog/src/index.rs create mode 100644 crates/catalog/src/tests_index.rs create mode 100644 crates/catalog/src/tests_scale.rs diff --git a/crates/catalog/proptest-regressions/tests_index.txt b/crates/catalog/proptest-regressions/tests_index.txt new file mode 100644 index 000000000..9c827f0c0 --- /dev/null +++ b/crates/catalog/proptest-regressions/tests_index.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc b91df6f4f23ec90cae3c0fb934b04b806181b61570f00ba25826e2232b7e1932 # shrinks to steps = [(3, 0, 0, false)] +cc b11415bdf5e4133b65e00645d4051129c71bf927747d6384aa50107440e92e07 # shrinks to steps = [(0, 10, 166, false), (7, 1, 0, false), (1, 14, 0, false)] diff --git a/crates/catalog/src/index.rs b/crates/catalog/src/index.rs new file mode 100644 index 000000000..2c347473b --- /dev/null +++ b/crates/catalog/src/index.rs @@ -0,0 +1,101 @@ +//! Lookups derived from the catalog's own data, so questions asked once per photo of a big library +//! don't scan a list each time: which stack a photo is in, whether a regular album holds it. +//! +//! They are never saved (the on-disk format is unchanged). Each is built on first use from what +//! the catalog holds, so a catalog read from a snapshot, replayed from the journal or cloned starts +//! without them and builds them when asked. Once built, [`crate::Catalog::apply`] keeps them up to +//! date: the ops that change stacks or album photos (`AddStack`, `RemoveStack`, `SetStack`, +//! `AddAlbum`, `RemoveAlbum`, `SetAlbumPhotos`) update them as they apply, inverses and the +//! rollback of a failed batch included, as those are ops too. + +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; + +use crate::{AlbumId, Catalog, PhotoId, StackId}; + +#[derive(Default)] +pub(crate) struct Indexes { + /// Photo → the stack it is in; `None` when the catalog was read with a photo in two stacks + /// (only a hand-edited file can hold that: ops refuse it), where a scan says which one counts. + stacks: OnceLock>>, + /// Album → its photos (regular albums; a smart album's list is empty). + albums: OnceLock>>, +} + +/// A copy builds its own when asked (copies are made to be saved, not queried). +impl Clone for Indexes { + fn clone(&self) -> Self { + Indexes::default() + } +} + +/// Derived from the rest of the catalog: two catalogs with the same data are equal whatever was +/// built. +impl PartialEq for Indexes { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for Indexes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let stacks = self.stacks.get().map(|m| m.as_ref().map(HashMap::len)); + f.debug_struct("Indexes").field("stacks", &stacks).field("albums", &self.albums.get().map(HashMap::len)).finish() + } +} + +impl Indexes { + /// Stack `id` held `old` and holds `new` now (empty: it was added, or is gone). + pub(crate) fn stack_changed(&mut self, id: StackId, old: &[PhotoId], new: &[PhotoId]) { + let Some(Some(m)) = self.stacks.get_mut() else { return }; + for p in old { + if m.get(p) == Some(&id) { + m.remove(p); + } + } + for p in new { + m.insert(*p, id); + } + } + + /// Album `id` holds `photos` now (`None`: it is gone). + pub(crate) fn album_changed(&mut self, id: AlbumId, photos: Option<&[PhotoId]>) { + let Some(m) = self.albums.get_mut() else { return }; + match photos { + Some(photos) => { + m.insert(id, photos.iter().copied().collect()); + } + None => { + m.remove(&id); + } + } + } +} + +impl Catalog { + /// The stack photo `photo` is in, by the index (built on first use). + pub(crate) fn stack_id_of(&self, photo: PhotoId) -> Option { + let m = self.index.stacks.get_or_init(|| { + let mut m = HashMap::new(); + for s in self.stacks.values() { + for p in &s.photos { + if m.insert(*p, s.id).is_some_and(|other| other != s.id) { + return None; + } + } + } + Some(m) + }); + match m { + Some(m) => m.get(&photo).copied(), + // in id order, the first stack listing the photo has it + None => self.stacks.values().find(|s| s.photos.contains(&photo)).map(|s| s.id), + } + } + + /// Whether album `album`'s own list holds `photo`, by the index (built on first use). + pub(crate) fn album_lists(&self, album: AlbumId, photo: PhotoId) -> bool { + let m = self.index.albums.get_or_init(|| self.albums.values().map(|a| (a.id, a.photos.iter().copied().collect())).collect()); + m.get(&album).is_some_and(|s| s.contains(&photo)) + } +} diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index cc24266f2..eec22fa9c 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -15,6 +15,7 @@ pub mod dates; pub mod folders; +mod index; pub mod journal; pub mod keywords; pub mod local; @@ -275,6 +276,9 @@ pub struct Catalog { /// Increments on every applied op. #[serde(skip)] pub revision: u64, + /// Lookups derived from the above (never saved), see [`index`]. + #[serde(skip)] + index: index::Indexes, } impl Catalog { @@ -357,13 +361,18 @@ impl Catalog { /// no smart album). pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool { match self.albums.get(&id) { - // guarded: a smart album testing smart albums can't loop or recurse without end - Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)), - Some(a) => a.photos.contains(&p.id), + Some(Album { smart: Some(rules), .. }) => self.smart_album_has(id, rules, &rules.prepare_one(), p), + Some(a) => self.album_lists(a.id, p.id), None => false, } } + /// Whether smart album `id` with `rules` (as [`Filter::prepare`]d) holds `p`. + fn smart_album_has(&self, id: AlbumId, rules: &Filter, prepared: &query::Prepared, p: &Photo) -> bool { + // guarded: a smart album testing smart albums can't loop or recurse without end + !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches_in(p, self, prepared)) + } + /// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the /// rules of `from` would then change when those of `to` do). Loops in saved rules end the /// search, they don't repeat it. @@ -411,7 +420,10 @@ impl Catalog { /// The photos of an album: the stored list, or a smart album's current matches (id order). pub fn album_photos(&self, id: AlbumId) -> Vec { match self.albums.get(&id) { - Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).map(|p| p.id).collect(), + Some(Album { smart: Some(rules), .. }) => { + let prepared = rules.prepare(); + self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).map(|p| p.id).collect() + } Some(a) => a.photos.clone(), None => Vec::new(), } @@ -421,7 +433,10 @@ impl Catalog { pub fn album_count(&self, id: AlbumId) -> usize { match self.albums.get(&id) { // counted in place: no id list is built just for its length - Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(), + Some(Album { smart: Some(rules), .. }) => { + let prepared = rules.prepare(); + self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).count() + } Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(), None => 0, } @@ -569,6 +584,7 @@ impl Catalog { } let id = album.id; self.next_album = self.next_album.max(id.0 + 1); + self.index.album_changed(id, Some(&album.photos)); self.albums.insert(id, album); Op::RemoveAlbum { id } } @@ -577,6 +593,7 @@ impl Catalog { return Err(CatalogError::Invalid("folder is not empty".into())); } let a = self.albums.remove(&id).ok_or(CatalogError::NoAlbum(id))?; + self.index.album_changed(id, None); Op::AddAlbum { album: a } } Op::RenameAlbum { id, name } => { @@ -614,13 +631,16 @@ impl Catalog { Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) } } Op::SetAlbumPhotos { id, photos } => { - let a = self.album_mut(id)?; + // the field, not `album_mut`: the index is updated while the album is borrowed + let a = self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))?; if (a.folder || a.smart.is_some()) && !photos.is_empty() { return Err(CatalogError::Invalid( if a.folder { "folders can't hold photos" } else { "smart albums update automatically" }.into(), )); } - Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) } + let old = std::mem::replace(&mut a.photos, photos); + self.index.album_changed(id, Some(&a.photos)); + Op::SetAlbumPhotos { id, photos: old } } Op::SetAlbumCover { id, cover } => { let a = self.album_mut(id)?; @@ -641,11 +661,13 @@ impl Catalog { self.validate_stack(stack.id, &stack.photos)?; let id = stack.id; self.next_stack = self.next_stack.max(id.0 + 1); + self.index.stack_changed(id, &[], &stack.photos); self.stacks.insert(id, stack); Op::RemoveStack { id } } Op::RemoveStack { id } => { let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?; + self.index.stack_changed(id, &s.photos, &[]); Op::AddStack { stack: s } } Op::SetStack { id, photos, collapsed } => { @@ -654,7 +676,9 @@ impl Catalog { } self.validate_stack(id, &photos)?; let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?; - let old = Op::SetStack { id, photos: std::mem::replace(&mut s.photos, photos), collapsed: s.collapsed }; + let old_photos = std::mem::replace(&mut s.photos, photos); + self.index.stack_changed(id, &old_photos, &s.photos); + let old = Op::SetStack { id, photos: old_photos, collapsed: s.collapsed }; s.collapsed = collapsed; old } @@ -853,10 +877,14 @@ mod tests_folders; #[cfg(test)] mod tests_format_version; #[cfg(test)] +mod tests_index; +#[cfg(test)] mod tests_journal; #[cfg(test)] mod tests_local; #[cfg(test)] mod tests_lock; #[cfg(test)] +mod tests_scale; +#[cfg(test)] mod tests_torn_append; diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 8b694a021..9d3eb90c7 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -112,6 +112,21 @@ fn shuffle_rank(seed: u64, id: PhotoId) -> u64 { mix64(mix64(seed) ^ id.0) } +/// What [`Filter::prepare`] works out once for a pass over many photos. +pub(crate) struct Prepared { + root: Option, + only: Option>, +} + +/// The ids of `photos` by `key`, ties by id (so the order is total and the same every time), +/// reversed when not `ascending`. Each photo's key is worked out once. +fn sorted<'a, K: Ord>(photos: &[&'a Photo], ascending: bool, key: impl Fn(&'a Photo) -> K) -> Vec { + let mut keyed: Vec<(K, PhotoId)> = photos.iter().map(|p| (key(p), p.id)).collect(); + // ids differ, so there are no ties and an unstable sort gives the one order there is + keyed.sort_unstable_by(|a, b| if ascending { a.cmp(b) } else { b.cmp(a) }); + keyed.into_iter().map(|(_, id)| id).collect() +} + fn token_matches(p: &Photo, tok: &str) -> bool { let t = tok.to_lowercase(); if let Some((field, val)) = t.split_once(':') { @@ -234,7 +249,18 @@ impl Filter { } pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool { - self.matches_in(p, cat, self.library_root().as_deref()) + self.matches_in(p, cat, &self.prepare_one()) + } + + /// [`Filter::prepare`] for testing one photo (the `only` ids are looked through, not hashed). + pub(crate) fn prepare_one(&self) -> Prepared { + Prepared { root: self.library_root(), only: None } + } + + /// What [`Filter::matches_in`] needs worked out once for a pass over many photos rather than + /// once per photo: the library folder's identity and the `only` ids as a set. + pub(crate) fn prepare(&self) -> Prepared { + Prepared { root: self.library_root(), only: (!self.only.is_empty()).then(|| self.only.iter().copied().collect()) } } /// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice. @@ -244,9 +270,9 @@ impl Filter { self.library_folder.as_deref().filter(|d| !d.trim().is_empty()).map(folder_key) } - /// [`Filter::matches`] with the library folder already turned into its identity, so a query - /// over a big library does that once, not once per photo. - fn matches_in(&self, p: &Photo, cat: &Catalog, root: Option<&str>) -> bool { + /// [`Filter::matches`] with what [`Filter::prepare`] works out done already, so a query over a + /// big library does that once, not once per photo. + pub(crate) fn matches_in(&self, p: &Photo, cat: &Catalog, prepared: &Prepared) -> bool { if p.deleted != self.deleted { return false; } @@ -285,7 +311,7 @@ impl Filter { if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) { return false; } - if !self.only.is_empty() && !self.only.contains(&p.id) { + if !self.only.is_empty() && !prepared.only.as_ref().map_or_else(|| self.only.contains(&p.id), |only| only.contains(&p.id)) { return false; } if let Some(want) = &self.merged { @@ -325,7 +351,7 @@ impl Filter { { return false; } - if let Some(root) = root + if let Some(root) = prepared.root.as_deref() && !photo_in_root(p, root) { return false; @@ -362,22 +388,19 @@ impl Filter { impl Catalog { /// Photos matching `filter`, in `sort` order (ties broken by id for stability). pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec { - let root = filter.library_root(); - let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect(); - v.sort_by(|a, b| { - let o = match sort.key { - SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), - SortKey::ImportDate => a.imported.cmp(&b.imported), - SortKey::EditDate => a.edited.cmp(&b.edited), - SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()), - SortKey::Rating => a.rating.cmp(&b.rating), - SortKey::FileSize => a.file_size.cmp(&b.file_size), - SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)), - } - .then(a.id.cmp(&b.id)); - if sort.ascending { o } else { o.reverse() } - }); - v.into_iter().map(|p| p.id).collect() + let prepared = filter.prepare(); + let v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, &prepared)).collect(); + let asc = sort.ascending; + match sort.key { + SortKey::CaptureDate => sorted(&v, asc, |p| (p.captured.as_deref(), p.imported.as_str())), + SortKey::ImportDate => sorted(&v, asc, |p| p.imported.as_str()), + SortKey::EditDate => sorted(&v, asc, |p| p.edited.as_deref()), + // each name lower-cased once, not twice per comparison + SortKey::FileName => sorted(&v, asc, |p| p.file_name.to_lowercase()), + SortKey::Rating => sorted(&v, asc, |p| p.rating), + SortKey::FileSize => sorted(&v, asc, |p| p.file_size), + SortKey::Random => sorted(&v, asc, |p| shuffle_rank(sort.seed, p.id)), + } } /// Year → month → day counts for the "By Date" section (newest first). @@ -430,8 +453,8 @@ impl Catalog { pub fn people_in(&self, filter: &Filter) -> Vec { let filter = Filter { person: None, ..filter.clone() }; let mut m: std::collections::HashMap = Default::default(); - let root = filter.library_root(); - for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) { + let prepared = filter.prepare(); + for p in self.photos().filter(|p| filter.matches_in(p, self, &prepared)) { let mut seen: Vec = Vec::new(); for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index f17f12e01..a87c9effc 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -598,6 +598,35 @@ pub fn now() -> String { } } +/// A flag as rules name it (its variant, lower case), without building a string per photo. +fn flag_name(f: crate::Flag) -> &'static str { + match f { + crate::Flag::None => "none", + crate::Flag::Pick => "pick", + crate::Flag::Reject => "reject", + } +} + +/// A colour label's colour as rules name it (lower case). +fn label_colour(l: crate::ColorLabel) -> &'static str { + match l { + crate::ColorLabel::Red => "red", + crate::ColorLabel::Yellow => "yellow", + crate::ColorLabel::Green => "green", + crate::ColorLabel::Blue => "blue", + crate::ColorLabel::Purple => "purple", + } +} + +/// A media kind as rules name it (lower case). +fn kind_name(k: crate::MediaKind) -> &'static str { + match k { + crate::MediaKind::Image => "image", + crate::MediaKind::Raw => "raw", + crate::MediaKind::Video => "video", + } +} + fn lower(v: &Value) -> String { match v { Value::String(s) => s.trim().to_lowercase(), @@ -778,16 +807,16 @@ impl Rule { match field { "rating" => num_op(op, Some(p.rating as f64), value), "flag" => { - let f = format!("{:?}", p.flag).to_lowercase(); + let f = flag_name(p.flag); (f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is") } "label" => { - let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into()); + let l = p.label.map_or("none", label_colour); // custom label names count too - let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want); + let named = p.label.is_some_and(|l| cat.custom_label_name(l).map_or(label_colour(l) == want, |n| n.to_lowercase() == want)); (l == want || named) == (op == "is") } - "kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"), + "kind" => (kind_name(p.kind) == want) == (op == "is"), "edited" => yes(p.is_edited()), "hasGps" => yes(m.gps.is_some()), "virtualCopy" => yes(p.copy_of.is_some()), diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs index 304106aef..83dbc13ab 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -20,7 +20,7 @@ impl Catalog { } /// The stack a photo belongs to. pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> { - self.stacks.values().find(|s| s.photos.contains(&photo)) + self.stack_id_of(photo).and_then(|id| self.stacks.get(&id)) } /// Photo → (stack, position in the stack), for every stacked photo. pub fn stack_index(&self) -> HashMap { @@ -40,7 +40,7 @@ impl Catalog { return Err(CatalogError::NoPhoto(*p)); } } - if self.stacks.values().any(|s| s.id != id && s.photos.iter().any(|p| seen.contains(p))) { + if photos.iter().any(|p| self.stack_id_of(*p).is_some_and(|s| s != id)) { return Err(CatalogError::Invalid("a photo can be in only one stack".into())); } Ok(()) diff --git a/crates/catalog/src/tests_index.rs b/crates/catalog/src/tests_index.rs new file mode 100644 index 000000000..abfd4ef51 --- /dev/null +++ b/crates/catalog/src/tests_index.rs @@ -0,0 +1,222 @@ +//! The derived indexes ([`crate::index`]) answer as a scan of the catalog would, through every op, +//! inverse, failed batch, snapshot, replay and clone; and the faster query paths give the same +//! results as before. + +use proptest::prelude::*; + +use super::*; + +const PHOTOS: u64 = 8; + +fn library() -> Catalog { + let mut c = Catalog::new(); + for i in 0..PHOTOS { + let p = Photo::new(PhotoId(i + 1), Source::Demo { scene: 1 }, &format!("p{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + c +} + +/// What the indexes must say, worked out by scanning (as the catalog did before it had them). +fn scanned(c: &Catalog) -> (Vec>, Vec>) { + let photos: Vec = (1..=PHOTOS + 1).map(PhotoId).collect(); + let stacks = photos.iter().map(|p| c.stacks().find(|s| s.photos.contains(p)).map(|s| s.id)).collect(); + let albums = (1..=4) + .map(|a| { + photos + .iter() + .map(|p| c.photo(*p).is_some() && c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none() && al.photos.contains(p))) + .collect() + }) + .collect(); + (stacks, albums) +} + +/// What the indexes say. +fn indexed(c: &Catalog) -> (Vec>, Vec>) { + let photos: Vec = (1..=PHOTOS + 1).map(PhotoId).collect(); + let stacks = photos.iter().map(|p| c.stack_of(*p).map(|s| s.id)).collect(); + let albums = (1..=4) + .map(|a| { + photos + .iter() + .map(|p| c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none()) && c.photo(*p).is_some_and(|ph| c.album_contains(AlbumId(a), ph))) + .collect() + }) + .collect(); + (stacks, albums) +} + +/// Photo ids from bits (the extra id names a photo that isn't there: such ops fail). +fn ids(bits: u16) -> Vec { + (0..=PHOTOS).filter(|i| bits & (1 << i) != 0).map(|i| PhotoId(i + 1)).collect() +} + +fn op(c: &Catalog, kind: u8, n: u64, bits: u16, collapsed: bool) -> Op { + match kind % 8 { + 0 => Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed } }, + 1 => Op::RemoveStack { id: StackId(n % 4 + 1) }, + 2 => Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed }, + 3 => { + let mut a = Album::new(AlbumId(n % 4 + 1), "A"); + a.photos = ids(bits); + Op::AddAlbum { album: a } + } + 4 => Op::RemoveAlbum { id: AlbumId(n % 4 + 1) }, + 5 => Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) }, + // a batch whose last op fails half the time: what it did is rolled back + 6 => Op::Batch { + ops: vec![ + Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) }, + Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits.rotate_left(3)), collapsed }, + Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(!bits), collapsed } }, + ], + }, + // as the engine deletes a photo: out of its albums and stacks too, in one batch + _ => c.delete_permanently_ops(PhotoId(n % (PHOTOS + 1) + 1)), + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn indexes_agree_with_a_scan(steps in proptest::collection::vec((0u8..8, 0u64..16, 0u16..512, any::()), 1..40)) { + let mut c = library(); + let start = c.to_snapshot(); + // built before the ops, so every op has to keep them up to date + prop_assert_eq!(indexed(&c), scanned(&c)); + let mut log = String::new(); + let mut inverses = Vec::new(); + for (kind, n, bits, collapsed) in steps { + let op = op(&c, kind, n, bits, collapsed); + if let Ok(inv) = c.apply(op.clone()) { + log.push_str(&Catalog::op_to_log_line(&op)); + inverses.push(inv); + } + prop_assert_eq!(indexed(&c), scanned(&c)); + } + // a snapshot, a replayed journal and a clone build their own + let snap = Catalog::from_snapshot(&c.to_snapshot()).unwrap(); + prop_assert_eq!(indexed(&snap), scanned(&c)); + let mut replayed = Catalog::from_snapshot(&start).unwrap(); + prop_assert_eq!(indexed(&replayed), scanned(&replayed)); + replayed.replay(&log).unwrap(); + prop_assert_eq!(indexed(&replayed), scanned(&c)); + prop_assert_eq!(indexed(&c.clone()), scanned(&c)); + prop_assert!(c.clone() == c, "the indexes don't take part in equality"); + // undone step by step + for inv in inverses.into_iter().rev() { + c.apply(inv).unwrap(); + prop_assert_eq!(indexed(&c), scanned(&c)); + } + prop_assert!(c.stacks().next().is_none() && c.albums().next().is_none(), "all undone"); + } +} + +#[test] +fn a_photo_in_another_stack_is_refused_by_the_index() { + let mut c = library(); + c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap(); + let e = c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false } }).unwrap_err(); + assert_eq!(e, CatalogError::Invalid("a photo can be in only one stack".into())); + // its own stack may keep it + c.apply(Op::SetStack { id: StackId(1), photos: vec![PhotoId(2), PhotoId(1), PhotoId(3)], collapsed: true }).unwrap(); + assert_eq!(c.stack_of(PhotoId(3)).map(|s| s.id), Some(StackId(1))); +} + +/// The sort keys are worked out once per photo now; the order is the one the comparisons gave. +#[test] +fn sorted_queries_keep_their_order() { + let mut c = Catalog::new(); + let names = ["b.jpg", "A.jpg", "a.jpg", "Été.jpg", "été.jpg", "C.JPG", "b.jpg"]; + for (i, n) in names.iter().enumerate() { + let mut p = Photo::new(PhotoId(i as u64 + 1), Source::Demo { scene: 1 }, n, "JPEG", 60, 40, &format!("2026-01-0{}T00:00:00", 7 - i % 3)); + p.captured = (i % 2 == 0).then(|| format!("2025-0{}-01T00:00:00", 1 + i % 4)); + p.rating = (i % 3) as u8; + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let all = Filter::default(); + for key in [SortKey::CaptureDate, SortKey::ImportDate, SortKey::EditDate, SortKey::FileName, SortKey::Rating, SortKey::FileSize, SortKey::Random] + { + for ascending in [true, false] { + let sort = Sort { key, ascending, seed: 7, ..Default::default() }; + // the comparison the query used to sort by + let mut want: Vec<&Photo> = c.photos().map(|p| p.as_ref()).collect(); + want.sort_by(|a, b| { + let o = match key { + SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), + SortKey::ImportDate => a.imported.cmp(&b.imported), + SortKey::EditDate => a.edited.cmp(&b.edited), + SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()), + SortKey::Rating => a.rating.cmp(&b.rating), + SortKey::FileSize => a.file_size.cmp(&b.file_size), + SortKey::Random => mix64(mix64(7) ^ a.id.0).cmp(&mix64(mix64(7) ^ b.id.0)), + } + .then(a.id.cmp(&b.id)); + if ascending { o } else { o.reverse() } + }); + let want: Vec = want.iter().map(|p| p.id).collect(); + assert_eq!(c.query(&all, &sort), want, "{key:?} ascending {ascending}"); + } + } + // `only` as a set gives the photos it names + let only = Filter { only: vec![PhotoId(3), PhotoId(1), PhotoId(3)], ..Default::default() }; + assert_eq!(c.query(&only, &Sort { key: SortKey::FileName, ascending: true, ..Default::default() }), vec![PhotoId(3), PhotoId(1)]); +} + +/// Rules name flags, labels and kinds as their variants in lower case (what `{:?}` gave). +#[test] +fn rule_names_are_the_variants_in_lower_case() { + let mut c = Catalog::new(); + let mut n = 0; + for flag in [Flag::None, Flag::Pick, Flag::Reject] { + for label in + [None, Some(ColorLabel::Red), Some(ColorLabel::Yellow), Some(ColorLabel::Green), Some(ColorLabel::Blue), Some(ColorLabel::Purple)] + { + for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] { + n += 1; + let mut p = Photo::new(PhotoId(n), Source::Demo { scene: 1 }, "x.jpg", "JPEG", 60, 40, "2026-01-01T00:00:00"); + (p.flag, p.label, p.kind) = (flag, label, kind); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + } + } + c.apply(Op::SetLabelName { label: ColorLabel::Blue, name: Some("Sky".into()) }).unwrap(); + let count = |field: &str, value: String| { + let rules: RuleSet = + serde_json::from_value(serde_json::json!({"match": "all", "rules": [{"field": field, "op": "is", "value": value}]})).unwrap(); + c.query(&Filter { rule_set: Some(rules), ..Default::default() }, &Sort::default()).len() + }; + for flag in [Flag::None, Flag::Pick, Flag::Reject] { + assert_eq!(count("flag", format!("{flag:?}").to_lowercase()), 18, "{flag:?}"); + } + assert_eq!(count("flag", "picked".into()), 18); + for label in ColorLabel::ALL { + assert_eq!(count("label", format!("{label:?}").to_uppercase()), 9, "{label:?}"); + } + assert_eq!(count("label", "sky".into()), 9, "a custom name"); + assert_eq!(count("label", "none".into()), 9); + for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] { + assert_eq!(count("kind", format!("{kind:?}")), 18, "{kind:?}"); + } +} + +/// A hand-edited file can put a photo in two stacks (ops refuse it): the stack index then gives +/// the answers a scan of the stacks gives, before and after either stack goes. +#[test] +fn a_photo_in_two_stacks_read_from_a_file_answers_as_a_scan() { + let mut c = library(); + c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap(); + c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(3), PhotoId(4)], collapsed: false } }).unwrap(); + let mut v: serde_json::Value = serde_json::from_str(&c.to_snapshot()).unwrap(); + v["stacks"]["2"]["photos"] = serde_json::json!([2, 3, 4]); + let mut c = Catalog::from_snapshot(&v.to_string()).unwrap(); + assert_eq!(indexed(&c), scanned(&c)); + assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), Some(StackId(1))); + // the second stack can't be changed to keep it, as before + assert!(c.apply(Op::SetStack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false }).is_err()); + c.apply(Op::RemoveStack { id: StackId(1) }).unwrap(); + assert_eq!(indexed(&c), scanned(&c)); + assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), Some(StackId(2))); +} diff --git a/crates/catalog/src/tests_scale.rs b/crates/catalog/src/tests_scale.rs new file mode 100644 index 000000000..81ddccc41 --- /dev/null +++ b/crates/catalog/src/tests_scale.rs @@ -0,0 +1,92 @@ +//! What library queries cost at 100k photos (ignored: +//! `cargo test --release -p lightcraft-catalog --lib scale_100k -- --ignored --nocapture`). + +use std::time::Instant; + +use serde_json::json; + +use super::*; + +/// 100k photos, 10k two-photo stacks, a 20k-photo album, keywords and flags. +fn library(n: u64) -> (Catalog, AlbumId) { + let mut c = Catalog::new(); + let mut ops: Vec = (0..n) + .map(|i| { + let mut p = Photo::new( + PhotoId(i + 1), + Source::Demo { scene: (i % 20) as u32 }, + &format!("IMG_{:06}.jpg", mix64(i) % 1_000_000), + "JPEG", + 6000, + 4000, + "2026-01-01T00:00:00", + ); + p.captured = Some(format!("20{:02}-{:02}-{:02}T{:02}:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28, i % 24)); + p.rating = (i % 6) as u8; + p.flag = if i % 7 == 0 { Flag::Pick } else { Flag::None }; + p.meta.keywords = vec![format!("kw{}", i % 500), "travel|italy".into()]; + Op::AddPhoto { photo: Box::new(p) } + }) + .collect(); + ops.extend( + (0..n / 10) + .map(|s| Op::AddStack { stack: Stack { id: StackId(s + 1), photos: vec![PhotoId(s * 10 + 1), PhotoId(s * 10 + 2)], collapsed: false } }), + ); + let album = AlbumId(1); + let mut a = Album::new(album, "Big"); + a.photos = (0..n).step_by(5).map(|i| PhotoId(i + 1)).collect(); + ops.push(Op::AddAlbum { album: a }); + let t = Instant::now(); + c.apply(Op::Batch { ops }).unwrap(); + eprintln!("build {n} photos, {} stacks: {:.0} ms", n / 10, t.elapsed().as_secs_f64() * 1e3); + (c, album) +} + +fn smart(c: &mut Catalog, id: u64, rules: serde_json::Value) -> AlbumId { + let mut a = Album::new(AlbumId(id), format!("Smart {id}")); + a.smart = Some(Box::new(Filter { rule_set: Some(serde_json::from_value(rules).unwrap()), ..Default::default() })); + c.apply(Op::AddAlbum { album: a }).unwrap(); + AlbumId(id) +} + +/// Best of five runs (the machine may be busy with other work). +fn time(what: &str, f: impl Fn() -> T) -> T { + let mut best = f64::MAX; + let mut r = None; + for _ in 0..5 { + let t = Instant::now(); + r = Some(f()); + best = best.min(t.elapsed().as_secs_f64() * 1e3); + } + eprintln!("{what}: {best:.1} ms"); + r.unwrap() +} + +#[test] +#[ignore] +fn scale_100k() { + let (mut c, album) = library(100_000); + let all = Filter::default(); + time("query, capture date", || c.query(&all, &Sort::default())); + time("query, file name", || c.query(&all, &Sort { key: SortKey::FileName, ..Default::default() })); + time("query, text", || c.query(&Filter { text: "kw42".into(), ..Default::default() }, &Sort::default())); + time("query, album of 20k", || c.query(&Filter { album: Some(album), ..Default::default() }, &Sort::default())); + let only: Vec = (0..5000).map(|i| PhotoId(i * 7 + 1)).collect(); + time("query, only 5k ids", || c.query(&Filter { only: only.clone(), ..Default::default() }, &Sort::default())); + let stacked = smart(&mut c, 10, json!({"match": "all", "rules": [{"field": "stacked", "op": "is", "value": true}]})); + let flagged = smart( + &mut c, + 11, + json!({"match": "all", "rules": [{"field": "flag", "op": "is", "value": "pick"}, {"field": "kind", "op": "is", "value": "image"}]}), + ); + let keyword = smart(&mut c, 12, json!({"match": "all", "rules": [{"field": "keywords", "op": "contains", "value": "kw42"}]})); + let in_album = smart(&mut c, 13, json!({"match": "all", "rules": [{"field": "album", "op": "is", "value": album.0}]})); + let recent = smart(&mut c, 14, json!({"match": "all", "rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "years"}}]})); + for (what, a) in [("stacked", stacked), ("flag + kind", flagged), ("keyword", keyword), ("in album", in_album), ("in the last 2 years", recent)] { + let n = time(&format!("smart album count, {what}"), || c.album_count(a)); + eprintln!(" = {n}"); + } + time("albums_of", || c.albums_of(PhotoId(6))); + time("arrange stacks", || c.arrange_stacks(&c.query(&all, &Sort::default()))); + time("stack_of × 1000", || (0..1000u64).filter(|i| c.stack_of(PhotoId(i * 97 + 1)).is_some()).count()); +} From 3f3ffa541a1d3466fa3723dace04d45409792764 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 16:48:16 -0500 Subject: [PATCH 05/52] perf(engine): #1136 album order and album commands without per-photo scans A manual album shown in its own order sorted with position() per photo (O(n^2): 2.0 s for 20k photos, now 18 ms). album.addPhotos, removePhotos and toggleTarget test membership with sets. The 100k scale test measures all three. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 12 +++++++++--- crates/engine/src/lib.rs | 8 ++++++-- crates/engine/src/tests_library.rs | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 2d06d73f6..9a5cb410b 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1147,8 +1147,10 @@ pub fn specs() -> Vec { let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?; let mut photos = al.photos.clone(); let before = photos.len(); + // looked up, not searched for: adding thousands to a big album stays linear + let mut held: std::collections::HashSet = photos.iter().copied().collect(); for t in targets { - if !photos.contains(&t) { + if held.insert(t) { photos.push(t); } } @@ -1179,11 +1181,14 @@ pub fn specs() -> Vec { } }; let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?; - let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t)); + // looked up, not searched for: thousands toggled in a big album stay linear + let mut held: std::collections::HashSet = al.photos.iter().copied().collect(); + let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t)); let photos: Vec = if all_in { + let targets: std::collections::HashSet = targets.iter().copied().collect(); al.photos.iter().copied().filter(|x| !targets.contains(x)).collect() } else { - al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect() + al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect() }; let name = al.name.clone(); let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied()); @@ -1317,6 +1322,7 @@ pub fn specs() -> Vec { if al.is_smart() { return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); } + let targets: std::collections::HashSet = targets.into_iter().collect(); let photos: Vec = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; ok() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc6c431e..09fb12f2a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -874,8 +874,12 @@ impl Session { && !al.is_smart() && self.filter == Filter::default() { - let order = al.photos.clone(); - visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX)); + // each photo's place looked up, not searched for (a big album sorted in O(n log n)) + let mut place: std::collections::HashMap = std::collections::HashMap::with_capacity(al.photos.len()); + for (i, id) in al.photos.iter().enumerate() { + place.entry(*id).or_insert(i); + } + visible.sort_by_key(|id| place.get(id).copied().unwrap_or(usize::MAX)); if !self.sort.ascending { visible.reverse(); } diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 8a2599e5c..0fa72e453 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -440,6 +440,21 @@ fn scale_100k_library_queries() { m("undo", &mut || { s.execute("edit.undo", &serde_json::json!({})).unwrap(); }); + // a 20k-photo album shown in its own order (capture-date sort, no filter) + let big = s.execute("album.create", &serde_json::json!({"name": "Big"})).unwrap()["id"].as_u64().unwrap(); + let ids: Vec = (0..n).rev().step_by(5).map(|i| i + 1).collect(); + m("add 20k to an album", &mut || { + s.execute("album.addPhotos", &serde_json::json!({"id": big, "ids": ids})).unwrap(); + }); + m("album of 20k (album order)", &mut || { + s.execute("library.source", &serde_json::json!({"kind": "album", "id": big})).unwrap(); + let _ = s.visible_cloned(); + }); + s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap(); + let half: Vec = ids.iter().copied().step_by(2).collect(); + m("remove 10k from the album", &mut || { + s.execute("album.removePhotos", &serde_json::json!({"id": big, "ids": half})).unwrap(); + }); worst.sort_by(|a, b| b.1.total_cmp(&a.1)); eprintln!("slowest: {:?}", &worst[..3]); } From 39c33a99d6b7105fea2f783238c1a74ff2c8faa8 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 16:48:16 -0500 Subject: [PATCH 06/52] perf(ui): #1136 dialogs count once per library change, not every frame Create Smart Album ran a full query for its count and Rename Keyword scanned every photo on each frame the dialog was open; Merge Keywords recomputed suggestions. They use Caches::dialog_count (keyed by catalog revision and what is counted, and the minute for relative-date rules) and the cached suggestions. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 18 ++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 20 ++++++++++++++------ crates/ui-egui/src/tests_keywording.rs | 21 +++++++++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..35801ba9f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1477,6 +1477,9 @@ pub struct Caches { /// What the open smart-album rule dialog shows besides the rules ([`Caches::rules_view`]). rules_view: Option<(u64, std::sync::Arc)>, pub rules_view_scans: usize, + /// A count an open dialog shows ([`Caches::dialog_count`]), by what it counts. + dialog_count: Option<(u64, usize)>, + pub dialog_count_scans: usize, /// The problems of every saved smart album that has some ([`Caches::smart_album_problems`]). smart_problems: Option<(u64, std::sync::Arc>>)>, pub smart_problem_scans: usize, @@ -1749,6 +1752,21 @@ impl Caches { v } + /// A count an open dialog shows (photos a filter matches, photos with a keyword…): `count` runs + /// again only when the catalog or `what` (what is counted) changes, not every frame. + pub(crate) fn dialog_count(&mut self, cat: &lightcraft_catalog::Catalog, what: impl std::hash::Hash, count: impl FnOnce() -> usize) -> usize { + let k = key_of((cat.revision, what)); + if let Some((key, n)) = self.dialog_count + && key == k + { + return n; + } + let n = count(); + self.dialog_count_scans += 1; + self.dialog_count = Some((k, n)); + n + } + /// The problems of every saved smart album that has some (`Catalog::smart_album_problems`), /// for the sidebar's ⚠ marks: worked out again only when the catalog changes. pub fn smart_album_problems( diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..60ccdb6e3 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -430,7 +430,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } let rules = app.session.view_rules(); - let n = app.session.catalog.query(&rules, &Default::default()).len(); + // counted when the library or the rules change (or, for "in the last…" rules, the minute), not every frame + let now = (app.session.clock)(); + let minute = if rules.depends_on_now() { now.get(..16).unwrap_or(&now) } else { "" }; + let n = app.caches.dialog_count(&app.session.catalog, ("smartAlbum", format!("{rules:?}"), minute), || { + lightcraft_catalog::rules::set_now(Some(now.clone())); + app.session.catalog.query(&rules, &Default::default()).len() + }); ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label)); ui.label( egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) @@ -573,7 +579,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::RenameKeyword { from, to } => { - let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count(); + let n = app.caches.dialog_count(&app.session.catalog, ("renameKeyword", from.as_str()), || { + app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count() + }); if dialog_field(ui, "field:keywordName", to, "New name") { confirm = true; } @@ -589,11 +597,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } let options: Vec = app - .session - .catalog - .keyword_suggestions(from, into, 12) - .into_iter() + .caches + .suggestions(&app.session.catalog, from, into, 12) + .iter() .filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f))) + .cloned() .collect(); ui.horizontal_wrapped(|ui| { for k in options { diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index a130b86b2..17ddc6508 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -227,3 +227,24 @@ fn containing_keywords_view_lists_parents_too() { } assert!(!has(&h, "keywordChip:beach"), "the chips give way"); } + +/// Rename Keyword's "used by n photos" and Create Smart Album's live count scan the library once +/// per change to it, not every frame the dialog is open. +#[test] +fn open_dialogs_count_once_per_library_change() { + let (mut h, ids) = two_selected(); + h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: "beach".into(), to: "beach".into() }); + let before = h.app.caches.dialog_count_scans; + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.caches.dialog_count_scans, before + 1, "unchanged frames reuse the count"); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[1]], "keywords": ["sea"]}})); + h.step(); + assert_eq!(h.app.caches.dialog_count_scans, before + 2, "a library change counts again"); + h.app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: "Beach".into(), parent: None }); + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.caches.dialog_count_scans, before + 3, "another dialog counts its own photos, once"); +} From d79dad9ab158b98f4a26925f2ae81d45288dd095 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 16:48:16 -0500 Subject: [PATCH 07/52] docs(gaps): #1136 gap 26 notes indexed stack and album lookups at 100k Co-Authored-By: Claude Opus 5.5 --- docs/gaps.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..325c7939e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified; gap 26: stack and album lookups indexed at 100k photos) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -40,7 +40,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 23 | **Accessibility** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) | | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | | 25 | **File writes**: lossy DNG, Render to DNG; AVIF import (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 21–37 | [file-format-parity](file-format-parity.md) | -| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | +| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple only; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 15–30 | [hardware-parity](hardware-parity.md) | | 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | From aec225b6ac404ec7bc13924518f74e0a53a92ae5 Mon Sep 17 00:00:00 2001 From: GabrielePecchioli <71943308+GabrielePecchioli@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:45:26 +0200 Subject: [PATCH 08/52] Add Italian interface translation (#1192) Italian (`it`) catalogs: 1,992 interface messages and all 246 format strings (2,201 of 2,398 messages; What's New left in English like most languages). 410 labels reuse the PhotoCraft, VectorCraft, PdfCraft and WordCraft Italian catalogs where the meaning fits a photo app; 46 were retranslated. Counts follow a label ("Foto importate: 3") because Italian adjectives agree with the number; the English plural suffix is consumed with {:.0}. Language table entry, menu command, three tests mirroring Ukrainian's, the button-fit test extended to Italian, and the Settings language buttons now wrap so an eleventh language doesn't widen the dialog past a short window. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +- assets/ATTRIBUTION.md | 1 + crates/ui-egui/locales/it-formats.json | 248 +++ crates/ui-egui/locales/it.json | 1994 +++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 109 +- crates/ui-egui/src/menus.rs | 2 + crates/ui-egui/src/panels/keyword_list.rs | 4 +- crates/ui-egui/src/panels/settings.rs | 5 +- docs/localization-parity.md | 12 +- docs/localization.md | 1 + 10 files changed, 2368 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/locales/it-formats.json create mode 100644 crates/ui-egui/locales/it.json diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..91a8f1cfd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,7 +96,7 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc | Korean | ko | 0% | none (no Hangul face) | | Vietnamese | vi | 0% | none | -Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%). +Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%). ## Upcoming @@ -161,6 +161,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 7ade9dac2..db81eb53f 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -26,6 +26,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none | | craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file | | `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders | +| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders | Notes - Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json new file mode 100644 index 000000000..0a263caa4 --- /dev/null +++ b/crates/ui-egui/locales/it-formats.json @@ -0,0 +1,248 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} con diversi file simili: usa Trova)", + " ({} in Recently Deleted)": " ({} in Eliminate di recente)", + " · {:.0}% clipped": " · {:.0}% tagliato", + " · {failed} couldn't be rendered": " · impossibili da elaborare: {failed}", + " · {skipped} skipped (file exists)": " · saltate: {skipped} (il file esiste)", + " · {} duplicate{} skipped": " · duplicati saltati: {}{:.0}", + " · {} failed: {}": " · {} con errori: {}", + " · {} moved": " · spostate: {}", + " · {} not readable": " · illeggibili: {}", + " · {} original{} left at the source": " · originali lasciati nell'origine: {}{:.0}", + " · {} restored from Recently Deleted": " · ripristinate da Eliminate di recente: {}", + " — not carried over: {}{}": " · non trasferito: {}{}", + "+{} more": "+{} altre", + ", {failed} file{} not readable": ", file illeggibili: {failed}{:.0}", + "; {kept} already had a location": "; avevano già una posizione: {kept}", + "; {outside} outside its time range": "; fuori dal loro intervallo di tempo: {outside}", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commit, +{} / −{} righe (Δ {}), +{} / −{} risorse binarie, {} - {}", + "Add {local_n} to My Photos": "Aggiungi {local_n} a Le mie foto", + "Add {} Photo{}": "Aggiungi {} foto{:.0}", + "Add {} Photo{}, restore {}": "Aggiungi {} foto{:.0}, ripristina {}", + "Add “{}” to Favorites": "Aggiungi “{}” ai preferiti", + "Added {n} photo{} to My Photos": "Foto aggiunte a Le mie foto: {n}{:.0}", + "Added {n} photo{} to “{}”": "Foto aggiunte a “{1}”: {n}{0:.0}", + "Added {what} to {name}": "Aggiunto a {name}: {what}", + "Adding photos… {} of {}": "Aggiunta delle foto… {} di {}", + "All {n} selected photos move by the same amount.": "Tutte le {n} foto selezionate si spostano della stessa quantità.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Già nella libreria, in Eliminate di recente: foto selezionate {}{:.0} · clic destro → Ripristina, oppure Elimina definitivamente per importarle da capo", + "Already in the library: {} photo{} selected in All Photos": "Già nella libreria (selezionate in Tutte le foto): {}{:.0}", + "Auto ({})": "Automatico ({})", + "Auto Import: added {} photo{}": "Importazione automatica · foto aggiunte: {}{:.0}", + "Auto Sync: edits apply to {n} photos": "Sincronizzazione automatica: le modifiche si applicano a {n} foto", + "Automatic ({} MB)": "Automatico ({} MB)", + "Build 1:1 Previews ({scope})": "Genera anteprime 1:1 ({scope})", + "Build Smart Previews ({scope})": "Genera anteprime avanzate ({scope})", + "Build Standard-Sized Previews ({scope})": "Genera anteprime di dimensione standard ({scope})", + "Building previews for {} photos…": "Generazione delle anteprime per {} foto…", + "Click {}:1": "Clic {}:1", + "Couldn't open the editor: {e}": "Impossibile aprire l'editor: {e}", + "Create {n} Virtual Copies": "Crea {n} copie virtuali", + "Creates {} stacks from {} of {scope}": "Crea pile: {} (da {} di {scope})", + "Decoder: {reason}": "Decodificatore: {reason}", + "Delete \"{label}\"": "Elimina \"{label}\"", + "Delete \"{label}\" (and the mask)": "Elimina \"{label}\" (e la maschera)", + "Delete {n} Photos": "Elimina {n} foto", + "Delete “{current}”": "Elimina “{current}”", + "Discard Smart Previews ({scope})": "Elimina anteprime avanzate ({scope})", + "Done ({what}): {done} photo{plural}": "Fatto ({what}): {done} foto{plural:.0}", + "Duplicate \"{label}\"": "Duplica \"{label}\"", + "Example: {example}": "Esempio: {example}", + "Exported {n} photo{}": "Foto esportate: {n}{:.0}", + "Exported {ok} of {total} photo{}": "Foto esportate: {ok} di {total}{:.0}", + "Exported {} point curve preset{}": "Preset di curva a punti esportati: {}{:.0}", + "Exported {} preset{}": "Preset esportati: {}{:.0}", + "Exporting {} of {total}": "Esportazione {} di {total}", + "Filter bar — {active} active filter{}": "Barra dei filtri · filtri attivi: {active}{:.0}", + "Focus {:.0}": "Messa a fuoco {:.0}", + "Folder “{}” (and its subfolders)": "Cartella “{}” (e sottocartelle)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Foto mancanti ritrovate: {n}{:.0}; ancora mancanti: {left}{unsure}", + "Healed {n} dust spot{}": "Macchie di polvere corrette: {n}{:.0}", + "Import cancelled · {} photo{} added": "Importazione annullata · foto aggiunte: {}{:.0}", + "Import failed: {e}": "Importazione non riuscita: {e}", + "Import warnings ({n}):\n{warnings}": "Avvisi di importazione ({n}):\n{warnings}", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Il profilo XMP \"{profile}\" non è stato applicato a {path}; i colori possono differire da Lightroom.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La geometria di Lightroom non è stata conservata per {path}; {mode} verrà ricalcolato.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometria di Lightroom per {path} non è stata conservata; la trasformazione salvata viene ignorata perché il raddrizzamento è disattivato.", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "La geometria di Lightroom per {path} non è stata conservata; la trasformazione salvata del raddrizzamento guidato non è supportata.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "La geometria di Lightroom per {path} non è stata conservata; la trasformazione salvata del raddrizzamento non è supportata.", + "XMP adjustments were not imported for {path}: {fields}.": "Le regolazioni XMP non sono state importate per {path}: {fields}.", + "Imported {n} point curve preset{}": "Preset di curva a punti importati: {n}{:.0}", + "Imported {n} preset{}": "Preset importati: {n}{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Preset importati: {n}{:.0}, file illeggibili: {f}{:.0}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Profili importati: {profiles}{:.0} (Browser dei profili → i loro gruppi)", + "Imported {} photo{}": "Foto importate: {}{:.0}", + "Insert at the cursor (also written {})": "Inserisci al cursore (si scrive anche {})", + "Label Set: {name}": "Set di etichette: {name}", + "LightCraft could not open its window: {e}": "LightCraft non è riuscito ad aprire la sua finestra: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft non è riuscito ad aprire la sua finestra: {e}\n\nI dettagli sono nel file di log: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catalogo di Lightroom esaminato: foto {photos}, raccolte {collections}, mancanti {missing}", + "Lightroom import complete: {imported} photos added": "Importazione da Lightroom completata: foto aggiunte {imported}", + "Lightroom import complete: {imported} photos added, {failed} failed": "Importazione da Lightroom completata: foto aggiunte {imported}, con errori {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Importazione da Lightroom completata: foto aggiunte {imported}; avviso sull'indice: {warning}", + "Lightroom import failed: {e}{warning}": "Importazione da Lightroom non riuscita: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Analisi di Lightroom non riuscita: {e}{warning}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Caratteri: {} (OFL). Icone: originali.", + "Matches: {}": "Corrispondenze: {}", + "Merging… {stage} {:.0}%": "Unione in corso… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "Spostare {what} in Eliminate di recente?", + "Named ({named_n})": "Con nome ({named_n})", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Pennello “{k}”: fai clic sulle foto per aggiungerla o toglierla · Esc interrompe", + "Paste into {n} photo{} — only settings that were copied are pasted": "Incolla in {n} foto{:.0}: vengono incollate solo le impostazioni copiate", + "Preset: {name}": "Preset: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Solo anteprima: si modifica il JPEG incorporato dalla fotocamera ({})", + "Preview only — {}": "Solo anteprima: {}", + "Previews ready for {done} photo{plural}": "Anteprime pronte per {done} foto{plural:.0}", + "Profile: {}": "Profilo: {}", + "Proof Copy ({})": "Copia di prova ({})", + "Proof Preview · {}": "Anteprima di prova · {}", + "Quick Develop · {n} photos": "Sviluppo rapido · {n} foto", + "Quick Develop: {name}": "Sviluppo rapido: {name}", + "Rated {}": "Valutata {}", + "Reading Lightroom catalog… {done} of {total}": "Lettura del catalogo Lightroom… {done} di {total}", + "Reading photos… {done} of {total}": "Lettura delle foto… {done} di {total}", + "Reading photos… {} of {}": "Lettura delle foto… {} di {}", + "Redo {}": "Ripeti {}", + "Remove “{name}” and its {count} photo{} from the library?": "Togliere dalla libreria “{name}” e le sue foto ({count}{:.0})?", + "Remove “{}” from Favorites": "Rimuovi “{}” dai preferiti", + "Removed from {}": "Rimosso da {}", + "Removed {what} from {name}": "Rimosso da {name}: {what}", + "Rename {n} Photos…": "Rinomina {n} foto…", + "Rename “{name}”": "Rinomina “{name}”", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Rinomina “{from}” in {n} foto{:.0} (anche le parole chiave sotto di essa). Usa | per i livelli, es. Viaggi|Italia. Un nome esistente unisce le due.", + "Rendering on the CPU: {why}": "Rendering sulla CPU: {why}", + "Replace {} with:": "Sostituisci {} con:", + "Reset {name}": "Reimposta {name}", + "Reset {} Channel": "Reimposta canale {}", + "Restore {}": "Ripristina {}", + "Restore {} Photo{}": "Ripristina {} foto{:.0}", + "Restored {} photo{} from Recently Deleted": "Foto ripristinate da Eliminate di recente: {}{:.0}", + "Saved export preset “{}”": "Preset di esportazione “{}” salvato", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Valuta messa a fuoco ed esposizione di {scope} e trova scatti simili fatti a pochi secondi di distanza (raffiche).", + "Show {hidden} hidden location{}": "Mostra le posizioni nascoste: {hidden}{:.0}", + "Showing {n} of {}": "Mostrate {n} di {}", + "Smart album: {}": "Album smart: {}", + "Tagged {n} photo{} from the tracklog": "Foto geolocalizzate dal tracciato: {n}{:.0}", + "The SAM 3 download failed: {e}": "Download di SAM 3 non riuscito: {e}", + "Undo {}": "Annulla {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Tag sconosciuti{s:.0}: {} (restano come li hai scritti{:.0}; vedi Tag)", + "Updated “{name}”": "“{name}” aggiornato", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versione {}: libreria fotografica e sviluppo raw clean-room, in puro Rust.", + "Version: {}": "Versione: {}", + "Virtual copy “{name}” of {of}": "Copia virtuale “{name}” di {of}", + "Working on {what} for {} photos…": "Elaborazione di {what} per {} foto…", + "none (CPU rendering): {why}": "nessuna (rendering sulla CPU): {why}", + "the {n} selected photos": "le {n} foto selezionate", + "the {} photos in view": "le {} foto nella vista", + "these {count} photos": "queste {count} foto", + "{count} smart preview{} · {:.1} MB": "Anteprime avanzate: {count}{0:.0} · {1:.1} MB", + "{f} folders (and their subfolders)": "{f} cartelle (e le loro sottocartelle)", + "{label} Grading": "Gradazione di {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} su ogni foto selezionata", + "{label} {index} of {n}": "{label} {index} di {n}", + "{label}, {n} photos": "{label}, {n} foto", + "{matching} of {t} photos": "{matching} di {t} foto", + "{matching} photos": "{matching} foto", + "{mode} spot {} of {}": "{mode}: macchia {} di {}", + "{month}": "{month:02}", + "{month} {year}": "{month:02}/{year}", + "{msg} ({failed} failed)": "{msg} ({failed} con errori)", + "{name} Label": "Etichetta {name}", + "{name} Off": "{name}: disattivato", + "{name} On": "{name}: attivato", + "{name} opened for editing; it is stacked with the original": "{name} aperta per la modifica; è in pila con l'originale", + "{name} — double-click to reset it": "{name}: doppio clic per reimpostarlo", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Modifiche non scritte sul disco: {n}{0:.0}: {e}\nSe esci ora, andranno perse.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Modifiche salvate in memoria ma non scritte sul disco: {n}{0:.0}: {e}\nLightCraft riprova da solo; se esci ora, andranno perse.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Modifiche salvate solo in memoria: {n}{:.0}: {e}; LightCraft riproverà", + "{n} found · {sel} selected": "Trovate: {n} · selezionate: {sel}", + "{n} photos": "{n} foto", + "{n} photos in {} are hidden by the filters above": "I filtri qui sopra nascondono {n} foto in {}", + "{n} photo{}": "{n} foto{:.0}", + "{n} photo{} match · updates automatically as photos change": "Foto corrispondenti: {n}{:.0} · si aggiorna da solo quando le foto cambiano", + "{n} photo{} now · updates automatically as photos change": "{n} foto{:.0} ora · si aggiorna da solo quando le foto cambiano", + "{pid} (missing)": "{pid} (mancante)", + "{sel_n} selected · {counted}": "Selezionate: {sel_n} · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb} {n} foto{:.0}", + "{weekday}, {day}": "{weekday} {day}", + "{weekday}, {day} {month} {year}": "{weekday} {day}/{month:02}/{year}", + "{what} merge added": "Unione {what} aggiunta", + "{what} merge failed: {e}": "Unione {what} non riuscita: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "Direttive {{date:…}}: {}", + "{} and higher": "{} e superiori", + "{} contributors · {} commits": "Collaboratori: {} · commit: {}", + "{} failed": "Con errori: {}", + "{} files": "{} file", + "{} files and {f} folder{}": "{} file, cartelle: {f}{:.0}", + "{} item{}": "Elementi: {}{:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} di 4 guide: trascina lungo le linee che dovrebbero essere verticali o orizzontali.", + "{} photos": "{} foto", + "{} photos · exposures {} EV": "{} foto · esposizioni {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "{} foto · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "Foto illeggibili: {}{:.0}", + "{} sample{}": "Campioni: {}{:.0}", + "{} sec": "{} s", + "{} spot(s) on this photo": "Macchie in questa foto: {}", + "{} — show their photos": "{}: mostra le loro foto", + "· {dups} already in the library": "· già nella libreria: {dups}", + "· {n} photo{}": "· {n} foto{:.0}", + "· {trashed} in Recently Deleted": "· {trashed} in Eliminate di recente", + "… and {more} more": "… e altre {more}", + "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft non sa ancora decodificare questa variante raw ({}). Stai modificando l'anteprima JPEG incorporata dalla fotocamera, che contiene già lo stile immagine della fotocamera (ad es. Monocromatico) e il bilanciamento del bianco.", + "Filtered: {label}": "Filtrate: {label}", + "{n} similar photo{} · View ▸ Clear Filters to see all": "Foto simili: {n}{:.0} · Visualizza → Cancella filtri per vederle tutte", + "Moved; {} photo(s) relinked": "Spostata; foto ricollegate: {}", + "{} label (click more labels to show any of them)": "Etichetta {} (fai clic su altre etichette per mostrarne una qualsiasi)", + ", {} star{}": ", stelle: {}{:.0}", + ", {} label": ", etichetta {}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Scaricato da {host}. Quando è arrivato ed è stato verificato, LightCraft lo installa e {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "In esecuzione sul processore: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Conservate nella cartella “denoise” della libreria; al raggiungimento del limite si eliminano per prime le più vecchie che nessuna foto usa.", + "Denoising {name}: {done} of {total} tiles{}": "Riduzione disturbo di {name}: {done} di {total} riquadri{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} di {} da {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Generazione dell'immagine con il disturbo ridotto… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Addestrato su: {provenance}", + " · {queued} waiting": " · in attesa: {queued}", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Mostra le condizioni del modello, poi scarica {} da {host}, lo installa e comincia a usarlo. Non viene inviato nient'altro.", + "In line for AI Denoise ({ahead} ahead)…": "In coda per la riduzione disturbo IA ({ahead} prima)…", + "Running on {}.": "In esecuzione su {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Metti dentro “{parent}”", + "Delete the keyword “{name}”?": "Eliminare la parola chiave “{name}”?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Viene tolta da {count} foto{:.0}, insieme alle parole chiave sotto di essa. Annulla la riporta.", + "Create Keyword Tag Inside “{name}”…": "Crea tag parola chiave dentro “{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” ha già “{name}”. Unire le due?", + "There is a “{name}” at the top level already. Merge the two?": "Al livello principale c'è già “{name}”. Unire le due?", + "Added “{keyword}” to {n} photo{}": "Aggiunta “{keyword}” a {n} foto{:.0}", + "On {have} of {of} selected photos": "Su {have} di {of} foto selezionate", + "Remove “{name}”": "Rimuovi “{name}”", + "There is a keyword set “{name}” already": "Esiste già un set di parole chiave “{name}”", + "Added {n} keyword{}": "Parole chiave aggiunte: {n}{:.0}", + "; {n} gained synonyms": "; con nuovi sinonimi: {n}", + "Exported {n} keyword{}": "Parole chiave esportate: {n}{:.0}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " · Capture One non importa un elenco con ; , < o > in una parola chiave: {names}", + " — left out, as a keyword list can't hold them: {names}": " · escluse, perché un elenco di parole chiave non può contenerle: {names}", + "{done} of {total}": "{done} di {total}", + "{done} of {total} MB": "{done} di {total} MB", + "{pct} %": "{pct}%", + "+{n} more": "+{n} altre", + "1 task is still running: {names}. Quitting stops it.": "1 attività è ancora in corso: {names}. Uscendo si interrompe.", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} attività sono ancora in corso: {names}. Uscendo si interrompono.", + "{what} merge cancelled": "Unione {what} annullata", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Foto locali saltate: {n}{0:.0}; gli album contengono foto della libreria (prima aggiungile a Le mie foto)", + "New Folder in “{name}”": "Nuova cartella in “{name}”", + "Moved {} photo(s)": "Foto spostate: {}", + "not added to Apple Photos: {e}": "non aggiunte a Foto di Apple: {e}", + "{n} added to Apple Photos album “{album}”": "Aggiunte all'album “{album}” di Foto di Apple: {n}", + "{n} added to Apple Photos": "Aggiunte a Foto di Apple: {n}", + "{n} photos opened for editing; each is stacked with its original": "{n} foto aperte per la modifica; ciascuna è in pila con il suo originale" +} diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json new file mode 100644 index 000000000..382fb80c9 --- /dev/null +++ b/crates/ui-egui/locales/it.json @@ -0,0 +1,1994 @@ +{ + " (library default)": " (predefinito della libreria)", + " (inverted)": " (invertita)", + " · best of its burst": " · la migliore della sua raffica", + " · in a burst": " · in una raffica", + "% of all commits": "% di tutti i commit", + "+ Group": "+ Gruppo", + "+ Rule": "+ Regola", + "0: nothing is rejected.": "0: non viene scartato niente.", + "1 day": "1 giorno", + "2026-04 or 2026-04-12": "2026-04 oppure 2026-04-12", + "= exactly": "= esattamente", + "A blank template keeps the original names.": "Un modello vuoto mantiene i nomi originali.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Un filtro nasconde le foto di questa vista: cambialo o toglilo (Visualizza → Cancella filtri)", + "A one-time download of about": "Un download unico di circa", + "AI Mask Model Status": "Stato del modello IA per le maschere", + "About": "Informazioni", + "About LightCraft": "Informazioni su LightCraft", + "Active": "Attivo", + "Activity": "Attività", + "Actual size": "Dimensione reale", + "Add": "Aggiungi", + "Add Catchlight": "Aggiungi riflesso negli occhi", + "Add Folder to Local": "Aggiungi cartella a Locale", + "Add Mask": "Aggiungi maschera", + "Add Object Click": "Aggiungi oggetto con un clic", + "Add Point Color Sample": "Aggiungi campione colore puntuale", + "Add Red Eye Correction": "Aggiungi correzione occhi rossi", + "Add Remove Spot": "Aggiungi rimozione macchie", + "Add Selected Photos": "Aggiungi le foto selezionate", + "Add a rule (⌥-click: a group)": "Aggiungi una regola (⌥-clic: un gruppo)", + "Add in place": "Aggiungi sul posto", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Aggiungi sul posto: la libreria usa i file dove si trovano; niente viene copiato o spostato.", + "Add keyword": "Aggiungi parola chiave", + "Add number": "Aggiungi numero", + "Add to": "Aggiungi a", + "Add to Album": "Aggiungi all'album", + "Add to Favorites": "Aggiungi ai preferiti", + "Add to Local": "Aggiungi a Locale", + "Add to My Photos": "Aggiungi a Le mie foto", + "Add to Selected Photos": "Aggiungi alle foto selezionate", + "Add to Target Album": "Aggiungi all'album di destinazione", + "Add to this mask": "Aggiungi a questa maschera", + "Add/Subtract/Intersect Mask": "Aggiungi, sottrai o interseca maschera", + "Added to photos you import that don't already have it.": "Aggiunto alle foto che importi e che non lo hanno già.", + "Adding": "Aggiunta in corso", + "Adding Lightroom photos…": "Aggiunta delle foto di Lightroom…", + "Advanced search…": "Ricerca avanzata…", + "After": "Dopo", + "After 30 days": "Dopo 30 giorni", + "After 7 days": "Dopo 7 giorni", + "After 90 days": "Dopo 90 giorni", + "After a year": "Dopo un anno", + "Album": "Album", + "Albums": "Album", + "All": "Tutti", + "All Metadata": "Tutti i metadati", + "All Metadata…": "Tutti i metadati…", + "All Photos": "Tutte le foto", + "All rights reserved": "Tutti i diritti riservati", + "All the photos imported from this disk are included.": "Sono incluse tutte le foto importate da questo disco.", + "All types": "Tutti i tipi", + "Alt Text": "Testo alternativo", + "Always": "Sempre", + "Amount": "Quantità", + "Analyzing the photo for AI masks…": "Analisi della foto per le maschere IA…", + "Any Searchable Text": "Qualsiasi testo ricercabile", + "Aperture": "Diaframma", + "Application": "Applicazione", + "Apply": "Applica", + "Apply Color Label Set": "Applica set di etichette colore", + "Apply Filter Preset": "Applica preset di filtro", + "Apply Metadata Preset": "Applica preset di metadati", + "Apply Point Curve Preset": "Applica preset di curva a punti", + "Apply Preset": "Applica preset", + "Apply Settings JSON": "Applica impostazioni JSON", + "Approved": "Approvata", + "Aqua": "Acqua", + "Aqua Hue": "Tonalità acqua", + "Aqua Luminance": "Luminanza acqua", + "Aqua Saturation": "Saturazione acqua", + "Arrange Panel": "Disponi il pannello", + "ArtCraft Website": "Sito web di ArtCraft", + "ArtCraft — more creative apps": "ArtCraft: altre app creative", + "As Shot": "Come scattata", + "As shot": "Come scattata", + "Ascending": "Crescente", + "Aspect": "Proporzioni", + "Aspect Ratio": "Proporzioni", + "Assisted Culling": "Selezione assistita", + "Assisted Culling…": "Selezione assistita…", + "Auto": "Automatico", + "Auto Advance": "Avanzamento automatico", + "Auto Advance Off": "Avanzamento automatico disattivato", + "Auto Advance On": "Avanzamento automatico attivato", + "Auto Advance after rating or flagging": "Avanzamento automatico dopo valutazione o contrassegno", + "Auto Advance: move to the next photo after rating or flagging": "Avanzamento automatico: passa alla foto successiva dopo una valutazione o un contrassegno", + "Auto Align": "Allineamento automatico", + "Auto B&W Mix": "Mix bianco e nero automatico", + "Auto Crop": "Ritaglio automatico", + "Auto Import": "Importazione automatica", + "Auto Import Now": "Importa automaticamente ora", + "Auto Import Settings": "Impostazioni di importazione automatica", + "Auto Mask": "Maschera automatica", + "Auto Select": "Selezione automatica", + "Auto Settings": "Impostazioni automatiche", + "Auto Straighten": "Raddrizzamento automatico", + "Auto Sync": "Sincronizzazione automatica", + "Auto settings applied": "Impostazioni automatiche applicate", + "Auto-Stack by Capture Time": "Raggruppa automaticamente per ora di scatto", + "Auto-Stack by Capture Time…": "Raggruppa automaticamente per ora di scatto…", + "Auto-Tag Photos from Tracklog": "Geolocalizza automaticamente le foto dal tracciato", + "Auto-Tag from Tracklog": "Geolocalizza dal tracciato", + "Auto-Tag from Tracklog…": "Geolocalizza dal tracciato…", + "Automatic": "Automatico", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automatico disegna la vista Dettaglio alla dimensione in cui è mostrata, fino ai pixel della foto. Scegli una dimensione per limitarla: più piccola è più veloce.", + "Automatically Write Changes into XMP": "Scrivi automaticamente le modifiche nell'XMP", + "Automatically write changes into XMP sidecars": "Scrivi automaticamente le modifiche nei file XMP collaterali", + "Autumn Gold": "Oro d'autunno", + "B&W": "B/N", + "B&W Mix": "Mix B/N", + "B&W Mixer": "Mixer B/N", + "Back": "Indietro", + "Back Up Library…": "Esegui backup della libreria…", + "Back to Edit": "Torna a Modifica", + "Back to Edit (E)": "Torna a Modifica (E)", + "Back to Grid": "Torna alla griglia", + "Background": "Sfondo", + "Balance": "Bilanciamento", + "Basic": "Base", + "Before": "Prima", + "Before/After (Y)": "Prima/Dopo (Y)", + "Before/After Settings": "Impostazioni Prima/Dopo", + "Before/After Split": "Prima/Dopo diviso", + "Before/After Split Top/Bottom": "Prima/Dopo diviso sopra/sotto", + "Before/After Top/Bottom": "Prima/Dopo sopra/sotto", + "Begin Interaction": "Inizia interazione", + "Best of Similar Shots": "La migliore tra scatti simili", + "Binary assets added": "Risorse binarie aggiunte", + "Binary assets removed": "Risorse binarie rimosse", + "Bit depth": "Profondità bit", + "Black & White": "Bianco e nero", + "Blacks": "Neri", + "Bleach Bypass": "Bleach bypass", + "Bleached": "Sbiancato", + "Blending": "Fusione", + "Blue": "Blu", + "Blue Hour Boost": "Ora blu potenziata", + "Blue Hue": "Tonalità blu", + "Blue Luminance": "Luminanza blu", + "Blue Primary": "Primario blu", + "Blue Saturation": "Saturazione blu", + "Blue channel": "Canale blu", + "Blue label": "Etichetta blu", + "Blue/Yellow Fringe": "Frange blu/gialle", + "Blurry photos below the score are flagged as rejects.": "Le foto mosse sotto il punteggio vengono contrassegnate come scartate.", + "Boundary Warp": "Deformazione dei bordi", + "Bright & Airy": "Luminoso e arioso", + "Bring All to Front": "Porta tutto in primo piano", + "Browse Folder": "Sfoglia cartella", + "Browse Folder…": "Sfoglia cartella…", + "Browse Profiles": "Sfoglia profili", + "Browse…": "Sfoglia…", + "Brush": "Pennello", + "Brush Settings": "Impostazioni pennello", + "Brush Stroke": "Tratto pennello", + "Build Previews": "Genera anteprime", + "Build Smart Previews": "Genera anteprime avanzate", + "By Date": "Per data", + "By day (YYYY/YYYY-MM-DD)": "Per giorno (AAAA/AAAA-MM-GG)", + "By month (YYYY/YYYY-MM)": "Per mese (AAAA/AAAA-MM)", + "CPU threads": "Thread della CPU", + "Calibration": "Calibrazione", + "Camera": "Fotocamera", + "Camera make and model": "Marca e modello della fotocamera", + "Cameras and Cards": "Fotocamere e schede", + "Cancel": "Annulla", + "Cancel AI Mask Model Download": "Annulla il download del modello IA per le maschere", + "Cancel Download": "Annulla download", + "Cancel Interaction": "Annulla interazione", + "Cancel Preview Build": "Annulla generazione anteprime", + "Candidate": "Candidata", + "Caption": "Didascalia", + "Capture Date": "Data di scatto", + "Capture date and time in your own format (directives below)": "Data e ora di scatto nel formato che preferisci (direttive qui sotto)", + "Capture date as YYYYMMDD": "Data di scatto come AAAAMMGG", + "Captured": "Scattata", + "Catalog Statistics": "Statistiche del catalogo", + "Catchlight": "Riflesso negli occhi", + "Change": "Cambia", + "Change Component Mode": "Cambia modalità del componente", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "I caratteri non ammessi nei nomi dei file (/ \\ : * ? \" < > |) diventano -; a un nome già esistente si aggiunge -1, -2….", + "Check All": "Seleziona tutto", + "Check Output Path": "Controlla il percorso di uscita", + "Check the files of this type": "Seleziona i file di questo tipo", + "Checking names…": "Controllo dei nomi…", + "Choose Another Library…": "Scegli un'altra libreria…", + "Choose Edit Settings to Copy": "Scegli le impostazioni di modifica da copiare", + "Choose Edit Settings to Copy…": "Scegli le impostazioni di modifica da copiare…", + "Choose Folder…": "Scegli cartella…", + "Choose a photo in the grid or filmstrip": "Scegli una foto nella griglia o nella striscia", + "Choose…": "Scegli…", + "Cinematic": "Cinematografico", + "City": "Città", + "Clarity": "Chiarezza", + "Clean Studio": "Studio pulito", + "Clear": "Cancella", + "Clear Cache": "Svuota cache", + "Clear Filters": "Cancella filtri", + "Clear Guides": "Cancella guide", + "Clear History": "Cancella cronologia", + "Clear Preview Cache": "Svuota cache delle anteprime", + "Clear Quick Collection": "Svuota la raccolta rapida", + "Clear Reference": "Cancella riferimento", + "Clear all": "Cancella tutto", + "Clear form": "Svuota il modulo", + "Clear rating": "Togli valutazione", + "Clear rating and advance": "Togli valutazione e avanza", + "Cleared History": "Cronologia cancellata", + "Click Zoom Ratio": "Rapporto di zoom al clic", + "Click a shortcut, then press the new keys (Esc cancels).": "Fai clic su una scorciatoia, poi premi i nuovi tasti (Esc annulla).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Fai clic su un tag per inserirlo. Gli esempi si riferiscono a IMG_0042.CR3 scattata il 14 gen 2026 alle 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Fai clic sulle foto nella griglia per aggiungere o togliere la parola chiave; Esc interrompe", + "Click the object to select it · ⌥-click leaves a part out": "Fai clic sull'oggetto per selezionarlo · ⌥-clic esclude una parte", + "Click the photo to pick a colour · ⇧-click adds more": "Fai clic sulla foto per scegliere un colore · ⇧-clic ne aggiunge altri", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Fai clic sulla foto per scegliere un colore; ⇧-clic ne aggiunge altri (fino a 5)", + "Click to clear these filters": "Fai clic per togliere questi filtri", + "Click to restore · double-click to rename": "Clic per ripristinare · doppio clic per rinominare", + "Clone": "Clona", + "Close": "Chiudi", + "Close Panel": "Chiudi pannello", + "Close Window": "Chiudi finestra", + "Close all": "Chiudi tutto", + "Close file": "Chiudi file", + "Close panel": "Chiudi pannello", + "Cloudy": "Nuvoloso", + "Collapse": "Comprimi", + "Collapse All Stacks": "Comprimi tutte le pile", + "Collapse Stack": "Comprimi pila", + "Color": "Colore", + "Color Grading": "Gradazione colore", + "Color Label": "Etichetta colore", + "Color Label Names": "Nomi delle etichette colore", + "Color Label Sets": "Set di etichette colore", + "Color Mixer": "Miscelatore colore", + "Color Noise Reduction": "Riduzione disturbo colore", + "Color Priority": "Priorità colore", + "Color Range": "Intervallo colori", + "Color label cleared": "Etichetta colore tolta", + "Color space": "Spazio colore", + "Color space: sRGB (AVIF)": "Spazio colore: sRGB (AVIF)", + "Colors": "Colori", + "Combine files…": "Combina file…", + "Command Journal": "Registro dei comandi", + "Comments panel": "Pannello commenti", + "Commits": "Commit", + "Company": "Azienda", + "Compare": "Confronta", + "Compare (Shift+C)": "Confronta (Maiusc+C)", + "Compare Before and After": "Confronta prima e dopo", + "Compare needs at least two photos": "Per confrontare servono almeno due foto", + "Component options": "Opzioni del componente", + "Compression": "Compressione", + "Concrete Cool": "Cemento freddo", + "Confirm before deleting": "Chiedi conferma prima di eliminare", + "Confirm before moving photos to Recently Deleted": "Chiedi conferma prima di spostare le foto in Eliminate di recente", + "Constrain Crop": "Vincola il ritaglio", + "Continue": "Continua", + "Continue Without Saving": "Continua senza salvare", + "Contrast": "Contrasto", + "Contributors": "Collaboratori", + "Convert to DNG": "Converti in DNG", + "Cool Fade": "Dissolvenza fredda", + "Cool Morning": "Mattino fresco", + "Copy": "Copia", + "Copy After's Settings to Before": "Copia le impostazioni di Dopo in Prima", + "Copy Before to After": "Copia Prima in Dopo", + "Copy Before's Settings to After": "Copia le impostazioni di Prima in Dopo", + "Copy Edit Settings": "Copia impostazioni di modifica", + "Copy History Step Settings to Before": "Copia le impostazioni del passaggio in Prima", + "Copy Metadata": "Copia metadati", + "Copy as DNG": "Copia come DNG", + "Copy into the library (else use the files where they are)": "Copia nella libreria (altrimenti usa i file dove si trovano)", + "Copy pages": "Copia pagine", + "Copy the files (into the library's Originals/, or a folder you choose)": "Copia i file (negli Originals/ della libreria, o in una cartella a tua scelta)", + "Copy to": "Copia in", + "Copy to Clipboard": "Copia negli appunti", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Copia: i file vengono copiati nella cartella qui sotto e la libreria usa le copie; gli originali restano come sono.", + "Copyright": "Copyright", + "Copyright Info URL": "URL informazioni copyright", + "Copyright Status": "Stato del copyright", + "Country": "Paese", + "Create Album": "Crea album", + "Create Album…": "Crea album…", + "Create Folder": "Crea cartella", + "Create Folder…": "Crea cartella…", + "Create New Mask": "Crea nuova maschera", + "Create PDF from clipboard": "Crea PDF dagli appunti", + "Create PDF from file…": "Crea PDF da file…", + "Create PDF from images…": "Crea PDF da immagini…", + "Create Preset": "Crea preset", + "Create Preset…": "Crea preset…", + "Create Proof Copy": "Crea copia di prova", + "Create Smart Album": "Crea album smart", + "Create Smart Album from Filter…": "Crea album smart dal filtro…", + "Create Smart Album…": "Crea album smart…", + "Create Stack": "Crea pila", + "Create Version": "Crea versione", + "Create Version from Step": "Crea versione dal passaggio", + "Create Virtual Copy": "Crea copia virtuale", + "Creator": "Autore", + "Creator metadata": "Metadati dell'autore", + "Crisp Landscape": "Paesaggio nitido", + "Crisp Vista": "Panorama nitido", + "Crop": "Ritaglia", + "Crop & Rotate": "Ritaglia e ruota", + "Crop & Rotate (C)": "Ritaglia e ruota (C)", + "Crop Aspect": "Proporzioni del ritaglio", + "Crop, Rotate, Geometry": "Ritaglio, rotazione, geometria", + "Cross Process": "Processo incrociato", + "Culling": "Selezione", + "Curve": "Curva", + "Custom": "Personalizzato", + "Custom template…": "Modello personalizzato…", + "Custom…": "Personalizzato…", + "Cut": "Taglia", + "Cut pages": "Taglia pagine", + "Cycle Crop Overlay": "Cambia sovrapposizione del ritaglio", + "Cycle Crop Overlay Orientation": "Cambia orientamento della sovrapposizione del ritaglio", + "Cycle Info Overlay": "Cambia sovrapposizione delle informazioni", + "Cycle Mask Overlay Color": "Cambia colore della sovrapposizione maschera", + "Cycle Mask Overlay Mode": "Cambia modalità della sovrapposizione maschera", + "Cylindrical": "Cilindrica", + "Dark & Moody": "Scuro e intenso", + "Darken": "Scurisci", + "Darks": "Toni scuri", + "Date": "Data", + "Date Groups": "Gruppi per data", + "Day": "Giorno", + "Daylight": "Luce diurna", + "Decrease Brush Feather": "Riduci sfumatura del pennello", + "Decrease Brush Size": "Riduci dimensione del pennello", + "Deep Sky": "Cielo profondo", + "Defringe": "Elimina frange", + "Deghost Amount": "Quantità di rimozione fantasmi", + "Dehaze": "Elimina foschia", + "Delete": "Elimina", + "Delete (⌫)": "Elimina (⌫)", + "Delete Album": "Elimina album", + "Delete All Masks": "Elimina tutte le maschere", + "Delete Color Label Set": "Elimina set di etichette colore", + "Delete Component": "Elimina componente", + "Delete Export Preset": "Elimina preset di esportazione", + "Delete Filter Preset": "Elimina preset di filtro", + "Delete Imported Profile": "Elimina profilo importato", + "Delete Keyword": "Elimina parola chiave", + "Delete Keyword Set": "Elimina set di parole chiave", + "Delete Mask": "Elimina maschera", + "Delete Metadata Preset": "Elimina preset di metadati", + "Delete Permanently": "Elimina definitivamente", + "Delete Photo": "Elimina foto", + "Delete Photos": "Elimina foto", + "Delete Point Color": "Elimina colore puntuale", + "Delete Point Color Sample": "Elimina campione colore puntuale", + "Delete Point Curve Preset": "Elimina preset di curva a punti", + "Delete Preset": "Elimina preset", + "Delete Red Eye": "Elimina occhi rossi", + "Delete Red Eye Correction": "Elimina correzione occhi rossi", + "Delete Spot": "Elimina macchia", + "Delete Version": "Elimina versione", + "Delete all": "Elimina tutto", + "Delete all spots": "Elimina tutte le macchie", + "Delete mask": "Elimina maschera", + "Delete them": "Eliminale", + "Density": "Densità", + "Depth Range": "Intervallo di profondità", + "Descending": "Decrescente", + "Describe": "Descrivi", + "Describe what to keep": "Descrivi cosa tenere", + "Describe what to leave out": "Descrivi cosa escludere", + "Describe what to select": "Descrivi cosa selezionare", + "Describe…": "Descrivi…", + "Deselect": "Deseleziona", + "Deselect All": "Deseleziona tutto", + "Desert Heat": "Calore del deserto", + "Desert Warmth": "Tepore del deserto", + "Desktop": "Scrivania", + "Detail": "Dettaglio", + "Detail (D)": "Dettaglio (D)", + "Details": "Dettagli", + "Diagonal": "Diagonale", + "Discard Preview Cache": "Elimina cache delle anteprime", + "Display P3": "Display P3", + "Display name": "Nome visualizzato", + "Display theme": "Tema di visualizzazione", + "Distortion": "Distorsione", + "Document properties…": "Proprietà documento…", + "Don't enlarge": "Non ingrandire", + "Done": "Fine", + "Download": "Scarica", + "Download AI Mask Model": "Scarica il modello IA per le maschere", + "Download the SAM 3 Model?": "Scaricare il modello SAM 3?", + "Downloads": "Download", + "Drag along the horizon; double-click for Auto": "Trascina lungo l'orizzonte; doppio clic per Automatico", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Trascina sopra un occhio nella foto; la pupilla all'interno viene trovata da sola.", + "Draw Guides": "Disegna guide", + "Duplicate": "Duplica", + "Duplicate Component": "Duplica componente", + "Duplicate Mask": "Duplica maschera", + "Duplicate and Invert Mask": "Duplica e inverti maschera", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Ogni / apre un livello di cartella; i tag vengono compilati per ogni foto (un livello senza metadati diventa \"sconosciuto\").", + "Edge": "Bordo", + "Edit": "Modifica", + "Edit (E)": "Modifica (E)", + "Edit Capture Time": "Modifica ora di scatto", + "Edit Capture Time…": "Modifica ora di scatto…", + "Edit Color Label Names": "Modifica nomi delle etichette colore", + "Edit Color Label Names…": "Modifica nomi delle etichette colore…", + "Edit Copy for External Editor": "Modifica copia nell'editor esterno", + "Edit Date": "Modifica data", + "Edit Info": "Modifica informazioni", + "Edit Label Names": "Modifica nomi delle etichette", + "Edit Label Names…": "Modifica nomi delle etichette…", + "Edit Mask": "Modifica maschera", + "Edit Mask Component": "Modifica componente della maschera", + "Edit Sections": "Modifica sezioni", + "Edit Smart Album": "Modifica album smart", + "Edit Smart Album…": "Modifica album smart…", + "Edit Spot": "Modifica macchia", + "Edit in External Editor": "Modifica nell'editor esterno", + "Edit settings copied": "Impostazioni di modifica copiate", + "Edited": "Modificata", + "Edited or not": "Modificata o no", + "Editing": "Modifica", + "Effect": "Effetto", + "Effects": "Effetti", + "Empty Recently Deleted": "Svuota Eliminate di recente", + "Enable Lens Corrections": "Attiva correzioni obiettivo", + "Enable Profile Corrections": "Attiva correzioni del profilo", + "End Interaction": "Termina interazione", + "English": "Inglese", + "Enter Full Screen": "Schermo intero", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Inserisci una data come AAAA-MM-GG HH:MM:SS", + "Erase": "Cancella", + "Every EXIF, GPS and XMP field in the file": "Ogni campo EXIF, GPS e XMP del file", + "Existing previews": "Anteprime esistenti", + "Exit": "Esci", + "Expand": "Espandi", + "Expand All Stacks": "Espandi tutte le pile", + "Expand Stack": "Espandi pila", + "Expand/Collapse Stack": "Espandi/comprimi pila", + "Export": "Esporta", + "Export Album with Preset": "Esporta album con preset", + "Export Album…": "Esporta album…", + "Export As…": "Esporta come…", + "Export Group…": "Esporta gruppo…", + "Export Now": "Esporta ora", + "Export Point Curve Presets": "Esporta preset di curva a punti", + "Export Point Curve Presets…": "Esporta preset di curva a punti…", + "Export Presets": "Esporta predefiniti", + "Export Presets…": "Esporta preset…", + "Export User Presets…": "Esporta preset utente…", + "Export stopped unexpectedly": "L'esportazione si è interrotta in modo imprevisto", + "Export with Preset": "Esporta con preset", + "Export with Previous": "Esporta con le impostazioni precedenti", + "Exporting": "Esportazione in corso", + "Export…": "Esporta…", + "Exposure": "Tempo di esposizione", + "Exposure (shutter · aperture · ISO)": "Esposizione (tempo · diaframma · ISO)", + "Extended Description": "Descrizione estesa", + "External Editor": "Editor esterno", + "Face Boxes": "Riquadri dei volti", + "Face boxes": "Riquadri dei volti", + "Face names written to XMP by Lightroom and other apps show up here.": "Qui compaiono i nomi dei volti scritti nell'XMP da Lightroom e da altre app.", + "Faded Matte": "Opaco sbiadito", + "Faded Slide": "Diapositiva sbiadita", + "Faded Urban": "Città sbiadita", + "Favorite Preset": "Preset preferito", + "Favorite Profile": "Profilo preferito", + "Favorites": "Preferiti", + "Feather": "Sfumatura", + "File": "File", + "File & date": "File e data", + "File Format": "Formato file", + "File Name": "Nome file", + "File Path": "Percorso del file", + "File Size": "Dimensione del file", + "File Type": "Tipo di file", + "File name": "Nome file", + "File types:": "Tipi di file:", + "Filename": "Nome file", + "Fill": "Riempi", + "Fill Edges": "Riempi i bordi", + "Film": "Pellicola", + "Filmstrip": "Striscia", + "Filmstrip (/)": "Striscia (/)", + "Filter": "Filtro", + "Filter Bar": "Barra dei filtri", + "Filter Presets": "Preset di filtro", + "Filter bar": "Barra dei filtri", + "Filter fields": "Campi del filtro", + "Find Dust Spots": "Trova macchie di polvere", + "Find Missing Photos": "Trova foto mancanti", + "Find Missing Photos…": "Trova foto mancanti…", + "Find Similar Photos": "Trova foto simili", + "Find tools and commands…": "Trova strumenti e comandi…", + "Find…": "Trova…", + "First": "Primo", + "First commit": "Primo commit", + "Fit": "Adatta", + "Fit (F)": "Adatta (F)", + "Fit on Screen": "Adatta allo schermo", + "Fit to width": "Adatta alla larghezza", + "Fit visible": "Adatta all'area visibile", + "Five stars": "Cinque stelle", + "Fixed keys": "Tasti fissi", + "Flag": "Bandiera", + "Flag as Pick": "Contrassegna come scelta", + "Flag as Reject": "Contrassegna come scartata", + "Flagged as Pick": "Contrassegnata come scelta", + "Flagged as Reject": "Contrassegnata come scartata", + "Flash": "Flash", + "Flip H": "Capovolgi O", + "Flip Horizontal": "Capovolgi orizzontalmente", + "Flip Overlay (⇧O)": "Capovolgi sovrapposizione (⇧O)", + "Flip V": "Capovolgi V", + "Flip Vertical": "Capovolgi verticalmente", + "Float panel": "Rendi pannello flottante", + "Flow": "Flusso", + "Fluorescent": "Fluorescente", + "Focal Length": "Lunghezza focale", + "Focal length": "Lunghezza focale", + "Focus (assisted culling)": "Messa a fuoco (selezione assistita)", + "Folder": "Cartella", + "Folder name (renamed on disk; its photos follow)": "Nome della cartella (rinominata sul disco; le sue foto la seguono)", + "Folders": "Cartelle", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Le cartelle e {date} usano l'ora di scatto; una foto che non ce l'ha usa la data di oggi.", + "Food": "Cibo", + "Forget Unchanged Local Photos": "Dimentica le foto locali non modificate", + "Forget unchanged photos": "Dimentica le foto non modificate", + "Form fields panel": "Pannello campi modulo", + "Format": "Formato", + "Forward": "Avanti", + "Frame time": "Tempo per fotogramma", + "Frame update": "Aggiornamento del fotogramma", + "Free": "Libero", + "Fresh & Bright": "Fresco e luminoso", + "Friday": "Venerdì", + "Full": "Completo", + "Full Screen Preview": "Anteprima a schermo intero", + "Lights Out": "Luci spente", + "Full screen mode": "Modalità schermo intero", + "Full size": "Dimensione piena", + "GPS Track Log": "Tracciato GPS", + "GPU Rendering": "Rendering con GPU", + "GPU rendering": "Rendering con GPU", + "General": "Generale", + "Generated demo photo": "Foto dimostrativa generata", + "Geometry": "Geometria", + "Get Develop Settings": "Ottieni impostazioni di sviluppo", + "Global Hue": "Tonalità globale", + "Global Luminance": "Luminanza globale", + "Global Saturation": "Saturazione globale", + "Glossy": "Lucido", + "Go to History Step": "Vai al passaggio della cronologia", + "Golden": "Dorato", + "Golden Glow": "Bagliore dorato", + "Golden Hour": "Ora d'oro", + "Grab bag": "Varie", + "Grain": "Grana", + "Graphic": "Grafica", + "Green": "Verde", + "Green Amount": "Quantità verde", + "Green Hue": "Tonalità verde", + "Green Hue High": "Tonalità verde alta", + "Green Hue Low": "Tonalità verde bassa", + "Green Luminance": "Luminanza verde", + "Green Primary": "Primario verde", + "Green Saturation": "Saturazione verde", + "Green channel": "Canale verde", + "Green label": "Etichetta verde", + "Grid": "Griglia", + "Grid Info": "Informazioni della griglia", + "Gritty Street": "Strada grintosa", + "Group": "Gruppo", + "Group by Date: Automatic": "Raggruppa per data: automatico", + "Group by Day": "Raggruppa per giorno", + "Group by Month": "Raggruppa per mese", + "Group by Year": "Raggruppa per anno", + "Group by date": "Raggruppa per data", + "Group into Stack": "Raggruppa in una pila", + "Group name": "Nome gruppo", + "Guided": "Guidato", + "Guided Upright": "Raddrizzamento guidato", + "Guided Upright Guides": "Guide del raddrizzamento guidato", + "HDR Merge": "Unione HDR", + "HDR Merge Preview": "Anteprima unione HDR", + "HDR Panorama Merge": "Unione panorama HDR", + "HDR Panorama Merge Preview": "Anteprima unione panorama HDR", + "HDR Panorama with Last Settings": "Panorama HDR con le ultime impostazioni", + "HDR Panoramas": "Panorami HDR", + "HDR Panorama…": "Panorama HDR…", + "HDR panorama": "Panorama HDR", + "HDR panoramas": "Panorami HDR", + "HDR with Last Settings": "HDR con le ultime impostazioni", + "HDR…": "HDR…", + "Has Edits": "Con modifiche", + "Has GPS": "Con GPS", + "Heal": "Correggi", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Correggi le piccole macchie scure e sfumate che la polvere del sensore lascia sulle zone uniformi", + "Height": "Altezza", + "Height (px)": "Altezza (px)", + "Help": "Aiuto", + "Hide LightCraft": "Nascondi LightCraft", + "Hide Mask": "Nascondi maschera", + "Hide Others": "Nascondi altri", + "Hides this shortcut only; the folder and its photos stay as they are": "Nasconde solo questa scorciatoia; la cartella e le sue foto restano come sono", + "High": "Alta", + "High Contrast B&W": "B/N ad alto contrasto", + "Highlight": "Luci", + "Highlights": "Luci", + "Highlights Hue": "Tonalità delle luci", + "Highlights Luminance": "Luminanza delle luci", + "Highlights Saturation": "Saturazione delle luci", + "Highlights split": "Divisione delle luci", + "Histogram": "Istogramma", + "History": "Cronologia", + "History & Activity (Y)": "Cronologia e attività (Y)", + "Home": "Cartella personale", + "Horizontal": "Orizzontale", + "Horizontal inset (% of short edge)": "Margine orizzontale (% del lato corto)", + "Horizontal text": "Testo orizzontale", + "Hours": "Ore", + "How to install the model": "Come installare il modello", + "Hue": "Tonalità", + "Hue Range": "Intervallo di tonalità", + "ISO Speed": "Sensibilità ISO", + "ISO speed": "Sensibilità ISO", + "If file exists": "Se il file esiste", + "Image": "Immagine", + "Import": "Importa", + "Import Date": "Data di importazione", + "Import Lightroom Catalog": "Importa catalogo Lightroom", + "Import Lightroom Catalog…": "Importa catalogo Lightroom…", + "Import Photos": "Importa foto", + "Import Photos…": "Importa foto…", + "Import Point Curve Presets": "Importa preset di curva a punti", + "Import Point Curve Presets…": "Importa preset di curva a punti…", + "Import Presets": "Importa predefiniti", + "Import Presets…": "Importa preset…", + "Import Profiles": "Importa profili", + "Import Profiles & Presets…": "Importa profili e preset…", + "Import as new": "Importa come nuova", + "Import from Device": "Importa da dispositivo", + "Import from Folder…": "Importa da cartella…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importa le foto con File → Importa foto… (Cmd+Maiusc+I), oppure trascinale qui", + "Imported": "Importato", + "Imported Photos": "Foto importate", + "Importing": "Importazione in corso", + "Importing Lightroom Catalog": "Importazione del catalogo Lightroom", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "L'importazione cerca le foto nell'origine; non aggiunge la cartella a Locale (usa Locale → Sfoglia cartella… per lavorare in una cartella senza importare).", + "In Photo Grid and Square Grid: Flag as Pick and advance": "In Griglia foto e Griglia quadrata: contrassegna come scelta e avanza", + "In Photo Grid and Square Grid: expand or collapse the stack": "In Griglia foto e Griglia quadrata: espandi o comprimi la pila", + "In Recently Deleted": "In Eliminate di recente", + "In library": "Nella libreria", + "In memory — nothing is saved": "In memoria: non viene salvato niente", + "In use": "In uso", + "In-memory session: these settings last until LightCraft quits.": "Sessione in memoria: queste impostazioni durano finché LightCraft resta aperto.", + "Include subfolders": "Includi sottocartelle", + "Increase Brush Feather": "Aumenta sfumatura del pennello", + "Increase Brush Size": "Aumenta dimensione del pennello", + "Info": "Informazioni", + "Info (I)": "Informazioni (I)", + "Info Overlay Off": "Sovrapposizione informazioni disattivata", + "Info Overlay: Exposure": "Sovrapposizione informazioni: esposizione", + "Info Overlay: File": "Sovrapposizione informazioni: file", + "Info overlay": "Sovrapposizione informazioni", + "Insert at the cursor": "Inserisci al cursore", + "Inspect Lightroom Catalog": "Esamina catalogo Lightroom", + "Inspect Photo": "Esamina foto", + "Inspecting Lightroom Catalog": "Analisi del catalogo Lightroom", + "Install the SAM 3 Model": "Installa il modello SAM 3", + "Instant ’70s": "Istantanea anni '70", + "Interface": "Interfaccia", + "Interface language": "Lingua dell'interfaccia", + "Intersect": "Intersezione", + "Intersect with": "Interseca con", + "Into one folder": "In un'unica cartella", + "Invert": "Inverti", + "Invert Component": "Inverti componente", + "Invert Mask": "Inverti maschera", + "JPEG (Large)": "JPEG (grande)", + "JPEG (Small)": "JPEG (piccolo)", + "Join the ArtCraft Discord": "Unisciti al Discord di ArtCraft", + "Join the ArtCraft Discord…": "Unisciti al Discord di ArtCraft…", + "Join the ArtCraft community on Discord": "Unisciti alla community di ArtCraft su Discord", + "Keep in Local": "Tieni in Locale", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Tienila su un disco con spazio libero: le anteprime avanzate occupano circa 1 MB per foto. La cache delle miniature resta nella libreria.", + "Keyboard Shortcuts": "Scorciatoie da tastiera", + "Keyboard shortcuts": "Scorciatoie da tastiera", + "Keyword": "Parola chiave", + "Keyword Painter": "Pennello parole chiave", + "Keyword Set": "Set di parole chiave", + "Keyword Sets": "Set di parole chiave", + "Keyword Suggestions": "Suggerimenti di parole chiave", + "Keyword to paint": "Parola chiave da applicare", + "Keywords": "Parole chiave", + "Keywords (K)": "Parole chiave (K)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Le parole chiave che aggiungi compaiono qui; ⌥1-⌥9 le applicano.", + "Label": "Etichetta", + "Landscape": "Paesaggio", + "Language": "Lingua", + "Last": "Ultimo", + "Last GPU fallback": "Ultimo ripiego dalla GPU", + "Last commit": "Ultimo commit", + "Last loupe render": "Ultimo rendering della lente", + "Last view": "Ultima vista", + "Lateral Chromatic Aberration": "Aberrazione cromatica laterale", + "Layer": "Livello", + "Layers": "Livelli", + "Leave them": "Lasciale", + "Lens": "Obiettivo", + "Lens Blur": "Sfocatura obiettivo", + "Lens Corrections": "Correzioni obiettivo", + "Level": "Livello", + "Library": "Libreria", + "Library Info": "Informazioni sulla libreria", + "Library Originals": "Originali della libreria", + "Library Preferences": "Preferenze della libreria", + "Library State": "Stato della libreria", + "Library saved": "Libreria salvata", + "Licence:": "Licenza:", + "Light": "Luce", + "LightCraft Default": "Predefinito di LightCraft", + "LightCraft Help": "Aiuto di LightCraft", + "LightCraft Preset": "Preset di LightCraft", + "LightCraft Presets": "Preset di LightCraft", + "LightCraft Website": "Sito web di LightCraft", + "LightCraft could not start": "Impossibile avviare LightCraft", + "LightCraft couldn't find where to keep your library.": "LightCraft non ha trovato dove salvare la tua libreria.", + "LightCraft couldn't open the library at": "LightCraft non è riuscito ad aprire la libreria in", + "LightCraft on GitHub": "LightCraft su GitHub", + "LightCraft website": "Sito web di LightCraft", + "LightCraft — Second Window": "LightCraft: seconda finestra", + "Lightroom Classic Catalog": "Catalogo di Lightroom Classic", + "Lightroom import cancelled": "Importazione da Lightroom annullata", + "Lightroom inspection cancelled": "Analisi di Lightroom annullata", + "Lights": "Toni chiari", + "Limit file size (KB, 0 = off)": "Limita dimensione del file (KB, 0 = nessun limite)", + "Line delta": "Differenza righe", + "Linear": "Lineare", + "Linear Gradient": "Sfumatura lineare", + "Lines +/−": "Righe +/−", + "Lines added": "Righe aggiunte", + "Lines deleted": "Righe eliminate", + "List Albums": "Elenca album", + "List Develop Controls": "Elenca controlli di sviluppo", + "List History": "Elenca cronologia", + "List Presets": "Elenca preset", + "List Profiles": "Elenca profili", + "List this folder in Local from now on": "Mostra d'ora in poi questa cartella in Locale", + "Loading preview…": "Caricamento anteprima…", + "Local": "Locale", + "Local folders": "Cartelle locali", + "Local library — no cloud account needed": "Libreria locale: non serve un account cloud", + "Locate Missing File…": "Trova file mancante…", + "Locate Photo": "Trova foto", + "Location": "Posizione", + "Lock": "Blocca", + "Lock Crop Aspect": "Blocca proporzioni del ritaglio", + "Locked": "Bloccato", + "Long Edge": "Lato lungo", + "Looking for photos…": "Ricerca delle foto…", + "Lossless": "Senza perdita", + "Low": "Bassa", + "Luminance": "Luminanza", + "Luminance Range": "Intervallo di luminanza", + "Lush Greens": "Verdi rigogliosi", + "Magenta": "Magenta", + "Magenta Hue": "Tonalità magenta", + "Magenta Luminance": "Luminanza magenta", + "Magenta Saturation": "Saturazione magenta", + "Make Candidate the Select": "Rendi la candidata quella scelta", + "Manual": "Manuale", + "Marquee zoom": "Ingrandimento selettivo", + "Mask Adjustment": "Regolazione della maschera", + "Masking": "Mascheratura", + "Masking (M)": "Maschere (M)", + "Masks": "Maschere", + "Match": "Corrispondenza", + "Match Total Exposures": "Uniforma le esposizioni totali", + "Matte": "Opaca", + "Matte Soft": "Opaco morbido", + "Med": "Medio", + "Medium Contrast": "Contrasto medio", + "Megapixels": "Megapixel", + "Memory Budget": "Memoria disponibile", + "Memory Usage": "Uso della memoria", + "Memory budget": "Memoria disponibile", + "Memory for caches": "Memoria per le cache", + "Menu": "Menu", + "Merge": "Unisci", + "Merge Keywords": "Unisci parole chiave", + "Merge into…": "Unisci in…", + "Merged": "Unita", + "Merged PRs": "PR unite", + "Merging…": "Unione in corso…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Sono condizioni di Meta, non di LightCraft. Scaricandolo le accetti.", + "Metadata": "Metadati", + "Metadata Preset": "Preset di metadati", + "Metadata Presets": "Preset di metadati", + "Metadata preset": "Preset di metadati", + "Midpoint": "Punto medio", + "Midtones": "Mezzi toni", + "Midtones Hue": "Tonalità dei mezzitoni", + "Midtones Luminance": "Luminanza dei mezzitoni", + "Midtones Saturation": "Saturazione dei mezzitoni", + "Midtones split": "Divisione dei mezzitoni", + "Minimize": "Riduci a icona", + "Missing Photos": "Foto mancanti", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "I metadati mancanti ({camera}, {title}…) lasciano uno spazio vuoto; un nome che risulta vuoto mantiene il nome originale.", + "Misty Morning": "Mattino nebbioso", + "Mode": "Metodo", + "Model": "Modello", + "Models": "Modelli", + "Modified Date": "Data di modifica", + "Monday": "Lunedì", + "Mono Red Filter": "Mono con filtro rosso", + "Mono Rich": "Mono ricco", + "Mono Soft": "Mono morbido", + "Monochrome": "Monocromia", + "Month": "Mese", + "Moody": "Cupo", + "Moody Portrait": "Ritratto cupo", + "More": "Altro", + "More preset options": "Altre opzioni del preset", + "Move": "Sposta", + "Move Album": "Sposta album", + "Move Down": "Sposta giù", + "Move Down in Stack": "Sposta giù nella pila", + "Move Folder": "Sposta cartella", + "Move Folder To…": "Sposta cartella in…", + "Move Mask": "Sposta maschera", + "Move Preset to Group": "Sposta preset nel gruppo", + "Move Preset to New Group": "Sposta preset in un nuovo gruppo", + "Move Up": "Sposta su", + "Move Up in Stack": "Sposta su nella pila", + "Move in Stack": "Sposta nella pila", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Sposta i file (negli Originals/ della libreria, o in una cartella a tua scelta), togliendoli dall'origine", + "Move them": "Spostale", + "Move to": "Sposta in", + "Move to Group": "Sposta nel gruppo", + "Move to group": "Sposta nel gruppo", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Sposta: i file vengono spostati nella cartella qui sotto; ogni originale (con il suo XMP collaterale) viene tolto dall'origine solo dopo aver verificato la copia. I duplicati e i file con errori restano dove sono.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Sposta in Eliminate di recente ogni foto importata da questo disco; nessun file viene toccato", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Sposta in Eliminate di recente le foto importate da questa cartella; nessun file viene toccato", + "Muted": "Attenuato", + "Muted Film": "Pellicola attenuata", + "My Photos": "Le mie foto", + "My Photos Panel": "Pannello Le mie foto", + "Name": "Nome", + "Name (A–Z)": "Nome (A-Z)", + "Name of the folder the original is in": "Nome della cartella in cui si trova l'originale", + "Named": "Con nome", + "Named People": "Persone con nome", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "I nomi compaiono nel menu delle etichette, nella barra dei filtri e nel pannello Informazioni, e vengono scritti nell'XMP. Vuoto = il nome del colore.", + "Navigator": "Navigatore", + "Needs Work": "Da rivedere", + "Neon Dusk": "Tramonto al neon", + "Neon Night": "Notte al neon", + "Nested group": "Gruppo annidato", + "Neutral": "Neutro", + "Never": "Mai", + "New": "Nuovo", + "New Album": "Nuovo album", + "New Album…": "Nuovo album…", + "New Folder…": "Nuova cartella…", + "New Group…": "Nuovo gruppo…", + "New Smart Album": "Nuovo album smart", + "New Smart Album from Filter…": "Nuovo album smart dal filtro…", + "New Smart Album…": "Nuovo album smart…", + "New album": "Nuovo album", + "New album…": "Nuovo album…", + "New blank PDF": "Nuovo PDF vuoto", + "New name": "Nuovo nome", + "New time": "Nuova ora", + "New…": "Nuovo…", + "Next Photo": "Foto successiva", + "Night Blue": "Blu notte", + "No": "No", + "No Camera or Card Found": "Nessuna fotocamera o scheda trovata", + "No Date Groups": "Nessun raggruppamento per data", + "No Stars": "Nessuna stella", + "No Tool": "Nessuno strumento", + "No automatic versions.": "Nessuna versione automatica.", + "No camera": "Nessuna fotocamera", + "No contributor data was built into this copy.": "Questa copia non include dati sui collaboratori.", + "No date": "Nessuna data", + "No dust spots found": "Nessuna macchia di polvere trovata", + "No edits yet.": "Ancora nessuna modifica.", + "No exposure information": "Nessuna informazione di esposizione", + "No filter presets yet": "Ancora nessun preset di filtro", + "No lens data embedded in this file (DNG lens opcodes only).": "In questo file non ci sono dati dell'obiettivo (solo opcode obiettivo DNG).", + "No masks yet. Choose a mask type above.": "Ancora nessuna maschera. Scegli un tipo di maschera qui sopra.", + "No matching commands": "Nessun comando corrispondente", + "No matching photos": "Nessuna foto corrispondente", + "No metadata found": "Nessun metadato trovato", + "No model credits were built into this copy.": "Questa copia non include i crediti dei modelli.", + "No named people in these photos": "In queste foto non ci sono persone con nome", + "No named people yet": "Ancora nessuna persona con nome", + "No new presets": "Nessun nuovo preset", + "No photo selected": "Nessuna foto selezionata", + "No photos": "Nessuna foto", + "No photos found": "Nessuna foto trovata", + "No photos in this folder": "Nessuna foto in questa cartella", + "No photos match the active filters": "Nessuna foto corrisponde ai filtri attivi", + "No photos match the filters (View → Clear Filters)": "Nessuna foto corrisponde ai filtri (Visualizza → Cancella filtri)", + "No raw photos yet: cameras appear here once their photos are in the library.": "Ancora nessuna foto raw: le fotocamere compaiono qui quando le loro foto sono nella libreria.", + "No reference photo": "Nessuna foto di riferimento", + "No usable GPU found: rendering on the CPU": "Nessuna GPU utilizzabile: rendering sulla CPU", + "No versions yet. Create one to keep this look.": "Ancora nessuna versione. Creane una per conservare questo aspetto.", + "Noise": "Disturbo", + "Noise Reduction": "Riduzione disturbo", + "Non-raw photos": "Foto non raw", + "None": "Nessuno", + "Not Now": "Non ora", + "Nothing to compare": "Niente da confrontare", + "Nothing to survey": "Niente da esaminare", + "Nudge Develop Value": "Sposta di poco un valore di sviluppo", + "OK": "OK", + "Object": "Oggetto", + "Object Mask": "Maschera oggetto", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Le maschere Oggetto e Descrivi usano SAM 3, il modello di segmentazione di Meta. Non fa parte di LightCraft, e tutto il resto funziona anche senza.", + "October 2026": "Ottobre 2026", + "Off": "Disattivato", + "Offset X": "Spostamento X", + "Offset Y": "Spostamento Y", + "Opacity": "Opacità", + "Opacity (%)": "Opacità (%)", + "Open Library": "Apri libreria", + "Open Library…": "Apri libreria…", + "Open in": "Apri in", + "Open in Detail": "Apri in Dettaglio", + "Open the place in OpenStreetMap": "Apri il luogo in OpenStreetMap", + "Open…": "Apri…", + "Optics": "Ottica", + "Optimize Library": "Ottimizza libreria", + "Optional name": "Nome facoltativo", + "Options": "Opzioni", + "Orange": "Arancione", + "Orange Hue": "Tonalità arancione", + "Orange Luminance": "Luminanza arancione", + "Orange Saturation": "Saturazione arancione", + "Orientation": "Orientamento", + "Original": "Originale", + "Original + Settings": "Originale + impostazioni", + "Original extension without the dot": "Estensione originale senza il punto", + "Original file name without its extension": "Nome del file originale senza estensione", + "Original files": "File originali", + "Original offline · editing the smart preview": "Originale non disponibile · si modifica l'anteprima avanzata", + "Original offline · no smart preview": "Originale non disponibile · nessuna anteprima avanzata", + "Originals": "Originali", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Altre immagini (JPEG, PNG, TIFF, HEIC…)", + "Overlay": "Sovrapponi", + "Overwrite": "Sovrascrivi", + "Pages": "Pagine", + "Paint": "Dipingi", + "Paint only areas like the one under the brush": "Dipingi solo le zone simili a quella sotto il pennello", + "Paint over a distraction on the photo to remove it.": "Dipingi sopra un elemento di disturbo nella foto per rimuoverlo.", + "Paint overlay": "Sovrapposizione della pittura", + "Panels": "Pannelli", + "Panorama": "Panorama", + "Panorama Merge": "Unione panorama", + "Panorama Merge Preview": "Anteprima unione panorama", + "Panorama with Last Settings": "Panorama con le ultime impostazioni", + "Panoramas": "Panorami", + "Panorama…": "Panorama…", + "Parametric curve": "Curva parametrica", + "Paste": "Incolla", + "Paste Edit Settings": "Incolla impostazioni di modifica", + "Paste Metadata": "Incolla metadati", + "Paste Selected Settings": "Incolla impostazioni selezionate", + "Paste Selected Settings…": "Incolla impostazioni selezionate…", + "Paste Settings": "Incolla impostazioni", + "Paste Settings from Previous": "Incolla impostazioni dalla precedente", + "Paste from Previous": "Incolla dalla precedente", + "Paste pages": "Incolla pagine", + "Pastel Haze": "Foschia pastello", + "People": "Persone", + "Percent": "Percentuale", + "Percentage": "Percentuale", + "Performance": "Prestazioni", + "Person": "Persona", + "Perspective": "Prospettiva", + "Pet Eye": "Occhi degli animali", + "Pet Eye Catchlight": "Riflesso negli occhi degli animali", + "Photo": "Foto", + "Photo Grid": "Griglia foto", + "Photo Grid (G)": "Griglia foto (G)", + "Photo Merge": "Unione di foto", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → Modifica nell'editor esterno (⇧⌘E) genera una copia TIFF a 16 bit, la mette in pila con l'originale e la apre qui (il nome di un'app su macOS, il percorso di un programma altrove).", + "Photos": "Foto", + "Photos per bracket (0 = auto)": "Foto per bracketing (0 = automatico)", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Le foto viste in Locale ma mai aggiunte o modificate escono dal catalogo quando la loro cartella non viene sfogliata da questo tempo (controllo all'apertura della libreria). I file restano sul disco; sfogliando la cartella ricompaiono.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Le foto che arrivano in questa cartella (scatto collegato, scanner, app di sincronizzazione) vengono aggiunte appena sono complete.", + "Pick": "Scelta", + "Pick Flag": "Contrassegno scelta", + "Pick a colour on the photo with the eyedropper.": "Scegli un colore sulla foto con il contagocce.", + "Pick the sharpest photo of each burst": "Scegli la foto più nitida di ogni raffica", + "Picked": "Scelta", + "Picks": "Scelte", + "Pictures": "Immagini", + "Pixels": "Pixel", + "Point Color": "Colore puntuale", + "Point Curve": "Curva a punti", + "Point Curve Presets": "Preset di curva a punti", + "Point curve": "Curva a punti", + "Portrait": "Ritratto", + "Position": "Posizione", + "Preferences": "Preferenze", + "Preferences…": "Preferenze…", + "Prepare AI Masks": "Prepara le maschere IA", + "Preset": "Predefinito", + "Preset name": "Nome del preset", + "Presets": "Predefiniti", + "Presets & Profiles": "Preset e profili", + "Presets (Shift+P)": "Preset (Maiusc+P)", + "Presets and profiles": "Preset e profili", + "Presets ▾": "Preset ⌄", + "Press keys…": "Premi tasti…", + "Preview": "Anteprima", + "Preview Build Progress": "Avanzamento della generazione anteprime", + "Preview only": "Solo anteprima", + "Preview size": "Dimensione dell'anteprima", + "Preview unavailable": "Anteprima non disponibile", + "Previews": "Anteprime", + "Previous Photo": "Foto precedente", + "Print": "Stampa", + "Print…": "Stampa…", + "Profile": "Profilo", + "Profile Browser": "Browser dei profili", + "Profile Distortion": "Distorsione del profilo", + "Profile Menu": "Menu dei profili", + "Profile Vignetting": "Vignettatura del profilo", + "Profile:": "Profilo:", + "Profiles": "Profili", + "Projection": "Proiezione", + "Proof copy created": "Copia di prova creata", + "Properties": "Proprietà", + "Public domain": "Pubblico dominio", + "Pupil Size": "Dimensione pupilla", + "Purple": "Viola", + "Purple Amount": "Quantità viola", + "Purple Hue": "Tonalità viola", + "Purple Hue High": "Tonalità viola alta", + "Purple Hue Low": "Tonalità viola bassa", + "Purple Luminance": "Luminanza viola", + "Purple Saturation": "Saturazione viola", + "Put the locations you removed from Local back (no files change)": "Rimetti in Locale le posizioni che avevi tolto (nessun file cambia)", + "Quality": "Qualità", + "Query Photos": "Interroga le foto", + "Quick Collection": "Raccolta rapida", + "Quick Develop": "Sviluppo rapido", + "Quiet Green": "Verde quieto", + "Quit": "Esci", + "Quit Anyway": "Esci comunque", + "Quit LightCraft": "Esci da LightCraft", + "Quit with unsaved changes?": "Uscire con modifiche non salvate?", + "RAW decoding": "Decodifica RAW", + "Radial": "Radiale", + "Radial Gradient": "Sfumatura radiale", + "Radius": "Raggio", + "Random": "Casuale", + "Range": "Intervallo", + "Rate ★": "Valuta ★", + "Rate ★ and advance": "Valuta ★ e avanza", + "Rate ★★": "Valuta ★★", + "Rate ★★ and advance": "Valuta ★★ e avanza", + "Rate ★★★": "Valuta ★★★", + "Rate ★★★ and advance": "Valuta ★★★ e avanza", + "Rate ★★★★": "Valuta ★★★★", + "Rate ★★★★ and advance": "Valuta ★★★★ e avanza", + "Rate ★★★★★": "Valuta ★★★★★", + "Rate ★★★★★ and advance": "Valuta ★★★★★ e avanza", + "Rating": "Valutazione", + "Rating cleared": "Valutazione tolta", + "Ratings & flags": "Valutazioni e contrassegni", + "Raw": "Raw", + "Raw default": "Predefinito raw", + "Raw defaults": "Predefiniti raw", + "Raw files": "File raw", + "Raw photos": "Foto raw", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Le foto raw come DNG, con le modifiche incorporate. Le opzioni di dimensione, colore e uscita non si applicano.", + "Read Metadata from File": "Leggi i metadati dal file", + "Read mode": "Modalità lettura", + "Read the SAM License": "Leggi la licenza SAM", + "Reading": "Lettura", + "Reading Lightroom catalog…": "Lettura del catalogo Lightroom…", + "Reading folder…": "Lettura della cartella…", + "Real name": "Nome reale", + "Recent": "Recenti", + "Recent Keywords": "Parole chiave recenti", + "Recently Added": "Aggiunte di recente", + "Recently Deleted": "Eliminate di recente", + "Red": "Rosso", + "Red Eye": "Occhi rossi", + "Red Hue": "Tonalità rosso", + "Red Luminance": "Luminanza rosso", + "Red Primary": "Primario rosso", + "Red Saturation": "Saturazione rosso", + "Red channel": "Canale rosso", + "Red label": "Etichetta rossa", + "Red/Cyan Fringe": "Frange rosse/ciano", + "Redo": "Ripeti", + "Reference": "Riferimento", + "Reference View": "Vista di riferimento", + "Reference the files where they are": "Usa i file dove si trovano", + "Refine": "Rifinisci", + "Refine AI Mask Detail": "Rifinisci il dettaglio della maschera IA", + "Refine Edges": "Rifinisci i bordi", + "Refine Mask Edges": "Rifinisci i bordi della maschera", + "Refine Saturation": "Rifinisci la saturazione", + "Refining the mask's detail…": "Rifinitura del dettaglio della maschera…", + "Refresh Source": "Aggiorna l'origine", + "Refresh Source (/)": "Aggiorna l'origine (/)", + "Reject": "Scarta", + "Reject below focus": "Scarta sotto la soglia di messa a fuoco", + "Rejected": "Rifiutato", + "Rejects": "Scartate", + "Reliability": "Affidabilità", + "Relink Photo": "Ricollega foto", + "Reload Changed Files": "Ricarica i file modificati", + "Reload from Disk": "Ricarica dal disco", + "Remove": "Rimuovi", + "Remove (H)": "Rimuovi (H)", + "Remove CA": "Rimuovi AC", + "Remove Chromatic Aberration": "Rimuovi aberrazione cromatica", + "Camera local tone mapping": "Mappatura tonale locale della fotocamera", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Applica la mappatura tonale locale salvata nel raw (Apple ProRAW), come la mostra la fotocamera", + "Remove Disk from Library": "Rimuovi disco dalla libreria", + "Remove Disk from Library…": "Rimuovi disco dalla libreria…", + "Remove Face Box": "Rimuovi riquadro del volto", + "Remove Folder from Library": "Rimuovi cartella dalla libreria", + "Remove a filter above, or choose Clear all": "Togli un filtro qui sopra, oppure scegli Cancella tutto", + "Remove from Album": "Rimuovi dall'album", + "Remove from Favorites": "Rimuovi dai preferiti", + "Remove from Library…": "Rimuovi dalla libreria…", + "Remove from Local": "Rimuovi da Locale", + "Remove from Photo": "Rimuovi dalla foto", + "Remove from Selected Photos": "Rimuovi dalle foto selezionate", + "Remove from Stack": "Rimuovi dalla pila", + "Remove from Target Album": "Rimuovi dall'album di destinazione", + "Remove from survey": "Rimuovi dalla panoramica", + "Remove location info": "Rimuovi le informazioni sulla posizione", + "Remove shortcut": "Rimuovi scorciatoia", + "Remove this face box (undo with Edit ▸ Undo)": "Rimuovi questo riquadro del volto (annulla con Modifica → Annulla)", + "Remove this filter": "Rimuovi questo filtro", + "Remove this group": "Rimuovi questo gruppo", + "Remove this rule": "Rimuovi questa regola", + "Rename": "Rinomina", + "Rename Album": "Rinomina album", + "Rename Component": "Rinomina componente", + "Rename Folder": "Rinomina cartella", + "Rename Folder…": "Rinomina cartella…", + "Rename Keyword": "Rinomina parola chiave", + "Rename Keyword…": "Rinomina parola chiave…", + "Rename Mask": "Rinomina maschera", + "Rename Photos": "Rinomina foto", + "Rename Photos…": "Rinomina foto…", + "Rename Preset": "Rinomina preset", + "Rename Preview": "Anteprima della rinomina", + "Rename Template Tags": "Tag del modello di rinomina", + "Rename Version": "Rinomina versione", + "Rename…": "Rinomina…", + "Rendering": "Rendering", + "Rendering…": "Rendering in corso…", + "Reorder Masks": "Riordina maschere", + "Reset": "Reimposta", + "Reset All": "Reimposta tutto", + "Reset All Curves": "Reimposta tutte le curve", + "Reset All Keyboard Shortcuts": "Reimposta tutte le scorciatoie da tastiera", + "Reset Before to Import State": "Riporta Prima allo stato di importazione", + "Reset Control": "Reimposta controllo", + "Reset Crop": "Reimposta ritaglio", + "Reset Edits": "Reimposta modifiche", + "Reset Panel Layout": "Reimposta disposizione dei pannelli", + "Reset Parametric Curve": "Reimposta curva parametrica", + "Reset Section": "Reimposta sezione", + "Reset Tone Curve": "Reimposta curva dei toni", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Reimposta tutte le curve: curve a punti (tutti i canali) e parametrica (doppio clic su un canale per reimpostare solo quello)", + "Reset all edits (Cmd+Shift+R)": "Reimposta tutte le modifiche (Cmd+Maiusc+R)", + "Reshuffle": "Rimescola", + "Resize Face Box": "Ridimensiona riquadro del volto", + "Resize panel window": "Ridimensiona la finestra del pannello", + "Resize panels": "Ridimensiona pannelli", + "Resize to": "Ridimensiona a", + "Resolution (ppi)": "Risoluzione (ppi)", + "Restore": "Ripristina", + "Restore (no shortcut)": "Ripristina (nessuna scorciatoia)", + "Restore Library from Backup…": "Ripristina la libreria da un backup…", + "Restore Version": "Ripristina versione", + "Restore keeps their edits; Import as new deletes the old record first.": "Ripristina mantiene le loro modifiche; Importa come nuova elimina prima il vecchio record.", + "Restore them": "Ripristinale", + "Retouch": "Ritocco", + "Reverse the order": "Inverti l'ordine", + "Revert": "Ripristina", + "Review": "Revisione", + "Review Import": "Rivedi importazione", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Clic destro su una foto → Imposta come foto di riferimento, poi Visualizza → Vista di riferimento (Maiusc+R)", + "Right-click a preset to delete it": "Clic destro su un preset per eliminarlo", + "Rights Usage Terms": "Condizioni d'uso dei diritti", + "Rotate": "Ruota", + "Rotate Crop": "Ruota il ritaglio", + "Rotate Crop Aspect": "Ruota le proporzioni del ritaglio", + "Rotate Left": "Ruota a sinistra", + "Rotate Right": "Ruota a destra", + "Roughness": "Rugosità", + "Roundness": "Rotondità", + "Rules": "Regole", + "SAM 3 download stopped: it resumes where it left off next time.": "Download di SAM 3 interrotto: la prossima volta riprende da dove si è fermato.", + "Same as raw default": "Uguale al predefinito raw", + "Sample Mask Color": "Campiona il colore della maschera", + "Sample a colour on the photo": "Campiona un colore sulla foto", + "Saturation": "Saturazione", + "Saturation Range": "Intervallo di saturazione", + "Saturday": "Sabato", + "Save": "Salva", + "Save As…": "Salva con nome…", + "Save Color Label Set": "Salva set di etichette colore", + "Save Current Filter as Preset…": "Salva il filtro corrente come preset…", + "Save Current Keywords as Set…": "Salva le parole chiave correnti come set…", + "Save Export Preset": "Salva preset di esportazione", + "Save Filter Preset": "Salva preset di filtro", + "Save Keyword Set": "Salva set di parole chiave", + "Save Metadata Preset": "Salva preset di metadati", + "Save Metadata Preset…": "Salva preset di metadati…", + "Save Metadata to File": "Salva i metadati nel file", + "Save Point Curve Preset": "Salva preset di curva a punti", + "Save Point Curve…": "Salva curva a punti…", + "Save as Preset": "Salva come preset", + "Save as Set…": "Salva come set…", + "Save as Smart Album…": "Salva come album smart…", + "Save as set": "Salva come set", + "Save as…": "Salva con nome…", + "Save preset": "Salva preset", + "Saving Lightroom import index…": "Salvataggio dell'indice di importazione di Lightroom…", + "Scale": "Scala", + "Scan failed": "Scansione non riuscita", + "Scanning": "Scansione in corso", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "I punteggi restano sulle foto: filtra o crea album smart con Messa a fuoco e La migliore tra scatti simili.", + "Screen": "Schermo", + "Scroll menu down": "Scorri il menu in giù", + "Scroll menu up": "Scorri il menu in su", + "Search": "Cerca", + "Search Photos": "Cerca foto", + "Search commands or keys": "Cerca comandi o tasti", + "Search…": "Cerca…", + "Seasons": "Stagioni", + "Second Window": "Seconda finestra", + "Select": "Seleziona", + "Select All": "Seleziona tutto", + "Select Luminance Range": "Seleziona intervallo di luminanza", + "Select Mask": "Seleziona maschera", + "Select Photos": "Seleziona foto", + "Select Spot": "Seleziona macchia", + "Select a photo to edit": "Seleziona una foto da modificare", + "Select by": "Seleziona per", + "Select by Flag, Rating or Label": "Seleziona per contrassegno, valutazione o etichetta", + "Select photos, then choose View → Survey (N)": "Seleziona le foto, poi scegli Visualizza → Panoramica (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Seleziona due foto, poi scegli Visualizza → Confronta (Maiusc+C)", + "Selected": "Selezionati", + "Selecting…": "Selezione in corso…", + "Selenium Tone": "Viraggio al selenio", + "Send Feedback…": "Invia un feedback…", + "Sepia Tone": "Viraggio seppia", + "Sequence number zero-padded to N digits (1–9)": "Numero progressivo con zeri iniziali fino a N cifre (1-9)", + "Sequence number, counting from the start number": "Numero progressivo, a partire dal numero iniziale", + "Services": "Servizi", + "Set": "Imposta", + "Set Album Cover": "Imposta copertina dell'album", + "Set Before": "Imposta Prima", + "Set Color Label": "Imposta etichetta colore", + "Set Crop": "Imposta ritaglio", + "Set Develop Values": "Imposta valori di sviluppo", + "Set Flag": "Imposta contrassegno", + "Set Image Zoom and Pan": "Imposta zoom e spostamento dell'immagine", + "Set Keyboard Shortcut": "Imposta scelta rapida da tastiera", + "Set Mask Adjustments": "Imposta regolazioni della maschera", + "Set Point Curve": "Imposta curva a punti", + "Set Profile": "Imposta profilo", + "Set Rating": "Imposta valutazione", + "Set Top of Stack": "Imposta la prima della pila", + "Set as Album Cover": "Imposta come copertina dell'album", + "Set as Before": "Imposta come Prima", + "Set as Reference Photo": "Imposta come foto di riferimento", + "Set as Target Album": "Imposta come album di destinazione", + "Set as Target Album (B adds to it)": "Imposta come album di destinazione (B aggiunge)", + "Set as Top of Stack": "Imposta come prima della pila", + "Set date & time": "Imposta data e ora", + "Set name": "Imposta nome", + "Set the mix from the photo's colours": "Imposta il mix dai colori della foto", + "Settings": "Impostazioni", + "Settings file problem": "Problema nel file delle impostazioni", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Impostazioni da cui partono le nuove foto raw. Cambiarle non tocca le foto già nella libreria.", + "Settings pasted": "Impostazioni incollate", + "Settings to include": "Impostazioni da includere", + "Settings…": "Impostazioni…", + "Shade": "Ombra", + "Shadow": "Ombra", + "Shadows": "Ombre", + "Shadows Hue": "Tonalità delle ombre", + "Shadows Luminance": "Luminanza delle ombre", + "Shadows Saturation": "Saturazione delle ombre", + "Shadows Tint": "Tinta delle ombre", + "Shadows split": "Divisione delle ombre", + "Sharpen": "Nitidezza", + "Sharpening": "Contrasto", + "Sharpness": "Nitidezza", + "Shift": "Maiusc", + "Shift by": "Sposta di", + "Short Edge": "Lato corto", + "Show": "Mostra", + "Show All": "Mostra tutto", + "Show Clipping": "Mostra tagli", + "Show Deghost Overlay": "Mostra sovrapposizione della rimozione fantasmi", + "Show Hidden Local Locations": "Mostra le posizioni locali nascoste", + "Show Luminance Map": "Mostra mappa di luminanza", + "Show Mask": "Mostra maschera", + "Show Mask Overlay": "Mostra sovrapposizione maschera", + "Show Mask Pins": "Mostra i punti delle maschere", + "Show Original": "Mostra originale", + "Show Photo Counts": "Mostra il numero di foto", + "Show Photos with Keyword": "Mostra le foto con la parola chiave", + "Show Pins": "Mostra i punti", + "Show Source": "Mostra origine", + "Show Thumbnails": "Mostra miniature", + "Show commands without a shortcut": "Mostra i comandi senza scorciatoia", + "Show destination gamut warning": "Mostra avviso gamma di destinazione", + "Show display gamut warning": "Mostra avviso gamma dello schermo", + "Show file names": "Mostra i nomi dei file", + "Show in Explorer": "Mostra in Esplora file", + "Show in File Manager": "Mostra nel gestore file", + "Show in Finder": "Mostra nel Finder", + "Show on Map": "Mostra sulla mappa", + "Show overlay (O)": "Mostra sovrapposizione (O)", + "Show ratings, flags and edit badges": "Mostra valutazioni, contrassegni e indicatori di modifica", + "Show the Navigator while zoomed in": "Mostra il Navigatore quando lo zoom è attivo", + "Show the template tags; click one to insert it": "Mostra i tag del modello; fai clic su uno per inserirlo", + "Show/Hide Mask": "Mostra/nascondi maschera", + "Show/hide My Photos panel": "Mostra/nascondi il pannello Le mie foto", + "Shutter speed": "Tempo di scatto", + "Side panels": "Pannelli laterali", + "Sidecar names": "Nomi dei file collaterali", + "Size": "Dimensione", + "Size (% of short edge)": "Dimensione (% del lato corto)", + "Size limit": "Limite di dimensione", + "Skip": "Salta", + "Sky": "Cielo", + "Slideshow": "Presentazione", + "Slideshow paused": "Presentazione in pausa", + "Slideshow resumed": "Presentazione ripresa", + "Slideshow · Space pauses · Esc ends": "Presentazione · Spazio mette in pausa · Esc termina", + "Smart Album": "Album smart", + "Smart Album Rule Fields": "Campi delle regole degli album smart", + "Smart Preview Status": "Stato delle anteprime avanzate", + "Smart Previews Location": "Posizione delle anteprime avanzate", + "Smart preview available": "Anteprima avanzata disponibile", + "Smart previews": "Anteprime avanzate", + "Smart previews folder changed": "Cartella delle anteprime avanzate cambiata", + "Smoothness": "Uniformità", + "Soft B&W": "B/N morbido", + "Soft Pastel": "Pastello morbido", + "Soft Proofing": "Prova colore a video", + "Soft Skin": "Pelle morbida", + "Sort": "Ordina", + "Sort by": "Ordina per", + "Source": "Origine", + "Source code on GitHub": "Codice sorgente su GitHub", + "Spherical": "Sferica", + "Spiral": "Spirale", + "Split Stack": "Dividi la pila", + "Spring Fresh": "Primavera fresca", + "Square Grid": "Griglia quadrata", + "Square Grid (G toggles)": "Griglia quadrata (G alterna)", + "Square Grid: show file names and formats": "Griglia quadrata: mostra nomi e formati dei file", + "Stack": "Pila", + "Stack photos taken within this time of each other:": "Metti in pila le foto scattate entro questo intervallo l'una dall'altra:", + "Stacks": "Pile", + "Standard": "Standard", + "Star rating (0–5)": "Valutazione a stelle (0-5)", + "Start at": "Inizia da", + "Start number": "Numero iniziale", + "Starting": "Avvio in corso", + "Startup": "Avvio", + "State / Province": "Regione / provincia", + "Stop": "Interrompi", + "Stop Building Previews": "Interrompi la generazione delle anteprime", + "Stop Using as Target Album": "Non usare più come album di destinazione", + "Stopping…": "Interruzione in corso…", + "Straighten": "Raddrizza", + "Straighten Tool": "Strumento raddrizza", + "Strong Contrast": "Contrasto forte", + "Style": "Stile", + "Subfolder": "Sottocartella", + "Subfolder name": "Nome della sottocartella", + "Subject": "Soggetto", + "Subtract": "Sottrai", + "Subtract from this mask": "Sottrai da questa maschera", + "Suggestions": "Suggerimenti", + "Summer Haze": "Foschia estiva", + "Sunday": "Domenica", + "Survey": "Panoramica", + "Survey (N)": "Panoramica (N)", + "Swap": "Scambia", + "Swap Aspect (X)": "Scambia proporzioni (X)", + "Swap Before and After": "Scambia Prima e Dopo", + "Swap Before and After Settings": "Scambia le impostazioni di Prima e Dopo", + "Swap Compare Photos": "Scambia le foto del confronto", + "Switch light / dark theme": "Alterna tema chiaro/scuro", + "Sync Settings": "Sincronizza impostazioni", + "System": "Sistema", + "System Info": "Info sistema", + "System Info…": "Info sistema…", + "System default": "Predefinito di sistema", + "Table": "Tabella", + "Tags": "Tag", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Tag: vedi Tag accanto al modello. I file vengono rinominati sul disco (con i loro XMP collaterali); ai nomi già esistenti si aggiunge -1, -2…", + "Take a snapshot": "Acquisisci istantanea", + "Targeted Adjustment": "Regolazione mirata", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Regolazione mirata: trascina in su o in giù sulla foto per regolare i colori in quel punto", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Regolazione mirata: trascina in su o in giù sulla foto per regolare la curva in quel punto", + "Teal & Amber": "Ottanio e ambra", + "Teal & Orange": "Ottanio e arancio", + "Temp": "Temp.", + "Temperature": "Temperatura", + "Template": "Modello", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Sessione temporanea: la tua libreria non è aperta, e niente di quello che fai qui viene salvato.", + "Text": "Testo", + "Text direction": "Direzione del testo", + "Texture": "Texture", + "That includes the photos in the folders inside it.": "Comprende anche le foto nelle cartelle al suo interno.", + "That key can't be used as a shortcut": "Quel tasto non si può usare come scorciatoia", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Il modello SAM 3 è installato: le maschere Oggetto e Descrivi sono pronte.", + "The download didn't work:": "Il download non è riuscito:", + "The number at the end of the original name (IMG_0042 → 0042)": "Il numero alla fine del nome originale (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "L'estensione originale viene sempre aggiunta; {ext} la mette anche dentro il nome.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "I file originali, invariati, ciascuno con un XMP collaterale che contiene le sue modifiche.", + "The photo has these settings": "La foto ha queste impostazioni", + "Theme": "Tema", + "They can be restored from Recently Deleted until it is emptied.": "Si possono ripristinare da Eliminate di recente finché non viene svuotato.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Finiscono in Eliminate di recente e si possono ripristinare; nessun file sul disco viene toccato.", + "Thirds": "Terzi", + "This Computer": "Questo computer", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Questa versione non può ancora scaricare il modello. Per installarlo a mano, metti model.safetensors, vocab.json e merges.txt del facebook/sam3 di Meta nella cartella qui sopra: Oggetto e Descrivi funzionano appena sono lì.", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Questa cartella non è disponibile (disco scollegato?). Le anteprime avanzate non vengono generate né usate finché non torna o non scegli un'altra cartella.", + "This photo can't be opened": "Impossibile aprire questa foto", + "Threshold": "Soglia", + "Thumbnail cache": "Cache delle miniature", + "Thursday": "Giovedì", + "Time zone": "Fuso orario", + "Tint": "Tinta", + "Title": "Titolo", + "Title metadata": "Metadati del titolo", + "Toggle Keyword from Set": "Applica o togli parola chiave dal set", + "Toggle Mask": "Attiva/disattiva maschera", + "Toggle Section": "Apri/chiudi sezione", + "Toggle Zoom": "Attiva/disattiva zoom", + "Tone Curve": "Curva dei toni", + "Tools": "Strumenti", + "Top Level": "Livello principale", + "Transfer": "Trasferimento", + "Treatment": "Trattamento", + "Triangle": "Triangolo", + "Try Again": "Riprova", + "Try Saving Again": "Riprova a salvare", + "Tuesday": "Martedì", + "Tungsten": "Tungsteno", + "Turn Off": "Disattiva", + "Turn on Include subfolders, or pick another folder under Local": "Attiva Includi sottocartelle, oppure scegli un'altra cartella in Locale", + "Type": "Tipo", + "Uncheck All": "Deseleziona tutto", + "Uncheck the files of this type": "Deseleziona i file di questo tipo", + "Undo": "Annulla", + "Unedited": "Non modificata", + "Unedited raws": "Raw non modificati", + "Unflag": "Togli contrassegno", + "Unflagged": "Senza contrassegno", + "Ungroup Stack": "Separa la pila", + "Unknown Date": "Data sconosciuta", + "Unknown tags stay as typed — check the preview for a {typo}.": "I tag sconosciuti restano come li hai scritti: controlla l'anteprima per un {typo}.", + "Unrated": "Senza valutazione", + "Unreadable": "Illeggibile", + "Update": "Aggiorna", + "Update Mask Shape": "Aggiorna la forma della maschera", + "Update Preset with Current Settings": "Aggiorna il preset con le impostazioni correnti", + "Update Rules from Current Filter": "Aggiorna le regole dal filtro corrente", + "Update Version": "Aggiorna versione", + "Update Version with Current Settings": "Aggiorna la versione con le impostazioni correnti", + "Update with Current Settings": "Aggiorna con le impostazioni correnti", + "Updated the edits saved in the external editor": "Modifiche salvate nell'editor esterno aggiornate", + "Upright": "Raddrizzamento", + "Urban": "Urbano", + "Use Keyword Set": "Usa set di parole chiave", + "Use Library Folder": "Usa la cartella della libreria", + "Use camera-specific defaults": "Usa i predefiniti specifici della fotocamera", + "Use the GPU for rendering": "Usa la GPU per il rendering", + "User Presets": "Preset utente", + "Username": "Nome utente", + "Value": "Valore", + "Variance": "Varianza", + "Version": "Versione", + "Version name": "Nome della versione", + "Versions": "Versioni", + "Versions (Shift+V)": "Versioni (Maiusc+V)", + "Vertical": "Verticale", + "Vertical inset (% of short edge)": "Margine verticale (% del lato corto)", + "Vertical text": "Testo verticale", + "Vibrance": "Vividezza", + "Videos": "Video", + "View": "Visualizza", + "Viewing and sharing": "Visione e condivisione", + "Vignette": "Vignettatura", + "Vignetting": "Vignettatura", + "Vintage": "Vintage", + "Virtual Copy": "Copia virtuale", + "Visible": "Visibile", + "Visualize Spots": "Visualizza macchie", + "Visualize Spots (A)": "Visualizza macchie (A)", + "Visualize range": "Visualizza intervallo", + "Vivid": "Vivace", + "Vivid Pop": "Vivace e brillante", + "Warm Film": "Pellicola calda", + "Warm Glow": "Bagliore caldo", + "Warm Print": "Stampa calda", + "Warm Table": "Tavola calda", + "Watched folder": "Cartella sorvegliata", + "Watermark": "Filigrana", + "Wednesday": "Mercoledì", + "What's New": "Novità", + "When rated or hovered": "Quando ha una valutazione o è sotto il puntatore", + "White Balance": "Bilanciamento del bianco", + "White Balance Selector": "Selettore del bilanciamento del bianco", + "White Balance Selector (W)": "Selettore del bilanciamento del bianco (W)", + "Whites": "Bianchi", + "Width": "Larghezza", + "Width (% of photo)": "Larghezza (% della foto)", + "Width (px)": "Larghezza (px)", + "Width × Height": "Larghezza × altezza", + "Will be replaced": "Verrà sostituita", + "Will be restored": "Verrà ripristinata", + "Window": "Finestra", + "Winter Blue": "Blu d'inverno", + "XMP Sidecar Preferences": "Preferenze dei file XMP collaterali", + "XMP sidecars": "File XMP collaterali", + "Year": "Anno", + "Yellow": "Giallo", + "Yellow Hue": "Tonalità giallo", + "Yellow Luminance": "Luminanza giallo", + "Yellow Saturation": "Saturazione giallo", + "Yellow label": "Etichetta gialla", + "Yes": "Sì", + "You can close this: the download continues, and resumes if interrupted.": "Puoi chiudere questa finestra: il download continua, e riprende se viene interrotto.", + "Your Name": "Il tuo nome", + "Your library couldn't be opened": "Impossibile aprire la tua libreria", + "Zoom": "Ingrandimento", + "Zoom 100%": "Zoom 100%", + "Zoom In": "Ingrandisci", + "Zoom Out": "Riduci", + "Zoom to Fit": "Zoom adatta", + "Zoom to page level": "Adatta alla pagina", + "a literal %": "un % letterale", + "a removed photo": "una foto rimossa", + "all": "tutte", + "and": "e", + "any": "almeno una", + "are empty": "sono vuoti", + "aren't empty": "non sono vuoti", + "blue": "blu", + "comma, separated": "separati, da virgola", + "contains": "contiene", + "copyrighted": "protetto da copyright", + "day 01–31": "giorno 01-31", + "days": "giorni", + "discarding smart previews": "eliminazione delle anteprime avanzate", + "doesn't contain": "non contiene", + "e.g. sky · the red car · car, road": "es. cielo · l'auto rossa · auto, strada", + "ends with": "finisce con", + "false": "no", + "green": "verde", + "hour 00–23": "ora 00-23", + "hours": "ore", + "image": "immagine", + "includes the folders inside it": "comprende le cartelle al suo interno", + "is": "è", + "is <": "è <", + "is >": "è >", + "is after": "è dopo", + "is before": "è prima", + "is between": "è tra", + "is empty": "è vuoto", + "is in the last": "è negli ultimi", + "is unknown": "è sconosciuto", + "is ≤": "è ≤", + "is ≥": "è ≥", + "isn't": "non è", + "isn't empty": "non è vuoto", + "isn't in the last": "non è negli ultimi", + "keep names — or e.g. {date}_{seq:3}": "mantieni i nomi, oppure ad es. {date}_{seq:3}", + "latitude, longitude": "latitudine, longitudine", + "memory only": "solo in memoria", + "minute": "minuto", + "month 01–12": "mese 01-12", + "months": "mesi", + "none": "nessuno", + "none (CPU rendering)": "nessuna (rendering sulla CPU)", + "none of": "nessuna", + "not available here": "non disponibile qui", + "of the following rules:": "delle regole seguenti:", + "or": "o", + "pick": "scelta", + "previews": "anteprime", + "publicDomain": "pubblico dominio", + "purple": "viola", + "raw": "raw", + "red": "rosso", + "reject": "scartata", + "saved in": "salvato in", + "second": "secondo", + "smart previews": "anteprime avanzate", + "starts with": "inizia con", + "the photos in view": "le foto nella vista", + "the selected photos": "le foto selezionate", + "this photo": "questa foto", + "true": "sì", + "unknown": "sconosciuto", + "video": "video", + "weeks": "settimane", + "year, 2 digits": "anno, 2 cifre", + "year, 4 digits": "anno, 4 cifre", + "years": "anni", + "yellow": "giallo", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} è l'ora di scatto; una foto che non ce l'ha usa l'ora in cui è stata importata.", + "© 2026 Your Name": "© 2026 Il tuo nome", + "© Your Name": "© Il tuo nome", + "− harder border · + softer border": "− bordo più netto · + bordo più morbido", + "≤ at most": "≤ al massimo", + "≥ at least": "≥ almeno", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)", + "Any camera": "Qualsiasi fotocamera", + "Any lens": "Qualsiasi obiettivo", + "Any keyword": "Qualsiasi parola chiave", + "Label Set": "Set di etichette", + ", picked": ", scelta", + ", rejected": ", scartata", + "Preview only: editing the camera's embedded JPEG": "Solo anteprima: si modifica il JPEG incorporato dalla fotocamera", + "Captured from": "Scattate dal", + "Captured until": "Scattate fino al", + "Only": "Solo", + "Open Log Folder": "Apri la cartella dei log", + "Back to this person": "Torna a questa persona", + "AI Denoise": "Riduzione disturbo IA", + "AI Denoise Model": "Modello di riduzione disturbo IA", + "AI Denoise is off: press Use on a model.": "La riduzione disturbo IA è disattivata: premi Usa su un modello.", + "AI Denoise needs a model. Install one in Settings.": "La riduzione disturbo IA ha bisogno di un modello. Installane uno nelle Impostazioni.", + "Accept & Download": "Accetta e scarica", + "Already installed. Remove it before installing a replacement.": "Già installato. Rimuovilo prima di installarne un altro.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatico usa la scheda grafica quando è più veloce del processore: la prima volta che si elabora una foto, su questo computer vengono cronometrati entrambi.", + "Cache": "Cache", + "Checking and installing the model…": "Controllo e installazione del modello…", + "Checking…": "Controllo in corso…", + "Clear cache": "Svuota cache", + "Denoise Models": "Modelli di riduzione disturbo", + "Denoise Settings": "Impostazioni della riduzione disturbo", + "Denoise all photos with an Amount": "Riduci il disturbo in tutte le foto con una quantità", + "Downloaded and checked. Installing…": "Scaricato e verificato. Installazione…", + "Failed its last test": "Non ha superato l'ultimo test", + "Graphics card": "Scheda grafica", + "I have read these terms and accept them for my own use": "Ho letto queste condizioni e le accetto per uso personale", + "Inspect Denoise Model": "Esamina il modello di riduzione disturbo", + "Install Denoise Model": "Installa un modello di riduzione disturbo", + "Install from file…": "Installa da file…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Installa un tuo modello ONNX con accanto denoise-model.json. Questa versione non offre download di modelli.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "La sua immagine con il disturbo ridotto non è ancora pronta, e le immagini non vengono generate da sole (Impostazioni, Riduzione disturbo IA).", + "LightCraft does not know this model, so it assumed:": "LightCraft non conosce questo modello, quindi ha ipotizzato:", + "Limit": "Limite", + "Make it now": "Generala ora", + "Make the denoised picture of the photos I am looking at": "Genera l'immagine con il disturbo ridotto delle foto che sto guardando", + "Next up for AI Denoise…": "Prossima per la riduzione disturbo IA…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Non viene scaricato niente finché non accetti. Il modello resta solo su questo computer; LightCraft non lo carica né lo condivide mai.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Vengono generate solo le foto con una quantità di riduzione disturbo IA sopra 0. Le esportazioni generano sempre la loro se manca.", + "Open page": "Apri pagina", + "Open the model's page": "Apri la pagina del modello", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Apre nel browser la pagina del modello, per leggerne i dettagli o scaricare il file da te.", + "Processor": "Processore", + "Set up AI Denoise…": "Configura la riduzione disturbo IA…", + "Speed": "Velocità", + "Starting…": "Avvio in corso…", + "Tested and works on this computer": "Testato e funzionante su questo computer", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Il modello di riduzione disturbo è installato e in uso: ora funziona il cursore Quantità riduzione disturbo IA in Dettaglio", + "The graphics card is set up when the next photo is made.": "La scheda grafica viene configurata quando si elabora la prossima foto.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Il modello resta solo su questo computer. LightCraft non lo carica né lo condivide mai.", + "The processor does the work.": "Il lavoro lo fa il processore.", + "There is no model file to install": "Non c'è nessun file di modello da installare", + "This build cannot run denoise models.": "Questa versione non può eseguire modelli di riduzione disturbo.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli di riduzione disturbo, quindi la riduzione disturbo IA non è disponibile. (L'app desktop ce l'ha.)", + "This file cannot be used yet": "Questo file non si può ancora usare", + "Tick the box to accept the model's terms first": "Prima spunta la casella per accettare le condizioni del modello", + "Try again": "Riprova", + "Try the graphics card again": "Riprova con la scheda grafica", + "Turn off": "Disattiva", + "Unknown licence": "Licenza sconosciuta", + "Use": "Usa", + "Waiting to start…": "In attesa di partire…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funziona sui file raw delle fotocamere con sensore standard (Bayer). Gli altri file mantengono la loro normale riduzione del disturbo.", + "cached picture": "immagine in cache", + "cached pictures": "immagini in cache", + "commercial use allowed": "uso commerciale consentito", + "denoiseModel.accept": "denoiseModel.accept", + "in use": "in uso", + "installed": "installato", + "makes AI Denoise available": "rende disponibile la riduzione disturbo IA", + "non-commercial use only": "solo uso non commerciale", + "photo has its denoised picture.": "foto ha la sua immagine con il disturbo ridotto.", + "photo waiting": "foto in attesa", + "photos have their denoised picture.": "foto hanno la loro immagine con il disturbo ridotto.", + "photos waiting": "foto in attesa", + "starts using it for AI Denoise": "comincia a usarlo per la riduzione disturbo IA", + "terms unclear": "condizioni poco chiare", + "the graphics card": "la scheda grafica", + "the graphics card is not used": "la scheda grafica non viene usata", + "Cancel Denoise": "Annulla riduzione disturbo", + "Cancel Denoise Model Download": "Annulla il download del modello di riduzione disturbo", + "Choose Denoise Model": "Scegli il modello di riduzione disturbo", + "Clear Denoise Cache": "Svuota la cache della riduzione disturbo", + "Denoise Model Downloads": "Download dei modelli di riduzione disturbo", + "Denoise Photos": "Riduci il disturbo delle foto", + "Denoise Pump": "Coda della riduzione disturbo", + "Denoise Status": "Stato della riduzione disturbo", + "Download Denoise Model": "Scarica il modello di riduzione disturbo", + "Remove Denoise Model": "Rimuovi il modello di riduzione disturbo", + "Test Denoise Model": "Testa il modello di riduzione disturbo", + "AI Denoise is enabled on the requesting photo": "La riduzione disturbo IA è attiva sulla foto che l'ha richiesta", + "After installation, AI Denoise will turn on for the requesting photo.": "Dopo l'installazione, la riduzione disturbo IA si attiverà sulla foto che l'ha richiesta.", + "Cancel pending action": "Annulla l'azione in sospeso", + "Install a denoise model to enable AI Denoise on this photo": "Installa un modello di riduzione disturbo per attivare la riduzione disturbo IA su questa foto", + "Pending photo action cancelled": "Azione sulla foto in sospeso annullata", + "Pending photo action cancelled because its library or photo changed": "Azione sulla foto in sospeso annullata perché la libreria o la foto sono cambiate", + "ONNX model": "Modello ONNX", + "commercial use subject to GPL conditions": "uso commerciale soggetto alle condizioni della GPL", + "Decrease Rating": "Diminuisci valutazione", + "Increase Rating": "Aumenta valutazione", + "Click to type a value": "Fai clic per scrivere un valore", + "Angle": "Angolo", + "Video": "Video", + "Copyrighted": "Protetta da copyright", + "Public Domain": "Pubblico dominio", + "Unknown": "Sconosciuto", + "Landscape (wide)": "Orizzontale (larga)", + "Portrait (tall)": "Verticale (alta)", + "Square": "Quadrato", + "In Color": "A colori", + "No Label": "Nessuna etichetta", + "Choose an album…": "Scegli un album…", + "No albums yet": "Ancora nessun album", + "Fix the marked rules to save this album.": "Correggi le regole segnate per salvare questo album.", + "Some rules need fixing (Edit Smart Album…):": "Alcune regole vanno corrette (Modifica album smart…):", + "isn't a field rules know": "non è un campo che le regole conoscono", + "can't be tested that way": "non si può verificare in quel modo", + "is yes or no": "è sì o no", + "needs a number": "richiede un numero", + "needs a time like 1/250 or 2": "richiede un tempo come 1/250 o 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "richiede una data come 2026, 2026-08 o 2026-08-14", + "has the first date after the second": "ha la prima data successiva alla seconda", + "needs two values": "richiede due valori", + "needs a number more than 0": "richiede un numero maggiore di 0", + "needs hours, days, weeks, months or years": "richiede ore, giorni, settimane, mesi o anni", + "isn't one of the choices": "non è tra le scelte possibili", + "needs something to look for (or use “is empty”)": "richiede qualcosa da cercare (oppure usa “è vuoto”)", + "needs something to look for (or use “isn't empty”)": "richiede qualcosa da cercare (oppure usa “non è vuoto”)", + "needs text": "richiede un testo", + "can't match any rating from 0 to 5": "non può corrispondere a nessuna valutazione da 0 a 5", + "needs an album": "richiede un album", + "names an album that no longer exists": "indica un album che non esiste più", + "can't test a folder; choose an album in it": "non si può verificare su una cartella; scegli un album al suo interno", + "This group is empty: add a rule or remove it.": "Questo gruppo è vuoto: aggiungi una regola o rimuovilo.", + "needs something to look for (or use “are empty”)": "richiede qualcosa da cercare (oppure usa “sono vuoti”)", + "needs something to look for (or use “aren't empty”)": "richiede qualcosa da cercare (oppure usa “non sono vuoti”)", + "Mo": "Lu", + "Tu": "Ma", + "We": "Me", + "Th": "Gi", + "Fr": "Ve", + "Sa": "Sa", + "Su": "Do", + "Pick a date": "Scegli una data", + "would make this album include itself through other smart albums": "farebbe includere questo album in se stesso attraverso altri album smart", + "Search albums": "Cerca album", + "No albums match": "Nessun album corrisponde", + "This is the album you're editing": "È l'album che stai modificando", + "It tests this album, so testing it back would loop": "Verifica questo album, quindi verificarlo a sua volta creerebbe un ciclo", + "Copy Name": "Nome della copia", + "In a Stack": "In una pila", + "File Extension": "Estensione del file", + "Video Duration": "Durata del video", + "Keyword Count": "Numero di parole chiave", + "People Count": "Numero di persone", + "Shutter Speed": "Tempo di scatto", + "Cropped": "Ritagliata", + "Keywords & People": "Parole chiave e persone", + "Description": "Descrizione", + "Camera Info": "Informazioni sulla fotocamera", + "Develop": "Sviluppo", + "Keyword List": "Elenco parole chiave", + "Filter Keywords": "Filtra parole chiave", + "No keywords yet": "Ancora nessuna parola chiave", + "No keywords match": "Nessuna parola chiave corrisponde", + "Keyword Name": "Nome della parola chiave", + "Synonyms": "Sinonimi", + "Separated by commas": "Separati da virgole", + "Keyword Tag Options": "Opzioni del tag parola chiave", + "Include on Export": "Includi nell'esportazione", + "Export Containing Keywords": "Esporta le parole chiave che la contengono", + "Export Synonyms": "Esporta i sinonimi", + "Create Keyword Tag": "Crea tag parola chiave", + "Edit Keyword Tag": "Modifica tag parola chiave", + "Create": "Crea", + "No photo has it; the keywords below it go too.": "Nessuna foto ce l'ha; vengono tolte anche le parole chiave sotto di essa.", + "Edit Keyword Tag…": "Modifica tag parola chiave…", + "Put New Keywords Inside This Keyword": "Metti le nuove parole chiave dentro questa", + "Purge Unused Keywords": "Elimina le parole chiave non usate", + "Delete Keyword…": "Elimina parola chiave…", + "Drop here for the top level": "Rilascia qui per il livello principale", + "Their photos and the keywords below them come together under one keyword.": "Le loro foto e le parole chiave sotto di esse si riuniscono sotto un'unica parola chiave.", + "Select photos to give them keywords.": "Seleziona delle foto per assegnare loro parole chiave.", + "Add to All Selected Photos": "Aggiungi a tutte le foto selezionate", + "Will Export": "Verrà esportata", + "No keywords are exported": "Nessuna parola chiave viene esportata", + "& Containing": "e contenenti", + "Edit Set…": "Modifica set…", + "Edit Keyword Set": "Modifica set di parole chiave", + "Keywords (⌥1–⌥9)": "Parole chiave (⌥1-⌥9)", + "A keyword set needs a name": "Un set di parole chiave ha bisogno di un nome", + "Save as a new set": "Salva come nuovo set", + "Recent Keywords is built in: choose another name": "Parole chiave recenti è predefinito: scegli un altro nome", + "Import Keywords…": "Importa parole chiave…", + "Export Keywords…": "Esporta parole chiave…", + "Import Keywords": "Importa parole chiave", + "Export Keywords": "Esporta parole chiave", + "Keyword Lists": "Elenchi di parole chiave", + "Synchronize Folder…": "Sincronizza cartella…", + "Synchronize Folder": "Sincronizza cartella", + "Synchronize": "Sincronizza", + "Scanning…": "Scansione…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Trova le foto aggiunte o mancanti in questa cartella sul disco, e i file XMP collaterali modificati da altre app", + "Bring the library up to date with this folder and the folders inside it:": "Aggiorna la libreria con questa cartella e le cartelle al suo interno:", + "Import new photos": "Importa le foto nuove", + "Remove missing photos from the library": "Togli dalla libreria le foto mancanti", + "Read metadata updates from XMP sidecars": "Leggi gli aggiornamenti dei metadati dai file XMP collaterali", + "The library is up to date with this folder.": "La libreria è aggiornata rispetto a questa cartella.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Le foto mancanti finiscono in Eliminate di recente e si possono ripristinare; nessun file sul disco viene toccato.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Leggere un file collaterale sostituisce valutazione, etichette, metadati e modifiche della foto con quello che contiene.", + "Skipped: files already in the library elsewhere": "Saltati: file già presenti altrove nella libreria", + "Skipped: files that can't be read": "Saltati: file che non si possono leggere", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Questa cartella non c'è: è stata spostata o rinominata, oppure il suo disco non è collegato.", + "Relink photos whose file was renamed or moved": "Ricollega le foto il cui file è stato rinominato o spostato", + "A folder is already being synchronized": "Una cartella è già in sincronizzazione", + "Synchronizing…": "Sincronizzazione in corso…", + "Relinked": "Ricollegate", + "Removed": "Rimosse", + "Metadata read": "Metadati letti", + "Failed": "Non superato", + "Stopped": "Interrotto", + "Stopped by an error (see the log)": "Interrotta da un errore (vedi il log)", + "Not saved": "Non salvato", + "A folder is being synchronized": "Una cartella è in sincronizzazione", + "Wait for the import to finish": "Aspetta che l'importazione finisca", + "Appearance": "Aspetto", + "Appearance Mode": "Modalità aspetto", + "Sync with System": "Segui il sistema", + "Light Mode": "Tema chiaro", + "Dark Mode": "Modalità scura", + "Next Appearance Mode": "Modalità aspetto successiva", + "Light Theme": "Tema chiaro", + "Dark Theme": "Tema scuro", + "In Use": "In uso", + "Charcoal": "Carboncino", + "Midnight": "Mezzanotte", + "Silver": "Argento", + "Paper": "Carta", + "Show less": "Mostra meno", + "Quit while tasks are running?": "Uscire mentre ci sono attività in corso?", + "Exporting contact sheet": "Esportazione del foglio provini", + "Reading folder": "Lettura della cartella", + "Scanning folder": "Scansione della cartella", + "Building previews": "Generazione delle anteprime", + "Building smart previews": "Generazione delle anteprime avanzate", + "Discarding smart previews": "Eliminazione delle anteprime avanzate", + "Importing Lightroom catalog": "Importazione del catalogo Lightroom", + "Reading Lightroom catalog": "Lettura del catalogo Lightroom", + "Merging": "Unione", + "Downloading model": "Download del modello", + "Finding faces": "Ricerca dei volti", + "Synchronizing folder": "Sincronizzazione della cartella", + "Looking for changes": "Ricerca delle modifiche", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Questo è un catalogo di Lightroom, non una libreria di LightCraft. Vuoi importarlo in una libreria di LightCraft?", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "L'importazione legge il catalogo senza modificarlo e aggiunge foto, modifiche, parole chiave e raccolte alla libreria aperta ora.", + "Albums hold library photos: Add to My Photos first": "Gli album contengono foto della libreria: prima aggiungi a Le mie foto", + "Add to My Photos First": "Prima aggiungi a Le mie foto", + "Fill Location from GPS": "Compila la posizione dal GPS", + "Download Place Names (GeoNames)": "Scarica i nomi dei luoghi (GeoNames)", + "Place Names Status": "Stato dei nomi dei luoghi", + "Fill Location": "Compila la posizione", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Compila Città, Regione e Paese vuoti dal GPS (nomi dei luoghi GeoNames, senza connessione)", + "Looking up places": "Ricerca dei luoghi", + "Downloading place names": "Download dei nomi dei luoghi", + "Place names downloaded": "Nomi dei luoghi scaricati", + "Place names download cancelled": "Download dei nomi dei luoghi annullato", + "Other…": "Altro…", + "Intent:": "Intento:", + "Perceptual": "Percettivo", + "Relative": "Relativo", + "Simulate Paper & Ink": "Simula carta e inchiostro", + "Content-Aware": "In base al contenuto", + "Sidebar Sections": "Sezioni della barra laterale", + "Reset Sidebar Sections": "Reimposta le sezioni della barra laterale", + "Hide This Section": "Nascondi questa sezione", + "No folders to browse": "Nessuna cartella da sfogliare", + "No photos yet": "Ancora nessuna foto", + "No imported folders yet": "Ancora nessuna cartella importata", + "Show Sidebar Section": "Mostra sezione della barra laterale", + "Move Sidebar Section": "Sposta sezione della barra laterale", + "New Library…": "Nuova libreria…", + "Open Recent Library": "Apri libreria recente", + "Recent Libraries": "Librerie recenti", + "Clear Recent Libraries": "Cancella le librerie recenti", + "No Recent Libraries": "Nessuna libreria recente", + "New Library: Choose Where to Put It": "Nuova libreria: scegli dove metterla", + "New Library": "Nuova libreria", + "Library name": "Nome della libreria", + "New Folder on Disk": "Nuova cartella sul disco", + "New Folder on Disk…": "Nuova cartella sul disco…", + "Move to Folder": "Sposta nella cartella", + "Move to Folder…": "Sposta nella cartella…", + "Move Selected Photos Here": "Sposta qui le foto selezionate", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Sposta i file e i loro XMP collaterali in questa cartella; non sovrascrive mai", + "Appearance Settings…": "Impostazioni aspetto…", + "Add to favorites": "Aggiungi ai preferiti", + "Remove from favorites": "Rimuovi dai preferiti", + "Add to Apple Photos": "Aggiungi a Foto di Apple", + "Adding to Apple Photos…": "Aggiunta a Foto di Apple…", + "not added to Apple Photos (cancelled)": "non aggiunte a Foto di Apple (annullato)", + "The first time, macOS asks whether LightCraft may control Photos.": "La prima volta, macOS chiede se LightCraft può controllare Foto.", + "Adding to Apple Photos": "Aggiunta a Foto di Apple", + "Add Face Model…": "Aggiungi modello dei volti…", + "Cancel Face Model Download": "Annulla il download del modello dei volti", + "Cancel Pending Photo Action": "Annulla l'azione in sospeso sulla foto", + "Choose Face Recognition Model": "Scegli il modello di riconoscimento dei volti", + "Choose a model below (Use) to start.": "Per cominciare, scegli un modello qui sotto (Usa).", + "Detect Faces": "Rileva i volti", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Serve a Rileva i volti (menu Foto): un piccolo download da fare una volta sola.", + "Download Face Model": "Scarica il modello dei volti", + "Drag Crop Handle": "Trascina la maniglia del ritaglio", + "Evaluate Face Recognition": "Valuta il riconoscimento dei volti", + "Face Model Downloads": "Download dei modelli dei volti", + "Face Models": "Modelli dei volti", + "Face Recognition On/Off": "Riconoscimento dei volti attivo/disattivo", + "Face detector": "Rilevatore di volti", + "Face recognition": "Riconoscimento dei volti", + "Faces": "Volti", + "Faces of a Person": "Volti di una persona", + "Go back to listing them by name": "Torna all'elenco per nome", + "Index Faces": "Indicizza i volti", + "Index Faces in the Background": "Indicizza i volti in background", + "Inspect Face Model File": "Esamina il file del modello dei volti", + "Install Face Model": "Installa il modello dei volti", + "Library Folders": "Cartelle della libreria", + "Name Face": "Dai un nome al volto", + "Name Faces": "Dai un nome ai volti", + "Recognise faces (experimental)": "Riconosci i volti (sperimentale)", + "Recognition models": "Modelli di riconoscimento", + "Remove Face Model": "Rimuovi il modello dei volti", + "Reorder Album": "Riordina album", + "Show Person": "Mostra persona", + "Sort Albums A–Z": "Ordina album A-Z", + "Sort Albums by Name": "Ordina album per nome", + "Sort Contents A–Z": "Ordina contenuti A-Z", + "Suggest Names for Faces": "Suggerisci nomi per i volti", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Suggerisce chi c'è in una foto a partire dai volti a cui hai dato un nome. Tutto resta sul tuo computer.", + "Test Face Model": "Testa il modello dei volti", + "This build cannot run recognition models: they can be added and chosen, not used.": "Questa versione non può eseguire modelli di riconoscimento: si possono aggiungere e scegliere, non usare.", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli dei volti, quindi l'elenco dei modelli non è disponibile. (L'app desktop ce l'ha.)", + "Unnamed Faces": "Volti senza nome" +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 109accfa9..0ae41bb0c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -126,6 +126,7 @@ language_table! { Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); Fr, "fr", "Français", "Latn", include_str!("../locales/fr.json"); Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); + It, "it", "Italiano", "Latn", include_str!("../locales/it.json"); } // The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a @@ -667,6 +668,7 @@ mod tests { ("app.language.german", Locale::De), ("app.language.russian", Locale::Ru), ("app.language.ukrainian", Locale::Uk), + ("app.language.italian", Locale::It), ]; // One command per language, and every command reachable from the menu table. assert_eq!(commands.len(), Locale::ALL.len()); @@ -921,6 +923,109 @@ mod tests { set_language(Locale::En); } + /// Italian's coverage of every catalog message and display label, reported like Ukrainian's. + #[test] + fn italian_coverage_of_catalogs_commands_controls_and_rules_is_reported() { + let catalog = Locale::It.catalog(); + let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); + let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); + if !lacking.is_empty() { + eprintln!("it lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); + } + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); + for (_, label, kind) in lightcraft_catalog::rules::FIELDS { + labels.push(label); + labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); + } + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); + if !missing.is_empty() { + eprintln!("it lacks {} display label(s) (shown in English): {missing:?}", missing.len()); + } + } + + #[test] + fn italian_switches_persists_formats_dates_and_preserves_values() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.italian", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::It); + assert_eq!(crate::menubar::checked(&app, "app.language.italian"), Some(true)); + assert_eq!(tr("Light"), "Luce"); + assert_eq!(tr("Landscape"), "Paesaggio"); + assert_eq!(tr("Home"), "Cartella personale"); + assert_eq!(date_group_label("2026-09-20", false), "Domenica 20/09/2026"); + assert_eq!(date_group_label("2026-09", false), "09/2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Domenica 20/09/2026 16:04:05"); + // Italian agrees adjectives with the count ("1 foto importata", "2 foto importate"), so the + // count follows a label instead, which reads right for every number; the English plural + // suffix is consumed and never shows. + for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] { + assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Foto importate: {n}")); + assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("{n} file, cartelle: {n}")); + let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" }); + let expected = if n == 1 { "1 immagine in cache".to_string() } else { format!("{n} immagini in cache") }; + assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), expected); + } + // This trailing argument is queue progress, not an English plural suffix. + let queued = tr_format!(" · {queued} waiting", queued = 5); + assert_eq!( + tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4), + "Riduzione disturbo di IMG.CR3: 1 di 4 riquadri · in attesa: 5" + ); + let name = "Color {title} 日本語"; + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Foto aggiunte a “{name}”: 22")); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(builtin_label("Warm Glow", true), "Bagliore caldo"); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"it""#)); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::It); + let ctx = egui::Context::default(); + for code in ["it", "it-IT", "it_IT.UTF-8", "it-Latn-IT", "it-CH"] { + assert_eq!(Locale::parse_tag(code), Some(Locale::It)); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::It); + } + set_language(Locale::En); + } + + #[test] + fn italian_panels_dialogs_and_letters_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::It); + assert!(text.contains("Le mie foto") && text.contains("Tutte le foto"), "{text}"); + for (command, title) in + [("app.about", "Informazioni su LightCraft"), ("app.shortcuts", "Scorciatoie da tastiera"), ("app.settings", "Impostazioni")] + { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::It); + assert!(text.contains(title), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "àèéìòùÀÈÉÌÒÙ’“”«»" + .chars() + .chain(Locale::It.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch))) + { + assert!(fonts.has_glyph(&font, ch), "missing Italian glyph {ch}"); + } + } + }); + set_language(Locale::En); + } + /// Every command, control, rule and rename label, and every line of What's New, is a message a /// catalog can translate. What a language lacks is reported, not failed (like /// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every @@ -1062,7 +1167,7 @@ mod tests { } #[test] - fn german_and_ukrainian_buttons_fit_the_bottom_bar_and_export_dialog() { + fn german_ukrainian_and_italian_buttons_fit_the_bottom_bar_and_export_dialog() { fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) { match shape { egui::epaint::Shape::Text(shape) => { @@ -1072,7 +1177,7 @@ mod tests { _ => {} } } - for language in [Locale::De, Locale::Uk] { + for language in [Locale::De, Locale::Uk, Locale::It] { let ctx = fonts_ctx(&[]); let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() }; let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..cf635c429 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -24,6 +24,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), + ("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"), ]; /// Every UI command: the languages, then everything else. `xtask parity` reads both tables from @@ -46,6 +47,7 @@ pub fn language_from_command(id: &str) -> Option { "app.language.german" => Some(crate::i18n::Locale::De), "app.language.russian" => Some(crate::i18n::Locale::Ru), "app.language.ukrainian" => Some(crate::i18n::Locale::Uk), + "app.language.italian" => Some(crate::i18n::Locale::It), _ => None, } } diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 2d691112a..9a41a47a1 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -617,13 +617,13 @@ mod tests { "Export Keywords…", ]; let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - for code in ["de", "es", "fr", "pt-br", "ru", "uk", "ja", "zh-hans", "zh-hant"] { + for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ja", "zh-hans", "zh-hant"] { let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap(); let catalog: std::collections::HashMap = serde_json::from_str(&text).unwrap(); for label in labels { assert!(catalog.contains_key(label), "{code}: “{label}”"); } } - assert_eq!(Locale::ALL.len(), 10, "a language added: list its file above"); + assert_eq!(Locale::ALL.len(), 11, "a language added: list its file above"); } } diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 3c7261e20..1991d2a6d 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -103,7 +103,10 @@ pub(super) fn choices(ui: &mut egui::Ui, id: &str, options: fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, crate::i18n::tr("Language"), |ui| { let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect(); - choices(ui, "settingsLanguage", &languages, &mut app.ui.language); + // One button per language: wrap rather than widen the dialog as languages are added. + ui.horizontal_wrapped(|ui| { + choices(ui, "settingsLanguage", &languages, &mut app.ui.language); + }); crate::i18n::set_language(app.ui.language); }); heading(ui, t, crate::i18n::tr("Library")); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 18aae618a..a1ae77b6d 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language measurement from the catalogs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Italian interface catalog added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How far each interface language is from `full`. How to add or maintain a language is in [localization.md](localization.md); per-language notes are in `localization-.md` @@ -58,15 +58,16 @@ that is promising but is not recorded as review, so the column says `no (communi | 11 | Korean | `ko` | 0 (0%) | none | **no Korean (Hangul) face**: needs a non-Adobe OFL face in craft-fonts (Noto CJK is allowed only for Chinese) | no | none | 14–22 h | | 12 | Vietnamese | `vi` | 0 (0%) | none | Latin with stacked diacritics (Inter covers it; check combining marks) | no | none (target: not shipped) | 10–16 h | -## Other languages LightCraft ships (3) +## Other languages LightCraft ships (4) | Language | Code | UI strings translated | Script | Native review | Status | To `full` | |---|---|---:|---|---|---|---:| | Traditional Chinese (Taiwan) | `zh-hant` | 1,993 / 2,304 (87%) | Hant; uses the Simplified Chinese face until craft-fonts has a TC face | no (community edits) | partial | 8–14 h (incl. the font) | | Russian | `ru` | 1,704 / 2,304 (74%) | Cyrillic (Inter) | no (community edits) | partial | 8–12 h | | Ukrainian | `uk` | 2,287 / 2,304 (99%) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h | +| Italian | `it` | 2,201 / 2,398 (92%, measured 2026-10-12; the rest is What's New) | Latin (Inter) | no (written with an agent; native-speaker pass pending) | partial (lower-layer errors English) | 3–5 h | -Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai +Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai (Thai needs shaping and line breaking without spaces). Each Latin-script one is ~10–16 h; Thai ~20–35 h on top of the shaping work. @@ -81,12 +82,13 @@ Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norw ## Totals -- Bring the 9 shipped non-English catalogs to `full`: ~55–90 h (incl. the shared work). +- Bring the 10 shipped non-English catalogs to `full`: ~55–90 h (incl. the shared work). - Add the 5 missing key languages: ~145–255 h, most of it RTL and complex shaping in egui. -- Reach the target's 16 languages: + ~60–100 h (Italian, Dutch, Polish, Swedish, Norwegian, Thai). +- Reach the target's 16 languages: + ~50–85 h (Dutch, Polish, Swedish, Norwegian, Thai). ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Italian interface catalog (`it`): 2,201 of 2,398 messages, What's New left in English | | 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates | diff --git a/docs/localization.md b/docs/localization.md index 715b871af..25959ffbf 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -30,6 +30,7 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h De, "de", "Deutsch", "Latn", include_str!("../locales/de.json"); Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); + It, "it", "Italiano", "Latn", include_str!("../locales/it.json"); } ``` From 4f1ed6df652651464b68f01c1bc453f032d0257d Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Mon, 12 Oct 2026 14:21:42 +0530 Subject: [PATCH 09/52] LRC-PRINT-LAYOUT: the print layout model (paper, margins, grid, package cells, packer, snapping) crates/engine/src/print/layout.rs is the pure page model the Print module builds on: ten named papers plus custom, orientation, margins and the printable area; Single Image, Contact Sheet (grid with spacing and square cells), Picture Package and Custom Package styles; package cells with a stacking order (send to back / backward / forward / bring to front); a deterministic MaxRects packer behind Auto Layout (largest first, earlier pages filled before a new one opens, a cell turned a quarter only when it fits nowhere as given); snap-to-cells and snap-to-grid helpers; ruler units and guide options. All lengths are inches in page space, after orientation. Every number from outside is checked (NaN, infinity, negative, oversized, cell and page caps) and Layout::validate guards saved layouts. No command or UI reaches it yet, so the parity rows keep their status. --- crates/engine/src/lib.rs | 1 + crates/engine/src/print/layout.rs | 1300 +++++++++++++++++++++++++++++ crates/engine/src/print/mod.rs | 4 + docs/gaps.md | 3 +- docs/parity-checklist.md | 7 +- 5 files changed, 1311 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/print/layout.rs create mode 100644 crates/engine/src/print/mod.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc6c431e..21fc30e46 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -56,6 +56,7 @@ pub mod originals; pub mod preset_import; pub mod preset_luminar; pub mod presets; +pub mod print; pub mod proof; pub mod rename; pub mod segment; diff --git a/crates/engine/src/print/layout.rs b/crates/engine/src/print/layout.rs new file mode 100644 index 000000000..8a9e1a027 --- /dev/null +++ b/crates/engine/src/print/layout.rs @@ -0,0 +1,1300 @@ +//! The Print module's page layout model: paper, margins, layout styles and cells. +//! +//! Pure data and geometry, no rendering. Every length is in **inches** in page space: the origin +//! is the top-left corner of the page *as printed* (after orientation), x grows right and y down. +//! Rulers show other units ([`Unit`]) but convert at the edge; the model never stores them. +//! +//! Four layout styles ([`Style`]): +//! * `singleImage`: one photo fills the printable area of each page; +//! * `contactSheet`: a grid of identical cells ([`GridSpec`]), the photos paginated over pages; +//! * `picturePackage`: one photo at several cell sizes, packed onto pages by [`Layout::auto_layout`]; +//! * `customPackage`: free placement of cells (overlap allowed, [`Stack`] order), each with its own photo. +//! +//! Package cells live in [`Layout::cells`] in stacking order (later = on top). Everything that +//! arrives from outside (commands, saved templates) passes through [`Layout::validate`]; all +//! setters reject NaN, infinite, negative and oversized numbers instead of storing them. +use serde::{Deserialize, Serialize}; + +/// Largest page edge accepted, inches. +pub const MAX_PAGE_IN: f64 = 60.0; +/// Smallest page edge accepted, inches. +pub const MIN_PAGE_IN: f64 = 1.0; +/// Smallest cell edge accepted, inches. +pub const MIN_CELL_IN: f64 = 0.1; +/// Package layouts hold at most this many cells. +pub const MAX_CELLS: usize = 200; +/// Package layouts span at most this many pages. +pub const MAX_PAGES: u32 = 50; +/// Largest margin accepted, inches. +pub const MAX_MARGIN_IN: f64 = 10.0; +/// The printable area must stay at least this wide and tall, inches. +pub const MIN_PRINTABLE_IN: f64 = 0.5; +/// Largest grid dimension of a contact sheet. +pub const MAX_GRID: usize = 20; +/// Geometry tolerance, inches (far below one printed pixel at any supported resolution). +pub const EPS: f64 = 1e-6; + +/// Paper sizes by name: portrait width and height in millimetres (ISO) or inches (US/photo). +const PAPER: &[(&str, f64, f64)] = &[ + ("a3", 297.0 / 25.4, 420.0 / 25.4), + ("a4", 210.0 / 25.4, 297.0 / 25.4), + ("a5", 148.0 / 25.4, 210.0 / 25.4), + ("letter", 8.5, 11.0), + ("legal", 8.5, 14.0), + ("tabloid", 11.0, 17.0), + ("4x6", 4.0, 6.0), + ("5x7", 5.0, 7.0), + ("8x10", 8.0, 10.0), + ("13x19", 13.0, 19.0), +]; + +/// Names accepted by [`PageSetup::paper`] (besides `custom`). +pub fn paper_names() -> impl Iterator { + PAPER.iter().map(|p| p.0) +} + +/// Portrait size in inches of a named paper (case-insensitive), `None` for unknown names and `custom`. +pub fn paper_size(name: &str) -> Option<(f64, f64)> { + let name = name.to_ascii_lowercase(); + PAPER.iter().find(|p| p.0 == name).map(|p| (p.1, p.2)) +} + +/// An axis-aligned rectangle in inches. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +impl Rect { + pub const fn new(x: f64, y: f64, w: f64, h: f64) -> Self { + Self { x, y, w, h } + } + pub fn right(&self) -> f64 { + self.x + self.w + } + pub fn bottom(&self) -> f64 { + self.y + self.h + } + pub fn area(&self) -> f64 { + self.w * self.h + } + pub fn is_finite(&self) -> bool { + self.x.is_finite() && self.y.is_finite() && self.w.is_finite() && self.h.is_finite() + } + /// Whether the interiors overlap by more than [`EPS`] on both axes (touching edges do not count). + pub fn overlaps(&self, o: &Rect) -> bool { + self.x < o.right() - EPS && o.x < self.right() - EPS && self.y < o.bottom() - EPS && o.y < self.bottom() - EPS + } + /// Whether `o` lies entirely inside `self`, within [`EPS`]. + pub fn contains(&self, o: &Rect) -> bool { + o.x >= self.x - EPS && o.y >= self.y - EPS && o.right() <= self.right() + EPS && o.bottom() <= self.bottom() + EPS + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Orientation { + #[default] + Portrait, + Landscape, +} + +/// Ruler and dimension units. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Unit { + #[default] + #[serde(rename = "in")] + Inches, + #[serde(rename = "cm")] + Centimeters, + #[serde(rename = "mm")] + Millimeters, + #[serde(rename = "pt")] + Points, +} + +impl Unit { + pub fn per_inch(self) -> f64 { + match self { + Unit::Inches => 1.0, + Unit::Centimeters => 2.54, + Unit::Millimeters => 25.4, + Unit::Points => 72.0, + } + } + pub fn from_inches(self, inches: f64) -> f64 { + inches * self.per_inch() + } + pub fn to_inches(self, value: f64) -> f64 { + value / self.per_inch() + } + pub fn label(self) -> &'static str { + match self { + Unit::Inches => "in", + Unit::Centimeters => "cm", + Unit::Millimeters => "mm", + Unit::Points => "pt", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Margins { + pub top: f64, + pub right: f64, + pub bottom: f64, + pub left: f64, +} + +impl Default for Margins { + fn default() -> Self { + Self::uniform(0.25) + } +} + +impl Margins { + pub const fn uniform(v: f64) -> Self { + Self { top: v, right: v, bottom: v, left: v } + } +} + +/// Paper, orientation and margins (Lightroom's Page Setup + the Page margins). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PageSetup { + /// A name from [`paper_names`] or `custom`. + pub paper: String, + /// Portrait size of a `custom` paper, inches. + pub custom_width: f64, + pub custom_height: f64, + pub orientation: Orientation, + pub margins: Margins, +} + +impl Default for PageSetup { + fn default() -> Self { + Self { paper: "letter".into(), custom_width: 8.5, custom_height: 11.0, orientation: Orientation::Portrait, margins: Margins::default() } + } +} + +fn check(v: f64, what: &str, min: f64, max: f64) -> Result { + if v.is_finite() && v >= min && v <= max { Ok(v) } else { Err(format!("{what} must be a number from {min} to {max}")) } +} + +impl PageSetup { + /// Page width and height in inches after orientation. + pub fn size(&self) -> Result<(f64, f64), String> { + let (w, h) = if self.paper.eq_ignore_ascii_case("custom") { + ( + check(self.custom_width, "custom width", MIN_PAGE_IN, MAX_PAGE_IN)?, + check(self.custom_height, "custom height", MIN_PAGE_IN, MAX_PAGE_IN)?, + ) + } else { + paper_size(&self.paper) + .ok_or_else(|| format!("unknown paper `{}` (one of {} or custom)", self.paper, paper_names().collect::>().join(", ")))? + }; + Ok(match self.orientation { + Orientation::Portrait => (w, h), + Orientation::Landscape => (h, w), + }) + } + + /// The area inside the margins. + pub fn printable(&self) -> Result { + let (w, h) = self.size()?; + let m = &self.margins; + let (t, r, b, l) = ( + check(m.top, "top margin", 0.0, MAX_MARGIN_IN)?, + check(m.right, "right margin", 0.0, MAX_MARGIN_IN)?, + check(m.bottom, "bottom margin", 0.0, MAX_MARGIN_IN)?, + check(m.left, "left margin", 0.0, MAX_MARGIN_IN)?, + ); + let (pw, ph) = (w - l - r, h - t - b); + if pw < MIN_PRINTABLE_IN || ph < MIN_PRINTABLE_IN { + return Err(format!("margins leave less than {MIN_PRINTABLE_IN} in of printable area")); + } + Ok(Rect::new(l, t, pw, ph)) + } + + /// The page size in PDF points (1/72 in). + pub fn size_points(&self) -> Result<(f64, f64), String> { + let (w, h) = self.size()?; + Ok((w * 72.0, h * 72.0)) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Style { + #[default] + SingleImage, + ContactSheet, + PicturePackage, + CustomPackage, +} + +impl Style { + /// Styles whose cells are listed in [`Layout::cells`]. + pub fn is_package(self) -> bool { + matches!(self, Style::PicturePackage | Style::CustomPackage) + } +} + +/// The contact-sheet grid. `singleImage` ignores it (one cell per page, no spacing). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct GridSpec { + pub columns: usize, + pub rows: usize, + /// Gap between columns / rows, inches. + pub spacing_x: f64, + pub spacing_y: f64, + /// Make the cells square (the grid is centred in the printable area). + pub keep_square: bool, +} + +impl Default for GridSpec { + fn default() -> Self { + Self { columns: 3, rows: 4, spacing_x: 0.1, spacing_y: 0.1, keep_square: false } + } +} + +/// One cell of a package layout. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct Cell { + pub id: u32, + /// Zero-based page the cell is on. + pub page: u32, + pub rect: Rect, + /// The photo printed in the cell (Custom Package); `None` uses the job's first photo. + /// Not part of a saved template. + #[serde(skip_serializing_if = "Option::is_none")] + pub photo: Option, +} + +/// Where a cell goes in the stacking order of its page. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Stack { + ToBack, + Backward, + Forward, + ToFront, +} + +/// How dragged cells snap. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Snap { + Off, + /// To the edges of the other cells and of the printable area. + #[default] + Cells, + /// To multiples of [`Guides::grid_size`] from the printable area's corner. + Grid, +} + +/// Rulers, grid and guides (Lightroom's Guides panel). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct Guides { + pub show_rulers: bool, + pub units: Unit, + pub snap: Snap, + /// Grid pitch, inches. + pub grid_size: f64, + pub show_margins: bool, + pub show_bleed: bool, + pub show_page_grid: bool, + pub show_cells: bool, + pub show_dimensions: bool, +} + +impl Default for Guides { + fn default() -> Self { + Self { + show_rulers: true, + units: Unit::Inches, + snap: Snap::Cells, + grid_size: 0.25, + show_margins: true, + show_bleed: false, + show_page_grid: false, + show_cells: true, + show_dimensions: false, + } + } +} + +impl Guides { + pub fn validate(&self) -> Result<(), String> { + check(self.grid_size, "grid size", 0.05, 10.0).map(|_| ()) + } +} + +/// The page layout: setup, style and cells. See the module docs for the units and style rules. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct Layout { + pub style: Style, + pub page: PageSetup, + pub grid: GridSpec, + /// Gap kept between cells by [`Layout::auto_layout`] and new cells, inches. + pub spacing: f64, + /// Pages of a package layout (at least 1 and past every cell). + pub pages: u32, + /// Package cells in stacking order, back to front. + pub cells: Vec, + next_id: u32, +} + +impl Default for Layout { + fn default() -> Self { + Self { + style: Style::SingleImage, + page: PageSetup::default(), + grid: GridSpec::default(), + spacing: 0.1, + pages: 1, + cells: Vec::new(), + next_id: 1, + } + } +} + +/// Free space on one page as a set of maximal rectangles (the MaxRects scheme). +struct Free(Vec); + +impl Free { + fn new(area: Rect) -> Self { + Self(vec![area]) + } + + /// Removes `used` from the free space. + fn occupy(&mut self, used: &Rect) { + let mut next = Vec::with_capacity(self.0.len() + 4); + for f in &self.0 { + if !f.overlaps(used) { + next.push(*f); + continue; + } + if used.x > f.x + EPS { + next.push(Rect::new(f.x, f.y, used.x - f.x, f.h)); + } + if used.right() < f.right() - EPS { + next.push(Rect::new(used.right(), f.y, f.right() - used.right(), f.h)); + } + if used.y > f.y + EPS { + next.push(Rect::new(f.x, f.y, f.w, used.y - f.y)); + } + if used.bottom() < f.bottom() - EPS { + next.push(Rect::new(f.x, used.bottom(), f.w, f.bottom() - used.bottom())); + } + } + // Drop rectangles contained in another (the first of two equal ones survives). + let mut keep: Vec = Vec::with_capacity(next.len()); + for (i, r) in next.iter().enumerate() { + let dominated = next.iter().enumerate().any(|(j, o)| i != j && o.contains(r) && (!r.contains(o) || j < i)); + if !dominated { + keep.push(*r); + } + } + self.0 = keep; + } + + /// The best spot for a `w` x `h` block: least leftover on the short side, then the long side, + /// then top-most and left-most. The block is turned a quarter (when `rotate`) only if it fits + /// nowhere as given, so cells keep their shape whenever they can. Returns the spot and + /// whether the block was turned. + fn best_fit(&self, w: f64, h: f64, rotate: bool) -> Option<(Rect, bool)> { + self.fit_block(w, h) + .map(|r| (r, false)) + .or_else(|| if rotate && (w - h).abs() >= EPS { self.fit_block(h, w).map(|r| (r, true)) } else { None }) + } + + fn fit_block(&self, bw: f64, bh: f64) -> Option { + let mut best: Option<((f64, f64, f64, f64), Rect)> = None; + let eq = |a: f64, b: f64| (a - b).abs() < EPS; + for f in &self.0 { + if bw > f.w + EPS || bh > f.h + EPS { + continue; + } + let (dw, dh) = (f.w - bw, f.h - bh); + let key = (dw.min(dh), dw.max(dh), f.y, f.x); + let better = best.as_ref().is_none_or(|(k, _)| { + if !eq(k.0, key.0) { + return key.0 < k.0; + } + if !eq(k.1, key.1) { + return key.1 < k.1; + } + if !eq(k.2, key.2) { + return key.2 < k.2; + } + key.3 < k.3 - EPS + }); + if better { + best = Some((key, Rect::new(f.x, f.y, bw, bh))); + } + } + best.map(|(_, r)| r) + } +} + +/// Where each block of [`pack`] went: page index, rectangle in page space and whether it was turned. +pub type Placement = (u32, Rect, bool); + +/// Packs blocks (width, height) onto pages of `area`, keeping `spacing` between them. +/// +/// Deterministic: blocks are placed largest first (area, then longer side, then input order) with +/// the MaxRects best-short-side-fit rule, filling earlier pages before opening a new one. Blocks +/// may be turned a quarter when `rotate`. Fails when a block cannot fit an empty page or the +/// result would need more than [`MAX_PAGES`] pages. The result is in input order. +pub fn pack(sizes: &[(f64, f64)], area: Rect, spacing: f64, rotate: bool) -> Result, String> { + if !area.is_finite() || area.w <= 0.0 || area.h <= 0.0 { + return Err("no printable area to pack into".into()); + } + let spacing = check(spacing, "spacing", 0.0, MAX_PAGE_IN)?; + if sizes.len() > MAX_CELLS { + return Err(format!("at most {MAX_CELLS} cells")); + } + for (w, h) in sizes { + check(*w, "cell width", MIN_CELL_IN, MAX_PAGE_IN)?; + check(*h, "cell height", MIN_CELL_IN, MAX_PAGE_IN)?; + } + // Each block carries `spacing` on its right and bottom; the area grows by the same so a block + // flush with the margin still fits. + let padded = Rect::new(area.x, area.y, area.w + spacing, area.h + spacing); + let mut order: Vec = (0..sizes.len()).collect(); + order.sort_by(|&a, &b| { + let (sa, sb) = (sizes.get(a).copied().unwrap_or_default(), sizes.get(b).copied().unwrap_or_default()); + (sb.0 * sb.1).total_cmp(&(sa.0 * sa.1)).then((sb.0.max(sb.1)).total_cmp(&sa.0.max(sa.1))).then(a.cmp(&b)) + }); + let mut pages: Vec = Vec::new(); + let mut out: Vec> = vec![None; sizes.len()]; + for i in order { + let (w, h) = sizes.get(i).copied().unwrap_or_default(); + let (bw, bh) = (w + spacing, h + spacing); + let mut placed = None; + for (p, free) in pages.iter().enumerate() { + if let Some(hit) = free.best_fit(bw, bh, rotate) { + placed = Some((p, hit)); + break; + } + } + let (p, (spot, turned)) = match placed { + Some(v) => v, + None => { + if pages.len() as u32 >= MAX_PAGES { + return Err(format!("the cells need more than {MAX_PAGES} pages")); + } + let fresh = Free::new(padded); + let hit = fresh.best_fit(bw, bh, rotate).ok_or_else(|| format!("a {w:.2} x {h:.2} in cell does not fit the printable area"))?; + pages.push(fresh); + (pages.len() - 1, hit) + } + }; + if let Some(free) = pages.get_mut(p) { + free.occupy(&spot); + } + let (cw, ch) = if turned { (h, w) } else { (w, h) }; + if let Some(slot) = out.get_mut(i) { + *slot = Some((p as u32, Rect::new(spot.x, spot.y, cw, ch), turned)); + } + } + out.into_iter().map(|p| p.ok_or_else(|| "internal packing error".to_string())).collect() +} + +/// Snaps a span (`start`, `len`) along one axis so its start or end lands on the nearest candidate +/// within `threshold`; unchanged when none is close. +fn snap_span(start: f64, len: f64, candidates: &[f64], threshold: f64) -> f64 { + let mut best: Option<(f64, f64)> = None; + for &c in candidates { + for shift in [c - start, c - (start + len)] { + if shift.abs() <= threshold && best.is_none_or(|(d, _)| shift.abs() < d) { + best = Some((shift.abs(), shift)); + } + } + } + start + best.map_or(0.0, |b| b.1) +} + +/// Snaps one coordinate (a dragged edge) to the nearest candidate within `threshold`. +fn snap_edge(v: f64, candidates: &[f64], threshold: f64) -> f64 { + candidates.iter().copied().filter(|c| (c - v).abs() <= threshold).min_by(|a, b| (a - v).abs().total_cmp(&(b - v).abs())).unwrap_or(v) +} + +impl Layout { + /// Page size in inches after orientation. + pub fn page_size(&self) -> Result<(f64, f64), String> { + self.page.size() + } + + /// The printable area (inside the margins). + pub fn printable(&self) -> Result { + self.page.printable() + } + + /// Checks every number and cross-reference; also repairs `next_id` so ids stay unique. + /// Run on anything that did not come from the setters (commands, templates). + pub fn validate(&mut self) -> Result<(), String> { + let area = self.page.printable()?; + check(self.spacing, "spacing", 0.0, 10.0)?; + if !(1..=MAX_GRID).contains(&self.grid.columns) || !(1..=MAX_GRID).contains(&self.grid.rows) { + return Err(format!("columns and rows must be 1 to {MAX_GRID}")); + } + check(self.grid.spacing_x, "column spacing", 0.0, 10.0)?; + check(self.grid.spacing_y, "row spacing", 0.0, 10.0)?; + for r in self.grid_rects_for(area)? { + if r.w < MIN_CELL_IN || r.h < MIN_CELL_IN { + return Err("the grid leaves cells smaller than 0.1 in".into()); + } + } + if self.cells.len() > MAX_CELLS { + return Err(format!("at most {MAX_CELLS} cells")); + } + if !(1..=MAX_PAGES).contains(&self.pages) { + return Err(format!("pages must be 1 to {MAX_PAGES}")); + } + let mut seen = std::collections::BTreeSet::new(); + for c in &self.cells { + if !seen.insert(c.id) { + return Err(format!("duplicate cell id {}", c.id)); + } + if c.page >= self.pages { + return Err(format!("cell {} is on page {} but the layout has {} pages", c.id, c.page + 1, self.pages)); + } + self.check_rect(&c.rect, area)?; + } + self.next_id = self.next_id.max(self.cells.iter().map(|c| c.id).max().unwrap_or(0).saturating_add(1)).max(1); + Ok(()) + } + + fn check_rect(&self, r: &Rect, area: Rect) -> Result<(), String> { + if !r.is_finite() || r.w < MIN_CELL_IN - EPS || r.h < MIN_CELL_IN - EPS { + return Err(format!("cells must be at least {MIN_CELL_IN} in wide and tall")); + } + if !area.contains(r) { + return Err("a cell lies outside the printable area".into()); + } + Ok(()) + } + + /// The cells of one grid page in reading order (`singleImage`: one cell filling the area). + pub fn grid_rects(&self) -> Result, String> { + self.grid_rects_for(self.page.printable()?) + } + + fn grid_rects_for(&self, area: Rect) -> Result, String> { + if self.style == Style::SingleImage { + return Ok(vec![area]); + } + let g = &self.grid; + let (cols, rows) = (g.columns.clamp(1, MAX_GRID), g.rows.clamp(1, MAX_GRID)); + let gaps_x = g.spacing_x * (cols - 1) as f64; + let gaps_y = g.spacing_y * (rows - 1) as f64; + let mut cw = (area.w - gaps_x) / cols as f64; + let mut ch = (area.h - gaps_y) / rows as f64; + if !(cw > 0.0 && ch > 0.0) { + return Err("the grid spacing leaves no room for cells".into()); + } + let (mut ox, mut oy) = (area.x, area.y); + if g.keep_square { + let side = cw.min(ch); + ox += (cw - side) * cols as f64 / 2.0; + oy += (ch - side) * rows as f64 / 2.0; + cw = side; + ch = side; + } + let mut out = Vec::with_capacity(cols * rows); + for r in 0..rows { + for c in 0..cols { + out.push(Rect::new(ox + c as f64 * (cw + g.spacing_x), oy + r as f64 * (ch + g.spacing_y), cw, ch)); + } + } + Ok(out) + } + + /// Cells per page of a grid style; 1 for the package styles. + pub fn cells_per_page(&self) -> usize { + match self.style { + Style::SingleImage => 1, + Style::ContactSheet => self.grid.columns.clamp(1, MAX_GRID) * self.grid.rows.clamp(1, MAX_GRID), + Style::PicturePackage | Style::CustomPackage => 1, + } + } + + /// Pages a print of `photos` photos needs: the photos over the grid for the grid styles, the + /// layout's own pages for the packages. + pub fn page_count(&self, photos: usize) -> usize { + match self.style { + Style::SingleImage | Style::ContactSheet => photos.max(1).div_ceil(self.cells_per_page()), + Style::PicturePackage | Style::CustomPackage => self.pages.max(1) as usize, + } + } + + pub fn cell(&self, id: u32) -> Option<&Cell> { + self.cells.iter().find(|c| c.id == id) + } + + fn index(&self, id: u32) -> Result { + self.cells.iter().position(|c| c.id == id).ok_or_else(|| format!("no cell {id}")) + } + + /// Cells of one page, back to front. + pub fn cells_on_page(&self, page: u32) -> impl Iterator { + self.cells.iter().filter(move |c| c.page == page) + } + + /// Sets the layout style. Entering a package style from a grid style keeps existing cells. + pub fn set_style(&mut self, style: Style) { + self.style = style; + } + + fn require_package(&self) -> Result<(), String> { + if self.style.is_package() { Ok(()) } else { Err("cells exist only in the Picture Package and Custom Package styles".into()) } + } + + /// Adds a `w` x `h` in cell at the first free spot of the earliest page with room (a new page + /// when none has), and returns its id. A cell too large for the printable area is refused. + pub fn add_cell(&mut self, w: f64, h: f64) -> Result { + self.require_package()?; + let area = self.printable()?; + check(w, "cell width", MIN_CELL_IN, MAX_PAGE_IN)?; + check(h, "cell height", MIN_CELL_IN, MAX_PAGE_IN)?; + if self.cells.len() >= MAX_CELLS { + return Err(format!("at most {MAX_CELLS} cells")); + } + if w > area.w + EPS || h > area.h + EPS { + return Err(format!("a {w:.2} x {h:.2} in cell is larger than the printable area ({:.2} x {:.2} in)", area.w, area.h)); + } + let padded = Rect::new(area.x, area.y, area.w + self.spacing, area.h + self.spacing); + let spot = (0..self.pages).find_map(|p| { + let mut free = Free::new(padded); + for c in self.cells_on_page(p) { + free.occupy(&Rect::new(c.rect.x, c.rect.y, c.rect.w + self.spacing, c.rect.h + self.spacing)); + } + free.best_fit(w + self.spacing, h + self.spacing, false).map(|(r, _)| (p, r)) + }); + let (page, at) = match spot { + Some(v) => v, + None => { + if self.pages >= MAX_PAGES { + return Err(format!("a layout holds at most {MAX_PAGES} pages")); + } + self.pages += 1; + (self.pages - 1, Rect::new(area.x, area.y, w, h)) + } + }; + let id = self.take_id(); + self.cells.push(Cell { id, page, rect: Rect::new(at.x, at.y, w, h), photo: None }); + Ok(id) + } + + fn take_id(&mut self) -> u32 { + let id = self.next_id.max(1); + self.next_id = id.saturating_add(1); + id + } + + /// Moves a cell so its top-left corner is at (`x`, `y`), kept inside the printable area. + pub fn move_cell(&mut self, id: u32, x: f64, y: f64) -> Result { + self.require_package()?; + let area = self.printable()?; + if !x.is_finite() || !y.is_finite() { + return Err("position must be finite".into()); + } + let i = self.index(id)?; + let cell = self.cells.get_mut(i).ok_or("no such cell")?; + let r = &mut cell.rect; + r.x = x.clamp(area.x, (area.right() - r.w).max(area.x)); + r.y = y.clamp(area.y, (area.bottom() - r.h).max(area.y)); + Ok(*r) + } + + /// Gives a cell a new rectangle (position and size), clamped into the printable area. + pub fn resize_cell(&mut self, id: u32, rect: Rect) -> Result { + self.require_package()?; + let area = self.printable()?; + if !rect.is_finite() { + return Err("rectangle must be finite".into()); + } + let w = check(rect.w, "cell width", MIN_CELL_IN, MAX_PAGE_IN)?.min(area.w); + let h = check(rect.h, "cell height", MIN_CELL_IN, MAX_PAGE_IN)?.min(area.h); + let i = self.index(id)?; + let cell = self.cells.get_mut(i).ok_or("no such cell")?; + cell.rect = Rect::new(rect.x.clamp(area.x, (area.right() - w).max(area.x)), rect.y.clamp(area.y, (area.bottom() - h).max(area.y)), w, h); + Ok(cell.rect) + } + + pub fn delete_cell(&mut self, id: u32) -> Result<(), String> { + let i = self.index(id)?; + self.cells.remove(i); + Ok(()) + } + + /// Removes every cell and extra page (Lightroom's Clear Layout). + pub fn clear(&mut self) { + self.cells.clear(); + self.pages = 1; + } + + /// Appends an empty page to a package layout and returns its zero-based index. + pub fn new_page(&mut self) -> Result { + self.require_package()?; + if self.pages >= MAX_PAGES { + return Err(format!("a layout holds at most {MAX_PAGES} pages")); + } + self.pages += 1; + Ok(self.pages - 1) + } + + /// Moves a cell to another page, keeping its rectangle (clamped inside the printable area). + pub fn set_cell_page(&mut self, id: u32, page: u32) -> Result<(), String> { + self.require_package()?; + if page >= self.pages { + return Err(format!("the layout has {} pages", self.pages)); + } + let i = self.index(id)?; + if let Some(c) = self.cells.get_mut(i) { + c.page = page; + } + Ok(()) + } + + /// Assigns (or with `None` clears) the photo printed in a cell. + pub fn set_cell_photo(&mut self, id: u32, photo: Option) -> Result<(), String> { + let i = self.index(id)?; + if let Some(c) = self.cells.get_mut(i) { + c.photo = photo; + } + Ok(()) + } + + /// Changes a cell's place in the stacking order of its page (Send to Back, Backward, Forward, + /// Bring to Front). Cells on other pages are not moved past. + pub fn restack(&mut self, id: u32, how: Stack) -> Result<(), String> { + let i = self.index(id)?; + let page = self.cells.get(i).map(|c| c.page).ok_or("no such cell")?; + let same: Vec = self.cells.iter().enumerate().filter(|(_, c)| c.page == page).map(|(k, _)| k).collect(); + let pos = same.iter().position(|&k| k == i).ok_or("no such cell")?; + let target = match how { + Stack::ToBack => 0, + Stack::Backward => pos.saturating_sub(1), + Stack::Forward => (pos + 1).min(same.len().saturating_sub(1)), + Stack::ToFront => same.len().saturating_sub(1), + }; + if target == pos { + return Ok(()); + } + let cell = self.cells.remove(i); + // Index in the shortened vector where the target neighbour now sits. + let at = same.get(target).map(|&k| if k > i { k - 1 } else { k }).ok_or("no such cell")?; + let at = if target > pos { at + 1 } else { at }; + self.cells.insert(at.min(self.cells.len()), cell); + Ok(()) + } + + /// Re-packs every cell of the package onto as few pages as possible (Auto Layout). Cells keep + /// their sizes (a cell may be turned a quarter to fit) and their stacking order; their + /// photos are untouched. Fails, changing nothing, when a cell cannot fit the printable area. + pub fn auto_layout(&mut self) -> Result<(), String> { + self.require_package()?; + let area = self.printable()?; + let sizes: Vec<(f64, f64)> = self.cells.iter().map(|c| (c.rect.w, c.rect.h)).collect(); + let placed = pack(&sizes, area, self.spacing, true)?; + let mut pages = 1; + for (c, (page, rect, _)) in self.cells.iter_mut().zip(placed) { + c.page = page; + c.rect = rect; + pages = pages.max(page + 1); + } + self.pages = pages; + Ok(()) + } + + /// Whether two cells on the same page overlap. + pub fn has_overlaps(&self) -> bool { + self.cells.iter().enumerate().any(|(i, a)| self.cells.iter().skip(i + 1).any(|b| a.page == b.page && a.rect.overlaps(&b.rect))) + } + + /// Snap candidates along each axis for cell `skip` on `page`: edges of other cells and of the + /// printable area, or grid lines. + fn candidates(&self, page: u32, skip: Option, snap: Snap, grid: f64) -> Result<(Vec, Vec), String> { + let area = self.printable()?; + let (mut xs, mut ys) = (Vec::new(), Vec::new()); + match snap { + Snap::Off => {} + Snap::Cells => { + xs.extend([area.x, area.right()]); + ys.extend([area.y, area.bottom()]); + for c in self.cells_on_page(page).filter(|c| Some(c.id) != skip) { + xs.extend([c.rect.x, c.rect.right()]); + ys.extend([c.rect.y, c.rect.bottom()]); + } + } + Snap::Grid => { + let grid = check(grid, "grid size", 0.05, 10.0)?; + let n = |len: f64| (len / grid).floor().min(4000.0) as usize; + xs.extend((0..=n(area.w)).map(|k| area.x + k as f64 * grid)); + ys.extend((0..=n(area.h)).map(|k| area.y + k as f64 * grid)); + xs.push(area.right()); + ys.push(area.bottom()); + } + } + Ok((xs, ys)) + } + + /// Snaps a cell being dragged: `rect` keeps its size, and its left or right edge (likewise top + /// or bottom) lands on a candidate within `threshold` inches. + pub fn snap_move(&self, id: u32, rect: Rect, snap: Snap, grid: f64, threshold: f64) -> Result { + if !rect.is_finite() || !threshold.is_finite() { + return Err("rectangle must be finite".into()); + } + let page = self.cell(id).map(|c| c.page).ok_or_else(|| format!("no cell {id}"))?; + let (xs, ys) = self.candidates(page, Some(id), snap, grid)?; + let area = self.printable()?; + let x = snap_span(rect.x, rect.w, &xs, threshold.max(0.0)); + let y = snap_span(rect.y, rect.h, &ys, threshold.max(0.0)); + Ok(Rect::new(x.clamp(area.x, (area.right() - rect.w).max(area.x)), y.clamp(area.y, (area.bottom() - rect.h).max(area.y)), rect.w, rect.h)) + } + + /// Snaps a cell being resized by its bottom-right corner: the right and bottom edges land on + /// candidates within `threshold`, the top-left corner stays. + pub fn snap_resize(&self, id: u32, rect: Rect, snap: Snap, grid: f64, threshold: f64) -> Result { + if !rect.is_finite() || !threshold.is_finite() { + return Err("rectangle must be finite".into()); + } + let page = self.cell(id).map(|c| c.page).ok_or_else(|| format!("no cell {id}"))?; + let (xs, ys) = self.candidates(page, Some(id), snap, grid)?; + let right = snap_edge(rect.right(), &xs, threshold.max(0.0)); + let bottom = snap_edge(rect.bottom(), &ys, threshold.max(0.0)); + Ok(Rect::new(rect.x, rect.y, (right - rect.x).max(MIN_CELL_IN), (bottom - rect.y).max(MIN_CELL_IN))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn package() -> Layout { + Layout { style: Style::PicturePackage, ..Default::default() } + } + + fn assert_clean(l: &Layout) { + let area = l.printable().unwrap(); + for c in &l.cells { + assert!(area.contains(&c.rect), "{c:?} outside {area:?}"); + assert!(c.page < l.pages); + } + assert!(!l.has_overlaps(), "{:#?}", l.cells); + } + + #[test] + fn papers_and_orientation() { + let mut p = PageSetup::default(); + assert_eq!(p.size().unwrap(), (8.5, 11.0)); + p.orientation = Orientation::Landscape; + assert_eq!(p.size().unwrap(), (11.0, 8.5)); + assert_eq!(p.size_points().unwrap(), (792.0, 612.0)); + p.paper = "A4".into(); + let (w, h) = p.size().unwrap(); + assert!((w - 11.693).abs() < 1e-3 && (h - 8.268).abs() < 1e-3); + for n in paper_names() { + p.paper = n.into(); + assert!(p.printable().is_ok(), "{n}"); + } + p.paper = "custom".into(); + p.custom_width = 20.0; + p.custom_height = 30.0; + assert_eq!(p.size().unwrap(), (30.0, 20.0)); + p.paper = "poster".into(); + assert!(p.size().is_err()); + } + + #[test] + fn hostile_numbers_are_rejected() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 1e9] { + let p = PageSetup { paper: "custom".into(), custom_width: bad, ..Default::default() }; + assert!(p.size().is_err(), "{bad}"); + let p = PageSetup { margins: Margins { left: bad, ..Margins::default() }, ..Default::default() }; + assert!(p.printable().is_err(), "{bad}"); + let mut l = package(); + assert!(l.add_cell(bad, 2.0).is_err(), "{bad}"); + assert!(l.add_cell(2.0, bad).is_err()); + let id = l.add_cell(2.0, 2.0).unwrap(); + assert!(bad.is_finite() || l.move_cell(id, bad, 0.0).is_err()); + assert!(l.resize_cell(id, Rect::new(0.0, 0.0, bad, 1.0)).is_err()); + assert!(bad.is_finite() || l.snap_move(id, Rect::new(bad, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); + let mut g = Layout { spacing: bad, ..package() }; + assert!(g.validate().is_err()); + assert!(pack(&[(1.0, 1.0)], l.printable().unwrap(), bad, true).is_err()); + assert!(pack(&[(bad, 1.0)], l.printable().unwrap(), 0.0, true).is_err()); + } + // Margins that leave nothing. + let p = PageSetup { margins: Margins::uniform(4.5), ..Default::default() }; + assert!(p.printable().is_err()); + let mut l = Layout { grid: GridSpec { columns: 0, ..Default::default() }, ..Default::default() }; + assert!(l.validate().is_err()); + l.grid.columns = 21; + assert!(l.validate().is_err()); + } + + #[test] + fn units_convert() { + for u in [Unit::Inches, Unit::Centimeters, Unit::Millimeters, Unit::Points] { + assert!((u.to_inches(u.from_inches(3.5)) - 3.5).abs() < 1e-12); + } + assert!((Unit::Millimeters.from_inches(1.0) - 25.4).abs() < 1e-12); + assert_eq!(Unit::Points.from_inches(2.0), 144.0); + assert_eq!(serde_json::to_string(&Unit::Centimeters).unwrap(), "\"cm\""); + } + + #[test] + fn contact_grid_fills_the_printable_area() { + let l = Layout { + style: Style::ContactSheet, + grid: GridSpec { columns: 3, rows: 4, spacing_x: 0.2, spacing_y: 0.1, keep_square: false }, + ..Default::default() + }; + let area = l.printable().unwrap(); + let cells = l.grid_rects().unwrap(); + assert_eq!(cells.len(), 12); + let first = cells.first().unwrap(); + let last = cells.last().unwrap(); + assert!((first.x - area.x).abs() < 1e-9 && (first.y - area.y).abs() < 1e-9); + assert!((last.right() - area.right()).abs() < 1e-9 && (last.bottom() - area.bottom()).abs() < 1e-9); + for (i, a) in cells.iter().enumerate() { + assert!((a.w - first.w).abs() < 1e-12 && (a.h - first.h).abs() < 1e-12); + for b in cells.iter().skip(i + 1) { + assert!(!a.overlaps(b)); + } + } + // Square cells are centred. + let sq = Layout { grid: GridSpec { keep_square: true, ..l.grid }, ..l.clone() }; + let cells = sq.grid_rects().unwrap(); + assert!(cells.iter().all(|c| (c.w - c.h).abs() < 1e-12)); + let (lo, hi) = (cells.iter().map(|c| c.x).fold(f64::MAX, f64::min), cells.iter().map(|c| c.right()).fold(f64::MIN, f64::max)); + assert!(((lo - area.x) - (area.right() - hi)).abs() < 1e-9); + // Single image ignores the grid. + let one = Layout { style: Style::SingleImage, ..l }; + assert_eq!(one.grid_rects().unwrap(), vec![area]); + } + + #[test] + fn page_counts() { + let mut l = Layout { style: Style::ContactSheet, grid: GridSpec { columns: 2, rows: 2, ..Default::default() }, ..Default::default() }; + assert_eq!(l.page_count(0), 1); + assert_eq!(l.page_count(4), 1); + assert_eq!(l.page_count(5), 2); + l.style = Style::SingleImage; + assert_eq!(l.page_count(3), 3); + l.style = Style::PicturePackage; + l.pages = 4; + assert_eq!(l.page_count(99), 4); + } + + #[test] + fn packer_is_deterministic_and_clean() { + let area = Layout::default().printable().unwrap(); + let sizes: Vec<(f64, f64)> = + [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (3.5, 5.0), (8.0, 10.0), (4.0, 6.0), (2.0, 2.0), (1.0, 1.5), (5.0, 7.0), (4.0, 6.0)] + .to_vec(); + let a = pack(&sizes, area, 0.1, true).unwrap(); + assert_eq!(a, pack(&sizes, area, 0.1, true).unwrap()); + assert_eq!(a.len(), sizes.len()); + for (i, (p, r, turned)) in a.iter().enumerate() { + assert!(area.contains(r), "{r:?}"); + let (w, h) = sizes[i]; + let expect = if *turned { (h, w) } else { (w, h) }; + assert!((r.w - expect.0).abs() < 1e-9 && (r.h - expect.1).abs() < 1e-9); + for (j, (q, s, _)) in a.iter().enumerate().skip(i + 1) { + if p == q { + // The spacing is kept between neighbours. + let grown = Rect::new(r.x, r.y, r.w + 0.1 - 2e-6, r.h + 0.1 - 2e-6); + assert!(!grown.overlaps(s), "{i} and {j}: {r:?} {s:?}"); + } + } + } + // The input order does not hide a block that needs a page of its own. + assert!(a.iter().map(|p| p.0).max().unwrap() >= 1); + } + + #[test] + fn packer_rotates_only_when_needed_and_refuses_oversize() { + let area = Rect::new(0.25, 0.25, 8.0, 10.5); + // 10 x 5 only fits a portrait page turned. + let r = pack(&[(10.0, 5.0)], area, 0.0, true).unwrap(); + assert!(r[0].2 && (r[0].1.w - 5.0).abs() < 1e-9 && (r[0].1.h - 10.0).abs() < 1e-9); + assert!(pack(&[(10.0, 5.0)], area, 0.0, false).is_err()); + let r = pack(&[(4.0, 6.0)], area, 0.0, true).unwrap(); + assert!(!r[0].2, "a block that fits as given is not turned"); + assert!(pack(&[(9.0, 11.0)], area, 0.0, true).is_err()); + // A block that exactly fills the area fits despite the spacing. + assert!(pack(&[(8.0, 10.5)], area, 0.2, true).is_ok()); + assert!(pack(&vec![(1.0, 1.0); MAX_CELLS + 1], area, 0.0, true).is_err()); + // More pages than allowed. + assert!(pack(&vec![(8.0, 10.5); 51], area, 0.0, true).is_err()); + assert_eq!(pack(&vec![(8.0, 10.5); 50], area, 0.0, true).unwrap().iter().map(|p| p.0).max(), Some(49)); + } + + #[test] + fn packing_is_dense() { + // Four 4x5.25 cells tile a letter page's 8 x 10.5 area exactly. + let area = Rect::new(0.25, 0.25, 8.0, 10.5); + let r = pack(&[(4.0, 5.25); 4], area, 0.0, false).unwrap(); + assert!(r.iter().all(|p| p.0 == 0)); + // Eight 4x6 photos fit two pages (four per page with the rotation trick is impossible: 4x6 x2 per column). + let r = pack(&[(4.0, 6.0); 4], area, 0.0, true).unwrap(); + assert_eq!(r.iter().map(|p| p.0).max(), Some(1)); + } + + #[test] + fn add_cell_fills_gaps_then_opens_pages() { + let mut l = package(); + let a = l.add_cell(3.9, 6.0).unwrap(); + let b = l.add_cell(3.9, 6.0).unwrap(); + assert_eq!(l.cell(a).unwrap().rect.x, 0.25); + assert_ne!(l.cell(a).unwrap().rect, l.cell(b).unwrap().rect); + assert_clean(&l); + // Keep adding until a second page appears. + for _ in 0..6 { + l.add_cell(3.9, 6.0).unwrap(); + } + assert!(l.pages >= 2); + assert_clean(&l); + assert_eq!(l.cells.len(), 8); + assert!(l.add_cell(9.0, 9.0).is_err(), "wider than the printable area"); + let mut grid = Layout::default(); + assert!(grid.add_cell(1.0, 1.0).is_err()); + } + + #[test] + fn cell_cap() { + let mut l = package(); + for _ in 0..MAX_CELLS { + if l.add_cell(0.5, 0.5).is_err() { + break; + } + } + assert!(l.cells.len() <= MAX_CELLS); + let n = l.cells.len(); + if n == MAX_CELLS { + assert!(l.add_cell(0.5, 0.5).is_err()); + } + } + + #[test] + fn move_and_resize_stay_inside() { + let mut l = package(); + let id = l.add_cell(2.0, 3.0).unwrap(); + let r = l.move_cell(id, 100.0, -5.0).unwrap(); + let area = l.printable().unwrap(); + assert!((r.right() - area.right()).abs() < 1e-9 && (r.y - area.y).abs() < 1e-9); + let r = l.resize_cell(id, Rect::new(7.0, 9.0, 50.0, 50.0)).unwrap(); + assert!(area.contains(&r)); + assert!(l.resize_cell(id, Rect::new(0.0, 0.0, 0.0, 1.0)).is_err()); + assert!(l.move_cell(77, 0.0, 0.0).is_err()); + l.delete_cell(id).unwrap(); + assert!(l.delete_cell(id).is_err()); + } + + #[test] + fn stacking_order() { + let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; + let ids: Vec = (0..4).map(|_| l.add_cell(1.0, 1.0).unwrap()).collect(); + let order = |l: &Layout| l.cells.iter().map(|c| c.id).collect::>(); + assert_eq!(order(&l), ids); + l.restack(ids[0], Stack::ToFront).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::Backward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[0], ids[3]]); + l.restack(ids[0], Stack::Forward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::Forward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::ToBack).unwrap(); + assert_eq!(order(&l), ids); + l.restack(ids[0], Stack::Backward).unwrap(); + assert_eq!(order(&l), ids); + assert!(l.restack(99, Stack::ToBack).is_err()); + } + + #[test] + fn stacking_ignores_other_pages() { + let mut l = Layout { style: Style::CustomPackage, pages: 2, ..Default::default() }; + let a = l.add_cell(1.0, 1.0).unwrap(); + let b = l.add_cell(1.0, 1.0).unwrap(); + let c = l.add_cell(1.0, 1.0).unwrap(); + l.set_cell_page(b, 1).unwrap(); + l.restack(a, Stack::ToFront).unwrap(); + // `a` goes past `c` (same page) and not necessarily past `b`. + let pos = |id| l.cells.iter().position(|x| x.id == id).unwrap(); + assert!(pos(a) > pos(c)); + } + + #[test] + fn overlap_is_allowed_by_move_and_reported() { + let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; + let a = l.add_cell(3.0, 3.0).unwrap(); + let b = l.add_cell(3.0, 3.0).unwrap(); + assert!(!l.has_overlaps()); + let r = l.cell(a).unwrap().rect; + l.move_cell(b, r.x + 1.0, r.y + 1.0).unwrap(); + assert!(l.has_overlaps()); + // Touching edges are not an overlap. + l.move_cell(b, r.right(), r.y).unwrap(); + assert!(!l.has_overlaps()); + } + + #[test] + fn auto_layout_repacks_cleanly_and_keeps_photos() { + let mut l = package(); + for (w, h) in [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (8.0, 10.0), (4.0, 6.0), (3.5, 5.0)] { + l.add_cell(w, h).unwrap(); + } + let first = l.cells[0].id; + l.set_cell_photo(first, Some(42)).unwrap(); + // Scramble positions to overlap, then auto layout. + for c in &mut l.cells { + c.rect.x = 0.25; + c.rect.y = 0.25; + c.page = 0; + } + l.pages = 1; + assert!(l.has_overlaps()); + l.auto_layout().unwrap(); + assert_clean(&l); + assert_eq!(l.cell(first).unwrap().photo, Some(42)); + assert_eq!(l.cells.len(), 6); + let again = l.clone(); + let mut l2 = again.clone(); + l2.auto_layout().unwrap(); + l.auto_layout().unwrap(); + assert_eq!(l, l2); + } + + #[test] + fn auto_layout_failure_changes_nothing() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + l.cells.push(Cell { id: 50, page: 0, rect: Rect::new(0.25, 0.25, 40.0, 40.0), photo: None }); + let before = l.clone(); + assert!(l.auto_layout().is_err()); + assert_eq!(l, before); + } + + #[test] + fn clear_and_new_page() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + assert_eq!(l.new_page().unwrap(), 1); + assert_eq!(l.pages, 2); + l.clear(); + assert_eq!((l.cells.len(), l.pages), (0, 1)); + for _ in 1..MAX_PAGES { + l.new_page().unwrap(); + } + assert!(l.new_page().is_err()); + let mut g = Layout::default(); + assert!(g.new_page().is_err()); + } + + #[test] + fn validate_catches_bad_cells() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + let ok = l.clone(); + let mut bad = ok.clone(); + bad.cells[0].rect.w = f64::NAN; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells[0].page = 3; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells[0].rect.x = 50.0; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells.push(bad.cells[0].clone()); + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.pages = 0; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.next_id = 0; + bad.validate().unwrap(); + assert!(bad.next_id > bad.cells[0].id); + assert!(serde_json::from_str::(r#"{"surprise":1}"#).is_err()); + let mut round: Layout = serde_json::from_str(&serde_json::to_string(&ok).unwrap()).unwrap(); + round.validate().unwrap(); + assert_eq!(round, ok); + } + + #[test] + fn snapping_to_cells_grid_and_margins() { + let mut l = Layout { style: Style::CustomPackage, spacing: 0.0, ..Default::default() }; + let a = l.add_cell(3.0, 3.0).unwrap(); + let b = l.add_cell(2.0, 2.0).unwrap(); + let ra = l.cell(a).unwrap().rect; + // Left edge of b near the right edge of a. + let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.x - ra.right()).abs() < 1e-9); + assert_eq!(m.y, 5.0); + // Too far: untouched. + let m = l.snap_move(b, Rect::new(ra.right() + 0.5, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.x - (ra.right() + 0.5)).abs() < 1e-9); + // Right edge snaps to the printable margin. + let area = l.printable().unwrap(); + let m = l.snap_move(b, Rect::new(area.right() - 2.0 - 0.03, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.right() - area.right()).abs() < 1e-9); + // Off leaves everything. + let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Off, 0.25, 0.1).unwrap(); + assert!((m.x - (ra.right() + 0.04)).abs() < 1e-9); + // Grid snapping lands on multiples from the printable corner. + let m = l.snap_move(b, Rect::new(1.37, 2.62, 2.0, 2.0), Snap::Grid, 0.5, 0.2).unwrap(); + assert!(((m.x - area.x) / 0.5).fract().abs() < 1e-9 || (((m.x - area.x) / 0.5).fract() - 1.0).abs() < 1e-9); + // Resize snaps the dragged corner only. + let r = l.snap_resize(b, Rect::new(5.0, 5.0, 2.1, 1.9), Snap::Grid, 0.5, 0.2).unwrap(); + assert_eq!((r.x, r.y), (5.0, 5.0)); + assert!(((r.right() - area.x) / 0.5 - ((r.right() - area.x) / 0.5).round()).abs() < 1e-9); + // Never below the minimum size. + let r = l.snap_resize(b, Rect::new(5.0, 5.0, 0.0, 0.0), Snap::Grid, 0.5, 0.2).unwrap(); + assert!(r.w >= MIN_CELL_IN && r.h >= MIN_CELL_IN); + assert!(l.snap_move(99, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); + assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, 0.0, 0.1).is_err()); + assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, f64::NAN, 0.1).is_err()); + } + + #[test] + fn cell_aspect_survives_auto_layout() { + let mut l = package(); + let id = l.add_cell(2.5, 3.5).unwrap(); + l.auto_layout().unwrap(); + let r = l.cell(id).unwrap().rect; + assert!((r.w / r.h - 2.5 / 3.5).abs() < 1e-9 || (r.h / r.w - 2.5 / 3.5).abs() < 1e-9); + } + + #[test] + fn guides_validate() { + assert!(Guides::default().validate().is_ok()); + for bad in [f64::NAN, 0.0, -1.0, 1e9] { + assert!(Guides { grid_size: bad, ..Default::default() }.validate().is_err()); + } + } + + #[test] + fn rect_helpers() { + let a = Rect::new(0.0, 0.0, 2.0, 2.0); + assert!(a.overlaps(&Rect::new(1.0, 1.0, 2.0, 2.0))); + assert!(!a.overlaps(&Rect::new(2.0, 0.0, 1.0, 1.0))); + assert!(a.contains(&Rect::new(0.5, 0.5, 1.0, 1.0))); + assert!(!a.contains(&Rect::new(1.5, 0.5, 1.0, 1.0))); + assert_eq!(a.area(), 4.0); + } +} diff --git a/crates/engine/src/print/mod.rs b/crates/engine/src/print/mod.rs new file mode 100644 index 000000000..d309dbd6f --- /dev/null +++ b/crates/engine/src/print/mod.rs @@ -0,0 +1,4 @@ +//! The Print module: page layout, composition of pages and PDF output. +//! +//! [`layout`] is the pure page model (paper, margins, cells); the rest of the module renders it. +pub mod layout; diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..724758c8a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 15: print layout model; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 15: the Print layout model (paper sizes, margins, grid, package cells, Auto Layout packer, snapping) is in the engine; commands, composition and UI still to land | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..ee965a945 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -749,9 +749,9 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | | LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | -| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:export.contactSheet`, `crates/ui-egui/src/panels/dialogs.rs` | A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing | +| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:export.contactSheet`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/print/layout.rs` | A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing. The new layout model (ten paper sizes + custom, orientation, margins, grid with spacing and square cells, package cells with Auto Layout packing) is in the engine but not yet reachable from a command or the UI | | LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | | | -| LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | | | +| LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | `crates/engine/src/print/layout.rs` | cell model, deterministic packer, stacking order and snapping exist in the engine; no command or UI yet | | LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | optional filename captions; no page backgrounds/identity plates | | LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution | | LRC-PRINT-TEMPLATES | Print templates | P2 | ⬜ | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-PRINT-LAYOUT: the Print layout model (paper, margins, grid, package cells, Auto Layout packer, snapping) lands in `print/layout.rs`; no row changes status until commands and UI follow | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | From 8336f06d283cae611344c8a97b5946963185e78f Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Mon, 12 Oct 2026 14:28:40 +0530 Subject: [PATCH 10/52] LRC-PRINT-IMAGESETTINGS: compose print pages (fit / Zoom to Fill, Rotate to Fit, border, stroke, background) print/compose.rs turns a layout and its photos into PageRasters: 16-bit interleaved RGB encoded in Rec. 2020 at the job's pixel density. Each cell's photo is rendered with the export pipeline at exactly the size the cell needs, then fitted (whole photo, centred) or zoomed to fill (centre crop), turned a quarter clockwise when Rotate to Fit makes it print larger (or crop less), framed by the Photo Border (package styles only) and finished with the Inner Stroke drawn inside the photo edge. The page background, border and stroke colours are given in sRGB and converted to the page encoding. Photos go to cells by layout style: Single Image and Contact Sheet paginate the selection, Picture Package repeats the first photo, Custom Package prints each cell's own photo. The job is prepared on the session and run off it with progress and cancellation, like the contact sheet; page and job pixel counts and page counts are capped so a hostile resolution or paper cannot exhaust memory. print/output.rs holds the interim PDF writer (Rec. 2020 to 8-bit sRGB JPEG pages sized to the paper in points) through the contact sheet's Pdf, now pub(crate). PrintJob carries the print resolution (72 to 720 ppi, 240 default). Tests draw on synthetic images for exact pixels (borders, strokes, crop, rotation direction, resampling) and on the demo library for whole pages. --- crates/engine/src/contact_sheet.rs | 10 +- crates/engine/src/print/compose.rs | 845 ++++++++++++++++++++++++++++ crates/engine/src/print/mod.rs | 81 ++- crates/engine/src/print/output.rs | 103 ++++ crates/engine/src/print/template.rs | 27 + docs/gaps.md | 1 + docs/parity-checklist.md | 7 +- 7 files changed, 1065 insertions(+), 9 deletions(-) create mode 100644 crates/engine/src/print/compose.rs create mode 100644 crates/engine/src/print/output.rs create mode 100644 crates/engine/src/print/template.rs diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs index e83ad8c67..af4228e67 100644 --- a/crates/engine/src/contact_sheet.rs +++ b/crates/engine/src/contact_sheet.rs @@ -197,14 +197,14 @@ fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> } // Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets. -struct Pdf { - bytes: Vec, +pub(crate) struct Pdf { + pub(crate) bytes: Vec, offsets: Vec, points: (f64, f64), } impl Pdf { - fn new(pages: usize, points: (f64, f64)) -> Self { + pub(crate) fn new(pages: usize, points: (f64, f64)) -> Self { let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points }; p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>"); let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::>().join(" "); @@ -226,7 +226,7 @@ impl Pdf { self.object(id, &body); } - fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) { + pub(crate) fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) { let id = 3 + index * 3; let (w, h) = self.points; self.object( @@ -246,7 +246,7 @@ impl Pdf { ); } - fn finish(mut self) -> Vec { + pub(crate) fn finish(mut self) -> Vec { let start = self.bytes.len(); self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes()); for offset in self.offsets.iter().skip(1) { diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs new file mode 100644 index 000000000..58b29b0d9 --- /dev/null +++ b/crates/engine/src/print/compose.rs @@ -0,0 +1,845 @@ +//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit), +//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s. +//! +//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one +//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a +//! background thread with progress and cancellation. +//! +//! Photos are rendered with the normal export pipeline into Rec. 2020 at 16 bits, at exactly the +//! pixel size the cell needs at the job's resolution; colours given in sRGB (background, border, +//! stroke) are converted to the same encoding. +use super::layout::{Layout, Rect, Style}; +use super::template::PrintSettings; +use super::{MAX_JOB_PAGES, MAX_JOB_PIXELS, MAX_PAGE_PIXELS, PageRaster}; +use crate::Session; +use crate::export::{ExportOptions, output_size}; +use crate::media::RenderJob; +use lightcraft_catalog::PhotoId; +use lightcraft_color::transfer::srgb_to_linear; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_pipeline::{DeepSamples, OutputDepth, OutputSpace}; +use serde::{Deserialize, Serialize}; + +/// Most photos one print job takes. +pub const MAX_PHOTOS: usize = 1000; +/// Widest Photo Border, inches. +pub const MAX_BORDER_IN: f64 = 2.0; +/// Widest Inner Stroke, points. +pub const MAX_STROKE_PT: f64 = 36.0; +/// Renders kept for cells that print the same photo at the same size. +const RENDER_CACHE: usize = 4; + +/// Lightroom's Image Settings panel. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct ImageSettings { + /// Fill each cell with the photo and crop what overflows (centred); off: fit the whole photo. + pub zoom_to_fill: bool, + /// Turn a photo a quarter when that makes it print larger (fit) or crop less (fill). + pub rotate_to_fit: bool, + /// A border around the photo inside its cell; Picture Package and Custom Package only. + pub border_enabled: bool, + /// Border width, inches. + pub border_width: f64, + /// Border colour, sRGB. + pub border_color: [u8; 3], + /// A stroke drawn inside the photo's edge, every layout style. + pub stroke_enabled: bool, + /// Stroke width, points. + pub stroke_width_pt: f64, + /// Stroke colour, sRGB. + pub stroke_color: [u8; 3], +} + +impl Default for ImageSettings { + fn default() -> Self { + Self { + zoom_to_fill: false, + rotate_to_fit: false, + border_enabled: false, + border_width: 0.25, + border_color: [255; 3], + stroke_enabled: false, + stroke_width_pt: 1.0, + stroke_color: [0; 3], + } + } +} + +impl ImageSettings { + pub fn validate(&self) -> Result<(), String> { + let ok = |v: f64, max: f64| v.is_finite() && (0.0..=max).contains(&v); + if !ok(self.border_width, MAX_BORDER_IN) { + return Err(format!("border width must be 0 to {MAX_BORDER_IN} in")); + } + if !ok(self.stroke_width_pt, MAX_STROKE_PT) { + return Err(format!("stroke width must be 0 to {MAX_STROKE_PT} pt")); + } + Ok(()) + } +} + +/// Lightroom's Page panel (the parts that are not margins or overlays). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PageStyle { + /// Page background colour, sRGB. + pub background: [u8; 3], +} + +impl Default for PageStyle { + fn default() -> Self { + Self { background: [255; 3] } + } +} + +/// An sRGB colour as 16-bit Rec. 2020 samples (the encoding of [`PageRaster`]). +pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] { + let lin = SRGB.to_space(&REC2020).apply_f32(c.map(|v| srgb_to_linear(f32::from(v) / 255.0))); + let trc = OutputSpace::Rec2020.trc(); + lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16) +} + +// ---- placement ------------------------------------------------------------------------------ + +/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + /// Turned a quarter (clockwise). + pub rotated: bool, + /// Scaled size after turning: at most the area when fitting, at least the area when filling. + pub w: usize, + pub h: usize, +} + +impl Placement { + /// The scaled size before turning (the size to render at). + pub fn unturned(&self) -> (usize, usize) { + if self.rotated { (self.h, self.w) } else { (self.w, self.h) } + } +} + +/// Decides turning and scale. `None` for an empty area or a photo without size. +pub fn place(iw: f64, ih: f64, aw: usize, ah: usize, fill: bool, rotate: bool) -> Option { + if !(iw.is_finite() && ih.is_finite() && iw >= 1.0 && ih >= 1.0) || aw == 0 || ah == 0 { + return None; + } + let (af, bf) = (aw as f64, ah as f64); + let scale = |w: f64, h: f64| if fill { (af / w).max(bf / h) } else { (af / w).min(bf / h) }; + let (s0, s1) = (scale(iw, ih), scale(ih, iw)); + let rotated = rotate && if fill { s1 < s0 * (1.0 - 1e-9) } else { s1 > s0 * (1.0 + 1e-9) }; + let (w, h) = if rotated { (ih * s1, iw * s1) } else { (iw * s0, ih * s0) }; + let (w, h) = if fill { + (((w - 1e-6).ceil() as usize).max(aw), ((h - 1e-6).ceil() as usize).max(ah)) + } else { + ((w.round() as usize).clamp(1, aw), (h.round() as usize).clamp(1, ah)) + }; + Some(Placement { rotated, w, h }) +} + +/// A rectangle in page pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PxRect { + pub x: usize, + pub y: usize, + pub w: usize, + pub h: usize, +} + +/// The pixel rectangle of an inch rectangle on a `pw` x `ph` page at `ppi`, clipped to the page. +pub fn px_rect(r: &Rect, ppi: f64, pw: usize, ph: usize) -> Option { + if !r.is_finite() { + return None; + } + let edge = |v: f64, max: usize| (v * ppi).round().clamp(0.0, max as f64) as usize; + let (x0, y0, x1, y1) = (edge(r.x, pw), edge(r.y, ph), edge(r.right(), pw), edge(r.bottom(), ph)); + (x1 > x0 && y1 > y0).then_some(PxRect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }) +} + +/// The cells of every page with the photo each prints, by layout style: Single Image and Contact +/// Sheet lay `photos` out in sequence over as many pages as needed; Picture Package prints the +/// first photo in every cell; Custom Package prints each cell's own photo (else the first). +pub fn plan_cells(layout: &Layout, photos: &[PhotoId]) -> Result>, String> { + let first = photos.first().copied().ok_or("select at least one photo to print")?; + if photos.len() > MAX_PHOTOS { + return Err(format!("a print takes at most {MAX_PHOTOS} photos")); + } + match layout.style { + Style::SingleImage | Style::ContactSheet => { + let grid = layout.grid_rects()?; + let per_page = grid.len().max(1); + let pages = photos.len().div_ceil(per_page); + if pages > MAX_JOB_PAGES { + return Err(format!("a print is limited to {MAX_JOB_PAGES} pages; use a bigger grid or fewer photos")); + } + Ok(photos.chunks(per_page).map(|chunk| chunk.iter().zip(&grid).map(|(id, r)| (*r, *id)).collect()).collect()) + } + Style::PicturePackage | Style::CustomPackage => { + let pages = layout.pages.max(1) as usize; + if pages > MAX_JOB_PAGES { + return Err(format!("a print is limited to {MAX_JOB_PAGES} pages")); + } + if layout.cells.is_empty() { + return Err("the layout has no cells; add some (print.cell.add) or use Auto Layout".into()); + } + let mut out: Vec> = vec![Vec::new(); pages]; + for c in &layout.cells { + let photo = match layout.style { + Style::CustomPackage => c.photo.map_or(first, PhotoId), + _ => first, + }; + out.get_mut(c.page as usize).ok_or("a cell is on a page the layout does not have")?.push((c.rect, photo)); + } + Ok(out) + } + } +} + +// ---- pixels ---------------------------------------------------------------------------------- + +/// 16-bit interleaved RGB. +#[derive(Clone, Debug, PartialEq)] +pub struct Image16 { + pub w: usize, + pub h: usize, + pub rgb: Vec, +} + +impl Image16 { + pub fn filled(w: usize, h: usize, c: [u16; 3]) -> Result { + let n = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or("image too large")?; + let mut rgb = Vec::new(); + rgb.try_reserve_exact(n).map_err(|_| "not enough memory for the page")?; + for _ in 0..w * h { + rgb.extend_from_slice(&c); + } + Ok(Self { w, h, rgb }) + } + + fn px(&self, x: usize, y: usize) -> Option<[u16; 3]> { + let i = y.checked_mul(self.w)?.checked_add(x)?.checked_mul(3)?; + let s = self.rgb.get(i..i.checked_add(3)?)?; + Some([*s.first()?, *s.get(1)?, *s.get(2)?]) + } + + /// Fills a rectangle (clipped to the image). + pub fn fill_rect(&mut self, x: usize, y: usize, w: usize, h: usize, c: [u16; 3]) { + let x1 = x.saturating_add(w).min(self.w); + for row in y..y.saturating_add(h).min(self.h) { + let Some(line) = self.rgb.get_mut((row * self.w + x.min(x1)) * 3..(row * self.w + x1) * 3) else { continue }; + for px in line.as_chunks_mut::<3>().0 { + *px = c; + } + } + } + + /// Copies the `w` x `h` block of `src` at (`sx`, `sy`) to (`dx`, `dy`), clipped to both images. + pub fn blit(&mut self, src: &Image16, sx: usize, sy: usize, dx: usize, dy: usize, w: usize, h: usize) { + let w = w.min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx)); + let h = h.min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy)); + for row in 0..h { + let from = src.rgb.get(((sy + row) * src.w + sx) * 3..((sy + row) * src.w + sx + w) * 3); + let to = self.rgb.get_mut(((dy + row) * self.w + dx) * 3..((dy + row) * self.w + dx + w) * 3); + if let (Some(from), Some(to)) = (from, to) { + to.copy_from_slice(from); + } + } + } + + /// A quarter turn clockwise. + pub fn rotated_cw(&self) -> Result { + let mut out = Image16::filled(self.h, self.w, [0; 3])?; + for y in 0..self.h { + for x in 0..self.w { + let Some(p) = self.px(x, y) else { continue }; + // (x, y) lands at (h - 1 - y, x). + let i = (x * out.w + (self.h - 1 - y)) * 3; + if let Some(d) = out.rgb.get_mut(i..i + 3) { + d.copy_from_slice(&p); + } + } + } + Ok(out) + } + + /// Scales to `nw` x `nh`: area-weighted when shrinking an axis, linear when growing it. + pub fn resized(&self, nw: usize, nh: usize) -> Result { + if nw == 0 || nh == 0 || self.w == 0 || self.h == 0 { + return Err("empty image".into()); + } + if (nw, nh) == (self.w, self.h) { + return Ok(self.clone()); + } + let wx = axis_weights(self.w, nw); + let wy = axis_weights(self.h, nh); + // Horizontal pass into 16-bit rows, then the vertical pass. + let mut mid = Image16::filled(nw, self.h, [0; 3])?; + for y in 0..self.h { + let Some(src) = self.rgb.get(y * self.w * 3..(y + 1) * self.w * 3) else { continue }; + let Some(dst) = mid.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue }; + for (x, taps) in wx.iter().enumerate() { + for c in 0..3 { + let v: f32 = taps.iter().map(|&(i, k)| src.get(i * 3 + c).map_or(0.0, |s| f32::from(*s) * k)).sum(); + if let Some(d) = dst.get_mut(x * 3 + c) { + *d = v.round().clamp(0.0, 65535.0) as u16; + } + } + } + } + let mut out = Image16::filled(nw, nh, [0; 3])?; + for (y, taps) in wy.iter().enumerate() { + let Some(dst) = out.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue }; + for (i, d) in dst.iter_mut().enumerate() { + let v: f32 = taps.iter().map(|&(row, k)| mid.rgb.get(row * nw * 3 + i).map_or(0.0, |s| f32::from(*s) * k)).sum(); + *d = v.round().clamp(0.0, 65535.0) as u16; + } + } + Ok(out) + } +} + +/// For each of `new` output samples, the `(source index, weight)` taps that make it (weights sum to 1). +fn axis_weights(old: usize, new: usize) -> Vec> { + let (o, n) = (old as f64, new as f64); + (0..new) + .map(|i| { + if new < old { + let step = o / n; + let (a, b) = (i as f64 * step, (i as f64 + 1.0) * step); + let first = a.floor() as usize; + let last = (b.ceil() as usize).min(old); + (first..last) + .filter_map(|j| { + let overlap = (b.min(j as f64 + 1.0) - a.max(j as f64)).max(0.0); + (overlap > 0.0).then_some((j, (overlap / step) as f32)) + }) + .collect() + } else { + let pos = (i as f64 + 0.5) * o / n - 0.5; + let j0 = pos.floor(); + let f = (pos - j0) as f32; + let at = |j: f64| j.clamp(0.0, o - 1.0) as usize; + vec![(at(j0), 1.0 - f), (at(j0 + 1.0), f)] + } + }) + .collect() +} + +/// Everything about a cell's look that does not depend on the photo. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CellStyle { + pub fill: bool, + pub rotate: bool, + pub border_px: usize, + pub border: [u16; 3], + pub stroke_px: usize, + pub stroke: [u16; 3], +} + +impl CellStyle { + /// The style for `settings` at `ppi`; the border applies to the package styles only. + pub fn of(settings: &PrintSettings, ppi: f64) -> Self { + let img = &settings.image; + let border_px = if img.border_enabled && settings.layout.style.is_package() { (img.border_width * ppi).round() as usize } else { 0 }; + let stroke_px = + if img.stroke_enabled && img.stroke_width_pt > 0.0 { ((img.stroke_width_pt / 72.0 * ppi).round() as usize).max(1) } else { 0 }; + Self { + fill: img.zoom_to_fill, + rotate: img.rotate_to_fit, + border_px, + border: rec2020_from_srgb8(img.border_color), + stroke_px, + stroke: rec2020_from_srgb8(img.stroke_color), + } + } +} + +/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when +/// asked), then the inner stroke on top of it. +pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> { + // The border may not eat the cell: at least one pixel of photo area stays. + let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2); + let (ax, ay) = (cell.x + border, cell.y + border); + let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border); + let p = place(photo.w as f64, photo.h as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?; + let (uw, uh) = p.unturned(); + let mut img = photo.resized(uw, uh)?; + if p.rotated { + img = img.rotated_cw()?; + } + // Where the photo lands: centred in the area, cropped to it when it overflows. + let (sx, sy) = (img.w.saturating_sub(aw) / 2, img.h.saturating_sub(ah) / 2); + let (w, h) = (img.w.min(aw), img.h.min(ah)); + let (dx, dy) = (ax + (aw - w) / 2, ay + (ah - h) / 2); + if border > 0 { + page.fill_rect(dx - border, dy - border, w + 2 * border, h + 2 * border, style.border); + } + page.blit(&img, sx, sy, dx, dy, w, h); + let s = style.stroke_px.min(w.min(h) / 2); + if s > 0 { + page.fill_rect(dx, dy, w, s, style.stroke); + page.fill_rect(dx, dy + h - s, w, s, style.stroke); + page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke); + page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke); + } + Ok(()) +} + +// ---- the job --------------------------------------------------------------------------------- + +struct Item { + page: usize, + name: String, + cell: PxRect, + key: (u64, usize, usize), + job: RenderJob, +} + +/// A print job set up from the session: one render per cell, ready to run on another thread. +pub struct Prepared { + pages: Vec<(usize, usize)>, + items: Vec, + ppi: f32, + background: [u16; 3], + style: CellStyle, +} + +impl Prepared { + /// Renders (progress steps) the job will run. + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Pages the job will produce. + pub fn pages(&self) -> usize { + self.pages.len() + } + + /// Renders every photo and composes the pages. `progress(done, name)` is called before each + /// render with the photo's file name; returning `false` cancels (no partial result). + pub fn run(self, progress: &mut dyn FnMut(usize, &str) -> bool) -> Result, String> { + let mut cache: Vec<((u64, usize, usize), Image16)> = Vec::new(); + let mut done = 0usize; + let mut items = self.items.into_iter().peekable(); + let mut out = Vec::with_capacity(self.pages.len()); + for (index, &(w, h)) in self.pages.iter().enumerate() { + let mut page = Image16::filled(w, h, self.background)?; + while let Some(item) = items.next_if(|i| i.page == index) { + if !progress(done, &item.name) { + return Err("Print cancelled".into()); + } + done += 1; + let at = cache.iter().position(|(k, _)| *k == item.key); + let at = match at { + Some(at) => at, + None => { + let img = render(item.job).map_err(|e| format!("{}: {e}", item.name))?; + if cache.len() >= RENDER_CACHE { + cache.remove(0); + } + cache.push((item.key, img)); + cache.len() - 1 + } + }; + let photo = &cache.get(at).ok_or("render cache lost an entry")?.1; + draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?; + } + out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb }); + } + if !progress(done, "") { + return Err("Print cancelled".into()); + } + Ok(out) + } +} + +fn render(job: RenderJob) -> Result { + let rendered = job.run().rendered?; + let deep = rendered.deep.ok_or("the render has no 16-bit samples")?; + let DeepSamples::U16(rgb) = deep.samples else { return Err("the render is not 16-bit".into()) }; + if deep.space != OutputSpace::Rec2020 || rgb.len() != deep.width.saturating_mul(deep.height).saturating_mul(3) { + return Err("the render is not Rec. 2020 RGB".into()); + } + Ok(Image16 { w: deep.width, h: deep.height, rgb }) +} + +/// Sets a print job up: the cells for `photos` under `settings.layout`, one render job per cell. +pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoId]) -> Result { + let mut settings = settings.clone(); + settings.validate()?; + let ppi = f64::from(settings.job.effective_ppi()); + let (pw_in, ph_in) = settings.layout.page_size()?; + let (pw, ph) = ((pw_in * ppi).round() as usize, (ph_in * ppi).round() as usize); + let page_px = (pw as u64).saturating_mul(ph as u64); + if pw == 0 || ph == 0 || page_px > MAX_PAGE_PIXELS { + return Err(format!("a page of {pw} x {ph} pixels is too large; lower the print resolution")); + } + let plan = plan_cells(&settings.layout, photos)?; + if page_px.saturating_mul(plan.len() as u64) > MAX_JOB_PIXELS { + return Err("the print is too large for memory; lower the print resolution or print fewer pages".into()); + } + let style = CellStyle::of(&settings, ppi); + let mut items = Vec::new(); + for (page, cells) in plan.iter().enumerate() { + for (rect, id) in cells { + let Some(cell) = px_rect(rect, ppi, pw, ph) else { continue }; + let photo = session.catalog.photo(*id).ok_or_else(|| format!("no photo {}", id.0))?; + let name = photo.file_name.clone(); + let (nw, nh) = output_size(photo, &ExportOptions::default()); + let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2); + let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border); + let p = place(nw as f64, nh as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?; + let (rw, rh) = p.unturned(); + let job = session.export_job(*id, rw, rh, OutputSpace::Rec2020, OutputDepth::U16)?; + items.push(Item { page, name, cell, key: (id.0, rw, rh), job }); + } + } + Ok(Prepared { pages: vec![(pw, ph); plan.len()], items, ppi: ppi as f32, background: rec2020_from_srgb8(settings.page.background), style }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::print::layout::{GridSpec, Margins}; + + const RED: [u16; 3] = [60000, 1000, 1000]; + const BLUE: [u16; 3] = [1000, 1000, 60000]; + const WHITE: [u16; 3] = [65535; 3]; + + /// A `w` x `h` image whose left half is red and right half blue, with a green top row. + fn halves(w: usize, h: usize) -> Image16 { + let mut img = Image16::filled(w, h, RED).unwrap(); + img.fill_rect(w / 2, 0, w - w / 2, h, BLUE); + img.fill_rect(0, 0, w, 1, [1000, 60000, 1000]); + img + } + + fn style() -> CellStyle { + CellStyle { fill: false, rotate: false, border_px: 0, border: [0; 3], stroke_px: 0, stroke: [0; 3] } + } + + fn at(img: &Image16, x: usize, y: usize) -> [u16; 3] { + img.px(x, y).unwrap() + } + + fn page() -> Image16 { + Image16::filled(100, 100, WHITE).unwrap() + } + + #[test] + fn placement_fit_fill_and_rotation() { + // 4:3 photo into a 2:1 area. + let fit = place(400.0, 300.0, 200, 100, false, false).unwrap(); + assert_eq!((fit.w, fit.h, fit.rotated), (133, 100, false)); + let fill = place(400.0, 300.0, 200, 100, true, false).unwrap(); + assert_eq!((fill.w, fill.h), (200, 150)); + // A portrait photo in a landscape area: turning prints it larger. + assert_eq!(place(300.0, 400.0, 400, 300, false, false).unwrap(), Placement { rotated: false, w: 225, h: 300 }); + let turned = place(300.0, 400.0, 400, 300, false, true).unwrap(); + assert_eq!((turned.rotated, turned.w, turned.h), (true, 400, 300)); + assert_eq!(turned.unturned(), (300, 400)); + // Filling a landscape cell with a portrait photo crops less when turned. + let f = place(300.0, 400.0, 400, 300, true, true).unwrap(); + assert!(f.rotated && f.w >= 400 && f.h >= 300); + // Matching orientation never turns. + assert!(!place(400.0, 300.0, 400, 300, false, true).unwrap().rotated); + assert!(!place(300.0, 300.0, 100, 100, false, true).unwrap().rotated); + // Hostile input. + for bad in [f64::NAN, f64::INFINITY, 0.0, -5.0] { + assert!(place(bad, 10.0, 10, 10, false, false).is_none()); + assert!(place(10.0, bad, 10, 10, true, true).is_none()); + } + assert!(place(10.0, 10.0, 0, 10, false, false).is_none()); + } + + #[test] + fn fit_centres_the_photo() { + let mut p = page(); + draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &style()).unwrap(); + // 40x20 scaled to 80x40, centred vertically at y 30..70. + assert_eq!(at(&p, 10, 50), RED); + assert_eq!(at(&p, 89, 50), BLUE); + assert_eq!(at(&p, 50, 20), WHITE, "above the photo"); + assert_eq!(at(&p, 50, 75), WHITE, "below the photo"); + assert_eq!(at(&p, 5, 50), WHITE, "outside the cell"); + } + + #[test] + fn zoom_to_fill_crops_the_centre() { + let mut p = page(); + let s = CellStyle { fill: true, ..style() }; + draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &s).unwrap(); + // The photo scales to 160x80 and the centre 80 columns remain: the colour edge is mid-cell. + for y in [10, 50, 89] { + assert_ne!(at(&p, 10, y), WHITE); + assert_ne!(at(&p, 89, y), WHITE); + } + assert_eq!(at(&p, 20, 50), RED); + assert_eq!(at(&p, 80, 50), BLUE); + // Nothing leaks outside the cell. + assert_eq!(at(&p, 9, 50), WHITE); + assert_eq!(at(&p, 90, 50), WHITE); + assert_eq!(at(&p, 50, 9), WHITE); + assert_eq!(at(&p, 50, 90), WHITE); + } + + #[test] + fn rotate_to_fit_turns_clockwise() { + // A portrait 20x40 photo (red left, blue right) in a landscape 80x40 cell. + let mut p = page(); + let cell = PxRect { x: 10, y: 10, w: 80, h: 40 }; + draw_cell(&mut p, &halves(20, 40), cell, &style()).unwrap(); + assert_eq!(at(&p, 20, 30), WHITE, "unturned the photo is a narrow strip in the middle"); + assert_eq!(at(&p, 45, 30), RED); + let mut q = page(); + draw_cell(&mut q, &halves(20, 40), cell, &CellStyle { rotate: true, ..style() }).unwrap(); + // Turned clockwise the left half (red) becomes the top half and the photo spans the cell. + assert_eq!(at(&q, 12, 12), RED, "{:?}", at(&q, 12, 12)); + assert_eq!(at(&q, 12, 48), BLUE); + assert_eq!(at(&q, 50, 20), RED); + assert_eq!(at(&q, 50, 40), BLUE); + assert_eq!(at(&q, 11, 30), at(&q, 11, 30)); + } + + #[test] + fn border_frames_the_photo_inside_the_cell() { + let mut p = page(); + let green = [100, 60000, 100]; + let s = CellStyle { border_px: 5, border: green, fill: true, ..style() }; + draw_cell(&mut p, &halves(60, 60), PxRect { x: 10, y: 10, w: 60, h: 60 }, &s).unwrap(); + assert_eq!(at(&p, 10, 40), green); + assert_eq!(at(&p, 14, 40), green); + assert_eq!(at(&p, 15, 40), RED); + assert_eq!(at(&p, 69, 40), green); + assert_eq!(at(&p, 64, 40), BLUE); + assert_eq!(at(&p, 40, 10), green); + assert_eq!(at(&p, 40, 69), green); + // A fitted photo gets the border hugging it, not the whole cell. + let mut q = page(); + let s = CellStyle { border_px: 5, border: green, ..style() }; + draw_cell(&mut q, &halves(60, 30), PxRect { x: 0, y: 0, w: 100, h: 100 }, &s).unwrap(); + // The 90x90 area fits a 90x45 photo at y 27..72 inside a frame 5 px thick. + assert_eq!(at(&q, 50, 23), green); + assert_eq!(at(&q, 50, 22), green); + assert_eq!(at(&q, 50, 21), WHITE); + // A border wider than the cell leaves the photo visible. + let mut r = page(); + let s = CellStyle { border_px: 500, border: green, ..style() }; + draw_cell(&mut r, &halves(10, 10), PxRect { x: 10, y: 10, w: 20, h: 20 }, &s).unwrap(); + assert_ne!(at(&r, 20, 20), WHITE); + } + + #[test] + fn stroke_is_drawn_inside_the_photo_edge() { + let mut p = page(); + let black = [0, 0, 0]; + let s = CellStyle { stroke_px: 3, stroke: black, fill: true, ..style() }; + draw_cell(&mut p, &halves(60, 60), PxRect { x: 20, y: 20, w: 60, h: 60 }, &s).unwrap(); + for (x, y) in [(20, 40), (22, 40), (77, 40), (79, 40), (40, 20), (40, 22), (40, 77), (40, 79)] { + assert_eq!(at(&p, x, y), black, "({x}, {y})"); + } + assert_eq!(at(&p, 23, 40), RED); + assert_eq!(at(&p, 76, 40), BLUE); + assert_eq!(at(&p, 19, 40), WHITE, "the stroke does not grow the photo"); + // Corners are covered exactly once and a huge stroke stays within the photo. + let mut q = page(); + let s = CellStyle { stroke_px: 1000, stroke: black, ..style() }; + draw_cell(&mut q, &halves(10, 10), PxRect { x: 20, y: 20, w: 40, h: 40 }, &s).unwrap(); + assert_eq!(at(&q, 40, 40), black); + assert_eq!(at(&q, 19, 40), WHITE); + assert_eq!(at(&q, 60, 40), WHITE); + } + + #[test] + fn colours_are_rec2020_encoded() { + assert_eq!(rec2020_from_srgb8([255; 3]), [65535; 3]); + assert_eq!(rec2020_from_srgb8([0; 3]), [0; 3]); + let grey = rec2020_from_srgb8([128; 3]); + assert!(grey[0].abs_diff(grey[1]) < 3 && grey[1].abs_diff(grey[2]) < 3); + // sRGB red sits inside the Rec. 2020 gamut: its green and blue are not zero. + let red = rec2020_from_srgb8([255, 0, 0]); + assert!(red[0] < 65535 && red[1] > 0); + } + + #[test] + fn resize_preserves_flat_colour_and_edges() { + let img = halves(64, 32); + for (w, h) in [(32, 16), (128, 64), (50, 70), (1, 1), (64, 31)] { + let r = img.resized(w, h).unwrap(); + assert_eq!((r.w, r.h), (w, h)); + assert_eq!(r.rgb.len(), w * h * 3); + } + let flat = Image16::filled(40, 30, [1234, 5678, 9999]).unwrap().resized(17, 61).unwrap(); + assert!(flat.rgb.chunks(3).all(|p| p == [1234, 5678, 9999])); + assert!(img.resized(0, 5).is_err()); + let r = img.resized(32, 16).unwrap(); + assert_eq!(at(&r, 4, 8), RED); + assert_eq!(at(&r, 28, 8), BLUE); + } + + #[test] + fn rotate_and_blit_are_clipped() { + let img = halves(3, 2); + let r = img.rotated_cw().unwrap(); + assert_eq!((r.w, r.h), (2, 3)); + let mut p = page(); + p.blit(&img, 0, 0, 99, 99, 10, 10); + assert_eq!(at(&p, 99, 99), at(&img, 0, 0)); + p.blit(&img, 5, 5, 0, 0, 3, 3); + p.fill_rect(95, 95, 50, 50, RED); + assert_eq!(at(&p, 99, 99), RED); + p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED); + } + + fn settings(style: Style) -> PrintSettings { + let mut s = PrintSettings::default(); + s.layout.style = style; + s.layout.page.paper = "4x6".into(); + s.layout.page.margins = Margins::uniform(0.5); + s.job.ppi = 100.0; + s + } + + #[test] + fn plans_follow_the_layout_style() { + let ids: Vec = (1..=5).map(PhotoId).collect(); + let mut s = settings(Style::ContactSheet); + s.layout.grid = GridSpec { columns: 2, rows: 2, ..Default::default() }; + let plan = plan_cells(&s.layout, &ids).unwrap(); + assert_eq!(plan.iter().map(Vec::len).collect::>(), vec![4, 1]); + assert_eq!(plan[1][0].1, PhotoId(5)); + let one = plan_cells(&settings(Style::SingleImage).layout, &ids).unwrap(); + assert_eq!(one.len(), 5); + assert!(plan_cells(&s.layout, &[]).is_err()); + assert!(plan_cells(&s.layout, &vec![PhotoId(1); MAX_PHOTOS + 1]).is_err()); + + let mut pkg = settings(Style::PicturePackage); + assert!(plan_cells(&pkg.layout, &ids).is_err(), "no cells yet"); + pkg.layout.add_cell(1.0, 1.0).unwrap(); + pkg.layout.add_cell(2.0, 2.0).unwrap(); + let plan = plan_cells(&pkg.layout, &ids).unwrap(); + assert_eq!(plan.len(), 1); + assert!(plan[0].iter().all(|(_, p)| *p == PhotoId(1)), "picture package repeats the first photo"); + + let mut custom = settings(Style::CustomPackage); + let a = custom.layout.add_cell(1.0, 1.0).unwrap(); + custom.layout.add_cell(1.0, 1.0).unwrap(); + custom.layout.set_cell_photo(a, Some(3)).unwrap(); + let plan = plan_cells(&custom.layout, &ids).unwrap(); + assert_eq!((plan[0][0].1, plan[0][1].1), (PhotoId(3), PhotoId(1))); + let blank_page = { + custom.layout.new_page().unwrap(); + plan_cells(&custom.layout, &ids).unwrap() + }; + assert_eq!(blank_page.iter().map(Vec::len).collect::>(), vec![2, 0]); + } + + fn first_ids(s: &Session, n: usize) -> Vec { + s.catalog.photos().take(n).map(|p| p.id).collect() + } + + #[test] + fn composes_pages_from_real_photos() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 3); + let mut set = settings(Style::ContactSheet); + set.layout.grid = GridSpec { columns: 1, rows: 2, spacing_x: 0.0, spacing_y: 0.0, keep_square: false }; + set.page.background = [200, 30, 30]; + set.image.zoom_to_fill = true; + set.image.stroke_enabled = true; + set.image.stroke_width_pt = 2.0; + set.image.stroke_color = [0, 0, 0]; + let work = prepare(&mut s, &set, &ids).unwrap(); + assert_eq!((work.len(), work.pages()), (3, 2)); + let mut steps = Vec::new(); + let pages = work + .run(&mut |i, name| { + steps.push((i, name.to_string())); + true + }) + .unwrap(); + assert_eq!(steps.iter().map(|s| s.0).collect::>(), vec![0, 1, 2, 3]); + assert_eq!(pages.len(), 2); + let first = &pages[0]; + assert_eq!((first.width, first.height, first.ppi), (400, 600, 100.0)); + assert_eq!(first.rgb.len(), 400 * 600 * 3); + let bg = rec2020_from_srgb8([200, 30, 30]); + let px = |p: &PageRaster, x: usize, y: usize| { + let i = (y * p.width as usize + x) * 3; + [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] + }; + assert_eq!(px(first, 5, 5), bg, "margin shows the page background"); + assert_eq!(px(first, 395, 595), bg); + // Zoom to fill: the top-left photo pixel (inside the 2 pt = 3 px stroke) is the stroke colour. + assert_eq!(px(first, 50, 50), [0, 0, 0]); + let centre = px(first, 200, 150); + assert_ne!(centre, bg); + assert_ne!(centre, [0, 0, 0]); + // The second page holds the third photo in the first cell only: the second cell is bare. + let second = &pages[1]; + assert_ne!(px(second, 200, 150), bg); + assert_eq!(px(second, 200, 450), bg); + // Cancelling gives no pages. + let work = prepare(&mut s, &set, &ids).unwrap(); + assert!(work.run(&mut |i, _| i < 1).is_err()); + } + + #[test] + fn package_renders_each_size_and_border() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 1); + let mut set = settings(Style::PicturePackage); + set.layout.add_cell(1.5, 1.0).unwrap(); + set.layout.add_cell(1.5, 1.0).unwrap(); + set.image.border_enabled = true; + set.image.border_width = 0.1; + set.image.border_color = [10, 200, 10]; + let pages = prepare(&mut s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap(); + assert_eq!(pages.len(), 1); + let green = rec2020_from_srgb8([10, 200, 10]); + let cell = set.layout.cells[0].rect; + let (x0, y0) = ((cell.x * 100.0).round() as usize, (cell.y * 100.0).round() as usize); + let at = |x: usize, y: usize| { + let i = (y * 400 + x) * 3; + pages[0].rgb[i..i + 3].to_vec() + }; + // The frame touches the cell edge on the photo's limiting axis: 10 px (0.1 in) of border all round. + let mid = (x0 + 75, y0 + 50); + assert_ne!(at(mid.0, mid.1), green.to_vec(), "the photo, not the border, is in the middle"); + let green_px = (y0..y0 + 100).flat_map(|y| (x0..x0 + 150).map(move |x| (x, y))).filter(|&(x, y)| at(x, y) == green.to_vec()).count(); + assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels"); + } + + #[test] + fn caps_and_hostile_settings_are_refused() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 1); + let mut set = settings(Style::SingleImage); + set.layout.page.paper = "custom".into(); + set.layout.page.custom_width = 60.0; + set.layout.page.custom_height = 60.0; + set.job.ppi = 720.0; + assert!(prepare(&mut s, &set, &ids).is_err(), "a 60 in page at 720 ppi is far beyond the page cap"); + let mut set = settings(Style::SingleImage); + set.job.ppi = f32::NAN; + assert!(prepare(&mut s, &set, &ids).is_err()); + let mut set = settings(Style::SingleImage); + set.image.border_width = f64::NAN; + assert!(prepare(&mut s, &set, &ids).is_err()); + set.image.border_width = 0.1; + set.image.stroke_width_pt = -1.0; + assert!(prepare(&mut s, &set, &ids).is_err()); + assert!(prepare(&mut s, &settings(Style::SingleImage), &[PhotoId(999_999)]).is_err()); + assert!(prepare(&mut s, &settings(Style::SingleImage), &[]).is_err()); + let set = settings(Style::SingleImage); + assert!(prepare(&mut s, &set, &vec![ids[0]; 101]).is_err(), "more than 100 pages"); + // 100 pages of 4x6 at 100 ppi is 24 Mpx: allowed; at 720 ppi it would be over the job cap. + let mut big = settings(Style::SingleImage); + big.job.ppi = 720.0; + assert!(prepare(&mut s, &big, &vec![ids[0]; 60]).is_err()); + } +} diff --git a/crates/engine/src/print/mod.rs b/crates/engine/src/print/mod.rs index d309dbd6f..a9ca3e35f 100644 --- a/crates/engine/src/print/mod.rs +++ b/crates/engine/src/print/mod.rs @@ -1,4 +1,83 @@ //! The Print module: page layout, composition of pages and PDF output. //! -//! [`layout`] is the pure page model (paper, margins, cells); the rest of the module renders it. +//! [`layout`] is the pure page model (paper, margins, cells); [`compose`] renders a layout and its +//! photos to [`PageRaster`]s; [`template`] holds the saved settings; [`output`] writes the pages +//! as a PDF. +pub mod compose; pub mod layout; +pub mod output; +pub mod template; + +use serde::{Deserialize, Serialize}; + +/// Lowest and highest print resolution, pixels per inch. +pub const MIN_PPI: f32 = 72.0; +pub const MAX_PPI: f32 = 720.0; +/// Pages a single print may have. +pub const MAX_JOB_PAGES: usize = 100; +/// Most pixels one page may have (about 100 megapixels: A3 at 720 ppi is 60). +pub const MAX_PAGE_PIXELS: u64 = 100_000_000; +/// Most pixels a print job may hold across all its pages (6 bytes each in memory). +pub const MAX_JOB_PIXELS: u64 = 200_000_000; + +/// One composed page, ready for output: row-major interleaved RGB, 16 bits per sample, encoded in +/// Rec. 2020 ([`lightcraft_pipeline::OutputSpace::Rec2020`]; the transfer curve is that space's). +/// The page covers its paper edge to edge: `width / ppi` by `height / ppi` inches. +#[derive(Clone, Debug, PartialEq)] +pub struct PageRaster { + pub width: u32, + pub height: u32, + pub ppi: f32, + pub rgb: Vec, +} + +/// How a print job is produced (Lightroom's Print Job panel; colour management joins it later). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PrintJob { + /// Print resolution in pixels per inch, 72 to 720. + pub ppi: f32, +} + +impl Default for PrintJob { + fn default() -> Self { + Self { ppi: 240.0 } + } +} + +impl PrintJob { + /// The resolution to render at: [`Self::ppi`] clamped to 72..=720 (240 for NaN). + pub fn effective_ppi(&self) -> f32 { + if self.ppi.is_finite() { self.ppi.clamp(MIN_PPI, MAX_PPI) } else { 240.0 } + } + + /// Rejects a resolution outside the supported range (commands and templates, which should + /// say so rather than silently clamp). + pub fn validate(&self) -> Result<(), String> { + if self.ppi.is_finite() && (MIN_PPI..=MAX_PPI).contains(&self.ppi) { + Ok(()) + } else { + Err(format!("ppi must be a number from {MIN_PPI} to {MAX_PPI}")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ppi_is_clamped_and_validated() { + assert_eq!(PrintJob::default().effective_ppi(), 240.0); + for (v, e) in [(10.0, 72.0), (5000.0, 720.0), (f32::NAN, 240.0), (f32::INFINITY, 240.0), (300.0, 300.0)] { + assert_eq!(PrintJob { ppi: v }.effective_ppi(), e); + } + assert!(PrintJob { ppi: 300.0 }.validate().is_ok()); + for bad in [0.0, 71.0, 721.0, f32::NAN, f32::NEG_INFINITY] { + assert!(PrintJob { ppi: bad }.validate().is_err()); + } + let j: PrintJob = serde_json::from_str("{}").unwrap(); + assert_eq!(j, PrintJob::default()); + assert!(serde_json::from_str::(r#"{"dpi": 1}"#).is_err()); + } +} diff --git a/crates/engine/src/print/output.rs b/crates/engine/src/print/output.rs new file mode 100644 index 000000000..f058ccf0b --- /dev/null +++ b/crates/engine/src/print/output.rs @@ -0,0 +1,103 @@ +//! Writes composed pages as a PDF. +//! +//! Interim writer: each page is converted from Rec. 2020 to 8-bit sRGB and embedded as a JPEG +//! covering the paper. Colour-managed output replaces the body of [`write`], not its signature. +use super::{MAX_JOB_PAGES, PageRaster, PrintJob}; +use crate::contact_sheet::Pdf; +use crate::export::{ExportFormat, ExportOptions, encode_image}; +use lightcraft_color::transfer::encode_srgb8; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_pipeline::OutputSpace; +use lightcraft_raster::Rgba8; + +/// Largest PDF produced, bytes. +const MAX_PDF_BYTES: usize = 256 * 1024 * 1024; + +/// A Rec. 2020 page as 8-bit sRGB (out-of-gamut colours are clipped). +pub fn page_to_srgb8(page: &PageRaster) -> Result { + let (w, h) = (page.width as usize, page.height as usize); + let n = w.checked_mul(h).ok_or("page too large")?; + if n == 0 || page.rgb.len() != n.checked_mul(3).ok_or("page too large")? { + return Err("a page's pixel data does not match its size".into()); + } + let trc = OutputSpace::Rec2020.trc(); + // 16-bit Rec. 2020 code → linear light, once per code value. + let lut: Vec = (0..=u16::MAX).map(|v| trc.decode(f32::from(v) / 65535.0)).collect(); + let m = REC2020.to_space(&SRGB).to_f32(); + let mut out = Rgba8::new(w, h); + for (dst, src) in out.data.iter_mut().zip(page.rgb.as_chunks::<3>().0) { + let l = src.map(|v| lut.get(usize::from(v)).copied().unwrap_or(0.0)); + let row = |r: usize| m.get(r).map_or(0.0, |m| m[0] * l[0] + m[1] * l[1] + m[2] * l[2]); + *dst = [encode_srgb8(row(0)), encode_srgb8(row(1)), encode_srgb8(row(2)), 255]; + } + Ok(out) +} + +/// The PDF for `pages` (all the same size): one page per raster, its MediaBox the paper size in +/// points (`pixels / ppi * 72`) with the image covering it. +pub fn write(pages: &[PageRaster], job: &PrintJob, title: &str) -> Result, String> { + let _ = (job, title); + let first = pages.first().ok_or("nothing to print")?; + if pages.len() > MAX_JOB_PAGES { + return Err(format!("a print is limited to {MAX_JOB_PAGES} pages")); + } + if !(first.ppi.is_finite() && first.ppi > 0.0) { + return Err("a page has no resolution".into()); + } + let points = |px: u32| f64::from(px) / f64::from(first.ppi) * 72.0; + let size = (points(first.width), points(first.height)); + let mut pdf = Pdf::new(pages.len(), size); + for (i, page) in pages.iter().enumerate() { + if (page.width, page.height) != (first.width, first.height) { + return Err("all pages of a PDF must be the same size".into()); + } + let rgba = page_to_srgb8(page)?; + let jpg = encode_image(&rgba, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?; + if pdf.bytes.len().saturating_add(jpg.len()) > MAX_PDF_BYTES { + return Err("the PDF would exceed 256 MiB; lower the print resolution or print fewer pages".into()); + } + pdf.page(i, &jpg, page.width as usize, page.height as usize); + } + Ok(pdf.finish()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::print::compose::rec2020_from_srgb8; + + fn raster(w: u32, h: u32, srgb: [u8; 3]) -> PageRaster { + let c = rec2020_from_srgb8(srgb); + PageRaster { width: w, height: h, ppi: 100.0, rgb: (0..w * h).flat_map(|_| c).collect() } + } + + #[test] + fn colours_survive_the_round_trip() { + for c in [[255u8, 255, 255], [0, 0, 0], [128, 128, 128], [200, 30, 30], [10, 200, 90]] { + let px = page_to_srgb8(&raster(2, 2, c)).unwrap().data[0]; + for k in 0..3 { + assert!(px[k].abs_diff(c[k]) <= 1, "{c:?} -> {px:?}"); + } + } + assert!(page_to_srgb8(&PageRaster { width: 2, height: 2, ppi: 100.0, rgb: vec![0; 5] }).is_err()); + assert!(page_to_srgb8(&PageRaster { width: 0, height: 2, ppi: 100.0, rgb: vec![] }).is_err()); + } + + #[test] + fn pdf_pages_have_the_paper_size() { + let pages = [raster(425, 550, [255, 255, 255]), raster(425, 550, [0, 0, 255])]; + let bytes = write(&pages, &PrintJob::default(), "t").unwrap(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("%PDF-1.4")); + assert!(text.contains("/Count 2")); + assert!(text.contains("/MediaBox [0 0 306.00 396.00]"), "4.25 x 5.5 in is 306 x 396 pt"); + assert!(text.contains("/Width 425 /Height 550")); + assert!(write(&[], &PrintJob::default(), "t").is_err()); + let mixed = [raster(10, 10, [0; 3]), raster(10, 12, [0; 3])]; + assert!(write(&mixed, &PrintJob::default(), "t").is_err()); + let bad = PageRaster { ppi: f32::NAN, ..raster(4, 4, [0; 3]) }; + assert!(write(&[bad], &PrintJob::default(), "t").is_err()); + let zero = PageRaster { ppi: 0.0, ..raster(4, 4, [0; 3]) }; + assert!(write(&[zero], &PrintJob::default(), "t").is_err()); + } +} diff --git a/crates/engine/src/print/template.rs b/crates/engine/src/print/template.rs new file mode 100644 index 000000000..2fd74ca60 --- /dev/null +++ b/crates/engine/src/print/template.rs @@ -0,0 +1,27 @@ +//! The settings a print is made from, and (with the saved-template commands) what a print +//! template stores. +use super::PrintJob; +use super::compose::{ImageSettings, PageStyle}; +use super::layout::{Guides, Layout}; +use serde::{Deserialize, Serialize}; + +/// Layout, image settings, page, guides and job: the whole Print module state. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PrintSettings { + pub layout: Layout, + pub image: ImageSettings, + pub page: PageStyle, + pub guides: Guides, + pub job: PrintJob, +} + +impl PrintSettings { + /// Checks every part; run on anything that did not come from the setters. + pub fn validate(&mut self) -> Result<(), String> { + self.layout.validate()?; + self.image.validate()?; + self.guides.validate()?; + self.job.validate() + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 724758c8a..4c3a84ef0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 15: print pages compose in the engine (fit / Zoom to Fill, Rotate to Fit, border, stroke, background) with an interim PDF writer; commands and UI still to land | | 2026-10-12 | minor | Gap 15: the Print layout model (paper sizes, margins, grid, package cells, Auto Layout packer, snapping) is in the engine; commands, composition and UI still to land | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ee965a945..e984542c8 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -748,11 +748,11 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | -| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | +| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | `crates/engine/src/print/compose.rs` | Zoom to Fill, Rotate to Fit, Photo Border (package styles), Inner Stroke and page background compose in the engine (16-bit Rec. 2020 pages, tested on pixels); no command or UI yet | | LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:export.contactSheet`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/print/layout.rs` | A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing. The new layout model (ten paper sizes + custom, orientation, margins, grid with spacing and square cells, package cells with Auto Layout packing) is in the engine but not yet reachable from a command or the UI | | LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | | | | LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | `crates/engine/src/print/layout.rs` | cell model, deterministic packer, stacking order and snapping exist in the engine; no command or UI yet | -| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | optional filename captions; no page backgrounds/identity plates | +| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs`, `crates/engine/src/print/compose.rs` | optional filename captions; page background colour composes in the engine (no command or UI yet); no identity plates | | LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution | | LRC-PRINT-TEMPLATES | Print templates | P2 | ⬜ | | | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-PRINT-IMAGESETTINGS: `print/compose.rs` composes pages (Zoom to Fill, Rotate to Fit, Photo Border, Inner Stroke, background) from the layout model into 16-bit Rec. 2020 `PageRaster`s, with an interim sRGB PDF writer in `print/output.rs`; rows stay until commands and UI reach them | | 2026-10-12 | minor | LRC-PRINT-LAYOUT: the Print layout model (paper, margins, grid, package cells, Auto Layout packer, snapping) lands in `print/layout.rs`; no row changes status until commands and UI follow | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | From 3b32f82e9e879e14d10b1f8afe96a572097fb895 Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Mon, 12 Oct 2026 14:35:03 +0530 Subject: [PATCH 11/52] LRC-PRINT-LAYOUTSTYLE: print.* commands for the layout styles, cells, templates and PDF The Print module's state lives in the session (Session::print), so the app, CLI, control channel and MCP share one print: layout style (Single Image, Contact Sheet, Picture Package, Custom Package), page setup, grid, image settings, page background, guides and job. 18 commands, none journaled (nothing here touches the catalog): print.get / set / reset set merges a partial patch, validates the whole result and changes nothing on error; a new paper or margin pulls cells back inside print.cell.add|move|resize|delete|stack|photo|page print.layout.autoLayout|newPage|clear print.exportPdf {path, ids?} composes and writes the PDF with an activity row, refuses to replace an original print.template.list|save|apply|delete Templates keep layout, image settings, page and job (not the photos assigned to cells, not the guides) and are saved in the library's prefs.json next to the export presets. They are stored as JSON and checked when applied, so a template from another version or a hostile one is an error on use and never makes the preferences file unreadable. Tests drive every command through the dispatcher (hostile numbers, unknown keys, caps, persistence across reopening a library) and a CLI end-to-end test chains set / add cells / Auto Layout / PDF in one run. --- apps/lightcraft-cli/tests/cli.rs | 39 ++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/print.rs | 351 ++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 9 + crates/engine/src/library.rs | 4 + crates/engine/src/print/layout.rs | 32 +++ crates/engine/src/print/template.rs | 97 ++++++++ crates/engine/src/tests_print.rs | 307 ++++++++++++++++++++++++ docs/gaps.md | 3 +- docs/parity-checklist.md | 23 +- 10 files changed, 855 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/cmd/print.rs create mode 100644 crates/engine/src/tests_print.rs diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 9aafc9310..fe29704b1 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -47,6 +47,45 @@ fn contact_sheet_exports_pdf_and_protects_originals() { assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output"); } +#[test] +fn print_commands_chain_into_a_picture_package_pdf() { + let input = tmp("print-input.png"); + gradient_png(&input); + let out = tmp("print.pdf"); + let (ok, lines, err) = run_cli( + &[ + "--import", + input.to_str().unwrap(), + "print.set", + r#"layout={"style":"picturePackage","page":{"paper":"4x6","orientation":"landscape"}}"#, + r#"image={"zoomToFill":true,"borderEnabled":true,"borderWidth":0.05}"#, + "job={\"ppi\":72}", + "print.cell.add", + "width=1.5", + "height=1", + "print.cell.add", + "width=1.5", + "height=1", + "print.layout.autoLayout", + "print.exportPdf", + &format!("path={}", out.display()), + ], + None, + ); + assert!(ok, "{err}: {lines:?}"); + let export = lines.last().unwrap(); + assert_eq!((export["result"]["pages"].as_u64(), export["result"]["photos"].as_u64()), (Some(1), Some(1))); + let bytes = std::fs::read(&out).unwrap(); + assert!(bytes.starts_with(b"%PDF-1.4")); + assert!(String::from_utf8_lossy(&bytes).contains("/MediaBox [0 0 432.00 288.00]"), "6 x 4 in"); + // a bad setting fails the run and writes nothing new + let before = std::fs::read(&out).unwrap(); + let (ok, _, _) = + run_cli(&["--import", input.to_str().unwrap(), "print.set", "job={\"ppi\":5}", "print.exportPdf", &format!("path={}", out.display())], None); + assert!(!ok); + assert_eq!(std::fs::read(&out).unwrap(), before); +} + fn tmp(name: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4de460761..5718d6546 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -32,6 +32,7 @@ mod organize; mod prefs; mod preset_files; pub mod previews; +mod print; mod query; mod sync; mod xmp; @@ -154,6 +155,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(preset_files::specs()); v.extend(prefs::specs()); v.extend(export::specs()); + v.extend(print::specs()); v.extend(before::specs()); v.extend(browse::specs()); v.extend(missing::specs()); diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs new file mode 100644 index 000000000..4fa77df86 --- /dev/null +++ b/crates/engine/src/cmd/print.rs @@ -0,0 +1,351 @@ +//! `print.*`: the Print module as commands. +//! +//! The module's state is [`Session::print`] (layout, image settings, page, guides, job), so the +//! app, CLI, control channel and MCP share one print. Lengths are inches in page space (origin at +//! the top-left of the page as printed); cell ids and photo ids are numbers. Nothing here touches +//! the catalog, so none of it is journaled or undoable; saved templates live in prefs.json. +//! +//! * `print.get`, `print.set {layout?, image?, page?, guides?, job?}`, `print.reset` +//! * `print.cell.add|move|resize|delete|stack|photo|page`, `print.layout.autoLayout|newPage|clear` +//! * `print.exportPdf {path, ids?}` +//! * `print.template.list|save|apply|delete` +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd, f64_req}; +use crate::print::layout::{Layout, MAX_PAGES, Rect, Snap, Stack, paper_names, paper_size}; +use crate::print::template::{MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name}; +use crate::{Result, Session}; + +fn rect_json(r: &Rect) -> Value { + json!({"x": r.x, "y": r.y, "w": r.w, "h": r.h}) +} + +/// The state, with what a front end needs to draw it (page size, printable area, grid cells). +fn state(s: &Session, photos: usize) -> Value { + let l = &s.print.layout; + let mut out = json!({ + "settings": s.print, + "papers": paper_names().filter_map(|n| paper_size(n).map(|(w, h)| json!({"name": n, "width": w, "height": h}))).collect::>(), + "overlaps": l.has_overlaps(), + "photos": photos, + }); + if let (Ok((w, h)), Ok(area), Some(o)) = (l.page_size(), l.printable(), out.as_object_mut()) { + o.insert("pageWidth".into(), json!(w)); + o.insert("pageHeight".into(), json!(h)); + o.insert("printable".into(), rect_json(&area)); + o.insert("pageCount".into(), json!(l.page_count(photos))); + o.insert("cellsPerPage".into(), json!(l.cells_per_page())); + if !l.style.is_package() { + o.insert("gridCells".into(), json!(l.grid_rects().unwrap_or_default().iter().map(rect_json).collect::>())); + } + } + out +} + +fn reply(s: &Session) -> Result { + Ok(state(s, s.targets(&Value::Null).len())) +} + +/// Applies `patch` over `dst`: objects merge key by key, anything else replaces. +fn merge(dst: &mut Value, patch: &Value, depth: usize) { + match (dst, patch) { + (Value::Object(d), Value::Object(p)) if depth < 8 => { + for (k, v) in p { + match d.get_mut(k) { + Some(slot) => merge(slot, v, depth + 1), + None => { + d.insert(k.clone(), v.clone()); + } + } + } + } + (d, p) => *d = p.clone(), + } +} + +fn get(s: &mut Session, p: &Value) -> Result { + Ok(state(s, s.targets(p).len())) +} + +fn set(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.set"; + let patch = p.as_object().ok_or_else(|| bad(ID, "params must be an object"))?; + if let Some(k) = patch.keys().find(|k| !matches!(k.as_str(), "layout" | "image" | "page" | "guides" | "job")) { + return Err(bad(ID, format!("unknown parameter `{k}` (one of layout, image, page, guides, job)"))); + } + if let Some(l) = patch.get("layout").and_then(Value::as_object) + && let Some(k) = l.keys().find(|k| matches!(k.as_str(), "cells" | "pages" | "nextId")) + { + return Err(bad(ID, format!("`layout.{k}` is changed with the print.cell.* and print.layout.* commands"))); + } + let mut value = serde_json::to_value(&s.print).map_err(|e| bad(ID, e.to_string()))?; + merge(&mut value, p, 0); + let mut next: PrintSettings = serde_json::from_value(value).map_err(|e| bad(ID, e.to_string()))?; + // A new paper, orientation or margin may leave cells outside the page: pull them back. + if next.layout.page.printable().is_ok() { + next.layout.fit_cells().map_err(|e| bad(ID, e))?; + } + next.validate().map_err(|e| bad(ID, e))?; + s.print = next; + reply(s) +} + +fn reset(s: &mut Session, _: &Value) -> Result { + s.print = PrintSettings::default(); + reply(s) +} + +fn cell_id(p: &Value, id: &str) -> Result { + p.get("id").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(id, "missing cell `id`")) +} + +fn opt_f64(p: &Value, key: &str, id: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().map(Some).ok_or_else(|| bad(id, format!("`{key}` must be a number"))), + } +} + +/// Snaps `r` (a cell being moved or resized) when the call asks for it. +fn snapped(l: &Layout, guides: &crate::print::layout::Guides, id: u32, r: Rect, p: &Value, resize: bool, cmd: &str) -> Result { + let on = match p.get("snap") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad(cmd, "`snap` must be true or false")), + }; + if !on || guides.snap == Snap::Off { + return Ok(r); + } + let threshold = opt_f64(p, "threshold", cmd)?.unwrap_or(0.1); + let f = if resize { Layout::snap_resize } else { Layout::snap_move }; + f(l, id, r, guides.snap, guides.grid_size, threshold).map_err(|e| bad(cmd, e)) +} + +fn cell_add(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.add"; + let (w, h) = (f64_req(p, "width", ID)?, f64_req(p, "height", ID)?); + let mut next = s.print.layout.clone(); + let id = next.add_cell(w, h).map_err(|e| bad(ID, e))?; + if let (Some(x), Some(y)) = (opt_f64(p, "x", ID)?, opt_f64(p, "y", ID)?) { + if let Some(page) = p.get("page").and_then(Value::as_u64) { + let page = u32::try_from(page).map_err(|_| bad(ID, "`page` is too large"))?; + next.set_cell_page(id, page).map_err(|e| bad(ID, e))?; + } + next.move_cell(id, x, y).map_err(|e| bad(ID, e))?; + } + if let Some(v) = p.get("photo").filter(|v| !v.is_null()) { + let photo = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id"))?; + next.set_cell_photo(id, Some(photo)).map_err(|e| bad(ID, e))?; + } + s.print.layout = next; + let cell = s.print.layout.cell(id).cloned(); + Ok(json!({"id": id, "cell": cell, "pages": s.print.layout.pages})) +} + +fn cell_move(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.move"; + let id = cell_id(p, ID)?; + let (x, y) = (f64_req(p, "x", ID)?, f64_req(p, "y", ID)?); + let mut next = s.print.layout.clone(); + let size = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?; + let target = snapped(&next, &s.print.guides, id, Rect::new(x, y, size.w, size.h), p, false, ID)?; + let r = next.move_cell(id, target.x, target.y).map_err(|e| bad(ID, e))?; + s.print.layout = next; + Ok(json!({"id": id, "rect": r})) +} + +fn cell_resize(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.resize"; + let id = cell_id(p, ID)?; + let mut next = s.print.layout.clone(); + let cur = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?; + let want = Rect::new( + opt_f64(p, "x", ID)?.unwrap_or(cur.x), + opt_f64(p, "y", ID)?.unwrap_or(cur.y), + opt_f64(p, "width", ID)?.unwrap_or(cur.w), + opt_f64(p, "height", ID)?.unwrap_or(cur.h), + ); + let target = snapped(&next, &s.print.guides, id, want, p, true, ID)?; + let r = next.resize_cell(id, target).map_err(|e| bad(ID, e))?; + s.print.layout = next; + Ok(json!({"id": id, "rect": r})) +} + +fn cell_delete(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.delete"; + let id = cell_id(p, ID)?; + s.print.layout.delete_cell(id).map_err(|e| bad(ID, e))?; + Ok(json!({"deleted": id, "cells": s.print.layout.cells.len()})) +} + +fn cell_stack(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.stack"; + let id = cell_id(p, ID)?; + let how = match p.get("order").and_then(Value::as_str) { + Some("toBack") => Stack::ToBack, + Some("backward") => Stack::Backward, + Some("forward") => Stack::Forward, + Some("toFront") => Stack::ToFront, + _ => return Err(bad(ID, "`order` must be toBack, backward, forward or toFront")), + }; + s.print.layout.restack(id, how).map_err(|e| bad(ID, e))?; + Ok(json!({"order": s.print.layout.cells.iter().map(|c| c.id).collect::>()})) +} + +fn cell_photo(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.photo"; + let id = cell_id(p, ID)?; + let photo = match p.get("photo") { + None | Some(Value::Null) => None, + Some(v) => { + let n = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id or null"))?; + if s.catalog.photo(lightcraft_catalog::PhotoId(n)).is_none() { + return Err(bad(ID, format!("no photo {n}"))); + } + Some(n) + } + }; + s.print.layout.set_cell_photo(id, photo).map_err(|e| bad(ID, e))?; + Ok(json!({"id": id, "photo": photo})) +} + +fn cell_page(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.page"; + let id = cell_id(p, ID)?; + let page = p.get("page").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(ID, "missing zero-based `page`"))?; + s.print.layout.set_cell_page(id, page).map_err(|e| bad(ID, e))?; + Ok(json!({"id": id, "page": page})) +} + +fn auto_layout(s: &mut Session, _: &Value) -> Result { + s.print.layout.auto_layout().map_err(|e| bad("print.layout.autoLayout", e))?; + Ok(json!({"pages": s.print.layout.pages, "cells": s.print.layout.cells})) +} + +fn new_page(s: &mut Session, _: &Value) -> Result { + let page = s.print.layout.new_page().map_err(|e| bad("print.layout.newPage", e))?; + Ok(json!({"page": page, "pages": s.print.layout.pages, "max": MAX_PAGES})) +} + +fn clear(s: &mut Session, _: &Value) -> Result { + s.print.layout.clear(); + Ok(json!({"pages": 1, "cells": 0})) +} + +fn export_pdf(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.exportPdf"; + let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(ID, "missing path"))?; + if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "path" | "ids" | "id"))) { + return Err(bad(ID, format!("unknown parameter `{k}` (one of path, ids; change the print with print.set)"))); + } + if p.get("ids").is_some_and(|v| v.as_array().is_none_or(|a| a.iter().any(|v| v.as_u64().is_none()))) { + return Err(bad(ID, "ids must be an array of photo ids")); + } + if p.get("id").is_some_and(|v| v.as_u64().is_none()) { + return Err(bad(ID, "id must be a photo id")); + } + s.check_write_target(path).map_err(|e| bad(ID, e))?; + let ids = s.targets(p); + let settings = s.print.clone(); + let title = std::path::Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string(); + let work = crate::print::compose::prepare(s, &settings, &ids).map_err(|e| bad(ID, e))?; + let total = work.len() as u64; + let row = s.activity.start("export", "Printing to PDF", crate::activity::Cancel::No); + row.progress(0, total); + let pages = work + .run(&mut |done, name| { + row.progress(done as u64, total); + row.detail(name); + true + }) + .map_err(|e| bad(ID, e))?; + let bytes = crate::print::output::write(&pages, &settings.job, &title).map_err(|e| bad(ID, e))?; + s.check_write_target(path).map_err(|e| bad(ID, e))?; + crate::export::write_file(path, &bytes).map_err(|e| bad(ID, e))?; + Ok(json!({"path": path, "pages": pages.len(), "photos": ids.len(), "bytes": bytes.len()})) +} + +// ---- templates ------------------------------------------------------------------------------ + +fn template_list(s: &Session) -> Value { + json!({"templates": s.print_templates.iter().map(|t| json!({"name": t.name, "style": t.settings.pointer("/layout/style"), "settings": t.settings})).collect::>()}) +} + +fn template_name(p: &Value, id: &str) -> Result { + clean_name(p.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing `name`"))?).map_err(|e| bad(id, e)) +} + +fn template_save(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.template.save"; + let name = template_name(p, ID)?; + let t = PrintTemplate::new(&name, &s.print).map_err(|e| bad(ID, e))?; + match s.print_templates.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = t, + None => { + if s.print_templates.len() >= MAX_TEMPLATES { + return Err(bad(ID, format!("at most {MAX_TEMPLATES} templates; delete one first"))); + } + s.print_templates.push(t); + } + } + s.save_prefs()?; + Ok(template_list(s)) +} + +fn template_apply(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.template.apply"; + let name = template_name(p, ID)?; + let t = s + .print_templates + .iter() + .find(|x| x.name.eq_ignore_ascii_case(&name)) + .ok_or_else(|| bad(ID, format!("no print template `{name}` (see print.template.list)")))?; + s.print = t.settings(&s.print.guides).map_err(|e| bad(ID, e))?; + reply(s) +} + +fn template_delete(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.template.delete"; + let name = template_name(p, ID)?; + let before = s.print_templates.len(); + s.print_templates.retain(|x| !x.name.eq_ignore_ascii_case(&name)); + if s.print_templates.len() == before { + return Err(bad(ID, format!("no print template `{name}`"))); + } + s.save_prefs()?; + Ok(template_list(s)) +} + +pub fn specs() -> Vec { + vec![ + cmd!(query "print.get", "Print Settings", [], None, + "{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches", + always, get), + cmd!(query "print.set", "Set Print Settings", [], None, + "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get", + always, set), + cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset), + cmd!(query "print.cell.add", "Add Print Cell", [], None, + "{width, height, x?, y?, page?, photo?} inches; picture/custom package → {id, cell, pages}; the cell goes to the first free spot unless x and y are given", + always, cell_add), + cmd!(query "print.cell.move", "Move Print Cell", [], None, "{id, x, y, snap?: bool, threshold?: inches} → {id, rect}; snap uses the guides' snap mode", always, cell_move), + cmd!(query "print.cell.resize", "Resize Print Cell", [], None, + "{id, width?, height?, x?, y?, snap?: bool, threshold?} → {id, rect}", always, cell_resize), + cmd!(query "print.cell.delete", "Delete Print Cell", [], None, "{id}", always, cell_delete), + cmd!(query "print.cell.stack", "Arrange Print Cell", [], None, "{id, order: toBack|backward|forward|toFront} → {order: cell ids back to front}", always, cell_stack), + cmd!(query "print.cell.photo", "Set Print Cell Photo", [], None, "{id, photo: photo id | null} (custom package: the photo printed in the cell)", always, cell_photo), + cmd!(query "print.cell.page", "Move Print Cell to Page", [], None, "{id, page: zero-based}", always, cell_page), + cmd!(query "print.layout.autoLayout", "Auto Layout", [], None, "{} — packs the package's cells onto as few pages as possible → {pages, cells}", always, auto_layout), + cmd!(query "print.layout.newPage", "New Print Page", [], None, "{} → {page, pages}", always, new_page), + cmd!(query "print.layout.clear", "Clear Print Layout", [], None, "{} — removes every cell and extra page", always, clear), + cmd!(query "print.exportPdf", "Print to PDF", [], None, + "{path, ids?: photo ids (default selection)} → {path, pages, photos, bytes}; prints the current print.get settings; replaces an existing output, never an original", + always, export_pdf), + cmd!(query "print.template.list", "Print Templates", [], None, "{} → {templates: [{name, style, settings}]}", always, |s, _| Ok(template_list(s))), + cmd!(query "print.template.save", "Save Print Template", [], None, + "{name} — saves the layout, image settings, page and job (not the cells' photos or the guides); an existing name (any case) is replaced → print.template.list", + always, template_save), + cmd!(query "print.template.apply", "Apply Print Template", [], None, "{name} → print.get", always, template_apply), + cmd!(query "print.template.delete", "Delete Print Template", [], None, "{name} → print.template.list", always, template_delete), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 21fc30e46..4cce1d8df 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -251,6 +251,11 @@ pub struct Session { pub last_export: Option, /// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json. pub export_presets: Vec, + /// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives + /// with the session, so the app, CLI, control channel and MCP all see the same print. + pub print: print::template::PrintSettings, + /// Saved print templates (`print.template.*`), persisted in prefs.json. + pub print_templates: Vec, /// Metadata presets (`metadata.*`), persisted in prefs.json. pub metadata_presets: Vec, /// Saved filter-bar settings (`filter.*`), persisted in prefs.json. @@ -371,6 +376,8 @@ impl Session { xmp: sidecar::XmpPrefs::default(), last_export: None, export_presets: Vec::new(), + print: Default::default(), + print_templates: Vec::new(), metadata_presets: Vec::new(), filter_presets: Vec::new(), curve_presets: Vec::new(), @@ -1020,6 +1027,8 @@ mod tests_persist; #[cfg(test)] mod tests_prefs; #[cfg(test)] +mod tests_print; +#[cfg(test)] mod tests_process; #[cfg(test)] mod tests_segment; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 2bb5511c2..5e4da681e 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -161,6 +161,8 @@ struct PrefsFile { last_export: Option, /// The user's export presets. export_presets: Vec, + /// Print templates. + print_templates: Vec, /// Metadata presets. metadata_presets: Vec, /// Filter presets. @@ -393,6 +395,7 @@ impl Session { self.xmp = prefs.xmp; self.last_export = prefs.last_export; self.export_presets = prefs.export_presets; + self.print_templates = prefs.print_templates; self.metadata_presets = prefs.metadata_presets; self.filter_presets = prefs.filter_presets; self.curve_presets = prefs.curve_presets; @@ -628,6 +631,7 @@ impl Session { xmp: self.xmp, last_export: self.last_export.clone(), export_presets: self.export_presets.clone(), + print_templates: self.print_templates.clone(), metadata_presets: self.metadata_presets.clone(), filter_presets: self.filter_presets.clone(), curve_presets: self.curve_presets.clone(), diff --git a/crates/engine/src/print/layout.rs b/crates/engine/src/print/layout.rs index 8a9e1a027..26d1818e5 100644 --- a/crates/engine/src/print/layout.rs +++ b/crates/engine/src/print/layout.rs @@ -819,6 +819,23 @@ impl Layout { Ok(()) } + /// Pulls every cell back inside the printable area (shrinking one that is bigger than it), for + /// after the paper, orientation or margins changed. + pub fn fit_cells(&mut self) -> Result<(), String> { + let area = self.printable()?; + for c in &mut self.cells { + let r = &mut c.rect; + if !r.is_finite() { + continue; + } + r.w = r.w.min(area.w); + r.h = r.h.min(area.h); + r.x = r.x.clamp(area.x, (area.right() - r.w).max(area.x)); + r.y = r.y.clamp(area.y, (area.bottom() - r.h).max(area.y)); + } + Ok(()) + } + /// Whether two cells on the same page overlap. pub fn has_overlaps(&self) -> bool { self.cells.iter().enumerate().any(|(i, a)| self.cells.iter().skip(i + 1).any(|b| a.page == b.page && a.rect.overlaps(&b.rect))) @@ -1271,6 +1288,21 @@ mod tests { assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, f64::NAN, 0.1).is_err()); } + #[test] + fn fit_cells_follows_the_margins() { + let mut l = package(); + let id = l.add_cell(7.0, 10.0).unwrap(); + l.page.margins = Margins::uniform(1.0); + assert!(l.validate().is_err(), "the cell no longer fits"); + l.fit_cells().unwrap(); + l.validate().unwrap(); + let r = l.cell(id).unwrap().rect; + assert!(l.printable().unwrap().contains(&r)); + assert!((r.w - 6.5).abs() < 1e-9 && (r.h - 9.0).abs() < 1e-9); + l.page.paper = "poster".into(); + assert!(l.fit_cells().is_err()); + } + #[test] fn cell_aspect_survives_auto_layout() { let mut l = package(); diff --git a/crates/engine/src/print/template.rs b/crates/engine/src/print/template.rs index 2fd74ca60..2465c123a 100644 --- a/crates/engine/src/print/template.rs +++ b/crates/engine/src/print/template.rs @@ -25,3 +25,100 @@ impl PrintSettings { self.job.validate() } } + +/// Longest template name, characters. +pub const MAX_NAME: usize = 80; +/// Most templates kept. +pub const MAX_TEMPLATES: usize = 200; + +/// A saved print template: the layout, image settings, page and job of a print, without the +/// photos assigned to cells and without the guides (a view preference). The settings are kept as +/// JSON so a template written by another version never makes the preferences file unreadable; +/// [`PrintTemplate::settings`] checks them when they are used. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrintTemplate { + pub name: String, + pub settings: serde_json::Value, +} + +/// A template name: trimmed, 1 to [`MAX_NAME`] characters, no control characters. +pub fn clean_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() || name.chars().count() > MAX_NAME || name.chars().any(char::is_control) { + return Err(format!("a template name is 1 to {MAX_NAME} characters without control characters")); + } + Ok(name.to_string()) +} + +impl PrintTemplate { + /// A template of `settings` (validated first) named `name`. + pub fn new(name: &str, settings: &PrintSettings) -> Result { + let mut s = settings.clone(); + s.validate()?; + for c in &mut s.layout.cells { + c.photo = None; + } + let mut value = serde_json::to_value(&s).map_err(|e| e.to_string())?; + if let Some(o) = value.as_object_mut() { + o.remove("guides"); + } + Ok(Self { name: clean_name(name)?, settings: value }) + } + + /// The settings, checked: unknown fields, hostile numbers and inconsistent layouts are errors. + /// `guides` are those of the print the template is applied to. + pub fn settings(&self, guides: &Guides) -> Result { + let mut s: PrintSettings = serde_json::from_value(self.settings.clone()).map_err(|e| format!("template `{}`: {e}", self.name))?; + s.guides = *guides; + s.validate().map_err(|e| format!("template `{}`: {e}", self.name))?; + Ok(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::print::layout::Style; + use serde_json::json; + + #[test] + fn a_template_keeps_the_look_not_the_photos_or_guides() { + let mut s = PrintSettings::default(); + s.layout.style = Style::CustomPackage; + let id = s.layout.add_cell(3.0, 2.0).unwrap(); + s.layout.set_cell_photo(id, Some(77)).unwrap(); + s.image.zoom_to_fill = true; + s.page.background = [10, 20, 30]; + s.job.ppi = 300.0; + s.guides.grid_size = 1.0; + let t = PrintTemplate::new(" Proof sheet ", &s).unwrap(); + assert_eq!(t.name, "Proof sheet"); + assert!(t.settings.get("guides").is_none()); + let back = t.settings(&Guides::default()).unwrap(); + assert_eq!(back.layout.cells.len(), 1); + assert_eq!(back.layout.cells[0].photo, None); + assert!(back.image.zoom_to_fill); + assert_eq!((back.page.background, back.job.ppi), ([10, 20, 30], 300.0)); + assert_eq!(back.guides, Guides::default(), "the guides are the caller's"); + } + + #[test] + fn hostile_templates_are_rejected() { + for name in ["", " ", "a\nb", &"x".repeat(MAX_NAME + 1)] { + assert!(PrintTemplate::new(name, &PrintSettings::default()).is_err(), "{name:?}"); + } + let bad = |v: serde_json::Value| PrintTemplate { name: "t".into(), settings: v }.settings(&Guides::default()).is_err(); + assert!(bad(json!("text"))); + assert!(bad(json!({"surprise": 1}))); + assert!(bad(json!({"job": {"ppi": 1e30}}))); + assert!(bad(json!({"layout": {"pages": 0}}))); + assert!(bad(json!({"layout": {"page": {"paper": "custom", "customWidth": -3}}}))); + assert!(bad(json!({"layout": {"cells": [{"id": 1, "rect": {"x": 0, "y": 0, "w": 1e9, "h": 1}}]}}))); + assert!(bad(json!({"image": {"borderWidth": -1}}))); + assert!(!bad(json!({}))); + let mut s = PrintSettings::default(); + s.job.ppi = f32::NAN; + assert!(PrintTemplate::new("t", &s).is_err()); + } +} diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs new file mode 100644 index 000000000..261d47d52 --- /dev/null +++ b/crates/engine/src/tests_print.rs @@ -0,0 +1,307 @@ +//! The `print.*` commands: state, cells, templates and the PDF. + +use lightcraft_catalog::MemStore; +use serde_json::{Value, json}; + +use crate::Session; +use crate::library::LibraryStores; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn fails(s: &mut Session, id: &str, p: Value) -> String { + s.execute(id, &p).expect_err(&format!("{id} {p} should fail")).to_string() +} + +fn package(s: &mut Session) { + run(s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "letter"}}})); +} + +#[test] +fn get_set_and_reset() { + let mut s = Session::with_demo(); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["settings"]["layout"]["style"], "singleImage"); + assert_eq!(g["pageWidth"], 8.5); + assert_eq!(g["printable"]["x"], 0.25); + assert_eq!(g["settings"]["job"]["ppi"], 240.0); + assert!(g["papers"].as_array().unwrap().iter().any(|p| p["name"] == "a4")); + assert_eq!(g["gridCells"].as_array().unwrap().len(), 1); + + let g = run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 2, "rows": 3}, "page": {"paper": "a4", "orientation": "landscape", "margins": {"left": 1.0}}}, "image": {"zoomToFill": true}, "job": {"ppi": 300.0}}), + ); + assert_eq!(g["settings"]["layout"]["page"]["margins"]["left"], 1.0); + assert_eq!(g["settings"]["layout"]["page"]["margins"]["top"], 0.25, "a patch merges"); + assert_eq!(g["gridCells"].as_array().unwrap().len(), 6); + assert!((g["pageWidth"].as_f64().unwrap() - 11.693).abs() < 1e-3); + assert_eq!(g["settings"]["image"]["zoomToFill"], true); + // the page count follows the photos: 6 cells per page + let g = run(&mut s, "print.get", json!({"ids": vec![1; 7]})); + assert_eq!(g["pageCount"], 2); + + let g = run(&mut s, "print.reset", json!({})); + assert_eq!(g["settings"]["job"]["ppi"], 240.0); +} + +#[test] +fn bad_settings_are_refused_and_change_nothing() { + let mut s = Session::with_demo(); + let before = run(&mut s, "print.get", json!({})); + for p in [ + json!({"typo": 1}), + json!({"layout": {"typo": 1}}), + json!({"layout": {"style": "poster"}}), + json!({"layout": {"cells": []}}), + json!({"layout": {"pages": 3}}), + json!({"layout": {"page": {"paper": "poster"}}}), + json!({"layout": {"page": {"paper": "custom", "customWidth": -1}}}), + json!({"layout": {"page": {"margins": {"top": 1e9}}}}), + json!({"layout": {"page": {"margins": {"top": 6.0, "bottom": 5.0}}}}), + json!({"layout": {"grid": {"columns": 0}}}), + json!({"layout": {"grid": {"columns": 9999}}}), + json!({"layout": {"spacing": -1}}), + json!({"image": {"borderWidth": 99}}), + json!({"image": {"strokeWidthPt": -2}}), + json!({"image": {"borderColor": [1, 2]}}), + json!({"image": {"zoomToFill": "yes"}}), + json!({"guides": {"gridSize": 0}}), + json!({"guides": {"units": "furlong"}}), + json!({"job": {"ppi": 1e30}}), + json!({"job": {"ppi": 10}}), + json!({"job": null}), + ] { + fails(&mut s, "print.set", p); + } + assert_eq!(run(&mut s, "print.get", json!({})), before); + assert!(s.execute("print.set", &json!([1])).is_err()); +} + +#[test] +fn cells_are_commands() { + let mut s = Session::with_demo(); + // grid styles have no cells + fails(&mut s, "print.cell.add", json!({"width": 2, "height": 2})); + package(&mut s); + let a = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap(); + let b = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0, "x": 5.0, "y": 5.0}))["id"].as_u64().unwrap(); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["settings"]["layout"]["cells"].as_array().unwrap().len(), 2); + assert_eq!(g["overlaps"], false); + // move, clamped inside the margins + let r = run(&mut s, "print.cell.move", json!({"id": a, "x": 99.0, "y": 0.0})); + assert_eq!(r["rect"]["x"], 8.25 - 3.0); + assert_eq!(r["rect"]["y"], 0.25); + // resize keeps what is not given + let r = run(&mut s, "print.cell.resize", json!({"id": b, "width": 4.0})); + assert_eq!((r["rect"]["w"].as_f64().unwrap(), r["rect"]["h"].as_f64().unwrap()), (4.0, 2.0)); + // snapping to the other cell's edge + let ra = run(&mut s, "print.get", json!({}))["settings"]["layout"]["cells"][0]["rect"].clone(); + let left = ra["x"].as_f64().unwrap(); + let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0, "snap": true})); + assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.0)).abs() < 1e-9, "{r}"); + let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0})); + assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.04)).abs() < 1e-9, "no snap unless asked: {r}"); + run(&mut s, "print.set", json!({"guides": {"snap": "off"}})); + let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0, "snap": true})); + assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.04)).abs() < 1e-9, "snap off"); + // stacking + let r = run(&mut s, "print.cell.stack", json!({"id": a, "order": "toFront"})); + assert_eq!(r["order"], json!([b, a])); + fails(&mut s, "print.cell.stack", json!({"id": a, "order": "sideways"})); + // photos and pages + let photo = s.catalog.photos().next().unwrap().id.0; + run(&mut s, "print.cell.photo", json!({"id": a, "photo": photo})); + fails(&mut s, "print.cell.photo", json!({"id": a, "photo": 999_999})); + run(&mut s, "print.layout.newPage", json!({})); + run(&mut s, "print.cell.page", json!({"id": b, "page": 1})); + fails(&mut s, "print.cell.page", json!({"id": b, "page": 7})); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["pageCount"], 2); + // delete, clear + run(&mut s, "print.cell.delete", json!({"id": b})); + fails(&mut s, "print.cell.delete", json!({"id": b})); + fails(&mut s, "print.cell.move", json!({"id": 99, "x": 0, "y": 0})); + fails(&mut s, "print.cell.move", json!({"x": 0, "y": 0})); + run(&mut s, "print.layout.clear", json!({})); + let g = run(&mut s, "print.get", json!({})); + assert_eq!((g["settings"]["layout"]["cells"].as_array().unwrap().len(), g["pageCount"].as_u64()), (0, Some(1))); +} + +#[test] +fn hostile_cell_numbers_are_refused() { + let mut s = Session::with_demo(); + package(&mut s); + for (w, h) in [(0.0, 2.0), (-1.0, 2.0), (1e12, 1e12), (2.0, 1e-9), (50.0, 50.0)] { + fails(&mut s, "print.cell.add", json!({"width": w, "height": h})); + } + fails(&mut s, "print.cell.add", json!({"width": "wide", "height": 2})); + fails(&mut s, "print.cell.add", json!({"width": 2})); + let id = run(&mut s, "print.cell.add", json!({"width": 2, "height": 2}))["id"].as_u64().unwrap(); + fails(&mut s, "print.cell.move", json!({"id": id, "x": "left", "y": 0})); + fails(&mut s, "print.cell.resize", json!({"id": id, "width": -4})); + fails(&mut s, "print.cell.resize", json!({"id": id, "width": "x"})); + fails(&mut s, "print.cell.move", json!({"id": id, "x": 1, "y": 1, "snap": "yes"})); + fails(&mut s, "print.cell.move", json!({"id": u64::MAX, "x": 1, "y": 1})); + let r = run(&mut s, "print.cell.move", json!({"id": id, "x": -1e300, "y": 1e300})); + assert_eq!(r["rect"]["x"], 0.25); +} + +#[test] +fn auto_layout_fills_pages_without_overlap() { + let mut s = Session::with_demo(); + run(&mut s, "print.set", json!({"layout": {"style": "picturePackage"}})); + for (w, h) in [(4.0, 6.0), (4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (2.5, 3.5), (2.5, 3.5), (8.0, 10.0)] { + run(&mut s, "print.cell.add", json!({"width": w, "height": h})); + } + let r = run(&mut s, "print.layout.autoLayout", json!({})); + assert!(r["pages"].as_u64().unwrap() >= 2); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["overlaps"], false); + let again = run(&mut s, "print.layout.autoLayout", json!({})); + assert_eq!(again["cells"], r["cells"], "deterministic"); + // an oversize cell that arrived by a paper change is pulled in, not left outside the page + run(&mut s, "print.set", json!({"layout": {"page": {"paper": "4x6"}}})); + let g = run(&mut s, "print.get", json!({})); + for c in g["settings"]["layout"]["cells"].as_array().unwrap() { + assert!(c["rect"]["x"].as_f64().unwrap() + c["rect"]["w"].as_f64().unwrap() <= 4.0 - 0.25 + 1e-9); + } +} + +fn temp_pdf(name: &str) -> String { + std::env::temp_dir().join(format!("lightcraft-print-{name}-{}.pdf", std::process::id())).to_string_lossy().to_string() +} + +#[test] +fn export_pdf_prints_the_current_settings() { + let mut s = Session::with_demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 2}, "page": {"paper": "a5"}}, "job": {"ppi": 100.0}}), + ); + let path = temp_pdf("export"); + let r = run(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); + assert_eq!((r["pages"].as_u64(), r["photos"].as_u64()), (Some(2), Some(3))); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(bytes.len() as u64, r["bytes"].as_u64().unwrap()); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("%PDF-1.4") && text.contains("/Count 2")); + // A5 is 148 x 210 mm = 419.53 x 595.28 pt, rounded to whole pixels at 100 ppi (583 x 827) + assert!(text.contains("/MediaBox [0 0 419.76 595.44]"), "{}", &text[..text.len().min(400)]); + // the row in the activity stack is gone when the print is done + assert!(s.activity.list().is_empty()); + fails(&mut s, "print.exportPdf", json!({})); + fails(&mut s, "print.exportPdf", json!({"path": path, "typo": 1})); + fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ["x"]})); + fails(&mut s, "print.exportPdf", json!({"path": path, "ids": [999_999]})); + // a package with no cells has nothing to print + run(&mut s, "print.set", json!({"layout": {"style": "picturePackage"}})); + fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); +} + +#[test] +fn custom_package_prints_each_cells_photo() { + let mut s = Session::with_demo(); + let ids: Vec = s.catalog.photos().take(2).map(|p| p.id.0).collect(); + run(&mut s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "4x6"}}, "job": {"ppi": 72.0}})); + let a = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap(); + let b = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap(); + run(&mut s, "print.cell.photo", json!({"id": a, "photo": ids[0]})); + run(&mut s, "print.cell.photo", json!({"id": b, "photo": ids[1]})); + let r = run(&mut s, "print.exportPdf", json!({"path": temp_pdf("custom"), "ids": [ids[0]]})); + assert_eq!(r["pages"], 1); +} + +#[test] +fn templates_save_apply_delete_and_persist() { + let files = MemStore::new(); + let open = |files: &MemStore| { + let mut s = Session::with_demo(); + let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + s + }; + let mut s = open(&files); + run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 4, "rows": 5}}, "page": {"background": [10, 20, 30]}, "guides": {"gridSize": 1.0}}), + ); + let r = run(&mut s, "print.template.save", json!({"name": "Proofs"})); + assert_eq!(r["templates"][0]["name"], "Proofs"); + assert!(r["templates"][0]["settings"].get("guides").is_none()); + run(&mut s, "print.template.save", json!({"name": "proofs"})); + assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 1, "the same name in another case replaces"); + + run(&mut s, "print.reset", json!({})); + run(&mut s, "print.set", json!({"guides": {"gridSize": 0.5}})); + let g = run(&mut s, "print.template.apply", json!({"name": "PROOFS"})); + assert_eq!(g["settings"]["layout"]["grid"]["columns"], 4); + assert_eq!(g["settings"]["page"]["background"], json!([10, 20, 30])); + assert_eq!(g["settings"]["guides"]["gridSize"], 0.5, "guides stay as they were"); + + // it survives reopening the library + let mut again = open(&files); + assert_eq!(run(&mut again, "print.template.list", json!({}))["templates"][0]["name"], "proofs"); + run(&mut again, "print.template.apply", json!({"name": "Proofs"})); + + fails(&mut s, "print.template.save", json!({"name": " "})); + fails(&mut s, "print.template.save", json!({})); + fails(&mut s, "print.template.apply", json!({"name": "Nope"})); + fails(&mut s, "print.template.delete", json!({"name": "Nope"})); + run(&mut s, "print.template.delete", json!({"name": "proofs"})); + assert!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty()); + assert!(run(&mut open(&files), "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty()); +} + +#[test] +fn a_hostile_template_in_the_preferences_does_not_break_the_library() { + let files = MemStore::new(); + let hostile = json!({"printTemplates": [ + {"name": "Bad", "settings": {"layout": {"pages": 0}, "extra": true}}, + {"name": "Huge", "settings": {"job": {"ppi": 1e300}}}, + {"name": "Fine", "settings": {"layout": {"style": "contactSheet"}}} + ], "cacheMb": 123}); + files.set("prefs.json", serde_json::to_vec(&hostile).unwrap()); + let mut s = Session::with_demo(); + let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + assert!(s.take_library_warnings().is_empty(), "the preferences are not damaged"); + assert_eq!(s.cache_mb, 123); + assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 3); + fails(&mut s, "print.template.apply", json!({"name": "Bad"})); + fails(&mut s, "print.template.apply", json!({"name": "Huge"})); + run(&mut s, "print.template.apply", json!({"name": "Fine"})); +} + +#[test] +fn every_print_command_is_registered_with_a_description() { + for id in [ + "print.get", + "print.set", + "print.reset", + "print.cell.add", + "print.cell.move", + "print.cell.resize", + "print.cell.delete", + "print.cell.stack", + "print.cell.photo", + "print.cell.page", + "print.layout.autoLayout", + "print.layout.newPage", + "print.layout.clear", + "print.exportPdf", + "print.template.list", + "print.template.save", + "print.template.apply", + "print.template.delete", + ] { + let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} missing")); + assert!(c.params.len() > 3, "{id}"); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 4c3a84ef0..72649a5bc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -29,7 +29,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); HLG; the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read) | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | -| 15 | **Print module**: custom packages, printer colour management (contact-sheet PDF only) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | +| 15 | **Print module**: no Print view (layouts, cells, image settings and templates exist as `print.*` commands only), printer colour management, identity plate / watermark / page numbers / crop marks / photo info, Custom Package Auto Layout | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 15: the Print module's layout styles, cells, image settings, templates and PDF are `print.*` commands (CLI, control, MCP); the Print view, printer colour management and page overlays remain | | 2026-10-12 | minor | Gap 15: print pages compose in the engine (fit / Zoom to Fill, Rotate to Fit, border, stroke, background) with an interim PDF writer; commands and UI still to land | | 2026-10-12 | minor | Gap 15: the Print layout model (paper sizes, margins, grid, package cells, Auto Layout packer, snapping) is in the engine; commands, composition and UI still to land | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e984542c8..75dc988ae 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-CELLS, LRC-PRINT-TEMPLATES and the other print rows: `print.*` commands; LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 407 | 50 | 73 | 35 | 192/200 (96%) | 145/157 (92%) | +| Lightroom Classic extras | 34 | 19 | 32 | 8 | — | 24/26 (92%) | +| **Total** | 408 | 54 | 69 | 35 | 193/201 (96%) | 145/157 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 530 in-scope rows — P0 98.0% of 200 · P1 95.2% of 157 · P2 49.7% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.9%** of 531 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.9% of 172. ## Top gaps and shortcut notes @@ -747,14 +747,14 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | | LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | -| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | -| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | `crates/engine/src/print/compose.rs` | Zoom to Fill, Rotate to Fit, Photo Border (package styles), Inner Stroke and page background compose in the engine (16-bit Rec. 2020 pages, tested on pixels); no command or UI yet | -| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:export.contactSheet`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/print/layout.rs` | A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing. The new layout model (ten paper sizes + custom, orientation, margins, grid with spacing and square cells, package cells with Auto Layout packing) is in the engine but not yet reachable from a command or the UI | -| LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | | | -| LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | `crates/engine/src/print/layout.rs` | cell model, deterministic packer, stacking order and snapping exist in the engine; no command or UI yet | -| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs`, `crates/engine/src/print/compose.rs` | optional filename captions; page background colour composes in the engine (no command or UI yet); no identity plates | +| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:print.set`, `cmd:print.get`, `cmd:print.exportPdf`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | Single Image, Contact Sheet, Picture Package and Custom Package are print layout styles reachable from commands, the CLI, control channel and MCP; no Print module UI yet (File → Contact Sheet PDF still the only dialog) | +| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | 🟡 | `cmd:print.set`, `crates/engine/src/print/compose.rs` | Zoom to Fill, Rotate to Fit, Photo Border (package styles), Inner Stroke (width in points, colour) through `print.set {image}`; pixel-tested; no panel yet | +| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi; commands only, no panel yet | +| LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | `cmd:print.set` | guide options (ruler units in/cm/mm/pt, snap off/cells/grid, grid size, show margins/bleed/page grid/cells/dimensions) are stored and used by snapping in `print.cell.move`/`resize`; nothing is drawn until the Print UI lands | +| LRC-PRINT-CELLS | Picture-package cells | P2 | 🟡 | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout` | add/move/resize/delete cells of any size, stacking order, per-cell photos (Custom Package), deterministic Auto Layout packer, snapping to cells or grid; no UI (drag placement) yet | +| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | page background colour and margins through `print.set`; optional filename captions in the contact-sheet PDF; no identity plates, watermarks, page numbers or crop marks | | LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution | -| LRC-PRINT-TEMPLATES | Print templates | P2 | ⬜ | | | +| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete` | named templates of layout, image settings, page and job saved with the library (prefs.json); no built-in templates, no UI, no template import/export | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | | LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | | LRC-WEB-COLOR | Web gallery colours | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS, LRC-PRINT-LAYOUT, LRC-PRINT-CELLS, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES: the Print module is reachable as 18 `print.*` commands (settings, cells, Auto Layout, PDF, templates saved in prefs.json) from the CLI, control channel and MCP; 🟡 until the Print UI lands, LRC-PRINT-GUIDES stays ⬜ | | 2026-10-12 | minor | LRC-PRINT-IMAGESETTINGS: `print/compose.rs` composes pages (Zoom to Fill, Rotate to Fit, Photo Border, Inner Stroke, background) from the layout model into 16-bit Rec. 2020 `PageRaster`s, with an interim sRGB PDF writer in `print/output.rs`; rows stay until commands and UI reach them | | 2026-10-12 | minor | LRC-PRINT-LAYOUT: the Print layout model (paper, margins, grid, package cells, Auto Layout packer, snapping) lands in `print/layout.rs`; no row changes status until commands and UI follow | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | From 32b5aefc5b1aac6cfe2db374ed6c101338a646b7 Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Mon, 12 Oct 2026 15:15:33 +0530 Subject: [PATCH 12/52] =?UTF-8?q?LRC-PRINT-GUIDES:=20the=20Print=20dialog?= =?UTF-8?q?=20(File=20=E2=86=92=20Print=E2=80=A6)=20with=20page=20preview,?= =?UTF-8?q?=20rulers,=20guides=20and=20cell=20editing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File → Print… (⌘P) opens the Print module as a dialog: settings on the left, the page drawn to scale on the right with the photos as they will print (fit or Zoom to Fill, turned by Rotate to Fit, Photo Border, Inner Stroke, page background). Settings column: templates (save, apply, delete), layout style, image settings, grid / package cell controls (standard and custom cell sizes, Auto Layout, New Page, Clear Layout), rulers and guides (units in/cm/mm/pt, snap off/cells/grid, grid size, margins, bleed, page grid, image cells, dimensions), page setup (paper, orientation, margins), page background and the print job. Preview: rulers in the chosen unit, dashed margins and bleed, page grid, dimension labels, page navigation. In the package styles cells are selected, dragged (print.cell.move) and resized from a corner handle (print.cell.resize) with the guides' snapping, arranged from the inspector row or a context menu, and given a photo (Custom Package). Every change is a print.* command; the drag is measured from where the pointer went down so the click-versus-drag slop is not lost. Print to PDF… picks a file and renders on a background thread with an activity-stack row, cancellation and original protection, like the contact sheet, which keeps working unchanged. Headless tests drive the dialog with real pointer input through the control channel (print:cell:N, print:resize:N, button:print*): styles and checkboxes, add / drag / resize / arrange / delete, snapping to a neighbour, Auto Layout and pages, templates, the PDF, the activity row, and that the dialog and its buttons fit the window. --- README.md | 14 + apps/lightcraft-cli/tests/cli.rs | 7 +- crates/engine/src/print/output.rs | 6 +- crates/engine/src/tests_print.rs | 13 +- crates/ui-egui/src/export_task.rs | 56 ++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menus.rs | 28 +- crates/ui-egui/src/panels/dialogs.rs | 11 +- crates/ui-egui/src/print_view.rs | 823 +++++++++++++++++++++++++++ crates/ui-egui/src/state.rs | 7 + crates/ui-egui/src/tests_print.rs | 300 ++++++++++ docs/gaps.md | 3 +- docs/parity-checklist.md | 23 +- 13 files changed, 1274 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/print_view.rs create mode 100644 crates/ui-egui/src/tests_print.rs diff --git a/README.md b/README.md index 8884f2bd3..8d6a8b414 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,20 @@ lightcraft --control 7980 ~/Pictures/trip with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle. This is a contact-sheet workflow, not a printer-driver or custom picture-package layout editor. +- **Print layouts (native app / CLI):** **File → Print…** opens the Print module's layout editor: Single Image, + Contact Sheet, Picture Package (Auto Layout packs cells onto pages) and Custom Package (drag and resize cells, + stacking order, snap to cells or a grid), Zoom to Fill / Rotate to Fit, Photo Border and Inner Stroke, paper + sizes from 4 × 6 to 13 × 19 and A3 to A5 or custom, margins, rulers in in/cm/mm/pt, page background, 72–720 ppi, + and saved templates. **Print to PDF…** renders in the background. Everything is a `print.*` command + (`print.get`, `print.set`, `print.cell.add`, `print.layout.autoLayout`, `print.exportPdf`, + `print.template.save`…), so the CLI and MCP drive it too: + + ```sh + lightcraft-cli run --import ~/Pictures/shoot print.set 'layout={"style":"picturePackage","page":{"paper":"8x10"}}' \ + print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf + ``` + + Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet. - **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a side door. - The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts. diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index fe29704b1..137caca7f 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -76,8 +76,11 @@ fn print_commands_chain_into_a_picture_package_pdf() { let export = lines.last().unwrap(); assert_eq!((export["result"]["pages"].as_u64(), export["result"]["photos"].as_u64()), (Some(1), Some(1))); let bytes = std::fs::read(&out).unwrap(); - assert!(bytes.starts_with(b"%PDF-1.4")); - assert!(String::from_utf8_lossy(&bytes).contains("/MediaBox [0 0 432.00 288.00]"), "6 x 4 in"); + assert!(bytes.starts_with(b"%PDF-")); + let text = String::from_utf8_lossy(&bytes); + let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len(); + let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + assert_eq!((nums[2], nums[3]), (432.0, 288.0), "6 x 4 in"); // a bad setting fails the run and writes nothing new let before = std::fs::read(&out).unwrap(); let (ok, _, _) = diff --git a/crates/engine/src/print/output.rs b/crates/engine/src/print/output.rs index f058ccf0b..937884a8e 100644 --- a/crates/engine/src/print/output.rs +++ b/crates/engine/src/print/output.rs @@ -88,9 +88,11 @@ mod tests { let pages = [raster(425, 550, [255, 255, 255]), raster(425, 550, [0, 0, 255])]; let bytes = write(&pages, &PrintJob::default(), "t").unwrap(); let text = String::from_utf8_lossy(&bytes); - assert!(text.starts_with("%PDF-1.4")); + assert!(text.starts_with("%PDF-")); assert!(text.contains("/Count 2")); - assert!(text.contains("/MediaBox [0 0 306.00 396.00]"), "4.25 x 5.5 in is 306 x 396 pt"); + let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len(); + let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + assert_eq!((nums[2], nums[3]), (306.0, 396.0), "4.25 x 5.5 in is 306 x 396 pt"); assert!(text.contains("/Width 425 /Height 550")); assert!(write(&[], &PrintJob::default(), "t").is_err()); let mixed = [raster(10, 10, [0; 3]), raster(10, 12, [0; 3])]; diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index 261d47d52..eb7ab032c 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -171,6 +171,14 @@ fn auto_layout_fills_pages_without_overlap() { } } +/// The first page's MediaBox width and height in points. +fn media_box(pdf: &[u8]) -> (f64, f64) { + let text = String::from_utf8_lossy(pdf); + let at = text.find("/MediaBox [").expect("a MediaBox") + "/MediaBox [".len(); + let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + (nums[2], nums[3]) +} + fn temp_pdf(name: &str) -> String { std::env::temp_dir().join(format!("lightcraft-print-{name}-{}.pdf", std::process::id())).to_string_lossy().to_string() } @@ -190,9 +198,10 @@ fn export_pdf_prints_the_current_settings() { let bytes = std::fs::read(&path).unwrap(); assert_eq!(bytes.len() as u64, r["bytes"].as_u64().unwrap()); let text = String::from_utf8_lossy(&bytes); - assert!(text.starts_with("%PDF-1.4") && text.contains("/Count 2")); + assert!(text.starts_with("%PDF-") && text.contains("/Count 2")); // A5 is 148 x 210 mm = 419.53 x 595.28 pt, rounded to whole pixels at 100 ppi (583 x 827) - assert!(text.contains("/MediaBox [0 0 419.76 595.44]"), "{}", &text[..text.len().min(400)]); + let (w, h) = media_box(&bytes); + assert!((w - 419.53).abs() < 1.0 && (h - 595.28).abs() < 1.0, "A5, got {w} x {h} pt"); // the row in the activity stack is gone when the print is done assert!(s.activity.list().is_empty()); fails(&mut s, "print.exportPdf", json!({})); diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 051ad22bd..470e2a377 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -132,6 +132,59 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result Result { + use lightcraft_engine::print::{compose, output}; + if app.export.is_some() { + return Err("an export is already running".into()); + } + let path = params.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or("missing path")?.to_string(); + app.session.check_write_target(&path)?; + let guard = app.session.original_guard(); + let settings = app.session.print.clone(); + let ids = app.session.targets(params); + let prepared = compose::prepare(&mut app.session, &settings, &ids)?; + let total = prepared.len(); + let title = std::path::Path::new(&path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string(); + let write = app.services.write_shared.clone(); + let progress = Arc::new(Mutex::new((0, String::new()))); + let cancel = Arc::new(AtomicBool::new(false)); + let (p, c) = (progress.clone(), cancel.clone()); + let (tx, rx) = channel(); + let photos = ids.len(); + let work = move || { + let result = prepared + .run(&mut |done, name| { + if let Ok(mut g) = p.lock() { + *g = (done, name.to_string()); + } + !c.load(Ordering::Relaxed) + }) + .and_then(|pages| { + if c.load(Ordering::Relaxed) { + return Err("Print cancelled".into()); + } + let bytes = output::write(&pages, &settings.job, &title)?; + guard.check(std::path::Path::new(&path))?; + match write { + Some(write) => write(&path, &bytes)?, + None => lightcraft_engine::export::write_file(&path, &bytes)?, + } + Ok(vec![json!({"path": path, "pages": pages.len(), "photos": photos, "bytes": bytes.len(), "print": true})]) + }); + let _ = tx.send(result.map(|files| (files, None, c.load(Ordering::Relaxed)))); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::Builder::new().name("print".into()).spawn(work).map_err(|e| e.to_string())?; + #[cfg(target_arch = "wasm32")] + work(); + let row = app.session.activity.start("export", "Printing to PDF", Cancel::Flag(cancel.clone())); + row.progress(0, total as u64); + app.export = Some(ExportTask { total, progress, cancel, rx, guard: row }); + Ok(json!({"background": true, "total": total})) +} + /// The toast's account of [`AfterExport::run`]'s Apple Photos outcome. pub fn apple_photos_note(outcome: &Value) -> String { if let Some(e) = outcome.get("error").and_then(Value::as_str) { @@ -200,6 +253,9 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(sheet) = sheet { m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]); } + if let Some(pdf) = files.first().filter(|f| f["print"] == true) { + m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]); + } if skipped > 0 { m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..96ef1d91e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,6 +26,7 @@ pub mod merge; mod model_setup; pub mod panels; pub mod pick; +pub mod print_view; pub mod region; pub mod render; pub mod shortcuts; @@ -104,6 +105,8 @@ mod tests_preset_import; #[cfg(test)] mod tests_preview_limit; #[cfg(test)] +mod tests_print; +#[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] mod tests_scroll; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..ff7f1519d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -162,6 +162,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"), ("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"), ("dialog.export", "Export…", None, "File"), + ("dialog.print", "Print…", Some("Cmd+P"), "File"), + ("app.print", "Print to PDF", None, ""), ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), ("app.contactSheet", "Export Contact Sheet PDF", None, ""), ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), @@ -1169,6 +1171,28 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.print = Default::default(); + app.ui.dialog = Some(Dialog::Print); + Ok(Value::Null) + } + "app.print" => { + if app.export.is_some() { + return Some(Err("an export is already running".into())); + } + let mut params = p.clone(); + if p.get("path").is_none() { + let req = PickRequest::save("Print to PDF", "PDF", &["pdf"], "Print.pdf"); + let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), + }; + let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; + params["path"] = json!(path); + } + crate::export_task::start_print(app, ¶ms) + } "app.contactSheet" => { if app.export.is_some() { return Some(Err("an export is already running".into())); @@ -1798,7 +1822,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option bool { match id { - "dialog.contactSheet" | "app.contactSheet" => !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none(), + "dialog.contactSheet" | "app.contactSheet" | "dialog.print" | "app.print" => { + !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none() + } s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => { app.session.active().is_some() diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..b574acbe3 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -176,6 +176,7 @@ pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md"); fn window_id(dialog: &Dialog) -> egui::Id { egui::Id::new(match dialog { Dialog::Import { .. } => "lightcraft-import-dialog", + Dialog::Print => "lightcraft-print-dialog", Dialog::Settings { .. } => "lightcraft-settings-dialog", _ => "lightcraft-dialog", }) @@ -251,6 +252,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::PasteSettings { .. } => "Paste Selected Settings", Dialog::Export { .. } => "Export", Dialog::ContactSheet { .. } => "Contact Sheet PDF", + Dialog::Print => "Print", Dialog::Merge { opts } => opts.title(), Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", @@ -270,6 +272,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls // rather than growing the window when the options below the grid get taller (e.g. Copy). let import = matches!(dlg, Dialog::Import { .. }); + // The Print dialog sizes itself to the window and scrolls its settings column on its own. + let print = matches!(dlg, Dialog::Print); let window_id = window_id(&dlg); // Every other dialog fits the window (#781): its options scroll when they don't, and the title // bar and the button row stay outside the scroll area. The room they take is measured on the @@ -289,13 +293,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { .. } => crate::import::DIALOG_SIZE[0], Dialog::SmartRules { .. } => 680.0, Dialog::AllMetadata { .. } => 620.0, + Dialog::Print => 1100.0, Dialog::FaceModel { .. } => 460.0, Dialog::LightroomCatalog { .. } => 440.0, _ => 380.0, }) .show(ctx, |ui| { ui.spacing_mut().item_spacing.y = 8.0; - body_h = dialog_body(ui, (!import).then_some(title.as_str()), body_max, |ui| match &mut dlg { + body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| match &mut dlg { Dialog::AutoStack { gap } => { ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); ui.add( @@ -1167,6 +1172,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), + Dialog::Print => crate::print_view::body(app, ui, &t), }); ui.add_space(4.0); ui.horizontal(|ui| { @@ -1203,6 +1209,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { add_label.as_str() } Dialog::ContactSheet { .. } => "Choose PDF…", + Dialog::Print => "Print to PDF…", Dialog::Merge { .. } => "Merge", Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", Dialog::DenoiseModel { .. } => "Install", @@ -1276,6 +1283,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { .. } | Dialog::Export { .. } | Dialog::ContactSheet { .. } + | Dialog::Print | Dialog::NewAlbum { .. } | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } @@ -1665,6 +1673,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("app.contactSheet", json!(options)), + Dialog::Print => app.run("app.print", json!({})), Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), Dialog::PasteSettings { groups } => app.run("develop.paste", json!({"groups": groups})), Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => { diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs new file mode 100644 index 000000000..7c1ab85fb --- /dev/null +++ b/crates/ui-egui/src/print_view.rs @@ -0,0 +1,823 @@ +//! The Print dialog (File ▸ Print…): settings on the left, the pages drawn to scale on the right. +//! +//! Every change goes through a `print.*` command ([`lightcraft_engine::cmd`]); this file only draws +//! the session's print ([`lightcraft_engine::Session::print`]) and turns pointer input into +//! commands: dragging a cell is `print.cell.move`, its corner handle `print.cell.resize`, with +//! the guides' snapping done by the command. Widgets register under `print:*`, `button:print*` +//! and `drag:print*` ids for the control channel and the headless tests. +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; +use lightcraft_catalog::PhotoId; +use lightcraft_engine::export::ExportOptions; +use lightcraft_engine::print::compose::{self, place}; +use lightcraft_engine::print::layout::{self, Snap, Style, Unit}; +use lightcraft_engine::print::template::PrintSettings; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::{register, text_button}; + +/// Width of the settings column. +const SETTINGS_W: f32 = 290.0; +/// Handle for resizing the selected cell, screen points. +const HANDLE: f32 = 12.0; +/// Snap distance while dragging, screen points. +const SNAP_PX: f32 = 8.0; +/// Cell sizes offered as buttons, inches (width, height). +const CELL_SIZES: &[(f64, f64)] = &[(2.0, 2.0), (2.5, 3.5), (3.5, 5.0), (4.0, 5.0), (4.0, 6.0), (5.0, 7.0), (8.0, 10.0), (3.0, 3.0)]; + +/// A drag of a cell in progress: the cell's rectangle when it began and how far the pointer went. +#[derive(Clone, Debug)] +pub struct Drag { + cell: u32, + origin: layout::Rect, + resize: bool, + /// Where the pointer was pressed; the distance dragged is measured from it, so the first few + /// points egui spends deciding between a click and a drag are not lost. + press: Pos2, + total: Vec2, +} + +/// What the Print dialog remembers between frames (not saved). +#[derive(Clone, Debug, Default)] +pub struct PrintUi { + /// The page being shown, zero-based. + pub page: usize, + /// The selected cell. + pub selected: Option, + drag: Option, + /// The name typed for Save Template. + pub template_name: String, + /// Size of the next cell added, inches. + pub new_cell: Option<(f64, f64)>, +} + +fn run(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Option { + match app.run(id, params) { + Ok(v) => Some(v), + Err(e) => { + app.toast(ctx, e); + None + } + } +} + +fn patch(app: &mut LightcraftApp, ctx: &egui::Context, params: Value) { + run(app, ctx, "print.set", params); +} + +fn heading(ui: &mut Ui, t: &Tokens, text: &str) { + ui.add_space(8.0); + ui.label(egui::RichText::new(crate::i18n::tr(text)).font(t.semibold(12.0)).color(t.text)); + ui.add_space(2.0); +} + +fn row(ui: &mut Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut Ui) -> R) -> R { + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(vec2(86.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); + }); + add(ui) + }) + .inner +} + +fn decimals(unit: Unit) -> usize { + match unit { + Unit::Inches | Unit::Centimeters => 2, + Unit::Millimeters => 1, + Unit::Points => 0, + } +} + +fn fmt_len(inches: f64, unit: Unit) -> String { + format!("{:.*} {}", decimals(unit), unit.from_inches(inches), unit.label()) +} + +/// A length field in the ruler's unit; the new value in inches when it changed. +fn length(ui: &mut Ui, id: &str, inches: f64, unit: Unit, min_in: f64, max_in: f64) -> Option { + let mut v = unit.from_inches(inches); + let r = ui.add( + egui::DragValue::new(&mut v) + .range(unit.from_inches(min_in)..=unit.from_inches(max_in)) + .speed(unit.per_inch() * 0.01) + .max_decimals(decimals(unit)) + .suffix(format!(" {}", unit.label())), + ); + register(ui.ctx(), format!("drag:{id}"), r.rect); + r.changed().then(|| unit.to_inches(v)) +} + +fn check(ui: &mut Ui, id: &str, label: &str, value: bool) -> Option { + let mut v = value; + let r = ui.checkbox(&mut v, crate::i18n::tr(label)); + register(ui.ctx(), format!("check:{id}"), r.rect); + r.changed().then_some(v) +} + +fn choice(ui: &mut Ui, id: &str, options: &[(V, &str)], current: V) -> Option { + let mut out = None; + ui.spacing_mut().item_spacing.x = 3.0; + for (i, (v, l)) in options.iter().enumerate() { + if text_button(ui, &format!("{id}-{i}"), l, current == *v).clicked() && current != *v { + out = Some(*v); + } + } + out +} + +fn colour(ui: &mut Ui, id: &str, rgb: [u8; 3]) -> Option<[u8; 3]> { + let mut c = rgb; + let r = ui.color_edit_button_srgb(&mut c); + register(ui.ctx(), format!("color:{id}"), r.rect); + (c != rgb).then_some(c) +} + +fn paper_label(name: &str) -> String { + match name { + "a3" | "a4" | "a5" => name.to_uppercase(), + "letter" => "Letter".into(), + "legal" => "Legal".into(), + "tabloid" => "Tabloid".into(), + "custom" => "Custom".into(), + other => other.replace('x', " × ") + " in", + } +} + +/// The dialog's body: the settings column and the page preview side by side. +pub fn body(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens) { + let ctx = ui.ctx().clone(); + let screen = ctx.content_rect(); + let width = (screen.width() - 80.0).clamp(640.0, 1180.0); + let height = (screen.height() - 190.0).clamp(280.0, 780.0); + let mut pu = std::mem::take(&mut app.ui.print); + let st = app.session.print.clone(); + let ids = app.session.targets(&json!({})); + // the page shown stays inside the print + let pages = st.layout.page_count(ids.len()); + pu.page = pu.page.min(pages.saturating_sub(1)); + if pu.selected.is_some_and(|c| st.layout.cell(c).is_none_or(|c| c.page as usize != pu.page)) && pu.drag.is_none() { + pu.selected = pu.selected.filter(|c| st.layout.cell(*c).is_some()); + } + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout(vec2(SETTINGS_W, height), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(vec2(SETTINGS_W, height)); + egui::ScrollArea::vertical().id_salt("print-settings").max_height(height).auto_shrink([false, false]).show(ui, |ui| { + ui.set_width(SETTINGS_W - 14.0); + settings(app, ui, t, &mut pu, &st, ids.len()); + }); + }); + // (a `separator` would take the dialog's whole free height) + let (line, _) = ui.allocate_exact_size(vec2(1.0, height), Sense::hover()); + ui.painter().line_segment([line.center_top(), line.center_bottom()], Stroke::new(1.0, t.divider)); + let w = (width - SETTINGS_W - 24.0).max(300.0); + ui.allocate_ui_with_layout(vec2(w, height), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(vec2(w, height)); + preview(app, ui, t, &mut pu, &st, &ids, pages); + }); + }); + app.ui.print = pu; +} + +fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, photos: usize) { + let ctx = ui.ctx().clone(); + let l = &st.layout; + let unit = st.guides.units; + ui.spacing_mut().item_spacing.y = 5.0; + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if photos == 1 { "" } else { "s" }, n = photos)).color(t.text_dim)); + + heading(ui, t, "Templates"); + ui.horizontal(|ui| { + crate::text_field::TextField::singleline("field:printTemplateName", &mut pu.template_name) + .hint(crate::i18n::tr("Template name")) + .width(140.0) + .show(ui); + if text_button(ui, "printSaveTemplate", "Save", false).clicked() && !pu.template_name.trim().is_empty() { + run(app, &ctx, "print.template.save", json!({"name": pu.template_name})); + } + }); + let names: Vec = app.session.print_templates.iter().map(|t| t.name.clone()).collect(); + for (i, name) in names.iter().enumerate() { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).color(t.text_label)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if text_button(ui, &format!("printTemplateDelete-{i}"), "Delete", false).clicked() { + run(app, &ctx, "print.template.delete", json!({"name": name})); + } + if text_button(ui, &format!("printTemplateApply-{i}"), "Apply", false).clicked() { + run(app, &ctx, "print.template.apply", json!({"name": name})); + pu.page = 0; + pu.selected = None; + } + }); + }); + } + + heading(ui, t, "Layout Style"); + let styles = + [(Style::SingleImage, "Single"), (Style::ContactSheet, "Contact"), (Style::PicturePackage, "Package"), (Style::CustomPackage, "Custom")]; + ui.horizontal_wrapped(|ui| { + if let Some(s) = choice(ui, "printStyle", &styles, l.style) { + patch(app, &ctx, json!({"layout": {"style": s}})); + pu.page = 0; + } + }); + + heading(ui, t, "Image Settings"); + let im = &st.image; + if let Some(v) = check(ui, "printZoomFill", "Zoom to Fill Frame", im.zoom_to_fill) { + patch(app, &ctx, json!({"image": {"zoomToFill": v}})); + } + if let Some(v) = check(ui, "printRotateFit", "Rotate to Fit", im.rotate_to_fit) { + patch(app, &ctx, json!({"image": {"rotateToFit": v}})); + } + if l.style.is_package() { + ui.horizontal(|ui| { + if let Some(v) = check(ui, "printBorder", "Photo Border", im.border_enabled) { + patch(app, &ctx, json!({"image": {"borderEnabled": v}})); + } + if let Some(w) = length(ui, "printBorderWidth", im.border_width, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"image": {"borderWidth": w}})); + } + if let Some(c) = colour(ui, "printBorderColor", im.border_color) { + patch(app, &ctx, json!({"image": {"borderColor": c}})); + } + }); + } + ui.horizontal(|ui| { + if let Some(v) = check(ui, "printStroke", "Inner Stroke", im.stroke_enabled) { + patch(app, &ctx, json!({"image": {"strokeEnabled": v}})); + } + let mut pt = im.stroke_width_pt; + let r = ui.add(egui::DragValue::new(&mut pt).range(0.0..=36.0).speed(0.05).max_decimals(2).suffix(" pt")); + register(ui.ctx(), "drag:printStrokeWidth", r.rect); + if r.changed() { + patch(app, &ctx, json!({"image": {"strokeWidthPt": pt}})); + } + if let Some(c) = colour(ui, "printStrokeColor", im.stroke_color) { + patch(app, &ctx, json!({"image": {"strokeColor": c}})); + } + }); + + heading(ui, t, "Layout"); + match l.style { + Style::SingleImage => { + ui.label(egui::RichText::new(crate::i18n::tr("One photo fills each page inside the margins.")).color(t.text_dim)); + } + Style::ContactSheet => { + let g = &l.grid; + row(ui, t, "Grid", |ui| { + let (mut c, mut r) = (g.columns, g.rows); + let a = ui.add(egui::DragValue::new(&mut c).range(1..=layout::MAX_GRID).suffix(" cols")); + register(ui.ctx(), "drag:printColumns", a.rect); + let b = ui.add(egui::DragValue::new(&mut r).range(1..=layout::MAX_GRID).suffix(" rows")); + register(ui.ctx(), "drag:printRows", b.rect); + if a.changed() || b.changed() { + patch(app, &ctx, json!({"layout": {"grid": {"columns": c, "rows": r}}})); + } + }); + row(ui, t, "Spacing", |ui| { + if let Some(v) = length(ui, "printSpacingX", g.spacing_x, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"grid": {"spacingX": v}}})); + } + if let Some(v) = length(ui, "printSpacingY", g.spacing_y, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"grid": {"spacingY": v}}})); + } + }); + if let Some(v) = check(ui, "printKeepSquare", "Keep Square", g.keep_square) { + patch(app, &ctx, json!({"layout": {"grid": {"keepSquare": v}}})); + } + } + Style::PicturePackage | Style::CustomPackage => { + ui.label(egui::RichText::new(crate::i18n::tr("Add a cell")).color(t.text_label)); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 3.0); + for (i, (w, h)) in CELL_SIZES.iter().enumerate() { + if text_button(ui, &format!("printAddCell-{i}"), &format!("{} × {}", trim(*w), trim(*h)), false).clicked() { + add_cell(app, &ctx, pu, *w, *h); + } + } + }); + let (mut w, mut h) = pu.new_cell.unwrap_or((3.0, 2.0)); + row(ui, t, "Custom size", |ui| { + if let Some(v) = length(ui, "printNewCellW", w, unit, layout::MIN_CELL_IN, 30.0) { + w = v; + } + if let Some(v) = length(ui, "printNewCellH", h, unit, layout::MIN_CELL_IN, 30.0) { + h = v; + } + }); + pu.new_cell = Some((w, h)); + ui.horizontal(|ui| { + if text_button(ui, "printAddCustom", "Add Cell", false).clicked() { + add_cell(app, &ctx, pu, w, h); + } + if l.style == Style::PicturePackage && text_button(ui, "printAutoLayout", "Auto Layout", false).clicked() { + run(app, &ctx, "print.layout.autoLayout", json!({})); + pu.page = 0; + } + }); + ui.horizontal(|ui| { + if text_button(ui, "printNewPage", "New Page", false).clicked() + && let Some(r) = run(app, &ctx, "print.layout.newPage", json!({})) + { + pu.page = r["page"].as_u64().unwrap_or(0) as usize; + } + if text_button(ui, "printClear", "Clear Layout", false).clicked() { + run(app, &ctx, "print.layout.clear", json!({})); + pu.page = 0; + pu.selected = None; + } + }); + row(ui, t, "Gap", |ui| { + if let Some(v) = length(ui, "printGap", l.spacing, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"spacing": v}})); + } + }); + } + } + + heading(ui, t, "Rulers, Grid & Guides"); + let g = &st.guides; + if let Some(v) = check(ui, "printRulers", "Show Rulers", g.show_rulers) { + patch(app, &ctx, json!({"guides": {"showRulers": v}})); + } + row(ui, t, "Units", |ui| { + let units = [(Unit::Inches, "in"), (Unit::Centimeters, "cm"), (Unit::Millimeters, "mm"), (Unit::Points, "pt")]; + if let Some(u) = choice(ui, "printUnits", &units, g.units) { + patch(app, &ctx, json!({"guides": {"units": u}})); + } + }); + row(ui, t, "Snap", |ui| { + if let Some(s) = choice(ui, "printSnap", &[(Snap::Off, "Off"), (Snap::Cells, "Cells"), (Snap::Grid, "Grid")], g.snap) { + patch(app, &ctx, json!({"guides": {"snap": s}})); + } + }); + row(ui, t, "Grid size", |ui| { + if let Some(v) = length(ui, "printGridSize", g.grid_size, unit, 0.05, 10.0) { + patch(app, &ctx, json!({"guides": {"gridSize": v}})); + } + }); + for (id, label, on, key) in [ + ("printShowMargins", "Page Margins", g.show_margins, "showMargins"), + ("printShowBleed", "Page Bleed", g.show_bleed, "showBleed"), + ("printShowGrid", "Page Grid", g.show_page_grid, "showPageGrid"), + ("printShowCells", "Image Cells", g.show_cells, "showCells"), + ("printShowDims", "Dimensions", g.show_dimensions, "showDimensions"), + ] { + if let Some(v) = check(ui, id, label, on) { + patch(app, &ctx, json!({"guides": {(key): v}})); + } + } + + heading(ui, t, "Page Setup"); + row(ui, t, "Paper", |ui| { + let current = if l.page.paper.eq_ignore_ascii_case("custom") { "custom".to_string() } else { l.page.paper.to_ascii_lowercase() }; + let c = egui::ComboBox::from_id_salt("printPaper").selected_text(paper_label(¤t)).show_ui(ui, |ui| { + for name in layout::paper_names().chain(std::iter::once("custom")) { + if ui.selectable_label(current == name, paper_label(name)).clicked() { + patch(app, &ctx, json!({"layout": {"page": {"paper": name}}})); + } + } + }); + register(ui.ctx(), "combo:printPaper", c.response.rect); + }); + if l.page.paper.eq_ignore_ascii_case("custom") { + row(ui, t, "Size", |ui| { + if let Some(v) = length(ui, "printCustomW", l.page.custom_width, unit, layout::MIN_PAGE_IN, layout::MAX_PAGE_IN) { + patch(app, &ctx, json!({"layout": {"page": {"customWidth": v}}})); + } + if let Some(v) = length(ui, "printCustomH", l.page.custom_height, unit, layout::MIN_PAGE_IN, layout::MAX_PAGE_IN) { + patch(app, &ctx, json!({"layout": {"page": {"customHeight": v}}})); + } + }); + } + row(ui, t, "Orientation", |ui| { + let o = [(layout::Orientation::Portrait, "Portrait"), (layout::Orientation::Landscape, "Landscape")]; + if let Some(v) = choice(ui, "printOrientation", &o, l.page.orientation) { + patch(app, &ctx, json!({"layout": {"page": {"orientation": v}}})); + } + }); + let m = &l.page.margins; + for (label, id, key, v) in [ + ("Left", "printMarginLeft", "left", m.left), + ("Right", "printMarginRight", "right", m.right), + ("Top", "printMarginTop", "top", m.top), + ("Bottom", "printMarginBottom", "bottom", m.bottom), + ] { + row(ui, t, label, |ui| { + if let Some(v) = length(ui, id, v, unit, 0.0, layout::MAX_MARGIN_IN) { + patch(app, &ctx, json!({"layout": {"page": {"margins": {(key): v}}}})); + } + }); + } + + heading(ui, t, "Page"); + row(ui, t, "Background", |ui| { + if let Some(c) = colour(ui, "printBackground", st.page.background) { + patch(app, &ctx, json!({"page": {"background": c}})); + } + }); + + heading(ui, t, "Print Job"); + row(ui, t, "Resolution", |ui| { + let mut ppi = st.job.ppi; + let r = ui.add(egui::DragValue::new(&mut ppi).range(72.0..=720.0).speed(1.0).max_decimals(0).suffix(" ppi")); + register(ui.ctx(), "drag:printPpi", r.rect); + if r.changed() { + patch(app, &ctx, json!({"job": {"ppi": ppi}})); + } + }); + ui.label(egui::RichText::new(crate::i18n::tr("PDF · sRGB · current edits")).color(t.text_dim)); +} + +fn trim(v: f64) -> String { + let s = format!("{v:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn add_cell(app: &mut LightcraftApp, ctx: &egui::Context, pu: &mut PrintUi, w: f64, h: f64) { + if let Some(r) = run(app, ctx, "print.cell.add", json!({"width": w, "height": h})) { + pu.selected = r["id"].as_u64().and_then(|v| u32::try_from(v).ok()); + pu.page = r["cell"]["page"].as_u64().unwrap_or(0) as usize; + } +} + +// ---- preview ------------------------------------------------------------------------------- + +/// Screen geometry of the page on show. +struct Sheet { + page: Rect, + /// Screen points per inch. + scale: f32, +} + +impl Sheet { + fn rect(&self, r: &layout::Rect) -> Rect { + Rect::from_min_size(self.page.min + vec2(r.x as f32, r.y as f32) * self.scale, vec2(r.w as f32, r.h as f32) * self.scale) + } +} + +fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, ids: &[PhotoId], pages: usize) { + let ctx = ui.ctx().clone(); + // room for the page buttons and, for a package, the selected cell's row (which may wrap) + let footer = if st.layout.style.is_package() { 104.0 } else { 44.0 }; + let size = vec2(ui.available_width(), (ui.available_height() - footer).max(120.0)); + let (area, _) = ui.allocate_exact_size(size, Sense::hover()); + let painter = ui.painter_at(area); + painter.rect_filled(area, 4.0, t.canvas); + let Ok((pw, ph)) = st.layout.page_size() else { + painter.text(area.center(), Align2::CENTER_CENTER, crate::i18n::tr("This page setup is not valid"), t.font(13.0), t.caution); + return; + }; + let g = &st.guides; + let ruler = if g.show_rulers { 22.0 } else { 0.0 }; + let inner = Rect::from_min_max(area.min + vec2(ruler, ruler), area.max).shrink(14.0); + let scale = ((inner.width() as f64 / pw).min(inner.height() as f64 / ph) as f32).max(1.0); + let sheet = Sheet { page: Rect::from_center_size(inner.center(), vec2(pw as f32, ph as f32) * scale), scale }; + register(&ctx, "print:page", sheet.page); + + painter.rect_filled(sheet.page.translate(vec2(2.0, 3.0)), 0.0, Color32::from_black_alpha(70)); + let bg = st.page.background; + painter.rect_filled(sheet.page, 0.0, Color32::from_rgb(bg[0], bg[1], bg[2])); + let guide = Stroke::new(1.0, Color32::from_rgb(90, 160, 240)); + if g.show_rulers { + rulers(&painter, t, &sheet, area, pw, ph, g.units); + } + let area_in = st.layout.printable().ok(); + if g.show_bleed { + let b = 0.125; + dashed(&painter, sheet.page.expand(b as f32 * scale), Stroke::new(1.0, t.caution)); + } + if g.show_page_grid + && let Some(a) = area_in + { + let faint = Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 70)); + let step = g.grid_size.max(0.05); + let mut x = 0.0; + while a.x + x <= a.right() + 1e-9 && x < 200.0 { + let sx = sheet.page.left() + (a.x + x) as f32 * scale; + painter.line_segment([pos2(sx, sheet.page.top()), pos2(sx, sheet.page.bottom())], faint); + x += step; + } + let mut y = 0.0; + while a.y + y <= a.bottom() + 1e-9 && y < 200.0 { + let sy = sheet.page.top() + (a.y + y) as f32 * scale; + painter.line_segment([pos2(sheet.page.left(), sy), pos2(sheet.page.right(), sy)], faint); + y += step; + } + } + if g.show_margins + && let Some(a) = area_in + { + dashed(&painter, sheet.rect(&layout::Rect::new(a.x, a.y, a.w, a.h)), guide); + } + + let package = st.layout.style.is_package(); + if package { + package_cells(app, ui, &painter, t, pu, st, ids, &sheet); + } else { + let plan = compose::plan_cells(&st.layout, ids).ok(); + let cells: Vec<(layout::Rect, Option)> = match plan.as_ref().and_then(|p| p.get(pu.page)) { + Some(page) => page.iter().map(|(r, id)| (*r, Some(*id))).collect(), + None => st.layout.grid_rects().unwrap_or_default().into_iter().map(|r| (r, None)).collect(), + }; + for (i, (r, photo)) in cells.iter().enumerate() { + let rect = sheet.rect(r); + register(&ctx, format!("print:gridcell:{i}"), rect); + paint_cell(app, &painter, t, st, rect, *photo, &sheet, r); + } + } + + // footer: page navigation and the selected cell + ui.horizontal(|ui| { + if text_button(ui, "printPagePrev", "◀", false).clicked() && pu.page > 0 { + pu.page -= 1; + pu.selected = None; + } + let label = ui.label(egui::RichText::new(format!("{} {} / {}", crate::i18n::tr("Page"), pu.page + 1, pages)).color(t.text)); + register(ui.ctx(), "print:pageLabel", label.rect); + if text_button(ui, "printPageNext", "▶", false).clicked() && pu.page + 1 < pages { + pu.page += 1; + pu.selected = None; + } + ui.label(egui::RichText::new(fmt_len(pw, g.units) + " × " + &fmt_len(ph, g.units)).color(t.text_dim)); + if package { + ui.label(egui::RichText::new(format!("{} {}", st.layout.cells.len(), crate::i18n::tr("cells"))).color(t.text_dim)); + if st.layout.has_overlaps() { + ui.label(egui::RichText::new(crate::i18n::tr("cells overlap")).color(t.caution)); + } + } + }); + if package { + inspector(app, ui, t, pu, st); + } +} + +fn press_origin(ui: &Ui, resp: &egui::Response) -> Pos2 { + ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()).unwrap_or_default() +} + +/// Draws and handles every cell of a package page, back to front. +#[allow(clippy::too_many_arguments)] +fn package_cells( + app: &mut LightcraftApp, + ui: &mut Ui, + painter: &egui::Painter, + t: &Tokens, + pu: &mut PrintUi, + st: &PrintSettings, + ids: &[PhotoId], + sheet: &Sheet, +) { + let ctx = ui.ctx().clone(); + let first = ids.first().copied(); + let cells: Vec = st.layout.cells_on_page(pu.page as u32).cloned().collect(); + for c in &cells { + let photo = if st.layout.style == Style::CustomPackage { c.photo.map(PhotoId).or(first) } else { first }; + let rect = sheet.rect(&c.rect); + paint_cell(app, painter, t, st, rect, photo, sheet, &c.rect); + let resp = ui.interact(rect, egui::Id::new(("print-cell", c.id)), Sense::click_and_drag()); + register(&ctx, format!("print:cell:{}", c.id), rect); + if resp.clicked() || resp.drag_started() { + pu.selected = Some(c.id); + } + if resp.drag_started() { + pu.drag = Some(Drag { cell: c.id, origin: c.rect, resize: false, press: press_origin(ui, &resp), total: Vec2::ZERO }); + } + if resp.dragged() + && let Some(d) = pu.drag.as_mut().filter(|d| d.cell == c.id && !d.resize) + { + d.total = resp.interact_pointer_pos().map_or(d.total, |p| p - d.press); + let (x, y) = (d.origin.x + f64::from(d.total.x / sheet.scale), d.origin.y + f64::from(d.total.y / sheet.scale)); + let threshold = f64::from(SNAP_PX / sheet.scale); + run(app, &ctx, "print.cell.move", json!({"id": c.id, "x": x, "y": y, "snap": true, "threshold": threshold})); + } + if resp.drag_stopped() { + pu.drag = None; + } + let id = c.id; + resp.context_menu(|ui| { + for (how, label) in + [("toFront", "Bring to Front"), ("forward", "Bring Forward"), ("backward", "Send Backward"), ("toBack", "Send to Back")] + { + if ui.button(crate::i18n::tr(label)).clicked() { + run(app, &ctx, "print.cell.stack", json!({"id": id, "order": how})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::i18n::tr("Delete Cell")).clicked() { + run(app, &ctx, "print.cell.delete", json!({"id": id})); + ui.close(); + } + }); + } + // the selected cell's outline and resize handle go over everything + let Some(sel) = pu.selected.and_then(|s| cells.iter().find(|c| c.id == s)) else { return }; + let rect = sheet.rect(&sel.rect); + painter.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + let handle = Rect::from_center_size(rect.right_bottom(), Vec2::splat(HANDLE)); + painter.rect_filled(handle, 2.0, t.accent); + let resp = ui.interact(handle, egui::Id::new(("print-resize", sel.id)), Sense::drag()); + register(&ctx, format!("print:resize:{}", sel.id), handle); + if resp.drag_started() { + pu.drag = Some(Drag { cell: sel.id, origin: sel.rect, resize: true, press: press_origin(ui, &resp), total: Vec2::ZERO }); + } + if resp.dragged() + && let Some(d) = pu.drag.as_mut().filter(|d| d.cell == sel.id && d.resize) + { + d.total = resp.interact_pointer_pos().map_or(d.total, |p| p - d.press); + let (w, h) = (d.origin.w + f64::from(d.total.x / sheet.scale), d.origin.h + f64::from(d.total.y / sheet.scale)); + let threshold = f64::from(SNAP_PX / sheet.scale); + run( + app, + &ctx, + "print.cell.resize", + json!({"id": sel.id, "width": w.max(layout::MIN_CELL_IN), "height": h.max(layout::MIN_CELL_IN), "snap": true, "threshold": threshold}), + ); + } + if resp.drag_stopped() { + pu.drag = None; + } +} + +/// The selected cell's numbers, its place in the stack and its photo. +fn inspector(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings) { + let ctx = ui.ctx().clone(); + let Some(cell) = pu.selected.and_then(|id| st.layout.cell(id)) else { + ui.label(egui::RichText::new(crate::i18n::tr("Click a cell to move, resize or arrange it")).color(t.text_dim)); + return; + }; + let (id, r, unit) = (cell.id, cell.rect, st.guides.units); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if let Some(v) = length(ui, "printCellX", r.x, unit, 0.0, 60.0) { + run(app, &ctx, "print.cell.move", json!({"id": id, "x": v, "y": r.y})); + } + if let Some(v) = length(ui, "printCellY", r.y, unit, 0.0, 60.0) { + run(app, &ctx, "print.cell.move", json!({"id": id, "x": r.x, "y": v})); + } + if let Some(v) = length(ui, "printCellW", r.w, unit, layout::MIN_CELL_IN, 60.0) { + run(app, &ctx, "print.cell.resize", json!({"id": id, "width": v, "height": r.h})); + } + if let Some(v) = length(ui, "printCellH", r.h, unit, layout::MIN_CELL_IN, 60.0) { + run(app, &ctx, "print.cell.resize", json!({"id": id, "width": r.w, "height": v})); + } + for (i, (how, label)) in [("toBack", "Back"), ("backward", "Backward"), ("forward", "Forward"), ("toFront", "Front")].into_iter().enumerate() + { + if text_button(ui, &format!("printStack-{i}"), label, false).clicked() { + run(app, &ctx, "print.cell.stack", json!({"id": id, "order": how})); + } + } + if st.layout.style == Style::CustomPackage { + if text_button(ui, "printCellPhoto", "Use Active Photo", false).clicked() { + let active = app.session.active().map(|p| p.0); + run(app, &ctx, "print.cell.photo", json!({"id": id, "photo": active})); + } + if cell.photo.is_some() && text_button(ui, "printCellPhotoClear", "Clear Photo", false).clicked() { + run(app, &ctx, "print.cell.photo", json!({"id": id, "photo": null})); + } + } + if text_button(ui, "printCellDelete", "Delete", false).clicked() { + run(app, &ctx, "print.cell.delete", json!({"id": id})); + pu.selected = None; + } + }); +} + +/// One cell on screen: the photo as it will print (fit or fill, turned, border, stroke). +#[allow(clippy::too_many_arguments)] +fn paint_cell( + app: &mut LightcraftApp, + painter: &egui::Painter, + t: &Tokens, + st: &PrintSettings, + cell: Rect, + photo: Option, + sheet: &Sheet, + inches: &layout::Rect, +) { + let im = &st.image; + if st.guides.show_cells { + painter.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 150, 190, 150)), StrokeKind::Inside); + } + let border = if st.layout.style.is_package() && im.border_enabled { + (im.border_width as f32 * sheet.scale).min(cell.width().min(cell.height()).max(2.0) / 2.0 - 1.0).max(0.0) + } else { + 0.0 + }; + let avail = cell.shrink(border); + if avail.width() < 1.0 || avail.height() < 1.0 { + return; + } + // the photo's shape: its cropped size, or 3:2 for a placeholder + let (nw, nh) = photo.and_then(|id| app.session.catalog.photo(id)).map_or((3.0, 2.0), |p| { + let (w, h) = lightcraft_engine::export::output_size(p, &ExportOptions::default()); + (w as f64, h as f64) + }); + let scale = 8.0; // sub-point resolution for the rounding in `place` + let aw = (f64::from(avail.width()) * scale).round() as usize; + let ah = (f64::from(avail.height()) * scale).round() as usize; + let Some(p) = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit) else { return }; + let shown = Rect::from_center_size(avail.center(), vec2(p.w as f32, p.h as f32) / scale as f32); + let visible = shown.intersect(avail); + if border > 0.0 { + let frame = visible.expand(border); + let c = im.border_color; + painter.rect_filled(frame.intersect(cell), 0.0, Color32::from_rgb(c[0], c[1], c[2])); + } + let clip = painter.with_clip_rect(avail.intersect(painter.clip_rect())); + let tex = photo.and_then(|id| { + let px = (shown.width().max(shown.height()) * 2.0).clamp(64.0, 1024.0) as usize; + crate::panels::grid::request_thumb(app, id, px, 10); + app.renderer.thumb(id).map(|tex| tex.tex.id()) + }); + match tex { + Some(id) => { + let mut mesh = egui::Mesh::with_texture(id); + // an unturned quad maps the texture straight; a turned one (a quarter clockwise) takes + // the left edge to the top + let uv: [Pos2; 4] = if p.rotated { + [pos2(0.0, 1.0), pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0)] + } else { + [pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)] + }; + let corners = [shown.left_top(), shown.right_top(), shown.right_bottom(), shown.left_bottom()]; + for (pos, uv) in corners.into_iter().zip(uv) { + mesh.vertices.push(egui::epaint::Vertex { pos, uv, color: Color32::WHITE }); + } + mesh.indices.extend([0, 1, 2, 0, 2, 3]); + clip.add(egui::Shape::mesh(mesh)); + } + None => { + clip.rect_filled(shown, 0.0, if photo.is_some() { Color32::from_gray(52) } else { Color32::from_gray(200) }); + } + } + if im.stroke_enabled && im.stroke_width_pt > 0.0 { + let w = (im.stroke_width_pt as f32 / 72.0 * sheet.scale).max(1.0).min(visible.width().min(visible.height()) / 2.0); + let c = im.stroke_color; + painter.rect_stroke(visible, 0.0, Stroke::new(w, Color32::from_rgb(c[0], c[1], c[2])), StrokeKind::Inside); + } + if st.guides.show_dimensions { + let text = format!("{} × {}", fmt_len(inches.w, st.guides.units), fmt_len(inches.h, st.guides.units)); + let galley = painter.layout_no_wrap(text, t.font(10.0), Color32::WHITE); + let at = pos2(cell.center().x - galley.size().x / 2.0, cell.bottom() - galley.size().y - 3.0); + painter.rect_filled(Rect::from_min_size(at, galley.size()).expand(2.0), 2.0, Color32::from_black_alpha(160)); + painter.galley(at, galley, Color32::WHITE); + } +} + +fn dashed(painter: &egui::Painter, r: Rect, stroke: Stroke) { + let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; + painter.extend(egui::Shape::dashed_line(&pts, stroke, 5.0, 4.0)); +} + +/// Rulers along the top and left of the preview, in `unit`, zero at the page's corner. +fn rulers(painter: &egui::Painter, t: &Tokens, sheet: &Sheet, area: Rect, pw: f64, ph: f64, unit: Unit) { + let (major, minor) = match unit { + Unit::Inches => (1.0, 0.25), + Unit::Centimeters => (1.0, 0.5), + Unit::Millimeters => (10.0, 5.0), + Unit::Points => (72.0, 18.0), + }; + let tick = Stroke::new(1.0, t.text_dim); + let ink = t.text_dim; + let top = Rect::from_min_max(area.min, pos2(area.max.x, area.min.y + 20.0)); + let left = Rect::from_min_max(area.min, pos2(area.min.x + 20.0, area.max.y)); + painter.rect_filled(top, 0.0, t.chrome); + painter.rect_filled(left, 0.0, t.chrome); + let ticks = |len_in: f64| (0..).map(move |k| f64::from(k) * minor).take_while(move |v| *v <= unit.from_inches(len_in) + 1e-9).take(2000); + for v in ticks(pw) { + let x = sheet.page.left() + unit.to_inches(v) as f32 * sheet.scale; + let is_major = (v / major - (v / major).round()).abs() < 1e-9; + painter.line_segment([pos2(x, top.bottom()), pos2(x, top.bottom() - if is_major { 10.0 } else { 5.0 })], tick); + if is_major { + painter.text(pos2(x + 2.0, top.top() + 1.0), Align2::LEFT_TOP, format!("{}", v.round() as i64), t.font(9.0), ink); + } + } + for v in ticks(ph) { + let y = sheet.page.top() + unit.to_inches(v) as f32 * sheet.scale; + let is_major = (v / major - (v / major).round()).abs() < 1e-9; + painter.line_segment([pos2(left.right(), y), pos2(left.right() - if is_major { 10.0 } else { 5.0 }, y)], tick); + if is_major { + painter.text(pos2(left.left() + 1.0, y + 1.0), Align2::LEFT_TOP, format!("{}", v.round() as i64), t.font(9.0), ink); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lengths_read_in_the_ruler_unit() { + assert_eq!(fmt_len(1.0, Unit::Inches), "1.00 in"); + assert_eq!(fmt_len(1.0, Unit::Millimeters), "25.4 mm"); + assert_eq!(fmt_len(1.0, Unit::Points), "72 pt"); + assert_eq!(trim(4.0), "4"); + assert_eq!(trim(2.5), "2.5"); + assert_eq!(paper_label("a4"), "A4"); + assert_eq!(paper_label("4x6"), "4 × 6 in"); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76066d55..5e43cbe93 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -735,6 +735,9 @@ pub struct UiState { pub status: String, #[serde(skip)] pub dialog: Option, + /// The Print dialog's page, selection and drag. + #[serde(skip)] + pub print: crate::print_view::PrintUi, } /// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything @@ -763,6 +766,9 @@ pub struct NameEdit { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Dialog { + /// File ▸ Print…: the Print module. Its state is the session's (`print.*`); the page on show + /// and the selected cell are [`UiState::print`]. + Print, ContactSheet { options: lightcraft_engine::contact_sheet::Options, }, @@ -1133,6 +1139,7 @@ impl Default for UiState { last_find_missing: None, status: String::new(), dialog: None, + print: Default::default(), } } } diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs new file mode 100644 index 000000000..6dbea7a40 --- /dev/null +++ b/crates/ui-egui/src/tests_print.rs @@ -0,0 +1,300 @@ +//! The Print dialog, driven through the control channel: styles, cells dragged and resized with +//! real pointer input, Auto Layout, pages, templates and the PDF. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(30); + +fn demo(services: Services) -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + h.settle(T); + h +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); +} + +fn drag(h: &mut Headless, id: &str, dx: f32, dy: f32) { + let r = h.request("ui.dragWidget", json!({"id": id, "dx": dx, "dy": dy, "steps": 8}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); +} + +fn cmd(h: &mut Headless, id: &str, params: Value) -> Value { + let r = h.request("engine.execute", json!({"command": id, "params": params}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); + r["result"].clone() +} + +/// The first page's MediaBox width and height in points. +fn media_box(pdf: &[u8]) -> (f64, f64) { + let text = String::from_utf8_lossy(pdf); + let at = text.find("/MediaBox [").expect("a MediaBox") + "/MediaBox [".len(); + let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + (nums[2], nums[3]) +} + +fn open_print(h: &mut Headless) { + cmd(h, "dialog.print", json!({})); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Print))); + assert!(has(h, "print:page"), "the page is on screen"); +} + +/// Screen points per inch of the page shown. +fn scale(h: &Headless) -> f32 { + rect(h, "print:page").width() / h.app.session.print.layout.page_size().unwrap().0 as f32 +} + +fn cell_rect(h: &Headless, id: u32) -> lightcraft_engine::print::layout::Rect { + h.app.session.print.layout.cell(id).unwrap().rect +} + +#[test] +fn the_print_dialog_opens_from_the_menu_command_and_closes() { + let mut h = demo(Services::default()); + open_print(&mut h); + assert!(has(&h, "button:printStyle-0") && has(&h, "check:printZoomFill") && has(&h, "button:dialogOk")); + assert!(rect(&h, "print:pageLabel").width() > 0.0); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); +} + +#[test] +fn style_buttons_and_checkboxes_run_print_set() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-1"); + assert_eq!(h.app.session.print.layout.style, lightcraft_engine::print::layout::Style::ContactSheet); + assert!(has(&h, "drag:printColumns") && has(&h, "print:gridcell:0")); + click(&mut h, "check:printZoomFill"); + click(&mut h, "check:printStroke"); + assert!(h.app.session.print.image.zoom_to_fill && h.app.session.print.image.stroke_enabled); + click(&mut h, "button:printOrientation-1"); + assert_eq!(h.app.session.print.layout.page.orientation, lightcraft_engine::print::layout::Orientation::Landscape); + let p = rect(&h, "print:page"); + assert!(p.width() > p.height(), "the page is drawn landscape"); + click(&mut h, "button:printUnits-2"); + assert_eq!(h.app.session.print.guides.units, lightcraft_engine::print::layout::Unit::Millimeters); + click(&mut h, "check:printShowGrid"); + assert!(h.app.session.print.guides.show_page_grid); +} + +#[test] +fn cells_are_added_dragged_resized_and_arranged() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-3"); + // snapping off, so the pointer maths is exact + cmd(&mut h, "print.set", json!({"guides": {"snap": "off"}})); + click(&mut h, "button:printAddCell-4"); + click(&mut h, "button:printAddCell-1"); + let cells: Vec = h.app.session.print.layout.cells.iter().map(|c| c.id).collect(); + assert_eq!(cells.len(), 2); + assert_eq!(h.app.session.print.layout.cells[0].rect.w, 4.0); + assert!(has(&h, &format!("print:cell:{}", cells[0]))); + + // drag the first cell with the pointer: the move is in inches, through print.cell.move + let before = cell_rect(&h, cells[0]); + let s = scale(&h); + drag(&mut h, &format!("print:cell:{}", cells[0]), 0.0, 100.0); + let after = cell_rect(&h, cells[0]); + assert!((after.y - before.y - f64::from(100.0 / s)).abs() < 0.05, "moved {} in, expected {}", after.y - before.y, 100.0 / s); + assert!((after.x - before.x).abs() < 0.05); + assert_eq!(h.app.ui.print.selected, Some(cells[0])); + + // resize with its corner handle + let r0 = cell_rect(&h, cells[0]); + drag(&mut h, &format!("print:resize:{}", cells[0]), 30.0, 20.0); + let r1 = cell_rect(&h, cells[0]); + assert!((r1.w - r0.w - f64::from(30.0 / s)).abs() < 0.05 && (r1.h - r0.h - f64::from(20.0 / s)).abs() < 0.05, "{r0:?} → {r1:?}"); + assert_eq!((r1.x, r1.y), (r0.x, r0.y), "the top-left corner stays"); + + // arrange: Front moves it last + click(&mut h, "button:printStack-3"); + assert_eq!(h.app.session.print.layout.cells.last().unwrap().id, cells[0]); + click(&mut h, "button:printStack-0"); + assert_eq!(h.app.session.print.layout.cells.first().unwrap().id, cells[0]); + + // the inspector's delete + click(&mut h, "button:printCellDelete"); + assert_eq!(h.app.session.print.layout.cells.len(), 1); + assert_eq!(h.app.ui.print.selected, None); +} + +#[test] +fn dragging_a_cell_snaps_to_its_neighbour_when_snap_is_on() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-3"); + cmd(&mut h, "print.set", json!({"layout": {"spacing": 0.0}})); + click(&mut h, "button:printAddCell-0"); + click(&mut h, "button:printAddCell-0"); + let ids: Vec = h.app.session.print.layout.cells.iter().map(|c| c.id).collect(); + assert_eq!(h.app.session.print.guides.snap, lightcraft_engine::print::layout::Snap::Cells); + let a = cell_rect(&h, ids[0]); + // park the second cell away from the first, then drag it back to 0.06 in from the first's right edge + cmd(&mut h, "print.cell.move", json!({"id": ids[1], "x": a.right() + 0.8, "y": a.y})); + let s = scale(&h); + drag(&mut h, &format!("print:cell:{}", ids[1]), -(0.74 * s), 0.0); + let r = cell_rect(&h, ids[1]); + assert!((r.x - a.right()).abs() < 1e-6, "snapped to the neighbour's edge: {} vs {}", r.x, a.right()); + // with snapping off the same drag lands where the pointer put it + cmd(&mut h, "print.set", json!({"guides": {"snap": "off"}})); + cmd(&mut h, "print.cell.move", json!({"id": ids[1], "x": a.right() + 0.8, "y": a.y})); + drag(&mut h, &format!("print:cell:{}", ids[1]), -(0.74 * s), 0.0); + let r = cell_rect(&h, ids[1]); + assert!((r.x - (a.right() + 0.06)).abs() < 0.05 && r.x > a.right() + 0.01, "unsnapped: {}", r.x - a.right()); +} + +#[test] +fn auto_layout_new_page_and_clear_buttons() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-2"); + for _ in 0..3 { + click(&mut h, "button:printAddCell-4"); + } + for _ in 0..3 { + click(&mut h, "button:printAddCell-5"); + } + let before_pages = h.app.session.print.layout.pages; + // scramble everything onto one spot, then pack + cmd(&mut h, "print.layout.autoLayout", json!({})); + click(&mut h, "button:printAutoLayout"); + assert!(!h.app.session.print.layout.has_overlaps()); + assert!(h.app.session.print.layout.pages >= before_pages.min(2)); + let pages = h.app.session.print.layout.pages; + click(&mut h, "button:printNewPage"); + assert_eq!(h.app.session.print.layout.pages, pages + 1); + assert_eq!(h.app.ui.print.page as u32, pages, "the new page is shown"); + // the next-page button stays on the last page + click(&mut h, "button:printPageNext"); + assert_eq!(h.app.ui.print.page as u32, pages); + click(&mut h, "button:printPagePrev"); + assert_eq!(h.app.ui.print.page as u32, pages - 1); + click(&mut h, "button:printClear"); + assert_eq!((h.app.session.print.layout.cells.len(), h.app.session.print.layout.pages), (0, 1)); + assert_eq!(h.app.ui.print.page, 0); +} + +#[test] +fn templates_save_apply_and_delete_from_the_dialog() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-1"); + cmd(&mut h, "print.set", json!({"layout": {"grid": {"columns": 5, "rows": 6}}})); + h.app.ui.print.template_name = "Sheet 5x6".into(); + click(&mut h, "button:printSaveTemplate"); + assert_eq!(h.app.session.print_templates.len(), 1); + cmd(&mut h, "print.reset", json!({})); + assert!(has(&h, "button:printTemplateApply-0")); + click(&mut h, "button:printTemplateApply-0"); + assert_eq!(h.app.session.print.layout.grid.columns, 5); + click(&mut h, "button:printTemplateDelete-0"); + assert!(h.app.session.print_templates.is_empty()); +} + +#[test] +fn print_to_pdf_picks_a_file_and_runs_in_the_background() { + let asked = Arc::new(Mutex::new(Vec::::new())); + let requests = asked.clone(); + let bytes = Arc::new(Mutex::new(Vec::new())); + let written = bytes.clone(); + let services = Services { + picker: Some(Box::new(move |req| { + requests.lock().unwrap().push(req); + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(vec!["Print.pdf".into()]).unwrap(); + Ok(rx) + })), + write_shared: Some(Arc::new(move |path, data| { + assert_eq!(path, "Print.pdf"); + *written.lock().unwrap() = data.to_vec(); + Ok(()) + })), + ..Default::default() + }; + let mut h = demo(services); + open_print(&mut h); + cmd(&mut h, "print.set", json!({"layout": {"page": {"paper": "5x7", "orientation": "landscape"}}, "job": {"ppi": 100.0}})); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none(), "the dialog closes once the print has started"); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.last_export_result.is_some()), "the print did not finish"); + let req = asked.lock().unwrap(); + assert_eq!(req[0].file_name.as_deref(), Some("Print.pdf")); + let b = bytes.lock().unwrap(); + assert!(b.starts_with(b"%PDF-")); + let (pw, ph) = media_box(&b); + assert!((pw - 504.0).abs() < 1.0 && (ph - 360.0).abs() < 1.0, "7 x 5 in, got {pw} x {ph} pt"); + assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["print"], true); +} + +#[test] +fn printing_shows_a_row_in_the_activity_stack_and_stops() { + let mut app = LightcraftApp::new( + lightcraft_engine::Session::with_demo(), + Services { write_shared: Some(Arc::new(|_, _| panic!("a stopped print wrote a file"))), ..Default::default() }, + ); + app.session.execute("print.set", &json!({"layout": {"style": "contactSheet"}, "job": {"ppi": 300.0}})).unwrap(); + let ids: Vec<_> = app.session.catalog.photos().take(12).map(|p| p.id.0).collect(); + crate::export_task::start_print(&mut app, &json!({"path": "Stop.pdf", "ids": ids})).unwrap(); + let rows = app.session.activity.list(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!((rows[0].kind, rows[0].label.as_str()), ("export", "Printing to PDF")); + assert!(rows[0].cancellable); + app.session.activity.cancel(rows[0].id).unwrap(); + let task = app.export.take().unwrap(); + assert!(task.cancel.load(std::sync::atomic::Ordering::Relaxed)); + drop(task); + assert!(app.session.activity.list().is_empty(), "the row goes with the print"); +} + +#[test] +fn the_print_menu_item_needs_a_photo_and_is_listed() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + assert!(crate::menus::ui_enabled(&app, "dialog.print")); + let empty = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + assert!(!crate::menus::ui_enabled(&empty, "dialog.print")); +} + +#[test] +fn the_print_dialog_fits_the_window_with_its_buttons_on_screen() { + for size in [[1400.0, 900.0], [1100.0, 760.0]] { + for style in ["singleImage", "customPackage"] { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, size, 1.0); + h.settle(T); + cmd(&mut h, "print.set", json!({"layout": {"style": style}})); + if style == "customPackage" { + cmd(&mut h, "print.cell.add", json!({"width": 4, "height": 6})); + } + open_print(&mut h); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + let dialog = rect(&h, "dialog:window"); + assert!(screen.contains_rect(dialog), "{style} at {size:?}: dialog {dialog:?} runs off the window"); + for b in ["button:dialogOk", "button:dialogCancel"] { + assert!(dialog.contains_rect(rect(&h, b)), "{style} at {size:?}: {b} is outside the dialog"); + } + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 72649a5bc..f24cbf5bc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -29,7 +29,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); HLG; the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read) | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | -| 15 | **Print module**: no Print view (layouts, cells, image settings and templates exist as `print.*` commands only), printer colour management, identity plate / watermark / page numbers / crop marks / photo info, Custom Package Auto Layout | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | +| 15 | **Print module**: printer colour management (profile, rendering intent, black point compensation, print adjustment and sharpening), identity plate / watermark / page numbers / crop marks / photo info overlays, Custom Package Auto Layout, printer-driver Page Setup and direct printing (the Print dialog writes a PDF) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 15 shrinks: File → Print… is a Print dialog with the four layout styles, cell editing, image settings, guides, page setup and templates; printer colour management, page overlays and direct printing remain | | 2026-10-12 | minor | Gap 15: the Print module's layout styles, cells, image settings, templates and PDF are `print.*` commands (CLI, control, MCP); the Print view, printer colour management and page overlays remain | | 2026-10-12 | minor | Gap 15: print pages compose in the engine (fit / Zoom to Fill, Rotate to Fit, border, stroke, background) with an interim PDF writer; commands and UI still to land | | 2026-10-12 | minor | Gap 15: the Print layout model (paper sizes, margins, grid, package cells, Auto Layout packer, snapping) is in the engine; commands, composition and UI still to land | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 75dc988ae..4edb4eff5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-CELLS, LRC-PRINT-TEMPLATES and the other print rows: `print.*` commands; LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-GUIDES and the other print rows: the Print dialog (File → Print…); LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-CELLS, LRC-PRINT-TEMPLATES and the other print rows: `print.*` commands; LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 34 | 19 | 32 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 54 | 69 | 35 | 193/201 (96%) | 145/157 (92%) | +| Lightroom Classic extras | 38 | 16 | 31 | 8 | — | 24/26 (92%) | +| **Total** | 412 | 51 | 68 | 35 | 193/201 (96%) | 145/157 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.9%** of 531 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.9% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.4%** of 531 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 52.3% of 172. ## Top gaps and shortcut notes @@ -747,14 +747,14 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | | LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | -| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:print.set`, `cmd:print.get`, `cmd:print.exportPdf`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | Single Image, Contact Sheet, Picture Package and Custom Package are print layout styles reachable from commands, the CLI, control channel and MCP; no Print module UI yet (File → Contact Sheet PDF still the only dialog) | -| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | 🟡 | `cmd:print.set`, `crates/engine/src/print/compose.rs` | Zoom to Fill, Rotate to Fit, Photo Border (package styles), Inner Stroke (width in points, colour) through `print.set {image}`; pixel-tested; no panel yet | -| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi; commands only, no panel yet | -| LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | `cmd:print.set` | guide options (ruler units in/cm/mm/pt, snap off/cells/grid, grid size, show margins/bleed/page grid/cells/dimensions) are stored and used by snapping in `print.cell.move`/`resize`; nothing is drawn until the Print UI lands | -| LRC-PRINT-CELLS | Picture-package cells | P2 | 🟡 | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout` | add/move/resize/delete cells of any size, stacking order, per-cell photos (Custom Package), deterministic Auto Layout packer, snapping to cells or grid; no UI (drag placement) yet | -| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | page background colour and margins through `print.set`; optional filename captions in the contact-sheet PDF; no identity plates, watermarks, page numbers or crop marks | +| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | ✅ | `cmd:dialog.print`, `cmd:print.set`, `cmd:print.get`, `cmd:print.exportPdf`, `cmd:export.contactSheet`, `crates/ui-egui/src/print_view.rs` | File → Print… (⌘P): Single Image, Contact Sheet, Picture Package and Custom Package, one page preview each; also `print.*` commands for the CLI, control channel and MCP; the older File → Contact Sheet PDF stays | +| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ✅ | `cmd:print.set`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | Zoom to Fill Frame, Rotate to Fit, Photo Border (width, colour; package styles) and Inner Stroke (width in points, colour) in the dialog, drawn in the preview and in the PDF; pixel-tested | +| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:app.print`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi, in a Print dialog with a scaled page preview; no printer-driver Page Setup, no cell size / alignment presets | +| LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview | +| LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | +| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; filename captions in the contact-sheet PDF; no identity plate, watermark, page numbers, crop marks or photo info | | LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution | -| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete` | named templates of layout, image settings, page and job saved with the library (prefs.json); no built-in templates, no UI, no template import/export | +| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | | LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | | LRC-WEB-COLOR | Web gallery colours | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-PRINT-GUIDES, LRC-PRINT-CELLS, LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS (✅), LRC-PRINT-LAYOUT, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES (🟡): File → Print… opens the Print dialog with a scaled page preview, rulers and guides, cells dragged and resized with snapping, image settings, page setup and templates, and prints to PDF in the background | | 2026-10-12 | minor | LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS, LRC-PRINT-LAYOUT, LRC-PRINT-CELLS, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES: the Print module is reachable as 18 `print.*` commands (settings, cells, Auto Layout, PDF, templates saved in prefs.json) from the CLI, control channel and MCP; 🟡 until the Print UI lands, LRC-PRINT-GUIDES stays ⬜ | | 2026-10-12 | minor | LRC-PRINT-IMAGESETTINGS: `print/compose.rs` composes pages (Zoom to Fill, Rotate to Fit, Photo Border, Inner Stroke, background) from the layout model into 16-bit Rec. 2020 `PageRaster`s, with an interim sRGB PDF writer in `print/output.rs`; rows stay until commands and UI reach them | | 2026-10-12 | minor | LRC-PRINT-LAYOUT: the Print layout model (paper, margins, grid, package cells, Auto Layout packer, snapping) lands in `print/layout.rs`; no row changes status until commands and UI follow | From 068caf361c494cb25c7ce34337e02e17cf3fec52 Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Mon, 12 Oct 2026 14:16:57 +0530 Subject: [PATCH 13/52] LRC-PRINT-JOB: print colour transform (printer / paper profiles, intent, black point compensation, print adjustment) codecs::print converts a composed print page (16-bit RGB in the Rec2020 output encoding) to the samples a print-ready file carries: - PrintTarget::Profile(icc): a printer / paper profile, RGB, CMYK or grey, matrix or LUT based, converted with moxcms; PrintTarget::Managed(space): the printer manages colour and the page goes in sRGB or Adobe RGB with that profile embedded. - Perceptual or relative colorimetric (ProofIntent, as soft proofing). - Black point compensation for relative colorimetric: the device's darkest is measured by a round trip of black through the profile and linear values are scaled to land the page's black there (Y' = Y(1 - Yb) + Yb, the published mapping for a neutral destination black), so shadows below the ink's black no longer clip to one value. Perceptual tables keep their own black mapping. - Print adjustment brightness / contrast (-100..100) on the sRGB-encoded values, black and white held, monotonic at every setting. Everything before the profile is per channel and folds into one 16-bit table; pages convert in bounded batches (parallel with the `parallel` feature). Hostile profiles, half pixels and NaN settings are errors or neutral, never panics. Not wired to the print job yet. --- crates/codecs/src/lib.rs | 1 + crates/codecs/src/print.rs | 375 +++++++++++++++++++++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 crates/codecs/src/print.rs diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index c96c5334a..9886e8064 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -31,6 +31,7 @@ mod jxl; mod jxl_encode; mod other; mod png_codec; +pub mod print; pub mod proof; mod psd; mod sniff; diff --git a/crates/codecs/src/print.rs b/crates/codecs/src/print.rs new file mode 100644 index 000000000..560e2710d --- /dev/null +++ b/crates/codecs/src/print.rs @@ -0,0 +1,375 @@ +//! Print colour: a composed print page (16-bit RGB encoded with the BT.2020 / BT.709 curve, as the +//! `Rec2020` output space renders it) to the samples a print-ready file carries, in the colour space +//! of the chosen printer / paper profile. +//! +//! - **Target**: a printer / paper ICC profile (RGB, CMYK or grey; matrix or LUT based) converts the +//! page to the device's own values. When the printer manages colour instead, the page is sent in a +//! well-known RGB space (sRGB, Adobe RGB) and that space's profile travels with it. +//! - **Rendering intent**: perceptual or relative colorimetric, as for soft proofing ([`ProofIntent`]). +//! - **Black point compensation** (relative colorimetric only): the darkest colour the device holds is +//! measured by a round trip of black through the profile, and the page's linear values are scaled +//! so its black lands there instead of every shadow below it clipping to the same ink. Linear +//! Rec.2020 is linear in XYZ, so with source black 0 and white 1 this is the published BPC mapping +//! `Y' = Y·(1 − Yb) + Yb` for a neutral destination black. Perceptual tables carry their own black +//! mapping and are left alone. +//! - **Print adjustment**: brightness and contrast (−100..100) for prints that come out darker or +//! flatter than the screen, on the sRGB-encoded values with black and white held. It is not shown +//! on screen (the print is what it corrects). +//! +//! Everything before the profile conversion is per channel, so it folds into one 65,536-entry table +//! indexed by the 16-bit sample; the conversion itself is moxcms's. +//! +//! Sources: ICC.1:2022 (rendering intents, profile classes); Adobe Systems, "Adobe Black Point +//! Compensation" (2006, public white paper) for the black point mapping. Own design otherwise. + +use std::sync::Arc; + +use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformExecutor, TransformOptions}; + +use crate::display::MAX_PROFILE_BYTES; +use crate::proof::ProofIntent; +use crate::space::{NamedSpace, Trc}; + +/// Where a print's colours go. +#[derive(Clone, Debug, PartialEq)] +pub enum PrintTarget { + /// The printer manages colour: the page is sent in this RGB space, with its profile embedded. + Managed(NamedSpace), + /// A printer / paper ICC profile: the page is converted to the device's values. + Profile(Arc>), +} + +/// How the page is rendered into the target. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrintColor { + pub intent: ProofIntent, + /// Map the page's black to the device's darkest (relative colorimetric only). + pub black_point_compensation: bool, + /// −100..100; 0 leaves the page as rendered. + pub brightness: f32, + /// −100..100; 0 leaves the page as rendered. + pub contrast: f32, +} + +impl Default for PrintColor { + fn default() -> Self { + PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, brightness: 0.0, contrast: 0.0 } + } +} + +/// Pixels per conversion batch: bounds the temporary float buffers whatever the page size. +const BATCH: usize = 1 << 16; + +/// The darkest black point compensation will lift the page's black to. A profile whose round trip +/// says more is broken or not a print profile; leave the page alone rather than wash it out. +const MAX_BLACK: f32 = 0.5; + +type Executor = Arc + Send + Sync>; + +/// A page conversion, built once per print job and applied to every page. +pub struct PrintTransform { + forward: Executor, + layout: Layout, + channels: usize, + icc: Arc>, + /// 16-bit sample → linear Rec.2020 after the print adjustment and black point compensation. + pre: Vec, + black: f32, + description: Option, +} + +impl std::fmt::Debug for PrintTransform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PrintTransform") + .field("channels", &self.channels) + .field("black", &self.black) + .field("description", &self.description) + .finish_non_exhaustive() + } +} + +fn guarded(f: impl FnOnce() -> Option) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten() +} + +fn rendering_intent(intent: ProofIntent) -> RenderingIntent { + match intent { + ProofIntent::Perceptual => RenderingIntent::Perceptual, + ProofIntent::Relative => RenderingIntent::RelativeColorimetric, + } +} + +/// A slider value as a finite fraction in −1..1 (NaN and infinities count as 0). +fn amount(v: f32) -> f32 { + if v.is_finite() { (v / 100.0).clamp(-1.0, 1.0) } else { 0.0 } +} + +/// The print adjustment on one sRGB-encoded value (0..1): brightness bends the mid-tones with a +/// power curve, contrast steepens (or flattens) them with `e − k·sin(2πe)/2π`, which keeps black, +/// mid grey and white where they are and stays monotonic for |k| < 1. +pub fn print_adjust(e: f32, brightness: f32, contrast: f32) -> f32 { + let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 }; + let (b, c) = (amount(brightness), amount(contrast)); + let e = if b != 0.0 { e.powf(2f32.powf(-0.5 * b)) } else { e }; + let k = 0.6 * c; + let tau = std::f32::consts::TAU; + (e - k * (tau * e).sin() / tau).clamp(0.0, 1.0) +} + +impl PrintTransform { + /// Build the conversion for a target. Errors say what's wrong with the profile; nothing here + /// panics on a hostile one. + pub fn new(target: &PrintTarget, color: &PrintColor) -> Result { + let icc = match target { + PrintTarget::Managed(n) => Arc::new(crate::icc::write_named(*n)), + PrintTarget::Profile(bytes) => Arc::clone(bytes), + }; + if icc.len() > MAX_PROFILE_BYTES { + return Err("the profile is too large".into()); + } + let dev = guarded(|| ColorProfile::new_from_slice(&icc).ok()).ok_or("not a valid ICC profile")?; + let (layout, channels) = match dev.color_space { + DataColorSpace::Rgb => (Layout::Rgb, 3), + DataColorSpace::Cmyk => (Layout::Rgba, 4), + DataColorSpace::Gray => (Layout::Gray, 1), + _ => return Err("only RGB, CMYK and grey printer profiles can be printed to".into()), + }; + let lin = crate::icc::linear_rec2020_profile(); + let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() }; + let forward: Executor = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rendering_intent(color.intent))).ok()) + .ok_or("can't convert to this profile")?; + + let black = if color.black_point_compensation && color.intent == ProofIntent::Relative { + let back: Executor = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(RenderingIntent::RelativeColorimetric)).ok()) + .ok_or("can't convert from this profile")?; + device_black(&forward, &back, channels) + } else { + 0.0 + }; + + let decode = Trc::Rec709.lut16(); + let (b, c) = (color.brightness, color.contrast); + let adjust = amount(b) != 0.0 || amount(c) != 0.0; + let pre = decode + .iter() + .map(|&lin| { + let lin = if adjust { + let e = lightcraft_color::transfer::linear_to_srgb(lin.clamp(0.0, 1.0)); + lightcraft_color::transfer::srgb_to_linear(print_adjust(e, b, c)) + } else { + lin.clamp(0.0, 1.0) + }; + lin * (1.0 - black) + black + }) + .collect(); + let description = crate::icc::parse(&icc).and_then(|i| i.description); + Ok(PrintTransform { forward, layout, channels, icc, pre, black, description }) + } + + /// Samples per pixel of the converted page: 3 (RGB), 4 (CMYK) or 1 (grey). + pub fn channels(&self) -> usize { + self.channels + } + + /// The profile the converted samples are in: embed it with them. + pub fn icc(&self) -> &Arc> { + &self.icc + } + + /// Whether the samples are CMYK (PDF readers show CMYK images inverted unless told otherwise, + /// so writers need to know). + pub fn is_cmyk(&self) -> bool { + self.layout == Layout::Rgba + } + + /// The linear black black point compensation lifts the page's black to (0 when off). + pub fn black(&self) -> f32 { + self.black + } + + /// The profile's description, for the job's settings and error messages. + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + /// Convert a page: `rgb` is interleaved 16-bit RGB in the BT.2020 / BT.709 encoding; the result + /// is interleaved 16-bit device samples ([`PrintTransform::channels`] per pixel). + pub fn apply(&self, rgb: &[u16]) -> Result, String> { + if !rgb.len().is_multiple_of(3) { + return Err("the page isn't whole RGB pixels".into()); + } + let pixels = rgb.len() / 3; + let ch = self.channels; + let out_len = pixels.checked_mul(ch).ok_or("the page is too large")?; + let mut out = vec![0u16; out_len]; + let run = |src: &[u16], dst: &mut [u16]| -> Option<()> { + let lin: Vec = src.iter().map(|&v| self.pre.get(usize::from(v)).copied().unwrap_or(0.0)).collect(); + let mut dev = vec![0f32; dst.len()]; + self.forward.transform(&lin, &mut dev).ok()?; + for (d, v) in dst.iter_mut().zip(dev) { + let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + *d = (v * 65535.0 + 0.5) as u16; + } + Some(()) + }; + let src = rgb.chunks(BATCH * 3); + let dst = out.chunks_mut(BATCH * ch); + #[cfg(feature = "parallel")] + let ok = { + use rayon::prelude::*; + let pairs: Vec<_> = src.zip(dst).collect(); + pairs.into_par_iter().all(|(s, d)| guarded(|| run(s, d)).is_some()) + }; + #[cfg(not(feature = "parallel"))] + let ok = src.zip(dst).all(|(s, d)| guarded(|| run(s, d)).is_some()); + if ok { Ok(out) } else { Err("the profile's tables can't be applied".into()) } + } +} + +/// The darkest the device holds, as linear Rec.2020 luminance: black there and back, relative +/// colorimetric both ways (for a device that reaches true black, 0). +fn device_black(forward: &Executor, back: &Executor, channels: usize) -> f32 { + let mut device = vec![0f32; channels]; + let mut lin = [0f32; 3]; + let ok = guarded(|| { + forward.transform(&[0.0; 3], &mut device).ok()?; + back.transform(&device, &mut lin).ok() + }); + if ok.is_none() { + return 0.0; + } + // BT.2020 luminance weights + let y = 0.2627 * lin[0] + 0.6780 * lin[1] + 0.0593 * lin[2]; + if y.is_finite() { y.clamp(0.0, MAX_BLACK) } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + use moxcms::ToneReprCurve; + + /// A page of one 16-bit pixel per linear grey level. + fn greys(levels: &[f32]) -> Vec { + levels.iter().flat_map(|&l| [(Trc::Rec709.from_linear(l) * 65535.0).round() as u16; 3]).collect() + } + + fn convert(target: &PrintTarget, color: PrintColor, levels: &[f32]) -> Vec { + PrintTransform::new(target, &color).unwrap().apply(&greys(levels)).unwrap() + } + + /// An RGB print profile whose ink can't get darker than linear 0.03: device 0 is 3 % grey. + fn raised_black_profile() -> Arc> { + let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::Srgb)).unwrap(); + let (g, e) = (2.2f32, 0.03f32); + let a = (1.0 - e).powf(1.0 / g); + // ICC parametric type 4: Y = (aX + b)^g + e for X >= d, Y = cX + f below + let trc = ToneReprCurve::Parametric(vec![g, a, 0.0, 0.0, 0.0, e, e]); + p.red_trc = Some(trc.clone()); + p.green_trc = Some(trc.clone()); + p.blue_trc = Some(trc); + Arc::new(p.encode().unwrap()) + } + + #[test] + fn managed_srgb_keeps_black_grey_and_white() { + let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor::default()).unwrap(); + assert_eq!(t.channels(), 3); + assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::Srgb)); + let out = t.apply(&greys(&[0.0, 0.18, 1.0])).unwrap(); + let mid = (lightcraft_color::transfer::linear_to_srgb(0.18) * 65535.0) as i32; + assert!(out[..3].iter().all(|&v| v < 64), "{out:?}"); + assert!(out[3..6].iter().all(|&v| (i32::from(v) - mid).abs() < 300), "{out:?} vs {mid}"); + assert!(out[6..].iter().all(|&v| v > 65400), "{out:?}"); + } + + #[test] + fn adobe_rgb_target_embeds_its_profile() { + let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::AdobeRgb), &PrintColor::default()).unwrap(); + assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::AdobeRgb)); + // a saturated Rec.2020 green is outside Adobe RGB: it comes out at the space's edge, not wrapped + let px = [0u16, 65535, 0]; + let out = t.apply(&px).unwrap(); + assert!(out[1] > 60000 && out[0] < 8000 && out[2] < 8000, "{out:?}"); + } + + #[test] + fn brightness_lifts_the_mid_tones_and_holds_the_ends() { + let target = PrintTarget::Managed(NamedSpace::Srgb); + let levels = [0.0, 0.18, 1.0]; + let plain = convert(&target, PrintColor::default(), &levels); + let bright = convert(&target, PrintColor { brightness: 50.0, ..PrintColor::default() }, &levels); + let dark = convert(&target, PrintColor { brightness: -50.0, ..PrintColor::default() }, &levels); + assert!(bright[3] > plain[3] + 2000 && dark[3] + 2000 < plain[3], "{dark:?} {plain:?} {bright:?}"); + for v in [&plain, &bright, &dark] { + assert!(v[0] < 64 && v[6] > 65400, "{v:?}"); + } + } + + #[test] + fn contrast_spreads_shadows_and_highlights_and_stays_monotonic() { + let target = PrintTarget::Managed(NamedSpace::Srgb); + let levels = [0.03, 0.6]; + let plain = convert(&target, PrintColor::default(), &levels); + let more = convert(&target, PrintColor { contrast: 60.0, ..PrintColor::default() }, &levels); + assert!(more[0] < plain[0] && more[3] > plain[3], "{plain:?} {more:?}"); + for c in [-100.0, 100.0] { + for b in [-100.0, 100.0] { + let ramp: Vec = (0..=64).map(|i| i as f32 / 64.0).map(|e| print_adjust(e, b, c)).collect(); + assert!(ramp.windows(2).all(|w| w[1] >= w[0]), "b {b} c {c}: {ramp:?}"); + assert!(ramp[0] == 0.0 && (ramp[64] - 1.0).abs() < 1e-6); + } + } + } + + #[test] + fn black_point_compensation_keeps_shadow_detail_above_the_inks_black() { + let target = PrintTarget::Profile(raised_black_profile()); + let shadows = [0.0, 0.006, 0.012, 0.018, 0.024, 0.5, 1.0]; + let relative = PrintColor { intent: ProofIntent::Relative, black_point_compensation: false, ..PrintColor::default() }; + let clipped = convert(&target, relative, &shadows); + let kept = convert(&target, PrintColor { black_point_compensation: true, ..relative }, &shadows); + let reds = |v: &[u16]| v.chunks(3).map(|p| p[0]).collect::>(); + let (clipped, kept) = (reds(&clipped), reds(&kept)); + // everything below the ink's black prints as (within rounding) the same device value without + // compensation … + assert!(clipped[..5].iter().all(|&v| v <= clipped[0] + 16), "{clipped:?}"); + // … and as distinct, rising values with it; white still prints as white + assert!(kept[..5].windows(2).all(|w| w[1] > w[0]) && kept[4] > kept[0] + 1000, "{kept:?}"); + assert!(kept[6] > 65400 && clipped[6] > 65400, "{kept:?} {clipped:?}"); + let t = PrintTransform::new(&target, &PrintColor { black_point_compensation: true, ..relative }).unwrap(); + assert!((t.black() - 0.03).abs() < 0.01, "{}", t.black()); + } + + #[test] + fn black_point_compensation_is_relative_colorimetric_only() { + let target = PrintTarget::Profile(raised_black_profile()); + let levels = [0.0, 0.01, 0.2]; + let with = convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, ..PrintColor::default() }, &levels); + let without = + convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: false, ..PrintColor::default() }, &levels); + assert_eq!(with, without); + let t = PrintTransform::new(&target, &PrintColor { intent: ProofIntent::Perceptual, ..PrintColor::default() }).unwrap(); + assert_eq!(t.black(), 0.0); + } + + #[test] + fn hostile_input_is_an_error_not_a_panic() { + let color = PrintColor::default(); + assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![])), &color).is_err()); + assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; 400])), &color).is_err()); + assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; MAX_PROFILE_BYTES + 1])), &color).is_err()); + let good = crate::icc::write_named(NamedSpace::DisplayP3); + for i in (0..good.len()).step_by(7) { + let mut b = good.clone(); + b[i] ^= 0x5a; + if let Ok(t) = PrintTransform::new(&PrintTarget::Profile(Arc::new(b)), &PrintColor { intent: ProofIntent::Relative, ..color }) { + let _ = t.apply(&greys(&[0.0, 0.5, 1.0])); + } + } + let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor { brightness: f32::NAN, contrast: f32::INFINITY, ..color }) + .unwrap(); + assert!(t.apply(&[1, 2]).is_err(), "half a pixel"); + assert_eq!(t.apply(&greys(&[0.18])).unwrap(), convert(&PrintTarget::Managed(NamedSpace::Srgb), color, &[0.18])); + assert!(print_adjust(f32::NAN, 50.0, 50.0).is_finite()); + } +} From d289fafbe2c6ec6b493e8b7f050e7d331b28d92c Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Mon, 12 Oct 2026 14:22:07 +0530 Subject: [PATCH 14/52] LRC-PRINT-JOB: PDF writer for print output (ICC-tagged 8 / 16-bit images, output intent) engine::pdf writes print-ready documents: pages of any size in points with raster images placed on them, each image tagged with its ICC profile as an ICCBased colour space (RGB, CMYK or grey), stored losslessly with Flate at 8 or 16 bits per component (16-bit samples big-endian), or copied as a JPEG stream where file size matters more. A profile shared by several images is written once. An optional output intent names the printer / paper profile the file was made for (PDF/X-style /OutputIntents in the catalog); the Info dictionary carries the title, as UTF-16 when it isn't plain ASCII. PDF 1.6 (16-bit images need 1.5), classic cross-reference table, deterministic bytes. Pages, images per page, page and image sizes are capped; bad sizes, placements, sample counts and component counts are errors, never panics. Tests read the files back through the xref table: image samples and profiles inflate to what went in, placements reach the content streams, one profile object is shared. Not wired to the print job yet. --- crates/engine/src/lib.rs | 1 + crates/engine/src/pdf.rs | 486 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 crates/engine/src/pdf.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4cce1d8df..2193c5178 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -53,6 +53,7 @@ pub mod memory; pub mod merge; mod model_download; pub mod originals; +pub mod pdf; pub mod preset_import; pub mod preset_luminar; pub mod presets; diff --git a/crates/engine/src/pdf.rs b/crates/engine/src/pdf.rs new file mode 100644 index 000000000..29f0b73f7 --- /dev/null +++ b/crates/engine/src/pdf.rs @@ -0,0 +1,486 @@ +//! A small PDF writer for print output: pages of a given size in points, each with raster images +//! placed on it, tagged with their ICC profile (`ICCBased`) so the numbers mean the same colour in +//! every reader and RIP, Flate-compressed (lossless) at 8 or 16 bits per component, or carried as +//! JPEG (`DCTDecode`) where small files matter more than exact samples. Optionally an output intent: +//! the printing condition (the printer / paper profile) the file was made for, as PDF/X readers +//! expect it in the catalog. Profiles shared by several images are written once. +//! +//! The file is PDF 1.6 (16-bit images need 1.5): classic cross-reference table, no object streams, +//! deterministic (no dates or IDs), so the same pages always give the same bytes. +//! +//! Sources: ISO 32000-1:2008 (§7.3 objects, §7.5 file structure, §8.6.5.5 ICCBased colour spaces, +//! §8.9 images, §14.11.5 output intents). Own design. + +use std::collections::HashMap; +use std::sync::Arc; + +/// Pages per document: a print job beyond this is a mistake, not a book. +pub const MAX_PAGES: usize = 1000; +/// Images per page. +pub const MAX_IMAGES_PER_PAGE: usize = 256; +/// Largest page side in points (200 inches): more than any printer's roll. +pub const MAX_PAGE_POINTS: f64 = 14_400.0; +/// Largest image, in pixels per side. +pub const MAX_IMAGE_SIDE: u32 = 65_535; + +/// How an image's samples are stored. +#[derive(Clone, Debug, PartialEq)] +pub enum ImageData { + /// Interleaved samples, `bits` (8 or 16) per component, 16-bit values big-endian: compressed + /// losslessly in the file. + Raw { bits: u8, samples: Vec }, + /// A baseline JPEG stream, copied into the file as it is. + Jpeg(Vec), +} + +/// A raster image and the colour space its samples are in. +#[derive(Clone, Debug, PartialEq)] +pub struct PdfImage { + pub width: u32, + pub height: u32, + /// Components per pixel: 1 (grey), 3 (RGB) or 4 (CMYK). + pub channels: u8, + pub data: ImageData, + /// The ICC profile the samples are in; without one the image is device colour. + pub icc: Option>>, +} + +impl PdfImage { + /// An image from interleaved 16-bit samples. + pub fn from_u16(width: u32, height: u32, channels: u8, samples: &[u16], icc: Option>>) -> PdfImage { + let bytes = samples.iter().flat_map(|v| v.to_be_bytes()).collect(); + PdfImage { width, height, channels, data: ImageData::Raw { bits: 16, samples: bytes }, icc } + } + + fn check(&self) -> Result<(), String> { + if self.width == 0 || self.height == 0 || self.width > MAX_IMAGE_SIDE || self.height > MAX_IMAGE_SIDE { + return Err(format!("an image of {} × {} pixels can't be written", self.width, self.height)); + } + if !matches!(self.channels, 1 | 3 | 4) { + return Err(format!("images have 1, 3 or 4 components, not {}", self.channels)); + } + match &self.data { + ImageData::Raw { bits, samples } => { + if !matches!(bits, 8 | 16) { + return Err(format!("images have 8 or 16 bits per component, not {bits}")); + } + let want = u64::from(self.width) * u64::from(self.height) * u64::from(self.channels) * u64::from(bits / 8); + if samples.len() as u64 != want { + return Err(format!("the image needs {want} bytes of samples, not {}", samples.len())); + } + } + ImageData::Jpeg(data) => { + if !data.starts_with(&[0xFF, 0xD8]) { + return Err("the image isn't a JPEG stream".into()); + } + } + } + Ok(()) + } +} + +/// Where an image goes on its page, in points from the page's bottom-left corner. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// One page: its size in points and the images on it, drawn in order (later ones on top). +#[derive(Clone, Debug, PartialEq)] +pub struct PdfPage { + pub width: f64, + pub height: f64, + pub images: Vec<(PdfImage, Placement)>, +} + +/// The printing condition the document is made for (PDF/X-style output intent). +#[derive(Clone, Debug, PartialEq)] +pub struct OutputIntent { + /// The printer / paper profile. + pub icc: Arc>, + /// Its components: 1, 3 or 4. + pub channels: u8, + /// A name for the condition (the profile's description). + pub condition: String, +} + +/// Document-level settings. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PdfInfo { + pub title: Option, + pub output_intent: Option, +} + +fn finite(v: f64) -> bool { + v.is_finite() +} + +/// A number as PDF writes it: up to four decimals, no exponent, no trailing zeros. +fn num(v: f64) -> String { + let s = format!("{:.4}", if finite(v) { v } else { 0.0 }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" || s.is_empty() { "0".into() } else { s.into() } +} + +/// A text string: plain ASCII as a literal string, anything else as UTF-16BE with a byte order mark. +fn text(s: &str) -> String { + if s.bytes().all(|b| (0x20..0x7f).contains(&b)) { + let mut out = String::with_capacity(s.len() + 2); + out.push('('); + for c in s.chars() { + if matches!(c, '(' | ')' | '\\') { + out.push('\\'); + } + out.push(c); + } + out.push(')'); + out + } else { + let mut out = String::from("'); + out + } +} + +struct Writer { + out: Vec, + offsets: Vec, +} + +impl Writer { + /// Reserve an object number (filled later by [`Writer::object`]). + fn reserve(&mut self) -> usize { + self.offsets.push(0); + self.offsets.len() + } + + fn object(&mut self, id: usize, dict: &str, stream: Option<&[u8]>) { + if let Some(slot) = self.offsets.get_mut(id.wrapping_sub(1)) { + *slot = self.out.len(); + } + self.out.extend_from_slice(format!("{id} 0 obj\n{dict}").as_bytes()); + if let Some(data) = stream { + self.out.extend_from_slice(b"\nstream\n"); + self.out.extend_from_slice(data); + self.out.extend_from_slice(b"\nendstream"); + } + self.out.extend_from_slice(b"\nendobj\n"); + } +} + +fn deflate(data: &[u8]) -> Vec { + miniz_oxide::deflate::compress_to_vec_zlib(data, 6) +} + +/// Write a document. Errors name the first page or image that can't be written. +pub fn write(pages: &[PdfPage], info: &PdfInfo) -> Result, String> { + if pages.is_empty() { + return Err("there are no pages to write".into()); + } + if pages.len() > MAX_PAGES { + return Err(format!("a document holds at most {MAX_PAGES} pages")); + } + for (n, page) in pages.iter().enumerate() { + let n = n + 1; + if !(finite(page.width) && finite(page.height)) + || page.width <= 0.0 + || page.height <= 0.0 + || page.width > MAX_PAGE_POINTS + || page.height > MAX_PAGE_POINTS + { + return Err(format!("page {n} has no usable size")); + } + if page.images.len() > MAX_IMAGES_PER_PAGE { + return Err(format!("page {n} has more than {MAX_IMAGES_PER_PAGE} images")); + } + for (image, at) in &page.images { + image.check().map_err(|e| format!("page {n}: {e}"))?; + if ![at.x, at.y, at.width, at.height].into_iter().all(finite) || at.width <= 0.0 || at.height <= 0.0 { + return Err(format!("page {n}: an image has no usable placement")); + } + } + } + if let Some(intent) = &info.output_intent + && !matches!(intent.channels, 1 | 3 | 4) + { + return Err("the output intent's profile has 1, 3 or 4 components".into()); + } + + let mut w = Writer { out: Vec::new(), offsets: Vec::new() }; + w.out.extend_from_slice(b"%PDF-1.6\n%\xE2\xE3\xCF\xD3\n"); + let catalog = w.reserve(); + let pages_id = w.reserve(); + let info_id = w.reserve(); + + // ICC profiles, each written once (keyed by content) with its component count + let mut profiles: HashMap, usize> = HashMap::new(); + let mut profile = |w: &mut Writer, icc: &Arc>, n: u8| -> usize { + if let Some(&id) = profiles.get(icc.as_slice()) { + return id; + } + let id = w.reserve(); + let data = deflate(icc); + let alternate = match n { + 1 => "/DeviceGray", + 4 => "/DeviceCMYK", + _ => "/DeviceRGB", + }; + w.object(id, &format!("<< /N {n} /Alternate {alternate} /Filter /FlateDecode /Length {} >>", data.len()), Some(&data)); + profiles.insert(icc.to_vec(), id); + id + }; + + let intent_id = info.output_intent.as_ref().map(|intent| profile(&mut w, &intent.icc, intent.channels)); + + let mut kids = Vec::with_capacity(pages.len()); + for page in pages { + let page_id = w.reserve(); + let mut xobjects = String::new(); + let mut content = String::new(); + for (k, (image, at)) in page.images.iter().enumerate() { + let space = match &image.icc { + Some(icc) => format!("[/ICCBased {} 0 R]", profile(&mut w, icc, image.channels)), + None => match image.channels { + 1 => "/DeviceGray".into(), + 4 => "/DeviceCMYK".into(), + _ => "/DeviceRGB".into(), + }, + }; + let id = w.reserve(); + let (bits, filter, data) = match &image.data { + ImageData::Raw { bits, samples } => (*bits, "/FlateDecode", deflate(samples)), + ImageData::Jpeg(jpeg) => (8, "/DCTDecode", jpeg.clone()), + }; + w.object( + id, + &format!( + "<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {space} /BitsPerComponent {bits} /Filter {filter} /Length {} >>", + image.width, + image.height, + data.len() + ), + Some(&data), + ); + xobjects.push_str(&format!("/Im{k} {id} 0 R ")); + content.push_str(&format!("q {} 0 0 {} {} {} cm /Im{k} Do Q\n", num(at.width), num(at.height), num(at.x), num(at.y))); + } + let content_id = w.reserve(); + let stream = deflate(content.as_bytes()); + w.object(content_id, &format!("<< /Filter /FlateDecode /Length {} >>", stream.len()), Some(&stream)); + w.object( + page_id, + &format!( + "<< /Type /Page /Parent {pages_id} 0 R /MediaBox [0 0 {} {}] /Resources << /XObject << {xobjects}>> >> /Contents {content_id} 0 R >>", + num(page.width), + num(page.height) + ), + None, + ); + kids.push(format!("{page_id} 0 R")); + } + w.object(pages_id, &format!("<< /Type /Pages /Count {} /Kids [{}] >>", kids.len(), kids.join(" ")), None); + + let intents = match (&info.output_intent, intent_id) { + (Some(intent), Some(id)) => format!( + " /OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Custom) /Info {} /DestOutputProfile {id} 0 R >>]", + text(&intent.condition) + ), + _ => String::new(), + }; + w.object(catalog, &format!("<< /Type /Catalog /Pages {pages_id} 0 R{intents} >>"), None); + let title = info.title.as_deref().map(|t| format!(" /Title {}", text(t))).unwrap_or_default(); + w.object(info_id, &format!("<< /Producer (LightCraft){title} >>"), None); + + let xref = w.out.len(); + let mut table = format!("xref\n0 {}\n0000000000 65535 f \n", w.offsets.len() + 1); + for offset in &w.offsets { + table.push_str(&format!("{offset:010} 00000 n \n")); + } + table.push_str(&format!("trailer\n<< /Size {} /Root {catalog} 0 R /Info {info_id} 0 R >>\nstartxref\n{xref}\n%%EOF\n", w.offsets.len() + 1)); + w.out.extend_from_slice(table.as_bytes()); + Ok(w.out) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_codecs::NamedSpace; + + /// The body of object `id` (dictionary and raw stream bytes), found through the xref table. + fn object(pdf: &[u8], id: usize) -> (String, Vec) { + // byte offsets: the streams are binary, so search the bytes, not a lossy string + let start = pdf.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10; + let tail = std::str::from_utf8(&pdf[start..]).unwrap(); + let xref: usize = tail.lines().next().unwrap().trim().parse().unwrap(); + let table = std::str::from_utf8(&pdf[xref..]).unwrap(); + let line = table.lines().nth(2 + id).unwrap(); + let offset: usize = line[..10].parse().unwrap(); + let head = format!("{id} 0 obj\n"); + assert_eq!(&pdf[offset..offset + head.len()], head.as_bytes(), "xref entry for {id}"); + let rest = &pdf[offset + head.len()..]; + let end = rest.windows(7).position(|w| w == b"\nendobj").unwrap(); + let body = &rest[..end]; + match body.windows(8).position(|w| w == b"\nstream\n") { + Some(p) => { + let data = &body[p + 8..body.len() - "\nendstream".len()]; + (String::from_utf8_lossy(&body[..p]).into_owned(), data.to_vec()) + } + None => (String::from_utf8_lossy(body).into_owned(), vec![]), + } + } + + fn inflate(data: &[u8]) -> Vec { + miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap() + } + + /// Object ids of every image XObject, in file order. + fn images(pdf: &[u8]) -> Vec { + let s = String::from_utf8_lossy(pdf); + s.match_indices(" 0 obj\n<< /Type /XObject /Subtype /Image").map(|(i, _)| s[..i].rsplit('\n').next().unwrap().parse().unwrap()).collect() + } + + fn reference(dict: &str, key: &str) -> usize { + let at = dict.find(key).unwrap() + key.len(); + dict[at..].trim_start().split(' ').next().unwrap().parse().unwrap() + } + + fn srgb() -> Arc> { + Arc::new(lightcraft_codecs::icc::write_named(NamedSpace::Srgb)) + } + + fn page(images: Vec<(PdfImage, Placement)>) -> PdfPage { + PdfPage { width: 612.0, height: 792.0, images } + } + + fn full(w: f64, h: f64) -> Placement { + Placement { x: 0.0, y: 0.0, width: w, height: h } + } + + #[test] + fn a_16_bit_icc_image_round_trips() { + let samples: Vec = (0..2 * 3 * 3).map(|i| (i * 3851) as u16).collect(); + let img = PdfImage::from_u16(3, 2, 3, &samples, Some(srgb())); + let pdf = write(&[page(vec![(img, full(612.0, 792.0))])], &PdfInfo::default()).unwrap(); + assert!(pdf.starts_with(b"%PDF-1.6\n")); + assert!(pdf.ends_with(b"%%EOF\n")); + let ids = images(&pdf); + assert_eq!(ids.len(), 1); + let (dict, data) = object(&pdf, ids[0]); + assert!(dict.contains("/Width 3 /Height 2") && dict.contains("/BitsPerComponent 16") && dict.contains("/FlateDecode"), "{dict}"); + let back: Vec = inflate(&data).chunks(2).map(|b| u16::from_be_bytes([b[0], b[1]])).collect(); + assert_eq!(back, samples); + let (profile, icc) = object(&pdf, reference(&dict, "/ICCBased")); + assert!(profile.contains("/N 3") && profile.contains("/Alternate /DeviceRGB"), "{profile}"); + assert_eq!(inflate(&icc), *srgb()); + assert_eq!(lightcraft_codecs::icc::parse(&inflate(&icc)).and_then(|i| i.named), Some(NamedSpace::Srgb)); + } + + #[test] + fn pages_sizes_placements_and_a_shared_profile() { + let px = |v: u16| PdfImage::from_u16(1, 1, 3, &[v, v, v], Some(srgb())); + let at = Placement { x: 36.0, y: 72.5, width: 288.0, height: 216.0 }; + let pages = [page(vec![(px(1), at), (px(2), full(612.0, 792.0))]), PdfPage { width: 841.89, height: 595.28, images: vec![(px(3), at)] }]; + let pdf = write(&pages, &PdfInfo::default()).unwrap(); + let s = String::from_utf8_lossy(&pdf); + assert!(s.contains("/Type /Pages /Count 2"), "two pages"); + assert!(s.contains("/MediaBox [0 0 612 792]") && s.contains("/MediaBox [0 0 841.89 595.28]")); + // the sRGB profile is written once for all three images + assert_eq!(s.matches("/N 3 /Alternate").count(), 1); + let first: Vec<_> = images(&pdf).iter().map(|&id| reference(&object(&pdf, id).0, "/ICCBased")).collect(); + assert!(first.windows(2).all(|w| w[0] == w[1]), "{first:?}"); + // placements reach the content streams + let contents: Vec = s + .match_indices("/Contents ") + .map(|(i, _)| s[i + 10..].split(' ').next().unwrap().parse::().unwrap()) + .map(|id| String::from_utf8(inflate(&object(&pdf, id).1)).unwrap()) + .collect(); + assert_eq!(contents[0], "q 288 0 0 216 36 72.5 cm /Im0 Do Q\nq 612 0 0 792 0 0 cm /Im1 Do Q\n"); + assert_eq!(write(&pages, &PdfInfo::default()).unwrap(), pdf, "deterministic"); + } + + #[test] + fn output_intent_and_title() { + let cmyk_like = srgb(); + let info = PdfInfo { + title: Some("Garden – prints (2)".into()), + output_intent: Some(OutputIntent { icc: cmyk_like.clone(), channels: 3, condition: "Glossy (Photo) \\ test".into() }), + }; + let img = PdfImage::from_u16(1, 1, 3, &[0, 0, 0], Some(cmyk_like)); + let pdf = write(&[page(vec![(img, full(10.0, 10.0))])], &info).unwrap(); + let (catalog, _) = object(&pdf, 1); + assert!(catalog.contains("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX"), "{catalog}"); + assert!(catalog.contains("/Info (Glossy \\(Photo\\) \\\\ test)"), "{catalog}"); + // the intent and the image share one profile object + let dest = reference(&catalog, "/DestOutputProfile"); + assert_eq!(reference(&object(&pdf, images(&pdf)[0]).0, "/ICCBased"), dest); + let (info_dict, _) = object(&pdf, 3); + assert!(info_dict.contains("/Title = ids.iter().map(|&id| object(&pdf, id)).collect(); + assert!(dicts[0].0.contains("/ColorSpace /DeviceCMYK") && dicts[0].0.contains("/BitsPerComponent 8")); + assert!(dicts[1].0.contains("/ColorSpace /DeviceGray")); + assert!(dicts[2].0.contains("/DCTDecode") && dicts[2].0.contains("/DeviceRGB")); + assert_eq!(dicts[2].1, jpeg, "JPEG streams are copied as they are"); + } + + #[test] + fn hostile_documents_are_errors() { + let ok = || PdfImage::from_u16(1, 1, 3, &[1, 2, 3], None); + let at = full(10.0, 10.0); + let bad = |pages: Vec| write(&pages, &PdfInfo::default()).is_err(); + assert!(bad(vec![])); + assert!(bad(vec![PdfPage { width: f64::NAN, height: 10.0, images: vec![] }])); + assert!(bad(vec![PdfPage { width: -1.0, height: 10.0, images: vec![] }])); + assert!(bad(vec![PdfPage { width: 1e9, height: 10.0, images: vec![] }])); + assert!(bad(vec![page(vec![(ok(), Placement { x: f64::INFINITY, ..at })])])); + assert!(bad(vec![page(vec![(ok(), Placement { width: 0.0, ..at })])])); + let mut short = ok(); + short.data = ImageData::Raw { bits: 16, samples: vec![0; 5] }; + assert!(bad(vec![page(vec![(short, at)])])); + let mut empty = ok(); + empty.width = 0; + assert!(bad(vec![page(vec![(empty, at)])])); + let mut odd = ok(); + odd.channels = 2; + assert!(bad(vec![page(vec![(odd, at)])])); + let mut not_jpeg = ok(); + not_jpeg.data = ImageData::Jpeg(vec![1, 2, 3]); + assert!(bad(vec![page(vec![(not_jpeg, at)])])); + let too_many = (0..MAX_PAGES + 1).map(|_| page(vec![])).collect(); + assert!(bad(too_many)); + let info = PdfInfo { title: None, output_intent: Some(OutputIntent { icc: srgb(), channels: 2, condition: String::new() }) }; + assert!(write(&[page(vec![])], &info).is_err()); + } + + #[test] + fn numbers_and_strings() { + assert_eq!(num(612.0), "612"); + assert_eq!(num(72.5), "72.5"); + assert_eq!(num(-0.00001), "0"); + assert_eq!(num(f64::NAN), "0"); + assert_eq!(num(1.0 / 3.0), "0.3333"); + assert_eq!(text("a(b)c\\"), "(a\\(b\\)c\\\\)"); + assert_eq!(text("é"), ""); + } +} From 9bc76a4e5868a1fd330cd66585b9857c10255751 Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Mon, 12 Oct 2026 14:27:58 +0530 Subject: [PATCH 15/52] LRC-PRINT-JOB: print job colour settings: profile, intent, print adjustment, print sharpening, depth engine::print_color turns composed pages into a print-ready PDF: - Profile: Managed by Printer sends the page in sRGB or Adobe RGB with that profile embedded and no output intent (the driver converts); a printer / paper .icc / .icm converts the page to the device's values here and becomes the document's output intent (named by the profile's description), so the driver must not convert again. Profiles over the size cap, unreadable or invalid files are errors that name the file. - Intent (perceptual / relative colorimetric), black point compensation and print adjustment brightness / contrast, through codecs::print. - Print sharpening Off / Low / Standard / High for Matte or Glossy paper, per photo at its print size, with the export sharpener. - 16 bits per component by default, 8 for smaller files. PrintColorSettings is serde (camelCase, defaults for missing fields) so print templates can save it. Page sizes are checked with overflow-safe arithmetic. Not wired to the print layout yet. --- crates/engine/src/lib.rs | 1 + crates/engine/src/print_color.rs | 353 +++++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 crates/engine/src/print_color.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2193c5178..cf0fa1637 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -58,6 +58,7 @@ pub mod preset_import; pub mod preset_luminar; pub mod presets; pub mod print; +pub mod print_color; pub mod proof; pub mod rename; pub mod segment; diff --git a/crates/engine/src/print_color.rs b/crates/engine/src/print_color.rs new file mode 100644 index 000000000..540e48952 --- /dev/null +++ b/crates/engine/src/print_color.rs @@ -0,0 +1,353 @@ +//! The print job's colour: which profile composed pages are converted to and how, print sharpening +//! for the paper, and the print-ready PDF they end up in. +//! +//! - **Profile**: "Managed by Printer" sends the page in sRGB or Adobe RGB with that profile +//! embedded (the driver converts); a printer / paper ICC profile converts the page to the device's +//! own values here and names the profile as the document's output intent, so the driver must not +//! convert again. +//! - **Intent, black point compensation, print adjustment**: [`lightcraft_codecs::print`]. +//! - **Print sharpening** (Low / Standard / High for Matte or Glossy paper): each photo is sharpened +//! at its print size before it goes on the page, with the export sharpener. +//! - **Depth**: 16 bits per component by default (no banding from the profile conversion); 8 bits +//! for smaller files. +//! +//! Pages arrive as 16-bit RGB in the `Rec2020` output encoding, the composed page of the layout. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use lightcraft_codecs::display::MAX_PROFILE_BYTES; +use lightcraft_codecs::print::{PrintColor, PrintTarget, PrintTransform}; +use lightcraft_codecs::{NamedSpace, proof::ProofIntent}; +use serde::{Deserialize, Serialize}; + +use crate::export::{DeepImage, DeepSamples, OutputSpace, SharpenAmount, SharpenFor, output_sharpen_deep}; +use crate::pdf::{ImageData, OutputIntent, PdfImage, PdfInfo, PdfPage, Placement}; + +/// The colour space a printer that manages colour receives. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ManagedSpace { + #[default] + Srgb, + AdobeRgb, +} + +/// Where the print's colours go. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum PrintProfile { + /// Managed by Printer: the page goes in this space and the driver converts it. + Managed { space: ManagedSpace }, + /// A printer / paper profile (an `.icc` / `.icm` file): converted here. + Icc { path: PathBuf }, +} + +impl Default for PrintProfile { + fn default() -> Self { + PrintProfile::Managed { space: ManagedSpace::Srgb } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrintIntent { + #[default] + Perceptual, + Relative, +} + +/// Print sharpening strength (Off leaves the photos as rendered). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrintSharpen { + Off, + Low, + #[default] + Standard, + High, +} + +/// The paper print sharpening is tuned for. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrintMedia { + Matte, + #[default] + Glossy, +} + +/// The print job's colour settings, as saved with print templates. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PrintColorSettings { + pub profile: PrintProfile, + pub intent: PrintIntent, + /// Map the page's black to the paper's darkest (relative colorimetric). + pub black_point_compensation: bool, + /// Print adjustment, −100..100. + pub brightness: f32, + /// Print adjustment, −100..100. + pub contrast: f32, + pub sharpen: PrintSharpen, + pub media: PrintMedia, + /// 16 bits per component in the PDF (else 8). + pub sixteen_bit: bool, +} + +impl Default for PrintColorSettings { + fn default() -> Self { + PrintColorSettings { + profile: PrintProfile::default(), + intent: PrintIntent::default(), + black_point_compensation: true, + brightness: 0.0, + contrast: 0.0, + sharpen: PrintSharpen::default(), + media: PrintMedia::default(), + sixteen_bit: true, + } + } +} + +/// Read a printer profile, refusing files that can't be one (the parse happens later). +fn read_profile(path: &Path) -> Result, String> { + let shown = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()); + let len = std::fs::metadata(path).map_err(|e| format!("can't read the printer profile {shown}: {e}"))?.len(); + if len > MAX_PROFILE_BYTES as u64 { + return Err(format!("the printer profile {shown} is too large")); + } + std::fs::read(path).map_err(|e| format!("can't read the printer profile {shown}: {e}")) +} + +/// A print job's colour, ready to apply: the conversion built once and what the PDF needs to know. +#[derive(Debug)] +pub struct PrintColorJob { + pub settings: PrintColorSettings, + transform: PrintTransform, + /// The printing condition named in the PDF (only for a printer profile). + intent: Option, +} + +impl PrintColorJob { + /// Build the job's conversion. Errors name the profile and what's wrong with it. + pub fn new(settings: &PrintColorSettings) -> Result { + let s = settings.clone(); + let color = PrintColor { + intent: match s.intent { + PrintIntent::Perceptual => ProofIntent::Perceptual, + PrintIntent::Relative => ProofIntent::Relative, + }, + black_point_compensation: s.black_point_compensation, + brightness: s.brightness, + contrast: s.contrast, + }; + let (target, name) = match &s.profile { + PrintProfile::Managed { space } => { + (PrintTarget::Managed(if *space == ManagedSpace::AdobeRgb { NamedSpace::AdobeRgb } else { NamedSpace::Srgb }), None) + } + PrintProfile::Icc { path } => { + let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + (PrintTarget::Profile(Arc::new(read_profile(path)?)), Some(name)) + } + }; + let transform = PrintTransform::new(&target, &color).map_err(|e| match &name { + Some(n) => format!("the printer profile {n}: {e}"), + None => e, + })?; + let intent = name.map(|file| OutputIntent { + icc: Arc::clone(transform.icc()), + channels: transform.channels() as u8, + condition: transform.description().map(str::to_owned).unwrap_or(file), + }); + Ok(PrintColorJob { settings: s, transform, intent }) + } + + /// Sharpen one photo for the paper, at the size it prints (before it goes on the page). + pub fn sharpen_photo(&self, photo: &mut DeepImage) { + let amount = match self.settings.sharpen { + PrintSharpen::Off => return, + PrintSharpen::Low => SharpenAmount::Low, + PrintSharpen::Standard => SharpenAmount::Standard, + PrintSharpen::High => SharpenAmount::High, + }; + let target = match self.settings.media { + PrintMedia::Matte => SharpenFor::Matte, + PrintMedia::Glossy => SharpenFor::Glossy, + }; + output_sharpen_deep(photo, target, amount); + } + + /// Convert a composed page (16-bit RGB, `Rec2020` output encoding) to the PDF image that covers + /// it, in the target's colours. + pub fn page_image(&self, width: u32, height: u32, rgb: &[u16]) -> Result { + let want = u64::from(width).checked_mul(u64::from(height)).and_then(|p| p.checked_mul(3)); + if width == 0 || height == 0 || want != Some(rgb.len() as u64) { + return Err(format!("a page of {width} × {height} pixels can't be made from {} samples", rgb.len())); + } + let device = self.transform.apply(rgb)?; + let channels = self.transform.channels() as u8; + let icc = Some(Arc::clone(self.transform.icc())); + Ok(if self.settings.sixteen_bit { + PdfImage::from_u16(width, height, channels, &device, icc) + } else { + let samples = device.iter().map(|&v| ((u32::from(v) * 255 + 32767) / 65535) as u8).collect(); + PdfImage { width, height, channels, data: ImageData::Raw { bits: 8, samples }, icc } + }) + } + + /// The print-ready document: each page image covers its page (`size` in points). + pub fn pdf(&self, pages: Vec<((f64, f64), PdfImage)>, title: &str) -> Result, String> { + let pages: Vec = pages + .into_iter() + .map(|((w, h), image)| PdfPage { width: w, height: h, images: vec![(image, Placement { x: 0.0, y: 0.0, width: w, height: h })] }) + .collect(); + let title = (!title.trim().is_empty()).then(|| title.to_owned()); + crate::pdf::write(&pages, &PdfInfo { title, output_intent: self.intent.clone() }) + } + + /// Samples per pixel the pages are converted to (3 RGB, 4 CMYK, 1 grey). + pub fn channels(&self) -> usize { + self.transform.channels() + } + + /// The profile's description (or file name) for the job's summary. + pub fn profile_name(&self) -> Option<&str> { + self.intent.as_ref().map(|i| i.condition.as_str()).or(self.transform.description()) + } +} + +/// A photo rendered for the page as the layout gets it from the export renderer. +pub fn is_print_ready(photo: &DeepImage) -> bool { + photo.space == OutputSpace::Rec2020 && matches!(photo.samples, DeepSamples::U16(_)) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_codecs::print::print_adjust; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("lc-print-color-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// A small, flat page of one 16-bit colour. + fn page(w: u32, h: u32, v: [u16; 3]) -> Vec { + (0..w * h).flat_map(|_| v).collect() + } + + #[test] + fn settings_default_and_round_trip() { + let d = PrintColorSettings::default(); + assert_eq!(d.profile, PrintProfile::Managed { space: ManagedSpace::Srgb }); + assert!(d.black_point_compensation && d.sixteen_bit); + let s = PrintColorSettings { + profile: PrintProfile::Icc { path: "/Library/ColorSync/Profiles/Paper.icc".into() }, + intent: PrintIntent::Relative, + brightness: 12.0, + sharpen: PrintSharpen::High, + media: PrintMedia::Matte, + ..d.clone() + }; + let json = serde_json::to_value(&s).unwrap(); + assert_eq!(json["profile"]["kind"], "icc"); + assert_eq!(json["intent"], "relative"); + assert_eq!(serde_json::from_value::(json).unwrap(), s); + // partial and unknown-free JSON fills in the defaults + let partial: PrintColorSettings = serde_json::from_str(r#"{"intent":"relative"}"#).unwrap(); + assert_eq!(partial, PrintColorSettings { intent: PrintIntent::Relative, ..d }); + assert!(serde_json::from_str::(r#"{"profile":{"kind":"cmyk"}}"#).is_err()); + } + + #[test] + fn managed_srgb_page_becomes_a_16_bit_srgb_pdf_without_an_output_intent() { + let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap(); + let img = job.page_image(4, 2, &page(4, 2, [65535, 65535, 65535])).unwrap(); + assert_eq!((img.channels, img.width, img.height), (3, 4, 2)); + let ImageData::Raw { bits: 16, samples } = &img.data else { panic!("16-bit samples") }; + assert!(samples.chunks(2).all(|b| u16::from_be_bytes([b[0], b[1]]) > 65400), "white stays white"); + let pdf = job.pdf(vec![((288.0, 144.0), img)], "Prints").unwrap(); + let s = String::from_utf8_lossy(&pdf); + assert!(s.contains("/ICCBased") && s.contains("/BitsPerComponent 16") && s.contains("/MediaBox [0 0 288 144]")); + assert!(!s.contains("/OutputIntents"), "the printer converts: no output intent"); + assert!(s.contains("/Title (Prints)")); + } + + #[test] + fn a_printer_profile_converts_and_becomes_the_output_intent() { + let dir = scratch("icc"); + let path = dir.join("Studio Glossy.icc"); + std::fs::write(&path, lightcraft_codecs::icc::write_named(NamedSpace::AdobeRgb)).unwrap(); + let settings = + PrintColorSettings { profile: PrintProfile::Icc { path }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() }; + let job = PrintColorJob::new(&settings).unwrap(); + assert_eq!(job.channels(), 3); + let img = job.page_image(2, 2, &page(2, 2, [0, 65535, 0])).unwrap(); + assert!(matches!(&img.data, ImageData::Raw { bits: 8, .. })); + let pdf = job.pdf(vec![((100.0, 100.0), img)], "").unwrap(); + let s = String::from_utf8_lossy(&pdf); + assert!(s.contains("/OutputIntents") && s.contains("/BitsPerComponent 8"), "{}", &s[..s.len().min(600)]); + assert!(!s.contains("/Title"), "an empty title is left out"); + assert!(job.profile_name().is_some()); + } + + #[test] + fn profile_errors_name_the_file() { + let dir = scratch("bad"); + let garbage = dir.join("Broken Paper.icc"); + std::fs::write(&garbage, [0u8; 300]).unwrap(); + let err = PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: garbage }, ..Default::default() }).unwrap_err(); + assert!(err.contains("Broken Paper"), "{err}"); + let missing = + PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("nope.icc") }, ..Default::default() }).unwrap_err(); + assert!(missing.contains("nope.icc"), "{missing}"); + } + + #[test] + fn print_adjustment_reaches_the_page() { + let mid = [32768u16; 3]; + let plain = PrintColorJob::new(&PrintColorSettings::default()).unwrap().page_image(1, 1, &mid).unwrap(); + let bright = PrintColorJob::new(&PrintColorSettings { brightness: 60.0, ..Default::default() }).unwrap().page_image(1, 1, &mid).unwrap(); + let first = |i: &PdfImage| match &i.data { + ImageData::Raw { samples, .. } => u16::from_be_bytes([samples[0], samples[1]]), + ImageData::Jpeg(_) => 0, + }; + assert!(first(&bright) > first(&plain) + 1500, "{} vs {}", first(&bright), first(&plain)); + assert!(print_adjust(0.5, 60.0, 0.0) > 0.5); + } + + #[test] + fn print_sharpening_follows_the_setting() { + // a hard vertical edge in a 16-bit Rec2020 photo + let (w, h) = (8usize, 4usize); + let samples: Vec = (0..w * h).flat_map(|i| if i % w < w / 2 { [20000u16; 3] } else { [40000u16; 3] }).collect(); + let photo = DeepImage { width: w, height: h, space: OutputSpace::Rec2020, samples: DeepSamples::U16(samples) }; + assert!(is_print_ready(&photo)); + let sharpened = |s: PrintSharpen| { + let job = PrintColorJob::new(&PrintColorSettings { sharpen: s, media: PrintMedia::Matte, ..Default::default() }).unwrap(); + let mut p = photo.clone(); + job.sharpen_photo(&mut p); + p + }; + assert_eq!(sharpened(PrintSharpen::Off), photo); + let edge = |p: &DeepImage| match &p.samples { + DeepSamples::U16(v) => (v[(w / 2 - 1) * 3], v[(w / 2) * 3]), + DeepSamples::F32(_) => (0, 0), + }; + let (dark, light) = edge(&sharpened(PrintSharpen::High)); + assert!(dark < 20000 && light > 40000, "the edge gets more contrast: {dark} {light}"); + } + + #[test] + fn hostile_pages_are_errors() { + let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap(); + assert!(job.page_image(0, 1, &[]).is_err()); + assert!(job.page_image(2, 2, &[0; 11]).is_err()); + assert!(job.page_image(u32::MAX, u32::MAX, &[0; 3]).is_err()); + let img = job.page_image(1, 1, &[0; 3]).unwrap(); + assert!(job.pdf(vec![((f64::NAN, 10.0), img)], "x").is_err()); + assert!(job.pdf(vec![], "x").is_err()); + } +} From 65775b47160a24e350bcaabee0f44a9143f9826d Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Mon, 12 Oct 2026 14:50:12 +0530 Subject: [PATCH 16/52] LRC-PRINT-JOB: colour-managed print PDFs (printer profiles, intent, black point compensation, print adjustment, print sharpening) print.exportPdf now converts each composed page to the job's colour target and writes it into a print-ready PDF: - PrintJob gains `color` (PrintColorSettings): Managed by Printer (sRGB / Adobe RGB, profile embedded, no output intent) or a printer / paper .icc / .icm converted here and named as the document's output intent; perceptual or relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte or Glossy; 16 or 8 bits per component. print.set / print.get and print templates carry it; out-of-range values are refused when set. - Each photo is sharpened for the paper right after it renders at its print size. - print/output.rs converts and Flate-compresses one page at a time (memory holds one page's device samples) into ICC-tagged images through engine::pdf; the PDF is 1.6. The 256 MiB cap counts the compressed pages. - A printer profile that can't be used (missing, too large, not a profile) fails the print before anything renders, naming the file. - contact_sheet's PDF writer goes back to private: only the contact sheet uses it. Tests: a printer profile reaches the PDF as ICCBased images plus an output intent, a junk profile fails naming the file, colour settings validate with the job, sharpening and media round-trip through print.set / print.get, a missing profile fails before rendering with no activity row; the existing print and CLI tests follow the PDF 1.6 output. Docs: LRC-PRINT-JOB and gap 15. --- crates/engine/src/cmd/print.rs | 4 +- crates/engine/src/contact_sheet.rs | 10 ++--- crates/engine/src/pdf.rs | 40 +++++++++++++++++-- crates/engine/src/print/compose.rs | 19 +++++++-- crates/engine/src/print/mod.rs | 26 +++++++----- crates/engine/src/print/output.rs | 63 ++++++++++++++++++++++-------- crates/engine/src/print_color.rs | 59 ++++++++++++++++++++++------ crates/engine/src/tests_print.rs | 27 +++++++++++++ docs/gaps.md | 5 ++- docs/parity-checklist.md | 5 ++- 10 files changed, 203 insertions(+), 55 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 4fa77df86..b9033ad05 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -247,6 +247,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { s.check_write_target(path).map_err(|e| bad(ID, e))?; let ids = s.targets(p); let settings = s.print.clone(); + // a printer profile that can't be used fails the print before anything renders + crate::print_color::PrintColorJob::new(&settings.job.color).map_err(|e| bad(ID, e))?; let title = std::path::Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string(); let work = crate::print::compose::prepare(s, &settings, &ids).map_err(|e| bad(ID, e))?; let total = work.len() as u64; @@ -322,7 +324,7 @@ pub fn specs() -> Vec { "{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches", always, get), cmd!(query "print.set", "Set Print Settings", [], None, - "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get", + "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get", always, set), cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset), cmd!(query "print.cell.add", "Add Print Cell", [], None, diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs index af4228e67..e83ad8c67 100644 --- a/crates/engine/src/contact_sheet.rs +++ b/crates/engine/src/contact_sheet.rs @@ -197,14 +197,14 @@ fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> } // Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets. -pub(crate) struct Pdf { - pub(crate) bytes: Vec, +struct Pdf { + bytes: Vec, offsets: Vec, points: (f64, f64), } impl Pdf { - pub(crate) fn new(pages: usize, points: (f64, f64)) -> Self { + fn new(pages: usize, points: (f64, f64)) -> Self { let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points }; p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>"); let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::>().join(" "); @@ -226,7 +226,7 @@ impl Pdf { self.object(id, &body); } - pub(crate) fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) { + fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) { let id = 3 + index * 3; let (w, h) = self.points; self.object( @@ -246,7 +246,7 @@ impl Pdf { ); } - pub(crate) fn finish(mut self) -> Vec { + fn finish(mut self) -> Vec { let start = self.bytes.len(); self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes()); for offset in self.offsets.iter().skip(1) { diff --git a/crates/engine/src/pdf.rs b/crates/engine/src/pdf.rs index 29f0b73f7..abce4061e 100644 --- a/crates/engine/src/pdf.rs +++ b/crates/engine/src/pdf.rs @@ -31,6 +31,8 @@ pub enum ImageData { Raw { bits: u8, samples: Vec }, /// A baseline JPEG stream, copied into the file as it is. Jpeg(Vec), + /// Samples already compressed with zlib (see [`PdfImage::deflated`]), `bits` per component. + Flate { bits: u8, data: Vec }, } /// A raster image and the colour space its samples are in. @@ -52,6 +54,17 @@ impl PdfImage { PdfImage { width, height, channels, data: ImageData::Raw { bits: 16, samples: bytes }, icc } } + /// The same image with its samples compressed now (checked first), so a long print doesn't + /// hold every page's uncompressed samples until the file is written. + pub fn deflated(self) -> Result { + self.check()?; + let data = match self.data { + ImageData::Raw { bits, samples } => ImageData::Flate { bits, data: deflate(&samples) }, + other => other, + }; + Ok(PdfImage { data, ..self }) + } + fn check(&self) -> Result<(), String> { if self.width == 0 || self.height == 0 || self.width > MAX_IMAGE_SIDE || self.height > MAX_IMAGE_SIDE { return Err(format!("an image of {} × {} pixels can't be written", self.width, self.height)); @@ -74,6 +87,11 @@ impl PdfImage { return Err("the image isn't a JPEG stream".into()); } } + ImageData::Flate { bits, data } => { + if !matches!(bits, 8 | 16) || data.is_empty() { + return Err("a compressed image has 8 or 16 bits per component and some data".into()); + } + } } Ok(()) } @@ -253,9 +271,10 @@ pub fn write(pages: &[PdfPage], info: &PdfInfo) -> Result, String> { }, }; let id = w.reserve(); - let (bits, filter, data) = match &image.data { - ImageData::Raw { bits, samples } => (*bits, "/FlateDecode", deflate(samples)), - ImageData::Jpeg(jpeg) => (8, "/DCTDecode", jpeg.clone()), + let (bits, filter, data): (u8, &str, std::borrow::Cow<[u8]>) = match &image.data { + ImageData::Raw { bits, samples } => (*bits, "/FlateDecode", deflate(samples).into()), + ImageData::Flate { bits, data } => (*bits, "/FlateDecode", data.as_slice().into()), + ImageData::Jpeg(jpeg) => (8, "/DCTDecode", jpeg.as_slice().into()), }; w.object( id, @@ -444,6 +463,21 @@ mod tests { assert_eq!(dicts[2].1, jpeg, "JPEG streams are copied as they are"); } + #[test] + fn compressing_early_writes_the_same_file() { + let samples: Vec = (0..4 * 3 * 3).map(|i| (i * 977) as u16).collect(); + let raw = PdfImage::from_u16(4, 3, 3, &samples, Some(srgb())); + let early = raw.clone().deflated().unwrap(); + assert!(matches!(early.data, ImageData::Flate { bits: 16, .. })); + let at = full(100.0, 75.0); + let a = write(&[page(vec![(raw, at)])], &PdfInfo::default()).unwrap(); + let b = write(&[page(vec![(early, at)])], &PdfInfo::default()).unwrap(); + assert_eq!(a, b); + let mut short = PdfImage::from_u16(4, 3, 3, &samples, None); + short.width = 5; + assert!(short.deflated().is_err(), "checked before compressing"); + } + #[test] fn hostile_documents_are_errors() { let ok = || PdfImage::from_u16(1, 1, 3, &[1, 2, 3], None); diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs index 58b29b0d9..91e5db69e 100644 --- a/crates/engine/src/print/compose.rs +++ b/crates/engine/src/print/compose.rs @@ -402,6 +402,8 @@ pub struct Prepared { ppi: f32, background: [u16; 3], style: CellStyle, + /// Print sharpening, applied to each photo once it's rendered at its print size. + color: crate::print_color::PrintColorSettings, } impl Prepared { @@ -437,7 +439,7 @@ impl Prepared { let at = match at { Some(at) => at, None => { - let img = render(item.job).map_err(|e| format!("{}: {e}", item.name))?; + let img = render(item.job, &self.color).map_err(|e| format!("{}: {e}", item.name))?; if cache.len() >= RENDER_CACHE { cache.remove(0); } @@ -457,9 +459,11 @@ impl Prepared { } } -fn render(job: RenderJob) -> Result { +fn render(job: RenderJob, color: &crate::print_color::PrintColorSettings) -> Result { let rendered = job.run().rendered?; - let deep = rendered.deep.ok_or("the render has no 16-bit samples")?; + let mut deep = rendered.deep.ok_or("the render has no 16-bit samples")?; + // print sharpening for the paper, at the size the photo prints + color.sharpen_photo(&mut deep); let DeepSamples::U16(rgb) = deep.samples else { return Err("the render is not 16-bit".into()) }; if deep.space != OutputSpace::Rec2020 || rgb.len() != deep.width.saturating_mul(deep.height).saturating_mul(3) { return Err("the render is not Rec. 2020 RGB".into()); @@ -498,7 +502,14 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI items.push(Item { page, name, cell, key: (id.0, rw, rh), job }); } } - Ok(Prepared { pages: vec![(pw, ph); plan.len()], items, ppi: ppi as f32, background: rec2020_from_srgb8(settings.page.background), style }) + Ok(Prepared { + pages: vec![(pw, ph); plan.len()], + items, + ppi: ppi as f32, + background: rec2020_from_srgb8(settings.page.background), + style, + color: settings.job.color.clone(), + }) } #[cfg(test)] diff --git a/crates/engine/src/print/mod.rs b/crates/engine/src/print/mod.rs index a9ca3e35f..c984cbf54 100644 --- a/crates/engine/src/print/mod.rs +++ b/crates/engine/src/print/mod.rs @@ -31,17 +31,20 @@ pub struct PageRaster { pub rgb: Vec, } -/// How a print job is produced (Lightroom's Print Job panel; colour management joins it later). +/// How a print job is produced (Lightroom's Print Job panel): resolution, and the colour +/// management, print adjustment, print sharpening and depth of [`crate::print_color`]. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase", deny_unknown_fields)] pub struct PrintJob { /// Print resolution in pixels per inch, 72 to 720. pub ppi: f32, + /// Profile, rendering intent, black point compensation, print adjustment, print sharpening. + pub color: crate::print_color::PrintColorSettings, } impl Default for PrintJob { fn default() -> Self { - Self { ppi: 240.0 } + Self { ppi: 240.0, color: crate::print_color::PrintColorSettings::default() } } } @@ -54,11 +57,10 @@ impl PrintJob { /// Rejects a resolution outside the supported range (commands and templates, which should /// say so rather than silently clamp). pub fn validate(&self) -> Result<(), String> { - if self.ppi.is_finite() && (MIN_PPI..=MAX_PPI).contains(&self.ppi) { - Ok(()) - } else { - Err(format!("ppi must be a number from {MIN_PPI} to {MAX_PPI}")) + if !(self.ppi.is_finite() && (MIN_PPI..=MAX_PPI).contains(&self.ppi)) { + return Err(format!("ppi must be a number from {MIN_PPI} to {MAX_PPI}")); } + self.color.validate() } } @@ -70,14 +72,20 @@ mod tests { fn ppi_is_clamped_and_validated() { assert_eq!(PrintJob::default().effective_ppi(), 240.0); for (v, e) in [(10.0, 72.0), (5000.0, 720.0), (f32::NAN, 240.0), (f32::INFINITY, 240.0), (300.0, 300.0)] { - assert_eq!(PrintJob { ppi: v }.effective_ppi(), e); + assert_eq!(PrintJob { ppi: v, ..Default::default() }.effective_ppi(), e); } - assert!(PrintJob { ppi: 300.0 }.validate().is_ok()); + assert!(PrintJob { ppi: 300.0, ..Default::default() }.validate().is_ok()); for bad in [0.0, 71.0, 721.0, f32::NAN, f32::NEG_INFINITY] { - assert!(PrintJob { ppi: bad }.validate().is_err()); + assert!(PrintJob { ppi: bad, ..Default::default() }.validate().is_err()); } let j: PrintJob = serde_json::from_str("{}").unwrap(); assert_eq!(j, PrintJob::default()); assert!(serde_json::from_str::(r#"{"dpi": 1}"#).is_err()); + // the colour settings ride along, defaults filled in, and are validated with the job + let j: PrintJob = serde_json::from_str(r#"{"ppi": 300, "color": {"intent": "relative", "brightness": 20}}"#).unwrap(); + assert_eq!(j.color.intent, crate::print_color::PrintIntent::Relative); + assert!(j.color.black_point_compensation && j.validate().is_ok()); + let j: PrintJob = serde_json::from_str(r#"{"color": {"contrast": 500}}"#).unwrap(); + assert!(j.validate().is_err()); } } diff --git a/crates/engine/src/print/output.rs b/crates/engine/src/print/output.rs index 937884a8e..e1c69e373 100644 --- a/crates/engine/src/print/output.rs +++ b/crates/engine/src/print/output.rs @@ -1,10 +1,11 @@ -//! Writes composed pages as a PDF. -//! -//! Interim writer: each page is converted from Rec. 2020 to 8-bit sRGB and embedded as a JPEG -//! covering the paper. Colour-managed output replaces the body of [`write`], not its signature. +//! Writes composed pages as a print-ready PDF: each page is converted to the job's colour target +//! (a printer / paper profile, or sRGB / Adobe RGB when the printer manages colour) with its +//! intent, black point compensation and print adjustment ([`crate::print_color`]), then embedded +//! covering the paper as an ICC-tagged, losslessly compressed 16-bit (or 8-bit) image. A printer +//! profile is also named as the document's output intent ([`crate::pdf`]). use super::{MAX_JOB_PAGES, PageRaster, PrintJob}; -use crate::contact_sheet::Pdf; -use crate::export::{ExportFormat, ExportOptions, encode_image}; +use crate::pdf::ImageData; +use crate::print_color::PrintColorJob; use lightcraft_color::transfer::encode_srgb8; use lightcraft_color::{REC2020, SRGB}; use lightcraft_pipeline::OutputSpace; @@ -34,9 +35,9 @@ pub fn page_to_srgb8(page: &PageRaster) -> Result { } /// The PDF for `pages` (all the same size): one page per raster, its MediaBox the paper size in -/// points (`pixels / ppi * 72`) with the image covering it. +/// points (`pixels / ppi * 72`) with the image covering it. Each page is converted and compressed +/// in turn, so memory holds one page's device samples at a time. pub fn write(pages: &[PageRaster], job: &PrintJob, title: &str) -> Result, String> { - let _ = (job, title); let first = pages.first().ok_or("nothing to print")?; if pages.len() > MAX_JOB_PAGES { return Err(format!("a print is limited to {MAX_JOB_PAGES} pages")); @@ -46,19 +47,23 @@ pub fn write(pages: &[PageRaster], job: &PrintJob, title: &str) -> Result MAX_PDF_BYTES { - return Err("the PDF would exceed 256 MiB; lower the print resolution or print fewer pages".into()); + let image = color.page_image(page.width, page.height, &page.rgb)?.deflated()?; + if let ImageData::Flate { data, .. } = &image.data { + bytes = bytes.saturating_add(data.len()); } - pdf.page(i, &jpg, page.width as usize, page.height as usize); + if bytes > MAX_PDF_BYTES { + return Err("the PDF would exceed 256 MiB; lower the print resolution, use 8 bits or print fewer pages".into()); + } + out.push((size, image)); } - Ok(pdf.finish()) + color.pdf(out, title) } #[cfg(test)] @@ -94,6 +99,8 @@ mod tests { let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); assert_eq!((nums[2], nums[3]), (306.0, 396.0), "4.25 x 5.5 in is 306 x 396 pt"); assert!(text.contains("/Width 425 /Height 550")); + // managed by the printer: sRGB, 16 bits, tagged, no output intent + assert!(text.contains("/ICCBased") && text.contains("/BitsPerComponent 16") && !text.contains("/OutputIntents")); assert!(write(&[], &PrintJob::default(), "t").is_err()); let mixed = [raster(10, 10, [0; 3]), raster(10, 12, [0; 3])]; assert!(write(&mixed, &PrintJob::default(), "t").is_err()); @@ -102,4 +109,28 @@ mod tests { let zero = PageRaster { ppi: 0.0, ..raster(4, 4, [0; 3]) }; assert!(write(&[zero], &PrintJob::default(), "t").is_err()); } + + #[test] + fn a_printer_profile_is_converted_to_and_named_as_the_output_intent() { + use crate::print_color::{PrintColorSettings, PrintIntent, PrintProfile}; + let dir = std::env::temp_dir().join(format!("lc-print-output-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("Lustre Paper.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap(); + let color = + PrintColorSettings { profile: PrintProfile::Icc { path: icc }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() }; + let job = PrintJob { color, ..Default::default() }; + let bytes = write(&[raster(20, 30, [200, 30, 30])], &job, "Lustre").unwrap(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/OutputIntents") && text.contains("/DestOutputProfile"), "{}", &text[..text.len().min(500)]); + assert!(text.contains("/BitsPerComponent 8") && text.contains("/Title (Lustre)")); + // a profile that isn't one fails the print and says which file + std::fs::write(dir.join("Junk.icc"), [7u8; 200]).unwrap(); + let junk = PrintJob { + color: PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("Junk.icc") }, ..Default::default() }, + ..Default::default() + }; + let err = write(&[raster(2, 2, [0; 3])], &junk, "t").unwrap_err(); + assert!(err.contains("Junk"), "{err}"); + } } diff --git a/crates/engine/src/print_color.rs b/crates/engine/src/print_color.rs index 540e48952..c897f8990 100644 --- a/crates/engine/src/print_color.rs +++ b/crates/engine/src/print_color.rs @@ -110,6 +110,39 @@ impl Default for PrintColorSettings { } } +impl PrintColorSettings { + /// Refuse settings a command or template shouldn't carry (out-of-range adjustments, an empty + /// profile path); whether the profile file is usable is checked when the job is built. + pub fn validate(&self) -> Result<(), String> { + for (name, v) in [("brightness", self.brightness), ("contrast", self.contrast)] { + if !(v.is_finite() && (-100.0..=100.0).contains(&v)) { + return Err(format!("{name} must be a number from -100 to 100")); + } + } + if let PrintProfile::Icc { path } = &self.profile + && path.as_os_str().is_empty() + { + return Err("the printer profile needs a path".into()); + } + Ok(()) + } + + /// Sharpen one photo for the paper, at the size it prints (before it goes on the page). + pub fn sharpen_photo(&self, photo: &mut DeepImage) { + let amount = match self.sharpen { + PrintSharpen::Off => return, + PrintSharpen::Low => SharpenAmount::Low, + PrintSharpen::Standard => SharpenAmount::Standard, + PrintSharpen::High => SharpenAmount::High, + }; + let target = match self.media { + PrintMedia::Matte => SharpenFor::Matte, + PrintMedia::Glossy => SharpenFor::Glossy, + }; + output_sharpen_deep(photo, target, amount); + } +} + /// Read a printer profile, refusing files that can't be one (the parse happens later). fn read_profile(path: &Path) -> Result, String> { let shown = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()); @@ -163,19 +196,9 @@ impl PrintColorJob { Ok(PrintColorJob { settings: s, transform, intent }) } - /// Sharpen one photo for the paper, at the size it prints (before it goes on the page). + /// Sharpen one photo for the paper ([`PrintColorSettings::sharpen_photo`]). pub fn sharpen_photo(&self, photo: &mut DeepImage) { - let amount = match self.settings.sharpen { - PrintSharpen::Off => return, - PrintSharpen::Low => SharpenAmount::Low, - PrintSharpen::Standard => SharpenAmount::Standard, - PrintSharpen::High => SharpenAmount::High, - }; - let target = match self.settings.media { - PrintMedia::Matte => SharpenFor::Matte, - PrintMedia::Glossy => SharpenFor::Glossy, - }; - output_sharpen_deep(photo, target, amount); + self.settings.sharpen_photo(photo); } /// Convert a composed page (16-bit RGB, `Rec2020` output encoding) to the PDF image that covers @@ -312,7 +335,7 @@ mod tests { let bright = PrintColorJob::new(&PrintColorSettings { brightness: 60.0, ..Default::default() }).unwrap().page_image(1, 1, &mid).unwrap(); let first = |i: &PdfImage| match &i.data { ImageData::Raw { samples, .. } => u16::from_be_bytes([samples[0], samples[1]]), - ImageData::Jpeg(_) => 0, + ImageData::Jpeg(_) | ImageData::Flate { .. } => 0, }; assert!(first(&bright) > first(&plain) + 1500, "{} vs {}", first(&bright), first(&plain)); assert!(print_adjust(0.5, 60.0, 0.0) > 0.5); @@ -340,6 +363,16 @@ mod tests { assert!(dark < 20000 && light > 40000, "the edge gets more contrast: {dark} {light}"); } + #[test] + fn validation_refuses_out_of_range_settings() { + assert!(PrintColorSettings::default().validate().is_ok()); + for (b, c) in [(101.0, 0.0), (0.0, -100.5), (f32::NAN, 0.0), (0.0, f32::INFINITY)] { + assert!(PrintColorSettings { brightness: b, contrast: c, ..Default::default() }.validate().is_err(), "{b} {c}"); + } + assert!(PrintColorSettings { profile: PrintProfile::Icc { path: PathBuf::new() }, ..Default::default() }.validate().is_err()); + assert!(PrintColorSettings { brightness: -100.0, contrast: 100.0, ..Default::default() }.validate().is_ok()); + } + #[test] fn hostile_pages_are_errors() { let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap(); diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index eb7ab032c..5b890a7f0 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -213,6 +213,33 @@ fn export_pdf_prints_the_current_settings() { fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); } +#[test] +fn export_pdf_with_a_printer_profile() { + let mut s = Session::with_demo(); + let ids: Vec = s.catalog.photos().take(1).map(|p| p.id.0).collect(); + let dir = std::env::temp_dir().join(format!("lightcraft-print-icc-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("Fine Art Matte.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap(); + let color = json!({"profile": {"kind": "icc", "path": icc}, "intent": "relative", "sharpen": "high", "media": "matte", "brightness": 10}); + run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 1}, "page": {"paper": "4x6"}}, "job": {"ppi": 72.0, "color": color}}), + ); + assert_eq!(run(&mut s, "print.get", json!({}))["settings"]["job"]["color"]["media"], "matte"); + let path = temp_pdf("icc"); + run(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); + let text = String::from_utf8_lossy(&std::fs::read(&path).unwrap()).into_owned(); + assert!(text.contains("/OutputIntents") && text.contains("/ICCBased") && text.contains("/BitsPerComponent 16")); + // out-of-range adjustments are refused when set; a missing profile fails before rendering + fails(&mut s, "print.set", json!({"job": {"color": {"contrast": 150}}})); + run(&mut s, "print.set", json!({"job": {"color": {"profile": {"kind": "icc", "path": dir.join("gone.icc")}}}})); + let err = fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); + assert!(err.contains("gone.icc"), "{err}"); + assert!(s.activity.list().is_empty(), "nothing started"); +} + #[test] fn custom_package_prints_each_cells_photo() { let mut s = Session::with_demo(); diff --git a/docs/gaps.md b/docs/gaps.md index f24cbf5bc..8bea73112 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 15: print layout model; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 15: printer colour management; gap 15: print layout model; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -29,7 +29,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); HLG; the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read) | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | -| 15 | **Print module**: printer colour management (profile, rendering intent, black point compensation, print adjustment and sharpening), identity plate / watermark / page numbers / crop marks / photo info overlays, Custom Package Auto Layout, printer-driver Page Setup and direct printing (the Print dialog writes a PDF) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | +| 15 | **Print module**: identity plate / watermark / page numbers / crop marks / photo info overlays, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 15: printer colour management lands (printer / paper ICC profiles or Managed by Printer, intent, black point compensation, print adjustment, print sharpening, 16-bit ICC-tagged PDF with an output intent); page overlays and direct printing remain | | 2026-10-12 | minor | Gap 15 shrinks: File → Print… is a Print dialog with the four layout styles, cell editing, image settings, guides, page setup and templates; printer colour management, page overlays and direct printing remain | | 2026-10-12 | minor | Gap 15: the Print module's layout styles, cells, image settings, templates and PDF are `print.*` commands (CLI, control, MCP); the Print view, printer colour management and page overlays remain | | 2026-10-12 | minor | Gap 15: print pages compose in the engine (fit / Zoom to Fill, Rotate to Fit, border, stroke, background) with an interim PDF writer; commands and UI still to land | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4edb4eff5..010506b9f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-GUIDES and the other print rows: the Print dialog (File → Print…); LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-CELLS, LRC-PRINT-TEMPLATES and the other print rows: `print.*` commands; LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-JOB: print colour management and ICC-tagged print PDFs; LRC-PRINT-GUIDES and the other print rows: the Print dialog (File → Print…); LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-CELLS, LRC-PRINT-TEMPLATES and the other print rows: `print.*` commands; LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -753,7 +753,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview | | LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | | LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; filename captions in the contact-sheet PDF; no identity plate, watermark, page numbers, crop marks or photo info | -| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution | +| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode | | LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | | LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-PRINT-JOB: print colour management (printer / paper ICC profiles or Managed by Printer, intent, black point compensation, print adjustment, print sharpening) and a 16-bit ICC-tagged PDF with an output intent; still 🟡 (no OS print dialog or JPEG output) | | 2026-10-12 | minor | LRC-PRINT-GUIDES, LRC-PRINT-CELLS, LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS (✅), LRC-PRINT-LAYOUT, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES (🟡): File → Print… opens the Print dialog with a scaled page preview, rulers and guides, cells dragged and resized with snapping, image settings, page setup and templates, and prints to PDF in the background | | 2026-10-12 | minor | LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS, LRC-PRINT-LAYOUT, LRC-PRINT-CELLS, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES: the Print module is reachable as 18 `print.*` commands (settings, cells, Auto Layout, PDF, templates saved in prefs.json) from the CLI, control channel and MCP; 🟡 until the Print UI lands, LRC-PRINT-GUIDES stays ⬜ | | 2026-10-12 | minor | LRC-PRINT-IMAGESETTINGS: `print/compose.rs` composes pages (Zoom to Fill, Rotate to Fit, Photo Border, Inner Stroke, background) from the layout model into 16-bit Rec. 2020 `PageRaster`s, with an interim sRGB PDF writer in `print/output.rs`; rows stay until commands and UI reach them | From 5cbaf64579795c1605dea891ba898619dff1e24a Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Mon, 12 Oct 2026 15:39:08 +0530 Subject: [PATCH 17/52] LRC-PRINT-GUIDES: the Print preview stops re-rendering thumbnails every frame A thumbnail is kept at one size. The page preview asked for each cell's photo at the cell's size on every frame, and the library grid behind the dialog asked for its own size, so the two replaced each other's request: while the Print dialog was open a thumbnail render was always in flight and about two render jobs were rebuilt per frame, keeping a core busy. The preview now uses the photo's thumbnail when there is one and asks for one only when there is none. Regression test: with the dialog open in Contact Sheet, Picture Package (the same photo in cells of different sizes) and Single Image, the dialog settles, no render is in flight and no thumbnail job is rebuilt over 60 idle frames. Before the fix the job count rose by about 100 every 50 frames. The Print dialog's UI tests now finish in seconds (they were waiting out each settle timeout). --- crates/ui-egui/src/print_view.rs | 9 +++++++-- crates/ui-egui/src/tests_print.rs | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index 7c1ab85fb..245c126ae 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -729,8 +729,13 @@ fn paint_cell( } let clip = painter.with_clip_rect(avail.intersect(painter.clip_rect())); let tex = photo.and_then(|id| { - let px = (shown.width().max(shown.height()) * 2.0).clamp(64.0, 1024.0) as usize; - crate::panels::grid::request_thumb(app, id, px, 10); + // Use the library's thumbnail when there is one. A thumbnail is kept at one size, so asking + // for another size here every frame made the preview and the grid behind the dialog + // re-render each other's thumbnail for as long as the dialog stayed open. + if app.renderer.thumb(id).is_none() { + let px = (shown.width().max(shown.height()) * 2.0).clamp(64.0, 1024.0) as usize; + crate::panels::grid::request_thumb(app, id, px, 10); + } app.renderer.thumb(id).map(|tex| tex.tex.id()) }); match tex { diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs index 6dbea7a40..a75191d03 100644 --- a/crates/ui-egui/src/tests_print.rs +++ b/crates/ui-egui/src/tests_print.rs @@ -298,3 +298,26 @@ fn the_print_dialog_fits_the_window_with_its_buttons_on_screen() { } } } + +#[test] +fn the_print_preview_does_not_rebuild_thumbnails_every_frame() { + let mut h = demo(Services { png: None, ..Default::default() }); + let before = h.app.renderer.thumb_jobs_built; + cmd(&mut h, "dialog.print", json!({})); + for style in ["contactSheet", "picturePackage", "singleImage"] { + cmd(&mut h, "print.set", json!({"layout": {"style": style}})); + if style == "picturePackage" { + // the same photo in cells of different sizes + cmd(&mut h, "print.cell.add", json!({"width": 2.0, "height": 2.5})); + cmd(&mut h, "print.cell.add", json!({"width": 5.0, "height": 7.0})); + } + assert!(h.settle(Duration::from_secs(5)), "{style}: the dialog settles"); + let built = h.app.renderer.thumb_jobs_built; + for _ in 0..60 { + h.step(); + } + assert_eq!(h.app.renderer.thumb_jobs_built, built, "{style}: no thumbnail is rebuilt while nothing changes"); + assert_eq!(h.app.renderer.in_flight(), 0, "{style}"); + } + assert!(h.app.renderer.thumb_jobs_built - before < 50, "{} jobs", h.app.renderer.thumb_jobs_built - before); +} From 64e0395c956efd8d34736e3dde7f65a637b56a6d Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Mon, 12 Oct 2026 15:40:04 +0530 Subject: [PATCH 18/52] LRC-PRINT-JOB: Print Job colour controls in the Print dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Print dialog's Print Job panel, under Resolution: - Profile: Managed by Printer (sRGB), Managed by Printer (Adobe RGB), or Other… to pick a printer / paper .icc / .icm with the colour-profile file picker the soft proof uses; the picked profile's name is shown. - Intent: Perceptual / Relative; Black Point Compensation (enabled for Relative). - Print Adjustment: Brightness and Contrast, -100..100. - Sharpening: Off / Low / Standard / High; Media: Matte / Glossy (enabled while sharpening is on). - 16 bits per channel. - A summary line: PDF, profile, depth, current edits. Each control sends print.set {job: {color}}, so the dialog, CLI, control channel, MCP and print templates share the settings. UI test: every control reaches the job (the Print Job panel is scrolled into view first, as a person would), Other… picks a profile from the picker, and the PDF printed with it carries the output intent at 8 bits. --- crates/ui-egui/src/print_view.rs | 86 ++++++++++++++++++++++++++++++- crates/ui-egui/src/tests_print.rs | 57 ++++++++++++++++++++ docs/parity-checklist.md | 3 +- 3 files changed, 144 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index 245c126ae..b7f6d44aa 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -11,6 +11,7 @@ use lightcraft_engine::export::ExportOptions; use lightcraft_engine::print::compose::{self, place}; use lightcraft_engine::print::layout::{self, Snap, Style, Unit}; use lightcraft_engine::print::template::PrintSettings; +use lightcraft_engine::print_color::{ManagedSpace, PrintColorSettings, PrintIntent, PrintMedia, PrintProfile, PrintSharpen}; use serde_json::{Value, json}; use crate::LightcraftApp; @@ -428,7 +429,90 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, patch(app, &ctx, json!({"job": {"ppi": ppi}})); } }); - ui.label(egui::RichText::new(crate::i18n::tr("PDF · sRGB · current edits")).color(t.text_dim)); + print_job_color(app, ui, t, &st.job.color); +} + +/// Send one colour setting of the print job (the rest stay as they are). +fn patch_color(app: &mut LightcraftApp, ctx: &egui::Context, key: &str, value: Value) { + patch(app, ctx, json!({"job": {"color": {(key): value}}})); +} + +fn to_value(v: V) -> Value { + serde_json::to_value(v).unwrap_or(Value::Null) +} + +/// The Print Job panel's colour management, print adjustment, print sharpening and depth. +fn print_job_color(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, color: &PrintColorSettings) { + let ctx = ui.ctx().clone(); + let managed = |space| PrintProfile::Managed { space }; + let shown = match &color.profile { + PrintProfile::Managed { space: ManagedSpace::Srgb } => crate::i18n::tr("Managed by Printer (sRGB)").to_string(), + PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => crate::i18n::tr("Managed by Printer (Adobe RGB)").to_string(), + PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()), + }; + let mut pick = false; + row(ui, t, "Profile", |ui| { + let c = egui::ComboBox::from_id_salt("print-profile").width(190.0).selected_text(shown.clone()).show_ui(ui, |ui| { + for (space, label) in [(ManagedSpace::Srgb, "Managed by Printer (sRGB)"), (ManagedSpace::AdobeRgb, "Managed by Printer (Adobe RGB)")] { + let r = ui.selectable_label(color.profile == managed(space), crate::i18n::tr(label)); + register(ui.ctx(), format!("item:printProfile-{}", to_value(space).as_str().unwrap_or_default()), r.rect); + if r.clicked() && color.profile != managed(space) { + patch_color(app, &ctx, "profile", to_value(managed(space))); + } + } + if let PrintProfile::Icc { .. } = &color.profile { + let _ = ui.selectable_label(true, shown.as_str()); + } + if app.services.pick_display_profile.is_some() { + let r = ui.selectable_label(false, crate::i18n::tr("Other…")); + register(ui.ctx(), "item:printProfile-other", r.rect); + pick |= r.clicked(); + } + }); + register(ui.ctx(), "combo:printProfile", c.response.rect); + }); + if pick && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) { + patch_color(app, &ctx, "profile", json!({"kind": "icc", "path": path})); + } + row(ui, t, "Intent", |ui| { + if let Some(v) = choice(ui, "printIntent", &[(PrintIntent::Perceptual, "Perceptual"), (PrintIntent::Relative, "Relative")], color.intent) { + patch_color(app, &ctx, "intent", to_value(v)); + } + }); + ui.add_enabled_ui(color.intent == PrintIntent::Relative, |ui| { + if let Some(v) = check(ui, "printBpc", "Black Point Compensation", color.black_point_compensation) { + patch_color(app, &ctx, "blackPointCompensation", json!(v)); + } + }); + for (key, label, value) in [("brightness", "Brightness", color.brightness), ("contrast", "Contrast", color.contrast)] { + row(ui, t, label, |ui| { + let mut v = value; + let r = ui.add(egui::DragValue::new(&mut v).range(-100.0..=100.0).speed(0.5).max_decimals(0)); + register(ui.ctx(), format!("drag:print{label}"), r.rect); + if r.changed() { + patch_color(app, &ctx, key, json!(v)); + } + }); + } + row(ui, t, "Sharpening", |ui| { + let options = [(PrintSharpen::Off, "Off"), (PrintSharpen::Low, "Low"), (PrintSharpen::Standard, "Standard"), (PrintSharpen::High, "High")]; + if let Some(v) = choice(ui, "printSharpen", &options, color.sharpen) { + patch_color(app, &ctx, "sharpen", to_value(v)); + } + }); + ui.add_enabled_ui(color.sharpen != PrintSharpen::Off, |ui| { + row(ui, t, "Media", |ui| { + if let Some(v) = choice(ui, "printMedia", &[(PrintMedia::Matte, "Matte"), (PrintMedia::Glossy, "Glossy")], color.media) { + patch_color(app, &ctx, "media", to_value(v)); + } + }); + }); + if let Some(v) = check(ui, "print16bit", "16 bits per channel", color.sixteen_bit) { + patch_color(app, &ctx, "sixteenBit", json!(v)); + } + let bits = if color.sixteen_bit { "16-bit" } else { "8-bit" }; + let summary = format!("PDF · {shown} · {bits} · {}", crate::i18n::tr("current edits")); + ui.label(egui::RichText::new(summary).color(t.text_dim)); } fn trim(v: f64) -> String { diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs index a75191d03..29c214e47 100644 --- a/crates/ui-egui/src/tests_print.rs +++ b/crates/ui-egui/src/tests_print.rs @@ -299,6 +299,63 @@ fn the_print_dialog_fits_the_window_with_its_buttons_on_screen() { } } +#[test] +fn print_job_colour_controls_reach_the_job_and_the_pdf() { + use lightcraft_engine::print_color::{PrintIntent, PrintMedia, PrintProfile, PrintSharpen}; + let dir = std::env::temp_dir().join(format!("lightcraft-print-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("Baryta Paper.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap(); + let picked = icc.to_string_lossy().into_owned(); + let services = Services { png: None, pick_display_profile: Some(Box::new(move || vec![picked.clone()])), ..Default::default() }; + let mut h = demo(services); + open_print(&mut h); + // the Print Job panel is at the bottom of the settings column: scroll down to it, as a person would + let r = h.request("ui.hoverWidget", json!({"id": "combo:printPaper"}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..4 { + h.request("ui.scroll", json!({"dy": -600.0}), T); + h.settle(T); + } + let job = |h: &Headless| h.app.session.print.job.color.clone(); + assert_eq!(job(&h).intent, PrintIntent::Perceptual); + assert!(!has(&h, "item:printProfile-other"), "the menu is closed"); + + click(&mut h, "button:printIntent-1"); + assert_eq!(job(&h).intent, PrintIntent::Relative); + click(&mut h, "check:printBpc"); + assert!(!job(&h).black_point_compensation); + click(&mut h, "button:printSharpen-3"); + click(&mut h, "button:printMedia-0"); + assert_eq!((job(&h).sharpen, job(&h).media), (PrintSharpen::High, PrintMedia::Matte)); + click(&mut h, "check:print16bit"); + assert!(!job(&h).sixteen_bit); + drag(&mut h, "drag:printBrightness", 30.0, 0.0); + assert!(job(&h).brightness > 0.0, "{}", job(&h).brightness); + + // a printer profile from the picker + click(&mut h, "combo:printProfile"); + click(&mut h, "item:printProfile-other"); + assert_eq!(job(&h).profile, PrintProfile::Icc { path: icc.clone() }); + + // the PDF uses it: converted pages, the profile named as the output intent, 8 bits + cmd( + &mut h, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 1}, "page": {"paper": "4x6"}}, "job": {"ppi": 72.0}}), + ); + let ids: Vec = h.app.session.catalog.photos().take(1).map(|p| p.id.0).collect(); + let out = dir.join("colour.pdf"); + cmd(&mut h, "print.exportPdf", json!({"path": out, "ids": ids})); + let text = String::from_utf8_lossy(&std::fs::read(&out).unwrap()).into_owned(); + assert!(text.contains("/OutputIntents") && text.contains("/BitsPerComponent 8") && text.contains("/ICCBased")); + + // back to Managed by Printer + click(&mut h, "combo:printProfile"); + click(&mut h, "item:printProfile-adobeRgb"); + assert_eq!(job(&h).profile, PrintProfile::Managed { space: lightcraft_engine::print_color::ManagedSpace::AdobeRgb }); +} + #[test] fn the_print_preview_does_not_rebuild_thumbnails_every_frame() { let mut h = demo(Services { png: None, ..Default::default() }); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 010506b9f..2fe882e49 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -753,7 +753,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview | | LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | | LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; filename captions in the contact-sheet PDF; no identity plate, watermark, page numbers, crop marks or photo info | -| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode | +| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/ui-egui/src/print_view.rs`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | the Print dialog's Print Job panel and `print.set {job}`: print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode | | LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | | LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-PRINT-JOB: the Print dialog's Print Job panel sets the profile (Managed by Printer or a printer / paper profile picked from disk), intent, black point compensation, print adjustment, print sharpening and depth | | 2026-10-12 | minor | LRC-PRINT-JOB: print colour management (printer / paper ICC profiles or Managed by Printer, intent, black point compensation, print adjustment, print sharpening) and a 16-bit ICC-tagged PDF with an output intent; still 🟡 (no OS print dialog or JPEG output) | | 2026-10-12 | minor | LRC-PRINT-GUIDES, LRC-PRINT-CELLS, LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS (✅), LRC-PRINT-LAYOUT, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES (🟡): File → Print… opens the Print dialog with a scaled page preview, rulers and guides, cells dragged and resized with snapping, image settings, page setup and templates, and prints to PDF in the background | | 2026-10-12 | minor | LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS, LRC-PRINT-LAYOUT, LRC-PRINT-CELLS, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES: the Print module is reachable as 18 `print.*` commands (settings, cells, Auto Layout, PDF, templates saved in prefs.json) from the CLI, control channel and MCP; 🟡 until the Print UI lands, LRC-PRINT-GUIDES stays ⬜ | From 6a5158d4ed14962770b321cb9e494c99910f6dbd Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 13 Oct 2026 03:01:31 +1100 Subject: [PATCH 19/52] i18n: translate dialog notes, toasts and tooltips (Contact Sheet, HDR export, slideshow, auto advance, import tokens) Refs #911 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/locales/de-formats.json | 3 ++- crates/ui-egui/locales/de.json | 14 ++++++++++- crates/ui-egui/locales/es-formats.json | 3 ++- crates/ui-egui/locales/es.json | 14 ++++++++++- crates/ui-egui/locales/fr-formats.json | 3 ++- crates/ui-egui/locales/fr.json | 14 ++++++++++- crates/ui-egui/locales/ja-formats.json | 3 ++- crates/ui-egui/locales/ja.json | 14 ++++++++++- crates/ui-egui/locales/pt-br-formats.json | 3 ++- crates/ui-egui/locales/pt-br.json | 14 ++++++++++- crates/ui-egui/locales/ru-formats.json | 3 ++- crates/ui-egui/locales/ru.json | 14 ++++++++++- crates/ui-egui/locales/uk-formats.json | 3 ++- crates/ui-egui/locales/uk.json | 14 ++++++++++- crates/ui-egui/locales/zh-hans-formats.json | 3 ++- crates/ui-egui/locales/zh-hans.json | 14 ++++++++++- crates/ui-egui/locales/zh-hant-formats.json | 3 ++- crates/ui-egui/locales/zh-hant.json | 14 ++++++++++- crates/ui-egui/src/import.rs | 2 +- crates/ui-egui/src/lib.rs | 4 ++-- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 26 ++++++++++----------- crates/ui-egui/src/panels/faces.rs | 6 ++--- crates/ui-egui/src/panels/settings.rs | 5 +++- crates/ui-egui/src/shortcuts.rs | 2 +- 25 files changed, 160 insertions(+), 40 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 7bcaf9f20..780b6c307 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}", "{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt", "{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt", - "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt" + "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", + "{n} selected photos · {pages} pages": "{n} ausgewählte Fotos · {pages} Seiten" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..d700d6c71 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2109,5 +2109,17 @@ "Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…", "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)", "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.", - "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt" + "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", + "Contact Sheet PDF": "Kontaktabzug-PDF", + "Letter": "Letter", + "Filename captions": "Dateinamen als Bildunterschrift", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · aktuelle Bearbeitungen · eingepasst ohne Beschnitt", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Nach dem Bestätigen den Speicherort des PDFs wählen. Das PDF aus dem Anzeigeprogramm drucken.", + "HDR output": "HDR-Ausgabe", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "In HDR bearbeitete Fotos: JPEG mit HDR-Gain-Map (sieht überall richtig aus, hellere Lichter auf HDR-Bildschirmen), AVIF mit 10 Bit, PNG und JPEG XL mit 16 Bit Rec. 2020 PQ; TIFF braucht 32-Bit-Gleitkomma", + "HDR TIFF needs 32-bit float.": "HDR-TIFF braucht 32-Bit-Gleitkomma.", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Farbraum: Rec. 2020 PQ (HDR-AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Farbraum: Rec. 2020 PQ, 16 Bit (HDR-PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Farbraum: Rec. 2020 PQ (HDR-JPEG XL)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Programme, die den cICP-Block des PNGs nicht lesen, zeigen PQ-Werte als sRGB: flau und blass. Für ein Bild, das überall richtig aussieht, JPEG (Gain-Map) verwenden." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 3dd94a934..5dd49b996 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple", "{n} added to Apple Photos": "{n} añadidas a Fotos de Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", + "{n} selected photos · {pages} pages": "{n} fotos seleccionadas · {pages} páginas" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..7c5053cd7 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1951,5 +1951,17 @@ "Adding to Apple Photos…": "Añadiendo a Fotos de Apple…", "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.", - "Adding to Apple Photos": "Añadiendo a Fotos de Apple" + "Adding to Apple Photos": "Añadiendo a Fotos de Apple", + "Contact Sheet PDF": "PDF de hoja de contactos", + "Letter": "Carta", + "Filename captions": "Pies con el nombre de archivo", + "150 dpi · sRGB · current edits · fit without cropping": "150 ppp · sRGB · ediciones actuales · ajustado sin recortar", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Elige dónde guardar el PDF después de confirmar. Imprime el PDF desde tu visor.", + "HDR output": "Salida HDR", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Fotos editadas en HDR: JPEG con mapa de ganancia HDR (se ve bien en todas partes, con luces más brillantes en pantallas HDR), AVIF a 10 bits, PNG y JPEG XL a 16 bits Rec. 2020 PQ; TIFF necesita coma flotante de 32 bits", + "HDR TIFF needs 32-bit float.": "El TIFF HDR necesita coma flotante de 32 bits.", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Espacio de color: Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Espacio de color: Rec. 2020 PQ, 16 bits (PNG HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Espacio de color: Rec. 2020 PQ (JPEG XL HDR)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Los visores que no leen el bloque cICP del PNG muestran los valores PQ como sRGB: planos y desvaídos. Para una imagen que se vea bien en todas partes, usa JPEG (mapa de ganancia)." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 65cf97311..bb3e9d1c3 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}", "{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »", "{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos", - "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original" + "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", + "{n} selected photos · {pages} pages": "{n} photos sélectionnées · {pages} pages" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ffd..d8e5f634f 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1951,5 +1951,17 @@ "Adding to Apple Photos…": "Ajout à Apple Photos…", "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)", "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.", - "Adding to Apple Photos": "Ajout à Apple Photos" + "Adding to Apple Photos": "Ajout à Apple Photos", + "Contact Sheet PDF": "Planche contact PDF", + "Letter": "Lettre US", + "Filename captions": "Légendes avec le nom de fichier", + "150 dpi · sRGB · current edits · fit without cropping": "150 ppp · sRVB · retouches actuelles · ajusté sans recadrage", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Choisissez l’emplacement du PDF après confirmation. Imprimez le PDF depuis votre visionneuse.", + "HDR output": "Sortie HDR", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Photos retouchées en HDR : JPEG avec carte de gain HDR (correct partout, hautes lumières plus vives sur les écrans HDR), AVIF en 10 bits, PNG et JPEG XL en 16 bits Rec. 2020 PQ ; le TIFF exige du 32 bits flottant", + "HDR TIFF needs 32-bit float.": "Le TIFF HDR exige du 32 bits flottant.", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Espace colorimétrique : Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Espace colorimétrique : Rec. 2020 PQ, 16 bits (PNG HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Espace colorimétrique : Rec. 2020 PQ (JPEG XL HDR)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Les visionneuses qui ne lisent pas le bloc cICP du PNG affichent les valeurs PQ comme du sRVB : plates et délavées. Pour une image correcte partout, utilisez le JPEG (carte de gain)." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ca286386d..c24e26501 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}", "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", "{n} added to Apple Photos": "{n}件をAppleの写真に追加しました", - "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています" + "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", + "{n} selected photos · {pages} pages": "選択した写真 {n} 枚 · {pages} ページ" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c35..062f8a7c6 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1953,5 +1953,17 @@ "Adding to Apple Photos…": "Appleの写真に追加中…", "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)", "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。", - "Adding to Apple Photos": "Appleの写真に追加中" + "Adding to Apple Photos": "Appleの写真に追加中", + "Contact Sheet PDF": "コンタクトシート PDF", + "Letter": "レター", + "Filename captions": "ファイル名のキャプション", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · 現在の編集 · トリミングせずに収める", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "確定後に PDF の保存先を選びます。PDF はビューアから印刷してください。", + "HDR output": "HDR 出力", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "HDR で編集した写真:JPEG は HDR ゲインマップ付き(どこでも正しく表示され、HDR ディスプレイではハイライトがより明るい)、AVIF は 10 ビット、PNG と JPEG XL は 16 ビット Rec. 2020 PQ。TIFF には 32 ビット浮動小数点が必要です", + "HDR TIFF needs 32-bit float.": "HDR TIFF には 32 ビット浮動小数点が必要です。", + "Color space: Rec. 2020 PQ (HDR AVIF)": "カラースペース:Rec. 2020 PQ(HDR AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "カラースペース:Rec. 2020 PQ、16 ビット(HDR PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "カラースペース:Rec. 2020 PQ(HDR JPEG XL)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "PNG の cICP チャンクを読まないビューアは PQ の値を sRGB として表示するため、平坦で色あせて見えます。どこでも正しく見せるには JPEG(ゲインマップ)を使ってください。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 0b3ebe80d..ed904f3a6 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple", "{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", + "{n} selected photos · {pages} pages": "{n} fotos selecionadas · {pages} páginas" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9c16f1110..541482dda 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1951,5 +1951,17 @@ "Adding to Apple Photos…": "Adicionando ao Fotos da Apple…", "not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.", - "Adding to Apple Photos": "Adicionando ao Fotos da Apple" + "Adding to Apple Photos": "Adicionando ao Fotos da Apple", + "Contact Sheet PDF": "Folha de contato em PDF", + "Letter": "Carta", + "Filename captions": "Legendas com o nome do arquivo", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · edições atuais · ajustado sem cortar", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Escolha onde salvar o PDF depois de confirmar. Imprima o PDF pelo seu visualizador.", + "HDR output": "Saída HDR", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Fotos editadas em HDR: JPEG com mapa de ganho HDR (fica certo em qualquer lugar, realces mais brilhantes em telas HDR), AVIF em 10 bits, PNG e JPEG XL em 16 bits Rec. 2020 PQ; TIFF requer ponto flutuante de 32 bits", + "HDR TIFF needs 32-bit float.": "TIFF HDR requer ponto flutuante de 32 bits.", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Espaço de cor: Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Espaço de cor: Rec. 2020 PQ, 16 bits (PNG HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Espaço de cor: Rec. 2020 PQ (JPEG XL HDR)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Visualizadores que não leem o bloco cICP do PNG mostram os valores PQ como sRGB: chapados e desbotados. Para uma imagem que fique certa em qualquer lugar, use JPEG (mapa de ganho)." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 11651aaa0..d0c1d6ed6 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", "{n} added to Apple Photos": "Добавлено в Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом" + "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", + "{n} selected photos · {pages} pages": "Выбрано фото: {n} · страниц: {pages}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a84297a05..8b49cfdb7 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2143,5 +2143,17 @@ "Adding to Apple Photos…": "Добавление в Apple Фото…", "not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)", "The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото».", - "Adding to Apple Photos": "Добавление в Apple Фото" + "Adding to Apple Photos": "Добавление в Apple Фото", + "Contact Sheet PDF": "PDF контактного листа", + "Letter": "Letter", + "Filename captions": "Подписи с именем файла", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · текущие правки · вписано без обрезки", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "После подтверждения выберите, куда сохранить PDF. Печатайте PDF из программы просмотра.", + "HDR output": "Вывод HDR", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Фото, обработанные в HDR: JPEG с картой усиления HDR (выглядит правильно везде, на HDR-экранах светлые участки ярче), AVIF — 10 бит, PNG и JPEG XL — 16 бит Rec. 2020 PQ; для TIFF нужен 32-битный формат с плавающей точкой", + "HDR TIFF needs 32-bit float.": "Для HDR TIFF нужен 32-битный формат с плавающей точкой.", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Цветовое пространство: Rec. 2020 PQ (HDR AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Цветовое пространство: Rec. 2020 PQ, 16 бит (HDR PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Цветовое пространство: Rec. 2020 PQ (HDR JPEG XL)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Программы, не читающие блок cICP в PNG, показывают значения PQ как sRGB: плоско и блекло. Чтобы изображение везде выглядело правильно, используйте JPEG (карта усиления)." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 7aca485e0..e2b086687 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "не додано до Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", "{n} added to Apple Photos": "Додано до Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом" + "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", + "{n} selected photos · {pages} pages": "Вибрано фото: {n} · сторінок: {pages}" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f7..23fa50f64 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2145,5 +2145,17 @@ "Adding to Apple Photos…": "Додавання до Apple Фото…", "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)", "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».", - "Adding to Apple Photos": "Додавання до Apple Фото" + "Adding to Apple Photos": "Додавання до Apple Фото", + "Contact Sheet PDF": "PDF контактного аркуша", + "Letter": "Letter", + "Filename captions": "Підписи з назвою файлу", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · поточні правки · вписано без обрізання", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Після підтвердження виберіть, куди зберегти PDF. Друкуйте PDF із програми перегляду.", + "HDR output": "Вивід HDR", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Фото, оброблені в HDR: JPEG із картою підсилення HDR (виглядає правильно всюди, на HDR-екранах світлі ділянки яскравіші), AVIF — 10 біт, PNG і JPEG XL — 16 біт Rec. 2020 PQ; для TIFF потрібен 32-бітний формат із рухомою комою", + "HDR TIFF needs 32-bit float.": "Для HDR TIFF потрібен 32-бітний формат із рухомою комою.", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Колірний простір: Rec. 2020 PQ (HDR AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Колірний простір: Rec. 2020 PQ, 16 біт (HDR PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Колірний простір: Rec. 2020 PQ (HDR JPEG XL)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Програми, які не читають блок cICP у PNG, показують значення PQ як sRGB: пласко й вицвіло. Щоб зображення всюди виглядало правильно, використовуйте JPEG (карта підсилення)." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index bf27ca6b2..1d43e79a4 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", "{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起" + "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", + "{n} selected photos · {pages} pages": "已选 {n} 张照片 · {pages} 页" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2b48b8e9b..2f8003f7b 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1989,5 +1989,17 @@ "Adding to Apple Photos…": "正在添加到 Apple 照片…", "not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。", - "Adding to Apple Photos": "正在添加到 Apple 照片" + "Adding to Apple Photos": "正在添加到 Apple 照片", + "Contact Sheet PDF": "联系表 PDF", + "Letter": "Letter", + "Filename captions": "文件名标题", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · 当前编辑 · 完整适合,不裁剪", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "确认后选择 PDF 的保存位置。请在查看器中打印 PDF。", + "HDR output": "HDR 输出", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "以 HDR 编辑的照片:JPEG 带 HDR 增益图(在任何地方都显示正确,在 HDR 显示器上高光更亮),AVIF 为 10 位,PNG 和 JPEG XL 为 16 位 Rec. 2020 PQ;TIFF 需要 32 位浮点", + "HDR TIFF needs 32-bit float.": "HDR TIFF 需要 32 位浮点。", + "Color space: Rec. 2020 PQ (HDR AVIF)": "色彩空间:Rec. 2020 PQ(HDR AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "色彩空间:Rec. 2020 PQ,16 位(HDR PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "色彩空间:Rec. 2020 PQ(HDR JPEG XL)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "不读取 PNG cICP 块的查看器会把 PQ 值当作 sRGB 显示:平淡且发灰。要在任何地方都显示正确,请使用 JPEG(增益图)。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 37b2db2ba..7541c84a8 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", "{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起" + "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", + "{n} selected photos · {pages} pages": "已選取 {n} 張照片 · {pages} 頁" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0e81c6e92..9b49062b9 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1951,5 +1951,17 @@ "Adding to Apple Photos…": "正在加入 Apple 照片…", "not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。", - "Adding to Apple Photos": "正在加入 Apple 照片" + "Adding to Apple Photos": "正在加入 Apple 照片", + "Contact Sheet PDF": "縮圖目錄 PDF", + "Letter": "Letter", + "Filename captions": "檔名說明", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · 目前的編輯 · 完整符合,不裁切", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "確認後選擇 PDF 的儲存位置。請在檢視器中列印 PDF。", + "HDR output": "HDR 輸出", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "以 HDR 編輯的照片:JPEG 附 HDR 增益圖(在任何地方都顯示正確,在 HDR 顯示器上亮部更亮),AVIF 為 10 位元,PNG 與 JPEG XL 為 16 位元 Rec. 2020 PQ;TIFF 需要 32 位元浮點", + "HDR TIFF needs 32-bit float.": "HDR TIFF 需要 32 位元浮點。", + "Color space: Rec. 2020 PQ (HDR AVIF)": "色彩空間:Rec. 2020 PQ(HDR AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "色彩空間:Rec. 2020 PQ,16 位元(HDR PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "色彩空間:Rec. 2020 PQ(HDR JPEG XL)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "不讀取 PNG cICP 區塊的檢視器會把 PQ 值當作 sRGB 顯示:平淡且泛白。要在任何地方都顯示正確,請使用 JPEG(增益圖)。" } diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a18357eca..a56289a10 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1384,7 +1384,7 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: for (i, tok) in TOKENS.iter().enumerate() { let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text( if tok.aliases.is_empty() { - "Insert at the cursor".to_string() + crate::i18n::tr("Insert at the cursor").to_string() } else { crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", ")) }, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..67184040d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -679,7 +679,7 @@ impl LightcraftApp { } (None, true) => { self.ui.unsaved_seen = false; - self.toast(ctx, "Library saved"); + self.toast(ctx, crate::i18n::tr("Library saved")); } (Some(_), true) => ctx.request_repaint_after(std::time::Duration::from_secs(2)), // retry (None, false) => {} @@ -1014,7 +1014,7 @@ impl LightcraftApp { if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids})) && r["reloaded"].as_array().is_some_and(|a| !a.is_empty()) { - self.toast(ctx, "Updated the edits saved in the external editor"); + self.toast(ctx, crate::i18n::tr("Updated the edits saved in the external editor")); } } self.ui.was_focused = focused; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..70b267d83 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -524,7 +524,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::make_select(app), "view.autoAdvance" => { app.ui.auto_advance = !app.ui.auto_advance; - app.toast(&ctx, if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" }); + app.toast(&ctx, crate::i18n::tr(if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" })); Ok(json!({"autoAdvance": app.ui.auto_advance})) } "view.back" => { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..4fb0fab70 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -632,19 +632,19 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { options.columns = options.columns.clamp(1, 8); options.rows = options.rows.clamp(1, 10); let n = app.session.targets(&json!({})).len(); - ui.label(format!("{n} selected photos · {} pages", n.div_ceil(options.columns * options.rows))); + ui.label(crate::i18n::tr_format!("{n} selected photos · {pages} pages", n = n, pages = n.div_ceil(options.columns * options.rows))); field(ui, "Paper", |ui| { egui::ComboBox::from_id_salt("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { ui.selectable_value(&mut options.paper, "a4".into(), "A4"); - ui.selectable_value(&mut options.paper, "letter".into(), "Letter"); + ui.selectable_value(&mut options.paper, "letter".into(), crate::i18n::tr("Letter")); }); }); - ui.checkbox(&mut options.landscape, "Landscape"); + ui.checkbox(&mut options.landscape, crate::i18n::tr("Landscape")); field(ui, "Columns", |ui| { ui.add(egui::DragValue::new(&mut options.columns).range(1..=8)); }); field(ui, "Rows", |ui| { ui.add(egui::DragValue::new(&mut options.rows).range(1..=10)); }); - ui.checkbox(&mut options.captions, "Filename captions"); - ui.label("150 dpi · sRGB · current edits · fit without cropping"); - ui.label("Choose a PDF destination after confirming. Print the PDF from your viewer."); + ui.checkbox(&mut options.captions, crate::i18n::tr("Filename captions")); + ui.label(crate::i18n::tr("150 dpi · sRGB · current edits · fit without cropping")); + ui.label(crate::i18n::tr("Choose a PDF destination after confirming. Print the PDF from your viewer.")); } Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => { use lightcraft_engine::export::{ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; @@ -705,20 +705,20 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { opts.bit_depth = Some(bd); } if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png | F::Jxl) { - let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float"; - ui.checkbox(&mut opts.hdr, "HDR output").on_hover_text(tip); + let tip = crate::i18n::tr("Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float"); + ui.checkbox(&mut opts.hdr, crate::i18n::tr("HDR output")).on_hover_text(tip); if opts.hdr && !opts.hdr_output() { - ui.label(egui::RichText::new("HDR TIFF needs 32-bit float.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("HDR TIFF needs 32-bit float.")).color(t.text_dim)); } } if !rendered { } else if opts.format == F::Avif && opts.hdr { - ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR AVIF)").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ (HDR AVIF)")).color(t.text_dim)); } else if opts.format == F::Png && opts.hdr { - ui.label(egui::RichText::new("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)").color(t.text_dim)) - .on_hover_text("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map)."); + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)")).color(t.text_dim)) + .on_hover_text(crate::i18n::tr("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).")); } else if opts.format == F::Jxl && opts.hdr { - ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR JPEG XL)").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ (HDR JPEG XL)")).color(t.text_dim)); } else if opts.format == F::Avif { ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim)); } else { diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 2b3fe0329..55e459480 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -368,9 +368,9 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, Some("done") => {} _ => { if let Some(url) = m["source"].as_str() { - let r = ui - .button("Open page") - .on_hover_text("Opens the model's own page in your browser, to read about it or get the file yourself."); + let r = ui.button("Open page").on_hover_text(crate::i18n::tr( + "Opens the model's own page in your browser, to read about it or get the file yourself.", + )); register(ui.ctx(), format!("faces:get:{id}"), r.rect); if r.clicked() { open_page(app, url); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 3c7261e20..817179e66 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -444,7 +444,10 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); let used = app.session.media.rendered.disk().map(|d| d.size()); row(ui, t, crate::i18n::tr("In use"), |ui| { - ui.label(RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| "memory only".into())).color(t.text)); + ui.label( + RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| crate::i18n::tr("memory only").to_string())) + .color(t.text), + ); let r = ui.button(crate::i18n::tr("Clear Cache")); register(ui.ctx(), "button:settingsClearCache", r.rect); if r.clicked() { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 129fb1d9e..d5bf1fa7c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -405,7 +405,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { { let now = ctx.input(|i| i.time); app.ui.slideshow = Some((interval, now + interval, !paused)); - app.toast(ctx, if paused { "Slideshow resumed" } else { "Slideshow paused" }); + app.toast(ctx, crate::i18n::tr(if paused { "Slideshow resumed" } else { "Slideshow paused" })); continue; } // flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey, From 1a5c4665da63d72fc2415079573ed15ae11572a3 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Mon, 12 Oct 2026 10:57:55 -0400 Subject: [PATCH 20/52] Fit a DNG's tone to its embedded preview when the file defines none For a DNG with its own colour matrix, an identity or absent ProfileToneCurve, no LookTable and no ProfileGainTableMap, fit only the tone and chroma curves to the camera JPEG, with the file's colour model untouched (profile tables and embedded vignetting applied to the sensor proxy as the loader renders it). The fit is used only when the preview is at least 90% of the raw's long edge, shows the same picture (structure gate), passes the existing held-out acceptance, and does not put more of the photo near white than the plain render (highlight gate, at 2x proxy luminance). Otherwise the render is unchanged. Other formats and catalogued photos are untouched. Render cache version 62. --- crates/engine/src/camera_preview.rs | 297 ++++++++++++++++++++++++++-- crates/engine/src/files.rs | 4 +- crates/engine/src/media.rs | 2 +- 3 files changed, 288 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..6521d4a15 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -69,8 +69,26 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { /// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's /// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the /// as-shot white, the camera transform at it and the fitted look. -pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTransform, Option) { - starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)) +pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&lightcraft_develop::EmbeddedLens>) -> (Xy, CameraTransform, Option) { + let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)); + if look.is_none() && dng_tone_eligible(raw, &t) { + let look = fit_dng_tone(raw, bytes, &t, lens); + return (xy, t, look); + } + (xy, t, look) +} + +/// Whether the profile's tone curve is absent or the identity (within 0.005 over the range). +fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool { + curve.is_none_or(|c| (0..=32).all(|i| { let x = i as f32 / 32.0; (c.eval(x) - x).abs() < 0.005 })) +} + +/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no +/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering). +pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { + let p = &raw.color.profile; + raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.look_table.is_none() + && p.gain_table_map.is_none() } /// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white @@ -112,7 +130,7 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf if !transform.matrix_is_fallback || !file_local_look(raw.format) { return (None, false); } - let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY) else { return (None, false) }; + let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0) else { return (None, false) }; let black_and_white = monochrome(&reference); // A camera profile pooled from many photos knows colours this photo shows too little of; // try its colour first and fit tone/chroma per photo (picture styles vary). @@ -265,14 +283,151 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool { }) } +/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model +/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given +/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near +/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than +/// the plain render ([`clips_more`]). +fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option { + let (mut sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION)?; + // As the loader renders it: the profile's tables around the baseline exposure. + let xy = lightcraft_raw::color::as_shot_white_xy(raw); + if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) { + let gain = 2f32.powf(transform.baseline_exposure as f32); + sensor.map_in_place(|p| tables.apply(p.map(|v| v / gain), gain)); + } + correct_vignetting(&mut sensor, lens, raw.orientation); + if (sensor.width, sensor.height) != (reference.width, reference.height) { + return None; + } + if !structure_agrees(&sensor, &reference) { + return None; + } + let unclipped = without_clipped(&sensor, &clipped); + let fit_on = unclipped.as_ref().unwrap_or(&sensor); + let colour = Some((Mat3::IDENTITY, None)); + let look = TONE_FITS.iter().find_map(|&tone| { + [false, true].into_iter().find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges })) + }); + let look = look?; + let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { + Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0), + None => ToneMap::new(0.0, 0.0, 0.0), + }; + if clips_more(&sensor, &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), &plain) { + return None; + } + Some(look) +} + +/// Whether the fitted tone map puts more of the photo's proxy pixels at or near white than the plain render does. +fn clips_more(sensor: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { + let near_white = |tone: &ToneMap| { + let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(CLIP_HEADROOM * luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); + n as f64 / sensor.data.len().max(1) as f64 + }; + let (a, b) = (near_white(fitted), near_white(plain)); + a > b +} + +/// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the +/// pipeline applies to the render (`optics::embedded_vignette_gain`; `lens` is in the oriented, cropped frame): the +/// camera JPEG has it applied, so a tone fitted without it brightens the render's corners twice. +fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::EmbeddedLens>, orientation: lightcraft_geom::Orientation) { + let Some(vignette) = lens.and_then(|l| l.vignette) else { return }; + let (w, h) = (sensor.width as f64, sensor.height as f64); + let (ow, oh) = if orientation.swaps_axes() { (h, w) } else { (w, h) }; + let width = sensor.width.max(1); + for (i, p) in sensor.data.iter_mut().enumerate() { + let (x, y) = orientation.map((i % width) as f64 + 0.5, (i / width) as f64 + 0.5, w, h); + let gain = lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, lightcraft_geom::Point::new(x, y), ow, oh) as f32; + *p = p.map(|v| v * gain); + } +} + +/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a +/// small thumbnail is not fitted. +const MIN_PREVIEW_FRACTION: f64 = 0.9; + +/// Luminance headroom of the highlight gate: the 96 px proxy averages small bright spots away, so [`clips_more`] +/// looks at the proxy at this multiple of its luminance (chosen on the CC0 and user DNGs: 1 passes a fit that clips +/// the brightest 0.04% of a photo, 2 refuses it while keeping most fits). +const CLIP_HEADROOM: f32 = 2.0; + +/// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach. +const MIN_STRUCTURE: f64 = 0.85; + +/// Whether the sensor proxy and the camera JPEG proxy show the same picture in the same place: their log luminance +/// correlates by at least [`MIN_STRUCTURE`] unshifted, and no shift of more than one proxy pixel (within ±3) +/// correlates better. +fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool { + let (w, h) = (sensor.width, sensor.height); + if (w, h) != (reference.width, reference.height) || w < 16 || h < 16 || sensor.data.len() != w * h || reference.data.len() != w * h { + return false; + } + let log = |img: &Rgb32f| -> Vec { + let l: Vec = + img.data.iter().map(|p| f64::from(luminance_2020(p.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }))).max(1e-4).ln()).collect(); + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let (mut sum, mut n) = (0.0, 0.0); + for yy in y.saturating_sub(2)..(y + 3).min(h) { + for xx in x.saturating_sub(2)..(x + 3).min(w) { + if let Some(v) = l.get(yy * w + xx) { + sum += v; + n += 1.0; + } + } + } + sum / f64::max(n, 1.0) + }) + .collect() + }; + let (a, b) = (log(sensor), log(reference)); + const M: usize = 3; + let corr = |dx: isize, dy: isize| -> f64 { + let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + for y in M..h - M { + for x in M..w - M { + let (xx, yy) = (x.saturating_add_signed(dx), y.saturating_add_signed(dy)); + let (Some(&u), Some(&v)) = (a.get(y * w + x), b.get(yy * w + xx)) else { continue }; + n += 1.0; + sa += u; + sb += v; + saa += u * u; + sbb += v * v; + sab += u * v; + } + } + let var = (saa - sa * sa / n) * (sbb - sb * sb / n); + if n < 2.0 || var <= 1e-12 { 0.0 } else { (sab - sa * sb / n) / var.sqrt() } + }; + let at_zero = corr(0, 0); + let (mut best, mut shift) = (at_zero, (0isize, 0isize)); + for dy in -(M as isize)..=M as isize { + for dx in -(M as isize)..=M as isize { + let c = corr(dx, dy); + if c > best + 1e-9 { + (best, shift) = (c, (dx, dy)); + } + } + } + at_zero >= MIN_STRUCTURE && shift.0.abs() <= 1 && shift.1.abs() <= 1 +} + /// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s /// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020), /// and per proxy pixel whether any sensor sample it covers is clipped. -fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec)> { +fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize, min_fraction: f64) -> Option<(Rgb32f, Rgb32f, Vec)> { let edge = (2 * size).max(384) as u32; let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?; - let mut reference = decoded.to_working(); let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); + // a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any + if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) { + return None; + } + let mut reference = decoded.to_working(); if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { return None; } @@ -474,7 +629,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option if !file_local_look(raw.format) { return None; } - let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?; + let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0)?; if !monochrome(&reference) { return None; } @@ -1748,7 +1903,7 @@ mod tests { assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap(); let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[]); + let (_, t, look) = starting_colour(&mut raw, &[], None); assert!(look.is_none()); assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); assert!(!t.matrix_is_fallback); @@ -1807,13 +1962,13 @@ mod tests { #[test] fn a_camera_outside_the_table_keeps_the_fallback() { let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[]); + let (_, t, look) = starting_colour(&mut raw, &[], None); assert!(look.is_none() && t.matrix_is_fallback); assert_eq!(raw.color, lightcraft_raw::ColorData::default()); // without a model there is no table row let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default()); raw.metadata.model = None; - assert!(starting_colour(&mut raw, &[]).1.matrix_is_fallback); + assert!(starting_colour(&mut raw, &[], None).1.matrix_is_fallback); } /// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is @@ -1836,7 +1991,7 @@ mod tests { let own = lightcraft_raw::color::has_matrix(&raw.color); let covered = raw.metadata.model.as_deref().and_then(|m| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), m)).is_some(); let before = raw.color.clone(); - let (_, t, fitted) = starting_colour(&mut raw, &bytes); + let (_, t, fitted) = starting_colour(&mut raw, &bytes, None); let spectral = !own && fitted.is_none() && covered; eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); assert_eq!(raw.color != before, spectral, "{name}"); @@ -2224,7 +2379,7 @@ mod tests { let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); let look = fit_preview(&raw, &bytes, &transform).0.expect("a camera look"); // the look fitted to the JPEG, as before this change - let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY).unwrap(); + let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0).unwrap(); let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap(); @@ -2810,4 +2965,122 @@ mod tests { reference.data.iter_mut().for_each(|p| *p = [0.4; 3]); assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG"); } + + /// A smooth synthetic scene (sensor proxy) and its camera rendering, `dx` pixels to the right. + fn scene_pair(w: usize, h: usize, dx: usize) -> (Rgb32f, Rgb32f) { + let scene = |x: f32, y: f32| -> [f32; 3] { + let l = 0.03 + 0.4 * (0.5 + 0.5 * (x * 0.21).sin() * (y * 0.17).cos()) + 0.2 * ((x - 30.0).hypot(y - 20.0) < 12.0) as u8 as f32; + [l * (0.8 + 0.3 * (y / h as f32)), l, l * (1.1 - 0.4 * (x / w as f32))] + }; + let camera = |p: [f32; 3]| { + let y = luminance_2020(p); + p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y) + }; + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + for y in 0..h { + for x in 0..w { + sensor.data[y * w + x] = scene(x as f32, y as f32); + reference.data[y * w + x] = camera(scene(x as f32 - dx as f32, y as f32)); + } + } + (sensor, reference) + } + + /// The structural gate accepts the same picture whatever its tone, and refuses a shifted, a broken (stripes), a + /// flat and a differently sized preview. + #[test] + fn dng_structure_gate_refuses_shifted_broken_and_unrelated_previews() { + let (sensor, reference) = scene_pair(96, 64, 0); + assert!(structure_agrees(&sensor, &reference)); + assert!(structure_agrees(&sensor, &scene_pair(96, 64, 1).1), "one proxy pixel (lens correction) is tolerated"); + assert!(!structure_agrees(&sensor, &scene_pair(96, 64, 3).1), "differently framed"); + let mut stripes = reference.clone(); + for (i, p) in stripes.data.iter_mut().enumerate() { + *p = if (i / 96) % 4 < 2 { [0.6, 0.1, 0.5] } else { [0.05, 0.4, 0.1] }; + } + assert!(!structure_agrees(&sensor, &stripes), "a broken preview"); + let mut flat = reference.clone(); + flat.data.fill([0.2; 3]); + assert!(!structure_agrees(&sensor, &flat)); + assert!(!structure_agrees(&sensor, &scene_pair(95, 64, 0).1)); + } + + /// With the file's colour given as the identity, only the tone is fitted: the colour stays and a camera that + /// renders mid-grey brighter than the default tone map is followed. + #[test] + fn dng_tone_fit_keeps_the_files_colour_and_follows_the_camera_tone() { + let (mut sensor, mut reference) = scene_pair(96, 64, 0); + for image in [&mut sensor, &mut reference] { + for p in image.data.iter_mut().step_by(5) { + *p = [p[0] * 1.6, p[1], p[2] * 0.5]; + } + } + let colour = Some((Mat3::IDENTITY, None)); + let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false }).expect("tone fitted"); + assert_eq!(look.matrix, Mat3::IDENTITY); + assert!(look.hue_sat.is_none()); + let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let target = 1.0 - (-2.5f32 * 0.18).exp(); + assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18)); + assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18)); + assert!(!clips_more(&sensor, &fitted, &fitted), "a tone never clips more than itself"); + } + + /// The highlight gate compares the share of near-white pixels of two tone maps, in one direction only. + #[test] + fn dng_highlight_gate_compares_near_white_shares() { + let mut sensor = Rgb32f::new(32, 32); + for (i, p) in sensor.data.iter_mut().enumerate() { + *p = [0.02 + 1.5 * i as f32 / 1024.0; 3]; + } + let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0)); + assert!(clips_more(&sensor, &bright, &dark) != clips_more(&sensor, &dark, &bright)); + assert!(!clips_more(&sensor, &bright, &bright)); + } + + /// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render. + #[test] + fn dng_sensor_proxy_gets_the_embedded_vignetting_correction() { + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette}; + let lens = EmbeddedLens { + vignette: Some(EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }), + ..Default::default() + }; + let mut proxy = Rgb32f::new(96, 64); + proxy.data.fill([0.2; 3]); + correct_vignetting(&mut proxy, Some(&lens), lightcraft_geom::Orientation::Normal); + let (centre, corner) = (proxy.data[32 * 96 + 48][1] / 0.2, proxy.data[0][1] / 0.2); + assert!((centre - 1.0).abs() < 0.01 && (1.9..2.0).contains(&corner), "centre {centre}, corner {corner}"); + let mut plain = Rgb32f::new(96, 64); + plain.data.fill([0.2; 3]); + correct_vignetting(&mut plain, None, lightcraft_geom::Orientation::Normal); + assert!(plain.data.iter().all(|p| *p == [0.2; 3])); + } + + /// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the + /// identity, no look table; and no other raw format. + #[test] + fn dng_tone_gates_follow_the_files_own_tags() { + use lightcraft_raw::profile::{ToneCurve, HsvTable}; + let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); + let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; + let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| { + let raw = raw_of(format, "Make", "Model", color); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + dng_tone_eligible(&raw, &t) + }; + assert!(eligible(RawFormat::Dng, color.clone())); + assert!(!eligible(RawFormat::Cr2, color.clone()), "other formats keep their own path"); + assert!(!eligible(RawFormat::Dng, Default::default()), "no matrix, no colour to keep"); + let mut identity = color.clone(); + identity.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.5, 1.0, 1.0]); + assert!(identity.profile.tone_curve.is_some() && eligible(RawFormat::Dng, identity), "an identity ProfileToneCurve is no tone"); + let mut curved = color.clone(); + curved.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); + assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept"); + let mut looked = color; + looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0); + assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering"); + } } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff77..afed9b77d 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -403,7 +403,7 @@ fn load_bytes_now( // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted - let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info)); + let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref()), || dng_mattes(&bytes, &info)); drop(bytes); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. @@ -472,7 +472,7 @@ fn load_bytes_now( // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). - let camera_color = (scale.own_matrix && !t.matrix_is_fallback && camera_look.is_none()).then(|| { + let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)).then(|| { let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) }); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..58b9712ac 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 61; +pub const RENDER_CACHE_VERSION: u64 = 62; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; From f89b70e1c11fb8df0c05cf6d0332aab7a53551d2 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Mon, 12 Oct 2026 11:36:22 -0400 Subject: [PATCH 21/52] Tone-only DNG fit: train on dark pixels, compare highlights with the camera preview too --- crates/engine/src/camera_preview.rs | 105 ++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 6521d4a15..86d6f5673 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -289,7 +289,14 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool { /// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than /// the plain render ([`clips_more`]). fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option { - let (mut sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION)?; + // Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate. + let refuse = |why: &str| { + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE refused {why}"); + } + None + }; + let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION) else { return refuse("preview") }; // As the loader renders it: the profile's tables around the baseline exposure. let xy = lightcraft_raw::color::as_shot_white_xy(raw); if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) { @@ -298,36 +305,42 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: } correct_vignetting(&mut sensor, lens, raw.orientation); if (sensor.width, sensor.height) != (reference.width, reference.height) { - return None; + return refuse("preview"); } if !structure_agrees(&sensor, &reference) { - return None; + return refuse("structure"); } let unclipped = without_clipped(&sensor, &clipped); let fit_on = unclipped.as_ref().unwrap_or(&sensor); let colour = Some((Mat3::IDENTITY, None)); let look = TONE_FITS.iter().find_map(|&tone| { - [false, true].into_iter().find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges })) + [false, true].into_iter().find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true })) }); - let look = look?; + let Some(look) = look else { return refuse("fit") }; let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0), None => ToneMap::new(0.0, 0.0, 0.0), }; - if clips_more(&sensor, &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), &plain) { - return None; + let fitted = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + if clips_more(&sensor, &reference, &fitted, &plain) { + return refuse("clip"); + } + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE accepted"); } Some(look) } -/// Whether the fitted tone map puts more of the photo's proxy pixels at or near white than the plain render does. -fn clips_more(sensor: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { +/// Whether the fitted tone map puts clearly more of the photo's proxy pixels at or near white than both the plain +/// render and the camera's own preview do ([`CLIP_TOLERANCE`]): a fit that clips what the camera clips too is the +/// camera's look, one that clips what neither does loses highlight detail. +fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { let near_white = |tone: &ToneMap| { - let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(CLIP_HEADROOM * luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); + let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); n as f64 / sensor.data.len().max(1) as f64 }; - let (a, b) = (near_white(fitted), near_white(plain)); - a > b + let preview = reference.data.iter().filter(|p| luminance_2020(**p) >= 0.98).count() as f64 / reference.data.len().max(1) as f64; + near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE } /// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the @@ -349,10 +362,14 @@ fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::Emb /// small thumbnail is not fitted. const MIN_PREVIEW_FRACTION: f64 = 0.9; -/// Luminance headroom of the highlight gate: the 96 px proxy averages small bright spots away, so [`clips_more`] -/// looks at the proxy at this multiple of its luminance (chosen on the CC0 and user DNGs: 1 passes a fit that clips -/// the brightest 0.04% of a photo, 2 refuses it while keeping most fits). -const CLIP_HEADROOM: f32 = 2.0; +/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark +/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows. +const DARK_FLOOR: (f32, f32) = (0.004, 0.001); + +/// Share of the proxy by which the fitted curve may clip more highlights than the plain render and the camera's +/// preview (chosen on the CC0 and user DNGs from the clipped share of their 1440 px renders: it refuses the fits +/// that clip about 1% more than the camera does and passes the ones that clip like the camera). +const CLIP_TOLERANCE: f64 = 0.001; /// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach. const MIN_STRUCTURE: f64 = 0.85; @@ -633,7 +650,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option f32 { /// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated /// colours (see [`EDGE_CONTRAST`]). type Pairs = Vec<([f64; 3], [f64; 3])>; -fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option) -> Option<(Pairs, Pairs)> { +fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option, dark: bool) -> Option<(Pairs, Pairs)> { if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { return None; } @@ -724,7 +741,8 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi if (0.015..=1.0).contains(&y) { bright.push((input.map(f64::from), output.map(f64::from))); } - if !output.iter().all(|v| *v > 0.004 && *v < 0.98) || !(0.015..0.85).contains(&y) { + let (ylo, clo) = if dark { DARK_FLOOR } else { (0.015, 0.004) }; + if !output.iter().all(|v| *v > clo && *v < 0.98) || !(ylo..0.85).contains(&y) { continue; } let min = output.iter().copied().fold(f32::INFINITY, f32::min); @@ -805,6 +823,8 @@ struct Attempt { tone: ToneFit, profile: bool, away_from_edges: bool, + /// Train on the dark end of the preview too ([`DARK_FLOOR`]): the tone-only fit of a DNG. + dark: bool, } /// The photo's look and the attempt that produced it: its own matrix (with and without a @@ -821,7 +841,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt order.iter().find_map(|&tone| { profiles.iter().find_map(|&profile| { [false, true].into_iter().find_map(|away_from_edges| { - let attempt = Attempt { tone, profile, away_from_edges }; + let attempt = Attempt { tone, profile, away_from_edges, dark: false }; fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt)) }) }) @@ -830,7 +850,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, attempt: Attempt) -> Option { let colour = if attempt.profile { colour.clone() } else { None }; - fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone) + fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark) } fn fit_pairs_on( @@ -839,10 +859,11 @@ fn fit_pairs_on( colour: Option<(Mat3, Option)>, edge_limit: Option, tone_fit: ToneFit, + dark: bool, ) -> Option { // A known colour model needs enough signal for tone fitting, not a scene rich enough to // learn a new colour matrix. Still reject monochrome references. - let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?; + let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit, dark)?; let candidates = match colour { Some(given) => vec![given], None => { @@ -988,7 +1009,7 @@ fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option { fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option) -> Option { // as for a known colour model: enough signal for the tone, but no monochrome reference - let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit)?; + let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?; let related = rank_correlation(&pairs); if related < PARTIAL_MIN_RANK_CORRELATION { if lightcraft_pipeline::profiling() { @@ -1098,7 +1119,7 @@ fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opti fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, edge_limit: Option) -> Option { // every pair counts: the JPEG has no colour to require - let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit)?; + let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?; let related = rank_correlation_ties(&pairs); if related < PARTIAL_MIN_RANK_CORRELATION { if lightcraft_pipeline::profiling() { @@ -1699,7 +1720,7 @@ mod tests { let mut pool = Vec::new(); for seed in 0..4 { let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5); - pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0); + pool.extend(collect_pairs(&sensor, &reference, 0.05, None, false).unwrap().0); } let (matrix, _) = fit_profile(&pool).unwrap(); // the camera's colours (luminance-normalised: the tone curve sets brightness) @@ -2645,7 +2666,7 @@ mod tests { assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone)); // without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone) let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS).unwrap(); - assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false }); + assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false }); assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored"); } @@ -3017,17 +3038,18 @@ mod tests { } } let colour = Some((Mat3::IDENTITY, None)); - let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false }).expect("tone fitted"); + let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }).expect("tone fitted"); assert_eq!(look.matrix, Mat3::IDENTITY); assert!(look.hue_sat.is_none()); let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); let target = 1.0 - (-2.5f32 * 0.18).exp(); assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18)); assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18)); - assert!(!clips_more(&sensor, &fitted, &fitted), "a tone never clips more than itself"); + assert!(!clips_more(&sensor, &reference, &fitted, &fitted), "a tone never clips more than itself"); } - /// The highlight gate compares the share of near-white pixels of two tone maps, in one direction only. + /// The highlight gate compares the share of near-white pixels of the fitted tone with the plain render's and the + /// camera preview's, in one direction only: clipping what the camera clips is not refused. #[test] fn dng_highlight_gate_compares_near_white_shares() { let mut sensor = Rgb32f::new(32, 32); @@ -3035,8 +3057,29 @@ mod tests { *p = [0.02 + 1.5 * i as f32 / 1024.0; 3]; } let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0)); - assert!(clips_more(&sensor, &bright, &dark) != clips_more(&sensor, &dark, &bright)); - assert!(!clips_more(&sensor, &bright, &bright)); + let mut white = Rgb32f::new(32, 32); + white.data.iter_mut().for_each(|p| *p = [1.0; 3]); + let mut black = Rgb32f::new(32, 32); + black.data.iter_mut().for_each(|p| *p = [0.1; 3]); + assert!(clips_more(&sensor, &black, &bright, &dark), "clips more than the plain render and a preview without white"); + assert!(!clips_more(&sensor, &black, &dark, &bright)); + assert!(!clips_more(&sensor, &black, &bright, &bright)); + assert!(!clips_more(&sensor, &white, &bright, &dark), "the camera preview clips as much"); + } + + /// A dark scene's shadows below the usual training floor still reach the tone-only fit. + #[test] + fn dng_tone_fit_trains_on_dark_pixels() { + let mut sensor = Rgb32f::new(64, 64); + let mut reference = Rgb32f::new(64, 64); + for (i, (s, r)) in sensor.data.iter_mut().zip(reference.data.iter_mut()).enumerate() { + let v = 0.0005 + 0.004 * (i % 64) as f32 / 64.0 + if i % 7 == 0 { 0.2 } else { 0.0 }; + *s = [v, v * 1.1, v * 0.9]; + *r = [(v * 3.0).min(0.9), (v * 3.3).min(0.9), (v * 2.7).min(0.9)]; + } + let usual = collect_pairs(&sensor, &reference, 0.0, None, false).map_or(0, |p| p.0.len()); + let dark = collect_pairs(&sensor, &reference, 0.0, None, true).map_or(0, |p| p.0.len()); + assert!(dark > usual, "{dark} vs {usual}"); } /// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render. From 0391362f2fef3db6cd659fde469eb2995dd2c7b3 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Mon, 12 Oct 2026 12:48:47 -0400 Subject: [PATCH 22/52] Format the DNG tone fit with rustfmt --- crates/engine/src/camera_preview.rs | 30 ++++++++++++++++++++++------- crates/engine/src/files.rs | 12 +++++++----- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 86d6f5673..39b0d1b1c 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -69,7 +69,11 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { /// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's /// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the /// as-shot white, the camera transform at it and the fitted look. -pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&lightcraft_develop::EmbeddedLens>) -> (Xy, CameraTransform, Option) { +pub(crate) fn starting_colour( + raw: &mut RawImage, + bytes: &[u8], + lens: Option<&lightcraft_develop::EmbeddedLens>, +) -> (Xy, CameraTransform, Option) { let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)); if look.is_none() && dng_tone_eligible(raw, &t) { let look = fit_dng_tone(raw, bytes, &t, lens); @@ -80,14 +84,22 @@ pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&li /// Whether the profile's tone curve is absent or the identity (within 0.005 over the range). fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool { - curve.is_none_or(|c| (0..=32).all(|i| { let x = i as f32 / 32.0; (c.eval(x) - x).abs() < 0.005 })) + curve.is_none_or(|c| { + (0..=32).all(|i| { + let x = i as f32 / 32.0; + (c.eval(x) - x).abs() < 0.005 + }) + }) } /// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no /// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering). pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { let p = &raw.color.profile; - raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.look_table.is_none() + raw.format == RawFormat::Dng + && !t.matrix_is_fallback + && identity_tone_curve(p.tone_curve.as_ref()) + && p.look_table.is_none() && p.gain_table_map.is_none() } @@ -314,7 +326,9 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: let fit_on = unclipped.as_ref().unwrap_or(&sensor); let colour = Some((Mat3::IDENTITY, None)); let look = TONE_FITS.iter().find_map(|&tone| { - [false, true].into_iter().find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true })) + [false, true] + .into_iter() + .find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true })) }); let Some(look) = look else { return refuse("fit") }; let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { @@ -441,7 +455,8 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?; let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); // a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any - if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) { + if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) + { return None; } let mut reference = decoded.to_working(); @@ -3038,7 +3053,8 @@ mod tests { } } let colour = Some((Mat3::IDENTITY, None)); - let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }).expect("tone fitted"); + let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }) + .expect("tone fitted"); assert_eq!(look.matrix, Mat3::IDENTITY); assert!(look.hue_sat.is_none()); let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); @@ -3105,7 +3121,7 @@ mod tests { /// identity, no look table; and no other raw format. #[test] fn dng_tone_gates_follow_the_files_own_tags() { - use lightcraft_raw::profile::{ToneCurve, HsvTable}; + use lightcraft_raw::profile::{HsvTable, ToneCurve}; let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| { diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index afed9b77d..d1c0e2747 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -403,7 +403,8 @@ fn load_bytes_now( // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted - let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref()), || dng_mattes(&bytes, &info)); + let ((xy, t, camera_look), mattes) = + rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref()), || dng_mattes(&bytes, &info)); drop(bytes); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. @@ -472,10 +473,11 @@ fn load_bytes_now( // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). - let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)).then(|| { - let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; - Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) - }); + let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)) + .then(|| { + let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; + Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) + }); let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); let local_tone = local_tone(&raw); let (temp, tint) = scale.as_shot(xy); From 5f2f013c8eb32e5a02a75d3071b82a52027efb24 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Mon, 12 Oct 2026 18:34:52 +0200 Subject: [PATCH 23/52] Perf: a full-size raw decode no longer copies its 64 MP image to "fit" it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `load_bytes_now` ran every development through `resample::fit`, which returns a clone when the image already fits. At full size (1:1 zoom, export) that cloned the whole picture: 797 MB and ~0.3 s for a 9984 × 6656 Sony ILCE-7RM6 raw. It now fits only when the image is larger, as the other preview paths in files.rs already do. Output unchanged (same pixels, by construction). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/files.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dc79b8b31..feefb21a0 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -450,7 +450,8 @@ fn load_bytes_now( stages.push(("highlights", t0.elapsed())); colour.apply(&mut img); stages.push(("colour", t0.elapsed())); - let img = fit(&img, max_edge, max_edge, Filter::Box); + // (`fit` copies an image that already fits: 797 MB and 0.3 s for a full-size 64 MP decode) + let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Box) } else { img }; stages.push(("fit", t0.elapsed())); let img = img.into_oriented(raw.orientation); stages.push(("orient", t0.elapsed())); From f4078135690352e8591e08a6a7fd2f0b1fa896f4 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Mon, 12 Oct 2026 18:35:00 +0200 Subject: [PATCH 24/52] =?UTF-8?q?Perf:=20AHD=20demosaic=201.7=C3=97=20fast?= =?UTF-8?q?er=20and=200.8=20GB=20lighter=20on=20a=2064=20MP=20raw,=20same?= =?UTF-8?q?=20pixels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, each leaving the output bit for bit as it was: - Each step runs only where the tile's output depends on it (choice ±0, homogeneity ±1, Lab ±2, greens ±3, mosaic ±5 inside the 6 px margin), with direct neighbour indices instead of an index clamped on every read; the clamped values at the buffer edge never reached the output. - A candidate pixel is built in registers before its Lab conversion: writing its channels through an index and reading the pixel back stalled on store forwarding (the hottest instructions of the profile). - Tiles are written into the output one band at a time, instead of all 64 MP of tiles being collected and then copied (797 MB held twice). Tiles inside the image copy their mosaic rows instead of reflecting and taking a modulo per sample. Sony ILCE-7RM6 9984 × 6656 Compressed HQ raw, demosaic only, best of 5 on 4 cores: 1.85 s → 1.05 s wall, 6.3 s → 4.0 s CPU. Verified identical (f32 bits) on 7 real raws (ILCE-7RM6 FF/APS-C, ARW6 HQ/C and lossless, ILCE-7RM4A) and, against the previous code, on random mosaics of all four Bayer layouts at 1 × 1 … 300 × 7, 257 × 130 (tile edges, reflection). Co-Authored-By: Claude Opus 5.5 --- crates/raw/src/demosaic/ahd.rs | 193 ++++++++++++++++----------------- 1 file changed, 94 insertions(+), 99 deletions(-) diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index 3f17413ff..a75c395aa 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -8,7 +8,10 @@ //! adaptive luminance / chrominance tolerances (ε_L, ε_C from the paper) — the homogeneity. //! 4. Sum homogeneity over a 3×3 window and pick the more homogeneous candidate (average on ties). //! -//! Work is done in overlapping tiles (bounded memory, rayon-parallel). +//! Work is done in overlapping tiles (bounded memory, rayon-parallel), one band of tiles at a time written +//! straight into the output. Each step runs only where the tile's output depends on it: the choice reads the +//! homogeneity 1 pixel around the tile, which reads Lab 2 pixels around, the candidates read the greens 3 +//! pixels around, and those read the mosaic 5 pixels around, inside the `MARGIN`. use super::Mosaic; use crate::Rgb32f; @@ -19,20 +22,22 @@ const MARGIN: usize = 6; pub(crate) fn ahd(m: &Mosaic) -> Rgb32f { let (w, h) = (m.w, m.h); - let tiles: Vec<(usize, usize)> = (0..h.div_ceil(TILE)).flat_map(|ty| (0..w.div_ceil(TILE)).map(move |tx| (tx * TILE, ty * TILE))).collect(); - let results: Vec<(usize, usize, usize, usize, Vec<[f32; 3]>)> = tiles - .par_iter() - .map(|&(x0, y0)| { - let (tw, th) = (TILE.min(w - x0), TILE.min(h - y0)); - (x0, y0, tw, th, tile(m, x0, y0, tw, th)) - }) - .collect(); let mut out = Rgb32f::new(w, h); - for (x0, y0, tw, th, px) in results { - for y in 0..th { - out.data[(y0 + y) * w + x0..(y0 + y) * w + x0 + tw].copy_from_slice(&px[y * tw..(y + 1) * tw]); + out.data.par_chunks_mut((w * TILE).max(1)).enumerate().for_each(|(ty, band)| { + let (y0, th) = (ty * TILE, band.len() / w.max(1)); + let tiles: Vec<(usize, usize, Vec<[f32; 3]>)> = (0..w.div_ceil(TILE)) + .into_par_iter() + .map(|tx| { + let (x0, tw) = (tx * TILE, TILE.min(w - tx * TILE)); + (x0, tw, tile(m, x0, y0, tw, th)) + }) + .collect(); + for (x0, tw, px) in tiles { + for (row, src) in band.chunks_exact_mut(w).zip(px.chunks_exact(tw)) { + row[x0..x0 + tw].copy_from_slice(src); + } } - } + }); out } @@ -56,110 +61,100 @@ fn tile(m: &Mosaic, x0: usize, y0: usize, tw: usize, th: usize) -> Vec<[f32; 3]> let (ox, oy) = (x0 as isize - MARGIN as isize, y0 as isize - MARGIN as isize); let mut mos = vec![0f32; n]; let mut col = vec![0u8; n]; - for y in 0..bh { - for x in 0..bw { - mos[y * bw + x] = m.get(ox + x as isize, oy + y as isize); - col[y * bw + x] = m.color(ox + x as isize, oy + y as isize); + for (y, (mrow, crow)) in mos.chunks_exact_mut(bw).zip(col.chunks_exact_mut(bw)).enumerate() { + let ry = super::reflect(oy + y as isize, m.h); + if ox >= 0 && ox as usize + bw <= m.w { + // inside the image: the row as it is, and its colours repeating with the pattern + let x0 = ry * m.w + ox as usize; + mrow.copy_from_slice(&m.data[x0..x0 + bw]); + let period: Vec = (0..m.cfa.width).map(|k| m.cfa.color_at(ox as usize + k, ry)).collect(); + for (c, &p) in crow.iter_mut().zip(period.iter().cycle()) { + *c = p; + } + } else { + for x in 0..bw { + mrow[x] = m.get(ox + x as isize, oy + y as isize); + crow[x] = m.color(ox + x as isize, oy + y as isize); + } } } - let cl = |x: isize, y: isize| -> usize { y.clamp(0, bh as isize - 1) as usize * bw + x.clamp(0, bw as isize - 1) as usize }; + // flat indices of the pixels `d` or more from the buffer's edge, row by row + let inner = |d: usize| (d..bh - d).flat_map(move |y| y * bw + d..y * bw + bw - d); // 1. directional greens let mut gh = vec![0f32; n]; let mut gv = vec![0f32; n]; - for y in 0..bh as isize { - for x in 0..bw as isize { - let i = cl(x, y); - let c = mos[i]; - if col[i] == 1 { - gh[i] = c; - gv[i] = c; - continue; - } - let (l, r) = (mos[cl(x - 1, y)], mos[cl(x + 1, y)]); - let est = (l + r) * 0.5 + (2.0 * c - mos[cl(x - 2, y)] - mos[cl(x + 2, y)]) * 0.25; - gh[i] = super::clamp_between(est, l, r); - let (u, d) = (mos[cl(x, y - 1)], mos[cl(x, y + 1)]); - let est = (u + d) * 0.5 + (2.0 * c - mos[cl(x, y - 2)] - mos[cl(x, y + 2)]) * 0.25; - gv[i] = super::clamp_between(est, u, d); + for i in inner(MARGIN - 3) { + let c = mos[i]; + if col[i] == 1 { + gh[i] = c; + gv[i] = c; + continue; } + let (l, r) = (mos[i - 1], mos[i + 1]); + let est = (l + r) * 0.5 + (2.0 * c - mos[i - 2] - mos[i + 2]) * 0.25; + gh[i] = super::clamp_between(est, l, r); + let (u, d) = (mos[i - bw], mos[i + bw]); + let est = (u + d) * 0.5 + (2.0 * c - mos[i - 2 * bw] - mos[i + 2 * bw]) * 0.25; + gv[i] = super::clamp_between(est, u, d); } - // 2. full candidates - let complete = |g: &[f32]| -> Vec<[f32; 3]> { + // 2. full candidates, and 3. their Lab + // (`p` with channel `c` set to `v`, in registers: writing channels through an index and reading the pixel + // back for its Lab stalled on store forwarding) + let set = |p: [f32; 3], c: u8, v: f32| [if c == 0 { v } else { p[0] }, if c == 1 { v } else { p[1] }, if c >= 2 { v } else { p[2] }]; + let complete = |g: &[f32]| -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { let mut rgb = vec![[0f32; 3]; n]; - for y in 0..bh as isize { - for x in 0..bw as isize { - let i = cl(x, y); - let own = col[i] as usize; - let gvv = g[i]; - let d = |dx: isize, dy: isize| { - let j = cl(x + dx, y + dy); - mos[j] - g[j] - }; - let p = &mut rgb[i]; - p[1] = gvv; - if own == 1 { - let ch = col[cl(x + 1, y)] as usize; - let cv = col[cl(x, y + 1)] as usize; - p[ch] = gvv + (d(-1, 0) + d(1, 0)) * 0.5; - p[cv] = gvv + (d(0, -1) + d(0, 1)) * 0.5; - } else { - p[own] = mos[i]; - p[2 - own] = gvv + (d(-1, -1) + d(1, -1) + d(-1, 1) + d(1, 1)) * 0.25; - } - } + let mut lab_of = vec![[0f32; 3]; n]; + for i in inner(MARGIN - 2) { + let own = col[i]; + let gvv = g[i]; + let d = |j: usize| mos[j] - g[j]; + let p = if own == 1 { + let p = set([0.0, gvv, 0.0], col[i + 1], gvv + (d(i - 1) + d(i + 1)) * 0.5); + set(p, col[i + bw], gvv + (d(i - bw) + d(i + bw)) * 0.5) + } else { + let p = set([0.0, gvv, 0.0], own, mos[i]); + set(p, 2 - own, gvv + (d(i - bw - 1) + d(i - bw + 1) + d(i + bw - 1) + d(i + bw + 1)) * 0.25) + }; + rgb[i] = p; + lab_of[i] = lab(p); } - rgb + (rgb, lab_of) }; - let rh = complete(&gh); - let rv = complete(&gv); - // 3. Lab + homogeneity - let lh: Vec<[f32; 3]> = rh.iter().map(|&p| lab(p)).collect(); - let lv: Vec<[f32; 3]> = rv.iter().map(|&p| lab(p)).collect(); + let (rh, lh) = complete(&gh); + let (rv, lv) = complete(&gv); + // homogeneity let dl = |a: &[f32; 3], b: &[f32; 3]| (a[0] - b[0]).abs(); let dc = |a: &[f32; 3], b: &[f32; 3]| (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2); let mut hh = vec![0u8; n]; let mut hv = vec![0u8; n]; - for y in 1..bh as isize - 1 { - for x in 1..bw as isize - 1 { - let i = cl(x, y); - let (l, r, u, d) = (cl(x - 1, y), cl(x + 1, y), cl(x, y - 1), cl(x, y + 1)); - let eps_l = dl(&lh[i], &lh[l]).max(dl(&lh[i], &lh[r])).min(dl(&lv[i], &lv[u]).max(dl(&lv[i], &lv[d]))); - let eps_c = dc(&lh[i], &lh[l]).max(dc(&lh[i], &lh[r])).min(dc(&lv[i], &lv[u]).max(dc(&lv[i], &lv[d]))); - let mut ch = 0; - let mut cv = 0; - for j in [l, r, u, d] { - if dl(&lh[i], &lh[j]) <= eps_l && dc(&lh[i], &lh[j]) <= eps_c { - ch += 1; - } - if dl(&lv[i], &lv[j]) <= eps_l && dc(&lv[i], &lv[j]) <= eps_c { - cv += 1; - } - } - hh[i] = ch; - hv[i] = cv; - } + for i in inner(MARGIN - 1) { + // left, right, up, down + let (l, r, u, d) = (i - 1, i + 1, i - bw, i + bw); + let (a, b) = (lh[i], lv[i]); + let dlh = [dl(&a, &lh[l]), dl(&a, &lh[r]), dl(&a, &lh[u]), dl(&a, &lh[d])]; + let dch = [dc(&a, &lh[l]), dc(&a, &lh[r]), dc(&a, &lh[u]), dc(&a, &lh[d])]; + let dlv = [dl(&b, &lv[l]), dl(&b, &lv[r]), dl(&b, &lv[u]), dl(&b, &lv[d])]; + let dcv = [dc(&b, &lv[l]), dc(&b, &lv[r]), dc(&b, &lv[u]), dc(&b, &lv[d])]; + let eps_l = dlh[0].max(dlh[1]).min(dlv[2].max(dlv[3])); + let eps_c = dch[0].max(dch[1]).min(dcv[2].max(dcv[3])); + hh[i] = (0..4).map(|k| u8::from(dlh[k] <= eps_l && dch[k] <= eps_c)).sum(); + hv[i] = (0..4).map(|k| u8::from(dlv[k] <= eps_l && dcv[k] <= eps_c)).sum(); } // 4. choose let mut out = Vec::with_capacity(tw * th); - for y in MARGIN..MARGIN + th { - for x in MARGIN..MARGIN + tw { - let (mut sh, mut sv) = (0u32, 0u32); - for dy in -1isize..=1 { - for dx in -1isize..=1 { - let j = cl(x as isize + dx, y as isize + dy); - sh += hh[j] as u32; - sv += hv[j] as u32; - } - } - let i = y * bw + x; - out.push(if sh > sv { - rh[i] - } else if sv > sh { - rv[i] - } else { - [(rh[i][0] + rv[i][0]) * 0.5, (rh[i][1] + rv[i][1]) * 0.5, (rh[i][2] + rv[i][2]) * 0.5] - }); + for i in inner(MARGIN) { + let (mut sh, mut sv) = (0u32, 0u32); + for j in [i - bw - 1, i - bw, i - bw + 1, i - 1, i, i + 1, i + bw - 1, i + bw, i + bw + 1] { + sh += hh[j] as u32; + sv += hv[j] as u32; } + out.push(if sh > sv { + rh[i] + } else if sv > sh { + rv[i] + } else { + [(rh[i][0] + rv[i][0]) * 0.5, (rh[i][1] + rv[i][1]) * 0.5, (rh[i][2] + rv[i][2]) * 0.5] + }); } out } From 79ab54e167fe4cdd12425cfb0f1ea46faeb2c009 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Mon, 12 Oct 2026 18:59:47 +0200 Subject: [PATCH 25/52] Perf: ARW6 decode 20-33 % faster, same samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The vertical inverse 5/3 lifts whole rows at once (`inverse_lift` with samples `w` values wide) instead of gathering each column through a strided iterator and scattering it back: the same integer arithmetic, sequential memory. The horizontal pass is the same function with w = 1. - `0^n 1` codes are counted with one `leading_zeros` over the next MAX_DPT + 1 bits instead of bit by bit (same bits consumed, same errors); each line reserves its values up front. - Colour reconstruction reads plane rows as slices and runs over rows in parallel (it was one thread per tile: 4 threads on a full-frame file whatever the machine). Best of 5 on 4 CPUs, previous and new decoder alternating: ILCE-7RM6 FF HQ 0.56 → 0.46 s, FF Compressed 0.54 → 0.38 s, APS-C HQ 0.34 → 0.23 s, APS-C Compressed 0.35 → 0.24 s (docs/arw6-compression.md → Timings). The decoded mosaic is identical (hash of every sample) on the 8 ARW6 corpus files and 2 private full-frame photos (ISO 100, ISO 12800); the pinned corpus sums still match. Co-Authored-By: Claude Opus 5.5 --- crates/raw/src/llvc.rs | 105 +++++++++++++++++++--------------- crates/raw/src/vendor/arw6.rs | 46 +++++++-------- docs/arw6-compression.md | 11 +++- 3 files changed, 93 insertions(+), 69 deletions(-) diff --git a/crates/raw/src/llvc.rs b/crates/raw/src/llvc.rs index 0e2cc39cc..ad386157c 100644 --- a/crates/raw/src/llvc.rs +++ b/crates/raw/src/llvc.rs @@ -14,6 +14,7 @@ fn corrupt(why: &str) -> RawError { } /// One bit, failing when the reader has run past the `source_bits` of its half. +#[inline(always)] fn bit(bits: &mut Bits<'_>, source_bits: usize) -> Result { let b = bits.get(1); if bits.consumed_bits() > source_bits { @@ -24,12 +25,14 @@ fn bit(bits: &mut Bits<'_>, source_bits: usize) -> Result { /// Count zero bits up to and including the terminating 1 (the `0^n 1` codes), bounded by `MAX_DPT`. fn zeros_then_one(bits: &mut Bits<'_>, source_bits: usize) -> Result { - let mut n = 0; - while bit(bits, source_bits)? == 0 { - n += 1; - if n > MAX_DPT { - return Err(corrupt("DPT code exceeds MAX_DPT")); - } + // the next MAX_DPT + 1 bits at once (more zeros than that is corrupt either way) + let n = bits.peek(MAX_DPT + 1).leading_zeros() - (32 - (MAX_DPT + 1)); + bits.skip((n + 1).min(MAX_DPT + 1)); + if bits.consumed_bits() > source_bits { + return Err(corrupt("past the end of its half")); + } + if n > MAX_DPT { + return Err(corrupt("DPT code exceeds MAX_DPT")); } Ok(n) } @@ -42,7 +45,7 @@ pub(crate) fn vld_decode_line(bits: &mut Bits<'_>, source_bits: usize, width: us let sets = width.div_ceil(4); let (mut abs, mut dpt, mut cnt, mut dpts) = ([0u32; 4], 0u32, sets, 0u8); let (mut abs_v, mut abs_e, mut dpt_v, mut dpt_e, mut zr) = (false, false, true, false, 0u64); - let mut out: Vec = Vec::new(); + let mut out: Vec = Vec::with_capacity(sets * 4); loop { // DPT value decoding (Figure 6.6). if cnt == 0 { @@ -180,43 +183,56 @@ pub(crate) fn band_rows(n: usize, phase: u8) -> (usize, usize) { (low, n - low) } -/// `v[i]` with `i` clamped into range (the whole-sample symmetric extension of the band); 0 for an empty band. -fn at(v: &[i32], i: isize) -> i32 { - let last = v.len() as isize - 1; - if last < 0 { - return 0; - } - v.get(i.clamp(0, last) as usize).copied().unwrap_or(0) -} - /// Saturating narrowing: hostile coefficients clamp instead of overflowing. fn sat(v: i64) -> i32 { v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32 } -/// 1-D inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes). -fn inverse_1d(l: &[i32], h: &[i32], phase: u8) -> Result> { - let (n, p) = (l.len() + h.len(), isize::from(phase)); - if band_rows(n, phase) != (l.len(), h.len()) || phase > 1 { - return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); +/// Sample `i` (`w` values wide) of a band of `n` samples, clamped into range (the whole-sample symmetric extension); +/// `zeros` for an empty band. +#[inline] +fn band_sample<'a>(v: &'a [i32], w: usize, n: usize, i: isize, zeros: &'a [i32]) -> &'a [i32] { + if n == 0 { + return zeros; } - let even: Vec = l - .iter() - .enumerate() - .map(|(j, &lv)| sat(i64::from(lv) - ((i64::from(at(h, j as isize - 1 + p)) + i64::from(at(h, j as isize + p)) + 2) >> 2))) - .collect(); - let mut x = vec![0; n]; - let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); - for (xv, &e) in x.iter_mut().skip(lo).step_by(2).zip(&even) { - *xv = e; - } - for (k, (xv, &hv)) in x.iter_mut().skip(hi).step_by(2).zip(h).enumerate() { - *xv = sat(i64::from(hv) + ((i64::from(at(&even, k as isize - p)) + i64::from(at(&even, k as isize + 1 - p))) >> 1)); - } - Ok(x) + let i = i.clamp(0, n as isize - 1) as usize; + v.get(i * w..(i + 1) * w).unwrap_or(zeros) } -/// Gathers the lines of the `low`/`high` pair along one axis (columns when `vertical`), transforms them, and scatters back. +/// Inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes), of a +/// signal whose samples are `w` values wide: `w = 1` for one line, a band's width for all its columns at once (row +/// by row, as each column alone would give). +fn inverse_lift(l: &[i32], h: &[i32], w: usize, phase: u8) -> Result> { + let w = w.max(1); + let (nl, nh) = (l.len() / w, h.len() / w); + let (n, p) = (nl + nh, isize::from(phase)); + if band_rows(n, phase) != (nl, nh) || phase > 1 { + return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); + } + let zeros = vec![0; w]; + let mut even = vec![0; nl * w]; + for (j, (e, lv)) in even.chunks_exact_mut(w).zip(l.chunks_exact(w)).enumerate() { + let (a, b) = (band_sample(h, w, nh, j as isize - 1 + p, &zeros), band_sample(h, w, nh, j as isize + p, &zeros)); + for (((e, &lv), &a), &b) in e.iter_mut().zip(lv).zip(a).zip(b) { + *e = sat(i64::from(lv) - ((i64::from(a) + i64::from(b) + 2) >> 2)); + } + } + let mut out = vec![0; n * w]; + let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); + for (o, e) in out.chunks_exact_mut(w).skip(lo).step_by(2).zip(even.chunks_exact(w)) { + o.copy_from_slice(e); + } + for (k, (o, hv)) in out.chunks_exact_mut(w).skip(hi).step_by(2).zip(h.chunks_exact(w)).enumerate() { + let (a, b) = (band_sample(&even, w, nl, k as isize - p, &zeros), band_sample(&even, w, nl, k as isize + 1 - p, &zeros)); + for (((o, &hv), &a), &b) in o.iter_mut().zip(hv).zip(a).zip(b) { + *o = sat(i64::from(hv) + ((i64::from(a) + i64::from(b)) >> 1)); + } + } + Ok(out) +} + +/// Inverse transform of the `low`/`high` pair along one axis: down the columns when `vertical` (all at once, row by +/// row), else along each row. fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result { if !low.ok() || !high.ok() { return Err(RawError::Corrupt("ARW6 wavelet: plane size mismatch".into())); @@ -228,17 +244,16 @@ fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result let (nl, nh) = if vertical { (low.height, high.height) } else { (low.width, high.width) }; let n = nl + nh; let (w, h) = if vertical { (across_l, n) } else { (n, across_l) }; + if across_l == 0 { + return Ok(Plane::zeros(w, h)); + } + if vertical { + return Ok(Plane { width: w, height: h, data: inverse_lift(&low.data, &high.data, w, phase)? }); + } let mut out = Plane::zeros(w, h); - let get = |p: &Plane, i: usize| -> Vec { - if vertical { p.data.iter().skip(i).step_by(p.width.max(1)).copied().collect() } else { p.row(i).map(<[i32]>::to_vec).unwrap_or_default() } - }; for i in 0..across_l { - let x = inverse_1d(&get(low, i), &get(high, i), phase)?; - if vertical { - for (o, v) in out.data.iter_mut().skip(i).step_by(w.max(1)).zip(x) { - *o = v; - } - } else if let Some(r) = out.data.get_mut(i * w..(i + 1) * w) { + let x = inverse_lift(low.row(i).unwrap_or_default(), high.row(i).unwrap_or_default(), 1, phase)?; + if let Some(r) = out.data.get_mut(i * w..(i + 1) * w) { r.copy_from_slice(&x); } } diff --git a/crates/raw/src/vendor/arw6.rs b/crates/raw/src/vendor/arw6.rs index f6f535bd6..cb903a6f3 100644 --- a/crates/raw/src/vendor/arw6.rs +++ b/crates/raw/src/vendor/arw6.rs @@ -468,36 +468,36 @@ pub(crate) fn colour(p: &TilePlanes) -> Result> { } let mw = w2.checked_mul(2).ok_or_else(|| corrupt("tile size"))?; let total = len.checked_mul(4).ok_or_else(|| corrupt("tile size"))?; - let at = |q: &Plane, j: usize, x: usize| -> i64 { q.data.get(j * w2 + x).copied().unwrap_or(0) as i64 }; + // (rows as slices: `at` is 0 past a row's end, as never happens once the sizes above match) + let at = |r: &[i32], x: usize| i64::from(r.get(x).copied().unwrap_or(0)); let mut g1 = vec![0i64; len]; - for j in 0..h2 { - let jp = j.saturating_sub(1); - for x in 0..w2 { + g1.par_chunks_mut(w2.max(1)).enumerate().for_each(|(j, row)| { + let (Some(m), Some(rp), Some(rj)) = (p.m.row(j), p.res.row(j.saturating_sub(1)), p.res.row(j)) else { return }; + for (x, g) in row.iter_mut().enumerate() { let xn = (x + 1).min(w2 - 1); - let r = at(&p.res, jp, x) + at(&p.res, jp, xn) + at(&p.res, j, x) + at(&p.res, j, xn); - if let Some(g) = g1.get_mut(j * w2 + x) { - *g = at(&p.m, j, x) - ((r + 4) >> 3); - } + let r = at(rp, x) + at(rp, xn) + at(rj, x) + at(rj, xn); + *g = at(m, x) - ((r + 4) >> 3); } - } - let g = |j: usize, x: usize| g1.get(j * w2 + x).copied().unwrap_or(0); + }); + let g1_row = |j: usize| g1.get(j * w2..(j + 1) * w2).unwrap_or(&[]); + let g = |r: &[i64], x: usize| r.get(x).copied().unwrap_or(0); let mut out = vec![0u16; total]; - for j in 0..h2 { - let jn = (j + 1).min(h2 - 1); - for x in 0..w2 { + // each plane row gives two mosaic rows + out.par_chunks_mut((2 * mw).max(1)).enumerate().for_each(|(j, rows)| { + let (gj, gn) = (g1_row(j), g1_row((j + 1).min(h2 - 1))); + let (Some(res), Some(c1), Some(c2), Some((top, bottom))) = (p.res.row(j), p.c1.row(j), p.c2.row(j), rows.split_at_mut_checked(mw)) else { + return; + }; + for (x, (t, b)) in top.as_chunks_mut::<2>().0.iter_mut().zip(bottom.as_chunks_mut::<2>().0.iter_mut()).enumerate() { let xp = x.saturating_sub(1); - let g2 = ((g(j, xp) + g(j, x) + g(jn, xp) + g(jn, x)) >> 2) + at(&p.res, j, x); - let mean = (g(j, x).clamp(0, 4095) + g2.clamp(0, 4095)) >> 1; - let r = 2 * at(&p.c1, j, x) + mean; - let b = 2 * at(&p.c2, j, x) + mean; - let cells = [(0, r), (1, g(j, x)), (mw, g2), (mw + 1, b)]; - for (off, v) in cells { - if let Some(o) = out.get_mut(2 * j * mw + 2 * x + off) { - *o = curve(v.clamp(0, 4095) as i32); - } + let g2 = ((g(gj, xp) + g(gj, x) + g(gn, xp) + g(gn, x)) >> 2) + at(res, x); + let mean = (g(gj, x).clamp(0, 4095) + g2.clamp(0, 4095)) >> 1; + let cells = [2 * at(c1, x) + mean, g(gj, x), g2, 2 * at(c2, x) + mean]; + for (o, v) in t.iter_mut().chain(b.iter_mut()).zip(cells) { + *o = curve(v.clamp(0, 4095) as i32); } } - } + }); Ok(out) } diff --git a/docs/arw6-compression.md b/docs/arw6-compression.md index 3b65c1d37..a45f8365f 100644 --- a/docs/arw6-compression.md +++ b/docs/arw6-compression.md @@ -171,4 +171,13 @@ Raw decode only, 4 CPUs, release build, best of 3 wall-clock (2026-10-10). Metho | ILCE-7RM6 APS-C, Compressed (HQ) | 29.7 MB | 6592x4372 | 0.35 s | | ILCE-7RM6 APS-C, Compressed | 30.9 MB | 6592x4372 | 0.34 s | -The 1.0 s budget for the full-frame file is met. Parallelised: rayon over the tiles, over the 13 streams of each tile and over its 3 components. +The 1.0 s budget for the full-frame file is met. Parallelised: rayon over the tiles, over the 13 streams of each tile and over its 3 components, and the colour reconstruction over rows. + +2026-10-12 performance pass (output unchanged, bit for bit): the vertical wavelet pass lifts whole rows instead of gathering each column, `0^n 1` codes are counted with one `leading_zeros`, and the colour reconstruction reads rows as slices, in parallel. Same method, best of 5, the previous and the new decoder run alternately on the same machine (4 CPUs, which was busier than on 2026-10-10), best of two rounds: + +| File | Before | After | +|---|---|---| +| ILCE-7RM6 FF, Compressed (HQ) | 0.56 s | 0.46 s | +| ILCE-7RM6 FF, Compressed | 0.54 s | 0.38 s | +| ILCE-7RM6 APS-C, Compressed (HQ) | 0.34 s | 0.23 s | +| ILCE-7RM6 APS-C, Compressed | 0.35 s | 0.24 s | From bc1f138ee3e8beade33bd60e039a1f8f418d453b Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Tue, 13 Oct 2026 01:22:34 +0200 Subject: [PATCH 26/52] Perf: ARW6 decode another 20-34 % faster, same samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The two halves of each stream are independent bit streams: they now decode in parallel (`decode_stream`) instead of one after the other inside each of a tile's 13 streams. - The vertical inverse 5/3 runs its rows in parallel. The horizontal pass lifts each row in place (`lift_row`, scratch space reused per thread), rows in parallel, instead of allocating two vectors per row and copying the result back; a test checks it against the vertical pass, row by row. Best of 3 on 4 CPUs, previous and new decoder alternating: ILCE-7RM6 FF HQ 0.60 → 0.48 s (process CPU 1.14 → 0.87 s), FF Compressed 0.62 → 0.48 s, APS-C HQ 0.34 → 0.23 s, APS-C Compressed 0.31 → 0.24 s (docs/arw6-compression.md → Timings). The decoded mosaic is identical (hash of every sample) on the 8 ARW6 files of the RawDB ILCE-7RM6 set and 3 private A7R VI photos. Co-Authored-By: Claude --- crates/raw/src/llvc.rs | 82 ++++++++++++++++++++++++++++------- crates/raw/src/vendor/arw6.rs | 5 ++- docs/arw6-compression.md | 9 ++++ 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/crates/raw/src/llvc.rs b/crates/raw/src/llvc.rs index ad386157c..17408c760 100644 --- a/crates/raw/src/llvc.rs +++ b/crates/raw/src/llvc.rs @@ -200,9 +200,10 @@ fn band_sample<'a>(v: &'a [i32], w: usize, n: usize, i: isize, zeros: &'a [i32]) } /// Inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes), of a -/// signal whose samples are `w` values wide: `w = 1` for one line, a band's width for all its columns at once (row -/// by row, as each column alone would give). +/// signal whose samples are `w` values wide: a band's width for all its columns at once (row by row, as each column +/// alone would give; rows in parallel). One line (`w = 1`) goes through [`lift_row`]. fn inverse_lift(l: &[i32], h: &[i32], w: usize, phase: u8) -> Result> { + use rayon::prelude::*; let w = w.max(1); let (nl, nh) = (l.len() / w, h.len() / w); let (n, p) = (nl + nh, isize::from(phase)); @@ -211,26 +212,49 @@ fn inverse_lift(l: &[i32], h: &[i32], w: usize, phase: u8) -> Result> { } let zeros = vec![0; w]; let mut even = vec![0; nl * w]; - for (j, (e, lv)) in even.chunks_exact_mut(w).zip(l.chunks_exact(w)).enumerate() { + even.par_chunks_exact_mut(w).zip(l.par_chunks_exact(w)).enumerate().for_each(|(j, (e, lv))| { let (a, b) = (band_sample(h, w, nh, j as isize - 1 + p, &zeros), band_sample(h, w, nh, j as isize + p, &zeros)); for (((e, &lv), &a), &b) in e.iter_mut().zip(lv).zip(a).zip(b) { *e = sat(i64::from(lv) - ((i64::from(a) + i64::from(b) + 2) >> 2)); } - } + }); let mut out = vec![0; n * w]; - let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); - for (o, e) in out.chunks_exact_mut(w).skip(lo).step_by(2).zip(even.chunks_exact(w)) { - o.copy_from_slice(e); - } - for (k, (o, hv)) in out.chunks_exact_mut(w).skip(hi).step_by(2).zip(h.chunks_exact(w)).enumerate() { + let lo = usize::from(phase); + out.par_chunks_exact_mut(w).enumerate().for_each(|(t, o)| { + if t % 2 == lo { + if let Some(e) = even.get((t - lo) / 2 * w..((t - lo) / 2 + 1) * w) { + o.copy_from_slice(e); + } + return; + } + let k = (t - (1 - lo)) / 2; + let Some(hv) = h.get(k * w..(k + 1) * w) else { return }; let (a, b) = (band_sample(&even, w, nl, k as isize - p, &zeros), band_sample(&even, w, nl, k as isize + 1 - p, &zeros)); for (((o, &hv), &a), &b) in o.iter_mut().zip(hv).zip(a).zip(b) { *o = sat(i64::from(hv) + ((i64::from(a) + i64::from(b)) >> 1)); } - } + }); Ok(out) } +/// [`inverse_lift`] of one line (`w = 1`) into `out` (`l.len() + h.len()` values), without allocating: `even` is +/// scratch space. The caller has checked the band sizes. +fn lift_row(l: &[i32], h: &[i32], phase: u8, even: &mut Vec, out: &mut [i32]) { + let (nl, nh, p) = (l.len(), h.len(), isize::from(phase)); + let hs = |i: isize| if nh == 0 { 0 } else { h.get(i.clamp(0, nh as isize - 1) as usize).map_or(0, |&v| i64::from(v)) }; + even.clear(); + even.extend(l.iter().enumerate().map(|(j, &lv)| sat(i64::from(lv) - ((hs(j as isize - 1 + p) + hs(j as isize + p) + 2) >> 2)))); + let even = &*even; + let es = |i: isize| if nl == 0 { 0 } else { even.get(i.clamp(0, nl as isize - 1) as usize).map_or(0, |&v| i64::from(v)) }; + let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); + for (o, &e) in out.iter_mut().skip(lo).step_by(2).zip(even.iter()) { + *o = e; + } + for (k, (o, &hv)) in out.iter_mut().skip(hi).step_by(2).zip(h).enumerate() { + *o = sat(i64::from(hv) + ((es(k as isize - p) + es(k as isize + 1 - p)) >> 1)); + } +} + /// Inverse transform of the `low`/`high` pair along one axis: down the columns when `vertical` (all at once, row by /// row), else along each row. fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result { @@ -250,13 +274,16 @@ fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result if vertical { return Ok(Plane { width: w, height: h, data: inverse_lift(&low.data, &high.data, w, phase)? }); } - let mut out = Plane::zeros(w, h); - for i in 0..across_l { - let x = inverse_lift(low.row(i).unwrap_or_default(), high.row(i).unwrap_or_default(), 1, phase)?; - if let Some(r) = out.data.get_mut(i * w..(i + 1) * w) { - r.copy_from_slice(&x); - } + if band_rows(n, phase) != (nl, nh) || phase > 1 { + return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); } + use rayon::prelude::*; + let mut out = Plane::zeros(w, h); + out.data.par_chunks_exact_mut(w.max(1)).enumerate().for_each_init(Vec::new, |even, (i, r)| { + if let (Some(l), Some(hi)) = (low.row(i), high.row(i)) { + lift_row(l, hi, phase, even, r); + } + }); Ok(out) } @@ -463,6 +490,29 @@ mod tests { assert_eq!(inverse_53_1d_rows(&col(&[3, -4]), &col(&[10, 20, 30]), 1).unwrap().data, vec![5, -5, 9, -17, 13]); } #[test] + fn horizontal_pass_gives_the_vertical_passs_samples() { + // two implementations of one lifting: `lift_row` along rows, `inverse_lift` down columns + let mut s = 0x9e37_79b9_7f4a_7c15u64; + let mut next = || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + (s % 8191) as i32 - 4095 + }; + for n in 1..12usize { + for phase in 0..2u8 { + let ((nl, nh), rows) = (band_rows(n, phase), 3); + let low = Plane { width: nl, height: rows, data: (0..nl * rows).map(|_| next()).collect() }; + let high = Plane { width: nh, height: rows, data: (0..nh * rows).map(|_| next()).collect() }; + let across = inverse_53_1d_cols(&low, &high, phase).unwrap(); + for r in 0..rows { + let down = inverse_53_1d_rows(&col(low.row(r).unwrap()), &col(high.row(r).unwrap()), phase).unwrap(); + assert_eq!(across.row(r).unwrap(), &down.data[..], "n {n}, phase {phase}, row {r}"); + } + } + } + } + #[test] fn inverse_53_rejects_mismatched_bands() { assert!(inverse_53_1d_rows(&col(&[1, 2, 3]), &col(&[1]), 0).is_err()); assert!(inverse_53_1d_rows(&col(&[1, 2]), &Plane::zeros(2, 2), 0).is_err()); diff --git a/crates/raw/src/vendor/arw6.rs b/crates/raw/src/vendor/arw6.rs index cb903a6f3..70f438190 100644 --- a/crates/raw/src/vendor/arw6.rs +++ b/crates/raw/src/vendor/arw6.rs @@ -329,9 +329,10 @@ type HalfLines = (Half, Vec>); fn decode_stream(tile: &[u8], st: &StreamRef, ntu: usize, w2: usize, layouts: &[HalfRows]) -> Result> { let halves = stream_halves(tile, st, ntu)?; let width = line_width(st.group, w2); + // halves are independent bit streams: decode them in parallel halves - .into_iter() - .zip(layouts) + .into_par_iter() + .zip(layouts.par_iter()) .map(|(half, rows)| { let end = half.start.checked_add(half.len).ok_or_else(|| corrupt("half position"))?; let bytes = tile.get(half.start..end).ok_or_else(|| corrupt("half outside the tile"))?; diff --git a/docs/arw6-compression.md b/docs/arw6-compression.md index a45f8365f..334b651f1 100644 --- a/docs/arw6-compression.md +++ b/docs/arw6-compression.md @@ -181,3 +181,12 @@ The 1.0 s budget for the full-frame file is met. Parallelised: rayon over the ti | ILCE-7RM6 FF, Compressed | 0.54 s | 0.38 s | | ILCE-7RM6 APS-C, Compressed (HQ) | 0.34 s | 0.23 s | | ILCE-7RM6 APS-C, Compressed | 0.35 s | 0.24 s | + +2026-10-13 (output unchanged, bit for bit): the two halves of each stream, which are independent bit streams, decode in parallel; the vertical wavelet pass runs its rows in parallel; the horizontal pass lifts each row in place (scratch space reused per thread), rows in parallel, instead of allocating two vectors per row. Same method, best of 3, the previous and the new decoder run alternately, on another machine (Ryzen 7 9800X3D, a 4-CPU cgroup quota shared with other builds); CPU is the process CPU time of the decode (all threads): + +| File | Before | After | CPU before | CPU after | +|---|---|---|---|---| +| ILCE-7RM6 FF, Compressed (HQ) | 0.60 s | 0.48 s | 1.14 s | 0.87 s | +| ILCE-7RM6 FF, Compressed | 0.62 s | 0.48 s | 1.14 s | 0.89 s | +| ILCE-7RM6 APS-C, Compressed (HQ) | 0.34 s | 0.23 s | 0.50 s | 0.40 s | +| ILCE-7RM6 APS-C, Compressed | 0.31 s | 0.24 s | 0.51 s | 0.42 s | From d9cea1404a7f7f09d10074bda9bcd617e96acb7e Mon Sep 17 00:00:00 2001 From: cookingwithsimon <335935942+cookingwithsimon@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:08:53 +0000 Subject: [PATCH 27/52] LR-IMP-PROFILES: presets apply the imported profile they name (#1001) A Lightroom preset's crs:Look (name, group, UUID, amount; the Look table in .lrtemplate files) is kept as profile.look and resolved against the user's imported profiles by name, the group breaking ties. Lightroom's six basic profiles map to ours; its own creative/camera profiles and profiles not imported yet are reported per preset. References are resolved again after profile.import / profile.deleteImported, so profiles and presets can be imported in either order. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FxP2xEy623xzWGqCWXVwtb --- crates/engine/src/cmd/lut_profiles.rs | 3 + crates/engine/src/cmd/preset_files.rs | 14 +- crates/engine/src/lib.rs | 1 + crates/engine/src/preset_import.rs | 31 ++++ crates/engine/src/profile_refs.rs | 205 ++++++++++++++++++++++++++ crates/engine/src/tests_import.rs | 68 +++++++++ docs/parity-checklist.md | 5 +- docs/xmp-interop.md | 15 +- 8 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/profile_refs.rs diff --git a/crates/engine/src/cmd/lut_profiles.rs b/crates/engine/src/cmd/lut_profiles.rs index 349e66ea8..f03809b62 100644 --- a/crates/engine/src/cmd/lut_profiles.rs +++ b/crates/engine/src/cmd/lut_profiles.rs @@ -135,6 +135,8 @@ fn import(s: &mut Session, p: &Value) -> Result { imported.push(json!({"id": id, "name": display, "group": group})); } s.save_prefs()?; + // presets that name these profiles use them now + crate::profile_refs::refresh(s); Ok(json!({"imported": imported, "failed": failed})) } @@ -160,6 +162,7 @@ pub fn specs() -> Vec { } lightcraft_pipeline::lut::unregister(&id); s.save_prefs()?; + crate::profile_refs::refresh(s); Ok(json!({"deleted": id})) }), ] diff --git a/crates/engine/src/cmd/preset_files.rs b/crates/engine/src/cmd/preset_files.rs index fc385f34c..6ece6d63c 100644 --- a/crates/engine/src/cmd/preset_files.rs +++ b/crates/engine/src/cmd/preset_files.rs @@ -33,10 +33,20 @@ fn import(s: &mut Session, p: &Value) -> Result { if let Some(g) = &group { it.preset.group = g.clone(); } + // the profile it names (issue #1001): one the user imported, or ours for a basic one + let mut profile = Value::Null; + if let Some((r, _)) = crate::profile_refs::apply(&mut it.preset.settings, &s.lut_profiles) { + let look = it.preset.settings["profile"]["look"].clone(); + it.unmapped.retain(|u| u != "Look"); + it.unmapped.extend(crate::profile_refs::report(&look, &r)); + profile = json!({"name": look["name"], "id": it.preset.settings["profile"].get("id")}); + } let (name, grp) = (it.preset.name.clone(), it.preset.group.clone()); let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() }; match id { - Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})), + Some(id) => { + imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped, "profile": profile})) + } None => skipped += 1, } } @@ -83,7 +93,7 @@ pub fn specs() -> Vec { "Import Presets", [], None, - "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}", + "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; a profile the preset names applies when it was imported with profile.import (before or after) → {imported: [{id,name,group,file,unmapped,profile: {name, id}|null}], skipped, failed}", always, import ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9631b7d84..70c97b1e1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -56,6 +56,7 @@ pub mod originals; pub mod preset_import; pub mod preset_luminar; pub mod presets; +pub mod profile_refs; pub mod proof; pub mod rename; pub mod segment; diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index 044139ff9..282d08535 100644 --- a/crates/engine/src/preset_import.rs +++ b/crates/engine/src/preset_import.rs @@ -371,6 +371,20 @@ pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_ma let mut values = crate::crs_masks::Values::new(); for (k, v) in fields { let key = format!("crs:{k}"); + // the profile a preset names: its fields as XMP's struct fields (`crs:Look/crs:Name`…) + if k == "Look" + && let Lua::Table(_, look) = v + { + for (f, fv) in look { + let val = match fv { + Lua::Num(n) => format!("{n}"), + Lua::Str(s) => delocalize(s), + _ => continue, + }; + props.insert(format!("crs:Look/crs:{f}"), vec![val]); + } + continue; + } if crate::crs_masks::CONTAINERS.contains(&key.as_str()) { values.insert(key.clone(), crate::crs_masks::from_lua(v)); } @@ -473,6 +487,12 @@ pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: Stri o.remove("wb"); } } + // the profile it names, resolved against the user's imported profiles by `preset.import` + if let Some(look) = crate::profile_refs::look_ref(props) + && let Some(o) = settings.as_object_mut() + { + o.insert("profile".into(), serde_json::json!({"look": look})); + } if settings.as_object().is_none_or(Map::is_empty) { return None; } @@ -660,6 +680,17 @@ text]], z = ZSTR "loc" }"#, assert_eq!(d.light.exposure, 0.35); } + #[test] + fn lrtemplate_keeps_the_profile_it_names() { + let t = r#"s = { title = "Film", value = { settings = { Exposure2012 = 0.2, + Look = { Name = "Portra 400", Amount = 0.5, UUID = "ABCD", Group = "$$$/Pack=Film Pack", Parameters = { Version = "14.0" } } } } }"#; + let v = read_presets("Film.lrtemplate", t.as_bytes(), None).unwrap(); + assert_eq!( + v[0].preset.settings["profile"], + serde_json::json!({"look": {"name": "Portra 400", "amount": 50.0, "group": "Film Pack", "uuid": "abcd"}}) + ); + } + #[test] fn old_process_version_fields_are_approximated() { let t = "s = { title = \"Old\", value = { settings = { Exposure = 0.5, Contrast = 50, FillLight = 20, HighlightRecovery = 30, Clarity = 10, ToneCurve = { 0, 0, 64, 50, 255, 255 } } } }"; diff --git a/crates/engine/src/profile_refs.rs b/crates/engine/src/profile_refs.rs new file mode 100644 index 000000000..b9a7d2d37 --- /dev/null +++ b/crates/engine/src/profile_refs.rs @@ -0,0 +1,205 @@ +//! Profiles named by imported presets (issue #1001). +//! +//! A Lightroom preset doesn't carry its profile's look: it names it (`crs:Look`: name, group, +//! UUID, amount), and many presets get most of their look from that profile. On import the +//! reference is kept in the preset's settings as `profile.look`, and resolved against the +//! profiles the user has imported (by name, the group breaking ties), so the preset applies the +//! profile like the other app does. The six basic profiles of the other app map to our own basic +//! profiles; its other built-in profiles can't be used here and the import says so. +//! +//! References are resolved again whenever profiles are imported or deleted, so a pack works in +//! either order. Only the user's own files are read; no profile content ships with LightCraft. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::cmd::lut_profiles::LutProfile; +use crate::crs::Props; + +/// The profile named by a preset's `crs:Look`, as kept in its settings (`profile.look`): +/// `{name, group?, uuid?, amount}` (amount in percent, like [`lightcraft_develop::Profile`]). +pub fn look_ref(props: &Props) -> Option { + let field = |k: &str| props.get(&format!("crs:Look/crs:{k}")).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty()); + let name = field("Name")?; + // stored as a fraction (1 = 100 %), 0–2 + let amount = field("Amount").and_then(|a| a.parse::().ok()).filter(|a| a.is_finite()).map_or(100.0, |a| (a * 100.0).clamp(0.0, 200.0)); + let mut look = json!({"name": name, "amount": amount}); + if let Some(g) = field("Group") { + look["group"] = json!(g); + } + if let Some(u) = field("UUID") { + look["uuid"] = json!(u.to_ascii_lowercase()); + } + Some(look) +} + +/// What a preset's profile reference resolved to. +#[derive(Clone, Debug, PartialEq)] +pub enum Resolved { + /// A profile the user imported (`lut:…`). + Imported(String), + /// One of our built-in profiles standing in for the other app's basic profile of that name. + BuiltIn(String), + /// One of the other app's own creative or camera profiles: not available here. + OtherAppBuiltIn, + /// A third-party profile the user hasn't imported (yet). + NotImported, +} + +fn norm(s: &str) -> String { + s.split_whitespace().collect::>().join(" ").to_lowercase() +} + +/// The other app's basic profiles and ours that stand in for them. +const BASIC: &[(&str, &str)] = &[ + ("adobe color", "lc.color"), + ("adobe neutral", "lc.neutral"), + ("adobe vivid", "lc.vivid"), + ("adobe landscape", "lc.landscape"), + ("adobe portrait", "lc.portrait"), + ("adobe monochrome", "lc.mono"), +]; + +/// The other app's own profiles: everything named `Adobe …` or `Camera …`, and its numbered +/// creative profiles (`Artistic 01`, `B&W 05`, `Modern 10`, `Vintage 02`). +fn other_app_builtin(name: &str) -> bool { + let n = norm(name); + if n.starts_with("adobe ") || n.starts_with("camera ") { + return true; + } + ["artistic ", "b&w ", "modern ", "vintage "] + .iter() + .any(|p| n.strip_prefix(p).is_some_and(|rest| !rest.is_empty() && rest.len() <= 2 && rest.chars().all(|c| c.is_ascii_digit()))) +} + +/// Resolve `look` (a `profile.look` value) against the imported profiles. +pub fn resolve(look: &Value, luts: &[LutProfile]) -> Resolved { + let name = norm(look.get("name").and_then(Value::as_str).unwrap_or_default()); + let group = look.get("group").and_then(Value::as_str).map(norm); + if !name.is_empty() { + let same_name: Vec<&LutProfile> = luts.iter().filter(|p| norm(&p.name) == name).collect(); + let pick = same_name.iter().find(|p| group.as_deref() == Some(norm(&p.group).as_str())).or(same_name.first()); + if let Some(p) = pick { + return Resolved::Imported(p.id.clone()); + } + } + if let Some((_, ours)) = BASIC.iter().find(|(theirs, _)| *theirs == name) { + return Resolved::BuiltIn((*ours).to_string()); + } + if other_app_builtin(&name) { Resolved::OtherAppBuiltIn } else { Resolved::NotImported } +} + +/// Set (or clear) `profile.id` / `profile.amount` in a preset's partial settings from its +/// `profile.look`. Returns what the reference resolved to (`None`: the preset names no profile) +/// and whether `settings` changed. +pub fn apply(settings: &mut Value, luts: &[LutProfile]) -> Option<(Resolved, bool)> { + let profile = settings.get_mut("profile")?.as_object_mut()?; + let look = profile.get("look")?.clone(); + let r = resolve(&look, luts); + let before = (profile.get("id").cloned(), profile.get("amount").cloned()); + match &r { + Resolved::Imported(id) | Resolved::BuiltIn(id) => { + profile.insert("id".into(), json!(id)); + profile.insert("amount".into(), look.get("amount").cloned().unwrap_or(json!(100.0))); + } + // the photo keeps its own profile + Resolved::OtherAppBuiltIn | Resolved::NotImported => { + profile.remove("id"); + profile.remove("amount"); + } + } + let changed = before != (profile.get("id").cloned(), profile.get("amount").cloned()); + Some((r, changed)) +} + +/// How the import reports a reference it couldn't resolve (one of the preset's `unmapped` names). +pub fn report(look: &Value, r: &Resolved) -> Option { + let name = look.get("name").and_then(Value::as_str).unwrap_or_default(); + match r { + Resolved::Imported(_) | Resolved::BuiltIn(_) => None, + Resolved::OtherAppBuiltIn => Some(format!("Profile “{name}” (Lightroom's own)")), + Resolved::NotImported => Some(format!("Profile “{name}” (not imported yet)")), + } +} + +/// Resolve every user preset's profile reference again (after profiles were imported or deleted). +/// Only the presets that change are touched, so an unchanged list isn't saved again. +pub fn refresh(s: &mut Session) { + let luts = s.lut_profiles.clone(); + let stale: Vec = s + .presets + .iter() + .enumerate() + .filter(|(_, p)| !p.builtin) + .filter(|(_, p)| { + let mut settings = p.settings.clone(); + apply(&mut settings, &luts).is_some_and(|(_, changed)| changed) + }) + .map(|(i, _)| i) + .collect(); + if stale.is_empty() { + return; + } + let presets = &mut *s.presets; + for i in stale { + if let Some(p) = presets.get_mut(i) { + apply(&mut p.settings, &luts); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lut(id: &str, name: &str, group: &str) -> LutProfile { + LutProfile { id: id.into(), name: name.into(), group: group.into(), file: String::new() } + } + + #[test] + fn resolves_by_name_with_the_group_breaking_ties() { + let luts = [lut("lut:a", "Kodak Portra 400", "Films A"), lut("lut:b", "Kodak portra 400", "RNI Films")]; + let look = json!({"name": "Kodak Portra 400", "group": "RNI Films", "amount": 100.0}); + assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:b".into())); + let look = json!({"name": "kodak portra 400", "amount": 100.0}); + assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:a".into()), "first by name without a group"); + assert_eq!(resolve(&json!({"name": "Fuji 400H"}), &luts), Resolved::NotImported); + } + + #[test] + fn other_app_profiles_map_to_ours_or_are_reported() { + assert_eq!(resolve(&json!({"name": "Adobe Monochrome"}), &[]), Resolved::BuiltIn("lc.mono".into())); + for n in ["Adobe Standard", "Camera Faithful", "Artistic 01", "B&W 12", "Vintage 3"] { + assert_eq!(resolve(&json!({"name": n}), &[]), Resolved::OtherAppBuiltIn, "{n}"); + } + assert_eq!(resolve(&json!({"name": "Vintage Film"}), &[]), Resolved::NotImported); + // an imported profile wins over the mapping + assert_eq!(resolve(&json!({"name": "Adobe Vivid"}), &[lut("lut:v", "Adobe Vivid", "Mine")]), Resolved::Imported("lut:v".into())); + } + + #[test] + fn apply_sets_and_clears_the_profile() { + let mut s = json!({"profile": {"look": {"name": "mb1 profile", "amount": 80.0}}, "light": {"highlights": -6.0}}); + assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, false))); + let luts = [lut("lut:mb1", "mb1 profile", "Imported")]; + assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), true))); + assert_eq!((s["profile"]["id"].clone(), s["profile"]["amount"].clone()), (json!("lut:mb1"), json!(80.0))); + assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), false)), "already resolved"); + assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, true)), "profile deleted"); + assert!(s["profile"].get("id").is_none()); + assert_eq!(apply(&mut json!({"light": {}}), &luts), None); + } + + #[test] + fn look_ref_reads_name_group_uuid_and_amount() { + let x = r#" + + + Erich McVey B&W Profiles + + "#; + let props = lightcraft_meta::parse_xmp(x).unwrap().properties; + assert_eq!(look_ref(&props), Some(json!({"name": "mb1 profile", "amount": 75.0, "group": "Erich McVey B&W Profiles", "uuid": "0a1b2c"}))); + assert_eq!(look_ref(&Props::new()), None); + } +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index ed4cc9162..ec5e1437e 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1612,3 +1612,71 @@ fn upper_and_mixed_case_extensions_are_found_by_import_browse_and_sidecar_lookup } let _ = std::fs::remove_dir_all(&src); } + +/// Issue #1001: a Lightroom preset that names a profile applies it once the user has imported a +/// profile of that name (before or after the preset), maps the basic Adobe profiles to ours, and +/// reports the ones it can't use. +#[test] +fn presets_apply_the_imported_profile_they_name() { + let src = temp_dir("lookref"); + let preset = |file: &str, name: &str, look: &str| { + let x = format!( + r#" + + + Film Pack + + "# + ); + std::fs::write(src.join(format!("{file}.xmp")), x).unwrap(); + }; + preset("a1", "Portra Soft", "Portra 400"); + preset("a2", "Mono", "Adobe Monochrome"); + preset("a3", "Artsy", "Artistic 01"); + let mut s = Session::with_demo(); + let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let by_name = |r: &serde_json::Value, n: &str| r["imported"].as_array().unwrap().iter().find(|i| i["name"] == n).cloned().unwrap(); + assert_eq!(by_name(&r, "Portra Soft")["unmapped"], json!(["Profile “Portra 400” (not imported yet)"]), "{r}"); + assert_eq!(by_name(&r, "Mono")["profile"], json!({"name": "Adobe Monochrome", "id": "lc.mono"})); + assert_eq!(by_name(&r, "Artsy")["unmapped"], json!(["Profile “Artistic 01” (Lightroom's own)"])); + let photo = s.catalog.photos().next().unwrap().id; + s.execute("library.select", &json!({"ids": [photo.0]})).unwrap(); + let pid = |s: &Session, n: &str| s.presets.iter().find(|p| p.name == n).unwrap().id.clone(); + let apply = |s: &mut Session, n: &str| { + let id = pid(s, n); + s.execute("preset.apply", &json!({"id": id})).unwrap(); + s.develop_of(photo).unwrap() + }; + // not imported yet: only grain comes across, the photo keeps its profile + let d = apply(&mut s, "Portra Soft"); + assert_eq!((d.profile.id.as_str(), d.grain.amount), ("lc.color", 20.0)); + // the basic Adobe profiles are ours + assert_eq!(apply(&mut s, "Mono").profile.id, "lc.mono"); + s.execute("develop.profile", &json!({"id": "lc.color", "amount": 100})).unwrap(); + assert_eq!(apply(&mut s, "Artsy").profile.id, "lc.color", "Lightroom's own creative profiles aren't ours"); + // importing the profile afterwards makes the preset use it, at the preset's amount + let mut cube = String::from("TITLE \"Portra 400\"\nLUT_3D_SIZE 2\n"); + for b in 0..2 { + for g in 0..2 { + for r in 0..2 { + cube.push_str(&format!("{r} {g} {b}\n")); + } + } + } + std::fs::create_dir_all(src.join("profiles/Film Pack")).unwrap(); + std::fs::write(src.join("profiles/Film Pack/portra.cube"), &cube).unwrap(); + let r = s.execute("profile.import", &json!({"paths": [src.join("profiles").to_string_lossy()]})).unwrap(); + let lut = r["imported"][0]["id"].as_str().unwrap().to_string(); + let d = apply(&mut s, "Portra Soft"); + assert_eq!((d.profile.id.as_str(), d.profile.amount, d.grain.amount), (lut.as_str(), 60.0, 20.0)); + // a preset imported after the profile resolves straight away + preset("a4", "Portra Hard", "portra 400"); + let r = s.execute("preset.import", &json!({"paths": [src.join("a4.xmp").to_string_lossy()]})).unwrap(); + assert_eq!((r["imported"][0]["profile"]["id"].as_str(), r["imported"][0]["unmapped"].clone()), (Some(lut.as_str()), json!([])), "{r}"); + // deleting the profile leaves the photo's own profile alone again + s.execute("profile.deleteImported", &json!({"id": lut})).unwrap(); + s.execute("develop.profile", &json!({"id": "lc.vivid", "amount": 100})).unwrap(); + assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid"); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..00ed83a9b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-IMP-PROFILES: presets apply the imported profile they name (#1001); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -77,7 +77,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | -| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | +| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | @@ -814,3 +814,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. | | 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | | 2026-09-30 – 2026-10-11 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) | +| 2026-10-13 | minor | LR-IMP-PROFILES: presets that name a profile apply the imported profile of that name, and Lightroom's basic profiles map to ours (issue #1001); status unchanged (✅) | diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index ce235e2d3..706a754ed 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -147,7 +147,18 @@ Values pass through our control specs, so anything outside our slider ranges get `crs:CameraProfile` is kept as the selected name in the photo's import warning and the active profile is left as it is: the name carries no rendering data and never selects an Adobe profile. Import reports such a profile as not applied (colours may differ from Lightroom), except Lightroom's default profiles (`Adobe Standard`, `Adobe Color`), which every sidecar names and which LightCraft's own starting look stands in for. -**Not mapped:** camera profiles and looks (`CameraProfile`, `Look`; we have our own profile set), red eye, lens blur, +**Profiles named by presets (`Look`, issue #1001).** A preset keeps the profile it names (`crs:Look`: `Name`, `Group`, +`UUID`, `Amount`; in `.lrtemplate` files the `Look` table) as `profile.look` in its settings. When the user has imported +a profile of that name (`profile.import`; the name compared ignoring case and repeated spaces, the group breaking +ties), applying the preset selects it at the look's amount (`Amount` 1 = 100 %). Lightroom's basic profiles map to our +own (`Adobe Color` → Color, `Adobe Neutral`, `Adobe Vivid`, `Adobe Landscape`, `Adobe Portrait`, `Adobe Monochrome` +→ Monochrome). Any other `Adobe …` / `Camera …` profile and the numbered creative ones (`Artistic 01`, `B&W 05`, +`Modern 10`, `Vintage 02`) can't be used here, and a third-party profile not imported yet leaves the photo's profile as it +is; import reports either per preset (`Profile “Name” (Lightroom's own)` / `(not imported yet)` in `unmapped`). +References are resolved again whenever profiles are imported or deleted, so a pack's profiles and presets can come in +either order. The look's embedded `Parameters` are not read. + +**Not mapped:** camera profiles (`CameraProfile`; we have our own profile set), red eye, lens blur, process-version 2010 field names, and AI features. Local adjustments and spot removal are read as described below. ## Spot removal @@ -198,7 +209,7 @@ carry over are listed in `preset.import`'s `unmapped` as `Mask: `. |---|---| | Ours: `.lcpreset` | JSON `{"format": "lightcraft.preset", "version": 1, "presets": [{id, name, group, settings}]}`, where `settings` is a partial develop-settings object (only the groups the preset includes). A file can hold one preset or many, and every preset keeps its group. Import also accepts a bare preset object or an array of them. | | Export | `preset.export {path, ids?, group?}`: all user presets by default, or the given ids or one group. In the app: File ▸ Export Presets…, Presets panel ▸ ⋯ ▸ Export User Presets…, or right-click a group ▸ Export Group…. | -| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | +| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, a profile named by `Look` that isn't imported, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | | XMP presets | Read with the `crs:` table above, with `crs:Name` as the name (falling back to the file name) and `crs:Group` as the group (falling back to "Imported Presets"). Only the fields the preset sets are included, so applying it leaves everything else alone and the Amount slider scales it like any other preset. We only read XMP presets; we don't write them. | LightCraft ships no third-party presets. Its built-in presets are its own values (`crates/engine/src/presets.rs`). From 25314aaacf39ed2c0616237489ae5bde1bbc94f6 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:09:47 +0300 Subject: [PATCH 28/52] LR-MERGE-PANO: a panorama of raws keeps the as-shot white balance of its frames (#1144) A raw whose white balance is only in the vendor's multipliers (CR2, NEF, ARW...) has no AsShotNeutral of its own. Loading a frame dropped the multipliers and stitch copied the first frame's colour tags, so a panorama of frames that reach the stitcher without a neutral wrote a DNG with neither AsShotNeutral nor AsShotWhiteXY, and a viewer had no white to start from (a strong colour cast until White Balance > Auto). Frames now carry the multipliers (Frame::wb_multipliers, filled from the decoded raw; load_frame_with is split so a decoded raw can be tested without a file). stitch takes the median R/G and B/G of the multipliers of the frames that are in the panorama (the mean of the middle two for an even count; frames without multipliers, or with non-finite or non-positive ones, do not vote) and sets the result's AsShotNeutral to their reciprocal, green = 1. The source's AsShotWhiteXY is dropped with it: a reader takes it before the neutral and the DNG spec makes the two exclusive. With no multipliers anywhere (DNG sources) the first frame's tags are kept, as before. In the app and the CLI a raw without colour matrices already gets a neutral from starting_colour_tags (#965). For frames balanced alike the new neutral renders the same (checked in the engine crate); it differs when the camera balanced the frames differently: the panorama follows the median instead of the first frame. Tests: the frame carries the multipliers; the consensus for odd and even counts, with missing and unusable values; stitching raw-style frames gives the median AsShotNeutral, ignores frames outside the panorama and leaves the colour as it was without multipliers; the written DNG, read back with the raw reader, has the reciprocal AsShotNeutral and the white balance the source's multipliers give. --- crates/engine/src/camera_preview.rs | 30 ++++ crates/engine/src/merge.rs | 1 + crates/merge/src/frame.rs | 250 ++++++++++++++++++++++++---- crates/merge/src/output.rs | 5 +- crates/merge/src/pano/mod.rs | 9 +- crates/merge/tests/white_balance.rs | 191 +++++++++++++++++++++ docs/camera-preview-colour.md | 11 ++ docs/parity-checklist.md | 5 +- 8 files changed, 460 insertions(+), 42 deletions(-) create mode 100644 crates/merge/tests/white_balance.rs diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 741fc391d..fb714025c 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -2404,6 +2404,36 @@ mod tests { } } + /// Photo Merge gives a panorama the white balance of its frames' vendor multipliers (issue #1144). A raw without + /// colour matrices (CR2, NEF…) is merged with the tags above, whose neutral is that white balance already: taking + /// it from the multipliers again leaves the merged DNG rendering as the tags do. + #[test] + fn a_merges_white_balance_from_the_multipliers_is_the_one_the_starting_colour_tags_have() { + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; + for (model, multipliers) in [("Canon EOS 6D Mark II", [2.0, 1.0, 1.5]), ("Canon EOS 600D", [1.9, 1.0, 1.7])] { + let mut raw = raw_of(RawFormat::Cr2, "Canon", model, Default::default()); + raw.wb_multipliers = Some(multipliers); + let tags = starting_colour_tags(&mut raw, &[]).expect("tags for a raw without colour matrices"); + let neutral = tags.as_shot_neutral.expect("the tags carry the as-shot white balance"); + // the reciprocal of the multipliers, up to the scale a neutral is given in + let scale = neutral[1] * multipliers[1] as f64; + for i in 0..3 { + assert!((neutral[i] * multipliers[i] as f64 - scale).abs() < 1e-3, "{model}: {neutral:?} vs 1 / {multipliers:?}"); + } + let mut merged = lightcraft_merge::FrameColor::Camera(Box::new(tags.clone())); + merged.set_as_shot_multipliers(multipliers); + let lightcraft_merge::FrameColor::Camera(merged) = merged else { panic!("camera colour") }; + let (a, b) = (camera_transform_of(&tags, as_shot_white_of(&tags, None)), camera_transform_of(&merged, as_shot_white_of(&merged, None))); + for i in 0..3 { + assert!((a.wb[i] - b.wb[i]).abs() < 1e-4 * a.wb[i], "{model}: {:?} vs {:?}", a.wb, b.wb); + } + for (ra, rb) in a.matrix.0.iter().zip(b.matrix.0) { + assert!(ra.iter().zip(rb).all(|(x, y)| (x - y).abs() < 1e-4), "{model}: {:?} vs {:?}", a.matrix, b.matrix); + } + assert_eq!(a.baseline_exposure, b.baseline_exposure); + } + } + /// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep. fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook { CameraLook { diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index 6a51dc316..c1b17be6d 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -183,6 +183,7 @@ impl MergeJob { orientation: r.orientation, metadata: r.metadata, baseline_exposure: r.baseline_exposure, + wb_multipliers: None, }; f.orient(); merged.push(f); diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs index 69743a1b3..ae91adffe 100644 --- a/crates/merge/src/frame.rs +++ b/crates/merge/src/frame.rs @@ -6,7 +6,7 @@ use lightcraft_geom::Orientation; use lightcraft_meta::Metadata; use lightcraft_raster::Rgb32f; use lightcraft_raster::resample::{Filter, fit}; -use lightcraft_raw::ColorData; +use lightcraft_raw::{ColorData, RawImage}; use crate::{MergeError, Result}; @@ -19,6 +19,21 @@ pub enum FrameColor { Linear { to_xyz_d50: Mat3 }, } +impl FrameColor { + /// Make `multipliers` (green = 1) the as-shot white balance of camera colour. A DNG says it as `AsShotNeutral`, the + /// reciprocal of the multipliers. The source's `AsShotWhiteXY` goes: a reader takes it before the neutral and the + /// DNG spec has the two exclude each other, so a stale one would hide the white balance set here. Linear colour has + /// no white balance to set, and multipliers that aren't finite and above zero change nothing. + pub fn set_as_shot_multipliers(&mut self, multipliers: [f32; 3]) { + if let FrameColor::Camera(c) = self + && multipliers.iter().all(|v| v.is_finite() && *v > 0.0) + { + c.as_shot_neutral = Some(multipliers.map(|v| 1.0 / f64::from(v))); + c.as_shot_white_xy = None; + } + } +} + /// One input photo. #[derive(Clone, Debug)] pub struct Frame { @@ -36,6 +51,9 @@ pub struct Frame { pub metadata: Metadata, /// Raw files: the source's `BaselineExposure` (EV), carried into the output. pub baseline_exposure: f64, + /// Raw files: the as-shot white-balance multipliers from the vendor's maker notes (green = 1), for formats such + /// as CR2 and NEF that have no `AsShotNeutral` of their own. `None` when the file doesn't say. + pub wb_multipliers: Option<[f32; 3]>, } impl Frame { @@ -53,6 +71,7 @@ impl Frame { orientation: Orientation::Normal, metadata: Metadata::default(), baseline_exposure: 0.0, + wb_multipliers: None, } } @@ -81,6 +100,29 @@ pub fn exif_exposure(m: &Metadata) -> Option { Some((t * iso / (n * n)).log2()) } +/// The as-shot white balance `frames` agree on, as multipliers with green = 1: the median R/G and the median B/G of +/// the frames that have usable multipliers (all three finite and above zero), so one frame the camera balanced +/// differently doesn't move it. With an even number of voters the median is the mean of the middle two. Frames without +/// usable multipliers don't vote; `None` when none does. +pub fn consensus_multipliers<'a>(frames: impl IntoIterator) -> Option<[f32; 3]> { + let (mut r_over_g, mut b_over_g) = (Vec::new(), Vec::new()); + for [r, g, b] in frames.into_iter().filter_map(|f| f.wb_multipliers) { + if [r, g, b].iter().all(|v| v.is_finite() && *v > 0.0) { + r_over_g.push(f64::from(r) / f64::from(g)); + b_over_g.push(f64::from(b) / f64::from(g)); + } + } + Some([median(r_over_g)? as f32, 1.0, median(b_over_g)? as f32]) +} + +/// The median of `v` (the mean of the middle two when the count is even); `None` when empty. +fn median(mut v: Vec) -> Option { + v.sort_by(f64::total_cmp); + let upper = *v.get(v.len() / 2)?; + let lower = *v.get(v.len().checked_sub(1)? / 2)?; + Some((lower + upper) / 2.0) +} + /// The saturation level of a frame: 1.0, or the (lower) level at which values pile up when the /// file's white level is optimistic. Returned slightly below the level. pub fn detect_clip(img: &Rgb32f) -> f32 { @@ -119,41 +161,8 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colo let metadata = lightcraft_meta::extract(bytes); let exposure = exif_exposure(&metadata); if lightcraft_raw::probe(bytes).is_some() { - let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; - // geometric lens corrections stay with the develop settings ("profile corrections") - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - if let Some(color) = colour(&mut raw, bytes) { - raw.color = color; - } - let small = max_edge.is_some_and(|m| m <= 1200); - let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; - let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?; - for p in image.data.iter_mut() { - *p = p.map(|v| v.max(0.0)); - } - let clip = detect_clip(&image); - if let Some(m) = max_edge - && image.width.max(image.height) > m - { - image = fit(&image, m, m, Filter::Box); - } - let mut f = Frame { - image, - clip, - exposure, - raw: true, - color: FrameColor::Camera(Box::new(raw.color.clone())), - orientation: raw.orientation, - metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() }, - baseline_exposure: raw.color.baseline_exposure, - }; - if f.exposure.is_none() { - f.exposure = exif_exposure(&f.metadata); - } - if orient { - f.orient(); - } - return Ok(f); + let raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; + return raw_frame(raw, bytes, metadata, exposure, max_edge, orient, colour); } let opts = match max_edge { Some(m) => lightcraft_codecs::DecodeOptions::fit(m as u32, m as u32), @@ -176,6 +185,7 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colo orientation: Orientation::from_exif(d.orientation), metadata, baseline_exposure: 0.0, + wb_multipliers: None, }; if orient { f.orient(); @@ -183,6 +193,54 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colo Ok(f) } +/// A decoded raw (from `bytes`, which `colour` may read) as a frame: developed to camera RGB, with its colour tags, +/// its as-shot white-balance multipliers and its orientation. +fn raw_frame( + mut raw: RawImage, + bytes: &[u8], + metadata: Metadata, + exposure: Option, + max_edge: Option, + orient: bool, + colour: &RawColour, +) -> Result { + // geometric lens corrections stay with the develop settings ("profile corrections") + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + if let Some(color) = colour(&mut raw, bytes) { + raw.color = color; + } + let small = max_edge.is_some_and(|m| m <= 1200); + let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; + let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?; + for p in image.data.iter_mut() { + *p = p.map(|v| v.max(0.0)); + } + let clip = detect_clip(&image); + if let Some(m) = max_edge + && image.width.max(image.height) > m + { + image = fit(&image, m, m, Filter::Box); + } + let mut f = Frame { + image, + clip, + exposure, + raw: true, + color: FrameColor::Camera(Box::new(raw.color.clone())), + orientation: raw.orientation, + metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() }, + baseline_exposure: raw.color.baseline_exposure, + wb_multipliers: raw.wb_multipliers, + }; + if f.exposure.is_none() { + f.exposure = exif_exposure(&f.metadata); + } + if orient { + f.orient(); + } + Ok(f) +} + #[cfg(test)] mod tests { use super::*; @@ -208,4 +266,124 @@ mod tests { } assert!((detect_clip(&img) - 0.9 * 0.995).abs() < 1e-6); } + + /// A 16×12 CR2-style mosaic (no colour tags, as the vendor decoders leave them) with as-shot multipliers. + fn mosaic(wb_multipliers: Option<[f32; 3]>) -> RawImage { + use lightcraft_raw::{BlackLevel, Cfa, OpcodeLists, RawData, RawFormat, Rect}; + let (w, h) = (16usize, 12usize); + RawImage { + format: RawFormat::Cr2, + width: w, + height: h, + cpp: 1, + data: RawData::U16((0..w * h).map(|i| (100 + (i * 37) % 900) as u16).collect()), + cfa: Cfa::bayer("RGGB"), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![1023.0], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, w, h), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers, + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Metadata::default(), + } + } + + fn frame_of(raw: RawImage, colour: &RawColour) -> Frame { + raw_frame(raw, &[], Metadata::default(), None, None, true, colour).unwrap() + } + + /// Issue #1144: the as-shot multipliers of a CR2, NEF… (its only white balance) were dropped on loading, so + /// nothing downstream could tell a merged DNG what the camera had balanced to. + #[test] + fn a_raw_frame_carries_the_as_shot_multipliers() { + let wb = [2.05, 1.0, 1.62]; + let f = frame_of(mosaic(Some(wb)), &|_, _| None); + assert!(f.raw); + assert_eq!(f.wb_multipliers, Some(wb)); + // colour tags the caller works out leave them alone + let own = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.6]), ..Default::default() }; + let f = frame_of(mosaic(Some(wb)), &|_, _| Some(own.clone())); + assert_eq!(f.wb_multipliers, Some(wb)); + assert_eq!(f.color, FrameColor::Camera(Box::new(own))); + // a file that gives none gives none + assert_eq!(frame_of(mosaic(None), &|_, _| None).wb_multipliers, None); + assert_eq!(Frame::from_linear_rec2020(Rgb32f::new(2, 2), None).wb_multipliers, None); + } + + fn voter(wb_multipliers: Option<[f32; 3]>) -> Frame { + Frame { wb_multipliers, ..Frame::from_linear_rec2020(Rgb32f::new(2, 2), None) } + } + + fn consensus(wb: &[Option<[f32; 3]>]) -> Option<[f32; 3]> { + let frames: Vec = wb.iter().map(|m| voter(*m)).collect(); + consensus_multipliers(&frames) + } + + fn close(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) + } + + #[test] + fn the_consensus_is_the_median_of_the_voters() { + // one voter is its own median + assert_eq!(consensus(&[Some([2.0, 1.0, 1.6])]), Some([2.0, 1.0, 1.6])); + // an odd count takes the middle one of each ratio, whichever frame it came from, whatever the order + let odd = [Some([2.4, 1.0, 1.4]), Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6])]; + assert_eq!(consensus(&odd), Some([2.0, 1.0, 1.6])); + // an outlier doesn't pull it + let outlier = [Some([2.0, 1.0, 1.6]), Some([2.1, 1.0, 1.5]), Some([1.9, 1.0, 1.7]), Some([5.0, 1.0, 0.7]), Some([2.0, 1.0, 1.6])]; + assert_eq!(consensus(&outlier), Some([2.0, 1.0, 1.6])); + // an even count takes the mean of the middle two + let even = [Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6]), Some([2.2, 1.0, 1.5]), Some([2.4, 1.0, 1.4])]; + assert!(close(consensus(&even).unwrap(), [2.1, 1.0, 1.55]), "{:?}", consensus(&even)); + let two = [Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])]; + assert!(close(consensus(&two).unwrap(), [2.5, 1.0, 1.5]), "{:?}", consensus(&two)); + // green is the unit even when a file's multipliers aren't normalised to it + assert!(close(consensus(&[Some([4.0, 2.0, 3.0])]).unwrap(), [2.0, 1.0, 1.5])); + } + + #[test] + fn frames_without_usable_multipliers_do_not_vote() { + assert_eq!(consensus(&[]), None); + assert_eq!(consensus(&[None, None]), None); + let bad = |m: [f32; 3]| consensus(&[Some(m)]); + assert_eq!(bad([0.0, 1.0, 1.5]), None); + assert_eq!(bad([2.0, 0.0, 1.5]), None); + assert_eq!(bad([2.0, 1.0, -1.5]), None); + assert_eq!(bad([f32::NAN, 1.0, 1.5]), None); + assert_eq!(bad([2.0, f32::INFINITY, 1.5]), None); + assert_eq!(bad([2.0, 1.0, f32::NEG_INFINITY]), None); + // mixed: the good ones decide, the missing and the unusable ones are not counted as anything + let mixed = [None, Some([2.0, 1.0, 1.6]), Some([f32::NAN, 1.0, 1.0]), Some([2.2, 1.0, 1.4]), Some([0.0, 0.0, 0.0])]; + assert!(close(consensus(&mixed).unwrap(), [2.1, 1.0, 1.5]), "{:?}", consensus(&mixed)); + assert_eq!(consensus(&[None, Some([2.0, 1.0, 1.6]), None]), Some([2.0, 1.0, 1.6])); + } + + #[test] + fn multipliers_become_the_reciprocal_as_shot_neutral() { + use lightcraft_color::{D50, Xy}; + let tagged = ColorData { as_shot_neutral: Some([0.9, 1.0, 0.9]), as_shot_white_xy: Some(Xy::new(0.34, 0.35)), ..Default::default() }; + let mut color = FrameColor::Camera(Box::new(tagged.clone())); + color.set_as_shot_multipliers([2.0, 1.0, 1.6]); + let FrameColor::Camera(c) = &color else { panic!("still camera colour") }; + assert_eq!(c.as_shot_neutral, Some([0.5, 1.0, 1.0 / f64::from(1.6f32)])); + assert_eq!(c.as_shot_white_xy, None, "a stale AsShotWhiteXY would be read before the neutral"); + assert_eq!(**c, ColorData { as_shot_neutral: c.as_shot_neutral, as_shot_white_xy: None, ..tagged.clone() }, "nothing else changes"); + // multipliers that aren't usable change nothing + for bad in [[0.0, 1.0, 1.0], [2.0, f32::NAN, 1.6], [2.0, 1.0, f32::INFINITY], [-2.0, 1.0, 1.6]] { + let mut color = FrameColor::Camera(Box::new(ColorData { as_shot_white_xy: Some(D50), ..Default::default() })); + let before = color.clone(); + color.set_as_shot_multipliers(bad); + assert_eq!(color, before, "{bad:?}"); + } + // linear colour has no white balance to set + let mut linear = Frame::from_linear_rec2020(Rgb32f::new(2, 2), None).color; + let before = linear.clone(); + linear.set_as_shot_multipliers([2.0, 1.0, 1.6]); + assert_eq!(linear, before); + } } diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index 8128644b6..2d966c2aa 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -4,8 +4,9 @@ //! - Pixel values are scaled so the brightest pixel sits just below 1.0 (the white level); the //! scale is carried in `BaselineExposure`, so a default rendering looks like the reference frame. //! - Raw sources: the reference frame's DNG colour tags (`ColorMatrix*`, `ForwardMatrix*`, -//! `CameraCalibration*`, `AsShotNeutral`, illuminants) are carried over unchanged — the data is -//! still camera RGB. +//! `CameraCalibration*`, `AsShotNeutral`, illuminants) are carried over — the data is still camera +//! RGB. A raw with its white balance only in vendor multipliers (CR2, NEF…) has none of its own: the +//! result's `AsShotNeutral` comes from the frames' multipliers ([`FrameColor::set_as_shot_multipliers`]). //! - Other sources: the "camera" is the file's linear RGB space; `ColorMatrix1` = XYZ(D65) → RGB, //! `ForwardMatrix1` = RGB → XYZ(D50), `AsShotNeutral` = (1, 1, 1). diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano/mod.rs index 309505614..076c0b85e 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano/mod.rs @@ -25,7 +25,7 @@ use serde::{Deserialize, Serialize}; pub use project::Projection; -use crate::frame::{Frame, FrameColor}; +use crate::frame::{Frame, FrameColor, consensus_multipliers}; use crate::linalg; use crate::{MergeError, Progress, Result, check}; use blend::{Blender, Buf, push_pull}; @@ -362,6 +362,11 @@ pub fn stitch(frames: Vec, opts: &PanoOptions, progress: &Progress) -> Re image = finish::fill_edges(&image, &alpha); } let r = &frames[used[0]]; + // the panorama's white balance is the median of the frames that are in it, not whichever frame came first + let mut color = r.color.clone(); + if let Some(multipliers) = consensus_multipliers(used.iter().filter_map(|&i| frames.get(i))) { + color.set_as_shot_multipliers(multipliers); + } Ok(PanoResult { image, alpha, @@ -375,7 +380,7 @@ pub fn stitch(frames: Vec, opts: &PanoOptions, progress: &Progress) -> Re fov, focal: f_out, origin: (ox, oy), - color: r.color.clone(), + color, metadata: r.metadata.clone(), raw: r.raw, baseline_exposure: r.baseline_exposure, diff --git a/crates/merge/tests/white_balance.rs b/crates/merge/tests/white_balance.rs new file mode 100644 index 000000000..4139250f6 --- /dev/null +++ b/crates/merge/tests/white_balance.rs @@ -0,0 +1,191 @@ +//! A panorama of raw files keeps their as-shot white balance (issue #1144). +//! +//! CR2, NEF and the like carry their white balance as vendor multipliers, not as DNG tags. The merged DNG has +//! to say it as `AsShotNeutral`, or a viewer has no white to start from and shows a colour cast. + +use lightcraft_color::{D50, Xy}; +use lightcraft_geom::Orientation; +use lightcraft_merge::frame::{Frame, FrameColor, consensus_multipliers, detect_clip}; +use lightcraft_merge::output::color_data; +use lightcraft_merge::pano::{PanoOptions, PanoResult, Projection, stitch}; +use lightcraft_merge::{DngSamples, no_progress, write_linear_dng}; +use lightcraft_meta::Metadata; +use lightcraft_raster::Rgb32f; +use lightcraft_raw::ColorData; + +type Multipliers = Option<[f32; 3]>; + +/// A camera that sees linear sRGB, with the colour matrices a DNG gives but no white balance of its own, as a raw +/// file whose white balance is only in the vendor multipliers would be after the app has worked its colour out. +fn camera_without_white_balance() -> ColorData { + let srgb = FrameColor::Linear { + to_xyz_d50: lightcraft_color::bradford(lightcraft_color::D65, lightcraft_color::D50).mul(&lightcraft_color::SRGB.to_xyz()), + }; + ColorData { as_shot_neutral: None, ..color_data(&srgb) } +} + +fn raw_frame(image: Rgb32f, color: &ColorData, wb_multipliers: Multipliers) -> Frame { + let clip = detect_clip(&image); + Frame { + image, + clip, + exposure: None, + raw: true, + color: FrameColor::Camera(Box::new(color.clone())), + orientation: Orientation::Normal, + metadata: Metadata::default(), + baseline_exposure: 0.0, + wb_multipliers, + } +} + +/// Overlapping views of one scene, one frame per entry of `wb`, in order. +fn shoot(color: &ColorData, wb: &[Multipliers]) -> Vec { + let n = wb.len(); + let yaws: Vec = (0..n).map(|i| (i as f64 - (n as f64 - 1.0) / 2.0) * 25.0).collect(); + let views = lightcraft_merge::synth::pano_views(400, 300, 350.0, &yaws).unwrap(); + views.into_iter().zip(wb).map(|(image, m)| raw_frame(image, color, *m)).collect() +} + +fn pano(frames: Vec) -> PanoResult { + stitch(frames, &PanoOptions { projection: Projection::Cylindrical, ..Default::default() }, &no_progress).unwrap() +} + +fn neutral_of(res: &PanoResult) -> Option<[f64; 3]> { + match &res.color { + FrameColor::Camera(c) => c.as_shot_neutral, + FrameColor::Linear { .. } => None, + } +} + +fn reciprocal(m: [f64; 3]) -> [f64; 3] { + m.map(|v| 1.0 / v) +} + +fn assert_close(got: [f64; 3], want: [f64; 3], tolerance: f64) { + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() <= tolerance), "{got:?} vs {want:?}"); +} + +/// The panorama as a DNG and read back with the repo's own raw reader. +fn dng_of(res: &PanoResult) -> lightcraft_raw::RawImage { + let bytes = write_linear_dng(&res.image, &res.color, Orientation::Normal, &res.metadata, res.baseline_exposure, DngSamples::U16).unwrap(); + lightcraft_raw::decode(&bytes).unwrap() +} + +const DAYLIGHT: [f32; 3] = [2.0, 1.0, 1.6]; + +#[test] +fn a_panorama_of_an_odd_number_of_raw_frames_has_their_median_white_balance() { + // three frames the camera balanced slightly differently: the middle R/G and the middle B/G, from different frames + let wb = [Some([2.1, 1.0, 1.55]), Some([1.9, 1.0, 1.7]), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used, [0, 1, 2]); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + let FrameColor::Camera(c) = &res.color else { panic!("camera colour") }; + assert_eq!(c.as_shot_white_xy, None); +} + +#[test] +fn a_panorama_of_an_even_number_of_frames_takes_the_mean_of_the_middle_two() { + let wb = [Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6]), Some([2.2, 1.0, 1.5]), Some([2.4, 1.0, 1.4])]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used.len(), 4); + let (r, b) = ((f64::from(2.0f32) + f64::from(2.2f32)) / 2.0, (f64::from(1.6f32) + f64::from(1.5f32)) / 2.0); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([r, 1.0, b]), 1e-6); + // two frames: the mean of both + let res = pano(shoot(&camera_without_white_balance(), &[Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])])); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.5, 1.0, 1.5]), 1e-6); +} + +#[test] +fn frames_without_multipliers_do_not_vote() { + // one frame of three has none (a DNG, say): the other two decide + let wb = [Some([2.0, 1.0, 1.6]), None, Some([2.2, 1.0, 1.4])]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used.len(), 3); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.1, 1.0, 1.5]), 1e-6); + // a first frame without them doesn't hide the others' + let wb = [None, Some(DAYLIGHT), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + // unusable values don't count either + let wb = [Some([f32::NAN, 1.0, 1.0]), Some(DAYLIGHT), Some([0.0, 1.0, 1.4]), Some([2.0, f32::INFINITY, 1.6]), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn a_frame_outside_the_panorama_does_not_vote() { + let color = camera_without_white_balance(); + let mut frames = shoot(&color, &[Some(DAYLIGHT), Some(DAYLIGHT), Some(DAYLIGHT)]); + // four photos of other scenes, balanced very differently: counted, they would be the majority + let others = lightcraft_scenes::demo_library().into_iter().filter(|s| s.kind != lightcraft_scenes::Kind::Canyon).take(4); + frames.extend(others.map(|scene| raw_frame(scene.render(400, 300), &color, Some([4.0, 1.0, 0.8])))); + assert_eq!(frames.len(), 7); + let res = pano(frames); + assert_eq!(res.used, [0, 1, 2], "the others don't connect"); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn without_any_multipliers_the_colour_is_the_first_frames_as_before() { + for source in [ + camera_without_white_balance(), + ColorData::default(), + ColorData { as_shot_neutral: Some([0.5, 1.0, 0.62]), ..camera_without_white_balance() }, + ] { + let frames = shoot(&source, &[None, None, None]); + let first = frames[0].color.clone(); + let res = pano(frames); + assert_eq!(res.color, first); + assert_eq!(neutral_of(&res), source.as_shot_neutral); + } + // a DNG's own AsShotWhiteXY survives when no frame brings multipliers + let xy = ColorData { as_shot_white_xy: Some(D50), ..camera_without_white_balance() }; + assert_eq!(pano(shoot(&xy, &[None, None, None])).color, FrameColor::Camera(Box::new(xy))); +} + +#[test] +fn the_consensus_replaces_a_neutral_or_white_point_the_first_frame_had() { + // a neutral the app worked out for the first frame, and a white point of the source: both give way + let had = ColorData { as_shot_neutral: Some([0.9, 1.0, 0.8]), as_shot_white_xy: Some(Xy::new(0.31, 0.33)), ..camera_without_white_balance() }; + let res = pano(shoot(&had, &[Some(DAYLIGHT), Some(DAYLIGHT), Some(DAYLIGHT)])); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + let FrameColor::Camera(c) = &res.color else { panic!("camera colour") }; + assert_eq!(c.as_shot_white_xy, None); + // everything else of the first frame's colour is as it was + assert_eq!(**c, ColorData { as_shot_neutral: c.as_shot_neutral, as_shot_white_xy: None, ..had }); +} + +#[test] +fn the_dng_says_the_median_white_balance_as_as_shot_neutral() { + let source = camera_without_white_balance(); + let wb = [Some([2.1, 1.0, 1.55]), Some([1.9, 1.0, 1.7]), Some(DAYLIGHT)]; + let frames = shoot(&source, &wb); + let median = consensus_multipliers(&frames).unwrap(); + let res = pano(frames); + let raw = dng_of(&res); + // AsShotNeutral is the reciprocal of the median multipliers, to the precision of a TIFF rational + let got = raw.color.as_shot_neutral.expect("AsShotNeutral in the DNG"); + assert_close(got, reciprocal(median.map(f64::from)), 2e-6); + assert_close(got, [0.5, 1.0, 0.625], 2e-6); + assert_eq!(raw.color.as_shot_white_xy, None, "the DNG spec has the two tags exclude each other"); + // what a viewer starts from: the white and the camera transform the source's multipliers give + let want = lightcraft_raw::color::as_shot_white_of(&source, Some(median)); + let xy = lightcraft_raw::color::as_shot_white_xy(&raw); + assert!((xy.x - want.x).abs() < 1e-4 && (xy.y - want.y).abs() < 1e-4, "{xy:?} vs {want:?}"); + let (got, want) = (lightcraft_raw::color::camera_transform(&raw, xy), lightcraft_raw::color::camera_transform_of(&source, want)); + assert!(got.wb.iter().zip(want.wb).all(|(a, b)| (a - b).abs() < 1e-3), "{:?} vs {:?}", got.wb, want.wb); + // the colour model and the picture are the source's, and the same without the colour matrices + assert_eq!(raw.color.forward_matrix[0].is_some(), source.forward_matrix[0].is_some()); + assert_eq!((raw.width, raw.height, raw.cpp), (res.image.width, res.image.height, 3)); + let bare = dng_of(&pano(shoot(&ColorData::default(), &wb))); + assert_close(bare.color.as_shot_neutral.expect("AsShotNeutral without colour matrices"), [0.5, 1.0, 0.625], 2e-6); +} + +#[test] +fn without_multipliers_the_dng_has_no_as_shot_neutral_as_before() { + let res = pano(shoot(&ColorData::default(), &[None, None, None])); + let raw = dng_of(&res); + assert_eq!((raw.color.as_shot_neutral, raw.color.as_shot_white_xy), (None, None)); +} diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 1210e6b54..f092e88f0 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -194,6 +194,17 @@ as the fitted table does, and the chroma curve has no DNG tag, so it is left out HDR merge of three copies of the photo against the photo alone (200 px, CIE76): mean ΔE 1.07 and 0.47, 95th percentile 2.65 and 1.15 (26 and 23 before, with no colour tags; 1.47 and 0.66 with the table left out too). +A raw whose white balance is only in its vendor multipliers (CR2, NEF, ARW…) has no `AsShotNeutral` of its own, so a +frame carries the multipliers (`Frame::wb_multipliers`) next to its colour tags. A panorama takes its white balance +from the frames that are in it: the median R/G and the median B/G of their multipliers (the mean of the middle two +for an even count; frames without usable multipliers, or outside the panorama, do not vote), written as +`AsShotNeutral`, the reciprocal, and any `AsShotWhiteXY` of the source dropped (a reader takes it first, and the +DNG spec has the two exclude each other). So the panorama is balanced for what the camera measured for most of its +frames, not for whichever frame came first. With no multipliers anywhere (DNG sources) the first frame's tags are kept +as before. For frames balanced alike this is the neutral `starting_colour_tags` already wrote, so the merged DNG +renders as before; it matters when the camera balanced the frames differently, and for a caller that loads frames +without a colour hook (`load_frame`), whose raw frames carried no white balance at all (issue #1144). + ## Nikon crop and preview colour metadata Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, height]` crop. The decoder validates the rectangle against the active sensor area and falls back to that area for missing or invalid values. The CFA origin is unchanged; cropping follows demosaicing. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..7fea9cf66 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-MERGE-PANO: a panorama of raws takes its as-shot white balance from the median of its frames (#1144); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -348,7 +348,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | -| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | +| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | @@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | From 0cd7513a8d76a35436efb2d528889afb969965c9 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:14:16 +0300 Subject: [PATCH 29/52] LR-MERGE-HDR: an HDR merge of raws keeps the as-shot white balance of its inputs (#1144) The HDR merge has the same cause as the panorama: its result took the reference frame's colour tags, and a raw whose white balance is only in the vendor's multipliers (CR2, NEF, ARW...) has no AsShotNeutral in them. merge_hdr now takes the median multipliers of all its inputs (the rule of consensus_multipliers: the mean of the middle two for an even count; missing, non-finite and non-positive values do not vote) and sets the result's AsShotNeutral to their reciprocal, as stitch does. With no multipliers (DNG sources) the reference frame's tags are kept, as before. HdrResult carries the multipliers so that the brackets of an HDR panorama vote as frames of their own when the merged brackets are stitched (the engine passes them on). In the app and the CLI the neutral from starting_colour_tags (#965) is already there for frames balanced alike, so those renders do not change. Tests: an HDR merge of raw-style frames gets the median AsShotNeutral (odd and even counts, missing and unusable multipliers ignored) and the written DNG says it; without multipliers the reference frame's colour is unchanged. --- crates/engine/src/merge.rs | 2 +- crates/merge/src/hdr.rs | 14 ++++++- crates/merge/tests/white_balance.rs | 57 ++++++++++++++++++++++++++++- docs/camera-preview-colour.md | 19 +++++----- docs/parity-checklist.md | 5 ++- 5 files changed, 82 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index c1b17be6d..c190cd489 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -183,7 +183,7 @@ impl MergeJob { orientation: r.orientation, metadata: r.metadata, baseline_exposure: r.baseline_exposure, - wb_multipliers: None, + wb_multipliers: r.wb_multipliers, }; f.orient(); merged.push(f); diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index 7c596c4a7..23f960187 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -26,7 +26,7 @@ use rayon::prelude::*; use serde::{Deserialize, Serialize}; use crate::align::{self, Alignment}; -use crate::frame::{Frame, FrameColor}; +use crate::frame::{Frame, FrameColor, consensus_multipliers}; use crate::{MergeError, Progress, Result, check}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -83,6 +83,9 @@ pub struct HdrResult { pub orientation: lightcraft_geom::Orientation, pub metadata: lightcraft_meta::Metadata, pub baseline_exposure: f64, + /// The inputs' as-shot white-balance multipliers they agree on ([`crate::frame::consensus_multipliers`]), already in + /// `color`; kept so merged brackets stitched into a panorama can vote as frames of their own. + pub wb_multipliers: Option<[f32; 3]>, } #[inline] @@ -265,6 +268,12 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) None => Plane::new(0, 0), }; let rf = &frames[reference]; + // the merge's white balance is the median of its inputs', as a panorama's: a CR2's or NEF's colour tags have none + let wb_multipliers = consensus_multipliers(&frames); + let mut color = rf.color.clone(); + if let Some(multipliers) = wb_multipliers { + color.set_as_shot_multipliers(multipliers); + } Ok(HdrResult { radiance: out, ghost, @@ -272,11 +281,12 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) ev, ev_exif, alignments, - color: rf.color.clone(), + color, raw: rf.raw, orientation: rf.orientation, metadata: rf.metadata.clone(), baseline_exposure: rf.baseline_exposure, + wb_multipliers, }) } diff --git a/crates/merge/tests/white_balance.rs b/crates/merge/tests/white_balance.rs index 4139250f6..84474a494 100644 --- a/crates/merge/tests/white_balance.rs +++ b/crates/merge/tests/white_balance.rs @@ -1,4 +1,4 @@ -//! A panorama of raw files keeps their as-shot white balance (issue #1144). +//! A panorama or an HDR merge of raw files keeps their as-shot white balance (issue #1144). //! //! CR2, NEF and the like carry their white balance as vendor multipliers, not as DNG tags. The merged DNG has //! to say it as `AsShotNeutral`, or a viewer has no white to start from and shows a colour cast. @@ -6,6 +6,7 @@ use lightcraft_color::{D50, Xy}; use lightcraft_geom::Orientation; use lightcraft_merge::frame::{Frame, FrameColor, consensus_multipliers, detect_clip}; +use lightcraft_merge::hdr::{HdrOptions, HdrResult, merge_hdr}; use lightcraft_merge::output::color_data; use lightcraft_merge::pano::{PanoOptions, PanoResult, Projection, stitch}; use lightcraft_merge::{DngSamples, no_progress, write_linear_dng}; @@ -189,3 +190,57 @@ fn without_multipliers_the_dng_has_no_as_shot_neutral_as_before() { let raw = dng_of(&res); assert_eq!((raw.color.as_shot_neutral, raw.color.as_shot_white_xy), (None, None)); } + +/// Exposures `evs` (EV, relative to the view's) of one view, one frame each, with `wb` as their multipliers. +fn bracket(color: &ColorData, evs: &[f64], wb: &[Multipliers]) -> Vec { + let view = lightcraft_merge::synth::pano_views(320, 240, 280.0, &[0.0]).unwrap().remove(0); + evs.iter() + .zip(wb) + .map(|(ev, m)| { + let k = 2f32.powf(*ev as f32); + let mut frame = raw_frame(view.map(|p| p.map(|v| (v * k).min(1.0))), color, *m); + frame.exposure = Some(*ev); + frame + }) + .collect() +} + +fn hdr(frames: Vec) -> HdrResult { + merge_hdr(frames, &HdrOptions::default(), &no_progress).unwrap() +} + +fn hdr_neutral(res: &HdrResult) -> Option<[f64; 3]> { + match &res.color { + FrameColor::Camera(c) => c.as_shot_neutral, + FrameColor::Linear { .. } => None, + } +} + +#[test] +fn an_hdr_merge_of_raw_frames_has_their_median_white_balance() { + let source = camera_without_white_balance(); + let wb = [Some([2.1, 1.0, 1.55]), Some(DAYLIGHT), Some([1.9, 1.0, 1.7])]; + let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &wb)); + assert_eq!(res.wb_multipliers, Some(DAYLIGHT)); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + // the DNG says it, to the precision of a TIFF rational + let bytes = write_linear_dng(&res.radiance, &res.color, res.orientation, &res.metadata, res.baseline_exposure, DngSamples::Half).unwrap(); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_close(raw.color.as_shot_neutral.expect("AsShotNeutral in the DNG"), [0.5, 1.0, 0.625], 2e-6); + // an even count takes the mean of the middle two; frames without multipliers do not vote + let res = hdr(bracket(&source, &[-1.0, 1.0], &[Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])])); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.5, 1.0, 1.5]), 1e-6); + let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &[None, Some([2.0, 1.0, 1.6]), Some([f32::NAN, 1.0, 1.0])])); + assert_eq!(res.wb_multipliers, Some([2.0, 1.0, 1.6])); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn an_hdr_merge_without_multipliers_keeps_the_reference_colour_as_before() { + let source = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.62]), ..camera_without_white_balance() }; + let frames = bracket(&source, &[-2.0, 0.0, 2.0], &[None, None, None]); + let reference = frames[1].color.clone(); + let res = hdr(frames); + assert_eq!((res.reference, res.wb_multipliers), (1, None)); + assert_eq!(res.color, reference); +} diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index f092e88f0..1661bf793 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -195,15 +195,16 @@ HDR merge of three copies of the photo against the photo alone (200 px, CIE76): percentile 2.65 and 1.15 (26 and 23 before, with no colour tags; 1.47 and 0.66 with the table left out too). A raw whose white balance is only in its vendor multipliers (CR2, NEF, ARW…) has no `AsShotNeutral` of its own, so a -frame carries the multipliers (`Frame::wb_multipliers`) next to its colour tags. A panorama takes its white balance -from the frames that are in it: the median R/G and the median B/G of their multipliers (the mean of the middle two -for an even count; frames without usable multipliers, or outside the panorama, do not vote), written as -`AsShotNeutral`, the reciprocal, and any `AsShotWhiteXY` of the source dropped (a reader takes it first, and the -DNG spec has the two exclude each other). So the panorama is balanced for what the camera measured for most of its -frames, not for whichever frame came first. With no multipliers anywhere (DNG sources) the first frame's tags are kept -as before. For frames balanced alike this is the neutral `starting_colour_tags` already wrote, so the merged DNG -renders as before; it matters when the camera balanced the frames differently, and for a caller that loads frames -without a colour hook (`load_frame`), whose raw frames carried no white balance at all (issue #1144). +frame carries the multipliers (`Frame::wb_multipliers`) next to its colour tags. A panorama, and an HDR merge from its +inputs, takes its white balance from the frames that are in it: the median R/G and the median B/G of their multipliers +(the mean of the middle two for an even count; frames without usable multipliers, or outside the panorama, do not +vote), written as `AsShotNeutral`, the reciprocal. Any `AsShotWhiteXY` of the source is dropped with it: a reader takes +it first, and the DNG spec has the two exclude each other. So the result is balanced for what the camera measured for +most of its frames, not for whichever frame came first; an HDR panorama's merged brackets vote with their bracket's +median. With no multipliers anywhere (DNG sources) the reference or first frame's tags are kept as before. For frames +balanced alike this is the neutral `starting_colour_tags` already wrote, so the merged DNG renders as before; it matters +when the camera balanced the frames differently, and for a caller that loads frames without a colour hook +(`load_frame`), whose raw frames carried no white balance at all (issue #1144). ## Nikon crop and preview colour metadata diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7fea9cf66..49c10e79a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-MERGE-PANO: a panorama of raws takes its as-shot white balance from the median of its frames (#1144); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-MERGE-PANO, LR-MERGE-HDR: a panorama and an HDR merge of raws take their as-shot white balance from the median of their frames (#1144); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -347,7 +347,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | +| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144 | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | +| 2026-10-13 | minor | LR-MERGE-HDR: an HDR merge's DNG takes its `AsShotNeutral` from the median of its inputs' vendor white-balance multipliers instead of the reference frame's tags (issue #1144); status unchanged (✅) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | From 2dad18d2ecdb590e99f64f7cb2e0919fe7568c07 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Tue, 13 Oct 2026 05:15:31 -0400 Subject: [PATCH 30/52] LR-RAW-NEF: stretch non-square pixels (D1X/D1H) to square The D1X/D1H raw SubIFD is 4028x1324 with XResolution 600 / YResolution 300: the pixel is twice as tall as it is wide. Nothing read that, so the picture came out about 3:1. Add a pixel aspect to RawImage/RawInfo, read it from the raw IFD resolution ratio (NEF) and from DefaultScale (DNG), accept only 1.5..4, and resample the rows after demosaic. developed_size() reports the stretched size, binning is off for non-square pixels, and the catalog and lens-profile sizing use developed_size(). --- crates/engine/src/camera_preview.rs | 3 ++ crates/engine/src/files.rs | 6 ++-- crates/engine/src/lens_profiles.rs | 3 +- crates/engine/src/tests_xmp.rs | 1 + crates/merge/src/output.rs | 1 + crates/raw/src/binned.rs | 3 +- crates/raw/src/color.rs | 1 + crates/raw/src/dng.rs | 19 ++++++++++++ crates/raw/src/dngwrite.rs | 1 + crates/raw/src/lib.rs | 46 ++++++++++++++++++++++++++--- crates/raw/src/saturation.rs | 1 + crates/raw/src/vendor/arw.rs | 1 + crates/raw/src/vendor/cr2.rs | 1 + crates/raw/src/vendor/cr2/sraw.rs | 1 + crates/raw/src/vendor/cr3.rs | 1 + crates/raw/src/vendor/kodak.rs | 1 + crates/raw/src/vendor/nef.rs | 14 ++++++++- crates/raw/src/vendor/orf.rs | 1 + crates/raw/src/vendor/pef.rs | 1 + crates/raw/src/vendor/raf.rs | 1 + crates/raw/src/vendor/rw2.rs | 1 + crates/raw/src/vendor/srw.rs | 1 + crates/raw/tests/dng.rs | 1 + crates/raw/tests/robust.rs | 1 + 24 files changed, 99 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 741fc391d..8fbcde9cb 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1656,6 +1656,7 @@ mod tests { color: ColorData::default(), wb_multipliers: Some([1.0; 3]), linearized: true, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata::default(), }; @@ -1999,6 +2000,7 @@ mod tests { color, wb_multipliers: Some([2.0, 1.0, 1.5]), linearized: false, + pixel_aspect: 1.0, opcodes: Default::default(), metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() }, } @@ -3125,6 +3127,7 @@ mod tests { color: ColorData::default(), wb_multipliers: Some([1.0; 3]), linearized: true, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() }, }; diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dc79b8b31..4ae58ceff 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -158,10 +158,8 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { } Err(Err(e)) => return Err(e), }; - let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32); - if w <= 1 || h <= 1 { - (w, h) = (raw.active_area.width as u32, raw.active_area.height as u32); - } + let (w, h) = raw.developed_size(); + let (mut w, mut h) = (w.max(1) as u32, h.max(1) as u32); if raw.orientation.swaps_axes() { std::mem::swap(&mut w, &mut h); } diff --git a/crates/engine/src/lens_profiles.rs b/crates/engine/src/lens_profiles.rs index 1894f7189..6338b3b40 100644 --- a/crates/engine/src/lens_profiles.rs +++ b/crates/engine/src/lens_profiles.rs @@ -64,8 +64,7 @@ fn frame_crop_factor(camera: Option, focal: f64, focal_35mm: Option) - pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option { crate::files::embedded_lens(raw).or_else(|| { let db = database()?; - let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); - let (w, h) = if c.width == 0 || c.height == 0 { (raw.active_area.width, raw.active_area.height) } else { (c.width, c.height) }; + let (w, h) = raw.developed_size(); lookup(db, &meta(), w as f64, h as f64, raw.orientation) }) } diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 58d0d7a14..386fd7c1d 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -584,6 +584,7 @@ pub(crate) fn synthetic_dng_of(w: usize, h: usize, cfa: &str, data: Vec, xm }, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index 8128644b6..ed24e636e 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -107,6 +107,7 @@ pub fn write_linear_dng( color: cd, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: meta, }; diff --git a/crates/raw/src/binned.rs b/crates/raw/src/binned.rs index 674651c90..0ad9adb3d 100644 --- a/crates/raw/src/binned.rs +++ b/crates/raw/src/binned.rs @@ -24,7 +24,7 @@ impl RawImage { /// and B; Bayer: `k` even, so that every block has the same layout)? pub fn can_bin(&self, k: usize) -> bool { let Some(cfa) = &self.cfa else { return false }; - if k < 2 || self.cpp != 1 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) { + if k < 2 || self.cpp != 1 || (self.pixel_aspect - 1.0).abs() >= 0.01 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) { return false; } // every window position relative to the pattern @@ -163,6 +163,7 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), } diff --git a/crates/raw/src/color.rs b/crates/raw/src/color.rs index a7cea813d..88081eebc 100644 --- a/crates/raw/src/color.rs +++ b/crates/raw/src/color.rs @@ -287,6 +287,7 @@ mod tests { color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: Default::default(), metadata: Default::default(), } diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 3c7ece61b..69185e193 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -331,6 +331,7 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result Result) -> f32 { + match scale { + Some([h, v]) if *h > 0.0 && *v > 0.0 && (1.5..=4.0).contains(&(v / h)) => (v / h) as f32, + _ => 1.0, + } +} + #[cfg(test)] mod tests { + #[test] + fn default_scale_makes_tall_pixels() { + assert_eq!(default_scale_aspect(Some(&[1.0, 2.0])), 2.0); + assert_eq!(default_scale_aspect(Some(&[1.0, 1.0])), 1.0); + assert_eq!(default_scale_aspect(Some(&[2.0, 1.0])), 1.0); + assert_eq!(default_scale_aspect(Some(&[1.0, 1.2])), 1.0); + assert_eq!(default_scale_aspect(None), 1.0); + } + use super::*; /// Store `image` the way DNG 1.7 interleaves it: row `r` of the image is written to stored row @@ -441,3 +459,4 @@ mod tests { assert!(deinterleave(&mut hostile, 8, 6, 1, 1 << 20, 1).is_err()); } } + diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 55a96984b..b935849d1 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -419,6 +419,7 @@ pub fn write_rendered_dng( color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: crate::OpcodeLists::default(), metadata: metadata.clone(), }; diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 120ce98e9..98b29dbd8 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -415,13 +415,27 @@ pub struct RawInfo { pub wb_multipliers: Option<[f32; 3]>, pub opcodes: OpcodeLists, pub metadata: Metadata, + /// See [`RawImage::pixel_aspect`]. + #[serde(default = "square_pixels")] + pub pixel_aspect: f32, +} + +fn square_pixels() -> f32 { + 1.0 +} + +/// Rows of a picture `height` rows tall once its pixels (taller than wide by `aspect`) are made square. +pub fn stretched_height(height: usize, aspect: f32) -> usize { + if (aspect - 1.0).abs() < 0.01 { height } else { ((height as f32 * aspect).round() as usize).max(1) } } impl RawInfo { - /// Size of the developed image before orientation: the default crop, else the active area. + /// Size of the developed image before orientation: the default crop, else the active area, with the rows + /// stretched for non-square pixels. pub fn developed_size(&self) -> (usize, usize) { let c = self.crop.clipped(self.active_area.width, self.active_area.height); - if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) } + let (w, h) = if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) }; + (w, stretched_height(h, self.pixel_aspect)) } } @@ -646,6 +660,10 @@ pub struct RawImage { pub wb_multipliers: Option<[f32; 3]>, /// Whether a `LinearizationTable` was applied to `data`. pub linearized: bool, + /// Height of a sensor pixel over its width (1.0 for square pixels). The D1X and D1H write 2.0: their frame is + /// 4028 x 1324 and the picture is 3:2 only once the rows are doubled. [`Self::develop`] stretches the rows by + /// this factor. + pub pixel_aspect: f32, pub opcodes: OpcodeLists, pub metadata: Metadata, } @@ -679,6 +697,7 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes.clone(), metadata: self.metadata.clone(), + pixel_aspect: self.pixel_aspect, } } @@ -698,6 +717,7 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes, metadata: self.metadata, + pixel_aspect: self.pixel_aspect, } } @@ -791,10 +811,16 @@ impl RawImage { pub fn finish_demosaiced(&self, mut rgb: Rgb32f) -> Rgb32f { opcodes::apply_list3(&self.opcodes.list3, &mut rgb); let c = self.develop_crop(rgb.width, rgb.height); - if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { + let rgb = if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { + rgb + } else { + rgb.into_crop(c.x, c.y, c.width, c.height) + }; + let h = stretched_height(rgb.height, self.pixel_aspect); + if h == rgb.height { return rgb; } - rgb.into_crop(c.x, c.y, c.width, c.height) + lightcraft_raster::resample::resize(&rgb, rgb.width, h, lightcraft_raster::resample::Filter::Mitchell) } /// The part of a `width × height` demosaiced picture (the active area) that [`Self::develop`] keeps: the default @@ -853,6 +879,7 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), }; @@ -869,6 +896,17 @@ mod tests { // a crop that pokes out is clipped to the picture whole.crop = Rect::new(6, 4, 10, 10); assert_eq!(whole.develop_crop(8, 6), Rect::new(6, 4, 2, 2)); + + // tall pixels (D1X: 2.0) are stretched to square ones: the rows double, `developed_size` says so + let mut tall = raw.clone(); + tall.pixel_aspect = 2.0; + let d = tall.develop(Method::Bilinear).unwrap(); + assert_eq!((d.width, d.height), (5, 8)); + assert_eq!(tall.info().developed_size(), (5, 8)); + assert_eq!(raw.info().developed_size(), (5, 4)); + assert!(!tall.can_bin(2)); + assert_eq!(stretched_height(1324, 2.0), 2648); + assert_eq!(stretched_height(1324, 1.0), 1324); } #[test] diff --git a/crates/raw/src/saturation.rs b/crates/raw/src/saturation.rs index aaa0a6dff..76c1cd503 100644 --- a/crates/raw/src/saturation.rs +++ b/crates/raw/src/saturation.rs @@ -135,6 +135,7 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), } diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 59a9cf59e..c2edc9f80 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -767,6 +767,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, linearized: false, + pixel_aspect: 1.0, // The distortion table is centred on the DNG-style default crop when the file has one: on the ILCE-7RM4A // it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and the warp was // validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 74d93534d..6c797ec4a 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -249,6 +249,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/cr2/sraw.rs b/crates/raw/src/vendor/cr2/sraw.rs index 68b406238..f8ef18e32 100644 --- a/crates/raw/src/vendor/cr2/sraw.rs +++ b/crates/raw/src/vendor/cr2/sraw.rs @@ -391,6 +391,7 @@ pub(super) fn decode(bytes: &[u8], tiff: &Tiff, raw: &Ifd, mode: Mode) -> Result // the camera's white balance is already applied, as in Sony's linear YCbCr wb_multipliers: Some([1.0; 3]), linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs index 7c93e3bac..b4c3d2d54 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -253,6 +253,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: levels.wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/kodak.rs b/crates/raw/src/vendor/kodak.rs index f7f82cff8..def2e369d 100644 --- a/crates/raw/src/vendor/kodak.rs +++ b/crates/raw/src/vendor/kodak.rs @@ -115,6 +115,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 695d55712..0b7a68e6a 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -98,6 +98,16 @@ fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits) } +/// Pixel height over width from the raw IFD's resolution tags: the D1X and D1H write 600 x 300 per inch for a +/// 4028 x 1324 frame, rows twice as tall as the columns are wide. Only ratios from 1.5 to 4 count (a frame +/// squashed 3:1 or 2:1), the rest is rounding or a stray tag. +pub(crate) fn pixel_aspect_of(raw: &Ifd) -> f32 { + match (raw.f64(t::X_RESOLUTION), raw.f64(t::Y_RESOLUTION)) { + (Some(x), Some(y)) if x > 0.0 && y > 0.0 && (1.5..=4.0).contains(&(x / y)) => (x / y).round() as f32, + _ => 1.0, + } +} + fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.all_ifds() .into_iter() @@ -190,9 +200,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Rect::new(x, y, cw, ch), _ => default_crop(mn.as_ref(), active_w, h), }; + let pixel_aspect = pixel_aspect_of(raw); let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); - metadata.height = Some(crop.height as u32); + metadata.height = Some(crate::stretched_height(crop.height, pixel_aspect) as u32); let img = RawImage { format: RawFormat::Nef, width: w, @@ -212,6 +223,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index ea7f30eb7..5a4776c39 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -329,6 +329,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index abb928e68..8ce426a24 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -318,6 +318,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 7b148929d..b45cf4902 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -212,6 +212,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists { list3: super::raf_lens::corrections(&camera_name(bytes), &ifd, active), ..Default::default() }, metadata, }; diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index b856fbd52..c96a1310f 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -616,6 +616,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists { list3: distortion(ifd0, active).into_iter().collect(), ..Default::default() }, metadata, }; diff --git a/crates/raw/src/vendor/srw.rs b/crates/raw/src/vendor/srw.rs index f64b451ff..a48f944b4 100644 --- a/crates/raw/src/vendor/srw.rs +++ b/crates/raw/src/vendor/srw.rs @@ -184,6 +184,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 9876e349e..d9c161779 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -59,6 +59,7 @@ fn synthetic(w: usize, h: usize, cfa: Option, cpp: usize) -> RawImage { color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, } diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/robust.rs index 7bd5a5e54..733b029b9 100644 --- a/crates/raw/tests/robust.rs +++ b/crates/raw/tests/robust.rs @@ -24,6 +24,7 @@ fn sample(comp: DngCompression) -> Vec { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Default::default(), }; From 6467d5432552ce86106773d5438e9adc72f0467f Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Mon, 12 Oct 2026 20:43:12 +0200 Subject: [PATCH 31/52] Perf: camera-look colour step 1.5-1.9x faster on full-size raws, same pixels The hue/saturation table of a fitted camera look (ILCE-7RM4A, APS-C ILCE-7RM6, ...) ran HsvTable::apply pixel by pixel: branches on the data (largest channel, hue sector), saturating float-to-usize casts and bounds-checked table reads, ~550 instructions and 1.2 branch misses per pixel. CameraColourModel::apply now runs it 16 pixels at a time (HueSat::apply_lanes -> HsvTable::apply_lanes, structure of arrays): per pixel the same IEEE operations in the same order, with the branches turned into selects and the table indices computed as exact f32 integers, so the compiler vectorises everything but the table reads, powf and the rare rem_euclid, which run lane by lane. DNG profile tables and the other colour models keep the per-pixel path. Measured on a Core Ultra 9 185H P-core (busy machine, min of N): - HsvTable alone, 72x5x5 fitted-look-shaped table, random colours, one thread: 243 -> 106 cycles (57 -> 26 ns) per pixel. - Colour stage of a full-size load, one thread: 60 MP ILCE-7RM4A 3.80 -> 1.99 s, 26 MP APS-C ILCE-7RM6 HQ 1.88 -> 0.98 s; 4 CPUs: 1.02 -> 0.67 s, 0.56 -> 0.30 s. Exactness: new tests compare the lanes with the one-by-one path bit for bit (to_bits) on tens of thousands of colours (every combination of zero, -0, tiny, negative, around 1, huge, NaN and +-inf components; grey; hues at the 0/360 degree wrap; near-black noise in the restore's blend) for fitted, value-scaled, 2D, many-turn and degenerate tables, 16 and 5 lanes, and on an image whose rows aren't a whole number of lanes. Stage fingerprints (decode, AHD, 2560 px and full-size loads) of 7 Sony raws are identical to origin/main. Sources: own design. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/camera_preview.rs | 81 ++++++++ crates/engine/src/files.rs | 75 ++++++-- crates/raw/src/profile.rs | 277 ++++++++++++++++++++++++++++ 3 files changed, 420 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 741fc391d..a3d13bfdc 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1180,6 +1180,42 @@ impl<'a> HueSat<'a> { } std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w) } + + /// [`Self::apply`] to `N` pixels at once (`rgb[channel][lane]`), bit for bit, through + /// [`HsvTable::apply_lanes`], with the guards as selects. + #[inline] + pub fn apply_lanes(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] { + let mul = |m: &[[f32; 3]; 3], v: &[[f32; N]; 3]| { + let mut out = [[0.0f32; N]; 3]; + for (row, out) in m.iter().zip(&mut out) { + for l in 0..N { + out[l] = row[0] * v[0][l] + row[1] * v[1][l] + row[2] * v[2][l]; + } + } + out + }; + let out = mul(&self.from, &self.table.apply_lanes(mul(&self.to, &rgb))); + let mut restored = rgb; + for l in 0..N { + let (input, out) = ([rgb[0][l], rgb[1][l], rgb[2][l]], [out[0][l], out[1][l], out[2][l]]); + let (before, after) = (luminance_2020(input), luminance_2020(out)); + let untouched = (before <= 0.0) | (after <= 0.0) | !(out[0].is_finite() & out[1].is_finite() & out[2].is_finite()); + let w = restore_weight(after / before); + for (c, restored) in restored.iter_mut().enumerate() { + let full = out[c] * before / after; + let blend = input[c] + (full - input[c]) * w; + // (`w >= 1` and `w <= 0` exclude each other) + restored[l] = if untouched | (w <= 0.0) { + input[c] + } else if w >= 1.0 { + full + } else { + blend + }; + } + } + restored + } } /// HSV hue (degrees) and saturation; `None` for black or non-finite colours. @@ -1951,6 +1987,51 @@ mod tests { assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel"); } + /// `apply_lanes` gives `apply`'s bits for any colour: random, near-black noise (the restore's blend), grey, + /// negative, tiny, above 1, huge, NaN and infinite components; on a fitted look's shape and a saturating table. + #[test] + fn hue_sat_lanes_match_one_by_one_bit_for_bit() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let unit = |r: u64, i: usize| ((r >> (16 * i)) & 0xffff) as f32 / 65535.0; + let fitted = (0..72 * 5 * 5) + .map(|_| { + let r = next(); + [unit(r, 0) * 60.0 - 30.0, 0.5 + unit(r, 1), 1.0] + }) + .collect(); + let fitted = HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data: fitted, srgb_value: true }; + let saturating = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let special = [0.0, -0.0, 1e-30, -1e-6, 0.002, 0.5, 1.0, 3.0, 1e30, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]; + let mut colours = Vec::new(); + for a in special { + for b in special { + colours.extend(special.map(|c| [a, b, c])); + } + } + for _ in 0..60_000 { + let r = next(); + let (offset, scale) = [(0.15, 1.3), (0.15e-3, 1.3e-3), (0.6, 5.0), (-0.002, 0.008)][(r >> 62) as usize]; + colours.push(std::array::from_fn(|i| unit(r, i) * scale - offset)); + } + for table in [fitted, saturating] { + let hue_sat = HueSat::new(&table).unwrap(); + for chunk in colours.chunks(16) { + let rgb: [[f32; 16]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.0, |p| p[c]))); + let out = hue_sat.apply_lanes(rgb); + for (l, p) in chunk.iter().enumerate() { + let (got, want) = ([out[0][l], out[1][l], out[2][l]], hue_sat.apply(*p)); + assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}"); + } + } + } + } + #[test] fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() { assert_eq!(restore_weight(0.0), 0.0); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index feefb21a0..0940f3125 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -266,19 +266,43 @@ impl CameraColourModel<'_> { /// White balance, matrix, tables and baseline exposure, in place. pub fn apply(&self, img: &mut Rgb32f) { let (wb, m, gain) = (self.wb, self.matrix.to_f32(), self.gain); - img.map_in_place(|p| { - let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - let rgb = [ - m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2], - m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2], - m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2], - ]; - let rgb = match self.tables { - Some(tables) => tables.apply(rgb, gain), - None => rgb.map(|v| v * gain), - }; - self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0)) - }); + if let (None, Some(hue_sat)) = (self.tables, self.hue_sat) { + // a fitted camera look: `pixel`, `LANES` pixels at a time so the table step vectorises (same bits) + const LANES: usize = 16; + lightcraft_raster::par_rows(&mut img.data, img.width, |_, row| { + for chunk in row.chunks_mut(LANES) { + let mut rgb = [[0.0f32; LANES]; 3]; + for (l, p) in chunk.iter().enumerate() { + let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + for (i, rgb) in rgb.iter_mut().enumerate() { + rgb[l] = (m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]) * gain; + } + } + let out = hue_sat.apply_lanes(rgb); + for (l, p) in chunk.iter_mut().enumerate() { + *p = [out[0][l], out[1][l], out[2][l]].map(|v| v.max(0.0)); + } + } + }); + return; + } + img.map_in_place(|p| self.pixel(&m, p)); + } + + /// [`Self::apply`] to one pixel; `m` is the matrix as `f32`. + fn pixel(&self, m: &[[f32; 3]; 3], p: [f32; 3]) -> [f32; 3] { + let (wb, gain) = (self.wb, self.gain); + let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + let rgb = [ + m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2], + m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2], + m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2], + ]; + let rgb = match self.tables { + Some(tables) => tables.apply(rgb, gain), + None => rgb.map(|v| v * gain), + }; + self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0)) } } @@ -1647,6 +1671,31 @@ mod tests { 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}; diff --git a/crates/raw/src/profile.rs b/crates/raw/src/profile.rs index 566c2b1c3..7ca6b0e38 100644 --- a/crates/raw/src/profile.rs +++ b/crates/raw/src/profile.rs @@ -123,6 +123,177 @@ impl HsvTable { }; hsv_to_rgb(hue + shift, s2, v2) } + + /// [`Self::apply`] to `N` colours at once (`rgb[channel][lane]`), bit for bit: the same operations, with the + /// branches on the data (largest channel, hue sector, wrap-around) turned into selects so the compiler + /// vectorises them. The table reads, `powf` and the rare `rem_euclid` run lane by lane. + pub fn apply_lanes(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] { + let [r, g, b] = rgb; + if !self.fits_lanes() { + let mut out = rgb; + for l in 0..N { + [out[0][l], out[1][l], out[2][l]] = self.apply([r[l], g[l], b[l]]); + } + return out; + } + // `active`: the colours the table applies to (`apply` returns black, NaN and infinite ones as they are) + let (mut max, mut active, mut first) = ([0.0f32; N], [true; N], [false; N]); + let (mut d, mut ratio, mut second, mut third) = ([0.0f32; N], [0.0f32; N], [0.0f32; N], [0.0f32; N]); + for l in 0..N { + let m = r[l].max(g[l]).max(b[l]); + active[l] = !(m.is_nan() | (m <= 0.0) | !m.is_finite()); + d[l] = m - r[l].min(g[l]).min(b[l]); + // hue_of, for each largest channel + ratio[l] = (g[l] - b[l]) / d[l]; + second[l] = (b[l] - r[l]) / d[l] + 2.0; + third[l] = (r[l] - g[l]) / d[l] + 4.0; + first[l] = active[l] & (d[l] > 0.0) & (m == r[l]); + max[l] = m; + } + let ratio = wrap_lanes(ratio, 6.0, first); + let (mut hue, mut sat) = ([0.0f32; N], [0.0f32; N]); + for l in 0..N { + let h = if max[l] == r[l] { + ratio[l] + } else if max[l] == g[l] { + second[l] + } else { + third[l] + }; + let h = h * 60.0; + hue[l] = if d[l] <= 0.0 { 0.0 } else { h }; + sat[l] = d[l] / max[l]; + } + let mut v_index = max; + if self.srgb_value { + for l in 0..N { + if active[l] { + v_index[l] = linear_to_srgb(max[l].min(1.0)); + } + } + } + let [shift, ss, vs] = self.lookup_lanes(&hue, &sat, &v_index, active); + let (mut s2, mut v2, mut curve) = ([0.0f32; N], [0.0f32; N], [false; N]); + for l in 0..N { + s2[l] = limited_select(sat[l], ss[l]); + v2[l] = if self.srgb_value { max[l] } else { limited_select(max[l], vs[l]) }; + curve[l] = self.srgb_value & active[l] & !((max[l] <= 1.0) & (vs[l] == 1.0)); + } + for l in 0..N { + if curve[l] { + v2[l] = if max[l] <= 1.0 { srgb_to_linear((v_index[l] * vs[l]).min(1.0)) } else { max[l] * srgb_to_linear(vs[l].min(1.0)).min(1.0) }; + } + } + // hsv_to_rgb + let hue = wrap_lanes(std::array::from_fn(|l| hue[l] + shift[l]), 360.0, active); + let mut out = rgb; + for l in 0..N { + let hp = hue[l] / 60.0; + // `(hp as u32).min(5) as f32` for every `hp`, NaN included + let step = |k: f32| if hp >= k { 1.0 } else { 0.0 }; + let sector = step(1.0) + step(2.0) + step(3.0) + step(4.0) + step(5.0); + let f = hp - sector; + let (s, v) = (s2[l], v2[l]); + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + let red = if (sector == 0.0) | (sector == 5.0) { + v + } else if sector == 1.0 { + q + } else if sector == 4.0 { + t + } else { + p + }; + let green = if sector == 0.0 { + t + } else if sector <= 2.0 { + v + } else if sector == 3.0 { + q + } else { + p + }; + let blue = if sector <= 1.0 { + p + } else if sector == 2.0 { + t + } else if sector == 5.0 { + q + } else { + v + }; + out[0][l] = if active[l] { red } else { r[l] }; + out[1][l] = if active[l] { green } else { g[l] }; + out[2][l] = if active[l] { blue } else { b[l] }; + } + out + } + + /// Whether [`Self::lookup_lanes`] can do this table's index arithmetic in `f32`: every index below 2²³. + fn fits_lanes(&self) -> bool { + let entries = self.hue_divisions.checked_mul(self.sat_divisions).and_then(|n| n.checked_mul(self.val_divisions.max(1))); + self.hue_divisions >= 1 && self.sat_divisions >= 1 && entries.is_some_and(|n| n <= 1 << 23) + } + + /// [`Self::lookup`] at `N` points (`(shift, saturation scale, value scale)[lane]`), bit for bit for the lanes + /// `wanted`, on a table that [`Self::fits_lanes`]. The indices are computed as `f32` (exact integers below 2²³: + /// `x as usize` is a saturating conversion that doesn't vectorise). + fn lookup_lanes(&self, h: &[f32; N], s: &[f32; N], v: &[f32; N], wanted: [bool; N]) -> [[f32; N]; 3] { + const EXACT: f32 = 8_388_608.0; // 2²³: ulp 1 from here + // `x as usize` for `0 ≤ x < 2²³`: rounded to an integer, minus 1 if it rounded up; NaN is 0 + let floor = |x: f32| { + let nearest = (x + EXACT) - EXACT; + let floor = if nearest > x { nearest - 1.0 } else { nearest }; + if x.is_nan() { 0.0 } else { floor } + }; + let (hn, sn, vn) = (self.hue_divisions as f32, self.sat_divisions as f32, self.val_divisions as f32); + let axis = |x: f32, n: f32| { + let f = x.clamp(0.0, 1.0) * (n - 1.0); + let i0 = floor(f).min((n - 2.0).max(0.0)); + let i1 = (i0 + 1.0).min(n - 1.0); + (i0, i1, (f - i0).clamp(0.0, 1.0)) + }; + let h = wrap_lanes(*h, 360.0, wanted); + // the corners' indices (v, h, s), s fastest, and the weights between them + let (mut at, mut t) = ([[0u32; N]; 8], [[0.0f32; N]; 3]); + for l in 0..N { + let hf = (h[l] / 360.0 * hn).clamp(0.0, hn); + let h0 = floor(hf).min(hn - 1.0); + // (h0 + 1) % hn + let h1 = if h0 + 1.0 == hn { 0.0 } else { h0 + 1.0 }; + let (s0, s1, ts) = axis(s[l], sn); + let (v0, v1, tv) = axis(v[l], vn); + let (v0, v1, tv) = if self.val_divisions > 1 { (v0, v1, tv) } else { (0.0, 0.0, 0.0) }; + for (k, (vi, hi)) in [(v0, h0), (v0, h1), (v1, h0), (v1, h1)].into_iter().enumerate() { + let row = (vi * hn + hi) * sn; + // an exact integer below 2²³ plus 2²³ has it as its mantissa + at[2 * k][l] = (row + s0 + EXACT).to_bits().wrapping_sub(EXACT.to_bits()); + at[2 * k + 1][l] = (row + s1 + EXACT).to_bits().wrapping_sub(EXACT.to_bits()); + } + [t[0][l], t[1][l], t[2][l]] = [(hf - h0).clamp(0.0, 1.0), ts, tv]; + } + let mut corner = [[[0.0f32; N]; 3]; 8]; + for l in 0..N { + for (corner, at) in corner.iter_mut().zip(&at) { + [corner[0][l], corner[1][l], corner[2][l]] = self.data.get(at[l] as usize).copied().unwrap_or([0.0, 1.0, 1.0]); + } + } + let lerp = |a: &[f32; N], b: &[f32; N], t: &[f32; N]| { + let mut out = [0.0f32; N]; + for l in 0..N { + out[l] = a[l] + (b[l] - a[l]) * t[l]; + } + out + }; + let [th, ts, tv] = &t; + let mut out = [[0.0f32; N]; 3]; + for (c, out) in out.iter_mut().enumerate() { + let plane = |k: usize| lerp(&lerp(&corner[k][c], &corner[k + 1][c], ts), &lerp(&corner[k + 2][c], &corner[k + 3][c], ts), th); + // `lookup`'s `v0 == v1` for every lane: `axis` gives `v1 = v0 + 1` whenever there are two values or more + *out = if self.val_divisions > 1 { lerp(&plane(0), &plane(4), tv) } else { plane(0) }; + } + out + } } /// `x · k` clipped to 1, for `x ≤ 1`; above 1, only scale factors below 1 apply (continuous at 1). @@ -145,6 +316,37 @@ fn wrap(x: f32, p: f32) -> f32 { } } +/// [`wrap`] of `N` values, bit for bit for the lanes `wanted`: branch-free but for the `rem_euclid` fallback. +#[inline] +fn wrap_lanes(x: [f32; N], p: f32, wanted: [bool; N]) -> [f32; N] { + let (mut out, mut slow) = ([0.0f32; N], [false; N]); + for l in 0..N { + let (first, below, second) = ((x[l] >= 0.0) & (x[l] < p), (x[l] > -p) & (x[l] < 0.0), (x[l] >= p) & (x[l] < 2.0 * p)); + let (up, down) = (x[l] + p, x[l] - p); + out[l] = if first { + x[l] + } else if below { + up + } else { + down + }; + slow[l] = wanted[l] & !(first | below | second); + } + for l in 0..N { + if slow[l] { + out[l] = x[l].rem_euclid(p); + } + } + out +} + +/// [`limited`] without a branch. +#[inline] +fn limited_select(x: f32, k: f32) -> f32 { + let (below, above) = ((x * k).min(1.0), x * k.min(1.0)); + if x <= 1.0 { below } else { above } +} + fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 { if d <= 0.0 { return 0.0; @@ -330,6 +532,81 @@ mod tests { } } + /// Every combination of awkward components (zero, negative, tiny, around 1, huge, NaN, ±infinity), and random + /// colours from black to above 1: grey, each channel largest, hues either side of the wrap-around at 0°. + fn awkward_colours() -> Vec<[f32; 3]> { + let special = [0.0, -0.0, 1e-30, -1e-30, 1e-45, 0.003_130_8, 0.5, -0.5, 1.0, 1.0f32.next_up(), 2.0, 1e30, -1e30]; + let special = [&special[..], &[f32::MAX, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]].concat(); + let mut out = Vec::new(); + for a in &special { + for b in &special { + out.extend(special.iter().map(|c| [*a, *b, *c])); + } + } + let mut seed = 0x2545_f491_4f6c_dd1du64; + for _ in 0..40_000 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let scale = [1.0, 1e-4, 8.0, 1e-38][(seed >> 56) as usize & 3]; + let c: [f32; 3] = std::array::from_fn(|i| (((seed >> (16 * i)) & 0xffff) as f32 / 65535.0 * 1.6 - 0.2) * scale); + out.push(match seed >> 60 { + 0 => [c[0]; 3], + 1 => [c[0].abs() + 0.1, c[1], c[1].next_up()], + 2 => [c[0].abs() + 0.1, c[1], c[1].next_down()], + _ => c, + }); + } + out + } + + /// `apply_lanes` against `apply`, bit for bit, `N` colours at a time (the last batch padded). + fn assert_lanes_match(t: &HsvTable, colours: &[[f32; 3]]) { + for chunk in colours.chunks(N) { + let rgb: [[f32; N]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.25, |p| p[c]))); + let out = t.apply_lanes(rgb); + for (l, p) in chunk.iter().enumerate() { + let (got, want) = ([out[0][l], out[1][l], out[2][l]], t.apply(*p)); + assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn lanes_match_one_by_one_bit_for_bit() { + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut table = |h: usize, s: usize, v: usize, srgb_value: bool, shift: f32, value_scale: bool| { + let data = (0..h * s * v) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let x = |k: u64| ((seed >> k) & 0xffff) as f32 / 65535.0; + [(x(0) * 2.0 - 1.0) * shift, 0.5 + x(16), if value_scale { 0.5 + x(32) } else { 1.0 }] + }) + .collect(); + HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data, srgb_value } + }; + let colours = awkward_colours(); + // a fitted camera look's shape; with value scales; a 2D DNG map turning hues by up to 5 turns; one hue and + // one saturation + for t in [ + table(72, 5, 5, true, 30.0, false), + table(72, 5, 5, true, 30.0, true), + table(90, 30, 1, false, 1800.0, true), + table(1, 1, 3, true, 30.0, true), + ] { + assert_lanes_match::<16>(&t, &colours); + assert_lanes_match::<5>(&t, &colours); + } + // entries missing (the identity fallback); too big for the lanes' index arithmetic (one by one) + let short = HsvTable { data: vec![[10.0, 1.5, 0.8]; 7], ..identity(4, 3, 2) }; + let huge = HsvTable { data: Vec::new(), ..identity(4096, 4096, 1) }; + for t in [short, huge] { + assert_lanes_match::<16>(&t, &colours); + } + } + fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool { a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol) } From ac677a26244b4ed00d6dfe4f47bbff66706e42a0 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Mon, 12 Oct 2026 04:42:22 +0200 Subject: [PATCH 32/52] LR-EDIT-OPTICS-PROFILE: decode Sony shading, CA and distortion tables with settings and provenance; apply nothing new Sony ARWs record shading, lateral chromatic aberration and distortion as a camera setting plus a radial table in the raw image IFD (0x7031-0x7037), with copies in the encrypted SR2SubIFD (0x797c-0x7982). They are now decoded for every body that records them and carried through RawInfo and RawImage as `lens_metadata.sony` (types in crates/raw/src/lens_metadata.rs, decoder in crates/raw/src/vendor/arw_lens.rs): - Raw-IFD fields are the ones the TIFF reader parsed, which is what the image itself uses. A scan of the directory adds diagnostics only: repeated tags, and why an entry the reader skipped was dropped (values outside the file, an unknown type, past the reader's limits). SR2SubIFD fields are read from their first entry. Only the exact layouts are accepted: SHORT[1] or non-negative SSHORT[1] settings, SSHORT[17] headed by 16 for shading and distortion, SSHORT[33] headed by 32 for CA. Nothing is allocated from a count in the file. - The raw IFD's copy wins. The SR2SubIFD copy fills in only for a tag the raw IFD lacks, never for a malformed one, and the two copies' agreement is recorded. - Settings keep their number and their documented meaning, Unknown(value) otherwise (never Off). - Framing evidence (stored frame, Sony and default crops, reference rectangle, coding, 3:2) and a per-component status. Only Applied components become opcodes. Applied corrections are unchanged: the ILCE-7RM4A distortion conversion receives exactly the table it received before, and no other body or component gains a correction. The ILCE-7CR distortion misses its camera JPEG at 24 mm, and shading and CA semantics are not validated on any body; see docs/sony-lens-corrections.md. Locating the SR2SubIFD, now done for every ARW that has one, selects the SR2Private offset, length and key with the TIFF reader's own rules, which lightcraft-tiff now exposes and builds its reader on (directory_entries, entry_span, ParseOptions::budget_for, decode_value). Only each field's first value is decoded, so the directory's other entries cost no memory sized by the file: a 16 MiB file whose SR2Private holds eight fields aliasing it peaks at 37 kB. DirectoryEntries keeps its layout private, and its positions() iterator uses checked arithmetic. Tests: synthetic decoder tests; a differential test of the SR2Private selection against the TIFF reader's in both byte orders (0 to 8,193 filler entries, unreadable and unusual first occurrences of each field, a used-up value budget); a dhat peak-heap test; public-corpus checks against the values ExifTool prints, with pinned and anchored SHA-256s (LIGHTCRAFT_CORPUS_REQUIRED=1 makes absent files fail); and an opt-in local inventory check (crates/raw/tests/sony_lens_inventory.rs). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/engine/src/camera_preview.rs | 3 + crates/engine/src/tests_xmp.rs | 1 + crates/merge/src/output.rs | 1 + crates/raw/Cargo.toml | 2 + crates/raw/src/binned.rs | 1 + crates/raw/src/color.rs | 1 + crates/raw/src/dng.rs | 1 + crates/raw/src/lens_metadata.rs | 187 ++++ crates/raw/src/lib.rs | 12 + crates/raw/src/saturation.rs | 1 + crates/raw/src/vendor/arw.rs | 48 +- crates/raw/src/vendor/arw_lens.rs | 1101 ++++++++++++++++++++++- crates/raw/src/vendor/cr2.rs | 1 + crates/raw/src/vendor/cr2/sraw.rs | 1 + crates/raw/src/vendor/cr3.rs | 1 + crates/raw/src/vendor/kodak.rs | 1 + crates/raw/src/vendor/nef.rs | 1 + crates/raw/src/vendor/orf.rs | 1 + crates/raw/src/vendor/pef.rs | 1 + crates/raw/src/vendor/raf.rs | 1 + crates/raw/src/vendor/rw2.rs | 1 + crates/raw/src/vendor/sr2.rs | 270 +++++- crates/raw/src/vendor/srw.rs | 1 + crates/raw/tests/corpus.rs | 223 +++++ crates/raw/tests/dng.rs | 1 + crates/raw/tests/robust.rs | 1 + crates/raw/tests/sony_lens_inventory.rs | 351 ++++++++ crates/raw/tests/sr2_heap.rs | 79 ++ crates/tiff/README.md | 4 + crates/tiff/src/lib.rs | 2 +- crates/tiff/src/reader.rs | 178 +++- docs/parity-checklist.md | 3 +- docs/sony-lens-corrections.md | 206 ++++- 35 files changed, 2536 insertions(+), 154 deletions(-) create mode 100644 crates/raw/src/lens_metadata.rs create mode 100644 crates/raw/tests/sony_lens_inventory.rs create mode 100644 crates/raw/tests/sr2_heap.rs diff --git a/Cargo.lock b/Cargo.lock index bf2ac824f..65dffed24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3418,6 +3418,7 @@ dependencies = [ name = "lightcraft-raw" version = "0.7.1" dependencies = [ + "dhat", "jpeg-encoder", "jxl-oxide", "lightcraft-color", diff --git a/Cargo.toml b/Cargo.toml index 7effbe0d9..1bf57fec8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ bytemuck = { version = "1", features = ["derive"] } crc32fast = "1" siphasher = "1" libc = "0.2" # dev-only: process CPU time in benches -dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap); never in default builds +dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and heap tests; never in product builds # formats quick-xml = "0.38" diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index c4824997c..5e70b58a9 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1659,6 +1659,7 @@ mod tests { linearized: true, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata::default(), + lens_metadata: Default::default(), }; let proxy = sensor_proxy(&raw, 2, 384).unwrap(); assert_eq!((proxy.width, proxy.height), (32, 32)); @@ -2002,6 +2003,7 @@ mod tests { linearized: false, opcodes: Default::default(), metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() }, + lens_metadata: Default::default(), } } @@ -3128,6 +3130,7 @@ mod tests { linearized: true, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() }, + lens_metadata: Default::default(), }; assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); // the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 58d0d7a14..41927181b 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -586,6 +586,7 @@ pub(crate) fn synthetic_dng_of(w: usize, h: usize, cfa: &str, data: Vec, xm linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; write_dng(&raw, &DngWriteOptions { xmp: xmp.map(str::to_string), ..Default::default() }).unwrap() } diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index f8b7e7173..e15448646 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -113,6 +113,7 @@ pub fn write_linear_dng( linearized: false, opcodes: OpcodeLists { list3: lens.to_vec(), ..Default::default() }, metadata: meta, + lens_metadata: Default::default(), }; let compression = DngCompression::Deflate { tile: 256, half: samples == DngSamples::Half }; lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression, ..Default::default() }).map_err(|e| MergeError::Output(e.to_string())) diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index 2ca9ef246..7b32cfe03 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -33,3 +33,5 @@ proptest = { workspace = true } zune-jpegxl = { workspace = true } # Pinned CC0 corpus identities; not a product dependency. sha2 = { version = "0.10", default-features = false, features = ["std"] } +# Peak-heap regression tests (tests/sr2_heap.rs); not a product dependency. +dhat = { workspace = true } diff --git a/crates/raw/src/binned.rs b/crates/raw/src/binned.rs index 674651c90..5eb99422b 100644 --- a/crates/raw/src/binned.rs +++ b/crates/raw/src/binned.rs @@ -165,6 +165,7 @@ mod tests { linearized: false, opcodes: OpcodeLists::default(), metadata: Metadata::default(), + lens_metadata: Default::default(), } } diff --git a/crates/raw/src/color.rs b/crates/raw/src/color.rs index a7cea813d..12c509190 100644 --- a/crates/raw/src/color.rs +++ b/crates/raw/src/color.rs @@ -289,6 +289,7 @@ mod tests { linearized: false, opcodes: Default::default(), metadata: Default::default(), + lens_metadata: Default::default(), } } diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 026aae90d..b076c8bc3 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -340,6 +340,7 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result, +} + +/// Sony's in-file lens-correction record: three independent components, each a camera setting and a radial table. +/// +/// Tag identities and storage types from the ExifTool EXIF / Sony tag-name documentation: raw-IFD `0x7031` / `0x7032` +/// (shading, "VignettingCorrection" / "VignettingCorrParams"), `0x7034` / `0x7035` (lateral chromatic aberration), +/// `0x7036` / `0x7037` (distortion), and the `SR2SubIFD` tables `0x797d`, `0x7980`, `0x7982`. The `SR2SubIFD` setting +/// tags `0x797c`, `0x797f`, `0x7981` are unnamed there; they sit next to the tables and equal the raw-IFD settings +/// in every file inspected. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyLensCorrections { + /// Shading (vignetting): 16 radial samples whose gain function, radius convention and processing stage are not + /// established. Opaque signed data. + pub shading: SonyComponent<[i16; 16]>, + /// Lateral chromatic aberration: two 16-sample radial curves. A public analysis reads them as red, then blue, + /// relative to green; that is not validated here. + pub chromatic_aberration: SonyComponent<[[i16; 16]; 2]>, + /// Distortion: 16 radial samples (see `docs/sony-lens-corrections.md` for the validated interpretation). + pub distortion: SonyComponent<[i16; 16]>, + /// Whether the encrypted `SR2SubIFD` was found and decrypted. `false`: no copies to fall back on or compare. + pub sr2_read: bool, + /// The framing and coding the tables have to be interpreted against. + pub geometry: SonyLensGeometry, +} + +/// One correction component: the camera's setting, its table, and whether LightCraft applies it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyComponent { + pub setting: SonyField, + /// The table's samples after the leading sample count, which must equal their number. + pub table: SonyField, + pub status: SonyLensStatus, +} + +/// One field read from the raw IFD and from the `SR2SubIFD`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyField { + /// The value in use: the raw IFD's when it holds a well-formed one, else the `SR2SubIFD`'s when the raw IFD + /// lacks the tag. `None` when no copy is usable, and when the raw IFD's copy is malformed: a corrupt field is + /// reported, not silently replaced by the other copy. The raw IFD's copy is the one the TIFF reader that decodes + /// the image parsed (its first occurrence, within the reader's limits). + pub value: Option, + pub source: Option, + pub raw_ifd: SonyCopy, + pub sr2: SonyCopy, + /// Whether the two copies are equal, when both are well-formed. The raw IFD's wins when they differ. + pub copies_agree: Option, + /// Diagnostics: the tag occurs more than once in the raw IFD / the `SR2SubIFD`. Only the first occurrence is used, + /// as the TIFF reader does (the raw IFD's: the first one whose values it could read). + #[serde(default)] + pub raw_ifd_repeated: bool, + #[serde(default)] + pub sr2_repeated: bool, +} + +/// Where a value was read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonySource { + /// The plain raw image IFD. + RawIfd, + /// The encrypted `SR2SubIFD`. + Sr2SubIfd, +} + +/// How one copy of a field was found. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyCopy { + /// Not in that directory (or the `SR2SubIFD` could not be read). + Absent, + /// Present with exactly the supported type and layout. + Valid, + /// Present, but refused. + Rejected(SonyFieldError), +} + +/// Why a present field was refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyFieldError { + /// Another TIFF field type than SSHORT (8) for a table, or SHORT (3) / SSHORT for a setting. + FieldType(u16), + /// Another number of values than the supported layout's: 17 (shading, distortion), 33 (CA) or 1 (setting). + Count(u64), + /// The leading sample count is not the layout's 16 (32 for CA). Older bodies store 11 (22) samples padded to the + /// same length, a layout that is not interpreted; a count of 0 goes with an "unavailable" setting. + CountPrefix(i16), + /// The values lie outside the file (raw IFD) or the decrypted block (`SR2SubIFD`). + OutsideData, + /// In the raw IFD but past what the TIFF reader that decodes the image reads (8,192 entries per directory, a + /// total value budget), so the image doesn't use it either. + PastReaderLimits, + /// A setting stored as a negative SSHORT. + NegativeSetting(i16), +} + +/// A camera correction setting: the stored number and its documented meaning. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SonySetting { + pub value: u16, + pub meaning: SonySettingMeaning, +} + +/// The documented meaning of a setting value (ExifTool EXIF tag-name documentation). Values are compared whole; +/// they are not read as bit masks. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonySettingMeaning { + /// Shading 256, CA 0, distortion 0. The table is still recorded: Off does not mean absent. + Off, + /// Shading 257 or 272, CA 1, distortion 1. + Auto, + /// Distortion 17: Auto, fixed by the lens. + ForcedAuto, + /// "No correction params available": shading 511, CA 255, distortion 255. + Unavailable, + /// Any other value (e.g. a shading "Low" setting, whose number is not documented). Never treated as Off. + Unknown(u16), +} + +/// Whether a component is applied, and if not, why. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyLensStatus { + /// Converted into the image's `OpcodeList3` (today: ILCE-7RM4A Bayer distortion in native 3:2 framing). + Applied, + /// No usable table. + NoTable, + /// Decoded only: no independent validation covers this component's rendering semantics on any body (shading, + /// lateral CA). + NotValidatedComponent, + /// Decoded only: the component is validated on other camera models, not on this one. + NotValidatedModel, + /// Decoded only: the model is validated, but not this coding or framing (linear YCbCr, a non-3:2 crop) or a + /// table found only in the `SR2SubIFD`. + NotValidatedMode, + /// Validated scope, but the table is all zeros: a valid no-op, nothing to apply. + Neutral, + /// Validated scope, but the conversion refused this table (values outside the validated range, a degenerate + /// crop, or a fitted map that misses the table or folds). + Rejected, +} + +/// The framing and coding evidence for interpreting the tables, all in raw-frame pixels. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyLensGeometry { + /// The stored raw frame, including any padding (lossless L files are padded). + pub storage_width: usize, + pub storage_height: usize, + /// Sony's crop tags (`0x74c7` / `0x74c8`) as recorded. + pub sony_crop: Option, + /// The raw IFD's `DefaultCropOrigin` / `DefaultCropSize` as recorded. + pub default_crop: Option, + /// The image's crop (the decoder's precedence: Sony's tags, then the default crop, then the recorded size). + pub image_crop: Rect, + /// The rectangle the validated distortion conversion is centred on and normalised to: the default crop when + /// the file has one, else the image crop. Clipped to the frame. + pub reference: Rect, + /// TIFF compression of the raw image (1 uncompressed, 7 lossless, 32767 ARW2, 32766 ARW6). + pub compression: u16, + /// Downsized lossless M/S data (linear YCbCr, not sensor CFA). + pub linear_ycbcr: bool, + /// Both the image crop and the reference are 3:2 within 0.5 %, the only framing validated. + pub native_3_2: bool, +} + +impl SonyField { + /// Whether either directory holds the tag (well-formed or not). + pub fn is_present(&self) -> bool { + self.raw_ifd != SonyCopy::Absent || self.sr2 != SonyCopy::Absent + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 0f366648c..29ace7025 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -35,6 +35,7 @@ pub mod highlight; #[cfg(feature = "jxl")] mod jxl; pub mod kelvin_presets; +pub mod lens_metadata; pub mod ljpeg; pub(crate) mod llvc; pub mod opcodes; @@ -50,6 +51,7 @@ mod vendor; pub use demosaic::{Method, demosaic}; pub use dngwrite::{DngCompression, DngWriteOptions, write_dng, write_rendered_dng}; +pub use lens_metadata::LensMetadata; pub use lightcraft_color::Mat3; pub use lightcraft_geom::Orientation; pub use lightcraft_meta::Metadata; @@ -415,6 +417,9 @@ pub struct RawInfo { pub wb_multipliers: Option<[f32; 3]>, pub opcodes: OpcodeLists, pub metadata: Metadata, + /// Lens-correction tables the file records, decoded but not necessarily applied (see [`crate::lens_metadata`]). + #[serde(default)] + pub lens_metadata: LensMetadata, } impl RawInfo { @@ -648,6 +653,10 @@ pub struct RawImage { pub linearized: bool, pub opcodes: OpcodeLists, pub metadata: Metadata, + /// Lens-correction tables the file records, decoded but not necessarily applied (see [`crate::lens_metadata`]); only + /// validated components are in `opcodes`. + #[serde(default)] + pub lens_metadata: LensMetadata, } /// Black-subtracted, white-normalised data covering the active area. @@ -679,6 +688,7 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes.clone(), metadata: self.metadata.clone(), + lens_metadata: self.lens_metadata.clone(), } } @@ -698,6 +708,7 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes, metadata: self.metadata, + lens_metadata: self.lens_metadata, } } @@ -862,6 +873,7 @@ mod tests { linearized: false, opcodes: OpcodeLists::default(), metadata: Metadata::default(), + lens_metadata: Default::default(), }; let direct = raw.develop(Method::Bilinear).unwrap(); let by_hand = raw.finish_demosaiced(demosaic(&raw.normalized().unwrap(), Method::Bilinear)); diff --git a/crates/raw/src/saturation.rs b/crates/raw/src/saturation.rs index aaa0a6dff..261d5023b 100644 --- a/crates/raw/src/saturation.rs +++ b/crates/raw/src/saturation.rs @@ -137,6 +137,7 @@ mod tests { linearized: false, opcodes: OpcodeLists::default(), metadata: Metadata::default(), + lens_metadata: Default::default(), } } diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 59a9cf59e..7a4e0cc45 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -44,8 +44,9 @@ //! through the generic TIFF path. use super::arw6; +use crate::lens_metadata::SonyLensGeometry; use crate::tiffraw::{Packing, check_image, read_image_in}; -use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; +use crate::{BlackLevel, Cfa, ColorData, LensMetadata, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::{Chunk, ImageInfo, Layout, chunk_bytes}; use lightcraft_tiff::tags::{self as t, photometric}; @@ -552,6 +553,14 @@ fn dng_default_crop(raw: &Ifd, w: usize, h: usize) -> Option { None } +/// A rectangle exactly as an origin tag and a size tag record it (two values each), when both exist and fit `usize`. +fn recorded_rect(raw: &Ifd, origin: u16, size: u16) -> Option { + let (Some(origin), Some(size)) = (raw.u64s(origin), raw.u64s(size)) else { return None }; + let (&[x, y], &[w, h]) = (origin.as_slice(), size.as_slice()) else { return None }; + let n = |v: u64| usize::try_from(v).ok(); + Some(Rect::new(n(x)?, n(y)?, n(w)?, n(h)?)) +} + /// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style /// default crop when the raw IFD has one, else the image size the camera records (see [`recorded_size_crop`]). /// @@ -748,6 +757,25 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); metadata.height = Some(crop.height as u32); + // The lens-correction record. The distortion table is centred on the DNG-style default crop when the file has + // one: on the ILCE-7RM4A it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and + // the warp was validated against Sony's exports with that centre. The opcode's centre is in active-area + // coordinates, so it stays on the optical centre whichever crop frames the image. + let reference = dng_default_crop(raw, w, h).unwrap_or(crop); + let geometry = SonyLensGeometry { + storage_width: w, + storage_height: h, + sony_crop: recorded_rect(raw, CROP_TOP_LEFT, CROP_SIZE), + default_crop: recorded_rect(raw, t::DEFAULT_CROP_ORIGIN, t::DEFAULT_CROP_SIZE), + image_crop: crop, + reference, + compression: info.compression, + linear_ycbcr: linear_rgb, + native_3_2: super::arw_lens::is_3_2(crop) && super::arw_lens::is_3_2(reference), + }; + // decrypted for the copies' agreement check too, not only when a plain field is missing: a few dozen kB, found by + // reading only three `SR2Private` entries (see `sr2::SubIfd::read`) + let (sony_lens, warp) = super::arw_lens::corrections(bytes, tiff.order, (raw, tiff.bigtiff), sr2(), &model, geometry); let img = RawImage { format: RawFormat::Arw, width: w, @@ -767,22 +795,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, linearized: false, - // The distortion table is centred on the DNG-style default crop when the file has one: on the ILCE-7RM4A - // it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and the warp was - // validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so - // it stays on the optical centre whichever crop frames the image. - opcodes: OpcodeLists { - // DefaultCrop can retain native geometry while Sony's tags select an aspect crop. - // Both the image framing and the table geometry must stay within the validated 3:2 scope. - list3: if linear_rgb || crop.height == 0 || (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 { - Vec::new() - } else { - let geometry = dng_default_crop(raw, w, h).unwrap_or(crop); - super::arw_lens::distortion(&model, raw, Rect::new(0, 0, w, h), geometry).into_iter().collect() - }, - ..Default::default() - }, + // only validated components become opcodes (see `arw_lens`) + opcodes: OpcodeLists { list3: warp.into_iter().collect(), ..Default::default() }, metadata, + lens_metadata: LensMetadata { sony: sony_lens }, }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/arw_lens.rs b/crates/raw/src/vendor/arw_lens.rs index f4e02d69d..aa7313e23 100644 --- a/crates/raw/src/vendor/arw_lens.rs +++ b/crates/raw/src/vendor/arw_lens.rs @@ -1,28 +1,348 @@ -//! Sony's plain raw-IFD distortion table, expressed as a DNG warp for the shared optics pipeline. +//! Sony's in-file lens-correction records (shading, lateral chromatic aberration, distortion): bounded decoding of +//! the raw image IFD's fields and their encrypted `SR2SubIFD` copies into [`SonyLensCorrections`], and the one +//! validated conversion, the ILCE-7RM4A's distortion table, into a DNG warp for the shared optics pipeline. //! -//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7037`, signed shorts). -//! Geometry inferred independently from files and Sony Imaging Edge exports; see docs/sony-lens-corrections.md. -//! No lens database or external decoder code. The unrelated MakerNote table has a different scale. +//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7031` to `0x7037`, signed shorts) and +//! https://exiftool.org/TagNames/Sony.html#SR2SubIFD (`0x797d`, `0x7980`, `0x7982`). The `SR2SubIFD` setting tags +//! `0x797c`, `0x797f` and `0x7981` sit next to those tables, unnamed there; they equal the raw IFD's settings in every +//! file inspected. Geometry inferred independently from files and Sony Imaging Edge exports; see +//! docs/sony-lens-corrections.md. No lens database and no external decoder code. The MakerNote's similarly named +//! tables have another scale and layout and are never read here. +//! +//! A raw-IFD field is the one the TIFF reader parsed, the copy the image itself uses (its first occurrence, within +//! the reader's limits). The directory is also scanned, for diagnostics only: whether a tag is repeated, and why an +//! entry the reader left out was dropped (values outside the file, an unknown type, or past the reader's limits). +//! `SR2SubIFD` fields are read straight from their first entry. Only the exact layouts are accepted: SHORT\[1\] (or +//! nonnegative SSHORT\[1\]) settings, SSHORT\[17\] headed by 16 for shading and distortion, SSHORT\[33\] headed by +//! 32 for CA. Nothing is allocated from a count. The raw IFD's copy wins; the `SR2SubIFD`'s is used only when the raw +//! IFD lacks the tag, and a malformed raw-IFD copy is reported, not replaced. Decoding grants nothing: only components +//! whose interpretation was independently validated for the model and mode are converted ([`SonyLensStatus`]), and +//! the conversion receives exactly the table the decoder applied before this record existed. +use super::sr2::SubIfd; +use crate::lens_metadata::{ + SonyComponent, SonyCopy, SonyField, SonyFieldError, SonyLensCorrections, SonyLensGeometry, SonyLensStatus, SonySetting, SonySettingMeaning, + SonySource, +}; use crate::{Opcode, Rect}; -use lightcraft_tiff::{Ifd, Value}; +use lightcraft_tiff::{ByteOrder, FieldType, Ifd, Value}; -const DISTORTION: u16 = 0x7037; +/// Each component's setting and table tags, `(raw IFD, SR2SubIFD)`. +const SHADING: Tags = Tags { setting: (0x7031, 0x797c), table: (0x7032, 0x797d) }; +const CA: Tags = Tags { setting: (0x7034, 0x797f), table: (0x7035, 0x7980) }; +const DISTORTION: Tags = Tags { setting: (0x7036, 0x7981), table: (0x7037, 0x7982) }; +/// TIFF field types. +const SHORT: u16 = 3; +const SSHORT: u16 = 8; +/// Camera models whose raw-IFD distortion table was independently validated. Exact names: ILCE-7RM4 and ILCE-7RM4A +/// are distinct models, not interchangeable aliases. +const DISTORTION_MODELS: &[&str] = &["ILCE-7RM4A"]; +/// Most directory entries scanned: a classic TIFF directory's largest count, also bounding a BigTIFF one. +const MAX_ENTRIES: usize = 1 << 16; const SAMPLES: usize = 1024; const MAX_ERROR: f64 = 0.0005; -/// Only independently validated camera models and the 16-entry layout. Unknown models/layouts -/// and implausible/non-smooth maps are left uncorrected. See docs/sony-lens-corrections.md. -/// A camera setting of Off does not remove the lens data: the user can enable it in the developer. -pub(super) fn distortion(model: &str, ifd: &Ifd, active: Rect, crop: Rect) -> Option { +#[derive(Clone, Copy)] +struct Tags { + setting: (u16, u16), + table: (u16, u16), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Shading, + Ca, + Distortion, +} + +/// The documented meaning of a setting value (ExifTool EXIF tag-name documentation), compared whole. +fn meaning(kind: Kind, value: u16) -> SonySettingMeaning { + use Kind::{Ca, Distortion, Shading}; + use SonySettingMeaning::*; + match (kind, value) { + (Shading, 256) | (Ca | Distortion, 0) => Off, + (Shading, 257 | 272) | (Ca | Distortion, 1) => Auto, + (Distortion, 17) => ForcedAuto, + (Shading, 511) | (Ca | Distortion, 255) => Unavailable, + _ => Unknown(value), + } +} + +/// One copy of a field. +#[derive(Clone, Debug, PartialEq)] +enum Read { + Absent, + Valid(T), + Rejected(SonyFieldError), +} + +impl Read { + fn map(self, f: impl FnOnce(T) -> U) -> Read { + match self { + Read::Absent => Read::Absent, + Read::Valid(v) => Read::Valid(f(v)), + Read::Rejected(e) => Read::Rejected(e), + } + } + fn copy(&self) -> SonyCopy { + match self { + Read::Absent => SonyCopy::Absent, + Read::Valid(_) => SonyCopy::Valid, + Read::Rejected(e) => SonyCopy::Rejected(*e), + } + } +} + +/// One copy of a field, and whether its directory holds the tag more than once. +struct Found { + read: Read, + repeated: bool, +} + +impl Found { + fn map(self, f: impl FnOnce(T) -> U) -> Found { + Found { read: self.read.map(f), repeated: self.repeated } + } +} + +/// A TIFF directory whose out-of-line values sit at `offset - base` in `data`: the raw IFD in the file (`base` 0), or +/// the decrypted `SR2SubIFD`, whose offsets are file offsets into the block itself. +struct Directory<'a> { + /// From the entry count on. + entries: &'a [u8], + data: &'a [u8], + base: usize, + order: ByteOrder, + /// BigTIFF layout: 8-byte entry count, 20-byte entries with an 8-byte count and value field. + big: bool, +} + +impl<'a> Directory<'a> { + /// The first entry of `tag`, and whether another follows. Only the first counts, as in the TIFF reader and + /// `sr2.rs`. Reads at most the entries the data holds, and at most [`MAX_ENTRIES`]. + fn entry(&self, tag: u16) -> (Option<&'a [u8]>, bool) { + let o = self.order; + let (n, head, size) = if self.big { (o.read_u64(self.entries, 0), 8, 20) } else { (o.read_u16(self.entries, 0).map(u64::from), 2, 12) }; + let Some(n) = n else { return (None, false) }; + let mut found = None; + for i in 0..usize::try_from(n).unwrap_or(usize::MAX).min(MAX_ENTRIES) { + let at = i.checked_mul(size).and_then(|at| at.checked_add(head)); + let Some(e) = at.and_then(|at| self.entries.get(at..at.checked_add(size)?)) else { break }; + if o.read_u16(e, 0) == Some(tag) { + if found.is_some() { + return (found, true); + } + found = Some(e); + } + } + (found, false) + } + + /// Field type and count of entry `e`. + fn field(&self, e: &[u8]) -> (u16, u64) { + let o = self.order; + let count = if self.big { o.read_u64(e, 4) } else { o.read_u32(e, 4).map(u64::from) }; + (o.read_u16(e, 2).unwrap_or(0), count.unwrap_or(u64::MAX)) + } + + /// `len` value bytes of entry `e`: inline when they fit the value field, else at the entry's offset. + fn values(&self, e: &'a [u8], len: usize) -> Option<&'a [u8]> { + let (field, width) = if self.big { (12, 8) } else { (8, 4) }; + if len <= width { + return e.get(field..field + len); + } + let offset = if self.big { self.order.read_u64(e, field)? } else { u64::from(self.order.read_u32(e, field)?) }; + let at = usize::try_from(offset).ok()?.checked_sub(self.base)?; + self.data.get(at..at.checked_add(len)?) + } + + /// Why the TIFF reader left out entry `e`, which the directory holds: a field type it doesn't know, values outside + /// the data, or else its own limits (the entries it reads per directory, its value budget). + fn unread(&self, e: &'a [u8]) -> SonyFieldError { + let (kind, count) = self.field(e); + let Some(size) = FieldType::from_u16(kind).map(FieldType::size) else { return SonyFieldError::FieldType(kind) }; + match count.checked_mul(size as u64).and_then(|len| usize::try_from(len).ok()).and_then(|len| self.values(e, len)) { + Some(_) => SonyFieldError::PastReaderLimits, + None => SonyFieldError::OutsideData, + } + } + + /// A radial table of exactly `N` samples in entry `e`: SSHORT\[N + 1\], the first value `N`. + fn table(&self, e: &'a [u8]) -> Read<[i16; N]> { + let (kind, count) = self.field(e); + if kind != SSHORT { + return Read::Rejected(SonyFieldError::FieldType(kind)); + } + if count != (N + 1) as u64 { + return Read::Rejected(SonyFieldError::Count(count)); + } + let Some(bytes) = self.values(e, 2 * (N + 1)) else { return Read::Rejected(SonyFieldError::OutsideData) }; + let at = |i: usize| self.order.read_u16(bytes, 2 * i).map_or(0, |v| v as i16); + let prefix = at(0); + if usize::try_from(prefix).ok() != Some(N) { + return Read::Rejected(SonyFieldError::CountPrefix(prefix)); + } + Read::Valid(std::array::from_fn(|i| at(i + 1))) + } + + /// A setting in entry `e`: SHORT\[1\], or SSHORT\[1\] when not negative. + fn setting(&self, e: &'a [u8], kind: Kind) -> Read { + let (field_type, count) = self.field(e); + if field_type != SHORT && field_type != SSHORT { + return Read::Rejected(SonyFieldError::FieldType(field_type)); + } + if count != 1 { + return Read::Rejected(SonyFieldError::Count(count)); + } + let Some(value) = self.values(e, 2).and_then(|b| self.order.read_u16(b, 0)) else { + return Read::Rejected(SonyFieldError::OutsideData); + }; + if field_type == SSHORT && (value as i16) < 0 { + return Read::Rejected(SonyFieldError::NegativeSetting(value as i16)); + } + Read::Valid(SonySetting { value, meaning: meaning(kind, value) }) + } +} + +/// A table as the TIFF reader parsed it: SSHORT\[N + 1\], the first value `N`. +fn parsed_table(value: &Value) -> Read<[i16; N]> { + let Value::SShort(v) = value else { return Read::Rejected(SonyFieldError::FieldType(value.field_type() as u16)) }; + if v.len() != N + 1 { + return Read::Rejected(SonyFieldError::Count(v.len() as u64)); + } + let at = |i: usize| v.get(i).copied().unwrap_or(0); + if usize::try_from(at(0)).ok() != Some(N) { + return Read::Rejected(SonyFieldError::CountPrefix(at(0))); + } + Read::Valid(std::array::from_fn(|i| at(i + 1))) +} + +/// A setting as the TIFF reader parsed it: SHORT\[1\], or SSHORT\[1\] when not negative (the documented and the stored +/// type differ). +fn parsed_setting(value: &Value, kind: Kind) -> Read { + let number = match value { + Value::Short(v) if v.len() != 1 => return Read::Rejected(SonyFieldError::Count(v.len() as u64)), + Value::SShort(v) if v.len() != 1 => return Read::Rejected(SonyFieldError::Count(v.len() as u64)), + Value::Short(v) => v.first().copied(), + Value::SShort(v) => match v.first().copied() { + Some(n) if n < 0 => return Read::Rejected(SonyFieldError::NegativeSetting(n)), + n => n.map(|n| n as u16), + }, + other => return Read::Rejected(SonyFieldError::FieldType(other.field_type() as u16)), + }; + number.map_or(Read::Rejected(SonyFieldError::Count(0)), |value| Read::Valid(SonySetting { value, meaning: meaning(kind, value) })) +} + +/// The raw IFD's copy of `tag`: the one the TIFF reader parsed, which is what the image itself uses. The directory +/// itself only adds diagnostics: whether the tag is repeated, and why an entry the reader left out was dropped. +fn raw_copy(ifd: &Ifd, dir: Option<&Directory<'_>>, tag: u16, read: impl Fn(&Value) -> Read) -> Found { + let (entry, repeated) = dir.map_or((None, false), |d| d.entry(tag)); + let read = match (ifd.get(tag), entry.zip(dir)) { + (Some(parsed), _) => read(&parsed.value), + (None, Some((e, d))) => Read::Rejected(d.unread(e)), + (None, None) => Read::Absent, + }; + Found { read, repeated } +} + +/// The `SR2SubIFD`'s copy of `tag`, read straight from its first entry. +fn sr2_copy<'a, T>(dir: Option<&Directory<'a>>, tag: u16, read: impl Fn(&Directory<'a>, &'a [u8]) -> Read) -> Found { + let Some(d) = dir else { return Found { read: Read::Absent, repeated: false } }; + let (entry, repeated) = d.entry(tag); + Found { read: entry.map_or(Read::Absent, |e| read(d, e)), repeated } +} + +/// One field from its two copies: the raw IFD's when well-formed, else the `SR2SubIFD`'s when the raw IFD lacks the +/// tag. +fn combine(raw: Found, sr2: Found) -> SonyField { + let (raw_ifd, sr2_copy) = (raw.read.copy(), sr2.read.copy()); + let copies_agree = match (&raw.read, &sr2.read) { + (Read::Valid(a), Read::Valid(b)) => Some(a == b), + _ => None, + }; + let (value, source) = match (raw.read, sr2.read) { + (Read::Valid(v), _) => (Some(v), Some(SonySource::RawIfd)), + (Read::Absent, Read::Valid(v)) => (Some(v), Some(SonySource::Sr2SubIfd)), + _ => (None, None), + }; + SonyField { value, source, raw_ifd, sr2: sr2_copy, copies_agree, raw_ifd_repeated: raw.repeated, sr2_repeated: sr2.repeated } +} + +/// Whether `r` has the native 3:2 framing, within 0.5 %. +pub(super) fn is_3_2(r: Rect) -> bool { + r.height > 0 && (r.width as f64 / r.height as f64 - 1.5).abs() <= 0.005 +} + +/// Decode the file's record and the validated distortion warp. `raw` is the raw image IFD as the TIFF reader parsed +/// it (in a BigTIFF file when `big`), `sr2` the decrypted `SR2SubIFD`. The record is `None` when neither directory +/// holds any of the six fields. +pub(super) fn corrections( + bytes: &[u8], + order: ByteOrder, + (raw, big): (&Ifd, bool), + sr2: Option<&SubIfd>, + model: &str, + geometry: SonyLensGeometry, +) -> (Option, Option) { + let plain = usize::try_from(raw.offset).ok().and_then(|at| Some(Directory { entries: bytes.get(at..)?, data: bytes, base: 0, order, big })); + let encrypted = sr2.map(|s| { + let (block, start) = s.block(); + Directory { entries: block, data: block, base: start, order: s.order(), big: false } + }); + let (p, s) = (plain.as_ref(), encrypted.as_ref()); + // CA: two contiguous 16-sample curves after the count + let split = |v: [i16; 32]| -> [[i16; 16]; 2] { std::array::from_fn(|c| std::array::from_fn(|i| v[16 * c + i])) }; + let table16 = |tags: (u16, u16)| combine(raw_copy(raw, p, tags.0, parsed_table::<16>), sr2_copy(s, tags.1, |d, e| d.table::<16>(e))); + let shading_table = table16(SHADING.table); + let distortion_table = table16(DISTORTION.table); + let ca_table = combine(raw_copy(raw, p, CA.table.0, parsed_table::<32>).map(split), sr2_copy(s, CA.table.1, |d, e| d.table::<32>(e)).map(split)); + let setting = |tags: Tags, kind: Kind| { + combine(raw_copy(raw, p, tags.setting.0, |v| parsed_setting(v, kind)), sr2_copy(s, tags.setting.1, |d, e| d.setting(e, kind))) + }; + let settings = [setting(SHADING, Kind::Shading), setting(CA, Kind::Ca), setting(DISTORTION, Kind::Distortion)]; + if !shading_table.is_present() && !ca_table.is_present() && !distortion_table.is_present() && !settings.iter().any(SonyField::is_present) { + return (None, None); + } + let [shading_setting, ca_setting, distortion_setting] = settings; + // Only the validated scope reaches the conversion: the raw IFD's table, as the TIFF reader parsed it, of a Bayer + // image whose image crop and reference are native 3:2 (DefaultCrop can keep native geometry while Sony's tags + // select an aspect crop). + let validated_model = DISTORTION_MODELS.contains(&model); + let in_mode = !geometry.linear_ycbcr && geometry.native_3_2 && distortion_table.source == Some(SonySource::RawIfd); + let (w, h) = (geometry.storage_width, geometry.storage_height); + let warp = match &distortion_table.value { + Some(table) if in_mode => distortion(model, table, Rect::new(0, 0, w, h), geometry.reference), + _ => None, + }; + let distortion_status = match &distortion_table.value { + None => SonyLensStatus::NoTable, + Some(_) if warp.is_some() => SonyLensStatus::Applied, + Some(_) if !validated_model => SonyLensStatus::NotValidatedModel, + Some(_) if !in_mode => SonyLensStatus::NotValidatedMode, + Some(table) if table.iter().all(|v| *v == 0) => SonyLensStatus::Neutral, + Some(_) => SonyLensStatus::Rejected, + }; + // No independent validation of shading or CA semantics exists on any body: decoded, never applied. + let unvalidated = |present: bool| if present { SonyLensStatus::NotValidatedComponent } else { SonyLensStatus::NoTable }; + let record = SonyLensCorrections { + shading: SonyComponent { status: unvalidated(shading_table.value.is_some()), setting: shading_setting, table: shading_table }, + chromatic_aberration: SonyComponent { status: unvalidated(ca_table.value.is_some()), setting: ca_setting, table: ca_table }, + distortion: SonyComponent { setting: distortion_setting, table: distortion_table, status: distortion_status }, + sr2_read: sr2.is_some(), + geometry, + }; + (Some(record), warp) +} + +/// The validated conversion of a raw-IFD distortion table into a `WarpRectilinear`. Only independently validated +/// camera models and the 16-entry layout; unknown models and implausible or non-smooth maps are left uncorrected. +/// See docs/sony-lens-corrections.md. A camera setting of Off does not remove the lens data: the user can enable it +/// in the developer. +fn distortion(model: &str, table: &[i16; 16], active: Rect, crop: Rect) -> Option { // A matching table shape does not establish its scale or radial normalization on another body. - // Keep this exact: ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases. - if model != "ILCE-7RM4A" { + if !DISTORTION_MODELS.contains(&model) { return None; } - let Value::SShort(values) = &ifd.get(DISTORTION)?.value else { return None }; - let [16, table @ ..] = values.as_slice() else { return None }; - let table: &[i16; 16] = table.try_into().ok()?; if table.iter().all(|v| *v == 0) || table.iter().any(|v| !(-4096..=4096).contains(v)) { return None; } @@ -122,22 +442,720 @@ fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { #[cfg(test)] mod tests { use super::*; - use lightcraft_tiff::Entry; + use crate::lens_metadata::SonyCopy::{Absent, Rejected, Valid}; + use crate::lens_metadata::SonyFieldError::*; + use crate::{LensMetadata, Mode, RawImage}; + use lightcraft_tiff::ByteOrder::{Big, Little}; + use lightcraft_tiff::{IfdBuilder, ImageData, Tiff, TiffWriter, Value, tags as t}; - fn ifd(value: Value) -> Ifd { - Ifd { entries: vec![Entry { tag: DISTORTION, value, offset: 0 }], ..Default::default() } - } - fn table(values: &[i16]) -> Ifd { - ifd(Value::SShort(std::iter::once(16).chain(values.iter().copied()).collect())) - } const TELE: [i16; 16] = [-2, 1, 7, 17, 31, 48, 68, 93, 120, 152, 187, 225, 265, 309, 356, 403]; const WIDE: [i16; 16] = [14, 0, -27, -59, -106, -158, -223, -291, -367, -440, -519, -593, -667, -731, -792, -839]; const AREA: Rect = Rect { x: 0, y: 0, width: 9504, height: 6336 }; + /// The shading samples of the public ILCE-7CR samples (raw.pixls.us DSC00795/6/8). + const SHADING_T: [i16; 16] = [0, 64, 128, 224, 320, 480, 672, 896, 1184, 1472, 1824, 2240, 2816, 3552, 4512, 7328]; + const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + + /// A CA table with negative and positive samples and two different curves. + fn ca() -> [i16; 32] { + std::array::from_fn(|i| (i as i16 - 16) * 128) + } + fn ca_curves() -> [[i16; 16]; 2] { + let v = ca(); + std::array::from_fn(|c| std::array::from_fn(|i| v[16 * c + i])) + } + fn sshort(prefix: i16, samples: &[i16]) -> Value { + Value::SShort(std::iter::once(prefix).chain(samples.iter().copied()).collect()) + } + /// The six raw-IFD fields: shading 272 (Auto), CA 1 (Auto), and the distortion `setting` and `table`. + fn plain(table: &[i16; 16], setting: u16) -> Vec<(u16, Value)> { + vec![ + (SHADING.setting.0, Value::Short(vec![272])), + (SHADING.table.0, sshort(16, &SHADING_T)), + (CA.setting.0, Value::Short(vec![1])), + (CA.table.0, sshort(32, &ca())), + (DISTORTION.setting.0, Value::Short(vec![setting])), + (DISTORTION.table.0, sshort(16, table)), + ] + } + /// The same fields as `SR2SubIFD` entries `(tag, type, values)`. + fn encrypted(table: &[i16; 16], setting: u16) -> Vec<(u16, u16, Vec)> { + let t = |prefix: i16, v: &[i16]| std::iter::once(prefix).chain(v.iter().copied()).map(|x| x as u16).collect::>(); + vec![ + (SHADING.setting.1, SHORT, vec![272]), + (SHADING.table.1, SSHORT, t(16, &SHADING_T)), + (CA.setting.1, SHORT, vec![1]), + (CA.table.1, SSHORT, t(32, &ca())), + (DISTORTION.setting.1, SHORT, vec![setting]), + (DISTORTION.table.1, SSHORT, t(16, table)), + ] + } + fn set(fields: &mut Vec<(u16, Value)>, tag: u16, value: Value) { + fields.retain(|(t, _)| *t != tag); + fields.push((tag, value)); + } + fn word(order: ByteOrder, v: u32) -> Vec { + let mut out = Vec::new(); + order.put_u32(&mut out, v); + out + } + + /// A small uncompressed Sony CFA file (the raw IFD is IFD0) with the lens fields `plain` in its raw IFD and, when + /// `sr2` is set, an encrypted `SR2SubIFD` holding those entries, reached as Sony stores it: `DNGPrivateData` -> + /// `SR2Private` IFD -> block. + struct Arw { + model: &'static str, + order: ByteOrder, + bigtiff: bool, + size: (u32, u32), + sony_crop: Option<[u32; 4]>, + default_crop: Option<[u32; 4]>, + plain: Vec<(u16, Value)>, + sr2: Option)>>, + /// The key stored in `SR2SubIFDKey`; the block is always encrypted with the usual one. + stored_key: [u8; 4], + /// An edit to the decrypted `SR2SubIFD` before it is encrypted. + sr2_patch: Option>, + } + + impl Arw { + fn new(model: &'static str, order: ByteOrder) -> Arw { + Arw { + model, + order, + bigtiff: false, + size: (32, 24), + sony_crop: Some([2, 2, 30, 20]), + default_crop: Some([2, 2, 30, 20]), + plain: plain(&TELE, 0), + sr2: Some(encrypted(&TELE, 0)), + stored_key: KEY, + sr2_patch: None, + } + } + + fn build(&self) -> Vec { + let (w, h) = self.size; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(self.model.into())); + 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![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + if let Some([x, y, cw, ch]) = self.sony_crop { + raw.set(0x74c7, Value::Long(vec![x, y])); + raw.set(0x74c8, Value::Long(vec![cw, ch])); + } + if let Some([x, y, cw, ch]) = self.default_crop { + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![x, y])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![cw, ch])); + } + if self.sr2.is_some() { + raw.set(t::DNG_PRIVATE_DATA, Value::Byte(vec![0; 4])); + } + for (tag, value) in &self.plain { + raw.set(*tag, value.clone()); + } + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![0; (w * h * 2) as usize]] }); + let mut file = TiffWriter::new(self.order, self.bigtiff).write(&[raw]).unwrap(); + let Some(entries) = &self.sr2 else { return file }; + let o = self.order; + let at = Tiff::parse(&file).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + let private = file.len(); + let start = private + 2 + 3 * 12 + 4; + let refs: Vec<(u16, u16, &[u16])> = entries.iter().map(|(tag, kind, v)| (*tag, *kind, v.as_slice())).collect(); + let mut block = super::super::sr2::tests::block(start, &refs, o); + if let Some(patch) = &self.sr2_patch { + super::super::sr2::tests::encrypt(&mut block); + patch(&mut block); + super::super::sr2::tests::encrypt(&mut block); + } + o.put_u16(&mut file, 3); + for (tag, value) in [(0x7200u16, start as u32), (0x7201, block.len() as u32)] { + o.put_u16(&mut file, tag); + o.put_u16(&mut file, 4); + o.put_u32(&mut file, 1); + o.put_u32(&mut file, value); + } + o.put_u16(&mut file, 0x7221); + o.put_u16(&mut file, 7); + o.put_u32(&mut file, 4); + file.extend(self.stored_key); + o.put_u32(&mut file, 0); + file.extend(block); + file[at..at + 4].copy_from_slice(&word(o, private as u32)); + file + } + } + + /// Byte position of IFD0's entry for `tag` in a classic TIFF `file`. + fn entry_at(file: &[u8], order: ByteOrder, tag: u16) -> usize { + let ifd = order.read_u32(file, 4).unwrap() as usize; + let n = usize::from(order.read_u16(file, ifd).unwrap()); + (0..n).map(|i| ifd + 2 + 12 * i).find(|&e| order.read_u16(file, e) == Some(tag)).unwrap() + } + + /// Header and full decode of `file`, which must describe the same image; the full one. + fn decode(file: &[u8]) -> RawImage { + let header = super::super::arw::decode(file, Mode::Header).unwrap(); + let full = super::super::arw::decode(file, Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + full + } + fn sony(img: &RawImage) -> &SonyLensCorrections { + img.lens_metadata.sony.as_ref().expect("a Sony lens record") + } + /// (has a value, source, raw-IFD copy, SR2SubIFD copy, agreement) of one field. + fn prov(f: &SonyField) -> (bool, Option, SonyCopy, SonyCopy, Option) { + (f.value.is_some(), f.source, f.raw_ifd, f.sr2, f.copies_agree) + } + /// [`prov`] of the three settings, then the three tables. + fn provenance(s: &SonyLensCorrections) -> [(bool, Option, SonyCopy, SonyCopy, Option); 6] { + [ + prov(&s.shading.setting), + prov(&s.chromatic_aberration.setting), + prov(&s.distortion.setting), + prov(&s.shading.table), + prov(&s.chromatic_aberration.table), + prov(&s.distortion.table), + ] + } + + #[test] + fn decodes_all_three_components_with_settings_and_provenance() { + let raw = Some(SonySource::RawIfd); + for order in [Little, Big] { + for bigtiff in [false, true] { + for (model, warps, distortion_status) in + [("ILCE-7CR", 0, SonyLensStatus::NotValidatedModel), ("ILCE-7RM4A", 1, SonyLensStatus::Applied)] + { + let case = format!("{model} {order:?} bigtiff={bigtiff}"); + let mut arw = Arw::new(model, order); + arw.bigtiff = bigtiff; + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(img.opcodes.list3.len(), warps, "{case}"); + assert!(s.sr2_read, "{case}"); + assert_eq!(provenance(s), [(true, raw, Valid, Valid, Some(true)); 6], "{case}"); + assert_eq!(s.shading.table.value, Some(SHADING_T), "{case}"); + assert_eq!(s.chromatic_aberration.table.value, Some(ca_curves()), "{case}"); + assert_eq!(s.distortion.table.value, Some(TELE), "{case}"); + assert_eq!(s.shading.setting.value, Some(SonySetting { value: 272, meaning: SonySettingMeaning::Auto })); + assert_eq!(s.chromatic_aberration.setting.value, Some(SonySetting { value: 1, meaning: SonySettingMeaning::Auto })); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 0, meaning: SonySettingMeaning::Off })); + // readable shading and CA tables are not corrections + assert_eq!(s.shading.status, SonyLensStatus::NotValidatedComponent, "{case}"); + assert_eq!(s.chromatic_aberration.status, SonyLensStatus::NotValidatedComponent, "{case}"); + assert_eq!(s.distortion.status, distortion_status, "{case}"); + let crop = Rect::new(2, 2, 30, 20); + let geometry = SonyLensGeometry { + storage_width: 32, + storage_height: 24, + sony_crop: Some(crop), + default_crop: Some(crop), + image_crop: crop, + reference: crop, + compression: 1, + linear_ycbcr: false, + native_3_2: true, + }; + assert_eq!(s.geometry, geometry, "{case}"); + // the applied warp is the validated conversion of the raw IFD's table, as before + let reference: Vec = distortion(model, &TELE, Rect::new(0, 0, 32, 24), crop).into_iter().collect(); + assert_eq!(img.opcodes.list3, reference, "{case}"); + // DNG export keeps the warp and invents no Sony record + let dng = crate::write_dng(&img, &Default::default()).unwrap(); + let back = crate::decode(&dng).unwrap(); + assert_eq!(back.opcodes.list3, img.opcodes.list3, "{case}"); + assert_eq!(back.lens_metadata, LensMetadata::default(), "{case}"); + } + } + } + } + + #[test] + fn settings_keep_their_number_and_unknown_values_are_never_off() { + use Kind::{Ca, Distortion, Shading}; + use SonySettingMeaning::*; + for (kind, value, expected) in [ + (Shading, 256, Off), + (Shading, 257, Auto), + (Shading, 272, Auto), + (Shading, 511, Unavailable), + (Shading, 0, Unknown(0)), + (Shading, 1, Unknown(1)), + (Shading, 255, Unknown(255)), + (Shading, 258, Unknown(258)), + (Shading, 273, Unknown(273)), + (Ca, 0, Off), + (Ca, 1, Auto), + (Ca, 255, Unavailable), + (Ca, 2, Unknown(2)), + (Ca, 17, Unknown(17)), + (Ca, 256, Unknown(256)), + (Distortion, 0, Off), + (Distortion, 1, Auto), + (Distortion, 17, ForcedAuto), + (Distortion, 255, Unavailable), + (Distortion, 2, Unknown(2)), + (Distortion, 16, Unknown(16)), + (Distortion, 256, Unknown(256)), + (Distortion, u16::MAX, Unknown(u16::MAX)), + ] { + assert_eq!(meaning(kind, value), expected, "{kind:?} {value}"); + } + for order in [Little, Big] { + let read = |value: Value| { + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2 = None; + set(&mut arw.plain, DISTORTION.setting.0, value); + sony(&decode(&arw.build())).distortion.setting.clone() + }; + assert_eq!(read(Value::Short(vec![17])).value, Some(SonySetting { value: 17, meaning: ForcedAuto })); + assert_eq!(read(Value::Short(vec![9])).value, Some(SonySetting { value: 9, meaning: Unknown(9) })); + // the documented editor type (SSHORT) is accepted when not negative + assert_eq!(read(Value::SShort(vec![1])).value, Some(SonySetting { value: 1, meaning: Auto })); + for (value, error) in [ + (Value::SShort(vec![-1]), NegativeSetting(-1)), + (Value::SShort(vec![i16::MIN]), NegativeSetting(i16::MIN)), + (Value::Long(vec![1]), FieldType(4)), + (Value::Ascii("1".into()), FieldType(2)), + (Value::Short(vec![1, 1]), Count(2)), + (Value::Short(vec![0; 40]), Count(40)), + ] { + let f = read(value.clone()); + assert_eq!(prov(&f), (false, None, Rejected(error), Absent, None), "{value:?} {order:?}"); + } + } + } + + #[test] + fn malformed_raw_ifd_tables_are_refused_and_not_replaced_by_the_sr2_copy() { + let d = DISTORTION.table.0; + let cases = [ + (d, sshort(16, &[]), Count(1)), + (d, Value::SShort(vec![16, 0]), Count(2)), + (d, sshort(16, &TELE[..15]), Count(16)), + (d, Value::SShort(std::iter::once(16).chain(TELE).chain([0]).collect()), Count(18)), + (d, Value::SShort(vec![15; 17]), CountPrefix(15)), + (d, sshort(-16, &TELE), CountPrefix(-16)), + (d, sshort(0, &TELE), CountPrefix(0)), + (d, Value::Short(vec![16; 17]), FieldType(3)), + (d, Value::Long(vec![16; 17]), FieldType(4)), + (d, Value::Double(vec![f64::NAN; 17]), FieldType(12)), + (d, Value::Undefined(vec![16; 34]), FieldType(7)), + (SHADING.table.0, sshort(32, &[0; 32]), Count(33)), + (SHADING.table.0, Value::Short(std::iter::once(16).chain([0; 16]).collect()), FieldType(3)), + (CA.table.0, sshort(16, &ca()[..16]), Count(17)), + (CA.table.0, sshort(16, &ca()), CountPrefix(16)), + (CA.table.0, sshort(33, &ca()), CountPrefix(33)), + ]; + for order in [Little, Big] { + for (tag, value, error) in &cases { + let mut arw = Arw::new("ILCE-7RM4A", order); + set(&mut arw.plain, *tag, value.clone()); + let img = decode(&arw.build()); + let s = sony(&img); + let field = if *tag == SHADING.table.0 { + prov(&s.shading.table) + } else if *tag == CA.table.0 { + prov(&s.chromatic_aberration.table) + } else { + prov(&s.distortion.table) + }; + // the SR2SubIFD's good copy does not hide the corruption + assert_eq!(field, (false, None, Rejected(*error), Valid, None), "{tag:#x} {value:?} {order:?}"); + // the validated distortion needs its own raw-IFD table; a bad unrelated table doesn't remove it + assert_eq!(img.opcodes.list3.len(), usize::from(*tag != d), "{tag:#x} {value:?}"); + assert_eq!(s.distortion.status == SonyLensStatus::NoTable, *tag == d); + } + } + } + + #[test] + fn counts_and_offsets_beyond_the_data_are_reported_without_reading_them() { + for order in [Little, Big] { + let good = Arw::new("ILCE-7RM4A", order).build(); + let e = entry_at(&good, order, DISTORTION.table.0); + let len = good.len() as u32; + for (field, value, error) in [ + // counts: the TIFF reader drops an entry whose values would pass the end of the file + (4, 0x7fff_ffff, OutsideData), + (4, u32::MAX, OutsideData), + (4, 0, Count(0)), + (4, 16, Count(16)), + // value offsets past the end, straddling it, at it + (8, u32::MAX, OutsideData), + (8, len - 10, OutsideData), + (8, len, OutsideData), + ] { + let mut file = good.clone(); + file[e + field..e + field + 4].copy_from_slice(&word(order, value)); + let img = decode(&file); + let s = sony(&img); + assert_eq!(prov(&s.distortion.table), (false, None, Rejected(error), Valid, None), "{field} {value} {order:?}"); + assert!(img.opcodes.list3.is_empty()); + assert_eq!(s.distortion.status, SonyLensStatus::NoTable); + assert_eq!(s.shading.table.value, Some(SHADING_T)); + } + } + } + + #[test] + fn a_table_past_the_tiff_readers_entry_limit_is_not_applied() { + // The TIFF reader that decodes the image reads 8,192 entries of a directory; with 8,200 filler entries ahead + // of them the image never sees the lens fields, so they must not become a correction (main applies none). + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.size, arw.sony_crop, arw.default_crop, arw.sr2) = ((30, 20), None, None, None); + let reference = decode(&arw.build()); + assert_eq!(reference.opcodes.list3.len(), 1, "control: the same file without filler is corrected"); + arw.plain = (0..8200u16).map(|i| (0x5000 + i, Value::Byte(vec![0]))).chain(plain(&TELE, 0)).collect(); + let img = decode(&arw.build()); + assert!(img.opcodes.list3.is_empty(), "{order:?}: a table the TIFF reader never read was applied"); + // the directory scan still finds and reports it + let s = sony(&img); + assert_eq!(prov(&s.distortion.table), (false, None, Rejected(PastReaderLimits), Absent, None)); + assert_eq!(s.distortion.status, SonyLensStatus::NoTable); + } + } + + #[test] + fn a_repeated_tag_keeps_the_copy_the_tiff_reader_keeps() { + let crop = Rect::new(2, 2, 30, 20); + let warp = |table: &[i16; 16]| -> Vec { distortion("ILCE-7RM4A", table, Rect::new(0, 0, 32, 24), crop).into_iter().collect() }; + for order in [Little, Big] { + // a second distortion table (WIDE) right after the first (TELE), under a placeholder tag renamed to 0x7037 + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.push((0x7040, sshort(16, &WIDE))); + let good = arw.build(); + let mut tag = Vec::new(); + order.put_u16(&mut tag, DISTORTION.table.0); + let mut twice = good.clone(); + let e = entry_at(&twice, order, 0x7040); + twice[e..e + 2].copy_from_slice(&tag); + // both valid: the first one, as the TIFF reader keeps it and main applies it + let img = decode(&twice); + assert_eq!(img.opcodes.list3, warp(&TELE), "{order:?}: both copies valid"); + assert_eq!(sony(&img).distortion.table.value, Some(TELE)); + // reported as a diagnostic, which changes nothing else + let f = &sony(&img).distortion.table; + assert_eq!((f.raw_ifd_repeated, f.sr2_repeated, f.raw_ifd, f.copies_agree), (true, false, Valid, Some(true))); + assert_eq!(sony(&img).distortion.status, SonyLensStatus::Applied); + assert!(!sony(&decode(&good)).distortion.table.raw_ifd_repeated); + // the first one's values outside the file: the TIFF reader drops it and keeps the second + let mut dropped = twice.clone(); + let first = entry_at(&dropped, order, DISTORTION.table.0); + dropped[first + 8..first + 12].copy_from_slice(&word(order, u32::MAX)); + let img = decode(&dropped); + assert_eq!(img.opcodes.list3, warp(&WIDE), "{order:?}: first copy unreadable"); + assert_eq!(sony(&img).distortion.table.value, Some(WIDE)); + assert!(sony(&img).distortion.table.raw_ifd_repeated); + // the first one malformed (the setting's SHORT entry renamed): the TIFF reader keeps it, nothing applies + let mut short = good.clone(); + let e = entry_at(&short, order, DISTORTION.setting.0); + short[e..e + 2].copy_from_slice(&tag); + let img = decode(&short); + assert!(img.opcodes.list3.is_empty(), "{order:?}: first copy malformed"); + assert_eq!(sony(&img).distortion.table.raw_ifd, Rejected(FieldType(SHORT))); + } + } + + #[test] + fn sr2_copies_fill_in_only_missing_raw_ifd_fields_and_are_never_applied() { + let (raw, sr2) = (Some(SonySource::RawIfd), Some(SonySource::Sr2SubIfd)); + for order in [Little, Big] { + // nothing in the raw IFD: every field from the SR2SubIFD, decoded, not applied even on the validated + // model (only the raw IFD's table was validated) + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.clear(); + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(provenance(s), [(true, sr2, Absent, Valid, None); 6]); + assert_eq!( + (s.shading.table.value, s.chromatic_aberration.table.value, s.distortion.table.value), + (Some(SHADING_T), Some(ca_curves()), Some(TELE)) + ); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 0, meaning: SonySettingMeaning::Off })); + assert_eq!(s.distortion.status, SonyLensStatus::NotValidatedMode); + assert!(img.opcodes.list3.is_empty()); + // only the distortion table in the raw IFD: that one is used and applied, the rest comes from the SR2SubIFD + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.retain(|(tag, _)| *tag == DISTORTION.table.0); + let img = decode(&arw.build()); + let s = sony(&img); + let p = provenance(s); + assert_eq!(p[5], (true, raw, Valid, Valid, Some(true))); + assert!(p[..5].iter().all(|f| *f == (true, sr2, Absent, Valid, None)), "{p:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (SonyLensStatus::Applied, 1)); + // the raw IFD's tag numbers inside the SR2SubIFD are not its tables + let mut arw = Arw::new("ILCE-7CR", order); + arw.plain.clear(); + let plain_tags = [SHADING.setting.0, SHADING.table.0, CA.setting.0, CA.table.0, DISTORTION.setting.0, DISTORTION.table.0]; + arw.sr2 = Some(encrypted(&TELE, 0).into_iter().zip(plain_tags).map(|((_, kind, v), tag)| (tag, kind, v)).collect()); + assert_eq!(decode(&arw.build()).lens_metadata, LensMetadata::default()); + } + } + + #[test] + fn disagreeing_copies_are_reported_and_the_raw_ifd_wins() { + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.sr2 = Some(encrypted(&WIDE, 17)); + let img = decode(&arw.build()); + let s = sony(&img); + let raw = Some(SonySource::RawIfd); + assert_eq!(prov(&s.distortion.table), (true, raw, Valid, Valid, Some(false))); + assert_eq!(prov(&s.distortion.setting), (true, raw, Valid, Valid, Some(false))); + assert_eq!(prov(&s.shading.table), (true, raw, Valid, Valid, Some(true))); + assert_eq!(s.distortion.table.value, Some(TELE)); + assert_eq!(s.distortion.setting.value.map(|v| v.value), Some(0)); + // the validated conversion is unchanged: the raw IFD's table + let reference: Vec = distortion("ILCE-7RM4A", &TELE, Rect::new(0, 0, 32, 24), Rect::new(2, 2, 30, 20)).into_iter().collect(); + assert_eq!(img.opcodes.list3, reference); + } + } + + #[test] + fn unreadable_or_hostile_sr2_subifds_give_no_copies() { + let raw = Some(SonySource::RawIfd); + for order in [Little, Big] { + // stored with another key: the block decrypts to noise, no copy is found, nothing falls back on it + let mut arw = Arw::new("ILCE-7CR", order); + arw.stored_key = [1, 2, 3, 4]; + let img = decode(&arw.build()); + let s = sony(&img); + assert!(provenance(s).iter().all(|&(value, source, plain, sr2, agree)| value + && source == raw + && plain == Valid + && sr2 != Valid + && agree.is_none())); + arw.plain.clear(); + let img = decode(&arw.build()); + assert!(img.lens_metadata.sony.as_ref().is_none_or(|s| provenance(s).iter().all(|f| !f.0 && f.3 != Valid))); + // no SR2SubIFD; DNGPrivateData pointing past the file or into the image + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2 = None; + let s = sony(&decode(&arw.build())).clone(); + assert!(!s.sr2_read); + assert_eq!(provenance(&s), [(true, raw, Valid, Absent, None); 6]); + let good = Arw::new("ILCE-7CR", order).build(); + let at = Tiff::parse(&good).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + for pointer in [u32::MAX, good.len() as u32, 8] { + let mut file = good.clone(); + file[at..at + 4].copy_from_slice(&word(order, pointer)); + let img = decode(&file); + assert!(sony(&img).distortion.table.sr2 != Valid, "{pointer}"); + assert_eq!(sony(&img).distortion.table.value, Some(TELE)); + } + // hostile entries in a well-encrypted block (the distortion table is its sixth entry): another type, huge + // count, a value offset before the block or past it + let sixth = 2 + 12 * 5; + let put = move |at: usize, bytes: Vec| move |b: &mut [u8]| b[at..at + bytes.len()].copy_from_slice(&bytes); + let mut short = Vec::new(); + order.put_u16(&mut short, SHORT); + for (patch, error) in [ + (Box::new(put(sixth + 2, short.clone())) as Box, FieldType(SHORT)), + (Box::new(put(sixth + 4, word(order, u32::MAX))), Count(u64::from(u32::MAX))), + (Box::new(put(sixth + 4, word(order, 1))), Count(1)), + (Box::new(put(sixth + 8, word(order, 0))), OutsideData), + (Box::new(put(sixth + 8, word(order, u32::MAX))), OutsideData), + ] { + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2_patch = Some(patch); + let s = sony(&decode(&arw.build())).clone(); + assert_eq!(prov(&s.distortion.table), (true, raw, Valid, Rejected(error), None), "{error:?}"); + assert_eq!(prov(&s.shading.table), (true, raw, Valid, Valid, Some(true))); + } + // an entry count past the end of the block: the scan stops where the block does + let mut count = Vec::new(); + order.put_u16(&mut count, u16::MAX); + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2_patch = Some(Box::new(put(0, count))); + assert_eq!(provenance(sony(&decode(&arw.build()))), [(true, raw, Valid, Valid, Some(true)); 6]); + } + } + + #[test] + fn framing_and_coding_evidence_gate_the_validated_distortion() { + use SonyLensStatus::*; + let r = Rect::new; + for order in [Little, Big] { + let run = |arw: &Arw| { + let img = decode(&arw.build()); + let s = sony(&img).clone(); + (s, img) + }; + // Sony's crop at x = 0 and the default crop at x = 2 (the A7R IVA's 0 versus 32): the image keeps Sony's + // crop, the table is centred on and normalised to the default crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.sony_crop = Some([0, 2, 30, 20]); + let (s, img) = run(&arw); + let g = &s.geometry; + assert_eq!( + (g.sony_crop, g.default_crop, g.image_crop, g.reference), + (Some(r(0, 2, 30, 20)), Some(r(2, 2, 30, 20)), r(0, 2, 30, 20), r(2, 2, 30, 20)) + ); + assert_eq!((img.crop, s.distortion.status), (r(0, 2, 30, 20), Applied)); + let Some(Opcode::WarpRectilinear { center, .. }) = img.opcodes.list3.first() else { panic!("warp") }; + assert!((center[0] - 16.5 / 31.0).abs() < 1e-12 && (center[1] - 11.5 / 23.0).abs() < 1e-12, "{center:?}"); + // agreeing crops (the A7CR's case), in a frame padded beyond them (lossless L): the storage is recorded, + // the reference stays the crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.size = (40, 28); + let (s, _) = run(&arw); + assert_eq!((s.geometry.storage_width, s.geometry.storage_height, s.geometry.reference), (40, 28, r(2, 2, 30, 20))); + assert_eq!(s.distortion.status, Applied); + // without a default crop the reference is the image crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.default_crop = None; + let (s, _) = run(&arw); + assert_eq!((s.geometry.default_crop, s.geometry.reference, s.distortion.status), (None, r(2, 2, 30, 20), Applied)); + // non-3:2 framing: an aspect crop in Sony's tags, in the default crop, or a 4:3 frame without crop tags + for (sony_crop, default_crop) in + [(Some([2, 2, 30, 16]), Some([2, 2, 30, 20])), (Some([2, 2, 30, 20]), Some([2, 2, 30, 16])), (None, None)] + { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.sony_crop, arw.default_crop) = (sony_crop, default_crop); + let (s, img) = run(&arw); + assert!(!s.geometry.native_3_2, "{sony_crop:?} {default_crop:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (NotValidatedMode, 0), "{sony_crop:?} {default_crop:?}"); + } + // neutral, out-of-range and folding tables in the validated scope; models outside it + for (model, table, status) in [ + ("ILCE-7RM4A", [0; 16], Neutral), + ("ILCE-7RM4A", [i16::MIN; 16], Rejected), + ("ILCE-7RM4A", [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], Rejected), + ("ILCE-7RM4", TELE, NotValidatedModel), + ("ILCE-7CR", [0; 16], NotValidatedModel), + ("", TELE, NotValidatedModel), + ] { + let mut arw = Arw::new(model, order); + arw.plain = plain(&table, 1); + let (s, img) = run(&arw); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (status, 0), "{model} {table:?}"); + assert_eq!(s.distortion.table.value, Some(table)); + } + } + } + + #[test] + fn out_of_range_crop_tags_are_recorded_and_never_applied() { + for order in [Little, Big] { + // past the frame, enormous, empty, partly outside + for crop in [[99999, 0, 30, 20], [u32::MAX; 4], [2, 2, 0, 0], [30, 2, 30, 20]] { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.sony_crop, arw.default_crop) = (Some(crop), Some(crop)); + let img = decode(&arw.build()); + let s = sony(&img); + let [x, y, w, h] = crop.map(|v| v as usize); + assert_eq!((s.geometry.sony_crop, s.geometry.default_crop), (Some(Rect::new(x, y, w, h)), Some(Rect::new(x, y, w, h))), "{crop:?}"); + assert!(!s.geometry.native_3_2, "{crop:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (SonyLensStatus::NotValidatedMode, 0), "{crop:?}"); + assert_eq!(s.distortion.table.value, Some(TELE)); + } + } + } + + #[test] + fn exif_orientation_does_not_change_the_record() { + // the tables and the warp are in sensor coordinates; the engine orients the warp (`engine::files::embedded_lens`) + let reference = decode(&Arw::new("ILCE-7RM4A", Little).build()); + for orientation in 1..=8u16 { + let mut arw = Arw::new("ILCE-7RM4A", Little); + arw.plain.push((t::ORIENTATION, Value::Short(vec![orientation]))); + let img = decode(&arw.build()); + assert_eq!(img.orientation, crate::Orientation::from_exif(orientation)); + assert_eq!((&img.lens_metadata, &img.opcodes), (&reference.lens_metadata, &reference.opcodes), "{orientation}"); + } + } + + #[test] + fn linear_ycbcr_files_are_decoded_but_not_corrected() { + // a downsized lossless (M/S) coding, as in arw.rs's test, from the validated model with 3:2 crop tags + for order in [Little, Big] { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7RM4A".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![15, 15, 15])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::YCBCR])); + raw.set(t::COMPRESSION, Value::Short(vec![7])); + raw.set(0x7010, Value::Short(vec![0; 4])); + raw.set(0x74c7, Value::Long(vec![0, 0])); + raw.set(0x74c8, Value::Long(vec![3, 2])); + for (tag, value) in plain(&TELE, 17) { + raw.set(tag, value); + } + raw.set_image(ImageData::Tiles { tile_width: 4, tile_height: 4, tiles: vec![crate::ljpeg::tests::fixture_420()] }); + let img = decode(&TiffWriter::new(order, false).write(&[raw]).unwrap()); + let s = sony(&img); + assert!(s.geometry.linear_ycbcr && s.geometry.native_3_2); + assert_eq!((s.geometry.compression, s.distortion.status, img.opcodes.list3.len()), (7, SonyLensStatus::NotValidatedMode, 0)); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 17, meaning: SonySettingMeaning::ForcedAuto })); + assert_eq!(s.distortion.table.value, Some(TELE)); + } + } + + #[test] + fn files_without_lens_fields_have_no_record() { + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.clear(); + arw.sr2 = None; + let img = decode(&arw.build()); + assert_eq!((img.lens_metadata.clone(), img.opcodes.list3.len()), (LensMetadata::default(), 0)); + // an SR2SubIFD holding other tags only + arw.sr2 = Some(vec![(0x7310, SHORT, vec![512; 4])]); + assert_eq!(decode(&arw.build()).lens_metadata, LensMetadata::default()); + // one field anywhere is a record + arw.sr2 = Some(vec![(DISTORTION.setting.1, SHORT, vec![255])]); + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 255, meaning: SonySettingMeaning::Unavailable })); + assert_eq!( + (s.distortion.status, s.shading.status, s.chromatic_aberration.status), + (SonyLensStatus::NoTable, SonyLensStatus::NoTable, SonyLensStatus::NoTable) + ); + } + } + + #[test] + fn corrupt_files_never_panic() { + for order in [Little, Big] { + let good = Arw::new("ILCE-7RM4A", order).build(); + let tiff = Tiff::parse(&good).unwrap(); + let strip = tiff.ifds[0].image().unwrap().chunks(good.len() as u64)[0]; + let image = strip.offset as usize..(strip.offset + strip.len) as usize; + let check = |file: &[u8]| { + let header = super::super::arw::decode(file, Mode::Header); + let full = super::super::arw::decode(file, Mode::Full); + if let (Ok(h), Ok(f)) = (header, full) { + assert_eq!((h.lens_metadata, h.opcodes), (f.lens_metadata, f.opcodes)); + } + }; + for len in 0..good.len() { + check(&good[..len]); + } + for at in (0..good.len()).filter(|at| !image.contains(at)) { + for v in [0, 0xff, good[at] ^ 0x80, good[at] ^ 0x01] { + let mut file = good.clone(); + file[at] = v; + check(&file); + } + } + } + } #[test] fn matches_independent_sony_reference_geometry() { // On/off feature correspondences from Imaging Edge 4.1 at 600 mm, independently fitted. - let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7RM4A", &table(&TELE), AREA, AREA).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7RM4A", &TELE, AREA, AREA).unwrap() else { panic!("warp") }; assert_eq!(center, [0.5, 0.5]); let reference = [0.9760159044, 0.0254891644, -0.0005984764, -0.0010120638]; for i in 0..=100 { @@ -151,8 +1169,8 @@ mod tests { fn barrel_framing_and_offset_crop_keep_the_same_geometry() { let crop = Rect::new(32, 20, 9504, 6336); let active = Rect::new(0, 0, 9600, 6376); - let Opcode::WarpRectilinear { planes: a, .. } = distortion("ILCE-7RM4A", &table(&WIDE), AREA, AREA).unwrap() else { panic!("warp") }; - let Opcode::WarpRectilinear { planes: b, center } = distortion("ILCE-7RM4A", &table(&WIDE), active, crop).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes: a, .. } = distortion("ILCE-7RM4A", &WIDE, AREA, AREA).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes: b, center } = distortion("ILCE-7RM4A", &WIDE, active, crop).unwrap() else { panic!("warp") }; assert!(a[0][0] > 1.02 && a[0][0] < 1.03, "barrel correction expands the middle"); assert!((center[0] * 9599.0 - 4783.5).abs() < 1e-10); assert!((center[1] * 6375.0 - 3187.5).abs() < 1e-10); @@ -162,37 +1180,28 @@ mod tests { } } + /// The conversion's own refusals; missing and malformed layouts are refused by the decoder before it (see + /// `malformed_raw_ifd_tables_are_refused_and_not_replaced_by_the_sr2_copy`). #[test] - fn refuses_missing_unknown_malformed_or_unusable_tables() { - for bad in [ - Ifd::default(), - table(&[]), - table(&TELE[..15]), - table(&[0; 16]), - table(&[i16::MIN; 16]), - table(&[0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), - ifd(Value::SShort(vec![15; 17])), - ifd(Value::Short(vec![16; 17])), - ifd(Value::Double(vec![f64::NAN; 17])), - ] { + fn refuses_unusable_tables() { + for bad in [[0; 16], [i16::MIN; 16], [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]] { assert!(distortion("ILCE-7RM4A", &bad, AREA, AREA).is_none()); } - assert!(distortion("ILCE-7RM4A", &table(&TELE), Rect::new(0, 0, 1, 1), AREA).is_none()); - assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(0, 0, 9504, 5346)).is_none()); - assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(99999, 0, 10, 10)).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, Rect::new(0, 0, 1, 1), AREA).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, Rect::new(0, 0, 9504, 5346)).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, Rect::new(99999, 0, 10, 10)).is_none()); } + #[test] fn refuses_unvalidated_models_even_with_a_valid_table() { - let valid = table(&TELE); - assert!(distortion("ILCE-7RM4A", &valid, AREA, AREA).is_some()); - for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown"] { - assert!(distortion(model, &valid, AREA, AREA).is_none(), "{model}"); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, AREA).is_some()); + for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown", "ILCE-7CR"] { + assert!(distortion(model, &TELE, AREA, AREA).is_none(), "{model}"); } } #[test] fn header_and_full_decode_preserve_correction_through_dng() { - use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, tags as t}; for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 0), ("", 0)] { for (height, expected) in [(20, expected), (16, 0)] { for order in [ByteOrder::Little, ByteOrder::Big] { @@ -211,7 +1220,7 @@ mod tests { raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20])); // An aspect crop must stay excluded even when DefaultCrop still describes 3:2. raw.set(0x74c8, Value::Long(vec![30, height])); - raw.set(DISTORTION, Value::SShort(std::iter::once(16).chain(TELE).collect())); + raw.set(DISTORTION.table.0, Value::SShort(std::iter::once(16).chain(TELE).collect())); // Zero pixels have the same byte representation in both orders. raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap(); diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 74d93534d..f6bc1798a 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -251,6 +251,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/cr2/sraw.rs b/crates/raw/src/vendor/cr2/sraw.rs index 68b406238..e1b2988e9 100644 --- a/crates/raw/src/vendor/cr2/sraw.rs +++ b/crates/raw/src/vendor/cr2/sraw.rs @@ -393,6 +393,7 @@ pub(super) fn decode(bytes: &[u8], tiff: &Tiff, raw: &Ifd, mode: Mode) -> Result linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs index 7c93e3bac..2c37c99d3 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -255,6 +255,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; image.validate_for(mode)?; Ok(image) diff --git a/crates/raw/src/vendor/kodak.rs b/crates/raw/src/vendor/kodak.rs index f7f82cff8..61c238740 100644 --- a/crates/raw/src/vendor/kodak.rs +++ b/crates/raw/src/vendor/kodak.rs @@ -117,6 +117,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 7d5b24a4f..4343afe3f 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -219,6 +219,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { ..Default::default() }, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index ea7f30eb7..834be3c62 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -331,6 +331,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index 86db33540..840761f30 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -326,6 +326,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 7b148929d..0ff69cd19 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -214,6 +214,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { linearized: false, opcodes: OpcodeLists { list3: super::raf_lens::corrections(&camera_name(bytes), &ifd, active), ..Default::default() }, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index b856fbd52..67f0ff10c 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -618,6 +618,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { linearized: false, opcodes: OpcodeLists { list3: distortion(ifd0, active).into_iter().collect(), ..Default::default() }, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/sr2.rs b/crates/raw/src/vendor/sr2.rs index 212533add..10fc15074 100644 --- a/crates/raw/src/vendor/sr2.rs +++ b/crates/raw/src/vendor/sr2.rs @@ -45,7 +45,7 @@ //! of an accident 2⁻⁴⁰⁶⁴). For the DSC-R1's key `63 be 01 3e` the 11932-byte block becomes a well-formed 101-entry //! directory (black `0x7300`, white balance `0x7303`, crop `0x74c3`). -use lightcraft_tiff::{ByteOrder, Ifd, ParseOptions, tags as t}; +use lightcraft_tiff::{ByteOrder, EntrySpan, Ifd, ParseOptions, decode_value, directory_entries, entry_span, tags as t}; /// `SR2Private` tags. const SUBIFD_OFFSET: u16 = 0x7200; @@ -136,11 +136,8 @@ impl SubIfd { /// [`MAX_LEN`]. pub(crate) fn read(bytes: &[u8], ifd0: &Ifd, order: ByteOrder) -> Option { let at = order.read_u32(ifd0.bytes(t::DNG_PRIVATE_DATA)?, 0)?; - let opts = ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; - let (private, _) = lightcraft_tiff::parse_ifd_at(bytes, u64::from(at), order, 0, false, &opts).ok()?; - let key: [u8; 4] = private.bytes(SUBIFD_KEY)?.try_into().ok()?; - let start = usize::try_from(private.u64(SUBIFD_OFFSET)?).ok()?; - let len = usize::try_from(private.u64(SUBIFD_LENGTH)?).ok()?; + let fields = private_fields(bytes, u64::from(at), order); + let (start, len, key) = (fields.offset?, fields.length?, fields.key?); if len > MAX_LEN { return None; } @@ -165,6 +162,13 @@ impl SubIfd { self.order } + /// The decrypted block (the directory from its entry count on) and the file offset it was stored at, which its + /// value offsets count from: for readers that check each entry's exact type and count themselves + /// (`arw_lens`). + pub(crate) fn block(&self) -> (&[u8], usize) { + (&self.plain, self.start) + } + /// The field type and value bytes of a SHORT or SSHORT entry; `None` when the tag is absent, has another type /// or its values lie outside the block. pub(crate) fn short_bytes(&self, tag: u16) -> Option<(u16, &[u8])> { @@ -193,9 +197,69 @@ impl SubIfd { } } +/// The `SR2Private` fields that locate the block. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct PrivateFields { + offset: Option, + length: Option, + key: Option<[u8; 4]>, +} + +/// The block's file offset, length and key from the `SR2Private` directory at `at`, exactly as the TIFF reader would +/// give them (`parse_ifd_at` with its default limits, then its `u64` and `bytes` lookups), without decoding the +/// directory. The selection uses the reader's own rules: its directory layout and entry limit +/// ([`directory_entries`]), which entries it can read ([`entry_span`]) and its value budget; each field is its first +/// readable occurrence. Of that entry only the first value is decoded (the key's first five bytes, which settle +/// whether it is four bytes), so a hostile directory can't make this allocate memory sized by its other entries, +/// which could each alias the whole file, before the block's own [`MAX_LEN`] is checked. +fn private_fields(bytes: &[u8], at: u64, order: ByteOrder) -> PrivateFields { + let opts = ParseOptions::default(); + let Ok(dir) = directory_entries(bytes, at, order, false, opts.max_entries) else { return PrivateFields::default() }; + let mut budget = opts.budget_for(bytes.len()); + let (mut offset, mut length, mut key) = (None, None, None); + for p in dir.positions() { + let Some(span) = entry_span(bytes, p, order, false, 0) else { continue }; + if span.len > budget { + continue; + } + budget -= span.len; + match span.tag { + SUBIFD_OFFSET if offset.is_none() => offset = Some(first_number(bytes, &span, order)), + SUBIFD_LENGTH if length.is_none() => length = Some(first_number(bytes, &span, order)), + SUBIFD_KEY if key.is_none() => key = Some(four_bytes(bytes, &span, order)), + _ => {} + } + } + PrivateFields { offset: offset.flatten(), length: length.flatten(), key: key.flatten() } +} + +/// The entry's first value as the reader's `u64` lookup reads it (an unsigned or non-negative integer), as a `usize`. +fn first_number(bytes: &[u8], span: &EntrySpan, order: ByteOrder) -> Option { + if span.count == 0 { + return None; + } + let start = usize::try_from(span.start).ok()?; + let first = bytes.get(start..start.checked_add(span.field_type.size())?)?; + usize::try_from(decode_value(order, span.field_type, first).get_u64(0)?).ok() +} + +/// The entry's bytes as the reader's `bytes` lookup gives them, when they are exactly four. Decoding the first five +/// value bytes settles it: BYTE and UNDEFINED give all their bytes, ASCII its text before the first NUL, which +/// decoding never makes shorter. +fn four_bytes(bytes: &[u8], span: &EntrySpan, order: ByteOrder) -> Option<[u8; 4]> { + let start = usize::try_from(span.start).ok()?; + let head = bytes.get(start..start.checked_add(usize::try_from(span.len).ok()?.min(5))?)?; + <[u8; 4]>::try_from(decode_value(order, span.field_type, head).as_bytes()?).ok() +} + #[cfg(test)] pub(crate) mod tests { use super::*; + /// TIFF field types. + const BYTE: u16 = 1; + const LONG: u16 = 4; + const UNDEFINED: u16 = 7; + const IFD: u16 = 13; use lightcraft_tiff::ByteOrder::{Big, Little}; /// An `SR2SubIFD` block for file offset `start`, encrypted: SHORT/SSHORT entries `(tag, type, values)`, values @@ -345,6 +409,200 @@ pub(crate) mod tests { } } + /// An `SR2Private` directory of `entries` `(tag, type, count, value field)` at file offset 0. + fn private(entries: &[(u16, u16, u32, [u8; 4])], order: ByteOrder) -> Vec { + let mut out = Vec::new(); + order.put_u16(&mut out, entries.len() as u16); + for &(tag, kind, count, value) in entries { + order.put_u16(&mut out, tag); + order.put_u16(&mut out, kind); + order.put_u32(&mut out, count); + out.extend(value); + } + order.put_u32(&mut out, 0); + out + } + + /// `(offset, length, key)` as one of the readers selects them. + type Selected = (Option, Option, Option<[u8; 4]>); + + /// What `SubIfd::read` on main selects: the TIFF reader's parse of the directory with main's options, then the + /// directory's `u64` and `bytes` lookups. + fn reader_selects(bytes: &[u8], at: usize, order: ByteOrder) -> Selected { + let opts = lightcraft_tiff::ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; + let Ok((private, _)) = lightcraft_tiff::parse_ifd_at(bytes, at as u64, order, 0, false, &opts) else { return (None, None, None) }; + let n = |tag: u16| private.u64(tag).and_then(|v| usize::try_from(v).ok()); + (n(SUBIFD_OFFSET), n(SUBIFD_LENGTH), private.bytes(SUBIFD_KEY).and_then(|b| <[u8; 4]>::try_from(b).ok())) + } + + /// Synthetic `SR2Private` directories at offset 0, with what each tests: filler entries ahead of the fields, and + /// unreadable, unusual or valid first occurrences of each field ahead of a valid second one. + fn differential_cases(order: ByteOrder) -> Vec<(String, Vec)> { + let w = |v: u32| { + let mut b = Vec::new(); + order.put_u32(&mut b, v); + [b[0], b[1], b[2], b[3]] + }; + let h = |a: u16, b: u16| { + let mut out = Vec::new(); + order.put_u16(&mut out, a); + order.put_u16(&mut out, b); + [out[0], out[1], out[2], out[3]] + }; + let valid = [(SUBIFD_OFFSET, LONG, 1, w(4096)), (SUBIFD_LENGTH, LONG, 1, w(64)), (SUBIFD_KEY, UNDEFINED, 4, KEY)]; + let mut cases = Vec::new(); + for fillers in [0usize, 1, 255, 256, 257, 8189, 8190, 8191, 8192, 8193] { + let mut entries = vec![(0x7300, LONG, 1, w(7)); fillers]; + entries.extend(valid); + cases.push((format!("{fillers} fillers"), private(&entries, order))); + } + // a first occurrence ahead of the valid one; pointer values point into a 32-byte tail after the directory + let tail_at = 2 + 12 * 4 + 4; + let mut tail: Vec = (1..=32).collect(); + tail[4..8].copy_from_slice(b"\x11\x22\x33\x44"); + tail[8] = 0; + let numeric: Vec<(&str, u16, u32, [u8; 4])> = vec![ + ("unknown type 0", 0, 1, w(8)), + ("unknown type 14", 14, 1, w(8)), + ("unknown type 0xffff", 0xffff, 1, w(8)), + ("LONG[2] past the file", LONG, 2, w(u32::MAX)), + ("LONG[2] straddling the end", LONG, 2, w(tail_at as u32 + 28)), + ("LONG[u32::MAX]", LONG, u32::MAX, w(tail_at as u32)), + ("DOUBLE[u32::MAX]", 12, u32::MAX, w(tail_at as u32)), + ("LONG[0]", LONG, 0, w(0)), + ("LONG[2] in the file", LONG, 2, w(tail_at as u32)), + ("SHORT[1]", SHORT, 1, h(9, 0)), + ("SHORT[2] inline", SHORT, 2, h(9, 10)), + ("BYTE[3]", BYTE, 3, [5, 6, 7, 0]), + ("SSHORT -1", SSHORT, 1, h(0xffff, 0)), + ("SSHORT 5", SSHORT, 1, h(5, 0)), + ("SLONG -1", 9, 1, w(u32::MAX)), + ("LONG8 at a pointer", 16, 1, w(tail_at as u32)), + ("RATIONAL", 5, 1, w(tail_at as u32)), + ("ASCII", 2, 4, *b"abc\0"), + ("IFD", IFD, 1, w(4096)), + ]; + for (tag, other) in [(SUBIFD_OFFSET, [valid[1], valid[2]]), (SUBIFD_LENGTH, [valid[0], valid[2]])] { + for (what, kind, count, field) in &numeric { + let valid_one = if tag == SUBIFD_OFFSET { valid[0] } else { valid[1] }; + let mut d = private(&[(tag, *kind, *count, *field), valid_one, other[0], other[1]], order); + d.extend(&tail); + cases.push((format!("{tag:#x}: first {what}"), d)); + } + } + let keys: Vec<(&str, u16, u32, [u8; 4])> = vec![ + ("unknown type", 0, 4, KEY), + ("UNDEFINED[8] past the file", UNDEFINED, 8, w(u32::MAX)), + ("UNDEFINED[5] in the file", UNDEFINED, 5, w(tail_at as u32)), + ("UNDEFINED[3]", UNDEFINED, 3, KEY), + ("UNDEFINED[0]", UNDEFINED, 0, KEY), + ("BYTE[4]", BYTE, 4, [1, 2, 3, 4]), + ("SBYTE[4]", 6, 4, KEY), + ("ASCII[4]", 2, 4, KEY), + ("ASCII[4] with NUL", 2, 4, [0x41, 0x42, 0, 0x43]), + ("ASCII[4] invalid UTF-8", 2, 4, [0xff, 0x41, 0, 0]), + ("ASCII[4] two invalid bytes", 2, 4, [0xc3, 0x28, 0x41, 0]), + ("ASCII[9] at a pointer, NUL at 4", 2, 9, w(tail_at as u32 + 4)), + ("ASCII[8] at a pointer, no NUL", 2, 8, w(tail_at as u32 + 12)), + ("LONG[1]", LONG, 1, KEY), + ]; + for (what, kind, count, field) in &keys { + let mut d = private(&[(SUBIFD_KEY, *kind, *count, *field), valid[0], valid[1], valid[2]], order); + d.extend(&tail); + cases.push((format!("key: first {what}"), d)); + } + // the reader's value budget (8 × the data's length + 1 MiB), used up by entries aliasing the data + for aliases in [23u32, 24] { + let mut entries = vec![(0x7300, UNDEFINED, 1 << 16, w(0)); aliases as usize]; + entries.extend(valid); + let mut d = private(&entries, order); + d.resize(1 << 16, 0); + cases.push((format!("{aliases} entries using the value budget"), d)); + } + // no entries; more entries declared than the data holds + cases.push(("empty directory".into(), private(&[], order))); + let mut long = private(&valid, order); + long[..2].copy_from_slice(&if order == Little { 10u16.to_le_bytes() } else { 10u16.to_be_bytes() }); + long.truncate(2 + 12 * 3); + cases.push(("10 entries declared, 3 present".into(), long)); + cases + } + + #[test] + fn private_fields_match_the_tiff_readers_selection() { + let mut differ = Vec::new(); + for order in [Little, Big] { + for (case, d) in differential_cases(order) { + let f = private_fields(&d, 0, order); + let (ours, reader) = ((f.offset, f.length, f.key), reader_selects(&d, 0, order)); + if ours != reader { + differ.push(format!("{order:?} {case}: {ours:?}, reader {reader:?}")); + } + } + assert_eq!(private_fields(&[], 0, order), PrivateFields::default()); + assert_eq!(private_fields(&private(&[], order), u64::MAX, order), PrivateFields::default()); + } + assert!(differ.is_empty(), "{} cases differ from the TIFF reader:\n{}", differ.len(), differ.join("\n")); + // what the reader (and so this) gives in the cases the review named: the limit is 8,192 entries, a later + // copy stands in for an unreadable first one, and a used-up value budget hides the fields + let found = |d: &[u8]| private_fields(d, 0, Little) == PrivateFields { offset: Some(4096), length: Some(64), key: Some(KEY) }; + let cases = differential_cases(Little); + let case = |name: &str| cases.iter().find(|(c, _)| c == name).map(|(_, d)| d.clone()).unwrap(); + for name in [ + "0 fillers", + "256 fillers", + "8189 fillers", + "0x7200: first LONG[2] past the file", + "0x7201: first unknown type 14", + "key: first UNDEFINED[8] past the file", + "23 entries using the value budget", + ] { + assert!(found(&case(name)), "{name}"); + } + for name in ["8190 fillers", "8192 fillers", "24 entries using the value budget", "0x7200: first ASCII", "key: first UNDEFINED[3]"] { + assert!(!found(&case(name)), "{name}"); + } + } + + /// The case that broke on review: valid entries ahead of the three fields, and an unreadable first offset ahead of + /// a valid one. Main's reader finds the block; so must this one (else the black level, white balance and crop it + /// holds are lost). + #[test] + fn the_block_is_found_behind_filler_and_unreadable_entries() { + for order in [Little, Big] { + for (fillers, unreadable_first) in [(256usize, false), (300, false), (0, true), (256, true)] { + let w = |v: u32| { + let mut b = Vec::new(); + order.put_u32(&mut b, v); + [b[0], b[1], b[2], b[3]] + }; + let n = fillers + 3 + usize::from(unreadable_first); + let start = 2 + 12 * n + 4; + let block = block(start, &[(0x7310, SHORT, &[800; 4])], order); + let mut entries = vec![(0x7300, LONG, 1, w(7)); fillers]; + if unreadable_first { + entries.push((SUBIFD_OFFSET, LONG, 2, w(u32::MAX))); + } + entries.extend([ + (SUBIFD_OFFSET, LONG, 1, w(start as u32)), + (SUBIFD_LENGTH, LONG, 1, w(block.len() as u32)), + (SUBIFD_KEY, UNDEFINED, 4, KEY), + ]); + let mut file = private(&entries, order); + assert_eq!(file.len(), start); + file.extend(&block); + let mut pointer = Vec::new(); + order.put_u32(&mut pointer, 0); + let ifd0 = Ifd { + entries: vec![lightcraft_tiff::Entry { tag: t::DNG_PRIVATE_DATA, value: lightcraft_tiff::Value::Byte(pointer), offset: 0 }], + ..Default::default() + }; + let black = SubIfd::read(&file, &ifd0, order).and_then(|s| s.shorts(0x7310)); + assert_eq!(black, Some(vec![800.0; 4]), "{order:?}: {fillers} fillers, unreadable first offset {unreadable_first}"); + } + } + } + #[test] fn hostile_directories_give_none() { let start = 4096; diff --git a/crates/raw/src/vendor/srw.rs b/crates/raw/src/vendor/srw.rs index f64b451ff..7d5a70aaf 100644 --- a/crates/raw/src/vendor/srw.rs +++ b/crates/raw/src/vendor/srw.rs @@ -186,6 +186,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index a96165b5e..98cbe6d2d 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1053,6 +1053,229 @@ fn nikon_in_file_distortion_is_limited_to_validated_bodies() { eprintln!("Nikon distortion boundary: {checked} corpus files checked"); } +/// The Sony samples of the corpus manifest (`RAW_SAMPLES` in `xtask/src/main.rs`) with their pinned SHA-256. +const SONY_SAMPLES: &[(&str, &str)] = &[ + ("arw-sony-a7rm4a-compressed.arw", "690c774f1d7bc1db3fa8c2489762743d8e7586e50b6f65d0b6a61cb467972c67"), + ("arw-sony-a9m2-compressed.arw", "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97"), + ("arw-sony-a7m3-compressed.arw", "250784580ea527442c09004417bb0eead484f2bf3ee8f9121a776ac65bb50d0f"), + ("arw-sony-a7m3-uncompressed.arw", "ece80551abf64949dbe826985a80f1d9b265401ee4ea6b989e1829549616fdcd"), + ("arw-sony-a7m4-14bit.arw", "639a6d4db881f1359e3ea7e1137b314e59417d24e6d4d862b7202111a2c823b2"), + ("arw-sony-a7m4-lossless-l.arw", "851b43c2116c4139104a5036f83ac3b6a148789b2142214dd7192c13972b25b6"), + ("arw-sony-a7m4-lossless-m.arw", "d453005714327addd75bcb99c1c6223173dc92f2a59542d82e6761ef0e0e7571"), + ("arw-sony-a7m4-lossless-s.arw", "cbbd0930c7d8706dff84c68a2004454266e6fd0d8354f5f76a106b5d776e0223"), + ("arw-sony-a7cr-lossless-l.arw", "3e1642f3a1ae7c9f93228c5f09f27e362608e6a4ca1b3e5d8dd9eb8fae576997"), + ("arw-sony-a7cr-lossless-m.arw", "5bcba4acc52a5b902074581b4a2f5fb3c79ec56bd0fdc1746b9e2c0f9d3e9c88"), + ("arw-sony-a7cr-compressed.arw", "f5096e8fbccf0842c8a57763cabd2836608f019eb7fd54f7b22260c2f5bc4da5"), + ("arw-sony-a7cm2-lossless-l.arw", "342d893bd71fd050c273df181e3039d3f12d88947b0662de5de5325c84cc449b"), + ("arw-sony-a7cm2-compressed-m.arw", "002c86d38a16b08a2438780728b4fda8efcc745fa6c26231dad6af67b383b11e"), + ("arw-sony-a7rm4-14bit-compressed.arw", "e6dafe42643f69ab9d1fd00414b7a1f5104df354bb589201600ac934b29b5e4a"), + ("arw-sony-rx100m3.arw", "cb81392e3a8810231ce341fb45de3e4d1b9e2f87bc47f3c314d084041ccfd138"), + ("arw-sony-rx100.arw", "579a485b5126a25cbd55cbd5dadfa7d09cf021c99cc7d4869f9e56e3f759390b"), + ("arw-sony-a7rm2-12bit-uncompressed.arw", "71e0888396ef52c7e6a990b4e72ebb4d8413a1fc1c35bada449870539cef6e39"), + ("arw-sony-a500.arw", "1407fb596a391df67c15b90026a1a76702a2e7386dbdff2f0815b903ea49cead"), + ("arw-sony-a700.arw", "3159e28892bf0771d01525cd4b6190f9c15bbb19fa2fab6d2515ced0594e8f40"), + ("arw-sony-a33.arw", "1a59856394f10d4fadb40f5ab9c6d1c89f473f4dbcdefd216fbbbbf1ad8d21f9"), + ("arw-sony-a3500-5600k.arw", "04c4fe04425c3e6fff7c8af0f811923c35e750300373357bd54342150c5505d8"), + ("arw-sony-a7s-shade.arw", "7cc338a0abc8fdad32d61006f1f8f412297b93e040c7efba572eb2bd8f8e8be2"), + ("arw-sony-a7sm2-16x9.arw", "46044fb6a9c805f2b4915cd4970316b4d04b0566bcf5287afaad3ef648d7a3db"), + ("arw-sony-a580-16x9.arw", "5b0924d39151239dce19e92e08318f4f62a5a8ac276bd7180ec8463d2cfee709"), + ("arw-sony-a900-packed12.arw", "ac7c1532df77c321e8010aa1be60c1b9f245b1ac0db6f38fe617a73aae81af50"), +]; + +/// SHA-256 of [`SONY_SAMPLES`] as `name sha256` lines sorted by name: anchors the pins independently of the +/// manifest, so editing both together (as a drift or a mistake) still fails `sony_pins_match_the_corpus_manifest`. +/// All 25 pins were checked against the downloaded files when it was set. +const SONY_PINS_SHA256: &str = "4cfc349194278146356b463c59ede500331c6ca5fc50aec4e92d7cd847b17b68"; + +/// A Sony corpus file's bytes, checked against its pinned SHA-256 before anything decodes them; `None` (with a +/// note) when the file is absent, unless `LIGHTCRAFT_CORPUS_REQUIRED=1` asks for every pinned file. A file that +/// differs from its pin fails the test. +fn pinned_sony(root: &Path, name: &str) -> Option> { + use sha2::{Digest, Sha256}; + let expected = SONY_SAMPLES.iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("{name}: no pinned SHA-256")).1; + let bytes = match std::fs::read(root.join(name)) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + assert!(std::env::var_os("LIGHTCRAFT_CORPUS_REQUIRED").is_none_or(|v| v != "1"), "{name}: absent, and LIGHTCRAFT_CORPUS_REQUIRED=1"); + eprintln!("skip: {name} absent"); + return None; + } + Err(e) => panic!("{name}: {e}"), + }; + let actual: String = Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(actual, expected, "{name}: corpus file differs from its pinned SHA-256"); + Some(bytes) +} + +#[test] +fn sony_pins_match_the_corpus_manifest() { + use sha2::{Digest, Sha256}; + let mut lines: Vec = SONY_SAMPLES.iter().map(|(name, sha)| format!("{name} {sha}\n")).collect(); + lines.sort(); + let digest: String = Sha256::digest(lines.concat()).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(digest, SONY_PINS_SHA256, "the pinned Sony SHA-256 table changed: check every changed pin against its file, then update the anchor"); + let manifest = include_str!("../../../xtask/src/main.rs"); + for (name, sha) in SONY_SAMPLES { + let entry = manifest.find(&format!("\"{name}\"")).unwrap_or_else(|| panic!("{name} is not in the manifest")); + assert!(manifest[entry..].split_once("),").is_some_and(|(e, _)| e.contains(sha)), "{name}: pinned SHA-256 differs from the manifest"); + } +} + +#[test] +#[should_panic(expected = "differs from its pinned SHA-256")] +fn a_sony_fixture_that_differs_from_its_pin_fails() { + struct Gone(PathBuf); + impl Drop for Gone { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let dir = Gone(std::env::temp_dir().join(format!("lightcraft-sony-pin-{}", std::process::id()))); + std::fs::create_dir_all(&dir.0).unwrap(); + std::fs::write(dir.0.join("arw-sony-a7cr-compressed.arw"), b"not the pinned file").unwrap(); + pinned_sony(&dir.0, "arw-sony-a7cr-compressed.arw"); +} + +/// Sony's lens-correction records (`lightcraft_raw::lens_metadata`, `crates/raw/src/vendor/arw_lens.rs`) on the public +/// samples: shading, CA and distortion decoded from the raw IFD with their settings, equal to their `SR2SubIFD` copies, +/// the same from header and full decode, and nothing applied beyond the validated ILCE-7RM4A distortion. Expected +/// tables and settings: what ExifTool, run as a black-box tool, prints for these files. Every file is checked against +/// its pinned SHA-256 before it is decoded; absent files are skipped. +#[test] +fn sony_lens_records_of_the_public_samples() { + use lightcraft_raw::Rect; + use lightcraft_raw::lens_metadata::{SonyCopy, SonyLensStatus, SonySettingMeaning as M, SonySource}; + let root = corpus_root().join("raw"); + // ILCE-7CR (Sigma 24-70mm F2.8 DG DN Art at 49.5 mm): one scene in three codings, identical tables + let a7cr_shading = [0, 64, 128, 224, 320, 480, 672, 896, 1184, 1472, 1824, 2240, 2816, 3552, 4512, 7328]; + let a7cr_ca = [ + [-256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -128], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -128, -128, -256, -256], + ]; + let a7cr_distortion = [4, 1, 1, 3, 7, 13, 22, 35, 51, 72, 98, 132, 173, 223, 279, 348]; + // ILCE-7RM4A (FE 24-105mm F4 G OSS at 24 mm) + let a7rm4a_shading = [0, 32, 112, 256, 448, 688, 976, 1296, 1712, 2336, 3120, 4080, 5104, 6112, 7024, 7904]; + let a7rm4a_ca = [ + [1408, 1280, 1152, 1024, 1024, 1024, 1024, 1024, 896, 896, 896, 768, 768, 640, 512, 384], + [512, 512, 384, 384, 384, 384, 384, 512, 512, 512, 512, 640, 640, 640, 768, 640], + ]; + let a7rm4a_distortion = [14, 0, -29, -65, -113, -171, -239, -311, -392, -471, -555, -634, -713, -782, -843, -891]; + let a7cr_crop = Rect::new(32, 20, 9504, 6336); + // (file, settings, tables, distortion status, storage, compression, linear YCbCr, Sony crop, default crop) + let cases = [ + ( + "arw-sony-a7cr-compressed.arw", + [272, 1, 0], + (a7cr_shading, a7cr_ca, a7cr_distortion), + SonyLensStatus::NotValidatedModel, + (9600, 6376), + 32767, + false, + a7cr_crop, + a7cr_crop, + ), + ( + "arw-sony-a7cr-lossless-l.arw", + [272, 1, 0], + (a7cr_shading, a7cr_ca, a7cr_distortion), + SonyLensStatus::NotValidatedModel, + (9728, 6656), + 7, + false, + a7cr_crop, + a7cr_crop, + ), + ( + "arw-sony-a7cr-lossless-m.arw", + [272, 1, 0], + (a7cr_shading, a7cr_ca, a7cr_distortion), + SonyLensStatus::NotValidatedModel, + (6656, 4608), + 7, + true, + Rect::new(20, 12, 6240, 4160), + Rect::new(20, 12, 6240, 4160), + ), + ( + "arw-sony-a7rm4a-compressed.arw", + [257, 1, 17], + (a7rm4a_shading, a7rm4a_ca, a7rm4a_distortion), + SonyLensStatus::Applied, + (9600, 6376), + 32767, + false, + Rect::new(0, 20, 9504, 6336), + Rect::new(32, 20, 9504, 6336), + ), + ]; + let mut checked = 0; + for (name, settings, (shading, ca, distortion), status, (w, h), compression, linear, sony_crop, default_crop) in cases { + let Some(bytes) = pinned_sony(&root, name) else { continue }; + let full = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}"); + let s = full.lens_metadata.sony.as_ref().unwrap_or_else(|| panic!("{name}: no Sony lens record")); + assert!(s.sr2_read, "{name}"); + let got = [s.shading.setting.value, s.chromatic_aberration.setting.value, s.distortion.setting.value].map(|v| v.map(|v| v.value)); + assert_eq!(got, settings.map(Some), "{name}: settings"); + let meanings = [s.shading.setting.value, s.chromatic_aberration.setting.value, s.distortion.setting.value].map(|v| v.map(|v| v.meaning)); + let forced = if settings[2] == 17 { M::ForcedAuto } else { M::Off }; + assert_eq!(meanings, [Some(M::Auto), Some(M::Auto), Some(forced)], "{name}: setting meanings"); + assert_eq!( + (s.shading.table.value, s.chromatic_aberration.table.value, s.distortion.table.value), + (Some(shading), Some(ca), Some(distortion)), + "{name}" + ); + for (source, raw, sr2, agree) in [ + (s.shading.table.source, s.shading.table.raw_ifd, s.shading.table.sr2, s.shading.table.copies_agree), + ( + s.chromatic_aberration.table.source, + s.chromatic_aberration.table.raw_ifd, + s.chromatic_aberration.table.sr2, + s.chromatic_aberration.table.copies_agree, + ), + (s.distortion.table.source, s.distortion.table.raw_ifd, s.distortion.table.sr2, s.distortion.table.copies_agree), + (s.shading.setting.source, s.shading.setting.raw_ifd, s.shading.setting.sr2, s.shading.setting.copies_agree), + ( + s.chromatic_aberration.setting.source, + s.chromatic_aberration.setting.raw_ifd, + s.chromatic_aberration.setting.sr2, + s.chromatic_aberration.setting.copies_agree, + ), + (s.distortion.setting.source, s.distortion.setting.raw_ifd, s.distortion.setting.sr2, s.distortion.setting.copies_agree), + ] { + assert_eq!((source, raw, sr2, agree), (Some(SonySource::RawIfd), SonyCopy::Valid, SonyCopy::Valid, Some(true)), "{name}: provenance"); + } + assert_eq!( + (s.shading.status, s.chromatic_aberration.status, s.distortion.status), + (SonyLensStatus::NotValidatedComponent, SonyLensStatus::NotValidatedComponent, status), + "{name}" + ); + assert_eq!(full.opcodes.list3.len(), usize::from(status == SonyLensStatus::Applied), "{name}: only applied components are opcodes"); + let g = &s.geometry; + assert_eq!((g.storage_width, g.storage_height, g.compression, g.linear_ycbcr), (w, h, compression, linear), "{name}"); + assert_eq!( + (g.sony_crop, g.default_crop, g.image_crop, g.reference), + (Some(sony_crop), Some(default_crop), sony_crop, default_crop), + "{name}" + ); + assert!(g.native_3_2, "{name}"); + eprintln!("{name}: shading, CA and distortion decoded; distortion {status:?}"); + checked += 1; + } + // every Sony corpus file: only the ILCE-7RM4A's distortion is ever applied, and an applied status is an opcode + for (name, _) in SONY_SAMPLES { + let Some(bytes) = pinned_sony(&root, name) else { continue }; + let info = probe_info(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + let Some(s) = &info.lens_metadata.sony else { continue }; + let applied = s.distortion.status == SonyLensStatus::Applied; + assert_eq!(applied, info.metadata.model.as_deref() == Some("ILCE-7RM4A"), "{name}"); + assert_eq!(info.opcodes.list3.len(), usize::from(applied), "{name}"); + assert!(![s.shading.status, s.chromatic_aberration.status].contains(&SonyLensStatus::Applied), "{name}"); + checked += 1; + } + eprintln!("Sony lens records: {checked} checks on corpus files"); +} + /// Kodak DCS520C (`crates/raw/src/vendor/kodak.rs`): the two-component lossless JPEG strip decodes through the /// file's response curve to a 1736 x 1160 GRBG mosaic with 12-bit white, per-position black levels and the /// white balance of the vendor IFD. Looks for the corpus copy, then the raw.pixls.us folder; skips without either. diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index f95afef22..921e8900a 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -61,6 +61,7 @@ fn synthetic(w: usize, h: usize, cfa: Option, cpp: usize) -> RawImage { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), } } diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/robust.rs index 6dc383de8..17e108f6f 100644 --- a/crates/raw/tests/robust.rs +++ b/crates/raw/tests/robust.rs @@ -26,6 +26,7 @@ fn sample(comp: DngCompression) -> Vec { linearized: false, opcodes: OpcodeLists::default(), metadata: Default::default(), + lens_metadata: Default::default(), }; lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression: comp, ..Default::default() }).unwrap() } diff --git a/crates/raw/tests/sony_lens_inventory.rs b/crates/raw/tests/sony_lens_inventory.rs new file mode 100644 index 000000000..3eda88418 --- /dev/null +++ b/crates/raw/tests/sony_lens_inventory.rs @@ -0,0 +1,351 @@ +//! Opt-in local check of Sony's lens-correction records ([`lightcraft_raw::lens_metadata`]); it does nothing unless +//! `LIGHTCRAFT_SONY_LENS_DIR` names a directory of ARW files, so CI never runs it. The files there (searched +//! recursively) are only read, from their headers, and one line per file is printed: settings, where each table came +//! from, whether its raw-IFD and `SR2SubIFD` copies agree, sample ranges and status. Header and full decode are +//! compared too when `LIGHTCRAFT_SONY_LENS_FULL=1` (slow on large files). +//! +//! With `LIGHTCRAFT_SONY_LENS_INVENTORY` naming a CSV inventory of those files (comma-separated, no quoting, a header +//! row, one row per file name), every file must have a row, every row a file, and these columns must match where +//! present: `split` (the file's folder name), `raw_size` (`WxH`), `compression`, `crop_origin` / `crop_size` (Sony's +//! crop tags, `x y` / `w h`), and for each of `shading`, `ca` and `distortion`: `_entries` (TIFF count), `_bytes`, +//! `_samples` (the leading sample count), `_min` / `_max` (over the samples), `_sr2_equal` (`True` / `False`), +//! `_setting_raw` and `_setting` (a label: `Off`, `Auto`, `Auto (ILCE-1)`, `Auto fixed by lens`, `No correction +//! params available`). A file that doesn't decode must still have a row, and fails the check when it has one. +//! +//! Any decode error fails the check except an unsupported variant (`RawError::Unsupported`, e.g. the packed ARW of +//! some older bodies): those are counted, and the count must equal `LIGHTCRAFT_SONY_LENS_UNSUPPORTED` (default 0). +//! +//! `LIGHTCRAFT_SONY_LENS_DIR=… LIGHTCRAFT_SONY_LENS_INVENTORY=….csv cargo test -p lightcraft-raw --release --test +//! sony_lens_inventory -- --nocapture` + +use lightcraft_raw::lens_metadata::{SonyCopy, SonyField, SonyLensCorrections, SonyLensStatus, SonySetting, SonySettingMeaning, SonySource}; +use lightcraft_raw::{RawError, RawInfo, decode, probe_info}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +fn arw_files(dir: &Path, depth: usize, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && depth < 8 { + arw_files(&path, depth + 1, out); + } else if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("arw")) { + out.push(path); + } + } +} + +/// One component's columns, from the decoded record. +struct Component { + setting: Option<(u16, SonySettingMeaning)>, + /// Samples when the table was decoded. + samples: Option>, + source: Option, + /// The raw-IFD and `SR2SubIFD` copies' states. + copies: (SonyCopy, SonyCopy), + agree: Option, + status: String, +} + +fn component(setting: &SonyField, table: &SonyField, samples: Option>, status: SonyLensStatus) -> Component { + Component { + setting: setting.value.map(|s| (s.value, s.meaning)), + samples, + source: table.source, + copies: (table.raw_ifd, table.sr2), + agree: table.copies_agree, + status: format!("{status:?}"), + } +} + +/// Shading, CA and distortion of a record, by CSV column prefix. +fn components(s: &SonyLensCorrections) -> [(&'static str, Component); 3] { + let (sh, ca, d) = (&s.shading, &s.chromatic_aberration, &s.distortion); + [ + ("shading", component(&sh.setting, &sh.table, sh.table.value.map(|v| v.to_vec()), sh.status)), + ("ca", component(&ca.setting, &ca.table, ca.table.value.map(|v| v.concat()), ca.status)), + ("distortion", component(&d.setting, &d.table, d.table.value.map(|v| v.to_vec()), d.status)), + ] +} + +fn label(meaning: SonySettingMeaning) -> &'static [&'static str] { + match meaning { + SonySettingMeaning::Off => &["Off"], + SonySettingMeaning::Auto => &["Auto", "Auto (ILCE-1)"], + SonySettingMeaning::ForcedAuto => &["Auto fixed by lens"], + SonySettingMeaning::Unavailable => &["No correction params available"], + SonySettingMeaning::Unknown(_) => &[], + } +} + +/// The columns this check derives from a file, by CSV column name. +fn columns(path: &Path, info: &RawInfo) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut put = |k: &str, v: String| { + out.insert(k.to_string(), v); + }; + if let Some(split) = path.parent().and_then(|p| p.file_name()) { + put("split", split.to_string_lossy().into_owned()); + } + let Some(s) = &info.lens_metadata.sony else { return out }; + let g = &s.geometry; + put("raw_size", format!("{}x{}", g.storage_width, g.storage_height)); + put("compression", g.compression.to_string()); + if let Some(c) = g.sony_crop { + put("crop_origin", format!("{} {}", c.x, c.y)); + put("crop_size", format!("{} {}", c.width, c.height)); + } + for (name, c) in components(s) { + if let Some(v) = &c.samples { + put(&format!("{name}_entries"), (v.len() + 1).to_string()); + put(&format!("{name}_bytes"), (2 * (v.len() + 1)).to_string()); + put(&format!("{name}_samples"), v.len().to_string()); + put(&format!("{name}_min"), v.iter().min().map_or(String::new(), i16::to_string)); + put(&format!("{name}_max"), v.iter().max().map_or(String::new(), i16::to_string)); + } + put(&format!("{name}_sr2_equal"), c.agree.map_or("unknown", |a| if a { "True" } else { "False" }).to_string()); + if let Some((value, _)) = c.setting { + put(&format!("{name}_setting_raw"), value.to_string()); + } + } + out +} + +fn summary(info: &RawInfo) -> String { + let Some(s) = &info.lens_metadata.sony else { return "no lens record".into() }; + let mut line = format!("sr2 {} |", if s.sr2_read { "read" } else { "absent" }); + for (name, c) in components(s) { + let setting = c.setting.map_or("-".into(), |(v, m)| format!("{v} {m:?}")); + let range = c + .samples + .as_ref() + .map_or("no table".into(), |v| format!("{} samples {}..{}", v.len(), v.iter().min().unwrap_or(&0), v.iter().max().unwrap_or(&0))); + line += + &format!(" {name}: {setting}, {range}, {:?} (copies {:?} / {:?}), agree {:?}, {} |", c.source, c.copies.0, c.copies.1, c.agree, c.status); + } + line + &format!(" crop {:?} reference {:?} 3:2 {}", s.geometry.image_crop, s.geometry.reference, s.geometry.native_3_2) +} + +/// `(header, rows by file name)` of a simple CSV file. +fn read_csv(path: &Path) -> (Vec, BTreeMap>) { + let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let mut lines = text.lines().filter(|l| !l.trim().is_empty()); + let header: Vec = lines.next().expect("CSV header").split(',').map(|s| s.trim().to_string()).collect(); + let file = header.iter().position(|h| h == "file").expect("a `file` column"); + let mut rows = BTreeMap::new(); + for line in lines { + assert!(!line.contains('"'), "quoted CSV fields are not supported: {line}"); + let row: Vec = line.split(',').map(|s| s.trim().to_string()).collect(); + assert_eq!(row.len(), header.len(), "CSV row with {} fields, header has {}: {line}", row.len(), header.len()); + let name = row[file].clone(); + assert!(rows.insert(name.clone(), row).is_none(), "{name} listed twice"); + } + (header, rows) +} + +#[test] +fn sony_lens_records_reproduce_a_local_inventory() { + let Some(dir) = std::env::var_os("LIGHTCRAFT_SONY_LENS_DIR").map(PathBuf::from) else { + eprintln!("skip: LIGHTCRAFT_SONY_LENS_DIR unset"); + return; + }; + let full = std::env::var_os("LIGHTCRAFT_SONY_LENS_FULL").is_some_and(|v| v == "1"); + let inventory = std::env::var_os("LIGHTCRAFT_SONY_LENS_INVENTORY").map(PathBuf::from); + let unsupported = std::env::var("LIGHTCRAFT_SONY_LENS_UNSUPPORTED").map_or(0, |v| v.parse().expect("LIGHTCRAFT_SONY_LENS_UNSUPPORTED: a count")); + let report = check(&dir, inventory.as_deref(), full, unsupported); + assert!(report.problems.is_empty(), "{} problems", report.problems.len()); +} + +/// What [`check`] found. +#[derive(Debug, Default)] +struct Report { + files: usize, + records: usize, + without_record: usize, + unsupported: usize, + problems: Vec, +} + +/// Check every ARW under `dir` (see the module docs). Any decode error is a problem except `Unsupported`, which +/// is counted and must come to exactly `expected_unsupported`; with an inventory, every file (decoded or not) must +/// have a row, and a file with a row must decode. +fn check(dir: &Path, inventory: Option<&Path>, full: bool, expected_unsupported: usize) -> Report { + let inventory = inventory.map(read_csv); + let mut files = Vec::new(); + arw_files(dir, 0, &mut files); + files.sort(); + let mut report = Report { files: files.len(), ..Default::default() }; + if files.is_empty() { + report.problems.push(format!("no ARW files under {}", dir.display())); + } + let (mut matched, mut mismatches, mut seen) = (0usize, Vec::new(), std::collections::BTreeSet::new()); + let compared: Vec = ["split", "raw_size", "compression", "crop_origin", "crop_size"] + .into_iter() + .map(String::from) + .chain( + ["shading", "ca", "distortion"] + .into_iter() + .flat_map(|k| ["entries", "bytes", "samples", "min", "max", "sr2_equal", "setting_raw"].into_iter().map(move |c| format!("{k}_{c}"))), + ) + .collect(); + for path in &files { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + seen.insert(name.clone()); + let info = match probe_info(&bytes) { + Ok(info) => info, + Err(e) => { + eprintln!("{name}: {e}"); + match (&inventory, &e) { + (Some((_, rows)), _) if rows.contains_key(&name) => mismatches.push(format!("{name}: does not decode: {e}")), + (Some(_), _) => mismatches.push(format!("{name}: does not decode ({e}) and is not in the inventory")), + (None, RawError::Unsupported(_)) => {} + (None, _) => mismatches.push(format!("{name}: does not decode: {e}")), + } + report.unsupported += usize::from(matches!(e, RawError::Unsupported(_))); + continue; + } + }; + if full { + match decode(&bytes) { + Ok(image) if image.info() == info => {} + Ok(_) => mismatches.push(format!("{name}: header and full decode differ")), + Err(e) => mismatches.push(format!("{name}: full decode fails: {e}")), + } + } + if info.lens_metadata.sony.is_some() { + report.records += 1; + } else { + report.without_record += 1; + } + eprintln!("{name}: {} {}", info.metadata.model.as_deref().unwrap_or("?"), summary(&info)); + let Some((header, rows)) = &inventory else { continue }; + let Some(row) = rows.get(&name) else { + mismatches.push(format!("{name}: not in the inventory")); + continue; + }; + let got = columns(path, &info); + let mut ok = true; + for (i, column) in header.iter().enumerate() { + let expected = &row[i]; + if let Some(setting) = column.strip_suffix("_setting") + && let Some(s) = &info.lens_metadata.sony + { + let meaning = components(s).into_iter().find(|(n, _)| *n == setting).and_then(|(_, c)| c.setting).map(|(_, m)| m); + if !meaning.is_some_and(|m| label(m).contains(&expected.as_str())) { + mismatches.push(format!("{name}: {column} {meaning:?}, inventory {expected}")); + ok = false; + } + continue; + } + if !compared.contains(column) { + continue; + } + let value = got.get(column).map(String::as_str).unwrap_or("(none)"); + if value != expected { + mismatches.push(format!("{name}: {column} {value}, inventory {expected}")); + ok = false; + } + } + matched += usize::from(ok); + } + if let Some((header, rows)) = &inventory { + for name in rows.keys().filter(|n| !seen.contains(*n)) { + mismatches.push(format!("{name}: in the inventory, no such file")); + } + let checked = header.iter().filter(|c| compared.contains(c) || c.ends_with("_setting")).count(); + assert!(checked > 0, "the inventory has none of the compared columns"); + eprintln!("{matched} of {} inventory rows reproduced from {} files ({checked} columns compared)", rows.len(), files.len()); + } + if report.unsupported != expected_unsupported { + mismatches.push(format!("{} files are unsupported variants, {expected_unsupported} expected", report.unsupported)); + } + eprintln!( + "{} files: {} with a lens record, {} decoded without one, {} unsupported ({expected_unsupported} expected)", + report.files, report.records, report.without_record, report.unsupported + ); + for m in &mismatches { + eprintln!("problem: {m}"); + } + report.problems.extend(mismatches); + report +} + +/// A directory under the system temporary directory, removed when dropped. +struct Scratch(PathBuf); + +impl Scratch { + fn new(name: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lightcraft-sony-lens-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A small Sony CFA file with a distortion setting (a lens record), or, `unsupported`, the packed 12-bit ARW variant +/// the decoder refuses as unsupported. +fn synthetic_arw(unsupported: bool) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7CR".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(0x7036, Value::Short(vec![0])); + if unsupported { + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(0x7010, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0; 32 * 4 + 8]] }); + } else { + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0; 32 * 4 * 2]] }); + } + TiffWriter::default().write(&[raw]).unwrap() +} + +#[test] +fn files_that_fail_to_decode_fail_the_check() { + // a file that is no raw at all fails the check, whatever is expected + let dir = Scratch::new("decode-error"); + std::fs::write(dir.0.join("not-a-raw.arw"), b"not a raw file").unwrap(); + let report = check(&dir.0, None, false, 0); + assert!(!report.problems.is_empty(), "an ARW that does not decode passed the check"); + assert!(!check(&dir.0, None, false, 1).problems.is_empty(), "a decode error counted as an unsupported variant"); + // an unsupported variant is counted, and must be expected + let dir = Scratch::new("unsupported"); + std::fs::write(dir.0.join("good.arw"), synthetic_arw(false)).unwrap(); + std::fs::write(dir.0.join("packed.arw"), synthetic_arw(true)).unwrap(); + let report = check(&dir.0, None, false, 0); + assert_eq!((report.files, report.records, report.unsupported), (2, 1, 1)); + assert_eq!(report.problems.len(), 1, "{:?}", report.problems); + assert!(check(&dir.0, None, true, 1).problems.is_empty()); + // with an inventory, a file that fails must still be listed: here it is not + let csv = dir.0.join("inventory.csv"); + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,0\n").unwrap(); + // (the inventory itself sits in the directory, but only .arw files are checked) + let mut report = check(&dir.0, Some(&csv), false, 1); + report.problems.sort(); + assert_eq!(report.problems.len(), 1, "{:?}", report.problems); + assert!(report.problems[0].contains("packed.arw") && report.problems[0].contains("not in the inventory"), "{:?}", report.problems); + // listed but failing, and listed and decoding + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,0\npacked.arw,0\n").unwrap(); + let report = check(&dir.0, Some(&csv), false, 1); + assert!(report.problems.len() == 1 && report.problems[0].contains("packed.arw: does not decode"), "{:?}", report.problems); + std::fs::remove_file(dir.0.join("packed.arw")).unwrap(); + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,0\n").unwrap(); + assert!(check(&dir.0, Some(&csv), false, 0).problems.is_empty()); + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,17\n").unwrap(); + assert!(!check(&dir.0, Some(&csv), false, 0).problems.is_empty(), "a wrong value passed"); +} diff --git a/crates/raw/tests/sr2_heap.rs b/crates/raw/tests/sr2_heap.rs new file mode 100644 index 000000000..2b936d18c --- /dev/null +++ b/crates/raw/tests/sr2_heap.rs @@ -0,0 +1,79 @@ +//! Peak heap of reading Sony's `SR2Private` directory (`crates/raw/src/vendor/sr2.rs`), measured with dhat's testing +//! mode. Every ARW with `DNGPrivateData` has its `SR2SubIFD` located and decrypted, so the directory that points to +//! it must not cost memory sized by its other entries: a hostile file can give it fields that each alias most of the +//! file. Only the block's offset, length and key may be read. One test per binary (dhat's profiler is global). + +use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, Tiff, TiffWriter, Value, tags as t}; + +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +/// A 32 × 24 Sony CFA file of `size` bytes whose `SR2Private` directory holds the block's offset, length and key plus +/// `aliases` UNDEFINED fields that each cover the first `size` bytes of the file. Plain black level and white balance +/// are present, so nothing but the lens record needs the `SR2SubIFD`. +fn hostile_arw(size: usize, aliases: u16) -> Vec { + let o = ByteOrder::Little; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7CR".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![24])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set(0x7310, Value::Short(vec![512; 4])); + raw.set(0x7313, Value::Short(vec![2048, 1024, 1024, 1536])); + // a distortion setting, so the file has a lens record that says whether the SR2SubIFD was read + raw.set(0x7036, Value::Short(vec![0])); + raw.set(t::DNG_PRIVATE_DATA, Value::Byte(vec![0; 4])); + raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); + let mut file = TiffWriter::new(o, false).write(&[raw]).unwrap(); + let pointer = Tiff::parse(&file).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + file.resize(size, 0); + let private = file.len(); + let entries = 3 + aliases; + let block_at = private + 2 + 12 * usize::from(entries) + 4; + let block_len = 64u32; + o.put_u16(&mut file, entries); + for (tag, kind, count, value) in [(0x7200u16, 4u16, 1u32, block_at as u32), (0x7201, 4, 1, block_len)] { + o.put_u16(&mut file, tag); + o.put_u16(&mut file, kind); + o.put_u32(&mut file, count); + o.put_u32(&mut file, value); + } + o.put_u16(&mut file, 0x7221); + o.put_u16(&mut file, 7); + o.put_u32(&mut file, 4); + file.extend([0x11, 0x22, 0x33, 0x44]); + for i in 0..aliases { + o.put_u16(&mut file, 0x7230 + i); + o.put_u16(&mut file, 7); + o.put_u32(&mut file, size as u32); + o.put_u32(&mut file, 0); + } + o.put_u32(&mut file, 0); + file.resize(block_at + block_len as usize, 0); + let mut at = Vec::new(); + o.put_u32(&mut at, private as u32); + file[pointer..pointer + 4].copy_from_slice(&at); + file +} + +#[test] +fn sr2_private_fields_cost_no_memory_sized_by_the_file() { + // eight fields of 16 MiB each: 128 MiB if they were decoded, within the TIFF reader's default value budget + let file = hostile_arw(16 << 20, 8); + let _profiler = dhat::Profiler::builder().testing().build(); + let header = lightcraft_raw::probe_info(&file).unwrap(); + let full = lightcraft_raw::decode(&file).unwrap(); + let peak = dhat::HeapStats::get().max_bytes; + // the block was still found and decrypted (its content is noise: no SR2SubIFD copies) + for info in [&header, &full.info()] { + let sony = info.lens_metadata.sony.as_ref().expect("a lens record"); + assert!(sony.sr2_read, "the SR2SubIFD was not read"); + } + eprintln!("peak heap {peak} bytes decoding a {} byte file", file.len()); + assert!(peak < 2 << 20, "peak heap {peak} bytes decoding a {} byte file", file.len()); +} diff --git a/crates/tiff/README.md b/crates/tiff/README.md index c691dd641..cfaaffffb 100644 --- a/crates/tiff/README.md +++ b/crates/tiff/README.md @@ -18,6 +18,10 @@ later the TIFF/DNG exporters. - `image::ImageInfo` — width/height, bits, samples, compression, photometric, planar config, predictor, sample format and the strip or tile chunk list (`chunks()`, `chunk_bytes()`); missing byte counts are inferred. +- `directory_entries`, `entry_span`, `ParseOptions::budget_for` and `decode_value` are the reader's own rules + (directory layout and entry limit, which entries it reads, its value budget, value decoding), for callers that + must select entries exactly as `parse_ifd_at` would without decoding the whole directory (`lightcraft-raw`'s + Sony `SR2Private` lookup). - `TiffWriter` writes IFD chains with strips or tiles, `SubIFDs` and pointer IFDs, in either byte order, classic or BigTIFF; `writer::rational` / `srational` approximate floats as rationals. diff --git a/crates/tiff/src/lib.rs b/crates/tiff/src/lib.rs index bafa3eb5e..1d0b74d0d 100644 --- a/crates/tiff/src/lib.rs +++ b/crates/tiff/src/lib.rs @@ -25,7 +25,7 @@ mod reader; pub mod tags; pub mod writer; -pub use reader::{ParseOptions, parse_ifd_at}; +pub use reader::{DirectoryEntries, EntrySpan, ParseOptions, decode as decode_value, directory_entries, entry_span, parse_ifd_at}; pub use writer::{IfdBuilder, ImageData, TiffWriter}; use serde::{Deserialize, Serialize}; diff --git a/crates/tiff/src/reader.rs b/crates/tiff/src/reader.rs index a644159b7..4a80a906b 100644 --- a/crates/tiff/src/reader.rs +++ b/crates/tiff/src/reader.rs @@ -26,6 +26,101 @@ impl Default for ParseOptions { } } +impl ParseOptions { + /// The value budget for `data_len` bytes of input: [`Self::value_budget`], else `8 × data_len + 1 MiB`. + pub fn budget_for(&self, data_len: usize) -> u64 { + self.value_budget.unwrap_or((data_len as u64).saturating_mul(8).saturating_add(1 << 20)) + } +} + +/// Where a directory's entries are and how many of them the reader reads, as [`directory_entries`] found them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DirectoryEntries { + /// Position of the first entry. + table: usize, + /// Entries read: the declared count, cut to the entries the data holds and to the `max_entries` limit. + read: usize, + /// The declared entry count. + declared: u64, + /// Bytes per entry: 12, or 20 in BigTIFF. + entry_size: usize, +} + +impl DirectoryEntries { + /// Positions of the entries read, in directory order. Checked arithmetic: the iteration ends rather than + /// overflow, whatever the layout. + pub fn positions(&self) -> impl Iterator + use<> { + let (table, size) = (self.table, self.entry_size); + (0..self.read).map_while(move |i| i.checked_mul(size)?.checked_add(table)) + } + + /// The number of entries read. + pub fn read(&self) -> usize { + self.read + } + + /// The entry count the directory declares. + pub fn declared(&self) -> u64 { + self.declared + } +} + +/// The directory at `offset` as the reader walks it: its entry table and how many entries it reads. The same errors +/// as [`parse_ifd_at`] for a directory it can't read: past the data, empty, or without room for one entry. +pub fn directory_entries(data: &[u8], offset: u64, order: ByteOrder, bigtiff: bool, max_entries: usize) -> Result { + let pos = usize::try_from(offset).map_err(|_| TiffError::Truncated(offset))?; + let (n, head, entry_size) = if bigtiff { + (order.read_u64(data, pos).ok_or(TiffError::Truncated(offset))?, 8usize, 20usize) + } else { + (order.read_u16(data, pos).ok_or(TiffError::Truncated(offset))? as u64, 2, 12) + }; + let table = pos + head; + let fit = (data.len().saturating_sub(table) / entry_size) as u64; + let read = n.min(fit).min(max_entries as u64) as usize; + if n == 0 { + return Err(TiffError::Invalid(format!("empty IFD at {offset}"))); + } + if read == 0 { + return Err(TiffError::Truncated(offset)); + } + Ok(DirectoryEntries { table, read, declared: n, entry_size }) +} + +/// A directory entry the reader can read: its tag, type and count, and where its values are. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EntrySpan { + pub tag: u16, + pub field_type: FieldType, + pub count: u64, + /// Position of the values in the data: the entry's own value field when they fit it, else `base` plus the offset + /// stored there. + pub start: u64, + /// `count × size` bytes. + pub len: u64, +} + +/// The entry at `pos`, when the reader reads it: a known field type, `count × size` without overflow, and values +/// inside `data`. Nothing is decoded; [`parse_ifd_at`] also drops an entry whose values exceed what is left of its +/// value budget ([`ParseOptions::budget_for`]), which this leaves to the caller. +pub fn entry_span(data: &[u8], pos: usize, order: ByteOrder, bigtiff: bool, base: u64) -> Option { + let tag = order.read_u16(data, pos)?; + let field_type = FieldType::from_u16(order.read_u16(data, pos.checked_add(2)?)?)?; + let (count, field, inline) = if bigtiff { + (order.read_u64(data, pos.checked_add(4)?)?, pos.checked_add(12)?, 8u64) + } else { + (order.read_u32(data, pos.checked_add(4)?)? as u64, pos.checked_add(8)?, 4) + }; + let len = count.checked_mul(field_type.size() as u64)?; + let start = if len <= inline { + field as u64 + } else { + let rel = if bigtiff { order.read_u64(data, field)? } else { order.read_u32(data, field)? as u64 }; + base.checked_add(rel)? + }; + let end = start.checked_add(len)?; + (end <= data.len() as u64).then_some(EntrySpan { tag, field_type, count, start, len }) +} + /// Parse the header: (byte order, bigtiff, first IFD offset). pub(crate) fn header(data: &[u8]) -> Result<(ByteOrder, bool, u64)> { header_with(data, true) @@ -97,7 +192,7 @@ struct Ctx<'a> { impl<'a> Ctx<'a> { fn new(data: &'a [u8], order: ByteOrder, big: bool, opts: &'a ParseOptions) -> Self { - let budget = opts.value_budget.unwrap_or((data.len() as u64).saturating_mul(8).saturating_add(1 << 20)); + let budget = opts.budget_for(data.len()); Self { data, order, big, opts, visited: HashSet::new(), budget } } @@ -116,34 +211,18 @@ impl<'a> Ctx<'a> { if !self.visited.insert(offset) { return Err(TiffError::Invalid(format!("IFD loop at {offset}"))); } - let pos = usize::try_from(offset).map_err(|_| TiffError::Truncated(offset))?; - let (n, head, entry_size, inline) = if self.big { - (self.order.read_u64(self.data, pos).ok_or(TiffError::Truncated(offset))?, 8usize, 20usize, 8usize) - } else { - (self.order.read_u16(self.data, pos).ok_or(TiffError::Truncated(offset))? as u64, 2, 12, 4) - }; - let table = pos + head; - let fit = (self.data.len().saturating_sub(table) / entry_size) as u64; - let n_read = n.min(fit).min(self.opts.max_entries as u64) as usize; - if n == 0 { - return Err(TiffError::Invalid(format!("empty IFD at {offset}"))); - } - if n_read == 0 { - return Err(TiffError::Truncated(offset)); - } - let mut entries = Vec::with_capacity(n_read); - for i in 0..n_read { - let p = table + i * entry_size; - if let Some(e) = self.entry(p, base, inline) { + let dir = directory_entries(self.data, offset, self.order, self.big, self.opts.max_entries)?; + let mut entries = Vec::with_capacity(dir.read); + for p in dir.positions() { + if let Some(e) = self.entry(p, base) { entries.push(e); } } entries.sort_by_key(|e| e.tag); entries.dedup_by_key(|e| e.tag); - // the next-IFD offset follows the whole table, so only a table read in full has one: a count far past the - // file (a BigTIFF's is 64-bit) must not reach the arithmetic (issue #1516) - let next = if n == n_read as u64 { - match n_read.checked_mul(entry_size).and_then(|len| table.checked_add(len)).and_then(|p| self.read_off(p)) { + // Only a fully read table has a next-IFD offset. Keep hostile BigTIFF counts out of the arithmetic (#1516). + let next = if dir.declared == dir.read as u64 { + match dir.read.checked_mul(dir.entry_size).and_then(|len| dir.table.checked_add(len)).and_then(|p| self.read_off(p)) { Some(0) | None => 0, Some(v) => base.checked_add(v).unwrap_or(0), } @@ -181,33 +260,19 @@ impl<'a> Ctx<'a> { ifd.interop = interop; } - fn entry(&mut self, p: usize, base: u64, inline: usize) -> Option { - let o = self.order; - let tag = o.read_u16(self.data, p)?; - let typ = FieldType::from_u16(o.read_u16(self.data, p + 2)?)?; - let (count, field) = if self.big { (o.read_u64(self.data, p + 4)?, p + 12) } else { (o.read_u32(self.data, p + 4)? as u64, p + 8) }; - let total = count.checked_mul(typ.size() as u64)?; - let start = if total <= inline as u64 { - field as u64 - } else { - let rel = self.read_off(field)?; - base.checked_add(rel)? - }; - let end = start.checked_add(total)?; - if end > self.data.len() as u64 { + fn entry(&mut self, p: usize, base: u64) -> Option { + let span = entry_span(self.data, p, self.order, self.big, base)?; + if span.len > self.budget { return None; } - if total > self.budget { - return None; - } - self.budget -= total; - let bytes = &self.data[start as usize..end as usize]; - Some(Entry { tag, value: decode(o, typ, bytes), offset: start }) + self.budget -= span.len; + let bytes = self.data.get(usize::try_from(span.start).ok()?..usize::try_from(span.start + span.len).ok()?)?; + Some(Entry { tag: span.tag, value: decode(self.order, span.field_type, bytes), offset: span.start }) } } -/// Decode `bytes` (exactly `count × size` long) as values of type `typ`. -pub(crate) fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { +/// Decode `bytes` (exactly `count × size` long, or the first values of an entry) as values of type `typ`. +pub fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { fn arr(c: &[u8]) -> [u8; N] { let mut a = [0u8; N]; a.copy_from_slice(c); @@ -237,3 +302,24 @@ pub(crate) fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { FieldType::SLong8 => Value::SLong8(b.as_chunks::<8>().0.iter().map(|c| o.u64(arr(c)) as i64).collect()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn directory_positions_never_overflow() { + // a hand-built layout the reader never produces: the iterator stops instead of overflowing + let hostile = DirectoryEntries { table: usize::MAX, read: 2, declared: 2, entry_size: 12 }; + assert_eq!(hostile.positions().collect::>(), vec![usize::MAX]); + assert_eq!(hostile.positions().nth(1), None); + let wide = DirectoryEntries { table: usize::MAX - 30, read: 4, declared: 4, entry_size: 12 }; + assert_eq!(wide.positions().collect::>(), vec![usize::MAX - 30, usize::MAX - 18, usize::MAX - 6]); + // a directory read from data lists its entries + let mut data = Vec::new(); + ByteOrder::Little.put_u16(&mut data, 2); + data.extend([0; 24]); + let dir = directory_entries(&data, 0, ByteOrder::Little, false, 8192).unwrap(); + assert_eq!(dir.positions().collect::>(), vec![2, 14]); + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..e5e0feb9d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -211,7 +211,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus and Sony raws not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A distortion above is applied: on the ILCE-7CR the unchanged distortion interpretation misses the camera JPEG at 24 mm (median 1.6 px, p95 6.2 px at 2376 px), and Sony shading and CA semantics are not validated on any body (`docs/sony-lens-corrections.md`); Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A distortion only); status unchanged (🟡) | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md index 375f773ab..53c5e9755 100644 --- a/docs/sony-lens-corrections.md +++ b/docs/sony-lens-corrections.md @@ -1,25 +1,137 @@ -# Sony embedded distortion corrections +# Sony embedded lens corrections -LightCraft reads the signed 16-sample distortion table in the raw image IFD (`0x7037`) of -Sony **ILCE-7RM4A (A7R IVA)** Bayer ARWs. Other camera models are deliberately left uncorrected, -even when they contain a table with the same layout. -It converts that table into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction control, -CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction. +Sony ARWs record three lens corrections per shot in the raw image IFD: shading (vignetting), lateral chromatic +aberration (CA) and distortion, each as a camera setting and a radial table, with copies in the encrypted +`SR2SubIFD`. LightCraft **decodes all three** wherever a file carries them and **applies one**: the distortion table +of the **ILCE-7RM4A (A7R IVA)** in Bayer ARWs with native 3:2 framing, independently validated with the +FE 24–105mm F4 G OSS and FE 200–600mm F5.6–6.3 G OSS. Every other model, and shading and CA on every model, are +decoded only: no warp, vignetting or CA correction is added for them. The decoded record stays with the image +(`RawInfo::lens_metadata` / `RawImage::lens_metadata`, types in `crates/raw/src/lens_metadata.rs`, decoder in +`crates/raw/src/vendor/arw_lens.rs`) for diagnostics and for the validation work described below. -This adds **distortion only**. Sony vignetting and lateral chromatic-aberration tables are not decoded. -The enabled and independently validated model is ILCE-7RM4A with the FE 24–105mm F4 G OSS and -FE 200–600mm F5.6–6.3 G OSS. Linear YCbCr ARWs and non-3:2 camera crops are excluded pending validation. An aspect crop may -retain the full-frame radial normalization; the decoder does not guess that relationship. Older files that carry -only encrypted correction metadata, different table lengths, and rejected tables remain uncorrected. -The camera's distortion Off setting does not erase its table. Newly imported photos use LightCraft's existing -embedded-profile default (enabled); the profile correction control can disable or adjust it. +The applied distortion is converted into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction +control, CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction. Linear YCbCr ARWs +and non-3:2 camera crops are excluded pending validation. An aspect crop may retain the full-frame radial +normalization; the decoder does not guess that relationship. Tables of other lengths and rejected tables remain +uncorrected, and a table found only in the encrypted `SR2SubIFD` is never applied. The camera's distortion Off +setting does not erase its table. Newly imported photos use LightCraft's existing embedded-profile default +(enabled); the profile correction control can disable or adjust it. + +## What is decoded + +| Component | Raw-IFD setting / table | `SR2SubIFD` setting / table | Accepted layout | +|---|---|---|---| +| Shading | `0x7031` / `0x7032` | `0x797c` / `0x797d` | SHORT\[1\]; SSHORT\[17\]: 16, then 16 samples | +| Lateral CA | `0x7034` / `0x7035` | `0x797f` / `0x7980` | SHORT\[1\]; SSHORT\[33\]: 32, then two 16-sample curves | +| Distortion | `0x7036` / `0x7037` | `0x7981` / `0x7982` | SHORT\[1\]; SSHORT\[17\]: 16, then 16 samples | + +- **Bounded, exact reading.** A raw-IFD field is the one the TIFF reader parsed, the copy the image itself uses: + its first occurrence, within the reader's limits (8,192 entries per directory, a total value budget). The directory + is also scanned, in either byte order and in BigTIFF, for diagnostics only: a tag that occurs more than once is + flagged (`raw_ifd_repeated`, `sr2_repeated`), and an entry the reader left out is reported with its reason (values + outside the file, an unknown type, or past the reader's limits) rather than treated as absent. `SR2SubIFD` fields + are read straight from their first entry. A setting may also be a nonnegative SSHORT (the documented editor type + differs from the type the cameras store). Anything else is refused with its reason (`SonyFieldError`: field type, + count, leading sample count, values outside the file or block, past the reader's limits, a negative setting). + Nothing is allocated from a count in the file. +- **Raw IFD first, `SR2SubIFD` as a checked fallback.** The raw IFD's copy wins. The `SR2SubIFD`'s copy is used only + when the raw IFD lacks the tag; a malformed raw-IFD copy is reported, never silently replaced. When both copies are + well-formed their agreement is recorded (`copies_agree`), and on disagreement the raw IFD's copy is used. The block + is decrypted (by the existing `vendor/sr2.rs`) for every ARW that has one, so the check always runs; a block that + can't be found or decrypted gives no copies. Locating it selects the `SR2Private` directory's offset, length and + key exactly as the TIFF reader would (the reader's own directory layout, 8,192-entry limit, readable-entry rules + and value budget, then the first readable occurrence of each), but decodes only each field's first value, so the + directory's other entries cost no memory however large they claim to be; the block itself is limited to 1 MiB. The + plain tag numbers are not looked up inside the `SR2SubIFD`. +- **Settings keep their number and a documented meaning:** shading 256 Off, 257 and 272 Auto, 511 unavailable; CA + 0 Off, 1 Auto, 255 unavailable; distortion 0 Off, 1 Auto, 17 Auto fixed by the lens, 255 unavailable. Any other value + is `Unknown(value)`, never Off (the A7CR's Low shading setting has no documented number). Values are not read as + bit masks. Off does not mean absent: the table is still recorded. +- **Only the 16-sample (CA: 2 × 16) layout is interpreted.** Many bodies store 11 (CA: 22) samples padded into the + same 17 (33) values: older compacts and bodies such as the DSC-RX100 III and ILCE-3500, and the ILCE-7M4's APS-C + captures (its full-frame captures carry 16). Those tables are refused as `CountPrefix(11)` and stay + uninterpreted. An all-zero table is kept: it is a valid no-op, not a sign of an unsupported lens. +- **Framing evidence travels with the tables:** the stored frame (padding included), Sony's crop tags and the DNG + default crop as recorded, the image crop, the reference rectangle the distortion conversion is centred on and + normalized to (the default crop when present, else the image crop), compression, whether the data is linear + YCbCr (lossless M/S), and whether both crops are native 3:2. +- **Per-component status.** `Applied` (converted into an opcode), `NoTable`, `NotValidatedComponent` (shading and CA + on every body), `NotValidatedModel`, `NotValidatedMode` (linear YCbCr, non-3:2 framing, or a distortion table found + only in the `SR2SubIFD`), `Neutral` (all-zero table in the validated scope) and `Rejected` (the validated + conversion refused the table). A readable table never implies a usable correction; only `Applied` components + become opcodes. + +The applied path is unchanged: the validated conversion still receives the raw IFD's `0x7037` table exactly as the +TIFF reader parsed it, with the same model, framing and fit checks, and produces the same warp. A repeated tag keeps +the reader's choice and is only flagged, and a table the reader never reads (past its entry limit) is reported but +never applied, so already-catalogued photos, their previews and their stored lens data stay consistent with a fresh +decode. + +## What each component still needs + +The following summarizes an investigation on the ILCE-7CR (A7CR): an owner's collection of 131 raws with four +lenses (Voigtländer APO-LANTHAR 28mm F2 and 50mm F2, both manual focus; Sony FE 24–50mm F2.8 G; Tamron 35–150mm +F2–2.8), plus the three public raw.pixls.us A7CR samples. No private photographs, names or derived images are +part of this repository. + +- **Inventory.** All 131 raws carry all three tables in both the raw IFD and the `SR2SubIFD`, and the copies agree + in every file; all four lenses provide tables, the manual-focus ones included. Shading is 272 (Auto) and CA 1 + (Auto) everywhere; distortion is 17 (Auto, fixed by the lens) on the nine FE 24–50 files and 0 (Off) on the rest. + Every image crop is 9504 × 6336 at (32, 20), in uncompressed, compressed and lossless-L (padded to 9728 × 6656) + storage. The decoder reproduces this inventory exactly (see *Local inventory check*). +- **Distortion.** The table layout and crop tags match the A7R IVA's, but its interpretation applied unchanged does + not reproduce the A7CR camera JPEG at 24 mm: at 2376 px wide, median / 95th-percentile feature residuals were + 1.61 / 6.17 px (12.98 / 51.19 px uncorrected, 6,539 matches), with a signed radial pattern (about +1.7 px at + radii 0.25–0.5, −7.4 px at 0.8–1) that no translation removes. At 50 mm it agrees (0.23 / 1.05 px, from + 2.21 / 12.25 px). Scaling the raw table by about 10/9, or using the MakerNote's distortion array instead, brings + 24 mm to about 0.37 / 1.10 and 0.33 / 0.97 px. Those are exploratory comparisons on two files against the camera + JPEG, not a model: the MakerNote values are roughly 1.11 times the raw table without one exact ratio, and the + A7R IVA was validated against a different reference renderer (Imaging Edge exports). Needed: A7CR Imaging Edge + On/Off exports at 24 and 50 mm and with another lens, compared with the raw table at its fixed scale; a decision on + the reference (camera JPEG or Imaging Edge); then ILCE-7CR added to the validated models for the full-frame Bayer + 3:2 mode only. Neither a 10/9 multiplier nor the MakerNote table is used. +- **Shading.** The layout decodes, but no public specification gives the sample-to-gain function, its radius + convention on the A7CR, or whether the raw samples already contain part of the correction. Brightness ratios on six + natural scenes show no consistent missing peripheral brightening. Needed: evenly lit flat fields with shading + Off / Auto / Low at several apertures and focal lengths, raw CFA ratios between them, and Imaging Edge On/Off + exports, to fix the gain function and processing stage and rule out double correction. +- **Lateral CA.** A public analysis reads the table as a red, then a blue curve relative to green, at a 2⁻²¹ + relative radial scale. On three distortion-Off files that candidate reduced red and blue feature residuals + modestly (for example 0.35 → 0.29 px median for red with the 28 mm), which supports investigating it but certifies + neither scale, interpolation nor processing stage. Two product changes come before it: embedded CA must be + independent of the distortion amount (today `pipeline::optics` scales the whole embedded per-plane warp with the + profile distortion control), and the red and blue planes must be resampled in camera RGB, before the camera colour + model mixes them (`engine::files::load_bytes_now` applies the colour model before the optics stage). Then + validation on full-resolution achromatic edges. +- **Other modes.** APS-C, aspect crops and lossless M/S (linear YCbCr) captures were not measured and stay + excluded. + +## Local inventory check + +`crates/raw/tests/sony_lens_inventory.rs` is opt-in and does nothing in CI. With `LIGHTCRAFT_SONY_LENS_DIR` naming a +directory of ARWs it decodes each from its headers and prints its record (settings, sources, copies' agreement, +sample ranges, statuses); `LIGHTCRAFT_SONY_LENS_FULL=1` also compares header and full decode. With +`LIGHTCRAFT_SONY_LENS_INVENTORY` naming a CSV inventory it checks every file's table counts, sizes, sample counts, +minima and maxima, raw-IFD versus `SR2SubIFD` agreement, settings (numbers and labels), stored size, compression and +crop tags. A file with a row must decode, and every file must have a row, whether it decodes or not. Any decode +error fails the check except an unsupported variant, which is counted and must equal `LIGHTCRAFT_SONY_LENS_UNSUPPORTED` +(default 0). On the 131 A7CR raws above, all 131 inventory rows reproduce and every file decodes. On 133 public Sony +ARWs (127 raw.pixls.us samples from 100 bodies, four corpus copies and two files attached to a public issue), 110 carry +a record, 14 decode without one, and 9 are unsupported variants (eight packed ARWs of older bodies and one ARW +without a CFA image); header and full decode agree on the 124 that decode, and no record has disagreeing copies. ## Clean-room evidence -The [ExifTool EXIF tag-name documentation](https://exiftool.org/TagNames/EXIF.html) identifies -`0x7037` as 17 signed 16-bit integers. The first value is the count, 16, followed by radial samples. -No external photo decoder implementation or Adobe lens profile was consulted. -Sony Imaging Edge Edit 4.1 was used only as a black-box reference on local scratch copies. +The [ExifTool EXIF tag-name documentation](https://exiftool.org/TagNames/EXIF.html) names the raw-IFD tags +`0x7031` to `0x7037` (VignettingCorrection / VignettingCorrParams, ChromaticAberrationCorrection / +ChromaticAberrationCorrParams, DistortionCorrection / DistortionCorrParams) with their setting values, and gives the +tables as 17 or 33 signed 16-bit integers whose first value is the count. The +[Sony SR2SubIFD documentation](https://exiftool.org/TagNames/Sony.html#SR2SubIFD) names the encrypted copies +`0x797d`, `0x7980` and `0x7982`; the setting tags next to them (`0x797c`, `0x797f`, `0x7981`) are unnamed there and +were identified by inspecting files, where they equal the raw-IFD settings. The corpus test's expected tables are the +values ExifTool, run as a black-box tool, prints for the public samples. No external photo decoder implementation, +lens database or Adobe lens profile was consulted. Sony Imaging Edge Edit 4.1 was used only as a black-box reference +on local scratch copies. The observed inverse mapping, with radius `r` normalized to the default crop's half diagonal, is: @@ -45,7 +157,8 @@ so the warp stays on the optical centre either way. On the A7R IVA that framing pixels left of Sony's exports (eight pixels in a 2376-pixel-wide export). The main raw-IFD table is important: the similarly named MakerNote table has different values and -must not be substituted into this formula. Main raw-IFD and SR2SubIFD values agreed in the test files. +must not be substituted into this formula. Main raw-IFD and SR2SubIFD values agreed in the test files, and the +`SR2SubIFD` copy is never converted. ## Validation @@ -90,7 +203,18 @@ The distortion geometry remains aligned while every body's image framing follows Tests cover signedness/length rejection, zero and malformed tables, independent 600 mm reference geometry, barrel framing, offset crop normalization, both TIFF byte orders, header/full consistency, -and preservation through DNG export. No private photographs or reference TIFFs are committed. +and preservation through DNG export. The decoder's synthetic tests (`crates/raw/src/vendor/arw_lens.rs`) add both +byte orders and BigTIFF, exact SHORT / SSHORT types, negative samples, missing, short, long, huge and zero counts, +wrong leading counts, values outside the file, a table past the TIFF reader's entry limit (not applied), repeated +tags (the reader's copy kept, as before), unknown and negative settings, raw-IFD versus +`SR2SubIFD` precedence and agreement, the correct `SR2SubIFD` tag numbers, a wrong decryption key, hostile entries +inside the decrypted block, linear YCbCr, framing evidence (Sony's crop at x = 0 against the default crop, agreeing +crops, padded storage, aspect crops, crop tags outside the frame), all eight EXIF orientations leaving the record +unchanged, and neutral, out-of-range and folding tables. On a small classic-TIFF fixture in both byte orders, every +truncation and four mutations of each byte outside the pixel data (set to 0x00 and 0xff, top and low bit flipped) +decode or fail without a panic. `tests/sr2_heap.rs` measures the peak heap of decoding a 16 MiB file whose +`SR2Private` directory holds eight fields aliasing the whole file: 37 kB, against 134 MB when those fields were +decoded. No private photographs or reference TIFFs are committed. ## Camera coverage and regression boundary @@ -99,27 +223,35 @@ model names, and every other model remain unsupported by this distortion decoder numbers and table lengths do not establish the scale, interpolation or crop normalization on another body. Support can expand after independent geometry validation; do not add model-name prefix matching. -The current `cargo xtask corpus --download` manifest contains 23 Sony CC0 samples across 17 bodies, -including samples 4822 (A7R IVA / 24–105 mm) and 3989 (A9 II / 200–600 mm). All 23 files have -pinned SHA-256 hashes and are checked with `sony_embedded_distortion_is_limited_to_validated_models`: +The current `cargo xtask corpus --download` manifest contains 25 Sony CC0 samples across 18 bodies, +including samples 4822 (A7R IVA / 24–105 mm) and 3989 (A9 II / 200–600 mm), each with a pinned SHA-256 that the +download checks. `sony_embedded_distortion_is_limited_to_validated_models` checks 23 of them (the two ILCE-7CM2 +codings have their own test), and `sony_lens_records_of_the_public_samples` verifies all 25 against their pins +(`SONY_SAMPLES` in `crates/raw/tests/corpus.rs`, itself anchored by a pinned digest and compared with the manifest) +before decoding. **No CI job fetches the corpus**: these fixture tests run only where it has been downloaded, and +otherwise skip each absent file. `LIGHTCRAFT_CORPUS_REQUIRED=1` makes `sony_lens_records_of_the_public_samples` +fail on any absent pinned file instead. -| Camera | Corpus samples | Raw-IFD table observed | Expected correction | +| Camera | Corpus samples | Decoded record: tables, settings (shading / CA / distortion) | Applied | |---|---|---|---| -| ILCE-7RM4A | Compressed (4822) | 16 samples | One distortion warp | -| ILCE-9M2 | Compressed (3989) | 16 samples | None | -| ILCE-7M3 | Compressed, uncompressed | 16 samples | None | -| ILCE-7M4 | 14-bit, lossless L/M/S | 11 samples in the 14-bit file; 16 in L/M/S | None | -| ILCE-7RM2 | 12-bit uncompressed | 16 samples | None | -| DSC-RX100 | One | No raw-IFD table | None | -| DSC-RX100M3 | One | 11 samples | None | -| ILCE-7CR | Compressed, lossless L/M | Not used; model excluded | None | -| ILCE-7RM4 | 14-bit compressed | Not used; model excluded | None | -| DSLR-A500, DSLR-A700, SLT-A33 | One each | Not used; model excluded | None | -| ILCE-3500, ILCE-7S | 5600 K, Shade respectively | Not used; model excluded | None | -| ILCE-7SM2, DSLR-A580 | 16:9 framing, one each | Not used; model excluded | None | -| DSLR-A900 | Packed 12-bit | Not used; model excluded | None | +| ILCE-7RM4A | Compressed (4822) | All three tables; 257 / 1 / 17 | One distortion warp | +| ILCE-9M2 | Compressed (3989) | All three; 257 / 1 / 0 | None | +| ILCE-7M3 | Compressed, uncompressed | All three; 257 / 1 / 0 | None | +| ILCE-7M4 | 14-bit, lossless L/M/S | L/M/S: all three, 272 / 1 / 17; the 14-bit file (APS-C framing, 4608 × 3072): settings only, its 11-sample tables are not interpreted | None | +| ILCE-7RM2 | 12-bit uncompressed | All three; 256 / 0 / 1 | None | +| DSC-RX100 | One | Shading, from the `SR2SubIFD` (the raw IFD has no tables); CA and distortion in the 11-sample layout | None | +| DSC-RX100M3 | One | Shading; CA and distortion in the 11-sample layout | None | +| ILCE-7CR | Compressed, lossless L/M | All three; 272 / 1 / 0 | None | +| ILCE-7RM4 | 14-bit compressed | All three; 257 / 1 / 17 | None | +| ILCE-3500 | 5600 K | Settings only (unavailable) | None | +| ILCE-7S | Shade | An all-zero CA table; settings unavailable | None | +| ILCE-7SM2 | 16:9 framing | All-zero shading and CA tables; settings unavailable | None | +| DSLR-A500, DSLR-A700, SLT-A33, DSLR-A580, DSLR-A900 | One each | No record | None | -These are **model-boundary regression checks, not new geometric validation of those models**. In particular, +These are **model-boundary regression checks, not new geometric validation of those models**. +`sony_lens_records_of_the_public_samples` also checks the exact tables, settings, provenance and framing evidence of +the three A7CR codings and the A7R IVA sample, and that across the Sony corpus only the A7R IVA's distortion is ever +applied. In particular, refusing the A7 III tables resolves the unverified corner warp identified in review. The lossless M/S files are linear YCbCr and remain excluded independently of the model check. Header and full decode must agree for every sample. Synthetic tests also verify both TIFF byte orders, exact model matching, From c03eb8222f08faddba0ae18275be3e6e69791847 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Mon, 12 Oct 2026 20:24:17 +0200 Subject: [PATCH 33/52] LR-IMP-CAMERA-COVERAGE: verify 30 public camera models Recheck all 54 files in the 41-model shortlist on main 25ef65c8. Pin 40 accepted files by SHA-256 and full decoded sensor FNV-1a, and record their camera-image correlation. Exclude eleven candidate models after checking identity, camera module, provenance and references. Update generated coverage and tracking documents to 265 verified models. Keep Nikon coding limits and the own-matrix DNG rendering issue explicit. Co-Authored-By: Codex --- ROADMAP.md | 15 +- crates/raw/tests/corpus.rs | 10 +- crates/raw/tests/coverage_sweep_corpus.rs | 66 +++++++ docs/gaps.md | 3 +- docs/parity-checklist.md | 5 +- docs/raw-parity.md | 56 +++--- docs/raw/coverage-sweep.tsv | 45 +++++ docs/raw/coverage.tsv | 60 +++---- docs/raw/evidence.tsv | 30 ++++ docs/target-app-parity.md | 13 +- xtask/src/main.rs | 201 ++++++++++++++++++++++ 11 files changed, 434 insertions(+), 70 deletions(-) create mode 100644 crates/raw/tests/coverage_sweep_corpus.rs create mode 100644 docs/raw/coverage-sweep.tsv diff --git a/ROADMAP.md b/ROADMAP.md index 3f02a1199..9bc5d53ff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified, 236 models; EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Leica Q3 verified; EOS R5 Mark II CR3 verified; RAW decoding 77%; 30 models verified on 40 CC0 files; 267 verified models, 18%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -12,7 +12,7 @@ from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are | | Value | Kind | |---|---:|---| -| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount | +| **Feature breadth** (checklist) | **81.6%** of 532 rows (P0 98.0%, P1 95.2%, P2 50.0%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount | | **Feature breadth** (weighted by use) | **~77%** | estimated | | **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions | | **Mainstream practitioner** | **~49%** (44–54%) | estimated | @@ -44,8 +44,8 @@ Hours are calibrated on this repo's PR history; see | Dimension | Ready | Breadth | Remaining | Doc | |---|---:|---:|---:|---| -| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | -| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | +| Features | ~61% | ~77% (checklist 81.6%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | +| ↳ RAW cameras, colour, lens profiles | ~56% | 82% of 1,446 Adobe models decode, 18% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | | UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | @@ -61,7 +61,7 @@ Hours are calibrated on this repo's PR history; see | Area | Ready | Breadth | Remaining | |---|---:|---:|---:| | Import and library | 80% | 90% | 25–45 h | -| RAW decoding and camera coverage | 77% | 81% | 120–220 h | +| RAW decoding and camera coverage | 77% | 82% | 120–220 h | | Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h | | Global develop adjustments | 70% | 93% | 30–60 h | | Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h | @@ -114,6 +114,10 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-12: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor + hashes pinned. Total: 267 verified models (18%); 43% of photographers on a verified body. + Eleven candidates excluded after rechecking identity, provenance and camera-image agreement. + Phone and drone DNG tone differences remain tracked in #1277. - 2026-10-11: Leica Q3 verified by a recorded check of 38 private DNGs (all decode and develop at full size; mean ΔE to the camera JPEG 6.3–13.9). 236 models verified; headline numbers unchanged. - 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a @@ -164,6 +168,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged | +| 2026-10-12 | minor | Camera-coverage sweep: 30 models verified on 40 CC0 files; 267 verified, 917 unverified, 226 preview-only; 43% of photographers on a verified body. Sensor verification does not establish colour parity (#1277). Refreshed the stale checklist summary: 81.6% across 532 rows | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index a96165b5e..20d0959d7 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1,7 +1,8 @@ //! Corpus test over `corpus/raw/**` (git-ignored CC0 samples from raw.pixls.us, fetched with //! `cargo xtask corpus --download`; `LIGHTCRAFT_CORPUS` overrides the corpus root). Skips cleanly when absent. //! -//! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report +//! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, generic CFA TIFF, +//! older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report //! `Unsupported` for one of the variants we know we don't decode yet. Prints decode times //! (`cargo test -p lightcraft-raw --release --test corpus -- --nocapture`). @@ -41,7 +42,7 @@ fn corpus_raw_decodes() { let t0 = Instant::now(); let preview = embedded_preview(&bytes); let tp = t0.elapsed().as_secs_f64() * 1e3; - // DNG previews are optional (and some carry only an uncompressed RGB thumbnail); vendor raws embed a JPEG, + // DNG and generic CFA TIFF previews can be absent or uncompressed RGB; other vendor raws embed a JPEG, // except the Panasonic `.RAW` files of 2005–2007 and Canon's explicit HEVC preview tracks. if let Some(p) = &preview { let jpeg = p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]); @@ -51,7 +52,10 @@ fn corpus_raw_decodes() { let hevc_preview = fmt == RawFormat::Cr3 && lightcraft_meta::cr3::parse_cr3(&bytes) .is_some_and(|c| c.tracks.iter().any(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Other(*b"HEVC") && t.data.is_some())); - assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-") || hevc_preview, "{name}: no embedded preview"); + assert!( + matches!(fmt, RawFormat::Dng | RawFormat::CfaTiff) || name.starts_with("raw-panasonic-") || hevc_preview, + "{name}: no embedded preview" + ); } let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024); let t1 = Instant::now(); diff --git a/crates/raw/tests/coverage_sweep_corpus.rs b/crates/raw/tests/coverage_sweep_corpus.rs new file mode 100644 index 000000000..78f384651 --- /dev/null +++ b/crates/raw/tests/coverage_sweep_corpus.rs @@ -0,0 +1,66 @@ +//! Sensor regressions for the public camera-coverage sweep. +//! +//! `docs/raw/coverage-sweep.tsv` records the file URLs, SHA-256 hashes, decoded dimensions, +//! FNV-1a sensor hashes and camera-image comparisons measured on main 25ef65c8. +//! Sensor hashes pin LightCraft's own output, not an independent decoder's output. +//! Hash input is every row-major sample in little-endian order, including masked borders. +//! Fetch the CC0 files with `cargo xtask corpus --download`. Missing files are reported. + +use lightcraft_raw::{RawData, decode, probe_info}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +const RECORDS: &str = include_str!("../../../docs/raw/coverage-sweep.tsv"); + +#[test] +fn coverage_sweep_sensor_decodes_are_pinned() { + let corpus = + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); + let mut checked = 0; + for line in RECORDS.lines().filter(|line| !line.starts_with('#')).skip(1) { + let columns: Vec<_> = line.split('\t').collect(); + assert_eq!(columns.len(), 14, "invalid coverage record: {line}"); + let name = columns[3]; + let path = corpus.join("raw").join(name); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skip: {} absent", path.display()); + continue; + } + Err(error) => panic!("{}: {error}", path.display()), + }; + assert_eq!(format!("{:x}", Sha256::digest(&bytes)), columns[5], "{name}: file SHA-256"); + let raw = decode(&bytes).unwrap_or_else(|error| panic!("{name}: {error}")); + raw.validate().unwrap(); + assert_eq!(raw.info(), probe_info(&bytes).unwrap(), "{name}: probe and decode disagree"); + let dimensions: Vec = columns[6..9].iter().map(|value| value.parse().unwrap()).collect(); + assert_eq!([raw.width, raw.height, raw.cpp], dimensions.as_slice(), "{name}: sensor dimensions"); + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + let mut feed = |byte: u8| hash = (hash ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3); + match &raw.data { + RawData::U16(samples) => { + assert_eq!(columns[9], "u16", "{name}: sample type"); + for sample in samples { + for byte in sample.to_le_bytes() { + feed(byte); + } + } + } + RawData::F32(samples) => { + assert_eq!(columns[9], "f32", "{name}: sample type"); + for sample in samples { + assert!(sample.is_finite(), "{name}: non-finite sample"); + for byte in sample.to_le_bytes() { + feed(byte); + } + } + } + } + assert_eq!(format!("{hash:016x}"), columns[10], "{name}: sensor samples changed"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); + checked += 1; + eprintln!("checked {name}: {}x{}, {hash:016x}", raw.width, raw.height); + } + eprintln!("camera-coverage sweep: {checked} files checked"); +} diff --git a/docs/gaps.md b/docs/gaps.md index ee6e58d1c..6871a3485 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A distortion and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 267 of 1,446 Adobe models verified (18%), ~43% of photographers on a verified body; Nikon 9 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). The public sweep adds 30 models on 40 pinned CC0 files; own-matrix DNG tone remains different (#1277). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Camera-coverage sweep: 30 models verified on 40 CC0 files; 267 verified, 917 unverified, 226 preview-only; 43% of photographers on a verified body. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | patch | Gaps 10, 22 and 23 each had two rows (two branches edited them; the merges kept both): the older copies are removed, and `cargo xtask parity` (in `ci`) now fails when a gap number is listed twice | | 2026-10-11 | minor | Gap 10 shrinks: Leica Q3 verified by a recorded check of 38 private DNGs (236 models verified; Leica 14); gap not closed | | 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..23e96b393 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -80,8 +80,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde267 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: 151 CC0 corpus files, including 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us sweep: 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned; 267 of 1,446 models verified (18%), 917 unverified, 226 preview-only and 36 unsupported. Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Camera-coverage sweep: 30 models verified on 40 CC0 files; 267 verified, 917 unverified, 226 preview-only; 43% of photographers on a verified body. Sensor verification does not establish colour parity (#1277). Refreshed the stale checklist summary: 81.6% across 532 rows | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 811353a70..52164d50b 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -14,15 +14,13 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **236** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **941** (65%) | measured from the format rules (631 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **233** (16%) | measured | -| Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **232** (16%) | measured | +| Decoded from sensor data and **verified** on a real file | **267** (18%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **917** (63%) | measured from the format rules (611 of them DNG, mostly phones) | +| **Preview-only** (embedded JPEG) | **226** (16%) | measured | | **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | -| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 83 / 639 / 24 / 8 of 754 | measured | +| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 106 / 618 / 22 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | -| …of which on a body we have verified | **~42%** | estimated, same weights | +| …of which on a body we have verified | **~43%** | estimated, same weights | | Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **4 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | | Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured | | **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~56%** (range 50–60%) | estimated, see [Scores](#scores) | @@ -43,7 +41,7 @@ committed table is stale. `lightcraft_engine::import::EXTENSIONS`, and `KNOWN_UNSUPPORTED` in `crates/raw/tests/corpus.rs`. Doc claims were not used. - **Verified** means a real file from that body decoded from sensor data in: the corpus test over the - `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 101 CC0 files; bodies whose sample is in + `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 151 CC0 files; bodies whose sample is in `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder; the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue @@ -53,6 +51,17 @@ committed table is stale. at 400 px; the files are not committed). Bulk-check body lists were matched to Adobe names through the raw.pixls.us folder names. Checks without a body list (≈ 60 NEFs, 35 CR2 bodies for the CFA layout) are not counted as verified. +- **2026-10-12 public sweep:** 30 further models on 40 CC0 files, independently downloaded and + decoded on main `25ef65c8`; [raw/coverage-sweep.tsv](raw/coverage-sweep.tsv) records the URLs, + SHA-256, dimensions, FNV-1a sensor hashes and render/reference luminance correlations (0.6801 + to 0.9998). The hashes pin LightCraft's own output. The regression test is + `crates/raw/tests/coverage_sweep_corpus.rs`; downloads are in `RAW_SAMPLES`. Ten references are + embedded TIFF images; the rest are JPEGs. FFF TIFF previews are not display-ready and check + image structure only. Eleven candidate models were excluded for identity, provenance or + missing/mismatched reference images. D6 / Z 6 / Z 7 verification covers compressed NEF only; + uncompressed remains preview-only (#671), as do Z 8 HE / HE* files. Own-matrix phone, drone + and other DNGs can decode correctly while rendering darker than their camera JPEG (#1277). + Verified means sensor data decoded, not colour parity. - **Usage weights** ([raw/usage-weights.tsv](raw/usage-weights.tsv)): maker buckets from 2025 market data (Nikkei production: Canon ~36%, Sony ~29%, Nikon ~14%, Fujifilm ~12% of mirrorless; BCN retail: Sony 29.9%, Canon 27.4%, Nikon 15.1%), adjusted for the DSLR installed base and for the makers named @@ -69,23 +78,23 @@ committed table is stale. |---|---:|---:|---:|---:|---:|---| | Sony | 149 | 86 | 51 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 and DSC-F828 refused (regression #702); SRF not imported | | Canon | 134 | 12 | 99 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | -| Samsung | 128 | 1 | 118 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | -| Apple | 113 | 1 | 112 | 0 | 0 | — (DNG / ProRAW) | +| Samsung | 128 | 7 | 112 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | +| Apple | 113 | 4 | 109 | 0 | 0 | DNG / ProRAW | | Panasonic | 104 | 93 | 11 | 0 | 0 | — | -| Nikon | 99 | 5 | 94 | 0 | 0 | — (HE / HE★ is a per-shot setting, preview-only) | +| Nikon | 99 | 9 | 90 | 0 | 0 | HE / HE* and some uncompressed NEFs remain preview-only | | Fujifilm | 97 | 16 | 55 | 26 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) | -| Google | 88 | 1 | 87 | 0 | 0 | — | +| Google | 88 | 5 | 83 | 0 | 0 | DNG | | Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** | | Leica | 61 | 14 | 47 | 0 | 0 | — | | Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) | | Pentax / Ricoh | 60 | 4 | 56 | 0 | 0 | — | -| Hasselblad | 34 | 1 | 2 | 31 | 0 | Hasselblad-compressed 3FR / FFF | +| Hasselblad | 34 | 7 | 2 | 25 | 0 | Hasselblad-compressed 3FR / FFF | | Phase One / Leaf / Mamiya | 59 | 0 | 0 | 43 | 16 | IIQ, MOS preview; TIFF raws and MEF not imported | | Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | -| DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | +| DJI | 21 | 4 | 17 | 0 | 0 | DNG | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | -| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **236 (16%)** | **941 (65%)** | **233 (16%)** | **36 (2%)** | | +| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 3 | 11 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | +| **All** | **1,446** | **267 (18%)** | **917 (63%)** | **226 (16%)** | **36 (2%)** | | ## By use (estimated) @@ -93,16 +102,16 @@ committed table is stale. |---|---:|---:|---:|---| | Canon | 33% | 73% | 19% | 18 of the 22 popular bodies write CR3; 4 of them verified (M50, R8, R100, R5 Mark II), the rest at 0.6 | | Sony | 27% | 98% | 91% | bulk-checked | -| Nikon | 16% | 91% | 12% | decoders cover every NEF coding but HE; few bodies verified | +| Nikon | 16% | 92% | 19% | HE and some uncompressed NEFs remain preview-only; nine bodies verified | | Fujifilm | 10% | 91% | 45% | | | Panasonic | 4% | 100% | 98% | bulk-checked | | OM / Olympus | 3% | 3% | 1% | compressed ORF | -| Phones | 3% | 93% | 1% | DNG | +| Phones | 3% | 93% | 3% | DNG | | Pentax / Ricoh | 1.5% | 94% | 14% | | | Leica | 1% | 96% | 21% | the Q3 verified | -| DJI | 1% | 95% | 0% | | -| Medium format and others | 0.5% | 23% | 1% | IIQ, 3FR, X3F | -| **Weighted** | 100% | **85%** | **42%** | | +| DJI | 1% | 96% | 19% | | +| Medium format and others | 0.5% | 27% | 6% | IIQ, 3FR, X3F | +| **Weighted** | 100% | **85%** | **43%** | | About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually checked, and Canon, the largest bucket, is the weakest of the big four. @@ -144,9 +153,9 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | DNG | uncompressed, LJ92, lossy JPEG / Smart Preview, Deflate incl. float, LinearRaw, tiles / strips, row / column interleave | ✅ | 6 corpus DNGs; 4 ILCE-6400 JXL DNGs; 38 Leica Q3 DNGs (lossless JPEG, 14-bit, private check) | | DNG | JPEG XL (DNG 1.7, lossless and lossy) | ✅ | synthetic bit-exact; real lossless and lossy files | | DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) | -| DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL only; white-level pile-up fix #548 | +| DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL corpus; 13 further Apple, Google and Samsung models in the pinned sweep; white-level pile-up fix #548 | | DNG | GoPro GPR (VC-5) | ⬜ | refused | -| 3FR / FFF | DNG-like layouts | 🟡 | decoded when uncompressed or LJ92 | +| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; FFF neutral-matrix colour remains unverified | | 3FR / FFF | Hasselblad compression | ⬜ | preview | | IIQ, MOS, MRW, ERF, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) | | DCR, MEF, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported | @@ -272,6 +281,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Camera-coverage sweep: 30 models verified on 40 CC0 files; 267 verified, 917 unverified, 226 preview-only; 43% of photographers on a verified body. Sensor verification does not establish colour parity (#1277) | | 2026-10-11 | minor | Leica Q3 verified by a recorded check of 38 private DNGs (no decode failure; mean ΔE to the camera JPEG 6.3–13.9): 236 verified, 941 unverified, Leica bucket on a verified body 5% → 21%, weighted shares unchanged | | 2026-10-12 | patch | CR2 white level from the active area, not the saturated masked rows above it (#1272: EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1)); scores unchanged | | 2026-10-12 | minor | Canon PowerShot G9: as-shot white balance from WBInfo (`0x0029`, the row of the preset in ShotInfo), issue #1273; render unchanged (the camera-look fit already absorbed it), As Shot now the camera's; counts unchanged | diff --git a/docs/raw/coverage-sweep.tsv b/docs/raw/coverage-sweep.tsv new file mode 100644 index 000000000..8e3b2449a --- /dev/null +++ b/docs/raw/coverage-sweep.tsv @@ -0,0 +1,45 @@ +# CC0 raw.pixls.us camera-coverage check, 2026-10-12; main 25ef65c8. File SHA-256 matches the public listing. +# FNV-1a-64 pins LightCraft's own complete decoded samples, row-major little-endian, including masked borders. +# Default renders at up to 1200 px; Pearson r on Lab L* at 200 px after orientation/aspect alignment, threshold 0.6. +# Hasselblad PreviewTIFF references check image structure only. Own-matrix DNG tone remains subject to issue #1277. +maker camera id file url sha256 width height cpp samples fnv1a64 luma_r reference notes +Apple iPhone 6s Plus 915 coverage-915.dng https://raw.pixls.us/getfile.php/915/nice/Apple%20-%20iPhone%206s%20Plus%20-%2016bit%20%284:3%29.DNG 845fee4f09c832af3728778737f5ae9b99124debb821b6e2066b94aed1daa91c 4032 3024 1 u16 17e4d3c620d593c9 0.9712 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -10.109 +Apple iPhone 7 Plus 939 coverage-939.dng https://raw.pixls.us/getfile.php/939/nice/Apple%20-%20iPhone%207%20Plus%20-%2016bit%20%284:3%29.DNG e8c7ebd8f22f7281d165496b91c8fea8a8e79c8113172fb499e1d66d6045332e 4032 3024 1 u16 d0095a8b553f0f8a 0.9904 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -11.913 +Apple iPhone XS 3372 coverage-3372.dng https://raw.pixls.us/getfile.php/3372/nice/Apple%20-%20iPhone%20XS%20-%2016bit%20%284:3%29.dng 50bc64ee8cfbbfb6c0531e29ab428ff985506a16fe2d1d2e74656f21b54505f6 4032 3024 1 u16 a8e819ac4e5beb2a 0.9805 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -14.751 +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) 3009 coverage-3009.dng https://raw.pixls.us/getfile.php/3009/nice/Hasselblad%20-%20L1D-20c%20-%2016bit%20%283:2%29.DNG 7afecae0db80d832941eeeb3c0eb2dc03f5696637b6e78b3c592e8c002dce4ec 5568 3648 1 u16 b207313fa7dee601 0.9781 embedded JPEG 16bit (3:2); comparison Normal/crop-render; mean dL -8.41 +DJI DJI Mavic 3 (Hasselblad L2D-20c) 5140 coverage-5140.dng https://raw.pixls.us/getfile.php/5140/nice/Hasselblad%20-%20L2D-20c%20-%2016bit%20%284:3%29.DNG dbe98cb202f884ff11e0144899613b26d33cf9cddd9f1a62226f4352397f18e4 5376 3956 1 u16 2c78ceb8cb893ff1 0.8618 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL 0.418 +DJI DJI Mavic 4 Pro (DJI FC9287) 8041 coverage-8041.dng https://raw.pixls.us/getfile.php/8041/nice/DJI%20-%20FC9287%20-%2016bit%20%2816:9%29.DNG 901b7970f02f7197c782f111363feef02108f75811342d8cc4a2b66757bb7af0 8192 4608 1 u16 d1babb4646097d6f 0.6801 embedded JPEG 16bit (16:9); reference has a different distortion/crop; comparison Normal/crop-render; mean dL 2.583 +DJI DJI Mini 2 4785 coverage-4785.dng https://raw.pixls.us/getfile.php/4785/nice/DJI%20-%20FC7303%20-%2016bit%20%284:3%29.DNG 21a49e63966f82391ca1188c7559c3acc01a35a89a70194fda4bf44cbd64a851 4000 3000 1 u16 d1e66e08f335b55b 0.9328 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -9.016 +DJI DJI Mini 2 4898 coverage-4898.dng https://raw.pixls.us/getfile.php/4898/nice/DJI%20-%20FC7303%20-%2016bit%20%2816:9%29.DNG fb4f294e12cab7b507fd0b7c07f24e5d13e7c0dd127b534ce12c48d644c9363d 4000 2250 1 u16 acaab4fe42d3dc58 0.9716 embedded JPEG 16bit (16:9); comparison Normal/crop-render; mean dL -8.153 +Google Pixel 4 XL 3502 coverage-3502.dng https://raw.pixls.us/getfile.php/3502/nice/Google%20-%20Pixel%204%20XL%20-%2016bit%20%284:3%29.dng 0c03a6c93b4b8593cd0e4426166e6389710007238b82eb23861ef191322f0c54 3700 2774 1 u16 d121f233fdbaff2c 0.9143 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -0.351 +Google Pixel 4a Rear Wide Camera 4176 coverage-4176.dng https://raw.pixls.us/getfile.php/4176/nice/Google%20-%20Pixel%204a%20-%2016bit%20%284:3%29.dng 11b38186ee3455376291bcdb3049728a3f9dcd94b9e82ff76855d9f3430e44be 4032 3024 1 u16 ac056154d440cb2d 0.9515 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -2.361 +Google Pixel 6 Pro Rear Main Camera 5961 coverage-5961.dng https://raw.pixls.us/getfile.php/5961/nice/Google%20-%20Pixel%206%20Pro%20-%2016bit%20%284:3%29.dng 3a859e917a91698a3e15ce3b3721a48a1fb1296873e0697d3bf7c2793297200d 4080 3072 1 u16 96631250f12729ad 0.944 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -0.402 +Google Pixel 8 Pro Wide Camera* 7751 coverage-7751.dng https://raw.pixls.us/getfile.php/7751/nice/Google%20-%20Pixel%208%20Pro%20-%2016bit%20%284:3%29.dng 45420c595401547cca950ae58d552bc82b32d31ce1d58c082df33004016197f5 8160 6144 1 u16 5e30a4d3bfc914b3 0.9485 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -5.099 +Hasselblad CFV-50c 7217 coverage-7217.3fr https://raw.pixls.us/getfile.php/7217/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.3FR e27ff177519298fe7bae64506b975190789dc0e6ad70bea58be7c78fd094eae0 8374 6304 1 u16 14e60b95775930f2 0.9984 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL -14.199 +Hasselblad CFV-50c 7218 coverage-7218.fff https://raw.pixls.us/getfile.php/7218/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.fff c8d78912ae725a59acff42a4b4731350bbddd4a600423f319579261bd41da932 8374 6304 1 u16 db63ffcd176381ee 0.9443 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 28.828 +Hasselblad H5D-40 1640 coverage-1640.fff https://raw.pixls.us/getfile.php/1640/nice/Hasselblad%20-%20H5D-40%20-%2016bit%20%284:3%29.fff 209d464e13702de5f4fdb830ac26ac140c98a8d0dc3e81c5ba61d93677330832 7410 5586 1 u16 1d34f6f4e45b5eab 0.9574 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 29.541 +Hasselblad H5D-50c 1639 coverage-1639.fff https://raw.pixls.us/getfile.php/1639/nice/Hasselblad%20-%20H5D-50c%20-%2016bit%20%284:3%29.fff 1aad9319fcbab81b851a74c3eeaa2a07fc5013bf2c4e272fd31d6419b6b9adb0 8374 6304 1 u16 02a189d2a6b78d4e 0.9453 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 28.943 +Hasselblad X1D 2058 coverage-2058.3fr https://raw.pixls.us/getfile.php/2058/nice/Hasselblad%20-%20X1D%20-%2016bit%20%284:3%29.3FR 395acb2c706dc10270c58ec261e42a5e36f6ac8c39d2a767e86e83648aa318a3 8384 6304 1 u16 0b480efe4d2ed7c2 0.9753 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL -9.059 +Hasselblad X1D II 50C 3874 coverage-3874.fff https://raw.pixls.us/getfile.php/3874/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.fff 8c2451b3478fb529b4e9bd51ed3267999ce26eb1fcb5c3b93c3ae7bc56267794 8384 6304 1 u16 c7372897d70da8a8 0.9436 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 22.474 +Hasselblad X1D II 50C 3941 coverage-3941.3fr https://raw.pixls.us/getfile.php/3941/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.3FR 809d498263d6f07cbaa4b9a5be97933057b4ab21e7243bb7afbe3862f3b48b47 8384 6304 1 u16 3c3a77bf67197196 0.9982 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -11.176 +Hasselblad X2D 100C 6568 coverage-6568.fff https://raw.pixls.us/getfile.php/6568/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.fff 44977fdaa3063eacbb2ac416dfb531bb4e3b96f3745d073dc49fb1ebcd78bedc 11904 8842 1 u16 849906d491fba55a 0.9196 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 26.781 +Hasselblad X2D 100C 6565 coverage-6565.3fr https://raw.pixls.us/getfile.php/6565/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.3FR dcc5a4abe3498e6f25e89bb491995fd12c3b669d9277c71b45b49210d3e56280 11904 8842 1 u16 c07a267c0bc60a5f 0.9962 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -2.632 +Nikon D6 4726 coverage-4726.nef https://raw.pixls.us/getfile.php/4726/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF 3a098e704d0e1af97efc37541b43a37e5b3457d2f4e7828c0d8d9af37332f233 5584 3728 1 u16 272425c271bb0d76 0.9557 embedded JPEG 12bit 12bit compressed (Lossy (type 2)) (3:2); comparison Normal/crop-render; mean dL 0.037 +Nikon D6 4720 coverage-4720.nef https://raw.pixls.us/getfile.php/4720/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF 827bcfff8a3cd86278382c353e4542faab52cf12c365540f30753bb4c6fabc0f 5584 3728 1 u16 1d6a9f10a502fb14 0.9581 embedded JPEG 14bit 14bit compressed (Lossless) (3:2); comparison Normal/crop-render; mean dL 0.048 +Nikon D6 4721 coverage-4721.nef https://raw.pixls.us/getfile.php/4721/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF 081d5dbc1d6c97935020d33ffa486dcaa8d4564cbde280cfadcf26ff61da2838 5584 3728 1 u16 2d6c8af38e4a0ed4 0.9592 embedded JPEG 14bit 14bit compressed (Lossy (type 2)) (3:2); comparison Normal/crop-render; mean dL 0.043 +Nikon Z 6 3584 coverage-3584.nef https://raw.pixls.us/getfile.php/3584/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF 3cbd9acf9e9bfd7d0c62c46436109556a101f7535c5fd25f86dad6ea61bf3886 6064 4040 1 u16 a63b22e83bb7c82f 0.8749 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 0.327 +Nikon Z 6 3581 coverage-3581.nef https://raw.pixls.us/getfile.php/3581/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF 8ca94584578b35a422744c1a610174ce37df0691588edfdecaec3111b3fbbb02 6064 4040 1 u16 1e23d74a11c01a97 0.8752 embedded JPEG 14bit 14bit compressed (3:2); comparison Normal/crop-render; mean dL 0.353 +Nikon Z 6 3582 coverage-3582.nef https://raw.pixls.us/getfile.php/3582/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.NEF c079345fc93f53a4f0d322f8ddaae505f920c36015f25b58befccaa61db1af31 6064 4040 1 u16 0a0bc73568e125e0 0.8755 embedded JPEG 14bit 14bit lossless compressed (3:2); comparison Normal/crop-render; mean dL 0.464 +Nikon Z 7 2792 coverage-2792.nef https://raw.pixls.us/getfile.php/2792/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF c861546fbcf5826d5eb26d223e2143fa32968e89f7edbc5bb10c283ea5461ded 8288 5520 1 u16 c989690cb3e19ef9 0.7417 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 2.417 +Nikon Z 7 2789 coverage-2789.nef https://raw.pixls.us/getfile.php/2789/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF e54a6c4264bf685214f538036fd9d39088e510b2bfedf288e568f0db1897880e 8288 5520 1 u16 627867ac6a8dd4ad 0.7412 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 2.299 +Nikon Z 7 2790 coverage-2790.nef https://raw.pixls.us/getfile.php/2790/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF f0e756dbfd00f3b71cb3d8aa5b9e2e1be32f1052a6c4380e09ebc7c5e7748114 8288 5520 1 u16 8a52e6f3d8ff64dd 0.7389 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 2.585 +Nikon Z 8 6617 coverage-6617.nef https://raw.pixls.us/getfile.php/6617/nice/Nikon%20-%20Z%208%20-%208bit%208bit%20compressed%20%283:2%29.NEF c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40 8280 5520 1 u16 716e1158bab28b76 0.9563 embedded JPEG 8bit 8bit compressed (3:2); comparison Normal/crop-render; mean dL 2.044 +Parrot Anafi 2912 coverage-2912.dng https://raw.pixls.us/getfile.php/2912/nice/Parrot%20-%20Anafi%20-%2016bit%20%284:3%29.DNG a257a296047ff2d396109fd2e6947fa36aa7986695b6815f83979d59e98a84e6 5344 4016 1 u16 07a188c932a25a31 0.9895 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -3.924 +Samsung Galaxy S21 Ultra Rear Main Camera 6728 coverage-6728.dng https://raw.pixls.us/getfile.php/6728/nice/Samsung%20-%20Galaxy%20S21%20Ultra%20-%204:3.dng 5baeb5f0c4a125647df03b82c2d38b0f02784393bd39eff1a5b23d8f5308dc5e 4000 3000 3 u16 16c5d6faed83390d 0.9912 embedded JPEG 4:3; comparison Rotate180/crop-render; mean dL -10.83 +Samsung Galaxy S22 Ultra Rear Wide Camera 6261 coverage-6261.dng https://raw.pixls.us/getfile.php/6261/nice/Samsung%20-%20Galaxy%20S22%20Ulra%20-%204:3.dng 53e7f83a94c965495ba32d3ea6021ef08ed02244ce64d111d1329bbd01fd5581 4000 3000 3 u16 7cbe612adc5575b4 0.9739 embedded JPEG 4:3; comparison Normal/crop-render; mean dL -20.487 +Samsung Galaxy S23 Rear Wide Camera 6697 coverage-6697.dng https://raw.pixls.us/getfile.php/6697/nice/Samsung%20-%20Galaxy%20S23%20-%204:3.dng 1c04574f32b5a139922f075291c52018b89d9981ee155a16958a231c1fdbb067 8160 6120 3 u16 b3da1535e2e31e9f 0.9418 embedded JPEG 4:3; comparison Normal/crop-render; mean dL -6.585 +Samsung Galaxy S7 Edge 1297 coverage-1297.dng https://raw.pixls.us/getfile.php/1297/nice/Samsung%20-%20G935F%20-%2016bit%20%2816:9%29.dng 843617c8b572320b7badfb4ac2eda19479f5804a1dabb909c861bde23ed30561 4032 2268 1 u16 7e99d7da67022e5a 0.8677 exiftool:PreviewTIFF 16bit (16:9); comparison Normal/crop-render; mean dL 5.772 +Samsung Galaxy S8 2645 coverage-2645.dng https://raw.pixls.us/getfile.php/2645/nice/Samsung%20-%20SM-G950F%20-%2016bit%20%284:3%29.dng 6abdd353094964eaadddf69bba3a0d551ecce1bc0ed013a434691df4f64dcbf2 4032 3024 1 u16 b70ca9a743283754 0.9884 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL -4.112 +Samsung Galaxy S8+ 2310 coverage-2310.dng https://raw.pixls.us/getfile.php/2310/nice/Samsung%20-%20SM-G955F%20-%2016bit%20%284:3%29.dng 3cf5da4331d062bc6b31e0e8f5b7ff2cd21107de4262d79a2667f670516e8e8c 4032 3024 1 u16 c3a20523ad3b18cf 0.986 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL 2.437 +Sigma fp 7273 coverage-7273.dng https://raw.pixls.us/getfile.php/7273/nice/Sigma%20-%20fp%20-%2014bit%20%283:2%29.DNG 0844bcf23a49e21b5c2f8489de55ce38fb7b594e3ed337d6b9af2dd9341354e9 6064 4042 1 u16 f7b92f1904d6cc31 0.9754 embedded JPEG 14bit (3:2); comparison Normal/crop-render; mean dL 7.1 +Sigma fp L 6949 coverage-6949.dng https://raw.pixls.us/getfile.php/6949/nice/Sigma%20-%20fp%20L%20-%2014bit%20%283:2%29.DNG 8e7229f28be30bfa45289940eac04194af2b3474f6db286e4fa4070c145a0a70 9536 6344 1 u16 6896f73ec6b49231 0.9998 embedded JPEG 14bit (3:2); comparison Normal/crop-render; mean dL 1.597 diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index 7d224dc6e..7480123c1 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -97,16 +97,16 @@ Apple iPad Pro 12.9-inch (2018 model) DNG 2018 unverified DNG decoder (uncompres Apple iPad Pro 13-inch M4 (2024 version) Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPad mini (6th Generation) Rear Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 6s DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone 6s Plus DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 6s Plus DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone 7 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone 7 Plus DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 7 Plus DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone 8 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 8 Plus DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE (2020 version) DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone X DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone XR DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone XS DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone XS DNG 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone XS Max DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPad Air (5th generation) Rear Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE (3rd generation) Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -274,9 +274,9 @@ Casio EXILIM EX-ZR4100 DNG 2017 unverified DNG decoder (uncompressed, LJ92, loss Casio EXILIM EX-ZR5000 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Casio EXILIM EX-ZR5100 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Contax N Digital RAW 2003 preview RAW is opened from its embedded JPEG only -DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI AIR 2S DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mini 2 DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mini 2 DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Air 3 Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3 Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mini 3 Pro DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -287,13 +287,13 @@ DJI DJI Mavic 3 Cine (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncom DJI DJI Mavic 3 Pro Super Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 3 Pro Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 3 Pro (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mavic 3 (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 3 (Hasselblad L2D-20c) DNG 2023 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Osmo Pocket 3 D DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3S Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3S Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Osmo Action 5 Pro DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 4 Pro (DJI FC9284) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mavic 4 Pro (DJI FC9287) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 4 Pro (DJI FC9287) DNG 2025 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Mavic 4 Pro (Hasselblad L3D-100c) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DxO DxO ONE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Epson R-D1 ERF 2003 preview ERF is opened from its embedded JPEG only @@ -407,7 +407,7 @@ Google Pixel 3 XL DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, De Google Pixel 3 XL Front Standard Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 3 XL Front Wide Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 4 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 4 XL DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 4 XL DNG 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 5 Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5 Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5 Rear Ultra Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -415,7 +415,7 @@ Google Pixel 6 Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, Google Pixel 6 Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Pro Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 6 Pro Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6 Pro Rear Main Camera DNG 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 6 Pro Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Pro Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 7 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -426,7 +426,7 @@ Google Pixel 7 Pro Telephoto Camera DNG 2022 unverified DNG decoder (uncompresse Google Pixel 7 Pro Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 7 Pro Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 4a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 4a Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 4a Rear Wide Camera DNG 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 5a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5a Rear Main Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5a Rear Ultra Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -442,7 +442,7 @@ Google Pixel 8 Wide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, Google Pixel 8 Pro Front Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8 Pro Telephoto Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8 Pro Ultrawide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 8 Pro Wide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8 Pro Wide Camera* DNG 2023 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 8a Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8a Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8a Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -502,7 +502,7 @@ Hasselblad CF-39MS 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFH-22 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFH-39 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFV 3FR 2008 preview 3FR is opened from its embedded JPEG only -Hasselblad CFV-50c 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad CFV-50c 3FR 2008 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H2D DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Hasselblad H2D-22 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H2D-39 3FR 2008 preview 3FR is opened from its embedded JPEG only @@ -515,18 +515,18 @@ Hasselblad H3DII-39MS 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H3DII-50 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H4D-40 3FR 2010 preview 3FR is opened from its embedded JPEG only Hasselblad H4D-60 3FR 2010 preview 3FR is opened from its embedded JPEG only -Hasselblad H5D-40 3FR 2012 preview 3FR is opened from its embedded JPEG only +Hasselblad H5D-40 3FR 2012 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H5D-50 3FR 2012 preview 3FR is opened from its embedded JPEG only -Hasselblad H5D-50c 3FR 2013 preview 3FR is opened from its embedded JPEG only +Hasselblad H5D-50c 3FR 2013 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H5D-60 3FR 2013 preview 3FR is opened from its embedded JPEG only Hasselblad H6D-100c 3FR 2015 preview 3FR is opened from its embedded JPEG only Hasselblad HV ARW 2013 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Hasselblad Lunar 3FR 2012 verified issue #1275: raw.pixls.us 4051 (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data; black 512 and SR2SubIFD WB as on a NEX-7 Hasselblad Stellar II 3FR 2015 preview 3FR is opened from its embedded JPEG only -Hasselblad X1D 3FR 2015 preview 3FR is opened from its embedded JPEG only +Hasselblad X1D 3FR 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad X2D II 100C (**) 3FR 2024 preview 3FR is opened from its embedded JPEG only -Hasselblad X1D II 50C 3FR 2018 preview 3FR is opened from its embedded JPEG only -Hasselblad X2D 100C 3FR 2021 preview 3FR is opened from its embedded JPEG only +Hasselblad X1D II 50C 3FR 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X2D 100C 3FR 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Huawei Honor 20 Pro Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Huawei Honor 20 Pro Rear Telephoto Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Huawei Honor 20 Pro Rear Wide Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -723,7 +723,7 @@ Nikon D40 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy Nikon D40x NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon D50 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon D60 NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D6 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D6 NEF 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) Nikon D70 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon D70S NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon D80 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only @@ -766,12 +766,12 @@ Nikon Z 50 II NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lo Nikon Z 5 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z 5 2 NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z 5 2C NEF 2025 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 6 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 6 NEF 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) Nikon Z 6 II NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z 6 III NEF 2023 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 7 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 7 NEF 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) Nikon Z 7 II NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 8 NEF 2022 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 8 NEF 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); HE / HE* remain preview-only Nikon Z 9 (*See note) NEF 2021 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z fc NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z f NEF 2022 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -1007,7 +1007,7 @@ Panasonic LUMIX DC-S1M2ES (Final Support) RW2 2024 verified PR #215: 178 raw.pix Panasonic LUMIX DC-S1M2 RW2 2024 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX DC-ZS300 (DC-TZ300, DC-TX3) RW2 2025 unverified Panasonic decoder, every raw format Panasonic LUMIX DC-L10 RW2 2025 unverified Panasonic decoder, every raw format -Parrot Anafi DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Parrot Anafi DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Parrot ANAFI Ai DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Pentax 645D DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Pentax 645Z DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1127,16 +1127,16 @@ Samsung Galaxy S21 Rear Main Camera DNG 2020 unverified DNG decoder (uncompresse Samsung Galaxy S21 Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21+ Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21+ Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S21 Ultra Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S21 Ultra Rear Main Camera DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S21 Ultra Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21 Ultra Super Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21 Ultra Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S6 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S6 Edge DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S7 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S7 Edge DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S8 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S8+ DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S7 Edge DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8 DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8+ DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S9 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S9+ DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy Z Fold3 Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1154,11 +1154,11 @@ Samsung Galaxy S22 Ultra Front Camera DNG 2021 unverified DNG decoder (uncompres Samsung Galaxy S22 Ultra Rear Super Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S22 Ultra Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S22 Ultra Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S22 Ultra Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22 Ultra Rear Wide Camera DNG 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S23 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23 Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23 Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S23 Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23 Rear Wide Camera DNG 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S23+ Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23+ Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23+ Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1230,8 +1230,8 @@ Sigma BF DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JP Sigma DP1 X3F 2007 preview X3F is opened from its embedded JPEG only Sigma DP1s X3F 2008 preview X3F is opened from its embedded JPEG only Sigma DP2 X3F 2008 preview X3F is opened from its embedded JPEG only -Sigma fp DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Sigma fp L DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sigma fp DNG 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp L DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Sigma SD9 X3F 2003 preview X3F is opened from its embedded JPEG only Sigma SD10 X3F 2003 preview X3F is opened from its embedded JPEG only Sigma SD14 X3F 2007 preview X3F is opened from its embedded JPEG only diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 4ce812d8c..ce108ca7e 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -2,6 +2,9 @@ # "verified" means a real file from this body decoded from sensor data in a corpus test or a recorded bulk check (source column). maker camera status source Apple iPhone 12 Pro Rear Wide Camera verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Apple iPhone 6s Plus verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Apple iPhone 7 Plus verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Apple iPhone XS verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Canon EOS 40D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Canon EOS 550D (EOS Digital Rebel T2i, EOS Kiss X4 Digital) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Canon EOS 5D Mark II verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -14,6 +17,10 @@ Canon EOS R100 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor has Canon EOS R5 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hashes, docs/cr3.md); bulk: QP maps of 33,665 C-RAW files Canon EOS R8 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) Canon EOS-5D Mark III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 3 (Hasselblad L2D-20c) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 4 Pro (DJI FC9287) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mini 2 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Fujifilm GFX 100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm GFX 100RF (Final Support) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm GFX 100S verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -31,7 +38,17 @@ Fujifilm X-T4** verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm X-T5 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm X-T50 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Google Pixel 2 XL verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Google Pixel 4 XL verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 4a Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 6 Pro Rear Main Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 8 Pro Wide Camera* verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Hasselblad CFV-50c verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad H5D-40 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad H5D-50c verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad Lunar verified issue #1275: raw.pixls.us 4051 (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data; black 512 and SR2SubIFD WB as on a NEX-7 +Hasselblad X1D verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X1D II 50C verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X2D 100C verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Leica C (Typ 112) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica D-Lux (Typ 109) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica D-Lux 3 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body @@ -48,8 +65,12 @@ Leica V-LUX 4 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leic Leica V-LUX 5 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Nikon COOLPIX B700 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon D5100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon D6 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) Nikon D7000 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon D7500 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon Z 6 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 7 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 8 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); HE / HE* remain preview-only Nikon Z f verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus E-1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus EVOLT E-400 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -147,11 +168,20 @@ Panasonic LUMIX DMC-ZS40 (DMC-TZ60, DMC-TZ61) verified PR #215: 178 raw.pixls.us Panasonic LUMIX DMC-ZS50 (DMC-TZ70, DMC-TZ71) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX DMC-ZS60 (DMC-TZ80, DMC-TZ81, DMC-TZ85) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX ZS200 (DC-TX2, DC-TZ200, DC-TZ202, DC-TZ220, DC-ZS220) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Parrot Anafi verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Pentax K-3 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Pentax K-5 IIs verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Pentax K10D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Ricoh GR III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Samsung Galaxy S21 Ultra Rear Main Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S22 Ultra Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S23 Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S7 Edge verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8+ verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung TL500 (EX1) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Sigma fp verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp L verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Sony A100 (DSLR-A100) unsupported issue #702: regression, refused as not a readable photo Sony A200 (DSLR-A200) preview issue #535: preview-only on the raw.pixls.us sample Sony A230 (DSLR-A230) preview issue #535: preview-only on the raw.pixls.us sample diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ba62abb40..3ad4455cd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 40–70 h → 34–58 h after the accessibility audit) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 40–70 h → 34–58 h after the accessibility audit; 30 models verified on 40 CC0 files; 267 verified models, 18%) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -12,7 +12,7 @@ have their own documents: [raw-parity.md](raw-parity.md), [file-format-parity.md | Number | Value | Kind | |---|---:|---| -| **Feature breadth**, checklist | **81.1%** of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%) | **measured**: recount of [parity-checklist.md](parity-checklist.md) rows (✅ 1, 🟡 ½, 🚫 left out), the same rule `cargo xtask parity` uses | +| **Feature breadth**, checklist | **81.6%** of 532 in-scope rows (P0 98.0%, P1 95.2%, P2 50.0%) | **measured**: recount of [parity-checklist.md](parity-checklist.md) rows (✅ 1, 🟡 ½, 🚫 left out), the same rule `cargo xtask parity` uses | | **Feature breadth**, weighted by use | **~77%** | estimated: feature-area table below | | **Ready for real work** (full target) | **~61%** (range 56–66%) | estimated: weighted sum over the dimensions with written weights, see [below](#ready-for-real-work-mainstream-practitioner-and-essentials-user) | | **Mainstream practitioner** | **~49%** (range 44–54%) | estimated: weekly areas of a working photographer, depth 71.8% × 0.804 × colour 0.85 | @@ -46,8 +46,8 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Breadth | Ready for real work | Remaining | Doc | |---|---:|---:|---:|---| -| Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) | -| ↳ RAW cameras, colour and lens profiles (part of features) | 81% of Adobe's 1,446 models decode (16% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | +| Features (weighted areas below) | ~77% (checklist 81.6%, measured) | ~61% | 600–1,100 h | [Features](#features) | +| ↳ RAW cameras, colour and lens profiles (part of features) | 82% of Adobe's 1,446 models decode (18% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | | UI/UX fidelity | ~85% | ~70% | 34–58 h | [ui-parity.md](ui-parity.md) | | File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) | | Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) | @@ -67,7 +67,7 @@ from the checklist sections and the deep-dive documents. | Area | Weight | Breadth | Ready | Remaining | Notes | |---|---:|---:|---:|---:|---| | Import and library (import, folders, albums, smart albums, keywords, metadata, search, faces) | 15% | 90% | 80% | 25–45 h | checklist A–E 83–92%; robust journal; 85k-photo libraries | -| RAW decoding and camera coverage | 15% | 81% | 77% | 120–220 h | [raw-parity.md](raw-parity.md), measured model by model: 85% of photographers' cameras decode (use-weighted), × 0.9 for per-body bugs; CR3 variants, compressed ORF, HE NEF | +| RAW decoding and camera coverage | 15% | 82% | 77% | 120–220 h | [raw-parity.md](raw-parity.md), measured model by model: 85% of photographers' cameras decode (use-weighted), × 0.9 for per-body bugs; CR3 variants, compressed ORF, HE NEF | | Colour, camera profiles, render fidelity | 10% | 65% | 35% | 80–150 h | 4 bundled profiles vs Adobe's 1,500; per-file fits median ΔE ~5 vs the camera JPEG, ~10% far off; no fidelity suite ([raw-parity.md](raw-parity.md#colour)) | | Global develop adjustments (light, colour, curves, grading, effects, detail) | 15% | 93% | 70% | 30–60 h | checklist F 91.7%; character tuned by eye | | Optics, lens profiles, crop and geometry | 6% | 80% | 50% | 40–80 h | no lens database; crop / Upright complete | @@ -257,7 +257,7 @@ verification. Calibrated on this repository's history (git log and GitHub PR ope (#279), NEF lossy after split 4.7 h (#633), ORF 12.8 bpp 1.7 h (#651), HEIC 7 h (#609). - **Large features:** AI RAW denoise 8.3 h (#384, 12.8k lines), SAM 3 masks 14.9 h (#162), HDR editing and export 2.3 h (#646, on existing groundwork), contact-sheet PDF (#563), German UI - 13.8 h (#265). + 13.8 h (#267). - PR open time includes queueing and review, so these are upper bounds per feature; the ranges add a long-tail factor of 1.3–1.5× for verification against real files. @@ -298,6 +298,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged | +| 2026-10-12 | minor | Camera-coverage sweep: 30 models verified on 40 CC0 files; 267 verified, 917 unverified, 226 preview-only; 43% of photographers on a verified body. Sensor verification does not establish colour parity (#1277). Refreshed the stale checklist summary: 81.6% across 532 rows | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | | 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c392d0890..f5f293120 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -313,6 +313,207 @@ fn cmd_ci() -> Result<(), String> { /// The third field is the SHA-256 of the file as downloaded; `cargo xtask corpus --download` checks it. /// One per format / compression variant we decode or deliberately report as unsupported (preview only). const RAW_SAMPLES: &[(&str, &str, &str)] = &[ + // Camera-coverage sweep: CC0 files, sensor hashes and comparisons in docs/raw/coverage-sweep.tsv. + ( + "coverage-915.dng", + "https://raw.pixls.us/getfile.php/915/nice/Apple%20-%20iPhone%206s%20Plus%20-%2016bit%20%284:3%29.DNG", + "845fee4f09c832af3728778737f5ae9b99124debb821b6e2066b94aed1daa91c", + ), + ( + "coverage-939.dng", + "https://raw.pixls.us/getfile.php/939/nice/Apple%20-%20iPhone%207%20Plus%20-%2016bit%20%284:3%29.DNG", + "e8c7ebd8f22f7281d165496b91c8fea8a8e79c8113172fb499e1d66d6045332e", + ), + ( + "coverage-3372.dng", + "https://raw.pixls.us/getfile.php/3372/nice/Apple%20-%20iPhone%20XS%20-%2016bit%20%284:3%29.dng", + "50bc64ee8cfbbfb6c0531e29ab428ff985506a16fe2d1d2e74656f21b54505f6", + ), + ( + "coverage-3009.dng", + "https://raw.pixls.us/getfile.php/3009/nice/Hasselblad%20-%20L1D-20c%20-%2016bit%20%283:2%29.DNG", + "7afecae0db80d832941eeeb3c0eb2dc03f5696637b6e78b3c592e8c002dce4ec", + ), + ( + "coverage-5140.dng", + "https://raw.pixls.us/getfile.php/5140/nice/Hasselblad%20-%20L2D-20c%20-%2016bit%20%284:3%29.DNG", + "dbe98cb202f884ff11e0144899613b26d33cf9cddd9f1a62226f4352397f18e4", + ), + ( + "coverage-8041.dng", + "https://raw.pixls.us/getfile.php/8041/nice/DJI%20-%20FC9287%20-%2016bit%20%2816:9%29.DNG", + "901b7970f02f7197c782f111363feef02108f75811342d8cc4a2b66757bb7af0", + ), + ( + "coverage-4785.dng", + "https://raw.pixls.us/getfile.php/4785/nice/DJI%20-%20FC7303%20-%2016bit%20%284:3%29.DNG", + "21a49e63966f82391ca1188c7559c3acc01a35a89a70194fda4bf44cbd64a851", + ), + ( + "coverage-4898.dng", + "https://raw.pixls.us/getfile.php/4898/nice/DJI%20-%20FC7303%20-%2016bit%20%2816:9%29.DNG", + "fb4f294e12cab7b507fd0b7c07f24e5d13e7c0dd127b534ce12c48d644c9363d", + ), + ( + "coverage-3502.dng", + "https://raw.pixls.us/getfile.php/3502/nice/Google%20-%20Pixel%204%20XL%20-%2016bit%20%284:3%29.dng", + "0c03a6c93b4b8593cd0e4426166e6389710007238b82eb23861ef191322f0c54", + ), + ( + "coverage-4176.dng", + "https://raw.pixls.us/getfile.php/4176/nice/Google%20-%20Pixel%204a%20-%2016bit%20%284:3%29.dng", + "11b38186ee3455376291bcdb3049728a3f9dcd94b9e82ff76855d9f3430e44be", + ), + ( + "coverage-5961.dng", + "https://raw.pixls.us/getfile.php/5961/nice/Google%20-%20Pixel%206%20Pro%20-%2016bit%20%284:3%29.dng", + "3a859e917a91698a3e15ce3b3721a48a1fb1296873e0697d3bf7c2793297200d", + ), + ( + "coverage-7751.dng", + "https://raw.pixls.us/getfile.php/7751/nice/Google%20-%20Pixel%208%20Pro%20-%2016bit%20%284:3%29.dng", + "45420c595401547cca950ae58d552bc82b32d31ce1d58c082df33004016197f5", + ), + ( + "coverage-7217.3fr", + "https://raw.pixls.us/getfile.php/7217/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.3FR", + "e27ff177519298fe7bae64506b975190789dc0e6ad70bea58be7c78fd094eae0", + ), + ( + "coverage-7218.fff", + "https://raw.pixls.us/getfile.php/7218/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.fff", + "c8d78912ae725a59acff42a4b4731350bbddd4a600423f319579261bd41da932", + ), + ( + "coverage-1640.fff", + "https://raw.pixls.us/getfile.php/1640/nice/Hasselblad%20-%20H5D-40%20-%2016bit%20%284:3%29.fff", + "209d464e13702de5f4fdb830ac26ac140c98a8d0dc3e81c5ba61d93677330832", + ), + ( + "coverage-1639.fff", + "https://raw.pixls.us/getfile.php/1639/nice/Hasselblad%20-%20H5D-50c%20-%2016bit%20%284:3%29.fff", + "1aad9319fcbab81b851a74c3eeaa2a07fc5013bf2c4e272fd31d6419b6b9adb0", + ), + ( + "coverage-2058.3fr", + "https://raw.pixls.us/getfile.php/2058/nice/Hasselblad%20-%20X1D%20-%2016bit%20%284:3%29.3FR", + "395acb2c706dc10270c58ec261e42a5e36f6ac8c39d2a767e86e83648aa318a3", + ), + ( + "coverage-3874.fff", + "https://raw.pixls.us/getfile.php/3874/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.fff", + "8c2451b3478fb529b4e9bd51ed3267999ce26eb1fcb5c3b93c3ae7bc56267794", + ), + ( + "coverage-3941.3fr", + "https://raw.pixls.us/getfile.php/3941/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.3FR", + "809d498263d6f07cbaa4b9a5be97933057b4ab21e7243bb7afbe3862f3b48b47", + ), + ( + "coverage-6568.fff", + "https://raw.pixls.us/getfile.php/6568/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.fff", + "44977fdaa3063eacbb2ac416dfb531bb4e3b96f3745d073dc49fb1ebcd78bedc", + ), + ( + "coverage-6565.3fr", + "https://raw.pixls.us/getfile.php/6565/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.3FR", + "dcc5a4abe3498e6f25e89bb491995fd12c3b669d9277c71b45b49210d3e56280", + ), + ( + "coverage-4726.nef", + "https://raw.pixls.us/getfile.php/4726/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "3a098e704d0e1af97efc37541b43a37e5b3457d2f4e7828c0d8d9af37332f233", + ), + ( + "coverage-4720.nef", + "https://raw.pixls.us/getfile.php/4720/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "827bcfff8a3cd86278382c353e4542faab52cf12c365540f30753bb4c6fabc0f", + ), + ( + "coverage-4721.nef", + "https://raw.pixls.us/getfile.php/4721/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "081d5dbc1d6c97935020d33ffa486dcaa8d4564cbde280cfadcf26ff61da2838", + ), + ( + "coverage-3584.nef", + "https://raw.pixls.us/getfile.php/3584/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "3cbd9acf9e9bfd7d0c62c46436109556a101f7535c5fd25f86dad6ea61bf3886", + ), + ( + "coverage-3581.nef", + "https://raw.pixls.us/getfile.php/3581/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF", + "8ca94584578b35a422744c1a610174ce37df0691588edfdecaec3111b3fbbb02", + ), + ( + "coverage-3582.nef", + "https://raw.pixls.us/getfile.php/3582/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.NEF", + "c079345fc93f53a4f0d322f8ddaae505f920c36015f25b58befccaa61db1af31", + ), + ( + "coverage-2792.nef", + "https://raw.pixls.us/getfile.php/2792/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "c861546fbcf5826d5eb26d223e2143fa32968e89f7edbc5bb10c283ea5461ded", + ), + ( + "coverage-2789.nef", + "https://raw.pixls.us/getfile.php/2789/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "e54a6c4264bf685214f538036fd9d39088e510b2bfedf288e568f0db1897880e", + ), + ( + "coverage-2790.nef", + "https://raw.pixls.us/getfile.php/2790/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "f0e756dbfd00f3b71cb3d8aa5b9e2e1be32f1052a6c4380e09ebc7c5e7748114", + ), + ( + "coverage-6617.nef", + "https://raw.pixls.us/getfile.php/6617/nice/Nikon%20-%20Z%208%20-%208bit%208bit%20compressed%20%283:2%29.NEF", + "c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40", + ), + ( + "coverage-2912.dng", + "https://raw.pixls.us/getfile.php/2912/nice/Parrot%20-%20Anafi%20-%2016bit%20%284:3%29.DNG", + "a257a296047ff2d396109fd2e6947fa36aa7986695b6815f83979d59e98a84e6", + ), + ( + "coverage-6728.dng", + "https://raw.pixls.us/getfile.php/6728/nice/Samsung%20-%20Galaxy%20S21%20Ultra%20-%204:3.dng", + "5baeb5f0c4a125647df03b82c2d38b0f02784393bd39eff1a5b23d8f5308dc5e", + ), + ( + "coverage-6261.dng", + "https://raw.pixls.us/getfile.php/6261/nice/Samsung%20-%20Galaxy%20S22%20Ulra%20-%204:3.dng", + "53e7f83a94c965495ba32d3ea6021ef08ed02244ce64d111d1329bbd01fd5581", + ), + ( + "coverage-6697.dng", + "https://raw.pixls.us/getfile.php/6697/nice/Samsung%20-%20Galaxy%20S23%20-%204:3.dng", + "1c04574f32b5a139922f075291c52018b89d9981ee155a16958a231c1fdbb067", + ), + ( + "coverage-1297.dng", + "https://raw.pixls.us/getfile.php/1297/nice/Samsung%20-%20G935F%20-%2016bit%20%2816:9%29.dng", + "843617c8b572320b7badfb4ac2eda19479f5804a1dabb909c861bde23ed30561", + ), + ( + "coverage-2645.dng", + "https://raw.pixls.us/getfile.php/2645/nice/Samsung%20-%20SM-G950F%20-%2016bit%20%284:3%29.dng", + "6abdd353094964eaadddf69bba3a0d551ecce1bc0ed013a434691df4f64dcbf2", + ), + ( + "coverage-2310.dng", + "https://raw.pixls.us/getfile.php/2310/nice/Samsung%20-%20SM-G955F%20-%2016bit%20%284:3%29.dng", + "3cf5da4331d062bc6b31e0e8f5b7ff2cd21107de4262d79a2667f670516e8e8c", + ), + ( + "coverage-7273.dng", + "https://raw.pixls.us/getfile.php/7273/nice/Sigma%20-%20fp%20-%2014bit%20%283:2%29.DNG", + "0844bcf23a49e21b5c2f8489de55ce38fb7b594e3ed337d6b9af2dd9341354e9", + ), + ( + "coverage-6949.dng", + "https://raw.pixls.us/getfile.php/6949/nice/Sigma%20-%20fp%20L%20-%2014bit%20%283:2%29.DNG", + "8e7229f28be30bfa45289940eac04194af2b3474f6db286e4fa4070c145a0a70", + ), ( "arw-sony-a7rm4a-compressed.arw", "https://raw.pixls.us/getfile.php/4822/nice/Sony%20-%20ILCE-7RM4A%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", From 8feda7a99721478cffe752fe60e6b0728d625a05 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Mon, 12 Oct 2026 04:42:32 +0200 Subject: [PATCH 34/52] LR-IMP-FORMATS: the TIFF reader's next-IFD pointer no longer overflows on huge BigTIFF entry counts Pre-existing on main. The reader computed the position of a directory's next-IFD pointer before it checked that every declared entry had been read: a saturating multiplication of the declared count, then an unchecked addition. A 44-byte BigTIFF declaring u64::MAX entries therefore panicked with overflow checks on. The position is now computed only for a directory read whole, with checked arithmetic. 32-bit note: the same guard compared the counts after narrowing the declared one to usize, so on wasm32 a BigTIFF declaring 4,294,967,297 entries but holding one passed for a whole directory and its next pointer was followed. The counts now compare as 64-bit numbers (reads_every_entry). On wasm32 the old comparison followed all six malformed test chains and the new one follows none; 64-bit results are unchanged for every directory whose count is read whole. Regression tests (crates/tiff/src/reader.rs): a_bigtiff_directory_declaring_u64_max_entries_parses_without_overflow, which panicked before; a_bigtiff_count_past_u32_max_never_follows_the_next_pointer (the whole chain, on every target); and only_a_directory_read_whole_has_a_next_pointer (the comparison itself, on every target). Co-Authored-By: Claude Opus 5.5 --- crates/raw/src/dngwrite.rs | 1 + crates/raw/tests/robust.rs | 1 + crates/tiff/README.md | 4 +- crates/tiff/src/reader.rs | 89 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 379267c87..370b7c2b4 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -434,6 +434,7 @@ pub fn write_rendered_dng( linearized: false, opcodes: crate::OpcodeLists::default(), metadata: metadata.clone(), + lens_metadata: Default::default(), }; let order = ByteOrder::Little; write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp }) diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/robust.rs index 17e108f6f..70103bfd7 100644 --- a/crates/raw/tests/robust.rs +++ b/crates/raw/tests/robust.rs @@ -162,6 +162,7 @@ fn non_finite_black_levels_and_samples_develop() { linearized: false, opcodes: OpcodeLists::default(), metadata: Default::default(), + lens_metadata: Default::default(), }; for img in [raw.clone(), lightcraft_raw::decode(&lightcraft_raw::write_dng(&raw, &DngWriteOptions::default()).unwrap()).unwrap()] { let rgb = img.develop(Method::Ahd).unwrap(); diff --git a/crates/tiff/README.md b/crates/tiff/README.md index cfaaffffb..e6a771288 100644 --- a/crates/tiff/README.md +++ b/crates/tiff/README.md @@ -29,7 +29,9 @@ later the TIFF/DNG exporters. The reader never panics on malformed input (proptests feed random and mutated files): offsets are bounds-checked; out-of-range or unknown-type entries are skipped; IFD loops are detected; IFD count, -nesting depth and entries per IFD are limited; the total bytes of decoded values are capped at +nesting depth and entries per IFD are limited; a next-IFD pointer is followed only when every declared entry was +read, with the counts compared as 64-bit numbers (a BigTIFF declaring 4,294,967,297 entries but holding one can't +pass for a whole directory on a 32-bit target); the total bytes of decoded values are capped at `8 × input + 1 MiB` so a file cannot make us allocate far more than its own size. ## Sources diff --git a/crates/tiff/src/reader.rs b/crates/tiff/src/reader.rs index 4a80a906b..5f38dfad2 100644 --- a/crates/tiff/src/reader.rs +++ b/crates/tiff/src/reader.rs @@ -121,6 +121,13 @@ pub fn entry_span(data: &[u8], pos: usize, order: ByteOrder, bigtiff: bool, base (end <= data.len() as u64).then_some(EntrySpan { tag, field_type, count, start, len }) } +/// Whether a directory declaring `declared` entries was read whole (`read` entries), so that its next-IFD pointer +/// follows them. The counts compare as 64-bit numbers: narrowing the declared count to `usize` instead would, on a +/// 32-bit target, let a BigTIFF declaring 4,294,967,297 entries but holding one pass for a whole directory. +fn reads_every_entry(declared: u64, read: usize) -> bool { + u64::try_from(read).is_ok_and(|read| read == declared) +} + /// Parse the header: (byte order, bigtiff, first IFD offset). pub(crate) fn header(data: &[u8]) -> Result<(ByteOrder, bool, u64)> { header_with(data, true) @@ -220,9 +227,10 @@ impl<'a> Ctx<'a> { } entries.sort_by_key(|e| e.tag); entries.dedup_by_key(|e| e.tag); - // Only a fully read table has a next-IFD offset. Keep hostile BigTIFF counts out of the arithmetic (#1516). - let next = if dir.declared == dir.read as u64 { - match dir.read.checked_mul(dir.entry_size).and_then(|len| dir.table.checked_add(len)).and_then(|p| self.read_off(p)) { + // the next-IFD pointer follows the last entry, read only when every declared entry was (checked: a BigTIFF + // count can be anything up to u64::MAX) + let next = if reads_every_entry(dir.declared, dir.read) { + match dir.read.checked_mul(dir.entry_size).and_then(|n| n.checked_add(dir.table)).and_then(|p| self.read_off(p)) { Some(0) | None => 0, Some(v) => base.checked_add(v).unwrap_or(0), } @@ -307,6 +315,65 @@ pub fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { mod tests { use super::*; + /// A BigTIFF whose first IFD, at 80, declares `declared` entries but holds one, followed by a next-IFD pointer + /// `next`; a valid one-entry IFD sits at 16. + fn bigtiff_chain(order: ByteOrder, declared: u64, next: u64) -> Vec { + let ifd = |d: &mut Vec, count: u64, tag: u16, next: u64| { + order.put_u64(d, count); + order.put_u16(d, tag); + order.put_u16(d, 3); + order.put_u64(d, 1); + order.put_u16(d, 8); + d.extend([0; 6]); + order.put_u64(d, next); + }; + let mut d = if order == ByteOrder::Little { b"II".to_vec() } else { b"MM".to_vec() }; + order.put_u16(&mut d, 43); + order.put_u16(&mut d, 8); + order.put_u16(&mut d, 0); + order.put_u64(&mut d, 80); + ifd(&mut d, 1, 0x0101, 0); + d.resize(80, 0); + ifd(&mut d, declared, 0x0100, next); + d + } + + #[test] + fn only_a_directory_read_whole_has_a_next_pointer() { + // the comparison itself, on every target: a declared count past u32::MAX never matches a small read count + for (declared, read, whole) in [ + (0, 0, true), + (1, 1, true), + (8192, 8192, true), + (8193, 8192, false), + (1, 0, false), + ((1 << 32) + 1, 1, false), + ((2 << 32) + 1, 1, false), + ((1 << 63) + 1, 1, false), + (u64::MAX, 1, false), + ] { + assert_eq!(reads_every_entry(declared, read), whole, "{declared} declared, {read} read"); + } + } + + #[test] + fn a_bigtiff_count_past_u32_max_never_follows_the_next_pointer() { + // the whole chain: on 32-bit targets (wasm32) the old cast truncated these counts to 1 and followed the pointer + for order in [ByteOrder::Little, ByteOrder::Big] { + for declared in [(1u64 << 32) + 1, (2u64 << 32) + 1, (1u64 << 63) + 1] { + let d = bigtiff_chain(order, declared, 16); + let tiff = Tiff::parse(&d).unwrap(); + assert_eq!(tiff.ifds.len(), 1, "{order:?} {declared}"); + assert_eq!(tiff.ifds[0].u64(0x0100), Some(8)); + assert_eq!(parse_ifd_at(&d, 80, order, 0, true, &ParseOptions::default()).unwrap().1, 0, "{order:?} {declared}"); + } + // control: the same file declaring its one entry follows the pointer to the IFD at 16 + let whole = Tiff::parse(&bigtiff_chain(order, 1, 16)).unwrap(); + assert_eq!(whole.ifds.len(), 2, "{order:?}"); + assert_eq!(whole.ifds[1].u64(0x0101), Some(8)); + } + } + #[test] fn directory_positions_never_overflow() { // a hand-built layout the reader never produces: the iterator stops instead of overflowing @@ -322,4 +389,20 @@ mod tests { let dir = directory_entries(&data, 0, ByteOrder::Little, false, 8192).unwrap(); assert_eq!(dir.positions().collect::>(), vec![2, 14]); } + + #[test] + fn a_bigtiff_directory_declaring_u64_max_entries_parses_without_overflow() { + // 44 bytes: BigTIFF header (first IFD at 16), an entry count of u64::MAX, one 20-byte entry + let mut data = b"II\x2b\x00\x08\x00\x00\x00".to_vec(); + data.extend(16u64.to_le_bytes()); + data.extend(u64::MAX.to_le_bytes()); + data.extend(0x0100u16.to_le_bytes()); + data.extend(3u16.to_le_bytes()); + data.extend(1u64.to_le_bytes()); + data.extend([8, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(data.len(), 44); + let tiff = Tiff::parse(&data).unwrap(); + assert_eq!(tiff.ifds.len(), 1); + assert_eq!(tiff.ifds[0].u64(0x0100), Some(8)); + } } From 3c907e0c9d3b618a5ccf9f0aca9b4fa191d6c076 Mon Sep 17 00:00:00 2001 From: Sergio Perea Date: Wed, 14 Oct 2026 12:08:36 +0200 Subject: [PATCH 35/52] fix(export): a metadata/sharpen/conflict value that passes validation is the one exported `ExportOptions::validate` accepted `metadata`, `sharpen`, `sharpenAmount` and `conflict` case-insensitively, but `from_json` read them with serde's exact-case names and silently fell back to the default when that failed. `{"format": "jpeg", "metadata": "NONE"}` therefore passed validation and exported with `MetadataPolicy::All`: GPS, location and camera data went out in a file the caller asked to carry none. MCP, the control channel and the CLI all go through `from_params`, so all were affected. Each of the four enums gets a case-insensitive `parse` (the pattern `HdrCurve::parse` and `JxlEffort::parse` already use), and both `validate` and `from_json` call it, so validation and conversion can no longer disagree. Accepted inputs, error messages, defaults and `to_json` output are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/export.rs | 118 ++++++++++++++++++++++++++++-- crates/engine/src/tests_export.rs | 24 ++++++ 2 files changed, 134 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 403cb71f6..b3890077f 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -162,6 +162,17 @@ pub enum Conflict { Skip, } +impl Conflict { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "unique" => Some(Self::Unique), + "overwrite" => Some(Self::Overwrite), + "skip" => Some(Self::Skip), + _ => None, + } + } +} + /// A named set of `app.export` params. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ExportPreset { @@ -191,6 +202,18 @@ pub enum SharpenFor { Glossy, } +impl SharpenFor { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "none" => Some(Self::None), + "screen" => Some(Self::Screen), + "matte" => Some(Self::Matte), + "glossy" => Some(Self::Glossy), + _ => None, + } + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SharpenAmount { @@ -200,6 +223,17 @@ pub enum SharpenAmount { High, } +impl SharpenAmount { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "low" => Some(Self::Low), + "standard" => Some(Self::Standard), + "high" => Some(Self::High), + _ => None, + } + } +} + /// Where a watermark sits. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -634,6 +668,18 @@ pub enum MetadataPolicy { None, } +impl MetadataPolicy { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "all" => Some(Self::All), + "allexceptcamera" => Some(Self::AllExceptCamera), + "copyright" => Some(Self::Copyright), + "none" => Some(Self::None), + _ => None, + } + } +} + /// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or /// HLG (relative to the display; BT.2100 with SDR white at signal 0.75). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -907,6 +953,10 @@ impl ExportOptions { let s = string(cmd, k, v)?; if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } }; + // a choice `from_json` reads with the same `parse`, so what passes here is what gets exported + let parses = |k: &str, v: &Value, parse: fn(&str) -> bool, options: &[&str]| -> crate::Result<()> { + if parse(string(cmd, k, v)?) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } + }; for (k, v) in obj { if v.is_null() { continue; @@ -962,17 +1012,17 @@ impl ExportOptions { "naming" | "subfolder" | "path" | "dir" | "preset" => { string(cmd, k, v)?; } - "sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?, - "sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?, + "sharpen" => parses(k, v, |s| SharpenFor::parse(s).is_some(), &["none", "screen", "matte", "glossy"])?, + "sharpenAmount" => parses(k, v, |s| SharpenAmount::parse(s).is_some(), &["low", "standard", "high"])?, "hdrTransfer" => one_of(k, v, &["pq", "hlg"])?, - "conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?, + "conflict" => parses(k, v, |s| Conflict::parse(s).is_some(), &["unique", "overwrite", "skip"])?, "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, "jxlEffort" => one_of(k, v, &["fast", "normal"])?, "dngCompression" => { serde_json::from_value::(v.clone()) .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?; } - "metadata" => one_of(k, v, &["all", "allExceptCamera", "copyright", "none"])?, + "metadata" => parses(k, v, |s| MetadataPolicy::parse(s).is_some(), &["all", "allExceptCamera", "copyright", "none"])?, "colorSpace" => { if OutputSpace::parse(string(cmd, k, v)?).is_none() { return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020"))); @@ -1074,12 +1124,12 @@ impl ExportOptions { resize: Self::resize_from_json(p), ppi: u("ppi").filter(|v| *v > 0).map_or(d.ppi, |v| v.min(u16::MAX as u64) as u16), limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32), - sharpen: enm(p, "sharpen").unwrap_or(d.sharpen), - sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount), + sharpen: s("sharpen").and_then(SharpenFor::parse).unwrap_or(d.sharpen), + sharpen_amount: s("sharpenAmount").and_then(SharpenAmount::parse).unwrap_or(d.sharpen_amount), naming: s("naming").map_or(d.naming, str::to_string), start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32), subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")), - conflict: enm(p, "conflict").unwrap_or(d.conflict), + conflict: s("conflict").and_then(Conflict::parse).unwrap_or(d.conflict), tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() { Some("none") => TiffCompression::None, Some("lzw") => TiffCompression::Lzw, @@ -1087,7 +1137,7 @@ impl ExportOptions { _ => d.tiff_compression, }, dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression), - metadata: enm(p, "metadata").unwrap_or(d.metadata), + metadata: s("metadata").and_then(MetadataPolicy::parse).unwrap_or(d.metadata), remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location), watermark: match p.get("watermark") { Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }), @@ -3190,6 +3240,58 @@ mod tests { ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); } + /// A choice the validation accepts is the one exported: `metadata: "NONE"` passed the + /// case-insensitive check, then failed the exact-case read and fell back to `all`, so GPS and + /// camera data went out in a file meant to carry none. `sharpen`, `sharpenAmount` and + /// `conflict` had the same gap. + #[test] + fn accepted_choices_are_the_ones_exported() { + use serde_json::json; + let read = |k: &str, v: &str| ExportOptions::from_params(&json!({ k: v })).map_err(|e| e.to_string()); + let metadata = [ + ("all", MetadataPolicy::All), + ("allExceptCamera", MetadataPolicy::AllExceptCamera), + ("copyright", MetadataPolicy::Copyright), + ("none", MetadataPolicy::None), + ]; + for (name, want) in metadata { + for v in [name.to_string(), name.to_ascii_uppercase(), name.to_ascii_lowercase()] { + assert_eq!(read("metadata", &v).unwrap().metadata, want, "metadata: {v}"); + } + } + for (name, want) in [("none", SharpenFor::None), ("screen", SharpenFor::Screen), ("matte", SharpenFor::Matte), ("glossy", SharpenFor::Glossy)] + { + assert_eq!(read("sharpen", &name.to_ascii_uppercase()).unwrap().sharpen, want); + } + for (name, want) in [("low", SharpenAmount::Low), ("standard", SharpenAmount::Standard), ("high", SharpenAmount::High)] { + assert_eq!(read("sharpenAmount", &name.to_ascii_uppercase()).unwrap().sharpen_amount, want); + } + for (name, want) in [("unique", Conflict::Unique), ("overwrite", Conflict::Overwrite), ("skip", Conflict::Skip)] { + assert_eq!(read("conflict", &name.to_ascii_uppercase()).unwrap().conflict, want); + } + // an explicit value nothing reads as is an error, never the permissive default + for v in ["", " none", "nothing", "all-except-camera"] { + let e = read("metadata", v).unwrap_err(); + assert!(e.contains("`metadata` must be one of all|allExceptCamera|copyright|none"), "{v:?}: {e}"); + } + assert!(read("sharpenAmount", "max").unwrap_err().contains("`sharpenAmount` must be one of low|standard|high")); + assert!(read("conflict", "replace").unwrap_err().contains("`conflict` must be one of unique|overwrite|skip")); + // absent (or null) keeps the defaults + let d = ExportOptions::from_params(&json!({"metadata": null})).unwrap(); + assert_eq!( + (d.metadata, d.sharpen, d.sharpen_amount, d.conflict), + (MetadataPolicy::All, SharpenFor::None, SharpenAmount::Standard, Conflict::Unique) + ); + // `removeLocation` stays independent of the policy's spelling + let o = ExportOptions::from_params(&json!({"metadata": "AllExceptCamera", "removeLocation": true})).unwrap(); + assert_eq!((o.metadata, o.remove_location), (MetadataPolicy::AllExceptCamera, true)); + // stored settings (lenient `from_json`) read the same spellings + assert_eq!(ExportOptions::from_json(&json!({"metadata": "None"})).metadata, MetadataPolicy::None); + // and `to_json` still writes what the MCP schema lists + let j = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, conflict: Conflict::Skip, ..Default::default() }.to_json(); + assert_eq!((j["metadata"].as_str(), j["conflict"].as_str()), (Some("allExceptCamera"), Some("skip"))); + } + /// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in /// the `app.export` param string), instead of unknown keys passing and sizes clamping silently. #[test] diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 592f7349d..8617c9eb5 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -516,6 +516,30 @@ fn remove_location_holds_for_original_and_dng_exports() { let _ = std::fs::remove_dir_all(&dir); } +/// `metadata: "NONE"` as `app.export` sends it: the JPEG carries no location or camera data +/// (it used to be read as `all` and carry both). +#[test] +fn metadata_none_in_any_case_exports_no_metadata() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + s.execute("photo.setMeta", &json!({"ids": [id.0], "gps": [48.85, 2.35], "city": "Paris"})).unwrap(); + let export = |s: &mut Session, extra: serde_json::Value| { + let mut p = json!({"format": "jpeg", "longEdge": 64}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + lightcraft_meta::extract(&export_photo(s, id, &ExportOptions::from_params(&p).unwrap(), 1).unwrap().bytes) + }; + let all = export(&mut s, json!({})); + assert!(all.gps.is_some() && all.city.is_some(), "the default keeps everything: {all:?}"); + for v in ["none", "NONE", "None"] { + let m = export(&mut s, json!({"metadata": v})); + assert!(m.gps.is_none() && m.city.is_none() && m.model.is_none(), "{v}: {m:?}"); + } + let m = export(&mut s, json!({"metadata": "ALL", "removeLocation": true})); + assert!(m.gps.is_none() && m.city.is_none(), "{m:?}"); +} + /// An original raw whose own metadata holds GPS is refused under "Remove location info". #[test] fn remove_location_refuses_a_raw_that_embeds_gps() { From 0f398f6dbaafb274ef81be541f6cff0057e6b3bb Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 12:33:09 +0200 Subject: [PATCH 36/52] Edit panel: the histogram clipping buttons stay clear of the hovered scroll bar (fixes #1640) --- crates/ui-egui/src/panels/edit.rs | 9 ++++++++- crates/ui-egui/src/tests_panels.rs | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 533354b6c..2e59508eb 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -679,8 +679,15 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } let (lo, hi) = h.clipping(); - for (on, x, active) in [(lo > 0.003, plot.left() + 12.0, app.ui.show_clipping), (hi > 0.003, plot.right() - 12.0, app.ui.show_clipping)] { + // the panel's floating scroll bar widens over the right edge when hovered: both buttons keep + // clear of it, the left one too so they stay symmetric (#1640) + let scroll = ui.spacing().scroll; + let inset = 12.0 + scroll.bar_width + scroll.bar_outer_margin; + for (name, on, x, active) in + [("low", lo > 0.003, plot.left() + inset, app.ui.show_clipping), ("high", hi > 0.003, plot.right() - inset, app.ui.show_clipping)] + { let tri = Rect::from_center_size(pos2(x, plot.top() + 10.0), vec2(14.0, 12.0)); + register(ui.ctx(), format!("histogramClip:{name}"), tri); p.rect_filled(tri, 2.0, if active { t.hover } else { Color32::from_gray(60) }); paint(p, tri.shrink(2.0), Icon::ChevronDown, if on { Color32::WHITE } else { Color32::from_gray(130) }); } diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index b18c4e968..aa2c8ece5 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2365,3 +2365,20 @@ fn new_album_can_leave_the_selected_photos_out() { confirm(&mut h); assert!(photos(&h, "Nothing").is_empty()); } + +#[test] +fn the_histogram_clipping_buttons_stay_clear_of_the_hovered_scroll_bar() { + // every section open: the Edit panel scrolls, so its scroll bar runs past the histogram + let h = demo( + [1400.0, 700.0], + json!({"view": "detail", "right": "edit", "histogram": true, "openSections": ["light", "color", "effects", "detail", "optics"]}), + ); + let hist = widget(&h, "histogram"); + let (low, high) = (widget(&h, "histogramClip:low"), widget(&h, "histogramClip:high")); + // a hovered floating scroll bar grows to its full width at the panel's right edge, over the content + let scroll = h.view.ctx.global_style().spacing.scroll; + let bar_left = hist.right() - scroll.bar_outer_margin - scroll.bar_width; + assert!(high.right() <= bar_left, "the highlight clipping button {high:?} is under the scroll bar (from x = {bar_left})"); + // and the shadow one keeps the same inset from its edge + assert!(((low.left() - hist.left()) - (hist.right() - high.right())).abs() < 0.5, "{low:?} {high:?} in {hist:?}"); +} From 648b1b5a930fcf83471124ec98ad0d866658f2bc Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 14 Oct 2026 12:38:02 -0400 Subject: [PATCH 37/52] LRC-LIB-KEYWORDS: the Keyword List holds its place as the Keywords box above it changes Scrolled down a long Keyword List, ticking or unticking a keyword grew or shrank the Keywords box above it (a row of chips, suggestions) and slid the tree under the pointer. The panel now moves its scroll offset by the same amount and draws the pass again, so the list stays put. It holds only while the box's top is scrolled out of sight and the change does not come from the box itself (the pointer on it, or typing in its field). The offset is set on the scroll area as the redrawn pass starts, from the offset the pass before started at: a scroll asked for during a pass lands a frame later, and egui pulls a shrunken panel's offset back by the end of a pass. Ticks clicked one after another (a script, a quick hand) then land on the rows clicked. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/right.rs | 64 ++++++++++++++++++++++- crates/ui-egui/src/tests_keyword_list.rs | 66 ++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..612053308 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -15,6 +15,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); frame.show(ui, |ui| { + // (taken on every pass, so a hold the panel was not drawn for is forgotten) + let held = held_offset(ui.ctx()); let Some(id) = app.session.active() else { // the Keyword List is the library's, not a photo's: it is there without a selection if app.ui.right == RightPanel::Keywords { @@ -32,7 +34,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); return; }; - egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { + let mut area = egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]); + if let Some(offset) = held { + area = area.vertical_scroll_offset(offset); + } + // (where the pass starts: by its end egui may have pulled a shrunken panel's offset back) + let area_id = ui.data(|d| d.get_temp::(egui::Id::new(AREA))); + let start = held.or_else(|| area_id.and_then(|a| egui::scroll_area::State::load(ui.ctx(), a)).map(|s| s.offset.y)); + ui.data_mut(|d| match start { + Some(o) => { + d.insert_temp(egui::Id::new(OFFSET), o); + } + None => d.remove::(egui::Id::new(OFFSET)), + }); + let scrolled = area.show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; match app.ui.right { RightPanel::Edit => super::edit::show(app, ui, id), @@ -48,9 +63,25 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { RightPanel::None => {} } }); + ui.data_mut(|d| d.insert_temp(egui::Id::new(AREA), scrolled.id)); }); } +/// The panel's scroll area, its offset as the last pass started, and how far +/// [`hold_list_in_place`] asks it to move. +const AREA: &str = "right-scroll-id"; +const OFFSET: &str = "right-scroll-offset"; +const HOLD: &str = "right-scroll-hold"; + +/// Where [`hold_list_in_place`] holds the panel: its offset as the pass before started, moved by as much as +/// the Keywords box grew. Set on the scroll area as it starts, so the pass drawn again shows the list +/// in place (a scroll asked for during a pass only lands a frame later). +fn held_offset(ctx: &egui::Context) -> Option { + let grew = ctx.data_mut(|d| d.remove_temp::(egui::Id::new(HOLD)))?; + let offset = ctx.data(|d| d.get_temp::(egui::Id::new(OFFSET)))?; + Some((offset + grew).max(0.0)).filter(|o| o.is_finite()) +} + pub fn header(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); @@ -818,6 +849,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if app.session.catalog.photo(id).is_none() { return; } + let top = ui.cursor().top(); header(ui, "Keywords"); let t = Tokens::get(ui.ctx()); padded(ui, |ui| { @@ -897,9 +929,39 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); } }); + hold_list_in_place(ui, top); super::keyword_list::show(app, ui); } +/// Keeps the Keyword List where it is on screen when the Keywords box above it (starting at +/// `top`) grows or shrinks: a keyword ticked or unticked in the list, another selection. Without it +/// a long tree slides away from under the pointer by a row of chips each time. It holds only while +/// the box's top is scrolled out of sight (at the top of the panel the box is watched growing) and +/// while the change does not come from the box itself (the pointer on it, or typing in its field). +fn hold_list_in_place(ui: &mut egui::Ui, top: f32) { + let height = ui.cursor().top() - top; + let key = egui::Id::new("kw-box-height"); + let pass = ui.ctx().cumulative_pass_nr(); + // (the height drawn on the pass before: one from an earlier visit to the panel says nothing) + let before = ui.data(|d| d.get_temp::<(f32, u64)>(key)).filter(|&(_, drawn)| drawn.checked_add(1) == Some(pass)).map(|(h, _)| h); + ui.data_mut(|d| d.insert_temp(key, (height, pass))); + let Some(before) = before else { return }; + let grew = height - before; + if !grew.is_finite() || grew.abs() < 0.5 || top >= ui.clip_rect().top() - 0.5 { + return; + } + // (only the part of it in sight: the pointer on the top bar above a half-hidden box isn't on it) + let old_box = Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + before).intersect(ui.clip_rect()); + let on_box = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| old_box.contains(p)); + let typing = ui.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))); + if on_box || typing { + return; + } + ui.data_mut(|d| d.insert_temp(egui::Id::new(HOLD), grew)); + // (drawn again at once at the new offset, so the list never shows a frame out of place) + ui.ctx().request_discard("the Keyword List holds its place"); +} + /// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the /// selected photos; "Save as Set…" keeps the current nine under a name. fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 657e5f337..bdeaef0dc 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -428,3 +428,69 @@ fn dropping_photos_counts_those_that_get_the_keyword() { let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default(); assert!(toast.contains("1 photo") && !toast.contains("2 photos"), "{toast}"); } + +/// Scrolled down a long list, ticking and unticking keywords leaves the tree where it is: the +/// Keywording box above grows and shrinks out of sight instead of pushing the rows down. +#[test] +fn ticking_a_keyword_keeps_the_list_in_place() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + for i in 0..40 { + run(&mut h, "keyword.create", json!({"name": format!("zz{i:02}")})); + } + ask(&mut h, "ui.hoverWidget", json!({"id": "keywordList:topLevel"})); + let row = rect(&h, "keywordRow:zz30"); + ask(&mut h, "ui.scroll", json!({"dx": 0, "dy": -(row.top() - 300.0)})); + assert!(rect(&h, "field:keyword").top() < 0.0, "the box is scrolled out of sight"); + let mut box_moved = false; + let picked: Vec = (30..36).map(|i| format!("zz{i}")).collect(); + for (n, k) in picked.iter().chain(picked.iter().rev()).enumerate() { + let (before, box_top) = (rect(&h, &format!("keywordRow:{k}")), rect(&h, "field:keyword").top()); + ask(&mut h, "ui.clickWidget", json!({"id": format!("keywordCheck:{k}")})); + assert_eq!(rect(&h, &format!("keywordRow:{k}")), before, "{k} stayed under the pointer"); + // (the click found the row where it is drawn, not where a discarded pass had it) + assert_eq!(keywords_of(&h, ids[0]).contains(k), n < picked.len(), "{k} was the one ticked: {:?}", keywords_of(&h, ids[0])); + box_moved |= rect(&h, "field:keyword").top() != box_top; + } + assert!(keywords_of(&h, ids[0]).is_empty(), "ticked and unticked again"); + assert!(box_moved, "the box changed height (the list was held, not left alone)"); +} + +/// At the top of the panel the box is in sight: a ticked keyword's chip pushes the list down, +/// as before. +#[test] +fn at_the_top_the_box_grows_in_sight() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + let (title, box_top) = (rect(&h, "keywordList:topLevel"), rect(&h, "field:keyword")); + for k in ["aaa1", "aaa2", "aaa3", "aaa4", "aaa5", "aaa6"] { + run(&mut h, "keyword.create", json!({"name": k})); + ask(&mut h, "ui.clickWidget", json!({"id": format!("keywordCheck:{k}")})); + } + assert_eq!(rect(&h, "field:keyword"), box_top, "the panel stays at its top"); + assert!(rect(&h, "keywordList:topLevel").top() > title.top(), "the chips pushed the list down"); +} + +/// Ticks clicked one right after another land on the rows clicked: a click looks a row up where +/// it was last drawn, not where a pass drawn again (the list held in place) first had it. +#[test] +fn ticks_in_quick_succession_land_on_their_rows() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + for i in 0..40 { + run(&mut h, "keyword.create", json!({"name": format!("zz{i:02}")})); + } + ask(&mut h, "ui.hoverWidget", json!({"id": "keywordList:topLevel"})); + let row = rect(&h, "keywordRow:zz30"); + ask(&mut h, "ui.scroll", json!({"dx": 0, "dy": -(row.top() - 300.0)})); + let picked: Vec = (30..36).map(|i| format!("zz{i}")).collect(); + for k in &picked { + // (no settling in between, as a script or a quick hand clicks) + let r = h.request("ui.clickWidget", json!({"id": format!("keywordCheck:{k}")}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + let mut have = keywords_of(&h, ids[0]); + have.sort(); + assert_eq!(have, picked); +} From 2b635bb16759f5d90538e21ece67c8c31bde26c0 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 14 Oct 2026 12:41:48 -0400 Subject: [PATCH 38/52] LRC-LIB-KEYWORDS: control requests wait for a frame's first pass; a hold lasts one pass A pass drawn again after a discard still has the widgets of the pass thrown away, so a control request answered there found a row where it no longer is; it also added a 240 fps sample to perf.fps. Control requests and the fps sample now run on a frame's first pass only. The Keyword List's hold is kept for the pass right after it was asked for, so it never moves a panel that was not drawn in between. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 16 ++++++++++------ crates/ui-egui/src/panels/right.rs | 9 ++++++--- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..35d29e06b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1021,13 +1021,17 @@ impl LightcraftApp { if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } - let now = ctx.input(|i| i.time); - let dt = now - self.last_time; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + // (once a frame: a pass drawn again after a discard has no input of its own, and still the + // widgets of the pass thrown away, where a request would find them out of place) + if ctx.current_pass_index() == 0 { + let now = ctx.input(|i| i.time); + let dt = now - self.last_time; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_time = now; + self.drain_control(ctx); } - self.last_time = now; - self.drain_control(ctx); self.apply_settings(ctx); if !self.synthetic.is_empty() { ctx.request_repaint(); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 612053308..49f1949d0 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -15,7 +15,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); frame.show(ui, |ui| { - // (taken on every pass, so a hold the panel was not drawn for is forgotten) + // (taken on every pass the panel is drawn; one from earlier than the pass before is forgotten) let held = held_offset(ui.ctx()); let Some(id) = app.session.active() else { // the Keyword List is the library's, not a photo's: it is there without a selection @@ -77,7 +77,10 @@ const HOLD: &str = "right-scroll-hold"; /// the Keywords box grew. Set on the scroll area as it starts, so the pass drawn again shows the list /// in place (a scroll asked for during a pass only lands a frame later). fn held_offset(ctx: &egui::Context) -> Option { - let grew = ctx.data_mut(|d| d.remove_temp::(egui::Id::new(HOLD)))?; + let (grew, asked) = ctx.data_mut(|d| d.remove_temp::<(f32, u64)>(egui::Id::new(HOLD)))?; + if asked.checked_add(1) != Some(ctx.cumulative_pass_nr()) { + return None; + } let offset = ctx.data(|d| d.get_temp::(egui::Id::new(OFFSET)))?; Some((offset + grew).max(0.0)).filter(|o| o.is_finite()) } @@ -957,7 +960,7 @@ fn hold_list_in_place(ui: &mut egui::Ui, top: f32) { if on_box || typing { return; } - ui.data_mut(|d| d.insert_temp(egui::Id::new(HOLD), grew)); + ui.data_mut(|d| d.insert_temp(egui::Id::new(HOLD), (grew, pass))); // (drawn again at once at the new offset, so the list never shows a frame out of place) ui.ctx().request_discard("the Keyword List holds its place"); } From 6f9aae0445c385a792a5b08de18eb89e3a51d8f0 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 20:02:11 +0200 Subject: [PATCH 39/52] LR-PROF-DROPDOWN: a long profile name no longer widens the Edit panel (fixes #1759) --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/edit.rs | 5 +- crates/ui-egui/src/tests_edit_layout.rs | 68 +++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 11 +++- 4 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/tests_edit_layout.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..f711fbf2b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -65,6 +65,8 @@ mod tests_dialog_fit; #[cfg(test)] mod tests_dialogs; #[cfg(test)] +mod tests_edit_layout; +#[cfg(test)] mod tests_error_kinds; #[cfg(test)] mod tests_filmstrip; diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 533354b6c..81d5a00aa 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -159,7 +159,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(13.0)).color(t.text_dim)); let name = app.session.profile_info(&d.profile.id).map(|(name, _)| name).unwrap_or("Color"); - let r = crate::widgets::dropdown(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label); + // a long name is cut short before Browse Profiles: a wider row would widen the whole panel's + // content, and the sliders below would run past its edge + let max = ui.available_width() - 28.0 - ui.spacing().item_spacing.x; + let r = crate::widgets::dropdown_within(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label, max); egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::icon_button( diff --git a/crates/ui-egui/src/tests_edit_layout.rs b/crates/ui-egui/src/tests_edit_layout.rs new file mode 100644 index 000000000..7c6d79ff0 --- /dev/null +++ b/crates/ui-egui/src/tests_edit_layout.rs @@ -0,0 +1,68 @@ +//! The Edit panel at its narrowest with a long profile applied: the profile row fits (#1759). + +use egui::{Rect, vec2}; +use serde_json::json; + +use crate::{LightcraftApp, Services, headless::HeadlessView}; + +const LONG_ID: &str = "lut:kodak-portra-400-plus-one-stop-overexposed"; +const LONG_NAME: &str = "Kodak Portra 400 Plus One Stop Overexposed"; + +fn text_shapes(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(text) => out.push(text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_shapes(s, out)), + _ => {} + } +} + +fn panel(width: f32) -> (Vec, Vec<(String, Rect)>) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.session.lut_profiles.push(lightcraft_engine::cmd::lut_profiles::LutProfile { + id: LONG_ID.into(), + name: LONG_NAME.into(), + group: "Film Stocks".into(), + file: String::new(), + }); + app.run("develop.profile", json!({"id": LONG_ID})).unwrap(); + let photo = app.session.active().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx); + let mut texts = Vec::new(); + let mut widgets = Vec::new(); + for frame in 0..3 { + let raw = HeadlessView::raw_input(vec2(width, 4000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + crate::panels::edit::show(&mut app, ui, photo); + }); + }); + out.textures_delta.clear(); + texts.clear(); + for shape in &out.shapes { + text_shapes(&shape.shape, &mut texts); + } + widgets = crate::widgets::take_registry(&ctx); + } + (texts, widgets) +} + +#[test] +fn a_long_profile_name_leaves_the_edit_panel_its_width() { + let width = crate::state::RIGHT_WIDTH.min; + let (texts, widgets) = panel(width); + let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not drawn")).1; + let dropdown = rect("dropdown:profile"); + let browse = rect("icon:profileBrowser"); + assert!(dropdown.right() <= browse.left(), "the profile menu {dropdown:?} runs into Browse Profiles {browse:?}"); + let name = texts.iter().find(|t| t.galley.job.text == LONG_NAME).unwrap(); + assert!(name.galley.elided, "a name wider than the row is cut short"); + assert!(name.visual_bounding_rect().right() <= dropdown.right(), "the name leaves its menu"); + // every slider drawn, from the profile's Amount down + rect("sliderValue:profile.amount"); + rect("sliderValue:light.exposure"); + for (id, r) in widgets.iter().filter(|(id, _)| id.starts_with("slider")) { + assert!(r.right() <= width, "{id} {r:?} is cut off by the {width} pt panel"); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a6af3dc7a..34820cfcf 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -621,8 +621,15 @@ fn star_lit(i: u8, rating: u8, hover: Option) -> bool { /// A borderless dropdown button: `text` followed by a painted chevron (no font glyph needed). pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32) -> Response { + dropdown_within(ui, id, text, font, color, f32::INFINITY) +} + +/// [`dropdown`] at most `max` wide, chevron included: a longer `text` is cut short with …, and +/// shown in full on hover. +pub fn dropdown_within(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32, max: f32) -> Response { let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(text.to_string(), font, color); + let galley = one_line(ui.painter(), text, font, color, max - 20.0); + let elided = galley.elided; let size = vec2(galley.size().x + 20.0, galley.size().y.max(20.0)); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, text)); @@ -630,7 +637,7 @@ pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Co let c = if resp.hovered() { t.text } else { color }; ui.painter().galley(pos2(r.left(), r.center().y - galley.size().y / 2.0), galley, c); paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 7.0, r.center().y + 1.0), vec2(12.0, 12.0)), Icon::ChevronDown, c); - resp + if elided { resp.on_hover_text(text) } else { resp } } #[cfg(test)] From 18e808c08fc6d99647845c580ad8f30388dc0bb8 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 14 Oct 2026 13:37:48 -0400 Subject: [PATCH 40/52] LR-BEHAV-EMPTY-STATES: an empty library leads to Import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A library with no photos opened on the Detail view, whose empty state said "Choose a photo in the grid or filmstrip" while the grid was empty and hidden: the only screen that mentioned import was off screen, and Import itself was nowhere but the File menu and its shortcut. - An empty library (no photos, or only deleted or browsed ones) opens on the Photo Grid with the sidebar (Square Grid stays as it is), in the desktop app and in `lightcraft-cli snapshot`. The desktop app switches before it notes what its settings file holds, so the switch alone isn't saved. - The grid and the Detail view show "Your library is empty" with Import Photos… (primary) and Import Lightroom Catalog… (not on the web) as buttons, and a line saying photos and folders can be dropped into the window, with the key Import Photos… is bound to now (`⌘⇧I` on macOS, `Ctrl+Shift+I` elsewhere, or a remapped one). Not while a folder is browsed under Local or Recently Deleted is shown: their photos aren't in the library but are on screen. - The right-hand panel says "No photos yet / Import photos to get started" there instead of "Select a photo to edit". - The top bar has an Import button beside Export in every library. The centred search field's reserve grows by both sides' share (620, and a 190 minimum), so the filter icon stays clear of the icons on the right at every window width, also with macOS's title bar in the window. The top bar's icons are named for screen readers, and a refused click says why in a toast. - `empty_state` is `empty_message` with buttons that do the next step. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-cli/src/main.rs | 2 + apps/lightcraft/src/main.rs | 5 + crates/ui-egui/locales/de.json | 5 + crates/ui-egui/locales/es.json | 5 + crates/ui-egui/locales/fr.json | 5 + crates/ui-egui/locales/ja.json | 5 + crates/ui-egui/locales/pt-br.json | 5 + crates/ui-egui/locales/ru.json | 5 + crates/ui-egui/locales/uk.json | 5 + crates/ui-egui/locales/zh-hans.json | 5 + crates/ui-egui/locales/zh-hant.json | 5 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/detail.rs | 6 +- crates/ui-egui/src/panels/empty_library.rs | 76 +++++++++ crates/ui-egui/src/panels/grid.rs | 2 + crates/ui-egui/src/panels/mod.rs | 70 ++++++++ crates/ui-egui/src/panels/right.rs | 7 +- crates/ui-egui/src/panels/topbar.rs | 28 ++- crates/ui-egui/src/tests_empty_library.rs | 188 +++++++++++++++++++++ docs/parity-checklist.md | 2 +- docs/whats-new.md | 7 + 21 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/panels/empty_library.rs create mode 100644 crates/ui-egui/src/tests_empty_library.rs diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index b6cad57e0..a4057fbd9 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -791,6 +791,8 @@ fn snapshot(args: &[String]) -> Result<(), String> { // before the first frame can start background adapter discovery. // This UI state is session-local: the CLI neither loads nor saves desktop ui.json. app.ui.settings.gpu = false; + // as the desktop app opens it: an empty library on the grid, where Import is offered + lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app); let mut h = Headless::new(app, size, scale); let timeout = Duration::from_secs(60); let mut shots = 0usize; diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 1c70e19ea..4d81891ef 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -871,6 +871,11 @@ fn main() -> eframe::Result { app.notices.extend(launch_notices); app.notices.extend(window_notice); app.gpu_notice = true; + // an empty library opens where Import is offered (a grid, with the sidebar); before + // the snapshot below, so this alone isn't written to the settings file + if problem.is_none() && files.is_empty() { + lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app); + } // what's on disk now: only changes are written let writer = PrefsWriter { path: if in_memory { None } else { prefs_path() }, diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..ccca86bc2 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -956,6 +956,11 @@ "No new presets": "Keine neuen Vorgaben", "No photo selected": "Kein Foto ausgewählt", "No photos": "Keine Fotos", + "Your library is empty": "Deine Bibliothek ist leer", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Importierte Fotos erscheinen hier. Nichts wird kopiert oder verschoben, außer du wählst es.", + "or drop photos and folders into this window": "oder ziehe Fotos und Ordner in dieses Fenster", + "Import photos to get started": "Importiere Fotos, um loszulegen", + "or drop photos into this window": "oder ziehe Fotos in dieses Fenster", "No photos found": "Keine Fotos gefunden", "No photos in this folder": "Keine Fotos in diesem Ordner", "No photos match the active filters": "Keine Fotos entsprechen den aktiven Filtern", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..6b1ce1ff0 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -931,6 +931,11 @@ "No new presets": "No hay ajustes preestablecidos nuevos", "No photo selected": "No hay ninguna foto seleccionada", "No photos": "Sin fotos", + "Your library is empty": "Tu biblioteca está vacía", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Las fotos que importes aparecen aquí. No se copia ni se mueve nada a menos que lo elijas.", + "or drop photos and folders into this window": "o arrastra fotos y carpetas a esta ventana", + "Import photos to get started": "Importa fotos para empezar", + "or drop photos into this window": "o arrastra fotos a esta ventana", "No photos found": "No se encontraron fotos", "No photos in this folder": "No hay fotos en esta carpeta", "No photos match the active filters": "Ninguna foto coincide con los filtros activos", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..d6b4458fb 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -908,6 +908,11 @@ "No new presets": "Aucun nouveau préréglage", "No photo selected": "Aucune photo sélectionnée", "No photos": "Aucune photo", + "Your library is empty": "Votre bibliothèque est vide", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Les photos importées apparaissent ici. Rien n’est copié ni déplacé, sauf si vous le choisissez.", + "or drop photos and folders into this window": "ou déposez des photos et des dossiers dans cette fenêtre", + "Import photos to get started": "Importez des photos pour commencer", + "or drop photos into this window": "ou déposez des photos dans cette fenêtre", "No photos found": "Aucune photo trouvée", "No photos in this folder": "Aucune photo dans ce dossier", "No photos match the active filters": "Aucune photo ne correspond aux filtres actifs", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..2485c5e09 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -956,6 +956,11 @@ "No new presets": "新しいプリセットはありません", "No photo selected": "写真が選択されていません", "No photos": "写真がありません", + "Your library is empty": "ライブラリは空です", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "読み込んだ写真はここに表示されます。選択しない限り、コピーや移動は行われません。", + "or drop photos and folders into this window": "または写真やフォルダーをこのウィンドウにドロップ", + "Import photos to get started": "写真を読み込んで始めましょう", + "or drop photos into this window": "または写真をこのウィンドウにドロップ", "No photos found": "写真が見つかりません", "No photos in this folder": "このフォルダに写真はありません", "No photos match the active filters": "現在のフィルターに一致する写真がありません", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..c62dc59f9 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -920,6 +920,11 @@ "No new presets": "Nenhuma predefinição nova", "No photo selected": "Nenhuma foto selecionada", "No photos": "Sem fotos", + "Your library is empty": "Sua biblioteca está vazia", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "As fotos que você importar aparecem aqui. Nada é copiado ou movido, a menos que você escolha.", + "or drop photos and folders into this window": "ou arraste fotos e pastas para esta janela", + "Import photos to get started": "Importe fotos para começar", + "or drop photos into this window": "ou arraste fotos para esta janela", "No photos found": "Nenhuma foto encontrada", "No photos in this folder": "Nenhuma foto nesta pasta", "No photos match the active filters": "Nenhuma foto corresponde aos filtros ativos", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..3710e66a0 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -760,6 +760,11 @@ "No new presets": "Новых пресетов нет", "No photo selected": "Фото не выбрано", "No photos": "Нет фото", + "Your library is empty": "Библиотека пуста", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Импортированные фото появятся здесь. Ничего не копируется и не перемещается, пока вы сами этого не выберете.", + "or drop photos and folders into this window": "или перетащите фото и папки в это окно", + "Import photos to get started": "Импортируйте фото, чтобы начать", + "or drop photos into this window": "или перетащите фото в это окно", "No photos found": "Фото не найдены", "No photos in this folder": "В этой папке нет фото", "No photos match the active filters": "Нет фото, соответствующих активным фильтрам", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..2904a94f5 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1093,6 +1093,11 @@ "No new presets": "Немає нових наборів налаштувань", "No photo selected": "Фото не вибрано", "No photos": "Немає фото", + "Your library is empty": "Бібліотека порожня", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Імпортовані фото з’являться тут. Нічого не копіюється й не переміщується, доки ви самі цього не оберете.", + "or drop photos and folders into this window": "або перетягніть фото й теки в це вікно", + "Import photos to get started": "Імпортуйте фото, щоб почати", + "or drop photos into this window": "або перетягніть фото в це вікно", "No photos found": "Фото не знайдено", "No photos in this folder": "У цій теці немає фото", "No photos match the active filters": "Жодне фото не відповідає активним фільтрам", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..a1684a4fe 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -978,6 +978,11 @@ "No new presets": "没有新预设", "No photo selected": "未选择照片", "No photos": "没有照片", + "Your library is empty": "图库是空的", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "导入的照片会显示在这里。除非你选择,否则不会复制或移动任何文件。", + "or drop photos and folders into this window": "或将照片和文件夹拖放到此窗口", + "Import photos to get started": "导入照片以开始使用", + "or drop photos into this window": "或将照片拖放到此窗口", "No photos found": "未找到照片", "No photos in this folder": "此文件夹中没有照片", "No photos match the active filters": "没有照片符合当前筛选条件", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..7baabe717 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -978,6 +978,11 @@ "No new presets": "沒有新預設集", "No photo selected": "尚未選取照片", "No photos": "沒有照片", + "Your library is empty": "照片庫是空的", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "匯入的照片會顯示在這裡。除非你選擇,否則不會複製或移動任何檔案。", + "or drop photos and folders into this window": "或將照片和資料夾拖放到此視窗", + "Import photos to get started": "匯入照片以開始使用", + "or drop photos into this window": "或將照片拖放到此視窗", "No photos found": "找不到照片", "No photos in this folder": "此資料夾沒有照片", "No photos match the active filters": "沒有照片符合目前的篩選條件", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..fa32e02f5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -65,6 +65,8 @@ mod tests_dialog_fit; #[cfg(test)] mod tests_dialogs; #[cfg(test)] +mod tests_empty_library; +#[cfg(test)] mod tests_error_kinds; #[cfg(test)] mod tests_filmstrip; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..c487059a8 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -370,7 +370,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max)); } let Some(id) = app.session.active() else { - super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); + if super::empty_library::shown(&app.session) { + super::empty_library::show(app, ui, canvas); + } else { + super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); + } return; }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; diff --git a/crates/ui-egui/src/panels/empty_library.rs b/crates/ui-egui/src/panels/empty_library.rs new file mode 100644 index 000000000..d420ee1a0 --- /dev/null +++ b/crates/ui-egui/src/panels/empty_library.rs @@ -0,0 +1,76 @@ +//! A library with no photos yet: what the grid and the Detail view show instead of a dead end, +//! with Import Photos… and Import Lightroom Catalog… as buttons, and the view it opens on. + +use egui::Rect; +use serde_json::json; + +use super::EmptyAction; +use crate::LightcraftApp; +use crate::shortcuts::Keymap; +use crate::state::ViewMode; + +/// The library holds no photos: none, or only ones in Recently Deleted or browsed under Local. +pub fn is_empty(session: &lightcraft_engine::Session) -> bool { + !session.catalog.photos().any(|p| p.in_library()) +} + +/// What is shown is the empty library: not a folder browsed under Local nor Recently Deleted, +/// whose photos aren't in the library but are on screen. +pub fn shown(session: &lightcraft_engine::Session) -> bool { + use lightcraft_engine::LibrarySource; + !matches!(session.source, LibrarySource::Folder | LibrarySource::RecentlyDeleted) && is_empty(session) +} + +/// At launch, an empty library opens on a grid with the sidebar, where the way in is (Detail had +/// only "Choose a photo in the grid" to say). Square Grid stays as it is; a library with photos +/// opens as left. The desktop host calls this before it notes what its settings file holds, so +/// the switch isn't saved for its own sake. +pub fn open_on_empty_library(app: &mut LightcraftApp) { + if is_empty(&app.session) { + if app.ui.view != ViewMode::SquareGrid { + app.ui.view = ViewMode::PhotoGrid; + } + app.ui.left_panel = true; + } +} + +/// The line under the buttons: dropping works anywhere in the window, and the shortcut is the +/// one Import Photos… is bound to now, written as menus write it. +pub fn hint(keymap: &Keymap, mac: bool) -> String { + // the browser build reads dropped files, not folders + let drop = if cfg!(target_arch = "wasm32") { + crate::i18n::tr("or drop photos into this window") + } else { + crate::i18n::tr("or drop photos and folders into this window") + }; + match crate::shortcuts::shortcut_of(keymap, "file.addPhotos") { + Some(sc) => format!("{drop} · {}", crate::menubar::shortcut_text(sc, mac)), + None => drop.to_string(), + } +} + +const IMPORT_PHOTOS: EmptyAction = EmptyAction { command: "file.addPhotos", label: "Import Photos…", widget: "button:emptyImportPhotos" }; +const IMPORT_LIGHTROOM: EmptyAction = + EmptyAction { command: "file.importLightroom", label: "Import Lightroom Catalog…", widget: "button:emptyImportLightroom" }; + +/// The empty library's message and buttons, centred in `rect`. A click runs the command as its +/// menu item does; a refusal shows as a toast. +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, rect: Rect) { + // the browser build can't read a Lightroom catalog from disk: only offer what works there + let actions: &[EmptyAction] = if cfg!(target_arch = "wasm32") { &[IMPORT_PHOTOS] } else { &[IMPORT_PHOTOS, IMPORT_LIGHTROOM] }; + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let hint = hint(&app.ui.settings.keymap, mac); + let clicked = super::empty_state( + ui, + rect, + "Your library is empty", + "Photos you import show up here. Nothing is copied or moved unless you choose to.", + actions, + Some(&hint), + ); + if let Some(command) = clicked + && let Err(e) = app.run(command, json!({})) + { + app.toast_error(ui.ctx(), e); + } +} diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 52edfa460..24ae0020f 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -192,6 +192,8 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { ); } else if app.session.source == lightcraft_engine::LibrarySource::Folder { super::empty_message(ui, ui.max_rect(), "No photos in this folder", "Turn on Include subfolders, or pick another folder under Local"); + } else if super::empty_library::shown(&app.session) { + super::empty_library::show(app, ui, ui.max_rect()); } else { super::empty_message(ui, ui.max_rect(), "No photos", "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here"); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a8d99aed2..3331fa3d3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -9,6 +9,7 @@ pub mod denoise; pub mod detail; pub mod dialogs; pub mod edit; +pub mod empty_library; pub mod faces; pub mod filterbar; pub mod grid; @@ -99,3 +100,72 @@ pub fn empty_message(ui: &egui::Ui, rect: Rect, title: &str, body: &str) { p.text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, crate::i18n::tr(title), t.semibold(18.0), t.text_label); p.text(rect.center() + vec2(0.0, 14.0), Align2::CENTER_CENTER, crate::i18n::tr(body), t.font(13.0), t.text_dim); } + +/// A button under an empty state's message: the command it runs, its label, and the id it is +/// registered under (`button:…`) for scripts and tests. +pub struct EmptyAction { + pub command: &'static str, + pub label: &'static str, + pub widget: &'static str, +} + +/// [`empty_message`] with the next step under it: a row of buttons (the first is the primary +/// one, filled with the accent) and an optional dimmed hint line below them. The block is +/// centred in `rect` as one. Returns the command of the button clicked this frame. +pub fn empty_state(ui: &mut egui::Ui, rect: Rect, title: &str, body: &str, actions: &[EmptyAction], hint: Option<&str>) -> Option<&'static str> { + let t = Tokens::get(ui.ctx()); + let wrap = (rect.width() - 48.0).max(80.0); + let centred = |ui: &egui::Ui, text: &str, font: egui::FontId, color: egui::Color32| { + let mut job = egui::text::LayoutJob::simple(text.to_string(), font, color, wrap); + job.halign = egui::Align::Center; + ui.painter().layout_job(job) + }; + let title = centred(ui, crate::i18n::tr(title), t.semibold(18.0), t.text_label); + let body = centred(ui, crate::i18n::tr(body), t.font(13.0), t.text_dim); + let hint = hint.map(|h| centred(ui, h, t.font(12.0), t.text_dim)); + // the buttons' widths, and whether they fit side by side (else one above the other) + const BUTTON_H: f32 = 30.0; + const GAP: f32 = 8.0; + let widths: Vec = + actions.iter().map(|a| ui.painter().layout_no_wrap(crate::i18n::tr(a.label).to_string(), t.semibold(13.0), t.text).size().x + 32.0).collect(); + let row_w = widths.iter().sum::() + GAP * widths.len().saturating_sub(1) as f32; + let side_by_side = row_w <= wrap; + let buttons_h = match (actions.len(), side_by_side) { + (0, _) => 0.0, + (_, true) => BUTTON_H, + (n, false) => n as f32 * BUTTON_H + (n - 1) as f32 * GAP, + }; + let gap_above_buttons = if actions.is_empty() { 0.0 } else { 20.0 }; + let hint_h = hint.as_ref().map_or(0.0, |g| 12.0 + g.size().y); + let block = title.size().y + 6.0 + body.size().y + gap_above_buttons + buttons_h + hint_h; + let top = rect.center().y - block / 2.0; + let p = ui.painter().clone(); + let body_top = top + title.size().y + 6.0; + let buttons_top = body_top + body.size().y + gap_above_buttons; + p.galley(pos2(rect.center().x, top), title, t.text_label); + p.galley(pos2(rect.center().x, body_top), body, t.text_dim); + let mut clicked = None; + let mut x = rect.center().x - row_w / 2.0; + for (i, (a, w)) in actions.iter().zip(&widths).enumerate() { + let r = if side_by_side { + let r = Rect::from_min_size(pos2(x, buttons_top), vec2(*w, BUTTON_H)); + x += w + GAP; + r + } else { + let w = widths.iter().copied().fold(0.0, f32::max).min(wrap); + Rect::from_min_size(pos2(rect.center().x - w / 2.0, buttons_top + i as f32 * (BUTTON_H + GAP)), vec2(w, BUTTON_H)) + }; + let label = egui::RichText::new(crate::i18n::tr(a.label)).font(t.semibold(13.0)); + let button = + if i == 0 { egui::Button::new(label.color(egui::Color32::WHITE)).fill(t.accent) } else { egui::Button::new(label.color(t.text)) }; + let resp = ui.put(r, button); + crate::widgets::register(ui.ctx(), a.widget, resp.rect); + if resp.clicked() { + clicked = Some(a.command); + } + } + if let Some(g) = hint { + p.galley(pos2(rect.center().x, buttons_top + buttons_h + 12.0), g, t.text_dim); + } + clicked +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..139676670 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -29,7 +29,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { return; } let r = ui.max_rect(); - super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); + // every tab (Edit, Info, Versions…) says it: nothing to show until photos come in + if super::empty_library::shown(&app.session) { + super::empty_message(ui, r, "No photos yet", "Import photos to get started"); + } else { + super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); + } return; }; egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 03810aca9..f9e55a159 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -53,12 +53,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.integrated_titlebar { window_handle(app, ui, full, left as f32, 12.0); } - // room for the filter icon and the six icons on the right - let mut sw = 640.0f32.min(full.width() - 540.0).max(200.0); + // room for the filter icon and the seven icons on the right + // (the field is centred, so each icon on the right takes its width from both sides) + let mut sw = 640.0f32.min(full.width() - 620.0).max(190.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; - sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0); + sw = sw.min(2.0 * (full.center().x - menus_right)).max(190.0); } let search_left = full.center().x - sw / 2.0; ui.horizontal_centered(|ui| { @@ -119,6 +120,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // filter icon right of the search field let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0)); let fresp = ui.interact(fr, egui::Id::new("filter-btn"), Sense::click()); + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr("Filter bar"))); register(ui.ctx(), "icon:filter", fr); let filtering = app.session.filter != Default::default() || app.ui.filter_bar; paint( @@ -161,15 +163,24 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ) }); let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed"); + // Import Photos… beside Export, always one click away, with the key it is bound to now + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let import_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "file.addPhotos") { + Some(sc) => format!("{} ({})", crate::i18n::tr("Import Photos…"), crate::menubar::shortcut_text(sc, mac)), + None => crate::i18n::tr("Import Photos…").to_string(), + }; for (id, icon, tip, cmd) in [ ("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"), ("cloud", Icon::Cloud, cloud_tip, ""), ("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"), ("share", Icon::Share, "Export", "dialog.export"), + ("import", Icon::Plus, import_tip.as_str(), "file.addPhotos"), ("bell", Icon::Bell, "Activity", "panel.activity"), ] { let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); let resp = ui.interact(r, egui::Id::new(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); + // named for screen readers, as the icon buttons on the left are + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(tip))); register(ui.ctx(), format!("icon:{id}"), r); let warn = id == "cloud" && unsaved.is_some(); let colour = if warn { @@ -186,8 +197,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas); register(ui.ctx(), "indicator:unsaved", r); } - if resp.clicked() && !cmd.is_empty() { - let _ = app.run(cmd, json!({})); + // a refusal says why (Import while a Lightroom catalog is coming in) + if resp.clicked() + && !cmd.is_empty() + && let Err(e) = app.run(cmd, json!({})) + { + app.toast_error(ui.ctx(), e); } x -= 40.0; } @@ -195,7 +210,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); - let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip.clone()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip.clone())); register(ui.ctx(), "icon:appearance", r); paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); if resp.clicked() { diff --git a/crates/ui-egui/src/tests_empty_library.rs b/crates/ui-egui/src/tests_empty_library.rs new file mode 100644 index 000000000..2207875b9 --- /dev/null +++ b/crates/ui-egui/src/tests_empty_library.rs @@ -0,0 +1,188 @@ +//! LR-BEHAV-EMPTY-STATES: a library with no photos leads to Import. It opens on the Photo Grid +//! with the sidebar, the grid and the Detail view offer Import Photos… and Import Lightroom +//! Catalog… as buttons, and the top bar has an Import button for every library. + +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::empty_library; +use crate::state::ViewMode; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn app(session: lightcraft_engine::Session) -> LightcraftApp { + LightcraftApp::new(session, Services::default()) +} + +/// A picker that records that it was asked and gives no files (the person cancelled). +fn counting_picker(h: &mut Headless) -> Rc> { + let asked = Rc::new(Cell::new(0)); + let seen = asked.clone(); + h.app.services.pick_files = Some(Box::new(move || { + seen.set(seen.get() + 1); + Vec::new() + })); + asked +} + +#[test] +fn an_empty_library_opens_on_the_grid_with_the_sidebar() { + // Given a library with no photos, left in the Detail view with the sidebar hidden + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::Detail; + a.ui.left_panel = false; + // When it opens at launch + empty_library::open_on_empty_library(&mut a); + // Then it shows the Photo Grid and the sidebar, where Import is offered + assert_eq!(a.ui.view, ViewMode::PhotoGrid); + assert!(a.ui.left_panel); +} + +#[test] +fn an_empty_library_left_on_the_square_grid_stays_there() { + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::SquareGrid; + empty_library::open_on_empty_library(&mut a); + assert_eq!(a.ui.view, ViewMode::SquareGrid); + assert!(a.ui.left_panel); +} + +#[test] +fn a_library_with_photos_opens_as_it_was_left() { + let mut a = app(lightcraft_engine::Session::with_demo()); + a.ui.view = ViewMode::Detail; + a.ui.left_panel = false; + empty_library::open_on_empty_library(&mut a); + assert_eq!(a.ui.view, ViewMode::Detail); + assert!(!a.ui.left_panel); +} + +#[test] +fn deleted_photos_leave_the_library_empty() { + let mut s = lightcraft_engine::Session::with_demo(); + assert!(!empty_library::is_empty(&s)); + let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect(); + s.selection.ids = ids.clone(); + s.execute("photo.delete", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + assert!(empty_library::is_empty(&s), "photos in Recently Deleted are not in the library"); +} + +#[test] +fn the_empty_grid_offers_import_as_buttons() { + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::PhotoGrid; + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "button:emptyImportPhotos")); + #[cfg(not(target_arch = "wasm32"))] + assert!(has(&h, "button:emptyImportLightroom")); + // a click asks for the photos, as File ▸ Import Photos… does + let asked = counting_picker(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:emptyImportPhotos"}), T)["ok"], true); + h.step(); + assert_eq!(asked.get(), 1); +} + +#[test] +fn the_empty_detail_view_offers_import_instead_of_the_grid() { + // the dead end the critique found: Detail pointed at a grid with nothing in it + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::Detail; + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "button:emptyImportPhotos")); +} + +#[test] +fn detail_with_photos_but_none_selected_keeps_its_message() { + let mut a = app(lightcraft_engine::Session::with_demo()); + a.ui.view = ViewMode::Detail; + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.app.session.selection.ids.clear(); + h.app.session.selection.active = None; + h.step(); + h.step(); + assert!(h.app.session.active().is_none()); + assert!(!has(&h, "button:emptyImportPhotos"), "a library with photos needs no import prompt"); +} + +#[test] +fn the_top_bar_imports_in_every_library() { + let mut h = Headless::new(app(lightcraft_engine::Session::with_demo()), [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "icon:import")); + let asked = counting_picker(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:import"}), T)["ok"], true); + h.step(); + assert_eq!(asked.get(), 1); +} + +#[test] +fn the_hint_shows_the_shortcut_as_bound() { + let mut keymap = crate::state::AppSettings::default().keymap; + assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⌘⇧I"); + assert_eq!(empty_library::hint(&keymap, false), "or drop photos and folders into this window · Ctrl+Shift+I"); + // a remapped shortcut is the one shown + crate::shortcuts::assign(&mut keymap, "file.addPhotos", Some("Cmd+Alt+I")).unwrap(); + assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⌘⌥I"); + // and none at all leaves the key out + crate::shortcuts::assign(&mut keymap, "file.addPhotos", None).unwrap(); + assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window"); +} + +#[test] +fn a_folder_browsed_under_local_is_not_the_empty_library() { + // its photos aren't in the library, but they are what is on screen: no import prompt + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::Detail; + a.session.source = lightcraft_engine::LibrarySource::Folder; + assert!(empty_library::is_empty(&a.session)); + assert!(!empty_library::shown(&a.session)); + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(!has(&h, "button:emptyImportPhotos")); +} + +#[test] +fn recently_deleted_is_not_the_empty_library() { + // the deleted photos are on screen there: no import prompt beside them + let mut s = lightcraft_engine::Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect(); + s.selection.ids = ids.clone(); + s.execute("photo.delete", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + s.source = lightcraft_engine::LibrarySource::RecentlyDeleted; + assert!(empty_library::is_empty(&s)); + assert!(!empty_library::shown(&s)); +} + +#[test] +fn the_import_button_leaves_the_filter_and_appearance_icons_apart() { + // the search field is centred: a seventh icon on the right must not push it into them + // with the menus in the window (Windows, Linux, web) and in the native menu bar (macOS) + for (width, native_menu) in [900.0, 906.0, 1024.0, 1200.0, 1600.0].into_iter().flat_map(|w| [(w, false), (w, true)]) { + let mut a = app(lightcraft_engine::Session::with_demo()); + // a native menu bar comes with the title bar drawn in the window (macOS): a wider left margin + a.native_menu = native_menu; + a.integrated_titlebar = native_menu; + let mut h = Headless::new(a, [width, 800.0], 1.0); + h.step(); + h.step(); + let rect = |id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} at {width}")); + let (filter, appearance, import) = (rect("icon:filter"), rect("icon:appearance"), rect("icon:import")); + assert!(!filter.intersects(appearance), "{width} (native menu {native_menu}): filter {filter:?} meets appearance {appearance:?}"); + assert!(!filter.intersects(import), "{width} (native menu {native_menu}): filter {filter:?} meets import {import:?}"); + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..bbc33de41 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -470,7 +470,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge, also over a pinned header (a section picked up by the pinned header scrolls once the pointer has left it). A press on a header's button is never a section drag. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before:
\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another | | LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button | | LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed | -| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | +| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`, `empty_state`), `crates/ui-egui/src/panels/empty_library.rs`, `cmd:file.addPhotos`, `cmd:file.importLightroom`, `crates/ui-egui/src/tests_empty_library.rs` | each view says why it is empty (no photo selected, filters, an empty folder). A library with no photos (none, or only deleted or browsed ones) opens on the Photo Grid (Square Grid stays) with the sidebar, and the grid and Detail view (not while a folder is browsed under Local) offer Import Photos… (primary) and Import Lightroom Catalog… (desktop) as buttons, with a line saying photos and folders can be dropped into the window and the key Import Photos… is bound to now; the right-hand panel says to import photos first. The top bar has an Import button beside Export in every library. No drop-target highlight while files are dragged over the window yet, and a drop imports without the review the menu opens | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu | | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..0b76073fb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,13 @@ ## 2026-10-14 +### An empty library leads to Import +- A library with no photos now opens on the Photo Grid with the sidebar (Square Grid stays as it is), instead of a + Detail view that pointed at an empty grid. The grid and Detail view offer Import Photos… and, in the desktop app, + Import Lightroom Catalog… as buttons, and say that photos (and, in the desktop app, folders) can be dropped into + the window. The shortcut they show is the one Import Photos… has now. +- The top bar has an Import button, beside Export, in every library. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 9ef40b98866ca7270fa4621c83028357c7ed79b8 Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Wed, 14 Oct 2026 11:28:23 -0500 Subject: [PATCH 41/52] Commands: Refuse malformed photo ids instead of using the selection (#1506) A wrongly typed id or ids fell back to the selection, or kept only the valid part of the list. Session::targets now returns an error for those, and callers pass it on. --- crates/engine/src/cmd/browse.rs | 4 +-- crates/engine/src/cmd/convert.rs | 8 +++--- crates/engine/src/cmd/cull.rs | 2 +- crates/engine/src/cmd/develop.rs | 15 ++++++----- crates/engine/src/cmd/face_detect.rs | 2 +- crates/engine/src/cmd/face_recognize.rs | 2 +- crates/engine/src/cmd/geocode.rs | 2 +- crates/engine/src/cmd/keywords.rs | 6 ++--- crates/engine/src/cmd/library.rs | 21 +++++++-------- crates/engine/src/cmd/manage.rs | 6 ++--- crates/engine/src/cmd/merge.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/cmd/organize.rs | 14 +++++----- crates/engine/src/cmd/previews.rs | 6 ++--- crates/engine/src/cmd/xmp.rs | 4 +-- crates/engine/src/contact_sheet.rs | 2 +- crates/engine/src/lib.rs | 28 +++++++++++++++----- crates/engine/src/tests.rs | 32 +++++++++++++++++++++++ crates/ui-egui/src/menubar.rs | 2 +- crates/ui-egui/src/menus.rs | 6 ++--- crates/ui-egui/src/merge.rs | 4 +-- crates/ui-egui/src/panels/dialogs.rs | 14 +++++----- crates/ui-egui/src/panels/grid.rs | 4 +-- crates/ui-egui/src/panels/keyword_list.rs | 2 +- crates/ui-egui/src/panels/keywording.rs | 4 +-- crates/ui-egui/src/panels/left.rs | 2 +- crates/ui-egui/src/panels/right.rs | 4 +-- crates/ui-egui/src/shortcuts.rs | 2 +- crates/ui-egui/src/tests_keywording.rs | 2 +- docs/whats-new.md | 5 ++++ 30 files changed, 130 insertions(+), 79 deletions(-) diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index a681ec14b..0b4049a06 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -151,7 +151,7 @@ fn forget_local(s: &mut Session, p: &Value) -> Result { /// The browsed photos among `ids` (default: the selection) become library photos. fn add_to_library(s: &mut Session, p: &Value) -> Result { - let ids: Vec = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect(); + let ids: Vec = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect(); if ids.is_empty() { return Ok(json!({"added": 0})); } @@ -337,7 +337,7 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result { if !dir.is_dir() { return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)"))); } - let ids = s.targets(p); + let ids = s.targets(p)?; if ids.is_empty() { return Err(bad(C, "no photos to move: select some or give ids")); } diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 4d7f13aeb..ef01bf47f 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -58,7 +58,7 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path fn convert(s: &mut Session, p: &Value) -> Result { const C: &str = "photo.convertToDng"; - let ids = s.targets(p); + let ids = s.targets(p)?; let mut ops = Vec::new(); let mut converted = Vec::new(); let mut skipped = Vec::new(); @@ -124,7 +124,7 @@ pub fn specs() -> Vec { /// the library and stack it on top of the original. The host opens the file in the editor. fn edit_external(s: &mut Session, p: &Value) -> Result { const C: &str = "photo.editExternal"; - let mut ids = s.targets(p); + let mut ids = s.targets(p)?; ids.dedup(); if ids.is_empty() { return Err(bad(C, "no active photo")); @@ -274,7 +274,7 @@ pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_cat /// Re-read photos whose files changed on disk (an external editor saved them): new size, /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { - let ids = s.targets(p); + let ids = s.targets(p)?; let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids .iter() .filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) { @@ -320,7 +320,7 @@ fn reload(s: &mut Session, p: &Value) -> Result { fn duplicate(s: &mut Session, p: &Value) -> Result { const C: &str = "photo.duplicate"; let mut made = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue }; let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) }; let src = Path::new(path); diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index 51049797e..676519d29 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -20,7 +20,7 @@ fn secs(iso: &str) -> Option { } fn analyze(s: &mut Session, p: &Value) -> Result { - let ids: Vec = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() }; + let ids: Vec = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() }; let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32); let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false); // measure (thumbnail-level sources: fast, and enough for focus at 512 px) diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 6f064b260..f85998138 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -254,7 +254,7 @@ pub fn specs() -> Vec { }) }), cmd!("develop.reset", "Reset Edits", ["Photo"], Some("Cmd+Shift+R"), "{ids?}", has_selection, |s, p| { - let ids = s.targets(p); + let ids = s.targets(p)?; let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -304,7 +304,7 @@ pub fn specs() -> Vec { let targets = match named_photos(s, p, C)? { Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())), Some(ids) => ids, - None => s.targets(p), + None => s.targets(p)?, }; let ops: Vec = targets .into_iter() @@ -810,7 +810,7 @@ pub fn specs() -> Vec { s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?; let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone()); let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups); - let ids: Vec = s.targets(p).into_iter().filter(|id| *id != prev).collect(); + let ids: Vec = s.targets(p)?.into_iter().filter(|id| *id != prev).collect(); let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -837,7 +837,7 @@ pub fn specs() -> Vec { let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0); clip = lightcraft_develop::extract_groups(&full, &only); } - let ids = s.targets(p); + let ids = s.targets(p)?; let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -889,7 +889,7 @@ pub fn specs() -> Vec { let delta = f64_req(p, "delta", c)?; let label = format!("Quick Develop: {}", spec.label); let ops: Vec = s - .targets(p) + .targets(p)? .into_iter() .filter_map(|id| s.develop_of(id).map(|d| (id, d))) .filter_map(|(id, d)| { @@ -942,7 +942,7 @@ pub fn specs() -> Vec { }; let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?; let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0); - let ids: Vec = s.targets(p).into_iter().filter(|x| *x != reference).collect(); + let ids: Vec = s.targets(p)?.into_iter().filter(|x| *x != reference).collect(); let (mut ops, mut skipped) = (Vec::new(), Vec::new()); for id in ids { let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else { @@ -977,7 +977,8 @@ pub fn specs() -> Vec { let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?; let preset = s.presets.iter().find(|x| x.id == pid).cloned().ok_or_else(|| bad("preset.apply", format!("unknown preset `{pid}`")))?; let amount = f64_or(p, "amount", 100.0).clamp(0.0, 200.0) / 100.0; - let ids = s.targets(p); + // `id` names the preset here: the photos are `ids`, else the selection + let ids = s.targets(&json!({"ids": p.get("ids")}))?; let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) diff --git a/crates/engine/src/cmd/face_detect.rs b/crates/engine/src/cmd/face_detect.rs index 4676d1c65..2328a3db4 100644 --- a/crates/engine/src/cmd/face_detect.rs +++ b/crates/engine/src/cmd/face_detect.rs @@ -132,7 +132,7 @@ fn detect(s: &mut Session, p: &Value) -> Result { let detector = detector(s).map_err(|e| e.into_error(C))?; let started = Instant::now(); let (mut results, mut ops) = (Vec::new(), Vec::new()); - for id in s.targets(p) { + for id in s.targets(p)? { let (rgb, width, height) = match render_rgb(s, id, LOOK_EDGE) { Ok(r) => r, Err(e) => { diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index e099f13ec..a30776b51 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -751,7 +751,7 @@ mod imp { const C: &str = "faces.suggest"; let embedder = current(s, C)?; collect(s); - let targets = s.targets(p); + let targets = s.targets(p)?; let model_threshold = embedder.manifest().thresholds.match_cosine; let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v as f32); let threshold = num("threshold").or(model_threshold).unwrap_or(DEFAULT_THRESHOLD); diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs index ddbe6b13a..085183d58 100644 --- a/crates/engine/src/cmd/geocode.rs +++ b/crates/engine/src/cmd/geocode.rs @@ -115,7 +115,7 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place, fn reverse(s: &mut Session, p: &Value) -> Result { const C: &str = "metadata.reverseGeocode"; let overwrite = bool_or(p, "overwrite", false); - let ids = s.targets(p); + let ids = s.targets(p)?; let todo: Vec<(PhotoId, LatLon)> = ids .iter() .filter_map(|id| { diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index b2c7e90f0..fe09a92f9 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -256,7 +256,7 @@ pub fn specs() -> Vec { let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else { return Ok(json!({"changed": 0})); }; - let ids = s.targets(p); + let ids = s.targets(p)?; let all = !ids.is_empty() && ids.iter().all(|id| { s.catalog @@ -288,7 +288,7 @@ pub fn specs() -> Vec { always, |s, p| { let mut current: Vec = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { if let Some(ph) = s.catalog.photo(id) { current.extend(ph.meta.keywords.iter().cloned()); } @@ -336,7 +336,7 @@ pub fn specs() -> Vec { Some(parent) => format!("{parent}|{name}"), None => name, }); - let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() }; + let photos = if bool_or(p, "addToSelected", false) { s.targets(p)? } else { Vec::new() }; let op = s .catalog .create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 3a5575ea8..1908002cf 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -169,7 +169,7 @@ fn album_targets_or_err(s: &Session, command: &str, targets: Vec) -> Re /// Apply one op per target as a single undo step. fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option) -> Result { - let targets = s.targets(p); + let targets = s.targets(p)?; let ops: Vec = targets.iter().filter_map(|id| f(*id)).collect(); let n = ops.len(); if n > 0 { @@ -181,7 +181,7 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O /// Adjust each target's own rating; unchanged bounds do not create an undo step. fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result { let mut ops = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating; let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8; if rating != old { @@ -226,7 +226,7 @@ fn row_step(p: &Value) -> isize { } fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result { - let targets = s.targets(p); + let targets = s.targets(p)?; let mut ops = Vec::new(); for id in targets { if let Some(d) = s.develop_of(id) { @@ -241,7 +241,7 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result { } fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result { - let targets = s.targets(p); + let targets = s.targets(p)?; let mut ops = Vec::new(); for id in targets { if let Some(d) = s.develop_of(id) { @@ -807,7 +807,7 @@ pub fn specs() -> Vec { ), cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { let before = s.visible_cloned(); - let t = s.targets(p); + let t = s.targets(p)?; let op = s.catalog.delete_photos_permanently_ops(&t); s.commit("Delete Permanently", op)?; select_after_deletion(s, &before); @@ -824,7 +824,7 @@ pub fn specs() -> Vec { |s, p| { let strs = |k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::>()); - let targets = s.targets(p); + let targets = s.targets(p)?; // GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear let gps: Option> = match p.get("gps") { None => None, @@ -922,8 +922,7 @@ pub fn specs() -> Vec { } let folder = bool_or(p, "folder", false); let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); - let (photos, skipped) = - if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) }; + let (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) }; let id = s.catalog.alloc_album_id(); let cover = photos.first().copied(); s.commit( @@ -1163,7 +1162,7 @@ pub fn specs() -> Vec { has_library_selection, |s, p| { let id = album_param(p, "id", "album.addPhotos")?; - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); + let targets = ids_param(p).unwrap_or_else(|| s.selected()); let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; if al.is_smart() || al.folder { return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); @@ -1190,7 +1189,7 @@ pub fn specs() -> Vec { "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped", has_library_selection, |s, p| { - let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?; + let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.selected()))?; let (targets, skipped) = targets; let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)); let id = match target.or_else(|| s.catalog.quick_collection()) { @@ -1336,7 +1335,7 @@ pub fn specs() -> Vec { ), cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| { let id = album_param(p, "id", "album.removePhotos")?; - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); + let targets = ids_param(p).unwrap_or_else(|| s.selected()); let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; if al.is_smart() { return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index d06271525..66ddfe9fb 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -10,7 +10,7 @@ use crate::Result; fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec, String, usize)> { let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string(); let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize; - Ok((s.targets(p), template, start)) + Ok((s.targets(p)?, template, start)) } /// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name). @@ -96,7 +96,7 @@ fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result { let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0); let mut ops = Vec::new(); let mut photos = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { let Some(ph) = s.catalog.photo(id) else { continue }; if ph.meta.gps.is_some() && !replace { has_gps += 1; @@ -183,7 +183,7 @@ pub fn specs() -> Vec { |s, p| { use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso}; let c = "photo.setCaptureTime"; - let targets: Vec<_> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect(); + let targets: Vec<_> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect(); if targets.is_empty() { return Err(bad(c, "no photos")); } diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index a8869153a..6fcbad27f 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -20,7 +20,7 @@ fn can_merge(s: &Session) -> std::result::Result<(), String> { fn run(s: &mut Session, id: &str, p: &Value) -> Result { let (kind, finish) = crate::merge::parse(id, p)?; - let ids: Vec = s.targets(p); + let ids: Vec = s.targets(p)?; if !bool_or(p, "preview", false) { return s.merge_now(kind, finish, &ids); } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4de460761..f09ff2d78 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -105,7 +105,7 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> { /// can take (issue #684; Add to My Photos first). pub fn has_library_selection(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; - if s.targets(&serde_json::Value::Null).iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) } + if s.selected().iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) } } /// Why an album command refused a Local-only selection. pub const LOCAL_ONLY: &str = "only Local photos: albums hold library photos (Add to My Photos first)"; diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs index 494785628..24cc2061c 100644 --- a/crates/engine/src/cmd/organize.rs +++ b/crates/engine/src/cmd/organize.rs @@ -22,7 +22,7 @@ fn stacks_of(s: &Session, targets: &[PhotoId]) -> Vec { fn in_stack(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; - if stacks_of(s, &s.targets(&Value::Null)).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) } + if stacks_of(s, &s.selected()).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) } } fn has_stacks(s: &Session) -> std::result::Result<(), String> { @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"", has_selection, |s, p| { - let targets = s.targets(p); + let targets = s.targets(p)?; let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string); let mut made = Vec::new(); for t in targets { @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "{ids?, top?: photoId (default: the active photo), collapsed?: bool (default true)} — stacks the photos are already in are merged", has_selection, |s, p| { - let targets = s.targets(p); + let targets = s.targets(p)?; let top = p .get("top") .and_then(Value::as_u64) @@ -153,11 +153,11 @@ pub fn specs() -> Vec { } ), cmd!("stack.ungroup", "Ungroup Stack", ["Photo", "Stack"], Some("Cmd+Shift+G"), "{ids?}", in_stack, |s, p| { - let ops = s.catalog.ungroup_ops(&s.targets(p)); + let ops = s.catalog.ungroup_ops(&s.targets(p)?); Ok(json!({"ungrouped": commit_ops(s, "Ungroup Stack", ops)?})) }), cmd!("stack.remove", "Remove from Stack", ["Photo", "Stack"], None, "{ids?}", in_stack, |s, p| { - let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)); + let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)?); Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?})) }), cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| { @@ -189,7 +189,7 @@ pub fn specs() -> Vec { } ), cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| { - let stacks = stacks_of(s, &s.targets(p)); + let stacks = stacks_of(s, &s.targets(p)?); let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false); let collapsed = bool_or(p, "collapsed", !first); set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" }) @@ -212,7 +212,7 @@ pub fn specs() -> Vec { |s, p| { let gap = f64_or(p, "gap", 60.0).max(0.0); let explicit = p.get("ids").is_some(); - let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() }; + let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() }; let groups = s.catalog.auto_stack_groups(&pool, gap); let photos: usize = groups.iter().map(Vec::len).sum(); if bool_or(p, "preview", false) { diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 4fc7ababa..b63ccc2b4 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -152,7 +152,7 @@ fn build(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "a preview build is already running")); } // explicit ids, else the selection, else everything in view - let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; + let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() }; let jobs: Vec> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect(); let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() }); s.preview_build = Some(state.clone()); @@ -324,7 +324,7 @@ fn smart(s: &mut Session, p: &Value) -> Result { if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) { return Err(bad(C, "a preview build is already running")); } - let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; + let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() }; let mut jobs: Vec = Vec::new(); for id in ids { let Some(ph) = s.catalog.photo(id).cloned() else { continue }; @@ -503,7 +503,7 @@ pub fn specs() -> Vec { smart_location ), cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| { - let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?; + let id = s.targets(p)?.first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?; let online = match s.catalog.photo(id).map(|p| p.source.clone()) { Some(lightcraft_catalog::Source::File { path }) => std::path::Path::new(&path).exists(), _ => true, diff --git a/crates/engine/src/cmd/xmp.rs b/crates/engine/src/cmd/xmp.rs index cd89aa896..f163e499a 100644 --- a/crates/engine/src/cmd/xmp.rs +++ b/crates/engine/src/cmd/xmp.rs @@ -12,7 +12,7 @@ fn save(s: &mut Session, p: &Value) -> Result { let mut merged = Vec::new(); let mut backups = Vec::new(); let mut failed = Vec::new(); - let ids = s.targets(p); + let ids = s.targets(p)?; let owners = crate::sidecar::StemOwners::among(&s.catalog, &ids); for id in ids { match s.save_sidecar_with(id, &owners) { @@ -37,7 +37,7 @@ fn read(s: &mut Session, p: &Value) -> Result { let mut read = Vec::new(); let mut failed = Vec::new(); let mut warnings = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { match s.read_sidecar_op_with_warnings(id) { Ok(Some((op, from, mut photo_warnings))) => { ops.push(op); diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs index e83ad8c67..320323fa3 100644 --- a/crates/engine/src/contact_sheet.rs +++ b/crates/engine/src/contact_sheet.rs @@ -82,7 +82,7 @@ pub fn prepare(session: &mut Session, p: &Value) -> Result { if p.get("id").is_some_and(|v| v.as_u64().is_none()) { return Err("id must be a photo id".into()); } - let ids = session.targets(p); + let ids = session.targets(p).map_err(|e| e.to_string())?; if ids.is_empty() || ids.len() > 1000 { return Err("select between 1 and 1000 photos".into()); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..b8f9e7857 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -427,7 +427,11 @@ impl Session { } let empty = Value::Object(Default::default()); let params = if params.is_null() { &empty } else { params }; - self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params)) + self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params)).map_err(|e| match e { + // a shared parameter check ([`Session::targets`]) names the command it ran for + EngineError::BadParams { cmd, msg } if cmd.is_empty() => EngineError::BadParams { cmd: id.to_string(), msg }, + e => e, + }) } /// Run `f` as command `id` with what [`Session::execute`] does around every command: the @@ -963,14 +967,24 @@ impl Session { (self.visible_gen, self.visible.clone()) } - /// Targets of photo commands: explicit `ids`/`id` param, else the selection. - pub fn targets(&self, p: &Value) -> Vec { - if let Some(a) = p.get("ids").and_then(Value::as_array) { - return a.iter().filter_map(Value::as_u64).map(PhotoId).collect(); + /// Targets of photo commands: the explicit `ids` (photo ids) or `id` (a photo id) param, else [`Self::selected`]. + /// An `ids` or `id` that is there but isn't photo ids is an error, never the selection or part of the list + /// (issue #1506): a mistyped target mustn't turn a command meant for one photo into one for every selected photo. + pub fn targets(&self, p: &Value) -> Result> { + let invalid = |what: &str| EngineError::BadParams { cmd: String::new(), msg: format!("`{what}` must be photo ids (whole numbers)") }; + match p.get("ids") { + None | Some(Value::Null) => {} + Some(Value::Array(a)) => return a.iter().map(|v| v.as_u64().map(PhotoId).ok_or_else(|| invalid("ids"))).collect(), + Some(_) => return Err(invalid("ids")), } - if let Some(id) = p.get("id").and_then(Value::as_u64) { - return vec![PhotoId(id)]; + match p.get("id") { + None | Some(Value::Null) => Ok(self.selected()), + Some(v) => v.as_u64().map(|id| vec![PhotoId(id)]).ok_or_else(|| invalid("id")), } + } + + /// The photos commands act on when a call names none: the selection, else the active photo. + pub fn selected(&self) -> Vec { if self.selection.ids.is_empty() { self.selection.active.into_iter().collect() } else { self.selection.ids.clone() } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4e5e73850..dc586be0f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1486,3 +1486,35 @@ fn oversized_spots_render() { } } } + +/// A call that names its photos with a malformed `id` or `ids` (a string, a mixed list) is refused and changes +/// nothing; it used to act on the selection, or on the valid part of the list (issue #1506). Leaving the targets +/// out still means the selection. +#[test] +fn malformed_photo_targets_are_refused_not_widened() { + use lightcraft_catalog::PhotoId; + let mut s = Session::with_demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + s.execute("library.select", &json!({"ids": [ids[0], ids[1]]})).unwrap(); + let rating = |s: &Session, i: u64| s.catalog.photo(PhotoId(i)).unwrap().rating; + let before: Vec = ids.iter().map(|i| rating(&s, *i)).collect(); + for bad in [ + json!({"rating": 5, "id": "5"}), + json!({"rating": 5, "ids": [ids[2], "x"]}), + json!({"rating": 5, "ids": ids[2].to_string()}), + json!({"rating": 5, "id": -1}), + ] { + let err = s.execute("photo.rate", &bad).unwrap_err(); + assert!(matches!(&err, crate::EngineError::BadParams { cmd, .. } if cmd == "photo.rate"), "{bad}: {err}"); + assert_eq!(ids.iter().map(|i| rating(&s, *i)).collect::>(), before, "{bad} changed a rating"); + } + let snapshot = s.catalog.to_snapshot(); + assert!(s.execute("photo.deletePermanently", &json!({"id": "5"})).is_err()); + assert_eq!(s.catalog.to_snapshot(), snapshot, "nothing deleted"); + // no targets, or a null one: the selection + s.execute("photo.rate", &json!({"rating": 4, "id": null})).unwrap(); + assert_eq!((rating(&s, ids[0]), rating(&s, ids[1]), rating(&s, ids[2])), (4, 4, before[2])); + // a preset's id is not a photo target + let preset = s.presets.first().unwrap().id.clone(); + s.execute("preset.apply", &json!({"id": preset})).unwrap(); +} diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index fe6905cad..ae0b429ff 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -644,7 +644,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v } "Add to Album" => { - let targets = app.session.targets(&Value::Null); + let targets = app.session.selected(); let sel = !targets.is_empty(); // albums hold library photos (issue #684): a Local-only selection is told what to do first let local_only = crate::panels::grid::local_only(app, &targets); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ad31c57bf..ba7607e05 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1932,7 +1932,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "app.clearRecentLibraries" => !app.ui.settings.recent_libraries.is_empty(), "compare.swap" | "compare.makeSelect" | "compare.set" => app.ui.view == ViewMode::Compare, s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => { - app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none() + app.session.selected().len() >= 2 && app.merge.final_task.is_none() } _ => true, } @@ -1979,7 +1979,7 @@ pub fn confirm_delete(app: &mut LightcraftApp) -> bool { if !app.ui.settings.confirm_delete { return false; } - let count = app.session.targets(&json!({})).len(); + let count = app.session.selected().len(); if count == 0 { return false; } @@ -2097,7 +2097,7 @@ fn open_original_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value) -> } fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result { - let mut ids = app.session.targets(p); + let mut ids = app.session.targets(p).map_err(|e| e.to_string())?; ids.dedup(); if ids.is_empty() { return Err("no photo selected".into()); diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index a603692fa..05e7baa8e 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -148,7 +148,7 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b /// Open the merge dialog for the selection. pub fn open(app: &mut LightcraftApp, command: &str) -> Result { - let ids = app.session.targets(&json!({})); + let ids = app.session.selected(); if ids.len() < 2 { return Err("select at least 2 photos to merge".into()); } @@ -185,7 +185,7 @@ pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result Result { - let ids = app.session.targets(&json!({})); + let ids = app.session.selected(); if ids.len() < 2 { return Err("select at least 2 photos to merge".into()); } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a79261fdc..08b05e4bf 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -22,7 +22,7 @@ pub(crate) fn rename_preview( ) -> (Option>, usize) { use std::hash::{Hash, Hasher}; type Rows = std::sync::Arc>>>; - let ids = app.session.targets(&json!({})); + let ids = app.session.selected(); let photos = lightcraft_engine::rename::rename_photos(&app.session.catalog, &ids); let total = photos.len(); let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -71,7 +71,7 @@ fn watermark_preview( type Base = std::sync::Arc>)>>; type Shown = (u64, u8, lightcraft_engine::export::Watermark, usize, egui::TextureHandle); let t = Tokens::get(ui.ctx()); - let Some(&id) = app.session.targets(&json!({})).first() else { return }; + let Some(&id) = app.session.selected().first() else { return }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; let (out_w, out_h) = lightcraft_engine::export::output_size(&photo, &lightcraft_engine::export::ExportOptions { resize, ..Default::default() }); let export_short = out_w.min(out_h); @@ -439,7 +439,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ); } Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); field(ui, "Change", |ui| { ui.spacing_mut().item_spacing.x = 4.0; for (i, (label, m)) in [("Set date & time", "set"), ("Shift by", "shift"), ("Time zone", "zone")].iter().enumerate() { @@ -523,7 +523,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ); } Dialog::Rename { template, start } => { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); let template_id = egui::Id::new("rename-template"); let tags_open = field(ui, "Template", |ui| { @@ -614,7 +614,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } // as in Lightroom, the option is only there when something is selected - if !*folder && !app.session.targets(&json!({})).is_empty() { + if !*folder && !app.session.selected().is_empty() { ui.add_space(6.0); let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos")); crate::widgets::register(ui.ctx(), "check:albumIncludeSelected", r.rect); @@ -633,7 +633,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), Dialog::PasteSettings { groups } => { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); ui.label( egui::RichText::new(crate::i18n::tr_format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" }, n = n)) .color(t.text_dim), @@ -643,7 +643,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::ContactSheet { options } => { options.columns = options.columns.clamp(1, 8); options.rows = options.rows.clamp(1, 10); - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); ui.label(format!("{n} selected photos · {} pages", n.div_ceil(options.columns * options.rows))); field(ui, "Paper", |ui| { egui::ComboBox::from_id_salt("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 52edfa460..76952b5ce 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1040,7 +1040,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); - if local_only(app, &app.session.targets(&serde_json::Value::Null)) { + if local_only(app, &app.session.selected()) { // albums hold library photos (issue #684): nothing here to add until they join My Photos let add = ui.add_enabled(false, egui::Button::new(crate::i18n::tr("Add to Album"))).on_disabled_hover_text(albums_hint()); register(ui.ctx(), "button:contextAddToAlbum", add.rect); @@ -1146,7 +1146,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("develop.reset", json!({})); } ui.menu_button(crate::i18n::tr("Photo Merge"), |ui| { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); for (id, label) in [("dialog.mergeHdr", "HDR…"), ("dialog.mergePanorama", "Panorama…"), ("dialog.mergeHdrPanorama", "HDR Panorama…")] { if ui.add_enabled(n >= 2, egui::Button::new(crate::i18n::tr(label))).clicked() { let _ = app.run(id, json!({})); diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 2d691112a..57ffb3549 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -72,7 +72,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.label(egui::RichText::new(crate::i18n::tr(none)).color(t.text_dim)); }); } - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let ticks = app.caches.keyword_ticks(&app.session.catalog, &selection); for r in &rows { row(app, ui, r, &selection, &ticks, filter.trim().is_empty()); diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 857498599..6a0f8b493 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -30,7 +30,7 @@ pub fn view_switch(app: &mut LightcraftApp, ui: &mut egui::Ui) { /// exported files will carry. One only some of the photos have is marked with an asterisk. pub fn names_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let export = app.ui.keywording_view == crate::state::KeywordingView::WillExport; let names = app.caches.keyword_names(&app.session.catalog, &selection, export); if names.is_empty() { @@ -120,7 +120,7 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { /// The selection's keywords as chips: the name (right-click for its menu), and × to take it off /// every selected photo. One only some of them have is marked with an asterisk. pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let chips = app.caches.keyword_chips(&app.session.catalog, &selection); ui.horizontal_wrapped(|ui| { for c in chips.iter() { diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index fcaab2926..5e19661fc 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1571,7 +1571,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { ui.separator(); } if !a.folder && !a.is_smart() { - let local_only = super::grid::local_only(app, &app.session.targets(&serde_json::Value::Null)); + let local_only = super::grid::local_only(app, &app.session.selected()); let b = ui .add_enabled(!local_only, egui::Button::new(crate::i18n::tr("Add Selected Photos"))) .on_disabled_hover_text(super::grid::albums_hint()); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..60f1aa3cc 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -876,7 +876,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let typed = ui.data(|d| d.get_temp::(kid).unwrap_or_default()); let last = typed.rsplit(',').next().unwrap_or("").trim().to_string(); // the keywords every selected photo has: those only some have are still suggested - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let have: Vec = app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect(); let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, &last, 12)).clone(); @@ -904,7 +904,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { /// selected photos; "Save as Set…" keeps the current nine under a name. fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { // on: every selected photo has it (⌥1–⌥9 then take it off them); partly on: only some do - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let chips = app.caches.keyword_chips(&app.session.catalog, &selection); let t = Tokens::get(ui.ctx()); let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index b10387c76..c88d01683 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -491,7 +491,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { // B: the brush while editing; in the grids, add to the target album (Quick Collection) if f == "tool.brush" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { if let Ok(r) = app.run("album.toggleTarget", json!({})) { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); let name = r["name"].as_str().unwrap_or("Quick Collection").to_string(); app.toast( diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index a130b86b2..acf2c1d54 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -191,7 +191,7 @@ fn the_active_photo_alone_is_the_selection() { assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(ids[0])); assert!(h.app.session.selection.ids.is_empty()); assert!(has(&h, "keywordChip:Weddings") && !has(&h, "keywordChipPartial:Weddings"), "the active photo's keywords"); - let ticks = crate::panels::keyword_list::Ticks::of(&h.app.session.catalog, &h.app.session.targets(&json!({}))); + let ticks = crate::panels::keyword_list::Ticks::of(&h.app.session.catalog, &h.app.session.selected()); assert_eq!(ticks.tick("Weddings"), crate::panels::keyword_list::Tick::All); assert!(has(&h, "keywordCheck:beach")); } diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..0ba1a14cb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Reliability +- A command given photos it can't read as photo ids (`"id": "5"`, or a list with something else in it) from a + script, the control channel or MCP is now refused (issue #1506). It used to act on the selected photos instead, + or on the part of the list it could read, so a mistyped id could rate, move or delete the wrong photos. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From f50372d80887d1db6aad8c05fdcdc992e35dbb06 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 14 Oct 2026 16:55:38 -0400 Subject: [PATCH 42/52] Dialogs: the confirm button is on the right, filled when it is the next step A design critique of the first run found the import review's "Import N Photos" a plain button at the bottom left beside Cancel, though the welcome before it had taught that blue means go. - Every dialog's footer is right-aligned: the confirm on the right, Cancel to its left, added in that order so the keyboard goes through them as the eye does. The row spans the width the window gives its content this frame, so it reaches the window's edge without widening it. - The confirm is filled with the accent when it makes or brings in something (`confirm_style`); Delete and Remove, and an informational dialog's Close, stay plain. Which dialogs only show something is one list (`only_shows`). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/dialogs.rs | 78 ++++++++++++++++++---- crates/ui-egui/src/tests_dialog_primary.rs | 74 ++++++++++++++++++++ docs/whats-new.md | 5 ++ 4 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/src/tests_dialog_primary.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..544724671 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,6 +63,8 @@ mod tests_date_picker; #[cfg(test)] mod tests_dialog_fit; #[cfg(test)] +mod tests_dialog_primary; +#[cfg(test)] mod tests_dialogs; #[cfg(test)] mod tests_error_kinds; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a79261fdc..c91266a26 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1194,15 +1194,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), }); ui.add_space(4.0); - ui.horizontal(|ui| { + // the confirm on the right with Cancel to its left (macOS and GNOME's order), filled with + // the accent when it is the dialog's primary action. The row spans the width the window + // gives its content this frame (its default width, or its content's if wider), so it + // reaches the edge without widening the window. + let content_w = ui.available_width().max(ui.min_rect().width()).max(1.0); + let row_h = ui.spacing().interact_size.y; + ui.allocate_ui_with_layout(egui::vec2(content_w, row_h), egui::Layout::left_to_right(egui::Align::Center), |ui| { // a model file LightCraft cannot use has nothing to confirm, nor has a dialog that // only shows something (one Close, no Cancel / OK) let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported"); - let informational = unusable_model - || matches!( - dlg, - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew - ); + let informational = unusable_model || only_shows(&dlg); let sam = &app.session.segmenter; let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some()); // no download location in this build: nothing to offer but the manual install @@ -1212,13 +1214,6 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::SamModel { .. } => "Not Now", _ => "Cancel", }; - if !informational { - let r = ui.button(crate::i18n::tr(cancel)); - crate::widgets::register(ui.ctx(), "button:dialogCancel", r.rect); - if r.clicked() { - close = true; - } - } let add_label; let ok = match &dlg { Dialog::Import { opts } => { @@ -1258,7 +1253,37 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ) // smart-album rules that can't mean anything wait to be fixed && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()); - let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok)))); + let primary = !informational && can_confirm && confirm_style(&dlg) == ConfirmStyle::Primary; + // (Cancel first, then the confirm: the keyboard goes through them as the eye does) + let width_of = |label: &str| { + let text = egui::WidgetText::from(crate::i18n::tr(label)) + .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button) + .size() + .x; + (text + 2.0 * ui.spacing().button_padding.x).max(ui.spacing().interact_size.x) + }; + let gap = ui.spacing().item_spacing.x; + let mut buttons_w = 0.0; + if !informational { + buttons_w += width_of(cancel) + gap; + } + if !ok.is_empty() { + buttons_w += width_of(ok); + } + ui.add_space((ui.available_width() - buttons_w).max(0.0)); + if !informational { + let r = ui.button(crate::i18n::tr(cancel)); + crate::widgets::register(ui.ctx(), "button:dialogCancel", r.rect); + if r.clicked() { + close = true; + } + } + let button = if primary { + egui::Button::new(egui::RichText::new(crate::i18n::tr(ok)).color(egui::Color32::WHITE)).fill(t.accent) + } else { + egui::Button::new(crate::i18n::tr(ok)) + }; + let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, button)); if let Some(r) = &r { crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect); } @@ -1975,3 +2000,28 @@ mod watermark_position_tests { } } } + +/// How a dialog's confirm button looks: [`ConfirmStyle::Primary`] (the accent's fill) when it +/// makes or brings in something, plain when it deletes or removes (never the eye-catching +/// default) or when the dialog only shows something (its Close). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfirmStyle { + Primary, + Plain, +} + +pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle { + match dlg { + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::RemoveFolder { .. } => ConfirmStyle::Plain, + _ if only_shows(dlg) => ConfirmStyle::Plain, + _ => ConfirmStyle::Primary, + } +} + +/// A dialog that only shows something: one Close, nothing to confirm or cancel. +fn only_shows(dlg: &Dialog) -> bool { + matches!( + dlg, + Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew + ) +} diff --git a/crates/ui-egui/src/tests_dialog_primary.rs b/crates/ui-egui/src/tests_dialog_primary.rs new file mode 100644 index 000000000..29078d009 --- /dev/null +++ b/crates/ui-egui/src/tests_dialog_primary.rs @@ -0,0 +1,74 @@ +//! A dialog's confirm button reads as its primary action: on the right, Cancel to its left, and +//! filled with the accent when it makes or brings in something (Import, Merge, Save…). A +//! destructive confirm (Delete, Remove) stays plain: never the eye-catching default. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::dialogs::{ConfirmStyle, confirm_style}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} not on screen")) +} + +fn png(dir: &std::path::Path, name: &str) -> String { + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 120, 160, 255]; 64] }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let p = dir.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().to_string() +} + +#[test] +fn the_import_review_puts_its_confirm_on_the_right_as_the_primary_action() { + // Given the import review of two photos + let dir = std::env::temp_dir().join(format!("lc-ui-dialog-primary-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let paths = [png(&dir, "a.png"), png(&dir, "b.png")]; + let app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.step(); + h.app.run("file.addPhotos", json!({"paths": paths})).unwrap(); + h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| w == "button:dialogOk")); + h.step(); + // Then "Import 2 Photos" is right of Cancel, at the dialog's right edge + let (ok, cancel, window) = (rect(&h, "button:dialogOk"), rect(&h, "button:dialogCancel"), rect(&h, "dialog:window")); + assert!(ok.left() > cancel.right(), "confirm {ok:?} right of cancel {cancel:?}"); + assert!(window.right() - ok.right() < 40.0, "confirm {ok:?} at the right edge of {window:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn constructive_confirms_are_primary_and_destructive_ones_plain() { + use crate::state::Dialog; + assert_eq!(confirm_style(&Dialog::AutoStack { gap: 60.0 }), ConfirmStyle::Primary); + assert_eq!(confirm_style(&Dialog::ConfirmDelete { count: 3 }), ConfirmStyle::Plain); + // a dialog that only shows something has nothing to confirm: its Close is plain + assert_eq!(confirm_style(&Dialog::About), ConfirmStyle::Plain); +} + +#[test] +fn a_dialog_wider_than_its_content_still_ends_its_footer_at_its_edge() { + // Auto-Stack's title makes it wider than its slider and note: the buttons go to the edge, and + // the window doesn't grow for them + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.step(); + h.app.run("dialog.autoStack", json!({})).unwrap(); + for _ in 0..4 { + h.step(); + } + let (ok, window) = (rect(&h, "button:dialogOk"), rect(&h, "dialog:window")); + assert!(window.right() - ok.right() < 24.0, "confirm {ok:?} at the right edge of {window:?}"); + let width = window.width(); + for _ in 0..4 { + h.step(); + } + assert_eq!(rect(&h, "dialog:window").width(), width, "the footer doesn't widen the window"); +} diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..d8611d7a6 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### A dialog's confirm button says it's the next step +- In dialogs, the confirm button is on the right with Cancel to its left, and filled with the accent when it makes or + brings in something (Import N Photos, Merge, Save, Create, Download…). Delete and Remove stay plain, so a destructive + choice is never the eye-catching default. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From d0a9ca09dd0c4d146aae3a12a71c2babd42c5f6f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 14 Oct 2026 17:37:54 -0400 Subject: [PATCH 43/52] Buttons: one component with kinds, sizes and icons Buttons were egui's plain one, a fill set by hand where one had to stand out (a dialog's confirm, About's Discord link), with no hover or press shade on a filled one and no shared sizes. - `crate::button::Button`: what it is for (`Kind`: Primary filled with the accent, Secondary with a neutral fill and a border, Danger filled red, Ghost with no fill until hovered), how big (`Size`: Small 24, Medium 30, Large 36 points, each with its padding, text and icon size), and what it shows (text, an icon before the text, or an icon alone, which takes a name for screen readers and its tooltip). - Hover and press darken it; disabled (or in a disabled part of the window) fades it and takes no click, its tooltip or name still shown; a focused one shows an accent ring. A filled kind's fill is darkened as little as needed for white to read at 4.5:1 (the light themes' accent and the red would be too pale), in every state. Drawn within its own rectangle. - Named for screen readers, registered for scripts and tests (`.widget(id)`). Its egui id comes from the widget id (salted with `.id_salt`, e.g. the dialog's kind, so the keyboard's place doesn't carry from one dialog to the next) or from its place in the ui, moved on so buttons with the same text side by side stay apart. - Dialogs' footers (Cancel, the confirm; at least 72 points wide) and About's Discord link use it. The Export dialog's expected height in `tests_dialog_fit` grows by the 8 points of its footer's 30-point buttons. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/button.rs | 389 +++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/dialogs.rs | 52 ++-- crates/ui-egui/src/tests_button.rs | 159 ++++++++++ crates/ui-egui/src/tests_dialog_fit.rs | 5 +- docs/whats-new.md | 6 + 6 files changed, 583 insertions(+), 31 deletions(-) create mode 100644 crates/ui-egui/src/button.rs create mode 100644 crates/ui-egui/src/tests_button.rs diff --git a/crates/ui-egui/src/button.rs b/crates/ui-egui/src/button.rs new file mode 100644 index 000000000..f46156ed9 --- /dev/null +++ b/crates/ui-egui/src/button.rs @@ -0,0 +1,389 @@ +//! One button for the whole interface: what it is for ([`Kind`]: Primary, Secondary, Danger, +//! Ghost), how big ([`Size`]: Small, Medium, Large), and what it shows (text, an icon and text, +//! or an icon alone, which then needs a name for screen readers). It looks the same everywhere, +//! answers hover, press and the keyboard the same way, and its text always reads at WCAG AA on +//! its fill. +//! +//! ```ignore +//! if Button::new("Import Photos…").primary().widget("button:emptyImportPhotos").show(ui).clicked() { … } +//! Button::icon_only(Icon::Trash, "Delete mask").ghost().small().show(ui); +//! Button::new("Remove").icon(Icon::Trash).danger().show(ui); +//! ``` + +use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; + +use crate::icons::{Icon, paint}; +use crate::theme::Tokens; + +/// What a button is for, which decides how loud it is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Kind { + /// The step to take: filled with the accent. One per place (a dialog's confirm, the welcome's + /// Import Photos…). + Primary, + /// Any other action: a neutral fill with a border. + #[default] + Secondary, + /// A step that removes or erases something, when it is the one being asked for: filled red. + Danger, + /// A quiet action among content (a toolbar, a banner's Hide): no fill until hovered. + Ghost, +} + +/// How big a button is: its height, padding, text and icon size. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Size { + /// 24 points: in toolbars, banners and dense rows. + Small, + /// 30 points: dialogs and panels. + #[default] + Medium, + /// 36 points: a page's one main action (the welcome, About's Discord link). + Large, +} + +impl Size { + pub const fn height(self) -> f32 { + match self { + Size::Small => 24.0, + Size::Medium => 30.0, + Size::Large => 36.0, + } + } + fn padding(self) -> f32 { + match self { + Size::Small => 8.0, + Size::Medium => 12.0, + Size::Large => 16.0, + } + } + fn text(self) -> f32 { + match self { + Size::Small => 12.0, + Size::Medium => 13.0, + Size::Large => 14.0, + } + } + fn icon(self) -> f32 { + match self { + Size::Small => 14.0, + Size::Medium => 16.0, + Size::Large => 18.0, + } + } + fn gap(self) -> f32 { + match self { + Size::Small => 4.0, + Size::Medium => 6.0, + Size::Large => 8.0, + } + } + fn radius(self) -> f32 { + match self { + Size::Small => 4.0, + Size::Medium => 5.0, + Size::Large => 6.0, + } + } +} + +/// Where the pointer and the keyboard are, for the colours. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Idle, + Hovered, + Pressed, + Disabled, +} + +/// The colours a button of `kind` takes in `state`: fill (if any), border (if any), and what is +/// drawn on it (text and icon). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Look { + pub fill: Option, + pub border: Option, + pub content: Color32, +} + +/// WCAG 2 contrast of two opaque colours. +pub fn contrast(a: Color32, b: Color32) -> f32 { + let lum = |c: Color32| { + let ch = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.03928 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * ch(c.r()) + 0.7152 * ch(c.g()) + 0.0722 * ch(c.b()) + }; + let (x, y) = (lum(a), lum(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) +} + +/// `c` toward black by `amount` (0 to 1). +fn darker(c: Color32, amount: f32) -> Color32 { + crate::widgets::lerp(c, Color32::BLACK, amount) +} + +/// `fill`, darkened as little as needed for white to read on it at 4.5:1 (a light theme's or a +/// bright red's fill would otherwise be too pale for white text). +fn fill_for_white(fill: Color32) -> Color32 { + let mut f = fill; + for _ in 0..20 { + if contrast(Color32::WHITE, f) >= 4.5 { + break; + } + f = darker(f, 0.06); + } + f +} + +/// The colours of a `kind` button in `state` with the theme `t`. +pub fn look(t: &Tokens, kind: Kind, state: State) -> Look { + let filled = |base: Color32| { + let base = fill_for_white(base); + // hover and press go darker: white on them only reads better + let fill = match state { + State::Idle | State::Disabled => base, + State::Hovered => darker(base, 0.12), + State::Pressed => darker(base, 0.22), + }; + Look { fill: Some(fill), border: None, content: Color32::WHITE } + }; + let look = match kind { + Kind::Primary => filled(t.accent), + Kind::Danger => filled(t.reject), + Kind::Secondary => Look { + fill: Some(match state { + State::Idle | State::Disabled => t.button, + State::Hovered => t.hover, + State::Pressed => t.pressed, + }), + border: Some(t.button_border), + content: t.text, + }, + Kind::Ghost => Look { + fill: match state { + State::Idle | State::Disabled => None, + State::Hovered => Some(t.hover), + State::Pressed => Some(t.pressed), + }, + border: None, + content: t.text, + }, + }; + if state == State::Disabled { + let fade = |c: Color32| c.gamma_multiply(0.45); + Look { fill: look.fill.map(fade), border: look.border.map(fade), content: fade(look.content) } + } else { + look + } +} + +/// A button, built and then shown with [`Button::show`] (in the layout) or [`Button::show_at`] +/// (in a given rectangle). +#[derive(Clone, Debug)] +pub struct Button<'a> { + text: Option<&'a str>, + icon: Option, + name: Option<&'a str>, + kind: Kind, + size: Size, + enabled: bool, + widget: Option, + tooltip: Option<&'a str>, + min_width: f32, + id_salt: Option, +} + +impl<'a> Button<'a> { + /// A button with `text` (an English string, translated when drawn). + pub fn new(text: &'a str) -> Self { + Button { + text: Some(text), + icon: None, + name: None, + kind: Kind::default(), + size: Size::default(), + enabled: true, + widget: None, + tooltip: None, + min_width: 0.0, + id_salt: None, + } + } + + /// A button showing `icon` alone; `name` is what a screen reader says and the tooltip shows. + pub fn icon_only(icon: Icon, name: &'a str) -> Self { + Button { text: None, icon: Some(icon), name: Some(name), ..Button::new("") } + } + + /// An icon before the text. + pub fn icon(mut self, icon: Icon) -> Self { + self.icon = Some(icon); + self + } + + pub fn kind(mut self, kind: Kind) -> Self { + self.kind = kind; + self + } + pub fn primary(self) -> Self { + self.kind(Kind::Primary) + } + pub fn secondary(self) -> Self { + self.kind(Kind::Secondary) + } + pub fn danger(self) -> Self { + self.kind(Kind::Danger) + } + pub fn ghost(self) -> Self { + self.kind(Kind::Ghost) + } + + pub fn size(mut self, size: Size) -> Self { + self.size = size; + self + } + pub fn small(self) -> Self { + self.size(Size::Small) + } + pub fn large(self) -> Self { + self.size(Size::Large) + } + + /// Off: drawn faded, and a click does nothing. + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Registered under this id (`button:…`) for scripts, tests and `ui.clickWidget`; unique on + /// screen, as every registered id is. + pub fn widget(mut self, id: impl Into) -> Self { + self.widget = Some(id.into()); + self + } + + /// Shown on hover (an icon-only button shows its name without one). + pub fn tooltip(mut self, text: &'a str) -> Self { + self.tooltip = Some(text); + self + } + + /// Told apart from buttons of the same widget id in other places (each dialog's confirm is + /// `button:dialogOk`: salted with the dialog, the keyboard's place doesn't carry from one + /// dialog to the next). + pub fn id_salt(mut self, salt: impl std::hash::Hash + std::fmt::Debug) -> Self { + self.id_salt = Some(egui::Id::NULL.with(salt)); + self + } + + /// At least this wide (a row of buttons of one width). + pub fn min_width(mut self, width: f32) -> Self { + self.min_width = width; + self + } + + fn font(&self, t: &Tokens) -> egui::FontId { + // the loud kinds speak a little bolder + match self.kind { + Kind::Primary | Kind::Danger => t.semibold(self.size.text()), + Kind::Secondary | Kind::Ghost => t.font(self.size.text()), + } + } + + fn galley(&self, ui: &Ui) -> Option> { + let t = Tokens::get(ui.ctx()); + self.text.filter(|s| !s.is_empty()).map(|s| ui.painter().layout_no_wrap(crate::i18n::tr(s).to_string(), self.font(&t), Color32::WHITE)) + } + + /// How big it is drawn. + pub fn desired_size(&self, ui: &Ui) -> Vec2 { + let h = self.size.height(); + let text_w = self.galley(ui).map_or(0.0, |g| g.size().x); + let w = match (self.icon.is_some(), text_w > 0.0) { + // an icon alone: a square + (true, false) => h, + (true, true) => self.size.padding() * 2.0 + self.size.icon() + self.size.gap() + text_w, + (false, _) => self.size.padding() * 2.0 + text_w, + }; + vec2(w.max(self.min_width), h) + } + + /// Show it where the layout puts it. + pub fn show(self, ui: &mut Ui) -> Response { + let size = self.desired_size(ui); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + self.show_at(ui, rect) + } + + /// Show it in `rect` (sized by the caller, e.g. a row of equal buttons). + pub fn show_at(self, ui: &mut Ui, rect: Rect) -> Response { + let t = Tokens::get(ui.ctx()); + // stable across frames and moves: its widget id (with its salt) when it has one, else its + // place in the ui (moved on, so the next button in the same ui gets another) + let id = match &self.widget { + Some(w) => ui.id().with(("lc-button", w.as_str(), self.id_salt)), + None => { + let id = ui.auto_id_with(("lc-button", self.text.or(self.name), self.id_salt)); + ui.skip_ahead_auto_ids(1); + id + } + }; + // a button in a disabled part of the window is disabled too + let enabled = self.enabled && ui.is_enabled(); + let resp = ui.interact(rect, id, if enabled { Sense::click() } else { Sense::hover() }); + let words = crate::i18n::tr(self.name.or(self.text).unwrap_or_default()).to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, words.clone())); + if let Some(w) = &self.widget { + crate::widgets::register(ui.ctx(), w.clone(), rect); + } + let state = if !enabled { + State::Disabled + } else if resp.is_pointer_button_down_on() { + State::Pressed + } else if resp.hovered() { + State::Hovered + } else { + State::Idle + }; + let lk = look(&t, self.kind, state); + // (its own rectangle and its focus ring: a long text never runs over its neighbours) + let p = &ui.painter_at(rect.expand(5.0)); + let radius = self.size.radius(); + if let Some(fill) = lk.fill { + p.rect_filled(rect, radius, fill); + } + if let Some(border) = lk.border { + p.rect_stroke(rect, radius, Stroke::new(1.0, border), StrokeKind::Inside); + } + // where the keyboard is: an accent ring around it + if resp.has_focus() { + p.rect_stroke(rect.expand(2.0), radius + 2.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + } + let galley = self.galley(ui); + let icon_s = self.size.icon(); + let content_w = match (&galley, self.icon) { + (Some(g), Some(_)) => icon_s + self.size.gap() + g.size().x, + (Some(g), None) => g.size().x, + (None, Some(_)) => icon_s, + (None, None) => 0.0, + }; + let mut x = rect.center().x - content_w / 2.0; + if let Some(icon) = self.icon { + paint(p, Rect::from_center_size(pos2(x + icon_s / 2.0, rect.center().y), vec2(icon_s, icon_s)), icon, lk.content); + x += icon_s + self.size.gap(); + } + if let Some(g) = galley { + let at = pos2(x, rect.center().y - g.size().y / 2.0); + p.galley_with_override_text_color(at, g, lk.content); + } + // the tooltip, or an icon's name, also while disabled (what the button would do). egui + // calls a response disabled only in a disabled ui: a button turned off on its own still + // answers hover, and takes the ordinary tooltip + match self.tooltip.or(if self.text.is_none() { self.name } else { None }) { + Some(tip) if resp.enabled() => resp.on_hover_text(crate::i18n::tr(tip)), + Some(tip) => resp.on_disabled_hover_text(crate::i18n::tr(tip)), + None => resp, + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 544724671..5fb1f4e71 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod album_picker; pub mod brush_live; +pub mod button; mod command_error; pub mod control; pub mod credits; @@ -49,6 +50,8 @@ mod tests_appearance; #[cfg(test)] mod tests_before_after; #[cfg(test)] +mod tests_button; +#[cfg(test)] mod tests_compare; #[cfg(test)] mod tests_copy_paste_dialogs; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index c91266a26..743d6becd 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1169,11 +1169,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::theme::font_credits(app.chinese_font.is_some()) )); ui.add_space(10.0); - let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE)) - .fill(t.accent) - .min_size(egui::vec2(260.0, 34.0)); - let r = ui.add(discord).on_hover_text(crate::links::DISCORD); - crate::widgets::register(ui.ctx(), "button:aboutDiscord", r.rect); + let r = crate::button::Button::new("Join the ArtCraft Discord") + .primary() + .large() + .min_width(260.0) + .widget("button:aboutDiscord") + .tooltip(crate::links::DISCORD) + .show(ui); if r.clicked() { let _ = crate::links::open(app, crate::links::DISCORD); } @@ -1254,39 +1256,31 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // smart-album rules that can't mean anything wait to be fixed && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()); let primary = !informational && can_confirm && confirm_style(&dlg) == ConfirmStyle::Primary; + use crate::button::{Button, Kind}; + // (each kind of dialog its own buttons: the keyboard's place doesn't carry from a + // dialog to the one it opens) + let kind = std::mem::discriminant(&dlg); + let cancel_button = Button::new(cancel).widget("button:dialogCancel").id_salt(kind).min_width(72.0); + let ok_button = Button::new(ok) + .kind(if primary { Kind::Primary } else { Kind::Secondary }) + .enabled(can_confirm) + .widget("button:dialogOk") + .id_salt(kind) + .min_width(72.0); // (Cancel first, then the confirm: the keyboard goes through them as the eye does) - let width_of = |label: &str| { - let text = egui::WidgetText::from(crate::i18n::tr(label)) - .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button) - .size() - .x; - (text + 2.0 * ui.spacing().button_padding.x).max(ui.spacing().interact_size.x) - }; let gap = ui.spacing().item_spacing.x; let mut buttons_w = 0.0; if !informational { - buttons_w += width_of(cancel) + gap; + buttons_w += cancel_button.desired_size(ui).x + gap; } if !ok.is_empty() { - buttons_w += width_of(ok); + buttons_w += ok_button.desired_size(ui).x; } ui.add_space((ui.available_width() - buttons_w).max(0.0)); - if !informational { - let r = ui.button(crate::i18n::tr(cancel)); - crate::widgets::register(ui.ctx(), "button:dialogCancel", r.rect); - if r.clicked() { - close = true; - } - } - let button = if primary { - egui::Button::new(egui::RichText::new(crate::i18n::tr(ok)).color(egui::Color32::WHITE)).fill(t.accent) - } else { - egui::Button::new(crate::i18n::tr(ok)) - }; - let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, button)); - if let Some(r) = &r { - crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect); + if !informational && cancel_button.show(ui).clicked() { + close = true; } + let r = (!ok.is_empty()).then(|| ok_button.show(ui)); if r.is_some_and(|r| r.clicked()) { if informational { close = true; diff --git a/crates/ui-egui/src/tests_button.rs b/crates/ui-egui/src/tests_button.rs new file mode 100644 index 000000000..e06948fff --- /dev/null +++ b/crates/ui-egui/src/tests_button.rs @@ -0,0 +1,159 @@ +//! The button component: its sizes, its looks in every theme and state, what screen readers hear, +//! and that a disabled one can't be clicked. + +use egui::{Color32, Pos2, Rect, pos2, vec2}; + +use crate::button::{Button, Kind, Size, State, contrast, look}; +use crate::icons::Icon; +use crate::theme::{ThemeKind, Tokens}; + +/// Run frames of `draw` (with pointer `events` in the second), returning the AccessKit nodes' +/// (role, name) and what `draw` reported. +fn frames(events: Vec, mut draw: impl FnMut(&mut egui::Ui) -> T) -> (Vec<(String, String)>, T) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let mut found = Vec::new(); + let mut last = T::default(); + for frame in 0..3 { + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + events: if frame == 1 { events.clone() } else { Vec::new() }, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| last = draw(ui)); + out.textures_delta.clear(); + if let Some(update) = out.platform_output.accesskit_update.take() { + found = update.nodes.iter().map(|(_, n)| (format!("{:?}", n.role()), n.label().unwrap_or_default().to_string())).collect(); + } + } + (found, last) +} + +#[test] +fn the_three_sizes_and_an_icon_alone() { + let (_, sizes) = frames(Vec::new(), |ui| { + [Size::Small, Size::Medium, Size::Large] + .map(|s| (Button::new("Import Photos…").size(s).desired_size(ui), Button::icon_only(Icon::Trash, "Delete").size(s).desired_size(ui))) + .to_vec() + }); + let heights: Vec = sizes.iter().map(|(text, _)| text.y).collect(); + assert_eq!(heights, vec![24.0, 30.0, 36.0]); + for (text, icon) in &sizes { + assert_eq!(icon.x, icon.y, "an icon alone is square"); + assert!(text.x > icon.x, "text makes it wider"); + } + // an icon beside the text makes it wider still, and a minimum width holds + let (_, (plain, with_icon, wide)) = frames(Vec::new(), |ui| { + ( + Button::new("Remove").desired_size(ui).x, + Button::new("Remove").icon(Icon::Trash).desired_size(ui).x, + Button::new("OK").min_width(120.0).desired_size(ui).x, + ) + }); + assert!(with_icon > plain); + assert_eq!(wide, 120.0); +} + +#[test] +fn a_screen_reader_hears_every_button_by_its_name() { + let (found, _) = frames(Vec::new(), |ui| { + Button::new("Import Photos…").primary().show(ui); + Button::icon_only(Icon::Trash, "Delete mask").ghost().show(ui); + Button::new("Remove").icon(Icon::Trash).danger().enabled(false).show(ui); + }); + let has = |label: &str| found.iter().any(|(r, l)| r == "Button" && l == label); + assert!(has("Import Photos…"), "{found:?}"); + assert!(has("Delete mask"), "an icon alone is named: {found:?}"); + assert!(has("Remove"), "a disabled one is still announced: {found:?}"); +} + +#[test] +fn text_on_a_fill_reads_at_aa_in_every_theme_and_state() { + for kind in ThemeKind::ALL { + let t = Tokens::for_kind(kind); + for k in [Kind::Primary, Kind::Danger, Kind::Secondary] { + for state in [State::Idle, State::Hovered, State::Pressed] { + let lk = look(&t, k, state); + let fill = lk.fill.unwrap_or(t.canvas); + let c = contrast(lk.content, fill); + assert!(c >= 4.5, "{kind:?} {k:?} {state:?}: {c:.2}:1"); + } + } + // a ghost's text on the surfaces it sits on + let ghost = look(&t, Kind::Ghost, State::Idle); + assert!(ghost.fill.is_none()); + assert!(contrast(ghost.content, t.chrome) >= 4.5, "{kind:?} ghost on chrome"); + } +} + +#[test] +fn hover_and_press_are_darker_and_disabled_is_faded() { + let t = Tokens::for_kind(ThemeKind::Charcoal); + let luma = |c: Color32| u32::from(c.r()) + u32::from(c.g()) + u32::from(c.b()); + let fill = |s| look(&t, Kind::Primary, s).fill.map(luma).unwrap_or(0); + assert!(fill(State::Hovered) < fill(State::Idle)); + assert!(fill(State::Pressed) < fill(State::Hovered)); + let disabled = look(&t, Kind::Primary, State::Disabled); + assert!(disabled.content.a() < 255, "faded"); +} + +#[test] +fn a_disabled_button_cannot_be_clicked() { + let at = pos2(40.0, 15.0); + let click = vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, + ]; + let place = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0)); + let mut disabled_clicked = false; + let _ = frames(click.clone(), |ui| { + if Button::new("Delete").danger().enabled(false).show_at(ui, place).clicked() { + disabled_clicked = true; + } + }); + assert!(!disabled_clicked, "on any frame"); + let mut seen = false; + let _ = frames(click, |ui| { + if Button::new("Delete").danger().show_at(ui, place).clicked() { + seen = true; + } + }); + assert!(seen, "an enabled one is"); +} + +#[test] +fn buttons_in_one_place_with_the_same_text_are_told_apart() { + // two "Delete mask" buttons side by side, placed by the caller: two widgets, not one + let (found, _) = frames(Vec::new(), |ui| { + let a = Button::icon_only(Icon::Trash, "Delete mask").show_at(ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(30.0, 30.0))); + let b = Button::icon_only(Icon::Trash, "Delete mask").show_at(ui, Rect::from_min_size(pos2(40.0, 0.0), vec2(30.0, 30.0))); + assert_ne!(a.id, b.id); + }); + assert_eq!(found.iter().filter(|(r, l)| r == "Button" && l == "Delete mask").count(), 2, "{found:?}"); +} + +#[test] +fn a_button_in_a_disabled_part_of_the_window_says_it_is_disabled() { + // (egui refuses the click there anyway: what the button adds is looking and reading disabled) + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let mut disabled = None; + for _ in 0..3 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + ui.add_enabled_ui(false, |ui| { + let resp = Button::new("Delete").show_at(ui, Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0))); + // drawn as disabled: it asks for hover only, as a button turned off does + assert!(!resp.sense.senses_click(), "drawn as a button that can be clicked"); + }); + }); + out.textures_delta.clear(); + if let Some(update) = out.platform_output.accesskit_update.take() { + disabled = update.nodes.iter().find(|(_, n)| n.label() == Some("Delete")).map(|(_, n)| n.is_disabled()); + } + } + assert_eq!(disabled, Some(true), "a screen reader hears it disabled"); +} diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests_dialog_fit.rs index 29d0401c2..1185e94e5 100644 --- a/crates/ui-egui/src/tests_dialog_fit.rs +++ b/crates/ui-egui/src/tests_dialog_fit.rs @@ -95,8 +95,9 @@ fn export_dialog_is_unchanged_in_a_tall_window() { let dialog = rect(&h, "dialog:window"); // (846 tall as before #781: the whole dialog, nothing scrolled away; 615 wide since the // format row gained JPEG XL, #780. On macOS the dialog has one more row, "Add to Apple Photos" - // (#236), so it is 876 tall there) - let height = if cfg!(target_os = "macos") { 876.0 } else { 846.0 }; + // (#236), so it is 876 tall there. 8 more since its footer's buttons are the button + // component's medium size, 30 points, as the welcome's and every dialog's) + let height = if cfg!(target_os = "macos") { 884.0 } else { 854.0 }; assert!((dialog.height() - height).abs() <= 1.0, "{dialog:?}"); assert!((dialog.width() - 615.2).abs() <= 1.0, "{dialog:?}"); } diff --git a/docs/whats-new.md b/docs/whats-new.md index d8611d7a6..054105e2e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-14 +### One kind of button +- Dialogs' buttons and About's Discord link are drawn by one button component: primary (filled with the accent), + secondary, danger (filled red) and ghost, in three sizes, with an icon or without, and an icon alone (named for + screen readers). Each darkens a little when hovered and more when pressed, shows a ring where the keyboard is, and + its text reads at 4.5:1 or more on its fill in every theme. Dialog buttons are 30 points tall, like the welcome's. + ### A dialog's confirm button says it's the next step - In dialogs, the confirm button is on the right with Cancel to its left, and filled with the accent when it makes or brings in something (Import N Photos, Merge, Save, Create, Download…). Delete and Remove stay plain, so a destructive From 81a9569301426aafe844e17056a88ba4154df207 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 00:42:56 -0400 Subject: [PATCH 44/52] Batch 36: rustfmt after merging #1802 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/dialogs.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 8e73e62f6..44509cd06 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -2251,7 +2251,9 @@ pub enum ConfirmStyle { pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle { match dlg { - Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } | Dialog::RemoveFolder { .. } => ConfirmStyle::Plain, + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } | Dialog::RemoveFolder { .. } => { + ConfirmStyle::Plain + } _ if only_shows(dlg) => ConfirmStyle::Plain, _ => ConfirmStyle::Primary, } @@ -2261,7 +2263,12 @@ pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle { fn only_shows(dlg: &Dialog) -> bool { matches!( dlg, - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew + Dialog::About + | Dialog::Shortcuts + | Dialog::Settings { .. } + | Dialog::SystemInfo { .. } + | Dialog::AllMetadata { .. } + | Dialog::WhatsNew | Dialog::ManageEffects { .. } ) } From 60b938ab52abf962c0459affe780347802488edf Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 00:53:53 -0400 Subject: [PATCH 45/52] Batch 37: format catalogs list the strings this batch adds, in English until translated Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/it-formats.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index 17a48eeff..70a1bafbe 100644 --- a/crates/ui-egui/locales/it-formats.json +++ b/crates/ui-egui/locales/it-formats.json @@ -289,5 +289,7 @@ "{n} originals opened in the other editor": "{n} originals opened in the other editor", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", "Effect: {name}": "Effect: {name}", - " in {g} group{}": " in {g} group{}" + " in {g} group{}": " in {g} group{}", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", + "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages" } From 3c489dc4087a6c953922585186de5d32a8424d75 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 00:56:44 -0400 Subject: [PATCH 46/52] Batch 37: the print commands (#1215) take the Result that Session::targets returns since #1773 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/print.rs | 6 +++--- crates/ui-egui/src/export_task.rs | 2 +- crates/ui-egui/src/lib.rs | 4 ++-- crates/ui-egui/src/print_view.rs | 3 ++- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index b9033ad05..05a851893 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -43,7 +43,7 @@ fn state(s: &Session, photos: usize) -> Value { } fn reply(s: &Session) -> Result { - Ok(state(s, s.targets(&Value::Null).len())) + Ok(state(s, s.targets(&Value::Null)?.len())) } /// Applies `patch` over `dst`: objects merge key by key, anything else replaces. @@ -64,7 +64,7 @@ fn merge(dst: &mut Value, patch: &Value, depth: usize) { } fn get(s: &mut Session, p: &Value) -> Result { - Ok(state(s, s.targets(p).len())) + Ok(state(s, s.targets(p)?.len())) } fn set(s: &mut Session, p: &Value) -> Result { @@ -245,7 +245,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { return Err(bad(ID, "id must be a photo id")); } s.check_write_target(path).map_err(|e| bad(ID, e))?; - let ids = s.targets(p); + let ids = s.targets(p)?; let settings = s.print.clone(); // a printer profile that can't be used fails the print before anything renders crate::print_color::PrintColorJob::new(&settings.job.color).map_err(|e| bad(ID, e))?; diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 8bcbf532d..d1a1acebc 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -143,7 +143,7 @@ pub fn start_print(app: &mut LightcraftApp, params: &Value) -> Result Date: Thu, 15 Oct 2026 01:07:22 -0400 Subject: [PATCH 47/52] Batch 37: conflict markers left in three tracker headers by the #1327 merge resolved Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 8 +------- docs/parity-checklist.md | 4 ---- docs/target-app-parity.md | 8 +------- 3 files changed, 2 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d64d4cff5..d642c7af9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,13 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -<<<<<<< HEAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (RAW: 82% of Adobe's models decode, 36% verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 -||||||| 21125577 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 -======= -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled; camera-coverage sweep: 30 more models verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 ->>>>>>> rb/1327 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 79fdeaff5..0f9d2b943 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -795,12 +795,8 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -<<<<<<< HEAD | 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) | -||||||| 21125577 -======= | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) | ->>>>>>> rb/1327 | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: 17 uncompressed Olympus and Samsung Adobe models verified on one CC0 file each; status unchanged (🟡) | | 2026-10-13 | minor | LR-EDIT-SECTION-SOLO: Solo Mode for Edit sections (Window ▸ Edit Sections, section-header menu, ⌥-click); KEYC-PANELS notes it | | 2026-10-14 | minor | LR-MERGE-HDR ✅ → 🟡: tested on real 67 MP Sony brackets; moving clipped highlights (black-hole stars) and dark-sky ghosts fixed, merge memory cut ~25 %; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain (gap 29). LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set (`merge.hdrBatch`) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 11acb841e..07ae89fd7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,12 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -<<<<<<< HEAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (RAW: 82% of Adobe's models decode, 36% verified) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) -||||||| 21125577 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) -======= -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled; 30 more camera models verified on 40 CC0 files) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) ->>>>>>> rb/1327 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas From e5cc449bd1486615443503a857b6c42a6918aae0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 01:10:07 -0400 Subject: [PATCH 48/52] Batch 37: the contact-sheet export and batch-merge tests wait longer on a busy machine (they pass alone, timed out under the full test run) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/export_task.rs | 2 +- crates/ui-egui/src/tests_activity.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index d1a1acebc..7ac6a838a 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -341,7 +341,7 @@ mod contact_sheet_tests { crate::pick::poll(&mut app, &ctx); assert!(app.pending_picks.is_empty()); assert!(app.export.is_some(), "picker did not start export: {:?}", app.ui.toast); - let deadline = Instant::now() + Duration::from_secs(20); + let deadline = Instant::now() + Duration::from_secs(90); loop { // Poll the real worker without starting unrelated gallery renders each frame. poll(&mut app, &ctx); diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 493998fd3..126b3ef58 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -9,7 +9,8 @@ use crate::headless::Headless; use crate::panels::activity::{WIDTH, count_text}; use crate::{LightcraftApp, Services}; -const T: Duration = Duration::from_secs(20); +// generous: merges and renders run on worker threads that a busy machine (a full workspace test run) slows a lot +const T: Duration = Duration::from_secs(60); fn demo() -> Headless { let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); From 52a3b0ed84483f1458bf41f6cc7ca11dc9ba7eff Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 01:12:03 -0400 Subject: [PATCH 49/52] Batch 37: #1471's test mosaic sets #1176's lens_metadata Co-Authored-By: Claude Opus 5.5 --- crates/merge/src/frame.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs index 731f91a14..cb74e23ed 100644 --- a/crates/merge/src/frame.rs +++ b/crates/merge/src/frame.rs @@ -308,6 +308,7 @@ mod tests { wb_multipliers, linearized: false, opcodes: OpcodeLists::default(), + lens_metadata: Default::default(), metadata: Metadata::default(), } } From b00d7c02679c88f4410d35e5a20ef89f30584b0c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 01:33:01 -0400 Subject: [PATCH 50/52] Batch 37: the contact-sheet export test's deadline is a hang guard (it queues behind other tests' demo scenes) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/export_task.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 7ac6a838a..1aec84820 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -341,7 +341,9 @@ mod contact_sheet_tests { crate::pick::poll(&mut app, &ctx); assert!(app.pending_picks.is_empty()); assert!(app.export.is_some(), "picker did not start export: {:?}", app.ui.toast); - let deadline = Instant::now() + Duration::from_secs(90); + // a hang guard, not a speed check: the render queues behind other tests' demo scenes (generated one at a + // time process-wide), which on a loaded machine takes minutes + let deadline = Instant::now() + Duration::from_secs(300); loop { // Poll the real worker without starting unrelated gallery renders each frame. poll(&mut app, &ctx); From 2a4b53f86cb9cd23c03d6450ef9d39aa6f4b2e39 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 01:34:43 -0400 Subject: [PATCH 51/52] Batch 37: parity summary refreshed Co-Authored-By: Claude Opus 5.5 --- docs/parity-checklist.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 3bc5030ba..f04e80283 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 77 | 4 | 2 | 1 | 49/52 (94%) | 23/25 (92%) | -| Lightroom Classic extras | 36 | 23 | 27 | 8 | — | 24/26 (92%) | -| **Total** | 413 | 61 | 63 | 35 | 193/201 (96%) | 147/160 (92%) | +| Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) | +| **Total** | 417 | 61 | 59 | 35 | 193/201 (96%) | 147/160 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.6%** of 537 in-scope rows — P0 98.0% of 201 · P1 95.0% of 160 · P2 53.4% of 175. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 537 in-scope rows — P0 98.0% of 201 · P1 95.0% of 160 · P2 55.7% of 175. ## Top gaps and shortcut notes From a981538d056ef3b7fdec6a39668df37a9af60197 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 01:36:59 -0400 Subject: [PATCH 52/52] Batch 37: the #1506 What's New entry once Co-Authored-By: Claude Opus 5.5 --- docs/whats-new.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index 4006c0fc4..f85aa7c50 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -129,9 +129,6 @@ - The top bar has an Import button, beside Export, in every library. ### Reliability -- A command given photos it can't read as photo ids (`"id": "5"`, or a list with something else in it) from a - script, the control channel or MCP is now refused (issue #1506). It used to act on the selected photos instead, - or on the part of the list it could read, so a mistyped id could rate, move or delete the wrong photos. ### One kind of button - Dialogs' buttons and About's Discord link are drawn by one button component: primary (filled with the accent),