perf(engine): #1057 slider ticks don't redo the grid query

Session::visible() and source_total() key on a structured ViewKey compared
field by field (no per-frame Debug format of the filter) and on the view
revision, so a develop edit re-runs the query only when the filter, sort or
smart-album source reads develop state. At 100k photos a drag tick drops
from 68 ms (capture-date sort) / 27 ms (file name) to under 0.01 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chad Elliott 2026-10-11 17:16:00 -05:00
parent f80c3778bd
commit dfd6cbeea6
2 changed files with 228 additions and 13 deletions

View File

@ -175,15 +175,16 @@ pub struct Session {
pub filter: Filter,
pub sort: Sort,
pub selection: Selection,
/// The grid order for the current source/filter/sort (cached by catalog revision).
/// The grid order for the current source/filter/sort (cached by library revision, and by the
/// full catalog revision when the filter, sort or source reads develop state).
/// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]).
visible: Arc<[PhotoId]>,
visible_key: Option<(u64, String)>,
visible_key: Option<ViewKey>,
/// Identifies the current `visible` list: a new value (unique in the process) every time it
/// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids.
visible_gen: u64,
/// Photos in the current source with no filter on (cached like `visible`).
total: Option<((u64, String), usize)>,
total: Option<(ViewKey, usize)>,
pub undo: Vec<UndoEntry>,
/// Undo steps committed in this session, ever (the history drops its oldest past 1000, so
/// its length can't tell how many steps a command just made; see [`Session::commits`]).
@ -839,13 +840,14 @@ impl Session {
/// Photos shown in the grid/filmstrip for the current source, filter and sort.
pub fn visible(&mut self) -> &[PhotoId] {
let mut key =
(self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse, self.library_folder));
if self.source == LibrarySource::Missing && self.media.availability.is_background() {
// the view fills in as the background checks find files gone
key.1.push_str(&format!("|{}", self.media.availability.generation()));
}
if self.visible_key.as_ref() != Some(&key) {
// a develop edit (a slider tick) only redoes the query when the filter, sort or source reads develop state
let reads_develop = self.source_reads_develop() || self.sort.reads_develop() || self.catalog.filter_reads_develop(&self.filter);
let revision = self.catalog.view_revision(reads_develop);
// the Missing Photos view fills in as the background checks find files gone
let availability =
(self.source == LibrarySource::Missing && self.media.availability.is_background()).then(|| self.media.availability.generation());
if !self.visible_key.as_ref().is_some_and(|k| k.is(self, revision, true, availability)) {
let key = ViewKey::of(self, revision, true, availability);
// "in the last N days" rules count back from the session's clock
lightcraft_catalog::rules::set_now(Some((self.clock)()));
let mut f = self.source.to_filter(&self.filter, &self.catalog);
@ -916,8 +918,9 @@ impl Session {
if self.source == LibrarySource::Missing {
return None;
}
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder));
if self.total.as_ref().map(|t| &t.0) != Some(&key) {
let revision = self.catalog.view_revision(self.source_reads_develop());
if !self.total.as_ref().is_some_and(|t| t.0.is(self, revision, false, None)) {
let key = ViewKey::of(self, revision, false, None);
let mut f = self.source.to_filter(&Filter::default(), &self.catalog);
if self.source == LibrarySource::Folder {
let b = self.browse.clone().unwrap_or_default();
@ -933,12 +936,21 @@ impl Session {
self.total.as_ref().map(|t| t.1)
}
/// Whether what the current source holds can change with a develop edit (a smart album
/// whose rules read develop state; see [`lightcraft_catalog::revision`]).
fn source_reads_develop(&self) -> bool {
match self.source {
LibrarySource::Album(a) => self.catalog.album_reads_develop(a),
_ => false,
}
}
pub fn visible_cloned(&mut self) -> Vec<PhotoId> {
self.visible().to_vec()
}
/// The visible photos without copying them, with their generation: equal generations mean
/// the very same list (same catalog revision, source, filter, sort), so a frontend can cache
/// the very same list (same library, source, filter, sort), so a frontend can cache
/// whatever it derives from the list under that number.
pub fn visible_shared(&mut self) -> (u64, Arc<[PhotoId]>) {
self.visible();
@ -957,6 +969,45 @@ impl Session {
}
}
/// What [`Session::visible`] (or [`Session::source_total`]) was worked out from. Compared every
/// frame, so without formatting or copying anything.
#[derive(Clone, Debug, PartialEq)]
struct ViewKey {
revision: lightcraft_catalog::ViewRevision,
source: LibrarySource,
/// The filter bar and sort (`None` for the source total, which ignores them).
view: Option<(Filter, Sort)>,
browse: Option<Browse>,
library_folder: Option<String>,
availability: Option<u64>,
}
impl ViewKey {
fn of(s: &Session, revision: lightcraft_catalog::ViewRevision, filtered: bool, availability: Option<u64>) -> ViewKey {
ViewKey {
revision,
source: s.source,
view: filtered.then(|| (s.filter.clone(), s.sort)),
browse: s.browse.clone(),
library_folder: s.library_folder.clone(),
availability,
}
}
fn is(&self, s: &Session, revision: lightcraft_catalog::ViewRevision, filtered: bool, availability: Option<u64>) -> bool {
let view = match &self.view {
Some((f, sort)) => filtered && *f == s.filter && *sort == s.sort,
None => !filtered,
};
self.revision == revision
&& self.source == s.source
&& view
&& self.browse == s.browse
&& self.library_folder == s.library_folder
&& self.availability == availability
}
}
/// Automatic versions kept per photo.
pub const AUTO_VERSIONS: usize = 20;
@ -1003,6 +1054,8 @@ mod tests_import;
#[cfg(test)]
mod tests_import_move;
#[cfg(test)]
mod tests_invalidation;
#[cfg(test)]
mod tests_keyword_list;
#[cfg(test)]
mod tests_libops;

View File

@ -0,0 +1,162 @@
//! What a develop edit invalidates: a slider tick changes one photo's develop settings, so the
//! library-wide grid query only runs again when the current filter, sort or smart album reads
//! develop state (see `lightcraft_catalog::revision`).
use lightcraft_catalog::{Album, AlbumId, Filter, Op, Photo, PhotoId, RuleSet, Sort, SortKey, Source};
use serde_json::json;
use crate::{LibrarySource, Session};
/// A library of `n` photos with capture dates and a keyword each.
fn library(n: u64) -> Session {
let mut s = Session::new();
let ops = (0..n)
.map(|i| {
let mut p = Photo::new(
PhotoId(i + 1),
Source::Demo { scene: (i % 20) as u32 },
&format!("IMG_{i:06}.jpg"),
"JPEG",
6000,
4000,
"2026-01-01T00:00:00",
);
p.captured = Some(format!("20{:02}-{:02}-{:02}T10:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28));
p.meta.keywords = vec![format!("kw{}", i % 300)];
Op::AddPhoto { photo: Box::new(p) }
})
.collect();
s.commit("Add", Op::Batch { ops }).unwrap();
s
}
/// Change photo `id`'s exposure (a committed edit, or a slider tick inside an interaction).
fn expose(s: &mut Session, id: PhotoId, ev: f64) {
let mut d = (*s.develop_of(id).unwrap()).clone();
d.light.exposure = ev;
s.set_develop(id, d, "Exposure").unwrap();
}
fn smart(s: &mut Session, id: u64, rules: serde_json::Value) {
let mut a = Album::new(AlbumId(id), format!("Smart {id}"));
let rules: RuleSet = serde_json::from_value(rules).unwrap();
a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() }));
s.commit("New Smart Album", Op::AddAlbum { album: a }).unwrap();
}
/// Slider ticks and committed develop edits leave the grid's list as it is (same generation: the
/// query didn't run) when nothing in view reads develop state; a library change still redoes it.
#[test]
fn develop_edits_dont_redo_the_library_query() {
let mut s = library(50);
let (generation, _) = s.visible_shared();
let total = s.source_total();
let id = s.visible()[7];
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
for i in 0..10 {
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
assert_eq!(s.visible_shared().0, generation, "tick {i}");
}
s.execute("develop.endInteraction", &json!({})).unwrap();
expose(&mut s, id, 2.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.visible_shared().0, generation, "a committed edit and its undo");
assert_eq!(s.source_total(), total);
// a library change does
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
assert_ne!(s.visible_shared().0, generation);
}
/// Filters, sorts and smart albums that read develop state follow every develop edit, slider
/// ticks included.
#[test]
fn views_that_read_develop_state_follow_develop_edits() {
let mut s = library(20);
let ids = s.visible().to_vec();
let (a, b) = (ids[3], ids[11]);
s.execute("library.select", &json!({"ids": [a.0]})).unwrap();
// the filter bar's Edited, during a drag
s.filter = Filter { edited: Some(true), ..Default::default() };
assert!(s.visible().is_empty());
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap();
assert_eq!(s.visible(), [a], "a slider tick makes the photo edited");
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.0})).unwrap();
assert!(s.visible().is_empty(), "and back");
s.execute("develop.endInteraction", &json!({})).unwrap();
// a search token
s.filter = Filter { text: "edited:yes".into(), ..Default::default() };
expose(&mut s, b, 1.0);
assert_eq!(s.visible(), [b]);
// Edit Date: the photo edited last comes first
s.filter = Filter::default();
s.sort = Sort { key: SortKey::EditDate, ascending: false, ..Default::default() };
s.clock = Box::new(|| "2100-05-01T10:00:00".to_string());
expose(&mut s, a, 0.3);
assert_eq!(s.visible()[0], a);
s.clock = Box::new(|| "2100-05-01T11:00:00".to_string());
expose(&mut s, b, 0.7);
assert_eq!(s.visible()[0], b);
// a smart album testing Cropped, and one testing it through an Album rule
s.sort = Sort::default();
smart(&mut s, 100, json!({"rules": [{"field": "cropped", "op": "is", "value": true}]}));
smart(&mut s, 101, json!({"rules": [{"field": "album", "op": "is", "value": 100}]}));
for album in [100, 101] {
s.source = LibrarySource::Album(AlbumId(album));
assert!(s.visible().is_empty());
assert_eq!(s.source_total(), Some(0));
}
let mut d = (*s.develop_of(a).unwrap()).clone();
d.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.1, 0.9, 0.9);
s.set_develop(a, d, "Crop").unwrap();
for album in [100, 101] {
s.source = LibrarySource::Album(AlbumId(album));
assert_eq!(s.visible(), [a], "album {album}");
assert_eq!(s.source_total(), Some(1), "album {album}");
}
// and the size rules (a crop changes the shown shape)
s.source = LibrarySource::All;
let square: RuleSet = serde_json::from_value(json!({"rules": [{"field": "aspect", "op": "is", "value": "square"}]})).unwrap();
s.filter = Filter { rule_set: Some(square), ..Default::default() };
assert!(s.visible().is_empty());
let mut d = (*s.develop_of(b).unwrap()).clone();
d.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, 4000.0 / 6000.0, 1.0);
s.set_develop(b, d, "Crop").unwrap();
assert_eq!(s.visible(), [b]);
}
/// Per-tick cost of a slider drag on a big library (the develop op plus the grid query the
/// filmstrip asks for every frame):
/// `cargo test --release -p lightcraft-engine -- --ignored drag_tick_100k --nocapture`.
#[test]
#[ignore]
fn drag_tick_100k() {
let mut s = library(100_000);
let id = s.visible()[50_000];
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
let n = 60;
let tick = |s: &mut Session, name: &str| {
s.visible();
let t = std::time::Instant::now();
for i in 0..n {
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 100.0})).unwrap();
s.visible();
}
let ms = t.elapsed().as_secs_f64() * 1e3 / n as f64;
eprintln!("slider tick at 100k photos, {name}: {ms:.3} ms (develop.set + visible)");
};
tick(&mut s, "warm-up");
tick(&mut s, "by capture date");
s.sort = Sort { key: SortKey::FileName, ..Default::default() };
tick(&mut s, "by file name");
// a view that reads develop state still asks again on every tick
s.sort = Sort::default();
s.filter = Filter { edited: Some(false), ..Default::default() };
tick(&mut s, "Unedited filter");
s.execute("develop.endInteraction", &json!({})).unwrap();
}