mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 12:08:10 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()}))
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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(¤t);
|
||||
assert_eq!((out.exposure, out.shadows, out.amount), (1.0, 0.0, 60.0));
|
||||
assert!(e.matches(&out));
|
||||
assert!(!e.matches(¤t));
|
||||
}
|
||||
|
||||
#[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(¤t);
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)]
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
)]
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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}))
|
||||
|
||||
Reference in New Issue
Block a user