Merge PR #2954: LR-IMP-ADD-DIALOG: an import checks its probes and looks up sidecars several at a time

docs/parity-checklist.md: header keeps main's date plus the PR's item; the LR-IMP-ADD-DIALOG row keeps main's text and gets the PR's sentence; revision rows union.
docs/whats-new.md: both entries kept.
This commit is contained in:
aiongg
2026-10-31 02:08:40 +00:00
5 changed files with 292 additions and 31 deletions
+249
View File
@@ -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<T: Sync, R: Send>(items: &[T], cancel: &std::sync::atomic::AtomicBool, ask: impl Fn(&T) -> R + Sync) -> Vec<Option<R>> {
use std::sync::atomic::Ordering::Relaxed;
let mut answers: Vec<Option<R>> = 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<Vec<(usize, R)>> = 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<Seen> {
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<PathBuf> + Sync,
) -> Vec<Option<PathBuf>> {
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<String, ProbeInfo>,
cancel: &std::sync::atomic::AtomicBool,
ok: impl Fn(Seen, &ProbeInfo) -> bool,
) -> Vec<Option<ProbeInfo>> {
let held: Vec<Option<ProbeInfo>> = 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<String> = (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<Seen> = (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<String> = (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::<String>::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<String> = (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<Option<PathBuf>> = (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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="4"/></rdf:RDF></x:xmpmeta>"#;
let files: Vec<String> = ["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);
}
}
+23 -14
View File
@@ -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<crate::media::FileProbe>,
file_bytes: Option<crate::merge::ByteReader>,
/// Probes from a preceding scan (not read again).
cache: HashMap<String, ProbeInfo>,
pub(super) cache: HashMap<String, ProbeInfo>,
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<Option<ProbeInfo>> = 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<String> = 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),
+9 -15
View File
@@ -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<String>, progress: &Sc
let by_path = library_files(&input.library, &files);
let todo: Vec<String> = 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<Option<ProbeInfo>> = 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<String> = 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();
+3 -2
View File
@@ -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 (✅) |
+8
View File
@@ -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