mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 02:59:17 -06:00
A Lightroom collection imported whole (11 113 presets, a 42 MB presets.json) made every interaction take seconds: - Session::persist ran after every command and serialized the whole preset list to compare it with the last write: 609 ms per slider tick (release, the real collection). Session::presets is now a PresetList whose generation changes on mutable access; persist serializes only when it (or the profile lists) changed, and skips the write when the bytes hash the same. develop.set: 609 ms -> 2.3 ms; preset.apply: 598 ms -> 3.1 ms. - The Presets panel deep-cloned every preset into a BTreeMap, built a row, widget id and (with thumbnails) a variant render job for each of them every frame. Grouping is cached per generation and only rows in view are laid out: 5041 -> ~20 rows a frame, 2.3 ms with 5000 presets. - The preset pickers (Import dialog, Settings > Import) build only the rows in view. - add_presets and opening a library compared every preset with every other (quadratic); both use hash indexes now. Opening no longer serializes the list once just to remember it. Tests: develop commands don't re-serialize unchanged presets (and a change is still saved), generation semantics, a 20 000-preset import, and the panel building only visible rows (fails on the old code: 5041 rows). An ignored bench (presets_scale_bench, LIGHTCRAFT_BENCH_PRESETS) times a real presets.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
5.0 KiB
Rust
127 lines
5.0 KiB
Rust
//! A library with thousands of user presets (a Lightroom preset collection imported whole) stays
|
|
//! responsive: commands that don't touch presets never re-serialize them, and opening or importing
|
|
//! doesn't compare every preset with every other.
|
|
|
|
use std::path::PathBuf;
|
|
use std::time::Instant;
|
|
|
|
use lightcraft_develop::Preset;
|
|
use serde_json::json;
|
|
|
|
use crate::Session;
|
|
|
|
fn temp_dir(tag: &str) -> PathBuf {
|
|
let d = std::env::temp_dir().join(format!("lc-presets-scale-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
fn many(n: usize) -> Vec<Preset> {
|
|
(0..n)
|
|
.map(|i| Preset {
|
|
id: format!("user.coll-{}.look-{i}", i / 100),
|
|
name: format!("Look {i}"),
|
|
group: format!("Collection {}", i / 100),
|
|
settings: json!({"light": {"exposure": (i % 40) as f64 / 10.0 - 2.0, "contrast": (i % 50) as f64}, "color": {"vibrance": (i % 30) as f64}}),
|
|
favorite: false,
|
|
builtin: false,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn develop_commands_do_not_reserialize_unchanged_presets() {
|
|
let lib = temp_dir("no-reserialize");
|
|
let mut s = Session::new();
|
|
s.open_library(&lib, true).unwrap();
|
|
let added = s.add_presets(many(2000));
|
|
assert_eq!(added.len(), 2000);
|
|
s.persist().unwrap();
|
|
assert!(std::fs::metadata(lib.join("presets.json")).unwrap().len() > 100_000, "presets saved");
|
|
let before = crate::library::presets_serialized();
|
|
for i in 0..20 {
|
|
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
|
|
}
|
|
assert_eq!(crate::library::presets_serialized(), before, "slider commands left the preset list alone");
|
|
// a preset change is still saved
|
|
let pid = added[7].clone();
|
|
s.execute("preset.favorite", &json!({"id": pid})).unwrap();
|
|
assert!(crate::library::presets_serialized() > before);
|
|
s.close_library().unwrap();
|
|
drop(s);
|
|
let mut t = Session::new();
|
|
t.open_library(&lib, false).unwrap();
|
|
assert!(t.presets.iter().any(|p| p.id == pid && p.favorite), "favourite persisted");
|
|
assert_eq!(t.presets.iter().filter(|p| !p.builtin).count(), 2000);
|
|
let _ = std::fs::remove_dir_all(&lib);
|
|
}
|
|
|
|
#[test]
|
|
fn preset_list_generation_follows_mutable_access_only() {
|
|
let mut s = Session::new();
|
|
let g0 = s.presets.generation();
|
|
let _ = s.presets.iter().filter(|p| p.builtin).count();
|
|
let _ = s.presets.len();
|
|
assert_eq!(s.presets.generation(), g0, "reads keep the generation");
|
|
s.presets.push(many(1).remove(0));
|
|
let g1 = s.presets.generation();
|
|
assert_ne!(g1, g0);
|
|
for p in &mut s.presets {
|
|
p.favorite = false;
|
|
}
|
|
assert_ne!(s.presets.generation(), g1);
|
|
// generations are unique across sessions (UI caches key on them alone)
|
|
let other = Session::new();
|
|
assert_ne!(other.presets.generation(), s.presets.generation());
|
|
}
|
|
|
|
#[test]
|
|
fn importing_thousands_of_presets_is_not_quadratic() {
|
|
let mut s = Session::new();
|
|
let t = Instant::now();
|
|
let added = s.add_presets(many(20_000));
|
|
assert_eq!(added.len(), 20_000);
|
|
// the same presets again are all skipped as duplicates
|
|
assert!(s.add_presets(many(20_000)).is_empty());
|
|
// a clashing id with different settings gets a fresh, unique one
|
|
let mut clash = many(1).remove(0);
|
|
clash.settings = json!({"light": {"exposure": 3.0}});
|
|
let id = s.add_presets(vec![clash]).remove(0);
|
|
assert_ne!(id, "user.coll-0.look-0");
|
|
assert_eq!(s.presets.iter().filter(|p| p.id == id).count(), 1);
|
|
// quadratic was ~200 M comparisons here (tens of seconds in debug builds)
|
|
assert!(t.elapsed().as_secs() < 20, "took {:?}", t.elapsed());
|
|
}
|
|
|
|
/// Timing with a real preset collection: `LIGHTCRAFT_BENCH_PRESETS=path/to/presets.json cargo test
|
|
/// -p lightcraft-engine --release presets_scale_bench -- --ignored --nocapture`. The file is copied
|
|
/// into a temporary library; the original is only read.
|
|
#[test]
|
|
#[ignore]
|
|
fn presets_scale_bench() {
|
|
let Ok(src) = std::env::var("LIGHTCRAFT_BENCH_PRESETS") else {
|
|
eprintln!("set LIGHTCRAFT_BENCH_PRESETS");
|
|
return;
|
|
};
|
|
let lib = temp_dir("bench");
|
|
std::fs::copy(&src, lib.join("presets.json")).unwrap();
|
|
let mut s = Session::new();
|
|
let t = Instant::now();
|
|
s.open_library(&lib, true).unwrap();
|
|
eprintln!("open library: {:?} ({} presets)", t.elapsed(), s.presets.len());
|
|
let t = Instant::now();
|
|
for i in 0..20 {
|
|
s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
|
|
}
|
|
eprintln!("develop.set: {:?} per command", t.elapsed() / 20);
|
|
let pid = s.presets.iter().rev().find(|p| !p.builtin).map(|p| p.id.clone()).unwrap();
|
|
let t = Instant::now();
|
|
s.execute("preset.apply", &json!({"id": pid, "amount": 100})).unwrap();
|
|
eprintln!("preset.apply: {:?}", t.elapsed());
|
|
let t = Instant::now();
|
|
s.execute("preset.favorite", &json!({"id": pid})).unwrap();
|
|
eprintln!("preset.favorite (saves presets.json): {:?}", t.elapsed());
|
|
let _ = std::fs::remove_dir_all(&lib);
|
|
}
|