# Conflicts:
#	crates/catalog/src/lib.rs
#	crates/catalog/src/query.rs
#	crates/engine/src/cmd/library.rs
#	crates/ui-egui/src/tests_keywording.rs
#	docs/gaps.md
This commit is contained in:
Brandon Thomas committed 2026-10-14 23:46:16 -04:00
commit e6b2e6eefb
15 files changed
+607 -43

No files matched your search

@@ -0,0 +1,8 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc b91df6f4f23ec90cae3c0fb934b04b806181b61570f00ba25826e2232b7e1932 # shrinks to steps = [(3, 0, 0, false)]
cc b11415bdf5e4133b65e00645d4051129c71bf927747d6384aa50107440e92e07 # shrinks to steps = [(0, 10, 166, false), (7, 1, 0, false), (1, 14, 0, false)]
+101
View File
@@ -0,0 +1,101 @@
//! Lookups derived from the catalog's own data, so questions asked once per photo of a big library
//! don't scan a list each time: which stack a photo is in, whether a regular album holds it.
//!
//! They are never saved (the on-disk format is unchanged). Each is built on first use from what
//! the catalog holds, so a catalog read from a snapshot, replayed from the journal or cloned starts
//! without them and builds them when asked. Once built, [`crate::Catalog::apply`] keeps them up to
//! date: the ops that change stacks or album photos (`AddStack`, `RemoveStack`, `SetStack`,
//! `AddAlbum`, `RemoveAlbum`, `SetAlbumPhotos`) update them as they apply, inverses and the
//! rollback of a failed batch included, as those are ops too.
use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
use crate::{AlbumId, Catalog, PhotoId, StackId};
#[derive(Default)]
pub(crate) struct Indexes {
/// Photo → the stack it is in; `None` when the catalog was read with a photo in two stacks
/// (only a hand-edited file can hold that: ops refuse it), where a scan says which one counts.
stacks: OnceLock<Option<HashMap<PhotoId, StackId>>>,
/// Album → its photos (regular albums; a smart album's list is empty).
albums: OnceLock<HashMap<AlbumId, HashSet<PhotoId>>>,
}
/// A copy builds its own when asked (copies are made to be saved, not queried).
impl Clone for Indexes {
fn clone(&self) -> Self {
Indexes::default()
}
}
/// Derived from the rest of the catalog: two catalogs with the same data are equal whatever was
/// built.
impl PartialEq for Indexes {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl std::fmt::Debug for Indexes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let stacks = self.stacks.get().map(|m| m.as_ref().map(HashMap::len));
f.debug_struct("Indexes").field("stacks", &stacks).field("albums", &self.albums.get().map(HashMap::len)).finish()
}
}
impl Indexes {
/// Stack `id` held `old` and holds `new` now (empty: it was added, or is gone).
pub(crate) fn stack_changed(&mut self, id: StackId, old: &[PhotoId], new: &[PhotoId]) {
let Some(Some(m)) = self.stacks.get_mut() else { return };
for p in old {
if m.get(p) == Some(&id) {
m.remove(p);
}
}
for p in new {
m.insert(*p, id);
}
}
/// Album `id` holds `photos` now (`None`: it is gone).
pub(crate) fn album_changed(&mut self, id: AlbumId, photos: Option<&[PhotoId]>) {
let Some(m) = self.albums.get_mut() else { return };
match photos {
Some(photos) => {
m.insert(id, photos.iter().copied().collect());
}
None => {
m.remove(&id);
}
}
}
}
impl Catalog {
/// The stack photo `photo` is in, by the index (built on first use).
pub(crate) fn stack_id_of(&self, photo: PhotoId) -> Option<StackId> {
let m = self.index.stacks.get_or_init(|| {
let mut m = HashMap::new();
for s in self.stacks.values() {
for p in &s.photos {
if m.insert(*p, s.id).is_some_and(|other| other != s.id) {
return None;
}
}
}
Some(m)
});
match m {
Some(m) => m.get(&photo).copied(),
// in id order, the first stack listing the photo has it
None => self.stacks.values().find(|s| s.photos.contains(&photo)).map(|s| s.id),
}
}
/// Whether album `album`'s own list holds `photo`, by the index (built on first use).
pub(crate) fn album_lists(&self, album: AlbumId, photo: PhotoId) -> bool {
let m = self.index.albums.get_or_init(|| self.albums.values().map(|a| (a.id, a.photos.iter().copied().collect())).collect());
m.get(&album).is_some_and(|s| s.contains(&photo))
}
}
+40 -8
View File
@@ -15,6 +15,7 @@
pub mod dates;
pub mod folders;
mod index;
pub mod journal;
pub mod keywords;
pub mod local;
@@ -289,6 +290,9 @@ pub struct Catalog {
/// Which photos the latest ops touched ([`Catalog::touched_since`]).
#[serde(skip)]
changes: Changes,
/// Lookups derived from the above (never saved), see [`index`].
#[serde(skip)]
index: index::Indexes,
}
/// What of a photo an op changed: enough to tell which tallies of the library an edit leaves as they were (a
@@ -435,13 +439,18 @@ impl Catalog {
/// no smart album).
pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool {
match self.albums.get(&id) {
// guarded: a smart album testing smart albums can't loop or recurse without end
Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)),
Some(a) => a.photos.contains(&p.id),
Some(Album { smart: Some(rules), .. }) => self.smart_album_has(id, rules, &rules.prepare_one(), p),
Some(a) => self.album_lists(a.id, p.id),
None => false,
}
}
/// Whether smart album `id` with `rules` (as [`Filter::prepare`]d) holds `p`.
fn smart_album_has(&self, id: AlbumId, rules: &Filter, prepared: &query::Prepared, p: &Photo) -> bool {
// guarded: a smart album testing smart albums can't loop or recurse without end
!p.deleted && rules::smart_album_holds(id, p.id, || rules.matches_in(p, self, prepared))
}
/// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the
/// rules of `from` would then change when those of `to` do). Loops in saved rules end the
/// search, they don't repeat it.
@@ -489,7 +498,10 @@ impl Catalog {
/// The photos of an album: the stored list, or a smart album's current matches (id order).
pub fn album_photos(&self, id: AlbumId) -> Vec<PhotoId> {
match self.albums.get(&id) {
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).map(|p| p.id).collect(),
Some(Album { smart: Some(rules), .. }) => {
let prepared = rules.prepare();
self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).map(|p| p.id).collect()
}
Some(a) => a.photos.clone(),
None => Vec::new(),
}
@@ -499,7 +511,10 @@ impl Catalog {
pub fn album_count(&self, id: AlbumId) -> usize {
match self.albums.get(&id) {
// counted in place: no id list is built just for its length
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(),
Some(Album { smart: Some(rules), .. }) => {
let prepared = rules.prepare();
self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).count()
}
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
None => 0,
}
@@ -750,6 +765,7 @@ impl Catalog {
}
let id = album.id;
self.next_album = self.next_album.max(id.0.saturating_add(1));
self.index.album_changed(id, Some(&album.photos));
self.albums.insert(id, album);
Op::RemoveAlbum { id }
}
@@ -758,6 +774,7 @@ impl Catalog {
return Err(CatalogError::Invalid("folder is not empty".into()));
}
let a = self.albums.remove(&id).ok_or(CatalogError::NoAlbum(id))?;
self.index.album_changed(id, None);
Op::AddAlbum { album: a }
}
Op::RenameAlbum { id, name } => {
@@ -797,13 +814,16 @@ impl Catalog {
Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) }
}
Op::SetAlbumPhotos { id, photos } => {
let a = self.album_mut(id)?;
// the field, not `album_mut`: the index is updated while the album is borrowed
let a = self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))?;
if (a.folder || a.smart.is_some()) && !photos.is_empty() {
return Err(CatalogError::Invalid(
if a.folder { "folders can't hold photos" } else { "smart albums update automatically" }.into(),
));
}
Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) }
let old = std::mem::replace(&mut a.photos, photos);
self.index.album_changed(id, Some(&a.photos));
Op::SetAlbumPhotos { id, photos: old }
}
Op::SetAlbumCover { id, cover } => {
let a = self.album_mut(id)?;
@@ -824,11 +844,13 @@ impl Catalog {
self.validate_stack(stack.id, &stack.photos)?;
let id = stack.id;
self.next_stack = self.next_stack.max(id.0.saturating_add(1));
self.index.stack_changed(id, &[], &stack.photos);
self.stacks.insert(id, stack);
Op::RemoveStack { id }
}
Op::RemoveStack { id } => {
let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?;
self.index.stack_changed(id, &s.photos, &[]);
Op::AddStack { stack: s }
}
Op::SetStack { id, photos, collapsed } => {
@@ -837,7 +859,9 @@ impl Catalog {
}
self.validate_stack(id, &photos)?;
let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?;
let old = Op::SetStack { id, photos: std::mem::replace(&mut s.photos, photos), collapsed: s.collapsed };
let old_photos = std::mem::replace(&mut s.photos, photos);
self.index.stack_changed(id, &old_photos, &s.photos);
let old = Op::SetStack { id, photos: old_photos, collapsed: s.collapsed };
s.collapsed = collapsed;
old
}
@@ -1075,6 +1099,10 @@ impl Catalog {
self.next_album = self.next_album.max(album);
self.next_stack = self.next_stack.max(stack);
}
if !changed.is_empty() {
// stacks and albums changed outside `apply`: the lookups are built again when asked
self.index = index::Indexes::default();
}
changed
}
@@ -1124,6 +1152,8 @@ mod tests_folders;
#[cfg(test)]
mod tests_format_version;
#[cfg(test)]
mod tests_index;
#[cfg(test)]
mod tests_journal;
#[cfg(test)]
mod tests_local;
@@ -1132,4 +1162,6 @@ mod tests_lock;
#[cfg(test)]
mod tests_repair;
#[cfg(test)]
mod tests_scale;
#[cfg(test)]
mod tests_torn_append;
+34 -16
View File
@@ -135,6 +135,12 @@ fn shuffle_rank(seed: u64, id: PhotoId) -> u64 {
mix64(mix64(seed) ^ id.0)
}
/// What [`Filter::prepare`] works out once for a pass over many photos.
pub(crate) struct Prepared {
root: Option<String>,
only: Option<std::collections::HashSet<PhotoId>>,
}
fn token_matches(p: &Photo, tok: &str) -> bool {
let t = tok.to_lowercase();
if let Some((field, val)) = t.split_once(':') {
@@ -257,7 +263,18 @@ impl Filter {
}
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
self.matches_in(p, cat, self.library_root().as_deref())
self.matches_in(p, cat, &self.prepare_one())
}
/// [`Filter::prepare`] for testing one photo (the `only` ids are looked through, not hashed).
pub(crate) fn prepare_one(&self) -> Prepared {
Prepared { root: self.library_root(), only: None }
}
/// What [`Filter::matches_in`] needs worked out once for a pass over many photos rather than
/// once per photo: the library folder's identity and the `only` ids as a set.
pub(crate) fn prepare(&self) -> Prepared {
Prepared { root: self.library_root(), only: (!self.only.is_empty()).then(|| self.only.iter().copied().collect()) }
}
/// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice.
@@ -267,9 +284,9 @@ impl Filter {
self.library_folder.as_deref().filter(|d| !d.trim().is_empty()).map(folder_key)
}
/// [`Filter::matches`] with the library folder already turned into its identity, so a query
/// over a big library does that once, not once per photo.
fn matches_in(&self, p: &Photo, cat: &Catalog, root: Option<&str>) -> bool {
/// [`Filter::matches`] with what [`Filter::prepare`] works out done already, so a query over a
/// big library does that once, not once per photo.
pub(crate) fn matches_in(&self, p: &Photo, cat: &Catalog, prepared: &Prepared) -> bool {
if p.deleted != self.deleted {
return false;
}
@@ -308,7 +325,7 @@ impl Filter {
if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) {
return false;
}
if !self.only.is_empty() && !self.only.contains(&p.id) {
if !self.only.is_empty() && !prepared.only.as_ref().map_or_else(|| self.only.contains(&p.id), |only| only.contains(&p.id)) {
return false;
}
if let Some(want) = &self.merged {
@@ -348,7 +365,7 @@ impl Filter {
{
return false;
}
if let Some(root) = root
if let Some(root) = prepared.root.as_deref()
&& !photo_in_root(p, root)
{
return false;
@@ -398,12 +415,13 @@ impl Catalog {
/// album's rules, the filter bar), as long as they are still where the view looks (`within`: in the library,
/// the album, the folder; not deleted).
pub fn query_keeping(&self, filter: &Filter, sort: &Sort, keep: &std::collections::HashSet<PhotoId>, within: &Filter) -> Vec<PhotoId> {
let root = filter.library_root();
let within_root = within.library_root();
let prepared = filter.prepare();
// `within` only decides for the few kept photos: worked out per photo, not hashed
let within_prepared = within.prepare_one();
let mut v: Vec<&Photo> = self
.photos()
.map(|p| p.as_ref())
.filter(|p| filter.matches_in(p, self, root.as_deref()) || (keep.contains(&p.id) && within.matches_in(p, self, within_root.as_deref())))
.filter(|p| filter.matches_in(p, self, &prepared) || (keep.contains(&p.id) && within.matches_in(p, self, &within_prepared)))
.collect();
v.sort_by(|a, b| sort.order(a, b));
v.into_iter().map(|p| p.id).collect()
@@ -417,11 +435,11 @@ impl Catalog {
pub fn follow(&self, was: &[PhotoId], touched: &[PhotoId], filter: &Filter, sort: &Sort, within: Option<&Filter>) -> Vec<PhotoId> {
let listed: std::collections::HashSet<PhotoId> = was.iter().copied().filter(|id| touched.binary_search(id).is_ok()).collect();
let mut ids: Vec<PhotoId> = was.iter().copied().filter(|id| !listed.contains(id)).collect();
let (root, within_root) = (filter.library_root(), within.and_then(Filter::library_root));
let (prepared, within_prepared) = (filter.prepare_one(), within.map(Filter::prepare_one));
for id in touched {
let Some(p) = self.photo(*id) else { continue };
let kept = within.is_some_and(|w| listed.contains(id) && w.matches_in(p, self, within_root.as_deref()));
if filter.matches_in(p, self, root.as_deref()) || kept {
let kept = within.zip(within_prepared.as_ref()).is_some_and(|(w, wp)| listed.contains(id) && w.matches_in(p, self, wp));
if filter.matches_in(p, self, &prepared) || kept {
// (the list is in order without the touched photos, and stays so as each is put back)
let at = ids.partition_point(|other| self.photo(*other).is_some_and(|o| sort.order(o, p).is_lt()));
ids.insert(at, *id);
@@ -432,8 +450,8 @@ impl Catalog {
/// How many photos match `filter`: [`Catalog::query`]'s length without its sorting.
pub fn count(&self, filter: &Filter) -> usize {
let root = filter.library_root();
self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())).count()
let prepared = filter.prepare();
self.photos().filter(|p| filter.matches_in(p, self, &prepared)).count()
}
/// Year → month → day counts for the "By Date" section (newest first).
@@ -489,8 +507,8 @@ impl Catalog {
pub fn people_in(&self, filter: &Filter) -> Vec<Person> {
let filter = Filter { person: None, ..filter.clone() };
let mut m: std::collections::HashMap<String, (Person, f64)> = Default::default();
let root = filter.library_root();
for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) {
let prepared = filter.prepare();
for p in self.photos().filter(|p| filter.matches_in(p, self, &prepared)) {
let mut seen: Vec<String> = Vec::new();
for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
+33 -4
View File
@@ -598,6 +598,35 @@ pub fn now() -> String {
}
}
/// A flag as rules name it (its variant, lower case), without building a string per photo.
fn flag_name(f: crate::Flag) -> &'static str {
match f {
crate::Flag::None => "none",
crate::Flag::Pick => "pick",
crate::Flag::Reject => "reject",
}
}
/// A colour label's colour as rules name it (lower case).
fn label_colour(l: crate::ColorLabel) -> &'static str {
match l {
crate::ColorLabel::Red => "red",
crate::ColorLabel::Yellow => "yellow",
crate::ColorLabel::Green => "green",
crate::ColorLabel::Blue => "blue",
crate::ColorLabel::Purple => "purple",
}
}
/// A media kind as rules name it (lower case).
fn kind_name(k: crate::MediaKind) -> &'static str {
match k {
crate::MediaKind::Image => "image",
crate::MediaKind::Raw => "raw",
crate::MediaKind::Video => "video",
}
}
fn lower(v: &Value) -> String {
match v {
Value::String(s) => s.trim().to_lowercase(),
@@ -778,16 +807,16 @@ impl Rule {
match field {
"rating" => num_op(op, Some(p.rating as f64), value),
"flag" => {
let f = format!("{:?}", p.flag).to_lowercase();
let f = flag_name(p.flag);
(f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is")
}
"label" => {
let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into());
let l = p.label.map_or("none", label_colour);
// custom label names count too
let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want);
let named = p.label.is_some_and(|l| cat.custom_label_name(l).map_or(label_colour(l) == want, |n| n.to_lowercase() == want));
(l == want || named) == (op == "is")
}
"kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"),
"kind" => (kind_name(p.kind) == want) == (op == "is"),
"edited" => yes(p.is_edited()),
"hasGps" => yes(m.gps.is_some()),
"virtualCopy" => yes(p.copy_of.is_some()),
+2 -2
View File
@@ -21,7 +21,7 @@ impl Catalog {
}
/// The stack a photo belongs to.
pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> {
self.stacks.values().find(|s| s.photos.contains(&photo))
self.stack_id_of(photo).and_then(|id| self.stacks.get(&id))
}
/// Photo → (stack, position in the stack), for every stacked photo.
pub fn stack_index(&self) -> HashMap<PhotoId, (StackId, usize)> {
@@ -41,7 +41,7 @@ impl Catalog {
return Err(CatalogError::NoPhoto(*p));
}
}
if self.stacks.values().any(|s| s.id != id && s.photos.iter().any(|p| seen.contains(p))) {
if photos.iter().any(|p| self.stack_id_of(*p).is_some_and(|s| s != id)) {
return Err(CatalogError::Invalid("a photo can be in only one stack".into()));
}
Ok(())
+211
View File
@@ -0,0 +1,211 @@
//! The derived indexes ([`crate::index`]) answer as a scan of the catalog would, through every op,
//! inverse, failed batch, snapshot, replay and clone; and the faster query paths give the same
//! results as before.
use proptest::prelude::*;
use super::*;
const PHOTOS: u64 = 8;
fn library() -> Catalog {
let mut c = Catalog::new();
for i in 0..PHOTOS {
let p = Photo::new(PhotoId(i + 1), Source::Demo { scene: 1 }, &format!("p{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
c
}
/// What the indexes must say, worked out by scanning (as the catalog did before it had them).
fn scanned(c: &Catalog) -> (Vec<Option<StackId>>, Vec<Vec<bool>>) {
let photos: Vec<PhotoId> = (1..=PHOTOS + 1).map(PhotoId).collect();
let stacks = photos.iter().map(|p| c.stacks().find(|s| s.photos.contains(p)).map(|s| s.id)).collect();
let albums = (1..=4)
.map(|a| {
photos
.iter()
.map(|p| c.photo(*p).is_some() && c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none() && al.photos.contains(p)))
.collect()
})
.collect();
(stacks, albums)
}
/// What the indexes say.
fn indexed(c: &Catalog) -> (Vec<Option<StackId>>, Vec<Vec<bool>>) {
let photos: Vec<PhotoId> = (1..=PHOTOS + 1).map(PhotoId).collect();
let stacks = photos.iter().map(|p| c.stack_of(*p).map(|s| s.id)).collect();
let albums = (1..=4)
.map(|a| {
photos
.iter()
.map(|p| c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none()) && c.photo(*p).is_some_and(|ph| c.album_contains(AlbumId(a), ph)))
.collect()
})
.collect();
(stacks, albums)
}
/// Photo ids from bits (the extra id names a photo that isn't there: such ops fail).
fn ids(bits: u16) -> Vec<PhotoId> {
(0..=PHOTOS).filter(|i| bits & (1 << i) != 0).map(|i| PhotoId(i + 1)).collect()
}
fn op(c: &Catalog, kind: u8, n: u64, bits: u16, collapsed: bool) -> Op {
match kind % 8 {
0 => Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed } },
1 => Op::RemoveStack { id: StackId(n % 4 + 1) },
2 => Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed },
3 => {
let mut a = Album::new(AlbumId(n % 4 + 1), "A");
a.photos = ids(bits);
Op::AddAlbum { album: a }
}
4 => Op::RemoveAlbum { id: AlbumId(n % 4 + 1) },
5 => Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) },
// a batch whose last op fails half the time: what it did is rolled back
6 => Op::Batch {
ops: vec![
Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) },
Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits.rotate_left(3)), collapsed },
Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(!bits), collapsed } },
],
},
// as the engine deletes a photo: out of its albums and stacks too, in one batch
_ => c.delete_permanently_ops(PhotoId(n % (PHOTOS + 1) + 1)),
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn indexes_agree_with_a_scan(steps in proptest::collection::vec((0u8..8, 0u64..16, 0u16..512, any::<bool>()), 1..40)) {
let mut c = library();
let start = c.to_snapshot();
// built before the ops, so every op has to keep them up to date
prop_assert_eq!(indexed(&c), scanned(&c));
let mut log = String::new();
let mut inverses = Vec::new();
for (kind, n, bits, collapsed) in steps {
let op = op(&c, kind, n, bits, collapsed);
if let Ok(inv) = c.apply(op.clone()) {
log.push_str(&Catalog::op_to_log_line(&op));
inverses.push(inv);
}
prop_assert_eq!(indexed(&c), scanned(&c));
}
// a snapshot, a replayed journal and a clone build their own
let snap = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
prop_assert_eq!(indexed(&snap), scanned(&c));
let mut replayed = Catalog::from_snapshot(&start).unwrap();
prop_assert_eq!(indexed(&replayed), scanned(&replayed));
replayed.replay(&log).unwrap();
prop_assert_eq!(indexed(&replayed), scanned(&c));
prop_assert_eq!(indexed(&c.clone()), scanned(&c));
prop_assert!(c.clone() == c, "the indexes don't take part in equality");
// undone step by step
for inv in inverses.into_iter().rev() {
c.apply(inv).unwrap();
prop_assert_eq!(indexed(&c), scanned(&c));
}
prop_assert!(c.stacks().next().is_none() && c.albums().next().is_none(), "all undone");
}
}
#[test]
fn a_photo_in_another_stack_is_refused_by_the_index() {
let mut c = library();
c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap();
let e = c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false } }).unwrap_err();
assert_eq!(e, CatalogError::Invalid("a photo can be in only one stack".into()));
// its own stack may keep it
c.apply(Op::SetStack { id: StackId(1), photos: vec![PhotoId(2), PhotoId(1), PhotoId(3)], collapsed: true }).unwrap();
assert_eq!(c.stack_of(PhotoId(3)).map(|s| s.id), Some(StackId(1)));
}
/// The sort keys are worked out once per photo now; the order is the one the comparisons gave.
#[test]
fn sorted_queries_keep_their_order() {
let mut c = Catalog::new();
let names = ["b.jpg", "A.jpg", "a.jpg", "Été.jpg", "été.jpg", "C.JPG", "b.jpg"];
for (i, n) in names.iter().enumerate() {
let mut p = Photo::new(PhotoId(i as u64 + 1), Source::Demo { scene: 1 }, n, "JPEG", 60, 40, &format!("2026-01-0{}T00:00:00", 7 - i % 3));
p.captured = (i % 2 == 0).then(|| format!("2025-0{}-01T00:00:00", 1 + i % 4));
p.rating = (i % 3) as u8;
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
let all = Filter::default();
for key in [SortKey::CaptureDate, SortKey::ImportDate, SortKey::EditDate, SortKey::FileName, SortKey::Rating, SortKey::FileSize, SortKey::Random]
{
for ascending in [true, false] {
let sort = Sort { key, ascending, seed: 7, ..Default::default() };
// the comparison the query sorts by
let mut want: Vec<&Photo> = c.photos().map(|p| p.as_ref()).collect();
want.sort_by(|a, b| sort.order(a, b));
let want: Vec<PhotoId> = want.iter().map(|p| p.id).collect();
assert_eq!(c.query(&all, &sort), want, "{key:?} ascending {ascending}");
}
}
// `only` as a set gives the photos it names
let only = Filter { only: vec![PhotoId(3), PhotoId(1), PhotoId(3)], ..Default::default() };
assert_eq!(c.query(&only, &Sort { key: SortKey::FileName, ascending: true, ..Default::default() }), vec![PhotoId(3), PhotoId(1)]);
}
/// Rules name flags, labels and kinds as their variants in lower case (what `{:?}` gave).
#[test]
fn rule_names_are_the_variants_in_lower_case() {
let mut c = Catalog::new();
let mut n = 0;
for flag in [Flag::None, Flag::Pick, Flag::Reject] {
for label in
[None, Some(ColorLabel::Red), Some(ColorLabel::Yellow), Some(ColorLabel::Green), Some(ColorLabel::Blue), Some(ColorLabel::Purple)]
{
for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] {
n += 1;
let mut p = Photo::new(PhotoId(n), Source::Demo { scene: 1 }, "x.jpg", "JPEG", 60, 40, "2026-01-01T00:00:00");
(p.flag, p.label, p.kind) = (flag, label, kind);
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
}
}
c.apply(Op::SetLabelName { label: ColorLabel::Blue, name: Some("Sky".into()) }).unwrap();
let count = |field: &str, value: String| {
let rules: RuleSet =
serde_json::from_value(serde_json::json!({"match": "all", "rules": [{"field": field, "op": "is", "value": value}]})).unwrap();
c.query(&Filter { rule_set: Some(rules), ..Default::default() }, &Sort::default()).len()
};
for flag in [Flag::None, Flag::Pick, Flag::Reject] {
assert_eq!(count("flag", format!("{flag:?}").to_lowercase()), 18, "{flag:?}");
}
assert_eq!(count("flag", "picked".into()), 18);
for label in ColorLabel::ALL {
assert_eq!(count("label", format!("{label:?}").to_uppercase()), 9, "{label:?}");
}
assert_eq!(count("label", "sky".into()), 9, "a custom name");
assert_eq!(count("label", "none".into()), 9);
for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] {
assert_eq!(count("kind", format!("{kind:?}")), 18, "{kind:?}");
}
}
/// A hand-edited file can put a photo in two stacks (ops refuse it): loading repairs it (the photo
/// stays in the first) and the stack index gives the answers a scan of the stacks gives, before
/// and after either stack goes.
#[test]
fn a_photo_in_two_stacks_read_from_a_file_answers_as_a_scan() {
let mut c = library();
c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap();
c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(3), PhotoId(4)], collapsed: false } }).unwrap();
let mut v: serde_json::Value = serde_json::from_str(&c.to_snapshot()).unwrap();
v["stacks"]["2"]["photos"] = serde_json::json!([2, 3, 4]);
let mut c = Catalog::from_snapshot(&v.to_string()).unwrap();
assert_eq!(indexed(&c), scanned(&c));
assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), Some(StackId(1)));
// the second stack can't be changed to keep it, as before
assert!(c.apply(Op::SetStack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false }).is_err());
c.apply(Op::RemoveStack { id: StackId(1) }).unwrap();
assert_eq!(indexed(&c), scanned(&c));
assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), None, "the repair took it out of the second stack");
}
+92
View File
@@ -0,0 +1,92 @@
//! What library queries cost at 100k photos (ignored:
//! `cargo test --release -p lightcraft-catalog --lib scale_100k -- --ignored --nocapture`).
use std::time::Instant;
use serde_json::json;
use super::*;
/// 100k photos, 10k two-photo stacks, a 20k-photo album, keywords and flags.
fn library(n: u64) -> (Catalog, AlbumId) {
let mut c = Catalog::new();
let mut ops: Vec<Op> = (0..n)
.map(|i| {
let mut p = Photo::new(
PhotoId(i + 1),
Source::Demo { scene: (i % 20) as u32 },
&format!("IMG_{:06}.jpg", mix64(i) % 1_000_000),
"JPEG",
6000,
4000,
"2026-01-01T00:00:00",
);
p.captured = Some(format!("20{:02}-{:02}-{:02}T{:02}:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28, i % 24));
p.rating = (i % 6) as u8;
p.flag = if i % 7 == 0 { Flag::Pick } else { Flag::None };
p.meta.keywords = vec![format!("kw{}", i % 500), "travel|italy".into()];
Op::AddPhoto { photo: Box::new(p) }
})
.collect();
ops.extend(
(0..n / 10)
.map(|s| Op::AddStack { stack: Stack { id: StackId(s + 1), photos: vec![PhotoId(s * 10 + 1), PhotoId(s * 10 + 2)], collapsed: false } }),
);
let album = AlbumId(1);
let mut a = Album::new(album, "Big");
a.photos = (0..n).step_by(5).map(|i| PhotoId(i + 1)).collect();
ops.push(Op::AddAlbum { album: a });
let t = Instant::now();
c.apply(Op::Batch { ops }).unwrap();
eprintln!("build {n} photos, {} stacks: {:.0} ms", n / 10, t.elapsed().as_secs_f64() * 1e3);
(c, album)
}
fn smart(c: &mut Catalog, id: u64, rules: serde_json::Value) -> AlbumId {
let mut a = Album::new(AlbumId(id), format!("Smart {id}"));
a.smart = Some(Box::new(Filter { rule_set: Some(serde_json::from_value(rules).unwrap()), ..Default::default() }));
c.apply(Op::AddAlbum { album: a }).unwrap();
AlbumId(id)
}
/// Best of five runs (the machine may be busy with other work).
fn time<T>(what: &str, f: impl Fn() -> T) -> T {
let mut best = f64::MAX;
let mut r = None;
for _ in 0..5 {
let t = Instant::now();
r = Some(f());
best = best.min(t.elapsed().as_secs_f64() * 1e3);
}
eprintln!("{what}: {best:.1} ms");
r.unwrap()
}
#[test]
#[ignore]
fn scale_100k() {
let (mut c, album) = library(100_000);
let all = Filter::default();
time("query, capture date", || c.query(&all, &Sort::default()));
time("query, file name", || c.query(&all, &Sort { key: SortKey::FileName, ..Default::default() }));
time("query, text", || c.query(&Filter { text: "kw42".into(), ..Default::default() }, &Sort::default()));
time("query, album of 20k", || c.query(&Filter { album: Some(album), ..Default::default() }, &Sort::default()));
let only: Vec<PhotoId> = (0..5000).map(|i| PhotoId(i * 7 + 1)).collect();
time("query, only 5k ids", || c.query(&Filter { only: only.clone(), ..Default::default() }, &Sort::default()));
let stacked = smart(&mut c, 10, json!({"match": "all", "rules": [{"field": "stacked", "op": "is", "value": true}]}));
let flagged = smart(
&mut c,
11,
json!({"match": "all", "rules": [{"field": "flag", "op": "is", "value": "pick"}, {"field": "kind", "op": "is", "value": "image"}]}),
);
let keyword = smart(&mut c, 12, json!({"match": "all", "rules": [{"field": "keywords", "op": "contains", "value": "kw42"}]}));
let in_album = smart(&mut c, 13, json!({"match": "all", "rules": [{"field": "album", "op": "is", "value": album.0}]}));
let recent = smart(&mut c, 14, json!({"match": "all", "rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "years"}}]}));
for (what, a) in [("stacked", stacked), ("flag + kind", flagged), ("keyword", keyword), ("in album", in_album), ("in the last 2 years", recent)] {
let n = time(&format!("smart album count, {what}"), || c.album_count(a));
eprintln!(" = {n}");
}
time("albums_of", || c.albums_of(PhotoId(6)));
time("arrange stacks", || c.arrange_stacks(&c.query(&all, &Sort::default())));
time("stack_of × 1000", || (0..1000u64).filter(|i| c.stack_of(PhotoId(i * 97 + 1)).is_some()).count());
}
+9 -3
View File
@@ -1276,8 +1276,10 @@ pub fn specs() -> Vec<CommandSpec> {
let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?;
let mut photos = al.photos.clone();
let before = photos.len();
// looked up, not searched for: adding thousands to a big album stays linear
let mut held: std::collections::HashSet<PhotoId> = photos.iter().copied().collect();
for t in targets {
if !photos.contains(&t) {
if held.insert(t) {
photos.push(t);
}
}
@@ -1308,11 +1310,14 @@ pub fn specs() -> Vec<CommandSpec> {
}
};
let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?;
let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t));
// looked up, not searched for: thousands toggled in a big album stay linear
let mut held: std::collections::HashSet<PhotoId> = al.photos.iter().copied().collect();
let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t));
let photos: Vec<PhotoId> = if all_in {
let targets: std::collections::HashSet<PhotoId> = targets.iter().copied().collect();
al.photos.iter().copied().filter(|x| !targets.contains(x)).collect()
} else {
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect()
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect()
};
let name = al.name.clone();
let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied());
@@ -1463,6 +1468,7 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad("album.removePhotos", "album folders don't hold photos"));
}
let name = al.name.clone();
let targets: std::collections::HashSet<PhotoId> = targets.into_iter().collect();
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
let removed = al.photos.len().saturating_sub(photos.len());
if removed > 0 {
+6 -2
View File
@@ -1027,8 +1027,12 @@ impl Session {
&& !al.is_smart()
&& self.filter == Filter::default()
{
let order = al.photos.clone();
visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX));
// each photo's place looked up, not searched for (a big album sorted in O(n log n))
let mut place: std::collections::HashMap<PhotoId, usize> = std::collections::HashMap::with_capacity(al.photos.len());
for (i, id) in al.photos.iter().enumerate() {
place.entry(*id).or_insert(i);
}
visible.sort_by_key(|id| place.get(id).copied().unwrap_or(usize::MAX));
if !self.sort.ascending {
visible.reverse();
}
+15
View File
@@ -440,6 +440,21 @@ fn scale_100k_library_queries() {
m("undo", &mut || {
s.execute("edit.undo", &serde_json::json!({})).unwrap();
});
// a 20k-photo album shown in its own order (capture-date sort, no filter)
let big = s.execute("album.create", &serde_json::json!({"name": "Big"})).unwrap()["id"].as_u64().unwrap();
let ids: Vec<u64> = (0..n).rev().step_by(5).map(|i| i + 1).collect();
m("add 20k to an album", &mut || {
s.execute("album.addPhotos", &serde_json::json!({"id": big, "ids": ids})).unwrap();
});
m("album of 20k (album order)", &mut || {
s.execute("library.source", &serde_json::json!({"kind": "album", "id": big})).unwrap();
let _ = s.visible_cloned();
});
s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap();
let half: Vec<u64> = ids.iter().copied().step_by(2).collect();
m("remove 10k from the album", &mut || {
s.execute("album.removePhotos", &serde_json::json!({"id": big, "ids": half})).unwrap();
});
worst.sort_by(|a, b| b.1.total_cmp(&a.1));
eprintln!("slowest: {:?}", &worst[..3]);
}
+18
View File
@@ -1632,6 +1632,9 @@ pub struct Caches {
/// What the open smart-album rule dialog shows besides the rules ([`Caches::rules_view`]).
rules_view: Option<(u64, std::sync::Arc<RulesView>)>,
pub rules_view_scans: usize,
/// A count an open dialog shows ([`Caches::dialog_count`]), by what it counts.
dialog_count: Option<(u64, usize)>,
pub dialog_count_scans: usize,
/// The problems of every saved smart album that has some ([`Caches::smart_album_problems`]).
smart_problems: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, Vec<lightcraft_catalog::rules::Problem>>>)>,
pub smart_problem_scans: usize,
@@ -1953,6 +1956,21 @@ impl Caches {
v
}
/// A count an open dialog shows (photos a filter matches, photos with a keyword…): `count` runs
/// again only when the catalog or `what` (what is counted) changes, not every frame.
pub(crate) fn dialog_count(&mut self, cat: &lightcraft_catalog::Catalog, what: impl std::hash::Hash, count: impl FnOnce() -> usize) -> usize {
let k = key_of((cat.revision, what));
if let Some((key, n)) = self.dialog_count
&& key == k
{
return n;
}
let n = count();
self.dialog_count_scans += 1;
self.dialog_count = Some((k, n));
n
}
/// The problems of every saved smart album that has some (`Catalog::smart_album_problems`),
/// for the sidebar's ⚠ marks: worked out again only when the catalog changes.
pub fn smart_album_problems(
+14 -6
View File
@@ -463,7 +463,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
confirm = true;
}
let rules = app.session.view_rules();
let n = app.session.catalog.query(&rules, &Default::default()).len();
// counted when the library or the rules change (or, for "in the last…" rules, the minute), not every frame
let now = (app.session.clock)();
let minute = if rules.depends_on_now() { now.get(..16).unwrap_or(&now) } else { "" };
let n = app.caches.dialog_count(&app.session.catalog, ("smartAlbum", format!("{rules:?}"), minute), || {
lightcraft_catalog::rules::set_now(Some(now.clone()));
app.session.catalog.query(&rules, &Default::default()).len()
});
ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label));
ui.label(
egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n))
@@ -637,7 +643,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
Dialog::RenameKeyword { from, to } => {
let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count();
let n = app.caches.dialog_count(&app.session.catalog, ("renameKeyword", from.as_str()), || {
app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count()
});
if dialog_field(ui, "field:keywordName", to, "New name") {
confirm = true;
}
@@ -653,11 +661,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
confirm = true;
}
let options: Vec<String> = app
.session
.catalog
.keyword_suggestions(from, into, 12)
.into_iter()
.caches
.suggestions(&app.session.catalog, from, into, 12)
.iter()
.filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f)))
.cloned()
.collect();
ui.horizontal_wrapped(|ui| {
for k in options {
+21
View File
@@ -792,3 +792,24 @@ fn offers_never_cover_the_box() {
}
}
}
/// Rename Keyword's "used by n photos" and Create Smart Album's live count scan the library once
/// per change to it, not every frame the dialog is open.
#[test]
fn open_dialogs_count_once_per_library_change() {
let (mut h, ids) = two_selected();
h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: "beach".into(), to: "beach".into() });
let before = h.app.caches.dialog_count_scans;
for _ in 0..10 {
h.step();
}
assert_eq!(h.app.caches.dialog_count_scans, before + 1, "unchanged frames reuse the count");
ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[1]], "keywords": ["sea"]}}));
h.step();
assert_eq!(h.app.caches.dialog_count_scans, before + 2, "a library change counts again");
h.app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: "Beach".into(), parent: None });
for _ in 0..10 {
h.step();
}
assert_eq!(h.app.caches.dialog_count_scans, before + 3, "another dialog counts its own photos, once");
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (catalog import result, offline Local promotion and reduced copies; gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (catalog import result, offline Local promotion and reduced copies; gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice; gap 26: stack and album lookups indexed at 100k photos)
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
@@ -40,7 +40,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) |
| 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) |
| 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) |
| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) |
| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) |
| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) |
| 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) |
| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) |
@@ -75,6 +75,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged |
| 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h |
| 2026-10-14 | minor | Gap 10: 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models) |
| 2026-10-14 | minor | Gap 29 added: the HDR merge, tested on real 67 MP brackets, keeps moving clipped highlights and merges in batches now; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain |