From b273f8e6087ae9f20ebfc2f4fb3708fd263f0171 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:53:36 +0200 Subject: [PATCH 001/158] #798: read large Lightroom catalogs by page Generated with Codex. --- crates/engine/src/lightroom_catalog.rs | 37 ++++- crates/engine/src/lightroom_sqlite.rs | 220 ++++++++++++++++++++++++- docs/gaps.md | 2 +- docs/lightroom-catalog-import.md | 13 +- docs/parity-checklist.md | 2 +- 5 files changed, 257 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 6c1c7724d..a727491fb 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -8,7 +8,7 @@ use std::sync::{ atomic::{AtomicBool, AtomicUsize, Ordering}, }; -use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue}; +use crate::lightroom_sqlite::{Database, FileSource, LiveTable, Value as SqlValue}; use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source}; use lightcraft_geom::{Affine, Orientation, Point, Rect}; use serde::{Deserialize, Serialize}; @@ -352,15 +352,15 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, let before = std::fs::metadata(path).map_err(|e| e.to_string())?; let wal_path = PathBuf::from(format!("{}-wal", path.display())); let wal_before = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok())); - let main = bytes(path)?; + let main = FileSource::open(path)?; let wal = match std::fs::metadata(&wal_path) { Ok(_) => bytes(&wal_path)?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), Err(e) => return Err(e.to_string()), }; - let wal_len = committed_wal_len(&main, &wal)?; + let wal_len = committed_wal_len(main.header(), &wal)?; ensure_read_active(cancel)?; - let db = Database::open_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?; + let db = Database::open_file_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?; let after = std::fs::metadata(path).map_err(|e| e.to_string())?; let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok())); if before.len() != after.len() || before.modified().ok() != after.modified().ok() || wal_before != wal_after { @@ -471,6 +471,11 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, }); } ensure_read_active(cancel)?; + reader.db.ensure_unchanged()?; + let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok())); + if wal_before != wal_after { + return Err("catalog changed during reading; close Lightroom and retry".into()); + } Ok(out) } @@ -1302,6 +1307,30 @@ mod tests { assert_eq!(catalog.photos[0].uuid, "fixture-image"); assert_eq!(number(&catalog.photos[0].image, "rating"), 5); assert!(catalog.photos[0].path.ends_with("test.ARW")); + // Put the required image table beyond the old 1 GiB cutoff without allocating it. + // A main-file-sized allocation would defeat the purpose of the page-backed reader. + // The large fixture relies on Unix sparse-file behavior; small parity runs everywhere. + #[cfg(unix)] + { + let far_offset = 1u64 << 30; + schema.last_mut().unwrap()[3] = I((far_offset / 2048 + 1) as i64); + leaf(&mut bytes[..2048], 100, &schema); + use std::io::{Seek, SeekFrom, Write}; + let mut sparse = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).unwrap(); + sparse.write_all(&bytes).unwrap(); + sparse.set_len(far_offset + 2048).unwrap(); + sparse.seek(SeekFrom::Start(far_offset)).unwrap(); + sparse.write_all(&bytes[4 * 2048..5 * 2048]).unwrap(); + drop(sparse); + let large = read(&path).unwrap(); + assert_eq!(serde_json::to_value(&large.photos).unwrap(), serde_json::to_value(&catalog.photos).unwrap()); + let mut session = crate::Session::new(); + session.execute("library.importLightroom", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(session.catalog.len(), 1); + assert_eq!(session.catalog.photos().next().unwrap().rating, 5); + session.execute("edit.undo", &json!({})).unwrap(); + assert!(session.catalog.is_empty()); + } std::fs::write(&path, b"bad catalog").unwrap(); assert!(read(&path).is_err()); std::fs::remove_file(path).unwrap(); diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs index 900fe4a26..f00cb111f 100644 --- a/crates/engine/src/lightroom_sqlite.rs +++ b/crates/engine/src/lightroom_sqlite.rs @@ -6,7 +6,79 @@ //! than a SQL engine. It never writes its input and has no SQLite runtime //! dependency. +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; +use std::fs::{File, Metadata}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +const MAX_DATABASE_BYTES: u64 = 16 << 30; + +/// A bounded, read-only page source. Only referenced pages are loaded, never the whole file. +pub struct FileSource { + file: Mutex, + path: PathBuf, + before: Metadata, + header: Vec, +} + +impl FileSource { + pub fn open(path: &Path) -> Result { + if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() { + return Err("SQLite catalog must be a regular file".into()); + } + let mut file = File::open(path).map_err(|e| e.to_string())?; + let before = file.metadata().map_err(|e| e.to_string())?; + if !before.is_file() { + return Err("SQLite catalog must be a regular file".into()); + } + if before.len() > MAX_DATABASE_BYTES { + return Err("catalog exceeds 16 GiB import limit".into()); + } + let mut header = vec![0; 100]; + file.read_exact(&mut header).map_err(|e| format!("cannot read SQLite header: {e}"))?; + let source = Self { file: Mutex::new(file), path: path.into(), before, header }; + source.ensure_unchanged()?; + Ok(source) + } + + pub fn header(&self) -> &[u8] { + &self.header + } + + pub fn ensure_unchanged(&self) -> Result<(), String> { + let file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?; + let handle = file.metadata().map_err(|e| e.to_string())?; + let path = std::fs::metadata(&self.path).map_err(|_| "catalog changed during reading; close Lightroom and retry")?; + if !same_metadata(&self.before, &handle) || !same_metadata(&self.before, &path) { + return Err("catalog changed during reading; close Lightroom and retry".into()); + } + Ok(()) + } + + fn page(&self, page_id: u32, size: usize) -> Result, String> { + let start = u64::from(page_id).checked_sub(1).and_then(|n| n.checked_mul(size as u64)).ok_or("page offset overflow")?; + if start.checked_add(size as u64).is_none_or(|end| end > self.before.len()) { + return Err(format!("page {page_id} is truncated")); + } + let mut out = vec![0; size]; + let mut file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?; + file.seek(SeekFrom::Start(start)).and_then(|_| file.read_exact(&mut out)).map_err(|e| format!("cannot read SQLite page {page_id}: {e}"))?; + Ok(out) + } +} + +fn same_metadata(a: &Metadata, b: &Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if a.dev() != b.dev() || a.ino() != b.ino() { + return false; + } + } + a.len() == b.len() && a.modified().ok() == b.modified().ok() && a.created().ok() == b.created().ok() +} const MAX_PAGE_ID: u32 = 16_777_216; const MAX_COLUMNS: usize = 16_384; @@ -44,6 +116,7 @@ pub struct LiveTable { /// Read-only SQLite database image, with a committed WAL overlay if supplied. pub struct Database { main: Vec, + file: Option, overlay: HashMap>, page_size: usize, reserved: usize, @@ -84,17 +157,34 @@ impl Database { /// The WAL argument must include its 32-byte header and only complete /// frames through a commit. Frames are overlaid by page number, with the /// final commit's database-size field defining the visible page count. + #[cfg(test)] pub fn open_with_wal(main: Vec, committed_wal: &[u8]) -> Result { + Self::open(main, None, committed_wal) + } + + pub fn open_file_with_wal(source: FileSource, committed_wal: &[u8]) -> Result { + Self::open(source.header().to_vec(), Some(source), committed_wal) + } + + pub fn ensure_unchanged(&self) -> Result<(), String> { + if let Some(file) = &self.file { + file.ensure_unchanged()?; + } + Ok(()) + } + + fn open(main: Vec, file: Option, committed_wal: &[u8]) -> Result { let main_header = main.get(..100).ok_or("truncated SQLite header")?; if main_header.get(..16) != Some(b"SQLite format 3\0") { return Err("invalid SQLite header signature".into()); } let page_size = sqlite_page_size(be_u16(main_header, 16)?)?; validate_header_fields(main_header, page_size)?; - if main.len() < page_size || !main.len().is_multiple_of(page_size) { + let main_len = file.as_ref().map_or(main.len() as u64, |source| source.before.len()); + if main_len < page_size as u64 || !main_len.is_multiple_of(page_size as u64) { return Err("SQLite image is not a whole number of pages".into()); } - let main_pages = u32::try_from(main.len() / page_size).map_err(|_| "SQLite image is too large")?; + let main_pages = u32::try_from(main_len / page_size as u64).map_err(|_| "SQLite image is too large")?; if main_pages == 0 || main_pages > MAX_PAGE_ID { return Err("invalid SQLite image page count".into()); } @@ -140,7 +230,7 @@ impl Database { _ => return Err("unsupported SQLite text encoding".into()), }; - let mut db = Self { main, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() }; + let mut db = Self { main, file, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() }; let schema = db.parse_schema()?; db.tables = schema.tables; db.rowid_aliases = schema.aliases; @@ -166,17 +256,20 @@ impl Database { Ok(state.out) } - fn page(&self, page_id: u32) -> Result<&[u8], String> { + fn page(&self, page_id: u32) -> Result, String> { if page_id == 0 || page_id > self.page_count { return Err(format!("page {page_id} is outside database")); } if let Some(page) = self.overlay.get(&page_id) { - return Ok(page); + return Ok(Cow::Borrowed(page)); + } + if let Some(file) = &self.file { + return file.page(page_id, self.page_size).map(Cow::Owned); } let id = usize::try_from(page_id).map_err(|_| "page number does not fit host")?; let start = id.checked_sub(1).and_then(|n| n.checked_mul(self.page_size)).ok_or("page offset overflow")?; let end = start.checked_add(self.page_size).ok_or("page offset overflow")?; - self.main.get(start..end).ok_or_else(|| format!("page {page_id} is truncated")) + self.main.get(start..end).map(Cow::Borrowed).ok_or_else(|| format!("page {page_id} is truncated")) } fn visit_table(&self, page_id: u32, ncols: usize, alias: Option, depth: usize, state: &mut WalkState) -> Result<(), String> { @@ -219,7 +312,7 @@ impl Database { if state.payload_total > MAX_TABLE_PAYLOAD { return Err("SQLite table payload exceeds bounded import size".into()); } - let payload = self.payload(page, at, payload_len)?; + let payload = self.payload(&page, at, payload_len)?; let mut values = decode_record(&payload, ncols, self.encoding)?; if let Some(index) = alias { if index >= values.len() { @@ -325,7 +418,7 @@ impl Database { out.extend_from_slice(overflow.get(4..end).ok_or("truncated SQLite overflow page")?); remaining -= take; if remaining > 0 { - next = be_u32_at(overflow, 0)?; + next = be_u32_at(&overflow, 0)?; } } Ok(out) @@ -871,6 +964,72 @@ fn be_u32_at(bytes: &[u8], at: usize) -> Result { mod tests { use super::*; + struct Scratch(PathBuf); + impl Scratch { + fn new(bytes: &[u8]) -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("lightcraft-page-source-{}-{n}.lrcat", std::process::id())); + std::fs::write(&path, bytes).unwrap(); + Self(path) + } + } + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + + fn file_backed(mut db: Database) -> (Scratch, Database) { + let scratch = Scratch::new(&db.main); + db.file = Some(FileSource::open(&scratch.0).unwrap()); + db.main.truncate(100); + (scratch, db) + } + + #[test] + fn file_source_bounds_and_mutation_detection() { + let scratch = Scratch::new(&[0; 512]); + let source = FileSource::open(&scratch.0).unwrap(); + assert_eq!(source.page(1, 512).unwrap().len(), 512); + assert!(source.page(2, 512).unwrap_err().contains("truncated")); + File::options().write(true).open(&scratch.0).unwrap().set_len(100).unwrap(); + assert!(source.page(1, 512).is_err()); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); + drop(source); + // Unix set_len creates a hole. Do not reserve a 16 GiB normal file on Windows. + #[cfg(unix)] + { + File::options().write(true).open(&scratch.0).unwrap().set_len(MAX_DATABASE_BYTES + 1).unwrap(); + assert_eq!(FileSource::open(&scratch.0).err().unwrap(), "catalog exceeds 16 GiB import limit"); + } + } + + #[cfg(unix)] + #[test] + fn file_source_detects_same_sized_path_replacement() { + let original = Scratch::new(&[0; 512]); + let replacement = Scratch::new(&[0; 512]); + let source = FileSource::open(&original.0).unwrap(); + std::fs::rename(&replacement.0, &original.0).unwrap(); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); + } + + #[test] + fn file_source_detects_same_length_content_changes() { + let scratch = Scratch::new(&[0; 512]); + let source = FileSource::open(&scratch.0).unwrap(); + std::fs::write(&scratch.0, [1; 512]).unwrap(); + // Make the timestamp change deterministic even on filesystems with coarse timestamps. + File::options() + .write(true) + .open(&scratch.0) + .unwrap() + .set_modified(source.before.modified().unwrap() + std::time::Duration::from_secs(2)) + .unwrap(); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); + } + fn varint(mut value: u64) -> Vec { let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)]; value >>= 7; @@ -996,6 +1155,40 @@ mod tests { assert!(tables.iter().any(|table| table.name == "t")); let rows = db.read_table(2, 2).unwrap(); assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1))); + let scratch = Scratch::new(&db.main); + let file_db = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &[]).unwrap(); + assert_eq!(file_db.live_tables().unwrap(), tables); + assert_eq!(file_db.read_table(2, 2).unwrap(), rows); + assert_eq!(file_db.main.len(), 100); + file_db.ensure_unchanged().unwrap(); + // The same committed WAL page overrides the lazily read main page. + let mut wal = vec![0; 32 + 24 + 512]; + wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes()); + wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); + wal[8..12].copy_from_slice(&512u32.to_be_bytes()); + wal[32..36].copy_from_slice(&2u32.to_be_bytes()); + wal[36..40].copy_from_slice(&2u32.to_be_bytes()); + leaf(&mut wal[56..], 0, 2, &[3, 0, 15, b'y']); + let memory_wal = Database::open_with_wal(db.main.clone(), &wal).unwrap(); + let file_wal = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &wal).unwrap(); + assert_eq!(memory_wal.read_table(2, 2).unwrap(), file_wal.read_table(2, 2).unwrap()); + assert_eq!(file_wal.read_table(2, 2).unwrap()[0].values[0], Value::Integer(2)); + let mut malformed = db.main.clone(); + malformed.push(0); + let partial = Scratch::new(&malformed); + assert_eq!( + Database::open_file_with_wal(FileSource::open(&partial.0).unwrap(), &[]).err().unwrap(), + "SQLite image is not a whole number of pages" + ); + let mut malformed = db.main.clone(); + malformed[24..28].copy_from_slice(&1u32.to_be_bytes()); + malformed[92..96].copy_from_slice(&1u32.to_be_bytes()); + malformed[28..32].copy_from_slice(&3u32.to_be_bytes()); + let oversized = Scratch::new(&malformed); + assert_eq!( + Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(), + "SQLite header page count exceeds image" + ); } #[test] @@ -1012,6 +1205,7 @@ mod tests { leaf(&mut main[1024..1536], 0, 1, &[2, 0]); leaf(&mut main[1536..], 0, 2, &[2, 0]); let db = Database { + file: None, main, overlay: HashMap::new(), page_size: 512, @@ -1043,6 +1237,7 @@ mod tests { let mut aliases = HashMap::new(); aliases.insert(1, 0); let db = Database { + file: None, main, overlay: HashMap::new(), page_size: 512, @@ -1063,6 +1258,7 @@ mod tests { main[103..105].copy_from_slice(&1u16.to_be_bytes()); main[108..110].copy_from_slice(&490u16.to_be_bytes()); let db = Database { + file: None, main, overlay: HashMap::new(), page_size: 512, @@ -1073,6 +1269,8 @@ mod tests { rowid_aliases: HashMap::new(), }; assert!(db.read_table(1, 1).is_err()); + let (_scratch, db) = file_backed(db); + assert!(db.read_table(1, 1).is_err()); } #[test] @@ -1085,6 +1283,7 @@ mod tests { let mut main = vec![0u8; 2 * 512]; let db = Database { + file: None, main: std::mem::take(&mut main), overlay: HashMap::new(), page_size: 512, @@ -1111,6 +1310,10 @@ mod tests { assert_eq!(bytes.len(), 600); assert_eq!(&bytes[..92], &[b'x'; 92]); assert_eq!(&bytes[92..], &[b'y'; 508]); + let (_scratch, mut db) = file_backed(db); + db.overlay.clear(); + let bytes = db.payload(&cell_page, 0, 600).unwrap(); + assert_eq!(&bytes[92..], &[b'y'; 508]); } #[test] @@ -1134,6 +1337,7 @@ mod tests { let mut page = vec![0u8; 512]; page[16..18].copy_from_slice(&512u16.to_be_bytes()); let db = Database { + file: None, main: vec![0; 512], overlay: HashMap::new(), page_size: 512, diff --git a/docs/gaps.md b/docs/gaps.md index 64b1cb8ac..98cc17548 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -27,7 +27,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 234 of 1,446 Adobe models verified (16%), ~40% of photographers on a verified body; Nikon 5 bodies verified, Canon 11. 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) | +| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; main-file pages read on demand up to 16 GiB, with dense-table and WAL bounds retained (#798) | 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; HDR settings from `crs` XMP | 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) | | 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) | diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 0c18a6521..3ae13c75e 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -28,9 +28,16 @@ retained. Oldest managed archives are retired; legacy `.json` archives remain un `Interop/lightroom-index.json` records imported identities. Source catalogs and originals are never overwritten. -Native import is bounded to a 1 GiB database/WAL, 16 MiB XMP packets and one million rows per -table. The in-tree, read-only Rust SQLite B-tree reader follows SQLite’s public file-format -specification; it introduces no dependency or C runtime. Table payloads are capped at 256 MiB. +Native import reads referenced database pages on demand (at most 64 KiB per page), rather than +loading the whole `.lrcat`. Database files are capped at 16 GiB and 16,777,216 pages; the WAL +remains capped at 1 GiB. Tables still have a 256 MiB encoded-payload budget and one million rows, +individual records are capped at 64 MiB, and XMP packets at 16 MiB. These limits can still reject +large catalogs with dense tables; file size alone no longer determines the memory allocation. +The open file and source path are checked again after all tables are read. Close Lightroom before +importing: metadata checks detect ordinary concurrent changes, but are not SQLite snapshot isolation. + +The in-tree, read-only Rust SQLite B-tree reader follows SQLite's public file-format +specification; it introduces no dependency or C runtime. Optional history, keyword and collection tables that exceed limits are skipped with warnings; required photo tables still report errors. Unrelated unsupported schemas are ignored. A needed WITHOUT ROWID or virtual table produces an explicit error, handled as a warning when optional. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..2b6cf4814 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -679,7 +679,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | -| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | +| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; on-demand main-file pages up to 16 GiB with existing table/record bounds (WAL still capped at 1 GiB, issue #798); background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | From a2911a508621273c0c6f4cfed0b8387824ce844e Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Tue, 13 Oct 2026 12:56:50 +0200 Subject: [PATCH 002/158] LR-VIEW-ZOOM: drag-scale follow-ups from review (#330) - A full set of slow drags forgets only the one remembered longest ago instead of all 256 (each forgotten pair costs one more slow full-size drag). - DragScale's doc says a slow drag is remembered for its photo only. - A host frame time over 1 s (a hidden, occluded or stalled window) counts as unknown, like no frame time at all, so it can't mark a drag slow. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/region.rs | 69 ++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 4fb2e587b..f02f80df5 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -107,14 +107,20 @@ const GIVE_UP_FACTOR: f64 = 2.0; /// The full-size drafts (after the first) a drag times before it may give up on full size. const GIVE_UP_AFTER: usize = 3; -/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts. +/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts; past +/// it, the one remembered longest ago is forgotten. const SLOW_DRAGS_KEPT: usize = 256; +/// A host frame time (ms) longer than this is not a texture upload but a window that was hidden, +/// occluded or stalled: it counts as unknown, like a frame time from no host at all. +const FRAME_MS_KNOWN: f64 = 1000.0; + /// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it /// ends (a change in the middle would make the resolution jump), unless its full-size drafts are /// far over budget. A drag renders at full size when the GPU draws the view and no earlier drag of /// the same control on this photo went over budget at that size; otherwise at [`CPU_DRAFT_SCALE`] -/// (issue #330). Whether the GPU drew a render is not enough on its own: some views (a LUT +/// (issue #330). A slow drag is remembered for its photo only: the same control on another photo +/// (another size, other edits) starts at full size again and is timed afresh. Whether the GPU drew a render is not enough on its own: some views (a LUT /// profile, refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large /// draft costs a texture upload too, which only the time a draft took to show tells. #[derive(Clone, Debug, Default)] @@ -128,8 +134,8 @@ pub struct DragScale { /// The render time (ms) of a draft that reached the screen this frame: the time of the frame /// that uploads it is only known on the next one. pending: Option, - /// Drags whose full-size drafts went over budget. - slow: std::collections::HashSet<(lightcraft_catalog::PhotoId, String)>, + /// Drags whose full-size drafts went over budget, the one remembered longest ago first. + slow: std::collections::VecDeque<(lightcraft_catalog::PhotoId, String)>, } impl DragScale { @@ -165,7 +171,7 @@ impl DragScale { return; } if let Some(render) = self.pending.take() { - let frame = last_frame_ms.filter(|ms| ms.is_finite() && *ms >= 0.0).unwrap_or(0.0); + let frame = last_frame_ms.filter(|ms| ms.is_finite() && (0.0..=FRAME_MS_KNOWN).contains(ms)).unwrap_or(0.0); if self.samples.len() < 1024 { self.samples.push(render + frame); } @@ -189,7 +195,19 @@ impl DragScale { /// Remember drags of `control` on `photo` as too slow for full-size drafts (tests). #[cfg(test)] pub fn remember_slow(&mut self, photo: lightcraft_catalog::PhotoId, control: &str) { - self.slow.insert((photo, control.to_string())); + self.mark_slow((photo, control.to_string())); + } + + /// Remember a slow drag, forgetting the one remembered longest ago when the set is full (not + /// all of them: each forgotten pair costs one more slow full-size drag). + fn mark_slow(&mut self, key: (lightcraft_catalog::PhotoId, String)) { + if self.slow.contains(&key) { + return; + } + while self.slow.len() >= SLOW_DRAGS_KEPT { + self.slow.pop_front(); + } + self.slow.push_back(key); } /// The drag ended: remember it if its full-size drafts were over budget. @@ -197,10 +215,7 @@ impl DragScale { if let Some((key, _)) = self.current.take() && median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS) { - if self.slow.len() >= SLOW_DRAGS_KEPT { - self.slow.clear(); - } - self.slow.insert(key); + self.mark_slow(key); } self.samples.clear(); self.seen = None; @@ -522,6 +537,40 @@ mod tests { assert_eq!(hostile.scale(drag, true, None), Some(1.0)); } + // Given more slow drags than are remembered, only the one remembered longest ago is forgotten: + // clearing them all would bring back a slow full-size drag for every pair + #[test] + fn a_full_slow_set_forgets_only_the_oldest_drag() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + for photo in 0..=SLOW_DRAGS_KEPT as u64 { + d.remember_slow(PhotoId(photo), "Exposure"); + } + assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None), Some(1.0), "the oldest is forgotten"); + d.scale(None, true, None); + for photo in [1, SLOW_DRAGS_KEPT as u64] { + assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None), Some(CPU_DRAFT_SCALE), "photo {photo}"); + d.scale(None, true, None); + } + d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(CPU_DRAFT_SCALE)); + } + + // Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as + // unknown: fast renders don't mark the drag slow because the window wasn't drawing + #[test] + fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + d.scale(drag, true, None); + for key in 1..=6 { + d.observe(key, 6.0, Some(4000.0)); + } + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(1.0)); + } + // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), // the drag drops to the small draft after a few of them instead of at its end, and the next // drag starts small From 8d91abe60330d28e9ebce3e37e9170b93832be11 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Tue, 13 Oct 2026 13:33:38 +0200 Subject: [PATCH 003/158] LR-VIEW-ZOOM: a slow drag verdict needs two strikes and expires (#330) A busy moment (another app, a backup) could make a slider draft small for the rest of the session. DragScale keeps timing in wall-clock time, what the user waits for, but now: - a (photo, control) pair is slow only after two drags in a row over 20 ms (1.25x the 16 ms budget); the first makes it suspect, and a drag within 20 ms forgets the verdict; - a slow verdict lasts 30 s, then a drag tries full size again; a slow retry doubles the wait, up to 240 s (after WebRTC's CPU overuse detector: 40 s ramp-up delay, doubled after a failed up-switch, capped at 240 s). The time comes from egui's input clock, which the web build has too. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/detail.rs | 4 +- crates/ui-egui/src/region.rs | 319 ++++++++++++++++++++-------- 2 files changed, 235 insertions(+), 88 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index ba14091a7..6661ce92f 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -445,7 +445,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { && lightcraft_engine::gpu::available(); let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str())); - let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key)); + let now = ui.input(|i| i.time); + let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key), now); // render at the final size: a click-zoom animation only changes how the result is drawn let native_long = native[0].max(native[1]); let drawn_long = target_rect.width().max(target_rect.height()) * ppp; @@ -471,7 +472,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.drag_scale.observe(key, ms, app.perf.host_frame_ms); } // a pinch changes the zoom every frame: keep the sizes of before it until it is quiet - let now = ui.input(|i| i.time); let plan = app.size_hold.apply(id, now, navigating, plan); if app.size_hold.holding(now) { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS)); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index f02f80df5..1c2c78407 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -107,22 +107,50 @@ const GIVE_UP_FACTOR: f64 = 2.0; /// The full-size drafts (after the first) a drag times before it may give up on full size. const GIVE_UP_AFTER: usize = 3; -/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts; past -/// it, the one remembered longest ago is forgotten. -const SLOW_DRAGS_KEPT: usize = 256; +/// The most (photo, control) pairs [`DragScale`] keeps a verdict on; past it, the one remembered +/// longest ago is forgotten. +const VERDICTS_KEPT: usize = 256; /// A host frame time (ms) longer than this is not a texture upload but a window that was hidden, /// occluded or stalled: it counts as unknown, like a frame time from no host at all. const FRAME_MS_KNOWN: f64 = 1000.0; +/// A drag counts against its (photo, control) pair only when its full-size drafts took longer than +/// this (ms): a quarter over the budget, so a drag that only just misses it keeps full size. +const VERDICT_MS: f64 = DRAFT_BUDGET_MS * 1.25; + +/// How long (s) a pair found slow drafts small before a drag of it tries full size again: the +/// machine may only have been busy. Each retry that is slow too doubles the wait, up to +/// [`RETRY_MAX_S`]. A retry costs at most a few slow drafts before the drag gives up on full size. +const RETRY_AFTER_S: f64 = 30.0; + +/// The longest wait (s) before a slow pair tries full size again. +const RETRY_MAX_S: f64 = 240.0; + +/// What [`DragScale`] remembers about the drags of one control on one photo. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Verdict { + /// One drag was over budget at full size: maybe a busy moment, so the next stays full size. + Suspect, + /// Two drags in a row were: drafts are small until `until` (the host's clock, s), then a drag + /// tries full size again; `wait` is how long this verdict holds (s). + Slow { until: f64, wait: f64 }, +} + /// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it /// ends (a change in the middle would make the resolution jump), unless its full-size drafts are -/// far over budget. A drag renders at full size when the GPU draws the view and no earlier drag of -/// the same control on this photo went over budget at that size; otherwise at [`CPU_DRAFT_SCALE`] -/// (issue #330). A slow drag is remembered for its photo only: the same control on another photo -/// (another size, other edits) starts at full size again and is timed afresh. Whether the GPU drew a render is not enough on its own: some views (a LUT -/// profile, refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large -/// draft costs a texture upload too, which only the time a draft took to show tells. +/// far over budget. A drag renders at full size when the GPU draws the view and the same control +/// on this photo isn't known to be too slow at that size; otherwise at [`CPU_DRAFT_SCALE`] (issue +/// #330). Whether the GPU drew a render is not enough on its own: some views (a LUT profile, +/// refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large draft +/// costs a texture upload too, which only the time a draft took to show tells. +/// +/// A drag's time is wall-clock time, what the user waits for. So that a moment the machine was busy +/// (another app, a backup) can't make a slider draft small for the rest of the session, a pair is +/// slow only after two drags in a row over [`VERDICT_MS`], and only for a while: after +/// [`RETRY_AFTER_S`] a drag tries full size again, a fast one forgets the verdict and a slow one +/// doubles the wait. A verdict is for its photo only: the same control on another photo (another +/// size, other edits) is timed afresh. #[derive(Clone, Debug, Default)] pub struct DragScale { /// The drag running now: (photo, control) and its scale. @@ -134,26 +162,28 @@ pub struct DragScale { /// The render time (ms) of a draft that reached the screen this frame: the time of the frame /// that uploads it is only known on the next one. pending: Option, - /// Drags whose full-size drafts went over budget, the one remembered longest ago first. - slow: std::collections::VecDeque<(lightcraft_catalog::PhotoId, String)>, + /// Pairs whose full-size drafts went over budget, the one remembered longest ago first. + verdicts: std::collections::VecDeque<((lightcraft_catalog::PhotoId, String), Verdict)>, } impl DragScale { /// The draft scale for this frame, or `None` without a drag. `drag` is the photo and control /// being dragged; `gpu` whether the GPU draws this view; `shown` the key of the whole-frame - /// render on screen (a render from before the drag is not one of its drafts). - pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option) -> Option { + /// render on screen (a render from before the drag is not one of its drafts); `now` the host's + /// clock (s; egui's `input.time`). + pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option, now: f64) -> Option { let Some((photo, control)) = drag else { - self.finish(); + self.finish(now); return None; }; match &self.current { Some(((p, c), s)) if *p == photo && c == control => return Some(*s), - Some(_) => self.finish(), + Some(_) => self.finish(now), None => {} } let key = (photo, control.to_string()); - let s = if gpu && !self.slow.contains(&key) { 1.0 } else { CPU_DRAFT_SCALE }; + let small = matches!(self.verdict(&key), Some(Verdict::Slow { until, .. }) if now < until); + let s = if gpu && !small { 1.0 } else { CPU_DRAFT_SCALE }; self.current = Some((key, s)); self.seen = shown; Some(s) @@ -192,30 +222,58 @@ impl DragScale { } } - /// Remember drags of `control` on `photo` as too slow for full-size drafts (tests). + /// Remember drags of `control` on `photo` as too slow for full-size drafts, for good (tests). #[cfg(test)] pub fn remember_slow(&mut self, photo: lightcraft_catalog::PhotoId, control: &str) { - self.mark_slow((photo, control.to_string())); + self.set_verdict((photo, control.to_string()), Some(Verdict::Slow { until: f64::INFINITY, wait: RETRY_MAX_S })); } - /// Remember a slow drag, forgetting the one remembered longest ago when the set is full (not - /// all of them: each forgotten pair costs one more slow full-size drag). - fn mark_slow(&mut self, key: (lightcraft_catalog::PhotoId, String)) { - if self.slow.contains(&key) { - return; - } - while self.slow.len() >= SLOW_DRAGS_KEPT { - self.slow.pop_front(); - } - self.slow.push_back(key); + fn verdict(&self, key: &(lightcraft_catalog::PhotoId, String)) -> Option { + self.verdicts.iter().find(|(k, _)| k == key).map(|(_, v)| *v) } - /// The drag ended: remember it if its full-size drafts were over budget. - fn finish(&mut self) { + /// Set or (`None`) forget a pair's verdict. A new pair forgets the one remembered longest ago + /// when the list is full (not all of them: each forgotten pair costs one more slow drag). + fn set_verdict(&mut self, key: (lightcraft_catalog::PhotoId, String), verdict: Option) { + let at = self.verdicts.iter().position(|(k, _)| *k == key); + match (at, verdict) { + (Some(i), Some(v)) => { + if let Some(entry) = self.verdicts.get_mut(i) { + entry.1 = v; + } + } + (Some(i), None) => { + self.verdicts.remove(i); + } + (None, Some(v)) => { + while self.verdicts.len() >= VERDICTS_KEPT { + self.verdicts.pop_front(); + } + self.verdicts.push_back((key, v)); + } + (None, None) => {} + } + } + + /// The drag ended. A drag timed at full size updates its pair's verdict: over [`VERDICT_MS`] + /// a first time makes it suspect, a second slow for [`RETRY_AFTER_S`], and a slow retry + /// doubles the wait; within it, the verdict is forgotten. A drag never timed at full size (it + /// drafted small, or was too short) changes nothing. + fn finish(&mut self, now: f64) { if let Some((key, _)) = self.current.take() - && median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS) + && let Some(median) = median_after_first(&self.samples) { - self.mark_slow(key); + let verdict = if median > VERDICT_MS { + let wait = match self.verdict(&key) { + None => None, + Some(Verdict::Suspect) => Some(RETRY_AFTER_S), + Some(Verdict::Slow { wait, .. }) => Some((wait * 2.0).min(RETRY_MAX_S)), + }; + Some(wait.map_or(Verdict::Suspect, |wait| Verdict::Slow { until: now + wait, wait })) + } else { + None + }; + self.set_verdict(key, verdict); } self.samples.clear(); self.seen = None; @@ -442,10 +500,10 @@ mod tests { fn a_drag_shrinks_the_draft_only_on_the_cpu() { use lightcraft_catalog::PhotoId; let mut gpu = DragScale::default(); - assert_eq!(gpu.scale(None, true, None), None, "no drag, no draft"); - assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None), Some(1.0)); + assert_eq!(gpu.scale(None, true, None, 0.0), None, "no drag, no draft"); + assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None, 0.0), Some(1.0)); let mut cpu = DragScale::default(); - assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None), Some(CPU_DRAFT_SCALE)); + assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None, 0.0), Some(CPU_DRAFT_SCALE)); } // Given a drag that started before the GPU was ready, it keeps its size until it ends: the @@ -455,33 +513,120 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, false, None), Some(CPU_DRAFT_SCALE)); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); - assert_eq!(d.scale(None, true, None), None); - assert_eq!(d.scale(drag, true, None), Some(1.0), "the next drag gets full size"); + assert_eq!(d.scale(drag, false, None, 0.0), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0), "the next drag gets full size"); // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends - assert_eq!(d.scale(drag, false, None), Some(1.0)); + assert_eq!(d.scale(drag, false, None, 0.0), Some(1.0)); } - // Given full-size drafts over the slider budget (a LUT profile or refined mask the CPU renders, - // a photo past a GPU limit, a slow GPU), the next drag of that control on that photo drafts - // small; other controls and photos keep full size + /// One whole drag of `drag` at full size whose drafts all take `ms` to show, ending at `now`. + fn timed_drag(d: &mut DragScale, drag: Option<(lightcraft_catalog::PhotoId, &str)>, ms: f64, now: f64) -> Option { + let scale = d.scale(drag, true, None, now); + for key in 1..=6 { + d.observe(key, ms, None); + } + d.scale(None, true, None, now); + scale + } + + // Given full-size drafts over the slider budget twice in a row (a LUT profile or refined mask + // the CPU renders, a photo past a GPU limit, a slow GPU), the next drag of that control on + // that photo drafts small; once could be a busy moment. Other controls and photos keep full size #[test] - fn a_drag_over_budget_drafts_small_the_next_time() { + fn a_drag_over_budget_twice_drafts_small_the_next_time() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let exposure = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(exposure, true, Some(7)), Some(1.0)); + assert_eq!(d.scale(exposure, true, Some(7), 0.0), Some(1.0)); d.observe(7, 400.0, None); // the render from before the drag: not a draft of it - for (key, ms) in [(8, 300.0), (9, 20.0), (10, 25.0), (11, 9.0)] { + for (key, ms) in [(8, 300.0), (9, 30.0), (10, 25.0), (11, 9.0)] { d.observe(key, ms, None); d.observe(key, ms, None); // a render is timed once, however many frames show it } - assert_eq!(d.scale(exposure, true, None), Some(1.0), "over budget, but not far enough to give up mid-drag"); - assert_eq!(d.scale(None, true, None), None); - assert_eq!(d.scale(exposure, true, None), Some(CPU_DRAFT_SCALE)); - assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None), Some(1.0)); - assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(1.0)); + assert_eq!(d.scale(exposure, true, None, 0.0), Some(1.0), "over budget, but not far enough to give up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(timed_drag(&mut d, exposure, 25.0, 1.0), Some(1.0), "one slow drag: maybe the machine was busy"); + assert_eq!(d.scale(exposure, true, None, 2.0), Some(CPU_DRAFT_SCALE)); + d.scale(None, true, None, 2.0); + assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None, 2.0), Some(1.0)); + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 2.0), Some(1.0)); + } + + // Given a pair found slow, it drafts small only for a while: then a drag tries full size + // again; a slow retry doubles the wait (up to its cap), a fast one forgets the verdict + #[test] + fn a_slow_verdict_expires_and_is_retried() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 0.0); + timed_drag(&mut d, drag, 25.0, 0.0); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S - 1.0), Some(CPU_DRAFT_SCALE), "slow until the wait is over"); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S), Some(1.0), "then a drag tries full size: still slow"); + let mut at = RETRY_AFTER_S; + let mut wait = RETRY_AFTER_S * 2.0; + for _ in 0..6 { + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait - 1.0), Some(CPU_DRAFT_SCALE), "the wait doubled to {wait} s"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait), Some(1.0), "a retry after {wait} s, slow again"); + at += wait; + wait = (wait * 2.0).min(RETRY_MAX_S); + } + assert_eq!(wait, RETRY_MAX_S, "the wait stops growing"); + assert_eq!(timed_drag(&mut d, drag, 8.0, at + wait), Some(1.0), "a retry, fast this time"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 1.0), Some(1.0), "forgotten: one slow drag is a suspicion again"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 2.0), Some(1.0), "suspect: still full size"); + assert_eq!(d.scale(drag, true, None, at + wait + 3.0), Some(CPU_DRAFT_SCALE), "slow from the start again: wait 30 s"); + } + + // Given a drag only just over budget, it doesn't count against the pair (it would flip between + // the two sizes); one clearly over does + #[test] + fn only_a_drag_clearly_over_budget_counts() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..4 { + timed_drag(&mut d, drag, 18.0, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 4.0), Some(1.0), "18 ms drafts: just over the 16 ms budget"); + d.scale(None, true, None, 4.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 5.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 6.0); + assert_eq!(d.scale(drag, true, None, 7.0), Some(CPU_DRAFT_SCALE)); + } + + // Given one slow drag while the machine was busy, then fast ones, the slider keeps full size + #[test] + fn a_busy_moment_doesnt_make_a_slider_draft_small() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 40.0, 0.0); + for t in 1..=3 { + assert_eq!(timed_drag(&mut d, drag, 8.0, f64::from(t)), Some(1.0)); + } + timed_drag(&mut d, drag, 40.0, 4.0); + assert_eq!(d.scale(drag, true, None, 5.0), Some(1.0), "the fast drags forgot the first slow one"); + } + + // Given a hostile host clock (NaN, infinite, going backwards), a drag never panics; a clock it + // can't read tries full size + #[test] + fn a_hostile_clock_never_panics() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 100.0); + timed_drag(&mut d, drag, 25.0, 100.0); + assert_eq!(d.scale(drag, true, None, 50.0), Some(CPU_DRAFT_SCALE), "the clock went backwards: still slow"); + d.scale(None, true, None, 50.0); + assert_eq!(d.scale(drag, true, None, f64::NAN), Some(1.0)); + d.scale(None, true, None, f64::NAN); + for now in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] { + timed_drag(&mut d, drag, 25.0, now); + } } // Given full-size drafts within budget (the first one, rebuilding the stage caches, may be @@ -491,18 +636,18 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None); + d.scale(drag, true, None, 0.0); for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { d.observe(key, ms, None); } - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); // hostile times are ignored, never a panic for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { d.observe(key, ms, None); } - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); } // Given renders within budget whose frames (the texture upload) take it over, the drag counts @@ -512,29 +657,30 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None); - for key in 1..=5 { - d.observe(key, 8.0, Some(12.0)); // the previous frame, which uploaded the last draft + for _ in 0..2 { + d.scale(drag, true, None, 0.0); + for key in 1..=5 { + d.observe(key, 8.0, Some(14.0)); // the previous frame, which uploaded the last draft + } + d.observe(5, 8.0, Some(14.0)); + d.scale(None, true, None, 0.0); } - d.observe(5, 8.0, Some(12.0)); - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "8 ms renders + 12 ms frames: over 16 ms"); + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "8 ms renders + 14 ms frames: over budget"); // the same renders without the host's frame times (headless) stay within budget let mut headless = DragScale::default(); - headless.scale(drag, true, None); - for key in 1..=6 { - headless.observe(key, 8.0, None); + headless.scale(drag, true, None, 0.0); + for _ in 0..2 { + timed_drag(&mut headless, drag, 8.0, 0.0); } - headless.scale(None, true, None); - assert_eq!(headless.scale(drag, true, None), Some(1.0)); + assert_eq!(headless.scale(drag, true, None, 0.0), Some(1.0)); // a hostile frame time counts as nothing, never a panic let mut hostile = DragScale::default(); - hostile.scale(drag, true, None); + hostile.scale(drag, true, None, 0.0); for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { hostile.observe(key, 8.0, Some(frame)); } - hostile.scale(None, true, None); - assert_eq!(hostile.scale(drag, true, None), Some(1.0)); + hostile.scale(None, true, None, 0.0); + assert_eq!(hostile.scale(drag, true, None, 0.0), Some(1.0)); } // Given more slow drags than are remembered, only the one remembered longest ago is forgotten: @@ -543,17 +689,17 @@ mod tests { fn a_full_slow_set_forgets_only_the_oldest_drag() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); - for photo in 0..=SLOW_DRAGS_KEPT as u64 { + for photo in 0..=VERDICTS_KEPT as u64 { d.remember_slow(PhotoId(photo), "Exposure"); } - assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None), Some(1.0), "the oldest is forgotten"); - d.scale(None, true, None); - for photo in [1, SLOW_DRAGS_KEPT as u64] { - assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None), Some(CPU_DRAFT_SCALE), "photo {photo}"); - d.scale(None, true, None); + assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None, 0.0), Some(1.0), "the oldest is forgotten"); + d.scale(None, true, None, 0.0); + for photo in [1, VERDICTS_KEPT as u64] { + assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE), "photo {photo}"); + d.scale(None, true, None, 0.0); } d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten - assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE)); } // Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as @@ -563,33 +709,34 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None); + d.scale(drag, true, None, 0.0); for key in 1..=6 { d.observe(key, 6.0, Some(4000.0)); } - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); } // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), - // the drag drops to the small draft after a few of them instead of at its end, and the next - // drag starts small + // the drag drops to the small draft after a few of them instead of at its end; after a second + // such drag, the next starts small #[test] fn a_drag_far_over_budget_drafts_small_before_it_ends() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); let mut scales = Vec::new(); for key in 1..=8 { d.observe(key, 190.0, None); - scales.push(d.scale(drag, true, None)); + scales.push(d.scale(drag, true, None, 0.0)); } // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE)); + d.scale(None, true, None, 0.0); + assert_eq!(timed_drag(&mut d, drag, 190.0, 1.0), Some(1.0), "one such drag: maybe a busy moment"); + assert_eq!(d.scale(drag, true, None, 2.0), Some(CPU_DRAFT_SCALE)); } // Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is From 6a66860ea9db6fa85206f59453a7e84dd1793a32 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Tue, 13 Oct 2026 14:11:27 +0200 Subject: [PATCH 004/158] LR-VIEW-ZOOM: hidden-window test drags twice so it catches a missing frame-time bound (#330) One slow drag only makes the pair suspect, so a single hidden-window drag passed even without the FRAME_MS_KNOWN upper bound. Two such drags followed by a full-size third fail (0.6) when the bound is removed. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/region.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 1c2c78407..23b21bc19 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -703,18 +703,21 @@ mod tests { } // Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as - // unknown: fast renders don't mark the drag slow because the window wasn't drawing + // unknown: fast renders don't mark the drag slow because the window wasn't drawing. Two such + // drags, since one slow drag only makes the pair suspect #[test] fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None, 0.0); - for key in 1..=6 { - d.observe(key, 6.0, Some(4000.0)); + for t in 0..2u32 { + assert_eq!(d.scale(drag, true, None, f64::from(t)), Some(1.0), "drag {}", t + 1); + for key in 1..=6 { + d.observe(u64::from(t) * 10 + key, 6.0, Some(4000.0)); + } + d.scale(None, true, None, f64::from(t)); } - d.scale(None, true, None, 0.0); - assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); + assert_eq!(d.scale(drag, true, None, 2.0), Some(1.0), "after two hidden-window drags: still full size"); } // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), From 2b86ca0537203a8d96f656069f41ee49c303bd90 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:23:40 +0200 Subject: [PATCH 005/158] LR-EXP-TYPE: omit redundant alpha from opaque 8-bit exports (#1670) Preserve real transparency, pixels and embedded metadata. Cover RGB PNG, TIFF compression modes and WebP, plus real-alpha round trips. Co-authored-by: Codex --- crates/engine/src/export.rs | 47 ++++++++++++++++++++++++++++++++++++- docs/parity-checklist.md | 3 ++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 403cb71f6..69f4f7b03 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1351,7 +1351,15 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada let exif = meta.map(lightcraft_meta::write_exif); let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; - let e = EncodeImage::rgba8(&img); + // Opaque photo exports need no alpha channel (#1670). Keep real transparency for + // callers of this encoder, and leave unrelated formats on their existing path. + let rgb: Option> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp) + && img.data.iter().all(|p| p.last() == Some(&255))) + .then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect()); + let e = match &rgb { + Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)), + None => EncodeImage::rgba8(&img), + }; let r = match o.format { ExportFormat::Jpeg => { // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality). @@ -2268,6 +2276,43 @@ mod tests { img } + /// Issue #1670: removing a redundant alpha channel preserves samples and metadata. + #[test] + fn opaque_eight_bit_exports_are_rgb() { + let img = test_image(); + let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() }; + for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { + let options = ExportOptions { format, tiff_compression: compression, ..Default::default() }; + let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap(); + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); + assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB"); + assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8)); + assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels"); + assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice())); + assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test")); + if format == ExportFormat::Png { + assert_eq!(bytes[25], 2, "PNG IHDR declares RGB"); + } + if format != ExportFormat::Tiff { + break; + } + } + } + } + + #[test] + fn eight_bit_exports_preserve_real_alpha() { + let mut img = test_image(); + img.data[0][3] = 128; + for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap(); + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); + assert!(decoded.has_alpha, "{format:?} must retain real alpha"); + assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples"); + } + } + #[test] fn formats_have_magic() { let img = test_image(); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..4f922e47d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -385,7 +385,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | -| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build | +| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts 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 | From e65b97e23922ff0a9d45d981a1dea3f1b8968740 Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Wed, 14 Oct 2026 13:08:15 -0500 Subject: [PATCH 006/158] Model downloads: Time out and cancel the name lookup (#1532) The host name lookup blocked with no deadline before the cancel check, and each address then got the full connect timeout. The lookup now runs on a helper thread under the connect deadline, which all addresses share. --- crates/fetch/src/http.rs | 124 ++++++++++++++++++++++++++++++++------- docs/whats-new.md | 5 ++ 2 files changed, 109 insertions(+), 20 deletions(-) diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index c2b5bee1e..98eee913b 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -10,7 +10,7 @@ //! the downloading thread. No proxies (see docs/ai-masks.md). use std::io::{ErrorKind, Read, Write}; -use std::net::{TcpStream, ToSocketAddrs}; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; @@ -182,27 +182,68 @@ fn tls_config() -> Result, HttpError> { Ok(Arc::new(config)) } +/// Resolve a host name within `deadline`, checking the cancel flag while it runs. The system resolver blocks with no +/// timeout of its own, so it runs on a helper thread (which ends when the resolver returns); a lookup that takes too +/// long is given up on, and Cancel works during it (issue #1532). +fn lookup( + host: &str, + resolve: impl FnOnce() -> std::io::Result> + Send + 'static, + deadline: Instant, + limits: &Limits, +) -> Result, HttpError> { + if limits.cancel.load(Ordering::Relaxed) { + return Err(HttpError::Cancelled); + } + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("lc-fetch-lookup".into()) + .spawn(move || { + let _ = tx.send(resolve()); + }) + .map_err(|e| HttpError::Connect(format!("{host}: {e}")))?; + loop { + if limits.cancel.load(Ordering::Relaxed) { + return Err(HttpError::Cancelled); + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(HttpError::Connect(format!("{host}: the name lookup timed out"))); + } + match rx.recv_timeout(left.min(TICK)) { + Ok(r) => return r.map_err(|e| HttpError::Connect(format!("{host}: {e}"))), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Err(HttpError::Connect(format!("{host}: the name lookup failed"))), + } + } +} + +/// Connect to the first of `addrs` that answers, all of them together within `deadline` (each used to get the whole +/// connect timeout of its own, issue #1532). +fn connect_any(host: &str, addrs: &[SocketAddr], deadline: Instant, limits: &Limits) -> Result { + let mut last = format!("{host}: no address"); + for a in addrs { + if limits.cancel.load(Ordering::Relaxed) { + return Err(HttpError::Cancelled); + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(HttpError::Connect(format!("{host}: connecting timed out"))); + } + match TcpStream::connect_timeout(a, left) { + Ok(s) => return Ok(s), + Err(e) => last = format!("{host}: {e}"), + } + } + Err(HttpError::Connect(last)) +} + impl Stream { fn connect(url: &Url, limits: &Limits) -> Result { - let addrs: Vec<_> = (url.host.as_str(), url.port).to_socket_addrs().map_err(|e| HttpError::Connect(format!("{}: {e}", url.host)))?.collect(); - if addrs.is_empty() { - return Err(HttpError::Connect(format!("{}: no address", url.host))); - } - let mut last = String::new(); - let mut sock = None; - for a in addrs { - if limits.cancel.load(Ordering::Relaxed) { - return Err(HttpError::Cancelled); - } - match TcpStream::connect_timeout(&a, limits.connect) { - Ok(s) => { - sock = Some(s); - break; - } - Err(e) => last = format!("{}: {e}", url.host), - } - } - let sock = sock.ok_or(HttpError::Connect(last))?; + // the lookup and the connection attempts share one connect timeout + let deadline = Instant::now() + limits.connect; + let (host, port) = (url.host.clone(), url.port); + let addrs = lookup(&url.host, move || (host.as_str(), port).to_socket_addrs().map(Iterator::collect), deadline, limits)?; + let sock = connect_any(&url.host, &addrs, deadline, limits)?; sock.set_read_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?; sock.set_write_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?; let _ = sock.set_nodelay(true); @@ -512,6 +553,49 @@ pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result Limits<'_> { + Limits { connect: Duration::from_millis(300), stall: Duration::from_secs(1), cancel } + } + + /// A name lookup that hangs is given up on at the connect timeout, and Cancel stops it at once (issue #1532). + #[test] + fn a_hung_name_lookup_times_out_and_can_be_cancelled() { + let cancel = AtomicBool::new(false); + let slow = || { + std::thread::sleep(Duration::from_secs(10)); + Ok(Vec::new()) + }; + let t = Instant::now(); + let r = lookup("slow.example", slow, Instant::now() + Duration::from_millis(300), &limits(&cancel)); + assert!(matches!(&r, Err(HttpError::Connect(m)) if m.contains("timed out")), "{r:?}"); + assert!(t.elapsed() < Duration::from_secs(2), "{:?}", t.elapsed()); + cancel.store(true, Ordering::Relaxed); + let t = Instant::now(); + assert!(matches!(lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel)), Err(HttpError::Cancelled))); + assert!(t.elapsed() < Duration::from_secs(1)); + // a lookup that answers is used + let cancel = AtomicBool::new(false); + let local: SocketAddr = "127.0.0.1:9".parse().unwrap(); + assert_eq!(lookup("local", move || Ok(vec![local]), Instant::now() + Duration::from_secs(5), &limits(&cancel)).unwrap(), [local]); + } + + /// Several addresses that don't answer share one deadline instead of each getting the full timeout. + #[test] + fn addresses_share_one_connect_deadline() { + let cancel = AtomicBool::new(false); + // non-routable addresses: each either hangs until the deadline or fails at once, never longer + let addrs: Vec = (1..=5).map(|i| format!("10.255.255.{i}:81").parse().unwrap()).collect(); + let t = Instant::now(); + let r = connect_any("blackhole", &addrs, Instant::now() + Duration::from_millis(400), &limits(&cancel)); + assert!(r.is_err()); + assert!(t.elapsed() < Duration::from_millis(1500), "{:?}", t.elapsed()); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..3ca956764 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Reliability +- Model downloads (AI denoise, faces, SAM) give up on a server name that doesn't resolve within the connect timeout, + and Cancel stops a download that is still looking the name up (issue #1532). The lookup used to block with no time + limit, and each of a server's addresses then got the whole connect timeout of its own. + ### 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 48d69bfae5c05d70e09b8cbb66aefd57756d4c31 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:21:00 +0200 Subject: [PATCH 007/158] Drop out-of-range GPS coordinates read from XMP Found while testing crates/meta/src/xmp.rs:364 (parse_gps_coord, used at xmp.rs:539-544) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/meta/src/xmp.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 2a4d753dc..f7f45dee7 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -538,6 +538,8 @@ pub fn parse_xmp(s: &str) -> Result { } if let (Some(lat), Some(lon)) = (first("exif:GPSLatitude").and_then(|s| parse_gps_coord(&s)), first("exif:GPSLongitude").and_then(|s| parse_gps_coord(&s))) + && (-90.0..=90.0).contains(&lat) + && (-180.0..=180.0).contains(&lon) { let altitude = num("exif:GPSAltitude").map(|a| if first("exif:GPSAltitudeRef").as_deref() == Some("1") { -a } else { a }); m.gps = Some(Gps { latitude: lat, longitude: lon, altitude }); @@ -841,6 +843,24 @@ mod tests { assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); } + /// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°. + #[test] + fn xmp_gps_out_of_range_rejected() { + let gps = |lat: &str, lon: &str| { + let x = format!( + r#""# + ); + parse_xmp(&x).unwrap().metadata.gps + }; + assert_eq!(gps("95,0N", "200,30E"), None); + assert_eq!(gps("95,0N", "10,0E"), None); + assert_eq!(gps("10,0S", "200,30W"), None); + assert_eq!(gps("90,0,1N", "0,0E"), None); + assert_eq!(gps("90,0N", "180,0E"), Some(Gps { latitude: 90.0, longitude: 180.0, altitude: None })); + assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None })); + } + /// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how /// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one /// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas From 1130bda456dd0999e0325e3d41fc3280e5b59e14 Mon Sep 17 00:00:00 2001 From: Ege Gursel Date: Thu, 15 Oct 2026 00:23:41 -0400 Subject: [PATCH 008/158] fix(import): keep Lightroom luminance ranges open at white, report unmapped local sliders Two of the three problems reported in #1840: - A luminance range at full strength up to white (LumRange "a b 1 1", a typical highlights mask) ended at our scene white (0 EV) with no feather, so anything brighter (the sun, speculars, a sky after a positive exposure) fell out of the mask along a hard edge. That end now stays open. - Correction sliders we have no counterpart for (LocalGrain, ...) were dropped silently. They are now listed in `unmapped` as "Mask: LocalGrain"; LocalCurveRefineSaturation 100 counts as neutral. Adobe base looks, the third problem, are handled by #1469. Also adds my entry to contributors/people.toml. --- contributors/people.toml | 4 ++++ crates/engine/src/crs_masks.rs | 44 +++++++++++++++++++++++++++++++++- docs/xmp-interop.md | 3 ++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 27a8c8d9c..2548de569 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -31,3 +31,7 @@ display_name = "sanyamsingh001" [people.ArekatlaNishanthchowdary] real_name = "Arekatla Nishanth Chowdary" sync_github_name = true + +[people.egegl] +real_name = "Ege Gursel" +sync_github_name = true diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index 483d1778b..ea831e9c3 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -87,6 +87,21 @@ fn adjust(c: &XmpValue) -> Value { Value::Object(a) } +/// The correction's local sliders we have no counterpart for (e.g. `LocalGrain`), when set: +/// reported, never dropped silently. +fn unmapped_local(c: &XmpValue) -> Vec { + let XmpValue::Struct(m) = c else { return Vec::new() }; + m.keys() + .filter_map(|k| { + let field = k.strip_prefix("crs:").filter(|f| f.starts_with("Local"))?; + // 100 = "curve saturation unchanged", written with every correction + let neutral = if field == "LocalCurveRefineSaturation" { 100.0 } else { 0.0 }; + let set = num(c, k).is_some_and(|v| v != neutral); + (set && !LOCAL.iter().any(|(crs, ..)| *crs == field)).then(|| field.to_string()) + }) + .collect() +} + /// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log /// luminance on −8..+4 EV mapped to 0..1. fn lightness_to_range(l: f64) -> f64 { @@ -163,8 +178,12 @@ fn component(m: &XmpValue, aspect: f64) -> Result { Some("2") => { let q: Vec = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect(); let [a, b, c, d] = q[..] else { return Err("luminance range".into()) }; + // full strength at white (1, the top of their scale) takes in everything brighter: ours + // goes on above scene white, so that end stays open instead of cutting off at 0 EV + let open_top = c >= 1.0; let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d)); - json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)}) + let (hi, hi_feather) = if open_top { (1.0, 0.0) } else { (c, (d - c).max(0.0)) }; + json!({"kind": "luminanceRange", "lo": b, "hi": hi, "lo_feather": (b - a).max(0.0), "hi_feather": hi_feather}) } Some("3") => { let q: Vec = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect(); @@ -201,6 +220,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec, Vec) { if parts.is_empty() { continue; } + skipped.extend(unmapped_local(c)); // brush dabs of one correction are one brush component let mut merged: Vec = Vec::new(); for p in parts { @@ -454,6 +474,28 @@ mod tests { assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture"); } + #[test] + fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() { + // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack + let x = r#" + + + + + + + + +"#; + let d = lightcraft_meta::parse_xmp(x).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).expect("valid settings"); + let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() }; + assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range"); + assert_eq!(s.masks[0].adjust.clarity, -30.0); + assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported"); + } + #[test] fn lrtemplate_corrections() { let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = { diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index ce235e2d3..ee78c67eb 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -181,11 +181,12 @@ mask: `CorrectionName` → name, `CorrectionAmount` → Amount, inactive correct | `LocalExposure2012` | `adjust.exposure` | stored as a fraction, ×4 EV | | `LocalContrast2012`, `LocalHighlights2012`, `LocalShadows2012`, `LocalWhites2012`, `LocalBlacks2012`, `LocalClarity2012`, `LocalTexture`, `LocalDehaze`, `LocalTemperature`, `LocalTint`, `LocalSaturation`, `LocalHue`, `LocalSharpness`, `LocalLuminanceNoise`, `LocalMoire`, `LocalDefringe`, `LocalToningSaturation` | the matching `adjust.*` | stored −1..1, ×100 | | `LocalToningHue` | `adjust.color_hue` | degrees | +| other `Local*` sliders (`LocalGrain`…) | — | no counterpart in our masks: reported as `Mask: LocalGrain` when set (`LocalCurveRefineSaturation` 100 is neutral) | | `Mask/Gradient` (`FullX/Y`, `ZeroX/Y`) | linear gradient | full effect at the Full point | | `Mask/CircularGradient` (`Top/Left/Bottom/Right`, `Angle`, `Feather`, `Flipped`) | radial gradient | box fractions → long-edge radii (presets assume 3:2; sidecars use the photo's shape); `Flipped` → invert | | `Mask/Paint` (`Dabs` "d x y", `Radius`, `Flow`, `CenterWeight`, `MaskValue`) | brush | one brush component per correction; `MaskValue` ≤ 0 erases | | `Mask/Image` `MaskSubType` 1 / 2 | Subject / Sky | other AI selections are reported, not guessed | -| `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; colour ranges are reported | +| `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; a range at full strength at white (third `LumRange` value 1) stays open above our scene white; colour ranges are reported | | `MaskBlendMode` 0 / 1 / 2, `MaskInverted` | add / subtract / intersect, invert | | Applying a preset adds its masks to the photo's own (masks the photo already has are not added twice), and the Amount From 31f4ad43febfe7efacafe863713c2367cb63253b Mon Sep 17 00:00:00 2001 From: Ege Gursel Date: Thu, 15 Oct 2026 00:29:43 -0400 Subject: [PATCH 009/158] contributors: re-add my people.toml entry lost in the merge --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bec67597c..437a836cf 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -45,3 +45,7 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.egegl] +real_name = "Ege Gursel" +sync_github_name = true From 9a0f0761057aabd468336289a351697f51ea1e1d Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Sun, 11 Oct 2026 16:53:11 +0200 Subject: [PATCH 010/158] =?UTF-8?q?LR-VIEW-CLIPPING:=20=E2=8C=A5-drag=20pr?= =?UTF-8?q?eviews=20for=20Blacks,=20Whites,=20Exposure,=20Sharpening=20and?= =?UTF-8?q?=20NR=20(#937)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holding ⌥ (Option) while dragging a slider shows a diagnostic view in the loupe, as in Lightroom; releasing ⌥ or the slider brings the photo back and nothing is saved: - Blacks: shadow clipping (white; black where every channel is 0, the colour of the unclipped channels where only some are). - Whites / Exposure: highlight clipping (black; white where every channel is 255, the clipped channels' colour where only some are). - Sharpening Masking: the weight sharpening gets at each pixel, white on black. - Sharpening Amount / Radius / Detail, luminance NR: the image in grey. New `Overlay` variants (ShadowClip, HighlightClip, SharpenMask, Grey) are drawn by the shared `visualize::apply`, so CPU and GPU agree; the sharpening mask needs the detail planes and renders on the CPU (like HdrRange). The mask uses the same weight function as the render (`finish::sharpen_weight`). With a display profile the clipping views are tested in sRGB, like the histogram, and diagnostic views keep the photo's histogram instead of the view's. Tracker: LR-VIEW-CLIPPING ✅ → 🟡 (J only frames the photo, it doesn't paint clipped pixels) and LR-EDIT-DETAIL-SHARPEN ✅ → 🟡 (Radius and Detail don't reach the render). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/display.rs | 19 +++ crates/engine/src/media.rs | 15 ++- crates/gpu/src/lib.rs | 10 +- crates/pipeline/src/finish.rs | 37 +++++- crates/pipeline/src/lib.rs | 5 +- crates/pipeline/src/local.rs | 7 +- crates/pipeline/src/visualize.rs | 139 ++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/detail.rs | 41 ++++++- crates/ui-egui/src/tests_alt_preview.rs | 93 +++++++++++++++ crates/ui-egui/src/tests_masking.rs | 12 +- crates/ui-egui/src/tests_preview_limit.rs | 7 +- docs/parity-checklist.md | 23 ++-- 13 files changed, 368 insertions(+), 42 deletions(-) create mode 100644 crates/ui-egui/src/tests_alt_preview.rs diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index f47e099b6..8a91a6644 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -152,6 +152,25 @@ mod tests { } } + /// ⌥-drag views on a profiled display (issue #937): the histogram stays the photo's (not the + /// view's), and the clipping view is tested and drawn in sRGB, so it stays black and white. + #[test] + fn diagnostic_views_keep_the_photos_histogram() { + use lightcraft_pipeline::Overlay; + let mut session = crate::Session::with_demo(); + let id = session.visible_cloned()[0]; + let d = std::sync::Arc::new(p3()); + let mut run = |o| session.loupe_job(id, 320, 240, true).unwrap().with_display(Some(d.clone())).with_overlay(o).run().rendered.unwrap(); + let plain = run(Overlay::None); + let clip = run(Overlay::ShadowClip); + let (a, b) = (plain.histogram.mean_luma(), clip.histogram.mean_luma()); + assert!((a - b).abs() < 0.02, "the photo's histogram: {a} vs {b}"); + let px = &clip.image.data; + // (the profile may round white to 254) + let binary = px.iter().filter(|p| p[..3].iter().all(|v| *v <= 2 || *v >= 253)).count(); + assert!(binary as f64 > 0.99 * px.len() as f64, "{binary} of {}", px.len()); + } + #[test] fn load_reports_and_rejects() { let dir = std::env::temp_dir().join(format!("lightcraft-display-{}", std::process::id())); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6d1bfe0de..886c2f21c 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -968,11 +968,24 @@ impl RenderJob { // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use // the GPU when there is one. let gpu = self.cache.is_none(); - let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); + // a clipping view tests sRGB values, like the histogram: rendered in sRGB, then + // converted for the display below + let clip_view = self.request.overlay.is_clipping(); + let request = if clip_view { RenderRequest { display: None, ..self.request } } else { self.request }; + let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &request, self.stages.as_deref(), gpu); // for a display profile: the image goes to the display's values, the histogram // and the cached view preview stay sRGB let mut srgb = None; let display = match (&self.display, self.request.display) { + // a diagnostic view: the histogram stays the photo's (taken before the overlay + // was drawn), and nothing goes to the view cache + (Some(d), Some(_)) if self.request.overlay != lightcraft_pipeline::Overlay::None => { + let converted = if clip_view { d.profile.from_srgb(&mut rendered.image) } else { d.profile.correct(&mut rendered.image) }; + if let Err(e) = converted { + log::warn!("display profile: {e}"); + } + Some(d.id()) + } (Some(d), Some(_)) => { let keep = self.view_cache.is_some() && self.request.quality == Quality::Full; let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep); diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 76fb0f2c1..89315a344 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -118,7 +118,8 @@ pub fn last_fallback() -> Option { #[derive(Clone, Debug, PartialEq, Eq)] pub enum Fallback { /// The GPU was not tried: it is unavailable, disabled or closing ([`unavailable_reason`]), or - /// the request is one only the CPU renders (high bit depth, soft proof, HDR range overlay). + /// the request is one only the CPU renders (high bit depth, soft proof, HDR range or sharpening + /// mask overlay). NotTried, /// The render was tried on the GPU and failed, for this reason (the same text /// [`last_fallback`] reports). The CPU renders it instead. @@ -439,10 +440,13 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R /// Whether the GPU renders requests of this kind at all (a given render can still fall back to /// the CPU): the kernel writes 8-bit output, so high-bit-depth exports and soft proofs render on -/// the CPU, and so does Visualize HDR, which needs the float HDR render. [`try_render`] follows it, +/// the CPU, and so does Visualize HDR, which needs the float HDR render, and the sharpening mask, +/// which needs the detail planes. [`try_render`] follows it, /// and the loupe sizes its slider-drag drafts by it (issue #330), so the two can't drift apart. pub fn handles(depth: lightcraft_pipeline::OutputDepth, proofed: bool, overlay: lightcraft_pipeline::Overlay) -> bool { - depth == lightcraft_pipeline::OutputDepth::U8 && !proofed && overlay != lightcraft_pipeline::Overlay::HdrRange + depth == lightcraft_pipeline::OutputDepth::U8 + && !proofed + && !matches!(overlay, lightcraft_pipeline::Overlay::HdrRange | lightcraft_pipeline::Overlay::SharpenMask) } /// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index eb65d51a2..1fc68be00 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -5,7 +5,7 @@ use lightcraft_color::spline::{Lut1, MonotoneCurve}; use lightcraft_color::transfer::linear_to_srgb; use lightcraft_develop::{DevelopSettings, LocalAdjustments, Process, ToneCurve, VignetteStyle}; use lightcraft_geom::Point; -use lightcraft_raster::Rgba8; +use lightcraft_raster::{Plane, Rgba8}; use crate::colorops::ColorOps; use crate::geometry::Frame; @@ -14,6 +14,36 @@ use crate::output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc} use crate::tone::ToneMap; use crate::{Prepared, SourceInfo, for_rows}; +/// Sharpening's Masking as 0..1. +fn sharpen_masking(s: &DevelopSettings) -> f32 { + (s.detail.sharpen_masking / 100.0) as f32 +} + +/// How much sharpening applies at a pixel whose fine detail (log luminance minus its texture +/// blur) is `det`, with Masking `masking` (0..1): everywhere at 0, only on stronger edges above. +#[inline] +fn sharpen_weight(masking: f32, det: f32) -> f32 { + if masking > 0.0 { smooth(masking * 0.25, masking * 0.25 + 0.15, det.abs()) } else { 1.0 } +} + +/// [`sharpen_weight`] at every pixel of `p` (the view [`crate::Overlay::SharpenMask`] shows). +pub(crate) fn sharpen_weight_plane(p: &Prepared, s: &DevelopSettings) -> Plane { + let masking = sharpen_masking(s); + if masking <= 0.0 { + return Plane::filled(p.log_l.width, p.log_l.height, 1.0); + } + // the detail band the render sharpens (made here when sharpening is off) + let fresh; + let blur = match &p.texture_blur { + Some(b) if (b.width, b.height) == (p.log_l.width, p.log_l.height) => &**b, + _ => { + fresh = lightcraft_raster::blur::gaussian(&p.log_l, crate::local::texture_sigma(p.px_per_long)); + &fresh + } + }; + p.log_l.zip_map(blur, |l, b| sharpen_weight(masking, l - b)) +} + #[inline] fn smooth(e0: f32, e1: f32, x: f32) -> f32 { let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); @@ -244,7 +274,7 @@ impl FinishParams { tex, dehaze, sharpen: (s.detail.sharpen_amount / 150.0) as f32, - sharpen_mask: (s.detail.sharpen_masking / 100.0) as f32, + sharpen_mask: sharpen_masking(s), air: air_pre * gain, air_pre, gain, @@ -509,8 +539,7 @@ pub(crate) fn finish_with( let tame = 1.0 - 0.6 * smooth(0.4, 1.6, det.abs()); delta += tx * 1.1 * det.clamp(-1.0, 1.0) * tame; if sp != 0.0 { - let m = if sharpen_mask > 0.0 { smooth(sharpen_mask * 0.25, sharpen_mask * 0.25 + 0.15, det.abs()) } else { 1.0 }; - delta += sp * 1.3 * det.clamp(-0.8, 0.8) * m; + delta += sp * 1.3 * det.clamp(-0.8, 0.8) * sharpen_weight(sharpen_mask, det); } } // local Noise: smooth (or, negative, boost) small-amplitude detail, keep edges diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 455cec022..c87efaf0f 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -663,8 +663,11 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, } /// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a -/// fresh evaluation. +/// fresh evaluation. For [`Overlay::SharpenMask`], the weight sharpening gets at each pixel. fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option { + if o == Overlay::SharpenMask { + return Some(finish::sharpen_weight_plane(prep, &plan.settings)); + } let m = o.mask(&plan.settings)?; if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) { return Some(e.alpha.clone()); diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index 679acabfe..b6180086c 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -488,6 +488,11 @@ pub struct PlaneSigmas { pub chroma: Option, } +/// Gaussian sigma of the texture / sharpening detail band at `px_per_long` pixels per long edge. +pub(crate) fn texture_sigma(px_per_long: f64) -> f32 { + (0.0018 * px_per_long as f32).max(0.6) +} + pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneSigmas { let ppl = px_per_long as f32; let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0); @@ -508,7 +513,7 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS || local_any(|a| a.sharpness) || local_any(|a| a.noise) || s.masks.iter().any(|m| m.adjust.defringe > 0.0)) - .then(|| (0.0018 * ppl).max(0.6)); + .then(|| texture_sigma(px_per_long)); let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| (0.02 * ppl).max(1.0)); let chroma = (local_any(|a| a.moire) || s.masks.iter().any(|m| m.adjust.noise > 0.0)).then(|| (CHROMA_SIGMA * ppl).max(1.0)); let base_detail = if base.is_some() { tone_base_detail(s, px_per_long) } else { 0 }; diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index 8f3cf1884..3f1ab19fc 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -9,12 +9,20 @@ //! - **Mask overlay** (Masking): the evaluated alpha of one mask, drawn as a colour tint, a colour //! tint on a black-and-white image, the image on black / white, or the alpha as white on black //! ([`MaskView`]). Both renderers hand the same alpha plane (the one the render used) to [`apply`]. +//! - **Clipping while ⌥-dragging** Blacks / Whites / Exposure ([`Overlay::ShadowClip`], +//! [`Overlay::HighlightClip`]): every output channel at 0 (or 255) shown, everything else blank, +//! so the clipped areas, and the channels that clip, are all that's left to see. +//! - **Sharpening mask while ⌥-dragging Masking** ([`Overlay::SharpenMask`]): the weight the +//! render gives sharpening at each pixel, white on black (the CPU renderer draws it: it needs the +//! detail planes). +//! - **Grey while ⌥-dragging** Sharpening or luminance Noise Reduction ([`Overlay::Grey`]): the +//! render in grey, so detail and noise are judged without colour. use std::borrow::Cow; use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; use lightcraft_color::transfer::srgb_to_linear; -use lightcraft_color::{REC2020, SRGB}; +use lightcraft_color::{LUMA_709, REC2020, SRGB}; use lightcraft_develop::DevelopSettings; use lightcraft_raster::{Plane, Rgba8}; @@ -39,6 +47,17 @@ pub enum Overlay { /// Visualize HDR (photos edited in HDR): the SDR image dimmed to grey, with every pixel the HDR /// rendition puts above SDR white painted by how far above it is ([`HDR_BANDS`]). HdrRange, + /// Shadow clipping (⌥ on Blacks): white, black where every channel is 0, and where only some + /// are, the colour of the channels that aren't. + ShadowClip, + /// Highlight clipping (⌥ on Whites / Exposure): black, white where every channel is 255, and + /// where only some are, the colour of those channels. + HighlightClip, + /// Sharpening's weight after Masking (⌥ on Masking): white where it sharpens, black where + /// Masking holds it back. + SharpenMask, + /// The render in grey (⌥ on the Sharpening and luminance Noise Reduction sliders). + Grey, } /// [`Overlay::HdrRange`] colours (sRGB) for 0–1, 1–2, 2–3 and ≥ 3 stops above SDR white. @@ -125,6 +144,10 @@ impl Overlay { Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64), Overlay::AllMasks { view, color, opacity } => (5, pack_mask(0, view, color, opacity) as f64), Overlay::HdrRange => (4, 0.0), + Overlay::ShadowClip => (6, 0.0), + Overlay::HighlightClip => (7, 0.0), + Overlay::SharpenMask => (8, 0.0), + Overlay::Grey => (9, 0.0), } } @@ -143,6 +166,10 @@ impl Overlay { opacity: ((v as u64 >> 44) & 0x7f).min(100) as u8, } } + 6 => Overlay::ShadowClip, + 7 => Overlay::HighlightClip, + 8 => Overlay::SharpenMask, + 9 => Overlay::Grey, _ => Overlay::None, } } @@ -156,9 +183,19 @@ impl Overlay { Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity), Overlay::AllMasks { view, color, opacity } => 5 << 60 | pack_mask(0, view, color, opacity), Overlay::HdrRange => 0x4000, + Overlay::ShadowClip => 0x5000, + Overlay::HighlightClip => 0x6000, + Overlay::SharpenMask => 0x7000, + Overlay::Grey => 0x8000, } } + /// The ⌥-drag clipping views, which test the sRGB values the histogram counts (a display + /// render draws them in sRGB, then converts). + pub fn is_clipping(self) -> bool { + matches!(self, Overlay::ShadowClip | Overlay::HighlightClip) + } + /// The mask an [`Overlay::Mask`] shows (if it exists in `s` and has components). pub fn mask(self, s: &DevelopSettings) -> Option<&lightcraft_develop::Mask> { match self { @@ -181,12 +218,21 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { } /// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask -/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]); `masks` is the set of -/// alphas an [`Overlay::AllMasks`] shows, one per visible mask with components. +/// an [`Overlay::Mask`] shows, or the sharpening weight of an [`Overlay::SharpenMask`], at the +/// image's size (see [`Overlay::mask`]); `masks` is the set of alphas an [`Overlay::AllMasks`] +/// shows, one per visible mask with components. pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, masks: Option<&[Plane]>) { match o { // drawn by the renderer from the HDR rendition ([`hdr_range`]) Overlay::None | Overlay::HdrRange => {} + Overlay::ShadowClip => clipping(img, false), + Overlay::HighlightClip => clipping(img, true), + Overlay::Grey => grey(img), + Overlay::SharpenMask => { + if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) { + mask_view(img, a, MaskView::WhiteOnBlack, [255; 3], 100); + } + } Overlay::PointColorRange(i) => { if let Some(p) = plan.settings.point_colors.get(i as usize) { point_color_range(img, &PointK::new(p)); @@ -224,7 +270,7 @@ pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opa for (x, px) in row.iter_mut().enumerate() { let a = a.data[y * w + x].clamp(0.0, 1.0); let p = [px[0] as f32, px[1] as f32, px[2] as f32]; - let grey = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + let grey = luma8(*px); let out: [f32; 3] = match view { MaskView::Color => std::array::from_fn(|c| p[c] + (col[c] - p[c]) * a * op), MaskView::ColorOnBw => std::array::from_fn(|c| grey + (col[c] - grey) * a * op), @@ -238,6 +284,25 @@ pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opa }); } +/// Rec. 709 luma of an 8-bit pixel (0..255). +fn luma8(p: [u8; 4]) -> f32 { + LUMA_709[0] * p[0] as f32 + LUMA_709[1] * p[1] as f32 + LUMA_709[2] * p[2] as f32 +} + +/// The clipping view: with `high`, each channel at 255 on, the rest off (black where nothing +/// clips); otherwise each channel at 0 off, the rest on (white where nothing clips). +fn clipping(img: &mut Rgba8, high: bool) { + let on = move |v: u8| if (high && v == 255) || (!high && v != 0) { 255 } else { 0 }; + img.map_in_place(|p| [on(p[0]), on(p[1]), on(p[2]), 255]); +} + +fn grey(img: &mut Rgba8) { + img.map_in_place(|p| { + let g = luma8(p).round().clamp(0.0, 255.0) as u8; + [g, g, g, 255] + }); +} + /// Blur radius (Gaussian sigma) of the spot view, as a fraction of the long edge. const SPOT_SIGMA: f64 = 0.006; @@ -255,7 +320,7 @@ fn spots(img: &mut Rgba8, t: u8, ppl: f64) { } let l = lightcraft_raster::Plane::from_fn(w, h, |x, y| { let p = img.data[y * w + x]; - (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) / 255.0 + luma8(p) / 255.0 }); let b = lightcraft_raster::blur::gaussian(&l, (SPOT_SIGMA * ppl).max(0.8) as f32); let thr = spot_threshold(t); @@ -275,7 +340,7 @@ fn point_color_range(img: &mut Rgba8, k: &PointK) { let c = std::array::from_fn(|r| m[r][0] * lin[0] + m[r][1] * lin[1] + m[r][2] * lin[2]); let [l, ch, h] = lab_to_lch(oklab_from_2020(c)); let a = k.weight(l, ch, h); - let grey = 0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32; + let grey = luma8(*px); for c in 0..3 { px[c] = (grey + (px[c] as f32 - grey) * a).round().clamp(0.0, 255.0) as u8; } @@ -296,7 +361,7 @@ pub fn hdr_range(img: &mut Rgba8, hdr: &crate::DeepImage, luma: [f32; 3]) { let i = (y * w + x) * 3; let Some(c) = v.get(i..i + 3) else { continue }; let lum = luma[0] * c[0] + luma[1] * c[1] + luma[2] * c[2]; - let grey = (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) * 0.55; + let grey = luma8(*p) * 0.55; if lum > 1.0 { let stops = lum.log2(); let band = HDR_BANDS[(stops as usize).min(HDR_BANDS.len() - 1)]; @@ -343,6 +408,66 @@ mod tests { assert_eq!(Overlay::from_parts(k, v), Overlay::Spots(37)); } + #[test] + fn clipping_views_show_only_the_clipped_channels() { + // left to right: black, a red that clips only in red, mid grey, white + let src = Rgb32f::from_fn(40, 10, |x, _| match x / 10 { + 0 => [0.0; 3], + 1 => [0.9, 0.02, 0.02], + 2 => [0.18; 3], + _ => [8.0; 3], + }); + let (info, s) = (SourceInfo::default(), DevelopSettings::default()); + let shot = |o: Overlay| render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(40, 10) }); + let plain = shot(Overlay::None).image; + let at = |i: &Rgba8, x: usize| i.data[5 * 40 + x]; + assert_eq!((at(&plain, 5), at(&plain, 35)), ([0, 0, 0, 255], [255; 4]), "the scene clips at both ends"); + assert_eq!(at(&plain, 15)[0], 255, "the red clips in red"); + let hi = shot(Overlay::HighlightClip); + assert_eq!([5, 15, 25, 35].map(|x| at(&hi.image, x)), [[0, 0, 0, 255], [255, 0, 0, 255], [0, 0, 0, 255], [255; 4]]); + let lo = shot(Overlay::ShadowClip); + assert_eq!(at(&lo.image, 5), [0, 0, 0, 255], "black where every channel clips"); + assert_eq!([25, 35].map(|x| at(&lo.image, x)), [[255; 4]; 2], "white where none does"); + // only the view changes: the histogram is the photo's + assert_eq!(hi.histogram.clipping(), shot(Overlay::None).histogram.clipping()); + for o in [Overlay::ShadowClip, Overlay::HighlightClip, Overlay::SharpenMask, Overlay::Grey] { + let (k, v) = o.to_parts(); + assert_eq!(Overlay::from_parts(k, v), o); + assert_ne!(o.key(), Overlay::None.key()); + } + } + + #[test] + fn sharpen_mask_view_follows_masking() { + // a hard vertical edge between two flat halves + let src = Rgb32f::from_fn(200, 100, |x, _| if x < 100 { [0.05; 3] } else { [0.4; 3] }); + let info = SourceInfo::default(); + let mut s = DevelopSettings::default(); + s.detail.sharpen_amount = 60.0; + let shot = + |s: &DevelopSettings| render(&src, &info, s, &RenderRequest { overlay: Overlay::SharpenMask, ..RenderRequest::fit(200, 100) }).image; + let at = |i: &Rgba8, x: usize| i.data[50 * 200 + x][0]; + // Masking 0: sharpening applies everywhere + let open = shot(&s); + assert!(open.data.iter().all(|p| *p == [255; 4]), "all white at Masking 0"); + // Masking 100: only the edge is sharpened + s.detail.sharpen_masking = 100.0; + let edge = shot(&s); + assert_eq!((at(&edge, 20), at(&edge, 180)), (0, 0), "flat areas held back"); + assert!(at(&edge, 100).max(at(&edge, 99)) > 200, "the edge sharpened: {}", at(&edge, 100)); + // the mask shows with sharpening off too + s.detail.sharpen_amount = 0.0; + assert_eq!(shot(&s).data, edge.data); + } + + #[test] + fn grey_view_drops_the_colour() { + let src = Rgb32f::from_fn(8, 8, |_, _| [0.4, 0.1, 0.05]); + let req = RenderRequest { overlay: Overlay::Grey, ..RenderRequest::fit(8, 8) }; + let v = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &req).image; + assert!(v.data.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[0] > 0)); + } + #[test] fn point_color_range_keeps_the_selected_colour_only() { // left half orange, right half blue diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..f9577b224 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -54,6 +54,8 @@ mod tests_album_picker; #[cfg(test)] mod tests_album_remove; #[cfg(test)] +mod tests_alt_preview; +#[cfg(test)] mod tests_appearance; #[cfg(test)] mod tests_before_after; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 68b5346e3..2bae040ba 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -320,11 +320,11 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { /// Whether an overlay can be rendered on a region tile without changing what the tile means. /// Mask overlays (one mask, or every visible mask at once) are evaluated from the same mask -/// alphas as the full render; the other diagnostic overlays either need whole-frame context or -/// deliberately replace the loupe. +/// alphas as the full render, and the clipping and grey views work per pixel of it; the other +/// diagnostic overlays need whole-frame context or deliberately replace the loupe. pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { use lightcraft_pipeline::Overlay; - matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. }) + matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. } | Overlay::ShadowClip | Overlay::HighlightClip | Overlay::Grey) } /// What a window render of the loupe needs to know about the view it belongs to. @@ -518,7 +518,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.image_rect = Some(img_rect); // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); - let overlay = view_overlay(app, &d); + let overlay = view_overlay(app, &d, ui.input(|i| i.modifiers.alt && i.pointer.primary_down())); // a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`; // `ready` first: `available` would wait for a device still being created) let gpu = interacting @@ -1338,13 +1338,42 @@ fn targeted_drag(app: &mut LightcraftApp, resp: &egui::Response, map: &CanvasMap } } -/// The diagnostic overlay the loupe shows (the selected mask, Point Color's visualized range, +/// The view holding ⌥ (Option) while dragging slider `control` shows in the loupe, as in Lightroom +/// (issue #937): clipping on Blacks (shadows) and Whites / Exposure (highlights), the sharpening +/// mask on Masking, the image in grey on the other Sharpening and the luminance Noise Reduction +/// sliders. Releasing ⌥ or the slider brings the photo back; nothing is saved. +pub(crate) fn slider_preview(control: &str) -> Option { + SLIDER_PREVIEWS.iter().find(|(id, _)| *id == control).map(|(_, o)| *o) +} + +/// The controls with a [`slider_preview`], and the view each shows. +pub(crate) const SLIDER_PREVIEWS: [(&str, lightcraft_pipeline::Overlay); 10] = { + use lightcraft_pipeline::Overlay::{Grey, HighlightClip, ShadowClip, SharpenMask}; + [ + ("light.blacks", ShadowClip), + ("light.whites", HighlightClip), + ("light.exposure", HighlightClip), + ("detail.sharpenMasking", SharpenMask), + ("detail.sharpenAmount", Grey), + ("detail.sharpenRadius", Grey), + ("detail.sharpenDetail", Grey), + ("detail.nrLuminance", Grey), + ("detail.nrDetail", Grey), + ("detail.nrContrast", Grey), + ] +}; + +/// The diagnostic overlay the loupe shows (the ⌥-drag preview of the slider being dragged while +/// `alt`: ⌥ is held with the button down; the selected mask, Point Color's visualized range, /// Visualize Spots). -pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { +pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings, alt: bool) -> lightcraft_pipeline::Overlay { use lightcraft_pipeline::{MaskView, Overlay}; if app.ui.fullscreen { return Overlay::None; } + if alt && let Some(o) = app.ui.dragging_control.as_deref().and_then(slider_preview) { + return o; + } // Show every visible mask's region at once (Ctrl+Shift+O / Cmd+Shift+O), ahead of the selected-mask overlay // so the shortcut is not shadowed by the always-on single-mask overlay. if app.ui.right == RightPanel::Masking && app.ui.mask_overlay_all && d.masks.iter().any(|m| m.visible && !m.components.is_empty()) { diff --git a/crates/ui-egui/src/tests_alt_preview.rs b/crates/ui-egui/src/tests_alt_preview.rs new file mode 100644 index 000000000..24abcd7ce --- /dev/null +++ b/crates/ui-egui/src/tests_alt_preview.rs @@ -0,0 +1,93 @@ +//! Headless tests of the ⌥-drag previews (issue #937): holding ⌥ (Option) while dragging Blacks, +//! Whites, Exposure or a Sharpening / Noise Reduction slider shows a diagnostic view in the loupe. + +use std::time::Duration; + +use egui::{Event, Modifiers, PointerButton}; +use lightcraft_pipeline::Overlay; +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::detail::{SLIDER_PREVIEWS, slider_preview}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn detail() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 1400.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + if !h.app.ui.section_open("light") { + h.app.ui.toggle_section("light"); + } + h.app.renderer.keep_pixels = true; + h.settle(SETTLE); + h +} + +/// Share of the loupe render's pixels whose channels are all 0 or 255 (a clipping view). +fn binary_share(h: &Headless) -> f64 { + let t = h.app.renderer.textures.get(&crate::render::Slot::Main).expect("loupe render"); + let px = &t.pixels.as_ref().expect("pixels kept").pixels; + let binary = px.iter().filter(|c| [c.r(), c.g(), c.b()].iter().all(|v| *v == 0 || *v == 255)).count(); + binary as f64 / px.len().max(1) as f64 +} + +#[test] +fn alt_during_a_blacks_drag_shows_shadow_clipping_until_released() { + let mut h = detail(); + assert!(binary_share(&h) < 0.5, "the photo itself is no clipping view"); + let track = h.app.widgets.iter().find(|(w, _)| w == "slider:light.blacks").map(|(_, r)| *r).expect("Blacks slider on screen"); + let from = track.center(); + let before = h.app.session.develop_of(h.app.session.active().expect("photo")).expect("develop").light.blacks; + // a plain drag to the left, button held: the photo as usual + h.app.synthetic.push(Event::PointerMoved(from)); + h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + for i in 1..=8 { + h.app.synthetic.push(Event::PointerMoved(from - egui::vec2(6.0 * i as f32, 0.0))); + } + h.settle(SETTLE); + assert_eq!(h.app.ui.dragging_control.as_deref(), Some("light.blacks")); + assert!(binary_share(&h) < 0.5, "no preview without ⌥"); + // ⌥ pressed mid-drag: the shadow clipping view + h.app.synthetic.push(Event::ModifiersChanged(Modifiers::ALT)); + h.settle(SETTLE); + assert!(binary_share(&h) > 0.99, "the clipping view: {}", binary_share(&h)); + // ⌥ released: the photo again, the drag still on + h.app.synthetic.push(Event::ModifiersChanged(Modifiers::NONE)); + h.settle(SETTLE); + assert!(binary_share(&h) < 0.5, "the photo is back"); + h.app.synthetic.push(Event::PointerButton { + pos: from - egui::vec2(48.0, 0.0), + button: PointerButton::Primary, + pressed: false, + modifiers: Modifiers::NONE, + }); + h.settle(SETTLE); + assert_eq!(h.app.ui.dragging_control, None); + let after = h.app.session.develop_of(h.app.session.active().expect("photo")).expect("develop").light.blacks; + assert!(after < before, "the drag itself still moved Blacks: {before} → {after}"); +} + +#[test] +fn each_slider_has_its_preview() { + assert_eq!(slider_preview("light.blacks"), Some(Overlay::ShadowClip)); + for c in ["light.whites", "light.exposure"] { + assert_eq!(slider_preview(c), Some(Overlay::HighlightClip), "{c}"); + } + assert_eq!(slider_preview("detail.sharpenMasking"), Some(Overlay::SharpenMask)); + for c in ["detail.sharpenAmount", "detail.sharpenRadius", "detail.sharpenDetail", "detail.nrLuminance", "detail.nrDetail", "detail.nrContrast"] { + assert_eq!(slider_preview(c), Some(Overlay::Grey), "{c}"); + } + for c in ["light.contrast", "color.saturation", "detail.nrColor"] { + assert_eq!(slider_preview(c), None, "{c}"); + } + // every mapped id is a real control, once (a rename would silently drop its preview) + for (i, (c, _)) in SLIDER_PREVIEWS.iter().enumerate() { + assert!(lightcraft_develop::controls::find(c).is_some(), "{c}"); + assert!(!SLIDER_PREVIEWS[..i].iter().any(|(d, _)| d == c), "{c} twice"); + } +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index bbea5356e..5e7beba9b 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -159,23 +159,23 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() { let d = develop(&h); assert!(d.masks[0].adjust.exposure > 0.0); assert!(d.masks[0].visible, "hiding the overlay must not disable the mask"); - assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None); + assert_eq!(crate::panels::detail::view_overlay(&h.app, &d, false), Overlay::None); let mask = d.masks[0].clone(); let r = h.request("ui.key", json!({"key": "o"}), T); assert_eq!(r["ok"], true, "{r}"); assert!(h.app.ui.mask_overlay, "O can restore the overlay"); assert_eq!(develop(&h).masks[0], mask, "toggling the overlay leaves the mask and its effect unchanged"); - assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::Mask { .. })); + assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::Mask { .. })); // Ctrl+Shift+O / Cmd+Shift+O shows every visible mask's region at once (the new command), ahead of the selected // mask overlay so it is not shadowed by the always-on single-mask overlay. exec(&mut h, "view.maskOverlayAll", json!({})); assert!(h.app.ui.mask_overlay_all, "the all-masks overlay command turns it on"); - assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); + assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::AllMasks { .. })); exec(&mut h, "view.maskOverlayAll", json!({})); assert!(!h.app.ui.mask_overlay_all, "the all-masks overlay command toggles it off"); - assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); + assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h), false), Overlay::AllMasks { .. })); // Failed edits and global adjustments must not hide the mask overlay. assert!(h.app.run("mask.adjust", json!({"id": 999, "values": {"exposure": 1.0}})).is_err()); @@ -274,12 +274,12 @@ fn mask_overlay_keys_and_pins() { assert_eq!(h.app.session.active_mask, Some(2)); // the loupe asks the renderer for the selected mask's overlay let d = develop(&h); - let o = crate::panels::detail::view_overlay(&h.app, &d); + let o = crate::panels::detail::view_overlay(&h.app, &d, false); assert_eq!(o, Overlay::Mask { id: 2, view: MaskView::Color, color: [230, 30, 40], opacity: 50 }); // O toggles it, Shift+O cycles the colour (and leaves the crop overlay alone) h.request("ui.key", json!({"key": "o"}), T); assert!(!h.app.ui.mask_overlay); - assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None); + assert_eq!(crate::panels::detail::view_overlay(&h.app, &d, false), Overlay::None); h.request("ui.key", json!({"key": "o"}), T); let crop = h.app.ui.crop_overlay; let colour = h.app.ui.mask_overlay_color; diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index dfddb9c4f..4be0ad35f 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -212,7 +212,10 @@ mod in_the_loupe { h.settle(SETTLE); let id = h.app.session.active().expect("a photo"); let d = h.app.session.develop_of(id).expect("settings"); - assert!(matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::Mask { .. }), "the mask overlay is shown"); + assert!( + matches!(crate::panels::detail::view_overlay(&h.app, &d, false), lightcraft_pipeline::Overlay::Mask { .. }), + "the mask overlay is shown" + ); assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "showing the overlay keeps zoom and pan"); let main = rendered_long_edge(&h); assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); @@ -250,7 +253,7 @@ mod in_the_loupe { let id = h.app.session.active().expect("a photo"); let d = h.app.session.develop_of(id).expect("settings"); assert!( - matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::AllMasks { .. }), + matches!(crate::panels::detail::view_overlay(&h.app, &d, false), lightcraft_pipeline::Overlay::AllMasks { .. }), "the all-masks overlay is shown" ); let main = rendered_long_edge(&h); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 043aebb50..8c1fa715e 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-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (⌥-drag previews on Blacks / Whites / Exposure / Sharpening / luminance NR, #937; LR-VIEW-CLIPPING and LR-EDIT-DETAIL-SHARPEN downgraded to 🟡: J doesn't paint clipped pixels, Radius / Detail unused; ⌥-drag previews on Blacks / Whites / Exposure / Sharpening / luminance NR, #937; LR-VIEW-CLIPPING and LR-EDIT-DETAIL-SHARPEN downgraded to 🟡: J doesn't paint clipped pixels, Radius / Detail unused; 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 @@ -28,10 +28,10 @@ cited here still exists. Formerly docs/parity.md. |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 12 | 3 | 1 | 1 | 4/6 (67%) | 5/5 (100%) | | B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) | -| C. Views & navigation (VIEW) | 15 | 2 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | +| C. Views & navigation (VIEW) | 14 | 3 | 1 | 0 | 7/9 (78%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | -| F. Edit panel — global adjustments (EDIT) | 44 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | +| F. Edit panel — global adjustments (EDIT) | 43 | 3 | 3 | 1 | 27/28 (96%) | 13/14 (93%) | | G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) | | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | | Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) | -| **Total** | 418 | 62 | 59 | 35 | 193/201 (96%) | 148/161 (92%) | +| **Total** | 416 | 64 | 59 | 35 | 191/201 (95%) | 148/161 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 539 in-scope rows — P0 98.0% of 201 · P1 95.0% of 161 · P2 55.7% of 176. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.1%** of 539 in-scope rows — P0 97.5% of 201 · P1 95.0% of 161 · P2 55.7% of 176. ## Top gaps and shortcut notes @@ -140,7 +140,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | -| LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | +| LR-VIEW-CLIPPING | Clipping indicators | P0 | 🟡 | `cmd:view.clipping`, `crates/ui-egui/src/panels/detail.rs` (`clipping_overlay`, `slider_preview`) | J only frames the photo red (highlights) / blue (shadows) when any pixel clips; it doesn't paint the clipped pixels. ⌥-dragging Blacks / Whites / Exposure shows the clipped pixels and channels (`Overlay::ShadowClip` / `HighlightClip`, issue #937) | | LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag | | LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | |, `apps/lightcraft/src/hdr_display.rs` (`choose`, `preferred_hdr_format`) | SDR/HDR decision (platform, surface format, OS headroom, `LIGHTCRAFT_HDR_DISPLAY=0`) is unit-tested and logged at start; always SDR today because eframe configures an 8-bit surface; no OS headroom query yet; | @@ -180,12 +180,12 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto`, `crates/pipeline/src/auto.rs` | a Lightroom-like recipe calibrated black-box on Lightroom's Auto results for 24 Sony raws: Exposure from the scene's scene-linear log-average luminance (Lightroom's observed slope, -0.41 EV per EV), Highlights -66 and Shadows +52 (Lightroom's average Auto values), Contrast +68 and Vibrance +30 fitted on our scales. Held out one photo at a time: on the 16 photos whose start Sony's DRO doesn't brighten, a median dE76 of 6.4 from Lightroom's Auto results against 8.3 unedited; on all 24, 7.7 against 7.9 (issues #318, #209). Calibrated on Sony files only; Whites and Blacks stay 0 | | LR-EDIT-BW | Black & white | P0 | ✅ | `cmd:develop.treatment` | | | LR-EDIT-HDR-MODE | HDR editing | P2 | ✅ | `cmd:develop.hdr`, `ctl:hdr.maxEv`, `crates/ui-egui/src/panels/edit.rs` (`hdr_row`) | HDR button next to B&W in the Edit panel header, as Lightroom (Visualize HDR and the headroom limit in the Light panel while it's on); see Q. HDR | -| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | | +| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | ⌥-drag: highlight clipping view (#937) | | LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | ✅ | `ctl:light.contrast` | | | LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects, lens fringes or another surface beside it) and white where there is none, and pale partly clipped content (a white sky) turns white on its way to clipping (#523); binned previews and thumbnails decide clipping per sample before averaging (`develop_binned_masked`, `highlight::reconstruct_masked`), so a block with one clipped specular sample keeps its mean instead of jumping to the clip level, which reconstruction turned into green speckles at 500 px (#548) From process V2 the Highlights/Shadows base is the edge-aware filter of the 0.15 %-of-long-edge median of log luminance: twigs and fine texture take their surroundings' shift, so bright areas keep their local contrast as with Exposure (V1's base kept ~0.6 of it at −100, #632); region edges stay sharp | | LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | From process V2, the same base as Highlights: dark areas keep their local contrast when lifted (#632) | -| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | | -| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | | +| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | ⌥-drag: highlight clipping view, black where nothing clips (#937) | +| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | ⌥-drag: shadow clipping view, white where nothing clips (#937) | | LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*` | | | LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag; ⌥-drag moves it a tenth as far for fine adjustment, issue #1363); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON | | LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | | @@ -206,8 +206,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | | LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | ✅ | `ctl:vignette.*`, `crates/pipeline/src/finish.rs`, `crates/ui-egui/src/panels/edit.rs` | style picker (Highlight / Color / Paint) in the Effects section | | LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | `ctl:grain.*` | | -| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | no Alt-drag mask preview | -| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | radii in the original's pixels, so grid, loupe and smaller exports look like the full-size render scaled down (`local::nr_params`, `Plan::detail_scale`, #960) | +| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | 🟡 | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | ⌥-drag: Masking shows the sharpening mask white on black, the other sliders the image in grey (#937). Radius and Detail are saved but the render doesn't use them yet (fixed band at 0.18% of the long edge, `local::texture_sigma`) | +| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | radii in the original's pixels, so grid, loupe and smaller exports look like the full-size render scaled down (`local::nr_params`, `Plan::detail_scale`, #960); ⌥-drag: the image in grey (#937) | | LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | preview-size independent like luminance NR (#960) | | LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | 🟡 | cmd:denoise.toggle, cmd:denoise.models.install, cmd:denoise.queue, ctl:enhance.denoise, `crates/denoise/`, `docs/denoise.md` | non-destructive Bayer RAW, opt-in weights, pure-Rust CPU/GPU; GPU convolution uses independently written shared scalars (Metal equivalence regression, issue #479); tiles can also run on another computer's graphics card (`lightcraft-cli denoise-serve`, `crates/denoise/src/remote.rs`); X-Trans/linear RGB, model policy and Lightroom fidelity remain; closing the model setup without installing turns the switch back off (#922) | | LR-EDIT-DETAIL-RAWDETAILS | Improved demosaic toggle | P2 | ⬜ | | | @@ -871,6 +871,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LR-META-INFO: Nikon DSLR raws show their lens from the maker note; status unchanged (✅) | | 2026-10-13 | patch | LR-IMP-LOCAL: browsing a Local folder again forgets the untouched records of files deleted or moved by another app (issue #1392); status unchanged | | 2026-10-13 | minor | LR-MERGE-PANO: a panorama keeps its photos' resolution (up to 512 MP / 65,000 px) instead of a fixed 40 MP, held to what fits in memory (issue #1455); status unchanged | +| 2026-10-11 | minor | ⌥-drag previews (issue #937) noted on LR-EDIT-LIGHT-EXPOSURE / WHITES / BLACKS and LR-EDIT-DETAIL-NR; LR-VIEW-CLIPPING ✅ → 🟡 (J frames the photo, doesn't paint clipped pixels), LR-EDIT-DETAIL-SHARPEN ✅ → 🟡 (Radius and Detail don't reach the render) | | 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 342afcb81eecc30250c85bbe9506940350c2c510 Mon Sep 17 00:00:00 2001 From: witkowski01 Date: Thu, 15 Oct 2026 10:18:46 +0200 Subject: [PATCH 011/158] =?UTF-8?q?i18n(pl):=20menu=20command=20names,=20t?= =?UTF-8?q?he=20formats=20left=20in=20English,=20and=20Vibrance=20as=20Jas?= =?UTF-8?q?krawo=C5=9B=C4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/locales/pl-formats.json | 84 +++++++++--------- crates/ui-egui/locales/pl.json | 114 ++++++++++++++++++++++++- 2 files changed, 154 insertions(+), 44 deletions(-) diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index 1886098df..19f2c1a38 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -248,50 +248,50 @@ "{n} photos opened for editing; each is stacked with its original": "Zdjęcia otwarte do edycji: {n}; każde połączono w stos z oryginałem", "· {in_source} identical copies": "· identyczne kopie: {in_source}", "· {trashed} in Recently Deleted": "· w Ostatnio usuniętych: {trashed}", - " in {g} group{}": " in {g} group{}", - " · kept, not applied: {}": " · kept, not applied: {}", - " — kept but not applied: {}": " — kept but not applied: {}", - "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", - "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", - "Delete these {n} effect presets?": "Delete these {n} effect presets?", - "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", - "Effect: {name}": "Effect: {name}", - "Exported {} effect preset{}": "Exported {} effect preset{}", + " in {g} group{}": " w grupach: {g}{:.0}", + " · kept, not applied: {}": " · zachowane, niezastosowane: {}", + " — kept but not applied: {}": " — zachowane, ale niezastosowane: {}", + "Apply to “{mask}” on Selected Photos": "Zastosuj do „{mask}” na zaznaczonych zdjęciach", + "Delete the effect preset “{name}”?": "Usunąć ustawienie wstępne efektu „{name}”?", + "Delete these {n} effect presets?": "Usunąć te ustawienia wstępne efektów ({n})?", + "Delete {n} effect preset{}? This can't be undone.": "Usunąć ustawienia wstępne efektów ({n})?{:.0} Tej operacji nie można cofnąć.", + "Effect: {name}": "Efekt: {name}", + "Exported {} effect preset{}": "Wyeksportowane ustawienia wstępne efektów: {}{:.0}", "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", - "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", - "{n} preset{}": "{n} preset{}", - "{count} more warnings.": "{count} more warnings.", - "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", - "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", - "Applied {name}": "Applied {name}", - "Delete “{name}”?": "Delete “{name}”?", - "Updated {name}": "Updated {name}", - "Exported to {path}": "Exported to {path}", - "Saved template {name}": "Saved template {name}", - "Imported {name}": "Imported {name}", - "Skipped {problem}": "Skipped {problem}", - "…and {more} more": "…and {more} more", - "Template preview: {name}": "Template preview: {name}", - "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", - "{n} pages": "{n} pages", - "{n} JPEG files": "{n} JPEG files", - "{s} s of AV1 video": "{s} s of AV1 video", - "Slideshow exported: {what}": "Slideshow exported: {what}", - " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", - "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", - "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", - "Create the keyword “{name}”": "Create the keyword “{name}”", - "new, inside {parent}": "new, inside {parent}", - "Keywords of {n} selected photos": "Keywords of {n} selected photos", + "Update “{name}” with Current Settings": "Aktualizuj „{name}” bieżącymi ustawieniami", + "{n} preset{}": "Ustawienia wstępne: {n}{:.0}", + "{count} more warnings.": "Pozostałe ostrzeżenia: {count}.", + "{photos} catalog records processed; {collections} collections.": "Przetworzone rekordy katalogu: {photos}; kolekcje: {collections}.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Brakujące oryginały: {missing}. Ich rekordy w katalogu są dostępne; użyj polecenia „Znajdź brakujące zdjęcia”, aby ponownie je połączyć.", + "Applied {name}": "Zastosowano: {name}", + "Delete “{name}”?": "Usunąć „{name}”?", + "Updated {name}": "Zaktualizowano: {name}", + "Exported to {path}": "Wyeksportowano do {path}", + "Saved template {name}": "Zapisano szablon {name}", + "Imported {name}": "Zaimportowano: {name}", + "Skipped {problem}": "Pominięto: {problem}", + "…and {more} more": "…i jeszcze {more}", + "Template preview: {name}": "Podgląd szablonu: {name}", + "{n} photos in view · {slides} slides · {time}": "Zdjęcia w widoku: {n} · slajdy: {slides} · {time}", + "{n} pages": "Strony: {n}", + "{n} JPEG files": "Pliki JPEG: {n}", + "{s} s of AV1 video": "{s} s wideo AV1", + "Slideshow exported: {what}": "Wyeksportowano pokaz slajdów: {what}", + " · {n} skipped (could not be rendered)": " · pominięte (nie udało się wyrenderować): {n}", + "{n} raws can now be developed from their raw data": "Pliki RAW, które można teraz wywołać z danych surowych: {n}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nie udało się zapisać plików pomocniczych XMP zdjęć ({n}). Czy ich folder jest offline? LightCraft ponawia próby.", + "Create the keyword “{name}”": "Utwórz słowo kluczowe „{name}”", + "new, inside {parent}": "nowe, wewnątrz {parent}", + "Keywords of {n} selected photos": "Słowa kluczowe zaznaczonych zdjęć ({n})", "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", - "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", - "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", - "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", - "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", - "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", - "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft nieoczekiwanie zakończył działanie: {e}\n\nSzczegóły są w pliku dziennika: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft nieoczekiwanie zakończył działanie: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Zmienia nazwę twarzy w całej bibliotece (liczba twarzy: {n}).{:.0} Pliki nie są zmieniane.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}” to już istniejąca osoba. Połączenie utworzy jedną osobę; Cofnij ponownie je rozdzieli.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Dodano połączenie {what} w rozdzielczości {mp}: pełny rozmiar, {full}, wymaga więcej pamięci, niż ma ten komputer", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Dodano połączenie {what} w rozdzielczości {mp}, największej możliwej dla panoramy (pełny rozmiar to {full})", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Przestrzeń kolorów: Rec. 2020 {curve} (HDR AVIF)", + "{n} selected photos · {pages} pages": "Zaznaczone zdjęcia: {n} · strony: {pages}", "Already in “{}”": "Już w „{}”", "Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?", diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index 7416ce241..81a2ab60b 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -1511,7 +1511,7 @@ "Versions (Shift+V)": "Wersje (Shift+V)", "Vertical": "Pionowo", "Vertical text": "Tekst pionowy", - "Vibrance": "Dynamika", + "Vibrance": "Jaskrawość", "Videos": "Wideo", "View": "Widok", "Viewing and sharing": "Wyświetlanie i udostępnianie", @@ -2157,5 +2157,115 @@ "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "To jest katalog Lightrooma, a nie biblioteka LightCraft. Zaimportować go zamiast tego do biblioteki LightCraft?", "Unnamed Faces": "Nienazwane twarze", "Vertical inset (% of short edge)": "Margines pionowy (% krótszego boku)", - "not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)" + "not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)", + "Set Folder Color Label": "Ustaw etykietę kolorystyczną folderu", + "Refresh View": "Odśwież widok", + "Hold View Until Refreshed": "Wstrzymaj widok do odświeżenia", + "Photo Below": "Zdjęcie poniżej", + "Photo Above": "Zdjęcie powyżej", + "Cycle Flag": "Przełącz oznaczenie", + "Increase Flag Status": "Podnieś status oznaczenia", + "Decrease Flag Status": "Obniż status oznaczenia", + "Update to Current Process": "Aktualizuj do bieżącej wersji procesu", + "HDR": "HDR", + "Previous History Step": "Poprzedni krok historii", + "Next History Step": "Następny krok historii", + "Find Blemishes": "Znajdź skazy", + "Keyword Info": "Informacje o słowie kluczowym", + "Create Keyword": "Utwórz słowo kluczowe", + "Edit Keyword": "Edytuj słowo kluczowe", + "Move Keyword": "Przenieś słowo kluczowe", + "Put New Keywords Inside": "Umieszczaj nowe słowa kluczowe wewnątrz", + "Mask Effect Presets": "Ustawienia wstępne efektów maski", + "Apply Mask Effect": "Zastosuj efekt maski", + "Save Mask Effect": "Zapisz efekt maski", + "Update Mask Effect": "Aktualizuj efekt maski", + "Rename Mask Effect": "Zmień nazwę efektu maski", + "Duplicate Mask Effect": "Duplikuj efekt maski", + "Delete Mask Effect": "Usuń efekt maski", + "Favorite Mask Effect": "Oznacz efekt maski jako ulubiony", + "Move Mask Effect to Group": "Przenieś efekt maski do grupy", + "Rename Mask Effect Group": "Zmień nazwę grupy efektów maski", + "Set Effect for New Masks": "Ustaw efekt dla nowych masek", + "Import Mask Effects": "Importuj efekty maski", + "Export Mask Effects": "Eksportuj efekty maski", + "HDR for Each Bracket Set": "HDR dla każdej serii ekspozycji", + "Write Waiting XMP Sidecars": "Zapisz oczekujące pliki pomocnicze XMP", + "Slideshow Playback Settings": "Ustawienia odtwarzania pokazu slajdów", + "Slideshow Layout Settings": "Ustawienia układu pokazu slajdów", + "Slideshow Overlay Settings": "Ustawienia nakładek pokazu slajdów", + "Slideshow Title Settings": "Ustawienia tytułów pokazu slajdów", + "Slideshow Templates": "Szablony pokazu slajdów", + "Apply Slideshow Template": "Zastosuj szablon pokazu slajdów", + "Save Slideshow Template": "Zapisz szablon pokazu slajdów", + "Update Slideshow Template": "Aktualizuj szablon pokazu slajdów", + "Rename Slideshow Template": "Zmień nazwę szablonu pokazu slajdów", + "Delete Slideshow Template": "Usuń szablon pokazu slajdów", + "Import Slideshow Template": "Importuj szablon pokazu slajdów", + "Export Slideshow Template": "Eksportuj szablon pokazu slajdów", + "Slideshow Slide Preview": "Podgląd slajdu pokazu", + "Export Slideshow": "Eksportuj pokaz slajdów", + "Slideshow Export Status": "Stan eksportu pokazu slajdów", + "Export Contact Sheet PDF": "Eksportuj arkusz stykowy PDF", + "Apple Photos Imports": "Importy z aplikacji Zdjęcia", + "Print Settings": "Ustawienia drukowania", + "Set Print Settings": "Zmień ustawienia drukowania", + "Reset Print Settings": "Przywróć ustawienia drukowania", + "Add Print Cell": "Dodaj komórkę wydruku", + "Move Print Cell": "Przenieś komórkę wydruku", + "Resize Print Cell": "Zmień rozmiar komórki wydruku", + "Delete Print Cell": "Usuń komórkę wydruku", + "Arrange Print Cell": "Rozmieść komórkę wydruku", + "Set Print Cell Photo": "Ustaw zdjęcie komórki wydruku", + "Move Print Cell to Page": "Przenieś komórkę wydruku na stronę", + "Auto Layout": "Układ automatyczny", + "New Print Page": "Nowa strona wydruku", + "Clear Print Layout": "Wyczyść układ wydruku", + "Print to PDF": "Drukuj do PDF", + "Print Templates": "Szablony wydruku", + "Save Print Template": "Zapisz szablon wydruku", + "Apply Print Template": "Zastosuj szablon wydruku", + "Delete Print Template": "Usuń szablon wydruku", + "Recheck Preview-Only Raws": "Sprawdź ponownie pliki RAW dostępne tylko jako podgląd", + "Scan Folder for Changes": "Skanuj folder w poszukiwaniu zmian", + "Rename Person": "Zmień nazwę osoby", + "Read Focus Points": "Odczytaj punkty ostrości", + "Read Focus Points in the Background": "Odczytuj punkty ostrości w tle", + "Background Tasks": "Zadania w tle", + "Cancel Background Task": "Anuluj zadanie w tle", + "Select Only Active Photo": "Zaznacz tylko aktywne zdjęcie", + "Show Focus Point": "Pokaż punkt ostrości", + "Zoom to Focus Point": "Powiększ do punktu ostrości", + "Show in Compare": "Pokaż w porównaniu", + "Increase Grid Thumbnail Size": "Zwiększ rozmiar miniatur w siatce", + "Decrease Grid Thumbnail Size": "Zmniejsz rozmiar miniatur w siatce", + "Visualize HDR": "Wizualizuj HDR", + "Cycle Tool Overlay": "Przełącz nakładkę narzędzia", + "Choose Layout Image…": "Wybierz obraz układu…", + "Show Layout Image": "Pokaż obraz układu", + "Layout Image Opacity": "Krycie obrazu układu", + "Layout Image Matte": "Matowanie obrazu układu", + "Solo Mode": "Tryb solo", + "Slideshow Options…": "Opcje pokazu slajdów…", + "Export Slideshow…": "Eksportuj pokaz slajdów…", + "Pause / Resume Slideshow": "Wstrzymaj / wznów pokaz slajdów", + "Next Slide": "Następny slajd", + "Previous Slide": "Poprzedni slajd", + "End Slideshow": "Zakończ pokaz slajdów", + "Contact Sheet PDF…": "Arkusz stykowy PDF…", + "Edit in PhotoCraft": "Edytuj w PhotoCraft", + "Edit in Other Editor": "Edytuj w innym edytorze", + "Open Original in Other Editor (RAW, no copy)": "Otwórz oryginał w innym edytorze (RAW, bez kopii)", + "Save Current Settings as New Effect Preset…": "Zapisz bieżące ustawienia jako nowe ustawienie wstępne efektu…", + "Manage Effect Presets…": "Zarządzaj ustawieniami wstępnymi efektów…", + "Export Effect Presets…": "Eksportuj ustawienia wstępne efektów…", + "Display Profile": "Profil ekranu", + "HDR Headroom": "Zapas jasności HDR", + "SDR Brightness": "Jasność SDR", + "SDR Contrast": "Kontrast SDR", + "SDR Highlights": "Światła SDR", + "SDR Shadows": "Cienie SDR", + "SDR Whites": "Biele SDR", + "SDR Clarity": "Przejrzystość SDR", + "Face Models on the GPU": "Modele twarzy przetwarzane na GPU" } From c990dec0f0f15acb345f9340865eabe24a16272c Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Thu, 15 Oct 2026 11:27:07 +0200 Subject: [PATCH 012/158] LR-VIEW-ZOOM: tracker row describes the two-strike, expiring slow-drag verdict (#330) Co-Authored-By: Claude Opus 5.5 --- docs/parity-checklist.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..b2d471526 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-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **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 @@ -131,7 +131,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s; a drag within 20 ms forgets it. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | LR-VIEW-ZOOM: a slow slider-drag verdict needs two drags over 20 ms and expires after 30 s (doubling to 240 s); hidden-window frame times are ignored; the slow set forgets its oldest entry, not all (issue #330); status unchanged | | 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 (🟡) | | 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 (🟡) | | 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 (✅) | From de038ca3d4cc694df97dc1f979186513dc49db96 Mon Sep 17 00:00:00 2001 From: justinjohn0306 <34035011+justinjohn0306@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:31:06 +0530 Subject: [PATCH 013/158] #1880: allow 102 MP AI denoise inputs --- crates/denoise-core/src/run.rs | 17 ++++++++++++++++- docs/denoise.md | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/denoise-core/src/run.rs b/crates/denoise-core/src/run.rs index 987a859b9..4314ed8c3 100644 --- a/crates/denoise-core/src/run.rs +++ b/crates/denoise-core/src/run.rs @@ -14,7 +14,7 @@ use crate::manifest::Gain; use crate::tiles::{starts, weight}; /// Most pixels one picture may have (guards the allocations that follow from its size). -pub const MAX_PIXELS: usize = 100_000_000; +pub const MAX_PIXELS: usize = 110_000_000; /// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from. const MIN_SIGNAL: f32 = 0.003; @@ -642,4 +642,19 @@ mod tests { assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_)))); assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_)))); } + + #[test] + fn accepts_102_mp_inputs_and_rejects_inputs_above_the_limit() { + let layout = Layout { dx: 0, dy: 0 }; + let model = Mock { tile: 32, scale: 1.0 }; + let p = params(32, 8, Gain::None); + assert!(matches!( + denoise_bayer(&[], 11_648, 8_736, layout, &model, &p, &Control::default()), + Err(Error::Input(message)) if message == "the mosaic does not have width × height samples" + )); + assert!(matches!( + denoise_bayer(&[], MAX_PIXELS + 1, 1, layout, &model, &p, &Control::default()), + Err(Error::Input(message)) if message == "the picture has no size, or is too large" + )); + } } diff --git a/docs/denoise.md b/docs/denoise.md index 926b1bee7..7c3f5138f 100644 --- a/docs/denoise.md +++ b/docs/denoise.md @@ -77,7 +77,7 @@ pool. A scalar implementation checks the optimized operators on synthetic networ Tract is not a workspace dependency or a product feature. CPU inference and the GPU model module are feature-gated; ordinary engine/web builds do not compile the faer/GEMM backend. The engine depends on the inference crate only through `denoise = ["dep:lightcraft-denoise", …]`; -lightweight `lightcraft-denoise-core` metadata and cache formats remain available without inference. Pictures are capped at 100 megapixels; at most +lightweight `lightcraft-denoise-core` metadata and cache formats remain available without inference. Pictures are capped at 110 megapixels; at most four tile calls run together, chosen within the process-wide working-memory budget. One idle CPU workspace is retained, and cache costs reserve room for the blended picture. From 112e202fedbf578025b10d89a742365c35b90712 Mon Sep 17 00:00:00 2001 From: Adrian D'souza <102761952+bogusyogi@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:30:41 +0530 Subject: [PATCH 014/158] JPEG XL: check the size from the header before reading frames, under an allocation budget (#1517) Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/jxl.rs | 55 ++++++++++++++++++++++++++++++-- crates/codecs/tests/roundtrip.rs | 16 ++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/codecs/src/jxl.rs b/crates/codecs/src/jxl.rs index 26e144aed..3e6e155b5 100644 --- a/crates/codecs/src/jxl.rs +++ b/crates/codecs/src/jxl.rs @@ -8,7 +8,7 @@ use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc}; use crate::{DecodeOptions, Decoded, Error, Format, Result}; use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint}; -use jxl_oxide::{AuxBoxData, JxlImage}; +use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage}; const F: Format = Format::Jxl; @@ -16,10 +16,59 @@ fn err(e: impl std::fmt::Display) -> Error { Error::Malformed(F, e.to_string()) } +/// Bytes the decoder may hold per allowed pixel: four f32 channels, eight times over for frames, +/// extra channels and working buffers. The budget follows `max_pixels`, so oversized frames, extra +/// channels or modular trees inside an image of allowed size still fail (#1517). +const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8; + +/// The allocation budget never drops below this, so small images keep the decoder's fixed +/// overhead (group buffers, entropy tables) whatever `max_pixels` is. +const MIN_TRACKED_BYTES: u64 = 64 << 20; + +/// Bytes handed to the header stage at a time (the size `JxlImageBuilder::read` reads in). +const HEADER_CHUNK: usize = 4096; + +/// Parses the header, checks the image size against `opts`, and only then reads the frames, with +/// an allocation budget, so a huge image is refused before any of its pixels are decoded +/// (`JxlImageBuilder::read` loads everything first). +fn open(bytes: &[u8], opts: &DecodeOptions) -> Result { + let budget = opts.max_pixels.saturating_mul(TRACKED_BYTES_PER_PIXEL).max(MIN_TRACKED_BYTES); + let tracker = AllocTracker::with_limit(usize::try_from(budget).unwrap_or(usize::MAX)); + let mut uninit = JxlImage::builder().alloc_tracker(tracker).build_uninit(); + // Small slices, as `read` does: whatever the header stage has buffered when it initializes is + // parsed as frame data right away, so feeding the whole file here would skip the size check. + let mut rest = bytes; + let mut window = HEADER_CHUNK; + let mut img = loop { + let consumed = uninit.feed_bytes(rest.get(..window).unwrap_or(rest)).map_err(err)?; + rest = rest.get(consumed..).unwrap_or_default(); + match uninit.try_init().map_err(err)? { + InitializeResult::Initialized(img) => break img, + InitializeResult::NeedMoreData(more) => uninit = more, + } + if consumed == 0 { + // the parser wants more than the window holds at once + if window >= rest.len() { + return Err(err("truncated JPEG XL header")); + } + window = window.saturating_mul(2); + } + }; + check_size(F, img.width() as u64, img.height() as u64, opts)?; + while !rest.is_empty() { + let consumed = img.feed_bytes(rest).map_err(err)?; + if consumed == 0 { + break; + } + rest = rest.get(consumed..).unwrap_or_default(); + } + img.finalize().map_err(err)?; + Ok(img) +} + pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { - let mut img = JxlImage::builder().read(std::io::Cursor::new(bytes)).map_err(err)?; + let mut img = open(bytes, opts)?; let (w, h) = (img.width(), img.height()); - check_size(F, w as u64, h as u64, opts)?; let header = img.image_header(); let bits = header.metadata.bit_depth.bits_per_sample() as u8; let float = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. }); diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index af689ae5d..2ab55da31 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -734,6 +734,22 @@ fn jxl_lossless_exact_with_metadata() { assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image)); } +/// #1517: the size limit is checked from the header, before any frame is read, and a +/// file cut anywhere is an error, never a panic. +#[test] +#[cfg(feature = "jxl")] +fn jxl_size_limit_and_truncation() { + let b = encode_jxl(&EncodeImage::rgba8(&gradient(97, 61)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap(); + let r = decode(&b, DecodeOptions { max_pixels: 97 * 61 - 1, ..Default::default() }); + assert!(matches!(r, Err(Error::TooLarge(97, 61))), "{r:?}"); + let d = decode(&b, DecodeOptions { max_pixels: 97 * 61, ..Default::default() }).unwrap(); + assert_eq!((d.width, d.height), (97, 61)); + for cut in [0, 1, 2, 5, 12, 40, b.len() / 2, b.len() - 1] { + let _ = decode(&b[..cut], DecodeOptions::default()); + } + assert!(decode(&b[..12.min(b.len())], DecodeOptions::default()).is_err()); +} + #[test] #[cfg(feature = "jxl")] fn jxl_lossy_quality_and_effort() { From d770c5231377ccc1fd8c5c046665b6274ee8b3df Mon Sep 17 00:00:00 2001 From: Adrian D'souza <102761952+bogusyogi@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:30:36 +0530 Subject: [PATCH 015/158] Masks: bound the total dabs of one brush stroke (#1520) Co-Authored-By: Claude Opus 5.5 --- crates/pipeline/src/masks.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index f2aac5881..6db0e2e52 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -484,6 +484,11 @@ pub fn push_pressures(pressure: &mut Vec, added: usize, from: f32, to: f32) /// pointer is held still (the same screen position mapped again, up to float rounding). const STATIONARY_PX: f64 = 1e-6; +/// Most dabs one stroke holds (32 MiB of centres). A segment is capped on its own, but a stroke of +/// many hostile segments (an agent, a crafted XMP or catalog) would otherwise add up without +/// bound; past this the rest of the stroke lays nothing (#1520). +pub const MAX_STROKE_DABS: usize = 2_000_000; + /// The dabs of stroke `s` in a `w × h` output. pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDabs { let to_out = frame.norm_to_out(w, h); @@ -537,9 +542,10 @@ pub fn dab_spacing(r: f64) -> f64 { /// their dabs with this, so they cover the same pixels (issue #517). A sample where the pointer /// hasn't moved (a stationary hold records one per frame) or that is not a finite point lays /// nothing, so holding the brush still adds no coverage (#518). Returns whether `q` was stamped; -/// callers move `prev` on only then. +/// callers move `prev` on only then. A stroke holding [`MAX_STROKE_DABS`] takes no more. pub fn push_dabs(dabs: &mut Vec, prev: Option, q: Point, r: f64) -> bool { - if !(q.x.is_finite() && q.y.is_finite()) { + let room = MAX_STROKE_DABS.saturating_sub(dabs.len()); + if room == 0 || !(q.x.is_finite() && q.y.is_finite()) { return false; } if let Some(prev) = prev { @@ -551,6 +557,8 @@ pub fn push_dabs(dabs: &mut Vec, prev: Option, q: Point, r: f64) - // a hostile or degenerate segment (huge) gets no in-between dabs rather than a huge // allocation: 1 M dabs is far more than any on-screen stroke needs let n = if n.is_finite() { n.clamp(0.0, 1_000_000.0) as usize } else { 0 }; + // the in-between dabs and `q` together fit what is left of the stroke's budget + let n = n.min(room); for k in 1..n { dabs.push(prev.lerp(q, k as f64 / n as f64)); } @@ -838,6 +846,17 @@ mod tests { } assert_eq!(committed.dabs, again); assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9)); + // many huge segments stop at the stroke budget instead of adding up without bound + let mut big = Vec::new(); + let mut at = Point::new(0.0, 0.0); + for i in 0..5 { + let q = Point::new(if i % 2 == 0 { 1e12 } else { 0.0 }, 0.0); + if push_dabs(&mut big, Some(at), q, 1.0) { + at = q; + } + } + assert!(big.len() <= MAX_STROKE_DABS, "{} dabs", big.len()); + assert!(!push_dabs(&mut big, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more"); // a hostile segment places no in-between dabs let mut v = Vec::new(); assert!(!push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r)); From 8a4d6f2ebf104f9b68ce9b987dc7ec90f47ce63a Mon Sep 17 00:00:00 2001 From: Adrian D'souza <102761952+bogusyogi@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:30:46 +0530 Subject: [PATCH 016/158] HEIF: hold uncropped SPS sizes and a grid's tiles together to max_pixels (#1518) Co-Authored-By: Claude Opus 5.5 --- crates/heif/src/lib.rs | 22 ++++++++++++++ crates/heif/src/tests_synthetic.rs | 11 +++++++ crates/heif/src/vui.rs | 49 ++++++++++++++++++++++-------- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 8292024b1..21b9cf647 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -277,6 +277,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) }) } +/// How many times `max_pixels` the coded pictures of one item may hold together: a grid's tiles +/// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1. +const GRID_BUDGET: u64 = 2; + /// The decoded pictures of an item: one, or a grid's tiles in `dimg` order. struct Coded<'a> { frames: Vec, @@ -297,6 +301,24 @@ impl<'a> Coded<'a> { let first = *ids.first().ok_or_else(|| Error::Malformed("grid lists no tiles".into()))?; let first_props = if first == id { p.clone() } else { ctx.props(first).map_err(err)? }; let chroma_format = first_props.hvcc.as_ref().map_or(1, |c| c.chroma_format); + // #1518: the decoder allocates each picture at its SPS's uncropped size, which a + // conformance window (or a slice picking another SPS) can make far larger than the + // declared one, and a grid holds all its tiles at once. Both are refused before decoding. + let mut total = 0u64; + for t in &ids { + let tp = ctx.props(*t).map_err(err)?; + let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?; + let sizes = vui::coded_sizes(&hvcc.parameter_sets()).ok_or_else(|| Error::Malformed("unreadable sequence parameter set".into()))?; + let mut largest = 0u64; + for (w, h) in sizes { + heic_rs::image::check_pixels(w, h, limit).map_err(err)?; + largest = largest.max(u64::from(w) * u64::from(h)); + } + total = total.saturating_add(largest); + } + if total > limit.saturating_mul(GRID_BUDGET) { + return Err(Error::Limit(format!("the coded pictures hold {total} pixels, over the {limit}-pixel limit"))); + } let decode_one = |t: &u32| -> Result { let tp = ctx.props(*t).map_err(err)?; let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?; diff --git a/crates/heif/src/tests_synthetic.rs b/crates/heif/src/tests_synthetic.rs index e229af926..3259dc96d 100644 --- a/crates/heif/src/tests_synthetic.rs +++ b/crates/heif/src/tests_synthetic.rs @@ -146,6 +146,17 @@ fn limits_are_checked_before_decoding() { assert!(matches!(r, Err(Error::Limit(_))), "{r:?}"); } +/// #1518: a grid's tiles together are held to the pixel budget before any is decoded. A +/// 33 × 33 canvas fits 1089 pixels, but its four 32 × 32 tiles hold 4096: over twice the limit. +#[test] +fn grid_tiles_are_held_to_the_pixel_budget_together() { + let bytes = build(&Spec::new(33, 33, &grey)); + let r = decode(&bytes, &Options { max_pixels: 33 * 33, ..Default::default() }); + assert!(matches!(r, Err(Error::Limit(_))), "{r:?}"); + let d = decode(&bytes, &Options { max_pixels: 2 * 32 * 32, ..Default::default() }).unwrap(); + assert_eq!((d.width, d.height), (33, 33)); +} + #[test] fn truncated_and_corrupted_files_are_errors_not_panics() { let bytes = build(&Spec { props: vec![FULL_601, Prop::Irot(1)], thumbnail: Some(&grey), ..Spec::new(64, 64, &grey) }); diff --git a/crates/heif/src/vui.rs b/crates/heif/src/vui.rs index d732f2863..226604303 100644 --- a/crates/heif/src/vui.rs +++ b/crates/heif/src/vui.rs @@ -33,6 +33,41 @@ pub fn signal(parameter_sets: &[&[u8]]) -> Option { parse_sps(&unescape(sps.get(2..)?)) } +/// The uncropped picture size (`pic_width/height_in_luma_samples`) of every SPS among +/// `parameter_sets`: what the decoder allocates before it applies the conformance window, so the +/// pixel budget is checked against it (heic-rs reports only the first SPS, cropped). `None`: an +/// SPS this reader can't follow. +pub fn coded_sizes(parameter_sets: &[&[u8]]) -> Option> { + parameter_sets + .iter() + .filter(|n| n.first().is_some_and(|b| (b >> 1) & 0x3f == SPS_NUT)) + .map(|sps| { + let rbsp = unescape(sps.get(2..)?); + let r = &mut Bits { data: &rbsp, pos: 0 }; + sps_size(r).map(|(w, h, _)| (w, h)) + }) + .collect() +} + +/// Reads an SPS up to and including the picture size, leaving `r` after it: width, height and +/// `sps_max_sub_layers_minus1`. +fn sps_size(r: &mut Bits) -> Option<(u32, u32, u32)> { + r.skip(4)?; // sps_video_parameter_set_id + let max_sub_layers_minus1 = r.u(3)?; + if max_sub_layers_minus1 > 6 { + return None; + } + r.skip(1)?; // sps_temporal_id_nesting_flag + profile_tier_level(r, max_sub_layers_minus1)?; + r.ue()?; // sps_seq_parameter_set_id + if r.ue()? == 3 { + r.skip(1)?; // separate_colour_plane_flag + } + let width = r.ue()?; // pic_width_in_luma_samples + let height = r.ue()?; // pic_height_in_luma_samples + Some((width, height, max_sub_layers_minus1)) +} + /// Removes emulation prevention bytes (`00 00 03` → `00 00`). fn unescape(nal: &[u8]) -> Vec { let mut out = Vec::with_capacity(nal.len()); @@ -165,19 +200,7 @@ fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option Option { let r = &mut Bits { data: rbsp, pos: 0 }; - r.skip(4)?; // sps_video_parameter_set_id - let max_sub_layers_minus1 = r.u(3)?; - if max_sub_layers_minus1 > 6 { - return None; - } - r.skip(1)?; // sps_temporal_id_nesting_flag - profile_tier_level(r, max_sub_layers_minus1)?; - r.ue()?; // sps_seq_parameter_set_id - if r.ue()? == 3 { - r.skip(1)?; // separate_colour_plane_flag - } - r.ue()?; // pic_width_in_luma_samples - r.ue()?; // pic_height_in_luma_samples + let (_, _, max_sub_layers_minus1) = sps_size(r)?; if r.flag()? { for _ in 0..4 { r.ue()?; // conf_win_*_offset From a43cb685935ad9fceffc05a6fe5ba986d6209297 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Thu, 15 Oct 2026 14:04:14 +0200 Subject: [PATCH 017/158] LR-MERGE-HEADLESS: merge dialogs remember the last settings per kind The HDR / Panorama / HDR Panorama dialogs now open with the options of the last merge of that kind instead of the defaults, and those options are kept in the library's prefs.json (Session::last_merge) so they survive a restart. "With Last Settings" and HDR for Each Bracket Set read the same store. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 5 +++++ crates/engine/src/library.rs | 4 ++++ crates/engine/src/tests_prefs.rs | 19 ++++++++++++++++ crates/ui-egui/src/merge.rs | 23 +++++++++++++------ crates/ui-egui/src/tests_activity.rs | 33 ++++++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- 6 files changed, 78 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8a282ce3e..1b6db3b52 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -298,6 +298,10 @@ pub struct Session { pub xmp: sidecar::XmpPrefs, /// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json. pub last_export: Option, + /// The options of the last Photo Merge started, per command (`merge.hdr`, `merge.panorama`, + /// `merge.hdrPanorama`): the merge dialog opens with them and "… with Last Settings" uses + /// them. Persisted in prefs.json. + pub last_merge: std::collections::BTreeMap, /// 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 @@ -459,6 +463,7 @@ impl Session { library_identity: Arc::new(()), xmp: sidecar::XmpPrefs::default(), last_export: None, + last_merge: Default::default(), export_presets: Vec::new(), print: Default::default(), print_templates: Vec::new(), diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 51655aea5..e91f0bfff 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -207,6 +207,8 @@ struct PrefsFile { /// Parameters of the last export (for Export with Previous). #[serde(alias = "last_export")] last_export: Option, + /// Options of the last Photo Merge, per command. + last_merge: std::collections::BTreeMap, /// The user's export presets. export_presets: Vec, /// Print templates. @@ -492,6 +494,7 @@ impl Session { let prefs = settings.read::(files.as_mut(), "prefs.json").unwrap_or_default(); self.xmp = prefs.xmp; self.last_export = prefs.last_export; + self.last_merge = prefs.last_merge; self.export_presets = prefs.export_presets; self.print_templates = prefs.print_templates; self.metadata_presets = prefs.metadata_presets; @@ -746,6 +749,7 @@ impl Session { let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp, last_export: self.last_export.clone(), + last_merge: self.last_merge.clone(), export_presets: self.export_presets.clone(), print_templates: self.print_templates.clone(), metadata_presets: self.metadata_presets.clone(), diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index c69bb7da2..4c9d9582d 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -142,6 +142,25 @@ fn preferences_persist_with_the_library() { let _ = std::fs::remove_dir_all(&lib); } +#[test] +fn last_merge_settings_persist_with_the_library() { + let lib = temp_dir("merge"); + let opts = json!({"align": false, "deghost": "high"}); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.last_merge.insert("merge.hdr".into(), opts.clone()); + s.save_prefs().unwrap(); + s.close_library().unwrap(); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.last_merge.get("merge.hdr"), Some(&opts)); + assert!(!s.last_merge.contains_key("merge.panorama")); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} + #[test] fn default_copyright_and_creator_fill_gaps_on_import() { let dir = temp_dir("copyright"); diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index d4c9a0bc6..61485a271 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -108,8 +108,6 @@ pub struct MergeState { pub last_result: Option, /// Photos the dialog merges (the selection when it opened). pub ids: Vec, - /// The options of the last merge started, per kind ("merge.hdr" …): Merge with Last Settings. - pub last: std::collections::HashMap, /// HDR for Each Bracket Set: the sets still to merge after the running one. pub batch: std::collections::VecDeque>, } @@ -148,7 +146,15 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx, guard: None }) } -/// Open the merge dialog for the selection. +/// The options of the last merge of kind `command` started (kept in the library preferences), or +/// the defaults the first time. Fields missing from an older or hand-edited prefs.json take their +/// defaults; options that don't parse at all fall back to the defaults. +pub fn last_options(app: &LightcraftApp, command: &str) -> MergeDialog { + let saved = app.session.last_merge.get(command).and_then(|v| serde_json::from_value::(v.clone()).ok()); + MergeDialog { command: command.into(), ..saved.unwrap_or_default() } +} + +/// Open the merge dialog for the selection, with the options of the last merge of this kind. pub fn open(app: &mut LightcraftApp, command: &str) -> Result { let ids = app.session.selected(); if ids.len() < 2 { @@ -161,7 +167,7 @@ pub fn open(app: &mut LightcraftApp, command: &str) -> Result { if let Some(t) = app.merge.preview_task.take() { t.cancel.store(true, Ordering::Relaxed); } - app.ui.dialog = Some(crate::state::Dialog::Merge { opts: MergeDialog::for_command(command) }); + app.ui.dialog = Some(crate::state::Dialog::Merge { opts: last_options(app, command) }); Ok(json!({"photos": app.merge.ids.len()})) } @@ -180,7 +186,10 @@ pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result Result Result fn next_in_batch(app: &mut LightcraftApp) -> Result<(), String> { let Some(ids) = app.merge.batch.pop_front() else { return Ok(()) }; app.merge.ids = ids; - let opts = app.merge.last.get("merge.hdr").cloned().unwrap_or_else(|| MergeDialog::for_command("merge.hdr")); + let opts = last_options(app, "merge.hdr"); start_final(app, &opts).map(|_| ()) } diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 126b3ef58..bce9d2eb1 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -361,6 +361,39 @@ fn final_merge_shows_a_row_and_cancels() { let _ = std::fs::remove_dir_all(dir); } +/// The merge dialog opens with the options of the last merge of its kind (per kind), and "with Last +/// Settings" uses them; a kind never merged opens with the defaults. +#[test] +fn merge_dialog_remembers_the_last_settings_per_kind() { + let (mut h, dir) = bracket("merge-last"); + let opts = crate::merge::MergeDialog { + align: false, + deghost: "high".into(), + auto_settings: false, + ..crate::merge::MergeDialog::for_command("merge.hdr") + }; + crate::merge::open(&mut h.app, "merge.hdr").unwrap(); + h.app.ui.dialog = None; + crate::merge::start_final(&mut h.app, &opts).unwrap(); + let row = h.app.session.activity.list()[0].id; + h.app.session.activity.cancel(row).unwrap(); + assert!(h.step_until(T, |h| h.app.merge.final_task.is_none())); + crate::merge::open(&mut h.app, "merge.hdr").unwrap(); + let Some(crate::state::Dialog::Merge { opts: shown }) = &h.app.ui.dialog else { panic!("no merge dialog") }; + assert_eq!(shown, &opts); + crate::merge::open(&mut h.app, "merge.panorama").unwrap(); + let Some(crate::state::Dialog::Merge { opts: shown }) = &h.app.ui.dialog else { panic!("no merge dialog") }; + assert_eq!(shown, &crate::merge::MergeDialog::for_command("merge.panorama")); + h.app.ui.dialog = None; + // older or hand-edited preferences: missing fields take their defaults, junk is ignored + h.app.session.last_merge.insert("merge.panorama".into(), json!({"projection": "cylindrical", "command": "merge.hdr"})); + h.app.session.last_merge.insert("merge.hdrPanorama".into(), json!("junk")); + let pano = crate::merge::last_options(&h.app, "merge.panorama"); + assert_eq!((pano.command.as_str(), pano.projection.as_str(), pano.auto_crop), ("merge.panorama", "cylindrical", true)); + assert_eq!(crate::merge::last_options(&h.app, "merge.hdrPanorama"), crate::merge::MergeDialog::for_command("merge.hdrPanorama")); + let _ = std::fs::remove_dir_all(dir); +} + /// HDR for Each Bracket Set runs the merges one after the other, each with its activity row; cancelling one stops the /// rest. #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..85718ca79 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -355,7 +355,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); 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. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | | 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`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` | +| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time), kept in the library's prefs.json so they survive a restart; the HDR / Panorama / HDR Panorama dialogs open with them too (`merge_dialog_remembers_the_last_settings_per_kind`). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` | ## P. Enhance (ENH) From b9b80aee8a499f11f71a28c1f16d27c69a82750d Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Thu, 15 Oct 2026 15:35:11 +0200 Subject: [PATCH 018/158] LR-BEHAV-BG-TASKS: Reload from Disk reads its files in the background photo.reload decoded every targeted file on the UI thread, so a selection of raws froze the window until all were read. The command is now split into plan / probe / apply (engine `cmd::reload`); the app intercepts photo.reload and probes the files on a worker thread with a cancellable activity row (kind `reload`, counted progress), applying the result as one undo step. `wait: true` keeps the old in-place behaviour for agents; CLI/MCP unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/convert.rs | 33 +++------- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/reload.rs | 93 +++++++++++++++++++++++++++++ crates/engine/src/import.rs | 2 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 4 ++ crates/ui-egui/src/panels/edit.rs | 13 ++-- crates/ui-egui/src/panels/grid.rs | 5 +- crates/ui-egui/src/reload.rs | 67 +++++++++++++++++++++ crates/ui-egui/src/tasks.rs | 29 ++++++++- crates/ui-egui/src/tests_recheck.rs | 64 +++++++++++++++++++- docs/background-tasks.md | 1 + docs/parity-checklist.md | 5 +- 13 files changed, 283 insertions(+), 35 deletions(-) create mode 100644 crates/engine/src/cmd/reload.rs create mode 100644 crates/ui-egui/src/reload.rs diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index eae955cce..0871e4352 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -313,30 +313,15 @@ pub(crate) fn reload_ops( } /// Re-read photos whose files changed on disk (an external editor saved them): new size, -/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} +/// dimensions and content hash, cached sources dropped. The app runs the same steps +/// ([`crate::cmd::reload`]) with the files read on a worker thread. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { - 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()) { - Some(Source::File { path }) => Some((*id, path)), - _ => None, - }) - .collect(); - let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::>()); - let mut ops = Vec::new(); - let mut reloaded = Vec::new(); - for ((id, _), info) in paths.into_iter().zip(probed) { - let Ok(info) = info else { continue }; - reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded); - } - for id in &reloaded { - s.media.forget(*id); - } - if !ops.is_empty() { - s.commit("Reload Changed Files", Op::Batch { ops })?; - } - Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::>()})) + use crate::cmd::reload as r; + let items = r::plan(s, p)?; + let probe = r::file_probe(s); + let results = r::probe(probe.as_ref(), &items, &std::sync::atomic::AtomicBool::new(false), &|_| {}); + let reloaded = r::apply(s, items, results)?; + Ok(r::to_json(&reloaded)) } /// Duplicate: copy the file next to itself (`-copy`, never overwriting) and add it with the @@ -401,7 +386,7 @@ pub fn edit_specs() -> Vec { "Reload from Disk", ["Photo"], None, - "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}", + "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings); in the app the files are read in the background (activity row `reload`, cancellable) and the reply is {background: true, count} unless `wait: true` → {reloaded}", has_selection, reload ), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4b86d4012..bf6733a7a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -38,6 +38,7 @@ pub mod previews; mod print; mod query; pub mod recheck; +pub mod reload; mod slideshow; mod sync; mod xmp; diff --git a/crates/engine/src/cmd/reload.rs b/crates/engine/src/cmd/reload.rs new file mode 100644 index 000000000..9fc0a590f --- /dev/null +++ b/crates/engine/src/cmd/reload.rs @@ -0,0 +1,93 @@ +//! Reload from Disk (`photo.reload`), split so the app can read the files on a worker thread: a +//! raw re-decode takes a second or more per file, and a selection of them froze the window while +//! the command ran. [`plan`] lists the photos to re-read (no file-system calls), [`probe`] reads +//! their files without the session (cancellable, with progress), and [`apply`] commits what came +//! of it as one undo step. The command itself (agents, CLI, MCP) does all three at once. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use lightcraft_catalog::{Op, PhotoId, Source}; +use serde_json::{Value, json}; + +use crate::media::{FileProbe, ProbeInfo}; +use crate::{Result, Session}; + +/// A photo to re-read: its id and the file it had when it was listed (a photo relinked meanwhile +/// is skipped by [`apply`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Item { + pub id: PhotoId, + pub path: String, +} + +/// The photos `p` targets (`ids`, else the selection) that have a file. No file-system calls. +pub fn plan(s: &Session, p: &Value) -> Result> { + Ok(s.targets(p)? + .into_iter() + .filter_map(|id| match s.catalog.photo(id).map(|ph| &ph.source) { + Some(Source::File { path }) => Some(Item { id, path: path.clone() }), + _ => None, + }) + .collect()) +} + +/// The session's file probe, to [`probe`] with on a worker thread (`None` in a session without file +/// access, where a reload reads nothing but the file name, as the command does). +pub fn file_probe(s: &Session) -> Option { + s.media.file_probe.clone() +} + +/// Read each listed photo's file as Reload does, several at once. Doesn't touch the session. +/// `progress(done)` is called while it runs (from this thread); once `cancel` is set no further +/// file is started, and the files not reached come back as errors (left unchanged by [`apply`]). +pub fn probe( + probe: Option<&FileProbe>, + items: &[Item], + cancel: &AtomicBool, + progress: &dyn Fn(usize), +) -> Vec> { + let paths: Vec = items.iter().map(|i| i.path.clone()).collect(); + let done = AtomicUsize::new(0); + #[cfg(not(target_arch = "wasm32"))] + let r = std::thread::scope(|sc| { + let h = sc.spawn(|| crate::import::probe_all(probe, &paths, &done, cancel)); + while !h.is_finished() { + progress(done.load(Ordering::Relaxed)); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + // a probe that panicked (already logged): nothing is known about any file + h.join().unwrap_or_else(|_| paths.iter().map(|p| Err(format!("{p}: could not be read"))).collect()) + }); + #[cfg(target_arch = "wasm32")] + let r = crate::import::probe_all(probe, &paths, &done, cancel); + progress(done.load(Ordering::Relaxed)); + r +} + +/// Commit [`probe`]'s results as one undo step ("Reload Changed Files"): each photo and its virtual +/// copies get what their file is now. A file that couldn't be read changes nothing; a photo removed +/// or relinked since it was listed is skipped. Returns the photos that changed. +pub fn apply(s: &mut Session, items: Vec, results: Vec>) -> Result> { + let mut ops = Vec::new(); + let mut reloaded = Vec::new(); + for (item, info) in items.into_iter().zip(results) { + let Ok(info) = info else { continue }; + let current = s.catalog.photo(item.id).is_some_and(|ph| matches!(&ph.source, Source::File { path } if *path == item.path)); + if !current { + continue; + } + crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut reloaded); + } + for id in &reloaded { + s.media.forget(*id); + } + if !ops.is_empty() { + s.commit("Reload Changed Files", Op::Batch { ops })?; + } + Ok(reloaded) +} + +/// `{reloaded: [ids]}`. +pub fn to_json(reloaded: &[PhotoId]) -> Value { + json!({"reloaded": reloaded.iter().map(|i| i.0).collect::>()}) +} diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 9aa847b01..b98dcd591 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -494,7 +494,7 @@ pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> V /// Probe `paths` (several at once), counting each in `done`. Stops before the next file once /// `cancel` is set: the files not reached come back as errors. -fn probe_all( +pub(crate) fn probe_all( probe: Option<&crate::media::FileProbe>, paths: &[String], done: &std::sync::atomic::AtomicUsize, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..080996420 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub mod pick; pub mod print_view; pub mod recheck; pub mod region; +pub mod reload; pub mod render; pub mod shortcuts; pub mod sidebar; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9fcfc8b9d..7918d4f8a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -417,6 +417,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option app.toast_error(ctx, e), - Ok(_) if decoded => app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")), + Ok(r) if r["background"] == true => {} + // nothing to read in the background (a photo without a file): answered in place + Ok(_) if app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none()) => { + app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")) + } Ok(_) => app.toast(ctx, crate::i18n::tr("This raw variant still can't be decoded")), } } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index c6365b784..10d12ccb6 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1162,7 +1162,10 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } if ui.button(crate::i18n::tr("Reload from Disk")).clicked() { - let _ = app.run("photo.reload", json!({})); + // the files are read in the background (activity row `reload`) + if let Err(e) = app.run("photo.reload", json!({})) { + app.toast_error(ui.ctx(), e); + } } if ui.button(crate::i18n::tr("Create Virtual Copy")).clicked() { let _ = app.run("photo.virtualCopy", json!({})); diff --git a/crates/ui-egui/src/reload.rs b/crates/ui-egui/src/reload.rs new file mode 100644 index 000000000..32823cf16 --- /dev/null +++ b/crates/ui-egui/src/reload.rs @@ -0,0 +1,67 @@ +//! Reload from Disk in the app: the files are read on a worker thread (a raw's decode takes a +//! second or more, and a selection of them froze the window), with a row in the activity stack +//! that counts the files and can stop. What came of it is applied back here, as one undo step, +//! unless another library was opened meanwhile. Agents get the same with `wait: true`, which runs +//! the engine's command in place. + +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use lightcraft_engine::cmd::reload; +use serde_json::{Value, json}; + +use crate::LightcraftApp; + +/// The activity stack's label while the files are read (the menu item's name, already translated). +pub const LABEL: &str = "Reload from Disk"; + +/// `photo.reload` from the app: start reading the targeted files in the background → +/// `{background: true, count}`. `None` hands the command to the engine: with `wait: true`, or when +/// no targeted photo has a file (it then answers at once, refusing an empty selection as usual). +pub fn command(app: &mut LightcraftApp, p: &Value) -> Option> { + if p.get("wait").and_then(Value::as_bool) == Some(true) { + return None; + } + let items = match reload::plan(&app.session, p) { + Ok(items) => items, + Err(e) => return Some(Err(format!("photo.reload: {e}"))), + }; + if items.is_empty() { + return None; + } + let count = items.len(); + // the photos shown from their embedded preview, to say afterwards whether they decode now + let preview_only: Vec<_> = + items.iter().filter(|i| app.session.catalog.photo(i.id).is_some_and(|ph| ph.preview_only.is_some())).map(|i| i.id).collect(); + let library = app.session.library_generation(); + let probe = reload::file_probe(&app.session); + let cancel = Arc::new(AtomicBool::new(false)); + let flag = cancel.clone(); + let work = move |task: lightcraft_engine::activity::TaskHandle| { + let total = items.len() as u64; + task.progress(0, total); + let results = reload::probe(probe.as_ref(), &items, &flag, &|done| task.progress(done as u64, total)); + (items, results) + }; + let done = move |app: &mut LightcraftApp, ctx: &egui::Context, (items, results)| { + if app.session.library_generation() != library { + return; + } + if let Err(e) = reload::apply(&mut app.session, items, results) { + return app.toast_error(ctx, e.to_string()); + } + let cleared = preview_only.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|ph| ph.preview_only.is_none())).count(); + let message = match (count, preview_only.len(), cleared) { + (1, 1, 1) => crate::i18n::tr("This raw is now developed from its raw data").to_string(), + (1, 1, _) => crate::i18n::tr("This raw variant still can't be decoded").to_string(), + (_, _, 0) => return, + (_, _, 1) => crate::i18n::tr("1 raw can now be developed from its raw data").to_string(), + (_, _, n) => crate::i18n::tr_format!("{n} raws can now be developed from their raw data", n = n), + }; + app.toast(ctx, message); + }; + if let Err(e) = crate::tasks::spawn_tracked(app, LABEL, "reload", cancel, work, done) { + return Some(Err(e)); + } + Some(Ok(json!({"background": true, "count": count}))) +} diff --git a/crates/ui-egui/src/tasks.rs b/crates/ui-egui/src/tasks.rs index 70abd629a..86dc79381 100644 --- a/crates/ui-egui/src/tasks.rs +++ b/crates/ui-egui/src/tasks.rs @@ -50,6 +50,34 @@ pub fn spawn( kind: Option<&'static str>, work: impl FnOnce() -> T + Send + 'static, done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static, +) -> Result<(), String> { + let guard = kind.map(|k| app.session.activity.start(k, label, lightcraft_engine::activity::Cancel::No)); + start(app, label, guard, work, done) +} + +/// [`spawn`] for a task with progress that can stop: its row (`kind`, `label`) adopts `cancel`, +/// the flag `work` checks, so the row's ✕ and `activity.cancel` stop it; `work` reports progress +/// through the handle it is given. +pub fn spawn_tracked( + app: &mut LightcraftApp, + label: &str, + kind: &'static str, + cancel: std::sync::Arc, + work: impl FnOnce(lightcraft_engine::activity::TaskHandle) -> T + Send + 'static, + done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static, +) -> Result<(), String> { + let guard = app.session.activity.start(kind, label, lightcraft_engine::activity::Cancel::Flag(cancel)); + let handle = guard.handle(); + start(app, label, Some(guard), move || work(handle), done) +} + +/// Run `work` on a worker thread and list it in `app.tasks` with its activity row (if any). +fn start( + app: &mut LightcraftApp, + label: &str, + guard: Option, + work: impl FnOnce() -> T + Send + 'static, + done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static, ) -> Result<(), String> { let (tx, rx) = channel::(); let repaint = app.tasks.repaint.clone(); @@ -64,7 +92,6 @@ pub fn spawn( std::thread::Builder::new().name(format!("lc-task-{label}")).spawn(job).map_err(|e| format!("{label}: could not start: {e}"))?; #[cfg(target_arch = "wasm32")] job(); - let guard = kind.map(|k| app.session.activity.start(k, label, lightcraft_engine::activity::Cancel::No)); app.tasks.running.push(Task { label: label.to_string(), rx, _guard: guard }); Ok(()) } diff --git a/crates/ui-egui/src/tests_recheck.rs b/crates/ui-egui/src/tests_recheck.rs index 88149e464..c343050fc 100644 --- a/crates/ui-egui/src/tests_recheck.rs +++ b/crates/ui-egui/src/tests_recheck.rs @@ -78,13 +78,75 @@ fn recheck_result_is_dropped_after_switching_libraries() { assert!(app.session.catalog.photo(id).is_none(), "the other library is untouched"); } -/// The notice's Reload from Disk re-reads the file and says what came of it. +/// The notice's Reload from Disk re-reads the file in the background (a row in the activity stack, +/// the window not waiting for the decode) and says what came of it. #[test] fn notice_reload_button_rereads_the_file() { let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-reload-{}", std::process::id()))); let (mut app, id) = app_with_stale_photo(&dir.0); let ctx = egui::Context::default(); crate::panels::edit::reload_preview_only(&mut app, &ctx, id); + let rows = app.session.activity.list(); + let row = rows.iter().find(|r| r.kind == "reload").unwrap_or_else(|| panic!("no reload row: {rows:?}")); + assert_eq!(row.label, crate::reload::LABEL); + assert!(row.cancellable); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert!(app.session.activity.list().iter().all(|r| r.kind != "reload"), "the row goes with the task"); assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None); assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "This raw is now developed from its raw data"), "{:?}", app.ui.toast); + // one undo step + assert_eq!(app.session.undo.last().map(|u| u.label.as_str()), Some("Reload Changed Files")); +} + +/// Reload from Disk on a selection returns at once and reads the files on a worker thread; `wait` +/// runs it in place; a cancelled reload changes nothing it didn't read. +#[test] +fn reload_of_a_selection_runs_in_the_background() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-sel-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + app.session.selection = lightcraft_engine::Selection { ids: vec![id], active: Some(id) }; + let r = app.run("photo.reload", json!({})).unwrap(); + assert_eq!(r, json!({"background": true, "count": 1})); + assert!(app.tasks.is_running(crate::reload::LABEL)); + assert!(app.session.activity.list().iter().any(|r| r.kind == "reload")); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None); + + // agents can wait for the result: the engine's reply, nothing left running + let r = app.run("photo.reload", json!({"ids": [id.0], "wait": true})).unwrap(); + assert!(r.get("reloaded").is_some(), "{r}"); + assert!(app.tasks.is_empty()); + + // cancelled before any file is read: nothing changes + let p = app.session.catalog.photo(id).unwrap().clone(); + app.session + .catalog + .apply(lightcraft_catalog::Op::SetContent { + id, + width: p.width, + height: p.height, + file_size: p.file_size, + content_hash: p.content_hash.clone(), + preview_only: Some("Cr3 files are not decoded yet".into()), + preview_checked: "0.5.0".into(), + }) + .unwrap(); + let items = lightcraft_engine::cmd::reload::plan(&app.session, &json!({"ids": [id.0]})).unwrap(); + let probe = lightcraft_engine::cmd::reload::file_probe(&app.session); + let cancelled = std::sync::atomic::AtomicBool::new(true); + let results = lightcraft_engine::cmd::reload::probe(probe.as_ref(), &items, &cancelled, &|_| {}); + assert!(lightcraft_engine::cmd::reload::apply(&mut app.session, items, results).unwrap().is_empty()); + assert!(app.session.catalog.photo(id).unwrap().preview_only.is_some()); +} + +/// With no photo selected the command is refused at once, as before (no task, no row). +#[test] +fn reload_with_nothing_selected_starts_nothing() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-none-{}", std::process::id()))); + let (mut app, _) = app_with_stale_photo(&dir.0); + app.session.selection = lightcraft_engine::Selection::default(); + let _ = app.run("photo.reload", json!({})); + assert!(app.tasks.is_empty()); + assert!(app.session.activity.list().iter().all(|r| r.kind != "reload")); } diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 8ef8789d8..58fd3d6c5 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -60,6 +60,7 @@ guard.detail(&file_name); | Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish | | Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state | | Find Missing Photos | `crates/ui-egui/src/tasks.rs` | a generic background task with a row (`kind: Some(..)`) | +| Reload from Disk | `crates/ui-egui/src/reload.rs`, `crates/engine/src/cmd/reload.rs` | a command split into plan / probe / apply so the app reads the files on a worker thread (`tasks::spawn_tracked`: progress and `Cancel::Flag`); `wait: true` runs it in place | | Reading focus points | `crates/engine/src/focus_scan.rs` | a worker paced by the UI's per-frame pump (`focus.pump {pace}`); `Cancel::Flag`; commits in batches without undo steps | ## Testing diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..4473ed8e8 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-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **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 @@ -461,7 +461,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | -| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | +| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | LR-BEHAV-BG-TASKS: Reload from Disk reads its files on a worker thread with a cancellable activity row instead of freezing the window on a selection of raws; status unchanged (✅) | | 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 (🟡) | | 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 (🟡) | | 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 (✅) | From 79d726f8569381607ef9c98043f88ee0826ada1c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 15:38:03 +0200 Subject: [PATCH 019/158] LRC-PRINT-PAGE: Page Options print page numbers, page info and crop marks (gap 15) The Print dialog's Page section gets Lightroom's Page Options for Single Image and Contact Sheet: the page number at the bottom right, the print settings line (sharpening, profile, resolution) at the bottom left, and crop marks at the corners of each photo. They are `print.set {page: {pageNumbers, pageInfo, cropMarks}}`, saved with templates, drawn in the preview and composed into the PDF; marks and text turn white on a dark page. --- crates/engine/src/cmd/print.rs | 2 +- crates/engine/src/print/compose.rs | 252 ++++++++++++++++++++++++++++- crates/engine/src/tests_print.rs | 20 +++ crates/ui-egui/src/print_view.rs | 54 ++++++- crates/ui-egui/src/tests_print.rs | 29 ++++ docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- 7 files changed, 352 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 05a851893..26f5d9bc4 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -324,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, 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", + "{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], pageNumbers, pageInfo, cropMarks} (Page Options: single image and contact sheet only), 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/print/compose.rs b/crates/engine/src/print/compose.rs index 91e5db69e..f4e50703b 100644 --- a/crates/engine/src/print/compose.rs +++ b/crates/engine/src/print/compose.rs @@ -1,5 +1,6 @@ //! 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. +//! draws Photo Border and Inner Stroke, fills the page background, adds the Page Options (crop +//! marks, page number, page info) 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 @@ -28,6 +29,13 @@ pub const MAX_BORDER_IN: f64 = 2.0; 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; +/// A crop mark's length and its gap from the photo's corner, inches. +pub const CROP_MARK_IN: f64 = 0.25; +pub const CROP_MARK_GAP_IN: f64 = 0.0625; +/// A crop mark's weight, points. +pub const CROP_MARK_PT: f64 = 0.5; +/// Size of the page number and page info text, points. +pub const PAGE_TEXT_PT: f64 = 9.0; /// Lightroom's Image Settings panel. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -85,11 +93,18 @@ impl ImageSettings { pub struct PageStyle { /// Page background colour, sRGB. pub background: [u8; 3], + /// Page Options (Single Image and Contact Sheet, as in Lightroom; the packages ignore them): + /// the page number at the bottom right of each page. + pub page_numbers: bool, + /// The print settings (sharpening, profile, resolution) at the bottom left of each page. + pub page_info: bool, + /// Marks at the corners of each photo to cut along. + pub crop_marks: bool, } impl Default for PageStyle { fn default() -> Self { - Self { background: [255; 3] } + Self { background: [255; 3], page_numbers: false, page_info: false, crop_marks: false } } } @@ -100,6 +115,41 @@ pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] { lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16) } +/// Whether a layout style prints the Page Options (page numbers, page info, crop marks): +/// Lightroom offers them for Single Image and Contact Sheet; its packages have Cut Guides instead. +pub fn has_page_options(style: Style) -> bool { + !style.is_package() +} + +/// The colour of crop marks and page text on `background`: black, or white on a dark page. +pub fn mark_ink(background: [u8; 3]) -> [u8; 3] { + let [r, g, b] = background.map(f64::from); + if 0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0 { [255; 3] } else { [0; 3] } +} + +/// The Page Info line: print sharpening, profile and resolution (Lightroom names the printer +/// where the resolution is; this print goes to a PDF). +pub fn page_info_text(settings: &PrintSettings) -> String { + use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen}; + let c = &settings.job.color; + let media = match c.media { + PrintMedia::Matte => "Matte", + PrintMedia::Glossy => "Glossy", + }; + let sharpen = match c.sharpen { + PrintSharpen::Off => "Off".to_string(), + PrintSharpen::Low => format!("Low ({media})"), + PrintSharpen::Standard => format!("Standard ({media})"), + PrintSharpen::High => format!("High ({media})"), + }; + let profile = match &c.profile { + PrintProfile::Managed { space: ManagedSpace::Srgb } => "Managed by Printer (sRGB)".to_string(), + PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => "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()), + }; + format!("Print Sharpening: {sharpen} · Profile: {profile} · {:.0} ppi", settings.job.effective_ppi()) +} + // ---- placement ------------------------------------------------------------------------------ /// How a photo of `iw` x `ih` goes into an `aw` x `ah` area. @@ -355,8 +405,8 @@ impl CellStyle { } /// 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> { +/// asked), then the inner stroke on top of it. Returns where the photo landed. +pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result { // 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); @@ -382,7 +432,105 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell 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(()) + Ok(PxRect { x: dx, y: dy, w, h }) +} + +/// What the Page Options draw on every page, in page pixels. +struct Marks { + /// Crop mark length, gap from the corner and weight. + crop: Option<(usize, usize, usize)>, + numbers: bool, + info: Option, + ink: [u16; 3], + text_px: f32, + /// The printable area: the text sits in the margin below it, aligned with its sides. + area: Option, +} + +impl Marks { + fn of(settings: &PrintSettings, ppi: f64, pw: usize, ph: usize) -> Self { + let on = has_page_options(settings.layout.style); + let page = &settings.page; + let px = |inches: f64| (inches * ppi).round() as usize; + Marks { + crop: (on && page.crop_marks).then(|| (px(CROP_MARK_IN), px(CROP_MARK_GAP_IN), px(CROP_MARK_PT / 72.0).max(1))), + numbers: on && page.page_numbers, + info: (on && page.page_info).then(|| page_info_text(settings)), + ink: rec2020_from_srgb8(mark_ink(page.background)), + text_px: (PAGE_TEXT_PT / 72.0 * ppi) as f32, + area: settings.layout.printable().ok().and_then(|a| px_rect(&a, ppi, pw, ph)), + } + } + + /// Draws the marks of page `number` (one-based) around `photos`, the photos on it. + fn draw(&self, page: &mut Image16, photos: &[PxRect], number: usize) { + if let Some((len, gap, weight)) = self.crop { + for r in photos { + crop_marks(page, r, len, gap, weight, photos, self.ink); + } + } + let area = self.area.unwrap_or(PxRect { x: 0, y: 0, w: page.w, h: page.h }); + let (area_bottom, page_h) = ((area.y + area.h) as f32, page.h as f32); + // centred in the bottom margin, and on the paper when the margin is thinner than the text + let top = |h: f32| (area_bottom + (page_h - area_bottom - h) / 2.0).clamp(0.0, (page_h - h).max(0.0)); + if self.numbers + && let Some(t) = crate::text::TextLayout::new(&number.to_string(), self.text_px, false, crate::fonts::CRAFT_FONTS) + { + let x = ((area.x + area.w) as f32 - t.width).max(0.0); + draw_text(page, &t, x, top(t.height), self.ink); + } + if let Some(info) = &self.info + && let Some(t) = crate::text::TextLayout::new(info, self.text_px, false, crate::fonts::CRAFT_FONTS) + { + draw_text(page, &t, area.x as f32, top(t.height), self.ink); + } + } +} + +/// Crop marks for the photo at `r`: at each corner, a line along each of its edges that starts +/// `gap` past the corner and runs `len` outward, `weight` thick. Marks never cover a photo. +fn crop_marks(page: &mut Image16, r: &PxRect, len: usize, gap: usize, weight: usize, photos: &[PxRect], ink: [u16; 3]) { + let (x0, y0, x1, y1) = (r.x as i64, r.y as i64, (r.x + r.w) as i64, (r.y + r.h) as i64); + let (l, g, t) = (len as i64, gap as i64, weight.min(r.w).min(r.h) as i64); + let segments = [ + (x0 - g - l, y0, l, t), + (x1 + g, y0, l, t), + (x0 - g - l, y1 - t, l, t), + (x1 + g, y1 - t, l, t), + (x0, y0 - g - l, t, l), + (x0, y1 + g, t, l), + (x1 - t, y0 - g - l, t, l), + (x1 - t, y1 + g, t, l), + ]; + let on_photo = |x: usize, y: usize| photos.iter().any(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h); + for (sx, sy, sw, sh) in segments { + for y in sy.max(0)..(sy + sh).min(page.h as i64) { + for x in sx.max(0)..(sx + sw).min(page.w as i64) { + let (x, y) = (x as usize, y as usize); + if !on_photo(x, y) { + page.fill_rect(x, y, 1, 1, ink); + } + } + } + } +} + +/// Blends text in `ink` into `page` with its top-left corner at (`x`, `y`). +fn draw_text(page: &mut Image16, text: &crate::text::TextLayout, x: f32, y: f32, ink: [u16; 3]) { + let (w, h) = (page.w, page.h); + text.draw_in(x, y, w as f32, h as f32, |gx, gy, cov| { + let (Ok(gx), Ok(gy)) = (usize::try_from(gx), usize::try_from(gy)) else { return }; + if gx >= w || gy >= h { + return; + } + let i = (gy * w + gx) * 3; + if let Some(px) = page.rgb.get_mut(i..i + 3) { + let k = cov.clamp(0.0, 1.0); + for (p, c) in px.iter_mut().zip(ink) { + *p = (f32::from(*p) + (f32::from(c) - f32::from(*p)) * k).round().clamp(0.0, 65535.0) as u16; + } + } + }); } // ---- the job --------------------------------------------------------------------------------- @@ -402,6 +550,7 @@ pub struct Prepared { ppi: f32, background: [u16; 3], style: CellStyle, + marks: Marks, /// Print sharpening, applied to each photo once it's rendered at its print size. color: crate::print_color::PrintColorSettings, } @@ -430,6 +579,7 @@ impl Prepared { 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)?; + let mut placed = Vec::new(); while let Some(item) = items.next_if(|i| i.page == index) { if !progress(done, &item.name) { return Err("Print cancelled".into()); @@ -448,8 +598,9 @@ impl Prepared { } }; 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))?; + placed.push(draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?); } + self.marks.draw(&mut page, &placed, index + 1); out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb }); } if !progress(done, "") { @@ -508,6 +659,7 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI ppi: ppi as f32, background: rec2020_from_srgb8(settings.page.background), style, + marks: Marks::of(&settings, ppi, pw, ph), color: settings.job.color.clone(), }) } @@ -825,6 +977,94 @@ mod tests { assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels"); } + /// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the + /// margins are bare background unless something draws there). + fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec { + let ids = first_ids(s, 2); + let mut set = settings(style); + if style.is_package() { + set.layout.add_cell(3.0, 5.0).unwrap(); + set.layout.new_page().unwrap(); + let id = set.layout.add_cell(3.0, 5.0).unwrap(); + set.layout.set_cell_page(id, 1).unwrap(); + } + set.image.zoom_to_fill = true; + set.page = page; + prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap() + } + + fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] { + let i = (y * p.width as usize + x) * 3; + [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] + } + + /// The pixels of `x0..x1` x `y0..y1` that are not the white background. + fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize { + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count() + } + + #[test] + fn crop_marks_sit_outside_each_photo_corner() { + let mut s = Session::with_demo(); + let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default()); + assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked"); + let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() }); + // The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner, + // along each edge of the photo. + for p in &pages { + for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] { + assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})"); + } + for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] { + assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})"); + } + } + // a dark page gets white marks + let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() }); + assert_eq!(rgb(&dark[0], 30, 50), WHITE); + } + + #[test] + fn page_numbers_and_info_print_in_the_bottom_margin() { + let mut s = Session::with_demo(); + let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() }); + // bottom right: the number, different on each page; nothing at the bottom left + let (right, left) = ((200, 400), (0, 200)); + for p in &numbered { + assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number"); + assert_eq!(inked(p, left.0, left.1, 550, 600), 0); + assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top"); + } + let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::>(); + assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently"); + let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() }); + for p in &info { + assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line"); + } + // the packages have no Page Options in Lightroom: nothing is drawn + let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() }; + let package = page_options_pages(&mut s, Style::PicturePackage, all); + assert_eq!(package.len(), 2); + for p in &package { + assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0); + } + } + + #[test] + fn page_info_names_the_sharpening_profile_and_resolution() { + use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen}; + let mut set = PrintSettings::default(); + let line = page_info_text(&set); + assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi"); + set.job.color.sharpen = PrintSharpen::Off; + set.job.color.media = PrintMedia::Matte; + set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() }; + set.job.ppi = 300.0; + assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi"); + set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb }; + assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)")); + } + #[test] fn caps_and_hostile_settings_are_refused() { let mut s = Session::with_demo(); diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index 5b890a7f0..898faa889 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -341,3 +341,23 @@ fn every_print_command_is_registered_with_a_description() { assert!(c.params.len() > 3, "{id}"); } } + +#[test] +fn page_options_are_print_settings_and_template_fields() { + let mut s = Session::with_demo(); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["settings"]["page"], json!({"background": [255, 255, 255], "pageNumbers": false, "pageInfo": false, "cropMarks": false})); + let g = run(&mut s, "print.set", json!({"page": {"pageNumbers": true, "cropMarks": true}})); + assert_eq!((g["settings"]["page"]["pageNumbers"].as_bool(), g["settings"]["page"]["pageInfo"].as_bool()), (Some(true), Some(false))); + assert_eq!(g["settings"]["page"]["background"], json!([255, 255, 255]), "a patch merges"); + fails(&mut s, "print.set", json!({"page": {"pageInfo": "yes"}})); + fails(&mut s, "print.set", json!({"page": {"pageNumber": true}})); + // a template keeps them + run(&mut s, "print.template.save", json!({"name": "Marked"})); + run(&mut s, "print.reset", json!({})); + assert!(!s.print.page.crop_marks); + run(&mut s, "print.template.apply", json!({"name": "Marked"})); + assert!(s.print.page.page_numbers && s.print.page.crop_marks && !s.print.page.page_info); + let set = crate::find_command("print.set").unwrap(); + assert!(set.params.contains("pageNumbers") && set.params.contains("pageInfo") && set.params.contains("cropMarks"), "{}", set.params); +} diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index 71e836bc8..6e35f5b10 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -420,6 +420,19 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, patch(app, &ctx, json!({"page": {"background": c}})); } }); + if compose::has_page_options(l.style) { + ui.label(egui::RichText::new(crate::i18n::tr("Page Options")).color(t.text_label)); + let p = &st.page; + for (id, label, on, key) in [ + ("printPageNumbers", "Page Numbers", p.page_numbers, "pageNumbers"), + ("printPageInfo", "Page Info", p.page_info, "pageInfo"), + ("printCropMarks", "Crop Marks", p.crop_marks, "cropMarks"), + ] { + if let Some(v) = check(ui, id, label, on) { + patch(app, &ctx, json!({"page": {(key): v}})); + } + } + } heading(ui, t, "Print Job"); row(ui, t, "Resolution", |ui| { @@ -607,11 +620,13 @@ fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, s 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(), }; + let mut shown = Vec::new(); 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); + shown.extend(paint_cell(app, &painter, t, st, rect, *photo, &sheet, r)); } + page_options(&painter, st, &sheet, &shown, pu.page + 1); } // footer: page navigation and the selected cell @@ -782,7 +797,7 @@ fn paint_cell( photo: Option, sheet: &Sheet, inches: &layout::Rect, -) { +) -> Option { 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); @@ -794,7 +809,7 @@ fn paint_cell( }; let avail = cell.shrink(border); if avail.width() < 1.0 || avail.height() < 1.0 { - return; + return None; } // 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| { @@ -804,7 +819,7 @@ fn paint_cell( 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 p = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit)?; 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 { @@ -856,6 +871,37 @@ fn paint_cell( 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); } + Some(visible) +} + +/// The Page Options as the print draws them ([`compose`]): crop marks at the corners of each +/// photo, the page number at the bottom right and the print settings at the bottom left. +fn page_options(painter: &egui::Painter, st: &PrintSettings, sheet: &Sheet, photos: &[Rect], number: usize) { + let p = &st.page; + let ink = compose::mark_ink(p.background); + let ink = Color32::from_rgb(ink[0], ink[1], ink[2]); + if p.crop_marks { + let (len, gap) = (compose::CROP_MARK_IN as f32 * sheet.scale, compose::CROP_MARK_GAP_IN as f32 * sheet.scale); + let stroke = Stroke::new(1.0, ink); + for r in photos { + for (corner, dx, dy) in + [(r.left_top(), -1.0, -1.0), (r.right_top(), 1.0, -1.0), (r.left_bottom(), -1.0, 1.0), (r.right_bottom(), 1.0, 1.0)] + { + painter.line_segment([corner + vec2(dx * gap, 0.0), corner + vec2(dx * (gap + len), 0.0)], stroke); + painter.line_segment([corner + vec2(0.0, dy * gap), corner + vec2(0.0, dy * (gap + len))], stroke); + } + } + } + let Ok(area) = st.layout.printable() else { return }; + let area = sheet.rect(&area); + let font = egui::FontId::proportional((compose::PAGE_TEXT_PT as f32 / 72.0 * sheet.scale).max(4.0)); + let y = (area.bottom() + sheet.page.bottom()) / 2.0; + if p.page_numbers { + painter.text(pos2(area.right(), y), Align2::RIGHT_CENTER, number.to_string(), font.clone(), ink); + } + if p.page_info { + painter.text(pos2(area.left(), y), Align2::LEFT_CENTER, compose::page_info_text(st), font, ink); + } } fn dashed(painter: &egui::Painter, r: Rect, stroke: Stroke) { diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs index 29c214e47..28838f409 100644 --- a/crates/ui-egui/src/tests_print.rs +++ b/crates/ui-egui/src/tests_print.rs @@ -378,3 +378,32 @@ fn the_print_preview_does_not_rebuild_thumbnails_every_frame() { } assert!(h.app.renderer.thumb_jobs_built - before < 50, "{} jobs", h.app.renderer.thumb_jobs_built - before); } + +#[test] +fn page_options_checkboxes_set_the_page_and_hide_for_packages() { + let mut h = demo(Services::default()); + open_print(&mut h); + let scroll = |h: &mut Headless, dy: f32| { + 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": dy}), T); + h.settle(T); + } + }; + // the Page panel is below Page Setup: scroll down to it + scroll(&mut h, -600.0); + for (id, on) in [("check:printPageNumbers", 0), ("check:printPageInfo", 1), ("check:printCropMarks", 2)] { + click(&mut h, id); + let p = &h.app.session.print.page; + assert_eq!([p.page_numbers, p.page_info, p.crop_marks].iter().filter(|v| **v).count(), on + 1, "{id}"); + } + let p = &h.app.session.print.page; + assert!(p.page_numbers && p.page_info && p.crop_marks); + scroll(&mut h, 600.0); + click(&mut h, "button:printStyle-1"); + assert!(has(&h, "check:printCropMarks"), "a contact sheet has Page Options"); + // Lightroom's packages have no Page Options + click(&mut h, "button:printStyle-2"); + assert!(!has(&h, "check:printPageNumbers") && !has(&h, "check:printCropMarks")); +} diff --git a/docs/gaps.md b/docs/gaps.md index f11a74f7a..fc0bbafeb 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-14 · **Change:** minor (gap 10: 515 of 1,446 models verified, ~84% of photographers on a verified body) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (gap 15: Print Page Options — page numbers, page info, crop marks) 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; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | -| 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) | +| 15 | **Print module**: identity plate / watermark / photo info overlays, Cut Guides for the packages, 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) | @@ -77,6 +77,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain | | 2026-10-14 | 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-14 | minor | Gap 10: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277) | | 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..81b848b69 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-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **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 @@ -762,7 +762,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | 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-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; Page Options for Single Image and Contact Sheet (`print.set {page: {pageNumbers, pageInfo, cropMarks}}`, checkboxes in the Page section, kept in templates): the page number at the bottom right, the print settings line (sharpening, profile, resolution) at the bottom left, crop marks at each photo's corners, drawn in the preview and in the PDF (`print::compose::tests::crop_marks_sit_outside_each_photo_corner`); filename captions in the contact-sheet PDF; no identity plate, watermark, photo info or package Cut Guides | | 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 | 🚫 | | | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) | | 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 (🟡) | | 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 (🟡) | | 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 (✅) | From 7ac6dd81fe2a75bc7c0b0c505c1dd489cd314b55 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Thu, 15 Oct 2026 15:40:53 +0200 Subject: [PATCH 020/158] DOC-README: refresh current features and setup requirements Align feature descriptions, counts, exports and language options with the current code. Distinguish model-dependent features and implemented Print and slideshow workflows from their remaining gaps. Co-Authored-By: GPT-6.1 Sol --- README.md | 62 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 53ec908de..19eb7c0f2 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Split-tone shadows, midtones and highlights independently with drag-anywhere col ## Before & after -Hold \\ to peek at the original, press Y for side by side, or Shift+Y for a +Press \\ to toggle the Before view, Y for side by side, or Shift+Y for a split view. Every image below is a real screenshot of LightCraft, captured automatically by an agent through the [control channel](#built-for-agents). @@ -150,9 +150,13 @@ split view. Every image below is a real screenshot of LightCraft, captured autom Paint with a **Brush** (size, feather, flow, density, erase), drop **Linear** and **Radial Gradients** with draggable pins, or select by **Luminance Range**, **Color Range**, **Sky**, **Subject** and **Background**. Combine components -with **Add / Subtract / Intersect**, invert any of them, and dial in 15 local adjustments per mask (Temp, Tint, -Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise) -plus an overall Amount. +with **Add / Subtract / Intersect**, invert any of them, and dial in 17 local adjustments per mask (Temp, Tint, +Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise, +Moiré, Defringe), plus an overall Amount. + +Desktop **Object** and **Describe** masks use SAM 3 for click-based and text-prompt selection, with a separately +installed model; see [model setup and terms](docs/ai-masks.md). Subject, Sky and Background selections still use +classical heuristics. Existing model-generated masks render and export without the model installed.

Masking panel with a linear Sky mask and a radial Sun glow mask; the radial gradient is drawn as a red overlay around the sun on a lake scene @@ -164,7 +168,7 @@ plus an overall Amount. ## Presets, profiles & the Color Mixer -Eighteen hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp +Forty-one hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp Landscape* and more), each with an **Amount** slider from 0 to 200 %. Save your own from any group of settings, mark favourites, and copy, paste or sync edits across a whole selection with exactly the groups you choose. @@ -209,6 +213,10 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized, so they stay smooth whether you have forty photos or forty thousand. +The **People** view groups named faces, including names read from XMP; face boxes can be edited in the loupe. +On desktop, automatic detection and recognition use optional models installed through **Settings → Faces**; no weights are +bundled. See [face models and setup](docs/faces.md). + @@ -281,7 +289,13 @@ lightcraft --control 7980 ~/Pictures/trip 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. + The Print Job panel supports printer/paper ICC profiles, rendering intent, black point compensation, + print adjustment, sharpening and 8/16-bit PDF output. Direct printing through an OS print dialog and page + overlays (identity plate, watermark, page numbers) are not there yet. +- **Slideshows:** **View → Slideshow** plays the photos in view full screen, with fades, repeat and random order. + **Settings → Slideshow** configures layouts, templates, backgrounds, overlays and title screens. Native exports + support PDF, JPEG sequences and AV1 video in MP4 or WebM; music and a dedicated Slideshow module editor remain + missing. See [docs/slideshow.md](docs/slideshow.md). - **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. @@ -293,7 +307,7 @@ lightcraft --control 7980 ~/Pictures/trip - **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans, Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP, - PSD composites and JPEG XL open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice, + PSD composites, JPEG XL, GIF (first frame) and BMP open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice, as in PhotoCraft). - **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched. @@ -319,7 +333,7 @@ lightcraft --control 7980 ~/Pictures/trip LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and [docs/target-app-parity.md](docs/target-app-parity.md)): -- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in +- **By feature count we're at ~82%** of Lightroom (core features 98%), tracked row by row in [docs/parity-checklist.md](docs/parity-checklist.md). - **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for casual use.** It's great for JPEG/DNG and most Sony / Panasonic / @@ -327,9 +341,13 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det - **The biggest gaps:** - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - - **AI masks and denoise:** subject and sky selection are classical heuristics; + - **AI quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an + optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and + linear-RGB denoise and Super Resolution remain missing; see [AI masks](docs/ai-masks.md) and [AI Denoise](docs/denoise.md); - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); - - **on-screen HDR display, video and the Classic Print / Book / Map modules.** + - **on-screen HDR display, video import/editing, and the Book / Map module views.** Print layouts and + colour-managed PDF output work; direct printer output and page overlays remain missing. Slideshow playback + and export work, with a dedicated module editor and music still missing. - **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md). | Area | Status | @@ -337,16 +355,16 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det | Library: albums, folders, smart albums, stacks (incl. auto-stack), virtual copies, ratings, flags, labels, filter bar, search, sort, grids, filmstrip | ✅ | | Culling: Compare (synced zoom) and Survey views, auto-advance, instant previews | ✅ | | Light, Color, Effects (vignette styles), Tone Curve (+ refine saturation, targeted adjustment), Color Mixer (+ targeted), Point Color, Color Grading, Calibration, B&W | ✅ | -| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) | +| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect; Object and Describe selections | ✅ · Object/Describe require optional SAM 3; Subject/Sky remain heuristic ([details](docs/ai-masks.md)) | | Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ | | Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ | -| Camera colour: DNG files use their own matrices | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing | +| Camera colour: DNG matrices, guarded camera-JPEG look fits and built-in profiles for selected cameras | 🚧 measured calibration and per-model coverage remain incomplete ([raw parity](docs/raw-parity.md)) | | Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ | | RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) | -| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), Super Resolution ⬜ | -| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 | -| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ | -| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ | +| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights; [setup](docs/denoise.md)), Super Resolution ⬜ | +| Remove / Heal / Clone spots (auto source), non-generative Fill, editable spot pins, Visualize Spots, Red Eye and Pet Eye | ✅ · Fill uses PatchMatch; no generative fill | +| Export: JPEG / PNG / TIFF / flat PSD / WebP / AVIF / JPEG XL / raw or rendered DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ | +| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; gain map JPEG, PQ/HLG AVIF, PQ PNG/JPEG XL and float TIFF export | ✅ · HDR display ⬜ | | Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ | | Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ | | MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ | @@ -354,7 +372,11 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det | Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files, Panasonic / Leica RW2 / RWL distortion data and Nikon Z 6II / Z 8 / Z f distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ | | Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ | | GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 | -| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) | +| People view, XMP face names and editable face boxes; automatic face detection and recognition | ✅ · detection/recognition require optional models ([setup](docs/faces.md)) | +| GPX tracklog auto-tagging and offline reverse geocoding | ✅ · place names require the GeoNames dataset; Map view ⬜ | +| Print layouts, picture packages, templates and colour-managed PDF export | ✅ layouts/PDF · 🚧 direct printer output and page overlays | +| Slideshow playback, layouts, overlays, templates and PDF/JPEG/AV1 export | ✅ playback · 🚧 dedicated module editor and music ([details](docs/slideshow.md)) | +| Video import/editing and Book module | ⬜ | | Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 | ✅ works today · 🚧 in progress · ⬜ not started @@ -373,7 +395,7 @@ cargo run --release -p lightcraft -- --memory # a throwaway in-memory cargo run --release -p lightcraft -- --control 7980 # with the automation channel cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5 -cargo xtask ci # fmt, clippy, tests, layering, wasm checks +cargo xtask ci # fmt, clippy, HEIF, tests, parity, layers, assets, WASM ``` CI defaults to line-table debug information and one build job per 1.5 GB of RAM @@ -426,7 +448,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme). -**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština, ქართული) or **Settings → General → +**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული) or **Settings → General → Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md). **Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder @@ -536,7 +558,7 @@ option to start it. For unattended installs: `msiexec /i lightcraft--window | Format | x86_64 | aarch64 (ARM64) | Notes | |---|---|---|---| -| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | +| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | Portable; updates with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | | Flatpak | `lightcraft--linux-x86_64.flatpak` | `lightcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` | | Debian/Ubuntu | `lightcraft--linux-x86_64.deb` | `lightcraft--linux-aarch64.deb` | | | Fedora/RHEL/openSUSE | `lightcraft--linux-x86_64.rpm` | `lightcraft--linux-aarch64.rpm` | | From d020c55062ee450274ef8db8a62dc062e8860cce Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 15 Oct 2026 11:39:17 -0400 Subject: [PATCH 021/158] Contributors: credit nledford by real name Co-Authored-By: Claude --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bec67597c..8db54f2c0 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -45,3 +45,6 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.nledford] +real_name = "Nathaniel Ledford" From c806debada90b2e89199271794515cb49f55ab21 Mon Sep 17 00:00:00 2001 From: Pierre-Edouard Honnet Date: Thu, 15 Oct 2026 20:12:46 +0200 Subject: [PATCH 022/158] LR-IMP-FORMATS: CRX high-frequency band width from the tile geometry (#899) The decoder tried each high-frequency band at its synthesis width and up to two more columns, keeping the first width whose entropy stream ended exactly. Two widths can both do that with different coefficients (#899). Measured on every C-RAW plane in cr3_corpus.rs (CC0 raw.pixls.us files): each band decodes at exactly one width. A tile with a neighbour on its right codes bands 5 and 8 (the vertical high-pass of the level-2 and level-1 low-pass columns) at an odd width, so an even band gains one unused column; no other band is extended. The width now comes from that rule, and a stream that does not fit it is corrupt (the CR3 falls back to its preview). All corpus sensor hashes are unchanged. The property-test encoder now codes bands 5 and 8 the same way. Middle tiles (three or more C-RAW tiles) follow the left-tile rule; no public file has one. Co-Authored-By: Claude Opus 5.5 --- crates/raw/src/vendor/crx.rs | 110 ++++++++++++++++++++++------- crates/raw/tests/crx_properties.rs | 14 +++- docs/cr3.md | 12 ++++ 3 files changed, 109 insertions(+), 27 deletions(-) diff --git a/crates/raw/src/vendor/crx.rs b/crates/raw/src/vendor/crx.rs index 853efdffe..13f63a4a3 100644 --- a/crates/raw/src/vendor/crx.rs +++ b/crates/raw/src/vendor/crx.rs @@ -366,6 +366,39 @@ fn split_dimension(length: usize, before: bool, after: bool) -> (usize, usize) { (length.div_ceil(2) + usize::from(after && length.is_multiple_of(2)), length / 2 + usize::from(before) + usize::from(after)) } +/// The width at which high-frequency band `index` (1–9, coarsest level first) is entropy coded, +/// given its synthesis width `width`. +/// +/// A tile with a neighbour on its right codes the vertical high-pass band of the level-1 and level-2 +/// low-pass columns (bands 8 and 5, whose low-pass columns the next level transforms again) at an odd +/// width: an even synthesis width gains one column, which synthesis does not use. Every other band, +/// including band 2 of the last level, is coded at its synthesis width. Measured on every C-RAW plane +/// that `tests/cr3_corpus.rs` decodes (CC0 raw.pixls.us files): two-tile files with tile widths 2816, +/// 3144, 3192, 3444 and 3564 (G5 X Mark II, M50, R100, 250D, RP, R, 90D, M6 Mark II), where bands 5 +/// and 8 gain a column in left tiles exactly when the rule says so, and single-tile `0x100` and +/// `0x200` files, which never extend a band. In every one of those planes, each band's entropy stream +/// ends exactly at that width and at no other width from `width` to `width + 2`. The width used to be +/// probed by trying `width`, `width + 1` and `width + 2` in turn, but two widths can both consume a +/// stream exactly and give different coefficients (#899), so it is now taken from the geometry. +/// No public file has a middle tile (three or more C-RAW tiles); it is assumed to follow the left tile. +fn coded_band_width(index: usize, width: usize, edges: TileEdges) -> usize { + if edges.right && matches!(index, 5 | 8) { width | 1 } else { width } +} + +/// A high-frequency band entropy coded `coded_width` columns wide, cropped to its `width` synthesis columns. +fn coded_high_frequency_band(src: &[u8], width: usize, coded_width: usize, height: usize) -> Result> { + let values = high_frequency_band(src, coded_width, height)?; + if coded_width == width { + return Ok(values); + } + let mut cropped = Vec::new(); + cropped.try_reserve_exact(width.saturating_mul(height)).map_err(|_| RawError::Limit("CRX cropped band allocation"))?; + for row in values.chunks_exact(coded_width) { + cropped.extend_from_slice(row.get(..width).ok_or_else(|| corrupt("band crop outside row"))?); + } + Ok(cropped) +} + fn wavelet_plane(sample: &[u8], plane: &Plane, width: usize, height: usize, edges: TileEdges, qp: Option<&QpMap>) -> Result> { let mut dimensions = vec![(width, height)]; let mut high_dimensions = Vec::new(); @@ -383,32 +416,7 @@ fn wavelet_plane(sample: &[u8], plane: &Plane, width: usize, height: usize, edge let mut bands = Vec::with_capacity(10); for (i, (&(w, h), band)) in shapes.iter().zip(&plane.bands).enumerate() { let bytes = band_bytes(sample, band)?; - let mut data = if i == 0 { - lossless_plane(bytes, w, h)? - } else { - // Some CRX high-frequency bands retain a redundant right column - // beyond synthesis support (LH2 in the M50/R100 fixtures). Infer - // that small extension only from a complete, correctly aligned - // entropy stream, then crop each row to the synthesis support. - let mut decoded = None; - for extra in 0..=2 { - let coded_width = w.checked_add(extra).ok_or(RawError::Limit("CRX band width"))?; - if let Ok(values) = high_frequency_band(bytes, coded_width, h) { - if extra == 0 { - decoded = Some(values); - } else { - let mut cropped = Vec::new(); - cropped.try_reserve_exact(w * h).map_err(|_| RawError::Limit("CRX cropped band allocation"))?; - for row in values.chunks_exact(coded_width) { - cropped.extend_from_slice(row.get(..w).ok_or_else(|| corrupt("band crop outside row"))?); - } - decoded = Some(cropped); - } - break; - } - } - decoded.ok_or_else(|| RawError::Corrupt(format!("Canon CRX band {i}: unsupported geometry or corrupt entropy")))? - }; + let mut data = if i == 0 { lossless_plane(bytes, w, h)? } else { coded_high_frequency_band(bytes, w, coded_band_width(i, w, edges), h)? }; for (position, value) in data.iter_mut().enumerate() { let step = match band.quant { Quantization::Uniform(value) => quant_step(value, MAX_UNIFORM_QUANT)?, @@ -951,6 +959,56 @@ mod tests { } } + #[test] + fn coded_band_width_follows_the_measured_tile_geometry() { + let left_tile = TileEdges { right: true, ..TileEdges::default() }; + let right_tile = TileEdges { left: true, ..TileEdges::default() }; + let single = TileEdges::default(); + // Bands 5 and 8 of a left tile: an even width gains a column (RP 400 -> 401, R 862 -> 863, + // 90D / M6 Mark II 892 -> 893), an odd one does not (90D / M6 Mark II band 5 at 447). + for (band, width, coded) in [(5, 394, 395), (5, 400, 401), (5, 432, 433), (5, 447, 447), (8, 862, 863), (8, 892, 893), (8, 705, 705)] { + assert_eq!(coded_band_width(band, width, left_tile), coded, "band {band} at {width}"); + } + // The last level's low-pass columns are not transformed again: band 2 keeps an even width. + assert_eq!(coded_band_width(2, 198, left_tile), 198); + for band in [1, 3, 4, 6, 7, 9] { + assert_eq!(coded_band_width(band, 394, left_tile), 394, "band {band}"); + } + for band in 1..=9 { + assert_eq!(coded_band_width(band, 446, right_tile), 446, "band {band}"); + assert_eq!(coded_band_width(band, 446, single), 446, "band {band}"); + } + } + + #[test] + fn a_band_that_decodes_at_two_widths_uses_the_coded_width() { + // #899: entropy alignment alone does not identify the coded width. This 2-row stream is + // consumed exactly both 2 and 3 columns wide, with different retained coefficients. + let src = [0xc0, 0x0f]; + assert_eq!(high_frequency_band(&src, 2, 2).unwrap(), [0, 0, 5, -2]); + assert_eq!(high_frequency_band(&src, 3, 2).unwrap(), [0, 0, 5, 0, 0, 0]); + // The width probe took the first width that decoded (2). Band 8 of a left tile at an even + // width is coded one column wider, so the geometry selects 3 and crops it. + let coded = coded_band_width(8, 2, TileEdges { right: true, ..TileEdges::default() }); + assert_eq!(coded, 3); + assert_eq!(coded_high_frequency_band(&src, 2, coded, 2).unwrap(), [0, 0, 0, 0]); + // Elsewhere the same stream is read at its synthesis width. + assert_eq!(coded_high_frequency_band(&src, 2, coded_band_width(7, 2, TileEdges::default()), 2).unwrap(), [0, 0, 5, -2]); + // The #899 reproducer: an odd width is never extended, so the narrow reading is kept. + let src = [0x00, 0x33]; + assert_eq!(coded_band_width(5, 3, TileEdges { right: true, ..TileEdges::default() }), 3); + assert_eq!(coded_high_frequency_band(&src, 3, 3, 1).unwrap(), [5, 0, -1]); + assert_eq!(high_frequency_band(&src, 4, 1).unwrap(), [5, 0, 0, 0]); + } + + #[test] + fn a_band_that_fails_at_its_coded_width_is_an_error() { + // No fallback to another width: a stream that only fits 2 columns is corrupt where 3 are coded. + let src = [0xc0, 0x0f]; + assert!(high_frequency_band(&src, 4, 2).is_err()); + assert!(coded_high_frequency_band(&src, 3, 4, 2).is_err()); + } + #[test] fn craw_overshoot_at_clipped_highlights_is_clamped_to_the_bit_depth() { // As `three_level_craw_reconstructs_sensor_samples`, but each LL3 coefficient is +8200 diff --git a/crates/raw/tests/crx_properties.rs b/crates/raw/tests/crx_properties.rs index 32c9f65d3..804970de4 100644 --- a/crates/raw/tests/crx_properties.rs +++ b/crates/raw/tests/crx_properties.rs @@ -250,6 +250,7 @@ fn encode_qp_map(values: &[i32], width: usize, height: usize) -> Vec { // --- exact algebraic inverse of the crate's 5/3 synthesis (the forward transform under test) --- +#[derive(Clone)] struct Band { width: usize, height: usize, @@ -345,6 +346,17 @@ fn band_shapes(plane_width: usize, plane_height: usize, before: bool, after: boo [(lw3, lh3), (hw3, lh3), (lw3, hh3), (hw3, hh3), (hw2, lh2), (lw2, hh2), (hw2, hh2), (hw1, lh1), (lw1, hh1), (hw1, hh1)] } +/// The band as the camera codes it (crx.rs `coded_band_width`): with a tile on the right, bands 5 and 8 +/// are coded at an odd width, so an even band gains a column that synthesis ignores (zero here). +fn coded_band(band: &Band, index: usize, after: bool) -> Band { + if !(after && matches!(index, 5 | 8) && band.width.is_multiple_of(2)) { + return band.clone(); + } + let width = band.width + 1; + let data = band.data.chunks_exact(band.width).flat_map(|row| row.iter().copied().chain([0])).collect(); + Band { width, height: band.height, data } +} + fn encode_band(band: &Band, lowpass: bool) -> Vec { let mut bits = BitWriter::default(); if lowpass { @@ -568,7 +580,7 @@ fn encode_tile_planes( *plane_bytes = vec![encode_band(&band, true)]; } else { let bands = forward_bands(values, plane_w, plane_h, before, after, halo); - *plane_bytes = bands.iter().enumerate().map(|(i, band)| encode_band(band, i == 0)).collect(); + *plane_bytes = bands.iter().enumerate().map(|(i, band)| encode_band(&coded_band(band, i, after), i == 0)).collect(); } } encoded diff --git a/docs/cr3.md b/docs/cr3.md index 79babaea4..b133dfe6a 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -127,6 +127,18 @@ tiles. All ten sensor comparisons pass. R8 HEVC preview tracks are distinguished the primary Bayer RAW; the HEVC preview itself is not decoded. Missing corpus files are reported as skipped by the tests. +In a tile with a neighbour on its right, the vertical high-pass bands of the level-1 and level-2 +low-pass columns (bands 8 and 5) are entropy coded at an odd width: an even band carries one extra +column that synthesis does not use. No other band is extended, including the last level's (band 2). +The decoder takes each band's coded width from this rule. It used to try the synthesis width and up to +two more columns and keep the first that decoded to the exact end of the stream, but two widths can +both do that and give different coefficients (#899). The rule was measured on every C-RAW plane in +`cr3_corpus.rs`: two-tile files at tile widths 2816, 3144, 3192, 3444 and 3564 (G5 X Mark II, M50, +R100, 250D, RP, R, 90D, M6 Mark II), whose left tiles extend bands 5 and 8 exactly where the rule says, +and single-tile `0x100` and `0x200` files, which never extend a band. In every plane, each band decodes +at its coded width and at no other width up to two columns wider, so all sensor hashes are unchanged. +No public file has three or more C-RAW tiles; a middle tile follows the same rule, which is unverified. + The version `0x200` QP predictor and entropy framing were checked independently. Controlled input mutations establish floor averaging of paired QP rows before the nonlinear integer table, then multiplication, division by eight, addition From 0f67225e83dc3e41de9420d188af99ed2ff0b48d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 22:13:49 +0000 Subject: [PATCH 023/158] Hasselblad FFF: colour matrix from the embedded 3FR header An FFF (a 3FR converted on the desktop) has no colour matrix of its own: the camera's stays in the original 3FR header the FFF carries as a complete TIFF in private IFD0 tag 0xC51B. The new vendor::hasselblad reader decodes 3FR / FFF through dng::decode_as and, when the file has no matrix, takes the matrices from that header, so an FFF no longer develops with the "camera RGB = sRGB" fallback (H4D-40 as-shot white moves from xy 0.279, 0.356 to 0.325, 0.336). RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR (raw.pixls.us 931 and 709). Synthetic tests cover the embedded matrix, an own matrix winning, a missing or damaged header, and truncated files. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J2NDVXA8jQQWeJb4YVTb6b --- crates/raw/src/lib.rs | 4 +- crates/raw/src/vendor/hasselblad.rs | 176 ++++++++++++++++++++++++++++ crates/raw/src/vendor/mod.rs | 1 + docs/parity-checklist.md | 5 +- docs/raw-parity.md | 5 +- 5 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 crates/raw/src/vendor/hasselblad.rs diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 202983331..2800f0823 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -110,7 +110,7 @@ pub enum RawFormat { X3f, /// A TIFF-based raw that has no DNG version tag but describes itself the way DNG does (a /// full-resolution CFA image with black/white levels, a default crop and `AsShotNeutral`): - /// Hasselblad 3FR and FFF. Read by the DNG reader. + /// Hasselblad 3FR and FFF. Read by `vendor::hasselblad` (the DNG reader plus what Hasselblad adds). CfaTiff, /// Kodak DCS520C TIFF: a two-component lossless JPEG CFA sub-IFD with a response curve (`vendor::kodak`). KodakDcs, @@ -397,7 +397,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), RawFormat::Pef => vendor::pef::decode(bytes, mode), RawFormat::Orf => vendor::orf::decode(bytes, mode), - RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff).map(lift_clipped), + RawFormat::CfaTiff => vendor::hasselblad::decode(bytes, mode).map(lift_clipped), RawFormat::Srw => vendor::srw::decode(bytes, mode), RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode), RawFormat::Erf => vendor::erf::decode(bytes, mode), diff --git a/crates/raw/src/vendor/hasselblad.rs b/crates/raw/src/vendor/hasselblad.rs new file mode 100644 index 000000000..1a3b6c427 --- /dev/null +++ b/crates/raw/src/vendor/hasselblad.rs @@ -0,0 +1,176 @@ +//! Hasselblad 3FR and FFF raws ([`crate::RawFormat::CfaTiff`]). +//! +//! Sources: TIFF 6.0 (container), the DNG Specification (the tags these files borrow) and our own black-box +//! analysis of a CC0 H4D-40 FFF from raw.pixls.us (931, "Hasselblad - H4D-40 - 16bit (4:3).fff"); every number +//! below was measured on it. +//! +//! - Both are TIFFs without a DNG version tag that describe their raw image with DNG's tags: a full-resolution +//! CFA sub-IFD with `BlackLevel`, `WhiteLevel`, `DefaultCropOrigin` / `DefaultCropSize`, and `AsShotNeutral`. +//! [`crate::dng::decode_as`] reads all of that; this module adds what that reader does not know about. +//! - The raw image is uncompressed (X1D, X1D II, X2D, CFV 100C) or Hasselblad's lossless JPEG with predictor +//! selection value 8 ([`crate::ljpeg::decode_hasselblad`]); the H4D-40 FFF's 7410 x 5586 strip is the latter. +//! - No `CFAPattern`. The layout is RGGB from the sensor origin: averaged over the 6 x 6 sensor blocks behind each +//! pixel of the file's 1217 x 913 RGB IFD0 image (the default crop is exactly six times its size), the sites at +//! (0, 0) correlate 0.976 with its red and 0.726 with its blue, the sites at (1, 1) 0.896 with blue and 0.847 +//! with red, and the two green sites' diagonal differences are half those of the other pair. +//! - An FFF is a 3FR converted on the desktop. It has no colour matrix of its own: the camera's matrix stays in +//! the original 3FR header, which the FFF carries whole as a little-endian TIFF in the private IFD0 tag +//! `0xC51B` (4096 bytes in the sample: an IFD0 with Make, Model, `ColorMatrix1` and `AsShotNeutral`, a +//! `SubIFDs` pair for the raw and preview images, whose data offsets point into the old 3FR and are not read). +//! [`decode`] takes the matrices from there when the outer file has none. The white balance stays the outer +//! file's, which is the one the converter last saved. +use crate::dng; +use crate::{ColorData, Mode, RawFormat, RawImage, Result}; +use lightcraft_tiff::{ParseOptions, Tiff}; + +/// Private IFD0 tag holding the original 3FR header (a complete TIFF) inside an FFF. +pub(crate) const ORIGINAL_HEADER: u16 = 0xc51b; + +/// Upper bound on the embedded header's size: the sample's is 4 KiB; a larger block is not a header. +const MAX_HEADER: usize = 1 << 20; + +/// Decode a 3FR / FFF (or another TIFF raw in the DNG layout, see [`dng::is_plain_cfa_tiff`]). +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { + let mut img = dng::decode_as(bytes, mode, RawFormat::CfaTiff)?; + if !crate::color::has_matrix(&img.color) + && let Some(header) = original_header_color(bytes) + { + adopt_matrices(&mut img.color, &header); + } + Ok(img) +} + +/// The colour tags of the original 3FR header an FFF embeds in [`ORIGINAL_HEADER`], when it has a colour matrix. +fn original_header_color(bytes: &[u8]) -> Option { + let outer = Tiff::parse(bytes).ok()?; + let block = outer.ifds.first()?.bytes(ORIGINAL_HEADER)?; + if block.len() > MAX_HEADER { + return None; + } + let opts = ParseOptions { max_ifds: 16, max_depth: 2, ..Default::default() }; + let header = Tiff::parse_with(block, &opts).ok()?; + let ifd0 = header.ifds.first()?; + let raw = dng::raw_ifd(&header).unwrap_or(ifd0); + let color = dng::color_data(ifd0, raw, header.order); + crate::color::has_matrix(&color).then_some(color) +} + +/// Take the camera matrices (and the illuminants they belong to) of `from`; the rest of `color` stays. +fn adopt_matrices(color: &mut ColorData, from: &ColorData) { + color.illuminant = from.illuminant; + color.color_matrix = from.color_matrix; + color.forward_matrix = from.forward_matrix; + color.camera_calibration = from.camera_calibration; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RawData, RawError, decode as decode_file, probe, probe_info}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + + const MATRIX: [(i32, i32); 9] = + [(686, 1000), (-196, 1000), (-32, 1000), (-437, 1000), (1228, 1000), (235, 1000), (-165, 1000), (317, 1000), (675, 1000)]; + + fn rgb_ifd() -> IfdBuilder { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![3])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![vec![0x80; 36]] }); + ifd + } + + /// The camera's 3FR header as an FFF embeds it: a little-endian TIFF whose IFD0 has the colour matrix. + fn original_header(matrix: bool) -> Vec { + let mut ifd0 = rgb_ifd(); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D".into())); + if matrix { + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(MATRIX.to_vec())); + } + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(43, 100), (1, 1), (79, 100)])); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + /// An FFF in miniature: big-endian, IFD0 an RGB image with `AsShotNeutral` and the embedded header `header`, + /// a 16-bit uncompressed CFA sub-IFD of 8 x 6 samples, no colour matrix of its own unless `own_matrix`. + fn fff(header: Option>, own_matrix: bool) -> Vec { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); + 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::BLACK_LEVEL, Value::Rational(vec![(256, 1)])); + raw.set(t::WHITE_LEVEL, Value::Short(vec![65535])); + raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![(0..96u8).collect()] }); + let mut ifd0 = rgb_ifd(); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D-40".into())); + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (4, 5)])); + if own_matrix { + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + } + if let Some(h) = header { + ifd0.set(ORIGINAL_HEADER, Value::Undefined(h)); + } + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + fn matrix_row0(c: &ColorData) -> Option<[f64; 3]> { + c.color_matrix[0].map(|m| m.0[0]) + } + + /// The FFF's own tags give the samples and white balance; the colour matrix comes from the 3FR header it + /// embeds, so the image is not developed with the "camera RGB ≈ sRGB" fallback. + #[test] + fn fff_takes_the_colour_matrix_from_the_embedded_3fr_header() { + let bytes = fff(Some(original_header(true)), false); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode_file(&bytes).unwrap(); + assert_eq!(r.data, RawData::U16((0..48u16).map(|i| u16::from_be_bytes([2 * i as u8, 2 * i as u8 + 1])).collect())); + assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); + assert_eq!(matrix_row0(&r.color), Some([0.686, -0.196, -0.032])); + assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.8]), "the outer file's white balance"); + assert!(!crate::color::camera_transform_of(&r.color, lightcraft_color::D65).matrix_is_fallback); + // the import probe says the same + assert_eq!(matrix_row0(&probe_info(&bytes).unwrap().color), Some([0.686, -0.196, -0.032])); + } + + /// A matrix of the file's own wins; without one anywhere (no header, or a header without a matrix) the + /// file still decodes, with the fallback colour. + #[test] + fn own_matrix_wins_and_a_missing_one_is_not_an_error() { + let own = decode_file(&fff(Some(original_header(true)), true)).unwrap(); + assert_eq!(matrix_row0(&own.color), Some([1.0, 0.0, 0.0])); + for header in [None, Some(original_header(false)), Some(b"not a tiff".to_vec())] { + let r = decode_file(&fff(header, false)).unwrap(); + assert!(!crate::color::has_matrix(&r.color)); + } + } + + /// A truncated or damaged file never panics: an error, or a decode. + #[test] + fn damaged_files_do_not_panic() { + let bytes = fff(Some(original_header(true)), false); + for n in (0..bytes.len()).step_by(7) { + let _ = decode_file(&bytes[..n]); + let _ = probe_info(&bytes[..n]); + } + let mut header = original_header(true); + for i in (8..header.len()).step_by(3) { + header[i] ^= 0x5a; + match decode_file(&fff(Some(header.clone()), false)) { + Ok(_) | Err(RawError::Corrupt(_) | RawError::Tiff(_) | RawError::Unsupported(_) | RawError::Limit(_) | RawError::NotRaw) => {} + } + } + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 09c316d29..ef9616e09 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -12,6 +12,7 @@ pub mod cr3; pub mod crx; pub(crate) mod crx_wavelet; pub mod erf; +pub(crate) mod hasselblad; pub mod kodak; pub mod mef; pub mod nef; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..06b818e4c 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-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs; LR-IMP-FORMATS, Hasselblad FFF colour matrix from the embedded 3FR header) · **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 @@ -82,7 +82,7 @@ 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 (local adjustment templates become mask effects, LR-MASK-EFFECTS), 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); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | 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, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), 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-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`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`, `crates/raw/src/vendor/hasselblad.rs`): 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, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), 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`, `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: ~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). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). 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 | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | LR-IMP-FORMATS: a Hasselblad FFF develops with the camera's colour matrix from the original 3FR header it embeds (tag `0xC51B`) instead of the neutral matrix; status unchanged (🟡) | | 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 (🟡) | | 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 (🟡) | | 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 (✅) | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index d9cc06a93..42cca4610 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: 515 models verified after the CC0 per-model checks; Epson ERF and Mamiya MEF decode; measured Nikon image areas) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Hasselblad FFF colour matrix from the embedded 3FR header) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -159,7 +159,7 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus | 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 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 | 🟡 | six Hasselblad models verified on nine files; FFF neutral-matrix colour remains unverified | +| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; read by `vendor::hasselblad`: an FFF has no colour matrix of its own and now takes the camera's from the original 3FR header it embeds in tag `0xC51B` (it used to develop with the neutral matrix); RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR | | 3FR / FFF | Hasselblad compression | ⬜ | preview | | ERF | 12-bit in 16-byte blocks (R-D1, R-D1s, R-D1x) | ✅ | 3 CC0 corpus files; crop registered and WB measured against the embedded preview (no WB tag found); `vendor/erf.rs` | | IIQ, MOS, MRW, 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) | @@ -287,6 +287,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Hasselblad 3FR / FFF get their own reader (`crates/raw/src/vendor/hasselblad.rs`): an FFF takes its colour matrix from the original 3FR header in tag `0xC51B` instead of developing with the neutral matrix; RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; counts unchanged | | 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | | 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% | From 5bf0d45ba87be9e99cfaaf14dd1a1b41d04e14da Mon Sep 17 00:00:00 2001 From: fanyzp Date: Fri, 16 Oct 2026 02:20:40 +0800 Subject: [PATCH 024/158] Do not read a sibling's shared stem sidecar on import (#1905) With stem naming, `IMG_0001.xmp` is the sidecar of every file of that stem and, in a folder Lightroom has worked on, it is the raw's. On import the packet was looked up with the library naming alone, so a TIFF or JPEG arriving beside a raw of the same name read the raw's packet and took its rating, label, keywords and `crs:` develop settings (the case radialmonster reported in #92; the read side of a catalogued photo is #1523). `ImportStems` ranks the files an import is adding as if they were catalogued already, in the order `StemOwners` uses for writing: the library's files and the arriving ones together, a raw first, then the file name in lower case. A file that does not own the stem sidecar reads only its own `.xmp`, never the sibling's packet. A file that is not catalogued yet has no id, and the id it draws when the import commits is above every id in the library, so it loses a tie against a catalogued file; between arriving files the order they arrive in stands for it. Folder synchronize and the Lightroom catalog import go through the same function, so a file arriving there is ranked the same way. --- crates/engine/src/import.rs | 21 +++- crates/engine/src/sidecar.rs | 101 ++++++++++++++++-- .../engine/src/sidecar/tests_stem_owners.rs | 78 +++++++++++++- crates/engine/src/tests_import.rs | 91 ++++++++++++++++ docs/parity-checklist.md | 5 +- docs/xmp-interop.md | 6 +- 6 files changed, 283 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 9aa847b01..f3c7dc9c0 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -965,9 +965,11 @@ impl ImportJob { } /// What the library held when the import was set up, by path and by content: made once, with - /// the first files (on the thread that prepares them), from the library as it was then. - fn look_up_library(&mut self) { - let Some((library, fresh)) = self.library.take() else { return }; + /// the first files (on the thread that prepares them), from the library as it was then. The + /// snapshot comes back: the files the import is adding are ranked against it for the ownership + /// of the shared stem sidecars they read ([`crate::sidecar::ImportStems`]). + fn look_up_library(&mut self) -> Option { + let (library, fresh) = self.library.take()?; for p in library.photos() { // a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate if p.deleted && fresh { @@ -980,12 +982,14 @@ impl ImportJob { self.by_hash.insert(h.clone(), Some(p.id)); } } + Some(library) } /// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned. pub fn prepare_files(&mut self, files: Vec, cancel: &std::sync::atomic::AtomicBool) -> Prepared { use std::sync::atomic::Ordering::Relaxed; - self.look_up_library(); + // the library as it was, kept for the ownership of the shared stem sidecars below + let library = self.look_up_library(); let opts = self.opts.clone(); let mode = self.mode; let mut out = Prepared { scanned: files.len(), items: Vec::new() }; @@ -1025,6 +1029,13 @@ impl ImportJob { let probed: Vec> = cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); crate::memory::release(); + // Who owns the shared stem sidecar (`IMG_0001.xmp`) among the library's files and the ones + // arriving with this import: a JPEG or a TIFF arriving beside its raw reads its own sidecar + // and never the raw's packet (issue #1905). + let stems = crate::sidecar::ImportStems::for_import( + library.as_ref(), + todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))), + ); // Files are planned one by one, in order (duplicates, sidecars, names, folders); their // copies or moves — the slow part, reading and writing every byte and verifying it — are // collected and run on several threads (issue #367), then their outcomes take their places. @@ -1063,7 +1074,7 @@ impl ImportJob { // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files // and names the copy when the file itself has none let raw = info.kind == MediaKind::Raw; - let packet = crate::sidecar::find_sidecar(&path, self.naming) + let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path) .and_then(|f| std::fs::read_to_string(f).ok()) .or_else(|| info.xmp.clone().filter(|_| raw)); let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index ea06d69a7..88b2f1d12 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -15,7 +15,10 @@ //! **Shared names** (Stem naming): `IMG_0001.CR3` and `IMG_0001.JPG` would share `IMG_0001.xmp`. //! When two catalogued files map to one stem sidecar, it belongs to one of them (a raw first, then //! the first by file name — [`Session::sidecar_naming`]); the others use `.xmp` -//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other. +//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other. An import ranks +//! the files it is adding against the library the same way ([`ImportStems`]): a JPEG or a TIFF +//! arriving beside its raw's `IMG_0001.xmp` reads its own `.xmp`, never the raw's packet +//! (issue #1905). //! //! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except //! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`, @@ -23,7 +26,7 @@ //! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:` //! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -69,6 +72,18 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option { sidecar_candidates(original, naming).find(|p| p.is_file()) } +/// Find the sidecar for a file an import is adding, by the ownership [`ImportStems`] worked out +/// (issue #1905): [`find_sidecar`] with the library's naming, except that a file which does not +/// own its stem sidecar reads its own `.xmp` (the name it writes) and never the packet of +/// the sibling that owns the stem one. +pub(crate) fn find_sidecar_for_import(stems: &ImportStems, naming: SidecarNaming, original: &str) -> Option { + if stems.owns(original) { + return find_sidecar(original, naming); + } + let shared = xmp_cases(sidecar_path(original, SidecarNaming::Stem)); + sidecar_candidates(original, SidecarNaming::Full).filter(|p| !shared.contains(p)).find(|p| p.is_file()) +} + /// The names `original`'s sidecar can have, in the order they are looked for: under `naming`, /// then under the other naming (the full name also with the photo extension in the other case), /// each as `.xmp`, `.XMP` and `.Xmp`. @@ -87,7 +102,12 @@ pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl } v }) - .flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")]) + .flat_map(xmp_cases) +} + +/// The three ways one sidecar name can be written: `.xmp`, `.XMP` and `.Xmp`. +fn xmp_cases(p: PathBuf) -> [PathBuf; 3] { + [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")] } /// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated. @@ -526,6 +546,16 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec) { } } +/// Where a file stands in the order a shared stem sidecar is owned by: a raw first, then the file +/// name in lower case, then the catalog id (a file an import is adding has no id yet, and ranks +/// last among files of its kind and name: see [`ImportStems`]). +type Rank = (bool, String, u64); + +/// The rank of the file at `path`, of kind `kind`, catalogued as `id`. +fn rank_of(path: &str, kind: MediaKind, id: u64) -> Rank { + (kind != MediaKind::Raw, path.to_lowercase(), id) +} + /// Which photo owns each stem sidecar that several catalogued files map to (see /// [`Session::sidecar_naming`]). pub(crate) struct StemOwners(HashMap); @@ -536,10 +566,10 @@ impl StemOwners { /// owners were worked out before, for every write). #[cfg(test)] pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners { - let mut best: HashMap = HashMap::new(); + let mut best: HashMap = HashMap::new(); for p in cat.photos().filter(|p| p.copy_of.is_none()) { let Some(path) = file_path(p) else { continue }; - let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0); + let rank = rank_of(path, p.kind, p.id.0); let e = best.entry(stem_key(path)).or_insert_with(|| (rank.clone(), p.id, 0)); e.2 += 1; if rank < e.0 { @@ -564,7 +594,7 @@ impl StemOwners { /// The owners of the shared stems in `wanted`. fn where_wanted(cat: &lightcraft_catalog::Catalog, wanted: &std::collections::HashSet) -> StemOwners { // per stem: (raw first, then file name, then id) of the best candidate; and how many - let mut best: HashMap = HashMap::new(); + let mut best: HashMap = HashMap::new(); let mut key = String::new(); for p in cat.photos().filter(|p| p.copy_of.is_none()) { let Some(path) = file_path(p) else { continue }; @@ -572,7 +602,7 @@ impl StemOwners { if !wanted.contains(key.as_str()) { continue; } - let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0); + let rank = rank_of(path, p.kind, p.id.0); let e = best.entry(key.clone()).or_insert_with(|| (rank.clone(), p.id, 0)); e.2 += 1; if rank < e.0 { @@ -591,6 +621,63 @@ impl StemOwners { } } +/// Which of the files an import is adding may read the shared stem sidecar (`IMG_0001.xmp`) of +/// their stem, and which read their own `.xmp` (`IMG_0001.TIF.xmp`) instead (issue #1905: +/// before, a JPEG or a TIFF imported beside its raw read the raw's packet and took its rating, +/// metadata and develop settings). The order is the one [`StemOwners`] applies to writing, with +/// the arriving files ranked against the library and against each other: a raw first, then the +/// file name in lower case. A file that is not catalogued yet has no id, and the id it draws when +/// the import commits is above every id in the library, so it loses a tie against a catalogued +/// file; between arriving files the order they arrive in stands for it. +pub(crate) struct ImportStems { + /// The arriving files that own the stem sidecar of their stem, by path as it arrives. + owners: HashSet, +} + +impl ImportStems { + /// Work out [`ImportStems`] for the files `incoming`, in the order they arrive. `None` for a + /// library already looked over (a job prepared twice): there is nothing to rank against, so + /// every arriving file reads as it did before. The library is passed over once, and only the + /// stems the arriving files map to are looked at closer. + pub(crate) fn for_import<'a>(cat: Option<&lightcraft_catalog::Catalog>, incoming: impl Iterator) -> ImportStems { + // per stem: the best of the library's files, and the best arriving file with its path + let mut best: HashMap, Option<(Rank, &'a str)>)> = HashMap::new(); + for (arriving, (path, kind)) in incoming.enumerate() { + let e = best.entry(stem_key(path)).or_default(); + let rank = rank_of(path, kind, arriving as u64); + if e.1.as_ref().is_none_or(|(b, _)| rank < *b) { + e.1 = Some((rank, path)); + } + } + if let Some(cat) = cat { + let mut key = String::new(); + for p in cat.photos().filter(|p| p.copy_of.is_none()) { + let Some(path) = file_path(p) else { continue }; + stem_key_into(&mut key, path); + let Some(e) = best.get_mut(key.as_str()) else { continue }; + let rank = rank_of(path, p.kind, p.id.0); + if e.0.as_ref().is_none_or(|b| rank < *b) { + e.0 = Some(rank); + } + } + } + let owners = best + .into_values() + .filter_map(|(in_library, arriving)| { + let (rank, path) = arriving?; + let wins = in_library.as_ref().is_none_or(|b| (rank.0, rank.1.as_str()) < (b.0, b.1.as_str())); + wins.then(|| path.to_string()) + }) + .collect(); + ImportStems { owners } + } + + /// Whether the arriving file at `original` owns the stem sidecar of its stem. + pub(crate) fn owns(&self, original: &str) -> bool { + self.owners.contains(original) + } +} + /// How the sidecar of each of photos `ids` is named in library `cat`, whose naming is `naming` /// ([`Session::sidecar_namings`], for a thread that has the library and not the session). pub(crate) fn namings_in(cat: &lightcraft_catalog::Catalog, naming: SidecarNaming, ids: &[PhotoId]) -> Vec { diff --git a/crates/engine/src/sidecar/tests_stem_owners.rs b/crates/engine/src/sidecar/tests_stem_owners.rs index 43170f4f6..0d86fe92d 100644 --- a/crates/engine/src/sidecar/tests_stem_owners.rs +++ b/crates/engine/src/sidecar/tests_stem_owners.rs @@ -1,4 +1,5 @@ -//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`). +//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`), and +//! for the files an import is adding (`ImportStems`). //! //! A library can hold millions of photos; a sidecar that is written for one of them, or a folder //! that is synchronized, must not cost a table of them all. @@ -37,6 +38,11 @@ const PATHS: [&str; 26] = [ "/pics/dots/a.jpg", ]; +/// The kind a file of this name is catalogued as: the `.cr3` ones are raws. +fn kind_of(path: &str) -> MediaKind { + if path.to_lowercase().ends_with("cr3") { MediaKind::Raw } else { MediaKind::Image } +} + fn library() -> (Catalog, Vec) { let mut c = Catalog::new(); let mut ids = Vec::new(); @@ -44,9 +50,7 @@ fn library() -> (Catalog, Vec) { let id = c.alloc_photo_id(); let name = path.rsplit('/').next().unwrap_or(path); let mut p = Photo::new(id, Source::File { path: path.to_string() }, name, "X", 6, 4, "2026-01-01"); - if path.to_lowercase().ends_with("cr3") { - p.kind = MediaKind::Raw; - } + p.kind = kind_of(path); c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); ids.push(id); } @@ -114,6 +118,72 @@ fn owners_among_some_photos_hold_nothing_of_the_rest() { assert_eq!(namings(&some, &c, &first), [SidecarNaming::Stem, SidecarNaming::Full, SidecarNaming::Stem]); } +/// Given a library where files share stems in every way a name can, when files arrive in an +/// import, then each of them may read the shared stem sidecar exactly when it would own it once +/// it is catalogued: the arriving files are ranked against the library's files and against each +/// other, as the write rule ranks them (issue #1905). +#[test] +fn an_arriving_file_owns_the_stem_sidecar_exactly_when_it_would_once_catalogued() { + let (c, _) = library(); + // the arrivals: the same names under an upper-case folder, so each shares its stem with the + // library's files (the key lower-cases the path) without being one of them + let incoming: Vec<(String, MediaKind)> = PATHS + .iter() + .map(|p| { + let (dir, name) = p.rsplit_once('/').unwrap_or(("", p)); + (format!("{}/{name}", dir.to_uppercase()), kind_of(p)) + }) + .collect(); + // the library with these arrivals in it, the ids drawn in the order they arrive + let arriving_in = |base: &Catalog, add: &[usize]| -> Catalog { + let mut cat = base.clone(); + for &k in add { + let (path, kind) = &incoming[k]; + let id = cat.alloc_photo_id(); + let name = path.rsplit('/').next().unwrap_or(path); + let mut p = Photo::new(id, Source::File { path: path.clone() }, name, "X", 6, 4, "2026-01-01"); + p.kind = *kind; + cat.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + cat + }; + let owns_once_catalogued = |cat: &Catalog, k: usize| -> bool { + let path = &incoming[k].0; + let p = cat.photos().find(|p| matches!(&p.source, Source::File { path: x } if x == path)).unwrap(); + StemOwners::of(cat).naming(p, SidecarNaming::Stem) == SidecarNaming::Stem + }; + let as_arriving = |ks: &[usize]| -> Vec<(&str, MediaKind)> { ks.iter().map(|k| (incoming[*k].0.as_str(), incoming[*k].1)).collect() }; + let all: Vec = (0..incoming.len()).collect(); + for i in 0..incoming.len() { + let alone = ImportStems::for_import(Some(&c), as_arriving(&[i]).into_iter()); + assert_eq!(alone.owns(&incoming[i].0), owns_once_catalogued(&arriving_in(&c, &[i]), i), "{} alone", incoming[i].0); + for j in 0..incoming.len() { + // every pair arriving together + if i == j { + continue; + } + let pair = ImportStems::for_import(Some(&c), as_arriving(&[i, j]).into_iter()); + let cat = arriving_in(&c, &[i, j]); + for k in [i, j] { + assert_eq!( + pair.owns(&incoming[k].0), + owns_once_catalogued(&cat, k), + "{} with {}", + incoming[k].0, + incoming[if k == i { j } else { i }].0 + ); + } + } + } + // a job prepared twice (a library already looked over) has nothing to rank against: every + // one of them is ranked among the arrivals alone, as it would be in a library of just them + let alone = ImportStems::for_import(None, as_arriving(&all).into_iter()); + let only_arrivals = arriving_in(&Catalog::new(), &all); + for k in &all { + assert_eq!(alone.owns(&incoming[*k].0), owns_once_catalogued(&only_arrivals, *k), "{} without a library", incoming[*k].0); + } +} + /// The key written into a buffer (no allocation for a plain path) is the key: for every path /// above, for shapes a path from a file can have, and for every string of up to six of the /// characters that decide a key (a letter in each case, a dot, both separators, a letter that is diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 3d6469deb..ce8ef3171 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1748,3 +1748,94 @@ fn presets_apply_the_imported_profile_they_name() { assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid"); let _ = std::fs::remove_dir_all(&src); } + +/// A raw container LightCraft shows by its embedded JPEG: a TIFF whose IFD0 points at one. +fn write_raw(path: &Path) { + use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let px: Vec = (0..48 * 32).flat_map(|i| [(i % 251) as u8, 91, 201]).collect(); + let j = encode_jpeg(&EncodeImage::new(48, 32, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let off = 8 + 2 + 24 + 4; + let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, v) in [(513u16, off as u32), (514, j.len() as u32)] { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0, 0, 0, 0]); + f.extend_from_slice(&j); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, f).unwrap(); +} + +/// An edited TIFF of a raw: the same name, a TIFF (not a raw) to LightCraft. +fn write_tiff(path: &Path, seed: u8) { + use lightcraft_codecs::{EncodeImage, EncodeMeta, Samples, TiffCompression, encode_tiff}; + let (w, h) = (48usize, 32usize); + let px: Vec = (0..w * h).flat_map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed]).collect(); + let bytes = encode_tiff(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&px)), TiffCompression::Deflate, &EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +/// Issue #1905 (the import side of #1523): the stem sidecar `IMG_1.xmp` belongs to the raw first, +/// so a TIFF or JPEG arriving beside a raw of the same name reads its own `IMG_1.TIF.xmp`, never +/// the raw's packet, with or without the raw. Before, the arriving file took the raw's rating, +/// label, metadata and develop settings. +#[test] +fn a_file_arriving_beside_its_raws_stem_reads_its_own_sidecar() { + let sidecar = |rating: u8| { + format!( + r#" +"# + ) + }; + let read = |s: &Session, name: &str| -> u8 { s.catalog.photos().find(|p| p.file_name == name).map(|p| p.rating).unwrap_or_default() }; + + // the raw and the edited TIFF of it, same name, in one import + let src = temp_dir("stem-pair"); + write_raw(&src.join("IMG_1.NEF")); + write_tiff(&src.join("IMG_1.TIF"), 7); + std::fs::write(src.join("IMG_1.xmp"), sidecar(5)).unwrap(); + std::fs::write(src.join("IMG_1.TIF.xmp"), sidecar(2)).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}"); + assert_eq!(r["sidecars"], 2, "{r}"); + assert_eq!(read(&s, "IMG_1.NEF"), 5, "the raw reads the stem sidecar, which is its own"); + assert_eq!(read(&s, "IMG_1.TIF"), 2, "the TIFF reads its own sidecar, never the raw's"); + let _ = std::fs::remove_dir_all(&src); + + // the TIFF with no sidecar of its own: nothing of the raw's packet comes across + let src = temp_dir("stem-none"); + write_raw(&src.join("IMG_2.NEF")); + write_tiff(&src.join("IMG_2.TIF"), 8); + std::fs::write(src.join("IMG_2.xmp"), sidecar(4)).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}"); + assert_eq!(r["sidecars"], 1, "{r}"); + assert_eq!(read(&s, "IMG_2.NEF"), 4); + assert_eq!(read(&s, "IMG_2.TIF"), 0, "the raw's packet is not the TIFF's"); + let _ = std::fs::remove_dir_all(&src); + + // the raw already in the library, the edited TIFF arriving later + use lightcraft_catalog::{MediaKind, Op, Photo, Source}; + let src = temp_dir("stem-after"); + write_raw(&src.join("IMG_3.NEF")); + write_tiff(&src.join("IMG_3.TIF"), 9); + std::fs::write(src.join("IMG_3.xmp"), sidecar(3)).unwrap(); + let mut s = Session::new().with_fs(); + let raw = { + let id = s.catalog.alloc_photo_id(); + let path = src.join("IMG_3.NEF").to_string_lossy().into_owned(); + let mut p = Photo::new(id, Source::File { path }, "IMG_3.NEF", "NEF", 48, 32, "2026-01-14T05:58:48"); + p.kind = MediaKind::Raw; + p + }; + s.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.join("IMG_3.TIF").to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}"); + assert_eq!(r["sidecars"], 0, "{r}"); + assert_eq!(read(&s, "IMG_3.TIF"), 0, "the catalogued raw owns IMG_3.xmp"); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..1fcddcbbd 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-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** patch (LR-META-XMP: a file imported beside another one of its stem reads its own sidecar, never the sibling's shared stem packet, issue #1905) · **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 @@ -170,7 +170,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -887,3 +887,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LRC-LIB-CATALOG-IMPORT review: offline Local promotion and result dismissal across library switches covered; redundant operation/settings copies removed. Status and summary counts unchanged. | | 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | | 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) | +| 2026-10-16 | patch | LR-META-XMP: a TIFF or JPEG imported beside a raw of its stem reads its own `.xmp` and takes nothing from the raw's `IMG_0001.xmp`, whether it arrives with the raw or after it (a stem sidecar belongs to one file: a raw first, then the first by name); status unchanged (✅) | diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index c467a471a..c2ce80ea4 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -10,7 +10,7 @@ other tools), and let LightCraft pick up edits made elsewhere. |---|---| | Name | `.xmp` by default (`IMG_0001.CR3` → `IMG_0001.xmp`); `.xmp` (`IMG_0001.CR3.xmp`) with `naming: "full"`. Reading accepts either (preferred first) and `.XMP`. With stem naming, files sharing a stem don't share a sidecar: see *Shared names* below. | | Write | `photo.saveMetadataToFile {ids?}` (Photo ▸ Save Metadata to File, ⌘S), or automatically after every change with `library.xmpPreferences {autoWrite: true}` (File ▸ Automatically Write Changes into XMP). Slider drags are written once, when the drag ends; undo/redo rewrite the sidecar. An existing sidecar is **merged into, never replaced** (see *Saving into an existing sidecar*). Written atomically (temp file, fsync, rename). The result lists `written`, `merged` and `backups`. | -| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). For raw/DNG files without a sidecar, the XMP embedded in the file is used. `photo.readMetadataFromFile` never writes the sidecar, even with auto-write on: writing back what it has just read would add `lc:settings`, which later reads prefer over another app's `crs:` edits (issue #934). | +| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). For raw/DNG files without a sidecar, the XMP embedded in the file is used. `photo.readMetadataFromFile` never writes the sidecar, even with auto-write on: writing back what it has just read would add `lc:settings`, which later reads prefer over another app's `crs:` edits (issue #934). A file arriving in an import is ranked as if it were catalogued already: it reads its own `.xmp` when another file of its stem (a raw first) owns the shared stem sidecar (issue #1905). | | Preferences | `library.xmpPreferences {autoWrite?, naming?: stem\|full}`, stored in the library's `prefs.json`. | What we write (standard namespaces, so other tools can read the metadata): @@ -74,6 +74,10 @@ library share a stem, the stem sidecar belongs to one of them — a raw first, o the others write and read `.xmp` (`IMG_0001.JPG.xmp`), falling back to the stem sidecar for reading when they have none. So saving one photo never overwrites the other's metadata (`Session::sidecar_naming`). +An import ranks the files it is adding the same way, before they are catalogued: a TIFF or JPEG arriving beside a +raw of its stem reads its own `.xmp` and takes nothing from the raw's `IMG_0001.xmp`, whether it arrives +with the raw or after it (issue #1905). + ## Face regions (MWG) LightCraft reads face, pet, focus and barcode regions in the Metadata Working Group's region schema From b5dc500c542339a850bea37ab4ea3a669829688a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:58:05 +0000 Subject: [PATCH 025/158] Hasselblad 3FR / FFF: uncompressed RGB embedded previews These files carry no JPEG: their preview is an uncompressed 8-bit RGB IFD (1217 x 913 in the H4D-40 FFF, 218 x 218 in the CFV 3FR). The largest such image is handed on by embedded_preview as a small baseline TIFF, which lightcraft_codecs::decode reads, so 3FR / FFF show a preview in the grid and loupe before they are developed. No Orientation tag is written: the raw file's applies, as for JPEG previews without one. The FFF's 16-bit copy (with a TransferFunction of unknown meaning) is not used. decode_raw_preview applied a container-declared colour space by forcing the JPEG decoder on any owned preview; it now checks for JPEG bytes, so a wrapped TIFF (or a JPEG XL preview) goes to its own decoder. Checked on the CC0 H4D-40 FFF and CFV 3FR through embedded_preview_srgb: right colours, the 3FR upright through its Rotate90. Tests cover the wrap, the choice among RGB images, a short strip and truncated files. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J2NDVXA8jQQWeJb4YVTb6b --- crates/engine/src/files.rs | 5 +- crates/raw/src/preview.rs | 6 +- crates/raw/src/vendor/hasselblad.rs | 123 +++++++++++++++++++++++++++- docs/parity-checklist.md | 5 +- docs/raw-parity.md | 5 +- 5 files changed, 134 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dd246cf84..31bb930fb 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -707,8 +707,9 @@ pub(crate) fn decode_raw_preview( covering: Option, ) -> Option { let jpeg = stand_in_image(bytes, covering)?; - // the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader - let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_)); + // the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview + // wrapped as a TIFF) and a JPEG XL preview are read by their own decoders + let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]); match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) { Some(space) => { let space = match space { diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 1de7ab9e6..71dfad10d 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -148,8 +148,8 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>) } } -/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file — both decode with -/// `lightcraft_codecs::decode`. +/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file, or for a Hasselblad 3FR / FFF, which +/// has no JPEG, its uncompressed RGB preview wrapped as a baseline TIFF — all decode with `lightcraft_codecs::decode`. pub fn embedded_preview(bytes: &[u8]) -> Option> { preview_covering(bytes, None) } @@ -220,6 +220,8 @@ fn preview_covering(bytes: &[u8], min_edge: Option) -> Option> { .map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() }); // a TIFF raw whose preview no tag points to: one we can't decode (Leaf MOS), or Epson ERF (in its maker note) best.or_else(|| format.filter(|f| !f.is_supported() || *f == crate::RawFormat::Erf).and_then(|_| scan_for_jpeg(bytes))) + // Hasselblad 3FR / FFF carry no JPEG, only uncompressed RGB images: one wrapped as a baseline TIFF + .or_else(|| (format == Some(crate::RawFormat::CfaTiff)).then(|| crate::vendor::hasselblad::rgb_preview(&tiff, bytes)).flatten()) } /// The end of the DCT JPEG whose SOI is at `soi` (exclusive index after its EOI) and its component count, diff --git a/crates/raw/src/vendor/hasselblad.rs b/crates/raw/src/vendor/hasselblad.rs index 1a3b6c427..5f3342f9f 100644 --- a/crates/raw/src/vendor/hasselblad.rs +++ b/crates/raw/src/vendor/hasselblad.rs @@ -19,9 +19,14 @@ //! `SubIFDs` pair for the raw and preview images, whose data offsets point into the old 3FR and are not read). //! [`decode`] takes the matrices from there when the outer file has none. The white balance stays the outer //! file's, which is the one the converter last saved. +//! - No JPEG preview. IFD0 is an uncompressed 8-bit RGB image (1217 x 913 in the H4D-40 FFF, IFD1 pointing at the +//! same strip; 218 x 218 in the CFV 3FR, under the file's own `Orientation`); the FFF also has a 16-bit RGB +//! copy in a `SubIFDs` child, with a `TransferFunction` whose meaning is not known, so it is not used. +//! [`rgb_preview`] hands the largest 8-bit one on as a small baseline TIFF. use crate::dng; use crate::{ColorData, Mode, RawFormat, RawImage, Result}; -use lightcraft_tiff::{ParseOptions, Tiff}; +use lightcraft_tiff::image::{Layout, chunk_bytes}; +use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, ParseOptions, Tiff, TiffWriter, Value, tags as t}; /// Private IFD0 tag holding the original 3FR header (a complete TIFF) inside an FFF. pub(crate) const ORIGINAL_HEADER: u16 = 0xc51b; @@ -55,6 +60,58 @@ fn original_header_color(bytes: &[u8]) -> Option { crate::color::has_matrix(&color).then_some(color) } +/// Upper bound on a preview's pixel bytes (8-bit RGB): 64 MP, far above the 1.1 MP of the largest one seen. +const MAX_PREVIEW_BYTES: usize = (64 << 20) * 3; + +/// The file's largest uncompressed 8-bit RGB image (chunky, in strips), as a little-endian baseline TIFF holding +/// just those pixels, which `lightcraft_codecs::decode` reads. No `Orientation` tag is written: the viewer +/// applies the raw file's, which is the one that stands over this image in IFD0. +pub(crate) fn rgb_preview(tiff: &Tiff, bytes: &[u8]) -> Option> { + let info = tiff + .all_ifds() + .into_iter() + .filter_map(|i| i.image().ok()) + .filter(|i| { + i.photometric == t::photometric::RGB + && i.compression == t::compression::NONE + && i.samples_per_pixel == 3 + && i.bits_per_sample.iter().all(|&b| b == 8) + && i.planar == 1 + && i.sample_format == 1 + && matches!(i.layout, Layout::Strips { .. }) + && i.width > 0 + && i.height > 0 + }) + .max_by_key(|i| u64::from(i.width) * u64::from(i.height))?; + let (w, h) = (info.width as usize, info.height as usize); + let size = w.checked_mul(h)?.checked_mul(3)?; + if size > MAX_PREVIEW_BYTES { + return None; + } + let mut pixels = Vec::with_capacity(size); + for chunk in info.chunks(bytes.len() as u64) { + pixels.extend_from_slice(chunk_bytes(bytes, &chunk)?); + if pixels.len() >= size { + break; + } + } + // a short last strip is a truncated file: no preview rather than a picture with a black band + if pixels.len() < size { + return None; + } + pixels.truncate(size); + let mut ifd = IfdBuilder::new(); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![info.width])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![info.height])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); + ifd.set(t::COMPRESSION, Value::Short(vec![t::compression::NONE])); + ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: info.height, strips: vec![pixels] }); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).ok() +} + /// Take the camera matrices (and the illuminants they belong to) of `from`; the rest of `color` stays. fn adopt_matrices(color: &mut ColorData, from: &ColorData) { color.illuminant = from.illuminant; @@ -81,7 +138,7 @@ mod tests { ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![vec![0x80; 36]] }); + ifd.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![(0..36).collect()] }); ifd } @@ -100,6 +157,11 @@ mod tests { /// An FFF in miniature: big-endian, IFD0 an RGB image with `AsShotNeutral` and the embedded header `header`, /// a 16-bit uncompressed CFA sub-IFD of 8 x 6 samples, no colour matrix of its own unless `own_matrix`. fn fff(header: Option>, own_matrix: bool) -> Vec { + fff_with(header, own_matrix, vec![]) + } + + /// [`fff`] with more `SubIFDs` children of IFD0 (other preview images). + fn fff_with(header: Option>, own_matrix: bool, extra: Vec) -> Vec { let mut raw = IfdBuilder::new(); raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); @@ -122,6 +184,9 @@ mod tests { ifd0.set(ORIGINAL_HEADER, Value::Undefined(h)); } ifd0.add_sub_ifd(raw); + for e in extra { + ifd0.add_sub_ifd(e); + } TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() } @@ -164,6 +229,7 @@ mod tests { for n in (0..bytes.len()).step_by(7) { let _ = decode_file(&bytes[..n]); let _ = probe_info(&bytes[..n]); + let _ = crate::embedded_preview(&bytes[..n]); } let mut header = original_header(true); for i in (8..header.len()).step_by(3) { @@ -173,4 +239,57 @@ mod tests { } } } + + /// `(width, height, pixels, has an Orientation tag)` of a wrapped preview. + fn wrapped(tiff: &[u8]) -> (u32, u32, Vec, bool) { + let t = Tiff::parse(tiff).unwrap(); + let ifd = &t.ifds[0]; + let info = ifd.image().unwrap(); + let chunks = info.chunks(tiff.len() as u64); + let pixels = chunks.iter().flat_map(|c| chunk_bytes(tiff, c).unwrap().to_vec()).collect(); + (info.width, info.height, pixels, ifd.contains(t::ORIENTATION)) + } + + /// No JPEG in the file: the 8-bit RGB IFD0 comes out as a baseline TIFF of the same pixels, without an + /// orientation of its own (the raw file's applies). + #[test] + fn rgb_ifd0_is_the_embedded_preview() { + let bytes = fff(Some(original_header(true)), false); + let preview = crate::embedded_preview(&bytes).expect("a preview"); + assert_eq!(wrapped(&preview), (4, 3, (0..36).collect(), false)); + assert_eq!(crate::embedded_preview_covering(&bytes, 2000), Some(preview)); + } + + /// The largest 8-bit RGB image wins; a bigger 16-bit one (the FFF's copy with a `TransferFunction`) is not + /// used, and nor is a planar or compressed one. + #[test] + fn only_8_bit_chunky_uncompressed_rgb_is_taken() { + let image = |w: u32, h: u32, bits: u16, planar: u16, compression: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; 3])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression])); + ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![planar])); + let n = (w * h * 3 * u32::from(bits / 8)) as usize; + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![7; n]] }); + ifd + }; + let bytes = fff_with(None, false, vec![image(16, 12, 16, 1, 1), image(12, 9, 8, 2, 1), image(12, 9, 8, 1, 8)]); + assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()).0, 4, "IFD0's 4 x 3"); + let bytes = fff_with(None, false, vec![image(6, 6, 8, 1, 1)]); + assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()), (6, 6, vec![7; 108], false)); + } + + /// A preview strip cut short by a truncated file gives no preview, not a partial picture. + #[test] + fn short_preview_strip_gives_none() { + let mut ifd0 = rgb_ifd(); + ifd0.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![vec![1; 30]] }); + let tiff = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + assert_eq!(rgb_preview(&Tiff::parse(&tiff).unwrap(), &tiff), None); + } } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 06b818e4c..cda8fe3c9 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-15 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs; LR-IMP-FORMATS, Hasselblad FFF colour matrix from the embedded 3FR header) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs; LR-IMP-FORMATS, Hasselblad FFF colour matrix from the embedded 3FR header and 3FR / FFF RGB previews) · **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 @@ -82,7 +82,7 @@ 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 (local adjustment templates become mask effects, LR-MASK-EFFECTS), 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); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | 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`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`, `crates/raw/src/vendor/hasselblad.rs`): 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, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), 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-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`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): 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, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), 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`, `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: ~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). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). 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 | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) | | 2026-10-15 | minor | LR-IMP-FORMATS: a Hasselblad FFF develops with the camera's colour matrix from the original 3FR header it embeds (tag `0xC51B`) instead of the neutral matrix; status unchanged (🟡) | | 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 (🟡) | | 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 (🟡) | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 42cca4610..465588858 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Hasselblad FFF colour matrix from the embedded 3FR header) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Hasselblad FFF colour matrix from the embedded 3FR header; 3FR / FFF RGB previews) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -159,7 +159,7 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus | 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 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 | 🟡 | six Hasselblad models verified on nine files; read by `vendor::hasselblad`: an FFF has no colour matrix of its own and now takes the camera's from the original 3FR header it embeds in tag `0xC51B` (it used to develop with the neutral matrix); RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR | +| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; read by `vendor::hasselblad`: an FFF has no colour matrix of its own and now takes the camera's from the original 3FR header it embeds in tag `0xC51B` (it used to develop with the neutral matrix); RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; the embedded preview is the uncompressed 8-bit RGB image (no JPEG in these files), handed on as a baseline TIFF | | 3FR / FFF | Hasselblad compression | ⬜ | preview | | ERF | 12-bit in 16-byte blocks (R-D1, R-D1s, R-D1x) | ✅ | 3 CC0 corpus files; crop registered and WB measured against the embedded preview (no WB tag found); `vendor/erf.rs` | | IIQ, MOS, MRW, 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) | @@ -287,6 +287,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Hasselblad 3FR / FFF embedded previews: the uncompressed 8-bit RGB image, handed on as a baseline TIFF (checked on the CC0 H4D-40 FFF, 1217 × 913, and CFV 3FR, 218 × 218, upright through the file's orientation); counts unchanged | | 2026-10-15 | minor | Hasselblad 3FR / FFF get their own reader (`crates/raw/src/vendor/hasselblad.rs`): an FFF takes its colour matrix from the original 3FR header in tag `0xC51B` instead of developing with the neutral matrix; RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; counts unchanged | | 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | From dde403946b0d55bc99a7f731e7f2fa69fea59511 Mon Sep 17 00:00:00 2001 From: naryzach Date: Thu, 15 Oct 2026 12:17:25 -0700 Subject: [PATCH 026/158] raw: alias Canon EOS R6 Mark II to EOS R6m2 Canon EOS R6 Mark II writes its Exif model as 'Canon EOS R6 Mark II', which failed the spectral camera table lookup because the table registers the model as 'EOS R6m2'. Add the alias mapping and regression unit tests in lightcraft_raw and lightcraft_catalog. --- crates/catalog/src/model.rs | 1 + crates/raw/src/spectral.rs | 1 + crates/raw/src/spectral_table.rs | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 0c7ca5112..6ac3544ee 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -643,6 +643,7 @@ mod edited_tests { ("NIKON CORPORATION NIKON D850", "NEF", false), ("Canon Canon EOS 5D Mark III", "CR2", false), ("Canon Canon EOS R6m2", "CR3", false), + ("Canon Canon EOS R6 Mark II", "CR3", false), ("Canon Canon EOS R7", "CR3", true), ("FUJIFILM X-T4", "RAF", false), ("ILCE-7M3", "ARW", false), diff --git a/crates/raw/src/spectral.rs b/crates/raw/src/spectral.rs index cfa99ff5d..673c80138 100644 --- a/crates/raw/src/spectral.rs +++ b/crates/raw/src/spectral.rs @@ -142,6 +142,7 @@ mod tests { ("Canon", "Canon EOS REBEL T3i", "EOS 600D"), ("Canon", "Canon EOS Kiss X5", "EOS 600D"), ("Canon", "Canon EOS R5 Mark II", "EOS R5m2"), + ("Canon", "Canon EOS R6 Mark II", "EOS R6m2"), ("NIKON CORPORATION", "NIKON D800", "D800E"), ("NIKON CORPORATION", "NIKON Z 6_2", "Z f"), ("SONY", "ILCE-7RM4A", "ILCE-7RM4"), diff --git a/crates/raw/src/spectral_table.rs b/crates/raw/src/spectral_table.rs index f116e2335..e8703e471 100644 --- a/crates/raw/src/spectral_table.rs +++ b/crates/raw/src/spectral_table.rs @@ -335,6 +335,7 @@ pub(super) const ALIASES: &[(&str, &str, &str)] = &[ ("Canon", "EOS Rebel T3i", "EOS 600D"), // same model, alternative name ("Canon", "Kiss X5", "EOS 600D"), // same model, alternative name ("Canon", "EOS R5 Mark II", "EOS R5m2"), // same model, alternative name + ("Canon", "EOS R6 Mark II", "EOS R6m2"), // same model, alternative name ("Canon", "EOS 5DS R", "EOS 5DS"), // different model, same sensor ("Canon", "EOS 6D Mark II", "EOS RP"), // different model, same sensor ("Canon", "EOS R5 C", "EOS R5"), // different model, same sensor From 6938be39e4e69657e231e2c4083b83ba66f5cd40 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 19:17:57 +0000 Subject: [PATCH 027/158] Engine tests: drop the drop() of a closure (clippy drop_non_drop) Clippy 1.97 rejects `drop(write)` in the same-folder export test: the closure has no Drop, so its borrow of `written` already ends at its last use and the call did nothing. Removed, with a note on why the following read of `written` compiles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J2NDVXA8jQQWeJb4YVTb6b --- crates/engine/src/tests_export.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 2f1a05116..23969f505 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -1038,7 +1038,7 @@ fn same_folder_as_original_refuses_photos_without_a_folder() { assert_eq!(f["photo"], id.0, "{f}"); assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}"); } - drop(write); + // (`write` borrows `written` until its last use above; a closure has no `Drop` to wait for) assert!(written.is_empty(), "{written:?}"); // a photo on disk whose folder is gone (moved, an unplugged drive) From fa715c8c94696c4f15d4522d5d725e3c78fd94b8 Mon Sep 17 00:00:00 2001 From: skybluu <12523559+skybluu@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:36:38 -0500 Subject: [PATCH 028/158] Localize Info Overlay mode notices (#911) --- README.md | 2 +- README.zh-hans.md | 2 +- crates/ui-egui/src/headless.rs | 34 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 2 +- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 53ec908de..4dba64bec 100644 --- a/README.md +++ b/README.md @@ -427,7 +427,7 @@ Without it LightCraft builds and runs the same, but Chinese and Japanese text ha repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme). **Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština, ქართული) or **Settings → General → -Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md). +Language**; the choice applies immediately and persists. Info Overlay mode-change toasts use the selected interface language. See [docs/localization.md](docs/localization.md). **Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder (Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS diff --git a/README.zh-hans.md b/README.zh-hans.md index f2d155214..fc9373445 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -394,7 +394,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft 本仓库;见 [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)。 **界面语言:** **编辑 → 语言**(English、简体中文、繁體中文(台灣)、日本語、Português (Brasil)、Español、Deutsch、Русский、Français)或 **设置 → 一般 → -语言**;选择立即生效并会保留。见 [docs/localization.md](docs/localization.md)。 +语言**;选择立即生效并会保留。信息叠加模式切换提示使用所选界面语言。见 [docs/localization.md](docs/localization.md)。 **日志:** 桌面应用把日志写到标准错误,以及设置文件夹里的 `logs/lightcraft.log` (Linux `$XDG_CONFIG_HOME/lightcraft/logs/`,默认 `~/.config/lightcraft/logs/`;macOS diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 6178990b0..e0888c9bb 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -4292,6 +4292,40 @@ mod tests { h.settle(SETTLE); } + /// #911 (toast labels only): cycling the overlay announces each mode in the UI language. + #[test] + fn info_overlay_toasts_are_painted_in_the_users_language() { + use crate::i18n::Locale; + + struct RestoreLanguage(Locale); + impl Drop for RestoreLanguage { + fn drop(&mut self) { + crate::i18n::set_language(self.0); + } + } + let _restore = RestoreLanguage(crate::i18n::language()); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + assert_eq!(h.request("ui.set", json!({"view": "detail", "infoOverlay": "off"}), t)["ok"], true); + for (command, locale) in [("app.language.german", Locale::De), ("app.language.french", Locale::Fr), ("app.language.english", Locale::En)] { + assert_eq!(h.request("engine.execute", json!({"command": command}), t)["ok"], true); + assert_eq!(h.app.ui.language, locale); + assert_eq!(crate::i18n::language(), locale); + for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] { + let expected = locale.tr(key); + if locale != Locale::En { + assert_ne!(expected, key, "this locale must translate the tested message"); + } + assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true); + assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode)); + assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected); + h.step(); + let painted = h.painted_text(); + assert!(painted.iter().any(|text| text == expected), "{painted:?}"); + } + } + } + /// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the /// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9fcfc8b9d..cd6829db4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -705,7 +705,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option "Info Overlay: File", crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", }; - app.toast(&ctx, label); + app.toast(&ctx, crate::i18n::tr(label)); Ok(json!({"infoOverlay": app.ui.info_overlay})) } "view.chooseLayoutImage" => { From abf29ea392061b3ae81a5541c5afbef03c365d0e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:53:46 +0000 Subject: [PATCH 029/158] #1508: web backup refuses to start while changes are unsaved The browser backup copies the stored library files, so while a save to browser storage is failing it reported success but left out the queued changes. It now refuses with the number of unsaved changes and why (Session::unsaved), and is allowed again once they are written. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK --- apps/lightcraft-web/src/backup.rs | 12 ++++++++++++ apps/lightcraft-web/src/files.rs | 26 ++++++++++++++++++++++++++ apps/lightcraft-web/src/web.rs | 3 +++ docs/web.md | 4 +++- docs/whats-new.md | 8 ++++++++ 5 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/lightcraft-web/src/backup.rs b/apps/lightcraft-web/src/backup.rs index 00ec12a41..281a32296 100644 --- a/apps/lightcraft-web/src/backup.rs +++ b/apps/lightcraft-web/src/backup.rs @@ -52,6 +52,18 @@ pub fn valid_hash(h: &str) -> bool { !h.is_empty() && h.len() <= 128 && h.chars().all(|c| c.is_ascii_alphanumeric()) } +/// Why a backup can't start yet, given [`lightcraft_engine::Session::unsaved`]: changes applied in +/// the app but not yet written to browser storage (a save failed). The zip copies the stored +/// library files, so it would report success while missing them; `None` when everything is saved. +pub fn unsaved_refusal(unsaved: Option<(usize, &str)>) -> Option { + let (ops, why) = unsaved?; + let changes = if ops == 1 { "1 change".to_string() } else { format!("{ops} changes") }; + Some(format!( + "Backup not started: {changes} could not be saved to browser storage yet ({why}), so the backup would miss them. \ + Free up storage space; LightCraft retries the save with every change. Back up once nothing is left unsaved." + )) +} + /// Zip entry name for an original. pub fn original_entry(hash: &str, file_name: &str) -> String { let name: String = file_name.chars().map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }).collect(); diff --git a/apps/lightcraft-web/src/files.rs b/apps/lightcraft-web/src/files.rs index d0c9f348b..4b5f6289d 100644 --- a/apps/lightcraft-web/src/files.rs +++ b/apps/lightcraft-web/src/files.rs @@ -282,6 +282,32 @@ mod tests { assert_eq!(s2.catalog.photo(id).unwrap().rating, 5, "the rating survived"); } + /// A backup copies the stored library files, so while a save is failing it would miss the + /// queued changes: it is refused with the reason until they are written (#1508). + #[test] + fn backup_is_refused_while_changes_are_unsaved() { + let files = Files::default(); + let mut s = Session::new(); + let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false }; + s.open_library_in(stores, true).unwrap(); + assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "everything saved: the backup may start"); + files.set_error(Some("QuotaExceededError".into())); + let _ = s.execute("photo.rate", &json!({"rating": 5})); + let refusal = crate::backup::unsaved_refusal(s.unsaved()).expect("refused while the rating is unsaved"); + assert!(refusal.contains("could not be saved") && refusal.contains("QuotaExceeded"), "{refusal}"); + files.set_error(None); + s.persist_if_dirty(); + s.persist().unwrap(); + assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "allowed again once the change is written"); + } + + #[test] + fn unsaved_refusal_counts_changes() { + use crate::backup::unsaved_refusal; + assert!(unsaved_refusal(Some((1, "quota"))).unwrap().contains("1 change could not")); + assert!(unsaved_refusal(Some((3, "quota"))).unwrap().contains("3 changes could not")); + } + #[test] fn library_survives_reload_through_flushes() { let mut disk = BTreeMap::new(); diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 6a4487d09..3ce8eb66c 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -173,6 +173,9 @@ fn services(originals: Originals, backend: Option, files: Files, frozen screen_profile: None, backup_library: Some(Box::new(move |session: &mut Session| { let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) }; + if let Some(why) = crate::backup::unsaved_refusal(session.unsaved()) { + return Err(why); + } // the photos' own names for the originals in the zip let names: std::collections::HashMap = session .catalog diff --git a/docs/web.md b/docs/web.md index b275e837d..a755bfd80 100644 --- a/docs/web.md +++ b/docs/web.md @@ -111,7 +111,9 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not grant it, a notice says the library may be evicted under storage pressure. - **File ▸ Back Up Library…** downloads a zip of the library files and every stored original (`originals//`, so an unzipped backup is browsable; entries are stored, not - compressed; at most 4 GB). **File ▸ Restore Library from Backup…** reads such a zip into a + compressed; at most 4 GB). While a save is failing it refuses to start and says how many + changes are unsaved and why: the zip copies the stored library files, so it would miss them + (issue #1508). **File ▸ Restore Library from Backup…** reads such a zip into a new library folder (`library-restored-
Justified Photo Grid of 24 demo photos with ratings and flags, and a sidebar of nested albums under a Travel 2026 folder
Photo Grid with albums nested in a folder, ratings and flags. Demo library.