diff --git a/crates/engine/src/import/files.rs b/crates/engine/src/import/files.rs index 099794597..9cbe22997 100644 --- a/crates/engine/src/import/files.rs +++ b/crates/engine/src/import/files.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + #[cfg(not(target_arch = "wasm32"))] use super::commit::workers; #[cfg(test)] @@ -238,6 +240,112 @@ pub(crate) fn probe_all( .collect() } +/// What the disk says about a file ([`stat_all`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Seen { + /// Not asked about: `cancel` was set first. + Unchecked, + /// Not there, or not readable. + Gone, + At { + is_file: bool, + len: u64, + }, +} + +/// [`Seen`] of `path` from the file system. +fn stat_file(path: &str) -> Seen { + match std::fs::metadata(path) { + Ok(m) => Seen::At { is_file: m.is_file(), len: m.len() }, + Err(_) => Seen::Gone, + } +} + +/// Files asked about at once: a question to a network share (a stat, a name looked up) is a round +/// trip (50 ms and more under load) that waits for nothing but the server, so a few more than the +/// readers' threads. +#[cfg(not(target_arch = "wasm32"))] +const AT_ONCE: usize = 16; + +/// Run `ask` on each of `items`, several at once (one at a time, a folder of thousands of photos on +/// a network share waited minutes for its questions alone). The answers come in the order of +/// `items`; `None` for an item not reached once `cancel` is set (or whose asker died). +fn at_once(items: &[T], cancel: &std::sync::atomic::AtomicBool, ask: impl Fn(&T) -> R + Sync) -> Vec> { + use std::sync::atomic::Ordering::Relaxed; + let mut answers: Vec> = items.iter().map(|_| None).collect(); + #[cfg(not(target_arch = "wasm32"))] + { + let n = AT_ONCE.min(items.len()); + if n > 1 { + let next = std::sync::atomic::AtomicUsize::new(0); + let mine: Vec> = std::thread::scope(|sc| { + let askers: Vec<_> = (0..n) + .map(|_| { + sc.spawn(|| { + let mut mine = Vec::new(); + loop { + let i = next.fetch_add(1, Relaxed); + let Some(item) = items.get(i) else { break }; + if cancel.load(Relaxed) { + break; + } + mine.push((i, ask(item))); + } + mine + }) + }) + .collect(); + askers.into_iter().map(|a| a.join().unwrap_or_default()).collect() + }); + for (i, r) in mine.into_iter().flatten() { + if let Some(slot) = answers.get_mut(i) { + *slot = Some(r); + } + } + return answers; + } + } + for (slot, item) in answers.iter_mut().zip(items) { + if cancel.load(Relaxed) { + break; + } + *slot = Some(ask(item)); + } + answers +} + +/// Ask `stat` about each of `paths`, several at once ([`at_once`]); the ones not reached once +/// `cancel` is set are [`Seen::Unchecked`]. +pub(crate) fn stat_all(paths: &[&str], cancel: &std::sync::atomic::AtomicBool, stat: impl Fn(&str) -> Seen + Sync) -> Vec { + at_once(paths, cancel, |p| stat(p)).into_iter().map(|s| s.unwrap_or(Seen::Unchecked)).collect() +} + +/// The sidecar of each of `files` (`find` says which: a few names looked up on the disk for each, +/// a round trip apiece on a network share), several files at once ([`at_once`]); `None` for a +/// file with no sidecar or not reached once `cancel` is set. +pub(super) fn sidecars_of( + files: &[&str], + cancel: &std::sync::atomic::AtomicBool, + find: impl Fn(&str) -> Option + Sync, +) -> Vec> { + at_once(files, cancel, |f| find(f)).into_iter().map(Option::flatten).collect() +} + +/// The probes in `cache` of `files` that are still true: the file is looked at ([`stat_all`]) and +/// `ok` says it is as it was probed. `None` for a file with no probe in `cache`, one that changed, +/// and one not looked at because `cancel` was set. +pub(super) fn reusable_probes( + files: &[String], + cache: &mut HashMap, + cancel: &std::sync::atomic::AtomicBool, + ok: impl Fn(Seen, &ProbeInfo) -> bool, +) -> Vec> { + let held: Vec> = files.iter().map(|f| cache.remove(f)).collect(); + let asked: Vec<&str> = files.iter().zip(&held).filter(|(_, h)| h.is_some()).map(|(f, _)| f.as_str()).collect(); + let mut seen = stat_all(&asked, cancel, stat_file).into_iter(); + held.into_iter().map(|h| h.filter(|info| seen.next().is_some_and(|s| s != Seen::Unchecked && ok(s, info)))).collect() +} + /// Copy `src` into `dir` as `name` (default: its own name), made unique with -1, -2…; returns the /// new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, synced and /// checked against `probe_hash`, the content hash the probe computed — or compared byte for byte @@ -370,4 +478,145 @@ mod prepared_tests { assert!(seen.load(Relaxed) <= 8, "read {} files after cancel", seen.load(Relaxed)); assert!(prepared.items.is_empty()); } + + /// Given files on a network share, when an import checks the probes a scan left, then it asks + /// the disk about several files at once: one at a time, 5,990 photos spent 300 s on stats alone + /// (99 % of the import's time), each a round trip to the server. + #[test] + fn stats_are_asked_for_several_files_at_once() { + use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + let (active, together) = (AtomicUsize::new(0), AtomicUsize::new(0)); + let names: Vec = (0..6).map(|i| format!("file-{i}")).collect(); + let paths: Vec<&str> = names.iter().map(String::as_str).collect(); + let seen = stat_all(&paths, &std::sync::atomic::AtomicBool::new(false), |p| { + active.fetch_add(1, Relaxed); + // waits (at most a second) for another file to be asked about while this one is + let end = std::time::Instant::now() + std::time::Duration::from_secs(1); + while active.load(Relaxed) < 2 && std::time::Instant::now() < end { + std::thread::yield_now(); + } + together.fetch_max(active.load(Relaxed), Relaxed); + active.fetch_sub(1, Relaxed); + Seen::At { is_file: true, len: p.trim_start_matches("file-").parse().unwrap_or(u64::MAX) } + }); + assert!(together.load(Relaxed) >= 2, "the files were asked about one at a time"); + let lens: Vec = (0..6).map(|len| Seen::At { is_file: true, len }).collect(); + assert_eq!(seen, lens, "the answers keep the order of the files"); + } + + /// ✕ while the stats run: the files not asked about yet stay unchecked, and no more are asked. + #[test] + fn stats_stop_when_cancelled() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; + let (cancel, asked) = (AtomicBool::new(false), AtomicUsize::new(0)); + let names: Vec = (0..400).map(|i| format!("file-{i}")).collect(); + let paths: Vec<&str> = names.iter().map(String::as_str).collect(); + let seen = stat_all(&paths, &cancel, |_| { + asked.fetch_add(1, Relaxed); + cancel.store(true, Relaxed); + Seen::Gone + }); + assert_eq!(seen.len(), 400); + // at most the files the (up to 16) readers had already started + assert!(asked.load(Relaxed) <= 16, "asked about {} files after cancel", asked.load(Relaxed)); + assert_eq!(seen.iter().filter(|s| **s == Seen::Gone).count(), asked.load(Relaxed)); + assert!(seen.iter().all(|s| matches!(s, Seen::Gone | Seen::Unchecked))); + } + + /// A probe a scan left is used again for a file that is as it was, and only for it: a file that + /// changed size, one that is gone and one with no probe are read. + #[test] + fn a_scans_probe_is_reused_only_for_a_file_as_it_was() { + use std::sync::atomic::Ordering::Relaxed; + let dir = std::env::temp_dir().join(format!("lightcraft-reuse-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut files = Vec::new(); + for (name, bytes) in [("same.jpg", 10usize), ("grown.jpg", 10), ("fresh.jpg", 10)] { + let p = dir.join(name); + std::fs::write(&p, vec![0u8; bytes]).unwrap(); + files.push(p.to_string_lossy().to_string()); + } + let gone = dir.join("gone.jpg").to_string_lossy().to_string(); + files.push(gone.clone()); + let mut s = Session::new(); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + let probed = |size: u64| ProbeInfo { format: "JPEG".into(), file_size: size, ..ProbeInfo::default() }; + job.cache.insert(files[0].clone(), probed(10)); + job.cache.insert(files[1].clone(), probed(5)); + job.cache.insert(gone.clone(), probed(10)); + let read = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let read_in_probe = read.clone(); + job.probe = Some(std::sync::Arc::new(move |p: &str| { + read_in_probe.lock().unwrap().push(p.to_string()); + Ok(probed(10)) + })); + let prepared = job.prepare_files(files.clone(), &std::sync::atomic::AtomicBool::new(false)); + let mut read = read.lock().unwrap().clone(); + read.sort(); + let mut expect = vec![files[1].clone(), files[2].clone(), gone]; + expect.sort(); + assert_eq!(read, expect, "only what was not as the scan saw it is read"); + assert_eq!(job.read_total.load(Relaxed), 3); + assert_eq!(prepared.items.len(), 4); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Given photos on a network share, when an import looks for their sidecars, then it asks about + /// several photos at once: one photo after another, nine names each, they were 77 % of the + /// import's preparation (95 % where a folder listing could not vouch for the names). + #[test] + fn sidecars_are_looked_up_for_several_photos_at_once() { + use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + let (active, together) = (AtomicUsize::new(0), AtomicUsize::new(0)); + let names: Vec = (0..6).map(|i| format!("photo-{i}.jpg")).collect(); + let files: Vec<&str> = names.iter().map(String::as_str).collect(); + let found = sidecars_of(&files, &std::sync::atomic::AtomicBool::new(false), |f| { + active.fetch_add(1, Relaxed); + let end = std::time::Instant::now() + std::time::Duration::from_secs(1); + while active.load(Relaxed) < 2 && std::time::Instant::now() < end { + std::thread::yield_now(); + } + together.fetch_max(active.load(Relaxed), Relaxed); + active.fetch_sub(1, Relaxed); + f.ends_with("-3.jpg").then(|| PathBuf::from(f).with_extension("xmp")) + }); + assert!(together.load(Relaxed) >= 2, "the photos were looked up one at a time"); + let expect: Vec> = (0..6).map(|i| (i == 3).then(|| PathBuf::from("photo-3.xmp"))).collect(); + assert_eq!(found, expect, "each sidecar stays with its photo"); + } + + /// The sidecars found for a batch go to the photos they belong to, whichever have one, and a + /// photo that failed to probe takes none of the others'. + #[test] + fn a_batchs_sidecars_go_to_their_own_photos() { + let dir = std::env::temp_dir().join(format!("lightcraft-sidecars-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let xmp = r#""#; + let files: Vec = ["a.jpg", "broken.jpg", "c.jpg", "d.jpg"].iter().map(|n| dir.join(n).to_string_lossy().to_string()).collect(); + for f in &files { + std::fs::write(f, b"x").unwrap(); + } + std::fs::write(dir.join("c.xmp"), xmp).unwrap(); + let mut s = Session::new(); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + job.probe = Some(std::sync::Arc::new(|p: &str| { + if p.ends_with("broken.jpg") { + Err("unreadable".into()) + } else { + Ok(ProbeInfo { format: "JPEG".into(), file_size: 1, ..ProbeInfo::default() }) + } + })); + let prepared = job.prepare_files(files, &std::sync::atomic::AtomicBool::new(false)); + let rating = |name: &str| { + prepared.items.iter().find_map(|i| match i { + PreparedItem::Ready(r) if r.path.ends_with(name) => Some(r.sidecar.as_ref().and_then(|sc| sc.rating)), + _ => None, + }) + }; + assert_eq!((rating("a.jpg"), rating("c.jpg"), rating("d.jpg")), (Some(None), Some(Some(4)), Some(None))); + assert!(prepared.items.iter().any(|i| matches!(i, PreparedItem::Failed(p, _) if p.ends_with("broken.jpg")))); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/import/job.rs b/crates/engine/src/import/job.rs index a178035da..903308b03 100644 --- a/crates/engine/src/import/job.rs +++ b/crates/engine/src/import/job.rs @@ -1,5 +1,5 @@ use super::commit::{How, Plan, Slot, Transfer, commit_prepared, name_family, run_transfers, system_clock}; -use super::files::{expand, probe_all}; +use super::files::{Seen, expand, probe_all, reusable_probes, sidecars_of}; use super::types::{ImportCandidate, ImportMode, ImportOptions, ImportReport, Kept, OnDeleted}; use super::{HashMap, MediaKind, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Source}; @@ -44,7 +44,7 @@ pub struct ImportJob { pub(super) probe: Option, file_bytes: Option, /// Probes from a preceding scan (not read again). - cache: HashMap, + pub(super) cache: HashMap, naming: crate::sidecar::SidecarNaming, now: String, seq: usize, @@ -270,15 +270,10 @@ impl ImportJob { // files a preceding scan already probed are not read again (a network share is slow to read), // as long as they are still there, as they were: a file gone or changed since is probed again - let cached: Vec> = todo - .iter() - .map(|f| { - // (the web's files are not std::fs files: kept as probed) - self.cache - .remove(f) - .filter(|info| cfg!(target_arch = "wasm32") || std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() == info.file_size)) - }) - .collect(); + let cached = reusable_probes(&todo, &mut self.cache, cancel, |seen, info| { + // (the web's files are not std::fs files: kept as probed) + cfg!(target_arch = "wasm32") || matches!(seen, Seen::At { is_file: true, len } if len == info.file_size) + }); let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); // Reading the files is the slow part of adding them where they are: it is counted on its // own, and `cancel` stops it too (a large library takes long enough to want both). @@ -294,6 +289,20 @@ impl ImportJob { library.as_ref(), todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))), ); + // The sidecar of every file that was probed, looked up for the batch at once: each is a few + // names asked of the disk, which on a network share made a folder of thousands of photos wait + // a round trip per name, one name after another. + let asked: Vec<&str> = todo.iter().zip(&probed).filter(|(_, p)| p.is_ok()).map(|(f, _)| f.as_str()).collect(); + // (a file whose content the library has is skipped below as a duplicate: it asks the disk nothing) + let known: std::collections::HashSet<&str> = todo + .iter() + .zip(&probed) + .filter(|(_, p)| !opts.local && p.as_ref().is_ok_and(|i| i.content_hash.as_ref().is_some_and(|h| self.by_hash.contains_key(h)))) + .map(|(f, _)| f.as_str()) + .collect(); + let mut found = + sidecars_of(&asked, cancel, |f| if known.contains(f) { None } else { crate::sidecar::find_sidecar_for_import(&stems, self.naming, f) }) + .into_iter(); // Files are planned one by one, in order (duplicates, sidecars, names, folders); their // copies or moves — the slow part, reading and writing every byte and verifying it — are // collected and run on several threads (issue #367), then their outcomes take their places. @@ -311,6 +320,8 @@ impl ImportJob { continue; } }; + // (this file's place in the lookups above: files that failed to probe have none) + let found_sidecar = found.next().flatten(); // a copy of a file whose transfer is still pending: a duplicate only if that one succeeds if !opts.local && let Some(h) = &info.content_hash @@ -332,9 +343,7 @@ impl ImportJob { // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files // and names the copy when the file itself has none let raw = info.kind == MediaKind::Raw; - let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path) - .and_then(|f| std::fs::read_to_string(f).ok()) - .or_else(|| info.xmp.clone().filter(|_| raw)); + let packet = found_sidecar.and_then(|f| std::fs::read_to_string(f).ok()).or_else(|| info.xmp.clone().filter(|_| raw)); let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) { Ok(sc) => Some(sc), diff --git a/crates/engine/src/import/scan.rs b/crates/engine/src/import/scan.rs index ef925fa05..57ee995f2 100644 --- a/crates/engine/src/import/scan.rs +++ b/crates/engine/src/import/scan.rs @@ -1,4 +1,4 @@ -use super::files::{expand, probe_all}; +use super::files::{Seen, expand, probe_all, reusable_probes}; use super::types::ImportCandidate; use super::{HashMap, Path, PathBuf, PhotoId, ProbeInfo, Session, Source}; @@ -123,20 +123,14 @@ pub(crate) fn scan_files(mut input: ScanInput, files: Vec, progress: &Sc let by_path = library_files(&input.library, &files); let todo: Vec = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect(); progress.total.store(todo.len(), Relaxed); - // probes from a preceding `scan` are reused when the file is unchanged (same size) - let cached: Vec> = todo - .iter() - .map(|f| { - let info = input.cache.remove(f)?; - // (✕ set while the files' cached probes are checked: no more round trips to the disk; what is - // not reached counts as not probed, like the files after it) - if progress.cancel.load(Relaxed) { - return None; - } - let size = std::fs::metadata(f).map(|m| m.len()).ok(); - (size.is_none() || size == Some(info.file_size)).then_some(info) - }) - .collect(); + // probes from a preceding `scan` are reused when the file is unchanged (same size); a file that + // can't be looked at counts as unchanged (✕ set while they are checked: no more round trips to the + // disk; what is not reached counts as not probed, like the files after it) + let cached = reusable_probes(&todo, &mut input.cache, &progress.cancel, |seen, info| match seen { + Seen::At { len, .. } => len == info.file_size, + Seen::Gone => true, + Seen::Unchecked => false, + }); let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); progress.done.store(todo.len() - missing.len(), Relaxed); let mut fresh = probe_all(input.probe.as_ref(), &missing, &progress.done, &progress.cancel).into_iter(); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 98863fe6a..f7b9e74d6 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-29 · **Change:** minor (LRC-LIB-LIBFOLDERS: Don't ask again in the Move Photos question is kept only when the move started; a refused move leaves the question on; LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket (#2237, part of #2056); import loupe remains open; counts unchanged; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left, and for each photo's sidecar, up to 16 files at a time; LRC-LIB-LIBFOLDERS: Don't ask again in the Move Photos question is kept only when the move started; a refused move leaves the question on; LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket (#2237, part of #2056); import loupe remains open; counts unchanged; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **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 @@ -69,7 +69,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:view.importThumbSize`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/dialogs/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with a remembered 80–320 pt thumbnail-size slider (default 116, independent of the library grid), density-aware previews capped at 1024 px and retention limited to nearby cells; tiny sources are tried once per requested size bucket; renderable raws use a sharper render when the camera JPEG is undersized, retaining the JPEG for preview-only raws or an unavailable/failed render; the pre-import loupe and keyboard culling remain open under #2056; thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/widgets/album_picker.rs`, `crates/ui-egui/src/tests/album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests/import.rs`) | +| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:view.importThumbSize`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/dialogs/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with a remembered 80–320 pt thumbnail-size slider (default 116, independent of the library grid), density-aware previews capped at 1024 px and retention limited to nearby cells; tiny sources are tried once per requested size bucket; renderable raws use a sharper render when the camera JPEG is undersized, retaining the JPEG for preview-only raws or an unavailable/failed render; the pre-import loupe and keyboard culling remain open under #2056; thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/widgets/album_picker.rs`, `crates/ui-egui/src/tests/album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests/import.rs`). An import (and a scan of a folder scanned before) checks that each file a scan already probed is as it was by asking the disk about up to 16 files at a time (an import batch's files, at most), not one after another (`stat_all`, `crates/engine/src/import/files.rs`), and looks up the sidecars of a batch's photos the same way (`sidecars_of`); on a network share each file was a round trip | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → the import review), `crates/ui-egui/src/drop_overlay.rs`, `crates/ui-egui/src/tests/drop_import.rs` | while files are dragged over the window a veil and a card say what dropping does (review N items for import, import N presets, open the face model; a note to close the open dialog first when one is open); photos and folders open the import review as File ▸ Import does (Settings ▸ Import ▸ Import dropped files without review imports them at once; the web build always does); with no library open on the desktop (the launch list, a library that couldn't open) photos wait for the library opened and presets are refused, and the card says so; a drop while a dialog is open (also in the web build) or while an import is running is refused with a toast, and the card says so; a finished scan waits for an open dialog to close before its review opens; the control channel's `ui.dropFiles` hovers or drops files for scripts; dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests/import.rs` | | | LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/dialogs/import.rs`) | @@ -964,3 +964,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-27 | patch | MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Select Only Active Photo and Paste Settings from Previous were not in the Edit menu's layout and ended up after Settings…, near the end of the menu; they now sit with the other Select and Paste rows. On macOS the Edit menu no longer shows two separators in a row where Settings… moved to the app menu; status unchanged (✅) | | 2026-10-27 | patch | MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say that Close Window and the red close button of the main window quit the app, after the check Quit makes; kept on purpose; a user page says so; status unchanged (✅) | | 2026-10-24 | patch | LRC-LIB-LIBFOLDERS: Don't ask again in the Move Photos question is kept only when the move started, so a move that is refused no longer switches the question off; status unchanged (✅) | +| 2026-10-24 | patch | LR-IMP-ADD-DIALOG: an import checks that each file a scan already probed is as it was up to 16 files at a time (an import batch's files, at most) instead of one after another, and looks up a batch's sidecars the same way (`import::sidecars_of`); on a network share a folder of thousands of photos spent most of its import time waiting for those checks; status unchanged (✅) | diff --git a/docs/whats-new.md b/docs/whats-new.md index b1fe25141..04bf78035 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -16,6 +16,14 @@ ### A refused move no longer switches the Move Photos question off - Ticking Don't ask again in the question that appears when you drop photos on a Folders row used to be remembered even when the move was then refused (for example because a file of the same name is already in that folder). The choice is now kept only once the move has started, so a refused move leaves the question on. +### Importing a folder from a network share waits less +- After the import review, the import used to check that each photo was still as the review had seen it by asking the + disk about one photo after another, so on a network share every photo waited for a round trip to the server. It now + asks about up to 16 photos at a time, the same wait for every 16 instead of for each one. Reading the photos in the review + is unchanged. +- Looking for each photo's `.xmp` sidecar, which asks the disk several names for every photo, also goes up to 16 + photos at a time. + ### Relative-date filters and smart albums follow the clock - A current view or source count using “in the last” rules now refreshes when the session clock moves into a new minute, even when no catalog edit occurred. This fixes stale photos remaining in the grid and stale counts for