From 78f627228f561bdac42ff342e41114f1b5e8b73f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20L?= Date: Sun, 4 Oct 2026 11:21:25 +0200 Subject: [PATCH] Smart Previews: choose the folder per library, with a move/leave/delete choice (#26) Smart previews always lived in /Smart Previews, so a large set of proxies filled the library's drive. The folder is now a per-library setting (prefs.json): - library.smartPreviewsLocation shows the effective folder (path, count, size, availability) or changes it; changing it needs an explicit choice for the previews already built (move, leave or discard), and reset returns to the library's own folder. - The new folder must accept writes before anything moves. A folder whose parent is missing (an unplugged drive) is an error and is never created on the current drive; building also refuses while a chosen folder is unavailable. - Building, discarding, status and offline rendering all use the chosen folder. - Settings -> Performance -> Smart previews shows the path before building, what it holds, the existing-previews choice, Choose Folder... and Use Library Folder. The thumbnail cache stays in the library. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/previews.rs | 80 +++++++++++++++++- crates/engine/src/lib.rs | 3 + crates/engine/src/library.rs | 7 ++ crates/engine/src/smart.rs | 113 ++++++++++++++++++++++++++ crates/engine/src/tests_import.rs | 64 +++++++++++++++ crates/ui-egui/src/panels/settings.rs | 86 ++++++++++++++++++++ docs/parity.md | 2 +- 7 files changed, 353 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index dfeaecfb..8da19ce3 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -104,7 +104,12 @@ fn smart(s: &mut Session, p: &Value) -> Result { let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; let (mut built, mut removed, mut failed) = (0usize, 0usize, Vec::new()); if !discard { - std::fs::create_dir_all(&dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?; + // a chosen folder on another drive must be there: never recreate it on this one + if s.smart_previews_dir.is_some() { + crate::smart::check_writable(&dir).map_err(|e| bad(C, e))?; + } else { + std::fs::create_dir_all(&dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?; + } } for id in ids { let Some(ph) = s.catalog.photo(id).cloned() else { continue }; @@ -134,6 +139,69 @@ fn smart(s: &mut Session, p: &Value) -> Result { Ok(json!({"built": built, "removed": removed, "failed": failed})) } +/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be +/// used now (a chosen folder on an unplugged drive is `available: false`). +#[cfg(not(target_arch = "wasm32"))] +fn location_json(s: &Session) -> Value { + let dir = s.media.smart_dir.clone(); + let default = s.library.as_ref().filter(|l| l.on_disk).map(|l| crate::smart::dir(&l.dir)); + let (count, bytes) = dir.as_deref().map_or((0, 0), crate::smart::stats); + json!({ + "path": dir.as_ref().map(|d| d.to_string_lossy()), + "default": default.map(|d| d.to_string_lossy().to_string()), + "custom": s.smart_previews_dir.is_some(), + "available": dir.as_ref().is_some_and(|d| d.is_dir()) || s.smart_previews_dir.is_none(), + "count": count, + "bytes": bytes, + }) +} + +/// Show or change where smart previews are kept (per library). +#[cfg(not(target_arch = "wasm32"))] +fn smart_location(s: &mut Session, p: &Value) -> Result { + const C: &str = "library.smartPreviewsLocation"; + let current = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?; + let reset = p.get("reset").and_then(Value::as_bool).unwrap_or(false); + let chosen = str_param(p, "path").filter(|x| !x.trim().is_empty()); + if !reset && chosen.is_none() { + return Ok(location_json(s)); + } + let lib_dir = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()).ok_or_else(|| bad(C, "smart previews need a library on disk"))?; + let default = crate::smart::dir(&lib_dir); + let (new_dir, custom) = match chosen { + Some(x) if !reset => { + let d = std::path::PathBuf::from(x.trim()); + if !d.is_absolute() { + return Err(bad(C, "path must be an absolute folder path")); + } + (d.clone(), (d != default).then_some(d)) + } + _ => (default, None), + }; + if new_dir == current { + return Ok(location_json(s)); + } + // the new place must work before anything moves; there is no fallback to another drive + crate::smart::check_writable(&new_dir).map_err(|e| bad(C, e))?; + let (held, _) = crate::smart::stats(¤t); + let existing = match str_param(p, "existing") { + Some(x) => Some(crate::smart::Existing::parse(x).ok_or_else(|| bad(C, "existing must be move, leave or discard"))?), + None if held == 0 => Some(crate::smart::Existing::Leave), + None => None, + }; + let Some(existing) = existing else { + return Err(bad(C, format!("{held} smart preview(s) are in {}: pass existing = move, leave or discard", current.display()))); + }; + let (moved, failed) = crate::smart::migrate(¤t, &new_dir, existing); + s.smart_previews_dir = custom; + s.media.smart_dir = Some(new_dir); + s.save_prefs()?; + let mut r = location_json(s); + r["handled"] = json!(moved); + r["failed"] = json!(failed); + Ok(r) +} + /// Whether photo `id` has a smart preview. pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool { match (&s.media.smart_dir, s.catalog.photo(id)) { @@ -154,6 +222,16 @@ pub fn specs() -> Vec { always, smart ), + #[cfg(not(target_arch = "wasm32"))] + cmd!( + "library.smartPreviewsLocation", + "Smart Previews Location", + [], + None, + "{path?: absolute folder, reset?: bool, existing?: move|leave|discard} — without path/reset: where this library keeps its smart previews → {path, default, custom, available, count, bytes}. With path (or reset = back to `Smart Previews` in the library): use that folder instead (must exist or have an existing parent, and accept writes; never falls back to another drive). Smart previews already in the old folder need `existing`: move them, leave them (build again), or discard them → also {handled, failed}", + always, + smart_location + ), cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| { let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?; let online = match s.catalog.photo(id).map(|p| p.source.clone()) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 982b74af..d33cf7c0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -155,6 +155,8 @@ pub struct Session { pub import_defaults: import::ImportDefaults, /// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json). pub cache_mb: u32, + /// Where smart previews are kept when not in the library folder (persisted in prefs.json). + pub smart_previews_dir: Option, } impl Default for Session { @@ -212,6 +214,7 @@ impl Session { preview_build: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, + smart_previews_dir: None, } } diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index a252e07b..35d46580 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -126,6 +126,8 @@ struct PrefsFile { import: crate::import::ImportDefaults, /// Thumbnail disk cache budget (MB, 0 = default). cache_mb: u32, + /// Folder for smart previews (default: `Smart Previews` in the library). + smart_previews_dir: Option, } fn presets_json(s: &Session) -> String { @@ -200,6 +202,10 @@ impl Session { self.recent_keywords = prefs.recent_keywords; self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; + self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from); + if let Some(d) = &self.smart_previews_dir { + self.media.smart_dir = Some(d.clone()); + } // view state if let Some(v) = read_json::(files.as_mut(), "view.json") { self.source = v.source; @@ -320,6 +326,7 @@ impl Session { recent_keywords: self.recent_keywords.clone(), import: self.import_defaults.clone(), cache_mb: self.cache_mb, + smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()), }) .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index a5d243e1..79560c96 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -70,6 +70,82 @@ pub fn dir(library: &Path) -> PathBuf { library.join("Smart Previews") } +/// Check that smart previews can be written to `dir`: it must be (or, when its parent exists, +/// become) a directory that accepts a new file. Never creates missing parents, so an unplugged +/// drive's mount point is not silently recreated on the system drive. +pub fn check_writable(dir: &Path) -> Result<(), String> { + if !dir.is_dir() { + let parent_ok = dir.parent().is_some_and(|p| p.is_dir()); + if !parent_ok { + return Err(format!("{} is not available — reconnect the drive or choose another folder", dir.display())); + } + std::fs::create_dir(dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; + } + let probe = dir.join(format!(".lightcraft-write-test-{}", std::process::id())); + std::fs::write(&probe, b"x").map_err(|e| format!("{} is not writable (is the drive full or read-only?): {e}", dir.display()))?; + let _ = std::fs::remove_file(&probe); + Ok(()) +} + +/// The number of smart previews in `dir` and their total size in bytes. +pub fn stats(dir: &Path) -> (usize, u64) { + let Ok(rd) = std::fs::read_dir(dir) else { return (0, 0) }; + rd.flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == "lcsp")) + .fold((0, 0), |(n, b), e| (n + 1, b + e.metadata().map_or(0, |m| m.len()))) +} + +/// What to do with the smart previews in the old folder when the location changes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Existing { + /// Move them to the new folder (copy and delete across drives). + Move, + /// Leave them where they are (they stop being used; build again in the new folder). + Leave, + /// Delete them. + Discard, +} + +impl Existing { + pub fn parse(s: &str) -> Option { + match s { + "move" => Some(Existing::Move), + "leave" => Some(Existing::Leave), + "discard" => Some(Existing::Discard), + _ => None, + } + } +} + +/// Apply `what` to the smart previews in `from` for the new folder `to` → (moved or removed, failed). +pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec) { + let (mut done, mut failed) = (0, Vec::new()); + if what == Existing::Leave { + return (done, failed); + } + let Ok(rd) = std::fs::read_dir(from) else { return (done, failed) }; + for e in rd.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "lcsp")) { + let src = e.path(); + let r = match what { + Existing::Discard => std::fs::remove_file(&src), + _ => { + let dst = to.join(e.file_name()); + if dst.exists() { + // named by content: the copy already there is the same preview + std::fs::remove_file(&src) + } else { + std::fs::rename(&src, &dst).or_else(|_| std::fs::copy(&src, &dst).and_then(|_| std::fs::remove_file(&src))) + } + } + }; + match r { + Ok(()) => done += 1, + Err(err) => failed.push(format!("{}: {err}", src.display())), + } + } + (done, failed) +} + /// Load the proxy at `path`. pub fn load(path: &Path) -> Result, String> { let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; @@ -80,6 +156,43 @@ pub fn load(path: &Path) -> Result, String> { mod tests { use super::*; + fn temp(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-smart-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn writable_check_does_not_create_missing_parents() { + let base = temp("writable"); + assert!(check_writable(&base.join("new")).is_ok(), "one missing level is created"); + assert!(base.join("new").is_dir()); + let err = check_writable(&base.join("gone/deeper")).unwrap_err(); + assert!(err.contains("not available"), "{err}"); + assert!(!base.join("gone").exists(), "no fallback onto the current drive"); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn migrate_moves_leaves_or_discards() { + let base = temp("migrate"); + let (a, b) = (base.join("a"), base.join("b")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + std::fs::write(a.join("1.lcsp"), b"one").unwrap(); + std::fs::write(a.join("note.txt"), b"keep").unwrap(); + assert_eq!(stats(&a), (1, 3)); + assert_eq!(migrate(&a, &b, Existing::Leave).0, 0); + assert_eq!(stats(&a).0, 1); + assert_eq!(migrate(&a, &b, Existing::Move).0, 1); + assert_eq!((stats(&a).0, stats(&b).0), (0, 1)); + assert!(a.join("note.txt").exists(), "only smart previews are touched"); + assert_eq!(migrate(&b, &a, Existing::Discard).0, 1); + assert_eq!(stats(&b).0, 0); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn roundtrip_keeps_the_picture() { let img = lightcraft_scenes::demo_library()[0].render(160, 100); diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index bfdacd1f..2a1480ba 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -521,3 +521,67 @@ fn dust_spots_are_found_and_healed() { assert!(s.develop_of(id).unwrap().spots.is_empty(), "one undo step"); let _ = std::fs::remove_dir_all(&dir); } + +/// Smart previews can live outside the library: the folder is saved per library, used for +/// building, status and offline rendering, changing it needs an explicit choice for the +/// previews already built, and an unavailable folder is an error (never a silent fallback). +#[test] +fn smart_previews_folder_is_chosen_per_library() { + let src = temp_dir("smartloc-src"); + let lib = temp_dir("smartloc-lib"); + let away = temp_dir("smartloc-away"); + write_png(&src.join("a.png"), 7); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let loc = s.execute("library.smartPreviewsLocation", &json!({})).unwrap(); + assert_eq!(loc["custom"], false); + assert_eq!(loc["path"], lib.join("Smart Previews").to_string_lossy().as_ref()); + s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!(crate::smart::stats(&lib.join("Smart Previews")).0, 1); + + // relative paths and missing drives are refused, and nothing is created on this drive + assert!(s.execute("library.smartPreviewsLocation", &json!({"path": "rel/dir"})).is_err()); + let gone = away.join("unplugged/previews"); + let err = s.execute("library.smartPreviewsLocation", &json!({"path": gone.to_string_lossy()})).unwrap_err().to_string(); + assert!(err.contains("not available"), "{err}"); + assert!(!away.join("unplugged").exists()); + // the previews already built need an explicit choice + let to = away.join("proxies"); + let err = s.execute("library.smartPreviewsLocation", &json!({"path": to.to_string_lossy()})).unwrap_err().to_string(); + assert!(err.contains("existing"), "{err}"); + assert_eq!(s.execute("library.smartPreviewsLocation", &json!({})).unwrap()["custom"], false); + + let r = s.execute("library.smartPreviewsLocation", &json!({"path": to.to_string_lossy(), "existing": "move"})).unwrap(); + assert_eq!((r["custom"].clone(), r["handled"].clone(), r["count"].clone()), (json!(true), json!(1), json!(1)), "{r}"); + assert_eq!(crate::smart::stats(&lib.join("Smart Previews")).0, 0); + assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], true); + + // the setting is saved with the library + let mut again = Session::new().with_fs(); + again.open_library(&lib, false).unwrap(); + assert_eq!(again.execute("library.smartPreviewsLocation", &json!({})).unwrap()["path"], to.to_string_lossy().as_ref()); + + // offline original: the render comes from the proxy in the chosen folder + std::fs::rename(&src, src.with_extension("offline")).unwrap(); + again.media.forget(id); + again.render_now(id, 48, 32).expect("renders from the smart preview in the chosen folder"); + + // the chosen drive goes away: building says so and creates nothing on this drive + let drive = away.join("drive"); + std::fs::create_dir_all(&drive).unwrap(); + let on_drive = drive.join("proxies"); + again.execute("library.smartPreviewsLocation", &json!({"path": on_drive.to_string_lossy(), "existing": "leave"})).unwrap(); + std::fs::remove_dir_all(&drive).unwrap(); + let loc = again.execute("library.smartPreviewsLocation", &json!({})).unwrap(); + assert_eq!(loc["available"], false, "{loc}"); + let err = again.execute("library.smartPreviews", &json!({"ids": [id.0]})).unwrap_err().to_string(); + assert!(err.contains("not available"), "{err}"); + assert!(!drive.exists()); + + // back to the library's own folder, discarding the proxies left in the first chosen folder + let r = again.execute("library.smartPreviewsLocation", &json!({"reset": true})).unwrap(); + assert_eq!(r["custom"], false, "{r}"); + assert_eq!(r["path"], lib.join("Smart Previews").to_string_lossy().as_ref()); +} diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index c6961e54..518a26e2 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -376,6 +376,92 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let _ = app.run("library.clearPreviews", json!({})); } }); + #[cfg(not(target_arch = "wasm32"))] + smart_previews(app, ui, t); +} + +/// Where this library keeps its smart previews (the offline-editing proxies, which can be large): +/// the effective folder, what is in it, and choosing another one. +#[cfg(not(target_arch = "wasm32"))] +fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + // listing a big folder every frame would be slow: refresh every 2 s and after a change + let cache = egui::Id::new("smart-location"); + let now = ui.input(|i| i.time); + let loc: Value = match ui.data(|d| d.get_temp::<(f64, Value)>(cache)) { + Some((at, v)) if now - at < 2.0 => v, + _ => { + let v = app.run("library.smartPreviewsLocation", json!({})).unwrap_or(Value::Null); + ui.data_mut(|d| d.insert_temp(cache, (now, v.clone()))); + v + } + }; + if loc.is_null() { + return; + } + heading(ui, t, "Smart previews"); + let path = loc["path"].as_str().unwrap_or_default().to_string(); + let available = loc["available"].as_bool().unwrap_or(true); + let custom = loc["custom"].as_bool().unwrap_or(false); + row(ui, t, "Folder", |ui| { + let text = format!("{path}{}", if custom { "" } else { " (library default)" }); + ui.add(egui::Label::new(RichText::new(text.clone()).color(if available { t.text } else { t.reject })).truncate()).on_hover_text(text); + }); + let (count, bytes) = (loc["count"].as_u64().unwrap_or(0), loc["bytes"].as_u64().unwrap_or(0)); + if available { + hint(ui, t, &format!("{count} smart preview{} · {:.1} MB", if count == 1 { "" } else { "s" }, bytes as f64 / 1048576.0)); + } else { + hint( + ui, + t, + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.", + ); + } + // what happens to the previews already built when the folder changes + let mode_id = egui::Id::new("smart-existing"); + let mut mode: u8 = ui.data(|d| d.get_temp(mode_id)).unwrap_or(0); + if count > 0 { + row(ui, t, "Existing previews", |ui| { + if choices(ui, "settingsSmartExisting", &[(0u8, "Move them"), (1, "Leave them"), (2, "Delete them")], &mut mode) { + ui.data_mut(|d| d.insert_temp(mode_id, mode)); + } + }); + } + let existing = ["move", "leave", "discard"][mode.min(2) as usize]; + let mut result = None; + ui.horizontal(|ui| { + ui.add_space(LABEL_W); + if app.services.pick_folder.is_some() { + let r = ui.button("Choose Folder…"); + register(ui.ctx(), "button:settingsSmartChoose", r.rect); + if r.clicked() + && let Some(dir) = app.services.pick_folder.as_mut().and_then(|f| f()) + { + result = Some(app.run("library.smartPreviewsLocation", json!({"path": dir, "existing": existing}))); + } + } + let r = ui.add_enabled(custom, egui::Button::new("Use Library Folder")); + register(ui.ctx(), "button:settingsSmartReset", r.rect); + if r.clicked() { + result = Some(app.run("library.smartPreviewsLocation", json!({"reset": true, "existing": existing}))); + } + }); + if let Some(r) = result { + ui.data_mut(|d| d.remove::<(f64, Value)>(cache)); + match r { + Ok(v) => { + let failed = v["failed"].as_array().map_or(0, Vec::len); + let msg = match (existing, v["handled"].as_u64().unwrap_or(0)) { + (_, 0) => "Smart previews folder changed".to_string(), + ("move", n) => format!("Smart previews folder changed; moved {n}"), + ("discard", n) => format!("Smart previews folder changed; deleted {n}"), + _ => "Smart previews folder changed".to_string(), + }; + app.toast(ui.ctx(), if failed > 0 { format!("{msg} ({failed} failed)") } else { msg }); + } + Err(e) => app.toast(ui.ctx(), e), + } + } + hint(ui, t, "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library."); } // ---------------------------------------------------------------------------------- Interface diff --git a/docs/parity.md b/docs/parity.md index 4f45ebbd..23ad5046 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -463,7 +463,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-CLOUD-SYNC | Cloud sync | OOS | 🚫 | | | -| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs` | File ▸ Previews ▸ Build / Discard Smart Previews: ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status | +| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:library.smartPreviewsLocation`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs`, `crates/ui-egui/src/panels/settings.rs` | File ▸ Previews ▸ Build / Discard Smart Previews: ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status; the proxy folder is chosen per library (Settings → Performance → Smart previews, saved in the library): the effective path, count and size are shown, changing it needs a choice for the proxies already built (move / leave / delete), and an unavailable or unwritable folder is an error, never a fallback to the library drive. The thumbnail cache stays in the library | | LR-AI-UPDATE-INDICATOR | AI-settings update indicator | P2 | ⬜ | | | | LR-AI-CREDITS | Generative credits | OOS | 🚫 | | |