Merge pull request #48 from GlemNL/fix/issue-26-smart-previews-location
Smart Previews: choose the folder per library
This commit is contained in:
commit
cdaacc1e83
|
|
@ -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(¤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<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()) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(()) };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 | 🚫 | | |
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue