mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
Missing Photos (sidebar count, the view, library.missing, Find Missing) now shares one scope rule, cmd::missing::checked_path: non-deleted library photos backed by a file. Local browse records, which can outnumber the library by orders of magnitude, are never stat'ed. The Missing view checks only the photos its query kept instead of every catalog record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
616 lines
33 KiB
Rust
616 lines
33 KiB
Rust
//! Import: recursive folders, duplicate detection by content, add vs copy into the library.
|
|
|
|
use std::path::Path;
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::Session;
|
|
|
|
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
|
let d = std::env::temp_dir().join(format!("lc-import-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
/// A small procedural PNG (distinct per seed).
|
|
fn write_png(path: &Path, seed: u8) {
|
|
let (w, h) = (48usize, 32usize);
|
|
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed, 255]).collect();
|
|
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
|
|
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
std::fs::write(path, bytes).unwrap();
|
|
}
|
|
|
|
fn ids(v: &Value, key: &str) -> usize {
|
|
v[key].as_array().map(Vec::len).unwrap_or(0)
|
|
}
|
|
|
|
#[test]
|
|
fn recursive_import_with_duplicates() {
|
|
let src = temp_dir("src");
|
|
write_png(&src.join("a.png"), 1);
|
|
write_png(&src.join("trip/b.png"), 2);
|
|
write_png(&src.join("trip/day2/c.PNG"), 3);
|
|
std::fs::copy(src.join("a.png"), src.join("trip/a-copy.png")).unwrap(); // same bytes
|
|
write_png(&src.join(".hidden/x.png"), 9);
|
|
std::fs::write(src.join("notes.txt"), "not a photo").unwrap();
|
|
std::fs::write(src.join("trip/broken.jpg"), "garbage").unwrap();
|
|
|
|
let mut s = Session::new().with_fs();
|
|
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
|
assert_eq!(r["scanned"], 5, "{r}"); // a, b, c, a-copy, broken (hidden + txt skipped)
|
|
assert_eq!(ids(&r, "imported"), 3, "{r}");
|
|
assert_eq!(ids(&r, "duplicates"), 1, "{r}");
|
|
assert_eq!(r["duplicates"][0]["reason"], "content");
|
|
assert_eq!(ids(&r, "failed"), 1, "{r}");
|
|
assert_eq!(s.catalog.len(), 3);
|
|
assert!(s.catalog.photos().all(|p| p.content_hash.as_ref().is_some_and(|h| h.len() == 32) && p.width == 48));
|
|
|
|
// importing again: everything is a duplicate (by path), one undo step for the first import
|
|
let r2 = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
|
assert_eq!(ids(&r2, "imported"), 0);
|
|
assert_eq!(r2["duplicates"].as_array().unwrap().iter().filter(|d| d["reason"] == "path").count(), 3);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(s.catalog.len(), 0);
|
|
let _ = std::fs::remove_dir_all(&src);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_into_library_and_persist() {
|
|
let src = temp_dir("copysrc");
|
|
let lib = temp_dir("copylib");
|
|
write_png(&src.join("one.png"), 4);
|
|
write_png(&src.join("sub/one.png"), 5); // same name, different content
|
|
let mut s = Session::new().with_fs();
|
|
assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).is_err(), "copy needs a library");
|
|
s.open_library(&lib, false).unwrap();
|
|
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).unwrap();
|
|
assert_eq!(ids(&r, "imported"), 2, "{r}");
|
|
let paths: Vec<String> = s
|
|
.catalog
|
|
.photos()
|
|
.map(|p| match &p.source {
|
|
lightcraft_catalog::Source::File { path } => path.clone(),
|
|
_ => panic!(),
|
|
})
|
|
.collect();
|
|
for p in &paths {
|
|
assert!(Path::new(p).starts_with(lib.join("Originals")), "{p}");
|
|
assert!(Path::new(p).exists());
|
|
}
|
|
assert_ne!(paths[0], paths[1], "unique names");
|
|
// originals deleted from the source: the library copies remain usable after a restart
|
|
std::fs::remove_dir_all(&src).unwrap();
|
|
drop(s);
|
|
let mut s = Session::new().with_fs();
|
|
s.open_library(&lib, false).unwrap();
|
|
assert_eq!(s.catalog.len(), 2);
|
|
let id = s.catalog.photos().next().unwrap().id;
|
|
assert!(s.render_now(id, 32, 32).is_ok());
|
|
// the library folder itself is never re-imported
|
|
let r = s.execute("library.import", &json!({"paths": [lib.to_string_lossy()]})).unwrap();
|
|
assert_eq!(r["scanned"], 0, "{r}");
|
|
let _ = std::fs::remove_dir_all(&lib);
|
|
}
|
|
|
|
#[test]
|
|
fn civil_dates() {
|
|
assert_eq!(crate::import::civil(0), "1970-01-01T00:00:00");
|
|
assert_eq!(crate::import::civil(951_782_400), "2000-02-29T00:00:00");
|
|
assert_eq!(crate::import::civil(1_790_000_000), "2026-09-21T14:13:20");
|
|
}
|
|
|
|
#[test]
|
|
fn browsing_a_folder_lists_its_photos_without_adding_them() {
|
|
use crate::LibrarySource;
|
|
let dir = std::env::temp_dir().join(format!("lc-browse-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
|
let png = |p: &std::path::Path, seed: u8| {
|
|
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
|
|
let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() })
|
|
.unwrap();
|
|
std::fs::write(p, b).unwrap();
|
|
};
|
|
png(&dir.join("a.png"), 1);
|
|
png(&dir.join("b.png"), 2);
|
|
png(&dir.join("sub/c.png"), 3);
|
|
std::fs::write(dir.join("notes.txt"), "x").unwrap();
|
|
let mut s = Session::new().with_fs();
|
|
let r = s.execute("library.browse", &serde_json::json!({"path": dir.to_string_lossy()})).unwrap();
|
|
assert_eq!(r["photos"], 2, "{r}");
|
|
assert_eq!(s.source, LibrarySource::Folder);
|
|
assert_eq!(s.visible_cloned().len(), 2);
|
|
// not in the library
|
|
s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap();
|
|
assert!(s.visible_cloned().is_empty(), "browsed photos stay out of All Photos");
|
|
assert_eq!(s.execute("catalog.stats", &serde_json::json!({})).unwrap()["photos"], 0, "nor in the counts");
|
|
assert!(s.catalog.date_groups().is_empty());
|
|
// subfolders; browsing again reuses the photos
|
|
let r = s.execute("library.browse", &serde_json::json!({"path": dir.to_string_lossy(), "subfolders": true})).unwrap();
|
|
assert_eq!((r["photos"].as_u64(), r["new"].as_u64()), (Some(3), Some(1)));
|
|
// add one to the library
|
|
let first = s.visible_cloned()[0];
|
|
s.execute("photo.addToLibrary", &serde_json::json!({"ids": [first.0]})).unwrap();
|
|
// importing the folder for real brings in the rest (no duplicates of the browsed ones)
|
|
let r = s.execute("library.import", &serde_json::json!({"paths": [dir.to_string_lossy()]})).unwrap();
|
|
assert_eq!(r["imported"].as_array().map(Vec::len), Some(2), "{r}");
|
|
s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap();
|
|
assert_eq!(s.visible_cloned().len(), 3);
|
|
assert_eq!(s.catalog.photos().count(), 3);
|
|
assert!(s.execute("library.browse", &serde_json::json!({"path": dir.join("a.png").to_string_lossy()})).is_err());
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_files_are_found_and_relinked() {
|
|
let dir = std::env::temp_dir().join(format!("lc-missing-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(dir.join("old")).unwrap();
|
|
std::fs::create_dir_all(dir.join("moved/deeper")).unwrap();
|
|
let png = |p: &std::path::Path, seed: u8| {
|
|
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
|
|
let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() })
|
|
.unwrap();
|
|
std::fs::write(p, b).unwrap();
|
|
};
|
|
png(&dir.join("old/a.png"), 1);
|
|
png(&dir.join("old/b.png"), 2);
|
|
let mut s = Session::new().with_fs();
|
|
s.execute("library.import", &serde_json::json!({"paths": [dir.join("old").to_string_lossy()]})).unwrap();
|
|
assert_eq!(s.execute("library.missing", &serde_json::json!({})).unwrap().as_array().map(Vec::len), Some(0));
|
|
// the files move away
|
|
std::fs::rename(dir.join("old/a.png"), dir.join("moved/a.png")).unwrap();
|
|
std::fs::rename(dir.join("old/b.png"), dir.join("moved/deeper/b.png")).unwrap();
|
|
let m = s.execute("library.missing", &serde_json::json!({})).unwrap();
|
|
assert_eq!(m.as_array().map(Vec::len), Some(2));
|
|
s.execute("library.source", &serde_json::json!({"kind": "missing"})).unwrap();
|
|
assert_eq!(s.visible_cloned().len(), 2, "the Missing Photos source lists them");
|
|
s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap();
|
|
// one by hand
|
|
let id = m[0]["id"].as_u64().unwrap();
|
|
let name = std::path::Path::new(m[0]["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().to_string();
|
|
let new = if name == "a.png" { dir.join("moved/a.png") } else { dir.join("moved/deeper/b.png") };
|
|
s.execute("photo.relink", &serde_json::json!({"id": id, "path": new.to_string_lossy()})).unwrap();
|
|
assert_eq!(s.execute("library.missing", &serde_json::json!({})).unwrap().as_array().map(Vec::len), Some(1));
|
|
assert!(s.execute("photo.relink", &serde_json::json!({"id": id, "path": dir.join("nope.png").to_string_lossy()})).is_err());
|
|
// the rest by searching a folder
|
|
let r = s.execute("library.findMissing", &serde_json::json!({"folder": dir.join("moved").to_string_lossy()})).unwrap();
|
|
assert_eq!((r["found"].as_array().map(Vec::len), r["missing"].as_u64()), (Some(1), Some(0)), "{r}");
|
|
// undo points the photo back at the old path but doesn't move any file
|
|
s.execute("edit.undo", &serde_json::json!({})).unwrap();
|
|
assert_eq!(s.execute("library.missing", &serde_json::json!({})).unwrap().as_array().map(Vec::len), Some(1));
|
|
assert!(dir.join("moved/a.png").exists() && dir.join("moved/deeper/b.png").exists());
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn recently_added_covers_recent_imports_newest_first() {
|
|
let dir = std::env::temp_dir().join(format!("lc-recent-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let png = |name: &str, seed: u8| {
|
|
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]);
|
|
let b = crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() })
|
|
.unwrap();
|
|
std::fs::write(dir.join(name), b).unwrap();
|
|
dir.join(name).to_string_lossy().to_string()
|
|
};
|
|
let mut s = Session::new().with_fs();
|
|
// three imports: 60 days ago, 10 days ago, today
|
|
for (when, name, seed) in [("2026-08-01T09:00:00", "old.png", 1), ("2026-09-20T09:00:00", "mid.png", 2), ("2026-09-30T09:00:00", "new.png", 3)] {
|
|
let path = png(name, seed);
|
|
let w = when.to_string();
|
|
s.clock = Box::new(move || w.clone());
|
|
s.execute("library.import", &serde_json::json!({"paths": [path]})).unwrap();
|
|
}
|
|
s.execute("library.source", &serde_json::json!({"kind": "recentlyAdded"})).unwrap();
|
|
let names: Vec<String> = s.visible_cloned().iter().map(|id| s.catalog.photo(*id).unwrap().file_name.clone()).collect();
|
|
assert_eq!(names, ["new.png", "mid.png"], "the last 30 days, newest import first");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// Copy imports: a destination folder, flat / by-month folders, renamed copies (numbered in
|
|
/// import order), and a metadata preset on every photo.
|
|
#[test]
|
|
fn copy_with_destination_organize_rename_and_metadata_preset() {
|
|
let src = temp_dir("orgsrc");
|
|
let dest = temp_dir("orgdest");
|
|
write_png(&src.join("a.png"), 1);
|
|
write_png(&src.join("b.png"), 2);
|
|
let mut s = Session::new().with_fs();
|
|
s.execute("metadata.savePreset", &json!({"name": "Studio", "fields": {"copyright": "© Studio", "creator": "Sam"}})).unwrap();
|
|
assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "metadataPreset": "Nope"})).is_err());
|
|
assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy", "organize": "weekly"})).is_err());
|
|
let r = s
|
|
.execute(
|
|
"library.import",
|
|
&json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "flat",
|
|
"rename": "Shoot-{seq:3}", "renameStart": 7, "metadataPreset": "Studio"}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(ids(&r, "imported"), 2, "{r}");
|
|
let mut names: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect();
|
|
names.sort();
|
|
assert_eq!(names, ["Shoot-007.png", "Shoot-008.png"], "catalogued under the new names");
|
|
assert!(dest.join("Shoot-007.png").exists() && dest.join("Shoot-008.png").exists(), "flat: straight into the destination");
|
|
assert!(s.catalog.photos().all(|p| p.meta.copyright == "© Studio" && p.meta.creator == "Sam"));
|
|
// by month, no library needed when a destination is given
|
|
let dest2 = temp_dir("orgdest2");
|
|
write_png(&src.join("c.png"), 3);
|
|
let r = s
|
|
.execute(
|
|
"library.import",
|
|
&json!({"paths": [src.join("c.png").to_string_lossy()], "mode": "copy", "destination": dest2.to_string_lossy(), "organize": "month"}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(ids(&r, "imported"), 1, "{r}");
|
|
let p = s.catalog.photos().find(|p| p.file_name == "c.png").unwrap();
|
|
let lightcraft_catalog::Source::File { path } = &p.source else { panic!() };
|
|
let rel = Path::new(path).strip_prefix(&dest2).unwrap();
|
|
assert_eq!(rel.components().count(), 3, "YYYY/YYYY-MM/c.png: {rel:?}");
|
|
assert_eq!(rel.parent().unwrap().file_name().unwrap().len(), 7);
|
|
for d in [&src, &dest, &dest2] {
|
|
let _ = std::fs::remove_dir_all(d);
|
|
}
|
|
}
|
|
|
|
/// Auto import: files in the watched folder are added once their size held between two scans,
|
|
/// into the named album; non-photos are tried once; the selection stays put.
|
|
#[test]
|
|
fn auto_import_watched_folder() {
|
|
let dir = temp_dir("watch");
|
|
let mut s = Session::new().with_fs();
|
|
assert!(s.execute("library.autoImport", &json!({"folder": dir.join("nope").to_string_lossy()})).is_err());
|
|
s.execute("library.autoImport", &json!({"folder": dir.to_string_lossy(), "album": "Tethered"})).unwrap();
|
|
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
|
|
write_png(&dir.join("one.png"), 1);
|
|
std::fs::write(dir.join("notes.txt"), "not a photo").unwrap();
|
|
// first sight: wait (it may still be copying)
|
|
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
|
|
let r = s.execute("library.autoImportScan", &json!({})).unwrap();
|
|
assert_eq!(r["imported"].as_array().unwrap().len(), 1, "{r}");
|
|
let album = s.catalog.albums().find(|a| a.name == "Tethered").expect("album").id;
|
|
assert_eq!(s.catalog.album_count(album), 1);
|
|
assert!(s.selection.ids.is_empty(), "arrivals don't take the selection");
|
|
// nothing new: nothing happens, and the text file isn't retried
|
|
for _ in 0..2 {
|
|
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["imported"], json!([]));
|
|
}
|
|
write_png(&dir.join("two.png"), 2);
|
|
s.execute("library.autoImportScan", &json!({})).unwrap();
|
|
s.execute("library.autoImportScan", &json!({})).unwrap();
|
|
assert_eq!(s.catalog.album_count(album), 2);
|
|
s.execute("library.autoImport", &json!({"folder": null})).unwrap();
|
|
assert_eq!(s.execute("library.autoImportScan", &json!({})).unwrap()["folder"], json!(null));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// Smart previews: with the original offline the photo still renders (and edits apply) from
|
|
/// its proxy; without the proxy it can't be opened.
|
|
#[test]
|
|
fn smart_previews_stand_in_for_offline_originals() {
|
|
let src = temp_dir("smartsrc");
|
|
let lib = temp_dir("smartlib");
|
|
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 r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
|
assert_eq!(r["built"], 1, "{r}");
|
|
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap(), json!({"smartPreview": true, "originalOnline": true}));
|
|
let online = s.render_now(id, 48, 32).unwrap().image;
|
|
// the drive goes away
|
|
std::fs::rename(&src, src.with_extension("offline")).unwrap();
|
|
s.media.forget(id);
|
|
assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["originalOnline"], false);
|
|
let offline = s.render_now(id, 48, 32).expect("renders from the smart preview").image;
|
|
let diff: f64 = online.data.iter().zip(&offline.data).map(|(a, b)| (a[0] as f64 - b[0] as f64).abs()).sum::<f64>() / online.data.len() as f64;
|
|
assert!(diff < 6.0, "the proxy looks like the original: {diff}");
|
|
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
|
s.media.forget(id);
|
|
assert_ne!(s.render_now(id, 48, 32).unwrap().image.data, offline.data, "edits apply offline");
|
|
// without the proxy it can't be opened
|
|
s.execute("library.smartPreviews", &json!({"discard": true})).unwrap();
|
|
s.media.forget(id);
|
|
assert!(s.render_now(id, 48, 32).is_err());
|
|
let _ = std::fs::remove_dir_all(src.with_extension("offline"));
|
|
let _ = std::fs::remove_dir_all(&lib);
|
|
}
|
|
|
|
/// LUT profiles: a .cube imports as a creative profile (into the library), renders differently
|
|
/// (Amount 0 = no change), is listed in the profile browser and comes back with the library.
|
|
#[test]
|
|
fn cube_luts_become_profiles() {
|
|
let src = temp_dir("cubesrc");
|
|
let lib = temp_dir("cubelib");
|
|
// a warm look: red up, blue down
|
|
let n = 9;
|
|
let mut cube = String::from("TITLE \"Warm Test\"\nLUT_3D_SIZE 9\n");
|
|
for b in 0..n {
|
|
for g in 0..n {
|
|
for r in 0..n {
|
|
let f = |v: usize| v as f32 / (n - 1) as f32;
|
|
cube.push_str(&format!("{} {} {}\n", (f(r) * 1.15).min(1.0), f(g), f(b) * 0.8));
|
|
}
|
|
}
|
|
}
|
|
std::fs::create_dir_all(src.join("Film Looks")).unwrap();
|
|
std::fs::write(src.join("Film Looks/warm.cube"), &cube).unwrap();
|
|
std::fs::write(src.join("Film Looks/broken.cube"), "LUT_3D_SIZE 4\n0 0 0\n").unwrap();
|
|
let mut s = Session::new().with_fs();
|
|
s.open_library(&lib, true).unwrap();
|
|
let r = s.execute("profile.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
|
assert_eq!((r["imported"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (1, 1), "{r}");
|
|
let id = r["imported"][0]["id"].as_str().unwrap().to_string();
|
|
assert_eq!((r["imported"][0]["name"].as_str(), r["imported"][0]["group"].as_str()), (Some("Warm Test"), Some("Film Looks")));
|
|
let menu = s.profile_menu();
|
|
assert!(menu["groups"].as_array().unwrap().iter().any(|g| g["name"] == "Film Looks"), "{menu}");
|
|
let photo = s.catalog.photos().next().unwrap().id;
|
|
s.execute("library.select", &json!({"ids": [photo.0]})).unwrap();
|
|
let base = s.render_now(photo, 48, 32).unwrap().image;
|
|
s.execute("develop.profile", &json!({"id": id, "amount": 100})).unwrap();
|
|
let warm = s.render_now(photo, 48, 32).unwrap().image;
|
|
let mean = |img: &lightcraft_raster::Rgba8, k: usize| img.data.iter().map(|p| p[k] as f64).sum::<f64>() / img.data.len() as f64;
|
|
assert!(
|
|
mean(&warm, 0) > mean(&base, 0) && mean(&warm, 2) < mean(&base, 2),
|
|
"warmer: R {} → {}, B {} → {}",
|
|
mean(&base, 0),
|
|
mean(&warm, 0),
|
|
mean(&base, 2),
|
|
mean(&warm, 2)
|
|
);
|
|
s.execute("develop.profile", &json!({"id": id, "amount": 0})).unwrap();
|
|
assert_eq!(s.render_now(photo, 48, 32).unwrap().image.data, base.data, "Amount 0 = the photo as it was");
|
|
// the library keeps it
|
|
drop(s);
|
|
lightcraft_pipeline::lut::unregister(&id);
|
|
let mut s = Session::new().with_fs();
|
|
s.open_library(&lib, false).unwrap();
|
|
assert!(s.lut_profiles.iter().any(|p| p.id == id));
|
|
assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open");
|
|
s.execute("profile.deleteImported", &json!({"id": id})).unwrap();
|
|
assert!(lightcraft_pipeline::lut::get(&id).is_none());
|
|
let _ = std::fs::remove_dir_all(&src);
|
|
let _ = std::fs::remove_dir_all(&lib);
|
|
}
|
|
|
|
/// Folder rename / move on disk: files and sidecars go along, the photos are relinked and
|
|
/// still render; clashes and moving into itself are refused.
|
|
#[test]
|
|
fn folders_rename_and_move_with_their_photos() {
|
|
let root = temp_dir("folders");
|
|
let a = root.join("Trip");
|
|
write_png(&a.join("one.png"), 3);
|
|
std::fs::write(a.join("one.xmp"), "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>").unwrap();
|
|
std::fs::create_dir_all(root.join("Taken")).unwrap();
|
|
let mut s = Session::new().with_fs();
|
|
s.execute("library.import", &json!({"paths": [a.to_string_lossy()]})).unwrap();
|
|
let id = s.active().unwrap();
|
|
assert!(s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Taken"})).is_err(), "name taken");
|
|
assert!(s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "a/b"})).is_err());
|
|
let r = s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Italy 2026"})).unwrap();
|
|
assert_eq!(r["relinked"], 1);
|
|
let b = root.join("Italy 2026");
|
|
assert!(b.join("one.png").exists() && b.join("one.xmp").exists() && !a.exists());
|
|
let path = |s: &Session| match &s.catalog.photo(id).unwrap().source {
|
|
lightcraft_catalog::Source::File { path } => path.clone(),
|
|
_ => panic!(),
|
|
};
|
|
assert_eq!(path(&s), b.join("one.png").to_string_lossy());
|
|
s.media.forget(id);
|
|
assert!(s.render_now(id, 16, 16).is_ok());
|
|
assert!(s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": b.join("deeper").to_string_lossy()})).is_err(), "not into itself");
|
|
let r = s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": root.join("Archive").to_string_lossy()})).unwrap();
|
|
assert_eq!(r["relinked"], 1);
|
|
assert_eq!(path(&s), root.join("Archive/Italy 2026/one.png").to_string_lossy());
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
/// Assisted culling: a burst (same scene, seconds apart) of a sharp and two soft shots, plus an
|
|
/// unrelated photo: the burst is grouped, the sharp one is its best, blurry shots can be rejected.
|
|
#[test]
|
|
fn assisted_culling_groups_bursts_and_scores_focus() {
|
|
let dir = temp_dir("cull");
|
|
let scene = lightcraft_scenes::demo_library()[0].render(480, 320);
|
|
let soften = |img: &lightcraft_raster::Rgb32f, r: usize| {
|
|
let (w, h) = (img.width, img.height);
|
|
let mut out = img.clone();
|
|
for y in 0..h {
|
|
for x in 0..w {
|
|
let (mut acc, mut n) = ([0f32; 3], 0.0);
|
|
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
|
|
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
|
|
let c = img.data[yy * w + xx];
|
|
acc = [acc[0] + c[0], acc[1] + c[1], acc[2] + c[2]];
|
|
n += 1.0;
|
|
}
|
|
}
|
|
out.data[y * w + x] = acc.map(|v| v / n);
|
|
}
|
|
}
|
|
out
|
|
};
|
|
let save = |img: &lightcraft_raster::Rgb32f, name: &str| {
|
|
let data: Vec<[u8; 4]> = img
|
|
.data
|
|
.iter()
|
|
.map(|c| {
|
|
let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8;
|
|
[e(c[0]), e(c[1]), e(c[2]), 255]
|
|
})
|
|
.collect();
|
|
let png = lightcraft_codecs::encode_png(
|
|
&lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }),
|
|
&Default::default(),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(dir.join(name), png).unwrap();
|
|
};
|
|
save(&soften(&scene, 3), "a_soft.png");
|
|
save(&scene, "b_sharp.png");
|
|
save(&soften(&scene, 6), "c_softer.png");
|
|
save(&lightcraft_scenes::demo_library()[7].render(480, 320), "d_other.png");
|
|
let mut s = Session::new().with_fs();
|
|
let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap();
|
|
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
|
|
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
|
|
for (i, n) in ["a_soft.png", "b_sharp.png", "c_softer.png"].iter().enumerate() {
|
|
let id = by_name(&s, n);
|
|
s.commit("t", lightcraft_catalog::Op::SetCaptured { id, captured: Some(format!("2026-05-01T10:00:0{i}")) }).unwrap();
|
|
}
|
|
let other = by_name(&s, "d_other.png");
|
|
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: other, captured: Some("2026-05-01T10:00:04".into()) }).unwrap();
|
|
let r = s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": 0.0, "pickBest": true})).unwrap();
|
|
assert_eq!(r["groups"], 1, "{r}");
|
|
let a = |s: &Session, n: &str| s.catalog.photo(by_name(s, n)).unwrap().analysis.unwrap();
|
|
let (soft, sharp, softer, odd) = (a(&s, "a_soft.png"), a(&s, "b_sharp.png"), a(&s, "c_softer.png"), a(&s, "d_other.png"));
|
|
eprintln!("sharpness: sharp {} soft {} softer {} other {}", sharp.sharpness, soft.sharpness, softer.sharpness, odd.sharpness);
|
|
assert!(sharp.sharpness > soft.sharpness && soft.sharpness > softer.sharpness);
|
|
assert!(sharp.best && !soft.best && sharp.group.is_some() && sharp.group == softer.group);
|
|
assert!(odd.group.is_none(), "an unrelated photo isn't in the burst");
|
|
assert_eq!(s.catalog.photo(by_name(&s, "b_sharp.png")).unwrap().flag, lightcraft_catalog::Flag::Pick);
|
|
// reject the blurry ones (by score), as a rule too
|
|
let cut = (soft.sharpness + sharp.sharpness) / 2.0;
|
|
s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": cut})).unwrap();
|
|
assert_eq!(s.catalog.photo(by_name(&s, "c_softer.png")).unwrap().flag, lightcraft_catalog::Flag::Reject);
|
|
s.execute("library.filter", &json!({"ruleSet": {"rules": [{"field": "bestOfGroup", "op": "is", "value": true}]}})).unwrap();
|
|
let vis = s.visible_cloned();
|
|
assert!(vis.contains(&by_name(&s, "b_sharp.png")) && !vis.contains(&by_name(&s, "a_soft.png")) && vis.contains(&other));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step).
|
|
#[test]
|
|
fn dust_spots_are_found_and_healed() {
|
|
let dir = temp_dir("dust");
|
|
let (w, h) = (900usize, 600usize);
|
|
let spots = [(180.0f32, 140.0f32), (600.0, 300.0)];
|
|
let data: Vec<[u8; 4]> = (0..w * h)
|
|
.map(|i| {
|
|
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
|
let mut v = 0.5 + 0.3 * (y / h as f32);
|
|
for (cx, cy) in spots {
|
|
v -= 0.07 * (-((x - cx).powi(2) + (y - cy).powi(2)) / (2.0 * 25.0)).exp();
|
|
}
|
|
let b = (v * 255.0) as u8;
|
|
[b, b, (b as f32 * 1.15).min(255.0) as u8, 255]
|
|
})
|
|
.collect();
|
|
let png = lightcraft_codecs::encode_png(
|
|
&lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: w, height: h, data }),
|
|
&Default::default(),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(dir.join("sky.png"), png).unwrap();
|
|
let mut s = Session::new().with_fs();
|
|
s.execute("library.import", &json!({"paths": [dir.join("sky.png").to_string_lossy()]})).unwrap();
|
|
let id = s.active().unwrap();
|
|
let before = s.render_now(id, 900, 600).unwrap().image;
|
|
let r = s.execute("spot.findDust", &json!({})).unwrap();
|
|
assert_eq!(r["added"], 2, "{r}");
|
|
let after = s.render_now(id, 900, 600).unwrap().image;
|
|
let at = |img: &lightcraft_raster::Rgba8, x: usize, y: usize| img.data[y * 900 + x][0] as i32;
|
|
for (cx, cy) in spots {
|
|
assert!(at(&after, cx as usize, cy as usize) > at(&before, cx as usize, cy as usize) + 6, "the spot at {cx},{cy} is lifted");
|
|
}
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
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());
|
|
}
|
|
|
|
/// Missing Photos covers library photos only: a small library next to many Local browse
|
|
/// records (and a deleted photo) checks — and lists, and counts — the library files alone.
|
|
#[test]
|
|
fn missing_photos_skip_local_browse_records() {
|
|
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
|
let gone = std::env::temp_dir().join(format!("lc-missing-scope-{}", std::process::id()));
|
|
let mut s = Session::new();
|
|
let mut ops = Vec::new();
|
|
for i in 0..205u64 {
|
|
let path = gone.join(format!("IMG_{i:04}.jpg")).to_string_lossy().to_string();
|
|
let mut p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:04}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
|
|
p.local = i >= 5; // 5 library photos, 200 seen while browsing
|
|
p.deleted = i == 4;
|
|
ops.push(Op::AddPhoto { photo: Box::new(p) });
|
|
}
|
|
s.commit("Add", Op::Batch { ops }).unwrap();
|
|
let mut checked = 0;
|
|
let lost = crate::cmd::missing::missing_with(&s.catalog, |_| {
|
|
checked += 1;
|
|
false
|
|
});
|
|
assert_eq!((checked, lost.len()), (4, 4), "only the non-deleted library files are checked");
|
|
assert_eq!(crate::cmd::missing::candidates(&s.catalog).len(), 4, "the sidebar count checks the same files");
|
|
assert_eq!(s.execute("library.missing", &json!({})).unwrap().as_array().map(Vec::len), Some(4));
|
|
s.execute("library.source", &json!({"kind": "missing"})).unwrap();
|
|
assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts");
|
|
}
|