mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 20:48:33 -06:00
- new crate lightcraft-preview (L3): 128-bit content hashing (SipHash-1-3), cost-bounded Lru, DiskCache (JPEG q90 files keyed by content hash + settings hash + size + RENDER_CACHE_VERSION; temp+rename writes; LRU prune by mtime to 80% of budget; corrupt files dropped), PreviewCache (memory LRU over disk), JobPool (per-slot dedupe, priorities, reprioritize/drop, inline mode for wasm). - engine: Session::thumb_job buckets thumbnails to 128/256/384/512 and serves them from the cache without decoding the source; decoded thumbnail sources now in a 384 MB LRU (was unbounded); library attaches <library>/thumbs; library.info cache stats; library.clearPreviews. - ui-egui: Renderer runs on JobPool; thumbnails scrolled out of the prefetch area are dropped from the queue; on-screen thumbnails get re-prioritized; ui.inspect reports in-flight renders + thumb textures. - Demo library (24 photos): grid fully drawn 4.4 s cold -> 0.21 s warm (24/24 disk hits, no source decoded). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
5.3 KiB
Rust
129 lines
5.3 KiB
Rust
//! Persistent library: commands survive a restart (with and without a clean close).
|
|
|
|
use serde_json::json;
|
|
|
|
use crate::Session;
|
|
|
|
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
|
let d = std::env::temp_dir().join(format!("lc-engine-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
d
|
|
}
|
|
|
|
fn open(dir: &std::path::Path, seed: bool) -> Session {
|
|
let mut s = Session::new();
|
|
s.open_library(dir, seed).unwrap();
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn edits_survive_restart_without_close() {
|
|
let dir = temp_dir("crash");
|
|
let mut s = open(&dir, true);
|
|
assert!(s.library.as_ref().unwrap().report.created);
|
|
assert!(s.catalog.len() > 5, "seeded demo");
|
|
let id = s.selection.active.unwrap();
|
|
s.execute("photo.rate", &json!({"rating": 5})).unwrap();
|
|
s.execute("photo.flag", &json!({"flag": "reject"})).unwrap();
|
|
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.25})).unwrap();
|
|
// a slider drag: one undo step, logged once at the end
|
|
s.execute("develop.beginInteraction", &json!({"label": "Contrast"})).unwrap();
|
|
for v in [5, 10, 20, 33] {
|
|
s.execute("develop.set", &json!({"control": "light.contrast", "value": v})).unwrap();
|
|
}
|
|
s.execute("develop.endInteraction", &json!({})).unwrap();
|
|
s.execute("album.create", &json!({"name": "Keepers", "addSelected": true})).unwrap();
|
|
s.execute("preset.create", &json!({"name": "Bright"})).unwrap();
|
|
s.execute("edit.undo", &json!({})).ok();
|
|
s.execute("edit.redo", &json!({})).ok();
|
|
let expect = s.catalog.to_snapshot();
|
|
let info = s.execute("library.info", &json!({})).unwrap();
|
|
assert!(info["logRecords"].as_u64().unwrap() >= 5, "{info}");
|
|
drop(s); // no close: like a crash
|
|
|
|
let s2 = open(&dir, true);
|
|
let r = &s2.library.as_ref().unwrap().report;
|
|
assert!(!r.created && r.replayed >= 5, "{r:?}");
|
|
assert_eq!(s2.catalog.to_snapshot(), expect);
|
|
let p = s2.catalog.photo(id).unwrap();
|
|
assert_eq!((p.rating, p.develop.light.exposure, p.develop.light.contrast), (5, 1.25, 33.0));
|
|
assert!(s2.catalog.albums().any(|a| a.name == "Keepers" && a.photos == vec![id]));
|
|
assert!(s2.presets.iter().any(|p| p.name == "Bright" && !p.builtin));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn close_writes_snapshot_and_view_state() {
|
|
let dir = temp_dir("close");
|
|
let mut s = open(&dir, true);
|
|
let ids = s.visible_cloned();
|
|
s.execute("library.select", &json!({"ids": [ids[3].0]})).unwrap();
|
|
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
|
|
let expect = s.catalog.to_snapshot();
|
|
s.close_library().unwrap();
|
|
assert_eq!(std::fs::metadata(dir.join("catalog.log")).unwrap().len(), 0);
|
|
|
|
let s2 = open(&dir, true);
|
|
let r = &s2.library.as_ref().unwrap().report;
|
|
assert_eq!(r.replayed, 0);
|
|
assert_eq!(s2.catalog.to_snapshot(), expect);
|
|
assert_eq!(s2.selection.active, Some(ids[3]));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// Thumbnails rendered in one session are served from the disk cache in the next, without
|
|
/// loading the source; an edit changes the key.
|
|
#[test]
|
|
fn thumbnails_come_from_the_disk_cache_after_restart() {
|
|
let dir = temp_dir("thumbs");
|
|
let mut s = open(&dir, true);
|
|
let ids: Vec<_> = s.visible_cloned().into_iter().take(3).collect();
|
|
for id in &ids {
|
|
let job = s.thumb_job(*id, 230).unwrap();
|
|
assert_eq!(job.request.max_w, 256, "bucketed");
|
|
let r = job.run();
|
|
assert!(r.loaded.is_some(), "first render decodes the source");
|
|
s.accept(&r);
|
|
let img = r.rendered.unwrap().image;
|
|
assert_eq!(img.width.max(img.height), 256);
|
|
}
|
|
drop(s);
|
|
|
|
let mut s = open(&dir, true);
|
|
for id in &ids {
|
|
let r = s.thumb_job(*id, 240).unwrap().run();
|
|
assert!(r.loaded.is_none(), "served from cache");
|
|
let img = r.rendered.unwrap().image;
|
|
assert_eq!(img.width.max(img.height), 256);
|
|
}
|
|
let info = s.execute("library.info", &json!({})).unwrap();
|
|
assert_eq!(info["cache"]["disk"]["hits"], 3, "{info}");
|
|
// an edit is a new key: rendered (from the source), then cached
|
|
s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap();
|
|
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap();
|
|
let r = s.thumb_job(ids[0], 230).unwrap().run();
|
|
assert!(r.loaded.is_some());
|
|
// exact-size renders (loupe, before/after, exports) bypass the thumbnail cache
|
|
assert!(s.render_job(ids[0], 230, 230, true, true).unwrap().cache.is_none());
|
|
assert!(s.render_job(ids[0], 230, 230, false, true).unwrap().cache.is_none());
|
|
s.execute("library.clearPreviews", &json!({})).unwrap();
|
|
assert!(s.thumb_job(ids[1], 230).unwrap().run().loaded.is_some());
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn new_library_without_seed_is_empty_and_compacts() {
|
|
let dir = temp_dir("empty");
|
|
let mut s = open(&dir, false);
|
|
assert!(s.catalog.is_empty());
|
|
s.execute("album.create", &json!({"name": "A"})).unwrap();
|
|
s.execute("library.compact", &json!({})).unwrap();
|
|
let info = s.execute("library.info", &json!({})).unwrap();
|
|
assert_eq!(info["logRecords"], 0);
|
|
assert_eq!(info["snapshotSeq"], info["seq"]);
|
|
let s2 = open(&dir, true);
|
|
assert_eq!(s2.catalog.len(), 0, "an existing library is never seeded");
|
|
assert_eq!(s2.catalog.albums().count(), 1);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|