Merge pull request #48 from GlemNL/fix/issue-26-smart-previews-location

Smart Previews: choose the folder per library
This commit is contained in:
Brandon Thomas 2026-10-04 10:18:57 -04:00 committed by GitHub
commit cdaacc1e83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 353 additions and 2 deletions

View File

@ -104,7 +104,12 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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(&current);
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(&current, &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<CommandSpec> {
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()) {

View File

@ -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<std::path::PathBuf>,
}
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,
}
}

View File

@ -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<String>,
}
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::<ViewFile>(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(()) };

View File

@ -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<Existing> {
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<String>) {
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<Arc<Rgb32f>, String> {
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
@ -80,6 +156,43 @@ pub fn load(path: &Path) -> Result<Arc<Rgb32f>, 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);

View File

@ -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());
}

View File

@ -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

View File

@ -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 | 🚫 | | |