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 000000000..e1e099bc8 --- /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 64b9fcac5..387909a6d 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 d05f80abb..7b4548734 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 36b242741..2f27dc17c 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 ec31130d4..5da980657 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 b8114eaf2..b88156d9e 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 36a7378f6..5d6af424f 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 8c36aa742..a1958286d 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 000000000..18572449d --- /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 494b17ede..30f39a708 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 9a387c77c..5df4b66bd 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 1032a5cd9..7ee3853ed 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"];