mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (✅) |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user