mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
XMP.434: reconcile current main & complete warning locales
Preserve Target-aware sidecar operations, add French/Ukrainian warnings, and ensure omitted diagnostic fields decode in the native UI. Generated with Codex.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
//! `activity.list` / `activity.cancel`: the background tasks in flight (see [`crate::activity`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "activity.list",
|
||||
"Background Tasks",
|
||||
[],
|
||||
None,
|
||||
"{} → {tasks: [{id, kind, label, done, total, unit: count|bytes|percent, detail, cancellable, cancelling, ageMs}]} — the long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first; total 0 = not known yet",
|
||||
always,
|
||||
|s, _| Ok(json!({"tasks": s.activity.list()}))
|
||||
),
|
||||
cmd!(
|
||||
"activity.cancel",
|
||||
"Cancel Background Task",
|
||||
[],
|
||||
None,
|
||||
"{id} | {all: true} → {cancelled: n} — ask a task (or every cancellable task) to stop; it stops at its next file or photo. An unknown id (it may have just finished) or a task that can't be cancelled is an error",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "activity.cancel";
|
||||
if p.get("all").and_then(Value::as_bool) == Some(true) {
|
||||
return Ok(json!({"cancelled": s.activity.cancel_all()}));
|
||||
}
|
||||
let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, "id or all"))?;
|
||||
s.activity.cancel(id).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"cancelled": 1}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::activity::Cancel;
|
||||
|
||||
#[test]
|
||||
fn list_and_cancel_through_execute() {
|
||||
let mut s = Session::new();
|
||||
let g = s.activity.start("export", "Exporting", Cancel::Yes);
|
||||
let r = s.execute("activity.list", &json!({})).unwrap();
|
||||
assert_eq!(r["tasks"][0]["kind"], "export");
|
||||
assert_eq!(r["tasks"][0]["cancellable"], true);
|
||||
let r = s.execute("activity.cancel", &json!({"id": g.id()})).unwrap();
|
||||
assert_eq!(r["cancelled"], 1);
|
||||
assert!(g.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_all_and_errors() {
|
||||
let mut s = Session::new();
|
||||
let (_a, _b) = (s.activity.start("export", "E", Cancel::Yes), s.activity.start("faces", "F", Cancel::No));
|
||||
assert_eq!(s.execute("activity.cancel", &json!({"all": true})).unwrap()["cancelled"], 1);
|
||||
assert!(s.execute("activity.cancel", &json!({"id": 999})).is_err());
|
||||
assert!(s.execute("activity.cancel", &json!({})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,7 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
rename_folder_on_disk(from, to)?;
|
||||
let ops: Vec<Op> = s
|
||||
let mut ops: Vec<Op> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter_map(|p| match &p.source {
|
||||
@@ -301,6 +301,8 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
|
||||
})
|
||||
.collect();
|
||||
let n = ops.len();
|
||||
// what the library knows about the folder (and those inside it) goes along, in the same step
|
||||
ops.extend(s.catalog.folder_records_follow(from, to));
|
||||
let name = Path::new(to).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let renamed = src.parent() == dst.parent();
|
||||
let label = if renamed { format!("Rename Folder to “{name}”") } else { format!("Move Folder “{name}”") };
|
||||
|
||||
@@ -210,6 +210,25 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P
|
||||
ops
|
||||
}
|
||||
|
||||
/// Ops that give a photo the lens data its file carries now (`info`, a fresh probe), or none when
|
||||
/// it already has them. A catalog from before the lens data was read at import has none, so such a
|
||||
/// photo's correction stayed off. When the photo gains lens data and its develop settings are still
|
||||
/// what import gave it, the lens correction is turned on as import would; an edited photo keeps
|
||||
/// its settings.
|
||||
pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec<Op> {
|
||||
if info.embedded_lens == ph.embedded_lens {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut ops = vec![Op::SetEmbeddedLens { id, lens: info.embedded_lens.map(Box::new) }];
|
||||
// (a photo with a default-preset look keeps it: that look is its import state)
|
||||
if ph.embedded_lens.is_none() && info.embedded_lens.is_some() && ph.import_look.is_none() && !ph.is_edited() && !ph.develop.optics.lens_profile {
|
||||
let mut d = (*ph.develop).clone();
|
||||
d.optics.lens_profile = true;
|
||||
ops.push(Op::SetDevelop { id, settings: std::sync::Arc::new(d), label: "Lens Corrections".into(), edited: ph.edited.clone() });
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -229,21 +248,19 @@ 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 Some(op) = content_op(id, ph, info.clone()) else {
|
||||
if !meta_ops.is_empty() {
|
||||
ops.extend(meta_ops);
|
||||
reloaded.push(id);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
ops.push(op);
|
||||
ops.extend(meta_ops);
|
||||
let mut mine: Vec<Op> = content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(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)) {
|
||||
ops.extend(content_op(c.id, c, info.clone()));
|
||||
reloaded.push(c.id);
|
||||
let theirs: Vec<Op> = content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).collect();
|
||||
if !theirs.is_empty() {
|
||||
ops.extend(theirs);
|
||||
reloaded.push(c.id);
|
||||
}
|
||||
}
|
||||
reloaded.push(id);
|
||||
}
|
||||
for id in &reloaded {
|
||||
s.media.forget(*id);
|
||||
@@ -316,7 +333,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
"Reload from Disk",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}",
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}",
|
||||
has_selection,
|
||||
reload
|
||||
),
|
||||
@@ -359,4 +376,102 @@ mod tests {
|
||||
ph.captured = Some("2026-10-04T08:16:11".into());
|
||||
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
|
||||
}
|
||||
|
||||
fn lens() -> lightcraft_develop::EmbeddedLens {
|
||||
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,
|
||||
}
|
||||
}
|
||||
fn old_rw2() -> lightcraft_catalog::Photo {
|
||||
// as an older build catalogued it: no lens data, correction off
|
||||
lightcraft_catalog::Photo::new(
|
||||
lightcraft_catalog::PhotoId(1),
|
||||
Source::File { path: "/x.rw2".into() },
|
||||
"x.rw2",
|
||||
"RW2",
|
||||
4592,
|
||||
3448,
|
||||
"2026-10-06T00:00:00",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_adds_lens_data_and_turns_the_correction_on_for_an_untouched_photo() {
|
||||
let ph = old_rw2();
|
||||
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
||||
let ops = lens_ops(ph.id, &ph, &info);
|
||||
assert!(matches!(ops.first(), Some(Op::SetEmbeddedLens { lens: Some(_), .. })), "{ops:?}");
|
||||
let Some(Op::SetDevelop { settings, .. }) = ops.get(1) else { panic!("{ops:?}") };
|
||||
assert!(settings.optics.lens_profile);
|
||||
// once applied, the photo is still "unedited" and has what import would have given it
|
||||
let mut c = lightcraft_catalog::Catalog::default();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(ph) }).unwrap();
|
||||
for op in ops {
|
||||
c.apply(op).unwrap();
|
||||
}
|
||||
let after = c.photo(lightcraft_catalog::PhotoId(1)).unwrap();
|
||||
assert_eq!(after.embedded_lens, Some(lens()));
|
||||
assert!(!after.is_edited());
|
||||
assert_eq!(*after.develop, after.camera_defaults());
|
||||
assert!(lens_ops(after.id, after, &info).is_empty(), "nothing more to do the second time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_adds_lens_data_but_keeps_an_edited_photos_settings() {
|
||||
let mut ph = old_rw2();
|
||||
let mut d = (*ph.develop).clone();
|
||||
d.light.exposure = 0.7;
|
||||
ph.develop = std::sync::Arc::new(d);
|
||||
assert!(ph.is_edited());
|
||||
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
||||
let ops = lens_ops(ph.id, &ph, &info);
|
||||
assert!(matches!(ops.as_slice(), [Op::SetEmbeddedLens { lens: Some(_), .. }]), "{ops:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_leaves_a_photo_whose_user_switched_the_correction_off_alone() {
|
||||
let mut ph = old_rw2();
|
||||
ph.embedded_lens = Some(lens());
|
||||
let mut d = ph.camera_defaults();
|
||||
d.optics.lens_profile = false;
|
||||
ph.develop = std::sync::Arc::new(d);
|
||||
let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() };
|
||||
assert!(lens_ops(ph.id, &ph, &info).is_empty());
|
||||
}
|
||||
|
||||
/// End to end on a public Panasonic raw (skipped without the corpus): a catalog from before the lens data was read
|
||||
/// gets it on Reload, the render changes to the corrected one, and Undo takes it all back.
|
||||
#[test]
|
||||
fn corpus_reload_corrects_an_rw2_catalogued_without_lens_data() {
|
||||
let root = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"));
|
||||
let f = root.join("raw/rw2-panasonic-gx80.rw2");
|
||||
if !f.exists() {
|
||||
eprintln!("skip: {} absent", f.display());
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let ph = s.catalog.photo(id).unwrap().clone();
|
||||
assert!(ph.embedded_lens.is_some() && ph.develop.optics.lens_profile, "import reads it");
|
||||
// back to how 0.4.0 catalogued it
|
||||
let mut old = (*ph.develop).clone();
|
||||
old.optics.lens_profile = false;
|
||||
s.catalog.apply(Op::SetEmbeddedLens { id, lens: None }).unwrap();
|
||||
s.catalog.apply(Op::SetDevelop { id, settings: std::sync::Arc::new(old), label: "x".into(), edited: None }).unwrap();
|
||||
s.media.forget(id);
|
||||
let before = s.render_now(id, 160, 120).unwrap().image.data;
|
||||
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(r["reloaded"], json!([id.0]));
|
||||
let now = s.catalog.photo(id).unwrap().clone();
|
||||
assert_eq!(now.embedded_lens, ph.embedded_lens);
|
||||
assert!(now.develop.optics.lens_profile && !now.is_edited());
|
||||
assert_ne!(s.render_now(id, 160, 120).unwrap().image.data, before, "the corrected render differs");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let undone = s.catalog.photo(id).unwrap();
|
||||
assert!(undone.embedded_lens.is_none() && !undone.develop.optics.lens_profile, "one undo step");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
//! Denoise commands (`denoise.*`): the models (list, install, download, test, remove, choose), the settings, and the
|
||||
//! work (queue, cancel, status, pump, clear). The machinery they drive is [`crate::denoise`].
|
||||
//!
|
||||
//! Models live in the folder the host provides (`Session::denoise.models_dir`): one subfolder per model with
|
||||
//! `model.onnx`, `denoise-model.json` (its manifest) and `installed.json` (what the user accepted and the file's hash),
|
||||
//! plus `settings.json` beside them. Model files, archives and manifests are hostile input: sizes are capped, ids are
|
||||
//! validated before they become folder names, and nothing outside a model's own folder is ever touched.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, PoisonError, mpsc};
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_denoise::archive;
|
||||
use lightcraft_denoise::hash::sha256_file;
|
||||
use lightcraft_denoise::known::{self, Known};
|
||||
use lightcraft_denoise::manifest::{self, DenoiserManifest, MAX_MANIFEST_BYTES, MAX_MODEL_BYTES};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
||||
use crate::denoise::{Pace, RunOn, Settings, forget_gpu_set_ups, installed_models, read_capped, read_settings, write_atomic, write_settings};
|
||||
use crate::model_download::State;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
|
||||
EngineError::Other(format!("{what}: {e}"))
|
||||
}
|
||||
|
||||
fn models_dir(s: &Session, cmd: &str) -> Result<PathBuf> {
|
||||
s.denoise.models_dir.clone().ok_or_else(|| bad(cmd, "this build has nowhere to keep denoise models (the desktop app does)"))
|
||||
}
|
||||
|
||||
/// What a command says about a model.
|
||||
fn row(m: &DenoiserManifest, download_host: Option<String>, installed: bool, selected: bool, accepted: &Value) -> Value {
|
||||
json!({
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"version": m.version,
|
||||
"licence": m.licence,
|
||||
"provenance": m.provenance,
|
||||
"source": m.source,
|
||||
"sizeBytes": m.size_bytes,
|
||||
"sha256": m.sha256,
|
||||
"tile": m.tile,
|
||||
"known": known::find(&m.id).is_some(),
|
||||
// a pinned address LightCraft can fetch it from (the user presses Download), and which site that is
|
||||
"downloadHost": download_host,
|
||||
"installed": installed,
|
||||
"selected": selected,
|
||||
"accepted": accepted,
|
||||
})
|
||||
}
|
||||
|
||||
fn host_of(k: &Known) -> Option<String> {
|
||||
k.download.as_ref().map(|d| lightcraft_denoise::known::host(&d.url).to_string())
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.denoise_refresh_active(true);
|
||||
let dir = s.denoise.models_dir.clone();
|
||||
let st = dir.as_deref().map(read_settings).unwrap_or_default();
|
||||
let installed = dir.as_deref().map(installed_models).unwrap_or_default();
|
||||
let on_disk = |id: &str| installed.iter().find(|i| i.manifest.id == id);
|
||||
let chosen = |id: &str| st.model.as_deref() == Some(id);
|
||||
let mut models: Vec<Value> = Vec::new();
|
||||
for k in known::all() {
|
||||
let found = on_disk(&k.manifest.id);
|
||||
models.push(row(&k.manifest, host_of(&k), found.is_some(), chosen(&k.manifest.id), found.map_or(&Value::Null, |f| &f.accepted)));
|
||||
}
|
||||
for i in installed.iter().filter(|i| known::find(&i.manifest.id).is_none()) {
|
||||
models.push(row(&i.manifest, None, true, chosen(&i.manifest.id), &i.accepted));
|
||||
}
|
||||
Ok(json!({
|
||||
"dir": dir.as_ref().map(|d| d.display().to_string()),
|
||||
"productsDir": s.denoise_products_dir().map(|d| d.display().to_string()),
|
||||
"model": st.model,
|
||||
// whether this build can run denoise models (the `denoise` feature: our pure-Rust CPU/GPU runners)
|
||||
"runtime": cfg!(feature = "denoise"),
|
||||
"auto": st.auto(),
|
||||
"cacheGb": st.cache_bytes() >> 30,
|
||||
"threads": st.threads,
|
||||
"runOn": st.run_on().name(),
|
||||
"models": models,
|
||||
}))
|
||||
}
|
||||
|
||||
/// What a file the user pointed at is, as far as installing it goes.
|
||||
struct Source {
|
||||
manifest: DenoiserManifest,
|
||||
/// The `.onnx` inside the archive; `None` when the file is the `.onnx`.
|
||||
entry: Option<&'static str>,
|
||||
file_name: String,
|
||||
}
|
||||
|
||||
fn identify(path: &Path, c: &str, sha_known: Option<&str>) -> Result<Source> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| bad(c, format!("cannot read `{}`: {e}", path.display())))?;
|
||||
if !meta.is_file() {
|
||||
return Err(bad(c, format!("`{}` is not a file", path.display())));
|
||||
}
|
||||
if meta.len() == 0 || meta.len() > MAX_MODEL_BYTES {
|
||||
return Err(bad(c, "a model file must be between 1 byte and 2 GiB"));
|
||||
}
|
||||
let sha256 = match sha_known {
|
||||
Some(h) => h.to_string(),
|
||||
None => sha256_file(path).map_err(|e| fail(&format!("could not read {}", path.display()), e))?,
|
||||
};
|
||||
let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// an archive LightCraft knows (the file darktable publishes the model in)
|
||||
if let Some(k) = known::all().into_iter().find(|k| k.download.as_ref().is_some_and(|d| d.sha256 == sha256)) {
|
||||
return Ok(Source { manifest: k.manifest, entry: k.entry, file_name });
|
||||
}
|
||||
// the `.onnx` of a known model
|
||||
if let Some(m) = known::by_sha256(&sha256) {
|
||||
return Ok(Source { manifest: m, entry: None, file_name });
|
||||
}
|
||||
// an `.onnx` with its manifest beside it
|
||||
let sibling = path.with_file_name("denoise-model.json");
|
||||
if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx"))
|
||||
&& let Some(bytes) = read_capped(&sibling, MAX_MANIFEST_BYTES)
|
||||
{
|
||||
let m = manifest::parse(&bytes).map_err(|e| bad(c, format!("{}: {e}", sibling.display())))?;
|
||||
return Ok(Source { manifest: m, entry: None, file_name });
|
||||
}
|
||||
Err(bad(
|
||||
c,
|
||||
"LightCraft does not know this file. A denoise model is an .onnx file with a denoise-model.json beside it that says how to use it \
|
||||
(see docs/denoise.md), or an archive LightCraft can fetch itself (`denoise.models.download`)",
|
||||
))
|
||||
}
|
||||
|
||||
/// Install the model in `src`. A model that passes its self-test becomes the one in use (`activate`, the default): installing
|
||||
/// a model is how someone says they want it. `verified` is for a download whose hash was already checked.
|
||||
fn install_file(
|
||||
dir: &Path,
|
||||
loader: &crate::denoise::Loader,
|
||||
run_on: RunOn,
|
||||
accepted_at: String,
|
||||
c: &str,
|
||||
src: &Path,
|
||||
acknowledged: bool,
|
||||
verified: bool,
|
||||
) -> Result<(Value, Arc<dyn crate::denoise::Model>)> {
|
||||
let ident = identify(src, c, None)?;
|
||||
let m = ident.manifest.clone();
|
||||
manifest::validate(&m).map_err(|e| bad(c, e.to_string()))?;
|
||||
if !acknowledged {
|
||||
return Err(bad(
|
||||
c,
|
||||
"the licence has not been accepted: show the user its terms (see `denoise.models.list`) and pass `acknowledged: true` once they agree",
|
||||
));
|
||||
}
|
||||
let home = dir.join(&m.id);
|
||||
if home.join("model.onnx").exists() {
|
||||
return Err(bad(c, "that model is already installed; remove it before installing a replacement"));
|
||||
}
|
||||
std::fs::create_dir_all(&home).map_err(|e| fail("could not create the model's folder", e))?;
|
||||
let mut cleanup = InstallCleanup { home: home.clone(), armed: true };
|
||||
let (part, final_path) = (home.join("model.onnx.part"), home.join("model.onnx"));
|
||||
let _ = std::fs::remove_file(&part);
|
||||
let placed: Result<()> = match ident.entry {
|
||||
Some(entry) => {
|
||||
archive::extract(src, entry, &part, MAX_MODEL_BYTES).map(|_| ()).map_err(|e| fail("could not take the model out of the archive", e))
|
||||
}
|
||||
None if verified && std::fs::rename(src, &part).is_ok() => Ok(()),
|
||||
None => std::fs::copy(src, &part).map(|_| ()).map_err(|e| fail("could not copy the model (is the disk full?)", e)),
|
||||
};
|
||||
let sha256 = placed.and_then(|_| sha256_file(&part).map_err(|e| fail("could not check the model", e))).and_then(|h| match &m.sha256 {
|
||||
Some(want) if *want != h => Err(EngineError::Other("the model file is not the one its description names (its SHA-256 differs)".into())),
|
||||
_ => Ok(h),
|
||||
});
|
||||
let sha256 = match sha256 {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Test the staged file first. A failed replacement preserves the installed model.
|
||||
let model = loader(&part, &m).map_err(|e| bad(c, e))?;
|
||||
let test = match model.self_test(run_on) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(bad(c, e));
|
||||
}
|
||||
};
|
||||
// An installed id is immutable: installing again must never destroy a usable model.
|
||||
if final_path.exists() {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(bad(c, "that model is already installed; remove it before installing a replacement"));
|
||||
}
|
||||
std::fs::rename(&part, &final_path).map_err(|e| fail("could not finish the copy", e))?;
|
||||
write_atomic(&home.join("denoise-model.json"), &serde_json::to_vec_pretty(&m).map_err(|e| fail("manifest", e))?).map_err(EngineError::Other)?;
|
||||
let accepted = json!({
|
||||
"acceptedAt": accepted_at,
|
||||
"licence": m.licence.name,
|
||||
"commercial": m.licence.commercial,
|
||||
"fileName": ident.file_name,
|
||||
"sha256": sha256,
|
||||
"selfTest": test,
|
||||
});
|
||||
write_atomic(&home.join("installed.json"), &serde_json::to_vec_pretty(&accepted).map_err(|e| fail("record", e))?).map_err(EngineError::Other)?;
|
||||
cleanup.armed = false;
|
||||
Ok((json!({"installed": row(&m, known::find(&m.id).and_then(|k| host_of(&k)), true, false, &accepted), "model": m.id}), model))
|
||||
}
|
||||
|
||||
/// Removes only files created for this new installation, including on a worker panic.
|
||||
struct InstallCleanup {
|
||||
home: PathBuf,
|
||||
armed: bool,
|
||||
}
|
||||
impl Drop for InstallCleanup {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
for name in ["model.onnx.part", "model.onnx", "denoise-model.json", "installed.json"] {
|
||||
let _ = std::fs::remove_file(self.home.join(name));
|
||||
}
|
||||
let _ = std::fs::remove_dir(&self.home);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct InstallJob {
|
||||
id: String,
|
||||
activate: bool,
|
||||
result: mpsc::Receiver<std::result::Result<(Value, Arc<dyn crate::denoise::Model>), String>>,
|
||||
}
|
||||
|
||||
fn activate_install(s: &mut Session, result: &Value, model: Arc<dyn crate::denoise::Model>, activate: bool) -> Result<Value> {
|
||||
let dir = models_dir(s, "denoise.models.install")?;
|
||||
let id = result["installed"]["id"].as_str().ok_or_else(|| bad("denoise.models.install", "missing installed id"))?;
|
||||
let mut st = read_settings(&dir);
|
||||
if activate {
|
||||
st.model = Some(id.to_string());
|
||||
write_settings(&dir, &st).map_err(EngineError::Other)?;
|
||||
}
|
||||
s.denoise.touch();
|
||||
s.denoise_refresh_active(true);
|
||||
if let Some(a) = &s.denoise.active
|
||||
&& a.id == id
|
||||
{
|
||||
*a.model.lock().unwrap_or_else(PoisonError::into_inner) = Some(model);
|
||||
}
|
||||
let mut v = result.clone();
|
||||
v["installed"]["selected"] = json!(st.model.as_deref() == Some(id));
|
||||
v["model"] = json!(st.model);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn start_install(s: &mut Session, src: PathBuf, id: String, activate: bool, verified: bool) -> Result<Value> {
|
||||
const C: &str = "denoise.models.install";
|
||||
if s.denoise.install.is_some() {
|
||||
return Err(bad(C, "another model is being installed"));
|
||||
}
|
||||
let dir = models_dir(s, C)?;
|
||||
let loader = s.denoise.loader.clone();
|
||||
let run_on = s.denoise.settings.run_on();
|
||||
let at = (s.clock)();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
.name("denoise-model-install".into())
|
||||
.stack_size(16 * 1024 * 1024)
|
||||
.spawn(move || {
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _held = crate::memory::work_gate().acquire(512 * 1024 * 1024);
|
||||
install_file(&dir, &loader, run_on, at, C, &src, true, verified).map_err(|e| plain(&e))
|
||||
}))
|
||||
.unwrap_or_else(|_| Err("the model installation stopped unexpectedly".into()));
|
||||
if verified {
|
||||
let _ = std::fs::remove_file(&src);
|
||||
}
|
||||
let _ = tx.send(outcome);
|
||||
})
|
||||
.map_err(|e| bad(C, format!("could not start installation: {e}")))?;
|
||||
s.denoise.install = Some(InstallJob { id: id.clone(), activate, result: rx });
|
||||
s.denoise.downloads.set_outcome(&id, State::Installing);
|
||||
Ok(json!({"started": id}))
|
||||
}
|
||||
|
||||
fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.install";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let acknowledged = bool_or(p, "acknowledged", false);
|
||||
let activate = bool_or(p, "activate", true);
|
||||
if !acknowledged {
|
||||
return Err(bad(C, "the licence has not been accepted: show its terms first"));
|
||||
}
|
||||
if bool_or(p, "background", false) {
|
||||
return start_install(s, PathBuf::from(path), "local-install".into(), activate, false);
|
||||
}
|
||||
if s.denoise.install.is_some() {
|
||||
return Err(bad(C, "another model is being installed"));
|
||||
}
|
||||
let (result, model) =
|
||||
install_file(&models_dir(s, C)?, &s.denoise.loader, s.denoise.settings.run_on(), (s.clock)(), C, Path::new(path), acknowledged, false)?;
|
||||
activate_install(s, &result, model, activate)
|
||||
}
|
||||
|
||||
/// Read only the small adjacent manifest; hashing and execution happen in the install worker.
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.inspect";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let src = Path::new(path);
|
||||
if !src.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) {
|
||||
return Err(bad(C, "choose an .onnx file with denoise-model.json beside it"));
|
||||
}
|
||||
let bytes = read_capped(&src.with_file_name("denoise-model.json"), MAX_MANIFEST_BYTES)
|
||||
.ok_or_else(|| bad(C, "denoise-model.json is missing or too large"))?;
|
||||
let m = manifest::parse(&bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
Ok(json!({"kind": "model", "domain": "denoise", "path": path, "fileName": src.file_name().map(|n| n.to_string_lossy()), "model": m,
|
||||
"sizeBytes": std::fs::metadata(src).ok().map(|m| m.len()), "alreadyInstalled": dir.join(&m.id).join("model.onnx").is_file()}))
|
||||
}
|
||||
|
||||
/// What an error says, without the command it came from (for a line shown to the user).
|
||||
fn plain(e: &EngineError) -> String {
|
||||
match e {
|
||||
EngineError::BadParams { msg, .. } => msg.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install every download that has arrived: each was accepted when it was started, so it is installed and chosen without
|
||||
/// another question. Called by `denoise.models.downloads` and by every frame's `denoise.pump`.
|
||||
pub(crate) fn finish_downloads(s: &mut Session) {
|
||||
if let Some(job) = &s.denoise.install {
|
||||
let outcome = match job.result.try_recv() {
|
||||
Ok(v) => Some(v),
|
||||
Err(mpsc::TryRecvError::Empty) => None,
|
||||
Err(mpsc::TryRecvError::Disconnected) => Some(Err("the installation worker stopped".into())),
|
||||
};
|
||||
if let Some(outcome) = outcome
|
||||
&& let Some(job) = s.denoise.install.take()
|
||||
{
|
||||
let state = match outcome {
|
||||
Ok((v, model)) => match activate_install(s, &v, model, job.activate) {
|
||||
Ok(_) => State::Installed,
|
||||
Err(e) => State::Failed(plain(&e)),
|
||||
},
|
||||
Err(e) => State::Failed(e),
|
||||
};
|
||||
s.denoise.downloads.set_outcome(&job.id, state);
|
||||
}
|
||||
}
|
||||
if s.denoise.install.is_none()
|
||||
&& let Some((id, path, _sha)) = s.denoise.downloads.finished().into_iter().next()
|
||||
&& let Err(e) = start_install(s, path, id.clone(), true, true)
|
||||
{
|
||||
s.denoise.downloads.set_outcome(&id, State::Failed(plain(&e)));
|
||||
}
|
||||
}
|
||||
|
||||
fn download(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.download";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
let k = known::find(id).ok_or_else(|| bad(C, "LightCraft has no download for that model: get the file from its page, then add it"))?;
|
||||
let spec = k.download.clone().ok_or_else(|| bad(C, "LightCraft has no download for that model: get the file from its page, then add it"))?;
|
||||
if !cfg!(feature = "denoise") {
|
||||
return Err(bad(C, "this build cannot run denoise models, so there is nothing to download for it"));
|
||||
}
|
||||
if !bool_or(p, "acknowledged", false) {
|
||||
return Err(bad(
|
||||
C,
|
||||
"the licence has not been accepted: show the user the model's terms (see `denoise.models.list`) and pass `acknowledged: true` once they agree",
|
||||
));
|
||||
}
|
||||
if installed_models(&dir).iter().any(|i| i.manifest.id == spec.id) {
|
||||
return Err(bad(C, "that model is already installed"));
|
||||
}
|
||||
let host = lightcraft_denoise::known::host(&spec.url).to_string();
|
||||
s.denoise.downloads.start(spec, &dir, &s.activity, &k.manifest.name).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"started": id, "from": host}))
|
||||
}
|
||||
|
||||
fn download_row(id: &str, state: &State) -> Value {
|
||||
let from = known::find(id).and_then(|k| host_of(&k));
|
||||
match state {
|
||||
State::Running { bytes, total } => json!({"id": id, "state": "running", "bytes": bytes, "total": total, "from": from}),
|
||||
State::Done { path, .. } => json!({"id": id, "state": "done", "path": path.display().to_string(), "from": from}),
|
||||
State::Installing => json!({"id": id, "state": "installing", "from": from}),
|
||||
State::Installed => json!({"id": id, "state": "installed", "from": from}),
|
||||
State::Failed(why) => json!({"id": id, "state": "failed", "error": why, "from": from}),
|
||||
State::Cancelled => json!({"id": id, "state": "cancelled", "from": from}),
|
||||
}
|
||||
}
|
||||
|
||||
fn downloads(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
finish_downloads(s);
|
||||
let all: Vec<Value> = s.denoise.downloads.snapshot().iter().map(|(id, st)| download_row(id, st)).collect();
|
||||
Ok(json!({"running": s.denoise.downloads.running(), "downloads": all}))
|
||||
}
|
||||
|
||||
fn download_cancel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.downloadCancel";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
Ok(json!({"discarded": s.denoise.downloads.discard(id)}))
|
||||
}
|
||||
|
||||
fn test(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.test";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
let found = installed_models(&dir).into_iter().find(|i| i.manifest.id == id).ok_or_else(|| bad(C, "that model is not installed"))?;
|
||||
let run_on = s.denoise.settings.run_on();
|
||||
let result = (s.denoise.loader)(&found.onnx, &found.manifest).and_then(|m| m.self_test(run_on));
|
||||
let (ok, detail) = match &result {
|
||||
Ok(v) => (true, v.clone()),
|
||||
Err(e) => (false, json!(e)),
|
||||
};
|
||||
// keep the latest outcome with the model's record
|
||||
let mut record = found.accepted;
|
||||
if let Some(o) = record.as_object_mut() {
|
||||
o.insert("selfTest".into(), if ok { detail.clone() } else { json!({"ok": false, "error": detail}) });
|
||||
write_atomic(&found.onnx.with_file_name("installed.json"), &serde_json::to_vec_pretty(&record).map_err(|e| fail("record", e))?)
|
||||
.map_err(EngineError::Other)?;
|
||||
}
|
||||
Ok(json!({"id": id, "ok": ok, "result": detail}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.remove";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
if !lightcraft_denoise::licence::valid_id(id) {
|
||||
return Err(bad(C, "not a model id"));
|
||||
}
|
||||
let home = dir.join(id);
|
||||
// only a folder that is a model (it has a manifest) is ever deleted
|
||||
if !home.join("denoise-model.json").is_file() {
|
||||
return Err(bad(C, "that model is not installed"));
|
||||
}
|
||||
std::fs::remove_dir_all(&home).map_err(|e| fail("could not remove the model", e))?;
|
||||
let mut st = read_settings(&dir);
|
||||
if st.model.as_deref() == Some(id) {
|
||||
st.model = installed_models(&dir).into_iter().map(|i| i.manifest.id).find(|m| m != id);
|
||||
write_settings(&dir, &st).map_err(EngineError::Other)?;
|
||||
}
|
||||
s.denoise.touch();
|
||||
s.denoise_refresh_active(true);
|
||||
Ok(json!({"removed": id, "model": st.model}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.models.select";
|
||||
let dir = models_dir(s, C)?;
|
||||
let id = match p.get("id") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_str().ok_or_else(|| bad(C, "`id` must be a model id or null"))?.to_string()),
|
||||
};
|
||||
if let Some(id) = &id
|
||||
&& !installed_models(&dir).iter().any(|i| &i.manifest.id == id)
|
||||
{
|
||||
return Err(bad(C, "that denoise model is not installed"));
|
||||
}
|
||||
let mut st = read_settings(&dir);
|
||||
st.model = id;
|
||||
write_settings(&dir, &st).map_err(EngineError::Other)?;
|
||||
s.denoise.touch();
|
||||
s.denoise_refresh_active(true);
|
||||
Ok(json!({"model": st.model}))
|
||||
}
|
||||
|
||||
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.settings";
|
||||
let dir = models_dir(s, C)?;
|
||||
let mut st: Settings = read_settings(&dir);
|
||||
if let Some(v) = p.get("auto") {
|
||||
st.auto = Some(v.as_bool().ok_or_else(|| bad(C, "`auto` must be true or false"))?);
|
||||
}
|
||||
if let Some(v) = p.get("cacheGb") {
|
||||
let n =
|
||||
v.as_u64().filter(|n| (1..=10_000).contains(n)).ok_or_else(|| bad(C, "`cacheGb` must be a whole number of gigabytes from 1 to 10000"))?;
|
||||
st.cache_gb = Some(n as u32);
|
||||
}
|
||||
if let Some(v) = p.get("threads") {
|
||||
st.threads = match v {
|
||||
Value::Null => None,
|
||||
v => Some(v.as_u64().filter(|n| (1..=64).contains(n)).ok_or_else(|| bad(C, "`threads` must be from 1 to 64, or null"))? as u32),
|
||||
};
|
||||
}
|
||||
let run_on = match p.get("runOn") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_str().and_then(RunOn::parse).ok_or_else(|| bad(C, "`runOn` must be auto, gpu or cpu"))?),
|
||||
};
|
||||
if let Some(r) = run_on {
|
||||
st.run_on = Some(r.name().to_string());
|
||||
st.gpu = None;
|
||||
}
|
||||
write_settings(&dir, &st).map_err(EngineError::Other)?;
|
||||
if run_on.is_some() {
|
||||
// a choice of where denoise runs is also a go-ahead to try the card again: a set-up that crashed or gave up
|
||||
// before is forgotten, and the loaded model sets the card up afresh for the next photo
|
||||
forget_gpu_set_ups(&dir);
|
||||
if let Some(a) = &s.denoise.active {
|
||||
*a.model.lock().unwrap_or_else(PoisonError::into_inner) = None;
|
||||
}
|
||||
}
|
||||
s.denoise.touch();
|
||||
s.denoise_refresh_active(true);
|
||||
// a smaller limit takes effect now
|
||||
s.denoise_evict(None);
|
||||
Ok(json!({"auto": st.auto(), "cacheGb": st.cache_bytes() >> 30, "threads": st.threads, "runOn": st.run_on().name()}))
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
fn status_json(s: &Session, photo: Option<PhotoId>) -> Value {
|
||||
let (files, bytes) = s.denoise_cache_usage();
|
||||
let st = &s.denoise.settings;
|
||||
let model = s.denoise.active.as_ref().map(|a| json!({"id": a.id, "name": a.manifest.name}));
|
||||
let mut v = json!({
|
||||
"enabled": s.denoise.active.is_some(),
|
||||
"model": model,
|
||||
"runtime": cfg!(feature = "denoise"),
|
||||
"productsDir": s.denoise_products_dir().map(|d| d.display().to_string()),
|
||||
"queued": s.denoise.queued(),
|
||||
"running": s.denoise_running_json(),
|
||||
"ready": s.media.denoise.len(),
|
||||
"made": s.denoise_made(),
|
||||
"auto": st.auto(),
|
||||
"runOn": st.run_on().name(),
|
||||
"device": s.denoise_device(),
|
||||
"cache": {"files": files, "bytes": bytes, "limitBytes": st.cache_bytes()},
|
||||
"generation": s.denoise.generation,
|
||||
});
|
||||
if let (Some(id), Some(o)) = (photo, v.as_object_mut()) {
|
||||
o.insert("photo".into(), serde_json::to_value(s.denoise_photo_state(id)).unwrap_or(Value::Null));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A per-photo switch, independent of the selection and Auto Sync. Turning on starts at 50%.
|
||||
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.toggle";
|
||||
let id = match p.get("id") {
|
||||
Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, "`id` must be a photo id"))?),
|
||||
None => s.active().ok_or_else(|| bad(C, "select a raw photo"))?,
|
||||
};
|
||||
let photo = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?;
|
||||
if !crate::denoise::eligible(photo) {
|
||||
return Err(bad(C, "AI Denoise applies to raw photos developed from their sensor data"));
|
||||
}
|
||||
let mut d = (*s.develop_of(id).ok_or_else(|| bad(C, "no such photo"))?).clone();
|
||||
let on = match p.get("enabled") {
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(C, "`enabled` must be true or false"))?,
|
||||
None => !d.enhance.denoise_enabled(),
|
||||
};
|
||||
if on {
|
||||
if !d.enhance.denoise.is_finite() {
|
||||
return Err(bad(C, "the photo has an invalid Denoise amount"));
|
||||
}
|
||||
s.denoise_refresh_active(true);
|
||||
if s.denoise.active.is_none() {
|
||||
return Err(bad(C, "AI Denoise needs a model: install one in Settings > AI Denoise"));
|
||||
}
|
||||
}
|
||||
// The switch is its own setting: the Amount stays where it is (0 included) when it is flipped, so flipping compares
|
||||
// with and without. A photo that never had an Amount starts at 50 the first time.
|
||||
let amount = if on && d.enhance.denoise_on.is_none() && d.enhance.denoise <= 0.0 { 50.0 } else { d.enhance.denoise };
|
||||
if d.enhance.denoise != amount || d.enhance.denoise_on != Some(on) {
|
||||
s.end_interaction()?;
|
||||
d.enhance.denoise = amount;
|
||||
d.enhance.denoise_on = Some(on);
|
||||
let op = s.develop_op(id, d, "AI Denoise").ok_or_else(|| bad(C, "no such photo"))?;
|
||||
s.commit("AI Denoise", op)?;
|
||||
}
|
||||
if on {
|
||||
s.denoise_enqueue(&[id], true, false);
|
||||
} else {
|
||||
s.denoise_cancel(Some(id), false);
|
||||
}
|
||||
Ok(json!({"id": id.0, "enabled": on, "amount": amount}))
|
||||
}
|
||||
|
||||
fn queue(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "denoise.queue";
|
||||
s.denoise_refresh_active(true);
|
||||
if s.denoise.active.is_none() {
|
||||
return Err(bad(C, "no denoise model is chosen: install one first (`denoise.models.download` or `denoise.models.install`)"));
|
||||
}
|
||||
if s.denoise_products_dir().is_none() {
|
||||
return Err(bad(C, "denoise keeps its pictures in the library's folder: open a library on disk first"));
|
||||
}
|
||||
let ids = match ids_param(p) {
|
||||
Some(ids) => ids,
|
||||
None => match str_param(p, "scope").unwrap_or("selected") {
|
||||
"selected" => s.selection.active.iter().chain(s.selection.ids.iter()).copied().collect(),
|
||||
"visible" => s.visible_cloned().to_vec(),
|
||||
"withAmount" => s.catalog.photos().filter(|ph| ph.develop.denoise_amount() > 0.0).map(|ph| ph.id).collect(),
|
||||
other => return Err(bad(C, format!("unknown scope `{other}`: use selected, visible or withAmount"))),
|
||||
},
|
||||
};
|
||||
let queued = s.denoise_enqueue(&ids, false, bool_or(p, "retry", false));
|
||||
let mut v = status_json(s, None);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("added".into(), json!(queued));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn cancel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let photo = p.get("id").and_then(Value::as_u64).map(PhotoId);
|
||||
let n = s.denoise_cancel(photo, bool_or(p, "all", photo.is_none()));
|
||||
Ok(json!({"cancelled": n}))
|
||||
}
|
||||
|
||||
fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.denoise_refresh_active(false);
|
||||
Ok(status_json(s, p.get("id").and_then(Value::as_u64).map(PhotoId)))
|
||||
}
|
||||
|
||||
/// `denoise.pump {pace?}`: cheap to call every frame. Takes in what the background job has finished and keeps one going.
|
||||
fn pump(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.denoise_pump(Pace::parse(p.get("pace").and_then(Value::as_str)));
|
||||
s.denoise_track();
|
||||
Ok(json!({
|
||||
"active": s.denoise.active.is_some(),
|
||||
"running": s.denoise_running_json(),
|
||||
"queued": s.denoise.queued(),
|
||||
"ready": s.media.denoise.len(),
|
||||
"generation": s.denoise.generation,
|
||||
"downloading": s.denoise.downloads.running(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let (files, bytes) = s.denoise_clear();
|
||||
Ok(json!({"deleted": files, "bytes": bytes}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "denoise.models.list", "Denoise Models", [], None, "{} → {dir, productsDir, model, runtime, auto, cacheGb, threads, runOn, models: [{id, name, version, licence{name, commercial, url, notice}, provenance, source, sizeBytes, sha256, tile, known, downloadHost, installed, selected, accepted}]}", always, list),
|
||||
cmd!(query "denoise.models.inspect", "Inspect Denoise Model", [], None, "{path} → model terms from an adjacent manifest; never executes the file", always, inspect),
|
||||
cmd!(query "denoise.models.install", "Install Denoise Model", [], None, "{path, acknowledged: true, activate?: true, background?: false} → {installed, model} or {started} — install a denoise model from a file: an .onnx with a denoise-model.json beside it, or an archive LightCraft knows (the darktable `.dtmodel`). `acknowledged` must be true: the user has been shown the model's licence and accepted it. Unless `activate` is false it becomes the model in use", always, install),
|
||||
cmd!(query "denoise.models.download", "Download Denoise Model", [], None, "{id, acknowledged: true} → {started, from} — fetch a model LightCraft has a pinned address for (see `downloadHost` in the list) in the background over HTTPS with lightcraft-fetch. `acknowledged` must be true: the user has been shown the model's terms and accepted them. It is checked against its size and SHA-256, installed and chosen by itself; `denoise.models.downloads` shows how far it is", always, download),
|
||||
cmd!(query "denoise.models.downloads", "Denoise Model Downloads", [], None, "{} → {running, downloads: [{id, state: running | done | installed | failed | cancelled, bytes, total, error, from}]} — also installs any download that has arrived", always, downloads),
|
||||
cmd!(query "denoise.models.downloadCancel", "Cancel Denoise Model Download", [], None, "{id} → {discarded} — stop a download, or delete a finished one that was not installed", always, download_cancel),
|
||||
cmd!(query "denoise.models.test", "Test Denoise Model", [], None, "{id} → {ok, result} — load an installed model and check it gives sensible pictures; needs the denoise runtime", always, test),
|
||||
cmd!(query "denoise.models.remove", "Remove Denoise Model", [], None, "{id} → {removed, model} — delete an installed model (its cached pictures stay until they are evicted)", always, remove),
|
||||
cmd!(query "denoise.models.select", "Choose Denoise Model", [], None, "{id: installed model | null} → {model} — null switches denoise off", always, select),
|
||||
cmd!(query "denoise.settings", "Denoise Settings", [], None, "{auto?: bool, cacheGb?: 1..10000, threads?: 1..64 | null, runOn?: auto | gpu | cpu} → the settings — `auto`: make the picture of the photos being looked at that have a Denoise amount; `cacheGb`: how much the cached pictures may take; `threads`: most tiles run at once; `runOn`: where the model runs — `auto` (the default) the graphics card where it is faster than the processor (both timed on this computer when the card is set up), `gpu` the card whenever it can run the model, `cpu` the processor. Setting `runOn` also lets the card be tried again after a set-up that failed or closed LightCraft", always, settings),
|
||||
cmd!(
|
||||
"denoise.toggle",
|
||||
"AI Denoise",
|
||||
[],
|
||||
None,
|
||||
"{id?: photoId, enabled?: bool} → {id, enabled, amount} — turn AI Denoise on (50% initially) or off for one raw photo, independent of selection and Auto Sync; needs an installed model to turn on",
|
||||
always,
|
||||
toggle
|
||||
),
|
||||
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.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),
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Develop commands. All operate on the active photo unless `ids` are given (sync).
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Version};
|
||||
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
|
||||
use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
|
||||
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -74,6 +74,49 @@ fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)
|
||||
d.crop.geometry = crop_fit_angle(w, h, angle, a);
|
||||
}
|
||||
|
||||
/// Photo `id` is on an older rendering process than [`ProcessVersion::LATEST`].
|
||||
fn outdated(s: &Session, id: PhotoId) -> bool {
|
||||
s.catalog.photo(id).is_some_and(|p| p.develop.process.is_outdated())
|
||||
}
|
||||
|
||||
fn already_current() -> String {
|
||||
format!("already on the current process (version {})", ProcessVersion::LATEST.0)
|
||||
}
|
||||
|
||||
/// Update to Current Process applies to the selection (the selected photos, else the active
|
||||
/// photo): one of them is on an older rendering process. Calls that name their photos are
|
||||
/// checked by the command instead (`explicit_targets`).
|
||||
fn has_outdated_process(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let sel = &s.selection;
|
||||
let targets = if sel.ids.is_empty() { sel.active.as_slice() } else { sel.ids.as_slice() };
|
||||
if targets.iter().any(|id| outdated(s, *id)) { Ok(()) } else { Err(already_current()) }
|
||||
}
|
||||
|
||||
/// The photos a call names (`ids`, or `id`), each of which must be in the library; `None` when it
|
||||
/// names none (the selection applies).
|
||||
fn named_photos(s: &Session, p: &Value, c: &str) -> Result<Option<Vec<PhotoId>>> {
|
||||
if !super::names_photos(p) {
|
||||
return Ok(None);
|
||||
}
|
||||
let given = match p.get("ids").filter(|v| !v.is_null()) {
|
||||
Some(Value::Array(a)) => a.clone(),
|
||||
Some(_) => return Err(bad(c, "`ids` must be a list of photo ids")),
|
||||
None => p.get("id").cloned().into_iter().collect(),
|
||||
};
|
||||
if given.is_empty() {
|
||||
return Err(bad(c, "`ids` names no photos"));
|
||||
}
|
||||
let ids = given
|
||||
.iter()
|
||||
.map(|v| match v.as_u64().map(PhotoId) {
|
||||
Some(id) if s.catalog.photo(id).is_some() => Ok(id),
|
||||
_ => Err(bad(c, format!("no such photo {v}"))),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(Some(ids))
|
||||
}
|
||||
|
||||
/// The user preset named by `p.id` (built-ins can't be changed).
|
||||
fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Preset> {
|
||||
let pid = str_param(p, "id").ok_or_else(|| bad(c, "missing id"))?;
|
||||
@@ -203,12 +246,44 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
};
|
||||
s.develop_op(id, fresh, "Reset")
|
||||
// a fresh start renders with the current process (a section or slider reset doesn't change it)
|
||||
s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..fresh }, "Reset")
|
||||
})
|
||||
.collect();
|
||||
s.commit("Reset", Op::Batch { ops })?;
|
||||
ok()
|
||||
}),
|
||||
CommandSpec {
|
||||
explicit_targets: true,
|
||||
..cmd!(
|
||||
"develop.updateProcess",
|
||||
"Update to Current Process",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?}: moves the target photos (`ids`, else the selection) that are on an older rendering process to the current one, as one undo step (their look can change; the sliders keep their values; see docs/process-versions.md). Enabled while a selected photo is on an older process; named photos are checked themselves (each must exist, one must be on an older process) → {changed, process}",
|
||||
has_outdated_process,
|
||||
|s, p| {
|
||||
const C: &str = "develop.updateProcess";
|
||||
const LABEL: &str = "Update to Current Process";
|
||||
let targets = match named_photos(s, p, C)? {
|
||||
Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())),
|
||||
Some(ids) => ids,
|
||||
None => s.targets(p),
|
||||
};
|
||||
let ops: Vec<Op> = targets
|
||||
.into_iter()
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter(|(_, d)| d.process.is_outdated())
|
||||
.filter_map(|(id, d)| s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..(*d).clone() }, LABEL))
|
||||
.collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(LABEL, Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"changed": n, "process": ProcessVersion::LATEST}))
|
||||
}
|
||||
)
|
||||
},
|
||||
cmd!(
|
||||
"develop.resetSection",
|
||||
"Reset Section",
|
||||
@@ -572,6 +647,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
// ---- HDR editing
|
||||
cmd!(
|
||||
"develop.hdr",
|
||||
"HDR",
|
||||
[],
|
||||
None,
|
||||
"{enabled?: bool (default: toggle), maxEv?: number 0..5 (headroom limit, stops above SDR white)} — HDR editing: highlights above SDR white for HDR exports and displays; every SDR render uses the SDR rendition (the hdr.sdr* controls)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "develop.hdr")?;
|
||||
let cur = s.develop_of(id).unwrap_or_default().hdr;
|
||||
let enabled = bool_or(p, "enabled", !cur.enabled);
|
||||
let max_ev = match p.get("maxEv") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("develop.hdr", "`maxEv` must be a number"))?),
|
||||
};
|
||||
let label = if enabled { "HDR On" } else { "HDR Off" };
|
||||
edit(s, "develop.hdr", label, |d| {
|
||||
d.hdr.enabled = enabled;
|
||||
if let Some(m) = max_ev {
|
||||
d.hdr.max_ev = m.clamp(0.0, lightcraft_develop::Hdr::MAX_EV_LIMIT);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"enabled": enabled, "maxEv": s.develop_of(id).map(|d| d.hdr.max_ev)}))
|
||||
}
|
||||
),
|
||||
// ---- geometry: Upright
|
||||
cmd!(
|
||||
"geometry.upright",
|
||||
|
||||
@@ -80,6 +80,17 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "export.contactSheet", "Export Contact Sheet PDF", [], None,
|
||||
"{path, ids?: photo ids (default selection), paper?: a4|letter, landscape?: bool, columns?: 1..8, rows?: 1..10, captions?: bool} → {path, pages, photos, bytes}; replaces an existing output, never an original",
|
||||
always, |s, p| {
|
||||
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad("export.contactSheet", "missing path"))?;
|
||||
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
|
||||
let doc = crate::contact_sheet::prepare(s, p).and_then(|work| work.run(&mut |_, _| true)).map_err(|e| bad("export.contactSheet", e))?;
|
||||
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
|
||||
crate::export::write_file(path, &doc.bytes).map_err(|e| bad("export.contactSheet", e))?;
|
||||
Ok(json!({"path": path, "pages": doc.pages, "photos": doc.photos, "bytes": doc.bytes.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"export.presets",
|
||||
"Export Presets",
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! `faces.detect`: find faces with the YuNet detector the user has downloaded (Settings > Faces).
|
||||
//!
|
||||
//! The photo is rendered upright and uncropped with default settings (so edits, crops and rotations you have
|
||||
//! made do not matter), the detector runs on it, and the faces come back as fractions of the photo's width
|
||||
//! and height, the same frame face regions from XMP use. Unless `apply` is false, they are added to the photo as
|
||||
//! unnamed face regions in one undoable step; earlier detections are replaced, and regions that came from XMP (or
|
||||
//! that you drew or named) are never touched. Like every region edit it is catalog-only: no sidecar is written.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_catalog::Op;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_faces::known::YUNET_ID;
|
||||
use lightcraft_faces::yunet::{Detector, Face, Options};
|
||||
use lightcraft_meta::{Region, RegionKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_selection};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const C: &str = "faces.detect";
|
||||
/// Regions made by this command say so in their description; only those are replaced on a new run.
|
||||
pub(crate) const MARK: &str = "Detected by ";
|
||||
pub(crate) const LABEL: &str = "YuNet 2023mar";
|
||||
/// Long edge of the picture the detector looks at (it shrinks it to its own 640 anyway).
|
||||
const LOOK_EDGE: usize = 1280;
|
||||
/// YuNet is 232 KB; a file this much larger in its folder is not it.
|
||||
const MAX_MODEL_BYTES: u64 = 8 << 20;
|
||||
/// How much a detection must overlap a region the photo already has to count as the same face.
|
||||
const EXISTING_IOU: f64 = 0.3;
|
||||
|
||||
/// What to tell someone who has not downloaded the detector yet.
|
||||
const NOT_INSTALLED: &str = "finding faces needs the YuNet face detector (232 KB, MIT): download it in Settings > Faces";
|
||||
|
||||
/// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes).
|
||||
/// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none)
|
||||
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, String> {
|
||||
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
|
||||
let home = s.face_models_dir.as_deref().ok_or("this build has nowhere to keep face models (the desktop app does)")?.join(YUNET_ID);
|
||||
let path = home.join("model.onnx");
|
||||
// installing writes the manifest last, so a folder without one is not (yet) a model
|
||||
if !path.is_file() || !home.join("face-model.json").is_file() {
|
||||
return Err(NOT_INSTALLED.into());
|
||||
}
|
||||
let mut loaded = LOADED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some((p, d)) = loaded.as_ref()
|
||||
&& *p == path
|
||||
{
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let len = std::fs::metadata(&path).map_err(|e| format!("could not read the detector: {e}"))?.len();
|
||||
if len > MAX_MODEL_BYTES {
|
||||
return Err("the detector's file is far bigger than YuNet: remove it in Settings > Faces and download it again".into());
|
||||
}
|
||||
let bytes = std::fs::read(&path).map_err(|e| format!("could not read the detector: {e}"))?;
|
||||
let d = Arc::new(Detector::new(&bytes).map_err(|e| format!("{e}: remove the detector in Settings > Faces and download it again"))?);
|
||||
*loaded = Some((path, d.clone()));
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn face_json(f: &Face) -> Value {
|
||||
json!({
|
||||
"rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1},
|
||||
"score": f.score,
|
||||
"landmarks": f.landmarks.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A detected face that lies on a region the photo already has (from XMP, drawn or named) is that region, not a new face.
|
||||
fn overlaps_existing(f: &Face, regions: &[Region]) -> bool {
|
||||
regions.iter().any(|r| {
|
||||
let (w, h) = (
|
||||
(r.rect.x1.min(f64::from(f.x1)) - r.rect.x0.max(f64::from(f.x0))).max(0.0),
|
||||
(r.rect.y1.min(f64::from(f.y1)) - r.rect.y0.max(f64::from(f.y0))).max(0.0),
|
||||
);
|
||||
let inter = w * h;
|
||||
let union = (r.rect.x1 - r.rect.x0) * (r.rect.y1 - r.rect.y0) + f64::from((f.x1 - f.x0) * (f.y1 - f.y0)) - inter;
|
||||
union > 0.0 && inter / union >= EXISTING_IOU
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_detected(r: &Region) -> bool {
|
||||
r.description.as_deref().is_some_and(|d| d.starts_with(MARK))
|
||||
}
|
||||
|
||||
/// The photo as the faces see it: upright, uncropped, default settings, as 8-bit RGB with its size, its long edge at
|
||||
/// most `edge`.
|
||||
pub(crate) fn render_rgb(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: usize) -> std::result::Result<(Vec<u8>, usize, usize), String> {
|
||||
let settings = DevelopSettings::default();
|
||||
let job = s.preview_job(id, edge, edge, false, &settings).ok_or("no such photo")?;
|
||||
let image = job.run().rendered?.image;
|
||||
let rgb: Vec<u8> = image.data.iter().flat_map(|px| [px[0], px[1], px[2]]).collect();
|
||||
Ok((rgb, image.width, image.height))
|
||||
}
|
||||
|
||||
fn detect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let apply = p.get("apply").and_then(Value::as_bool).unwrap_or(true);
|
||||
let defaults = Options::default();
|
||||
let opts = Options {
|
||||
score: p.get("score").and_then(Value::as_f64).map_or(defaults.score, |v| v as f32),
|
||||
nms_iou: p.get("nmsIou").and_then(Value::as_f64).map_or(defaults.nms_iou, |v| v as f32),
|
||||
max_faces: p.get("maxFaces").and_then(Value::as_u64).map_or(defaults.max_faces, |v| usize::try_from(v).unwrap_or(defaults.max_faces)),
|
||||
};
|
||||
let detector = detector(s).map_err(|e| bad(C, e))?;
|
||||
let started = Instant::now();
|
||||
let (mut results, mut ops) = (Vec::new(), Vec::new());
|
||||
for id in s.targets(p) {
|
||||
let (rgb, width, height) = match render_rgb(s, id, LOOK_EDGE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
results.push(json!({"id": id.0, "error": e}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let faces = match detector.detect(&rgb, width, height, &opts) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
results.push(json!({"id": id.0, "error": e.to_string()}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if apply && let Some(photo) = s.catalog.photo(id) {
|
||||
let mut meta = photo.meta.clone();
|
||||
meta.regions.retain(|r| !is_detected(r));
|
||||
let fresh: Vec<&Face> = faces.iter().filter(|f| !overlaps_existing(f, &meta.regions)).collect();
|
||||
for f in fresh {
|
||||
meta.regions.push(Region {
|
||||
rect: lightcraft_geom::Rect { x0: f64::from(f.x0), y0: f64::from(f.y0), x1: f64::from(f.x1), y1: f64::from(f.y1) },
|
||||
kind: RegionKind::Face,
|
||||
name: None,
|
||||
description: Some(format!("{MARK}{LABEL}")),
|
||||
});
|
||||
}
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
|
||||
}
|
||||
results.push(json!({"id": id.0, "faces": faces.iter().map(face_json).collect::<Vec<_>>()}));
|
||||
}
|
||||
let applied = !ops.is_empty();
|
||||
if applied {
|
||||
s.commit("Detect Faces", Op::Batch { ops })?;
|
||||
// regions are not written to XMP, so a sidecar must not be rewritten for this
|
||||
s.skip_auto_write = true;
|
||||
}
|
||||
Ok(json!({"detector": LABEL, "applied": applied, "photos": results, "ms": started.elapsed().as_secs_f64() * 1000.0}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"faces.detect",
|
||||
"Detect Faces",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
|
||||
has_selection,
|
||||
detect
|
||||
)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn face(x0: f32, y0: f32, x1: f32, y1: f32) -> Face {
|
||||
Face { x0, y0, x1, y1, score: 0.9, landmarks: [(0.0, 0.0); 5] }
|
||||
}
|
||||
fn region(x0: f64, y0: f64, x1: f64, y1: f64) -> Region {
|
||||
Region { rect: lightcraft_geom::Rect { x0, y0, x1, y1 }, kind: RegionKind::Face, name: Some("Ann".into()), description: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_detection_on_an_existing_region_is_not_a_new_face() {
|
||||
let existing = [region(0.40, 0.20, 0.60, 0.50), region(0.10, 0.10, 0.20, 0.20)];
|
||||
// the same face, a bit larger or shifted (a Lightroom box is not YuNet's box)
|
||||
assert!(overlaps_existing(&face(0.38, 0.18, 0.64, 0.54), &existing));
|
||||
assert!(overlaps_existing(&face(0.42, 0.22, 0.58, 0.48), &existing));
|
||||
// another face, a sliver of overlap, and no regions at all
|
||||
assert!(!overlaps_existing(&face(0.70, 0.20, 0.90, 0.50), &existing));
|
||||
assert!(!overlaps_existing(&face(0.59, 0.49, 0.80, 0.80), &existing));
|
||||
assert!(!overlaps_existing(&face(0.0, 0.0, 0.0, 0.0), &existing), "an empty box overlaps nothing");
|
||||
assert!(!overlaps_existing(&face(0.4, 0.2, 0.6, 0.5), &[]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
//! Face model commands (`faces.*`): which models exist, inspecting a file the user dropped in, installing
|
||||
//! it once its licence is accepted, removing it, and the faces on/off setting.
|
||||
//!
|
||||
//! Models live in the folder the host provides (`Session::face_models_dir`): one subfolder per model with
|
||||
//! `model.onnx`, `face-model.json` (its manifest) and `installed.json` (what the user accepted), plus
|
||||
//! `settings.json` beside them. Model files and manifests are hostile input: sizes are capped, ids are
|
||||
//! validated before they become folder names, and nothing outside a model's own folder is ever touched.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lightcraft_faces::catalog::{self, Catalog};
|
||||
use lightcraft_faces::hash::sha256_file;
|
||||
use lightcraft_faces::manifest::{self, MAX_MANIFEST_BYTES, MAX_MODEL_BYTES, ModelManifest, Role};
|
||||
use lightcraft_faces::suggest::{Suggestion, suggest_with};
|
||||
use lightcraft_faces::{known, onnx};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::face_download::{self, State};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub(super) struct FaceSettings {
|
||||
pub(super) enabled: bool,
|
||||
/// The installed recogniser in use.
|
||||
pub(super) embedder: Option<String>,
|
||||
}
|
||||
|
||||
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
|
||||
EngineError::Other(format!("{what}: {e}"))
|
||||
}
|
||||
|
||||
fn models_dir(s: &Session, cmd: &str) -> Result<PathBuf> {
|
||||
s.face_models_dir.clone().ok_or_else(|| bad(cmd, "this build has nowhere to keep face models (the desktop app does)"))
|
||||
}
|
||||
|
||||
/// Read at most `max` bytes of a small file.
|
||||
fn read_capped(path: &Path, max: usize) -> Option<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
std::fs::File::open(path).ok()?.take(max as u64 + 1).read_to_end(&mut buf).ok()?;
|
||||
(buf.len() <= max).then_some(buf)
|
||||
}
|
||||
|
||||
pub(super) fn read_settings(dir: &Path) -> FaceSettings {
|
||||
read_capped(&dir.join("settings.json"), 4096).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
let part = path.with_extension("part");
|
||||
std::fs::write(&part, bytes).map_err(|e| fail(&format!("could not write {}", path.display()), e))?;
|
||||
std::fs::rename(&part, path).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
fail(&format!("could not write {}", path.display()), e)
|
||||
})
|
||||
}
|
||||
|
||||
fn write_settings(dir: &Path, st: &FaceSettings) -> Result<()> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| fail("could not create the face models folder", e))?;
|
||||
write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| fail("settings", e))?)
|
||||
}
|
||||
|
||||
pub(super) struct Installed {
|
||||
pub(super) manifest: ModelManifest,
|
||||
pub(super) accepted: Value,
|
||||
}
|
||||
|
||||
/// The models in the folder. A folder that is not a valid model (wrong name, no model file, a manifest that
|
||||
/// does not validate or does not match its folder) is ignored, never trusted.
|
||||
pub(super) fn installed_models(dir: &Path) -> Vec<Installed> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
let Some(name) = p.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue };
|
||||
if !manifest::valid_id(&name) || !p.is_dir() || !p.join("model.onnx").is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(m) = read_capped(&p.join("face-model.json"), MAX_MANIFEST_BYTES).and_then(|b| manifest::parse(&b).ok()) else { continue };
|
||||
if m.id != name {
|
||||
continue;
|
||||
}
|
||||
let accepted = read_capped(&p.join("installed.json"), 4096).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null);
|
||||
out.push(Installed { manifest: m, accepted });
|
||||
}
|
||||
out.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id));
|
||||
out
|
||||
}
|
||||
|
||||
/// Read the user's catalog (`catalog.json` in the models folder) anew: a few kilobytes, so it is always current.
|
||||
fn refresh_catalog(s: &mut Session) {
|
||||
let Some(dir) = s.face_models_dir.clone() else { return };
|
||||
let file = dir.join("catalog.json");
|
||||
s.face_catalog = match read_capped(&file, catalog::MAX_CATALOG_BYTES) {
|
||||
Some(bytes) => catalog::parse(&bytes),
|
||||
None if file.is_file() => catalog::parse(&vec![b' '; catalog::MAX_CATALOG_BYTES + 1]),
|
||||
None => Catalog::default(),
|
||||
};
|
||||
}
|
||||
|
||||
/// The manifests of the catalog's models (what a file's hash is matched against, besides the built-in ones).
|
||||
fn catalog_manifests(s: &Session) -> Vec<ModelManifest> {
|
||||
s.face_catalog.entries.iter().map(|e| e.manifest.clone()).collect()
|
||||
}
|
||||
|
||||
/// How to fetch a model: the built-in address, or the one in the user's catalog.
|
||||
fn download_spec(s: &Session, id: &str) -> Option<known::Download> {
|
||||
known::download(id).or_else(|| s.face_catalog.entries.iter().find(|e| e.manifest.id == id).and_then(catalog::Entry::download))
|
||||
}
|
||||
|
||||
fn row(s: &Session, m: &ModelManifest, installed: bool, selected: bool, accepted: &Value) -> Value {
|
||||
let from_catalog = s.face_catalog.entries.iter().any(|e| e.manifest.id == m.id);
|
||||
json!({
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"role": m.role,
|
||||
"licence": m.licence,
|
||||
"provenance": m.provenance,
|
||||
"source": m.source,
|
||||
"sizeBytes": m.size_bytes,
|
||||
"sha256": m.sha256,
|
||||
// how fast it is as a multiple of a ResNet-100 model (computers differ, ratios between models hardly do), and
|
||||
// that in words; null when nobody has measured it
|
||||
"speed": m.speed,
|
||||
"speedText": m.speed.map(manifest::describe_speed).filter(|t| !t.is_empty()),
|
||||
"known": from_catalog || known::all().iter().any(|k| k.id == m.id),
|
||||
// from the user's own catalog file
|
||||
"fromCatalog": from_catalog,
|
||||
// a pinned address LightCraft can fetch it from (the user presses Download), and which site that is
|
||||
"downloadHost": download_spec(s, &m.id).map(|d| known::host(&d.url).to_string()),
|
||||
"installed": installed,
|
||||
"selected": selected,
|
||||
"accepted": accepted,
|
||||
})
|
||||
}
|
||||
|
||||
struct Inspection {
|
||||
sha256: String,
|
||||
size: u64,
|
||||
file_name: String,
|
||||
suggestion: Suggestion,
|
||||
}
|
||||
|
||||
/// Look at a model file. `known_sha` is its SHA-256 when something already checked it (a finished download), so a
|
||||
/// file of hundreds of megabytes is not read again just to be hashed.
|
||||
fn inspect_file(path: &str, cmd: &str, known_sha: Option<&str>, extra: &[ModelManifest]) -> Result<Inspection> {
|
||||
let p = Path::new(path);
|
||||
let meta = std::fs::metadata(p).map_err(|e| bad(cmd, format!("cannot read `{path}`: {e}")))?;
|
||||
if !meta.is_file() {
|
||||
return Err(bad(cmd, format!("`{path}` is not a file")));
|
||||
}
|
||||
if meta.len() == 0 || meta.len() > MAX_MODEL_BYTES {
|
||||
return Err(bad(cmd, "a model file must be between 1 byte and 512 MiB"));
|
||||
}
|
||||
let sha256 = match known_sha {
|
||||
Some(h) => h.to_string(),
|
||||
None => sha256_file(p).map_err(|e| fail(&format!("could not read {path}"), e))?,
|
||||
};
|
||||
let file_name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let suggestion = match onnx::probe_path(p) {
|
||||
Ok(info) => suggest_with(&info, &sha256, meta.len(), &file_name, extra),
|
||||
Err(e) => Suggestion::Unsupported(e.to_string()),
|
||||
};
|
||||
Ok(Inspection { sha256, size: meta.len(), file_name, suggestion })
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
refresh_catalog(s);
|
||||
let dir = s.face_models_dir.clone();
|
||||
let (settings, installed) = match &dir {
|
||||
Some(d) => (read_settings(d), installed_models(d)),
|
||||
None => (FaceSettings::default(), Vec::new()),
|
||||
};
|
||||
let on_disk = |id: &str| installed.iter().find(|i| i.manifest.id == id);
|
||||
let mut models: Vec<Value> = Vec::new();
|
||||
for k in known::all() {
|
||||
let found = on_disk(&k.id);
|
||||
models.push(row(s, &k, found.is_some(), settings.embedder.as_deref() == Some(k.id.as_str()), found.map_or(&Value::Null, |f| &f.accepted)));
|
||||
}
|
||||
// the user's own catalog
|
||||
for e in &s.face_catalog.entries {
|
||||
let found = on_disk(&e.manifest.id);
|
||||
let chosen = settings.embedder.as_deref() == Some(e.manifest.id.as_str());
|
||||
models.push(row(s, &e.manifest, found.is_some(), chosen, found.map_or(&Value::Null, |f| &f.accepted)));
|
||||
}
|
||||
for i in &installed {
|
||||
let listed = known::all().iter().any(|k| k.id == i.manifest.id) || s.face_catalog.entries.iter().any(|e| e.manifest.id == i.manifest.id);
|
||||
if !listed {
|
||||
models.push(row(s, &i.manifest, true, settings.embedder.as_deref() == Some(i.manifest.id.as_str()), &i.accepted));
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"dir": dir.as_ref().map(|d| d.display().to_string()),
|
||||
"enabled": settings.enabled,
|
||||
"embedder": settings.embedder,
|
||||
// Native builds always have the pure-Rust runner; model files/background scanning remain native-only.
|
||||
"runtime": cfg!(not(target_arch = "wasm32")),
|
||||
"models": models,
|
||||
// the user's own list of models to download: where it goes, how many it holds, and what was wrong with the rest
|
||||
"catalog": {
|
||||
"path": dir.as_ref().map(|d| d.join("catalog.json").display().to_string()),
|
||||
"models": s.face_catalog.entries.len(),
|
||||
"errors": s.face_catalog.errors,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.inspect";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
refresh_catalog(s);
|
||||
let ins = inspect_file(path, C, None, &catalog_manifests(s))?;
|
||||
let installed = s.face_models_dir.as_deref().map(installed_models).unwrap_or_default();
|
||||
let (kind, model, assumptions, reason) = match &ins.suggestion {
|
||||
Suggestion::Known(m) => ("known", Some(m), Vec::new(), None),
|
||||
Suggestion::Draft { manifest, assumptions } => ("draft", Some(manifest), assumptions.clone(), None),
|
||||
Suggestion::Unsupported(why) => ("unsupported", None, Vec::new(), Some(why.clone())),
|
||||
};
|
||||
let already = model.is_some_and(|m| installed.iter().any(|i| i.manifest.sha256 == m.sha256 && i.manifest.id == m.id));
|
||||
Ok(json!({
|
||||
"path": path,
|
||||
"fileName": ins.file_name,
|
||||
"sizeBytes": ins.size,
|
||||
"sha256": ins.sha256,
|
||||
"kind": kind,
|
||||
"model": model.map(|m| row(s, m, already, false, &Value::Null)),
|
||||
"assumptions": assumptions,
|
||||
"reason": reason,
|
||||
"alreadyInstalled": already,
|
||||
}))
|
||||
}
|
||||
|
||||
fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.install";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let acknowledged = p.get("acknowledged").and_then(Value::as_bool).unwrap_or(false);
|
||||
let activate = p.get("activate").and_then(Value::as_bool).unwrap_or(true);
|
||||
refresh_catalog(s);
|
||||
let ins = inspect_file(path, C, None, &catalog_manifests(s))?;
|
||||
install_file(s, C, Path::new(path), ins, acknowledged, activate, false)
|
||||
}
|
||||
|
||||
/// Install the inspected model file `src`. A recognition model that passes its self-test becomes the one in use and
|
||||
/// recognition is switched on (`activate`, the default): installing a model is how someone says they want it.
|
||||
/// `verified` is for a download that was already checked against its recorded hash: it is moved into place instead
|
||||
/// of being copied and read again.
|
||||
fn install_file(s: &mut Session, c: &str, src: &Path, ins: Inspection, acknowledged: bool, activate: bool, verified: bool) -> Result<Value> {
|
||||
let dir = models_dir(s, c)?;
|
||||
let m = match ins.suggestion {
|
||||
Suggestion::Known(m) | Suggestion::Draft { manifest: m, .. } => m,
|
||||
Suggestion::Unsupported(why) => return Err(bad(c, format!("this model cannot be used yet: {why}"))),
|
||||
};
|
||||
if !matches!(m.role, Role::Embedder | Role::Detector) {
|
||||
return Err(bad(c, "only face detection and recognition models can be installed"));
|
||||
}
|
||||
manifest::validate(&m).map_err(|e| bad(c, e.to_string()))?;
|
||||
if !acknowledged {
|
||||
return Err(bad(c, "the licence has not been accepted: show the user its terms and pass `acknowledged: true` once they agree"));
|
||||
}
|
||||
let home = dir.join(&m.id);
|
||||
std::fs::create_dir_all(&home).map_err(|e| fail("could not create the model's folder", e))?;
|
||||
let (part, final_path) = (home.join("model.onnx.part"), home.join("model.onnx"));
|
||||
let moved = verified && std::fs::rename(src, &part).is_ok();
|
||||
let placed = if moved {
|
||||
Ok(())
|
||||
} else {
|
||||
std::fs::copy(src, &part).map_err(|e| fail("could not copy the model (is the disk full?)", e)).and_then(|_| match sha256_file(&part) {
|
||||
Ok(h) if h == ins.sha256 => Ok(()),
|
||||
Ok(_) => Err(EngineError::Other("the copy does not match the original (the file changed while copying?)".into())),
|
||||
Err(e) => Err(fail("could not check the copy", e)),
|
||||
})
|
||||
};
|
||||
if let Err(e) = placed {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(e);
|
||||
}
|
||||
// Validate the candidate before replacing a working installation.
|
||||
let test = match self_test(&part, &m) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
let _ = std::fs::remove_dir(&home);
|
||||
return Err(bad(c, e));
|
||||
}
|
||||
};
|
||||
if let Err(e) = std::fs::rename(&part, &final_path) {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(fail("could not finish the copy", e));
|
||||
}
|
||||
write_atomic(&home.join("face-model.json"), &serde_json::to_vec_pretty(&m).map_err(|e| fail("manifest", e))?)?;
|
||||
let accepted = json!({"acceptedAt": (s.clock)(), "licence": m.licence.name, "commercial": m.licence.commercial, "fileName": ins.file_name, "selfTest": test});
|
||||
write_atomic(&home.join("installed.json"), &serde_json::to_vec_pretty(&accepted).map_err(|e| fail("record", e))?)?;
|
||||
// a file that was downloaded and waited in the staging folder has served: the installed copy is the model now
|
||||
if src.parent() == Some(dir.join(face_download::STAGING).as_path()) {
|
||||
let _ = std::fs::remove_file(src);
|
||||
s.face_downloads.staged_file_used(src);
|
||||
}
|
||||
// the newest model is the one in use (the earlier one stays installed, and `faces.models.select` switches back)
|
||||
if activate && m.role == Role::Embedder {
|
||||
let mut st = read_settings(&dir);
|
||||
st.embedder = Some(m.id.clone());
|
||||
st.enabled = true;
|
||||
write_settings(&dir, &st)?;
|
||||
}
|
||||
let now = read_settings(&dir);
|
||||
let selected = now.embedder.as_deref() == Some(m.id.as_str());
|
||||
Ok(json!({"installed": row(s, &m, true, selected, &accepted), "active": {"embedder": now.embedder, "enabled": now.enabled}}))
|
||||
}
|
||||
|
||||
/// What an error says, without the command it came from (for a line shown to the user).
|
||||
fn plain(e: &EngineError) -> String {
|
||||
match e {
|
||||
EngineError::BadParams { msg, .. } => msg.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install every download that has arrived: each was accepted when it was started, so it is installed, chosen and
|
||||
/// switched on without another question. Called by `faces.models.downloads` and by every frame's `faces.pump`.
|
||||
pub(super) fn finish_downloads(s: &mut Session) {
|
||||
const C: &str = "faces.models.download";
|
||||
let extra = catalog_manifests(s);
|
||||
for (id, path, sha) in s.face_downloads.finished() {
|
||||
let outcome = inspect_file(&path.to_string_lossy(), C, Some(&sha), &extra).and_then(|ins| install_file(s, C, &path, ins, true, true, true));
|
||||
match outcome {
|
||||
Ok(_) => s.face_downloads.set_outcome(&id, State::Installed),
|
||||
Err(e) => {
|
||||
// a file that cannot be installed is of no use to anyone: throw it away, say why
|
||||
let _ = std::fs::remove_file(&path);
|
||||
s.face_downloads.set_outcome(&id, State::Failed(plain(&e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `faces.models.download {id, acknowledged: true}`: fetch a model LightCraft knows an address for, in the background;
|
||||
/// when it has arrived and checked out it is installed.
|
||||
fn download(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.download";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
refresh_catalog(s);
|
||||
let spec = download_spec(s, id).ok_or_else(|| bad(C, "LightCraft has no download for that model: get the file from its page, then add it"))?;
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Err(bad(C, "the web build downloads no models"));
|
||||
}
|
||||
if p.get("acknowledged").and_then(Value::as_bool) != Some(true) {
|
||||
return Err(bad(
|
||||
C,
|
||||
"the licence has not been accepted: show the user the model's terms (see `faces.models.list`) and pass `acknowledged: true` once they agree",
|
||||
));
|
||||
}
|
||||
if installed_models(&dir).iter().any(|i| i.manifest.id == spec.id) {
|
||||
return Err(bad(C, "that model is already installed"));
|
||||
}
|
||||
let host = spec.host().to_string();
|
||||
let name = known::all()
|
||||
.into_iter()
|
||||
.find(|m| m.id == spec.id)
|
||||
.map(|m| m.name)
|
||||
.or_else(|| s.face_catalog.entries.iter().find(|e| e.manifest.id == spec.id).map(|e| e.manifest.name.clone()))
|
||||
.unwrap_or_else(|| spec.id.clone());
|
||||
s.face_downloads.start(spec, &dir, &s.activity, &name).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"started": id, "from": host}))
|
||||
}
|
||||
|
||||
fn download_row(s: &Session, id: &str, state: &State) -> Value {
|
||||
let from = download_spec(s, id).map(|d| d.host().to_string());
|
||||
match state {
|
||||
State::Running { bytes, total } => json!({"id": id, "state": "running", "bytes": bytes, "total": total, "from": from}),
|
||||
State::Done { path, .. } => json!({"id": id, "state": "done", "path": path.display().to_string(), "from": from}),
|
||||
State::Installed => json!({"id": id, "state": "installed", "from": from}),
|
||||
State::Failed(why) => json!({"id": id, "state": "failed", "error": why, "from": from}),
|
||||
State::Cancelled => json!({"id": id, "state": "cancelled", "from": from}),
|
||||
}
|
||||
}
|
||||
|
||||
/// `faces.models.downloads`: where each download stands (a finished one is installed first).
|
||||
fn downloads(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
finish_downloads(s);
|
||||
let all: Vec<Value> = s.face_downloads.snapshot().iter().map(|(id, st)| download_row(s, id, st)).collect();
|
||||
Ok(json!({"running": s.face_downloads.running(), "downloads": all}))
|
||||
}
|
||||
|
||||
/// `faces.models.downloadCancel {id}`: stop a download, or clear a finished one (a staged file is deleted).
|
||||
fn download_cancel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.downloadCancel";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
Ok(json!({"discarded": s.face_downloads.discard(id)}))
|
||||
}
|
||||
|
||||
/// Load the model and run its self-test: `Ok(null)` in a build without the recognition runtime (nothing can
|
||||
/// be checked), `Ok(result)` when it passes, `Err(reason)` when it does not load or fails a check.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn self_test(path: &Path, m: &ModelManifest) -> std::result::Result<Value, String> {
|
||||
if m.role == Role::Detector {
|
||||
let bytes = read_capped(path, 8 << 20).ok_or("the detector cannot be read within its 8 MiB limit")?;
|
||||
let detector = lightcraft_faces::yunet::Detector::new(&bytes).map_err(|e| e.to_string())?;
|
||||
if Some(detector.input_side()) != usize::try_from(m.input.width).ok()
|
||||
|| m.input.width != m.input.height
|
||||
|| !matches!(&m.output, manifest::OutputSpec::Detector { decoder } if decoder == "yunet-v2")
|
||||
{
|
||||
return Err("unsupported detector input or output decoder".into());
|
||||
}
|
||||
detector.detect(&[0u8; 12], 2, 2, &lightcraft_faces::yunet::Options::default()).map_err(|e| e.to_string())?;
|
||||
return Ok(json!({"ok": true}));
|
||||
}
|
||||
let embedder = lightcraft_faces::runtime::Embedder::load(path, m).map_err(|e| e.to_string())?;
|
||||
let t = embedder.self_test();
|
||||
if !t.ok {
|
||||
let failed: Vec<&str> = t.checks.iter().filter(|(_, ok)| !*ok).map(|(c, _)| c.as_str()).collect();
|
||||
return Err(format!("the model failed its self-test: it does not {}", failed.join(", and does not ")));
|
||||
}
|
||||
serde_json::to_value(&t).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn self_test(_: &Path, _: &ModelManifest) -> std::result::Result<Value, String> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// `faces.models.test {id}`: run an installed model's self-test again.
|
||||
fn test(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.test";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
if !cfg!(not(target_arch = "wasm32")) {
|
||||
return Err(bad(C, "this build cannot run face recognition models"));
|
||||
}
|
||||
let found = installed_models(&dir).into_iter().find(|i| i.manifest.id == id).ok_or_else(|| bad(C, "that model is not installed"))?;
|
||||
if found.manifest.role != Role::Embedder {
|
||||
return Err(bad(C, "only recognition models have a self-test"));
|
||||
}
|
||||
let result = self_test(&dir.join(id).join("model.onnx"), &found.manifest);
|
||||
let (ok, detail) = match &result {
|
||||
Ok(v) => (true, v.clone()),
|
||||
Err(e) => (false, json!(e)),
|
||||
};
|
||||
// keep the latest outcome with the model's record
|
||||
let mut record = found.accepted;
|
||||
if let Some(o) = record.as_object_mut() {
|
||||
o.insert("selfTest".into(), if ok { detail.clone() } else { json!({"ok": false, "error": detail}) });
|
||||
write_atomic(&dir.join(id).join("installed.json"), &serde_json::to_vec_pretty(&record).map_err(|e| fail("record", e))?)?;
|
||||
}
|
||||
Ok(json!({"id": id, "ok": ok, "result": detail}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.remove";
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let dir = models_dir(s, C)?;
|
||||
if !manifest::valid_id(id) {
|
||||
return Err(bad(C, "not a model id"));
|
||||
}
|
||||
let home = dir.join(id);
|
||||
// only a folder that is a model (it has a manifest) is ever deleted
|
||||
if !home.join("face-model.json").is_file() {
|
||||
return Err(bad(C, "that model is not installed"));
|
||||
}
|
||||
std::fs::remove_dir_all(&home).map_err(|e| fail("could not remove the model", e))?;
|
||||
let mut st = read_settings(&dir);
|
||||
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);
|
||||
write_settings(&dir, &st)?;
|
||||
}
|
||||
Ok(json!({"removed": id, "embedder": st.embedder}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.models.select";
|
||||
let dir = models_dir(s, C)?;
|
||||
let id = match p.get("id") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_str().ok_or_else(|| bad(C, "`id` must be a model id or null"))?.to_string()),
|
||||
};
|
||||
if let Some(id) = &id
|
||||
&& !installed_models(&dir).iter().any(|i| &i.manifest.id == id && i.manifest.role == Role::Embedder)
|
||||
{
|
||||
return Err(bad(C, "that recognition model is not installed"));
|
||||
}
|
||||
let mut st = read_settings(&dir);
|
||||
st.embedder = id;
|
||||
write_settings(&dir, &st)?;
|
||||
Ok(json!({"embedder": st.embedder}))
|
||||
}
|
||||
|
||||
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);
|
||||
write_settings(&dir, &st)?;
|
||||
// the background scan notices at once, not within the second it trusts its last look
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
s.faces.enabled_seen = None;
|
||||
}
|
||||
Ok(json!({"enabled": st.enabled}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "faces.models.list", "Face Models", [], None, "{} → {dir, enabled, embedder, runtime, models: [{id, name, role, licence{name, commercial, url, notice}, provenance, source, sizeBytes, known, installed, selected}]}", always, list),
|
||||
cmd!(query "faces.models.inspect", "Inspect Face Model File", [], None, "{path} → what a .onnx file is: {kind: known | draft | unsupported, model, assumptions, reason, alreadyInstalled}; installs nothing", always, inspect),
|
||||
cmd!(query "faces.models.install", "Install Face Model", [], None, "{path, acknowledged: true, activate?: true} → {installed, active: {embedder, enabled}} — copy a .onnx face recognition model into the models folder. `acknowledged` must be true: the user has been shown its licence (see inspect) and accepted it. Unless `activate` is false the model becomes the one in use and recognition is switched on; an earlier model stays installed", always, install),
|
||||
cmd!(query "faces.models.download", "Download Face Model", [], None, "{id, acknowledged: true} → {started, from} — fetch a recognition model LightCraft has a pinned address for (see `downloadHost` in the list), in the background over pure-Rust https (lightcraft-fetch). `acknowledged` must be true: the user has been shown the model's terms and accepted them. It is checked against its size and SHA-256, then installed, chosen and switched on by itself; `faces.models.downloads` shows how far it is", always, download),
|
||||
cmd!(query "faces.models.downloads", "Face Model Downloads", [], None, "{} → {running, downloads: [{id, state: running | done | installed | failed | cancelled, bytes, total, error, from}]} — also installs any download that has arrived; `installed` stays listed until `faces.models.downloadCancel` clears it", always, downloads),
|
||||
cmd!(query "faces.models.downloadCancel", "Cancel Face Model Download", [], None, "{id} → {discarded} — stop a download, or delete a finished one that was not installed", always, download_cancel),
|
||||
cmd!(query "faces.models.test", "Test Face Model", [], None, "{id} → {ok, result: {loadMs, embedMs, dimension, checks}} — load an installed recognition model and check it gives sensible faces; needs the recognition runtime", always, test),
|
||||
cmd!(query "faces.models.remove", "Remove Face Model", [], None, "{id} — delete an installed model", always, remove),
|
||||
cmd!(query "faces.models.select", "Choose Face Recognition Model", [], None, "{id: installed recogniser | null}", always, select),
|
||||
cmd!(query "faces.enable", "Face Recognition On/Off", [], None, "{enabled?: bool} (toggles when omitted)", always, enable),
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge;
|
||||
//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords.
|
||||
|
||||
use lightcraft_catalog::keywords::{clean, is_under};
|
||||
use lightcraft_catalog::keywords::{KeywordInfo, clean, closest, is_under, reparent};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn strs(p: &Value, key: &str) -> Vec<String> {
|
||||
@@ -15,20 +15,54 @@ fn strs(p: &Value, key: &str) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit a keyword batch; returns how many photos changed. The keyword filter follows a renamed
|
||||
/// keyword and is cleared when its keyword is deleted.
|
||||
/// Commit a keyword batch; returns `{changed: photos changed, listed: keyword list changes}`. The
|
||||
/// keyword filter follows a renamed keyword and is cleared when its keyword is deleted.
|
||||
fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, follow: impl Fn(&str) -> Option<String>) -> Result<Value> {
|
||||
let n = match &op {
|
||||
lightcraft_catalog::Op::Batch { ops } => ops.len(),
|
||||
_ => 1,
|
||||
use lightcraft_catalog::Op;
|
||||
let (photos, listed) = match &op {
|
||||
Op::Batch { ops } => {
|
||||
(ops.iter().filter(|o| matches!(o, Op::SetMeta { .. })).count(), ops.iter().filter(|o| matches!(o, Op::SetKeyword { .. })).count())
|
||||
}
|
||||
Op::SetMeta { .. } => (1, 0),
|
||||
_ => (0, 1),
|
||||
};
|
||||
if n > 0 {
|
||||
if photos + listed > 0 {
|
||||
s.commit(label, op)?;
|
||||
}
|
||||
if let Some(k) = s.filter.keyword.clone() {
|
||||
s.filter.keyword = follow(&k);
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
Ok(json!({"changed": photos, "listed": listed}))
|
||||
}
|
||||
|
||||
/// A keyword's attributes from command params over `base`: those not given keep their value.
|
||||
fn info_from(p: &Value, base: KeywordInfo) -> KeywordInfo {
|
||||
KeywordInfo {
|
||||
synonyms: if p.get("synonyms").is_some() {
|
||||
strs(p, "synonyms").into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
||||
} else {
|
||||
base.synonyms
|
||||
},
|
||||
include_on_export: bool_or(p, "includeOnExport", base.include_on_export),
|
||||
export_containing: bool_or(p, "exportContaining", base.export_containing),
|
||||
export_synonyms: bool_or(p, "exportSynonyms", base.export_synonyms),
|
||||
person: bool_or(p, "person", base.person),
|
||||
new_keywords_inside: base.new_keywords_inside,
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyword a command names (`keyword`), as the library writes it.
|
||||
fn named_keyword(s: &Session, p: &Value, command: &str) -> Result<String> {
|
||||
let k = str_param(p, "keyword").ok_or_else(|| bad(command, "missing `keyword`"))?;
|
||||
s.catalog.keyword_path(k).ok_or_else(|| bad(command, format!("no keyword “{}”", clean(k))))
|
||||
}
|
||||
|
||||
/// A catalog refusal as a command error; a taken name says how to merge.
|
||||
fn refused(command: &str, e: lightcraft_catalog::CatalogError, how_to_merge: &str) -> crate::EngineError {
|
||||
match e {
|
||||
lightcraft_catalog::CatalogError::KeywordExists(k) => bad(command, format!("there is a keyword “{k}” already{how_to_merge}")),
|
||||
e => bad(command, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A named set of up to nine keywords.
|
||||
@@ -38,6 +72,12 @@ pub struct KeywordSet {
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
/// Two set names (or recent keywords) are the same whatever the case of any of their letters, as
|
||||
/// keywords are.
|
||||
fn same_set(a: &str, b: &str) -> bool {
|
||||
lightcraft_catalog::keywords::same(a.trim(), b.trim())
|
||||
}
|
||||
|
||||
/// The set name meaning "the nine most recently added keywords".
|
||||
pub const RECENT: &str = "Recent Keywords";
|
||||
|
||||
@@ -48,16 +88,54 @@ pub fn note_recent(s: &mut Session, added: &[String]) {
|
||||
if k.is_empty() {
|
||||
continue;
|
||||
}
|
||||
s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
|
||||
s.recent_keywords.retain(|x| !same_set(x, &k));
|
||||
s.recent_keywords.insert(0, k);
|
||||
}
|
||||
s.recent_keywords.truncate(9);
|
||||
let _ = s.save_prefs();
|
||||
}
|
||||
|
||||
/// The largest keyword list file read: a file is input (a list of 100,000 keywords is ~2 MB).
|
||||
const MAX_LIST_BYTES: u64 = 16 << 20;
|
||||
|
||||
/// The characters Capture One's keyword importer refuses in a list (`|` can't be in a name here).
|
||||
const CAPTURE_ONE_REFUSES: [char; 4] = [';', ',', '<', '>'];
|
||||
|
||||
/// A keyword list file's text: at most `MAX_LIST_BYTES`, UTF-8 (with or without a byte-order mark).
|
||||
fn read_list(path: &str) -> Result<String> {
|
||||
use std::io::Read;
|
||||
let file = std::fs::File::open(path).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_LIST_BYTES + 1).read_to_end(&mut bytes).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?;
|
||||
if bytes.len() as u64 > MAX_LIST_BYTES {
|
||||
return Err(bad("keyword.import", format!("{path}: larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20)));
|
||||
}
|
||||
// Notepad and some exporters save UTF-16: say how to get the file in, not just that it isn't
|
||||
if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) {
|
||||
return Err(bad("keyword.import", format!("{path}: UTF-16 text — save it as UTF-8 and import it again")));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| bad("keyword.import", format!("{path}: not UTF-8 text")))
|
||||
}
|
||||
|
||||
/// A set's slots as typed: each cleaned in its place (an empty one is an empty slot, so the others
|
||||
/// keep their ⌥ keys), each keyword once whatever its case (a second one leaves its slot empty),
|
||||
/// nine at most, no empty ones at the end.
|
||||
pub fn slots(typed: &[String]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for k in typed.iter().take(9) {
|
||||
let k = clean(k);
|
||||
let twice = !k.is_empty() && out.iter().any(|x| lightcraft_catalog::keywords::same(x, &k));
|
||||
out.push(if twice { String::new() } else { k });
|
||||
}
|
||||
while out.last().is_some_and(String::is_empty) {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones.
|
||||
pub fn current_keywords(s: &Session) -> Vec<String> {
|
||||
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| x.name.eq_ignore_ascii_case(n)));
|
||||
let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| same_set(&x.name, n)));
|
||||
let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone());
|
||||
v.truncate(9);
|
||||
v
|
||||
@@ -75,13 +153,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))),
|
||||
cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| {
|
||||
let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT);
|
||||
s.keyword_set = if name.eq_ignore_ascii_case(RECENT) || name.is_empty() {
|
||||
s.keyword_set = if same_set(name, RECENT) || name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
s.keyword_sets
|
||||
.iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(name))
|
||||
.find(|x| same_set(&x.name, name))
|
||||
.ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))?
|
||||
.name
|
||||
.clone(),
|
||||
@@ -95,24 +173,53 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Keyword Set",
|
||||
[],
|
||||
None,
|
||||
"{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current",
|
||||
"{name, keywords?: [up to 9, \"\" = an empty slot] (default: the renamed set's, else the current nine), replace?: the set it renames, new?: refuse a name another set has} — replaces a set of that name (or `replace`, in its place) and makes it current",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT))
|
||||
.filter(|n| !n.is_empty() && !same_set(n, RECENT))
|
||||
.ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))?
|
||||
.to_string();
|
||||
let mut keywords: Vec<String> = if p.get("keywords").is_some() {
|
||||
strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect()
|
||||
let same_name = |x: &KeywordSet, n: &str| same_set(&x.name, n);
|
||||
// the set it renames, if any (an empty `replace` is none)
|
||||
let renames = match str_param(p, "replace").map(str::trim).filter(|o| !o.is_empty()) {
|
||||
Some(old) => Some(
|
||||
s.keyword_sets
|
||||
.iter()
|
||||
.position(|x| same_name(x, old))
|
||||
.ok_or_else(|| bad("keyword.saveSet", format!("no keyword set `{old}`")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// keywords not given: the renamed set's own, else the current nine
|
||||
let keywords = if p.get("keywords").is_some() {
|
||||
slots(&strs(p, "keywords"))
|
||||
} else {
|
||||
current_keywords(s)
|
||||
match renames.and_then(|at| s.keyword_sets.get(at)) {
|
||||
Some(x) => x.keywords.clone(),
|
||||
None => current_keywords(s),
|
||||
}
|
||||
};
|
||||
keywords.truncate(9);
|
||||
let set = KeywordSet { name: name.clone(), keywords };
|
||||
match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = set,
|
||||
None => s.keyword_sets.push(set),
|
||||
match renames {
|
||||
// rename: in its place, onto a name no other set has
|
||||
Some(at) => {
|
||||
if s.keyword_sets.iter().enumerate().any(|(i, x)| i != at && same_name(x, &name)) {
|
||||
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
|
||||
}
|
||||
if let Some(x) = s.keyword_sets.get_mut(at) {
|
||||
*x = set;
|
||||
}
|
||||
}
|
||||
// a new set (`new`) never takes another set's name
|
||||
None if bool_or(p, "new", false) && s.keyword_sets.iter().any(|x| same_name(x, &name)) => {
|
||||
return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already")));
|
||||
}
|
||||
None => match s.keyword_sets.iter_mut().find(|x| same_name(x, &name)) {
|
||||
Some(x) => *x = set,
|
||||
None => s.keyword_sets.push(set),
|
||||
},
|
||||
}
|
||||
s.keyword_set = Some(name);
|
||||
s.save_prefs()?;
|
||||
@@ -122,11 +229,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?;
|
||||
let before = s.keyword_sets.len();
|
||||
s.keyword_sets.retain(|x| !x.name.eq_ignore_ascii_case(name));
|
||||
s.keyword_sets.retain(|x| !same_set(&x.name, name));
|
||||
if s.keyword_sets.len() == before {
|
||||
return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`")));
|
||||
}
|
||||
if s.keyword_set.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)) {
|
||||
if s.keyword_set.as_deref().is_some_and(|c| same_set(c, name)) {
|
||||
s.keyword_set = None;
|
||||
}
|
||||
s.save_prefs()?;
|
||||
@@ -145,12 +252,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|i| (1..=9).contains(i))
|
||||
.ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?;
|
||||
let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else {
|
||||
// (an empty slot does nothing)
|
||||
let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else {
|
||||
return Ok(json!({"changed": 0}));
|
||||
};
|
||||
let ids = s.targets(p);
|
||||
let all = !ids.is_empty()
|
||||
&& ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k))));
|
||||
&& ids.iter().all(|id| {
|
||||
s.catalog
|
||||
.photo(*id)
|
||||
.is_some_and(|ph| ph.meta.keywords.iter().any(|x| lightcraft_catalog::keywords::same(&clean(x), &clean(&k))))
|
||||
});
|
||||
let ids: Vec<u64> = ids.iter().map(|i| i.0).collect();
|
||||
let key = if all { "removeKeywords" } else { "addKeywords" };
|
||||
// applying from Recent Keywords mustn't reshuffle the numbers under the keys
|
||||
@@ -185,6 +297,184 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!(s.catalog.keyword_suggestions(¤t, str_param(p, "prefix").unwrap_or(""), n)))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "keyword.info",
|
||||
"Keyword Info",
|
||||
[],
|
||||
None,
|
||||
"{keyword} → {path, listed, count, synonyms, includeOnExport, exportContaining, exportSynonyms, person}",
|
||||
always,
|
||||
|s, p| {
|
||||
let path = named_keyword(s, p, "keyword.info")?;
|
||||
let info = s.catalog.keyword_info(&path).cloned();
|
||||
let count = s.catalog.photos().filter(|ph| ph.in_library() && ph.meta.keywords.iter().any(|k| is_under(k, &path))).count();
|
||||
let mut v = serde_json::to_value(info.clone().unwrap_or_default()).unwrap_or_default();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("path".into(), json!(path));
|
||||
o.insert("listed".into(), json!(info.is_some()));
|
||||
o.insert("count".into(), json!(count));
|
||||
o.entry("synonyms").or_insert(json!([]));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.create",
|
||||
"Create Keyword",
|
||||
[],
|
||||
None,
|
||||
"{name, parent?: keyword | null (default: the default parent; null = the top level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?, addToSelected?: bool, ids?} → {keyword}",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name").map(clean).filter(|n| !n.is_empty()).ok_or_else(|| bad("keyword.create", "missing `name`"))?;
|
||||
let parent = match p.get("parent") {
|
||||
Some(Value::String(k)) => Some(s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))),
|
||||
Some(_) => None,
|
||||
None => s.catalog.default_keyword_parent(),
|
||||
};
|
||||
let path = clean(&match parent {
|
||||
Some(parent) => format!("{parent}|{name}"),
|
||||
None => name,
|
||||
});
|
||||
let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() };
|
||||
let op = s
|
||||
.catalog
|
||||
.create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos)
|
||||
.map_err(|e| refused("keyword.create", e, ""))?;
|
||||
s.commit("Create Keyword", op)?;
|
||||
if !photos.is_empty() {
|
||||
note_recent(s, std::slice::from_ref(&path));
|
||||
}
|
||||
Ok(json!({"keyword": path}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.edit",
|
||||
"Edit Keyword",
|
||||
[],
|
||||
None,
|
||||
"{keyword, name?: its new name (one level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?} — what isn't given keeps its value",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = named_keyword(s, p, "keyword.edit")?;
|
||||
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
|
||||
let name = str_param(p, "name").unwrap_or(&leaf).to_string();
|
||||
let info = info_from(p, s.catalog.keyword_info(&from).cloned().unwrap_or_default());
|
||||
let op =
|
||||
s.catalog.edit_keyword_ops(&from, &name, info).map_err(|e| refused("keyword.edit", e, " (to merge them, use keyword.merge)"))?;
|
||||
let to = match from.rsplit_once('|') {
|
||||
Some((parent, _)) => format!("{parent}|{}", name.trim()),
|
||||
None => name.trim().to_string(),
|
||||
};
|
||||
commit_keywords(s, "Edit Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.move",
|
||||
"Move Keyword",
|
||||
[],
|
||||
None,
|
||||
"{keyword, parent: keyword | null (the top level), merge?: bool} — with the keywords below it; onto a name that is taken only with `merge: true`",
|
||||
always,
|
||||
|s, p| {
|
||||
let from = named_keyword(s, p, "keyword.move")?;
|
||||
let parent = match p.get("parent") {
|
||||
Some(Value::String(k)) => Some(k.clone()),
|
||||
Some(Value::Null) => None,
|
||||
_ => return Err(bad("keyword.move", "`parent` is a keyword, or null for the top level")),
|
||||
};
|
||||
// where it lands, as the catalog spells it: a parent that doesn't exist yet is made
|
||||
let leaf = from.rsplit('|').next().unwrap_or(&from).to_string();
|
||||
let to = match parent.as_deref().map(|k| s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))).filter(|k| !k.is_empty()) {
|
||||
Some(parent) => format!("{parent}|{leaf}"),
|
||||
None => leaf,
|
||||
};
|
||||
let op = s
|
||||
.catalog
|
||||
.move_keyword_ops(&from, parent.as_deref(), bool_or(p, "merge", false))
|
||||
.map_err(|e| refused("keyword.move", e, ": moving would merge the two (merge: true)"))?;
|
||||
commit_keywords(s, "Move Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() }))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.purgeUnused",
|
||||
"Purge Unused Keywords",
|
||||
[],
|
||||
None,
|
||||
"{} — takes off the keyword list the keywords no photo has → {purged}",
|
||||
always,
|
||||
|s, _| {
|
||||
let op = s.catalog.purge_unused_keywords_ops();
|
||||
let r = commit_keywords(s, "Purge Unused Keywords", op, |k| Some(k.to_string()))?;
|
||||
Ok(json!({"purged": r["listed"]}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.setDefaultParent",
|
||||
"Put New Keywords Inside",
|
||||
[],
|
||||
None,
|
||||
"{keyword: keyword | null} — the parent keyword.create puts new keywords inside (null: the top level)",
|
||||
always,
|
||||
|s, p| {
|
||||
let keyword = match p.get("keyword") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(named_keyword(s, p, "keyword.setDefaultParent")?),
|
||||
};
|
||||
let op = s.catalog.set_default_parent_ops(keyword.as_deref()).map_err(|e| bad("keyword.setDefaultParent", e.to_string()))?;
|
||||
commit_keywords(s, "Put New Keywords Inside", op, |k| Some(k.to_string()))?;
|
||||
Ok(json!({"keyword": s.catalog.default_keyword_parent()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.export",
|
||||
"Export Keywords",
|
||||
[],
|
||||
None,
|
||||
"{path?} — the keyword list as a file (Lightroom Classic's format, which Capture One, Photo Supreme and Bridge read) → {keywords, captureOneRefuses: keywords with ; , < > that Capture One's importer refuses, unwritable: keywords left out because the format can't hold them (a name in brackets or braces, a line break, deeper than 64 levels; a synonym as \"path {synonym}\"), text when there is no path}",
|
||||
always,
|
||||
|s, p| {
|
||||
let file = s.catalog.keyword_list_text();
|
||||
// Capture One's importer refuses ; , < > in a keyword's name
|
||||
let refuses: Vec<&str> = file
|
||||
.written
|
||||
.iter()
|
||||
.filter_map(|p| p.rsplit(lightcraft_catalog::keywords::SEP).next())
|
||||
.filter(|n| n.contains(CAPTURE_ONE_REFUSES))
|
||||
.collect();
|
||||
let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable});
|
||||
match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
|
||||
r["path"] = json!(path);
|
||||
}
|
||||
None => r["text"] = json!(file.text),
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.import",
|
||||
"Import Keywords",
|
||||
[],
|
||||
None,
|
||||
"{path | text} — a keyword list (Lightroom Classic's format, also Capture One's and Photo Supreme's Formatted Vocabulary File): adds the keywords the library doesn't have, gives those it has the list's synonyms; one undo step → {added, updated}",
|
||||
always,
|
||||
|s, p| {
|
||||
let text = match (str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()), str_param(p, "text")) {
|
||||
(Some(path), _) => read_list(path)?,
|
||||
(None, Some(text)) if text.len() as u64 > MAX_LIST_BYTES => {
|
||||
return Err(bad("keyword.import", format!("larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20)));
|
||||
}
|
||||
(None, Some(text)) => text.to_string(),
|
||||
(None, None) => return Err(bad("keyword.import", "give `path` or `text`")),
|
||||
};
|
||||
let entries = lightcraft_catalog::keywords::parse_keyword_list(&text).map_err(|e| bad("keyword.import", e.to_string()))?;
|
||||
let (op, added, updated) = s.catalog.import_keywords_ops(&entries);
|
||||
commit_keywords(s, "Import Keywords", op, |k| Some(k.to_string()))?;
|
||||
Ok(json!({"added": added, "updated": updated}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"keyword.rename",
|
||||
"Rename Keyword",
|
||||
@@ -197,9 +487,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let to = str_param(p, "to").ok_or_else(|| bad("keyword.rename", "missing `to`"))?.to_string();
|
||||
let op = s.catalog.rename_keyword_ops(&from, &to).map_err(|e| bad("keyword.rename", e.to_string()))?;
|
||||
let (f, t) = (clean(&from), clean(&to));
|
||||
commit_keywords(s, "Rename Keyword", op, |k| {
|
||||
Some(if is_under(k, &f) { format!("{t}{}", &k[f.len().min(k.len())..]) } else { k.to_string() })
|
||||
})
|
||||
commit_keywords(s, "Rename Keyword", op, |k| Some(if is_under(k, &f) { reparent(k, &f, &t) } else { k.to_string() }))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
@@ -229,8 +517,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let op = s.catalog.merge_keywords_ops(&from, &into).map_err(|e| bad("keyword.merge", e.to_string()))?;
|
||||
let (from, into) = (from.iter().map(|f| clean(f)).collect::<Vec<_>>(), clean(&into));
|
||||
commit_keywords(s, "Merge Keywords", op, |k| {
|
||||
Some(match from.iter().find(|f| is_under(k, f)) {
|
||||
Some(f) => format!("{into}{}", &k[f.len().min(k.len())..]),
|
||||
Some(match closest(k, &from) {
|
||||
Some(f) => reparent(k, f, &into),
|
||||
None => k.to_string(),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,20 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
use crate::{LibrarySource, Result, Selection, Session};
|
||||
|
||||
fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
|
||||
let at = before.iter().position(|id| Some(*id) == s.selection.active);
|
||||
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
|
||||
s.selection.ids.retain(|id| remaining.contains(id));
|
||||
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
|
||||
if s.selection.active.is_none()
|
||||
&& let Some(at) = at
|
||||
{
|
||||
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
|
||||
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
|
||||
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
/// The auto-import folder's visible files with their sizes (the file-system half of
|
||||
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
|
||||
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
|
||||
@@ -119,6 +133,14 @@ fn album_param(p: &Value, key: &str, c: &str) -> Result<AlbumId> {
|
||||
p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`")))
|
||||
}
|
||||
|
||||
/// An optional album id: absent or `null` is `None`, anything but a number is an error.
|
||||
fn opt_album_param(p: &Value, key: &str, c: &str) -> Result<Option<AlbumId>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().map(|n| Some(AlbumId(n))).ok_or_else(|| bad(c, format!("`{key}` must be an album id or null"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn strs(p: &Value, key: &str) -> Vec<String> {
|
||||
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default()
|
||||
}
|
||||
@@ -138,6 +160,24 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
/// Adjust each target's own rating; unchanged bounds do not create an undo step.
|
||||
fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<Value> {
|
||||
let mut ops = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating;
|
||||
let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8;
|
||||
if rating != old {
|
||||
ops.push(Op::SetRating { id, rating });
|
||||
}
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(label, Op::Batch { ops })?;
|
||||
}
|
||||
advance_if(s, p);
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
fn advance_if(s: &mut Session, p: &Value) {
|
||||
if bool_or(p, "advance", false) {
|
||||
let _ = step(s, 1);
|
||||
@@ -322,6 +362,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder {
|
||||
return Err(bad("library.filter", "a folder is already shown (library.source): show another source first"));
|
||||
}
|
||||
// checked like a smart album's rules when this call sets them: an unknown field or a value
|
||||
// that can't match is an error, not an empty grid. Rules that stopped checking since
|
||||
// (their album was deleted) don't block other filter changes.
|
||||
if let Some(rules) = f.rule_set.as_mut() {
|
||||
rules.upgrade();
|
||||
}
|
||||
let sets_rules = p.get("ruleSet").is_some_and(|r| !r.is_null());
|
||||
if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) {
|
||||
return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
|
||||
}
|
||||
s.filter = f;
|
||||
Ok(json!({"count": s.visible().len()}))
|
||||
}
|
||||
@@ -331,7 +381,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Library Folders",
|
||||
[],
|
||||
None,
|
||||
"{} → [{name, path, count, own, volume, selectable, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
|
||||
"{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
|
||||
always,
|
||||
|s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default())
|
||||
),
|
||||
@@ -379,6 +429,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"removed": ids.len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"folder.label",
|
||||
"Set Folder Color Label",
|
||||
[],
|
||||
None,
|
||||
"{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "folder.label";
|
||||
let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? {
|
||||
"none" => None,
|
||||
x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?),
|
||||
};
|
||||
// taking a label off always works, also once the folder's photos are gone
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() {
|
||||
return Err(bad(C, format!("{path}: no photo in the library was imported from it")));
|
||||
}
|
||||
if label.is_none() && s.catalog.folder_record(path).is_none() {
|
||||
return Ok(json!({"path": path, "label": label}));
|
||||
}
|
||||
let op = s.catalog.folder_label_op(path, label);
|
||||
s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?;
|
||||
Ok(json!({"path": path, "label": label}))
|
||||
}
|
||||
),
|
||||
cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| {
|
||||
s.filter = Default::default();
|
||||
Ok(json!({"count": s.visible().len()}))
|
||||
@@ -556,6 +633,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
advance_if(s, p);
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!("photo.decreaseRating", "Decrease Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
|
||||
change_rating(s, p, -1, "Decrease Rating")
|
||||
}),
|
||||
cmd!("photo.increaseRating", "Increase Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| {
|
||||
change_rating(s, p, 1, "Increase Rating")
|
||||
}),
|
||||
cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| {
|
||||
let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?;
|
||||
let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?;
|
||||
@@ -653,9 +736,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
// ---- delete / restore
|
||||
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
|
||||
let vis = s.visible_cloned();
|
||||
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
|
||||
select_after_deletion(s, &before);
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
|
||||
@@ -681,10 +764,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let t = s.targets(p);
|
||||
let op = s.catalog.delete_photos_permanently_ops(&t);
|
||||
s.commit("Delete Permanently", op)?;
|
||||
s.selection = Selection::default();
|
||||
select_after_deletion(s, &before);
|
||||
Ok(json!({"deleted": t.len()}))
|
||||
}),
|
||||
// ---- metadata
|
||||
@@ -748,16 +832,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
*field = v.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(k) = strs("keywords") {
|
||||
m.keywords = k;
|
||||
}
|
||||
for k in strs("addKeywords").unwrap_or_default() {
|
||||
if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
|
||||
// keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case
|
||||
use lightcraft_catalog::keywords::{clean, same};
|
||||
let add = |m: &mut lightcraft_catalog::Meta, k: &str| {
|
||||
let k = clean(k);
|
||||
if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) {
|
||||
m.keywords.push(k);
|
||||
}
|
||||
};
|
||||
if let Some(k) = strs("keywords") {
|
||||
m.keywords.clear();
|
||||
for k in k {
|
||||
add(&mut m, &k);
|
||||
}
|
||||
}
|
||||
for k in strs("addKeywords").unwrap_or_default() {
|
||||
add(&mut m, &k);
|
||||
}
|
||||
for k in strs("removeKeywords").unwrap_or_default() {
|
||||
m.keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
|
||||
let k = clean(&k);
|
||||
m.keywords.retain(|x| !same(&clean(x), &k));
|
||||
}
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(m) });
|
||||
}
|
||||
@@ -784,7 +878,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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 } },
|
||||
Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } },
|
||||
)?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}),
|
||||
@@ -801,7 +895,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(bad("album.createSmart", "empty name"));
|
||||
}
|
||||
let rules = match p.get("rules") {
|
||||
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart")?,
|
||||
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?,
|
||||
None => view_rules(s),
|
||||
};
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
@@ -811,8 +905,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| {
|
||||
use lightcraft_catalog::rules::{FIELDS, Kind, ops_for};
|
||||
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind (text|keywords|number|date|choice|bool|album: an album id), group (field-menu submenu, null at the top level), ops: [{op, label}], choices? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| {
|
||||
use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for};
|
||||
Ok(json!(FIELDS
|
||||
.iter()
|
||||
.map(|(id, label, kind)| {
|
||||
@@ -820,13 +914,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Kind::Text => "text",
|
||||
Kind::Keywords => "keywords",
|
||||
Kind::Number => "number",
|
||||
Kind::Album => "album",
|
||||
Kind::Date => "date",
|
||||
Kind::Choice(_) => "choice",
|
||||
Kind::Bool => "bool",
|
||||
};
|
||||
let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
|
||||
let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
|
||||
if let Kind::Choice(c) = kind {
|
||||
v["choices"] = json!(c);
|
||||
v["choices"] = json!(c.iter().map(|c| c.0).collect::<Vec<_>>());
|
||||
v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::<serde_json::Map<_, _>>().into();
|
||||
}
|
||||
v
|
||||
})
|
||||
@@ -837,18 +933,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Smart Album",
|
||||
[],
|
||||
None,
|
||||
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view)}",
|
||||
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view), name?: also rename it (blank keeps the name) — one undo step, both or neither}",
|
||||
always,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.setRules")?;
|
||||
let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?;
|
||||
let rules = if bool_or(p, "fromView", false) {
|
||||
view_rules(s)
|
||||
// the view is saved as it is, but never as a loop (showing A, which tests this
|
||||
// album, and saving that view here would make it test itself)
|
||||
let view = view_rules(s);
|
||||
let loops: Vec<String> = view
|
||||
.rule_set
|
||||
.as_ref()
|
||||
.map(|r| r.check_for(&s.catalog, Some(id)))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.chain(s.catalog.album_filter_problem(&view, Some(id)))
|
||||
.filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop)
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
if !loops.is_empty() {
|
||||
return Err(bad("album.setRules", loops.join("; ")));
|
||||
}
|
||||
view
|
||||
} else {
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let folder = cur.library_folder.clone();
|
||||
let base = if replace { Default::default() } else { cur };
|
||||
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?;
|
||||
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?;
|
||||
// the rules dialog has no folder field: replacing its rules keeps the folder
|
||||
// unless the call says (`libraryFolder: null`) to drop it
|
||||
if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() {
|
||||
@@ -856,7 +968,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
r
|
||||
};
|
||||
s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?;
|
||||
let set = Op::SetAlbumRules { id, rules: Box::new(rules) };
|
||||
// a new name goes with the rules: one step, applied (and undone) together
|
||||
let rename = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty() && s.catalog.album(id).is_some_and(|a| a.name != *n));
|
||||
match rename {
|
||||
Some(name) => s.commit("Edit Smart Album", Op::Batch { ops: vec![Op::RenameAlbum { id, name: name.to_string() }, set] })?,
|
||||
None => s.commit("Edit Smart Album", set)?,
|
||||
};
|
||||
Ok(json!({"count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
@@ -866,20 +984,125 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.commit("Rename Album", Op::RenameAlbum { id, name })?;
|
||||
ok()
|
||||
}),
|
||||
cmd!("album.delete", "Delete Album", [], None, "{id}", always, |s, p| {
|
||||
let id = album_param(p, "id", "album.delete")?;
|
||||
s.commit("Delete Album", Op::RemoveAlbum { id })?;
|
||||
if s.source == LibrarySource::Album(id) {
|
||||
s.source = LibrarySource::All;
|
||||
cmd!(
|
||||
"album.delete",
|
||||
"Delete Album",
|
||||
[],
|
||||
None,
|
||||
"{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step",
|
||||
always,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.delete")?;
|
||||
let folder = s.catalog.album(id).ok_or_else(|| bad("album.delete", "no such album"))?.folder;
|
||||
let mut children = std::collections::BTreeMap::<AlbumId, Vec<AlbumId>>::new();
|
||||
for album in s.catalog.albums() {
|
||||
if let Some(parent) = album.parent {
|
||||
children.entry(parent).or_default().push(album.id);
|
||||
}
|
||||
}
|
||||
// Iterative traversal: even a malformed cycle is rejected before any change.
|
||||
// Reverse parent-first order removes children first; the batch inverse restores
|
||||
// parents first, including memberships, smart rules, covers and manual ordering.
|
||||
let mut pending = vec![id];
|
||||
let mut order = Vec::new();
|
||||
let mut removed = std::collections::BTreeSet::new();
|
||||
while let Some(next) = pending.pop() {
|
||||
if !removed.insert(next) {
|
||||
return Err(bad("album.delete", "cycle in album folders"));
|
||||
}
|
||||
order.push(next);
|
||||
if let Some(nested) = children.remove(&next) {
|
||||
pending.extend(nested);
|
||||
}
|
||||
}
|
||||
let ops = order.into_iter().rev().map(|id| Op::RemoveAlbum { id }).collect();
|
||||
s.commit(if folder { "Delete Folder" } else { "Delete Album" }, Op::Batch { ops })?;
|
||||
if matches!(s.source, LibrarySource::Album(current) if removed.contains(¤t)) {
|
||||
s.source = LibrarySource::All;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
),
|
||||
cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| {
|
||||
let id = album_param(p, "id", "album.move")?;
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
s.commit("Move Album", Op::MoveAlbum { id, parent })?;
|
||||
ok()
|
||||
}),
|
||||
cmd!(
|
||||
"album.reorder",
|
||||
"Reorder Album",
|
||||
[],
|
||||
None,
|
||||
"{id, parent?: folderId|null, before?: albumId|null} — places the album (or folder) in `parent` (default: where it is; null: the top level) before the sibling `before` of the same kind, or last of its kind; the folder is then ordered by hand. One undo step. → {changed}: how many edits it took (0: already there)",
|
||||
always,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.reorder")?;
|
||||
let me = s.catalog.album(id).ok_or_else(|| bad("album.reorder", "no such album"))?;
|
||||
let parent = if p.get("parent").is_some() { opt_album_param(p, "parent", "album.reorder")? } else { me.parent };
|
||||
let before = opt_album_param(p, "before", "album.reorder")?;
|
||||
if before == Some(id) {
|
||||
// before itself: where it is
|
||||
return Ok(json!({"changed": 0}));
|
||||
}
|
||||
let mut order: Vec<AlbumId> = s.catalog.album_children(parent).into_iter().filter(|a| a.id != id).map(|a| a.id).collect();
|
||||
let at = match before {
|
||||
Some(b) => {
|
||||
let at = order.iter().position(|x| *x == b).ok_or_else(|| bad("album.reorder", "`before` is not inside that folder"))?;
|
||||
if s.catalog.album(b).is_none_or(|x| x.folder != me.folder) {
|
||||
return Err(bad("album.reorder", "folders are placed among folders and albums among albums"));
|
||||
}
|
||||
at
|
||||
}
|
||||
None if me.folder => order.iter().take_while(|x| s.catalog.album(**x).is_some_and(|a| a.folder)).count(),
|
||||
None => order.len(),
|
||||
};
|
||||
order.insert(at.min(order.len()), id);
|
||||
let moved = me.parent != parent;
|
||||
let mut ops = Vec::new();
|
||||
if moved {
|
||||
ops.push(Op::MoveAlbum { id, parent });
|
||||
}
|
||||
for (i, aid) in order.iter().enumerate() {
|
||||
let want = u32::try_from(i).ok();
|
||||
// a moved album starts without a place in its new folder
|
||||
let have = if moved && *aid == id { None } else { s.catalog.album(*aid).and_then(|a| a.order) };
|
||||
if have != want {
|
||||
ops.push(Op::SetAlbumOrder { id: *aid, order: want });
|
||||
}
|
||||
}
|
||||
if ops.is_empty() {
|
||||
return Ok(json!({"changed": 0}));
|
||||
}
|
||||
let changed = ops.len();
|
||||
s.commit("Reorder Album", Op::Batch { ops })?;
|
||||
Ok(json!({"changed": changed}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"album.sort",
|
||||
"Sort Albums by Name",
|
||||
[],
|
||||
None,
|
||||
"{parent?: folderId|null} — drops the hand order of the albums inside the folder (default: the top level), so they are listed by name → {changed}: how many albums lost their place",
|
||||
always,
|
||||
|s, p| {
|
||||
let parent = opt_album_param(p, "parent", "album.sort")?;
|
||||
let ops: Vec<Op> = s
|
||||
.catalog
|
||||
.album_children(parent)
|
||||
.into_iter()
|
||||
.filter(|a| a.order.is_some())
|
||||
.map(|a| Op::SetAlbumOrder { id: a.id, order: None })
|
||||
.collect();
|
||||
if ops.is_empty() {
|
||||
return Ok(json!({"changed": 0}));
|
||||
}
|
||||
let changed = ops.len();
|
||||
s.commit("Sort Albums by Name", Op::Batch { ops })?;
|
||||
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));
|
||||
@@ -1195,12 +1418,39 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// `base` with a partial Filter (JSON) merged on top.
|
||||
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
|
||||
/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see
|
||||
/// [`merge_rules`]).
|
||||
fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
|
||||
let mut v = serde_json::to_value(base).unwrap_or_default();
|
||||
lightcraft_develop::presets::deep_merge(&mut v, patch);
|
||||
let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
|
||||
if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) {
|
||||
return Err(bad(c, problem));
|
||||
let mut f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
|
||||
if let Some(rules) = f.rule_set.as_mut() {
|
||||
rules.upgrade();
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// `patch` merged onto `base`, refused when the rule set it sets has problems (`RuleSet::check_for`;
|
||||
/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a
|
||||
/// problem too).
|
||||
fn merge_rules(
|
||||
base: &lightcraft_catalog::Filter,
|
||||
patch: &Value,
|
||||
c: &str,
|
||||
cat: &lightcraft_catalog::Catalog,
|
||||
owner: Option<AlbumId>,
|
||||
) -> Result<lightcraft_catalog::Filter> {
|
||||
let f = merge_filter(base, patch, c)?;
|
||||
// only rules this change sets are checked: one that stopped checking since (its album was
|
||||
// deleted) doesn't block editing the album's other settings
|
||||
let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null());
|
||||
let mut problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default();
|
||||
// and a loop through the album field the change sets
|
||||
if patch.get("album").is_some_and(|a| !a.is_null()) {
|
||||
problems.extend(cat.album_filter_problem(&f, owner));
|
||||
}
|
||||
if !problems.is_empty() {
|
||||
return Err(bad(c, problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
@@ -1208,7 +1458,7 @@ fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Res
|
||||
/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup
|
||||
/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of
|
||||
/// one disk but a way to reach several.
|
||||
fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
|
||||
pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
|
||||
ids.iter()
|
||||
.filter_map(|id| s.catalog.photo(*id))
|
||||
.filter_map(|p| lightcraft_catalog::folders::volume_of(p))
|
||||
@@ -1236,7 +1486,9 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
|
||||
}
|
||||
}
|
||||
}
|
||||
merge_rules(&base, &Value::Object(patch), "").unwrap_or(base)
|
||||
// the view as it is: a rule that no longer checks (its album was deleted) just matches
|
||||
// nothing there, and mustn't cost the filter bar's settings
|
||||
merge_filter(&base, &Value::Object(patch), "").unwrap_or(base)
|
||||
}
|
||||
None => {
|
||||
let mut f = s.source.to_filter(&s.filter, &s.catalog);
|
||||
@@ -1304,3 +1556,7 @@ mod gps_tests {
|
||||
assert_eq!(p("hello"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library/tests_relative_ratings.rs"]
|
||||
mod relative_rating_tests;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Relative ratings dispatch through the real engine, including history and AutoWrite.
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
fn session() -> Result<Session> {
|
||||
let mut s = Session::new();
|
||||
for (id, rating) in [(1, 0), (2, 2), (3, 5), (4, 1)] {
|
||||
let mut photo = Photo::new(
|
||||
PhotoId(id),
|
||||
Source::File { path: format!("/lightcraft-relative-ratings/{id}.jpg") },
|
||||
&format!("{id}.jpg"),
|
||||
"JPEG",
|
||||
40,
|
||||
30,
|
||||
"2026-10-09",
|
||||
);
|
||||
photo.rating = rating;
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;
|
||||
}
|
||||
s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?;
|
||||
s.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2}))?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn ratings(s: &Session) -> Result<Vec<u8>> {
|
||||
(1..=4)
|
||||
.map(|id| s.catalog.photo(PhotoId(id)).map(|p| p.rating).ok_or_else(|| lightcraft_catalog::CatalogError::NoPhoto(PhotoId(id)).into()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_ratings_clamp_each_photo_and_form_one_history_step() {
|
||||
let mut s = session().unwrap();
|
||||
let undo = s.undo.len();
|
||||
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 2);
|
||||
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.active(), Some(PhotoId(2)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 2, 5, 1]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
|
||||
assert_eq!(s.execute("photo.decreaseRating", &json!({})).unwrap()["changed"], 3);
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 2, 4, 1]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_active_fallback_explicit_targets_and_errors_preserve_history() {
|
||||
let mut s = session().unwrap();
|
||||
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [1]})).unwrap()["changed"], 0);
|
||||
assert_eq!(s.execute("photo.increaseRating", &json!({"ids": [3]})).unwrap()["changed"], 0);
|
||||
assert!(s.undo.is_empty());
|
||||
s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) };
|
||||
assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 1);
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 1]);
|
||||
assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [4]})).unwrap()["changed"], 1);
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
|
||||
let undo = s.undo.len();
|
||||
assert!(s.execute("photo.increaseRating", &json!({"ids": [1, 999]})).is_err());
|
||||
assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
s.execute("photo.increaseRating", &json!({"advance": true})).unwrap();
|
||||
assert_eq!(s.active(), Some(PhotoId(3)));
|
||||
s.selection = Selection::default();
|
||||
assert!(s.execute("photo.decreaseRating", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_ratings_and_history_use_auto_write() {
|
||||
let mut s = session().unwrap();
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("lc-relative-ratings-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Metadata-only fixtures: no import, image decoding or model/service dependency.
|
||||
for id in 1..=4 {
|
||||
s.catalog
|
||||
.apply(Op::SetFile {
|
||||
id: PhotoId(id),
|
||||
file_name: format!("{id}.jpg"),
|
||||
source: Source::File { path: dir.join(format!("{id}.jpg")).to_string_lossy().into_owned() },
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
|
||||
s.execute("photo.increaseRating", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>1</xmp:Rating>"));
|
||||
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
|
||||
assert!(!dir.join("3.xmp").exists(), "unchanged upper bound must not write");
|
||||
assert!(!dir.join("4.xmp").exists(), "unselected photo must not write");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("<xmp:Rating>0</xmp:Rating>"));
|
||||
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>2</xmp:Rating>"));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("<xmp:Rating>3</xmp:Rating>"));
|
||||
s.execute("photo.decreaseRating", &json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(dir.join("3.xmp")).unwrap().contains("<xmp:Rating>4</xmp:Rating>"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -189,7 +189,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
// photos without a capture time start from their import time
|
||||
let base = |s: &crate::Session, id| s.catalog.photo(id).map(|p| p.date().to_string()).unwrap_or_default();
|
||||
let mut delta = (f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0).round() as i64;
|
||||
// seconds; a shift beyond 10,000 years is a mistake (and would overflow), not a date
|
||||
const MAX_SHIFT: f64 = 10_000.0 * 365.25 * 86_400.0;
|
||||
let shift = f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0;
|
||||
if shift.is_nan() || shift.abs() > MAX_SHIFT {
|
||||
return Err(bad(c, "the shift is more than 10,000 years"));
|
||||
}
|
||||
let mut delta = shift.round() as i64;
|
||||
let mut each: Option<String> = None;
|
||||
if let Some(t) = str_param(p, "time") {
|
||||
let t = normalize_iso(t).ok_or_else(|| bad(c, format!("`{t}` is not a date (YYYY-MM-DDTHH:MM:SS)")))?;
|
||||
@@ -197,23 +203,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
each = Some(t);
|
||||
} else {
|
||||
let anchor = s.active().filter(|a| targets.contains(a)).unwrap_or(targets[0]);
|
||||
let from = iso_seconds(&base(s, anchor)).ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
|
||||
let anchor_date = base(s, anchor);
|
||||
// An impossible calendar date cannot anchor changes to the other photos.
|
||||
let from = normalize_iso(anchor_date.trim().get(..10).unwrap_or(""))
|
||||
.and_then(|_| iso_seconds(&anchor_date))
|
||||
.ok_or_else(|| bad(c, "the photo's date doesn't parse"))?;
|
||||
delta += iso_seconds(&t).unwrap_or(from) - from;
|
||||
}
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
let mut out_of_range = 0usize;
|
||||
for id in &targets {
|
||||
let new = match &each {
|
||||
Some(t) => shift_iso(t, delta),
|
||||
None => shift_iso(&base(s, *id), delta),
|
||||
let from = match &each {
|
||||
Some(t) => t.clone(),
|
||||
None => base(s, *id),
|
||||
};
|
||||
let Some(new) = shift_iso(&from, delta) else {
|
||||
// a date that reads but would leave years 0000–9999 is refused below; one
|
||||
// that doesn't read is skipped, as before
|
||||
if iso_seconds(&from).is_some() {
|
||||
out_of_range += 1;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(new) = new else { continue };
|
||||
out.push(json!(new));
|
||||
if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str()) {
|
||||
ops.push(Op::SetCaptured { id: *id, captured: Some(new) });
|
||||
}
|
||||
}
|
||||
if out_of_range > 0 {
|
||||
return Err(bad(c, format!("the shift would take {out_of_range} photo(s) outside the years 0000–9999; nothing was changed")));
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit("Edit Capture Time", Op::Batch { ops })?;
|
||||
|
||||
@@ -255,7 +255,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
};
|
||||
let m = &mut masks[i];
|
||||
if let Some(MaskComponent { name: None, shape: MaskShape::Brush { strokes }, .. }) =
|
||||
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
|
||||
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
|
||||
{
|
||||
strokes.push(stroke);
|
||||
@@ -432,6 +432,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"available": crate::segment::Segmenter::AVAILABLE,
|
||||
"installed": g.installed(),
|
||||
"dir": g.dir.as_ref().map(|d| d.display().to_string()),
|
||||
"remote": g.remote_endpoint(),
|
||||
"loaded": g.loaded(),
|
||||
"busy": g.busy(),
|
||||
"analyzing": g.analyzing(),
|
||||
@@ -463,7 +464,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
));
|
||||
}
|
||||
let started = s.segmenter.start_download().map_err(ai_err)?;
|
||||
let started = s.segmenter.start_download(&s.activity).map_err(ai_err)?;
|
||||
Ok(json!({"started": started, "installed": s.segmenter.installed(), "downloading": s.segmenter.download_status().running}))
|
||||
}
|
||||
),
|
||||
|
||||
@@ -51,7 +51,7 @@ pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id:
|
||||
!cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f))
|
||||
}
|
||||
|
||||
fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None }
|
||||
}
|
||||
|
||||
@@ -4,15 +4,20 @@
|
||||
//! delete), `album.*`, `develop.*` (settings), `crop.*`, `mask.*`, `preset.*`, `version.*`,
|
||||
//! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…).
|
||||
|
||||
mod activity;
|
||||
mod before;
|
||||
pub(crate) mod browse;
|
||||
mod color;
|
||||
pub(crate) mod convert;
|
||||
mod cull;
|
||||
pub mod curves;
|
||||
pub(crate) mod denoise;
|
||||
mod develop;
|
||||
mod edit;
|
||||
mod export;
|
||||
pub(crate) mod face_detect;
|
||||
mod face_models;
|
||||
mod face_recognize;
|
||||
pub mod filters;
|
||||
pub mod keywords;
|
||||
pub mod library;
|
||||
@@ -27,6 +32,7 @@ mod prefs;
|
||||
mod preset_files;
|
||||
pub mod previews;
|
||||
mod query;
|
||||
mod sync;
|
||||
mod xmp;
|
||||
|
||||
use serde::Serialize;
|
||||
@@ -50,6 +56,10 @@ pub struct CommandSpec {
|
||||
pub run: Run,
|
||||
/// Record in the journal (false for queries).
|
||||
pub journal: bool,
|
||||
/// `enabled` judges the selection (menus, shortcuts); a call that names its photos (`ids` /
|
||||
/// `id`, see [`names_photos`]) skips it, and the command validates those photos itself.
|
||||
/// Off unless a command opts in (`CommandSpec { explicit_targets: true, ..cmd!(…) }`).
|
||||
pub explicit_targets: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -103,10 +113,10 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
macro_rules! cmd {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, explicit_targets: false }
|
||||
};
|
||||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, explicit_targets: false }
|
||||
};
|
||||
}
|
||||
pub(crate) use cmd;
|
||||
@@ -138,8 +148,14 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(before::specs());
|
||||
v.extend(browse::specs());
|
||||
v.extend(missing::specs());
|
||||
v.extend(sync::specs());
|
||||
v.extend(metadata::specs());
|
||||
v.extend(filters::specs());
|
||||
v.extend(denoise::specs());
|
||||
v.extend(face_models::specs());
|
||||
v.extend(face_detect::specs());
|
||||
v.extend(face_recognize::specs());
|
||||
v.extend(activity::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
@@ -150,6 +166,11 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
|
||||
// ---------- param helpers
|
||||
|
||||
/// The call names its target photos (`ids` or `id`, not null) instead of using the selection.
|
||||
pub(crate) fn names_photos(p: &Value) -> bool {
|
||||
["ids", "id"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null()))
|
||||
}
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Build previews ahead of time: each photo's grid thumbnail and its loupe view (standard size,
|
||||
//! or 1:1), rendered into the memory + disk preview cache so browsing and the loupe are instant.
|
||||
//! Runs on a background thread (the app keeps working; progress / cancel by command), or inline
|
||||
//! with `wait` (CLI, MCP, tests).
|
||||
//! Runs on a background thread (the app keeps working; progress / cancel by command, and a row in
|
||||
//! the activity stack, issue #345), or inline with `wait` (CLI, MCP, tests).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
@@ -9,6 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::activity::{Cancel, TaskGuard};
|
||||
use crate::media::{RenderJob, THUMB_SIZES};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -21,7 +22,8 @@ pub struct PreviewBuild {
|
||||
pub total: usize,
|
||||
pub done: AtomicUsize,
|
||||
pub failed: AtomicUsize,
|
||||
pub cancel: AtomicBool,
|
||||
/// Shared with the build's row in the activity stack (its ✕ sets it).
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
pub finished: AtomicBool,
|
||||
/// Damaged smart previews rebuilt (Build Smart Previews only).
|
||||
pub repaired: AtomicUsize,
|
||||
@@ -62,7 +64,19 @@ fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option<usize
|
||||
v
|
||||
}
|
||||
|
||||
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild) {
|
||||
/// The build's row in the activity stack (`what` as in [`PreviewBuild::what`]).
|
||||
fn task_for(s: &Session, state: &PreviewBuild) -> TaskGuard {
|
||||
let (kind, label) = match state.what {
|
||||
"" => ("previews", "Building previews"),
|
||||
"smart previews" => ("smartPreviews", "Building smart previews"),
|
||||
_ => ("smartPreviews", "Discarding smart previews"),
|
||||
};
|
||||
let task = s.activity.start(kind, label, Cancel::Flag(state.cancel.clone()));
|
||||
task.progress(0, state.total as u64);
|
||||
task
|
||||
}
|
||||
|
||||
fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard) {
|
||||
for photo_jobs in jobs {
|
||||
if state.cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
@@ -71,7 +85,8 @@ fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild) {
|
||||
if !ok {
|
||||
state.failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
state.done.fetch_add(1, Ordering::Relaxed);
|
||||
let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
task.progress(done as u64, state.total as u64);
|
||||
}
|
||||
state.finished.store(true, Ordering::Relaxed);
|
||||
}
|
||||
@@ -91,26 +106,35 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect();
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
|
||||
s.preview_build = Some(state.clone());
|
||||
let task = task_for(s, &state);
|
||||
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32");
|
||||
if wait {
|
||||
run_all(jobs, &state);
|
||||
run_all(jobs, &state, &task);
|
||||
return Ok(state.json());
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// the row stays while the worker runs (it is dropped with the closure, however that ends)
|
||||
let st = state.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-build-previews".into())
|
||||
.spawn(move || run_all(jobs, &st))
|
||||
.spawn(move || run_all(jobs, &st, &task))
|
||||
.map_err(|e| bad(C, format!("could not start: {e}")))?;
|
||||
}
|
||||
Ok(state.json())
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// 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
|
||||
/// proxies go stale when the fit changes).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>);
|
||||
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>, bool);
|
||||
|
||||
/// Held while one proxy is checked and (re)written, so Build Smart Previews and the opening look
|
||||
/// refresh ([`refresh_stale_smart_previews`]), on their own threads, never write the same file at once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static SMART_WRITE: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// What Build / Discard Smart Previews did.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -120,11 +144,29 @@ struct SmartCounts {
|
||||
built: usize,
|
||||
/// Of those, damaged ones (cut short by a crash or a full drive) that were rebuilt.
|
||||
repaired: usize,
|
||||
/// Of those, proxies written by an older camera-look fit that were rebuilt from their online originals.
|
||||
refreshed: usize,
|
||||
/// Of those kept as they were: out of date, but the original can't be read now (offline), so
|
||||
/// the stored look stays and the next run with the original online rebuilds them.
|
||||
stale_kept: usize,
|
||||
removed: usize,
|
||||
/// `[[id, why]]`
|
||||
failed: Vec<Value>,
|
||||
}
|
||||
|
||||
/// `source` if it reads the photo's original, else `None`: a cached source decoded from a smart
|
||||
/// preview (it has no decoder facts), a proxy standing in for an offline original, and the proxy
|
||||
/// fallback of a file source are not the original.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn original_only(source: crate::media::SourceRef) -> Option<crate::media::SourceRef> {
|
||||
use crate::media::SourceRef;
|
||||
match source {
|
||||
SourceRef::File { path, max_edge, loader, denoise, .. } => Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise }),
|
||||
SourceRef::Loaded(d) if d.info.is_some() => Some(SourceRef::Loaded(d)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The file-system half of Build / Discard Smart Previews (runs on a worker thread in the app).
|
||||
/// `custom`: the folder was chosen (on another drive, perhaps): it must be there, it is never
|
||||
/// recreated. With `state`, progress and cancel.
|
||||
@@ -134,7 +176,7 @@ fn smart_run(
|
||||
custom: bool,
|
||||
discard: bool,
|
||||
jobs: Vec<SmartJob>,
|
||||
state: Option<&PreviewBuild>,
|
||||
state: Option<(&PreviewBuild, &TaskGuard)>,
|
||||
) -> std::result::Result<SmartCounts, String> {
|
||||
let mut n = SmartCounts::default();
|
||||
if !discard {
|
||||
@@ -145,21 +187,27 @@ fn smart_run(
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
}
|
||||
for (id, path, source) in jobs {
|
||||
if state.is_some_and(|st| st.cancel.load(Ordering::Relaxed)) {
|
||||
for (id, path, source, look) in jobs {
|
||||
if state.is_some_and(|(st, _)| st.cancel.load(Ordering::Relaxed)) {
|
||||
break;
|
||||
}
|
||||
let _one = SMART_WRITE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if discard {
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
n.removed += 1;
|
||||
}
|
||||
} else {
|
||||
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
|
||||
// drive) is (re)built
|
||||
// a complete, current proxy is kept; a missing or damaged one (cut short by a crash or a
|
||||
// full drive) is (re)built, and so is one written by an older camera-look fit (see
|
||||
// `camera_preview::LOOK_VERSION`) when its original can be read
|
||||
let damaged = path.exists();
|
||||
if damaged && crate::smart::is_valid(&path) {
|
||||
let stale = look && damaged && crate::smart::is_stale(&path);
|
||||
if damaged && !stale && crate::smart::is_valid(&path) {
|
||||
n.built += 1;
|
||||
} else {
|
||||
// a stale proxy is rebuilt from the original only: never from itself (the file
|
||||
// fallback or a source decoded from a proxy would stamp the old look as current)
|
||||
let source = if stale { source.and_then(original_only) } else { source };
|
||||
// atomic: a failed write leaves no partial proxy that would pass for a built one;
|
||||
// and synced (issue #134): built so the photo can be edited while its original
|
||||
// is offline, when it is the only copy — the sync is small next to the decode
|
||||
@@ -171,24 +219,33 @@ fn smart_run(
|
||||
match r {
|
||||
Ok(()) => {
|
||||
n.built += 1;
|
||||
if damaged {
|
||||
if stale {
|
||||
n.refreshed += 1;
|
||||
} else if damaged {
|
||||
n.repaired += 1;
|
||||
if let Some(st) = state {
|
||||
if let Some((st, _)) = state {
|
||||
st.repaired.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) if stale => {
|
||||
// offline (or unreadable) original: the proxy is all there is, keep its look
|
||||
log::info!("smart preview {} keeps its older look: {e}", path.display());
|
||||
n.built += 1;
|
||||
n.stale_kept += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
n.failed.push(json!([id.0, e]));
|
||||
if let Some(st) = state {
|
||||
if let Some((st, _)) = state {
|
||||
st.failed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(st) = state {
|
||||
st.done.fetch_add(1, Ordering::Relaxed);
|
||||
if let Some((st, task)) = state {
|
||||
let done = st.done.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
task.progress(done as u64, st.total as u64);
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
@@ -215,18 +272,19 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let path = dir.join(crate::smart::file_name(&ph));
|
||||
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
|
||||
jobs.push((id, path, source));
|
||||
jobs.push((id, path, source, ph.relative_wb()));
|
||||
}
|
||||
let custom = s.smart_previews_dir.is_some();
|
||||
if background {
|
||||
let what = if discard { "discarding smart previews" } else { "smart previews" };
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), what, ..Default::default() });
|
||||
s.preview_build = Some(state.clone());
|
||||
let task = task_for(s, &state);
|
||||
let st = state.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-smart-previews".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = smart_run(&dir, custom, discard, jobs, Some(&st)) {
|
||||
if let Err(e) = smart_run(&dir, custom, discard, jobs, Some((&st, &task))) {
|
||||
log::warn!("{C}: {e}");
|
||||
*st.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
|
||||
}
|
||||
@@ -236,7 +294,54 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(state.json());
|
||||
}
|
||||
let r = smart_run(&dir, custom, discard, jobs, None).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"built": r.built, "repaired": r.repaired, "removed": r.removed, "failed": r.failed}))
|
||||
Ok(
|
||||
json!({"built": r.built, "repaired": r.repaired, "refreshed": r.refreshed, "staleKept": r.stale_kept, "removed": r.removed, "failed": r.failed}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bring smart previews written by an older camera-look fit up to date, once per fit version: when
|
||||
/// the smart previews folder has not been checked at this `LOOK_VERSION` (its marker,
|
||||
/// [`crate::smart::look_checked`]), a worker thread (the UI never waits on the drives) looks at the
|
||||
/// proxies of the raws that take the per-file camera look, the only ones the fit reaches (JPEG,
|
||||
/// PNG… proxies are never rebuilt), and rebuilds the stale ones from their originals. One whose
|
||||
/// original can't be read (offline) is kept as it is, still marked stale in its header: Build
|
||||
/// Smart Previews rebuilds it once the original is back. Only existing proxies are touched: none
|
||||
/// is built, and a missing folder is never created. Returns the thread, if started.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::thread::JoinHandle<()>> {
|
||||
let dir = s.media.smart_dir.clone()?;
|
||||
if !dir.is_dir() || crate::smart::look_checked(&dir) {
|
||||
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 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();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-smart-look".into())
|
||||
.spawn(move || refresh_stale(&dir, jobs))
|
||||
.map_err(|e| log::warn!("smart previews: could not start the look refresh: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// 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();
|
||||
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) {
|
||||
Ok(n) => log::info!("smart previews: {} refreshed to the current camera look, {} kept (original offline)", n.refreshed, n.stale_kept),
|
||||
Err(e) => {
|
||||
// not marked: the next opening tries again
|
||||
log::warn!("smart previews: look refresh: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = crate::smart::mark_look_checked(dir) {
|
||||
log::warn!("smart previews: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be
|
||||
|
||||
@@ -43,7 +43,9 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Va
|
||||
if let Some(rules) = &a.smart {
|
||||
v["smart"] = json!(true);
|
||||
v["rules"] = serde_json::to_value(rules).unwrap_or_default();
|
||||
v["rulesText"] = json!(rules.describe());
|
||||
v["rulesText"] = json!(rules.describe_with(cat));
|
||||
// rules that no longer check (an album they test was deleted…), as `rule 1: …`
|
||||
v["problems"] = json!(cat.smart_album_problems(a.id).iter().map(ToString::to_string).collect::<Vec<_>>());
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -149,9 +151,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| {
|
||||
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?} → the photo's develop settings; `process` is the rendering process they are interpreted by (see develop.updateProcess)", always, |s, p| {
|
||||
let id = photo_arg(s, p, "develop.get")?;
|
||||
Ok(s.develop_of(id).map(|d| d.to_json()).unwrap_or(Value::Null))
|
||||
Ok(s.develop_of(id)
|
||||
.map(|d| {
|
||||
let mut v = d.to_json();
|
||||
// saved settings leave V1 out (`ProcessVersion::is_legacy`); here it is always stated
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("process".into(), json!(d.process));
|
||||
}
|
||||
v
|
||||
})
|
||||
.unwrap_or(Value::Null))
|
||||
}),
|
||||
cmd!(query "develop.controls", "List Develop Controls", [], None, "{section?} — every slider with range, default and current value", always, |s, p| {
|
||||
let d = s.active().and_then(|id| s.develop_of(id)).unwrap_or_default();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Synchronize Folder commands (see [`crate::sync`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
||||
use crate::sync::{SyncChoice, scan, synchronize};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn path(p: &Value, c: &str) -> Result<String> {
|
||||
str_param(p, "path").filter(|d| !d.trim().is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `path`"))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "folder.scanChanges",
|
||||
"Scan Folder for Changes",
|
||||
[],
|
||||
None,
|
||||
"{path, disk?: bool (a whole disk or share, or a folder holding disks)} — compare a folder of the library (and the folders inside it) with the disk; changes nothing → {path, new: [{path, name, format, …}] files the library doesn't have, duplicates: files whose content it already has, unreadable: [{path, error}], missing: [{id, path}] photos whose file is gone, moved: [{id, from, to}] photos whose file is elsewhere in the folder (same content), metadata: [{id, path}] photos whose XMP sidecar was saved after they came in or were last edited here and says something the library doesn't}",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "folder.scanChanges";
|
||||
let path = path(p, C)?;
|
||||
let changes = scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?;
|
||||
let v = serde_json::to_value(&changes).unwrap_or_default();
|
||||
s.folder_changes = Some(changes);
|
||||
Ok(v)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"folder.synchronize",
|
||||
"Synchronize Folder",
|
||||
[],
|
||||
None,
|
||||
"{path, disk?: bool, scanned?: bool, importNew?: true, relinkMoved?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, relink photos whose file was renamed or moved within it, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while it is current (the folder's photos unchanged), else scans again — or, with `scanned: true`, refuses; one undo step, no file is touched → {imported, relinked, removed, read, failed: [[path, error]]}",
|
||||
always,
|
||||
sync
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn sync(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "folder.synchronize";
|
||||
let path = path(p, C)?;
|
||||
let d = SyncChoice::default();
|
||||
let choice = SyncChoice {
|
||||
import_new: bool_or(p, "importNew", d.import_new),
|
||||
relink_moved: bool_or(p, "relinkMoved", d.relink_moved),
|
||||
remove_missing: bool_or(p, "removeMissing", d.remove_missing),
|
||||
read_metadata: bool_or(p, "readMetadata", d.read_metadata),
|
||||
};
|
||||
let changes = match s.take_folder_changes(&path) {
|
||||
Some(c) => c,
|
||||
// the person agreed to what a scan showed: never act on something else
|
||||
None if bool_or(p, "scanned", false) => return Err(bad(C, format!("{path} changed since it was scanned: scan again"))),
|
||||
None => scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?,
|
||||
};
|
||||
let r = synchronize(s, changes, choice)?;
|
||||
Ok(json!({"imported": r.imported, "relinked": r.relinked, "removed": r.removed, "read": r.read, "failed": r.failed}))
|
||||
}
|
||||
Reference in New Issue
Block a user