Merge remote-tracking branch 'upstream/main' into feat/brush-shift-click

# Conflicts:
#	crates/engine/src/cmd/masks.rs
#	crates/ui-egui/src/panels/detail.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Michael Anderson
2026-10-14 23:45:14 -05:00
363 changed files with 48423 additions and 2362 deletions
+77 -4
View File
@@ -6,11 +6,14 @@
//! Each browse records when the folders it listed were browsed. Untouched records of folders
//! not browsed for [`Session::forget_local_days`] are forgotten (when the library opens, or with
//! `library.forgetLocal`); see [`lightcraft_catalog::local`] for what "untouched" means. Files
//! and sidecars on disk are never touched, and browsing the folder again brings them back.
//! and sidecars on disk are never touched, and browsing the folder again brings them back. A
//! browse also forgets at once the untouched records of the folder's files that are gone from
//! the disk (deleted or moved by another app), so the folder shows what is on disk.
use std::path::Path;
use lightcraft_catalog::{Op, PhotoId};
use lightcraft_catalog::query::in_folder;
use lightcraft_catalog::{Op, PhotoId, Source};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
@@ -42,11 +45,73 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
// looking at a folder never brings its deleted photos back (importing the files does)
let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() };
let report = import_with(s, &files, &opts)?;
let forgotten = forget_gone(s, &dir_s, subfolders, &files);
stamp_browsed(s, &dir_s, &files);
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
s.source = LibrarySource::Folder;
s.keep_active_in_view();
let shown = s.visible().len();
Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()}))
Ok(json!({
"path": dir_s,
"subfolders": subfolders,
"photos": shown,
"new": report.imported.len(),
"failed": report.failed.len(),
"forgotten": forgotten,
}))
}
impl Session {
/// Forget the untouched Local records of files gone from the folder `dir` (with its subfolders when
/// `subfolders`) that a browse just listed as `files`: what `library.browse` does after reading a folder, for a
/// UI that reads it on a worker thread instead. Returns how many went.
pub fn forget_gone_from(&mut self, dir: &str, subfolders: bool, files: &[String]) -> usize {
forget_gone(self, dir, subfolders, files)
}
}
/// Local records in the browsed folder whose files are no longer on disk (deleted or moved by
/// another app) and that nothing was changed on are forgotten, as `library.forgetLocal` forgets
/// them: the folder shows what is on disk, not photos that fail to load (and log an error) on
/// every visit (issue #1392). Records the user changed stay, shown as missing, and so do those
/// the session holds (selected, edited, in the undo history) or with a smart preview. Returns how
/// many went.
fn forget_gone(s: &mut Session, dir: &str, subfolders: bool, files: &[String]) -> usize {
let listed: std::collections::HashSet<&str> = files.iter().map(String::as_str).collect();
let held = held(s);
let smart = s.media.smart_dir.clone();
let gone: Vec<PhotoId> = s
.catalog
.photos()
.filter(|p| p.local && !p.deleted && !held.contains(&p.id))
// a smart preview may be all that is left of the picture
.filter(|p| !smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists()))
.filter(|p| match &p.source {
// a file left out of the listing is checked on disk: another spelling of its path, or
// a listing that missed it, keeps it
Source::File { path } => in_folder(path, dir, subfolders) && !listed.contains(path.as_str()) && !Path::new(path).exists(),
Source::Demo { .. } => false,
})
.map(|p| p.id)
.collect();
if gone.is_empty() {
return 0;
}
let mut forgotten = 0;
for chunk in s.catalog.forgettable_local(&gone).chunks(2000) {
let op = Op::Batch { ops: chunk.iter().map(|id| Op::RemovePhoto { id: *id }).collect() };
match s.catalog.apply(op.clone()) {
Ok(_) => {
s.pending_log.push(op);
forgotten += chunk.len();
}
Err(e) => log::warn!("forget Local records of files gone from {dir}: {e}"),
}
}
if forgotten > 0 {
log::info!("forgot {forgotten} untouched Local record(s) of files gone from {dir}");
}
forgotten
}
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
@@ -82,7 +147,15 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
/// Photos the session still needs, whatever their state: shown, selected, being edited, or
/// referred to by an undo / redo step.
fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> {
let mut keep: std::collections::HashSet<PhotoId> = s.visible().iter().copied().collect();
let mut keep = held(s);
keep.extend(s.visible().iter().copied());
keep
}
/// Photos the session holds whatever is shown: selected, being edited, or referred to by an undo
/// / redo step.
fn held(s: &Session) -> std::collections::HashSet<PhotoId> {
let mut keep: std::collections::HashSet<PhotoId> = std::collections::HashSet::new();
keep.extend(s.selection.ids.iter().copied());
keep.extend(s.selection.active);
keep.extend(s.previous_active);
+1 -1
View File
@@ -156,7 +156,7 @@ fn targeted(s: &mut Session, p: &Value) -> Result<Value> {
}
fn index(p: &Value, c: &str) -> Result<usize> {
Ok(f64_req(p, "index", c)? as usize)
super::index_req(p, "index", c)
}
pub fn specs() -> Vec<CommandSpec> {
+42 -22
View File
@@ -35,7 +35,8 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path
Some(r) => r(path)?,
None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?,
};
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?;
let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?;
crate::camera_preview::with_starting_colour(&mut raw, &bytes);
drop(bytes);
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() })
.map_err(|e| e.to_string())?;
@@ -201,12 +202,16 @@ fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts:
/// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or
/// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`].
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`], and a
/// preview-only photo this version hasn't checked yet: its stamp is refreshed so it isn't probed
/// again (see [`lightcraft_catalog::Photo::preview_checked`]).
pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> {
let preview_checked = lightcraft_catalog::preview_stamp(&info.preview_only);
let same = info.content_hash == ph.content_hash
&& info.file_size == ph.file_size
&& (info.width, info.height) == (ph.width, ph.height)
&& info.preview_only == ph.preview_only;
&& info.preview_only == ph.preview_only
&& preview_checked == ph.preview_checked;
(!same).then_some(Op::SetContent {
id,
width: info.width,
@@ -214,6 +219,7 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo
file_size: info.file_size,
content_hash: info.content_hash,
preview_only: info.preview_only,
preview_checked,
})
}
@@ -271,6 +277,37 @@ pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_cat
(ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) })
}
/// The ops that bring photo `id` and its virtual copies up to date with what their file is now
/// (`info`, a fresh probe), appended to `ops`; the photos they change are appended to `changed`.
/// Reload and the preview-only recheck (`library.recheckPreviewOnly`) share it.
pub(crate) fn reload_ops(
cat: &lightcraft_catalog::Catalog,
id: lightcraft_catalog::PhotoId,
info: &crate::media::ProbeInfo,
ops: &mut Vec<Op>,
changed: &mut Vec<lightcraft_catalog::PhotoId>,
) {
let Some(ph) = cat.photo(id) else { return };
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
// nothing already set is overwritten
let meta_ops = fill_missing_meta(id, ph, info);
let mut mine: Vec<Op> =
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, info)).chain(as_shot_wb_op(id, ph, info)).collect();
if !mine.is_empty() {
ops.append(&mut mine);
changed.push(id);
}
// virtual copies share the file
for c in cat.photos().filter(|c| c.copy_of == Some(id)) {
let theirs: Vec<Op> =
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, info)).chain(as_shot_wb_op(c.id, c, info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
changed.push(c.id);
}
}
}
/// 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> {
@@ -286,25 +323,8 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let mut ops = Vec::new();
let mut reloaded = Vec::new();
for ((id, _), info) in paths.into_iter().zip(probed) {
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
// nothing already set is overwritten
let meta_ops = fill_missing_meta(id, ph, &info);
let mut mine: Vec<Op> =
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect();
if !mine.is_empty() {
ops.append(&mut mine);
reloaded.push(id);
}
// virtual copies share the file
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
let theirs: Vec<Op> =
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect();
if !theirs.is_empty() {
ops.extend(theirs);
reloaded.push(c.id);
}
}
let Ok(info) = info else { continue };
reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded);
}
for id in &reloaded {
s.media.forget(*id);
+3 -4
View File
@@ -349,9 +349,8 @@ fn export_presets(s: &mut Session, p: &Value) -> Result<Value> {
if chosen.is_empty() {
return Err(bad(C, "no curve presets to export (save one first, or pass names)"));
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
}
std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
let target = path.to_string_lossy();
s.check_write_target(&target).map_err(|e| bad(C, e))?;
crate::export::write_file_durable(&target, to_file(&chosen).as_bytes()).map_err(crate::EngineError::Other)?;
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
}
+9 -8
View File
@@ -5,7 +5,7 @@ use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, Setti
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, ok, str_param};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, index_req, ok, str_param};
use crate::{Result, Session, media::SourceLevel};
fn active(s: &Session, c: &str) -> Result<PhotoId> {
@@ -144,7 +144,7 @@ fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Pre
/// Change version `p.index` of the active photo (`f` gets it and the current settings).
fn edit_version(s: &mut Session, p: &Value, c: &str, label: &str, f: impl FnOnce(&mut Version, std::sync::Arc<DevelopSettings>)) -> Result<Value> {
let id = active(s, c)?;
let i = f64_req(p, "index", c)? as usize;
let i = index_req(p, "index", c)?;
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
let mut versions = ph.versions.clone();
let v = versions.get_mut(i).ok_or_else(|| bad(c, "no such version"))?;
@@ -588,14 +588,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Straighten",
[],
None,
"{angle: degrees -45..45} — keeps the largest crop of the current aspect",
"{angle: degrees -45..45} — keeps the crop if it still fits, else shrinks it about its centre (the largest crop of its aspect stays the largest)",
has_active,
|s, p| {
let id = active(s, "crop.straighten")?;
let (w, h) = crop_dims(s, id);
let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0);
edit(s, "crop.straighten", "Straighten", |d| {
// keep the current crop if it still fits, else the largest of its aspect that does
// keep the current crop if it still fits, else shrink it about its centre until it does
d.crop.geometry = d.crop.geometry.with_angle(angle, w, h);
Ok(())
})
@@ -678,10 +678,11 @@ pub fn specs() -> Vec<CommandSpec> {
let (w, h) = crop_dims(s, id);
edit(s, "crop.rotateAspect", "Rotate Crop", |d| {
let r = d.crop.geometry.rect_px(w, h);
let a = r.height() / r.width();
// a degenerate crop has no aspect to rotate: fall back to the image's own
let a = Some(r.height() / r.width()).filter(|a| a.is_finite() && *a > 0.0);
d.crop.aspect = d.crop.aspect.map(|(x, y)| (y, x));
let angle = d.crop.geometry.angle;
d.crop.geometry = crop_fit_angle(w, h, angle, Some(a));
d.crop.geometry = crop_fit_angle(w, h, angle, a);
Ok(())
})
}),
@@ -1060,14 +1061,14 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("version.restore", "Restore Version", [], None, "{index}", has_active, |s, p| {
let id = active(s, "version.restore")?;
let i = f64_req(p, "index", "version.restore")? as usize;
let i = index_req(p, "index", "version.restore")?;
let v = s.catalog.photo(id).and_then(|ph| ph.versions.get(i).cloned()).ok_or_else(|| bad("version.restore", "no such version"))?;
s.set_develop(id, (*v.settings).clone(), &format!("Restore {}", v.name))?;
ok()
}),
cmd!("version.delete", "Delete Version", [], None, "{index}", has_active, |s, p| {
let id = active(s, "version.delete")?;
let i = f64_req(p, "index", "version.delete")? as usize;
let i = index_req(p, "index", "version.delete")?;
let mut versions = s.catalog.photo(id).map(|ph| ph.versions.clone()).unwrap_or_default();
if i >= versions.len() {
return Err(bad("version.delete", "no such version"));
+142 -6
View File
@@ -1,10 +1,11 @@
//! Recognition commands: `faces.index` (embed the faces in the library), `faces.suggest` (who might an unnamed
//! face be?), `faces.person` (one person's faces, and the unnamed faces that look like them), `faces.unnamed` (every
//! unnamed face, look-alikes together) and `faces.setName` / `faces.nameFaces` (name one face, or many at once, which is
//! what teaches the suggestions).
//! what teaches the suggestions). `faces.renamePerson` renames every in-library face of one person, or merges two
//! people when asked.
//!
//! Everything here is a suggestion for the user to confirm; nothing is ever named automatically. All of it is
//! catalog-only (no sidecar is written) and `faces.setName` is undoable.
//! catalog-only (no sidecar is written) and naming is undoable.
use lightcraft_catalog::Op;
use serde_json::{Value, json};
@@ -92,6 +93,92 @@ fn set_name(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": id.0, "index": index, "name": name}))
}
/// `faces.renamePerson {from, to, merge?}`: give every in-library face of `from` the exact name `to`, as one undo
/// step. Names match the People view (trimmed, case-insensitive). A `to` that is already someone else is refused
/// unless `merge` is set, and then the two people become one. The sidecar is not touched, and a refusal changes nothing.
fn rename_person(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "faces.renamePerson";
let from = match p.get("from") {
Some(Value::String(v)) => {
let t = v.trim();
if t.is_empty() {
return Err(bad(C, "missing `from`"));
}
t.to_string()
}
Some(_) => return Err(bad(C, "`from` must be text")),
None => return Err(bad(C, "missing `from`")),
};
let to = match p.get("to") {
Some(Value::String(v)) => {
let t = v.trim();
if t.is_empty() {
return Err(bad(C, "`to` must be a name"));
}
if t.chars().count() > 200 || t.chars().any(char::is_control) {
return Err(bad(C, "a name is at most 200 characters, without control characters"));
}
t.to_string()
}
Some(_) => return Err(bad(C, "`to` must be text")),
None => return Err(bad(C, "missing `to`")),
};
let merge = super::bool_or(p, "merge", false);
let from_key = from.to_lowercase();
let to_key = to.to_lowercase();
let mut matched = 0usize;
let mut other = false;
let mut faces = 0usize;
let mut ops = Vec::new();
let ids: Vec<_> = s.catalog.photos().filter(|ph| ph.in_library()).map(|ph| ph.id).collect();
for id in ids {
let Some(ph) = s.catalog.photo(id) else { continue };
let mut indexes = Vec::new();
for (i, r) in ph.meta.regions.iter().enumerate() {
if r.kind != lightcraft_meta::RegionKind::Face {
continue;
}
let Some(n) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
let key = n.to_lowercase();
if key == from_key {
matched += 1;
if r.name.as_deref() != Some(to.as_str()) {
indexes.push(i);
}
} else if key == to_key {
other = true;
}
}
if indexes.is_empty() {
continue;
}
let mut meta = ph.meta.clone();
for i in indexes {
if let Some(region) = meta.regions.get_mut(i) {
region.name = Some(to.clone());
faces += 1;
}
}
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
}
if matched == 0 {
return Err(bad(C, format!("no person named {from}")));
}
if faces == 0 {
return Err(bad(C, format!("{to} is already their name")));
}
if other && !merge {
return Err(bad(C, format!("{to} is already another person; pass merge: true to combine them")));
}
let photos = ops.len();
s.commit(if other { "Merge People" } else { "Rename Person" }, Op::Batch { ops })?;
s.skip_auto_write = true;
if s.filter.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key) {
s.filter.person = Some(to.clone());
}
Ok(json!({"faces": faces, "photos": photos, "from": from, "to": to, "merged": other}))
}
/// Most faces `faces.unnamed` lists.
const MAX_UNNAMED: usize = 3000;
@@ -275,7 +362,7 @@ mod imp {
}
let job = s.preview_job(id, EDGE, EDGE, false, &DevelopSettings::default())?;
let preview = s.scan_preview_of(&photo);
Some(Prepared { tag, epoch: s.faces.epoch, id, job, preview, todo, detect, detector })
Some(Prepared { tag, epoch: s.faces.epoch, id, content: crate::media::content_key(&photo), job, preview, todo, detect, detector })
}
/// The faces the detector found in a photo that had none become its face regions: unnamed, marked as detected (so a
@@ -299,13 +386,16 @@ mod imp {
}
}
/// Take a finished photo into the index (ignored when it was made for another model or another library). Returns
/// (embedded, failed).
/// Take a finished photo into the index (ignored when it was made for another model or another library, or from
/// a file the photo no longer has: issue #1524). Returns (embedded, failed).
fn ingest(s: &mut Session, d: Done) -> (usize, usize) {
s.faces.in_flight.remove(&d.id);
if d.epoch != s.faces.epoch || d.tag != s.faces.index.tag {
return (0, 0);
}
if s.catalog.photo(d.id).is_none_or(|p| crate::media::content_key(p) != d.content) {
return (0, 0);
}
if d.detect_wanted {
match &d.found {
Some(found) => {
@@ -830,7 +920,8 @@ mod imp {
}
fn finished(s: &Session, id: PhotoId, found: Option<Vec<Rect>>, results: Vec<(Rect, Option<Vec<f32>>, Rect)>) -> Done {
Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, results, detect_wanted: true, found }
let content = s.catalog.photo(id).map(|p| crate::media::content_key(p)).unwrap_or_default();
Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, content, results, detect_wanted: true, found }
}
#[test]
@@ -915,6 +1006,42 @@ mod imp {
let _ = std::fs::remove_dir_all(&d);
}
/// A Reload that finds the file replaced (an outside edit) gives the photo other pixels: what a worker made from
/// the old file is dropped when it comes back, the faces already embedded from it are forgotten and the photo is
/// searched again (issue #1524).
#[test]
fn faces_made_from_a_replaced_file_are_dropped() {
let (mut s, d) = loaded("replaced");
let (a, b) = two_photos(&s);
let rect = region(0.2, None).rect;
s.faces.index.insert(a.0, &rect, unit(&[(0, 1.0)]));
s.faces.scanned.insert(a.0);
s.faces.index.insert(b.0, &rect, unit(&[(1, 1.0)]));
s.faces.scanned.insert(b.0);
let old = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(2, 1.0)])), rect)]);
let (width, height) = s.catalog.photo(a).map(|p| (p.width, p.height)).unwrap();
let replace = Op::SetContent {
id: a,
width,
height,
file_size: 1234,
content_hash: Some("edited".into()),
preview_only: None,
preview_checked: String::new(),
};
s.commit("Reload Changed Files", replace).unwrap();
assert!(s.faces.index.get(a.0, &rect).is_none() && !s.faces.scanned.contains(a.0), "the old file's faces are forgotten");
assert!(needs_search(&s, s.catalog.photo(a).unwrap()), "and the photo is searched again");
assert!(s.faces.index.get(b.0, &rect).is_some() && s.faces.scanned.contains(b.0), "other photos keep theirs");
assert_eq!(ingest(&mut s, old), (0, 0), "a result made from the old file is dropped");
assert!(s.catalog.photo(a).unwrap().meta.regions.is_empty() && !s.faces.scanned.contains(a.0));
// what is made from the new file is taken in
let new = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(3, 1.0)])), rect)]);
assert_eq!(ingest(&mut s, new), (1, 0));
assert!(s.faces.scanned.contains(a.0) && s.faces.index.get(a.0, &rect).is_some());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_unnamed_faces_that_look_like_a_person_come_best_first() {
let (mut s, d) = loaded("similar");
@@ -1047,5 +1174,14 @@ pub fn specs() -> Vec<CommandSpec> {
super::always,
set_name
),
cmd!(
"faces.renamePerson",
"Rename Person",
[],
None,
"{from, to, merge?: bool} → {faces, photos, from, to, merged} — give every in-library face of `from` the name `to` (trimmed, any capitalization), one undo step; refused when `to` is already someone else unless `merge` is true, which combines the two; the sidecar is not touched",
super::always,
rename_person
),
]
}
+149 -19
View File
@@ -167,6 +167,19 @@ fn album_targets_or_err(s: &Session, command: &str, targets: Vec<PhotoId>) -> Re
Ok((kept, skipped))
}
/// Move to Recently Deleted and drop the photos from stacks so leftover members do not keep
/// showing `i/n` (issue #1493).
fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result<usize> {
let mut ops: Vec<Op> = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect();
let n = ops.len();
if n == 0 {
return Ok(0);
}
ops.extend(s.catalog.remove_from_stacks_ops(ids));
s.commit(label, Op::Batch { ops })?;
Ok(n)
}
/// Apply one op per target as a single undo step.
fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> {
let targets = s.targets(p);
@@ -196,6 +209,25 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<V
Ok(json!({"changed": n}))
}
/// Move each target's own flag by `step`: cycling wraps unflagged -> pick -> reject -> unflagged,
/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step.
fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> 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))?.flag;
let flag = step(old);
if flag != old {
ops.push(Op::SetFlag { id, flag });
}
}
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);
@@ -232,6 +264,10 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
if let Some(d) = s.develop_of(id) {
let mut d = (*d).clone();
d.orientation = d.orientation.rotated(cw);
// the crop lives in the oriented frame, so it turns with the image (else it keeps the old width and height and cuts the photo)
let turn = lightcraft_geom::Orientation::Normal.rotated(cw);
d.crop.geometry.rect = turn.map_norm_rect(d.crop.geometry.rect);
d.crop.aspect = d.crop.aspect.map(|(w, h)| (h, w));
ops.extend(s.develop_op(id, d, "Rotate"));
}
}
@@ -362,11 +398,8 @@ pub fn specs() -> Vec<CommandSpec> {
}
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
};
let vis = s.visible_cloned();
if s.selection.active.is_none_or(|a| !vis.contains(&a)) {
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
}
Ok(json!({"count": vis.len()}))
s.keep_active_in_view();
Ok(json!({"count": s.visible().len()}))
}
),
cmd!(
@@ -436,8 +469,7 @@ pub fn specs() -> Vec<CommandSpec> {
if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) {
return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them")));
}
let ops = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect();
s.commit("Remove Folder from Library", Op::Batch { ops })?;
mark_deleted(s, &ids, "Remove Folder from Library")?;
if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {
s.filter.library_folder = None;
}
@@ -510,6 +542,44 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}
),
cmd!(
"library.refresh",
"Refresh View",
["View"],
None,
"{} — show what matches now: a view that holds its photos (`library.holdView`) lets go of the ones that stopped matching a smart album's rules, Picks or the filter bar since it was opened, sorted or last refreshed",
always,
|s, _| {
s.refresh_view();
ok()
}
),
cmd!(
"library.holdView",
"Hold View Until Refreshed",
["View"],
None,
"{on?: bool (toggles when omitted)} → {hold} — when on, an edit that makes a photo stop matching the view (a keyword given in a smart album of photos without keywords, a flag taken off in Picks, a rating under the filter bar's) keeps it in the view until `library.refresh`, Reshuffle, or another source, filter or sort; photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder shown still takes it out of the view. Changing a smart album's rules is another view too. Off (the default): the view follows every edit. Kept with the library's preferences; asking for the state it already has changes nothing",
always,
|s, p| {
let on = match p.get("on") {
None | Some(Value::Null) => !s.hold_view,
Some(Value::Bool(on)) => *on,
Some(_) => return Err(bad("library.holdView", "on must be true or false")),
};
if on != s.hold_view {
let was = s.hold_view;
s.hold_view = on;
if let Err(e) = s.save_prefs() {
// not kept: not changed either, so asking again does what was asked
s.hold_view = was;
return Err(e);
}
s.refresh_view();
}
Ok(json!({"hold": s.hold_view}))
}
),
cmd!(
"library.shuffle",
"Reshuffle",
@@ -687,6 +757,40 @@ pub fn specs() -> Vec<CommandSpec> {
advance_if(s, p);
Ok(v)
}),
// Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux)
cmd!(
"photo.cycleFlag",
"Cycle Flag",
["Photo", "Set Flag"],
Some("`"),
"{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged",
has_selection,
|s, p| {
change_flag(s, p, "Cycle Flag", |f| match f {
Flag::None => Flag::Pick,
Flag::Pick => Flag::Reject,
Flag::Reject => Flag::None,
})
}
),
cmd!(
"photo.increaseFlag",
"Increase Flag Status",
["Photo", "Set Flag"],
Some("Cmd+Up"),
"{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick)",
has_selection,
|s, p| { change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick }) }
),
cmd!(
"photo.decreaseFlag",
"Decrease Flag Status",
["Photo", "Set Flag"],
Some("Cmd+Down"),
"{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject)",
has_selection,
|s, p| { change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject }) }
),
cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| {
for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))
}),
@@ -779,9 +883,10 @@ 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 targets = s.targets(p);
let n = mark_deleted(s, &targets, "Delete")?;
select_after_deletion(s, &before);
Ok(v)
Ok(json!({"changed": n}))
}),
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
id,
@@ -1334,17 +1439,38 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(result)
}
),
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
let id = album_param(p, "id", "album.removePhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
if al.is_smart() {
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
cmd!(
"album.removePhotos",
"Remove from Album",
["Photo"],
None,
"{id?: albumId (default: the album on screen), ids?} — takes the photos out of the album only: they stay in the library and in their folders (issue #1805; photo.delete removes them from the library) → {removed, album: name}",
has_selection,
|s, p| {
let id = match p.get("id") {
Some(_) => album_param(p, "id", "album.removePhotos")?,
None => match s.source {
LibrarySource::Album(a) => a,
_ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")),
},
};
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
if al.is_smart() {
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
}
if al.folder {
return Err(bad("album.removePhotos", "album folders don't hold photos"));
}
let name = al.name.clone();
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
let removed = al.photos.len().saturating_sub(photos.len());
if removed > 0 {
s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?;
}
Ok(json!({"removed": removed, "album": name}))
}
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?;
ok()
}),
),
cmd!("album.setCover", "Set as Album Cover", [], None, "{id: albumId, photo?: photoId}", has_active, |s, p| {
let id = album_param(p, "id", "album.setCover")?;
let photo = p.get("photo").and_then(Value::as_u64).map(PhotoId).or(s.active());
@@ -1623,3 +1749,7 @@ mod gps_tests {
#[cfg(test)]
#[path = "library/tests_relative_ratings.rs"]
mod relative_rating_tests;
#[cfg(test)]
#[path = "library/tests_flag_cycle.rs"]
mod flag_cycle_tests;
@@ -0,0 +1,100 @@
//! `photo.cycleFlag` / `photo.increaseFlag` / `photo.decreaseFlag` through the real engine.
use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::{Result, Selection, Session};
fn session() -> Result<Session> {
let mut s = Session::new();
for (id, flag) in [(1, Flag::None), (2, Flag::Pick), (3, Flag::Reject), (4, Flag::None)] {
let mut photo = Photo::new(
PhotoId(id),
Source::File { path: format!("/lightcraft-flag-cycle/{id}.jpg") },
&format!("{id}.jpg"),
"JPEG",
40,
30,
"2026-10-13",
);
photo.flag = flag;
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 flags(s: &Session) -> Vec<Flag> {
(1..=4).map(|id| s.catalog.photo(PhotoId(id)).unwrap().flag).collect()
}
#[test]
fn cycle_steps_unflagged_pick_reject_and_wraps() {
let mut s = session().unwrap();
s.execute("library.select", &json!({"ids": [1]})).unwrap();
let seen: Vec<Flag> = (0..4)
.map(|_| {
s.execute("photo.cycleFlag", &json!({})).unwrap();
flags(&s)[0]
})
.collect();
assert_eq!(seen, [Flag::Pick, Flag::Reject, Flag::None, Flag::Pick]);
}
#[test]
fn cycle_uses_each_photos_own_flag_in_one_undo_step() {
let mut s = session().unwrap();
let undo = s.undo.len();
assert_eq!(s.execute("photo.cycleFlag", &json!({})).unwrap()["changed"], 3);
assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]);
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.active(), Some(PhotoId(2)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::None]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]);
}
#[test]
fn explicit_ids_active_fallback_advance_and_errors() {
let mut s = session().unwrap();
s.execute("photo.cycleFlag", &json!({"ids": [4]})).unwrap();
assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::Pick]);
s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) };
s.execute("photo.cycleFlag", &json!({})).unwrap();
assert_eq!(flags(&s)[1], Flag::Reject);
s.execute("photo.cycleFlag", &json!({"advance": true})).unwrap();
assert_eq!(flags(&s)[1], Flag::None);
assert_eq!(s.active(), Some(PhotoId(3)));
let undo = s.undo.len();
assert!(s.execute("photo.cycleFlag", &json!({"ids": [1, 999]})).is_err());
assert_eq!(flags(&s)[0], Flag::None);
assert_eq!(s.undo.len(), undo);
s.selection = Selection::default();
assert!(s.execute("photo.cycleFlag", &json!({})).is_err());
assert_eq!(s.undo.len(), undo);
}
#[test]
fn increase_and_decrease_clamp_without_wrapping() {
let mut s = session().unwrap();
assert_eq!(s.execute("photo.increaseFlag", &json!({})).unwrap()["changed"], 2);
assert_eq!(flags(&s), [Flag::Pick, Flag::Pick, Flag::None, Flag::None]);
assert_eq!(s.execute("photo.increaseFlag", &json!({"ids": [2]})).unwrap()["changed"], 0);
s.execute("photo.decreaseFlag", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::None, Flag::None, Flag::Reject, Flag::None]);
s.execute("photo.decreaseFlag", &json!({})).unwrap();
assert_eq!(flags(&s), [Flag::Reject, Flag::Reject, Flag::Reject, Flag::None]);
let undo = s.undo.len();
assert_eq!(s.execute("photo.decreaseFlag", &json!({})).unwrap()["changed"], 0);
assert_eq!(s.undo.len(), undo, "no change, no history step");
}
#[test]
fn registered_with_classic_shortcuts() {
let specs = crate::command_specs();
let key = |id: &str| specs.iter().find(|c| c.id == id).and_then(|c| c.shortcut);
assert_eq!(key("photo.cycleFlag"), Some("`"));
assert_eq!(key("photo.increaseFlag"), Some("Cmd+Up"));
assert_eq!(key("photo.decreaseFlag"), Some("Cmd+Down"));
}
+681
View File
@@ -0,0 +1,681 @@
//! Mask effect preset files: Lightroom Classic local adjustment presets (`.lrtemplate`, Lua,
//! `type = "LocalizedAdjustmentPreset"`), Camera Raw local correction presets (`.xmp` with
//! `crs:Local*` fields, from its `LocalCorrections` folder), our `.lceffect` JSON, and `.zip`
//! bundles of these. Folders, and Lightroom users' "divider" presets (`10 ----- DETAIL ----- 10`,
//! empty presets that head a numbered section of the menu), become groups.
//!
//! Read from the files' public structure and black-box observation; settings we have no slider
//! for are reported and kept with the effect (an export carries them).
use lightcraft_develop::LocalAdjustments;
use serde_json::{Map, Value, json};
use super::mask_effects::{MaskEffect, upsert};
use super::{bad, str_param};
use crate::preset_import::{Lua, group_from_dir, parse_lua, read_zip};
use crate::{Result, Session};
/// File extension of exported effect presets (JSON).
pub const EFFECT_EXT: &str = "lceffect";
/// `format` tag of an exported effect-preset file.
const FILE_FORMAT: &str = "lightcraft.maskEffects";
/// The Lua `type` of a Lightroom local adjustment preset.
const LR_LOCAL_TYPE: &str = "LocalizedAdjustmentPreset";
/// Larger files aren't presets (or bundles of them).
const MAX_FILE: u64 = 64 << 20;
/// How deep nested Lua tables are kept as JSON.
const MAX_DEPTH: usize = 16;
/// An effect read from a file, with the settings it couldn't keep and where it came from.
#[derive(Clone, Debug)]
pub struct Found {
pub effect: MaskEffect,
pub unmapped: Vec<String>,
pub file: String,
/// Effects of one folder (or folder inside a bundle) share this key: divider presets group
/// the effects next to them.
pub dir: String,
/// The group the folder gives (when the file names none).
pub dir_group: Option<String>,
/// The file named its group (`.lceffect`, `crs:Group`): kept.
pub has_group: bool,
}
/// A readable name for a Lightroom local setting we can't carry over.
fn setting_label(key: &str) -> String {
let k = key.trim_start_matches("Local");
let lower = k.to_ascii_lowercase();
match lower.as_str() {
"grain" => "Grain".into(),
"toningluminance" => "Color luminance".into(),
"pointcolors" => "Point Color".into(),
"refinesaturation" => "Refine Saturation".into(),
"curverefinesaturation" => "Refine Saturation".into(),
"maincurve" | "redcurve" | "greencurve" | "bluecurve" => "Curve".into(),
_ => k.to_string(),
}
}
/// A Lua value as JSON (tables nested at most [`MAX_DEPTH`] deep).
fn lua_json(v: &Lua, depth: usize) -> Value {
match v {
Lua::Nil => Value::Null,
Lua::Bool(b) => json!(b),
Lua::Num(n) => serde_json::Number::from_f64(*n).map(Value::Number).unwrap_or(Value::Null),
Lua::Str(s) => json!(s),
Lua::Table(..) if depth >= MAX_DEPTH => Value::Null,
Lua::Table(items, fields) if fields.is_empty() => Value::Array(items.iter().map(|x| lua_json(x, depth + 1)).collect()),
Lua::Table(_, fields) => Value::Object(fields.iter().map(|(k, x)| (k.clone(), lua_json(x, depth + 1))).collect()),
}
}
/// Does a setting value do anything (a non-zero number, a non-empty table, …)?
fn meaningful(v: &Value) -> bool {
match v {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().is_some_and(|x| x.abs() > 1e-9),
Value::String(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
}
}
/// Is `text` a Lightroom local adjustment preset (`.lrtemplate`)?
pub fn is_lr_local_template(text: &str) -> bool {
parse_lua(text.trim_start_matches('\u{feff}'))
.ok()
.and_then(|root| root.get("type").and_then(Lua::str).map(|t| t == LR_LOCAL_TYPE))
.unwrap_or(false)
}
/// `$$$/Path/Key=Default` (a localisable string) → `Default`.
fn delocalize(t: &str) -> &str {
t.strip_prefix("$$$/").and_then(|r| r.split_once('=')).map_or(t, |(_, d)| d)
}
/// A Lightroom local adjustment preset → an effect named after its title (else `stem`), and the
/// settings we have no equivalent for (e.g. `Grain`, `Point Color`), which the effect keeps in
/// `extra`.
pub fn parse_lr_local_template(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> {
let root = parse_lua(text.trim_start_matches('\u{feff}'))?;
if root.get("type").and_then(Lua::str) != Some(LR_LOCAL_TYPE) {
return Err("not a local adjustment preset".into());
}
let Some(Lua::Table(_, fields)) = root.get("value") else { return Err("no adjustments in this local preset".into()) };
// `exposure2012` → `LocalExposure2012`: the template's keys are the XMP `Local*` names, lower camel case
let crs = |k: &str| {
let mut c = k.chars();
c.next().map(|f| format!("Local{}{}", f.to_ascii_uppercase(), c.as_str())).unwrap_or_default()
};
let num = |k: &str| fields.iter().find(|(f, _)| crs(f) == k).and_then(|(_, v)| if let Lua::Num(n) = v { Some(*n) } else { None });
let values = crate::crs_masks::local_values(num);
let mut extra = Map::new();
for (k, v) in fields {
if crate::crs_masks::is_local_field(&crs(k)) {
continue;
}
let j = lua_json(v, 0);
if meaningful(&j) {
extra.insert(k.clone(), j);
}
}
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(str::trim).filter(|t| !t.is_empty());
let name = title.map(|t| delocalize(t).trim().to_string()).filter(|t| !t.is_empty()).unwrap_or_else(|| stem.to_string());
build(name, values, extra)
}
fn build(name: String, values: Map<String, Value>, extra: Map<String, Value>) -> std::result::Result<(MaskEffect, Vec<String>), String> {
let adjust: LocalAdjustments = serde_json::from_value(Value::Object(values)).map_err(|e| format!("unreadable local preset: {e}"))?;
let mut unmapped: Vec<String> = extra.keys().map(|k| setting_label(k)).collect();
unmapped.sort();
unmapped.dedup();
let effect = MaskEffect { name, adjust, extra, ..Default::default() }.normalized();
Ok((effect, unmapped))
}
/// The local slider fields of a Camera Raw local correction preset: its top-level `crs:Local*`
/// properties, or (a preset typed `Local` without them) its first correction's.
fn xmp_local_fields(d: &lightcraft_meta::XmpData) -> Option<Map<String, Value>> {
let mut out = Map::new();
for (k, v) in &d.properties {
if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local") && !n.contains('/'))
&& let Some(x) = v.first()
{
out.insert(name.to_string(), json!(x.trim()));
}
}
if !out.is_empty() {
return Some(out);
}
let typed_local = d.properties.get("crs:PresetType").and_then(|v| v.first()).is_some_and(|t| t.trim().eq_ignore_ascii_case("local"));
if !typed_local {
return None;
}
let correction = crate::crs_masks::CONTAINERS.iter().find_map(|c| d.values.get(*c).and_then(|l| l.items().first()))?;
let lightcraft_meta::XmpValue::Struct(m) = correction else { return None };
for (k, v) in m {
if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local"))
&& let Some(x) = v.text()
{
out.insert(name.to_string(), json!(x));
}
}
Some(out)
}
/// Is `text` a Camera Raw local correction preset (XMP)?
pub fn is_local_xmp(text: &str) -> bool {
lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).ok().and_then(|d| xmp_local_fields(&d)).is_some()
}
/// A Camera Raw local correction preset (XMP) → an effect named after `crs:Name` (else `stem`),
/// in `crs:Group` if it names one.
pub fn parse_local_xmp(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> {
let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?;
let fields = xmp_local_fields(&d).ok_or("not a local correction preset (no crs:Local… settings)")?;
let num = |v: &Value| v.as_str().map(|s| s.strip_prefix('+').unwrap_or(s)).and_then(|s| s.parse::<f64>().ok()).filter(|x| x.is_finite());
let values = crate::crs_masks::local_values(|k| fields.get(k).and_then(num));
let mut extra = Map::new();
for (k, v) in &fields {
if crate::crs_masks::is_local_field(k) {
continue;
}
let v = num(v).map_or_else(|| v.clone(), |x| json!(x));
if meaningful(&v) {
extra.insert(k.clone(), v);
}
}
let first = |k: &str| d.properties.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let name = first("crs:Name").unwrap_or_else(|| stem.to_string());
let (mut e, unmapped) = build(name, values, extra)?;
e.group = first("crs:Group").unwrap_or_default();
Ok((e, unmapped))
}
/// Effects in an `.lceffect` file: `{format, version, effects: [...]}`, a bare array, or one effect.
pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<MaskEffect>, String> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not an effect preset file: {e}"))?;
let items = match v {
Value::Object(mut o) if o.contains_key("effects") => o.remove("effects").unwrap_or_default(),
v @ Value::Array(_) => v,
v @ Value::Object(_) => Value::Array(vec![v]),
_ => return Err("not an effect preset file".into()),
};
let list: Vec<MaskEffect> = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?;
let list: Vec<MaskEffect> = list.into_iter().filter(|e| !e.name.trim().is_empty()).map(MaskEffect::normalized).collect();
if list.is_empty() {
return Err("no effect presets in the file".into());
}
Ok(list)
}
fn ext_of(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
}
fn stem_of(name: &str) -> String {
std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "Effect".into())
}
type Parsed = std::result::Result<Vec<(MaskEffect, Vec<String>)>, String>;
/// The effects in file `name` (`bytes`) if it is a local preset (`.lrtemplate` of the local type,
/// a local correction `.xmp`, an `.lceffect`); `None` for any other file (a develop preset…).
pub fn parse_bytes(name: &str, bytes: &[u8]) -> Option<Parsed> {
let stem = stem_of(name);
match ext_of(name).as_str() {
"lrtemplate" => {
let text = String::from_utf8_lossy(bytes);
is_lr_local_template(&text).then(|| parse_lr_local_template(&text, &stem).map(|e| vec![e]))
}
"xmp" => {
let text = String::from_utf8_lossy(bytes);
is_local_xmp(&text).then(|| parse_local_xmp(&text, &stem).map(|e| vec![e]))
}
EFFECT_EXT => Some(parse_file(bytes).map(|v| v.into_iter().map(|e| (e, Vec::new())).collect())),
_ => None,
}
}
/// The group effects in folder `dir` (relative to the imported folder) go to.
pub fn group_for_dir(dir: &str) -> Option<String> {
effect_group_from_dir(dir)
}
/// Folder names that only say "local presets" (`Local Adjustment Presets`, `LocalCorrections`…)
/// give no group.
fn effect_group_from_dir(dir: &str) -> Option<String> {
const FILLER: &[&str] = &[
"local",
"adjustment",
"adjustments",
"localcorrections",
"corrections",
"correction",
"brush",
"brushes",
"mask",
"masks",
"effect",
"effects",
];
let generic = |part: &str| {
let lower = part.to_ascii_lowercase();
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
words.iter().any(|w| FILLER.contains(w))
&& words.iter().all(|w| FILLER.contains(w) || matches!(*w, "presets" | "preset" | "lightroom" | "camera" | "raw" | "classic"))
};
let kept: Vec<&str> = dir.split(['/', '\\']).filter(|p| !p.is_empty() && !generic(p)).collect();
group_from_dir(&kept.join("/"))
}
/// Effects found in `bytes` of file `file` (a `.zip` is opened: the local presets inside).
/// `dir_group`: the group the file's folder gives. Errors are `(file, message)`.
pub fn found_in(file: &str, bytes: &[u8], dir_group: Option<String>, out: &mut Vec<Found>, failed: &mut Vec<(String, String)>) -> bool {
let dir = std::path::Path::new(file).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
if ext_of(file) == "zip" {
let Ok(entries) = read_zip(bytes) else { return false };
let zip_group = effect_group_from_dir(&stem_of(file)).or(dir_group);
let mut any = false;
for (inner, data) in entries {
let inner_dir = inner.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or_default();
let group = effect_group_from_dir(&inner_dir).or_else(|| zip_group.clone());
let label = format!("{file}/{inner}");
match parse_bytes(&inner, &data) {
Some(Ok(list)) => {
any = true;
push(out, list, &label, format!("{file}/{inner_dir}"), group, &inner);
}
Some(Err(e)) => {
any = true;
failed.push((label, e));
}
None => {}
}
}
return any;
}
match parse_bytes(file, bytes) {
Some(Ok(list)) => {
push(out, list, file, dir, dir_group, file);
true
}
Some(Err(e)) => {
failed.push((file.to_string(), e));
true
}
None => false,
}
}
fn push(out: &mut Vec<Found>, list: Vec<(MaskEffect, Vec<String>)>, file: &str, dir: String, dir_group: Option<String>, name: &str) {
let named = ext_of(name) == EFFECT_EXT;
for (effect, unmapped) in list {
let has_group = named || !effect.group.is_empty();
out.push(Found { effect, unmapped, file: file.to_string(), dir: dir.clone(), dir_group: dir_group.clone(), has_group });
}
}
/// The number a preset name starts with (`24a-Eye` → 24).
fn leading_number(name: &str) -> Option<u64> {
let digits: String = name.trim().chars().take_while(char::is_ascii_digit).take(18).collect();
digits.parse().ok()
}
/// The section title of a divider preset — an effect that sets nothing, named like
/// `10 ----- DETAIL ENHANCERS ----- 10` (a run of at least three `-`, `=`, `_`, `*`, `#`, `~`, `—`).
pub fn divider_title(e: &MaskEffect) -> Option<String> {
if !e.is_empty() {
return None;
}
let marks = ['-', '=', '_', '*', '#', '~', '—', '–', '•', '.'];
let chars: Vec<char> = e.name.chars().collect();
let has_run = chars.windows(3).any(|w| marks.contains(&w[0]) && w[0] == w[1] && w[1] == w[2]);
if !has_run {
return None;
}
let title: String = e.name.chars().map(|c| if marks.contains(&c) { ' ' } else { c }).collect();
let title = title.trim().trim_start_matches(|c: char| c.is_ascii_digit() || c.is_whitespace());
let title = title.trim_end_matches(|c: char| c.is_ascii_digit() || c.is_whitespace());
let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
if !title.chars().any(char::is_alphanumeric) {
return None;
}
// "DETAIL ENHANCERS" → "Detail Enhancers"
let shouting = title.chars().any(char::is_alphabetic) && !title.chars().any(char::is_lowercase);
Some(if shouting {
title
.split(' ')
.map(|w| {
let mut c = w.chars();
c.next().map(|f| f.to_uppercase().chain(c.flat_map(char::to_lowercase)).collect::<String>()).unwrap_or_default()
})
.collect::<Vec<_>>()
.join(" ")
} else {
title
})
}
/// Give `found` their groups; returns the divider presets (which become groups, not effects) as
/// `(found, group)`. A numbered effect goes to the divider with the largest number not above its
/// own (`12 Sky` under `10 --- DETAIL --- 10`); others to their folder's group.
pub fn assign_groups(found: Vec<Found>, group: Option<&str>) -> (Vec<Found>, Vec<(Found, String)>) {
let (dividers, mut effects): (Vec<(Found, Option<String>)>, Vec<(Found, Option<String>)>) =
found.into_iter().map(|f| (divider_title(&f.effect).filter(|_| !f.has_group), f)).map(|(t, f)| (f, t)).partition(|(_, t)| t.is_some());
let dividers: Vec<(Found, String)> = dividers.into_iter().filter_map(|(f, t)| t.map(|t| (f, t))).collect();
for (f, _) in &mut effects {
if let Some(g) = group {
f.effect.group = g.to_string();
continue;
}
if f.has_group {
continue;
}
let section = leading_number(&f.effect.name).and_then(|n| {
dividers
.iter()
.filter(|(d, _)| d.dir == f.dir)
.filter_map(|(d, t)| leading_number(&d.effect.name).filter(|m| *m <= n).map(|m| (m, t)))
.max_by_key(|(m, _)| *m)
.map(|(_, t)| t.clone())
});
f.effect.group = section.or_else(|| f.dir_group.clone()).unwrap_or_default();
}
(effects.into_iter().map(|(f, _)| f).collect(), dividers)
}
/// Add the found effects (grouped; same-named ones replaced; effects left from an earlier import
/// of a divider dropped) and save → the report rows `(imported, dividers)`.
pub fn add_found(s: &mut Session, found: Vec<Found>, group: Option<&str>, dry: bool) -> Result<(Vec<Value>, Vec<Value>)> {
let (effects, dividers) = assign_groups(found, group);
let rows: Vec<Value> =
effects.iter().map(|f| json!({"name": f.effect.name, "group": f.effect.group, "file": f.file, "unmapped": f.unmapped})).collect();
let div_rows: Vec<Value> = dividers.iter().map(|(f, g)| json!({"name": f.effect.name, "group": g, "file": f.file})).collect();
if dry || (effects.is_empty() && dividers.is_empty()) {
return Ok((rows, div_rows));
}
s.mask_effects.retain(|e| !(e.is_empty() && dividers.iter().any(|(d, _)| d.effect.name.eq_ignore_ascii_case(&e.name))));
for f in effects {
upsert(s, f.effect);
}
s.save_prefs()?;
Ok((rows, div_rows))
}
/// The files under `paths` an effect import reads (folders walked, at most 8 levels), each with
/// the group its folder gives and whether it was named (not found in a folder).
fn effect_files(paths: &[String]) -> Vec<(String, Option<String>, bool)> {
fn walk(dir: &std::path::Path, base: &std::path::Path, depth: usize, out: &mut Vec<(String, Option<String>, bool)>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
for p in entries {
if p.is_dir() {
if depth < 8 {
walk(&p, base, depth + 1, out);
}
} else if p.extension().is_some_and(|e| ["lrtemplate", EFFECT_EXT, "xmp", "zip"].iter().any(|x| e.eq_ignore_ascii_case(x))) {
let rel = p.parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
out.push((p.to_string_lossy().to_string(), rel.and_then(|d| effect_group_from_dir(&d)), false));
}
}
}
let mut out = Vec::new();
for x in paths {
let p = std::path::Path::new(x);
if p.is_dir() {
walk(p, p.parent().unwrap_or(std::path::Path::new("")), 0, &mut out);
} else {
out.push((x.clone(), None, true));
}
}
out
}
/// Read a file of at most [`MAX_FILE`] bytes.
pub fn read_capped(path: &str) -> std::result::Result<Vec<u8>, String> {
let len = std::fs::metadata(path).map_err(|e| e.to_string())?.len();
if len > MAX_FILE {
return Err(format!("too large for a preset file ({} MB)", len >> 20));
}
std::fs::read(path).map_err(|e| e.to_string())
}
pub fn import_effects(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.importEffects";
let mut paths: Vec<String> =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
if let Some(x) = str_param(p, "path") {
paths.push(x.to_string());
}
if paths.is_empty() {
return Err(bad(C, "missing `path` / `paths`"));
}
let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty());
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let (mut found, mut failed) = (Vec::new(), Vec::new());
for (f, dir_group, named) in effect_files(&paths) {
match read_capped(&f) {
Ok(bytes) => {
if !found_in(&f, &bytes, dir_group, &mut found, &mut failed) && named {
failed.push((f, "not a local adjustment preset".into()));
}
}
Err(e) => failed.push((f, e)),
}
}
let (imported, dividers) = add_found(s, found, group, dry)?;
Ok(json!({"imported": imported, "dividers": dividers, "failed": failed.into_iter().map(|(f, e)| json!([f, e])).collect::<Vec<_>>()}))
}
pub fn export_effects(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.exportEffects";
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
let mut path = std::path::PathBuf::from(path);
if path.extension().is_none() {
path.set_extension(EFFECT_EXT);
}
let names: Option<Vec<String>> =
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
let group = str_param(p, "group").map(str::trim);
let chosen: Vec<&MaskEffect> = s
.mask_effects
.iter()
.filter(|e| names.as_ref().is_none_or(|n| n.iter().any(|x| x.eq_ignore_ascii_case(&e.name))))
.filter(|e| group.is_none_or(|g| g.eq_ignore_ascii_case(&e.group)))
.collect();
if chosen.is_empty() {
return Err(bad(C, "no effect presets to export"));
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
}
let text = serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 2, "effects": chosen}))
.map_err(|e| crate::EngineError::Other(e.to_string()))?;
std::fs::write(&path, text).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
}
#[cfg(test)]
mod tests {
use super::*;
// A synthetic template in the shape Lightroom writes (our own values).
const TEMPLATE: &str = r#"s = {
id = "00000000-0000-0000-0000-000000000001",
internalName = "Soft Glow",
title = "Soft Glow",
type = "LocalizedAdjustmentPreset",
value = {
clarity = 0.5,
clarity2012 = -0.25,
exposure = 0,
exposure2012 = 0.125,
grain = 0.25,
highlights2012 = -0.5,
luminanceNoise = 0.1,
refineSaturation = 0,
temperature = 0.2,
texture = -0.3,
toningHue = 240,
toningLuminance = 0.1,
toningSaturation = 0,
pointColors = {
{
HueRange = { LowerFull = 0.3, LowerNone = 0, UpperFull = 0.6, UpperNone = 1, },
HueShift = -0.05,
RangeAmount = 0.5,
SrcHue = 0.8,
},
},
},
version = 0,
}"#;
#[test]
fn reads_a_lightroom_local_template() {
assert!(is_lr_local_template(TEMPLATE));
let (e, unmapped) = parse_lr_local_template(TEMPLATE, "file").unwrap();
assert_eq!(e.name, "Soft Glow");
assert!((e.adjust.exposure - 0.5).abs() < 1e-9, "exposure2012 is a quarter of the stops");
assert!((e.adjust.clarity + 25.0).abs() < 1e-9, "the 2012 field wins over the old one");
assert!((e.adjust.highlights + 50.0).abs() < 1e-9);
assert!((e.adjust.temp - 20.0).abs() < 1e-9);
assert!((e.adjust.texture + 30.0).abs() < 1e-9);
assert!((e.adjust.noise - 10.0).abs() < 1e-9);
assert!((e.adjust.amount - 100.0).abs() < 1e-9);
assert_eq!(unmapped, vec!["Color luminance".to_string(), "Grain".into(), "Point Color".into()]);
assert_eq!(e.extra.get("grain"), Some(&json!(0.25)), "kept for export");
assert_eq!(e.extra["pointColors"][0]["HueRange"]["UpperFull"], json!(0.6));
assert!(!e.extra.contains_key("refineSaturation"), "zero settings aren't kept");
}
#[test]
fn develop_templates_are_not_local_ones() {
let develop = r#"s = { title = "X", type = "Develop", value = { settings = { Exposure2012 = 1 } } }"#;
assert!(!is_lr_local_template(develop));
assert!(parse_lr_local_template(develop, "x").is_err());
assert!(parse_bytes("x.lrtemplate", develop.as_bytes()).is_none(), "left to the develop preset import");
}
#[test]
fn malformed_files_are_errors_not_panics() {
for bad in [
"garbage {",
"",
"s = {",
r#"s = { type = "LocalizedAdjustmentPreset", value = 3 }"#,
r#"s = { type = "LocalizedAdjustmentPreset" }"#,
r#"s = { type = "LocalizedAdjustmentPreset", value = { exposure2012 = "x", grain = {{{{}}}} } }"#,
"s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 1e400, contrast2012 = -1e400 } }",
"\u{feff}s = { type = \"LocalizedAdjustmentPreset\", title = \"\", value = {} }",
] {
let _ = parse_lr_local_template(bad, "x");
let _ = parse_bytes("x.lrtemplate", bad.as_bytes());
}
// deeply nested tables
let deep = format!("s = {{ type = \"LocalizedAdjustmentPreset\", value = {{ pointColors = {}1{} }} }}", "{".repeat(200), "}".repeat(200));
let _ = parse_lr_local_template(&deep, "x");
let (e, _) = parse_lr_local_template("s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 9 } }", "Stem").unwrap();
assert_eq!((e.name.as_str(), e.adjust.exposure), ("Stem", 4.0), "values clamp, the file name names it");
for bad in ["", "<x:xmpmeta", "<x:xmpmeta></x:xmpmeta>", "\u{0}\u{1}"] {
assert!(parse_bytes("x.xmp", bad.as_bytes()).is_none_or(|r| r.is_err()));
}
for bad in ["", "3", "[]", "{\"effects\": 3}", "[{\"name\": 4}]", "{\"effects\": [{\"name\": \" \"}]}"] {
assert!(parse_file(bad.as_bytes()).is_err(), "{bad}");
}
assert!(!found_in("x.zip", b"PK\x05\x06garbage", None, &mut Vec::new(), &mut Vec::new()));
}
/// A Camera Raw local correction preset (our own values).
const ACR: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:PresetType="Local" crs:LocalExposure2012="+0.25" crs:LocalShadows2012="0.4" crs:LocalGrain="0.2" crs:LocalTexture="0">
<crs:Name><rdf:Alt><rdf:li xml:lang="x-default">Lift Shadows</rdf:li></rdf:Alt></crs:Name>
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Faces</rdf:li></rdf:Alt></crs:Group>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
#[test]
fn reads_a_camera_raw_local_preset() {
assert!(is_local_xmp(ACR));
let (e, unmapped) = parse_local_xmp(ACR, "file").unwrap();
assert_eq!((e.name.as_str(), e.group.as_str()), ("Lift Shadows", "Faces"));
assert_eq!((e.adjust.exposure, e.adjust.shadows), (1.0, 40.0));
assert_eq!(unmapped, vec!["Grain".to_string()]);
// a develop preset is not one
let develop = ACR
.replace(
"crs:LocalExposure2012=\"+0.25\" crs:LocalShadows2012=\"0.4\" crs:LocalGrain=\"0.2\" crs:LocalTexture=\"0\"",
"crs:Exposure2012=\"1\"",
)
.replace("PresetType=\"Local\"", "PresetType=\"Normal\"");
assert!(!is_local_xmp(&develop));
// a preset typed Local that keeps its sliders in a correction
let nested = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PresetType="Local">
<crs:MaskGroupBasedCorrections><rdf:Seq><rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.5"/></rdf:li></rdf:Seq></crs:MaskGroupBasedCorrections>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let (e, _) = parse_local_xmp(nested, "Soft").unwrap();
assert_eq!((e.name.as_str(), e.adjust.clarity), ("Soft", -50.0));
}
fn found(name: &str, exposure: f64, dir: &str) -> Found {
Found {
effect: MaskEffect { name: name.into(), adjust: LocalAdjustments { exposure, ..Default::default() }, ..Default::default() },
unmapped: Vec::new(),
file: format!("{dir}/{name}.lrtemplate"),
dir: dir.into(),
dir_group: None,
has_group: false,
}
}
#[test]
fn dividers_become_groups() {
let e = |n: &str| MaskEffect { name: n.into(), ..Default::default() };
assert_eq!(divider_title(&e("10 ----- DETAIL ENHANCERS ----- 10")).as_deref(), Some("Detail Enhancers"));
assert_eq!(divider_title(&e("50--------MISC-----50")).as_deref(), Some("Misc"));
assert_eq!(divider_title(&e("=== Portraits ===")).as_deref(), Some("Portraits"));
assert_eq!(divider_title(&e("11_General_-_All_Purpose")), None, "no run of marks");
assert_eq!(divider_title(&e("-----")), None, "no title");
let busy = MaskEffect { adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..e("20 --- X --- 20") };
assert_eq!(divider_title(&busy), None, "a divider sets nothing");
let list = vec![
found("01 Burn", -1.0, "a"),
found("10 ----- DETAIL ENHANCERS ----- 10", 0.0, "a"),
found("12 Sky", 0.5, "a"),
found("20 ----- RETOUCHING ----- 20", 0.0, "a"),
found("24a-Eye-Cleaner", 0.1, "a"),
found("99 Late", 0.1, "a"),
found("Burn (Darken)", -0.5, "a"),
Found { dir_group: Some("Other".into()), ..found("15 Elsewhere", 0.2, "b") },
];
let (effects, dividers) = assign_groups(list.clone(), None);
let groups: Vec<(&str, &str)> = effects.iter().map(|f| (f.effect.name.as_str(), f.effect.group.as_str())).collect();
assert_eq!(
groups,
vec![
("01 Burn", ""),
("12 Sky", "Detail Enhancers"),
("24a-Eye-Cleaner", "Retouching"),
("99 Late", "Retouching"),
("Burn (Darken)", ""),
("15 Elsewhere", "Other"),
]
);
assert_eq!(dividers.len(), 2);
let (effects, _) = assign_groups(list, Some("Mine"));
assert!(effects.iter().all(|f| f.effect.group == "Mine"));
}
#[test]
fn folder_names_that_only_say_local_presets_give_no_group() {
assert_eq!(effect_group_from_dir("Local Adjustment Presets"), None);
assert_eq!(effect_group_from_dir("LocalCorrections"), None);
assert_eq!(effect_group_from_dir("Local Adjustment Presets/Portrait Brushes"), Some("Portrait Brushes".into()));
assert_eq!(effect_group_from_dir("Skin Kit"), Some("Skin Kit".into()));
}
}
+756
View File
@@ -0,0 +1,756 @@
//! Mask effect presets: named sets of local adjustment sliders picked from the Masking panel's
//! Effect menu (Lightroom's local adjustment / brush presets), saved with the library preferences.
//! Grouped (folders in the menu), favourites, partial presets (only some sliders, with or without
//! Amount) and optional brush settings. Files: [`super::mask_effect_files`].
use std::cmp::Ordering;
use lightcraft_catalog::PhotoId;
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use super::mask_effect_files as files;
use super::{CommandSpec, always, bad, cmd, has_active, str_param};
use crate::{Result, Session};
/// The local sliders an effect can set (`LocalAdjustments` field names; Amount aside).
pub const FIELDS: &[&str] = &[
"temp",
"tint",
"exposure",
"contrast",
"highlights",
"shadows",
"whites",
"blacks",
"texture",
"clarity",
"dehaze",
"hue",
"saturation",
"sharpness",
"noise",
"moire",
"defringe",
"color_hue",
"color_sat",
];
/// Brush settings an effect can carry (Lightroom keeps these per brush, not in presets).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct BrushSettings {
/// Radius as a fraction of the image's long edge.
pub size: f64,
/// 0..100.
pub feather: f64,
pub flow: f64,
pub density: f64,
pub auto_mask: bool,
}
impl Default for BrushSettings {
fn default() -> Self {
Self { size: 0.04, feather: 50.0, flow: 100.0, density: 100.0, auto_mask: false }
}
}
impl BrushSettings {
fn clamped(self) -> Self {
let c = |v: f64, lo: f64, hi: f64, d: f64| if v.is_finite() { v.clamp(lo, hi) } else { d };
let d = Self::default();
Self {
size: c(self.size, 0.002, 0.5, d.size),
feather: c(self.feather, 0.0, 100.0, d.feather),
flow: c(self.flow, 1.0, 100.0, d.flow),
density: c(self.density, 1.0, 100.0, d.density),
auto_mask: self.auto_mask,
}
}
}
/// A named set of local adjustment sliders. Applying one sets the sliders it names (all of them
/// when `fields` is empty, as Lightroom's presets do: those it doesn't change go to 0); the mask's
/// Amount stays unless `with_amount`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct MaskEffect {
pub name: String,
pub adjust: LocalAdjustments,
/// The Effect menu folder ("" = none).
#[serde(skip_serializing_if = "String::is_empty")]
pub group: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub favorite: bool,
/// The sliders this effect sets ([`FIELDS`] names); empty = all of them.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<String>,
/// Applying sets the mask's Amount to `adjust.amount` too.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub with_amount: bool,
/// Brush settings the brush takes when the effect is applied.
#[serde(skip_serializing_if = "Option::is_none")]
pub brush: Option<BrushSettings>,
/// Settings of an imported preset that we have no slider for (Lightroom key → value), kept so
/// an export carries them.
#[serde(skip_serializing_if = "Map::is_empty")]
pub extra: Map<String, Value>,
}
impl Default for MaskEffect {
fn default() -> Self {
Self {
name: String::new(),
adjust: LocalAdjustments::default(),
group: String::new(),
favorite: false,
fields: Vec::new(),
with_amount: false,
brush: None,
extra: Map::new(),
}
}
}
/// Field `k` of `a` (0 for an unknown name).
pub fn get(a: &LocalAdjustments, k: &str) -> f64 {
serde_json::to_value(a).ok().and_then(|v| v.get(k).and_then(Value::as_f64)).unwrap_or(0.0)
}
/// `a` with field `k` set to `v` (unchanged for an unknown name).
pub fn set(a: &LocalAdjustments, k: &str, v: f64) -> LocalAdjustments {
let Ok(Value::Object(mut o)) = serde_json::to_value(a) else { return *a };
if !o.contains_key(k) || !v.is_finite() {
return *a;
}
o.insert(k.into(), json!(v));
serde_json::from_value(Value::Object(o)).unwrap_or(*a)
}
/// The sliders of `a` away from 0 (the colour's hue only with its saturation).
pub fn changed_fields(a: &LocalAdjustments) -> Vec<String> {
let mut out: Vec<String> = FIELDS.iter().filter(|k| get(a, k).abs() > 1e-9 && **k != "color_hue").map(|k| k.to_string()).collect();
if a.color_sat.abs() > 1e-9 {
out.push("color_hue".into());
}
out
}
impl MaskEffect {
/// The sliders this effect sets.
pub fn field_list(&self) -> Vec<&str> {
if self.fields.is_empty() { FIELDS.to_vec() } else { FIELDS.iter().copied().filter(|k| self.fields.iter().any(|f| f == k)).collect() }
}
/// `a` with this preset's sliders (and Amount, if it carries it).
pub fn apply(&self, a: &LocalAdjustments) -> LocalAdjustments {
let mut out = if self.fields.is_empty() {
LocalAdjustments { amount: a.amount, ..self.adjust }
} else {
self.field_list().iter().fold(*a, |acc, k| set(&acc, k, get(&self.adjust, k)))
};
if self.with_amount {
out.amount = self.adjust.amount;
}
out
}
/// Does `a` carry exactly this preset's sliders? A tint's hue doesn't count without its
/// saturation (it does nothing then).
pub fn matches(&self, a: &LocalAdjustments) -> bool {
let fields = self.field_list();
let colorless = |x: &LocalAdjustments| x.color_sat.abs() < 1e-9;
let same = |k: &str| (get(&self.adjust, k) - get(a, k)).abs() < 1e-6;
fields.iter().all(|k| *k == "color_hue" && colorless(&self.adjust) && colorless(a) || same(k))
&& (!self.with_amount || (self.adjust.amount - a.amount).abs() < 1e-6)
}
/// Does the effect set nothing (all its sliders at 0, nothing kept from an import)?
pub fn is_empty(&self) -> bool {
self.extra.is_empty() && changed_fields(&self.adjust).iter().all(|k| !self.field_list().contains(&k.as_str()))
}
/// The same effect with its values in range and its field list normalised.
pub fn normalized(mut self) -> Self {
self.name = self.name.trim().to_string();
self.group = self.group.trim().to_string();
let amount = if self.with_amount { self.adjust.amount } else { 100.0 };
self.adjust = super::masks::clamp_local(LocalAdjustments { amount, ..self.adjust });
self.fields.retain(|f| FIELDS.contains(&f.as_str()));
if self.fields.iter().any(|f| f == "color_sat") && !self.fields.iter().any(|f| f == "color_hue") {
self.fields.push("color_hue".into());
}
self.fields.sort_by_key(|f| FIELDS.iter().position(|k| k == f));
self.fields.dedup();
if self.fields.len() == FIELDS.len() {
self.fields.clear();
}
self.brush = self.brush.map(BrushSettings::clamped);
self
}
}
/// Compare names the way people number their presets: digit runs as numbers ("2 …" before
/// "10 …"), letters ignoring case.
pub fn natural_cmp(a: &str, b: &str) -> Ordering {
fn chunks(s: &str) -> Vec<(bool, String)> {
let mut out: Vec<(bool, String)> = Vec::new();
for c in s.chars() {
let d = c.is_ascii_digit();
match out.last_mut() {
Some((kind, text)) if *kind == d => text.push(c),
_ => out.push((d, c.to_string())),
}
}
out
}
let (x, y) = (chunks(a), chunks(b));
for (p, q) in x.iter().zip(y.iter()) {
let o = match (p, q) {
((true, m), (true, n)) => {
let (m, n) = (m.trim_start_matches('0'), n.trim_start_matches('0'));
m.len().cmp(&n.len()).then_with(|| m.cmp(n))
}
((_, m), (_, n)) => m.to_lowercase().cmp(&n.to_lowercase()),
};
if o != Ordering::Equal {
return o;
}
}
x.len().cmp(&y.len()).then_with(|| a.cmp(b))
}
/// The Effect menu's order: groups in the order they first appear (ungrouped first), each with
/// its effects (indices into `effects`) sorted by name.
pub fn menu_order(effects: &[MaskEffect]) -> Vec<(String, Vec<usize>)> {
let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
if effects.iter().any(|e| e.group.is_empty()) {
groups.push((String::new(), Vec::new()));
}
for (i, e) in effects.iter().enumerate() {
match groups.iter_mut().find(|(g, _)| g.eq_ignore_ascii_case(&e.group)) {
Some((_, v)) => v.push(i),
None => groups.push((e.group.clone(), vec![i])),
}
}
for (_, v) in &mut groups {
v.sort_by(|a, b| match (effects.get(*a), effects.get(*b)) {
(Some(x), Some(y)) => natural_cmp(&x.name, &y.name),
_ => Ordering::Equal,
});
}
groups
}
/// Name of the effect the mask adjustments `a` match, if any (full presets before partial ones).
pub fn matching_effect(s: &Session, a: &LocalAdjustments) -> Option<String> {
let full = s.mask_effects.iter().filter(|e| e.fields.is_empty()).find(|e| e.matches(a));
full.or_else(|| s.mask_effects.iter().find(|e| e.matches(a))).map(|e| e.name.clone())
}
fn position(s: &Session, name: &str) -> Option<usize> {
s.mask_effects.iter().position(|x| x.name.eq_ignore_ascii_case(name.trim()))
}
/// Add or replace (same name, ignoring case) an effect; a replaced one keeps its star.
pub(crate) fn upsert(s: &mut Session, e: MaskEffect) {
match position(s, &e.name).and_then(|i| s.mask_effects.get_mut(i)) {
Some(x) => {
let favorite = x.favorite || e.favorite;
*x = MaskEffect { favorite, ..e };
}
None => s.mask_effects.push(e),
}
}
fn name_param(p: &Value, c: &str) -> Result<String> {
name_of(p, "name", c)
}
fn name_of(p: &Value, key: &str, c: &str) -> Result<String> {
str_param(p, key).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, format!("missing `{key}`")))
}
fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
}
/// The names a command acts on: `names: [...]` or `name`.
fn names_param(p: &Value, c: &str) -> Result<Vec<String>> {
match strings(p, "names").filter(|v| !v.is_empty()) {
Some(v) => Ok(v),
None => name_param(p, c).map(|n| vec![n]),
}
}
fn effect_index(s: &Session, name: &str, c: &str) -> Result<usize> {
position(s, name).ok_or_else(|| bad(c, format!("no effect `{name}`")))
}
/// The selected mask's adjustments on the active photo.
fn selected_mask(s: &Session, p: &Value, c: &str) -> Result<Mask> {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mid = p
.get("id")
.and_then(Value::as_u64)
.and_then(|v| u32::try_from(v).ok())
.or(s.active_mask)
.ok_or_else(|| bad(c, "no mask selected (give `id`)"))?;
let d = s.develop_of(id).unwrap_or_default();
d.masks.iter().find(|m| m.id == mid).cloned().ok_or_else(|| bad(c, format!("no mask {mid}")))
}
/// The kind of a mask's first component (`brush`, `linear`, `radial`, `subject`…): the tool the
/// effect is remembered for.
pub fn mask_tool(m: &Mask) -> Option<String> {
m.components.first().and_then(|c| serde_json::to_value(&c.shape).ok()).and_then(|v| v.get("kind").and_then(Value::as_str).map(str::to_string))
}
/// The adjustments a new mask made with `tool` starts with: the effect last applied to a mask of
/// that kind (Lightroom carries the chosen effect over to the next mask), else all at 0.
pub fn new_mask_adjust(s: &Session, tool: &str) -> LocalAdjustments {
s.mask_tool_effects
.get(tool)
.and_then(|name| position(s, name))
.and_then(|i| s.mask_effects.get(i))
.map(|e| super::masks::clamp_local(e.apply(&LocalAdjustments::default())))
.unwrap_or_default()
}
fn list(s: &Session) -> Value {
let current = s
.active()
.and_then(|id| s.develop_of(id))
.and_then(|d| s.active_mask.and_then(|mid| d.masks.iter().find(|m| m.id == mid).map(|m| m.adjust)))
.and_then(|a| matching_effect(s, &a));
let groups: Vec<Value> = menu_order(&s.mask_effects)
.into_iter()
.map(|(g, ix)| json!({"name": g, "effects": ix.iter().filter_map(|i| s.mask_effects.get(*i)).map(|e| e.name.clone()).collect::<Vec<_>>()}))
.collect();
json!({"effects": s.mask_effects, "groups": groups, "current": current, "toolEffects": s.mask_tool_effects})
}
/// Masks on `d` named like one of `names` (ignoring case).
fn masks_named(d: &DevelopSettings, names: &[String]) -> Vec<u32> {
d.masks.iter().filter(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.name))).map(|m| m.id).collect()
}
fn apply_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.applyEffect";
let name = name_param(p, C)?;
let effect = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let d = s.develop_of(id).unwrap_or_default();
// the masks: `ids`, `all`, else `id` or the selected one
let ids: Vec<u32> = if p.get("all").and_then(Value::as_bool).unwrap_or(false) {
d.masks.iter().map(|m| m.id).collect()
} else if let Some(a) = p.get("ids").and_then(Value::as_array) {
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect()
} else {
vec![selected_mask(s, p, C)?.id]
};
if ids.is_empty() {
return Err(bad(C, "no masks to apply the effect to"));
}
if let Some(missing) = ids.iter().find(|m| !d.masks.iter().any(|x| x.id == **m)) {
return Err(bad(C, format!("no mask {missing}")));
}
let set_masks = |d: &DevelopSettings, ids: &[u32]| {
let mut d = d.clone();
for m in d.masks.iter_mut().filter(|m| ids.contains(&m.id)) {
m.adjust = super::masks::clamp_local(effect.apply(&m.adjust));
}
d
};
let label = format!("Mask Effect: {}", effect.name);
// with `photos: "selected"`, the other selected photos' masks of the same names too
let others: Vec<(PhotoId, DevelopSettings)> = if str_param(p, "photos") == Some("selected") {
let names: Vec<String> = d.masks.iter().filter(|m| ids.contains(&m.id)).map(|m| m.name.clone()).collect();
s.selection
.ids
.iter()
.filter(|x| **x != id)
.filter_map(|x| s.develop_of(*x).map(|o| (*x, o)))
.filter_map(|(x, o)| {
let found = masks_named(&o, &names);
(!found.is_empty()).then(|| (x, set_masks(&o, &found)))
})
.collect()
} else {
Vec::new()
};
let mine = set_masks(&d, &ids);
let photos = 1 + others.len();
if others.is_empty() {
s.set_develop(id, mine, &label)?;
} else {
// one undo step for every photo
let mut ops: Vec<_> = s.develop_op(id, mine, &label).into_iter().collect();
ops.extend(others.into_iter().filter_map(|(x, o)| s.develop_op(x, o, &label)));
s.commit(&label, lightcraft_catalog::Op::Batch { ops })?;
}
// the next mask made with this tool starts with the effect
if p.get("remember").and_then(Value::as_bool).unwrap_or(true) {
for m in d.masks.iter().filter(|m| ids.contains(&m.id)) {
if let Some(tool) = mask_tool(m) {
s.mask_tool_effects.insert(tool, effect.name.clone());
}
}
s.save_prefs()?;
}
Ok(json!({"name": effect.name, "masks": ids, "photos": photos, "brush": effect.brush}))
}
/// The fields a save keeps: `fields: [..]` | `"changed"` | `"all"` (default).
fn fields_param(p: &Value, a: &LocalAdjustments, c: &str) -> Result<Vec<String>> {
match p.get("fields") {
None | Some(Value::Null) => Ok(Vec::new()),
Some(Value::String(x)) if x == "all" => Ok(Vec::new()),
Some(Value::String(x)) if x == "changed" => {
let f = changed_fields(a);
if f.is_empty() { Err(bad(c, "the mask changes no slider; nothing to save")) } else { Ok(f) }
}
Some(Value::Array(v)) => {
let f: Vec<String> = v.iter().filter_map(Value::as_str).filter(|k| FIELDS.contains(k)).map(str::to_string).collect();
if f.is_empty() { Err(bad(c, format!("`fields` names no slider (one of {})", FIELDS.join(", ")))) } else { Ok(f) }
}
Some(_) => Err(bad(c, "`fields` is \"all\", \"changed\" or a list of slider names")),
}
}
fn brush_param(p: &Value, c: &str) -> Result<Option<BrushSettings>> {
match p.get("brush") {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
Some(v) => serde_json::from_value::<BrushSettings>(v.clone()).map(|b| Some(b.clamped())).map_err(|e| bad(c, format!("`brush`: {e}"))),
}
}
fn save_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.saveEffect";
let name = name_param(p, C)?;
let adjust = selected_mask(s, p, C)?.adjust;
let group = str_param(p, "group").map(|g| g.trim().to_string()).unwrap_or_default();
let e = MaskEffect {
name,
adjust,
group,
fields: fields_param(p, &adjust, C)?,
with_amount: p.get("amount").and_then(Value::as_bool).unwrap_or(false),
brush: brush_param(p, C)?,
..Default::default()
};
upsert(s, e.normalized());
s.save_prefs()?;
Ok(list(s))
}
/// Overwrite an effect's sliders with the selected mask's (keeping which sliders and whether
/// Amount it carries, its group and star).
fn update_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.updateEffect";
let name = name_param(p, C)?;
let i = effect_index(s, &name, C)?;
let adjust = selected_mask(s, p, C)?.adjust;
let brush = brush_param(p, C)?;
let e = s.mask_effects.get_mut(i).ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
let mut next = MaskEffect { adjust, extra: Map::new(), ..e.clone() };
if let Some(f) = p.get("fields").filter(|v| !v.is_null()) {
next.fields = fields_param(&json!({"fields": f}), &adjust, C)?;
}
if let Some(a) = p.get("amount").and_then(Value::as_bool) {
next.with_amount = a;
}
if brush.is_some() {
next.brush = brush;
}
*e = next.normalized();
s.save_prefs()?;
Ok(list(s))
}
fn rename_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.renameEffect";
let name = name_param(p, C)?;
let to = name_of(p, "to", C)?;
let i = effect_index(s, &name, C)?;
if position(s, &to).is_some_and(|j| j != i) {
return Err(bad(C, format!("an effect named `{to}` exists already")));
}
if let Some(e) = s.mask_effects.get_mut(i) {
e.name = to.clone();
}
for v in s.mask_tool_effects.values_mut() {
if v.eq_ignore_ascii_case(&name) {
*v = to.clone();
}
}
s.save_prefs()?;
Ok(list(s))
}
/// A name like `base` that no effect has: `base`, `base 2`, `base 3`…
fn free_name(s: &Session, base: &str) -> String {
(1..10_000)
.map(|n| if n == 1 { base.to_string() } else { format!("{base} {n}") })
.find(|n| position(s, n).is_none())
.unwrap_or_else(|| base.into())
}
fn duplicate_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.duplicateEffect";
let name = name_param(p, C)?;
let e = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
let to = match str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()) {
Some(t) if position(s, t).is_some() => return Err(bad(C, format!("an effect named `{t}` exists already"))),
Some(t) => t.to_string(),
None => free_name(s, &format!("{} copy", e.name)),
};
s.mask_effects.push(MaskEffect { name: to.clone(), favorite: false, ..e });
s.save_prefs()?;
let mut out = list(s);
out["name"] = json!(to);
Ok(out)
}
fn delete_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.deleteEffect";
let names = names_param(p, C)?;
if let Some(missing) = names.iter().find(|n| position(s, n).is_none()) {
return Err(bad(C, format!("no effect `{missing}`")));
}
s.mask_effects.retain(|x| !names.iter().any(|n| n.eq_ignore_ascii_case(&x.name)));
s.mask_tool_effects.retain(|_, v| !names.iter().any(|n| n.eq_ignore_ascii_case(v)));
s.save_prefs()?;
Ok(list(s))
}
fn favorite_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.favoriteEffect";
let names = names_param(p, C)?;
let mut idx = Vec::new();
for n in &names {
idx.push(effect_index(s, n, C)?);
}
let want = p.get("favorite").and_then(Value::as_bool);
for i in idx {
if let Some(e) = s.mask_effects.get_mut(i) {
e.favorite = want.unwrap_or(!e.favorite);
}
}
s.save_prefs()?;
Ok(list(s))
}
fn set_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.setEffectGroup";
let names = names_param(p, C)?;
let group = str_param(p, "group").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `group` (\"\" = none)"))?;
let mut idx = Vec::new();
for n in &names {
idx.push(effect_index(s, n, C)?);
}
// an existing group's spelling wins
let group = s.mask_effects.iter().map(|e| &e.group).find(|g| g.eq_ignore_ascii_case(&group)).cloned().unwrap_or(group);
for i in idx {
if let Some(e) = s.mask_effects.get_mut(i) {
e.group = group.clone();
}
}
s.save_prefs()?;
Ok(list(s))
}
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.renameEffectGroup";
let group = name_of(p, "group", C)?;
let to = str_param(p, "to").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `to` (\"\" = ungroup)"))?;
let mut n = 0;
for e in s.mask_effects.iter_mut().filter(|e| e.group.eq_ignore_ascii_case(&group)) {
e.group = to.clone();
n += 1;
}
if n == 0 {
return Err(bad(C, format!("no group `{group}`")));
}
s.save_prefs()?;
Ok(list(s))
}
fn tool_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.toolEffect";
let tool = name_of(p, "tool", C)?;
match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => {
let i = effect_index(s, n, C)?;
let name = s.mask_effects.get(i).map(|e| e.name.clone()).unwrap_or_default();
s.mask_tool_effects.insert(tool, name);
}
None => {
s.mask_tool_effects.remove(&tool);
}
}
s.save_prefs()?;
Ok(json!({"toolEffects": s.mask_tool_effects}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "mask.effects",
"Mask Effect Presets",
[],
None,
"{} → {effects: [{name, adjust, group?, favorite?, fields?, with_amount?, brush?, extra?}], groups: [{name, effects: [name]}] (menu order), current: the effect the selected mask matches or null, toolEffects: {tool: effect a new mask of that kind starts with}}",
always,
|s, _| Ok(list(s))
),
cmd!(
"mask.applyEffect",
"Apply Mask Effect",
[],
None,
"{name, id? | ids?: [maskId] | all?: bool, photos?: \"selected\" (also the masks of the same names on the other selected photos, one undo step), remember?: bool (true: new masks of this kind start with it)} — sets the effect's sliders (all, or the ones it keeps; Amount only if it carries it) → {name, masks, photos, brush}",
has_active,
apply_effect
),
cmd!(
"mask.saveEffect",
"Save Mask Effect",
[],
None,
"{name, id?, group?, fields?: \"all\" (default) | \"changed\" | [slider], amount?: bool (carry Amount), brush?: {size, feather, flow, density, auto_mask}} — the selected mask's sliders as an effect preset (replaces one of that name), saved with the library",
has_active,
save_effect
),
cmd!(
"mask.updateEffect",
"Update Mask Effect",
[],
None,
"{name, id?, fields?, amount?, brush?} — overwrite the effect with the selected mask's sliders (keeps its group, star and which sliders it carries unless given)",
has_active,
update_effect
),
cmd!("mask.renameEffect", "Rename Mask Effect", [], None, "{name, to}", always, rename_effect),
cmd!(
"mask.duplicateEffect",
"Duplicate Mask Effect",
[],
None,
"{name, to?} (default \"<name> copy\") → list + {name}",
always,
duplicate_effect
),
cmd!("mask.deleteEffect", "Delete Mask Effect", [], None, "{name | names: [name]} — removes effect presets", always, delete_effect),
cmd!("mask.favoriteEffect", "Favorite Mask Effect", [], None, "{name | names, favorite?: bool (default: toggle)}", always, favorite_effect),
cmd!("mask.setEffectGroup", "Move Mask Effect to Group", [], None, "{name | names, group: name (\"\" = none)}", always, set_group),
cmd!(
"mask.renameEffectGroup",
"Rename Mask Effect Group",
[],
None,
"{group, to} (\"\" ungroups; an existing name merges)",
always,
rename_group
),
cmd!(
"mask.toolEffect",
"Set Effect for New Masks",
[],
None,
"{tool: brush|linear|radial|subject|…, name?} — the effect new masks of that kind start with (no name: none)",
always,
tool_effect
),
cmd!(
"mask.importEffects",
"Import Mask Effects",
[],
None,
"{path | paths: [file or folder], group?, dryRun?} — Lightroom Classic local adjustment presets (.lrtemplate), Camera Raw local correction presets (.xmp), .lceffect files and .zip bundles of these; folders and Lightroom's divider presets (\"10 ----- DETAIL ----- 10\") become groups; same-named effects are replaced → {imported: [{name, group, file, unmapped}], dividers: [{name, group, file}], failed: [[file, error]]}",
always,
files::import_effects
),
cmd!(
"mask.exportEffects",
"Export Mask Effects",
[],
None,
"{path, names?: [name], group?: name (default: all)} — writes an .lceffect JSON file (groups, stars and kept Lightroom settings included) → {path, count}",
always,
files::export_effects
),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_keeps_amount_and_matches_ignore_it() {
let e = MaskEffect { name: "E".into(), adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..Default::default() };
let current = LocalAdjustments { shadows: 40.0, amount: 60.0, ..Default::default() };
let out = e.apply(&current);
assert_eq!((out.exposure, out.shadows, out.amount), (1.0, 0.0, 60.0));
assert!(e.matches(&out));
assert!(!e.matches(&current));
}
#[test]
fn partial_effects_leave_other_sliders_and_can_carry_amount() {
let e = MaskEffect {
name: "Lift".into(),
adjust: LocalAdjustments { shadows: 30.0, clarity: 99.0, amount: 70.0, ..Default::default() },
fields: vec!["shadows".into()],
with_amount: true,
..Default::default()
};
let current = LocalAdjustments { exposure: 0.5, clarity: 10.0, ..Default::default() };
let out = e.apply(&current);
assert_eq!((out.exposure, out.shadows, out.clarity, out.amount), (0.5, 30.0, 10.0, 70.0));
assert!(e.matches(&out));
assert!(!e.matches(&LocalAdjustments { amount: 100.0, ..out }), "Amount counts when carried");
}
#[test]
fn hue_without_saturation_still_matches() {
let e = MaskEffect {
name: "E".into(),
adjust: LocalAdjustments { color_hue: 240.0, exposure: 0.25, ..Default::default() },
..Default::default()
};
assert!(e.matches(&LocalAdjustments { exposure: 0.25, ..Default::default() }));
assert!(!e.matches(&LocalAdjustments { exposure: 0.25, color_sat: 10.0, ..Default::default() }));
}
#[test]
fn natural_order_and_groups() {
let mut v = vec!["10 b", "2 a", "Burn", "01 x", "1 y"];
v.sort_by(|a, b| natural_cmp(a, b));
assert_eq!(v, vec!["01 x", "1 y", "2 a", "10 b", "Burn"]);
let e = |n: &str, g: &str| MaskEffect { name: n.into(), group: g.into(), ..Default::default() };
let list = vec![e("12 b", "Detail"), e("3 a", ""), e("11 a", "detail"), e("20 r", "Retouch"), e("Burn", "")];
let order = menu_order(&list);
let names: Vec<(String, Vec<&str>)> = order.iter().map(|(g, ix)| (g.clone(), ix.iter().map(|i| list[*i].name.as_str()).collect())).collect();
assert_eq!(
names,
vec![("".to_string(), vec!["3 a", "Burn"]), ("Detail".to_string(), vec!["11 a", "12 b"]), ("Retouch".to_string(), vec!["20 r"]),]
);
}
#[test]
fn normalized_clamps_and_tidies_fields() {
let e = MaskEffect {
name: " X ".into(),
adjust: LocalAdjustments { exposure: 99.0, amount: 150.0, ..Default::default() },
fields: vec!["color_sat".into(), "bogus".into(), "exposure".into()],
brush: Some(BrushSettings { size: f64::NAN, flow: 0.0, ..Default::default() }),
..Default::default()
}
.normalized();
assert_eq!(e.name, "X");
assert_eq!(e.adjust.exposure, 4.0);
assert_eq!(e.adjust.amount, 100.0, "Amount isn't carried");
assert_eq!(e.fields, vec!["exposure", "color_hue", "color_sat"]);
let b = e.brush.unwrap();
assert_eq!((b.size, b.flow), (0.04, 1.0));
}
}
+41 -7
View File
@@ -160,11 +160,15 @@ const MAX_TAPER_STEPS: usize = 64;
fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<BrushStroke> {
let Some(&first) = to.points.first() else { return vec![to] };
let same = |a: f64, b: f64| (a - b).abs() <= 1e-9;
// pen pressure along the line: the last dab's at its start, the click's at its end (none when neither has any)
let pressured = !last.pressure.is_empty() || !to.pressure.is_empty();
let (p_from, p_to) = (last.pressure_at(0), to.pressure_at(0));
if same(last.size, to.size) && same(last.feather, to.feather) && same(last.flow, to.flow) {
let mut points = Vec::with_capacity(to.points.len() + 1);
points.push(from);
points.extend(to.points.iter().copied());
return vec![BrushStroke { points, ..to }];
let pressure = if pressured { std::iter::once(p_from).chain((0..to.points.len()).map(|i| to.pressure_at(i))).collect() } else { Vec::new() };
return vec![BrushStroke { points, pressure, ..to }];
}
// about 5% of size, or 2 points of feather or flow, per piece
let ratio = if last.size > 0.0 && to.size > 0.0 { (to.size / last.size).ln().abs() / 1.05f64.ln() } else { 0.0 };
@@ -178,11 +182,13 @@ fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<Br
let mut out: Vec<BrushStroke> = (0..steps)
.map(|i| {
let (t0, t1) = (i as f64 / steps as f64, (i + 1) as f64 / steps as f64);
let p = |t: f64| (f64::from(p_from) + (f64::from(p_to) - f64::from(p_from)) * t) as f32;
BrushStroke {
points: vec![at(t0), at(t1)],
size: lerp(last.size, to.size, t1),
feather: lerp(last.feather, to.feather, t1),
flow: lerp(last.flow, to.flow, t1),
pressure: if pressured { vec![p(t0), p(t1)] } else { Vec::new() },
..to.clone()
}
})
@@ -221,11 +227,14 @@ pub fn specs() -> Vec<CommandSpec> {
return Ok(json!({"pending": true}));
}
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
// the effect last applied to a mask of this kind
let adjust = super::mask_effects::new_mask_adjust(s, &kind);
let out = masks_edit(s, "mask.add", "Add Mask", |masks, active| {
masks.push(Mask {
id: next,
name: name.unwrap_or_else(|| format!("Mask {next}")),
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
adjust,
..Default::default()
});
*active = Some(next);
@@ -273,7 +282,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Stroke",
[],
None,
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, connect?: bool — start from the last point of the mask's last stroke (⇧-click), size / feather / flow tapering from that stroke's to these} → {activeMask, connected}",
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full), connect?: bool — start from the last point of the mask's last stroke (⇧-click), size / feather / flow tapering from that stroke's to these} → {activeMask, connected}",
has_active,
|s, p| {
let pts: Vec<Point> = p
@@ -283,6 +292,7 @@ pub fn specs() -> Vec<CommandSpec> {
.iter()
.filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?)))
.collect();
let pts_len = pts.len();
let d = BrushStroke::default();
let stroke = BrushStroke {
points: pts,
@@ -292,16 +302,18 @@ pub fn specs() -> Vec<CommandSpec> {
density: f64_or(p, "density", d.density),
erase: bool_or(p, "erase", false),
auto_mask: bool_or(p, "autoMask", false),
pressure: pressure_list(p.get("pressure"), pts_len)?,
};
let connect = bool_or(p, "connect", false);
let mut connected = false;
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask);
let adjust = super::mask_effects::new_mask_adjust(s, "brush");
let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) {
Some(i) => i,
None => {
masks.push(Mask { id: next, name: format!("Mask {next}"), ..Default::default() });
masks.push(Mask { id: next, name: format!("Mask {next}"), adjust, ..Default::default() });
*active = Some(next);
masks.len() - 1
}
@@ -314,7 +326,15 @@ pub fn specs() -> Vec<CommandSpec> {
let anchor = strokes.last().filter(|_| connect).and_then(|l| {
Some((
*l.points.last()?,
BrushStroke { points: Vec::new(), size: l.size, feather: l.feather, flow: l.flow, ..BrushStroke::default() },
BrushStroke {
points: Vec::new(),
size: l.size,
feather: l.feather,
flow: l.flow,
// the pressure the last dab was painted with, when the stroke recorded any
pressure: if l.pressure.is_empty() { Vec::new() } else { vec![l.pressure_at(l.points.len().saturating_sub(1))] },
..BrushStroke::default()
},
))
});
match anchor {
@@ -983,7 +1003,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_active,
|s, p| {
let c = "redeye.catchlight";
let i = super::f64_req(p, "index", c)? as usize;
let i = super::index_req(p, "index", c)?;
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
let eye = dd.red_eye.get_mut(i).ok_or_else(|| bad(c, "no such eye"))?;
@@ -998,7 +1018,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("redeye.delete", "Delete Red Eye Correction", [], None, "{index}", has_active, |s, p| {
let c = "redeye.delete";
let i = super::f64_req(p, "index", c)? as usize;
let i = super::index_req(p, "index", c)?;
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
if i >= dd.red_eye.len() {
@@ -1041,7 +1061,7 @@ fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::Deve
lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid)
}
fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
pub(crate) fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 };
c(&mut a.exposure, -4.0, 4.0);
for v in [
@@ -1069,3 +1089,17 @@ fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
c(&mut a.amount, 0.0, 200.0);
a
}
/// `mask.brushStroke`'s `pressure`: one number 0..1 per point (clamped), at most one per point; absent or `null`
/// is no pressure data.
fn pressure_list(v: Option<&Value>, points: usize) -> Result<Vec<f32>> {
let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
let list = v.as_array().ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1"))?;
if list.len() > points {
return Err(bad("mask.brushStroke", "pressure has more values than points"));
}
list.iter()
.map(|q| q.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(0.0, 1.0) as f32))
.collect::<Option<Vec<f32>>>()
.ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1"))
}
+31 -2
View File
@@ -1,4 +1,4 @@
//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`.
//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`, `merge.hdrBatch`.
//!
//! Run synchronously here (CLI, MCP, control channel); the desktop UI plans the same job with
//! [`Session::plan_merge`] and runs it on a worker thread. `preview: true` merges ≤ 1024 px frames
@@ -40,6 +40,26 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
Ok(info)
}
/// `merge.hdrBatch`: an HDR merge of each bracket set among the photos ([`lightcraft_catalog::Catalog::bracket_sets`]),
/// one after the other. A set that fails is reported and the others still run.
fn run_batch(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "merge.hdrBatch";
let (kind, finish) = crate::merge::parse("merge.hdr", p)?;
let ids: Vec<PhotoId> = s.targets(p);
let sets = s.catalog.bracket_sets(&ids);
if sets.is_empty() {
return Err(bad(ID, crate::merge::NO_BRACKET_SETS));
}
let (mut merged, mut failed) = (Vec::new(), Vec::new());
for set in sets {
match s.merge_now(kind.clone(), finish.clone(), &set) {
Ok(v) => merged.push(v),
Err(e) => failed.push(json!({"photos": set.iter().map(|p| p.0).collect::<Vec<_>>(), "error": e.to_string()})),
}
}
Ok(json!({"merged": merged, "failed": failed}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -56,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Panorama Merge",
[],
None,
"{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=40, preview=false, previewPath?}",
"{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=full size (≤ 512, held to memory), preview=false, previewPath?}",
can_merge,
|s, p| run(s, "merge.panorama", p)
),
@@ -69,5 +89,14 @@ pub fn specs() -> Vec<CommandSpec> {
can_merge,
|s, p| run(s, "merge.hdrPanorama", p)
),
cmd!(
"merge.hdrBatch",
"HDR for Each Bracket Set",
["Photo", "Photo Merge"],
None,
"{ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false}: an HDR merge of each bracket set among the photos (each stack, then photos taken less than a second apart at exposures ≥ ½ EV apart) → {merged: [{id, path, …}], failed: [{photos, error}]}",
can_merge,
run_batch
),
]
}
+16
View File
@@ -24,6 +24,8 @@ pub mod keywords;
pub mod library;
pub mod lut_profiles;
pub mod manage;
pub mod mask_effect_files;
pub mod mask_effects;
mod masks;
mod merge;
pub mod metadata;
@@ -33,6 +35,8 @@ mod prefs;
mod preset_files;
pub mod previews;
mod query;
pub mod recheck;
mod slideshow;
mod sync;
mod xmp;
@@ -145,6 +149,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(manage::specs());
v.extend(previews::specs());
v.extend(lut_profiles::specs());
v.extend(mask_effects::specs());
v.extend(cull::specs());
v.extend(convert::specs());
v.extend(convert::edit_specs());
@@ -153,10 +158,12 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(xmp::specs());
v.extend(preset_files::specs());
v.extend(prefs::specs());
v.extend(slideshow::specs());
v.extend(export::specs());
v.extend(before::specs());
v.extend(browse::specs());
v.extend(missing::specs());
v.extend(recheck::specs());
v.extend(sync::specs());
v.extend(metadata::specs());
v.extend(geocode::specs());
@@ -187,6 +194,15 @@ pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
}
/// A non-negative integer parameter: `index: -1` must be an error, not element 0 (`-1.0 as usize`
/// saturates to 0), just as `history.restore` already refuses a negative step index.
pub(crate) fn index_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
let i = f64_req(p, key, cmd)?;
if !(i.is_finite() && i >= 0.0) {
return Err(bad(cmd, format!("`{key}` must be a non-negative integer")));
}
Ok(i as usize)
}
pub(crate) fn f64_or(p: &Value, key: &str, d: f64) -> f64 {
p.get(key).and_then(Value::as_f64).unwrap_or(d)
}
+4 -1
View File
@@ -43,6 +43,9 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = i.get("perCamera").and_then(Value::as_bool) {
d.per_camera = v;
}
if let Some(v) = i.get("showOnCard").and_then(Value::as_bool) {
d.show_on_card = v;
}
if let Some(v) = i.get("copyright").and_then(Value::as_str) {
d.copyright = v.trim().to_string();
}
@@ -107,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Library Preferences",
[],
None,
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}",
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one), showOnCard?: bool (open the import review when a camera or card is connected)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}",
always,
prefs
)]
+33 -8
View File
@@ -2,6 +2,7 @@
use serde_json::{Value, json};
use super::mask_effect_files as mef;
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::preset_import::{group_from_dir, read_presets};
use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset};
@@ -18,6 +19,10 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let mut imported = Vec::new();
let mut failed = Vec::new();
let mut skipped = 0usize;
// local adjustment (brush) presets — Lightroom's .lrtemplate, Camera Raw's local .xmp, our
// .lceffect, also inside zips — become mask effects
let mut effects = Vec::new();
let mut effect_failed = Vec::new();
for top in &paths {
let from_dir = std::path::Path::new(top).is_dir();
for f in expand_preset_paths(std::slice::from_ref(top)) {
@@ -25,8 +30,27 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
// the path from the imported folder (inclusive) to the file's folder
let base = std::path::Path::new(top).parent().unwrap_or(std::path::Path::new(""));
let rel = std::path::Path::new(&f).parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
let dir_group = rel.filter(|_| from_dir).and_then(|d| group_from_dir(&d));
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| read_presets(&f, &b, dir_group));
let rel = rel.filter(|_| from_dir);
let bytes = match mef::read_capped(&f) {
Ok(b) => b,
Err(e) => {
failed.push(json!([f, e]));
continue;
}
};
let lower = f.to_ascii_lowercase();
let maybe_local = [".lrtemplate", ".xmp", ".zip", ".lceffect"].iter().any(|x| lower.ends_with(x));
let before = effects.len() + effect_failed.len();
let local = maybe_local && mef::found_in(&f, &bytes, rel.as_deref().and_then(mef::group_for_dir), &mut effects, &mut effect_failed);
// a zip may hold develop presets as well
if local && !lower.ends_with(".zip") {
continue;
}
let dir_group = rel.and_then(|d| group_from_dir(&d));
let parsed = read_presets(&f, &bytes, dir_group);
if local && parsed.is_err() && effects.len() + effect_failed.len() > before {
continue;
}
match parsed {
Ok(list) => {
for mut it in list {
@@ -45,7 +69,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed}))
failed.extend(effect_failed.into_iter().map(|(f, e)| json!([f, e])));
let (effects, dividers) = mef::add_found(s, effects, group.as_deref(), dry)?;
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed, "effects": effects, "effectDividers": dividers}))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
@@ -69,10 +95,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
if chosen.is_empty() {
return Err(bad("preset.export", "no presets to export (create one first, or pass ids/group)"));
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
}
std::fs::write(&path, to_lcpreset(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
let target = path.to_string_lossy();
s.check_write_target(&target).map_err(|e| bad("preset.export", e))?;
crate::export::write_file_durable(&target, to_lcpreset(&chosen).as_bytes()).map_err(crate::EngineError::Other)?;
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
}
@@ -83,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Presets",
[],
None,
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}",
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups) → {imported: [{id,name,group,file,unmapped}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}",
always,
import
),
+129
View File
@@ -0,0 +1,129 @@
//! Preview-only raws rechecked after an update (issue #662): a raw imported while its format or
//! variant couldn't be decoded keeps [`Photo::preview_only`] until something re-reads its file.
//! Each flag is stamped with the app version that set it ([`Photo::preview_checked`]); when a
//! library opens in another version, its stale preview-only photos are probed again and get
//! exactly what Reload from Disk would give them: a file that decodes now loses the flag, one that
//! still doesn't gets the new reason and a fresh stamp, so it isn't probed again.
//!
//! The work is split so the app can probe on a worker thread: [`stale`] lists what to probe (no
//! file-system calls), [`probe`] reads the files without the session, and [`apply`] applies the
//! results as LightCraft's own bookkeeping (journaled, not an undo step). Agents and tests use
//! `library.recheckPreviewOnly`, which does all three at once.
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd};
use crate::media::{FileProbe, ProbeInfo};
use crate::{Result, Session};
/// A preview-only photo to probe, and what the library knew about it when it was listed (so a
/// result for a photo that changed meanwhile is dropped).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Stale {
pub id: PhotoId,
pub path: String,
preview_only: Option<String>,
preview_checked: String,
}
impl Stale {
fn of(p: &Photo, path: &str) -> Stale {
Stale { id: p.id, path: path.to_string(), preview_only: p.preview_only.clone(), preview_checked: p.preview_checked.clone() }
}
/// The photo is still as it was listed: same file, flag and stamp.
fn current(&self, p: &Photo) -> bool {
crate::cmd::missing::checked_path(p) == Some(self.path.as_str())
&& p.preview_only == self.preview_only
&& p.preview_checked == self.preview_checked
}
}
/// The library's preview-only photos whose flag another app version set (or every preview-only
/// photo, with `all`): library photos with a file, not virtual copies (they follow the photo they
/// were copied from). No file-system calls.
pub fn stale(cat: &Catalog, all: bool) -> Vec<Stale> {
cat.photos()
.filter(|p| p.copy_of.is_none() && p.preview_only.is_some() && (all || p.preview_only_stale()))
.filter_map(|p| crate::cmd::missing::checked_path(p).map(|path| Stale::of(p, path)))
.collect()
}
/// The session's file probe, to [`probe`] with on a worker thread; `None` when the session has no
/// file access (a demo or browser session), where nothing can be rechecked.
pub fn file_probe(s: &Session) -> Option<FileProbe> {
s.media.file_probe.clone()
}
/// Probe each listed photo's file as Reload would. Doesn't touch the session.
pub fn probe(probe: &FileProbe, stale: Vec<Stale>) -> Vec<(Stale, std::result::Result<ProbeInfo, String>)> {
let paths: Vec<String> = stale.iter().map(|x| x.path.clone()).collect();
stale.into_iter().zip(crate::import::probe_with(probe, &paths)).collect()
}
/// What a recheck changed.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Rechecked {
/// Photos brought up to date (virtual copies included).
pub updated: Vec<PhotoId>,
/// Of those, the ones that develop from their raw data now.
pub cleared: Vec<PhotoId>,
}
impl Rechecked {
pub fn to_json(&self) -> Value {
json!({"updated": self.updated.iter().map(|i| i.0).collect::<Vec<_>>(), "cleared": self.cleared.iter().map(|i| i.0).collect::<Vec<_>>()})
}
}
/// Apply [`probe`]'s results: each photo gets the ops Reload would give it, all in one batch that is
/// journaled but isn't an undo step. A file that can't be read (missing, offline) changes nothing,
/// so it is tried again next time; a photo that changed since it was listed (relinked, reloaded,
/// removed) is skipped.
pub fn apply(s: &mut Session, results: Vec<(Stale, std::result::Result<ProbeInfo, String>)>) -> Result<Rechecked> {
let mut ops = Vec::new();
let mut out = Rechecked::default();
for (item, info) in results {
let Ok(info) = info else { continue };
if !s.catalog.photo(item.id).is_some_and(|p| item.current(p)) {
continue;
}
let first = out.updated.len();
crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut out.updated);
if info.preview_only.is_none() {
out.cleared.extend_from_slice(&out.updated[first..]);
}
}
if !ops.is_empty() {
s.apply_system(Op::Batch { ops })?;
}
for id in &out.updated {
s.media.forget(*id);
}
Ok(out)
}
fn recheck(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.recheckPreviewOnly";
let all = p.get("all").and_then(Value::as_bool).unwrap_or(false);
// without file access every probe "succeeds" with nothing known, which would clear every flag
let fp = file_probe(s).ok_or_else(|| bad(C, "this session has no file access"))?;
let listed = stale(&s.catalog, all);
let n = listed.len();
let mut r = apply(s, probe(&fp, listed))?.to_json();
r["checked"] = json!(n);
Ok(r)
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"library.recheckPreviewOnly",
"Recheck Preview-Only Raws",
[],
None,
"{all?: bool} — probe again the raws shown from their embedded preview whose flag another LightCraft version set (the app does this in the background when a library opens; `all` rechecks every preview-only raw, e.g. when testing a decoder build of the same version), and bring each up to date as Reload from Disk would; not an undo step, develop settings and history are kept; files that can't be read are left for next time → {checked, updated: [ids], cleared: [ids] (develop from their raw data now)}",
always,
recheck
)]
}
+513
View File
@@ -0,0 +1,513 @@
//! `slideshow.playback`: the slideshow's Playback settings (slide and fade durations, fade colour,
//! random order, repeat, preparing previews in advance). `slideshow.layout`: how a slide looks
//! (Layout, Options and Backdrop: margins, aspect preview, zoom to fill, stroke, shadow, backdrop
//! colour, colour wash, background image). Both are library preferences, saved in the library's
//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.overlays`:
//! the identity plate, rating stars, text overlays with tokens and the watermark drawn on every
//! slide (also a library preference). `slideshow.titles`: the intro and ending screens (colour,
//! identity plate or text, scale, duration; a library preference). `slideshow.preview` composes
//! one slide, or a title screen, as a PNG (with the library's settings, or a template's).
//! `slideshow.templates.*`: the Template Browser (built-in and user templates; see
//! [`crate::slide_template`]). `slideshow.export`: the slideshow as a PDF, a JPEG sequence or an
//! AV1 video, in the background ([`crate::slideshow_export`]); `slideshow.exportStatus` reads how
//! it is going.
use lightcraft_catalog::PhotoId;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::slide_template::{self as tpl, SlideLook};
use crate::{Result, Session};
/// Largest side `slideshow.preview` renders.
pub const PREVIEW_MAX: usize = 4096;
fn playback(s: &mut Session, p: &Value) -> Result<Value> {
let changes = p.as_object().is_some_and(|o| !o.is_empty());
if changes {
let next = s.slideshow.merged(p).map_err(|e| bad("slideshow.playback", e))?;
if next != s.slideshow {
s.slideshow = next;
s.save_prefs()?;
}
}
Ok(s.slideshow.to_json())
}
fn layout(s: &mut Session, p: &Value) -> Result<Value> {
let changes = p.as_object().is_some_and(|o| !o.is_empty());
if changes {
let next = s.slide_layout.merged(p).map_err(|e| bad("slideshow.layout", e))?;
if next != s.slide_layout {
s.slide_layout = next;
s.save_prefs()?;
}
}
Ok(s.slide_layout.to_json())
}
fn overlays(s: &mut Session, p: &Value) -> Result<Value> {
let changes = p.as_object().is_some_and(|o| !o.is_empty());
if changes {
let next = s.slide_overlays.merged(p).map_err(|e| bad("slideshow.overlays", e))?;
if next != s.slide_overlays {
s.slide_overlays = next;
s.save_prefs()?;
}
}
let mut out = s.slide_overlays.to_json();
out["warnings"] = json!(s.slide_overlays.warnings());
Ok(out)
}
fn titles(s: &mut Session, p: &Value) -> Result<Value> {
let changes = p.as_object().is_some_and(|o| !o.is_empty());
if changes {
let next = s.slide_titles.merged(p).map_err(|e| bad("slideshow.titles", e))?;
if next != s.slide_titles {
s.slide_titles = next;
s.save_prefs()?;
}
}
let mut out = s.slide_titles.to_json();
out["warnings"] = json!(s.slide_titles.warnings(&s.slide_overlays));
Ok(out)
}
/// A slide size from the request: both sides given, one (the other from the aspect preview), or
/// neither (1280 wide). Each side 16–[`PREVIEW_MAX`].
fn preview_size(layout: &lightcraft_slideshow::SlideSettings, p: &Value) -> Result<(usize, usize)> {
const C: &str = "slideshow.preview";
let side = |k: &str| -> Result<Option<usize>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => match v.as_u64() {
Some(n) if (16..=PREVIEW_MAX as u64).contains(&n) => Ok(Some(n as usize)),
_ => Err(bad(C, format!("`{k}` must be a whole number of pixels from 16 to {PREVIEW_MAX}, not {v}"))),
},
}
};
let ratio = layout.aspect.ratio(f64::NAN);
let clamp = |v: f64| (v.round() as usize).clamp(16, PREVIEW_MAX);
Ok(match (side("width")?, side("height")?) {
(Some(w), Some(h)) => (w, h),
(Some(w), None) => (w, clamp(w as f64 / ratio)),
(None, Some(h)) => (clamp(h as f64 * ratio), h),
(None, None) => (1280, clamp(1280.0 / ratio)),
})
}
/// Write `png` to `path`, or put it in the reply as base64.
fn deliver(png: &[u8], path: Option<&str>, out: &mut Value) -> Result<()> {
match path {
Some(path) => {
crate::export::write_file(path, png).map_err(crate::EngineError::Other)?;
out["path"] = json!(path);
}
None => out["png"] = json!(base64(png)),
}
Ok(())
}
fn encode_png(img: &lightcraft_raster::Rgba8) -> Result<Vec<u8>> {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default())
.map_err(|e| crate::EngineError::Other(format!("PNG: {e}")))
}
/// `slideshow.preview {title}`: a title screen (whether or not it is switched on).
fn preview_title(s: &mut Session, p: &Value, title: &Value, look: &SlideLook) -> Result<Value> {
const C: &str = "slideshow.preview";
let t = title
.as_str()
.and_then(crate::slideshow::Title::parse)
.ok_or_else(|| bad(C, format!("`title` must be \"intro\" or \"ending\", not {title}")))?;
if p.get("id").is_some_and(|v| !v.is_null()) {
return Err(bad(C, "pass `id` or `title`, not both"));
}
let (w, h) = preview_size(&look.layout, p)?;
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty());
if let Some(path) = path {
s.check_write_target(path).map_err(|e| bad(C, e))?;
}
let c = crate::slide_title::TitleCompose::new(&look.titles, &look.overlays, t, w, h);
let img = c.compose();
let png = encode_png(&img)?;
let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
let mut out = json!({
"title": t.id(),
"enabled": look.titles.screen(t).enabled,
"width": img.width,
"height": img.height,
"plate": c.plate_box().map(rect),
});
deliver(&png, path, &mut out)?;
Ok(out)
}
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.preview";
// a template's look (the Template Browser's hover preview), else the library's settings
let template = match p.get("template") {
None | Some(Value::Null) => None,
Some(Value::String(id)) => Some(s.slide_template(id.trim()).map_err(|e| bad(C, e))?),
Some(v) => return Err(bad(C, format!("`template` must be a template id, not {v}"))),
};
let look = template.as_ref().map_or_else(|| SlideLook::of(s), |t| t.look.clone());
if let Some(t) = p.get("title").filter(|v| !v.is_null()) {
let mut out = preview_title(s, p, t, &look)?;
if let Some(t) = &template {
out["template"] = json!(t.id);
}
return Ok(out);
}
let id = match p.get("id") {
None | Some(Value::Null) => s.active().ok_or_else(|| bad(C, "no photo selected (select one or pass `id`)"))?,
Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, format!("`id` must be a photo id, not {v}")))?),
};
if s.catalog.photo(id).is_none() {
return Err(bad(C, format!("no photo {}", id.0)));
}
let (w, h) = preview_size(&look.layout, p)?;
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty());
if let Some(path) = path {
s.check_write_target(path).map_err(|e| bad(C, e))?;
}
let settings = look.layout.clone();
let img = s.slide_now_look(&settings, &look.overlays, id, w, h).map_err(crate::EngineError::Other)?;
let png = encode_png(&img)?;
let aspect = s.slide_photo_aspect(id).unwrap_or(1.5);
let l = lightcraft_slideshow::layout(&settings, aspect, img.width, img.height);
let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
let mut out = json!({
"id": id.0,
"width": img.width,
"height": img.height,
"cell": rect(l.cell),
"photo": rect(l.visible),
});
let photo = s.catalog.photo(id).cloned();
let o = crate::slide_overlay::SlideOverlays::resolve(&look.overlays, photo.as_deref());
out["overlays"] = o.boxes(&l).into_iter().map(|(k, r)| json!({"kind": k.id(), "box": rect(r)})).collect();
if let Some(t) = &template {
out["template"] = json!(t.id);
}
deliver(&png, path, &mut out)?;
Ok(out)
}
/// Standard padded base64 (for an image returned in the reply).
fn base64(bytes: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
let at = |shift: u32| A.get((n >> shift) as usize & 63).copied().unwrap_or(b'=') as char;
out.push(at(18));
out.push(at(12));
out.push(if chunk.len() > 1 { at(6) } else { '=' });
out.push(if chunk.len() > 2 { at(0) } else { '=' });
}
out
}
// ------------------------------------------------------------------------------- templates
/// The settings as the `slideshow.*` commands return them (for template replies).
fn settings_json(s: &Session) -> Value {
json!({
"playback": s.slideshow.to_json(),
"layout": s.slide_layout.to_json(),
"overlays": s.slide_overlays.to_json(),
"titles": s.slide_titles.to_json(),
})
}
fn tpl_list(s: &mut Session, _p: &Value) -> Result<Value> {
let l = s.slide_templates();
Ok(json!({
"templates": l.templates.iter().map(|t| t.info()).collect::<Vec<_>>(),
"previous": s.slide_templates_previous.is_some(),
"folder": s.slide_templates_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
"problems": l.problems,
}))
}
/// The template `id` or `name` names.
fn named(s: &mut Session, p: &Value, c: &str) -> Result<std::sync::Arc<tpl::Template>> {
s.slide_template_named(str_param(p, "id"), str_param(p, "name")).map_err(|e| bad(c, e))
}
fn tpl_apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.apply";
let t = named(s, p, C)?;
let warnings = s.apply_slide_look(&t.look)?;
let mut out = json!({"applied": t.id, "name": t.name, "warnings": warnings, "previous": s.slide_templates_previous.is_some()});
out["settings"] = settings_json(s);
Ok(out)
}
fn tpl_reply(t: &tpl::Template) -> Value {
t.info()
}
fn tpl_save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.save";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let replace = match p.get("replace") {
None | Some(Value::Null) => false,
Some(v) => v.as_bool().ok_or_else(|| bad(C, format!("`replace` must be true or false, not {v}")))?,
};
let t = s.save_slide_template(name, replace).map_err(|e| bad(C, e))?;
Ok(tpl_reply(&t))
}
fn id_param<'a>(p: &'a Value, c: &str) -> Result<&'a str> {
str_param(p, "id").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(c, "missing `id` (a template id from slideshow.templates.list)"))
}
fn tpl_update(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.update";
let id = id_param(p, C)?;
let t = s.update_slide_template(id).map_err(|e| bad(C, e))?;
Ok(tpl_reply(&t))
}
fn tpl_rename(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.rename";
let id = id_param(p, C)?;
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let t = s.rename_slide_template(id, name).map_err(|e| bad(C, e))?;
Ok(tpl_reply(&t))
}
fn tpl_delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.delete";
let id = id_param(p, C)?;
let t = s.delete_slide_template(id).map_err(|e| bad(C, e))?;
Ok(json!({"deleted": t.id, "name": t.name}))
}
fn tpl_import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.import";
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
let (t, renamed) = s.import_slide_template(std::path::Path::new(path)).map_err(|e| bad(C, e))?;
let mut out = tpl_reply(&t);
out["renamed"] = json!(renamed);
out["warnings"] = json!(t.look.warnings(s));
Ok(out)
}
fn tpl_export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.templates.export";
let id = id_param(p, C)?;
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
let t = s.slide_template(id).map_err(|e| bad(C, e))?;
s.check_write_target(path).map_err(|e| bad(C, e))?;
let bytes = serde_json::to_vec_pretty(&t.to_doc()).map_err(|e| crate::EngineError::Other(e.to_string()))?;
crate::export::write_file(path, &bytes).map_err(crate::EngineError::Other)?;
Ok(json!({"id": t.id, "name": t.name, "path": path}))
}
/// `slideshow.export`: prepare on this thread, run on a worker (or here with `wait`).
fn export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.export";
if cfg!(target_arch = "wasm32") {
return Err(bad(C, "slideshow export needs the desktop app or lightcraft-cli"));
}
if s.slideshow_export.as_ref().is_some_and(|r| r.running()) {
return Err(bad(C, "a slideshow export is already running (activity.cancel stops it)"));
}
let prepared = crate::slideshow_export::prepare(s, p).map_err(|e| bad(C, e))?;
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false);
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let task = s.activity.start("export", "Exporting slideshow", crate::activity::Cancel::Flag(cancel.clone()));
let total = prepared.total();
task.progress(0, total);
task.detail(&prepared.request.path);
let settings = &prepared.request.settings;
let run = std::sync::Arc::new(crate::slideshow_export::ExportRun {
task_id: task.id(),
format: settings.format.id().into(),
path: prepared.request.path.clone(),
total: total.into(),
cancel,
..Default::default()
});
s.slideshow_export = Some(run.clone());
let started = json!({
"taskId": task.id(),
"background": !wait,
"format": settings.format.id(),
"path": prepared.request.path,
"slides": prepared.len(),
"seconds": prepared.seconds(),
"total": total,
"width": prepared.width,
"height": prepared.height,
"warnings": prepared.warnings,
});
if wait {
crate::slideshow_export::run_to_end(prepared, &run, &task);
drop(task);
return match run.result() {
Some(Ok(v)) => Ok(v),
Some(Err(e)) => Err(bad(C, e)),
None => Err(bad(C, "the export did not finish")),
};
}
#[cfg(not(target_arch = "wasm32"))]
{
// the row goes when the worker ends, however it ends
let r = run.clone();
std::thread::Builder::new()
.name("lc-slideshow-export".into())
.spawn(move || crate::slideshow_export::run_to_end(prepared, &r, &task))
.map_err(|e| bad(C, format!("could not start: {e}")))?;
}
Ok(started)
}
fn export_status(s: &mut Session, _: &Value) -> Result<Value> {
Ok(s.slideshow_export.as_ref().map_or_else(|| json!({"running": false}), |r| r.json()))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"slideshow.playback",
"Slideshow Playback Settings",
[],
None,
"{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}",
always,
playback
),
cmd!(
"slideshow.layout",
"Slideshow Layout Settings",
[],
None,
"{margins?: all four, marginLeft?, marginRight?, marginTop?, marginBottom?: fractions of the slide's shorter side (0–0.45; with linkMargins one sets all four), linkMargins?: bool, showGuides?: bool (margin guides in the slide editor), aspect?: \"screen\"|\"16:9\"|\"4:3\" (aspect preview), zoomToFill?: bool (crop the photo to fill its cell), strokeBorder?: bool, strokeColor?: \"#rrggbb\"|[r,g,b], strokeWidth?: fraction of the shorter side (0–0.05), castShadow?: bool, shadowOpacity?: 0–1, shadowOffset?: 0–0.1, shadowRadius?: 0–0.1 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right (−45 = down right), backdropColor?: colour, colorWash?: bool, washColor?: colour, washOpacity?: 0–1, washAngle?: degrees (the side the wash colour is full at), backgroundImage?: photo id|null, backgroundOpacity?: 0–1} — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings",
always,
layout
),
cmd!(
"slideshow.overlays",
"Slideshow Overlay Settings",
[],
None,
"{identityPlate?: {enabled?, text?, image?: PNG/JPEG file (drawn instead of the text; \"\" = the text), size?: height as a fraction of the slide's shorter side (0.005–0.5), color?: \"#rrggbb\"|[r,g,b], opacity?: 0–1, …placement, behind?: bool (render behind the photo)}, ratingStars?: {enabled?, color?, size?: a star's height (0.005–0.15), …placement}, texts?: [text overlay] (replaces the list, at most 16; each entry merged onto the one at its index), addText?: text overlay ({} = the default \"{file}\"), removeText?: index, showWatermark?: bool, watermark?: text | the export watermark object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth} (merged; sizes relative to the photo)} where a text overlay is {text?: template with tokens ({file}, {name}, {title}, {caption}, {date} (YYYY-MM-DD) / {date:%d.%m.%Y}, {exposure}, {shutter}, {aperture}, {focal}, {iso}, {camera}, {lens}, {rating}, {creator}, {copyright}, {folder}, {ext}; unknown tags show as typed), size?: line height (0.005–0.3), color?, opacity?, …placement, shadow?: bool, shadowOpacity?: 0–1, shadowOffset?, shadowRadius?: 0–0.05 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right} and the placement keys are anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, offsetX?, offsetY?: fractions of the slide's shorter side (x right, y down; −1–1), relativeTo?: \"slide\"|\"photo\" (the overlay is kept on the slide) — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library; slideshow playback and slideshow.preview draw them → the settings and warnings (an unreadable identity plate image, unknown tokens)",
always,
overlays
),
cmd!(
"slideshow.titles",
"Slideshow Title Settings",
[],
None,
"{intro?: screen, ending?: screen} where a screen is {enabled?: bool, color?: background \"#rrggbb\"|[r,g,b], content?: \"none\"|\"identityPlate\" (the Overlays identity plate, its text or image)|\"text\", text?: the text shown with content \"text\", overrideColor?: bool (draw the plate or text in textColor instead of the identity plate's colour; an image plate keeps its colours), textColor?: colour, scale?: height of the plate or text line as a fraction of the slide's shorter side (0.005–0.5; text wider than the slide shrinks to fit), duration?: seconds (0.5–120) | null (the slide duration)} — the intro plays once before the first slide, the ending after the last one when repeat is off; both fade with the playback fade; random order never moves them. Omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings and warnings (an unreadable identity plate image, a screen with nothing to show)",
always,
titles
),
cmd!(
query "slideshow.templates.list",
"Slideshow Templates",
[],
None,
"{} → {templates: [{id: \"builtin:…\"|\"user:…\", name, builtin, group: \"builtin\"|\"user\", description?, path? (a user template's file)}], previous: bool (the settings before the last apply can be restored with apply {id: \"previous\"}), folder: the user templates folder | null, problems: [\"file: why it was skipped\"]} — built-ins first, then user templates by name (files in <settings>/slideshow-templates, at most 1000)",
always,
tpl_list
),
cmd!(
"slideshow.templates.apply",
"Apply Slideshow Template",
[],
None,
"{id: template id | \"previous\" (the settings before the last apply) | name: template name (case-insensitive)} — replaces the playback (prepare previews kept), layout, overlays and titles settings with the template's and saves them with the library; there is no undo: the replaced settings are kept as \"previous\" for this session → {applied, name, warnings: [a background photo not in this library, an unreadable identity plate / watermark image…], previous, settings: {playback, layout, overlays, titles}}",
always,
tpl_apply
),
cmd!(
"slideshow.templates.save",
"Save Slideshow Template",
[],
None,
"{name: 1–80 characters, replace?: bool (over a user template of that name)} — the current playback, layout, overlays and titles settings as a user template, <settings>/slideshow-templates/<name>.json (path separators and other reserved characters become _); a name taken (case-insensitive) or a built-in's is refused unless replace → {id, name, builtin, group, path}",
always,
tpl_save
),
cmd!(
"slideshow.templates.update",
"Update Slideshow Template",
[],
None,
"{id: user template} — replace its settings with the current ones (name kept); built-ins can't change → {id, name, builtin, group, path}",
always,
tpl_update
),
cmd!(
"slideshow.templates.rename",
"Rename Slideshow Template",
[],
None,
"{id: user template, name} — its file follows the name; a name taken is refused → {id (the new one), name, builtin, group, path}",
always,
tpl_rename
),
cmd!(
"slideshow.templates.delete",
"Delete Slideshow Template",
[],
None,
"{id: user template} — deletes its file → {deleted, name}",
always,
tpl_delete
),
cmd!(
"slideshow.templates.import",
"Import Slideshow Template",
[],
None,
"{path: a template .json file (at most 256 KB)} — checked like a saved one (format, version, every setting) and saved as a user template under its name (else the file's); a name taken gets \" (2)\"… → {id, name, builtin, group, path, renamed, warnings}",
always,
tpl_import
),
cmd!(
"slideshow.templates.export",
"Export Slideshow Template",
[],
None,
"{id: template (built-in or user), path: .json file to write} → {id, name, path}",
always,
tpl_export
),
cmd!(
query "slideshow.preview",
"Slideshow Slide Preview",
[],
None,
"{id?: photo (default: the active one) | title?: \"intro\"|\"ending\", template?: a template id (slideshow.templates.list): compose with its settings instead of the library's, which don't change (the Template Browser's hover preview), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}; with `title`, that title screen (`slideshow.titles`; drawn even while it is off) → {title, enabled, width, height, plate: [x0,y0,x1,y1] | null (the plate or text), path | png}",
always,
preview
),
cmd!(
query "slideshow.export",
"Export Slideshow",
[],
None,
"{format: \"pdf\"|\"jpeg\"|\"video\", path: the .pdf / .mp4 / .webm file, or for jpeg the folder (created if missing), size?: pdf/jpeg the long side in pixels (64–8192, default 1920), video the frame height (64–2160 or \"720p\"/\"1080p\", default 720), quality?: 1–100 (JPEG quality, default 90; video default 75), fps?: 24|25|30|60 (video, default 30), container?: \"mp4\"|\"webm\" (video, default mp4), fullScreen?: bool (pdf: open in the viewer's full screen mode, default true), speed?: 1–10 (video encoder speed, default 10 = fastest), name?: jpeg file name prefix (default the folder's name: <name>-001.jpg…), ids?: photo ids (default the photos in view, in view order), wait?: bool (run here and reply with the result)} — the photos' slides with the slideshow layout, overlays and the intro/ending screens that are on; video is AV1 (no audio), each slide held for its duration and fades rendered as frames, at most 2 hours. Runs in the background with a row in the activity stack (activity.cancel {id: taskId} stops it; nothing is left behind: files are written as .part and renamed when complete); a photo that can't be rendered is skipped with a warning → {taskId, background, format, path, slides, seconds, total, width, height, warnings}; with wait: the result (slideshow.exportStatus)",
always,
export
),
cmd!(
query "slideshow.exportStatus",
"Slideshow Export Status",
[],
None,
"{} → the last slideshow export: {taskId, format, path, running, done, total (slides, or frames for video), cancelled, result?: {format, path, width, height, slides, skipped, warnings, pages? | files? | frames, samples, keyFrames, seconds, fps, container, codec: \"av1\", audio: false; bytes}, error?} | {running: false} when there was none",
always,
export_status
),
]
}
+12
View File
@@ -108,6 +108,18 @@ pub fn specs() -> Vec<CommandSpec> {
always,
prefs
),
cmd!(
"library.retrySidecars",
"Write Waiting XMP Sidecars",
[],
None,
"{} — writes again the automatic XMP sidecars that failed (folder offline, read-only or full) whose originals are reachable now → {written, waiting}",
always,
|s, _| {
let written = s.retry_unsaved_sidecars();
Ok(json!({"written": written, "waiting": s.unsaved_sidecars()}))
}
),
cmd!("library.toggleAutoWriteXmp", "Automatically Write Changes into XMP", ["File"], None, "{} → {autoWrite}", always, |s, _| {
let on = !s.xmp.auto_write;
prefs(s, &json!({"autoWrite": on}))