From 594c046d521422eeedb3cb32f218012ae0d24d08 Mon Sep 17 00:00:00 2001 From: OneSlips Date: Sun, 11 Oct 2026 12:42:38 +1000 Subject: [PATCH 1/3] LR-MASK-EFFECTS: mask effect (brush) presets and Lightroom local preset import Named sets of local adjustment sliders, picked from a new Effect menu above the mask sliders in the Masking panel (apply sets every slider and keeps Amount; save current; import; right-click delete; the menu names the matching effect, else Custom). Saved with the library preferences. Commands: mask.effects, mask.applyEffect, mask.saveEffect, mask.deleteEffect, mask.importEffects, mask.exportEffects (.lceffect). Lightroom local adjustment presets (.lrtemplate with type LocalizedAdjustmentPreset) are read by mask.importEffects and routed there by preset.import / File > Import Profiles & Presets, which used to reject them as having no develop settings. Their lower-camel Local* keys map through the same table as XMP mask fields (crs_masks::local_values); sliders without a counterpart (toningLuminance, grain) are reported. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mask_effects.rs | 379 ++++++++++++++++++++++++++ crates/engine/src/cmd/masks.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/preset_files.rs | 22 +- crates/engine/src/crs_masks.rs | 16 +- crates/engine/src/lib.rs | 3 + crates/engine/src/library.rs | 4 + crates/engine/src/tests_prefs.rs | 54 ++++ crates/ui-egui/src/menus.rs | 7 +- crates/ui-egui/src/panels/masking.rs | 51 ++++ crates/ui-egui/src/tests_masking.rs | 33 +++ 11 files changed, 567 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/cmd/mask_effects.rs diff --git a/crates/engine/src/cmd/mask_effects.rs b/crates/engine/src/cmd/mask_effects.rs new file mode 100644 index 0000000000..64b9fcac57 --- /dev/null +++ b/crates/engine/src/cmd/mask_effects.rs @@ -0,0 +1,379 @@ +//! 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. +//! Imported from Lightroom `.lrtemplate` local presets (`type = "LocalizedAdjustmentPreset"`) +//! and our own `.lceffect` JSON files. + +use lightcraft_develop::LocalAdjustments; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd, has_active, str_param}; +use crate::preset_import::{Lua, parse_lua}; +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"; + +/// A named set of local adjustment sliders. Applying one sets every slider of the mask (those the +/// preset doesn't name go to 0); the mask's Amount stays. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct MaskEffect { + pub name: String, + pub adjust: LocalAdjustments, +} + +impl MaskEffect { + /// `a` with this preset's sliders and `a`'s Amount. + pub fn apply(&self, a: &LocalAdjustments) -> LocalAdjustments { + LocalAdjustments { amount: a.amount, ..self.adjust } + } + /// Does `a` carry exactly this preset's sliders (Amount aside)? + pub fn matches(&self, a: &LocalAdjustments) -> bool { + let strip = |a: &LocalAdjustments| LocalAdjustments { amount: 100.0, ..*a }; + let (x, y) = (serde_json::to_value(strip(&self.adjust)).unwrap_or_default(), serde_json::to_value(strip(a)).unwrap_or_default()); + match (x.as_object(), y.as_object()) { + (Some(x), Some(y)) => x.iter().all(|(k, v)| { + let (p, q) = (v.as_f64().unwrap_or(0.0), y.get(k).and_then(Value::as_f64).unwrap_or(0.0)); + (p - q).abs() < 1e-6 + }), + _ => false, + } + } +} + +/// Name of the effect the mask adjustments `a` match, if any. +pub fn matching_effect(s: &Session, a: &LocalAdjustments) -> Option { + s.mask_effects.iter().find(|e| e.matches(a)).map(|e| e.name.clone()) +} + +/// Add or replace (same name, ignoring case) an effect. +fn upsert(s: &mut Session, e: MaskEffect) { + match s.mask_effects.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&e.name)) { + Some(x) => *x = e, + None => s.mask_effects.push(e), + } +} + +fn name_param(p: &Value, c: &str) -> Result { + str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `name`")) +} + +/// The selected mask's adjustments on the active photo. +fn selected_adjust(s: &Session, p: &Value, c: &str) -> Result<(u32, LocalAdjustments)> { + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mid = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask).ok_or_else(|| bad(c, "no mask selected (give `id`)"))?; + let d = s.develop_of(id).unwrap_or_default(); + let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?; + Ok((mid, m.adjust)) +} + +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)); + json!({"effects": s.mask_effects, "current": current}) +} + +fn apply_effect(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.applyEffect"; + let name = name_param(p, C)?; + let effect = s.mask_effects.iter().find(|x| x.name.eq_ignore_ascii_case(&name)).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?; + let (mid, _) = selected_adjust(s, p, C)?; + let id = s.active().ok_or_else(|| bad(C, "no active photo"))?; + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + let m = d.masks.iter_mut().find(|m| m.id == mid).ok_or_else(|| bad(C, format!("no mask {mid}")))?; + m.adjust = super::masks::clamp_local(effect.apply(&m.adjust)); + s.set_develop(id, d, &format!("Mask Effect: {}", effect.name))?; + Ok(json!({"name": effect.name, "mask": mid})) +} + +fn save_effect(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.saveEffect"; + let name = name_param(p, C)?; + let (_, adjust) = selected_adjust(s, p, C)?; + upsert(s, MaskEffect { name, adjust: LocalAdjustments { amount: 100.0, ..adjust } }); + s.save_prefs()?; + Ok(list(s)) +} + +fn delete_effect(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.deleteEffect"; + let name = name_param(p, C)?; + let n = s.mask_effects.len(); + s.mask_effects.retain(|x| !x.name.eq_ignore_ascii_case(&name)); + if n == s.mask_effects.len() { + return Err(bad(C, format!("no effect `{name}`"))); + } + s.save_prefs()?; + Ok(list(s)) +} + +/// 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) +} + +/// A Lightroom local adjustment preset → an effect named after its title (else `stem`), and the +/// non-zero sliders we have no equivalent for (e.g. `toningLuminance`, `grain`). +pub fn parse_lr_local_template(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec), 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 unmapped: Vec = fields + .iter() + .filter(|(k, v)| matches!(v, Lua::Num(n) if n.abs() > 1e-9) && !crate::crs_masks::is_local_field(&crs(k))) + // a colour tint's hue without saturation does nothing + .filter(|(k, _)| k != "toningHue") + .map(|(k, _)| k.clone()) + .collect(); + let adjust: LocalAdjustments = serde_json::from_value(Value::Object(values)).map_err(|e| format!("unreadable local preset: {e}"))?; + 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| t.strip_prefix("$$$/").and_then(|r| r.split_once('=')).map_or(t, |(_, d)| d).to_string()).unwrap_or_else(|| stem.to_string()); + Ok((MaskEffect { name, adjust: super::masks::clamp_local(LocalAdjustments { amount: 100.0, ..adjust }) }, unmapped)) +} + +/// Effects in an `.lceffect` file: `{format, version, effects: [...]}`, a bare array, or one effect. +pub fn parse_file(bytes: &[u8]) -> std::result::Result, 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(o) if o.contains_key("effects") => o["effects"].clone(), + Value::Array(_) => v.clone(), + Value::Object(_) => Value::Array(vec![v.clone()]), + _ => return Err("not an effect preset file".into()), + }; + let list: Vec = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?; + let list: Vec = list + .into_iter() + .filter(|e| !e.name.trim().is_empty()) + .map(|e| MaskEffect { name: e.name.trim().to_string(), adjust: super::masks::clamp_local(LocalAdjustments { amount: 100.0, ..e.adjust }) }) + .collect(); + if list.is_empty() { + return Err("no effect presets in the file".into()); + } + Ok(list) +} + +/// Effects in file `path` (`.lrtemplate` or `.lceffect`) with the sliders each one couldn't keep. +pub fn read_file(path: &str) -> std::result::Result)>, String> { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let p = std::path::Path::new(path); + let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Effect".into()); + match p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).as_deref() { + Some("lrtemplate") => parse_lr_local_template(&String::from_utf8_lossy(&bytes), &stem).map(|e| vec![e]), + _ => parse_file(&bytes).map(|v| v.into_iter().map(|e| (e, Vec::new())).collect()), + } +} + +/// Add `effects` (same-named ones replaced) and save → the import report rows. +pub fn add_effects(s: &mut Session, effects: Vec<(MaskEffect, Vec, String)>) -> Result> { + let rows: Vec = effects.iter().map(|(e, unmapped, file)| json!({"name": e.name, "file": file, "unmapped": unmapped})).collect(); + let any = !effects.is_empty(); + for (e, _, _) in effects { + upsert(s, e); + } + if any { + s.save_prefs()?; + } + Ok(rows) +} + +/// The files under `paths` (folders walked, at most 8 levels) an effect import reads. +fn effect_files(paths: &[String]) -> Vec { + fn walk(dir: &std::path::Path, depth: usize, out: &mut Vec) { + 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, depth + 1, out); + } + } else if p.extension().is_some_and(|e| e.eq_ignore_ascii_case("lrtemplate") || e.eq_ignore_ascii_case(EFFECT_EXT)) { + out.push(p.to_string_lossy().to_string()); + } + } + } + let mut out = Vec::new(); + for x in paths { + let p = std::path::Path::new(x); + if p.is_dir() { + walk(p, 0, &mut out); + } else { + out.push(x.clone()); + } + } + out +} + +fn import_effects(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.importEffects"; + let mut paths: Vec = + 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 (mut ok, mut failed) = (Vec::new(), Vec::new()); + for f in effect_files(&paths) { + match read_file(&f) { + Ok(list) => ok.extend(list.into_iter().map(|(e, u)| (e, u, f.clone()))), + Err(e) => failed.push(json!([f, e])), + } + } + let imported = add_effects(s, ok)?; + Ok(json!({"imported": imported, "failed": failed})) +} + +fn export_effects(s: &mut Session, p: &Value) -> Result { + 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> = + p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + 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)))).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": 1, "effects": chosen})).unwrap_or_default(); + 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()})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + query "mask.effects", + "Mask Effect Presets", + [], + None, + "{} → {effects: [{name, adjust: {exposure, contrast, …}}], current: name of the effect the selected mask's sliders match, or null}", + always, + |s, _| Ok(list(s)) + ), + cmd!( + "mask.applyEffect", + "Apply Mask Effect", + [], + None, + "{name, id?} — sets every slider of the selected mask (or mask `id`) from the effect preset; Amount stays", + has_active, + apply_effect + ), + cmd!( + "mask.saveEffect", + "Save Mask Effect", + [], + None, + "{name, id?} — the selected mask's sliders as an effect preset (replaces one of that name), saved with the library", + has_active, + save_effect + ), + cmd!("mask.deleteEffect", "Delete Mask Effect", [], None, "{name} — removes an effect preset", always, delete_effect), + cmd!( + "mask.importEffects", + "Import Mask Effects", + [], + None, + "{path | paths: [file or folder]} — Lightroom local adjustment presets (.lrtemplate) and .lceffect files; same-named effects are replaced → {imported: [{name, file, unmapped}], failed: [[file, error]]}", + always, + import_effects + ), + cmd!( + "mask.exportEffects", + "Export Mask Effects", + [], + None, + "{path, names?: [name] (default: all)} — writes an .lceffect JSON file → {path, count}", + always, + export_effects + ), + ] +} + +#[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, + highlights2012 = -0.5, + temperature = 0.2, + texture = -0.3, + toningHue = 240, + toningLuminance = 0.1, + toningSaturation = 0, + }, + 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.amount - 100.0).abs() < 1e-9); + assert_eq!(unmapped, vec!["toningLuminance".to_string()]); + } + + #[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_lr_local_template("garbage {", "x").is_err()); + assert!(parse_lr_local_template(r#"s = { type = "LocalizedAdjustmentPreset", value = 3 }"#, "x").is_err()); + } + + #[test] + fn apply_keeps_amount_and_matches_ignore_it() { + let e = MaskEffect { name: "E".into(), adjust: LocalAdjustments { exposure: 1.0, ..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)); + } +} diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index ae8b862b60..d05f80abb9 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -971,7 +971,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 [ diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4de460761f..36b242741e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -24,6 +24,7 @@ pub mod keywords; pub mod library; pub mod lut_profiles; pub mod manage; +pub mod mask_effects; mod masks; mod merge; pub mod metadata; @@ -145,6 +146,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()); diff --git a/crates/engine/src/cmd/preset_files.rs b/crates/engine/src/cmd/preset_files.rs index fc385f34c5..ec31130d42 100644 --- a/crates/engine/src/cmd/preset_files.rs +++ b/crates/engine/src/cmd/preset_files.rs @@ -18,9 +18,22 @@ fn import(s: &mut Session, p: &Value) -> Result { let mut imported = Vec::new(); let mut failed = Vec::new(); let mut skipped = 0usize; + // Lightroom local adjustment (brush) presets become mask effects + let mut effects = 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)) { + if f.to_ascii_lowercase().ends_with(".lrtemplate") { + let text = std::fs::read(&f).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default(); + if super::mask_effects::is_lr_local_template(&text) { + let stem = std::path::Path::new(&f).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + match super::mask_effects::parse_lr_local_template(&text, &stem) { + Ok((e, unmapped)) => effects.push((e, unmapped, f.clone())), + Err(e) => failed.push(json!([f, e])), + } + continue; + } + } // presets in a folder go to a group named after it (unless the file names its own) // 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("")); @@ -45,7 +58,12 @@ fn import(s: &mut Session, p: &Value) -> Result { } } } - Ok(json!({"imported": imported, "skipped": skipped, "failed": failed})) + let effects = if dry { + effects.iter().map(|(e, unmapped, file)| json!({"name": e.name, "file": file, "unmapped": unmapped})).collect() + } else { + super::mask_effects::add_effects(s, effects)? + }; + Ok(json!({"imported": imported, "skipped": skipped, "failed": failed, "effects": effects})) } fn export(s: &mut Session, p: &Value) -> Result { @@ -83,7 +101,7 @@ pub fn specs() -> Vec { "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; Lightroom local adjustment presets (.lrtemplate) become mask effects (`mask.effects`) → {imported: [{id,name,group,file,unmapped}], skipped, failed, effects: [{name,file,unmapped}]}", always, import ), diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index 483d1778b3..798555e4f5 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -64,13 +64,20 @@ const LOCAL: &[(&str, &str, f64)] = &[ ("LocalClarity", "clarity", 100.0), ]; -fn adjust(c: &XmpValue) -> Value { +/// Is `crs` (without the `crs:` prefix) a local slider field we map? +pub(crate) fn is_local_field(crs: &str) -> bool { + LOCAL.iter().any(|(k, _, _)| *k == crs) +} + +/// Our local adjustment values from the `Local*` slider fields that `get` finds (the name without +/// the `crs:` prefix → the stored fraction); process-version 2012 fields win over the older ones. +pub(crate) fn local_values(get: impl Fn(&str) -> Option) -> serde_json::Map { let mut a = serde_json::Map::new(); for (crs, ours, k) in LOCAL { if a.contains_key(*ours) { continue; } - if let Some(v) = num(c, &format!("crs:{crs}")) { + if let Some(v) = get(crs).filter(|v| v.is_finite()) { let lim = if *ours == "exposure" { 4.0 } else if *ours == "color_hue" { @@ -82,6 +89,11 @@ fn adjust(c: &XmpValue) -> Value { a.insert((*ours).into(), json!(v)); } } + a +} + +fn adjust(c: &XmpValue) -> Value { + let mut a = local_values(|crs| num(c, &format!("crs:{crs}"))); let amount = num(c, "crs:CorrectionAmount").unwrap_or(1.0); a.insert("amount".into(), json!((amount * 100.0).clamp(0.0, 200.0))); Value::Object(a) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc6c431ee..b8114eaf25 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -257,6 +257,8 @@ pub struct Session { /// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]), /// persisted in prefs.json. pub curve_presets: Vec, + /// Mask effect presets (the Masking panel's Effect menu). + pub mask_effects: Vec, /// Saved colour-label name sets. pub label_sets: Vec, /// Look-alike signatures by content key (Find Similar). @@ -373,6 +375,7 @@ impl Session { metadata_presets: Vec::new(), filter_presets: Vec::new(), curve_presets: Vec::new(), + mask_effects: Vec::new(), label_sets: Vec::new(), signatures: Default::default(), lut_profiles: Vec::new(), diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 2bb5511c2d..36a7378f66 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -167,6 +167,8 @@ struct PrefsFile { filter_presets: Vec, /// Point-curve presets. curve_presets: Vec, + /// Mask effect presets. + mask_effects: Vec, /// Colour-label name sets. label_sets: Vec, /// Imported LUT profiles. @@ -396,6 +398,7 @@ impl Session { self.metadata_presets = prefs.metadata_presets; self.filter_presets = prefs.filter_presets; self.curve_presets = prefs.curve_presets; + self.mask_effects = prefs.mask_effects; self.label_sets = prefs.label_sets; self.lut_profiles = prefs.lut_profiles; crate::cmd::lut_profiles::register_all(self); @@ -631,6 +634,7 @@ impl Session { metadata_presets: self.metadata_presets.clone(), filter_presets: self.filter_presets.clone(), curve_presets: self.curve_presets.clone(), + mask_effects: self.mask_effects.clone(), label_sets: self.label_sets.clone(), lut_profiles: self.lut_profiles.clone(), keyword_sets: self.keyword_sets.clone(), diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 3534064f2f..494b17ede9 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -265,6 +265,60 @@ fn curve_presets_save_apply_export_import_and_persist() { let _ = std::fs::remove_dir_all(&lib); } +#[test] +fn mask_effects_import_apply_save_export_and_persist() { + let lib = temp_dir("effects"); + let src = lib.join("Local Adjustment Presets"); + std::fs::create_dir_all(&src).unwrap(); + // a Lightroom local adjustment preset (synthetic values) and a develop template beside it + std::fs::write( + src.join("Brighten.lrtemplate"), + "s = {\n\ttitle = \"Brighten\",\n\ttype = \"LocalizedAdjustmentPreset\",\n\tvalue = {\n\t\texposure2012 = 0.25,\n\t\tshadows2012 = 0.2,\n\t\tgrain = 0.3,\n\t},\n\tversion = 0,\n}", + ) + .unwrap(); + std::fs::write(src.join("Look.lrtemplate"), "s = { title = \"Look\", type = \"Develop\", value = { settings = { Exposure2012 = 0.5 } } }") + .unwrap(); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, true).unwrap(); + // the general preset import routes the local one to the effects + let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 1, "the develop template stays a develop preset"); + assert_eq!(r["effects"][0]["name"], "Brighten"); + assert_eq!(r["effects"][0]["unmapped"], json!(["grain"])); + assert_eq!(s.mask_effects.len(), 1); + // apply to a mask: every slider set, Amount kept + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + s.execute("mask.adjust", &json!({"values": {"clarity": 30, "amount": 70}})).unwrap(); + assert!(s.execute("mask.applyEffect", &json!({"name": "nope"})).is_err()); + s.execute("mask.applyEffect", &json!({"name": "brighten"})).unwrap(); + let d = s.develop_of(s.active().unwrap()).unwrap(); + let a = d.masks.last().unwrap().adjust; + assert_eq!((a.exposure, a.shadows, a.clarity, a.amount), (1.0, 20.0, 0.0, 70.0)); + assert_eq!(s.execute("mask.effects", &json!({})).unwrap()["current"], "Brighten"); + // save the mask's sliders under a new name, export, delete, re-import + s.execute("mask.adjust", &json!({"values": {"dehaze": 15}})).unwrap(); + s.execute("mask.saveEffect", &json!({"name": "Brighten + Dehaze"})).unwrap(); + let r = s.execute("mask.exportEffects", &json!({"path": lib.join("out").join("mine").to_string_lossy()})).unwrap(); + assert_eq!(r["count"], 2); + let path = r["path"].as_str().unwrap().to_string(); + assert!(path.ends_with(".lceffect")); + s.execute("mask.deleteEffect", &json!({"name": "brighten + dehaze"})).unwrap(); + assert!(s.execute("mask.deleteEffect", &json!({"name": "brighten + dehaze"})).is_err()); + let r = s.execute("mask.importEffects", &json!({"path": path})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 2); + assert_eq!(s.mask_effects.len(), 2, "same-named effects are replaced"); + s.close_library().unwrap(); + } + // saved with the library preferences + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let names: Vec = s.mask_effects.iter().map(|e| e.name.clone()).collect(); + assert_eq!(names, ["Brighten", "Brighten + Dehaze"]); + assert_eq!(s.mask_effects[1].adjust.dehaze, 15.0); + let _ = std::fs::remove_dir_all(&lib); +} + #[test] fn resizing_the_disk_cache_keeps_the_cache_and_its_thumbnails() { let lib = temp_dir("resize"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0e..81811fe0e6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1559,9 +1559,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option 0 { + parts.push(crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", if effects == 1 { "" } else { "s" }, e = effects)); + } let profile_news = !parts.is_empty() || !problems.is_empty(); let msg = match (n, failed) { - // a zip of LUTs only: the profile half already says what happened + // a zip of LUTs (or only local presets): the other half already says what happened (0, 0) if profile_news => None, (0, 0) => Some("No new presets".to_string()), (n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)), diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 640bc4f11a..5e3f8dc6db 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -16,6 +16,56 @@ const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, step: f ControlSpec { id, label, section: Section::Light, min, max, default: 0.0, step, decimals, track } } +/// The Effect menu above the mask's sliders: apply, save, import and delete effect presets. +fn effect_row(app: &mut LightcraftApp, ui: &mut egui::Ui, adjust: &LocalAdjustments) { + use lightcraft_engine::cmd::mask_effects::matching_effect; + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Effect")).font(t.font(12.0)).color(t.text_label)); + let current = matching_effect(&app.session, adjust); + let label = current.clone().unwrap_or_else(|| crate::i18n::tr("Custom").to_string()); + let r = crate::widgets::dropdown(ui, "maskEffect", &label, t.font(12.0), t.text); + egui::Popup::menu(&r).show(|ui| { + ui.set_min_width(220.0); + let effects: Vec = app.session.mask_effects.iter().map(|e| e.name.clone()).collect(); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for name in &effects { + let r = ui.selectable_label(current.as_deref() == Some(name.as_str()), name); + register(ui.ctx(), format!("maskEffect:{name}"), r.rect); + if r.clicked() { + let _ = app.run("mask.applyEffect", json!({"name": name})); + ui.close(); + } + r.context_menu(|ui| { + if ui.button(crate::i18n::tr("Delete Preset")).clicked() { + let _ = app.run("mask.deleteEffect", json!({"name": name})); + ui.close(); + } + }); + } + }); + if !effects.is_empty() { + ui.separator(); + } + let r = ui.button(crate::i18n::tr("Save Current Settings as New Preset…")); + register(ui.ctx(), "maskEffectMenu:save", r.rect); + if r.clicked() { + crate::panels::dialogs::prompt(app, "Save Effect Preset", "Preset name", "", "mask.saveEffect", json!({}), "name"); + ui.close(); + } + if ui.add_enabled(app.services.pick_preset_files.is_some(), egui::Button::new(crate::i18n::tr("Import Presets…"))).clicked() { + let _ = app.run("file.importPresets", json!({})); + ui.close(); + } + if !effects.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("Right-click a preset to delete it")).font(t.font(11.0)).color(t.text_dim)); + } + }); + }); + }); +} + /// Local adjustment sliders (key = `LocalAdjustments` field name). pub const LOCAL: &[ControlSpec] = &[ spec("temp", "Temp", -100.0, 100.0, 1.0, 0, Track::Temp), @@ -368,6 +418,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { brush_settings(app, ui); } divider(ui); + effect_row(app, ui, &m.adjust); for s in LOCAL { let v = local_get(&m.adjust, s.id); let out = slider(ui, s, v, true, None); diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 8024c62c5c..eb2b18a229 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -137,6 +137,9 @@ fn new_brush_starts_a_separate_mask_in_paint_mode() { fn mask_adjustments_hide_overlay_without_disabling_the_mask() { use lightcraft_pipeline::Overlay; let mut h = detail("panel.masking"); + // tall enough for the Exposure slider below the Effect menu to be on screen + let r = h.request("ui.resize", json!({"width": 1200, "height": 1000}), T); + assert_eq!(r["ok"], true, "{r}"); exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.5, 0.2], "end": [0.5, 0.7]})); assert!(h.app.ui.mask_overlay); @@ -1416,3 +1419,33 @@ fn a_fast_brush_stroke_shows_without_gaps_before_the_button_is_released() { assert!((0..3).all(|i| (l[i] - k[i]).abs() < 24.0), "live {l:?} vs committed {k:?} at ({x}, {y})"); } } + +#[test] +fn effect_dropdown_applies_saves_and_shows_the_matching_effect() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "radial"})); + exec(&mut h, "mask.adjust", json!({"values": {"exposure": 0.7, "shadows": 25, "amount": 80}})); + h.step(); + // save the mask's sliders from the menu (name prompt) + click(&mut h, "dropdown:maskEffect"); + click(&mut h, "maskEffectMenu:save"); + let mut dlg = h.app.ui.dialog.take().expect("a name prompt opens"); + match &mut dlg { + crate::state::Dialog::TextPrompt { command, value, .. } => { + assert_eq!(command, "mask.saveEffect"); + *value = "Lift".into(); + } + _ => panic!("a name prompt opens"), + } + let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg); + assert!(r.is_ok(), "{r:?}"); + assert_eq!(h.app.session.mask_effects.len(), 1); + // change the sliders, then pick the effect again: sliders back, Amount kept + exec(&mut h, "mask.adjust", json!({"values": {"exposure": -1, "clarity": 40, "amount": 50}})); + h.step(); + click(&mut h, "dropdown:maskEffect"); + click(&mut h, "maskEffect:Lift"); + let a = develop(&h).masks.last().expect("a mask").adjust; + assert_eq!((a.exposure, a.shadows, a.clarity, a.amount), (0.7, 25.0, 0.0, 50.0)); + assert_eq!(exec(&mut h, "mask.effects", json!({}))["current"], "Lift"); +} From 0f40a2d2f590ae2d334e03dfa9a9075ae63ac4ff Mon Sep 17 00:00:00 2001 From: OneSlips Date: Sun, 11 Oct 2026 23:52:22 +1000 Subject: [PATCH 2/3] LR-MASK-EFFECTS: manage, group and fully import local adjustment presets (engine) Mask effects gain groups, stars, partial presets (only some sliders, with or without Amount), optional brush settings and the settings an imported preset carries that we can't apply (kept for export). New commands: mask.updateEffect, renameEffect, duplicateEffect, favoriteEffect, setEffectGroup, renameEffectGroup, toolEffect; applyEffect takes several masks, all masks, or the same-named masks on the selected photos in one undo step. A new mask starts with the effect last applied to a mask of its kind (kept with the library). Import (mask.importEffects and preset.import): Lightroom Classic local .lrtemplate, Camera Raw local-correction .xmp, .lceffect and zips of these; folders and LrC divider presets ("10 ----- DETAIL ----- 10") become groups; grain / Point Color / toningLuminance reported and kept. Malformed files are errors, never panics. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mask_effect_files.rs | 681 ++++++++++++++++ crates/engine/src/cmd/mask_effects.rs | 881 +++++++++++++++------ crates/engine/src/cmd/masks.rs | 6 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/preset_files.rs | 50 +- crates/engine/src/lib.rs | 6 + crates/engine/src/library.rs | 4 + crates/engine/src/presets.rs | 2 +- crates/engine/src/tests_mask_effects.rs | 293 +++++++ crates/engine/src/tests_prefs.rs | 2 +- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/pick.rs | 2 +- 12 files changed, 1652 insertions(+), 278 deletions(-) create mode 100644 crates/engine/src/cmd/mask_effect_files.rs create mode 100644 crates/engine/src/tests_mask_effects.rs diff --git a/crates/engine/src/cmd/mask_effect_files.rs b/crates/engine/src/cmd/mask_effect_files.rs new file mode 100644 index 0000000000..e1e099bc8a --- /dev/null +++ b/crates/engine/src/cmd/mask_effect_files.rs @@ -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, + 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, + /// 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> { + 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, extra: Map) -> std::result::Result<(MaskEffect, Vec), String> { + let adjust: LocalAdjustments = serde_json::from_value(Value::Object(values)).map_err(|e| format!("unreadable local preset: {e}"))?; + let mut unmapped: Vec = 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> { + 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> { + 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::().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, 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 = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?; + let list: Vec = 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)>, 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 { + 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 { + 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 { + 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, out: &mut Vec, 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, list: Vec<(MaskEffect, Vec)>, file: &str, dir: String, dir_group: Option, 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 { + 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 { + if !e.is_empty() { + return None; + } + let marks = ['-', '=', '_', '*', '#', '~', '—', '–', '•', '.']; + let chars: Vec = 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::>().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::()).unwrap_or_default() + }) + .collect::>() + .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, group: Option<&str>) -> (Vec, Vec<(Found, String)>) { + let (dividers, mut effects): (Vec<(Found, Option)>, Vec<(Found, Option)>) = + 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, group: Option<&str>, dry: bool) -> Result<(Vec, Vec)> { + let (effects, dividers) = assign_groups(found, group); + let rows: Vec = + effects.iter().map(|f| json!({"name": f.effect.name, "group": f.effect.group, "file": f.file, "unmapped": f.unmapped})).collect(); + let div_rows: Vec = 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, bool)> { + fn walk(dir: &std::path::Path, base: &std::path::Path, depth: usize, out: &mut Vec<(String, Option, 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, 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 { + const C: &str = "mask.importEffects"; + let mut paths: Vec = + 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::>()})) +} + +pub fn export_effects(s: &mut Session, p: &Value) -> Result { + 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> = + 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 ["", "", "\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#" + + Lift Shadows + Faces +"#; + + #[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#" + + +"#; + 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())); + } +} diff --git a/crates/engine/src/cmd/mask_effects.rs b/crates/engine/src/cmd/mask_effects.rs index 64b9fcac57..387909a6d2 100644 --- a/crates/engine/src/cmd/mask_effects.rs +++ b/crates/engine/src/cmd/mask_effects.rs @@ -1,75 +1,317 @@ //! 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. -//! Imported from Lightroom `.lrtemplate` local presets (`type = "LocalizedAdjustmentPreset"`) -//! and our own `.lceffect` JSON files. +//! 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 lightcraft_develop::LocalAdjustments; +use std::cmp::Ordering; + +use lightcraft_catalog::PhotoId; +use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask}; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +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::preset_import::{Lua, parse_lua}; 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"; +/// 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", +]; -/// A named set of local adjustment sliders. Applying one sets every slider of the mask (those the -/// preset doesn't name go to 0); the mask's Amount stays. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +/// 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 MaskEffect { - pub name: String, - pub adjust: LocalAdjustments, +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 MaskEffect { - /// `a` with this preset's sliders and `a`'s Amount. - pub fn apply(&self, a: &LocalAdjustments) -> LocalAdjustments { - LocalAdjustments { amount: a.amount, ..self.adjust } +impl Default for BrushSettings { + fn default() -> Self { + Self { size: 0.04, feather: 50.0, flow: 100.0, density: 100.0, auto_mask: false } } - /// Does `a` carry exactly this preset's sliders (Amount aside)? - pub fn matches(&self, a: &LocalAdjustments) -> bool { - let strip = |a: &LocalAdjustments| LocalAdjustments { amount: 100.0, ..*a }; - let (x, y) = (serde_json::to_value(strip(&self.adjust)).unwrap_or_default(), serde_json::to_value(strip(a)).unwrap_or_default()); - match (x.as_object(), y.as_object()) { - (Some(x), Some(y)) => x.iter().all(|(k, v)| { - let (p, q) = (v.as_f64().unwrap_or(0.0), y.get(k).and_then(Value::as_f64).unwrap_or(0.0)); - (p - q).abs() < 1e-6 - }), - _ => 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, } } } -/// Name of the effect the mask adjustments `a` match, if any. -pub fn matching_effect(s: &Session, a: &LocalAdjustments) -> Option { - s.mask_effects.iter().find(|e| e.matches(a)).map(|e| e.name.clone()) +/// 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, + /// 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, + /// 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, } -/// Add or replace (same name, ignoring case) an effect. -fn upsert(s: &mut Session, e: MaskEffect) { - match s.mask_effects.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&e.name)) { - Some(x) => *x = e, +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 { + let mut out: Vec = 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)> { + let mut groups: Vec<(String, Vec)> = 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 { + 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 { + 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 { - str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `name`")) + name_of(p, "name", c) +} + +fn name_of(p: &Value, key: &str, c: &str) -> Result { + 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> { + 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> { + 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 { + position(s, name).ok_or_else(|| bad(c, format!("no effect `{name}`"))) } /// The selected mask's adjustments on the active photo. -fn selected_adjust(s: &Session, p: &Value, c: &str) -> Result<(u32, LocalAdjustments)> { +fn selected_mask(s: &Session, p: &Value, c: &str) -> Result { let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; - let mid = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask).ok_or_else(|| bad(c, "no mask selected (give `id`)"))?; + 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(); - let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?; - Ok((mid, m.adjust)) + 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 { + 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 { @@ -78,194 +320,274 @@ fn list(s: &Session) -> Value { .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)); - json!({"effects": s.mask_effects, "current": current}) + let groups: Vec = 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::>()})) + .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 { + 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 { const C: &str = "mask.applyEffect"; let name = name_param(p, C)?; - let effect = s.mask_effects.iter().find(|x| x.name.eq_ignore_ascii_case(&name)).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?; - let (mid, _) = selected_adjust(s, 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 mut d = (*s.develop_of(id).unwrap_or_default()).clone(); - let m = d.masks.iter_mut().find(|m| m.id == mid).ok_or_else(|| bad(C, format!("no mask {mid}")))?; - m.adjust = super::masks::clamp_local(effect.apply(&m.adjust)); - s.set_develop(id, d, &format!("Mask Effect: {}", effect.name))?; - Ok(json!({"name": effect.name, "mask": mid})) + let d = s.develop_of(id).unwrap_or_default(); + // the masks: `ids`, `all`, else `id` or the selected one + let ids: Vec = 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 = 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> { + 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 = 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> { + match p.get("brush") { + None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None), + Some(v) => serde_json::from_value::(v.clone()).map(|b| Some(b.clamped())).map_err(|e| bad(c, format!("`brush`: {e}"))), + } } fn save_effect(s: &mut Session, p: &Value) -> Result { const C: &str = "mask.saveEffect"; let name = name_param(p, C)?; - let (_, adjust) = selected_adjust(s, p, C)?; - upsert(s, MaskEffect { name, adjust: LocalAdjustments { amount: 100.0, ..adjust } }); + 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 { + 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 { + 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 { + 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 { const C: &str = "mask.deleteEffect"; - let name = name_param(p, C)?; - let n = s.mask_effects.len(); - s.mask_effects.retain(|x| !x.name.eq_ignore_ascii_case(&name)); - if n == s.mask_effects.len() { - return Err(bad(C, format!("no effect `{name}`"))); + 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 { + 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)) } -/// 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) -} - -/// A Lightroom local adjustment preset → an effect named after its title (else `stem`), and the -/// non-zero sliders we have no equivalent for (e.g. `toningLuminance`, `grain`). -pub fn parse_lr_local_template(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec), 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()); +fn set_group(s: &mut Session, p: &Value) -> Result { + 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)?); } - 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 unmapped: Vec = fields - .iter() - .filter(|(k, v)| matches!(v, Lua::Num(n) if n.abs() > 1e-9) && !crate::crs_masks::is_local_field(&crs(k))) - // a colour tint's hue without saturation does nothing - .filter(|(k, _)| k != "toningHue") - .map(|(k, _)| k.clone()) - .collect(); - let adjust: LocalAdjustments = serde_json::from_value(Value::Object(values)).map_err(|e| format!("unreadable local preset: {e}"))?; - 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| t.strip_prefix("$$$/").and_then(|r| r.split_once('=')).map_or(t, |(_, d)| d).to_string()).unwrap_or_else(|| stem.to_string()); - Ok((MaskEffect { name, adjust: super::masks::clamp_local(LocalAdjustments { amount: 100.0, ..adjust }) }, unmapped)) -} - -/// Effects in an `.lceffect` file: `{format, version, effects: [...]}`, a bare array, or one effect. -pub fn parse_file(bytes: &[u8]) -> std::result::Result, 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(o) if o.contains_key("effects") => o["effects"].clone(), - Value::Array(_) => v.clone(), - Value::Object(_) => Value::Array(vec![v.clone()]), - _ => return Err("not an effect preset file".into()), - }; - let list: Vec = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?; - let list: Vec = list - .into_iter() - .filter(|e| !e.name.trim().is_empty()) - .map(|e| MaskEffect { name: e.name.trim().to_string(), adjust: super::masks::clamp_local(LocalAdjustments { amount: 100.0, ..e.adjust }) }) - .collect(); - if list.is_empty() { - return Err("no effect presets in the file".into()); - } - Ok(list) -} - -/// Effects in file `path` (`.lrtemplate` or `.lceffect`) with the sliders each one couldn't keep. -pub fn read_file(path: &str) -> std::result::Result)>, String> { - let bytes = std::fs::read(path).map_err(|e| e.to_string())?; - let p = std::path::Path::new(path); - let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Effect".into()); - match p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).as_deref() { - Some("lrtemplate") => parse_lr_local_template(&String::from_utf8_lossy(&bytes), &stem).map(|e| vec![e]), - _ => parse_file(&bytes).map(|v| v.into_iter().map(|e| (e, Vec::new())).collect()), - } -} - -/// Add `effects` (same-named ones replaced) and save → the import report rows. -pub fn add_effects(s: &mut Session, effects: Vec<(MaskEffect, Vec, String)>) -> Result> { - let rows: Vec = effects.iter().map(|(e, unmapped, file)| json!({"name": e.name, "file": file, "unmapped": unmapped})).collect(); - let any = !effects.is_empty(); - for (e, _, _) in effects { - upsert(s, e); - } - if any { - s.save_prefs()?; - } - Ok(rows) -} - -/// The files under `paths` (folders walked, at most 8 levels) an effect import reads. -fn effect_files(paths: &[String]) -> Vec { - fn walk(dir: &std::path::Path, depth: usize, out: &mut Vec) { - 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, depth + 1, out); - } - } else if p.extension().is_some_and(|e| e.eq_ignore_ascii_case("lrtemplate") || e.eq_ignore_ascii_case(EFFECT_EXT)) { - out.push(p.to_string_lossy().to_string()); - } + // 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(); } } - let mut out = Vec::new(); - for x in paths { - let p = std::path::Path::new(x); - if p.is_dir() { - walk(p, 0, &mut out); - } else { - out.push(x.clone()); - } - } - out + s.save_prefs()?; + Ok(list(s)) } -fn import_effects(s: &mut Session, p: &Value) -> Result { - const C: &str = "mask.importEffects"; - let mut paths: Vec = - 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()); +fn rename_group(s: &mut Session, p: &Value) -> Result { + 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 paths.is_empty() { - return Err(bad(C, "missing `path` / `paths`")); + if n == 0 { + return Err(bad(C, format!("no group `{group}`"))); } - let (mut ok, mut failed) = (Vec::new(), Vec::new()); - for f in effect_files(&paths) { - match read_file(&f) { - Ok(list) => ok.extend(list.into_iter().map(|(e, u)| (e, u, f.clone()))), - Err(e) => failed.push(json!([f, e])), - } - } - let imported = add_effects(s, ok)?; - Ok(json!({"imported": imported, "failed": failed})) + s.save_prefs()?; + Ok(list(s)) } -fn export_effects(s: &mut Session, p: &Value) -> Result { - 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); +fn tool_effect(s: &mut Session, p: &Value) -> Result { + 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); + } } - let names: Option> = - p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - 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)))).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": 1, "effects": chosen})).unwrap_or_default(); - 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()})) + s.save_prefs()?; + Ok(json!({"toolEffects": s.mask_tool_effects})) } pub fn specs() -> Vec { @@ -275,7 +597,7 @@ pub fn specs() -> Vec { "Mask Effect Presets", [], None, - "{} → {effects: [{name, adjust: {exposure, contrast, …}}], current: name of the effect the selected mask's sliders match, or null}", + "{} → {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)) ), @@ -284,7 +606,7 @@ pub fn specs() -> Vec { "Apply Mask Effect", [], None, - "{name, id?} — sets every slider of the selected mask (or mask `id`) from the effect preset; Amount stays", + "{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 ), @@ -293,28 +615,67 @@ pub fn specs() -> Vec { "Save Mask Effect", [], None, - "{name, id?} — the selected mask's sliders as an effect preset (replaces one of that name), saved with the library", + "{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.deleteEffect", "Delete Mask Effect", [], None, "{name} — removes an effect preset", always, delete_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 \" 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]} — Lightroom local adjustment presets (.lrtemplate) and .lceffect files; same-named effects are replaced → {imported: [{name, file, unmapped}], failed: [[file, error]]}", + "{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, - import_effects + files::import_effects ), cmd!( "mask.exportEffects", "Export Mask Effects", [], None, - "{path, names?: [name] (default: all)} — writes an .lceffect JSON file → {path, count}", + "{path, names?: [name], group?: name (default: all)} — writes an .lceffect JSON file (groups, stars and kept Lightroom settings included) → {path, count}", always, - export_effects + files::export_effects ), ] } @@ -323,57 +684,73 @@ pub fn specs() -> Vec { 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, - highlights2012 = -0.5, - temperature = 0.2, - texture = -0.3, - toningHue = 240, - toningLuminance = 0.1, - toningSaturation = 0, - }, - 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.amount - 100.0).abs() < 1e-9); - assert_eq!(unmapped, vec!["toningLuminance".to_string()]); - } - - #[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_lr_local_template("garbage {", "x").is_err()); - assert!(parse_lr_local_template(r#"s = { type = "LocalizedAdjustmentPreset", value = 3 }"#, "x").is_err()); - } - #[test] fn apply_keeps_amount_and_matches_ignore_it() { - let e = MaskEffect { name: "E".into(), adjust: LocalAdjustments { exposure: 1.0, ..Default::default() } }; + 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)); + } } diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index d05f80abb9..7b45487346 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -171,11 +171,14 @@ pub fn specs() -> Vec { 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); @@ -245,11 +248,12 @@ pub fn specs() -> Vec { }; 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"); 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 } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 36b242741e..2f27dc17c6 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -24,6 +24,7 @@ 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; diff --git a/crates/engine/src/cmd/preset_files.rs b/crates/engine/src/cmd/preset_files.rs index ec31130d42..5da9806573 100644 --- a/crates/engine/src/cmd/preset_files.rs +++ b/crates/engine/src/cmd/preset_files.rs @@ -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,28 +19,38 @@ fn import(s: &mut Session, p: &Value) -> Result { let mut imported = Vec::new(); let mut failed = Vec::new(); let mut skipped = 0usize; - // Lightroom local adjustment (brush) presets become mask effects + // 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)) { - if f.to_ascii_lowercase().ends_with(".lrtemplate") { - let text = std::fs::read(&f).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default(); - if super::mask_effects::is_lr_local_template(&text) { - let stem = std::path::Path::new(&f).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); - match super::mask_effects::parse_lr_local_template(&text, &stem) { - Ok((e, unmapped)) => effects.push((e, unmapped, f.clone())), - Err(e) => failed.push(json!([f, e])), - } - continue; - } - } // presets in a folder go to a group named after it (unless the file names its own) // 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 { @@ -58,12 +69,9 @@ fn import(s: &mut Session, p: &Value) -> Result { } } } - let effects = if dry { - effects.iter().map(|(e, unmapped, file)| json!({"name": e.name, "file": file, "unmapped": unmapped})).collect() - } else { - super::mask_effects::add_effects(s, effects)? - }; - Ok(json!({"imported": imported, "skipped": skipped, "failed": failed, "effects": effects})) + 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 { @@ -101,7 +109,7 @@ pub fn specs() -> Vec { "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; Lightroom local adjustment presets (.lrtemplate) become mask effects (`mask.effects`) → {imported: [{id,name,group,file,unmapped}], skipped, failed, effects: [{name,file,unmapped}]}", + "{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 ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b8114eaf25..b88156d9ea 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -259,6 +259,9 @@ pub struct Session { pub curve_presets: Vec, /// Mask effect presets (the Masking panel's Effect menu). pub mask_effects: Vec, + /// The effect a new mask of each kind (`brush`, `linear`…) starts with: the one last applied + /// to a mask of that kind. + pub mask_tool_effects: std::collections::BTreeMap, /// Saved colour-label name sets. pub label_sets: Vec, /// Look-alike signatures by content key (Find Similar). @@ -376,6 +379,7 @@ impl Session { filter_presets: Vec::new(), curve_presets: Vec::new(), mask_effects: Vec::new(), + mask_tool_effects: Default::default(), label_sets: Vec::new(), signatures: Default::default(), lut_profiles: Vec::new(), @@ -1014,6 +1018,8 @@ mod tests_library; #[cfg(test)] mod tests_lightroom_folder; #[cfg(test)] +mod tests_mask_effects; +#[cfg(test)] mod tests_merge; #[cfg(test)] mod tests_organize; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 36a7378f66..5d6af424f0 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -169,6 +169,8 @@ struct PrefsFile { curve_presets: Vec, /// Mask effect presets. mask_effects: Vec, + /// The effect new masks of each kind start with. + mask_tool_effects: std::collections::BTreeMap, /// Colour-label name sets. label_sets: Vec, /// Imported LUT profiles. @@ -399,6 +401,7 @@ impl Session { self.filter_presets = prefs.filter_presets; self.curve_presets = prefs.curve_presets; self.mask_effects = prefs.mask_effects; + self.mask_tool_effects = prefs.mask_tool_effects; self.label_sets = prefs.label_sets; self.lut_profiles = prefs.lut_profiles; crate::cmd::lut_profiles::register_all(self); @@ -635,6 +638,7 @@ impl Session { filter_presets: self.filter_presets.clone(), curve_presets: self.curve_presets.clone(), mask_effects: self.mask_effects.clone(), + mask_tool_effects: self.mask_tool_effects.clone(), label_sets: self.label_sets.clone(), lut_profiles: self.lut_profiles.clone(), keyword_sets: self.keyword_sets.clone(), diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index 8c36aa7429..a1958286d9 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -88,7 +88,7 @@ pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result, String pub fn expand_preset_paths(paths: &[String]) -> Vec { let preset_file = |p: &Path| { let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) + ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack", crate::cmd::mask_effect_files::EFFECT_EXT].contains(&ext.as_str()) }; let mut out = Vec::new(); for p in paths { diff --git a/crates/engine/src/tests_mask_effects.rs b/crates/engine/src/tests_mask_effects.rs new file mode 100644 index 0000000000..18572449d0 --- /dev/null +++ b/crates/engine/src/tests_mask_effects.rs @@ -0,0 +1,293 @@ +//! Mask effect presets end to end: managing them (rename, update, duplicate, delete, stars, +//! groups), applying (several masks, other photos, new masks remembering the effect), saving +//! some sliders, and importing Lightroom / Camera Raw local presets from folders and zips. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use crate::Session; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-effects-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A Lightroom local preset (synthetic values) in the Lua shape Lightroom writes. +fn lr_local(title: &str, value: &str) -> String { + format!( + "s = {{\n\tid = \"0\",\n\tinternalName = \"{title}\",\n\ttitle = \"{title}\",\n\ttype = \"LocalizedAdjustmentPreset\",\n\tvalue = {{\n{value}\n\t}},\n\tversion = 0,\n}}" + ) +} + +const ZERO: &str = "\t\texposure2012 = 0,\n\t\ttoningHue = 240,\n\t\ttoningSaturation = 0,"; + +fn names(v: &Value) -> Vec { + v["effects"].as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap().to_string()).collect() +} + +fn mask_adjust(s: &Session, id: u32) -> lightcraft_develop::LocalAdjustments { + s.develop_of(s.active().unwrap()).unwrap().masks.iter().find(|m| m.id == id).unwrap().adjust +} + +fn with_effect(s: &mut Session, name: &str, values: Value) { + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + s.execute("mask.adjust", &json!({"values": values})).unwrap(); + s.execute("mask.saveEffect", &json!({"name": name})).unwrap(); + s.execute("mask.delete", &json!({})).unwrap(); +} + +#[test] +fn managing_effects() { + let mut s = Session::with_demo(); + with_effect(&mut s, "Lift", json!({"shadows": 30})); + with_effect(&mut s, "Dark", json!({"exposure": -1})); + // rename (not onto another one's name) + assert!(s.execute("mask.renameEffect", &json!({"name": "lift", "to": "dark"})).is_err()); + assert!(s.execute("mask.renameEffect", &json!({"name": "nope", "to": "x"})).is_err()); + let v = s.execute("mask.renameEffect", &json!({"name": "lift", "to": "Lift Shadows"})).unwrap(); + assert_eq!(names(&v), ["Lift Shadows", "Dark"]); + // duplicate: " copy", then " copy 2" + let v = s.execute("mask.duplicateEffect", &json!({"name": "Dark"})).unwrap(); + assert_eq!(v["name"], "Dark copy"); + let v = s.execute("mask.duplicateEffect", &json!({"name": "Dark"})).unwrap(); + assert_eq!(v["name"], "Dark copy 2"); + assert!(s.execute("mask.duplicateEffect", &json!({"name": "Dark", "to": "dark copy"})).is_err()); + // stars and groups + s.execute("mask.favoriteEffect", &json!({"name": "Dark copy"})).unwrap(); + assert!(s.mask_effects[2].favorite); + s.execute("mask.favoriteEffect", &json!({"name": "Dark copy"})).unwrap(); + assert!(!s.mask_effects[2].favorite, "toggles"); + s.execute("mask.setEffectGroup", &json!({"names": ["Dark", "Dark copy"], "group": "Burn"})).unwrap(); + s.execute("mask.setEffectGroup", &json!({"name": "Dark copy 2", "group": "burn"})).unwrap(); + assert_eq!(s.mask_effects[3].group, "Burn", "an existing group's spelling wins"); + let v = s.execute("mask.effects", &json!({})).unwrap(); + assert_eq!(v["groups"], json!([{"name": "", "effects": ["Lift Shadows"]}, {"name": "Burn", "effects": ["Dark", "Dark copy", "Dark copy 2"]}])); + s.execute("mask.renameEffectGroup", &json!({"group": "BURN", "to": "Darken"})).unwrap(); + assert!(s.mask_effects.iter().skip(1).all(|e| e.group == "Darken")); + assert!(s.execute("mask.renameEffectGroup", &json!({"group": "Burn", "to": "x"})).is_err()); + // update with the selected mask's sliders + s.execute("mask.add", &json!({"kind": "linear"})).unwrap(); + s.execute("mask.adjust", &json!({"values": {"exposure": -2, "clarity": 10}})).unwrap(); + s.execute("mask.updateEffect", &json!({"name": "Dark"})).unwrap(); + let dark = &s.mask_effects[1]; + assert_eq!((dark.adjust.exposure, dark.adjust.clarity, dark.group.as_str()), (-2.0, 10.0, "Darken")); + // delete several (the menu asks first) + s.execute("mask.deleteEffect", &json!({"names": ["Dark copy", "Dark copy 2"]})).unwrap(); + assert!(s.execute("mask.deleteEffect", &json!({"names": ["Dark", "gone"]})).is_err(), "nothing deleted when one is missing"); + assert_eq!(s.mask_effects.len(), 2); + for bad in [json!({}), json!({"name": ""}), json!({"name": 3}), json!({"names": [1, 2]})] { + assert!(s.execute("mask.deleteEffect", &bad).is_err(), "{bad}"); + assert!(s.execute("mask.favoriteEffect", &bad).is_err()); + } +} + +#[test] +fn saving_some_sliders_and_amount() { + let mut s = Session::with_demo(); + s.execute("mask.add", &json!({"kind": "brush"})).unwrap(); + s.execute("mask.adjust", &json!({"values": {"shadows": 25, "texture": -10, "amount": 60}})).unwrap(); + s.execute("mask.saveEffect", &json!({"name": "Only", "fields": "changed", "amount": true, "brush": {"size": 0.1, "flow": 40}})).unwrap(); + let e = &s.mask_effects[0]; + assert_eq!(e.fields, ["shadows", "texture"]); + assert!(e.with_amount); + assert_eq!(e.brush.map(|b| (b.size, b.flow)), Some((0.1, 40.0))); + s.execute("mask.saveEffect", &json!({"name": "Pick", "fields": ["exposure", "bogus"]})).unwrap(); + assert_eq!(s.mask_effects[1].fields, ["exposure"]); + for bad in [ + json!({"name": "x", "fields": []}), + json!({"name": "x", "fields": 3}), + json!({"name": "x", "fields": "some"}), + json!({"name": "x", "brush": 7}), + ] { + assert!(s.execute("mask.saveEffect", &bad).is_err(), "{bad}"); + } + // applying a partial effect leaves the other sliders and sets Amount; it brings its brush + s.execute("mask.adjust", &json!({"values": {"shadows": 0, "texture": 0, "clarity": 50, "amount": 100}})).unwrap(); + let mid = s.active_mask.unwrap(); + let r = s.execute("mask.applyEffect", &json!({"name": "only"})).unwrap(); + assert_eq!(r["brush"]["flow"], 40.0); + let a = mask_adjust(&s, mid); + assert_eq!((a.shadows, a.texture, a.clarity, a.amount), (25.0, -10.0, 50.0, 60.0)); + // a mask that changes nothing has nothing to save as "changed" + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + assert!(s.execute("mask.saveEffect", &json!({"name": "Empty", "fields": "changed"})).is_err()); +} + +#[test] +fn applying_to_several_masks_and_photos_is_one_undo_step() { + let mut s = Session::with_demo(); + with_effect(&mut s, "Bright", json!({"exposure": 1})); + let ids: Vec = s.catalog.photos().take(2).map(|p| p.id.0).collect(); + s.execute("library.select", &json!({"ids": ids, "active": ids[0]})).unwrap(); + for _ in 0..2 { + s.execute("mask.add", &json!({"kind": "radial", "name": "Face"})).unwrap(); + } + s.execute("mask.add", &json!({"kind": "linear", "name": "Sky"})).unwrap(); + let all: Vec = s.develop_of(s.active().unwrap()).unwrap().masks.iter().map(|m| m.id).collect(); + let face = all[all.len() - 3]; + s.execute("mask.applyEffect", &json!({"name": "Bright", "ids": [face, all[all.len() - 2]], "remember": false})).unwrap(); + assert_eq!(mask_adjust(&s, face).exposure, 1.0); + assert_eq!(mask_adjust(&s, *all.last().unwrap()).exposure, 0.0, "only the masks named"); + assert!(s.execute("mask.applyEffect", &json!({"name": "Bright", "ids": [999]})).is_err()); + assert!(s.execute("mask.applyEffect", &json!({"name": "Bright", "ids": []})).is_err()); + s.execute("mask.applyEffect", &json!({"name": "Bright", "all": true, "remember": false})).unwrap(); + assert_eq!(mask_adjust(&s, *all.last().unwrap()).exposure, 1.0); + // the other selected photo's "Face" mask gets the effect too, in the same undo step + s.execute("library.select", &json!({"ids": ids, "active": ids[1]})).unwrap(); + s.execute("mask.add", &json!({"kind": "radial", "name": "face"})).unwrap(); + s.execute("mask.add", &json!({"kind": "radial", "name": "Other"})).unwrap(); + s.execute("library.select", &json!({"ids": ids, "active": ids[0]})).unwrap(); + with_effect(&mut s, "Warm", json!({"temp": 20})); + let r = s.execute("mask.applyEffect", &json!({"name": "Warm", "id": face, "photos": "selected", "remember": false})).unwrap(); + assert_eq!(r["photos"], 2); + let other = s.develop_of(lightcraft_catalog::PhotoId(ids[1])).unwrap(); + assert_eq!(other.masks.iter().map(|m| m.adjust.temp).collect::>(), [20.0, 0.0]); + s.execute("edit.undo", &json!({})).unwrap(); + let other = s.develop_of(lightcraft_catalog::PhotoId(ids[1])).unwrap(); + assert_eq!(other.masks[0].adjust.temp, 0.0, "undone on every photo"); + assert_eq!(mask_adjust(&s, face).temp, 0.0); +} + +#[test] +fn new_masks_start_with_the_last_effect_of_their_tool() { + let mut s = Session::with_demo(); + with_effect(&mut s, "Dodge", json!({"exposure": 0.5})); + with_effect(&mut s, "Grad", json!({"highlights": -40})); + s.execute("mask.add", &json!({"kind": "brush"})).unwrap(); + s.execute("mask.applyEffect", &json!({"name": "Dodge"})).unwrap(); + s.execute("mask.add", &json!({"kind": "linear"})).unwrap(); + s.execute("mask.applyEffect", &json!({"name": "Grad"})).unwrap(); + assert_eq!(s.execute("mask.effects", &json!({})).unwrap()["toolEffects"], json!({"brush": "Dodge", "linear": "Grad"})); + // a new brush mask (painted with no mask selected) starts as Dodge, a new gradient as Grad + s.execute("mask.select", &json!({"id": null})).unwrap(); + s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.2], [0.3, 0.3]]})).unwrap(); + assert_eq!(mask_adjust(&s, s.active_mask.unwrap()).exposure, 0.5); + s.execute("mask.add", &json!({"kind": "linear"})).unwrap(); + assert_eq!(mask_adjust(&s, s.active_mask.unwrap()).highlights, -40.0); + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + assert_eq!(mask_adjust(&s, s.active_mask.unwrap()).exposure, 0.0, "radial has none"); + // set / clear explicitly; renaming and deleting the effect follow + s.execute("mask.toolEffect", &json!({"tool": "radial", "name": "dodge"})).unwrap(); + assert!(s.execute("mask.toolEffect", &json!({"tool": "radial", "name": "nope"})).is_err()); + s.execute("mask.renameEffect", &json!({"name": "Dodge", "to": "Dodge 2"})).unwrap(); + assert_eq!(s.mask_tool_effects.get("brush").map(String::as_str), Some("Dodge 2")); + s.execute("mask.deleteEffect", &json!({"name": "Dodge 2"})).unwrap(); + assert!(!s.mask_tool_effects.contains_key("brush") && !s.mask_tool_effects.contains_key("radial")); + s.execute("mask.toolEffect", &json!({"tool": "linear"})).unwrap(); + assert!(s.mask_tool_effects.is_empty()); +} + +#[test] +fn lightroom_folder_import_groups_by_dividers_and_round_trips() { + let lib = temp_dir("folder"); + let src = lib.join("Local Adjustment Presets"); + std::fs::create_dir_all(src.join("Kit")).unwrap(); + let files: [(&str, String); 7] = [ + ("01 Burn.lrtemplate", lr_local("01 Burn", "\t\texposure2012 = -0.25,")), + ("10 ----- DETAIL ----- 10.lrtemplate", lr_local("10 ----- DETAIL ----- 10", ZERO)), + ("12 Sky.lrtemplate", lr_local("12 Sky | Cloud", "\t\thighlights2012 = -1,\n\t\tgrain = 0.06,")), + ("20 ----- RETOUCHING ----- 20.lrtemplate", lr_local("20 ----- RETOUCHING ----- 20", ZERO)), + ("21 Skin.lrtemplate", lr_local("21 Skin", "\t\tclarity2012 = -1,\n\t\tsharpness = -0.25,")), + ("Kit/Glow.lrtemplate", lr_local("Glow", "\t\tdehaze = -0.2,")), + ("Look.lrtemplate", "s = { title = \"Look\", type = \"Develop\", value = { settings = { Exposure2012 = 0.5 } } }".to_string()), + ]; + for (f, text) in &files { + std::fs::write(src.join(f), text).unwrap(); + } + std::fs::write(src.join("broken.lrtemplate"), "s = { type = \"LocalizedAdjustmentPreset\", value = 5 }").unwrap(); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, true).unwrap(); + // an earlier import that kept the dividers as effects + s.mask_effects.push(crate::cmd::mask_effects::MaskEffect { name: "10 ----- DETAIL ----- 10".into(), ..Default::default() }); + let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 1, "the develop template stays a develop preset"); + assert_eq!(r["effectDividers"].as_array().unwrap().len(), 2); + assert_eq!(r["failed"].as_array().unwrap().len(), 1, "{r}"); + let groups: Vec<(String, String)> = s.mask_effects.iter().map(|e| (e.name.clone(), e.group.clone())).collect(); + assert_eq!( + groups, + [ + ("01 Burn".to_string(), "".to_string()), + ("12 Sky | Cloud".into(), "Detail".into()), + ("21 Skin".into(), "Retouching".into()), + ("Glow".into(), "Kit".into()), + ], + "the old divider effect is gone; folders and dividers give groups" + ); + let sky = &r["effects"].as_array().unwrap().iter().find(|e| e["name"] == "12 Sky | Cloud").unwrap()["unmapped"]; + assert_eq!(sky, &json!(["Grain"])); + // the effects' own import: a dry run changes nothing + let r = s.execute("mask.importEffects", &json!({"path": src.to_string_lossy(), "dryRun": true, "group": "X"})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 4); + assert!(s.mask_effects.iter().all(|e| e.group != "X")); + // export one group, everything; re-import keeps groups, stars and the kept grain + s.execute("mask.favoriteEffect", &json!({"name": "21 Skin"})).unwrap(); + let out = lib.join("backup.lceffect"); + let r = s.execute("mask.exportEffects", &json!({"path": out.to_string_lossy(), "group": "retouching"})).unwrap(); + assert_eq!(r["count"], 1); + assert!(s.execute("mask.exportEffects", &json!({"path": out.to_string_lossy(), "group": "none"})).is_err()); + s.execute("mask.exportEffects", &json!({"path": out.to_string_lossy()})).unwrap(); + let before = s.mask_effects.clone(); + s.execute("mask.deleteEffect", &json!({"names": before.iter().map(|e| e.name.clone()).collect::>()})).unwrap(); + // through the general import too (File ▸ Import Profiles & Presets…, drag & drop) + let r = s.execute("preset.import", &json!({"paths": [out.to_string_lossy()]})).unwrap(); + assert_eq!(r["effects"].as_array().unwrap().len(), 4); + assert_eq!(s.mask_effects, before, "round trip"); + assert_eq!(s.mask_effects[1].extra.get("grain"), Some(&json!(0.06))); + s.close_library().unwrap(); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.mask_effects.len(), 4, "persisted"); + assert!(s.mask_effects[2].favorite); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn zips_and_camera_raw_xmp_presets_import() { + let lib = temp_dir("zip"); + let xmp = r#" + + Mute +"#; + let develop_xmp = r#" +"#; + let brush = lr_local("Brush A", "\t\tshadows2012 = 0.5,"); + let zip = crate::preset_import::tests::zip( + &[("Skin Kit/Brush A.lrtemplate", brush.as_bytes()), ("Skin Kit/Mute.xmp", xmp.as_bytes()), ("Looks/Bright.xmp", develop_xmp.as_bytes())], + true, + ); + let path = lib.join("bundle.zip"); + std::fs::write(&path, zip).unwrap(); + let mut s = Session::with_demo(); + let r = s.execute("preset.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 1, "the develop XMP: {r}"); + assert_eq!(r["effects"].as_array().unwrap().len(), 2); + let got: Vec<(String, String)> = s.mask_effects.iter().map(|e| (e.name.clone(), e.group.clone())).collect(); + assert_eq!(got, [("Brush A".to_string(), "Skin Kit".to_string()), ("Mute".into(), "Skin Kit".into())]); + assert_eq!(s.mask_effects[1].adjust.saturation, -50.0); + // the effects' own import reads the zip and a lone XMP; a develop XMP named on its own fails + let mut s = Session::with_demo(); + let lone = lib.join("Mute.xmp"); + std::fs::write(&lone, xmp).unwrap(); + let dev = lib.join("Bright.xmp"); + std::fs::write(&dev, develop_xmp).unwrap(); + let r = s.execute("mask.importEffects", &json!({"paths": [path.to_string_lossy(), lone.to_string_lossy(), dev.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 3); + assert_eq!(r["failed"].as_array().unwrap().len(), 1); + assert_eq!(s.mask_effects.len(), 2, "same names replaced"); + // unreadable things are errors, never panics + std::fs::write(lib.join("junk.zip"), b"PK\x03\x04\xff\xff").unwrap(); + std::fs::write(lib.join("junk.lceffect"), b"{\"effects\": [{\"name\": \"x\", \"adjust\": {\"exposure\": \"high\"}}]}").unwrap(); + for f in ["junk.zip", "junk.lceffect", "missing.lrtemplate"] { + let r = s.execute("mask.importEffects", &json!({"path": lib.join(f).to_string_lossy()})).unwrap(); + assert_eq!(r["failed"].as_array().unwrap().len(), 1, "{f}: {r}"); + let _ = s.execute("preset.import", &json!({"paths": [lib.join(f).to_string_lossy()]})); + } + assert!(s.execute("mask.importEffects", &json!({})).is_err()); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 494b17ede9..30f39a7086 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -285,7 +285,7 @@ fn mask_effects_import_apply_save_export_and_persist() { let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); assert_eq!(r["imported"].as_array().unwrap().len(), 1, "the develop template stays a develop preset"); assert_eq!(r["effects"][0]["name"], "Brighten"); - assert_eq!(r["effects"][0]["unmapped"], json!(["grain"])); + assert_eq!(r["effects"][0]["unmapped"], json!(["Grain"])); assert_eq!(s.mask_effects.len(), 1); // apply to a mask: every slider set, Amount kept s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77cd..5df4b66bdb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1385,7 +1385,7 @@ pub fn is_model_file(path: &str) -> bool { /// Files dropped on the window that are presets rather than photos. pub fn is_preset_file(path: &str) -> bool { let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - ["lcpreset", "lrtemplate", "xmp", "zip", "cube", "lmp", "mplumpack"].contains(&ext.as_str()) + ["lcpreset", "lrtemplate", "xmp", "zip", "cube", "lmp", "mplumpack", "lceffect"].contains(&ext.as_str()) } /// Splits dropped files into presets and everything else. An `.xmp` dropped next to a photo with the same diff --git a/crates/ui-egui/src/pick.rs b/crates/ui-egui/src/pick.rs index 1032a5cd98..7ee3853ed8 100644 --- a/crates/ui-egui/src/pick.rs +++ b/crates/ui-egui/src/pick.rs @@ -58,7 +58,7 @@ impl PickRequest { } /// What File ▸ Import Profiles & Presets… opens. -pub const PRESET_EXTENSIONS: &[&str] = &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"]; +pub const PRESET_EXTENSIONS: &[&str] = &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube", "lceffect"]; /// What Import Point Curve Presets… opens. pub const CURVE_PRESET_EXTENSIONS: &[&str] = &["lccurve", "json"]; From bb925f6d2ecf957c962470ad3f9718ee20b5ceda Mon Sep 17 00:00:00 2001 From: OneSlips Date: Mon, 12 Oct 2026 00:08:11 +1000 Subject: [PATCH 3/3] LR-MASK-EFFECTS: Effect menu, New Effect Preset dialog and Manage window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Effect menu above the mask sliders is grouped (★ Favorites first, then groups in menu order), searchable, scrolls, works with ↑ ↓ Return, marks the matching effect (else Custom) and previews an effect on the photo while the pointer rests on it. Right-click: apply to all masks or to the same-named mask on the selected photos, update, rename, duplicate, star, move to group, delete (asks first). Reset Sliders; Update "" with Current Settings. New Effect Preset dialog: name, group, which sliders (All / None / Changed Only), Amount, brush size/feather/flow. Manage Effect Presets window: search, inline rename, update with mask, duplicate, stars, groups, export, delete with confirmation. Names padded with runs of spaces (as LrC users do) are shown collapsed. Import toasts name the groups and the settings kept but not applied. New messages translated in every catalog. Docs: parity row and xmp-interop. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de-formats.json | 14 +- crates/ui-egui/locales/es-formats.json | 14 +- crates/ui-egui/locales/fr-formats.json | 14 +- crates/ui-egui/locales/ja-formats.json | 14 +- crates/ui-egui/locales/pt-br-formats.json | 14 +- crates/ui-egui/locales/ru-formats.json | 14 +- crates/ui-egui/locales/uk-formats.json | 14 +- crates/ui-egui/locales/zh-hans-formats.json | 14 +- crates/ui-egui/locales/zh-hant-formats.json | 14 +- crates/ui-egui/src/control.rs | 5 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 51 +- crates/ui-egui/src/panels/dialogs.rs | 32 +- crates/ui-egui/src/panels/mask_effects.rs | 703 ++++++++++++++++++++ crates/ui-egui/src/panels/masking.rs | 52 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/state.rs | 22 + crates/ui-egui/src/tests_mask_effects.rs | 215 ++++++ crates/ui-egui/src/tests_masking.rs | 14 +- docs/parity-checklist.md | 14 +- docs/xmp-interop.md | 34 +- 21 files changed, 1190 insertions(+), 81 deletions(-) create mode 100644 crates/ui-egui/src/panels/mask_effects.rs create mode 100644 crates/ui-egui/src/tests_mask_effects.rs diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 7bcaf9f205..b517767b91 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}", "{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt", "{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt", - "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt" + "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", + " in {g} group{}": " in {g} Gruppe(n){:.0}", + " · kept, not applied: {}": " · behalten, nicht angewendet: {}", + " — kept but not applied: {}": " — behalten, aber nicht angewendet: {}", + "Apply to “{mask}” on Selected Photos": "Auf „{mask}“ in den ausgewählten Fotos anwenden", + "Delete the effect preset “{name}”?": "Effektvorgabe „{name}“ löschen?", + "Delete these {n} effect presets?": "Diese {n} Effektvorgaben löschen?", + "Delete {n} effect preset{}? This can't be undone.": "{n} Effektvorgabe(n) löschen?{:.0} Dies kann nicht rückgängig gemacht werden.", + "Effect: {name}": "Effekt: {name}", + "Exported {} effect preset{}": "Exportierte Effektvorgaben: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} Maskeneffekt(e) importiert{:.0} (Maskieren → Effekt)", + "Update “{name}” with Current Settings": "„{name}“ mit aktuellen Einstellungen aktualisieren", + "{n} preset{}": "{n} Vorgabe(n){:.0}" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 3dd94a934e..0bdf189c9f 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple", "{n} added to Apple Photos": "{n} añadidas a Fotos de Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", + " in {g} group{}": " en {g} grupo(s){:.0}", + " · kept, not applied: {}": " · conservado, no aplicado: {}", + " — kept but not applied: {}": " — se conservan pero no se aplican: {}", + "Apply to “{mask}” on Selected Photos": "Aplicar a «{mask}» en las fotos seleccionadas", + "Delete the effect preset “{name}”?": "¿Eliminar el ajuste preestablecido de efecto «{name}»?", + "Delete these {n} effect presets?": "¿Eliminar estos {n} ajustes preestablecidos de efecto?", + "Delete {n} effect preset{}? This can't be undone.": "¿Eliminar {n} ajuste(s) preestablecido(s) de efecto?{:.0} No se puede deshacer.", + "Effect: {name}": "Efecto: {name}", + "Exported {} effect preset{}": "Ajustes de efecto exportados: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} efecto(s) de máscara importado(s){:.0} (Máscaras → Efecto)", + "Update “{name}” with Current Settings": "Actualizar «{name}» con los ajustes actuales", + "{n} preset{}": "{n} ajuste(s){:.0}" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 65cf973118..1012a9eea4 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}", "{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »", "{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos", - "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original" + "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", + " in {g} group{}": " dans {g} groupe(s){:.0}", + " · kept, not applied: {}": " · conservé, non appliqué : {}", + " — kept but not applied: {}": " — conservés mais non appliqués : {}", + "Apply to “{mask}” on Selected Photos": "Appliquer à « {mask} » sur les photos sélectionnées", + "Delete the effect preset “{name}”?": "Supprimer le paramètre prédéfini d’effet « {name} » ?", + "Delete these {n} effect presets?": "Supprimer ces {n} paramètres prédéfinis d’effet ?", + "Delete {n} effect preset{}? This can't be undone.": "Supprimer {n} paramètre(s) prédéfini(s) d’effet ?{:.0} Cette action est irréversible.", + "Effect: {name}": "Effet : {name}", + "Exported {} effect preset{}": "Paramètres d’effet exportés : {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} effet(s) de masque importé(s){:.0} (Masquage → Effet)", + "Update “{name}” with Current Settings": "Mettre à jour « {name} » avec les réglages actuels", + "{n} preset{}": "{n} paramètre(s){:.0}" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ca286386dc..137c3775e5 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}", "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", "{n} added to Apple Photos": "{n}件をAppleの写真に追加しました", - "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています" + "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", + " in {g} group{}": "({g}グループ){:.0}", + " · kept, not applied: {}": " · 保持(適用なし):{}", + " — kept but not applied: {}": " — 保持(適用なし):{}", + "Apply to “{mask}” on Selected Photos": "選択した写真の「{mask}」に適用", + "Delete the effect preset “{name}”?": "効果プリセット「{name}」を削除しますか?", + "Delete these {n} effect presets?": "これらの効果プリセット{n}件を削除しますか?", + "Delete {n} effect preset{}? This can't be undone.": "効果プリセット{n}件を削除しますか?{:.0}元に戻せません。", + "Effect: {name}": "効果:{name}", + "Exported {} effect preset{}": "効果プリセットを{}件書き出しました{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "マスク効果を{e}件読み込みました{:.0}(マスク → 効果)", + "Update “{name}” with Current Settings": "「{name}」を現在の設定で更新", + "{n} preset{}": "{n}件のプリセット{:.0}" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 0b3ebe80d4..65e69e9a85 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple", "{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", + " in {g} group{}": " em {g} grupo(s){:.0}", + " · kept, not applied: {}": " · mantido, não aplicado: {}", + " — kept but not applied: {}": " — mantidos, mas não aplicados: {}", + "Apply to “{mask}” on Selected Photos": "Aplicar a “{mask}” nas fotos selecionadas", + "Delete the effect preset “{name}”?": "Excluir a predefinição de efeito “{name}”?", + "Delete these {n} effect presets?": "Excluir estas {n} predefinições de efeito?", + "Delete {n} effect preset{}? This can't be undone.": "Excluir {n} predefinição(ões) de efeito?{:.0} Não é possível desfazer.", + "Effect: {name}": "Efeito: {name}", + "Exported {} effect preset{}": "Predefinições de efeito exportadas: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} efeito(s) de máscara importado(s){:.0} (Máscaras → Efeito)", + "Update “{name}” with Current Settings": "Atualizar “{name}” com as configurações atuais", + "{n} preset{}": "{n} predefinição(ões){:.0}" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 11651aaa0f..1facff2a1d 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", "{n} added to Apple Photos": "Добавлено в Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом" + "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", + " in {g} group{}": " в группах: {g}{:.0}", + " · kept, not applied: {}": " · сохранено, не применяется: {}", + " — kept but not applied: {}": " — сохранено, но не применяется: {}", + "Apply to “{mask}” on Selected Photos": "Применить к «{mask}» на выбранных фото", + "Delete the effect preset “{name}”?": "Удалить пресет эффекта «{name}»?", + "Delete these {n} effect presets?": "Удалить эти пресеты эффектов ({n})?", + "Delete {n} effect preset{}? This can't be undone.": "Удалить пресеты эффектов ({n})?{:.0} Это нельзя отменить.", + "Effect: {name}": "Эффект: {name}", + "Exported {} effect preset{}": "Экспортировано пресетов эффектов: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Импортировано эффектов маски: {e}{:.0} (Маски → Эффект)", + "Update “{name}” with Current Settings": "Обновить «{name}» текущими настройками", + "{n} preset{}": "Пресетов: {n}{:.0}" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 7aca485e0f..5eb36d0334 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "не додано до Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", "{n} added to Apple Photos": "Додано до Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом" + "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", + " in {g} group{}": " у групах: {g}{:.0}", + " · kept, not applied: {}": " · збережено, не застосовується: {}", + " — kept but not applied: {}": " — збережено, але не застосовується: {}", + "Apply to “{mask}” on Selected Photos": "Застосувати до «{mask}» на вибраних фото", + "Delete the effect preset “{name}”?": "Видалити пресет ефекту «{name}»?", + "Delete these {n} effect presets?": "Видалити ці пресети ефектів ({n})?", + "Delete {n} effect preset{}? This can't be undone.": "Видалити пресети ефектів ({n})?{:.0} Це не можна скасувати.", + "Effect: {name}": "Ефект: {name}", + "Exported {} effect preset{}": "Експортовано пресетів ефектів: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Імпортовано ефектів маски: {e}{:.0} (Маски → Ефект)", + "Update “{name}” with Current Settings": "Оновити «{name}» поточними налаштуваннями", + "{n} preset{}": "Пресетів: {n}{:.0}" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index bf27ca6b2c..e57dd2a459 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", "{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起" + "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", + " in {g} group{}": "({g} 个分组){:.0}", + " · kept, not applied: {}": " · 已保留,未应用:{}", + " — kept but not applied: {}": " — 已保留但未应用:{}", + "Apply to “{mask}” on Selected Photos": "应用到所选照片的“{mask}”", + "Delete the effect preset “{name}”?": "删除效果预设“{name}”?", + "Delete these {n} effect presets?": "删除这 {n} 个效果预设?", + "Delete {n} effect preset{}? This can't be undone.": "删除 {n} 个效果预设?{:.0}此操作无法撤销。", + "Effect: {name}": "效果:{name}", + "Exported {} effect preset{}": "已导出 {} 个效果预设{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "已导入 {e} 个蒙版效果{:.0}(蒙版 → 效果)", + "Update “{name}” with Current Settings": "用当前设置更新“{name}”", + "{n} preset{}": "{n} 个预设{:.0}" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 37b2db2ba9..a30e6d5956 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", "{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起" + "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", + " in {g} group{}": "({g} 個群組){:.0}", + " · kept, not applied: {}": " · 已保留,未套用:{}", + " — kept but not applied: {}": " — 已保留但未套用:{}", + "Apply to “{mask}” on Selected Photos": "套用到所選照片的「{mask}」", + "Delete the effect preset “{name}”?": "刪除效果預設集「{name}」?", + "Delete these {n} effect presets?": "刪除這 {n} 個效果預設集?", + "Delete {n} effect preset{}? This can't be undone.": "刪除 {n} 個效果預設集?{:.0}此動作無法復原。", + "Effect: {name}": "效果:{name}", + "Exported {} effect preset{}": "已匯出 {} 個效果預設集{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "已匯入 {e} 個遮色片效果{:.0}(遮色片 → 效果)", + "Update “{name}” with Current Settings": "以目前設定更新「{name}」", + "{n} preset{}": "{n} 個預設集{:.0}" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cf2dda385b..1f68507ab2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -364,7 +364,10 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest if (r.is_err() && matches!( d, - crate::state::Dialog::Import { .. } | crate::state::Dialog::SamModel { .. } | crate::state::Dialog::SmartRules { .. } + crate::state::Dialog::Import { .. } + | crate::state::Dialog::SamModel { .. } + | crate::state::Dialog::SmartRules { .. } + | crate::state::Dialog::SaveEffect { .. } )) || (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d)) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5df4b66bdb..7ee7b7dbde 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -88,6 +88,8 @@ mod tests_library_problem; #[cfg(test)] mod tests_lights_out; #[cfg(test)] +mod tests_mask_effects; +#[cfg(test)] mod tests_masking; #[cfg(test)] mod tests_masking_layout; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 81811fe0e6..293527f13c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -184,6 +184,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), ("file.importPresets", "Import Profiles & Presets…", None, "File"), ("file.exportPresets", "Export Presets…", None, "File"), + // Masking panel ▸ Effect menu + ("dialog.saveMaskEffect", "Save Current Settings as New Effect Preset…", None, ""), + ("dialog.manageMaskEffects", "Manage Effect Presets…", None, ""), + ("file.exportMaskEffects", "Export Effect Presets…", None, ""), ("file.importKeywords", "Import Keywords…", None, "File"), ("file.exportKeywords", "Export Keywords…", None, "File"), // Edit panel ▸ Curve ▸ Point Curve dropdown @@ -1562,7 +1566,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option 0 { - parts.push(crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", if effects == 1 { "" } else { "s" }, e = effects)); + parts.push(crate::panels::mask_effects::import_toast(v)); } let profile_news = !parts.is_empty() || !problems.is_empty(); let msg = match (n, failed) { @@ -1696,6 +1700,49 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + if app.session.active_mask.is_none() { + return Some(Err("select a mask first".into())); + } + crate::panels::mask_effects::open_save(app); + Ok(Value::Null) + } + "dialog.manageMaskEffects" => { + crate::panels::mask_effects::open_manage(app); + Ok(Value::Null) + } + "file.exportMaskEffects" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let name = format!("{}.lceffect", p.get("group").and_then(Value::as_str).unwrap_or("Effect Presets")); + let req = PickRequest::save( + crate::i18n::tr("Export Effect Presets"), + crate::i18n::tr("LightCraft Effect Presets"), + &["lceffect"], + name.clone(), + ); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + let Some(path) = path else { return Some(Ok(Value::Null)) }; + let mut params = json!({"path": path}); + for k in ["names", "group"] { + if let Some(v) = p.get(k) { + params[k] = v.clone(); + } + } + let r = app.session.execute("mask.exportEffects", ¶ms).map_err(|e| e.to_string()); + match &r { + Ok(v) => app.toast(&ctx, crate::i18n::tr_format!("Exported {} effect preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })), + Err(e) => app.toast_error(&ctx, e.clone()), + } + return Some(r); + } "file.exportPresets" => { let group = p.get("group").and_then(Value::as_str).map(str::to_string); let path = match p.get("path").and_then(Value::as_str) { @@ -1821,6 +1868,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) } "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), + "file.exportMaskEffects" => !app.session.mask_effects.is_empty(), + "dialog.saveMaskEffect" => app.session.active().is_some() && app.session.active_mask.is_some(), "file.exportCurvePresets" => !app.session.curve_presets.is_empty(), "view.compare" => app.session.catalog.len() > 1, "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca99..c14d086d5c 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -255,6 +255,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog", + Dialog::SaveEffect { .. } => "New Effect Preset", + Dialog::ManageEffects { .. } => "Manage Effect Presets", + Dialog::DeleteEffects { names } if names.len() == 1 => "Delete Effect Preset", + Dialog::DeleteEffects { .. } => "Delete Effect Presets", Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library", Dialog::RemoveFolder { .. } => "Remove Folder from Library", Dialog::SynchronizeFolder { .. } => "Synchronize Folder", @@ -291,6 +295,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::AllMetadata { .. } => 620.0, Dialog::FaceModel { .. } => 460.0, Dialog::LightroomCatalog { .. } => 440.0, + Dialog::ManageEffects { .. } => 560.0, _ => 380.0, }) .show(ctx, |ui| { @@ -1088,6 +1093,22 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .color(t.text_dim), ); } + Dialog::SaveEffect { name, group, fields, amount, brush } => { + if crate::panels::mask_effects::save_body(app, ui, name, group, fields, amount, brush) { + confirm = true; + } + } + Dialog::ManageEffects { search, selected, rename, confirm_delete, new_group } => { + crate::panels::mask_effects::manage_body(app, ui, search, selected, rename, confirm_delete, new_group) + } + Dialog::DeleteEffects { names } => { + let what = match names.as_slice() { + [one] => crate::i18n::tr_format!("Delete the effect preset “{name}”?", name = one), + _ => crate::i18n::tr_format!("Delete these {n} effect presets?", n = names.len()), + }; + ui.label(what); + ui.label(egui::RichText::new(crate::i18n::tr("Masks keep their sliders. Export presets first to keep a copy.")).color(t.text_dim)); + } Dialog::ConfirmDelete { count } => { let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); @@ -1177,6 +1198,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { || matches!( dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew + | Dialog::ManageEffects { .. } ); let sam = &app.session.segmenter; let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some()); @@ -1208,7 +1230,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::DenoiseModel { .. } => "Install", Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", Dialog::FaceModel { .. } if !informational => "Install", - Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } => "Delete", + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete", + Dialog::SaveEffect { .. } => "Create", // (not "Merge": that is photo merging, HDR / panorama, in some languages) Dialog::MoveKeyword { .. } => "Merge Keywords", Dialog::KeywordSet { .. } => "Save", @@ -1282,6 +1305,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { | Dialog::KeywordTag { .. } | Dialog::KeywordSet { .. } | Dialog::SynchronizeFolder { .. } + | Dialog::SaveEffect { .. } ) => { app.toast(ctx, e) @@ -1680,9 +1704,13 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) } + Dialog::SaveEffect { name, group, fields, amount, brush } => { + crate::panels::mask_effects::save_confirm(app, name, group, fields, *amount, *brush) + } + Dialog::DeleteEffects { names } => app.run("mask.deleteEffect", json!({"names": names})), Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})), d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d), - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null), + Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::ManageEffects { .. } => Ok(serde_json::Value::Null), } } diff --git a/crates/ui-egui/src/panels/mask_effects.rs b/crates/ui-egui/src/panels/mask_effects.rs new file mode 100644 index 0000000000..505dde129a --- /dev/null +++ b/crates/ui-egui/src/panels/mask_effects.rs @@ -0,0 +1,703 @@ +//! Mask effect presets in the UI: the Effect menu above a mask's sliders (grouped, searchable, +//! keyboard-driven, previews the effect on the photo while the pointer rests on it), the +//! New Effect Preset dialog (which sliders, Amount, brush settings) and the Manage Effect Presets +//! window (search, rename, update, duplicate, stars, groups, delete). Every change runs a +//! `mask.*Effect` command. + +use egui::{Sense, vec2}; +use lightcraft_develop::Mask; +use lightcraft_engine::cmd::mask_effects::{self as fx, FIELDS, MaskEffect}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::widgets::register; + +/// A slider's label in the effect lists (the colour tint's hue and saturation are one "Color"). +pub fn field_label(k: &str) -> &'static str { + match k { + "color_hue" | "color_sat" => "Color", + _ => super::masking::LOCAL.iter().find(|s| s.id == k).map_or("", |s| s.label), + } +} + +/// The choices of the New Effect Preset checklist: each slider once (Color = hue + saturation). +fn checklist() -> Vec<&'static str> { + FIELDS.iter().copied().filter(|k| *k != "color_hue").collect() +} + +/// "Exposure +0.50 · Clarity −20" — what an effect sets, for the lists. +pub fn summary(e: &MaskEffect) -> String { + let fields = e.field_list(); + let mut parts: Vec = fx::changed_fields(&e.adjust) + .iter() + .filter(|k| fields.contains(&k.as_str()) && k.as_str() != "color_hue") + .map(|k| { + let v = fx::get(&e.adjust, k); + match k.as_str() { + "exposure" => format!("{} {:+.2}", crate::i18n::tr(field_label(k)), v), + "color_sat" => format!("{} {:.0}°/{:.0}", crate::i18n::tr("Color"), e.adjust.color_hue, v), + _ => format!("{} {:+.0}", crate::i18n::tr(field_label(k)), v), + } + }) + .collect(); + if e.with_amount { + parts.push(format!("{} {:.0}", crate::i18n::tr("Amount"), e.adjust.amount)); + } + if parts.is_empty() { + parts.push(crate::i18n::tr("No change").to_string()); + } + parts.join(" · ").replace('-', "−") +} + +/// An effect's name as shown: runs of spaces collapsed (Lightroom users pad names to line up +/// parts of them in its menu; the stored name keeps them). +pub fn display_name(name: &str) -> String { + name.split_whitespace().collect::>().join(" ") +} + +/// The effect the selected mask's tool last applied (what Update / Rename in the menu act on). +fn last_applied(app: &LightcraftApp, mask: &Mask) -> Option { + let tool = fx::mask_tool(mask)?; + let name = app.session.mask_tool_effects.get(&tool)?; + app.session.mask_effects.iter().find(|e| e.name.eq_ignore_ascii_case(name)).map(|e| e.name.clone()) +} + +/// Apply an effect to `mask` (and take the brush settings it carries). +pub fn apply(app: &mut LightcraftApp, ctx: &egui::Context, name: &str, params: Value) { + let mut p = params; + p["name"] = json!(name); + match app.run("mask.applyEffect", p) { + Ok(v) => take_brush(app, &v["brush"]), + Err(e) => app.toast_error(ctx, e), + } +} + +fn take_brush(app: &mut LightcraftApp, b: &Value) { + let Some(b) = b.as_object() else { return }; + let f = |k: &str| b.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(v) = f("size") { + app.ui.brush_size = (v as f32).clamp(0.002, 0.5); + } + if let Some(v) = f("feather") { + app.ui.brush_feather = (v as f32).clamp(0.0, 100.0); + } + if let Some(v) = f("flow") { + app.ui.brush_flow = (v as f32).clamp(1.0, 100.0); + } + if let Some(v) = b.get("auto_mask").and_then(Value::as_bool) { + app.ui.brush_auto_mask = v; + } +} + +/// The brush settings in use, as an effect carries them. +fn brush_now(app: &LightcraftApp) -> Value { + json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, "auto_mask": app.ui.brush_auto_mask}) +} + +/// Open the New Effect Preset dialog for the selected mask. +pub fn open_save(app: &mut LightcraftApp) { + let brush = app.ui.tool == "brush"; + app.ui.dialog = Some(Dialog::SaveEffect { + name: String::new(), + group: String::new(), + fields: checklist().iter().map(|k| k.to_string()).collect(), + amount: false, + brush, + }); +} + +/// Open the Manage Effect Presets window. +pub fn open_manage(app: &mut LightcraftApp) { + app.ui.dialog = + Some(Dialog::ManageEffects { search: String::new(), selected: Vec::new(), rename: None, confirm_delete: false, new_group: String::new() }); +} + +/// The menu's search text and keyboard highlight. +#[derive(Clone, Default)] +struct MenuState { + search: String, + highlight: Option, + focused: bool, +} + +fn state_id() -> egui::Id { + egui::Id::new("maskEffectMenuState") +} + +/// The effects the menu lists for `query`: (section heading, effect name, its row widget id). +fn menu_rows(effects: &[MaskEffect], query: &str) -> Vec<(String, String, String)> { + let q = query.trim().to_lowercase(); + let hit = |e: &MaskEffect| q.is_empty() || e.name.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q); + let mut rows = Vec::new(); + if q.is_empty() { + let mut favs: Vec<&MaskEffect> = effects.iter().filter(|e| e.favorite).collect(); + favs.sort_by(|a, b| fx::natural_cmp(&a.name, &b.name)); + rows.extend(favs.into_iter().map(|e| ("★ Favorites".to_string(), e.name.clone(), format!("maskEffectFav:{}", e.name)))); + } + for (group, ix) in fx::menu_order(effects) { + for e in ix.iter().filter_map(|i| effects.get(*i)).filter(|e| hit(e)) { + rows.push((group.clone(), e.name.clone(), format!("maskEffect:{}", e.name))); + } + } + rows +} + +/// The Effect menu above the mask's sliders. +pub fn effect_row(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask) { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Effect")).font(t.font(12.0)).color(t.text_label)); + let current = fx::matching_effect(&app.session, &mask.adjust); + let label = current.as_deref().map(display_name).unwrap_or_else(|| crate::i18n::tr("Custom").to_string()); + let r = crate::widgets::dropdown(ui, "maskEffect", &label, t.font(12.0), t.text); + if r.clicked() { + ui.data_mut(|d| d.insert_temp(state_id(), MenuState::default())); + } + egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| menu(app, ui, mask, current.as_deref())); + }); + }); +} + +fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, current: Option<&str>) { + let t = Tokens::get(ui.ctx()); + ui.set_min_width(260.0); + ui.set_max_width(320.0); + let mut st: MenuState = ui.data(|d| d.get_temp(state_id())).unwrap_or_default(); + let before = st.search.clone(); + let r = crate::text_field::TextField::singleline("maskEffectSearch", &mut st.search) + .hint(crate::i18n::tr("Search effects")) + .width(f32::INFINITY) + .show(ui); + if !st.focused { + r.response.request_focus(); + st.focused = true; + } + if st.search != before { + st.highlight = None; + } + let rows = menu_rows(&app.session.mask_effects, &st.search); + // ↑ ↓ move through the list, Return applies + let (down, up, enter) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp), + i.key_pressed(egui::Key::Enter) && !rows.is_empty(), + ) + }); + let moved = down || up; + if !rows.is_empty() { + let last = rows.len() - 1; + st.highlight = match (st.highlight, down, up) { + (None, true, _) => Some(0), + (None, _, true) => Some(last), + (Some(h), true, _) => Some((h + 1).min(last)), + (Some(h), _, true) => Some(h.saturating_sub(1)), + (h, _, _) => h.filter(|h| *h <= last), + }; + } + let mut chosen: Option = None; + if enter { + chosen = rows.get(st.highlight.unwrap_or(0)).map(|(_, n, _)| n.clone()); + } + let mut hovered: Option = None; + if app.session.mask_effects.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("No effect presets yet")).color(t.text_dim)); + } else if rows.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("No effect matches")).color(t.text_dim)); + } + egui::ScrollArea::vertical().id_salt("maskEffectList").max_height(420.0).auto_shrink([false, true]).show(ui, |ui| { + let mut section: Option<&str> = None; + for (i, (group, name, widget)) in rows.iter().enumerate() { + if section != Some(group.as_str()) { + section = Some(group.as_str()); + if !group.is_empty() { + ui.add_space(4.0); + let heading = if group == "★ Favorites" { crate::i18n::tr("★ Favorites").to_string() } else { group.clone() }; + ui.label(egui::RichText::new(heading).font(t.semibold(11.5)).color(t.text_dim)); + } + } + let fav = app.session.mask_effects.iter().any(|e| e.favorite && &e.name == name); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + // (rows scrolled out of view can't be clicked) + if ui.is_rect_visible(rect) { + register(ui.ctx(), widget.clone(), rect); + } + let lit = st.highlight == Some(i) || resp.hovered(); + if lit { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + if current == Some(name.as_str()) { + ui.painter().text(rect.left_center() + vec2(4.0, 0.0), egui::Align2::LEFT_CENTER, "✓", t.font(12.0), t.accent); + } + let room = rect.width() - 22.0 - if fav { 16.0 } else { 0.0 }; + let g = crate::widgets::one_line(ui.painter(), &display_name(name), t.font(12.5), t.text, room); + let elided = g.elided; + ui.painter().galley(rect.left_center() + vec2(18.0, -g.size().y / 2.0), g, t.text); + if fav { + ui.painter().text(rect.right_center() - vec2(4.0, 0.0), egui::Align2::RIGHT_CENTER, "★", t.font(11.0), t.text_dim); + } + if moved && st.highlight == Some(i) { + resp.scroll_to_me(None); + } + let resp = if elided { resp.on_hover_text(display_name(name)) } else { resp }; + if resp.hovered() || (st.highlight == Some(i) && moved) { + hovered = Some(name.clone()); + } + if resp.clicked() { + chosen = Some(name.clone()); + } + resp.context_menu(|ui| row_menu(app, ui, mask, name, fav)); + } + }); + // resting on an effect previews it on the photo (no history entry) + if let Some(name) = hovered.or_else(|| st.highlight.and_then(|h| rows.get(h)).map(|(_, n, _)| n.clone())) { + preview(app, mask, &name); + } + ui.data_mut(|d| d.insert_temp(state_id(), st)); + if let Some(name) = chosen { + apply(app, ui.ctx(), &name, json!({"id": mask.id})); + ui.close(); + return; + } + ui.separator(); + let item = |ui: &mut egui::Ui, id: &str, text: String, enabled: bool| { + let r = ui.add_enabled(enabled, egui::Button::new(text).frame(false)); + register(ui.ctx(), id.to_string(), r.rect); + r.clicked() + }; + if item(ui, "maskEffectMenu:reset", crate::i18n::tr("Reset Sliders").to_string(), true) { + let zero: serde_json::Map = FIELDS.iter().map(|k| (k.to_string(), json!(0.0))).collect(); + let _ = app.run("mask.adjust", json!({"id": mask.id, "values": zero})); + if let Some(tool) = fx::mask_tool(mask) { + let _ = app.run("mask.toolEffect", json!({"tool": tool})); + } + ui.close(); + } + if item(ui, "maskEffectMenu:save", crate::i18n::tr("Save Current Settings as New Preset…").to_string(), true) { + open_save(app); + ui.close(); + } + if let Some(last) = last_applied(app, mask) { + let text = crate::i18n::tr_format!("Update “{name}” with Current Settings", name = last); + if item(ui, "maskEffectMenu:update", text, current != Some(last.as_str())) { + let _ = app.run("mask.updateEffect", json!({"name": last, "id": mask.id})); + ui.close(); + } + } + if item(ui, "maskEffectMenu:manage", crate::i18n::tr("Manage Presets…").to_string(), true) { + open_manage(app); + ui.close(); + } + if item(ui, "maskEffectMenu:import", crate::i18n::tr("Import Presets…").to_string(), app.services.pick_preset_files.is_some()) { + let _ = app.run("file.importPresets", json!({})); + ui.close(); + } + let any = !app.session.mask_effects.is_empty(); + if item(ui, "maskEffectMenu:export", crate::i18n::tr("Export Presets…").to_string(), any && app.services.save_preset_file.is_some()) { + let _ = app.run("file.exportMaskEffects", json!({})); + ui.close(); + } +} + +/// The photo with `name` applied to `mask`, shown while the pointer rests on it. +fn preview(app: &mut LightcraftApp, mask: &Mask, name: &str) { + let Some(id) = app.session.active() else { return }; + let Some(e) = app.session.mask_effects.iter().find(|e| e.name == name) else { return }; + let Some(d) = app.session.develop_of(id) else { return }; + let mut d = (*d).clone(); + let Some(m) = d.masks.iter_mut().find(|m| m.id == mask.id) else { return }; + m.adjust = e.apply(&m.adjust); + app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Effect: {name}", name = display_name(name)), settings: d }); +} + +/// The right-click menu of an effect in the Effect menu. +fn row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, name: &str, fav: bool) { + let run = |app: &mut LightcraftApp, ui: &mut egui::Ui, text: &str, cmd: &str, p: Value| { + let r = ui.button(crate::i18n::tr(text)); + register(ui.ctx(), format!("maskEffectRow:{text}"), r.rect); + if r.clicked() { + if let Err(e) = app.run(cmd, p) { + app.toast_error(ui.ctx(), e); + } + ui.close(); + } + }; + let masks = app.session.active().and_then(|id| app.session.develop_of(id)).map_or(0, |d| d.masks.len()); + if ui.add_enabled(masks > 1, egui::Button::new(crate::i18n::tr("Apply to All Masks"))).clicked() { + apply(app, ui.ctx(), name, json!({"all": true})); + ui.close(); + } + if app.session.selection.ids.len() > 1 { + let text = crate::i18n::tr_format!("Apply to “{mask}” on Selected Photos", mask = mask.name); + if ui.button(text).on_hover_text(crate::i18n::tr("Also the masks of the same name on the other selected photos")).clicked() { + apply(app, ui.ctx(), name, json!({"id": mask.id, "photos": "selected"})); + ui.close(); + } + } + ui.separator(); + run(app, ui, "Update with Current Settings", "mask.updateEffect", json!({"name": name, "id": mask.id})); + if ui.button(crate::i18n::tr("Rename…")).clicked() { + crate::panels::dialogs::prompt(app, "Rename Effect Preset", "Preset name", name, "mask.renameEffect", json!({"name": name}), "to"); + ui.close(); + } + run(app, ui, "Duplicate", "mask.duplicateEffect", json!({"name": name})); + run(app, ui, if fav { "Remove from Favorites" } else { "Add to Favorites" }, "mask.favoriteEffect", json!({"name": name, "favorite": !fav})); + ui.menu_button(crate::i18n::tr("Move to Group"), |ui| group_menu(app, ui, &[name.to_string()])); + ui.separator(); + if ui.button(crate::i18n::tr("Delete…")).clicked() { + app.ui.dialog = Some(Dialog::DeleteEffects { names: vec![name.to_string()] }); + ui.close(); + } +} + +/// The groups to move effects `names` to, None, and New Group…. +fn group_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, names: &[String]) { + let groups: Vec = fx::menu_order(&app.session.mask_effects).into_iter().map(|(g, _)| g).filter(|g| !g.is_empty()).collect(); + for g in groups.iter().map(String::as_str).chain(std::iter::once("")) { + let label = if g.is_empty() { crate::i18n::tr("None").to_string() } else { g.to_string() }; + if ui.button(label).clicked() { + let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": g})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::i18n::tr("New Group…")).clicked() { + let names = json!({"names": names}); + crate::panels::dialogs::prompt(app, "New Effect Group", "Group name", "", "mask.setEffectGroup", names, "group"); + ui.close(); + } +} + +/// The New Effect Preset dialog's body. +pub fn save_body( + app: &LightcraftApp, + ui: &mut egui::Ui, + name: &mut String, + group: &mut String, + fields: &mut Vec, + amount: &mut bool, + brush: &mut bool, +) -> bool { + let t = Tokens::get(ui.ctx()); + let r = crate::text_field::TextField::singleline("field:effectName", name).hint(crate::i18n::tr("Preset name")).width(f32::INFINITY).show(ui); + if !r.editing && name.is_empty() && ui.memory(|m| m.focused().is_none()) { + r.response.request_focus(); + } + let enter = r.ending == Some(crate::text_field::Ending::Return); + ui.horizontal(|ui| { + crate::text_field::TextField::singleline("field:effectGroup", group) + .hint(crate::i18n::tr("Group (optional)")) + .width(ui.available_width() - 30.0) + .show(ui); + let pick = ui.small_button("…").on_hover_text(crate::i18n::tr("Choose a group")); + register(ui.ctx(), "button:effectGroupPick", pick.rect); + egui::Popup::menu(&pick).show(|ui| { + for (g, _) in fx::menu_order(&app.session.mask_effects) { + if ui.button(if g.is_empty() { crate::i18n::tr("None").to_string() } else { g.clone() }).clicked() { + *group = g; + } + } + }); + }); + ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim)); + let adjust = app + .session + .active() + .and_then(|id| app.session.develop_of(id)) + .and_then(|d| app.session.active_mask.and_then(|mid| d.masks.iter().find(|m| m.id == mid).map(|m| m.adjust))); + let changed = adjust.as_ref().map(fx::changed_fields).unwrap_or_default(); + let list = checklist(); + ui.columns(2, |cols| { + for (i, k) in list.iter().enumerate() { + let mut on = fields.iter().any(|f| f == k); + let text = egui::RichText::new(crate::i18n::tr(field_label(k))); + // the sliders this mask changes stand out; the others are dimmed + let text = if changed.iter().any(|c| c == k) { text.color(t.text).strong() } else { text.color(t.text_dim) }; + let r = cols[i % 2].checkbox(&mut on, text); + register(&r.ctx, format!("effectInclude:{k}"), r.rect); + if r.changed() { + fields.retain(|f| f != k); + if on { + fields.push(k.to_string()); + } + } + } + }); + ui.horizontal(|ui| { + for (id, text) in [("all", "All"), ("none", "None"), ("changed", "Changed Only")] { + let b = ui.small_button(crate::i18n::tr(text)); + register(ui.ctx(), format!("effectInclude:{id}"), b.rect); + if b.clicked() { + *fields = match id { + "all" => list.iter().map(|k| k.to_string()).collect(), + "none" => Vec::new(), + _ => changed.iter().filter(|k| k.as_str() != "color_hue").cloned().collect(), + }; + } + } + }); + ui.label( + egui::RichText::new(crate::i18n::tr("Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied.")) + .color(t.text_dim) + .size(11.0), + ); + let r = ui.checkbox(amount, crate::i18n::tr("Include Amount")); + register(ui.ctx(), "check:effectAmount", r.rect); + let r = ui.checkbox(brush, crate::i18n::tr("Include brush size, feather and flow")); + register(ui.ctx(), "check:effectBrush", r.rect); + enter +} + +/// Save from the New Effect Preset dialog. +pub fn save_confirm(app: &mut LightcraftApp, name: &str, group: &str, fields: &[String], amount: bool, brush: bool) -> Result { + if name.trim().is_empty() { + return Err(crate::i18n::tr("Name the preset").to_string()); + } + if fields.is_empty() { + return Err(crate::i18n::tr("Choose at least one setting to include").to_string()); + } + let mut keep: Vec = fields.to_vec(); + if keep.iter().any(|f| f == "color_sat") { + keep.push("color_hue".into()); + } + let all = checklist().iter().all(|k| keep.iter().any(|f| f == k)); + let mut p = json!({"name": name.trim(), "group": group.trim(), "amount": amount}); + p["fields"] = if all { json!("all") } else { json!(keep) }; + if brush { + p["brush"] = brush_now(app); + } + app.run("mask.saveEffect", p) +} + +/// The Manage Effect Presets window's body. +pub fn manage_body( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + search: &mut String, + selected: &mut Vec, + rename: &mut Option<(String, String)>, + confirm_delete: &mut bool, + new_group: &mut String, +) { + let t = Tokens::get(ui.ctx()); + ui.set_min_width(500.0); + // effects that went away (deleted, renamed) leave the selection + selected.retain(|n| app.session.mask_effects.iter().any(|e| &e.name == n)); + ui.horizontal(|ui| { + crate::text_field::TextField::singleline("field:effectSearch", search) + .hint(crate::i18n::tr("Search effects")) + .width(ui.available_width() - 120.0) + .show(ui); + let n = app.session.mask_effects.len(); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} preset{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); + }); + let q = search.trim().to_lowercase(); + let effects = app.session.mask_effects.clone(); + let mut toggle_star: Option<(String, bool)> = None; + let mut commit_rename: Option<(String, String)> = None; + egui::ScrollArea::vertical().id_salt("manageEffects").max_height(380.0).auto_shrink([false, true]).show(ui, |ui| { + for (group, ix) in fx::menu_order(&effects) { + let list: Vec<&MaskEffect> = ix + .iter() + .filter_map(|i| effects.get(*i)) + .filter(|e| q.is_empty() || e.name.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q)) + .collect(); + if list.is_empty() { + continue; + } + ui.add_space(4.0); + let heading = if group.is_empty() { crate::i18n::tr("Ungrouped").to_string() } else { group.clone() }; + let h = ui.add( + egui::Label::new(egui::RichText::new(format!("{heading} ({})", list.len())).font(t.semibold(12.0)).color(t.text_label)) + .sense(Sense::click()), + ); + register(ui.ctx(), format!("effectGroup:{group}"), h.rect); + if h.clicked() { + // a click on the heading selects the group + *selected = list.iter().map(|e| e.name.clone()).collect(); + } + for e in list { + let sel = selected.contains(&e.name); + ui.horizontal(|ui| { + let star = ui.add(egui::Button::new(if e.favorite { "★" } else { "☆" }).frame(false)).on_hover_text(crate::i18n::tr("Favorite")); + if ui.is_rect_visible(star.rect) { + register(ui.ctx(), format!("effectStar:{}", e.name), star.rect); + } + if star.clicked() { + toggle_star = Some((e.name.clone(), !e.favorite)); + } + if let Some((old, text)) = rename.as_mut().filter(|(old, _)| *old == e.name) { + let r = crate::text_field::TextField::singleline("field:effectRename", text).width(220.0).select_on_focus(true).show(ui); + if !r.editing && r.ending.is_none() { + r.response.request_focus(); + } + if r.cancelled() { + *rename = None; + } else if r.committed() { + commit_rename = Some((old.clone(), text.trim().to_string())); + } + return; + } + let (rect, r) = ui.allocate_exact_size(vec2(240.0, 22.0), Sense::click()); + if ui.is_rect_visible(rect) { + register(ui.ctx(), format!("effectRow:{}", e.name), rect); + } + if sel { + ui.painter().rect_filled(rect, 3.0, t.tool_active); + } else if r.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let g = crate::widgets::one_line(ui.painter(), &display_name(&e.name), t.font(12.5), t.text, rect.width() - 8.0); + let elided = g.elided; + ui.painter().galley(rect.left_center() + vec2(4.0, -g.size().y / 2.0), g, t.text); + let r = if elided { r.on_hover_text(display_name(&e.name)) } else { r }; + if r.clicked() { + let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); + if add { + if sel { + selected.retain(|n| n != &e.name); + } else { + selected.push(e.name.clone()); + } + } else { + *selected = vec![e.name.clone()]; + } + } + if r.double_clicked() { + *rename = Some((e.name.clone(), e.name.clone())); + } + let mut info = summary(e); + if !e.extra.is_empty() { + info += &crate::i18n::tr_format!(" · kept, not applied: {}", e.extra.keys().cloned().collect::>().join(", ")); + } + ui.add(egui::Label::new(egui::RichText::new(info).color(t.text_dim).size(11.0)).truncate()); + }); + } + } + }); + if let Some((name, on)) = toggle_star { + let _ = app.run("mask.favoriteEffect", json!({"name": name, "favorite": on})); + } + if let Some((old, to)) = commit_rename { + *rename = None; + if !to.is_empty() && to != old { + match app.run("mask.renameEffect", json!({"name": old, "to": to})) { + Ok(_) => *selected = vec![to], + Err(e) => app.toast_error(ui.ctx(), e), + } + } + } + ui.separator(); + let n = selected.len(); + let one = (n == 1).then(|| selected.first().cloned()).flatten(); + let mask = app.session.active_mask.filter(|_| app.session.active().is_some()); + if *confirm_delete && n > 0 { + ui.label( + egui::RichText::new(crate::i18n::tr_format!("Delete {n} effect preset{}? This can't be undone.", if n == 1 { "" } else { "s" }, n = n)) + .color(t.text), + ); + ui.horizontal(|ui| { + let d = ui.button(crate::i18n::tr("Delete")); + register(ui.ctx(), "button:effectDeleteConfirm", d.rect); + if d.clicked() { + let _ = app.run("mask.deleteEffect", json!({"names": selected.clone()})); + selected.clear(); + *confirm_delete = false; + } + let c = ui.button(crate::i18n::tr("Cancel")); + register(ui.ctx(), "button:effectDeleteCancel", c.rect); + if c.clicked() { + *confirm_delete = false; + } + }); + return; + } + ui.horizontal_wrapped(|ui| { + let button = |ui: &mut egui::Ui, id: &str, text: &str, enabled: bool| { + let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(text))); + register(ui.ctx(), format!("button:effect{id}"), r.rect); + r.clicked() + }; + if button(ui, "Apply", "Apply", one.is_some() && mask.is_some()) + && let (Some(name), Some(mid)) = (&one, mask) + { + apply(app, ui.ctx(), name, json!({"id": mid})); + } + if button(ui, "Update", "Update with Mask", one.is_some() && mask.is_some()) + && let (Some(name), Some(mid)) = (&one, mask) + { + let _ = app.run("mask.updateEffect", json!({"name": name, "id": mid})); + } + if button(ui, "Rename", "Rename", one.is_some()) + && let Some(name) = &one + { + *rename = Some((name.clone(), name.clone())); + } + if button(ui, "Duplicate", "Duplicate", one.is_some()) + && let Some(name) = &one + && let Ok(v) = app.run("mask.duplicateEffect", json!({"name": name})) + && let Some(copy) = v["name"].as_str() + { + *selected = vec![copy.to_string()]; + } + if button(ui, "Favorite", "★ Favorite", n > 0) { + let all_fav = selected.iter().all(|s| effects.iter().any(|e| &e.name == s && e.favorite)); + let _ = app.run("mask.favoriteEffect", json!({"names": selected.clone(), "favorite": !all_fav})); + } + let g = ui.add_enabled(n > 0, egui::Button::new(crate::i18n::tr("Move to Group…"))); + register(ui.ctx(), "button:effectGroup", g.rect); + egui::Popup::menu(&g).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + let names = selected.clone(); + for (grp, _) in fx::menu_order(&app.session.mask_effects) { + let label = if grp.is_empty() { crate::i18n::tr("None").to_string() } else { grp.clone() }; + if ui.button(label).clicked() { + let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": grp})); + ui.close(); + } + } + ui.separator(); + let r = crate::text_field::TextField::singleline("field:effectNewGroup", new_group) + .hint(crate::i18n::tr("New group — Return")) + .width(180.0) + .show(ui); + if r.ending == Some(crate::text_field::Ending::Return) && !new_group.trim().is_empty() { + let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": new_group.trim()})); + new_group.clear(); + ui.close(); + } + }); + if button(ui, "Export", "Export…", n > 0 && app.services.save_preset_file.is_some()) { + let _ = app.run("file.exportMaskEffects", json!({"names": selected.clone()})); + } + if button(ui, "Delete", "Delete…", n > 0) { + *confirm_delete = true; + } + }); + if mask.is_none() { + ui.label(egui::RichText::new(crate::i18n::tr("Select a mask to apply or update presets.")).color(t.text_dim).size(11.0)); + } +} + +/// The toast after an import (`preset.import` / `mask.importEffects` result): how many effects, +/// in how many groups, and the settings they carry that LightCraft can't apply. +pub fn import_toast(v: &Value) -> String { + let rows: Vec<&Value> = v["effects"].as_array().or_else(|| v["imported"].as_array()).into_iter().flatten().collect(); + let n = rows.len(); + let mut msg = crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", if n == 1 { "" } else { "s" }, e = n); + let mut groups: Vec<&str> = rows.iter().filter_map(|r| r["group"].as_str()).filter(|g| !g.is_empty()).collect(); + groups.sort_unstable(); + groups.dedup(); + if !groups.is_empty() { + msg += &crate::i18n::tr_format!(" in {g} group{}", if groups.len() == 1 { "" } else { "s" }, g = groups.len()); + } + let mut skipped: Vec<&str> = rows.iter().flat_map(|r| r["unmapped"].as_array().into_iter().flatten()).filter_map(Value::as_str).collect(); + skipped.sort_unstable(); + skipped.dedup(); + if !skipped.is_empty() { + msg += &crate::i18n::tr_format!(" — kept but not applied: {}", skipped.join(", ")); + } + msg +} diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 5e3f8dc6db..1cfe5ab27e 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -16,56 +16,6 @@ const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, step: f ControlSpec { id, label, section: Section::Light, min, max, default: 0.0, step, decimals, track } } -/// The Effect menu above the mask's sliders: apply, save, import and delete effect presets. -fn effect_row(app: &mut LightcraftApp, ui: &mut egui::Ui, adjust: &LocalAdjustments) { - use lightcraft_engine::cmd::mask_effects::matching_effect; - let t = Tokens::get(ui.ctx()); - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr("Effect")).font(t.font(12.0)).color(t.text_label)); - let current = matching_effect(&app.session, adjust); - let label = current.clone().unwrap_or_else(|| crate::i18n::tr("Custom").to_string()); - let r = crate::widgets::dropdown(ui, "maskEffect", &label, t.font(12.0), t.text); - egui::Popup::menu(&r).show(|ui| { - ui.set_min_width(220.0); - let effects: Vec = app.session.mask_effects.iter().map(|e| e.name.clone()).collect(); - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for name in &effects { - let r = ui.selectable_label(current.as_deref() == Some(name.as_str()), name); - register(ui.ctx(), format!("maskEffect:{name}"), r.rect); - if r.clicked() { - let _ = app.run("mask.applyEffect", json!({"name": name})); - ui.close(); - } - r.context_menu(|ui| { - if ui.button(crate::i18n::tr("Delete Preset")).clicked() { - let _ = app.run("mask.deleteEffect", json!({"name": name})); - ui.close(); - } - }); - } - }); - if !effects.is_empty() { - ui.separator(); - } - let r = ui.button(crate::i18n::tr("Save Current Settings as New Preset…")); - register(ui.ctx(), "maskEffectMenu:save", r.rect); - if r.clicked() { - crate::panels::dialogs::prompt(app, "Save Effect Preset", "Preset name", "", "mask.saveEffect", json!({}), "name"); - ui.close(); - } - if ui.add_enabled(app.services.pick_preset_files.is_some(), egui::Button::new(crate::i18n::tr("Import Presets…"))).clicked() { - let _ = app.run("file.importPresets", json!({})); - ui.close(); - } - if !effects.is_empty() { - ui.label(egui::RichText::new(crate::i18n::tr("Right-click a preset to delete it")).font(t.font(11.0)).color(t.text_dim)); - } - }); - }); - }); -} - /// Local adjustment sliders (key = `LocalAdjustments` field name). pub const LOCAL: &[ControlSpec] = &[ spec("temp", "Temp", -100.0, 100.0, 1.0, 0, Track::Temp), @@ -418,7 +368,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { brush_settings(app, ui); } divider(ui); - effect_row(app, ui, &m.adjust); + super::mask_effects::effect_row(app, ui, &m); for s in LOCAL { let v = local_get(&m.adjust, s.id); let out = slider(ui, s, v, true, None); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a8d99aed28..1fd55c9a66 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -17,6 +17,7 @@ pub mod keyword_list; pub mod keywording; pub mod left; pub mod library_problem; +pub mod mask_effects; pub mod masking; pub mod notices; pub mod people; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76066d55b..16ddddbf70 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -963,6 +963,28 @@ pub enum Dialog { folder: String, catalog: Option, }, + /// Save the selected mask's sliders as a mask effect preset: which sliders (`FIELDS` names, + /// the colour as `color_sat`), whether it carries Amount and the brush settings. + SaveEffect { + name: String, + group: String, + fields: Vec, + amount: bool, + brush: bool, + }, + /// Manage Effect Presets: search, the selected effects, an inline rename (old, new), + /// a delete waiting for confirmation, a new group's name being typed. + ManageEffects { + search: String, + selected: Vec, + rename: Option<(String, String)>, + confirm_delete: bool, + new_group: String, + }, + /// Confirm deleting mask effect presets. + DeleteEffects { + names: Vec, + }, /// Confirm moving photos to Recently Deleted. ConfirmDelete { count: usize, diff --git a/crates/ui-egui/src/tests_mask_effects.rs b/crates/ui-egui/src/tests_mask_effects.rs new file mode 100644 index 0000000000..53377c7cd2 --- /dev/null +++ b/crates/ui-egui/src/tests_mask_effects.rs @@ -0,0 +1,215 @@ +//! Headless tests of mask effect presets: the Effect menu (groups, search, keys, hover preview, +//! right-click actions), the New Effect Preset dialog and the Manage Effect Presets window. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn detail() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + exec(&mut h, "panel.masking", json!({})); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn req(h: &mut Headless, method: &str, params: serde_json::Value) { + let r = h.request(method, params, T); + assert_eq!(r["ok"], true, "{method}: {r}"); +} + +fn click(h: &mut Headless, id: &str) { + req(h, "ui.clickWidget", json!({"id": id})); +} + +fn mask(h: &Headless) -> lightcraft_develop::Mask { + let id = h.app.session.active().expect("active photo"); + let d = h.app.session.develop_of(id).unwrap_or_default(); + d.masks.iter().find(|m| Some(m.id) == h.app.session.active_mask).cloned().expect("a selected mask") +} + +/// Three effects in two groups, one starred; a radial mask selected. +fn setup() -> Headless { + let mut h = detail(); + exec(&mut h, "mask.add", json!({"kind": "radial"})); + for (name, values, group) in [ + ("01 Dodge", json!({"exposure": 0.5}), ""), + ("12 Sky", json!({"highlights": -60}), "Detail"), + ("21 Skin", json!({"clarity": -40}), "Retouch"), + ] { + exec(&mut h, "mask.adjust", json!({"values": values})); + exec(&mut h, "mask.saveEffect", json!({"name": name, "group": group})); + let zero: serde_json::Map = + lightcraft_engine::cmd::mask_effects::FIELDS.iter().map(|k| (k.to_string(), json!(0))).collect(); + exec(&mut h, "mask.adjust", json!({"values": zero})); + } + exec(&mut h, "mask.favoriteEffect", json!({"name": "21 Skin"})); + h.step(); + h +} + +#[test] +fn effect_menu_groups_searches_and_applies_with_keys() { + let mut h = setup(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + let widgets = h.request("ui.widgets", json!({}), T); + let ids: Vec = + widgets["result"].as_array().map(|a| a.iter().filter_map(|w| w["id"].as_str().map(str::to_string)).collect()).unwrap_or_default(); + for id in ["maskEffect:01 Dodge", "maskEffect:12 Sky", "maskEffect:21 Skin", "maskEffectFav:21 Skin", "maskEffectSearch", "maskEffectMenu:manage"] + { + assert!(ids.iter().any(|x| x == id), "{id} in {ids:?}"); + } + // the pointer resting on an effect previews it on the photo + req(&mut h, "ui.hoverWidget", json!({"id": "maskEffect:12 Sky"})); + h.step(); + let preview = h.app.hover_preview.as_ref().expect("a hover preview"); + assert_eq!(preview.settings.masks.last().map(|m| m.adjust.highlights), Some(-60.0)); + assert_eq!(mask(&h).adjust.highlights, 0.0, "only previewed"); + // typing filters; ↓ and Return apply the first match + req(&mut h, "ui.text", json!({"text": "skin"})); + h.step(); + let widgets = h.request("ui.widgets", json!({}), T); + let ids: Vec = + widgets["result"].as_array().map(|a| a.iter().filter_map(|w| w["id"].as_str().map(str::to_string)).collect()).unwrap_or_default(); + assert!(!ids.iter().any(|x| x == "maskEffect:12 Sky"), "filtered out: {ids:?}"); + req(&mut h, "ui.key", json!({"key": "ArrowDown"})); + h.step(); + req(&mut h, "ui.key", json!({"key": "Enter"})); + h.step(); + h.step(); + assert_eq!(mask(&h).adjust.clarity, -40.0); + assert_eq!(exec(&mut h, "mask.effects", json!({}))["current"], "21 Skin"); + // a new radial mask now starts with it (the menu remembers per tool) + exec(&mut h, "mask.add", json!({"kind": "radial"})); + assert_eq!(mask(&h).adjust.clarity, -40.0); +} + +#[test] +fn effect_menu_right_click_and_delete_asks_first() { + let mut h = setup(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + // the row's actions are commands; delete goes through a confirmation + h.app.ui.dialog = Some(Dialog::DeleteEffects { names: vec!["01 Dodge".into()] }); + h.step(); + assert_eq!(h.app.session.mask_effects.len(), 3, "nothing deleted before confirming"); + click(&mut h, "button:dialogCancel"); + assert_eq!(h.app.session.mask_effects.len(), 3); + h.app.ui.dialog = Some(Dialog::DeleteEffects { names: vec!["01 Dodge".into()] }); + h.step(); + req(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(h.app.session.mask_effects.len(), 2); + // Reset Sliders zeroes the mask and forgets the tool's effect + exec(&mut h, "mask.applyEffect", json!({"name": "12 Sky"})); + h.step(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + click(&mut h, "maskEffectMenu:reset"); + h.step(); + assert_eq!(mask(&h).adjust.highlights, 0.0); + assert!(h.app.session.mask_tool_effects.is_empty()); +} + +#[test] +fn save_dialog_picks_sliders_amount_and_brush() { + let mut h = setup(); + exec(&mut h, "mask.adjust", json!({"values": {"shadows": 30, "texture": 15, "amount": 70}})); + h.step(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + click(&mut h, "maskEffectMenu:save"); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::SaveEffect { .. })), "{:?}", h.app.ui.dialog); + // nothing named: an error, the dialog stays + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], false, "{r}"); + click(&mut h, "effectInclude:changed"); + h.step(); + click(&mut h, "check:effectAmount"); + h.step(); + req(&mut h, "ui.clickWidget", json!({"id": "field:effectName"})); + req(&mut h, "ui.text", json!({"text": "Lift"})); + h.step(); + let Some(Dialog::SaveEffect { fields, amount, .. }) = &h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) }; + assert_eq!((fields.clone(), *amount), (vec!["shadows".to_string(), "texture".into()], true)); + req(&mut h, "ui.dialog.confirm", json!({})); + let e = h.app.session.mask_effects.iter().find(|e| e.name == "Lift").expect("saved"); + assert_eq!((e.fields.clone(), e.with_amount, e.adjust.amount), (vec!["shadows".to_string(), "texture".into()], true, 70.0)); + assert!(e.brush.is_none(), "not the brush tool"); +} + +#[test] +fn manage_window_renames_groups_stars_duplicates_and_deletes() { + let mut h = setup(); + exec(&mut h, "dialog.manageMaskEffects", json!({})); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::ManageEffects { .. }))); + // select and rename inline + click(&mut h, "effectRow:12 Sky"); + h.step(); + click(&mut h, "button:effectRename"); + h.step(); + req(&mut h, "ui.key", json!({"key": "A", "modifiers": {"command": true}})); + req(&mut h, "ui.text", json!({"text": "12 Sky Darken"})); + req(&mut h, "ui.key", json!({"key": "Enter"})); + h.step(); + h.step(); + assert!( + h.app.session.mask_effects.iter().any(|e| e.name == "12 Sky Darken"), + "{:?}", + h.app.session.mask_effects.iter().map(|e| &e.name).collect::>() + ); + // duplicate selects the copy; star it; apply it to the mask + click(&mut h, "button:effectDuplicate"); + h.step(); + let Some(Dialog::ManageEffects { selected, .. }) = &h.app.ui.dialog else { panic!() }; + assert_eq!(selected, &vec!["12 Sky Darken copy".to_string()]); + click(&mut h, "effectStar:12 Sky Darken copy"); + h.step(); + assert!(h.app.session.mask_effects.iter().any(|e| e.name == "12 Sky Darken copy" && e.favorite)); + click(&mut h, "button:effectApply"); + h.step(); + assert_eq!(mask(&h).adjust.highlights, -60.0); + // search narrows the list; a group heading selects the group + req(&mut h, "ui.clickWidget", json!({"id": "field:effectSearch"})); + req(&mut h, "ui.text", json!({"text": "retouch"})); + h.step(); + click(&mut h, "effectGroup:Retouch"); + h.step(); + let Some(Dialog::ManageEffects { selected, .. }) = &h.app.ui.dialog else { panic!() }; + assert_eq!(selected, &vec!["21 Skin".to_string()]); + // delete asks first, inside the window + click(&mut h, "button:effectDelete"); + h.step(); + assert_eq!(h.app.session.mask_effects.len(), 4); + click(&mut h, "button:effectDeleteConfirm"); + h.step(); + assert_eq!(h.app.session.mask_effects.len(), 3); + assert!(matches!(h.app.ui.dialog, Some(Dialog::ManageEffects { .. })), "stays open"); +} + +#[test] +fn imported_effects_toast_reports_groups_and_what_was_kept() { + let v = json!({"effects": [ + {"name": "a", "group": "Detail", "unmapped": ["Grain"]}, + {"name": "b", "group": "Retouch", "unmapped": ["Point Color", "Grain"]}, + {"name": "c", "group": "", "unmapped": []}, + ]}); + let msg = crate::panels::mask_effects::import_toast(&v); + assert!(msg.contains("3 mask effects") && msg.contains("2 groups") && msg.contains("Grain, Point Color"), "{msg}"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index eb2b18a229..179d0e4a5c 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -137,9 +137,6 @@ fn new_brush_starts_a_separate_mask_in_paint_mode() { fn mask_adjustments_hide_overlay_without_disabling_the_mask() { use lightcraft_pipeline::Overlay; let mut h = detail("panel.masking"); - // tall enough for the Exposure slider below the Effect menu to be on screen - let r = h.request("ui.resize", json!({"width": 1200, "height": 1000}), T); - assert_eq!(r["ok"], true, "{r}"); exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.5, 0.2], "end": [0.5, 0.7]})); assert!(h.app.ui.mask_overlay); @@ -1426,16 +1423,13 @@ fn effect_dropdown_applies_saves_and_shows_the_matching_effect() { exec(&mut h, "mask.add", json!({"kind": "radial"})); exec(&mut h, "mask.adjust", json!({"values": {"exposure": 0.7, "shadows": 25, "amount": 80}})); h.step(); - // save the mask's sliders from the menu (name prompt) + // save the mask's sliders from the menu (the New Effect Preset dialog) click(&mut h, "dropdown:maskEffect"); click(&mut h, "maskEffectMenu:save"); - let mut dlg = h.app.ui.dialog.take().expect("a name prompt opens"); + let mut dlg = h.app.ui.dialog.take().expect("the save dialog opens"); match &mut dlg { - crate::state::Dialog::TextPrompt { command, value, .. } => { - assert_eq!(command, "mask.saveEffect"); - *value = "Lift".into(); - } - _ => panic!("a name prompt opens"), + crate::state::Dialog::SaveEffect { name, .. } => *name = "Lift".into(), + d => panic!("the save dialog opens: {d:?}"), } let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg); assert!(r.is_ok(), "{r:?}"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c5..1c392e9b17 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; LR-MASK-EFFECTS: local adjustment presets with Lightroom Classic import) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -36,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 407 | 50 | 73 | 35 | 192/200 (96%) | 145/157 (92%) | +| **Total** | 409 | 51 | 72 | 35 | 193/201 (96%) | 146/158 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 530 in-scope rows — P0 98.0% of 200 · P1 95.2% of 157 · P2 49.7% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.7%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.3% of 158 · P2 50.0% of 172. ## Top gaps and shortcut notes @@ -77,7 +77,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | -| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | +| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | @@ -302,7 +302,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | | | LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | -| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets | +| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve | +| LR-MASK-EFFECTS | Local adjustment (effect / brush) presets | P1 | ✅ | `cmd:mask.effects`, `cmd:mask.applyEffect`, `cmd:mask.saveEffect`, `cmd:mask.updateEffect`, `cmd:mask.renameEffect`, `cmd:mask.duplicateEffect`, `cmd:mask.deleteEffect`, `cmd:mask.favoriteEffect`, `cmd:mask.setEffectGroup`, `cmd:mask.renameEffectGroup`, `cmd:mask.toolEffect`, `cmd:mask.importEffects`, `cmd:mask.exportEffects`, `cmd:dialog.saveMaskEffect`, `cmd:dialog.manageMaskEffects`, `cmd:file.exportMaskEffects`, `crates/engine/src/cmd/mask_effects.rs`, `crates/engine/src/cmd/mask_effect_files.rs`, `crates/ui-egui/src/panels/mask_effects.rs` | Effect menu above the mask sliders: grouped (folders, ★ Favorites first), searchable, scrolls, ↑ ↓ Return, ✓ on the matching effect (else Custom), resting on an effect previews it on the photo; right-click: apply to all masks / to the same-named mask on the selected photos (one undo step), update, rename, duplicate, star, move to group, delete (asks first); Reset Sliders; New Effect Preset dialog: which sliders (All / None / Changed Only), Amount, brush size/feather/flow; any mask type; Manage Effect Presets window (search, inline rename, update, duplicate, stars, groups, export, delete with confirmation); a new mask starts with the effect last applied to a mask of its kind (per tool, kept with the library); imports Lightroom Classic local `.lrtemplate` presets, Camera Raw local-correction `.xmp` presets, `.lceffect` and `.zip` bundles, through File ▸ Import Profiles & Presets… and drag & drop too; folders and LrC divider presets (`10 ----- DETAIL ----- 10`) become groups; settings with no local slider here (`grain`, Point Color `pointColors`, `toningLuminance`) are reported and kept with the effect for export, not applied (🟡 for those); brush size/feather/flow aren't in LrC presets (it keeps them per brush), so only `.lceffect` carries them; no built-in effects | | LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | @@ -787,6 +788,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-MASK-EFFECTS (new, ✅): local adjustment (effect / brush) presets: Effect menu, New Effect Preset dialog, Manage window, groups and favorites; imports Lightroom Classic local `.lrtemplate`, Camera Raw local `.xmp`, `.lceffect` and `.zip`. LR-IMP-PROFILES and LR-MASK-SLIDERS notes updated | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 3669d1199d..066afe2ea4 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -192,13 +192,45 @@ Applying a preset adds its masks to the photo's own (masks the photo already has slider scales them. A sidecar's masks replace the photo's, because a sidecar holds the whole edit. Components we can't carry over are listed in `preset.import`'s `unmapped` as `Mask: `. +## Local adjustment presets (mask effects) + +`mask.importEffects {path | paths, group?, dryRun?}` (and `preset.import`, File ▸ Import Profiles & Presets…, drag & drop) +reads these into the Masking panel's Effect menu (`crates/engine/src/cmd/mask_effect_files.rs`): + +| File | How it is read | +|---|---| +| Lightroom Classic `.lrtemplate` (`%APPDATA%\Adobe\Lightroom\Local Adjustment Presets`, `~/Library/Application Support/Adobe/Lightroom/Local Adjustment Presets`) | a Lua table with `type = "LocalizedAdjustmentPreset"`; `title` (else `internalName`, else the file name) → name; `value` holds the `Local*` field names in lower camel case: `exposure2012` → `LocalExposure2012`, mapped as in the table above (`exposure`, `contrast`, `clarity` are the older process version's; the 2012 fields win); `luminanceNoise` → Noise, `toningHue` / `toningSaturation` → Color | +| Camera Raw local-correction `.xmp` (`CameraRaw/LocalCorrections`) | top-level `crs:Local*` properties (or, in a file with `crs:PresetType="Local"` without them, its first correction's); `crs:Name` → name, `crs:Group` → group. Develop presets (no `crs:Local*`) stay develop presets | +| `.lceffect` | ours: `{"format": "lightcraft.maskEffects", "version": 2, "effects": [{name, adjust, group?, favorite?, fields?, with_amount?, brush?, extra?}]}` (a bare array or one effect also reads); `mask.exportEffects {path, names?, group?}` writes it | +| `.zip` | the presets above inside it; its folders give groups | + +Groups: a file's folder (relative to the imported folder; names that only say "local adjustment presets" don't count) +names its group, unless the file names one. Lightroom users mark sections of the flat preset menu with **divider +presets**: presets that set nothing, named like `10 ----- DETAIL ENHANCERS ----- 10` (a run of three or more `-`, `=`, +`_`, `*`, `#`, `~`, `—`, `.`). These become groups instead of effects: a preset whose name starts with a number goes to +the divider with the largest number not above it (`12 Sky` under `10 ----- DETAIL ENHANCERS ----- 10` → *Detail +Enhancers*); presets without a number keep their folder's group. An earlier import's divider effects are removed. +Same-named effects are replaced (a star is kept). + +Settings with no local slider here are listed per preset in `unmapped` (and the toast) and **kept** with the effect in +`extra` (an `.lceffect` export carries them), but not applied: `grain` (Grain; LightCraft has no local grain), +`pointColors` (Point Color: `SrcHue/Sat/Lum`, `HueShift`, `SatScale`, `LumScale`, `RangeAmount`, `Hue/Sat/LumRange` +`{LowerNone, LowerFull, UpperFull, UpperNone}`; there is global Point Color only), `toningLuminance`. Zero values aren't +listed (`refineSaturation = 0` in most files). Lightroom keeps brush size, feather, flow and density per brush, not in +presets, so there is nothing to import for them; an effect saved here can carry them (`brush`). + +Applying (`mask.applyEffect {name, id? | ids? | all?, photos?: "selected"}`) sets the sliders the effect carries — +all of them for an imported preset (unnamed ones to 0, as Lightroom does), or only the ones chosen when it was saved +(`mask.saveEffect {fields: "changed" | [..]}`) — and Amount only if it carries it. The effect is remembered for the +mask's kind: the next brush / linear / radial / … mask starts with it (`mask.toolEffect`). + ## Preset files | | | |---|---| | Ours: `.lcpreset` | JSON `{"format": "lightcraft.preset", "version": 1, "presets": [{id, name, group, settings}]}`, where `settings` is a partial develop-settings object (only the groups the preset includes). A file can hold one preset or many, and every preset keeps its group. Import also accepts a bare preset object or an array of them. | | Export | `preset.export {path, ids?, group?}`: all user presets by default, or the given ids or one group. In the app: File ▸ Export Presets…, Presets panel ▸ ⋯ ▸ Export User Presets…, or right-click a group ▸ Export Group…. | -| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | +| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Local adjustment (brush) presets — Lightroom Classic's `.lrtemplate` with `type = "LocalizedAdjustmentPreset"`, Camera Raw's local-correction `.xmp`, our `.lceffect`, also inside zips — become mask effects (`mask.effects`) instead (see *Local adjustment presets* below); the result lists them under `effects`, and divider presets under `effectDividers`. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | | XMP presets | Read with the `crs:` table above, with `crs:Name` as the name (falling back to the file name) and `crs:Group` as the group (falling back to "Imported Presets"). Only the fields the preset sets are included, so applying it leaves everything else alone and the Amount slider scales it like any other preset. We only read XMP presets; we don't write them. | LightCraft ships no third-party presets. Its built-in presets are its own values (`crates/engine/src/presets.rs`).