diff --git a/Cargo.lock b/Cargo.lock index bf2ac824f..5f445192f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3073,7 +3073,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.7.1" +version = "0.8.0" dependencies = [ "eframe", "egui", @@ -3097,7 +3097,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.7.1" +version = "0.8.0" dependencies = [ "crc32fast", "lightcraft-develop", @@ -3114,7 +3114,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.7.1" +version = "0.8.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -3130,7 +3130,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.7.1" +version = "0.8.0" dependencies = [ "avif-serialize", "bytemuck", @@ -3159,7 +3159,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-geom", "proptest", @@ -3168,7 +3168,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.7.1" +version = "0.8.0" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3185,7 +3185,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-raster", "miniz_oxide 0.8.9", @@ -3200,7 +3200,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3212,7 +3212,7 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.7.1" +version = "0.8.0" dependencies = [ "ab_glyph", "harfrust", @@ -3252,7 +3252,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.7.1" +version = "0.8.0" dependencies = [ "matrixmultiply", "ndarray", @@ -3266,7 +3266,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.7.1" +version = "0.8.0" dependencies = [ "log", "rustls", @@ -3277,7 +3277,7 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.7.1" +version = "0.8.0" dependencies = [ "miniz_oxide 0.8.9", "serde", @@ -3285,7 +3285,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.7.1" +version = "0.8.0" dependencies = [ "proptest", "serde", @@ -3293,7 +3293,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.7.1" +version = "0.8.0" dependencies = [ "bytemuck", "lightcraft-color", @@ -3311,7 +3311,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.7.1" +version = "0.8.0" dependencies = [ "heic-rs", "rayon", @@ -3319,14 +3319,14 @@ dependencies = [ [[package]] name = "lightcraft-inpaint" -version = "0.7.1" +version = "0.8.0" dependencies = [ "rayon", ] [[package]] name = "lightcraft-lensdb" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-develop", "lightcraft-geom", @@ -3335,7 +3335,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3346,7 +3346,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3362,7 +3362,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-geom", "lightcraft-tiff", @@ -3374,7 +3374,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.7.1" +version = "0.8.0" dependencies = [ "image", "lightcraft-color", @@ -3392,7 +3392,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.7.1" +version = "0.8.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3404,7 +3404,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.7.1" +version = "0.8.0" dependencies = [ "libc", "lightcraft-color", @@ -3416,7 +3416,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.7.1" +version = "0.8.0" dependencies = [ "jpeg-encoder", "jxl-oxide", @@ -3438,7 +3438,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.7.1" +version = "0.8.0" dependencies = [ "image", "lightcraft-color", @@ -3449,7 +3449,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.7.1" +version = "0.8.0" dependencies = [ "candle-core", "candle-nn", @@ -3462,11 +3462,11 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.7.1" +version = "0.8.0" [[package]] name = "lightcraft-tiff" -version = "0.7.1" +version = "0.8.0" dependencies = [ "proptest", "serde", @@ -3475,7 +3475,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.7.1" +version = "0.8.0" dependencies = [ "craft-ui", "egui", @@ -3502,7 +3502,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.7.1" +version = "0.8.0" dependencies = [ "crc32fast", "eframe", @@ -6972,7 +6972,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.7.1" +version = "0.8.0" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 7effbe0d9..2b89df35e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.7.1" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/crates/catalog/src/tests_lock.rs b/crates/catalog/src/tests_lock.rs index d464d5c25..baa8da659 100644 --- a/crates/catalog/src/tests_lock.rs +++ b/crates/catalog/src/tests_lock.rs @@ -126,33 +126,40 @@ fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() { /// Issue #680, as it happens in practice: one thread spawns cheap children while another acquires, /// drops and re-acquires the lock on its own directory. Every acquire must succeed (before the fix -/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, pass or fail. +/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, and on a +/// slow or loaded machine (the FreeBSD CI VM, where a spawn can take longer than that) until a few +/// children have been spawned during them, at most a minute. #[cfg(unix)] #[test] fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() { use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + + /// Children spawned while the lock cycles, at least, before the test may stop. + const CHILDREN: usize = 5; let dir = temp_dir("spawn"); let stop = Arc::new(AtomicBool::new(false)); + let count = Arc::new(AtomicUsize::new(0)); let spawner = { - let stop = Arc::clone(&stop); + let (stop, count) = (Arc::clone(&stop), Arc::clone(&count)); std::thread::spawn(move || { - let mut spawned = 0usize; while !stop.load(Ordering::Relaxed) { if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() { let _ = c.wait(); - spawned += 1; + count.fetch_add(1, Ordering::Relaxed); } } - spawned + count.load(Ordering::Relaxed) }) }; - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let t0 = Instant::now(); + let enough = |cycles: usize| (cycles >= 200_000 || t0.elapsed() >= Duration::from_secs(2)) && count.load(Ordering::Relaxed) >= CHILDREN; let mut cycles = 0usize; let mut refused = Vec::new(); - while cycles < 200_000 && std::time::Instant::now() < deadline { + while !enough(cycles) && t0.elapsed() < Duration::from_secs(60) { match LibraryLock::acquire(&dir, "LightCraft") { Ok(l) => assert!(l.held()), // dropped here: the next cycle must get it back Err(e) => refused.push(format!("cycle {cycles}: {e:?}")), diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index acaa303fc..85f27d321 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use std::borrow::Cow; use crate::DevelopSettings; @@ -185,8 +186,7 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value { /// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped /// through the control specs. pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings { - let base = s.to_json(); - let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) }; + let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) }; // (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`) let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone()); for c in crate::controls::CONTROLS { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..0f9be6e52 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -506,16 +506,22 @@ impl Session { } fn apply_catalog(&mut self, op: Op) -> Result { - let inv = self.catalog.apply(op.clone())?; + // Retain only cache IDs while the catalog consumes the operation. Cloning an import + // batch here also cloned every photo, although commit already keeps the journal copy. + let mut invalidated = Vec::new(); let mut pending = vec![&op]; while let Some(op) = pending.pop() { match op { - Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => self.media.forget(*id), - Op::AddPhoto { photo } => self.media.forget(photo.id), + Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => invalidated.push(*id), + Op::AddPhoto { photo } => invalidated.push(photo.id), Op::Batch { ops } => pending.extend(ops), _ => {} } } + let inv = self.catalog.apply(op)?; + for id in invalidated { + self.media.forget(id); + } Ok(inv) } diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 2519057a3..e841362ea 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -698,14 +698,22 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex let mut ops = Vec::new(); let now = (s.clock)(); let mut missing = Vec::new(); + // Normal import counts probed originals; catalog import also adds offline records, + // distinct originals with identical bytes, and virtual copies. + let mut imported = files.imported.len(); + let mut promoted = HashSet::new(); let mut unmapped = BTreeMap::new(); let mut preserved = 0; // Masters precede their virtual copies. let mut photos: Vec<_> = data.photos.iter().collect(); photos.sort_by_key(|p| number(&p.image, "masterImage") != 0); + let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect(); let mut planned: HashMap = HashMap::new(); for src in photos { let master = number(&src.image, "masterImage"); + if master == 0 && missing_sources.contains(&key(&src.path)) { + missing.push(src.path.clone()); + } let indexed = index .photos .get(&identity(&data, src)) @@ -736,9 +744,6 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex } else { let id = s.catalog.alloc_photo_id(); let name = Path::new(&src.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| src.path.clone()); - if missing_sources.contains(&key(&src.path)) { - missing.push(src.path.clone()); - } let mut p = Photo::new( id, Source::File { path: src.path.clone() }, @@ -807,7 +812,21 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex if keep_develop { (p.develop, p.edited) = previous_develop; } + // Missing files bypass normal-file preparation, including its Local promotion. + // Import still joins the library, and a virtual copy must inherit that membership. + let was_local = p.local; + p.local = false; + let photo_id = p.id; + // Only virtual-copy masters need a second owned photo while operations are staged. + // Keeping every photo here doubled the new-record working set on large catalogs. + if copy_masters.contains(&src.source_id) { + planned.insert(photo_id, p.clone()); + } if s.catalog.photo(p.id).is_some() { + if was_local && promoted.insert(p.id) { + imported += 1; + ops.push(Op::SetLocal { id: p.id, local: false }); + } ops.extend([ Op::SetRating { id: p.id, rating: p.rating }, Op::SetFlag { id: p.id, flag: p.flag }, @@ -817,10 +836,10 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex ]); ops.push(Op::SetDevelop { id: p.id, settings: p.develop.clone(), label: "Lightroom catalog".into(), edited: p.edited.clone() }); } else { - ops.push(Op::AddPhoto { photo: Box::new(p.clone()) }); + imported += 1; + ops.push(Op::AddPhoto { photo: Box::new(p) }); } - by_path.entry(key(&src.path)).or_insert(p.id); - planned.insert(p.id, p); + by_path.entry(key(&src.path)).or_insert(photo_id); } let mut albums = HashMap::new(); for collection in &data.collections { @@ -893,7 +912,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex } s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog"); Ok(AppliedImport { - report: json!({"source":data.source,"photos":ids.len(),"imported":files.imported.len(),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), + report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), index: index.clone(), }) } @@ -938,12 +957,62 @@ mod tests { warnings: Vec::new(), } } + #[test] + fn offline_local_master_and_copy_join_library_with_one_undo_step() { + let mut s = crate::Session::new(); + let data = sample(); + let master_id = s.catalog.alloc_photo_id(); + let mut master = Photo::new(master_id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 20, 10, "before"); + master.local = true; + s.commit("Browse folder", Op::AddPhoto { photo: Box::new(master) }).unwrap(); + s.undo.clear(); + let before = s.catalog.photo(master_id).unwrap().clone(); + let mut index = ImportIndex::default(); + let missing_sources = [key("/missing/test.ARW")].into_iter().collect(); + let mut import = |s: &mut crate::Session| { + let undo0 = s.undo.len(); + apply_prepared( + s, + data.clone(), + ApplyPrepared { + update_existing: false, + files: Default::default(), + index: &mut index, + archive_path: None, + missing_sources: &missing_sources, + undo0, + }, + ) + .unwrap() + .report + }; + let first = import(&mut s); + assert_eq!(first["mapping"]["1"], master_id.0); + assert_eq!(first["imported"], 2, "the promoted master and new copy both join All Photos"); + assert_eq!(first["missing"], json!(["/missing/test.ARW"])); + assert_eq!(s.catalog.len(), 2); + assert!(s.catalog.photos().all(|photo| !photo.local)); + assert_eq!(s.undo.len(), 1); + let second = import(&mut s); + assert_eq!(second["imported"], 0); + assert_eq!(second["mapping"], first["mapping"]); + s.undo_step().unwrap(); + s.undo_step().unwrap(); + assert_eq!(s.catalog.len(), 1); + assert_eq!(s.catalog.photo(master_id), Some(&before)); + assert!(s.catalog.albums().next().is_none()); + let third = import(&mut s); + assert_eq!(third["imported"], 2); + assert!(s.catalog.photos().all(|photo| !photo.local)); + } + #[test] fn imports_copies_collections_ratings_and_mapped_edits_in_one_undo_step() { let mut s = crate::Session::new(); let data = sample(); let r = apply(&mut s, data.clone(), false).unwrap(); assert_eq!(r["photos"], 2); + assert_eq!(r["imported"], 2, "the offline master and virtual copy both count as added"); assert_eq!(r["missing"].as_array().unwrap().len(), 1); let master = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap(); assert_eq!(master.rating, 5); @@ -1241,6 +1310,10 @@ mod tests { s.commit("Changed time", Op::SetCaptured { id, captured: None }).unwrap(); let second = apply(&mut s, data, true).unwrap(); assert_eq!(first["mapping"], second["mapping"]); + assert_eq!(first["imported"], 2); + assert_eq!(second["imported"], 0, "reimport updates records instead of adding them"); + assert_eq!(second["missing"].as_array().unwrap().len(), 1, "offline originals are reported on reimport too"); + assert_eq!(second["library"], dir.to_string_lossy().as_ref()); assert_eq!(s.catalog.len(), 2); assert_eq!(s.catalog.albums().count(), 2); assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56")); @@ -1359,6 +1432,63 @@ mod tests { std::fs::remove_file(path).unwrap(); } + #[test] + fn preparation_skips_missing_original_probes_but_keeps_catalog_records() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("source.lrcat"); + let root = dir.join("offline"); + let tables = photo_tables((root.to_str().unwrap(), "")); + std::fs::write(&source, native_catalog(&tables)).unwrap(); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Err("a known-missing original must not be probed".into()) + })); + let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source, false).unwrap(); + let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); + assert_eq!(probes.load(Ordering::Relaxed), 0); + let result = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); + assert_eq!(result.report["imported"], 1); + assert_eq!(result.report["failed"], json!([])); + assert_eq!(result.report["missing"].as_array().unwrap().len(), 1); + let photo = s.catalog.photos().next().unwrap(); + assert_eq!(photo.rating, 5); + assert_eq!((photo.width, photo.height), (20, 10)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.catalog.is_empty()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn probed_original_is_counted_once_and_reimport_adds_nothing() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-present-probes-{}", std::process::id())); + std::fs::create_dir_all(dir.join("Photos")).unwrap(); + let source = dir.join("source.lrcat"); + std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); + std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Ok(crate::media::ProbeInfo { format: "ARW".into(), width: 20, height: 10, ..Default::default() }) + })); + for expected in [1, 0] { + let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source.clone(), false).unwrap(); + let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); + let completion = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); + assert_eq!(completion.report["imported"], expected); + assert_eq!(completion.report["missing"], json!([])); + assert_eq!(completion.report["failed"], json!([])); + assert_eq!(s.catalog.len(), 1); + } + assert_eq!(probes.load(Ordering::Relaxed), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn a_root_missing_here_is_found_relative_to_the_catalog() { let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id())); diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index 5e7cb02d4..8de1cc806 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -247,23 +247,14 @@ impl LightroomJob { if cancel.load(Ordering::Relaxed) { return Err(cancelled()); } - let paths: Vec = data - .photos - .iter() - .filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0) - .map(|photo| photo.path.clone()) - .collect(); - let missing_sources = data - .photos - .iter() - .filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0) - .filter(|photo| !Path::new(&photo.path).is_file()) - .map(|photo| normalize(&photo.path)) - .collect(); - // the catalog step's last unit (validation, and looking for each original), then the photos + let (paths, missing_sources) = original_paths(&data, cancel)?; + // Catalog entries name files, never folders. Missing originals already have enough + // catalog metadata for placeholders; probing them again only adds failed I/O and errors. + // Complete the catalog step before the separate read/add photo progress starts. self.progress.catalog_done.fetch_add(1, Ordering::Relaxed); self.progress.photos.store(true, Ordering::Relaxed); - let normal = self.import.prepare(&paths, cancel); + self.import.total.fetch_add(paths.len(), Ordering::Relaxed); + let normal = self.import.prepare_files(paths, cancel); if cancel.load(Ordering::Relaxed) { normal.rollback(); return Err(cancelled()); @@ -323,6 +314,30 @@ pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) Ok(LightroomCompletion { report: applied.report, finalization: LightroomArchiveFinalization { path, bytes } }) } +fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(Vec, HashSet)> { + let mut paths = Vec::new(); + let mut missing = HashSet::new(); + let mut seen = HashSet::new(); + for photo in &data.photos { + if cancel.load(Ordering::Relaxed) { + return Err(cancelled()); + } + if lightroom_catalog::number_for_job(&photo.image, "masterImage") != 0 { + continue; + } + let key = normalize(&photo.path); + if !seen.insert(key.clone()) { + continue; + } + if Path::new(&photo.path).is_file() { + paths.push(photo.path.clone()); + } else { + missing.insert(key); + } + } + Ok((paths, missing)) +} + fn normalize(path: &str) -> String { let path = path.replace('\\', "/"); if cfg!(windows) { path.to_lowercase() } else { path } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4e5e73850..f881cc71b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6,6 +6,39 @@ fn demo() -> Session { Session::with_demo() } +#[test] +fn nested_catalog_operations_invalidate_sources_after_success_and_on_undo_redo() { + use crate::media::SourceLevel; + use lightcraft_catalog::{Op, PhotoId}; + let mut s = demo(); + let id = s.active().unwrap(); + let image = std::sync::Arc::new(lightcraft_raster::Rgb32f::new(1, 1)); + let cache = |s: &mut Session| { + for level in [SourceLevel::Thumb, SourceLevel::Preview, SourceLevel::Full] { + s.media.insert(id, level, image.clone()); + } + }; + cache(&mut s); + // An invalid operation must not throw away usable sources. + assert!(s.apply_catalog(Op::Batch { ops: vec![Op::RemovePhoto { id }, Op::RemovePhoto { id: PhotoId(u64::MAX) }] }).is_err()); + assert!(s.catalog.photo(id).is_some(), "the failed batch restores the removed photo"); + for level in [SourceLevel::Thumb, SourceLevel::Preview, SourceLevel::Full] { + assert!(s.media.has_source(id, level), "the failed batch keeps usable sources"); + } + s.commit("Remove", Op::Batch { ops: vec![Op::Batch { ops: vec![Op::RemovePhoto { id }] }] }).unwrap(); + for level in [SourceLevel::Thumb, SourceLevel::Preview, SourceLevel::Full] { + assert!(!s.media.has_source(id, level)); + } + cache(&mut s); + s.undo_step().unwrap(); + assert!(s.catalog.photo(id).is_some()); + assert!(!s.media.has_source(id, SourceLevel::Full), "undo AddPhoto must invalidate the source too"); + cache(&mut s); + s.redo_step().unwrap(); + assert!(s.catalog.photo(id).is_none()); + assert!(!s.media.has_source(id, SourceLevel::Full)); +} + fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings { (*s.develop_of(s.active().unwrap()).unwrap()).clone() } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index dff2ddb75..bced728d5 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", "{name} opened in the other editor": "{name} im anderen Editor geöffnet", "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet", - "· {in_source} identical copies": "· {in_source} identische Kopien" + "· {in_source} identical copies": "· {in_source} identische Kopien", + "{count} more warnings.": "{count} weitere Warnungen.", + "{photos} catalog records processed; {collections} collections.": "{photos} Katalogeinträge verarbeitet; {collections} Sammlungen.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} Originale fehlen. Ihre Katalogeinträge sind verfügbar; verwende „Fehlende Fotos suchen“, um sie neu zu verknüpfen." } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..21e934f2e 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2118,5 +2118,10 @@ "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", + "Lightroom Catalog Import": "Lightroom-Katalogimport", + "Destination library": "Zielbibliothek", + "Show in Library": "In Bibliothek anzeigen", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom-Kataloge werden in die geöffnete Bibliothek importiert; es wird kein eigener Katalogordner erstellt.", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Diese Fotos befinden sich nur im Arbeitsspeicher. Öffne eine Bibliothek auf dem Datenträger und importiere den Katalog erneut, um sie zu speichern." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 452fc6621..f42a6b67d 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", "{name} opened in the other editor": "{name} abierto en el otro editor", "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor", - "· {in_source} identical copies": "· {in_source} copias idénticas" + "· {in_source} identical copies": "· {in_source} copias idénticas", + "{count} more warnings.": "{count} advertencias más.", + "{photos} catalog records processed; {collections} collections.": "{photos} registros del catálogo procesados; {collections} colecciones.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Faltan {missing} originales. Sus registros del catálogo están disponibles; usa Buscar fotos que faltan para volver a vincularlos." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..22d18117a 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1995,5 +1995,10 @@ "Adding to Apple Photos": "Añadiendo a Fotos de Apple", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.", + "Lightroom Catalog Import": "Importación de catálogo de Lightroom", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Estas fotos solo están en memoria. Abre una biblioteca en disco e importa el catálogo de nuevo para guardarlas.", + "Show in Library": "Mostrar en la biblioteca", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Los catálogos de Lightroom se importan a la biblioteca abierta; no se crea una carpeta de catálogo separada.", + "Destination library": "Biblioteca de destino" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index d85e837a3..00e8736f5 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", "{name} opened in the other editor": "{name} ouvert dans l’autre éditeur", "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur", - "· {in_source} identical copies": "· {in_source} copies identiques" + "· {in_source} identical copies": "· {in_source} copies identiques", + "{count} more warnings.": "{count} avertissements supplémentaires.", + "{photos} catalog records processed; {collections} collections.": "{photos} entrées du catalogue traitées ; {collections} collections.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originaux sont manquants. Leurs entrées de catalogue sont disponibles ; utilisez Rechercher les photos manquantes pour rétablir les liens." } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..016b6bcd3 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1957,5 +1957,10 @@ "Adding to Apple Photos": "Ajout à Apple Photos", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.", + "Lightroom Catalog Import": "Importation de catalogue Lightroom", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Ces photos sont uniquement en mémoire. Ouvrez une bibliothèque sur disque et importez à nouveau le catalogue pour les enregistrer.", + "Show in Library": "Afficher dans la bibliothèque", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Les catalogues Lightroom sont importés dans la bibliothèque ouverte ; aucun dossier de catalogue distinct n’est créé.", + "Destination library": "Bibliothèque de destination" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 211e47e29..e7a61e9ef 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", "{name} opened in the other editor": "{name}を別のエディタで開きました", "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました", - "· {in_source} identical copies": "・同一のコピー {in_source} 件" + "· {in_source} identical copies": "・同一のコピー {in_source} 件", + "{count} more warnings.": "ほかに{count}件の警告があります。", + "{photos} catalog records processed; {collections} collections.": "カタログの{photos}件のレコードを処理しました。コレクションは{collections}件です。", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing}件のオリジナルが見つかりません。カタログのレコードは利用できます。「見つからない写真を検索」で再リンクしてください。" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..b53142ed5 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1961,5 +1961,10 @@ "Adding to Apple Photos": "Appleの写真に追加中", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", - "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。", + "Lightroom Catalog Import": "Lightroomカタログの読み込み", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "これらの写真はメモリ内にのみあります。ディスク上のライブラリを開き、カタログを再度読み込んで保存してください。", + "Show in Library": "ライブラリに表示", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroomカタログは開いているライブラリに読み込まれます。別のカタログフォルダーは作成されません。", + "Destination library": "読み込み先ライブラリ" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index e81969e0f..e42baa907 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", "{name} opened in the other editor": "{name} aberto no outro editor", "{n} originals opened in the other editor": "{n} originais abertos no outro editor", - "· {in_source} identical copies": "· {in_source} cópias idênticas" + "· {in_source} identical copies": "· {in_source} cópias idênticas", + "{count} more warnings.": "Mais {count} avisos.", + "{photos} catalog records processed; {collections} collections.": "{photos} registros do catálogo processados; {collections} coleções.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originais estão ausentes. Seus registros no catálogo estão disponíveis; use Encontrar fotos ausentes para vinculá-los novamente." } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..2c30c3433 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1998,5 +1998,10 @@ "Recognition models": "Modelos de reconhecimento", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.", + "Lightroom Catalog Import": "Importação de catálogo do Lightroom", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Estas fotos estão apenas na memória. Abra uma biblioteca no disco e importe o catálogo novamente para salvá-las.", + "Show in Library": "Mostrar na biblioteca", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Os catálogos do Lightroom são importados para a biblioteca aberta; nenhuma pasta de catálogo separada é criada.", + "Destination library": "Biblioteca de destino" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 38e28e356..3d4230f6b 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", "{name} opened in the other editor": "{name} открыт в другом редакторе", "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}", - "· {in_source} identical copies": "· идентичных копий: {in_source}" + "· {in_source} identical copies": "· идентичных копий: {in_source}", + "{count} more warnings.": "Других предупреждений: {count}.", + "{photos} catalog records processed; {collections} collections.": "Обработано записей каталога: {photos}; коллекций: {collections}.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не найдены оригиналы: {missing}. Их записи каталога доступны; используйте поиск отсутствующих фотографий, чтобы восстановить ссылки." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..21bbfa629 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2151,5 +2151,10 @@ "Adding to Apple Photos": "Добавление в Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.", + "Lightroom Catalog Import": "Импорт каталога Lightroom", + "Destination library": "Библиотека назначения", + "Show in Library": "Показать в библиотеке", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Каталоги Lightroom импортируются в открытую библиотеку; отдельная папка каталога не создаётся.", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Эти фотографии находятся только в памяти. Откройте библиотеку на диске и импортируйте каталог повторно, чтобы сохранить их." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 55636422f..c87222131 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", "{name} opened in the other editor": "{name} відкрито в іншому редакторі", "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}", - "· {in_source} identical copies": "· ідентичних копій: {in_source}" + "· {in_source} identical copies": "· ідентичних копій: {in_source}", + "{count} more warnings.": "Інших попереджень: {count}.", + "{photos} catalog records processed; {collections} collections.": "Оброблено записів каталогу: {photos}; колекцій: {collections}.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не знайдено оригінали: {missing}. Їхні записи каталогу доступні; скористайтеся пошуком відсутніх фотографій, щоб відновити посилання." } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..a99951e0b 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2151,5 +2151,10 @@ "Adding to Apple Photos": "Додавання до Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.", + "Lightroom Catalog Import": "Імпорт каталогу Lightroom", + "Destination library": "Бібліотека призначення", + "Show in Library": "Показати в бібліотеці", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Каталоги Lightroom імпортуються у відкриту бібліотеку; окрема папка каталогу не створюється.", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Ці фотографії є лише в пам’яті. Відкрийте бібліотеку на диску та імпортуйте каталог повторно, щоб зберегти їх." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 782f8d731..3f8f77f40 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", "{name} opened in the other editor": "已在其他编辑器中打开 {name}", "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件", - "· {in_source} identical copies": "· {in_source} 个相同副本" + "· {in_source} identical copies": "· {in_source} 个相同副本", + "{count} more warnings.": "另有 {count} 条警告。", + "{photos} catalog records processed; {collections} collections.": "已处理 {photos} 条目录记录;{collections} 个收藏集。", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 个原始文件缺失。其目录记录可用;请使用“查找缺失照片”重新链接文件。" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..61ae900ff 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1997,5 +1997,10 @@ "Adding to Apple Photos": "正在添加到 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", - "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。", + "Lightroom Catalog Import": "Lightroom 目录导入", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "这些照片仅保存在内存中。请打开磁盘上的图库并再次导入目录以保存照片。", + "Show in Library": "在图库中显示", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目录会导入到当前打开的图库中,不会创建单独的目录文件夹。", + "Destination library": "目标图库" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 56db559c6..afdcd7bbb 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", "{name} opened in the other editor": "已在其他編輯器中開啟 {name}", "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案", - "· {in_source} identical copies": "· {in_source} 個相同副本" + "· {in_source} identical copies": "· {in_source} 個相同副本", + "{count} more warnings.": "另有 {count} 則警告。", + "{photos} catalog records processed; {collections} collections.": "已處理 {photos} 筆目錄記錄;{collections} 個收藏集。", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 個原始檔案遺失。其目錄記錄可用;請使用「尋找遺失相片」重新連結檔案。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..bf59e775a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1959,5 +1959,10 @@ "Adding to Apple Photos": "正在加入 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", - "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", + "Lightroom Catalog Import": "Lightroom 目錄匯入", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "這些相片僅保存在記憶體中。請開啟磁碟上的圖庫並再次匯入目錄以儲存相片。", + "Show in Library": "在圖庫中顯示", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目錄會匯入目前開啟的圖庫,不會建立獨立的目錄資料夾。", + "Destination library": "目標圖庫" } diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 97ed3b78d..84a5e1d6b 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -263,25 +263,39 @@ mod contact_sheet_tests { })), ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + // Exercise the menu/picker/background PDF workflow with a small procedural original. + // A full-size demo makes this test's deadline depend on unrelated concurrent renders. + let demo = lightcraft_engine::Session::with_demo(); + let id = demo.active().unwrap(); + let mut photo = (**demo.catalog.photo(id).unwrap()).clone(); + photo.width = 96; + photo.height = 64; + let mut session = lightcraft_engine::Session::new(); + session.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + session.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap(); + let mut app = LightcraftApp::new(session, services); app.run("dialog.contactSheet", json!({})).unwrap(); let Some(Dialog::ContactSheet { options }) = app.ui.dialog.as_mut() else { panic!("no contact sheet settings") }; options.paper = "letter".into(); options.landscape = true; - let mut h = crate::headless::Headless::new(app, [1000.0, 800.0], 1.0); - let dialog = h.app.ui.dialog.take().unwrap(); - crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap(); - assert_eq!(h.app.pending_picks.len(), 1); + let dialog = app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut app, &dialog).unwrap(); + assert_eq!(app.pending_picks.len(), 1); + let ctx = egui::Context::default(); + crate::pick::poll(&mut app, &ctx); + assert!(app.pending_picks.is_empty()); + assert!(app.export.is_some(), "picker did not start export: {:?}", app.ui.toast); let deadline = Instant::now() + Duration::from_secs(20); loop { - // The activity stack lays out text, which egui only allows inside a frame. - h.step(); - if h.app.last_export_result.is_some() { + // Poll the real worker without starting unrelated gallery renders each frame. + poll(&mut app, &ctx); + if app.export.is_none() { break; } assert!(Instant::now() < deadline, "export did not finish"); std::thread::sleep(Duration::from_millis(10)); } + let result = app.last_export_result.as_ref().unwrap_or_else(|| panic!("export failed: {:?}", app.ui.toast)); let req = asked.lock().unwrap(); assert_eq!(req[0].kind, PickKind::Save); assert_eq!(req[0].filter.as_ref().unwrap().1, &["pdf"]); @@ -289,7 +303,7 @@ mod contact_sheet_tests { let b = bytes.lock().unwrap(); assert!(b.starts_with(b"%PDF-1.4")); assert!(String::from_utf8_lossy(&b).contains("/MediaBox [0 0 792.00 612.00]")); - assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["photos"], 1); + assert_eq!(result["files"][0]["photos"], 1); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..03b13e242 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -383,6 +383,8 @@ pub struct LightcraftApp { pub lightroom: Option, /// Last terminal Lightroom result, exposed by the command's status/wait response. pub lightroom_last: Option, + /// A completed import result waiting for another modal dialog to close. + pub(crate) lightroom_result: Option, /// The library generation the window title and the recent list were last updated for. pub shown_library: Option, /// A background export in progress. @@ -476,6 +478,7 @@ impl LightcraftApp { sync_run: None, lightroom: None, lightroom_last: None, + lightroom_result: None, shown_library: None, export: None, tasks: Default::default(), diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 195e63e2a..d7ea85122 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -243,6 +243,9 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask, let kind = task.kind; if let Some(o) = value.as_object_mut() { o.entry("path").or_insert_with(|| json!(task.path)); + if let Some(library) = app.session.library.as_ref().filter(|l| l.on_disk) { + o.entry("library").or_insert_with(|| json!(library.dir.to_string_lossy())); + } } let message = if let Some(e) = value.get("error").and_then(Value::as_str) { let warning = value.get("indexWarning").and_then(Value::as_str).map_or(String::new(), |w| format!("; index warning: {w}")); @@ -280,11 +283,61 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask, crate::i18n::tr_format!("Lightroom import complete: {imported} photos added, {failed} failed", imported = imported, failed = failed) } }; + if matches!(kind, Kind::Import) && value.get("cancelled") != Some(&Value::Bool(true)) { + app.lightroom_result = Some(result_dialog(&value, &message)); + show_result(app); + } app.lightroom_last = Some(value); app.lightroom = None; app.toast_for(ctx, message, 6.0); } +fn show_result(app: &mut LightcraftApp) { + if app.ui.dialog.is_none() { + app.ui.dialog = app.lightroom_result.take(); + } +} + +fn result_dialog(value: &Value, summary: &str) -> crate::state::Dialog { + let report = value.get("report").unwrap_or(value); + let mut details = Vec::new(); + if let Some(photos) = report["photos"].as_u64() { + details.push(crate::i18n::tr_format!( + "{photos} catalog records processed; {collections} collections.", + photos = photos, + collections = report["collections"].as_u64().unwrap_or(0) + )); + } + if let Some(failures) = report["failed"].as_array() { + details + .extend(failures.iter().take(4).map(|failure| format!("{}: {}", failure[0].as_str().unwrap_or(""), failure[1].as_str().unwrap_or("")))); + } + let missing = report["missing"].as_array().map_or(0, Vec::len); + if missing > 0 { + details.push(crate::i18n::tr_format!( + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", + missing = missing + )); + } + if let Some(warning) = value.get("indexWarning").and_then(Value::as_str) { + details.push(warning.to_string()); + } + if let Some(warnings) = report["warnings"].as_array() { + // Results can contain one warning per image; keep the dialog bounded. + details.extend(warnings.iter().take(8).filter_map(Value::as_str).map(str::to_string)); + if warnings.len() > 8 { + details.push(crate::i18n::tr_format!("{count} more warnings.", count = warnings.len() - 8)); + } + } + let first = value.get("error").is_none().then(|| report["mapping"].as_object()?.values().filter_map(Value::as_u64).min()).flatten(); + crate::state::Dialog::LightroomResult { + summary: summary.to_string(), + library: report["library"].as_str().or_else(|| value["library"].as_str()).map(str::to_string), + details, + first, + } +} + #[cfg(not(target_arch = "wasm32"))] fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) -> Result { busy(app)?; @@ -348,6 +401,7 @@ pub fn command(app: &mut LightcraftApp, id: &str, p: &Value, ctx: &egui::Context /// Poll owner-thread work, commit a prepared import, and finish its archive index. pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + show_result(app); let Some(mut task) = app.lightroom.take() else { return }; let message = match task.rx.try_recv() { Ok(m) => m, @@ -553,6 +607,68 @@ mod tests { } } + #[test] + fn lightroom_result_stays_readable_and_shows_photos_hidden_by_a_filter() { + let mut h = demo(); + let id = h.app.session.catalog.photos().next().unwrap().id.0; + h.app.session.source = lightcraft_engine::LibrarySource::Picks; + h.app.session.filter.text = "no-photo-has-this-name".into(); + h.app.session.filter.only = vec![lightcraft_catalog::PhotoId(u64::MAX)]; + h.app.session.filter.labels = vec![lightcraft_catalog::ColorLabel::Red]; + h.app.session.filter.rule_set = Some(lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::rules::Match::All, + rules: vec![lightcraft_catalog::rules::Rule::Field { field: "rating".into(), op: "is".into(), value: json!(5) }], + }); + let (task, _tx, _, _) = fake_import(&h); + let value = json!({"photos": 1, "imported": 1, "collections": 0, "missing": ["D:/Photos/a.NEF"], "mapping": {"1": id}, "library": "C:/Pictures/LightCraft Library"}); + terminal(&mut h.app, &h.view.ctx, &task, value); + assert!(h.app.ui.toast.as_ref().unwrap().0.contains("1 photos added")); + h.step(); + h.step(); + let text = h.painted_text().join(" "); + assert!(text.contains("Destination library"), "{text}"); + assert!(text.contains("C:/Pictures/LightCraft Library"), "{text}"); + assert!(text.contains("1 originals are missing"), "{text}"); + assert!(text.contains("Show in Library"), "{text}"); + // The activity and toast can go away; the modal result remains until dismissed. + h.app.ui.toast = None; + h.step(); + assert!(h.app.ui.dialog.is_some()); + let dialog = h.app.ui.dialog.clone().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap(); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert_eq!(h.app.session.filter, lightcraft_catalog::Filter::default()); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(id)); + assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid); + assert!(!h.app.session.visible().is_empty()); + } + + #[test] + fn lightroom_failure_stays_readable_without_offering_a_success_action() { + let mut h = demo(); + let (task, _tx, _, _) = fake_import(&h); + terminal(&mut h.app, &h.view.ctx, &task, json!({"error": "catalog or WAL exceeds 1 GiB import limit"})); + h.step(); + h.step(); + assert!(h.painted_text().join(" ").contains("exceeds 1 GiB import limit")); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::LightroomResult { first: None, .. }))); + assert!(!h.app.widgets.iter().any(|(id, _)| id == "button:dialogCancel")); + } + + #[test] + fn lightroom_result_waits_for_an_existing_dialog() { + let mut h = demo(); + h.app.ui.dialog = Some(crate::state::Dialog::About); + let (task, _tx, _, _) = fake_import(&h); + terminal(&mut h.app, &h.view.ctx, &task, json!({"error": "catalog could not be read"})); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::About))); + assert!(h.app.lightroom_result.is_some()); + h.app.ui.dialog = None; + tick(&mut h.app, &h.view.ctx); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::LightroomResult { .. }))); + assert!(h.app.lightroom_result.is_none()); + } + #[test] fn inspect_report_keeps_empty_catalog_shape() { let report = inspect_report(CatalogImport { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a79261fdc..fab26e573 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -226,6 +226,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::RenameAlbum { .. } => "Rename Album", Dialog::Rename { .. } => "Rename Photos", Dialog::Import { .. } => "Import Photos", + Dialog::LightroomResult { .. } => "Lightroom Catalog Import", Dialog::LabelNames { .. } => "Edit Color Label Names", Dialog::CaptureTime { .. } => "Edit Capture Time", Dialog::RenameKeyword { .. } => "Rename Keyword", @@ -345,6 +346,20 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::model_dialog(app, ui, &t, info, accepted), Dialog::FaceModel { info, accepted, .. } => crate::panels::faces::model_dialog(app, ui, &t, info, accepted), + Dialog::LightroomResult { summary, library, details, .. } => { + ui.label(summary.as_str()); + ui.add_space(8.0); + if let Some(library) = library { + ui.label(crate::i18n::tr("Destination library")); + ui.add(egui::Label::new(library.as_str()).wrap().selectable(true)); + ui.label(crate::i18n::tr("Lightroom catalogs import into the open library; no separate catalog folder is created.")); + } else { + ui.label(crate::i18n::tr("These photos are only in memory. Open a library on disk and import the catalog again to save them.")); + } + for detail in details.iter() { + ui.add(egui::Label::new(detail).wrap().selectable(true)); + } + } Dialog::SystemInfo { rows } => { egui::Grid::new("sysinfo").num_columns(2).spacing([16.0, 4.0]).striped(true).show(ui, |ui| { for (k, v) in rows.iter() { @@ -1199,6 +1214,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // only shows something (one Close, no Cancel / OK) let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported"); let informational = unusable_model + || matches!(dlg, Dialog::LightroomResult { first: None, .. }) || matches!( dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew @@ -1210,6 +1226,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let cancel = match &dlg { Dialog::SamModel { .. } if sam_running || sam_installed || sam_nowhere => "Close", Dialog::SamModel { .. } => "Not Now", + Dialog::LightroomResult { .. } => "Close", _ => "Cancel", }; if !informational { @@ -1227,6 +1244,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n); add_label.as_str() } + Dialog::LightroomResult { first: Some(_), .. } => "Show in Library", Dialog::ContactSheet { .. } => "Choose PDF…", Dialog::Merge { .. } => "Merge", Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", @@ -1552,6 +1570,20 @@ pub(crate) fn album_entries(cat: &lightcraft_catalog::Catalog, editing: Option Result { match dlg { + Dialog::LightroomResult { first, .. } => { + if let Some(first) = first { + app.run("library.source", json!({"kind": "all"}))?; + let mut filter = serde_json::to_value(lightcraft_catalog::Filter::default()).map_err(|e| e.to_string())?; + // The filter command merges a patch; these defaults are omitted by serde. + filter["only"] = json!([]); + filter["labels"] = json!([]); + filter["ruleSet"] = serde_json::Value::Null; + app.run("library.filter", filter)?; + app.run("library.select", json!({"ids": [first]}))?; + app.ui.view = crate::state::ViewMode::PhotoGrid; + } + Ok(serde_json::Value::Null) + } Dialog::SamModel { then, .. } => { if app.session.segmenter.installed() { // installed: start what the user was doing diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index a0717ccfd..6c38df7c7 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -412,7 +412,10 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } else { format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into())) }; - hint(ui, t, &status); + // One line, the whole text on hover: the reason can be a long driver message (no adapter + // among the backends …), and the GPU state is process-wide, so it may change between frames; + // a hint that wrapped would move every control below it under the pointer. + ui.add(egui::Label::new(RichText::new(crate::i18n::tr(&status)).size(11.0).color(t.text_dim)).truncate()).on_hover_text(&status); row(ui, t, crate::i18n::tr("Preview size"), |ui| { let opts: Vec<(u32, String)> = PREVIEW_LIMITS.iter().map(|e| (*e, if *e == 0 { crate::i18n::tr("Automatic").to_string() } else { format!("{e} px") })).collect(); @@ -953,6 +956,10 @@ fn switch_library(app: &mut LightcraftApp, path: &str) -> Result app.session.close_library().map_err(|e| e.to_string())?; let opened = app.session.open_library(&dir, false).map(|_| ()).map_err(|e| format!("{path}: {e}")); app.lightroom_last = None; + app.lightroom_result = None; + if matches!(app.ui.dialog.as_ref(), Some(crate::state::Dialog::LightroomResult { .. })) { + app.ui.dialog = None; + } app.renderer.forget_all(); app.ui.compare = None; if let Err(err) = opened { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cc583e7b3..c43b08101 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -783,6 +783,13 @@ pub struct NameEdit { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Dialog { + /// A Lightroom import result stays readable after its activity row disappears. + LightroomResult { + summary: String, + library: Option, + details: Vec, + first: Option, + }, ContactSheet { options: lightcraft_engine::contact_sheet::Options, }, diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests_dialog_fit.rs index 0014c6bdb..29d0401c2 100644 --- a/crates/ui-egui/src/tests_dialog_fit.rs +++ b/crates/ui-egui/src/tests_dialog_fit.rs @@ -134,3 +134,26 @@ fn a_dialog_list_shrinks_to_fit_a_short_window() { let dialog = rect(&h, "dialog:window"); assert!(dialog.height() <= 320.0, "{dialog:?}"); } + +/// Settings ▸ Performance: the GPU status line above the controls stays one line whatever it says +/// (without a driver it is a long wgpu message, e.g. on the FreeBSD CI), so the controls below it +/// don't move when the process-wide GPU state changes between two frames (a click aimed at +/// "Memory for caches" then landed on the gap above it). +#[test] +fn gpu_status_never_moves_the_performance_controls() { + let mut h = demo([1300.0, 820.0]); + let r = h.request("ui.key", json!({"key": ",", "cmd": true}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), T); + assert_eq!(r["ok"], true, "{r}"); + let mut seen = vec![]; + for gpu in [true, false, true] { + // the setting is applied process-wide at the next frame (`app.gpu`), as other tests do + h.app.ui.settings.gpu = gpu; + h.step(); + h.step(); + seen.push((gpu, rect(&h, "button:settingsMemory-2"), lightcraft_engine::gpu::unavailable_reason())); + } + let first = seen[0].1; + assert!(seen.iter().all(|(_, r, _)| *r == first), "the memory buttons moved: {seen:?}"); +} diff --git a/crates/ui-egui/src/tests_offline.rs b/crates/ui-egui/src/tests_offline.rs index 3e3ed49d6..9e0b85b78 100644 --- a/crates/ui-egui/src/tests_offline.rs +++ b/crates/ui-egui/src/tests_offline.rs @@ -1,10 +1,14 @@ //! Slow or offline drives never stall a frame (issue #104): with an injected file-system probe -//! that takes far longer than a frame (a sleeping NAS), the grid, the Info panel, the Missing -//! Photos view, the Rename dialog and an import keep drawing frames quickly, the probe never runs -//! on the UI thread, and the background answers still arrive. +//! that does not answer at all until the test lets it (a sleeping NAS), the grid, the Info panel, +//! the Rename dialog, the Missing Photos view and an import keep drawing frames, the probe never +//! runs on the UI thread, and the background answers arrive once the drive wakes up. +//! +//! The NAS is a gate, not a sleep: a frame that waited for the drive would wait for good (until a +//! watchdog gives up and records it), so the tests need no frame-time budget and hold on a slow +//! or loaded machine (the FreeBSD CI VM) as they do on a fast one. -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; use std::thread::ThreadId; use std::time::{Duration, Instant}; @@ -16,11 +20,9 @@ use crate::headless::Headless; use crate::state::{RightPanel, ViewMode}; use crate::{LightcraftApp, Services}; -/// What one file-system call takes on the "NAS". -const SLOW: Duration = Duration::from_millis(400); -/// A frame that waited for one such call would take at least this long. -const FRAME_LIMIT: Duration = Duration::from_millis(300); const N: u64 = 12; +/// How long the drive stays asleep at most: only a frame stuck waiting for it gets this far. +const WATCHDOG: Duration = Duration::from_secs(120); /// Threads the probe ran on, and how often. #[derive(Clone, Default)] @@ -39,9 +41,59 @@ impl Calls { } } +/// The sleeping drive: every file-system call waits until [`Nas::wake`] (or takes one of the +/// calls [`Nas::let_through`] allows). A watchdog wakes it after [`WATCHDOG`] and records that it +/// had to (some frame was waiting for the drive). +#[derive(Default)] +struct Nas { + /// (awake, calls let through while asleep) + state: Mutex<(bool, usize)>, + changed: Condvar, + forced: AtomicBool, +} + +impl Nas { + fn asleep() -> Arc { + let nas = Arc::new(Nas::default()); + let dog = nas.clone(); + std::thread::spawn(move || { + let state = dog.state.lock().unwrap(); + let (mut state, _) = dog.changed.wait_timeout_while(state, WATCHDOG, |s| !s.0).unwrap(); + if !state.0 { + dog.forced.store(true, Ordering::SeqCst); + state.0 = true; + dog.changed.notify_all(); + } + }); + nas + } + fn wait(&self) { + let state = self.state.lock().unwrap(); + let mut state = self.changed.wait_while(state, |s| !s.0 && s.1 == 0).unwrap(); + if !state.0 { + state.1 -= 1; + } + } + /// Answer `n` more calls, then sleep again. + fn let_through(&self, n: usize) { + self.state.lock().unwrap().1 += n; + self.changed.notify_all(); + } + fn wake(&self) { + self.state.lock().unwrap().0 = true; + self.changed.notify_all(); + } + /// The watchdog had to wake the drive: a frame waited for it. + fn forced(&self) -> bool { + self.forced.load(Ordering::SeqCst) + } +} + /// `N` photos whose originals are on an unreachable share, a smart previews folder, and a probe -/// that sleeps like a NAS waking up before saying "not there". -fn offline_library(calls: &Calls) -> Session { +/// that waits for the NAS to wake up before saying "not there". Called on the UI thread: a call +/// from that thread is recorded and answered at once, so a regression fails the thread check +/// instead of hanging the test. +fn offline_library(calls: &Calls, nas: &Arc) -> Session { let mut s = Session::new(); for i in 0..N { let path = format!("/lightcraft-offline-nas/IMG_{i:03}.jpg"); @@ -49,94 +101,96 @@ fn offline_library(calls: &Calls) -> Session { s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); } s.media.smart_dir = Some(std::env::temp_dir().join("lightcraft-offline-smart-previews-none")); - let c = calls.clone(); + let (c, nas, ui_thread) = (calls.clone(), nas.clone(), std::thread::current().id()); s.media.availability.set_probe(Arc::new(move |_| { c.record(); - std::thread::sleep(SLOW); + if std::thread::current().id() != ui_thread { + nas.wait(); + } false })); s } -fn max_frame(h: &mut Headless, frames: usize) -> Duration { - let mut worst = Duration::ZERO; +/// Run `frames` frames while the drive sleeps; each one has to finish without it. +fn frames_while_asleep(h: &mut Headless, nas: &Nas, frames: usize, what: &str) { for _ in 0..frames { - let t0 = Instant::now(); h.step(); - worst = worst.max(t0.elapsed()); + assert!(!nas.forced(), "a {what} frame waited for the sleeping drive"); } - worst } #[test] fn offline_originals_never_block_a_frame() { let calls = Calls::default(); - let app = LightcraftApp::new(offline_library(&calls), Services { png: None, ..Default::default() }); + let nas = Nas::asleep(); + let app = LightcraftApp::new(offline_library(&calls, &nas), Services { png: None, ..Default::default() }); let mut h = Headless::new(app, [1200.0, 800.0], 1.0); let ui_thread = std::thread::current().id(); h.app.ui.view = ViewMode::PhotoGrid; h.step(); - // The first frame installs fonts; finish the first layout before timing the views. + // The first frame installs fonts; finish the first layout before the drive matters. h.step(); - assert!(h.app.fonts_ready, "the UI is initialized before frame timing starts"); + assert!(h.app.fonts_ready, "the UI is initialized"); assert!(h.app.session.media.availability.is_background(), "the app checks files off the UI thread"); // the grid: every cell asks for its thumbnail (which asks whether the original is there) h.app.session.execute("library.select", &json!({"ids": [1]})).unwrap(); - let worst = max_frame(&mut h, 20); - assert!(worst < FRAME_LIMIT, "grid frame took {worst:?}"); + frames_while_asleep(&mut h, &nas, 20, "grid"); // the Info panel of the active photo, in the loupe h.app.ui.view = ViewMode::Detail; h.app.ui.right = RightPanel::Info; - let worst = max_frame(&mut h, 20); - assert!(worst < FRAME_LIMIT, "Info panel frame took {worst:?}"); + frames_while_asleep(&mut h, &nas, 20, "Info panel"); - // the Missing Photos view fills in as the background checks find the files gone + // the Rename dialog's preview (it checks which names are taken on disk) h.app.ui.view = ViewMode::PhotoGrid; h.app.ui.right = RightPanel::None; + h.app.session.execute("library.select", &json!({"ids": [1, 2, 3]})).unwrap(); + h.app.ui.dialog = Some(crate::state::Dialog::Rename { template: "Trip-{seq:3}".into(), start: 1 }); + frames_while_asleep(&mut h, &nas, 20, "Rename dialog"); + h.app.ui.dialog = None; + + // the Missing Photos view: nothing is known yet, so nothing is listed, and frames go on h.app.session.execute("library.source", &json!({"kind": "missing"})).unwrap(); assert_eq!(h.app.session.source, LibrarySource::Missing); + frames_while_asleep(&mut h, &nas, 20, "Missing Photos"); + assert!(calls.n.load(Ordering::SeqCst) > 0, "the probe was asked"); + assert_eq!(h.app.session.visible().len(), 0, "unknown files count as there"); + + // the drive answers: the view fills in as the background checks find the files gone + nas.wake(); let t0 = Instant::now(); - let mut worst = Duration::ZERO; while h.app.session.visible().len() < N as usize && t0.elapsed() < Duration::from_secs(60) { - let f = Instant::now(); h.step(); - worst = worst.max(f.elapsed()); std::thread::sleep(Duration::from_millis(5)); } assert_eq!(h.app.session.visible().len(), N as usize, "every photo found missing in the end"); - assert!(worst < FRAME_LIMIT, "Missing Photos frame took {worst:?}"); - - // the Rename dialog's preview (it checks which names are taken on disk) - h.app.session.execute("library.select", &json!({"ids": [1, 2, 3]})).unwrap(); - h.app.ui.dialog = Some(crate::state::Dialog::Rename { template: "Trip-{seq:3}".into(), start: 1 }); - let worst = max_frame(&mut h, 20); - assert!(worst < FRAME_LIMIT, "Rename dialog frame took {worst:?}"); - - assert!(calls.n.load(Ordering::SeqCst) > 0, "the probe was asked"); assert_eq!(calls.on(ui_thread), 0, "the slow probe never ran on the UI thread"); } /// An import whose files take long to read (probe: decoding headers and hashing on a slow drive) -/// runs on a worker thread: frames stay quick, the probe never runs on the UI thread, and Cancel -/// stops it with what was read so far added. +/// runs on a worker thread: frames go on while the drive holds every read, the probe never runs +/// on the UI thread, and Cancel stops it with what was read so far added. #[test] fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() { let dir = std::env::temp_dir().join(format!("lc-ui-slow-import-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - // enough files that the import is still running when its activity row shows (after 0.5 s) let n = 120; for i in 0..n { std::fs::write(dir.join(format!("IMG_{i:03}.jpg")), format!("not really a jpeg {i}")).unwrap(); } let calls = Calls::default(); + let nas = Nas::asleep(); let mut s = Session::new(); - let c = calls.clone(); + let (c, drive, ui_thread) = (calls.clone(), nas.clone(), std::thread::current().id()); s.media.file_probe = Some(Arc::new(move |p: &str| { c.record(); - std::thread::sleep(Duration::from_millis(150)); + // on the UI thread: answered at once, so a regression fails the thread check, not by hanging + if std::thread::current().id() != ui_thread { + drive.wait(); + } Ok(lightcraft_engine::media::ProbeInfo { width: 60, height: 40, @@ -147,32 +201,35 @@ fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() { })); let app = LightcraftApp::new(s, Services { png: None, ..Default::default() }); let mut h = Headless::new(app, [1200.0, 800.0], 1.0); - let ui_thread = std::thread::current().id(); h.step(); - // The first frame installs fonts; finish the first layout before timing the import. + // The first frame installs fonts; finish the first layout before the drive matters. h.step(); - assert!(h.app.fonts_ready, "the UI is initialized before import timing starts"); + assert!(h.app.fonts_ready, "the UI is initialized"); let undo0 = h.app.session.undo.len(); crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap(); - // some batches arrive; meanwhile every frame is quick - let t0 = Instant::now(); - let mut worst = Duration::ZERO; - while h.app.import.as_ref().is_some_and(|t| t.imported < 8) && t0.elapsed() < Duration::from_secs(60) { - let f = Instant::now(); - h.step(); - worst = worst.max(f.elapsed()); - std::thread::sleep(Duration::from_millis(5)); - } - assert!(worst < FRAME_LIMIT, "import frame took {worst:?}"); - assert!(h.app.import.is_some(), "still importing"); - let status = h.app.import.as_ref().map(crate::import::ImportTask::status).unwrap_or_default(); - assert!(status["total"].as_u64().unwrap_or(0) >= 8, "{status}"); - // Cancel (the ✕ of its row in the activity stack): the files being read finish, nothing new starts + // every read is held: frames go on, and the import's row shows (after 0.5 s) with its ✕ + frames_while_asleep(&mut h, &nas, 20, "import"); + assert!(h.app.import.is_some(), "importing"); let id = h.app.session.activity.list().first().map(|t| t.id).expect("an import row"); let cross = format!("activity:cancel:{id}"); - assert!(h.step_until(Duration::from_secs(10), |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); + assert!(!nas.forced(), "an import frame waited for the sleeping drive"); + // the drive answers one batch of reads: it joins the catalog while the next batch waits + let batch = lightcraft_engine::import::batch_size(); + nas.let_through(batch); + let t0 = Instant::now(); + while h.app.import.as_ref().is_some_and(|t| t.imported < batch) && t0.elapsed() < Duration::from_secs(60) { + h.step(); + assert!(!nas.forced(), "an import frame waited for the sleeping drive"); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(h.app.import.is_some(), "still importing"); + let status = h.app.import.as_ref().map(crate::import::ImportTask::status).unwrap_or_default(); + assert!(status["imported"].as_u64().unwrap_or(0) >= 8 && status["total"].as_u64().unwrap_or(0) >= 8, "{status}"); + // Cancel (the ✕ of its row in the activity stack): the files being read finish, nothing new starts let r = h.request("ui.clickWidget", json!({"id": cross}), Duration::from_secs(10)); assert_eq!(r["ok"], true, "{r}"); + nas.wake(); let t0 = Instant::now(); while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(60) { h.step(); diff --git a/crates/ui-egui/src/tests_open_lightroom.rs b/crates/ui-egui/src/tests_open_lightroom.rs index 028801b56..cddfa2bef 100644 --- a/crates/ui-egui/src/tests_open_lightroom.rs +++ b/crates/ui-egui/src/tests_open_lightroom.rs @@ -88,8 +88,10 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { // library open now. (This fake catalog then fails to read: only its path matters here.) assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true); h.step(); - assert!(h.app.ui.dialog.is_none()); - h.step_until(T, |h| h.app.lightroom.is_none()); + // A fast read failure can already have replaced the offer with its lasting result. + assert!(!matches!(h.app.ui.dialog, Some(Dialog::LightroomCatalog { .. }))); + assert!(h.step_until(T, |h| h.app.lightroom.is_none())); + assert!(matches!(&h.app.ui.dialog, Some(Dialog::LightroomResult { summary, first: None, .. }) if summary.contains("failed"))); let last = h.app.lightroom_last.clone().unwrap_or_default(); assert_eq!(last["path"], json!(lrcat), "{last}"); assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone())); diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index f4e384ef6..4f058f536 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -881,9 +881,18 @@ mod in_the_loupe { assert!(window[0].0 > whole[0].0, "over the whole-frame render"); } + /// The engine caches' share of the default memory budget (1.5 GiB) on a machine with 6 GiB of + /// RAM or more: room for both panes' 24 MP originals (288 MB each as linear float). + const COMPARE_CACHE: usize = 768 << 20; + /// Compare view of the demo library at `zoom`, settled. + /// + /// The session's cache budget is set here rather than taken from the process-wide memory + /// budget, which other tests change while they run (Settings ▸ Performance ▸ 1 GB leaves a + /// 512 MiB share, too small for two originals, so one pane's was evicted). fn compare_at(zoom: crate::state::Zoom) -> Headless { let (mut h, _) = detail(); + h.app.session.media.set_budget(COMPARE_CACHE); let r = h.request("engine.execute", json!({"command": "view.compare"}), T); assert_eq!(r["ok"], true, "{r}"); assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare); diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests_switch_library.rs index 8c5208e3d..d1ad85c96 100644 --- a/crates/ui-egui/src/tests_switch_library.rs +++ b/crates/ui-egui/src/tests_switch_library.rs @@ -55,6 +55,26 @@ fn app_in(dir: &std::path::Path) -> LightcraftApp { LightcraftApp::new(session, Services::default()) } +#[test] +fn switching_libraries_dismisses_the_previous_lightroom_result() { + let dir = temp("switch-lightroom-result"); + let mut app = app_in(&dir); + let result = || crate::state::Dialog::LightroomResult { + summary: "Imported into first".into(), + library: Some(dir.join("first").to_string_lossy().into_owned()), + details: Vec::new(), + first: Some(1), + }; + app.ui.dialog = Some(result()); + app.lightroom_result = Some(result()); + app.lightroom_last = Some(json!({"imported": 1})); + app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Second"})).unwrap(); + assert!(app.ui.dialog.is_none(), "the old destination and photo ID must not act on the new library"); + assert!(app.lightroom_result.is_none()); + assert!(app.lightroom_last.is_none()); + let _ = std::fs::remove_dir_all(dir); +} + fn recent_submenu(app: &LightcraftApp) -> Vec { use crate::menubar::MenuNode; let bar = crate::menubar::menu_bar(app); diff --git a/docs/gaps.md b/docs/gaps.md index ee6e58d1c..e63fcbf6b 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-13 · **Change:** patch (gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (catalog import result, offline Local promotion and reduced copies; gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice) 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 @@ -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**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 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; 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**: 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) | @@ -91,3 +91,6 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-10 | minor | Beta total raised to 340–600 h to include the ~92 open core-path user issues | | 2026-10-10 | major | Created from the checklist's Top gaps (whose items 1–2 had been duplicated by a merge) and ROADMAP's Where we stand; re-ranked; added evidence, impact, hours and beta flags; added platform-CI, lens-database, HE NEF and localization gaps | | 2026-10-13 | minor | Gap 8 narrows: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (`docs/nikon-lens-corrections.md`) | +| 2026-10-12 | minor | Catalog migration reports offline records as additions, keeps the destination/result readable, skips missing-original probes and stages only masters needed for virtual copies. Approximate rendering and database/table limits remain. | +| 2026-10-13 | minor | Catalog import review: offline Local records join All Photos, redundant operation/settings copies removed, and result invalidation across library switches covered. Approximate rendering and database/table limits remain. | +| 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. | diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 1f9deeea4..5ad4383fd 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -15,9 +15,21 @@ lightcraft-cli run --library "LightCraft Library" library.inspectLightroom path= lightcraft-cli run --library "LightCraft Library" library.importLightroom path="Lightroom Catalog.lrcat" ``` +Import adds records to the currently open LightCraft library; it does not create a separate +folder named after the Lightroom catalog. The desktop result stays open until dismissed and +shows the destination library, the number of records added, missing originals and warnings. +**Show in Library** opens All Photos, clears filters and selects an imported record. Errors stay +readable in the same result window. If another dialog is open, the result waits until that dialog closes. The result +is also available through `library.importLightroom {"status":true}` during that session. + Original files are referenced in place. Ratings, picks/rejects, color labels, XMP metadata, hierarchical keywords, regular collections/collection sets, virtual copies and mapped develop -settings are imported. Missing originals remain catalogued for relinking. Existing LightCraft +settings are imported. Missing originals remain catalogued for relinking, count as added records and are reported +separately from failures reading files that are present. Known-missing originals are not probed +again. Previously browsed Local records join All Photos even when their originals are offline; +their promotion is counted once and undone together with the catalog import. +Virtual copies count as added records too; reimport reports zero additions when all +records already exist. Existing LightCraft edits are preserved by default; `updateExisting=true` explicitly replaces them. Persistent import identity prevents reimport from duplicating virtual copies and collections. One undo step reverses catalog changes; any saved recovery archives remain available. @@ -32,8 +44,10 @@ 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 +Each native `.lrcat` database and WAL file is independently limited to 1 GiB +(1,073,741,824 bytes). This bounds the current whole-file reader; referenced photo sizes and +photo counts are not part of that limit. XMP packets are capped at 16 MiB and each table at one million rows. +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. 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 @@ -61,6 +75,12 @@ Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog it is reported, included in any saved archive, and never clamped into a real slider value. Deferred Adobe Auto Tone is not evaluated by the importer; LightCraft's Auto control remains available after migration. +New photo records are moved into staged operations; only masters needed for virtual copies +retain a second owned record while the import commits. +Applying catalog operations collects affected source-cache IDs before consuming the operations, +avoiding another full operation copy. Full-strength develop patches reuse their JSON without +serializing an unused copy of the current settings. + Native desktop inspection/import runs on a cancellable background worker with progress. Only prepared catalog operations commit on the owner thread; archive index writing stays on a worker. Late results from a closed or switched library are rejected, including reopening the same path. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 383f1703c..15b8d10b3 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 (LR-PROF-CAMERACOLOR: the camera look follows lens-corrected JPEGs; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-PROF-CAMERACOLOR: the camera look follows lens-corrected JPEGs; catalog import: persistent result, offline Local promotion and library-switch dismissal; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **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 @@ -692,7 +692,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; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | | 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 (also a catalog whose tables are all still in its WAL: the main file's schema format 0 is an empty schema), 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, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), 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. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | +| 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 (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; 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, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), 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. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | | 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`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | | LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open | | 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) | @@ -827,3 +827,6 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. | | 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | | 2026-09-30 – 2026-10-11 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) | +| 2026-10-12 | minor | LRC-LIB-CATALOG-IMPORT: lasting destination/result, offline records and virtual copies count as added; redundant missing-file probes and staged photo copies removed. Status and summary counts unchanged. | +| 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. |