mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
Merge origin/main into slideshow-playback (icons: Play/Pause beside grading and appearance icons; settings: Slideshow tab beside appearance cards; render: Slide and CompareRegion slots; tracker summary and whats-new)
This commit is contained in:
@@ -39,7 +39,9 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let report = import_with(s, &files, &ImportOptions { mode: ImportMode::Add, local: true, ..Default::default() })?;
|
||||
// looking at a folder never brings its deleted photos back (importing the files does)
|
||||
let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() };
|
||||
let report = import_with(s, &files, &opts)?;
|
||||
stamp_browsed(s, &dir_s, &files);
|
||||
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
|
||||
s.source = LibrarySource::Folder;
|
||||
@@ -105,7 +107,7 @@ impl Session {
|
||||
let smart = self.media.smart_dir.clone();
|
||||
let plan = self
|
||||
.catalog
|
||||
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| d.join(crate::smart::file_name(p)).exists()));
|
||||
.forget_local_plan(&now, days, &|p| keep.contains(&p.id) || smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists()));
|
||||
if dry_run {
|
||||
return plan;
|
||||
}
|
||||
@@ -195,6 +197,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"path": to, "relinked": n}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"folder.create",
|
||||
"New Folder on Disk",
|
||||
[],
|
||||
None,
|
||||
"{parent, name} — create a folder on disk inside `parent` (refused if the name is taken); one undo step (undo removes it again while it is empty) → {path}",
|
||||
always,
|
||||
create_folder
|
||||
),
|
||||
cmd!(
|
||||
"photo.moveToFolder",
|
||||
"Move to Folder",
|
||||
[],
|
||||
None,
|
||||
"{folder, ids?} — move the photos' files (default: the selection) with their XMP sidecars into `folder` on disk and relink them; virtual copies follow their master; across volumes the file is copied, verified and only then removed; never overwrites (a name taken in `folder` refuses the whole move); missing/offline and Local photos are refused; one undo step (undo moves them back) → {moved, folder}",
|
||||
always,
|
||||
move_to_folder
|
||||
),
|
||||
cmd!(
|
||||
"library.browse",
|
||||
"Browse Folder",
|
||||
@@ -249,6 +269,127 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result
|
||||
std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))
|
||||
}
|
||||
|
||||
/// Do an undo step's folder change on disk: rename `from` → `to`; with `to` empty, remove the
|
||||
/// empty folder `from`; with `from` empty, create the folder `to`. Never overwrites.
|
||||
pub(crate) fn folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> {
|
||||
match (from.is_empty(), to.is_empty()) {
|
||||
(false, false) => rename_folder_on_disk(from, to),
|
||||
(false, true) => remove_empty_folder(from),
|
||||
(true, false) => {
|
||||
if Path::new(to).exists() {
|
||||
return Err(format!("{to} already exists"));
|
||||
}
|
||||
std::fs::create_dir(to).map_err(|e| format!("{to}: {e}"))
|
||||
}
|
||||
(true, true) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an empty folder; one with anything in it (hidden files too) is refused.
|
||||
fn remove_empty_folder(path: &str) -> std::result::Result<(), String> {
|
||||
let p = Path::new(path);
|
||||
if !p.is_dir() {
|
||||
return Err(format!("{path} is not a folder"));
|
||||
}
|
||||
let mut entries = std::fs::read_dir(p).map_err(|e| format!("{path}: {e}"))?;
|
||||
if entries.next().is_some() {
|
||||
return Err(format!("{path} is not empty"));
|
||||
}
|
||||
std::fs::remove_dir(p).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// A folder path parameter without trailing separators (`None` when missing or blank).
|
||||
fn folder_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
|
||||
str_param(p, key).map(|x| x.trim_end_matches(['/', '\\'])).filter(|x| !x.is_empty())
|
||||
}
|
||||
|
||||
/// `folder.create`: a new folder on disk inside `parent`; undo removes it while it is empty.
|
||||
fn create_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "folder.create";
|
||||
let parent = folder_param(p, "parent").ok_or_else(|| bad(C, "missing parent"))?;
|
||||
let name = str_param(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..")
|
||||
.ok_or_else(|| bad(C, "give a folder name (no slashes)"))?;
|
||||
if !Path::new(parent).is_dir() {
|
||||
return Err(bad(C, format!("{parent} is not a folder (offline, moved or deleted?)")));
|
||||
}
|
||||
let path = Path::new(parent).join(name).to_string_lossy().to_string();
|
||||
if Path::new(&path).exists() {
|
||||
return Err(bad(C, format!("{path} already exists: choose another name")));
|
||||
}
|
||||
folder_on_disk("", &path).map_err(|e| bad(C, e))?;
|
||||
let undo = crate::FolderMove { from: path.clone(), to: String::new() };
|
||||
if let Err(e) = s.commit_with_folder(&format!("Create Folder “{name}”"), Op::Batch { ops: vec![] }, undo) {
|
||||
let _ = remove_empty_folder(&path);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
|
||||
/// `photo.moveToFolder`: move the photos' files (with their sidecars) into `folder` and relink
|
||||
/// them, as one undo step. Virtual copies follow their master's file. Never overwrites: a name
|
||||
/// already taken in `folder` refuses the whole move.
|
||||
fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.moveToFolder";
|
||||
let folder = folder_param(p, "folder").ok_or_else(|| bad(C, "missing folder"))?;
|
||||
let dir = Path::new(folder);
|
||||
if !dir.is_dir() {
|
||||
return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)")));
|
||||
}
|
||||
let ids = s.targets(p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no photos to move: select some or give ids"));
|
||||
}
|
||||
let mut plans: Vec<crate::rename::RenamePlan> = Vec::new();
|
||||
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut clashes: Vec<String> = Vec::new();
|
||||
for id in ids {
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
|
||||
if ph.local {
|
||||
return Err(bad(C, format!("{} is not in the library (Local): add it first", ph.file_name)));
|
||||
}
|
||||
let lightcraft_catalog::Source::File { path } = &ph.source else {
|
||||
return Err(bad(C, format!("{} has no file on disk", ph.file_name)));
|
||||
};
|
||||
if plans.iter().any(|pl| pl.from_path.as_deref() == Some(path.as_str())) {
|
||||
continue; // a virtual copy of a photo already moving
|
||||
}
|
||||
let src = Path::new(path);
|
||||
if !src.is_file() {
|
||||
return Err(bad(C, format!("{path} is missing or offline: find it first (Find Missing Photos)")));
|
||||
}
|
||||
let Some(file) = src.file_name() else { return Err(bad(C, format!("{path}: no file name"))) };
|
||||
if src.parent() == Some(dir) {
|
||||
continue; // already there
|
||||
}
|
||||
let to = dir.join(file);
|
||||
let to_s = to.to_string_lossy().to_string();
|
||||
if to.exists() || !taken.insert(to_s.to_lowercase()) {
|
||||
clashes.push(file.to_string_lossy().to_string());
|
||||
continue;
|
||||
}
|
||||
let name = ph.file_name.clone();
|
||||
plans.push(crate::rename::RenamePlan { id: id.0, from: name.clone(), to: name, from_path: Some(path.clone()), to_path: Some(to_s) });
|
||||
}
|
||||
if !clashes.is_empty() {
|
||||
let s_ = if clashes.len() == 1 { "" } else { "s" };
|
||||
return Err(bad(
|
||||
C,
|
||||
format!("{} already in {folder}: nothing was moved. Rename the photo{s_} first or choose another folder", clashes.join(", ")),
|
||||
));
|
||||
}
|
||||
let n = plans.len();
|
||||
if n == 0 {
|
||||
return Ok(json!({"moved": 0, "folder": folder}));
|
||||
}
|
||||
let plural = |k: usize| if k == 1 { "" } else { "s" };
|
||||
let _task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No);
|
||||
let name = dir.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| folder.to_string());
|
||||
s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)))?;
|
||||
Ok(json!({"moved": n, "folder": folder}))
|
||||
}
|
||||
|
||||
/// After a folder moved from `from` to `to`: a browsed folder at or below it follows.
|
||||
pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
|
||||
// a library folder chosen at or below it follows too, as a source or as a filter
|
||||
@@ -285,6 +426,9 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
|
||||
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
// Repairs retain their exact old paths, including those on surviving history entries.
|
||||
// Complete them before the directory moves; an unavailable repair leaves it in place.
|
||||
s.repair_sidecars_with(&crate::rename::RealFs).map_err(|e| e.to_string())?;
|
||||
rename_folder_on_disk(from, to)?;
|
||||
let mut ops: Vec<Op> = s
|
||||
.catalog
|
||||
|
||||
@@ -120,8 +120,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit
|
||||
/// TIFF next to the original (`<name>-Edit.tif`, never overwriting), add it to the library and
|
||||
/// stack it on top of the original. The host opens the file in the editor.
|
||||
/// TIFF (or PSD, or 8-bit) next to the original (`<name>-Edit.tif`, never overwriting), add it to
|
||||
/// the library and stack it on top of the original. The host opens the file in the editor.
|
||||
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.editExternal";
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
@@ -139,9 +139,19 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto,
|
||||
_ => lightcraft_pipeline::OutputSpace::AdobeRgb,
|
||||
};
|
||||
let format = match super::str_param(p, "format").unwrap_or("tiff") {
|
||||
"tiff" | "tif" => crate::export::ExportFormat::Tiff,
|
||||
"psd" => crate::export::ExportFormat::Psd,
|
||||
f => return Err(bad(C, format!("format {f:?}: use tiff or psd"))),
|
||||
};
|
||||
let bit_depth = match p.get("bitDepth").and_then(Value::as_u64).unwrap_or(16) {
|
||||
8 => 8,
|
||||
16 => 16,
|
||||
d => return Err(bad(C, format!("bitDepth {d}: use 8 or 16"))),
|
||||
};
|
||||
let opts = crate::export::ExportOptions {
|
||||
format: crate::export::ExportFormat::Tiff,
|
||||
bit_depth: Some(16),
|
||||
format,
|
||||
bit_depth: Some(bit_depth),
|
||||
color_space: space,
|
||||
naming: "{name}-Edit".into(),
|
||||
conflict: crate::export::Conflict::Unique,
|
||||
@@ -156,7 +166,8 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let to = crate::export::Destination { dir, exact: None };
|
||||
crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
|
||||
let out = written.into_iter().find(|w| w.ends_with(".tif")).ok_or_else(|| bad(C, "nothing was written"))?;
|
||||
let ext = format!(".{}", format.extension());
|
||||
let out = written.into_iter().find(|w| w.ends_with(&ext)).ok_or_else(|| bad(C, "nothing was written"))?;
|
||||
let r = s.execute("library.import", &json!({"paths": [out]}))?;
|
||||
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?;
|
||||
// stack: the edit on top of the original, expanded so both show
|
||||
@@ -229,6 +240,14 @@ pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog:
|
||||
ops
|
||||
}
|
||||
|
||||
/// The op that gives a raw the as-shot white balance its file reads now (`info`, a fresh probe), or none when the
|
||||
/// catalog already has it or the photo is not a raw edited in Kelvin. A raw imported before its camera's matrices
|
||||
/// gave it a Kelvin scale keeps the relative scale's 6500 / 0 until a decode or Reload reads the file (issue #730).
|
||||
pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Option<Op> {
|
||||
let read = info.as_shot_wb.filter(|(t, tint)| t.is_finite() && tint.is_finite())?;
|
||||
(ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) })
|
||||
}
|
||||
|
||||
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
||||
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -248,14 +267,16 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
|
||||
// nothing already set is overwritten
|
||||
let meta_ops = fill_missing_meta(id, ph, &info);
|
||||
let mut mine: Vec<Op> = content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).collect();
|
||||
let mut mine: Vec<Op> =
|
||||
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect();
|
||||
if !mine.is_empty() {
|
||||
ops.append(&mut mine);
|
||||
reloaded.push(id);
|
||||
}
|
||||
// virtual copies share the file
|
||||
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
||||
let theirs: Vec<Op> = content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).collect();
|
||||
let theirs: Vec<Op> =
|
||||
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect();
|
||||
if !theirs.is_empty() {
|
||||
ops.extend(theirs);
|
||||
reloaded.push(c.id);
|
||||
@@ -342,7 +363,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
"Edit Copy for External Editor",
|
||||
[],
|
||||
None,
|
||||
"{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `<name>-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)",
|
||||
"{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, format?: tiff (default) | psd, bitDepth?: 16 (default) | 8, dir?} — render the active photo with its edits as `<name>-Edit.tif` (or `.psd`, flat, which PhotoCraft saves back to in place) next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)",
|
||||
has_active,
|
||||
edit_external
|
||||
),
|
||||
@@ -381,6 +402,7 @@ mod tests {
|
||||
lightcraft_develop::EmbeddedLens {
|
||||
warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }),
|
||||
vignette: None,
|
||||
stages: Default::default(),
|
||||
}
|
||||
}
|
||||
fn old_rw2() -> lightcraft_catalog::Photo {
|
||||
|
||||
@@ -653,7 +653,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(query "denoise.queue", "Denoise Photos", [], None, "{ids?: [photoId], scope?: selected | visible | withAmount, retry?: false} → {added, …status} — make the denoised pictures of raw photos that have none, in the background. `retry` tries photos that failed before", always, queue),
|
||||
cmd!(query "denoise.cancel", "Cancel Denoise", [], None, "{id?, all?} → {cancelled} — stop the photo being made and/or forget the ones waiting (all of them when `id` is omitted)", always, cancel),
|
||||
cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status),
|
||||
cmd!(query "denoise.status", "Denoise Status", [], None, "{id?} → {enabled, model, runtime, productsDir, queued, running: {photo, done, total, seconds} | null, ready, made, auto, runOn, device: {kind: gpu | cpu | pending | none, adapter?, reason?, cardMs?, cpuMs?, fellBack?, remote?: {address, state: pending | unavailable | used | idle | slower | given up, device?, remoteMs?, localMs?, reason?, failures?, lastError?}}, cache: {files, bytes, limitBytes}, generation, photo?: {state: notApplicable | noModel | ready | queued | running | failed | idle, …}}", always, status),
|
||||
cmd!(query "denoise.pump", "Denoise Pump", [], None, "{pace?: pause | light | normal | full} → {active, running, queued, ready, generation, downloading} — called every frame by the app: takes in finished work and keeps one photo going", always, pump),
|
||||
cmd!(query "denoise.clear", "Clear Denoise Cache", [], None, "{} → {deleted, bytes} — delete every cached denoised picture of this library (they are made again when wanted)", always, clear),
|
||||
]
|
||||
|
||||
@@ -37,6 +37,17 @@ fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
|
||||
if o.swaps_axes() { (h, w) } else { (w, h) }
|
||||
}
|
||||
|
||||
/// After a generic control write (`develop.set`, `develop.adjust`, …) that touched `crop.angle`:
|
||||
/// refit the crop as `crop.straighten` does, from the crop it had `before`, so the box never takes in
|
||||
/// canvas outside the photo (issue #742).
|
||||
fn refit_crop_angle(touched: bool, before: CropGeometry, d: &mut DevelopSettings, (w, h): (f64, f64)) {
|
||||
if touched {
|
||||
d.crop.geometry = before.with_angle(d.crop.geometry.angle, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
const CROP_ANGLE: &str = "crop.angle";
|
||||
|
||||
/// `v` as a list of numbers; `None` unless it is an array of nothing but numbers.
|
||||
fn numbers(v: Option<&Value>) -> Option<Vec<f64>> {
|
||||
v?.as_array()?.iter().map(Value::as_f64).collect()
|
||||
@@ -146,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Develop Values",
|
||||
[],
|
||||
None,
|
||||
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls",
|
||||
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls; `crop.angle` turns the crop as crop.straighten does (kept if it still fits, else the largest of its aspect inside the photo)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let mut vals: Vec<(String, f64)> = Vec::new();
|
||||
@@ -167,10 +178,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
|
||||
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
|
||||
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
let angle = vals.iter().any(|(k, _)| k == CROP_ANGLE);
|
||||
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo, dims: (f64, f64)| {
|
||||
let before = d.crop.geometry;
|
||||
if vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
|
||||
lightcraft_pipeline::local::normalize_wb(d, info);
|
||||
if d.wb.mode == WbMode::AsShot {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
}
|
||||
for (k, v) in &vals {
|
||||
controls::set(d, k, *v);
|
||||
@@ -178,6 +194,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
d.wb.mode = WbMode::Custom;
|
||||
}
|
||||
}
|
||||
refit_crop_angle(angle, before, d, dims);
|
||||
};
|
||||
if let Some(ids) = p.get("ids").and_then(Value::as_array) {
|
||||
let ids: Vec<PhotoId> = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect();
|
||||
@@ -187,7 +204,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|(id, d)| {
|
||||
let mut d = (*d).clone();
|
||||
let info = s.source_info(id);
|
||||
apply(&mut d, &info);
|
||||
apply(&mut d, &info, crop_dims(s, id));
|
||||
s.develop_op(id, d, &label)
|
||||
})
|
||||
.collect();
|
||||
@@ -196,8 +213,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let id = active(s, "develop.set")?;
|
||||
let info = s.source_info(id);
|
||||
let dims = crop_dims(s, id);
|
||||
edit(s, "develop.set", &label, |d| {
|
||||
apply(d, &info);
|
||||
apply(d, &info, dims);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -206,9 +224,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string();
|
||||
let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?;
|
||||
let delta = f64_req(p, "delta", "develop.adjust")?;
|
||||
let info = s.source_info(active(s, "develop.adjust")?);
|
||||
let id = active(s, "develop.adjust")?;
|
||||
let (info, dims) = (s.source_info(id), crop_dims(s, id));
|
||||
edit(s, "develop.adjust", spec.label, |d| {
|
||||
let before = d.crop.geometry;
|
||||
if c == "wb.temp" || c == "wb.tint" {
|
||||
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||||
if d.wb.mode == WbMode::AsShot {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
@@ -217,6 +238,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let v = controls::get(d, &c).unwrap_or(spec.default);
|
||||
controls::set(d, &c, v + delta);
|
||||
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -244,10 +266,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// of its current as-shot reference
|
||||
let info = s.source_info(id);
|
||||
let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default();
|
||||
DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
|
||||
let mut fresh = DevelopSettings {
|
||||
wb: lightcraft_develop::WhiteBalance {
|
||||
mode: WbMode::AsShot,
|
||||
temp: info.as_shot_temp,
|
||||
tint: info.as_shot_tint,
|
||||
..Default::default()
|
||||
},
|
||||
..defaults
|
||||
}
|
||||
};
|
||||
lightcraft_pipeline::local::normalize_wb(&mut fresh, &info);
|
||||
fresh
|
||||
}
|
||||
};
|
||||
// a fresh start renders with the current process (a section or slider reset doesn't change it)
|
||||
@@ -306,8 +335,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("develop.resetControl", "Reset Control", [], None, "{control}", has_active, |s, p| {
|
||||
let c = str_param(p, "control").ok_or_else(|| bad("develop.resetControl", "missing control"))?.to_string();
|
||||
let spec = controls::find(&c).ok_or_else(|| bad("develop.resetControl", "unknown control"))?;
|
||||
let dims = crop_dims(s, active(s, "develop.resetControl")?);
|
||||
edit(s, "develop.resetControl", spec.label, |d| {
|
||||
let before = d.crop.geometry;
|
||||
controls::set(d, &c, spec.default);
|
||||
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -381,7 +413,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
m => m
|
||||
.preset()
|
||||
.map(|(t, ti)| {
|
||||
// presets are relative to daylight for rendered files
|
||||
// presets are Kelvin on raws with that scale, relative to daylight (6500 K = as shot) elsewhere
|
||||
if info.raw && !info.relative_wb { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
|
||||
})
|
||||
.unwrap_or((info.as_shot_temp, info.as_shot_tint)),
|
||||
@@ -391,6 +423,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// The resolved temperature/tint is stored, so rendering never depends on the mode; the
|
||||
// mode is kept for the UI's WB dropdown.
|
||||
edit(s, "develop.wb", "White Balance", |d| {
|
||||
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||||
d.wb.mode = mode;
|
||||
d.wb.temp = t.clamp(2000.0, 50000.0);
|
||||
d.wb.tint = tint.clamp(-150.0, 150.0);
|
||||
@@ -420,6 +453,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let patch = lightcraft_raster::Rgb32f::filled(4, 4, acc);
|
||||
let (t, tint) = lightcraft_pipeline::auto::auto_wb(&patch, &info);
|
||||
edit(s, "develop.wbPick", "White Balance", |d| {
|
||||
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||||
d.wb.mode = WbMode::Custom;
|
||||
d.wb.temp = t;
|
||||
d.wb.tint = tint;
|
||||
@@ -493,10 +527,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
d.crop.geometry.rect = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
|
||||
}
|
||||
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
|
||||
d.crop.geometry.angle = a.clamp(-45.0, 45.0);
|
||||
}
|
||||
d.crop.geometry = d.crop.geometry.constrained(w, h);
|
||||
let angle = p.get("angle").and_then(Value::as_f64).map(|a| a.clamp(-45.0, 45.0));
|
||||
d.crop.geometry = match (p.get("rect"), angle) {
|
||||
// a new angle alone turns the crop as Straighten does (issue #742)
|
||||
(None, Some(a)) => d.crop.geometry.with_angle(a, w, h),
|
||||
(_, a) => CropGeometry { angle: a.unwrap_or(d.crop.geometry.angle), ..d.crop.geometry }.constrained(w, h),
|
||||
};
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -505,7 +541,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Drag Crop Handle",
|
||||
[],
|
||||
None,
|
||||
"{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock",
|
||||
"{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop), keepRatio?: bool (default false)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock; keepRatio holds the frame's current proportions for this drag even with the lock off (the UI sends it while Shift is held)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "crop.drag")?;
|
||||
@@ -530,11 +566,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
_ => return Err(bad("crop.drag", "`start` needs 4 numbers")),
|
||||
};
|
||||
let keep_ratio = bool_or(p, "keepRatio", false);
|
||||
edit(s, "crop.drag", "Crop", |d| {
|
||||
let start = CropGeometry { rect: given.unwrap_or(d.crop.geometry.rect), angle: d.crop.geometry.angle };
|
||||
// A locked crop keeps the shape it has: the lock stores a width:height tuple whose
|
||||
// orientation is reinterpreted per image, so the frame itself is the truth.
|
||||
let ratio = d.crop.aspect.and(Some(start.rect_px(w, h).aspect())).filter(|a| a.is_finite() && *a > 0.0);
|
||||
// `keepRatio` (Shift held) asks for the same thing for one drag without touching the lock.
|
||||
let ratio = (d.crop.aspect.is_some() || keep_ratio).then(|| start.rect_px(w, h).aspect()).filter(|a| a.is_finite() && *a > 0.0);
|
||||
d.crop.geometry = drag_crop(start, handle, from, to, w, h, ratio);
|
||||
Ok(())
|
||||
})
|
||||
@@ -552,11 +590,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (w, h) = crop_dims(s, id);
|
||||
let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0);
|
||||
edit(s, "crop.straighten", "Straighten", |d| {
|
||||
let r = d.crop.geometry.rect_px(w, h);
|
||||
let fit = crop_fit_angle(w, h, angle, Some(r.width() / r.height()));
|
||||
// keep the current crop size if it still fits, else shrink
|
||||
let cand = CropGeometry { rect: d.crop.geometry.rect, angle };
|
||||
d.crop.geometry = if cand.is_within_image(w, h) { cand } else { fit };
|
||||
// keep the current crop if it still fits, else the largest of its aspect that does
|
||||
d.crop.geometry = d.crop.geometry.with_angle(angle, w, h);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -851,13 +886,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
let mut nd = (*d).clone();
|
||||
if nd.wb.mode == WbMode::AsShot && (ctl == "wb.temp" || ctl == "wb.tint") {
|
||||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||||
// each photo on its own scale: Kelvin (an old relative value converted first) or relative
|
||||
let info = s.source_info(id);
|
||||
nd.wb.temp = info.as_shot_temp;
|
||||
nd.wb.tint = info.as_shot_tint;
|
||||
lightcraft_pipeline::local::normalize_wb(&mut nd, &info);
|
||||
if nd.wb.mode == WbMode::AsShot {
|
||||
nd.wb.temp = info.as_shot_temp;
|
||||
nd.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
}
|
||||
let before = nd.crop.geometry;
|
||||
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
|
||||
controls::set(&mut nd, &ctl, v + delta);
|
||||
refit_crop_angle(ctl == CROP_ANGLE, before, &mut nd, crop_dims(s, id));
|
||||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||||
nd.wb.mode = WbMode::Custom;
|
||||
}
|
||||
|
||||
@@ -464,6 +464,8 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.embedder.as_deref() == Some(id) {
|
||||
// another installed recogniser takes over, so recognition keeps working; with none left nothing is chosen
|
||||
st.embedder = installed_models(&dir).into_iter().find(|i| i.manifest.id != id && i.manifest.role == Role::Embedder).map(|i| i.manifest.id);
|
||||
// on with nothing to run is not a state (issue #678)
|
||||
st.enabled &= st.embedder.is_some();
|
||||
write_settings(&dir, &st)?;
|
||||
}
|
||||
Ok(json!({"removed": id, "embedder": st.embedder}))
|
||||
@@ -491,7 +493,15 @@ fn enable(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.enable";
|
||||
let dir = models_dir(s, C)?;
|
||||
let mut st = read_settings(&dir);
|
||||
st.enabled = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled);
|
||||
let on = p.get("enabled").and_then(Value::as_bool).unwrap_or(!st.enabled);
|
||||
// on means something only with a model to run (issue #678): a toggle that is on with nothing
|
||||
// chosen would sit there doing nothing, with no word about why
|
||||
let usable =
|
||||
st.embedder.as_deref().is_some_and(|id| installed_models(&dir).iter().any(|i| i.manifest.id == id && i.manifest.role == Role::Embedder));
|
||||
if on && !usable {
|
||||
return Err(bad(C, "choose a recognition model first (Settings ▸ Faces ▸ Use)"));
|
||||
}
|
||||
st.enabled = on;
|
||||
write_settings(&dir, &st)?;
|
||||
// the background scan notices at once, not within the second it trusts its last look
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Reverse geocoding (issue #582): fill City / State / Country from a photo's GPS position, offline, with the
|
||||
//! GeoNames place names ([`lightcraft_geocode`]). The place names (CC-BY 4.0) are not shipped with the app: the user
|
||||
//! downloads them once (`metadata.downloadPlaceNames`, ~3 MB from download.geonames.org) into the settings folder;
|
||||
//! after that every lookup is local and no position ever leaves the machine.
|
||||
//!
|
||||
//! Sources: GeoNames dump readme (prose); own design.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId};
|
||||
use lightcraft_geocode::{GEONAMES_ATTRIBUTION, GEONAMES_FILES, GeoNames, LatLon};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_selection, str_param};
|
||||
use crate::activity::Cancel;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Where the place names live: `dataset` (a folder) or `<settings>/geonames`.
|
||||
fn dataset_dir(p: &Value) -> Option<PathBuf> {
|
||||
str_param(p, "dataset").map(PathBuf::from).or_else(|| crate::config::config_dir().map(|d| d.join("geonames")))
|
||||
}
|
||||
|
||||
/// How a place-names download ended.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum DownloadOutcome {
|
||||
/// Done: this many places.
|
||||
Done(usize),
|
||||
/// Failed, in words the user can act on.
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DownloadShared {
|
||||
running: bool,
|
||||
last: Option<DownloadOutcome>,
|
||||
/// The last outcome, until the UI has shown it ([`PlaceNames::take_outcome`]).
|
||||
unreported: Option<DownloadOutcome>,
|
||||
}
|
||||
|
||||
/// The session's place names: the parsed gazetteer (kept while its file is unchanged) and the download state.
|
||||
#[derive(Default)]
|
||||
pub struct PlaceNames {
|
||||
shared: Arc<Mutex<DownloadShared>>,
|
||||
/// (cities file, its size, its modification time, the parsed names).
|
||||
cache: Option<(PathBuf, u64, Option<SystemTime>, Arc<GeoNames>)>,
|
||||
}
|
||||
|
||||
impl PlaceNames {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, DownloadShared> {
|
||||
self.shared.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// A download that ended since the last call (the UI shows it once: an error stays readable as a toast).
|
||||
pub fn take_outcome(&self) -> Option<DownloadOutcome> {
|
||||
self.lock().unreported.take()
|
||||
}
|
||||
|
||||
pub fn downloading(&self) -> bool {
|
||||
self.lock().running
|
||||
}
|
||||
|
||||
/// The gazetteer in `dir`, parsed once and re-read only when the cities file's size or time changes.
|
||||
fn load(&mut self, dir: &Path) -> std::result::Result<Arc<GeoNames>, lightcraft_geocode::GeocodeError> {
|
||||
let file = dir.join(GEONAMES_FILES[0]);
|
||||
let meta = std::fs::metadata(&file).ok();
|
||||
let (len, mtime) = (meta.as_ref().map_or(0, |m| m.len()), meta.as_ref().and_then(|m| m.modified().ok()));
|
||||
if let Some((f, l, t, g)) = &self.cache
|
||||
&& meta.is_some()
|
||||
&& *f == file
|
||||
&& *l == len
|
||||
&& *t == mtime
|
||||
{
|
||||
return Ok(g.clone());
|
||||
}
|
||||
let g = Arc::new(GeoNames::load(dir)?);
|
||||
self.cache = Some((file, len, mtime, g.clone()));
|
||||
Ok(g)
|
||||
}
|
||||
}
|
||||
|
||||
fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = dataset_dir(p);
|
||||
let size = dir.as_ref().and_then(|d| std::fs::metadata(d.join(GEONAMES_FILES[0])).ok()).map(|m| m.len());
|
||||
let sh = s.place_names.lock();
|
||||
let (last, error) = match &sh.last {
|
||||
None => (Value::Null, Value::Null),
|
||||
Some(DownloadOutcome::Done(n)) => (json!({"places": n}), Value::Null),
|
||||
Some(DownloadOutcome::Failed(e)) => (json!("failed"), json!(e)),
|
||||
Some(DownloadOutcome::Cancelled) => (json!("cancelled"), Value::Null),
|
||||
};
|
||||
Ok(json!({
|
||||
"downloaded": size.is_some(), "bytes": size, "path": dir, "downloading": sh.running, "lastDownload": last, "lastError": error,
|
||||
"attribution": GEONAMES_ATTRIBUTION, "source": lightcraft_geocode::GEONAMES_BASE,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Which place fields to write: a field is filled when it is empty, or always with `overwrite`.
|
||||
fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place, overwrite: bool) {
|
||||
for (field, value) in [
|
||||
(&mut meta.location, &place.sublocation),
|
||||
(&mut meta.city, &place.city),
|
||||
(&mut meta.state, &place.state),
|
||||
(&mut meta.country, &place.country),
|
||||
] {
|
||||
if !value.is_empty() && (overwrite || field.trim().is_empty()) {
|
||||
*field = value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.reverseGeocode";
|
||||
let overwrite = bool_or(p, "overwrite", false);
|
||||
let ids = s.targets(p);
|
||||
let todo: Vec<(PhotoId, LatLon)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let (la, lo) = s.catalog.photo(*id)?.meta.gps?;
|
||||
let at = LatLon::new(la, lo);
|
||||
at.is_valid().then_some((*id, at))
|
||||
})
|
||||
.collect();
|
||||
if todo.is_empty() {
|
||||
return Err(bad(C, "none of these photos has a GPS position"));
|
||||
}
|
||||
let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?;
|
||||
let names = s.place_names.load(&dir).map_err(|e| bad(C, format!("{e}: download the place names first (Photo ▸ Download Place Names)")))?;
|
||||
// a large selection shows in the activity stack and can be stopped
|
||||
let task = (todo.len() > 1).then(|| s.activity.start("geocode", "Looking up places", Cancel::Yes));
|
||||
let mut ops = Vec::new();
|
||||
let mut found = Vec::new();
|
||||
for (n, (id, at)) in todo.iter().enumerate() {
|
||||
if let Some(t) = &task {
|
||||
if t.cancel_flag().is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed)) {
|
||||
return Err(bad(C, "cancelled"));
|
||||
}
|
||||
t.progress(n as u64, todo.len() as u64);
|
||||
}
|
||||
let Some(place) = names.reverse(*at) else { continue };
|
||||
let Some(ph) = s.catalog.photo(*id) else { continue };
|
||||
let mut meta = ph.meta.clone();
|
||||
fill(&mut meta, &place, overwrite);
|
||||
if meta != ph.meta {
|
||||
ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) });
|
||||
}
|
||||
found.push(json!({"id": id.0, "place": place}));
|
||||
}
|
||||
drop(task);
|
||||
let changed = ops.len();
|
||||
if !bool_or(p, "dryRun", false) && changed > 0 {
|
||||
s.commit("Reverse Geocode", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"looked": todo.len(), "found": found.len(), "changed": changed, "photos": found, "attribution": GEONAMES_ATTRIBUTION}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod download {
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use lightcraft_geocode::{GEONAMES_BASE, GEONAMES_ZIP};
|
||||
|
||||
use super::*;
|
||||
use crate::activity::Unit;
|
||||
|
||||
const MAX_ZIP: u64 = 64 * 1024 * 1024;
|
||||
const MAX_TEXT: usize = 256 * 1024 * 1024;
|
||||
|
||||
fn get(name: &str, base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result<(), String> {
|
||||
let _ = std::fs::remove_file(dir.join(name));
|
||||
let file = lightcraft_fetch::FileSpec { name, size: None, sha256: None, max: MAX_ZIP };
|
||||
lightcraft_fetch::download_url(&file, &format!("{base}{name}"), dir, &lightcraft_fetch::Options::default(), cancel, &mut |pr| {
|
||||
task.progress(pr.done, pr.total.max(pr.done))
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch and unpack the place names into `dir`; the new files replace the old ones only when complete.
|
||||
pub(super) fn run(base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result<usize, String> {
|
||||
let staging = dir.join(".download");
|
||||
std::fs::create_dir_all(&staging).map_err(|e| format!("could not create {}: {e}", staging.display()))?;
|
||||
get(GEONAMES_ZIP, base, &staging, cancel, task)?;
|
||||
let zip = std::fs::read(staging.join(GEONAMES_ZIP)).map_err(|e| e.to_string())?;
|
||||
let cities = lightcraft_geocode::unzip::extract(&zip, GEONAMES_FILES[0], MAX_TEXT).map_err(|e| e.to_string())?;
|
||||
let places = GeoNames::parse(&String::from_utf8_lossy(&cities), None, None).len();
|
||||
if places == 0 {
|
||||
return Err("the downloaded place list is empty".into());
|
||||
}
|
||||
let _ = std::fs::remove_file(staging.join(GEONAMES_ZIP));
|
||||
let write = |name: &str, data: &[u8]| std::fs::write(dir.join(name), data).map_err(|e| format!("could not save {name}: {e}"));
|
||||
for name in &GEONAMES_FILES[1..] {
|
||||
// optional: without them states stay blank and countries are ISO codes
|
||||
match get(name, base, &staging, cancel, task).and_then(|()| std::fs::read(staging.join(name)).map_err(|e| e.to_string())) {
|
||||
Ok(b) => write(name, &b)?,
|
||||
Err(e) => log::warn!("place names: {name}: {e}"),
|
||||
}
|
||||
let _ = std::fs::remove_file(staging.join(name));
|
||||
}
|
||||
write(GEONAMES_FILES[0], &cities)?;
|
||||
let _ = std::fs::remove_dir(&staging);
|
||||
Ok(places)
|
||||
}
|
||||
|
||||
/// A download base: https, or plain http to this machine's loopback address only (a test
|
||||
/// server), with nothing but a port or path after the host, so `http://127.0.0.1.example.com/`
|
||||
/// is refused.
|
||||
pub(super) fn base_allowed(base: &str) -> bool {
|
||||
base.starts_with("https://")
|
||||
|| base.strip_prefix("http://127.0.0.1").is_some_and(|rest| rest.is_empty() || rest.starts_with(':') || rest.starts_with('/'))
|
||||
}
|
||||
|
||||
/// `metadata.downloadPlaceNames`: starts a background download (shown in the activity stack, cancellable).
|
||||
pub(super) fn start(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.downloadPlaceNames";
|
||||
let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?;
|
||||
let base = str_param(p, "base").unwrap_or(GEONAMES_BASE).to_string();
|
||||
if !base_allowed(&base) {
|
||||
return Err(bad(C, "`base` must be an https:// address"));
|
||||
}
|
||||
let shared = s.place_names.shared.clone();
|
||||
{
|
||||
let mut sh = s.place_names.lock();
|
||||
if sh.running {
|
||||
return Err(bad(C, "the place names are already downloading"));
|
||||
}
|
||||
sh.running = true;
|
||||
}
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let task = s.activity.start("download", "Downloading place names", Cancel::Flag(cancel.clone()));
|
||||
task.set_unit(Unit::Bytes);
|
||||
task.detail("GeoNames");
|
||||
let handle = task.handle();
|
||||
let thread_dir = dir.clone();
|
||||
let thread_shared = shared.clone();
|
||||
let spawned = std::thread::Builder::new().name("place-names-download".into()).spawn(move || {
|
||||
// the activity row goes when the thread ends, however it ends
|
||||
let _task = task;
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&base, &thread_dir, &cancel, &handle)));
|
||||
let outcome = match outcome {
|
||||
Ok(Ok(n)) => DownloadOutcome::Done(n),
|
||||
_ if cancel.load(std::sync::atomic::Ordering::Relaxed) => DownloadOutcome::Cancelled,
|
||||
Ok(Err(e)) => DownloadOutcome::Failed(format!("Could not download the place names: {e}. Check the connection and try again.")),
|
||||
Err(_) => DownloadOutcome::Failed("The place names download stopped unexpectedly. Try again.".into()),
|
||||
};
|
||||
if let DownloadOutcome::Failed(e) = &outcome {
|
||||
log::warn!("{e}");
|
||||
}
|
||||
let mut sh = thread_shared.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
sh.running = false;
|
||||
sh.last = Some(outcome.clone());
|
||||
sh.unreported = Some(outcome);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
shared.lock().unwrap_or_else(PoisonError::into_inner).running = false;
|
||||
return Err(bad(C, format!("could not start the download: {e}")));
|
||||
}
|
||||
Ok(json!({"started": true, "path": dir, "attribution": GEONAMES_ATTRIBUTION}))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
#[allow(unused_mut)]
|
||||
let mut v = vec![
|
||||
cmd!(
|
||||
"metadata.reverseGeocode",
|
||||
"Fill Location from GPS",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step",
|
||||
has_selection,
|
||||
reverse
|
||||
),
|
||||
cmd!(query "metadata.placeNamesStatus", "Place Names Status", [], None, "{dataset?: folder} → {downloaded, bytes, path, attribution, source}", always, status),
|
||||
];
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v.push(cmd!(
|
||||
"metadata.downloadPlaceNames",
|
||||
"Download Place Names (GeoNames)",
|
||||
["Photo"],
|
||||
None,
|
||||
"{dataset?: folder} → {started, path, attribution} — downloads the GeoNames cities list (CC-BY 4.0, ~3 MB, from download.geonames.org) in the background for Fill Location from GPS; no photo data is sent",
|
||||
always,
|
||||
download::start
|
||||
));
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CITIES: &str = "2988507\tParis\tParis\t\t48.85341\t2.3488\tP\tPPLC\tFR\t\t11\t75\t\t\t2138551\t\t42\tEurope/Paris\t2024-01-01\n\
|
||||
2643743\tLondon\tLondon\t\t51.50853\t-0.12574\tP\tPPLC\tGB\t\tENG\t\t\t\t8961989\t\t25\tEurope/London\t2024-01-01\n";
|
||||
|
||||
fn dataset(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-geocode-cmd-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(GEONAMES_FILES[0]), CITIES).unwrap();
|
||||
std::fs::write(dir.join(GEONAMES_FILES[1]), "FR.11\tÎle-de-France\nGB.ENG\tEngland\n").unwrap();
|
||||
std::fs::write(dir.join(GEONAMES_FILES[2]), "#c\nFR\tFRA\t250\tFR\tFrance\nGB\tGBR\t826\tUK\tUnited Kingdom\n").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn set_gps(s: &mut Session, id: PhotoId, gps: Option<(f64, f64)>, city: &str) {
|
||||
let mut meta = s.catalog.photo(id).unwrap().meta.clone();
|
||||
meta.gps = gps;
|
||||
meta.city = city.into();
|
||||
meta.state.clear();
|
||||
meta.country.clear();
|
||||
meta.location.clear();
|
||||
s.commit("test", Op::SetMeta { id, meta: Box::new(meta) }).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fills_empty_fields_keeps_typed_ones_and_undoes() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = s.visible_cloned();
|
||||
let dir = dataset("fill");
|
||||
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
|
||||
set_gps(&mut s, ids[1], Some((51.5, -0.1)), "Westminster");
|
||||
set_gps(&mut s, ids[2], Some((f64::NAN, 0.0)), "");
|
||||
set_gps(&mut s, ids[3], Some((0.0, 0.0)), "");
|
||||
let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0, ids[2].0, ids[3].0], "dataset": dir})).unwrap();
|
||||
assert_eq!((r["looked"].as_u64(), r["found"].as_u64(), r["changed"].as_u64()), (Some(3), Some(2), Some(2)), "{r}");
|
||||
let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone();
|
||||
assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("Paris", "Île-de-France", "France"));
|
||||
let m1 = s.catalog.photo(ids[1]).unwrap().meta.clone();
|
||||
assert_eq!((m1.city.as_str(), m1.state.as_str(), m1.country.as_str()), ("Westminster", "England", "United Kingdom"));
|
||||
assert!(s.activity.list().is_empty(), "the activity row ends with the command");
|
||||
// one undo step
|
||||
s.undo_step().unwrap();
|
||||
assert!(s.catalog.photo(ids[0]).unwrap().meta.city.is_empty());
|
||||
assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Westminster");
|
||||
// overwrite replaces typed text
|
||||
s.execute("metadata.reverseGeocode", &json!({"ids": [ids[1].0], "dataset": dir, "overwrite": true})).unwrap();
|
||||
assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "London");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explains_missing_data_and_gps() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = s.visible_cloned();
|
||||
set_gps(&mut s, ids[0], None, "");
|
||||
let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0]})).unwrap_err();
|
||||
assert!(e.to_string().contains("GPS"), "{e}");
|
||||
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
|
||||
let missing = std::env::temp_dir().join(format!("lightcraft-geocode-none-{}", std::process::id()));
|
||||
let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": missing})).unwrap_err();
|
||||
assert!(e.to_string().contains("download"), "{e}");
|
||||
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": missing})).unwrap();
|
||||
assert_eq!(st["downloaded"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gazetteer_is_cached_until_its_file_changes() {
|
||||
let dir = dataset("cache");
|
||||
let mut names = PlaceNames::default();
|
||||
let a = names.load(&dir).unwrap();
|
||||
assert!(Arc::ptr_eq(&a, &names.load(&dir).unwrap()), "unchanged file: no re-read");
|
||||
// a different size invalidates it (a new download)
|
||||
std::fs::write(dir.join(GEONAMES_FILES[0]), "1\tRome\tRome\t\t41.9\t12.5\tP\tP\tIT\t\t07\n").unwrap();
|
||||
let b = names.load(&dir).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &b));
|
||||
assert_eq!(b.reverse(LatLon::new(41.9, 12.5)).unwrap().city, "Rome");
|
||||
std::fs::remove_file(dir.join(GEONAMES_FILES[0])).unwrap();
|
||||
assert!(names.load(&dir).is_err(), "a deleted file is not served from the cache");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A local HTTP server; the first path segment picks the behaviour: `good` (the synthetic dump), `garbage` (a zip
|
||||
/// that isn't one), `short` (the body ends before its Content-Length), anything else 404.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn serve() -> String {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
let zip = lightcraft_geocode::unzip::make(GEONAMES_FILES[0], CITIES.as_bytes());
|
||||
std::thread::spawn(move || {
|
||||
for conn in l.incoming() {
|
||||
let Ok(mut c) = conn else { continue };
|
||||
let mut r = BufReader::new(c.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
let _ = r.read_line(&mut line);
|
||||
loop {
|
||||
let mut h = String::new();
|
||||
if r.read_line(&mut h).unwrap_or(0) == 0 || h.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let path = line.split(' ').nth(1).unwrap_or("").to_string();
|
||||
let (status, body, len): (&str, Vec<u8>, Option<usize>) = match (path.split('/').nth(1).unwrap_or(""), path.ends_with(".zip")) {
|
||||
("good", true) => ("200 OK", zip.clone(), None),
|
||||
("good", false) if path.contains("countryInfo") => ("200 OK", b"FR\tFRA\t250\tFR\tFrance\n".to_vec(), None),
|
||||
("garbage", true) => ("200 OK", b"<html>not a zip</html>".to_vec(), None),
|
||||
("short", true) => ("200 OK", zip[..zip.len() / 2].to_vec(), Some(zip.len())),
|
||||
_ => ("404 Not Found", b"nope".to_vec(), None),
|
||||
};
|
||||
let n = len.unwrap_or(body.len());
|
||||
let _ = c.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: {n}\r\nConnection: close\r\n\r\n").as_bytes());
|
||||
let _ = c.write_all(&body);
|
||||
}
|
||||
});
|
||||
format!("http://127.0.0.1:{port}")
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn download(s: &mut Session, base: &str, dir: &Path) -> DownloadOutcome {
|
||||
s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": base})).unwrap();
|
||||
assert!(s.place_names.downloading() || s.place_names.lock().unreported.is_some());
|
||||
let started = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(o) = s.place_names.take_outcome() {
|
||||
// the activity row ends with the thread
|
||||
while !s.activity.list().is_empty() && started.elapsed().as_secs() < 30 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(s.activity.list().is_empty());
|
||||
return o;
|
||||
}
|
||||
assert!(started.elapsed().as_secs() < 60, "the download never ended");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn download_end_to_end_and_its_failures() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = s.visible_cloned();
|
||||
let base = serve();
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-geocode-dl-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": "http://example.com/"})).is_err());
|
||||
|
||||
for bad in ["missing", "garbage", "short"] {
|
||||
let o = download(&mut s, &format!("{base}/{bad}/"), &dir);
|
||||
let DownloadOutcome::Failed(e) = o else { panic!("{bad}: {o:?}") };
|
||||
assert!(e.contains("try again"), "{bad}: {e}");
|
||||
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap();
|
||||
assert_eq!((st["downloaded"].as_bool(), st["lastDownload"].as_str()), (Some(false), Some("failed")), "{bad}: {st}");
|
||||
assert_eq!(st["lastError"].as_str(), Some(e.as_str()));
|
||||
assert!(s.place_names.take_outcome().is_none(), "reported once");
|
||||
}
|
||||
|
||||
assert_eq!(download(&mut s, &format!("{base}/good/"), &dir), DownloadOutcome::Done(2));
|
||||
let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap();
|
||||
assert_eq!((st["downloaded"].as_bool(), st["lastError"].is_null(), st["lastDownload"]["places"].as_u64()), (Some(true), true, Some(2)));
|
||||
set_gps(&mut s, ids[0], Some((48.86, 2.34)), "");
|
||||
s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap();
|
||||
let m = s.catalog.photo(ids[0]).unwrap().meta.clone();
|
||||
// admin1 was a 404 (optional): the state stays blank, the rest is filled
|
||||
assert_eq!((m.city.as_str(), m.state.as_str(), m.country.as_str()), ("Paris", "", "France"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_base_is_https_or_loopback_only() {
|
||||
assert!(download::base_allowed("https://download.geonames.org/export/dump/"));
|
||||
assert!(download::base_allowed("http://127.0.0.1:8080"));
|
||||
assert!(download::base_allowed("http://127.0.0.1/dump"));
|
||||
assert!(!download::base_allowed("http://127.0.0.1.example.com/"));
|
||||
assert!(!download::base_allowed("http://127.0.0.1@example.com/"));
|
||||
assert!(!download::base_allowed("http://download.geonames.org/"));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Filter, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param};
|
||||
use crate::{LibrarySource, Result, Selection, Session};
|
||||
|
||||
fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
|
||||
@@ -82,7 +82,7 @@ pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
|
||||
}
|
||||
let on_deleted = match str_param(p, "onDeleted") {
|
||||
Some(v) => {
|
||||
crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (skip|restore|fresh)")))?
|
||||
crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (restore|skip|fresh)")))?
|
||||
}
|
||||
None => Default::default(),
|
||||
};
|
||||
@@ -149,6 +149,24 @@ fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
|
||||
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
|
||||
}
|
||||
|
||||
/// The targets an album can hold and how many were browsed (Local) photos, which album views
|
||||
/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members.
|
||||
fn album_targets(s: &Session, targets: Vec<PhotoId>) -> (Vec<PhotoId>, usize) {
|
||||
let before = targets.len();
|
||||
let kept: Vec<PhotoId> = targets.into_iter().filter(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)).collect();
|
||||
let skipped = before - kept.len();
|
||||
(kept, skipped)
|
||||
}
|
||||
|
||||
/// Album targets, or the error for a selection that was nothing but Local photos.
|
||||
fn album_targets_or_err(s: &Session, command: &str, targets: Vec<PhotoId>) -> Result<(Vec<PhotoId>, usize)> {
|
||||
let (kept, skipped) = album_targets(s, targets);
|
||||
if kept.is_empty() && skipped > 0 {
|
||||
return Err(bad(command, super::LOCAL_ONLY));
|
||||
}
|
||||
Ok((kept, skipped))
|
||||
}
|
||||
|
||||
/// Apply one op per target as a single undo step.
|
||||
fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
@@ -866,22 +884,31 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
// ---- albums
|
||||
cmd!("album.create", "New Album", ["File"], None, "{name, parent?: folderId, folder?: bool, addSelected?: bool}", always, |s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(bad("album.create", "empty name"));
|
||||
cmd!(
|
||||
"album.create",
|
||||
"New Album",
|
||||
["File"],
|
||||
None,
|
||||
"{name, parent?: folderId, folder?: bool, addSelected?: bool} → {id, skipped} (skipped: Local photos of the selection, which albums can't hold)",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(bad("album.create", "empty name"));
|
||||
}
|
||||
let folder = bool_or(p, "folder", false);
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
let (photos, skipped) =
|
||||
if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) };
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let cover = photos.first().copied();
|
||||
s.commit(
|
||||
if folder { "New Folder" } else { "New Album" },
|
||||
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } },
|
||||
)?;
|
||||
Ok(json!({"id": id.0, "skipped": skipped}))
|
||||
}
|
||||
let folder = bool_or(p, "folder", false);
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
let photos = if !folder && bool_or(p, "addSelected", false) { s.targets(&Value::Null) } else { vec![] };
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let cover = photos.first().copied();
|
||||
s.commit(
|
||||
if folder { "New Folder" } else { "New Album" },
|
||||
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } },
|
||||
)?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}),
|
||||
),
|
||||
cmd!(
|
||||
"album.createSmart",
|
||||
"New Smart Album…",
|
||||
@@ -1103,34 +1130,44 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"changed": changed}))
|
||||
}
|
||||
),
|
||||
cmd!("album.addPhotos", "Add to Album", ["Photo"], None, "{id: albumId, ids?: [photoIds]} (default: selection)", has_selection, |s, p| {
|
||||
let id = album_param(p, "id", "album.addPhotos")?;
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
|
||||
if al.is_smart() || al.folder {
|
||||
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
|
||||
}
|
||||
let mut photos = al.photos.clone();
|
||||
let before = photos.len();
|
||||
for t in targets {
|
||||
if !photos.contains(&t) {
|
||||
photos.push(t);
|
||||
cmd!(
|
||||
"album.addPhotos",
|
||||
"Add to Album",
|
||||
["Photo"],
|
||||
None,
|
||||
"{id: albumId, ids?: [photoIds]} (default: selection) → {added, skipped}; Local photos are skipped (albums hold library photos), only Local ones is an error",
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.addPhotos")?;
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
|
||||
if al.is_smart() || al.folder {
|
||||
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
|
||||
}
|
||||
let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?;
|
||||
let mut photos = al.photos.clone();
|
||||
let before = photos.len();
|
||||
for t in targets {
|
||||
if !photos.contains(&t) {
|
||||
photos.push(t);
|
||||
}
|
||||
}
|
||||
let added = photos.len() - before;
|
||||
let cover = al.cover.or(photos.first().copied());
|
||||
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
|
||||
Ok(json!({"added": added, "skipped": skipped}))
|
||||
}
|
||||
let added = photos.len() - before;
|
||||
let cover = al.cover.or(photos.first().copied());
|
||||
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
|
||||
Ok(json!({"added": added}))
|
||||
}),
|
||||
),
|
||||
cmd!(
|
||||
"album.toggleTarget",
|
||||
"Add to Target Album",
|
||||
[],
|
||||
None,
|
||||
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added}",
|
||||
has_selection,
|
||||
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped",
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?;
|
||||
let (targets, skipped) = targets;
|
||||
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
|
||||
let id = match target.or_else(|| s.catalog.quick_collection()) {
|
||||
Some(id) => id,
|
||||
@@ -1154,7 +1191,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if all_in { "Remove from Target Album" } else { "Add to Target Album" },
|
||||
Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] },
|
||||
)?;
|
||||
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id)}))
|
||||
Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id), "skipped": skipped}))
|
||||
}
|
||||
),
|
||||
cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| {
|
||||
@@ -1246,7 +1283,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Ok(json!({"imported": [], "folder": folder}));
|
||||
}
|
||||
let mode = if s.import_defaults.auto_copy { "copy" } else { "add" };
|
||||
let mut params = json!({"paths": fresh, "mode": mode});
|
||||
// a photo deleted from the library doesn't come back on its own because its file
|
||||
// is still in the watched folder (a copy's source is not a known path)
|
||||
let mut params = json!({"paths": fresh, "mode": mode, "onDeleted": "skip"});
|
||||
if let Some(a) = s.import_defaults.auto_album.clone() {
|
||||
match s.catalog.albums().find(|al| al.name.eq_ignore_ascii_case(&a) && !al.folder && !al.is_smart()) {
|
||||
Some(al) => params["album"] = json!(al.id.0),
|
||||
@@ -1326,7 +1365,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Photos",
|
||||
["File"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: skip|restore|fresh (a file that is in Recently Deleted: skip = leave it there and say so, restore = bring the photo back with its edits, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: restore|skip|fresh (a file that is in Recently Deleted: restore (default) = bring the photo back with its edits — relinked to the file when its own is gone, skip = leave it there and say so, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let req = import_params(s, p)?;
|
||||
|
||||
@@ -34,22 +34,34 @@ pub fn register_all(s: &Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `.cube` files in `paths` (folders searched, zip bundles opened).
|
||||
fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
|
||||
/// `.cube` files in `paths` (folders searched, zip bundles opened), and the files that couldn't
|
||||
/// be read with the reason (`[name, reason]`).
|
||||
fn cube_files(paths: &[String]) -> (Vec<(String, String, Option<String>)>, Vec<Value>) {
|
||||
// (name, text, group)
|
||||
let mut out = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let stem = |f: &Path| f.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
for p in paths {
|
||||
let path = Path::new(p);
|
||||
if path.is_dir() {
|
||||
let cube = |f: &Path| f.extension().is_some_and(|x| x.eq_ignore_ascii_case("cube"));
|
||||
for ep in crate::walk::files_in(path, None, crate::walk::Limits::default(), cube).files {
|
||||
if let Ok(t) = std::fs::read_to_string(&ep) {
|
||||
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
out.push((ep.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, group));
|
||||
match std::fs::read_to_string(&ep) {
|
||||
Ok(t) => {
|
||||
let group = ep.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
out.push((stem(&ep), t, group));
|
||||
}
|
||||
Err(e) => failed.push(json!([stem(&ep), e.to_string()])),
|
||||
}
|
||||
}
|
||||
} else if path.extension().is_some_and(|x| x.eq_ignore_ascii_case("zip")) {
|
||||
let Ok(b) = std::fs::read(path) else { continue };
|
||||
let b = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
failed.push(json!([stem(path), e.to_string()]));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for (inner, data) in crate::preset_import::read_zip(&b).unwrap_or_default() {
|
||||
if inner.to_ascii_lowercase().ends_with(".cube") {
|
||||
let ip = Path::new(&inner);
|
||||
@@ -65,11 +77,14 @@ fn cube_files(paths: &[String]) -> Vec<(String, String, Option<String>)> {
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Ok(t) = std::fs::read_to_string(path) {
|
||||
out.push((path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t, None));
|
||||
} else {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(t) => out.push((stem(path), t, None)),
|
||||
Err(e) => failed.push(json!([stem(path), e.to_string()])),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
(out, failed)
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -83,8 +98,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(d) = &dir {
|
||||
std::fs::create_dir_all(d).map_err(|e| bad(C, format!("{}: {e}", d.display())))?;
|
||||
}
|
||||
let (mut imported, mut failed) = (Vec::new(), Vec::new());
|
||||
for (name, text, group) in cube_files(&paths) {
|
||||
let (files, mut failed) = cube_files(&paths);
|
||||
let mut imported = Vec::new();
|
||||
for (name, text, group) in files {
|
||||
let lut = match lightcraft_pipeline::lut::Lut3d::parse_cube(&text) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
|
||||
@@ -690,12 +690,69 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"spots": out, "added": found.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"spot.findBlemishes",
|
||||
"Find Blemishes",
|
||||
[],
|
||||
None,
|
||||
"{sensitivity?: 0..100 (50), add?: bool (true), maxPerFace?: 1..20 (6)} — find small blemishes (darker or redder round spots on skin) on the faces of the active photo and add a heal spot on each (one undo step); needs the YuNet face detector (`faces.models.download {id: \"yunet-2023mar\"}`). Faces narrower than 160 px at 1600 px, and faces with more than 12 candidates (stubble, freckles, pores), get none → {faces, spots: [{x, y, size, face}], added}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "spot.findBlemishes";
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let detector = super::face_detect::detector(s).map_err(|e| bad(c, e))?;
|
||||
// the uncropped photo, so positions are the spots' own coordinates
|
||||
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
|
||||
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
|
||||
let rgb: Vec<u8> = img.data.iter().flat_map(|px| [px[0], px[1], px[2]]).collect();
|
||||
let faces =
|
||||
detector.detect(&rgb, img.width, img.height, &lightcraft_faces::yunet::Options::default()).map_err(|e| bad(c, e.to_string()))?;
|
||||
let boxes: Vec<lightcraft_pipeline::blemish::FaceBox> = faces
|
||||
.iter()
|
||||
.map(|f| lightcraft_pipeline::blemish::FaceBox {
|
||||
x0: f64::from(f.x0),
|
||||
y0: f64::from(f.y0),
|
||||
x1: f64::from(f.x1),
|
||||
y1: f64::from(f.y1),
|
||||
landmarks: f.landmarks.map(|(x, y)| (f64::from(x), f64::from(y))),
|
||||
})
|
||||
.collect();
|
||||
let max_per_face = (f64_or(p, "maxPerFace", 6.0) as usize).clamp(1, 20);
|
||||
let mut found = lightcraft_pipeline::blemish::detect(&img, &boxes, f64_or(p, "sensitivity", 50.0) as f32);
|
||||
let mut per_face = std::collections::BTreeMap::new();
|
||||
found.retain(|b| {
|
||||
let n = per_face.entry(b.face).or_insert(0usize);
|
||||
*n += 1;
|
||||
*n <= max_per_face
|
||||
});
|
||||
let out: Vec<Value> = found.iter().map(|b| json!({"x": b.x, "y": b.y, "size": b.radius, "face": b.face})).collect();
|
||||
if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() {
|
||||
return Ok(json!({"faces": boxes.len(), "spots": out, "added": 0}));
|
||||
}
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
let base = Spot::default();
|
||||
for b in &found {
|
||||
let mut spot = Spot {
|
||||
mode: SpotMode::Heal,
|
||||
points: vec![Point::new(b.x, b.y)],
|
||||
size: b.radius.clamp(SPOT_SIZE.0, SPOT_SIZE.1),
|
||||
feather: base.feather,
|
||||
opacity: 100.0,
|
||||
source_offset: None,
|
||||
};
|
||||
spot.source_offset = pick_source(s, id, &dd, &spot, None);
|
||||
dd.spots.push(spot);
|
||||
}
|
||||
s.set_develop(id, dd, "Find Blemishes")?;
|
||||
Ok(json!({"faces": boxes.len(), "spots": out, "added": found.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"spot.add",
|
||||
"Add Remove Spot",
|
||||
[],
|
||||
None,
|
||||
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
|
||||
"{mode?: remove|heal|clone|fill (content-aware), points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let mode: SpotMode =
|
||||
@@ -719,7 +776,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
};
|
||||
let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?;
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
if spot.source_offset.is_none() {
|
||||
if spot.source_offset.is_none() && spot.mode != SpotMode::Fill {
|
||||
// resolve the automatic source now: it gets a pin and stays put at every size
|
||||
spot.source_offset = pick_source(s, id, &dd, &spot, None);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) mod face_detect;
|
||||
mod face_models;
|
||||
mod face_recognize;
|
||||
pub mod filters;
|
||||
pub mod geocode;
|
||||
pub mod keywords;
|
||||
pub mod library;
|
||||
pub mod lut_profiles;
|
||||
@@ -101,6 +102,14 @@ pub fn has_active(s: &Session) -> std::result::Result<(), String> {
|
||||
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.selection.ids.is_empty() && s.selection.active.is_none() { Err("no photos selected".into()) } else { Ok(()) }
|
||||
}
|
||||
/// Albums hold library photos: a selection of only browsed (Local) photos has nothing an album
|
||||
/// can take (issue #684; Add to My Photos first).
|
||||
pub fn has_library_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
if s.targets(&serde_json::Value::Null).iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) }
|
||||
}
|
||||
/// Why an album command refused a Local-only selection.
|
||||
pub const LOCAL_ONLY: &str = "only Local photos: albums hold library photos (Add to My Photos first)";
|
||||
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) }
|
||||
}
|
||||
@@ -152,6 +161,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(missing::specs());
|
||||
v.extend(sync::specs());
|
||||
v.extend(metadata::specs());
|
||||
v.extend(geocode::specs());
|
||||
v.extend(filters::specs());
|
||||
v.extend(denoise::specs());
|
||||
v.extend(face_models::specs());
|
||||
|
||||
@@ -52,16 +52,17 @@ impl PreviewBuild {
|
||||
}
|
||||
}
|
||||
|
||||
/// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at
|
||||
/// `edge` (`None` = 1:1, the photo's full size).
|
||||
fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option<usize>) -> Vec<RenderJob> {
|
||||
/// The jobs that build `id`'s previews: the largest grid thumbnail and the view render at `edge`
|
||||
/// (`None` = 1:1, the photo's full size), the larger first.
|
||||
pub(crate) fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option<usize>) -> Vec<RenderJob> {
|
||||
let Some(p) = s.catalog.photo(id) else { return Vec::new() };
|
||||
let full = p.width.max(p.height) as usize;
|
||||
let e = edge.unwrap_or(full).min(full.max(1)).max(1);
|
||||
let mut v = Vec::new();
|
||||
v.extend(s.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1]));
|
||||
v.extend(s.loupe_job(id, e, e, true));
|
||||
v
|
||||
let thumb = THUMB_SIZES[THUMB_SIZES.len() - 1];
|
||||
let (view, grid) = (s.loupe_job(id, e, e, true), s.thumb_job(id, thumb));
|
||||
// the larger render goes first: the one after it renders from the source it loaded (see
|
||||
// `run_photo`), which must not be smaller than what it needs
|
||||
if e >= thumb { view.into_iter().chain(grid).collect() } else { grid.into_iter().chain(view).collect() }
|
||||
}
|
||||
|
||||
/// The build's row in the activity stack (`what` as in [`PreviewBuild::what`]).
|
||||
@@ -76,17 +77,66 @@ fn task_for(s: &Session, state: &PreviewBuild) -> TaskGuard {
|
||||
task
|
||||
}
|
||||
|
||||
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard) {
|
||||
for photo_jobs in jobs {
|
||||
if state.cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
/// Render one photo's jobs in order. The source the first job decodes serves the ones after it
|
||||
/// (the grid thumbnail is made from the view's source instead of decoding the file again).
|
||||
pub(crate) fn run_photo(jobs: Vec<RenderJob>) -> bool {
|
||||
let mut decoded: Option<crate::media::DecodedSource> = None;
|
||||
let mut ok = true;
|
||||
for mut j in jobs {
|
||||
if let Some(src) = &decoded
|
||||
&& !matches!(j.source, crate::media::SourceRef::Loaded(_))
|
||||
{
|
||||
j.source = crate::media::SourceRef::Loaded(Box::new(src.clone()));
|
||||
}
|
||||
let ok = photo_jobs.into_iter().all(|j| j.run().rendered.is_ok());
|
||||
if !ok {
|
||||
state.failed.fetch_add(1, Ordering::Relaxed);
|
||||
let r = j.run();
|
||||
ok &= r.rendered.is_ok();
|
||||
if decoded.is_none() {
|
||||
decoded = r.loaded;
|
||||
}
|
||||
let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
task.progress(done as u64, state.total as u64);
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// How many photos render side by side: as many as an export batch for standard previews (each
|
||||
/// render spreads over the cores already; the others fill them while one decodes), two for 1:1
|
||||
/// previews (a full-size source each).
|
||||
fn lanes(n: usize, full: bool) -> usize {
|
||||
let l = crate::export::export_parallelism(n);
|
||||
if full { l.min(2) } else { l }
|
||||
}
|
||||
|
||||
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard, lanes: usize) {
|
||||
let total = jobs.len();
|
||||
let queue = std::sync::Mutex::new(jobs.into_iter());
|
||||
let work = || {
|
||||
loop {
|
||||
if state.cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next();
|
||||
let Some(photo_jobs) = next else { break };
|
||||
let ok = crate::guard::catch("building previews", || run_photo(photo_jobs)).unwrap_or(false);
|
||||
if !ok {
|
||||
state.failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
task.progress(done as u64, total as u64);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::scope(|sc| {
|
||||
for k in 1..lanes.max(1) {
|
||||
// a lane that can't start leaves its photos to the others (and to this thread)
|
||||
if std::thread::Builder::new().name(format!("previews-{k}")).spawn_scoped(sc, work).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
work();
|
||||
});
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = lanes;
|
||||
work();
|
||||
}
|
||||
state.finished.store(true, Ordering::Relaxed);
|
||||
}
|
||||
@@ -107,9 +157,10 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
|
||||
s.preview_build = Some(state.clone());
|
||||
let task = task_for(s, &state);
|
||||
let lanes = lanes(jobs.len(), edge.is_none());
|
||||
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32");
|
||||
if wait {
|
||||
run_all(jobs, &state, &task);
|
||||
run_all(jobs, &state, &task, lanes);
|
||||
return Ok(state.json());
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -118,7 +169,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = state.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-build-previews".into())
|
||||
.spawn(move || run_all(jobs, &st, &task))
|
||||
.spawn(move || run_all(jobs, &st, &task, lanes))
|
||||
.map_err(|e| bad(C, format!("could not start: {e}")))?;
|
||||
}
|
||||
Ok(state.json())
|
||||
@@ -126,7 +177,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// One photo's smart preview to build or discard: (photo, its file in the smart previews folder,
|
||||
/// how to load the original at preview size, whether the photo takes the per-file camera look —
|
||||
/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::relative_wb`]: only those
|
||||
/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::fitted_look`]: only those
|
||||
/// proxies go stale when the fit changes).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>, bool);
|
||||
@@ -192,6 +243,9 @@ fn smart_run(
|
||||
break;
|
||||
}
|
||||
let _one = SMART_WRITE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Read either the new shard or a pre-upgrade flat file, without making new work
|
||||
// depend on having the original online.
|
||||
let path = crate::smart::existing_file_path(dir, path);
|
||||
if discard {
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
n.removed += 1;
|
||||
@@ -215,7 +269,14 @@ fn smart_run(
|
||||
.ok_or_else(|| "nothing to build from".to_string())
|
||||
.and_then(|s| s.load_source())
|
||||
.and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref()))
|
||||
.and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display())));
|
||||
.and_then(|b| {
|
||||
let parent = path.parent().ok_or_else(|| format!("{}: no parent folder", path.display()))?;
|
||||
// Only create the leaf shard: do not recreate a missing external drive.
|
||||
if !parent.is_dir() {
|
||||
std::fs::create_dir(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display()))
|
||||
});
|
||||
match r {
|
||||
Ok(()) => {
|
||||
n.built += 1;
|
||||
@@ -270,9 +331,9 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !matches!(ph.source, lightcraft_catalog::Source::File { .. }) {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(crate::smart::file_name(&ph));
|
||||
let path = crate::smart::sharded_file_path(&dir, &ph);
|
||||
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
|
||||
jobs.push((id, path, source, ph.relative_wb()));
|
||||
jobs.push((id, path, source, ph.fitted_look()));
|
||||
}
|
||||
let custom = s.smart_previews_dir.is_some();
|
||||
if background {
|
||||
@@ -314,9 +375,9 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::threa
|
||||
return None;
|
||||
}
|
||||
let max_edge = crate::media::SourceLevel::Preview.max_edge();
|
||||
let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.relative_wb()).collect();
|
||||
let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.fitted_look()).collect();
|
||||
let jobs: Vec<SmartJob> =
|
||||
photos.iter().map(|p| (p.id, dir.join(crate::smart::file_name(p)), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect();
|
||||
photos.iter().map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-smart-look".into())
|
||||
.spawn(move || refresh_stale(&dir, jobs))
|
||||
@@ -327,7 +388,11 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::threa
|
||||
/// The body of [`refresh_stale_smart_previews`]: rebuild the stale proxies among `jobs`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn refresh_stale(dir: &std::path::Path, jobs: Vec<SmartJob>) {
|
||||
let stale: Vec<SmartJob> = jobs.into_iter().filter(|(_, path, _, _)| crate::smart::is_stale(path)).collect();
|
||||
let stale: Vec<SmartJob> = jobs
|
||||
.into_iter()
|
||||
.map(|(id, path, source, look)| (id, crate::smart::existing_file_path(dir, path), source, look))
|
||||
.filter(|(_, path, _, _)| crate::smart::is_stale(path))
|
||||
.collect();
|
||||
if !stale.is_empty() {
|
||||
// `custom`: the folder is there already, and must not be recreated if it went away since
|
||||
match smart_run(dir, true, false, stale, None) {
|
||||
@@ -410,7 +475,7 @@ fn smart_location(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// 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)) {
|
||||
(Some(dir), Some(p)) => crate::smart::is_valid(&dir.join(crate::smart::file_name(p))),
|
||||
(Some(dir), Some(p)) => crate::smart::is_valid(&crate::smart::file_path(dir, p)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -465,3 +530,37 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{jobs_for, run_photo};
|
||||
use crate::Session;
|
||||
use crate::media::SourceRef;
|
||||
|
||||
// Given a photo's preview jobs, the grid thumbnail is made from the source the view decoded, not
|
||||
// by reading the file again (here: a thumbnail job pointing at a file that is not there)
|
||||
#[test]
|
||||
fn preview_build_makes_the_thumbnail_from_the_views_source() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut jobs = jobs_for(&mut s, id, Some(512));
|
||||
assert_eq!(jobs.len(), 2, "view and thumbnail");
|
||||
assert!(jobs[0].view_cache.is_some(), "the view renders first");
|
||||
jobs[1].source = SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None };
|
||||
assert!(run_photo(jobs), "the thumbnail rendered from the view's decoded source");
|
||||
}
|
||||
|
||||
// Given many photos, a standard preview build renders them side by side and counts every one
|
||||
#[test]
|
||||
fn preview_build_of_many_photos_counts_each_once() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().map(|p| p.id.0).collect();
|
||||
assert!(ids.len() > 4);
|
||||
let r = s.execute("library.buildPreviews", &json!({"size": "standard", "edge": 512, "ids": ids, "wait": true})).unwrap();
|
||||
assert_eq!(r["done"], json!(ids.len()), "{r}");
|
||||
assert_eq!(r["total"], json!(ids.len()));
|
||||
assert_eq!(r["failed"], json!(0));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user