LR-MASK-EFFECTS: manage, group and fully import local adjustment presets (engine)

Mask effects gain groups, stars, partial presets (only some sliders, with or
without Amount), optional brush settings and the settings an imported preset
carries that we can't apply (kept for export). New commands: mask.updateEffect,
renameEffect, duplicateEffect, favoriteEffect, setEffectGroup,
renameEffectGroup, toolEffect; applyEffect takes several masks, all masks, or
the same-named masks on the selected photos in one undo step. A new mask starts
with the effect last applied to a mask of its kind (kept with the library).

Import (mask.importEffects and preset.import): Lightroom Classic local
.lrtemplate, Camera Raw local-correction .xmp, .lceffect and zips of these;
folders and LrC divider presets ("10 ----- DETAIL ----- 10") become groups;
grain / Point Color / toningLuminance reported and kept. Malformed files are
errors, never panics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
OneSlips
2026-10-12 02:33:25 +10:00
co-authored by Claude Opus 5.5
parent 594c046d52
commit 0f40a2d2f5
12 changed files with 1652 additions and 278 deletions
+681
View File
@@ -0,0 +1,681 @@
//! Mask effect preset files: Lightroom Classic local adjustment presets (`.lrtemplate`, Lua,
//! `type = "LocalizedAdjustmentPreset"`), Camera Raw local correction presets (`.xmp` with
//! `crs:Local*` fields, from its `LocalCorrections` folder), our `.lceffect` JSON, and `.zip`
//! bundles of these. Folders, and Lightroom users' "divider" presets (`10 ----- DETAIL ----- 10`,
//! empty presets that head a numbered section of the menu), become groups.
//!
//! Read from the files' public structure and black-box observation; settings we have no slider
//! for are reported and kept with the effect (an export carries them).
use lightcraft_develop::LocalAdjustments;
use serde_json::{Map, Value, json};
use super::mask_effects::{MaskEffect, upsert};
use super::{bad, str_param};
use crate::preset_import::{Lua, group_from_dir, parse_lua, read_zip};
use crate::{Result, Session};
/// File extension of exported effect presets (JSON).
pub const EFFECT_EXT: &str = "lceffect";
/// `format` tag of an exported effect-preset file.
const FILE_FORMAT: &str = "lightcraft.maskEffects";
/// The Lua `type` of a Lightroom local adjustment preset.
const LR_LOCAL_TYPE: &str = "LocalizedAdjustmentPreset";
/// Larger files aren't presets (or bundles of them).
const MAX_FILE: u64 = 64 << 20;
/// How deep nested Lua tables are kept as JSON.
const MAX_DEPTH: usize = 16;
/// An effect read from a file, with the settings it couldn't keep and where it came from.
#[derive(Clone, Debug)]
pub struct Found {
pub effect: MaskEffect,
pub unmapped: Vec<String>,
pub file: String,
/// Effects of one folder (or folder inside a bundle) share this key: divider presets group
/// the effects next to them.
pub dir: String,
/// The group the folder gives (when the file names none).
pub dir_group: Option<String>,
/// The file named its group (`.lceffect`, `crs:Group`): kept.
pub has_group: bool,
}
/// A readable name for a Lightroom local setting we can't carry over.
fn setting_label(key: &str) -> String {
let k = key.trim_start_matches("Local");
let lower = k.to_ascii_lowercase();
match lower.as_str() {
"grain" => "Grain".into(),
"toningluminance" => "Color luminance".into(),
"pointcolors" => "Point Color".into(),
"refinesaturation" => "Refine Saturation".into(),
"curverefinesaturation" => "Refine Saturation".into(),
"maincurve" | "redcurve" | "greencurve" | "bluecurve" => "Curve".into(),
_ => k.to_string(),
}
}
/// A Lua value as JSON (tables nested at most [`MAX_DEPTH`] deep).
fn lua_json(v: &Lua, depth: usize) -> Value {
match v {
Lua::Nil => Value::Null,
Lua::Bool(b) => json!(b),
Lua::Num(n) => serde_json::Number::from_f64(*n).map(Value::Number).unwrap_or(Value::Null),
Lua::Str(s) => json!(s),
Lua::Table(..) if depth >= MAX_DEPTH => Value::Null,
Lua::Table(items, fields) if fields.is_empty() => Value::Array(items.iter().map(|x| lua_json(x, depth + 1)).collect()),
Lua::Table(_, fields) => Value::Object(fields.iter().map(|(k, x)| (k.clone(), lua_json(x, depth + 1))).collect()),
}
}
/// Does a setting value do anything (a non-zero number, a non-empty table, …)?
fn meaningful(v: &Value) -> bool {
match v {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().is_some_and(|x| x.abs() > 1e-9),
Value::String(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
}
}
/// Is `text` a Lightroom local adjustment preset (`.lrtemplate`)?
pub fn is_lr_local_template(text: &str) -> bool {
parse_lua(text.trim_start_matches('\u{feff}'))
.ok()
.and_then(|root| root.get("type").and_then(Lua::str).map(|t| t == LR_LOCAL_TYPE))
.unwrap_or(false)
}
/// `$$$/Path/Key=Default` (a localisable string) → `Default`.
fn delocalize(t: &str) -> &str {
t.strip_prefix("$$$/").and_then(|r| r.split_once('=')).map_or(t, |(_, d)| d)
}
/// A Lightroom local adjustment preset → an effect named after its title (else `stem`), and the
/// settings we have no equivalent for (e.g. `Grain`, `Point Color`), which the effect keeps in
/// `extra`.
pub fn parse_lr_local_template(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> {
let root = parse_lua(text.trim_start_matches('\u{feff}'))?;
if root.get("type").and_then(Lua::str) != Some(LR_LOCAL_TYPE) {
return Err("not a local adjustment preset".into());
}
let Some(Lua::Table(_, fields)) = root.get("value") else { return Err("no adjustments in this local preset".into()) };
// `exposure2012` → `LocalExposure2012`: the template's keys are the XMP `Local*` names, lower camel case
let crs = |k: &str| {
let mut c = k.chars();
c.next().map(|f| format!("Local{}{}", f.to_ascii_uppercase(), c.as_str())).unwrap_or_default()
};
let num = |k: &str| fields.iter().find(|(f, _)| crs(f) == k).and_then(|(_, v)| if let Lua::Num(n) = v { Some(*n) } else { None });
let values = crate::crs_masks::local_values(num);
let mut extra = Map::new();
for (k, v) in fields {
if crate::crs_masks::is_local_field(&crs(k)) {
continue;
}
let j = lua_json(v, 0);
if meaningful(&j) {
extra.insert(k.clone(), j);
}
}
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(str::trim).filter(|t| !t.is_empty());
let name = title.map(|t| delocalize(t).trim().to_string()).filter(|t| !t.is_empty()).unwrap_or_else(|| stem.to_string());
build(name, values, extra)
}
fn build(name: String, values: Map<String, Value>, extra: Map<String, Value>) -> std::result::Result<(MaskEffect, Vec<String>), String> {
let adjust: LocalAdjustments = serde_json::from_value(Value::Object(values)).map_err(|e| format!("unreadable local preset: {e}"))?;
let mut unmapped: Vec<String> = extra.keys().map(|k| setting_label(k)).collect();
unmapped.sort();
unmapped.dedup();
let effect = MaskEffect { name, adjust, extra, ..Default::default() }.normalized();
Ok((effect, unmapped))
}
/// The local slider fields of a Camera Raw local correction preset: its top-level `crs:Local*`
/// properties, or (a preset typed `Local` without them) its first correction's.
fn xmp_local_fields(d: &lightcraft_meta::XmpData) -> Option<Map<String, Value>> {
let mut out = Map::new();
for (k, v) in &d.properties {
if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local") && !n.contains('/'))
&& let Some(x) = v.first()
{
out.insert(name.to_string(), json!(x.trim()));
}
}
if !out.is_empty() {
return Some(out);
}
let typed_local = d.properties.get("crs:PresetType").and_then(|v| v.first()).is_some_and(|t| t.trim().eq_ignore_ascii_case("local"));
if !typed_local {
return None;
}
let correction = crate::crs_masks::CONTAINERS.iter().find_map(|c| d.values.get(*c).and_then(|l| l.items().first()))?;
let lightcraft_meta::XmpValue::Struct(m) = correction else { return None };
for (k, v) in m {
if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local"))
&& let Some(x) = v.text()
{
out.insert(name.to_string(), json!(x));
}
}
Some(out)
}
/// Is `text` a Camera Raw local correction preset (XMP)?
pub fn is_local_xmp(text: &str) -> bool {
lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).ok().and_then(|d| xmp_local_fields(&d)).is_some()
}
/// A Camera Raw local correction preset (XMP) → an effect named after `crs:Name` (else `stem`),
/// in `crs:Group` if it names one.
pub fn parse_local_xmp(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> {
let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?;
let fields = xmp_local_fields(&d).ok_or("not a local correction preset (no crs:Local… settings)")?;
let num = |v: &Value| v.as_str().map(|s| s.strip_prefix('+').unwrap_or(s)).and_then(|s| s.parse::<f64>().ok()).filter(|x| x.is_finite());
let values = crate::crs_masks::local_values(|k| fields.get(k).and_then(num));
let mut extra = Map::new();
for (k, v) in &fields {
if crate::crs_masks::is_local_field(k) {
continue;
}
let v = num(v).map_or_else(|| v.clone(), |x| json!(x));
if meaningful(&v) {
extra.insert(k.clone(), v);
}
}
let first = |k: &str| d.properties.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let name = first("crs:Name").unwrap_or_else(|| stem.to_string());
let (mut e, unmapped) = build(name, values, extra)?;
e.group = first("crs:Group").unwrap_or_default();
Ok((e, unmapped))
}
/// Effects in an `.lceffect` file: `{format, version, effects: [...]}`, a bare array, or one effect.
pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<MaskEffect>, String> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not an effect preset file: {e}"))?;
let items = match v {
Value::Object(mut o) if o.contains_key("effects") => o.remove("effects").unwrap_or_default(),
v @ Value::Array(_) => v,
v @ Value::Object(_) => Value::Array(vec![v]),
_ => return Err("not an effect preset file".into()),
};
let list: Vec<MaskEffect> = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?;
let list: Vec<MaskEffect> = list.into_iter().filter(|e| !e.name.trim().is_empty()).map(MaskEffect::normalized).collect();
if list.is_empty() {
return Err("no effect presets in the file".into());
}
Ok(list)
}
fn ext_of(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
}
fn stem_of(name: &str) -> String {
std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "Effect".into())
}
type Parsed = std::result::Result<Vec<(MaskEffect, Vec<String>)>, String>;
/// The effects in file `name` (`bytes`) if it is a local preset (`.lrtemplate` of the local type,
/// a local correction `.xmp`, an `.lceffect`); `None` for any other file (a develop preset…).
pub fn parse_bytes(name: &str, bytes: &[u8]) -> Option<Parsed> {
let stem = stem_of(name);
match ext_of(name).as_str() {
"lrtemplate" => {
let text = String::from_utf8_lossy(bytes);
is_lr_local_template(&text).then(|| parse_lr_local_template(&text, &stem).map(|e| vec![e]))
}
"xmp" => {
let text = String::from_utf8_lossy(bytes);
is_local_xmp(&text).then(|| parse_local_xmp(&text, &stem).map(|e| vec![e]))
}
EFFECT_EXT => Some(parse_file(bytes).map(|v| v.into_iter().map(|e| (e, Vec::new())).collect())),
_ => None,
}
}
/// The group effects in folder `dir` (relative to the imported folder) go to.
pub fn group_for_dir(dir: &str) -> Option<String> {
effect_group_from_dir(dir)
}
/// Folder names that only say "local presets" (`Local Adjustment Presets`, `LocalCorrections`…)
/// give no group.
fn effect_group_from_dir(dir: &str) -> Option<String> {
const FILLER: &[&str] = &[
"local",
"adjustment",
"adjustments",
"localcorrections",
"corrections",
"correction",
"brush",
"brushes",
"mask",
"masks",
"effect",
"effects",
];
let generic = |part: &str| {
let lower = part.to_ascii_lowercase();
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
words.iter().any(|w| FILLER.contains(w))
&& words.iter().all(|w| FILLER.contains(w) || matches!(*w, "presets" | "preset" | "lightroom" | "camera" | "raw" | "classic"))
};
let kept: Vec<&str> = dir.split(['/', '\\']).filter(|p| !p.is_empty() && !generic(p)).collect();
group_from_dir(&kept.join("/"))
}
/// Effects found in `bytes` of file `file` (a `.zip` is opened: the local presets inside).
/// `dir_group`: the group the file's folder gives. Errors are `(file, message)`.
pub fn found_in(file: &str, bytes: &[u8], dir_group: Option<String>, out: &mut Vec<Found>, failed: &mut Vec<(String, String)>) -> bool {
let dir = std::path::Path::new(file).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
if ext_of(file) == "zip" {
let Ok(entries) = read_zip(bytes) else { return false };
let zip_group = effect_group_from_dir(&stem_of(file)).or(dir_group);
let mut any = false;
for (inner, data) in entries {
let inner_dir = inner.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or_default();
let group = effect_group_from_dir(&inner_dir).or_else(|| zip_group.clone());
let label = format!("{file}/{inner}");
match parse_bytes(&inner, &data) {
Some(Ok(list)) => {
any = true;
push(out, list, &label, format!("{file}/{inner_dir}"), group, &inner);
}
Some(Err(e)) => {
any = true;
failed.push((label, e));
}
None => {}
}
}
return any;
}
match parse_bytes(file, bytes) {
Some(Ok(list)) => {
push(out, list, file, dir, dir_group, file);
true
}
Some(Err(e)) => {
failed.push((file.to_string(), e));
true
}
None => false,
}
}
fn push(out: &mut Vec<Found>, list: Vec<(MaskEffect, Vec<String>)>, file: &str, dir: String, dir_group: Option<String>, name: &str) {
let named = ext_of(name) == EFFECT_EXT;
for (effect, unmapped) in list {
let has_group = named || !effect.group.is_empty();
out.push(Found { effect, unmapped, file: file.to_string(), dir: dir.clone(), dir_group: dir_group.clone(), has_group });
}
}
/// The number a preset name starts with (`24a-Eye` → 24).
fn leading_number(name: &str) -> Option<u64> {
let digits: String = name.trim().chars().take_while(char::is_ascii_digit).take(18).collect();
digits.parse().ok()
}
/// The section title of a divider preset — an effect that sets nothing, named like
/// `10 ----- DETAIL ENHANCERS ----- 10` (a run of at least three `-`, `=`, `_`, `*`, `#`, `~`, `—`).
pub fn divider_title(e: &MaskEffect) -> Option<String> {
if !e.is_empty() {
return None;
}
let marks = ['-', '=', '_', '*', '#', '~', '—', '–', '•', '.'];
let chars: Vec<char> = e.name.chars().collect();
let has_run = chars.windows(3).any(|w| marks.contains(&w[0]) && w[0] == w[1] && w[1] == w[2]);
if !has_run {
return None;
}
let title: String = e.name.chars().map(|c| if marks.contains(&c) { ' ' } else { c }).collect();
let title = title.trim().trim_start_matches(|c: char| c.is_ascii_digit() || c.is_whitespace());
let title = title.trim_end_matches(|c: char| c.is_ascii_digit() || c.is_whitespace());
let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
if !title.chars().any(char::is_alphanumeric) {
return None;
}
// "DETAIL ENHANCERS" → "Detail Enhancers"
let shouting = title.chars().any(char::is_alphabetic) && !title.chars().any(char::is_lowercase);
Some(if shouting {
title
.split(' ')
.map(|w| {
let mut c = w.chars();
c.next().map(|f| f.to_uppercase().chain(c.flat_map(char::to_lowercase)).collect::<String>()).unwrap_or_default()
})
.collect::<Vec<_>>()
.join(" ")
} else {
title
})
}
/// Give `found` their groups; returns the divider presets (which become groups, not effects) as
/// `(found, group)`. A numbered effect goes to the divider with the largest number not above its
/// own (`12 Sky` under `10 --- DETAIL --- 10`); others to their folder's group.
pub fn assign_groups(found: Vec<Found>, group: Option<&str>) -> (Vec<Found>, Vec<(Found, String)>) {
let (dividers, mut effects): (Vec<(Found, Option<String>)>, Vec<(Found, Option<String>)>) =
found.into_iter().map(|f| (divider_title(&f.effect).filter(|_| !f.has_group), f)).map(|(t, f)| (f, t)).partition(|(_, t)| t.is_some());
let dividers: Vec<(Found, String)> = dividers.into_iter().filter_map(|(f, t)| t.map(|t| (f, t))).collect();
for (f, _) in &mut effects {
if let Some(g) = group {
f.effect.group = g.to_string();
continue;
}
if f.has_group {
continue;
}
let section = leading_number(&f.effect.name).and_then(|n| {
dividers
.iter()
.filter(|(d, _)| d.dir == f.dir)
.filter_map(|(d, t)| leading_number(&d.effect.name).filter(|m| *m <= n).map(|m| (m, t)))
.max_by_key(|(m, _)| *m)
.map(|(_, t)| t.clone())
});
f.effect.group = section.or_else(|| f.dir_group.clone()).unwrap_or_default();
}
(effects.into_iter().map(|(f, _)| f).collect(), dividers)
}
/// Add the found effects (grouped; same-named ones replaced; effects left from an earlier import
/// of a divider dropped) and save → the report rows `(imported, dividers)`.
pub fn add_found(s: &mut Session, found: Vec<Found>, group: Option<&str>, dry: bool) -> Result<(Vec<Value>, Vec<Value>)> {
let (effects, dividers) = assign_groups(found, group);
let rows: Vec<Value> =
effects.iter().map(|f| json!({"name": f.effect.name, "group": f.effect.group, "file": f.file, "unmapped": f.unmapped})).collect();
let div_rows: Vec<Value> = dividers.iter().map(|(f, g)| json!({"name": f.effect.name, "group": g, "file": f.file})).collect();
if dry || (effects.is_empty() && dividers.is_empty()) {
return Ok((rows, div_rows));
}
s.mask_effects.retain(|e| !(e.is_empty() && dividers.iter().any(|(d, _)| d.effect.name.eq_ignore_ascii_case(&e.name))));
for f in effects {
upsert(s, f.effect);
}
s.save_prefs()?;
Ok((rows, div_rows))
}
/// The files under `paths` an effect import reads (folders walked, at most 8 levels), each with
/// the group its folder gives and whether it was named (not found in a folder).
fn effect_files(paths: &[String]) -> Vec<(String, Option<String>, bool)> {
fn walk(dir: &std::path::Path, base: &std::path::Path, depth: usize, out: &mut Vec<(String, Option<String>, bool)>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
for p in entries {
if p.is_dir() {
if depth < 8 {
walk(&p, base, depth + 1, out);
}
} else if p.extension().is_some_and(|e| ["lrtemplate", EFFECT_EXT, "xmp", "zip"].iter().any(|x| e.eq_ignore_ascii_case(x))) {
let rel = p.parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
out.push((p.to_string_lossy().to_string(), rel.and_then(|d| effect_group_from_dir(&d)), false));
}
}
}
let mut out = Vec::new();
for x in paths {
let p = std::path::Path::new(x);
if p.is_dir() {
walk(p, p.parent().unwrap_or(std::path::Path::new("")), 0, &mut out);
} else {
out.push((x.clone(), None, true));
}
}
out
}
/// Read a file of at most [`MAX_FILE`] bytes.
pub fn read_capped(path: &str) -> std::result::Result<Vec<u8>, String> {
let len = std::fs::metadata(path).map_err(|e| e.to_string())?.len();
if len > MAX_FILE {
return Err(format!("too large for a preset file ({} MB)", len >> 20));
}
std::fs::read(path).map_err(|e| e.to_string())
}
pub fn import_effects(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.importEffects";
let mut paths: Vec<String> =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
if let Some(x) = str_param(p, "path") {
paths.push(x.to_string());
}
if paths.is_empty() {
return Err(bad(C, "missing `path` / `paths`"));
}
let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty());
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let (mut found, mut failed) = (Vec::new(), Vec::new());
for (f, dir_group, named) in effect_files(&paths) {
match read_capped(&f) {
Ok(bytes) => {
if !found_in(&f, &bytes, dir_group, &mut found, &mut failed) && named {
failed.push((f, "not a local adjustment preset".into()));
}
}
Err(e) => failed.push((f, e)),
}
}
let (imported, dividers) = add_found(s, found, group, dry)?;
Ok(json!({"imported": imported, "dividers": dividers, "failed": failed.into_iter().map(|(f, e)| json!([f, e])).collect::<Vec<_>>()}))
}
pub fn export_effects(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.exportEffects";
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
let mut path = std::path::PathBuf::from(path);
if path.extension().is_none() {
path.set_extension(EFFECT_EXT);
}
let names: Option<Vec<String>> =
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
let group = str_param(p, "group").map(str::trim);
let chosen: Vec<&MaskEffect> = s
.mask_effects
.iter()
.filter(|e| names.as_ref().is_none_or(|n| n.iter().any(|x| x.eq_ignore_ascii_case(&e.name))))
.filter(|e| group.is_none_or(|g| g.eq_ignore_ascii_case(&e.group)))
.collect();
if chosen.is_empty() {
return Err(bad(C, "no effect presets to export"));
}
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
}
let text = serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 2, "effects": chosen}))
.map_err(|e| crate::EngineError::Other(e.to_string()))?;
std::fs::write(&path, text).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
}
#[cfg(test)]
mod tests {
use super::*;
// A synthetic template in the shape Lightroom writes (our own values).
const TEMPLATE: &str = r#"s = {
id = "00000000-0000-0000-0000-000000000001",
internalName = "Soft Glow",
title = "Soft Glow",
type = "LocalizedAdjustmentPreset",
value = {
clarity = 0.5,
clarity2012 = -0.25,
exposure = 0,
exposure2012 = 0.125,
grain = 0.25,
highlights2012 = -0.5,
luminanceNoise = 0.1,
refineSaturation = 0,
temperature = 0.2,
texture = -0.3,
toningHue = 240,
toningLuminance = 0.1,
toningSaturation = 0,
pointColors = {
{
HueRange = { LowerFull = 0.3, LowerNone = 0, UpperFull = 0.6, UpperNone = 1, },
HueShift = -0.05,
RangeAmount = 0.5,
SrcHue = 0.8,
},
},
},
version = 0,
}"#;
#[test]
fn reads_a_lightroom_local_template() {
assert!(is_lr_local_template(TEMPLATE));
let (e, unmapped) = parse_lr_local_template(TEMPLATE, "file").unwrap();
assert_eq!(e.name, "Soft Glow");
assert!((e.adjust.exposure - 0.5).abs() < 1e-9, "exposure2012 is a quarter of the stops");
assert!((e.adjust.clarity + 25.0).abs() < 1e-9, "the 2012 field wins over the old one");
assert!((e.adjust.highlights + 50.0).abs() < 1e-9);
assert!((e.adjust.temp - 20.0).abs() < 1e-9);
assert!((e.adjust.texture + 30.0).abs() < 1e-9);
assert!((e.adjust.noise - 10.0).abs() < 1e-9);
assert!((e.adjust.amount - 100.0).abs() < 1e-9);
assert_eq!(unmapped, vec!["Color luminance".to_string(), "Grain".into(), "Point Color".into()]);
assert_eq!(e.extra.get("grain"), Some(&json!(0.25)), "kept for export");
assert_eq!(e.extra["pointColors"][0]["HueRange"]["UpperFull"], json!(0.6));
assert!(!e.extra.contains_key("refineSaturation"), "zero settings aren't kept");
}
#[test]
fn develop_templates_are_not_local_ones() {
let develop = r#"s = { title = "X", type = "Develop", value = { settings = { Exposure2012 = 1 } } }"#;
assert!(!is_lr_local_template(develop));
assert!(parse_lr_local_template(develop, "x").is_err());
assert!(parse_bytes("x.lrtemplate", develop.as_bytes()).is_none(), "left to the develop preset import");
}
#[test]
fn malformed_files_are_errors_not_panics() {
for bad in [
"garbage {",
"",
"s = {",
r#"s = { type = "LocalizedAdjustmentPreset", value = 3 }"#,
r#"s = { type = "LocalizedAdjustmentPreset" }"#,
r#"s = { type = "LocalizedAdjustmentPreset", value = { exposure2012 = "x", grain = {{{{}}}} } }"#,
"s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 1e400, contrast2012 = -1e400 } }",
"\u{feff}s = { type = \"LocalizedAdjustmentPreset\", title = \"\", value = {} }",
] {
let _ = parse_lr_local_template(bad, "x");
let _ = parse_bytes("x.lrtemplate", bad.as_bytes());
}
// deeply nested tables
let deep = format!("s = {{ type = \"LocalizedAdjustmentPreset\", value = {{ pointColors = {}1{} }} }}", "{".repeat(200), "}".repeat(200));
let _ = parse_lr_local_template(&deep, "x");
let (e, _) = parse_lr_local_template("s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 9 } }", "Stem").unwrap();
assert_eq!((e.name.as_str(), e.adjust.exposure), ("Stem", 4.0), "values clamp, the file name names it");
for bad in ["", "<x:xmpmeta", "<x:xmpmeta></x:xmpmeta>", "\u{0}\u{1}"] {
assert!(parse_bytes("x.xmp", bad.as_bytes()).is_none_or(|r| r.is_err()));
}
for bad in ["", "3", "[]", "{\"effects\": 3}", "[{\"name\": 4}]", "{\"effects\": [{\"name\": \" \"}]}"] {
assert!(parse_file(bad.as_bytes()).is_err(), "{bad}");
}
assert!(!found_in("x.zip", b"PK\x05\x06garbage", None, &mut Vec::new(), &mut Vec::new()));
}
/// A Camera Raw local correction preset (our own values).
const ACR: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:PresetType="Local" crs:LocalExposure2012="+0.25" crs:LocalShadows2012="0.4" crs:LocalGrain="0.2" crs:LocalTexture="0">
<crs:Name><rdf:Alt><rdf:li xml:lang="x-default">Lift Shadows</rdf:li></rdf:Alt></crs:Name>
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Faces</rdf:li></rdf:Alt></crs:Group>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
#[test]
fn reads_a_camera_raw_local_preset() {
assert!(is_local_xmp(ACR));
let (e, unmapped) = parse_local_xmp(ACR, "file").unwrap();
assert_eq!((e.name.as_str(), e.group.as_str()), ("Lift Shadows", "Faces"));
assert_eq!((e.adjust.exposure, e.adjust.shadows), (1.0, 40.0));
assert_eq!(unmapped, vec!["Grain".to_string()]);
// a develop preset is not one
let develop = ACR
.replace(
"crs:LocalExposure2012=\"+0.25\" crs:LocalShadows2012=\"0.4\" crs:LocalGrain=\"0.2\" crs:LocalTexture=\"0\"",
"crs:Exposure2012=\"1\"",
)
.replace("PresetType=\"Local\"", "PresetType=\"Normal\"");
assert!(!is_local_xmp(&develop));
// a preset typed Local that keeps its sliders in a correction
let nested = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PresetType="Local">
<crs:MaskGroupBasedCorrections><rdf:Seq><rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.5"/></rdf:li></rdf:Seq></crs:MaskGroupBasedCorrections>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let (e, _) = parse_local_xmp(nested, "Soft").unwrap();
assert_eq!((e.name.as_str(), e.adjust.clarity), ("Soft", -50.0));
}
fn found(name: &str, exposure: f64, dir: &str) -> Found {
Found {
effect: MaskEffect { name: name.into(), adjust: LocalAdjustments { exposure, ..Default::default() }, ..Default::default() },
unmapped: Vec::new(),
file: format!("{dir}/{name}.lrtemplate"),
dir: dir.into(),
dir_group: None,
has_group: false,
}
}
#[test]
fn dividers_become_groups() {
let e = |n: &str| MaskEffect { name: n.into(), ..Default::default() };
assert_eq!(divider_title(&e("10 ----- DETAIL ENHANCERS ----- 10")).as_deref(), Some("Detail Enhancers"));
assert_eq!(divider_title(&e("50--------MISC-----50")).as_deref(), Some("Misc"));
assert_eq!(divider_title(&e("=== Portraits ===")).as_deref(), Some("Portraits"));
assert_eq!(divider_title(&e("11_General_-_All_Purpose")), None, "no run of marks");
assert_eq!(divider_title(&e("-----")), None, "no title");
let busy = MaskEffect { adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..e("20 --- X --- 20") };
assert_eq!(divider_title(&busy), None, "a divider sets nothing");
let list = vec![
found("01 Burn", -1.0, "a"),
found("10 ----- DETAIL ENHANCERS ----- 10", 0.0, "a"),
found("12 Sky", 0.5, "a"),
found("20 ----- RETOUCHING ----- 20", 0.0, "a"),
found("24a-Eye-Cleaner", 0.1, "a"),
found("99 Late", 0.1, "a"),
found("Burn (Darken)", -0.5, "a"),
Found { dir_group: Some("Other".into()), ..found("15 Elsewhere", 0.2, "b") },
];
let (effects, dividers) = assign_groups(list.clone(), None);
let groups: Vec<(&str, &str)> = effects.iter().map(|f| (f.effect.name.as_str(), f.effect.group.as_str())).collect();
assert_eq!(
groups,
vec![
("01 Burn", ""),
("12 Sky", "Detail Enhancers"),
("24a-Eye-Cleaner", "Retouching"),
("99 Late", "Retouching"),
("Burn (Darken)", ""),
("15 Elsewhere", "Other"),
]
);
assert_eq!(dividers.len(), 2);
let (effects, _) = assign_groups(list, Some("Mine"));
assert!(effects.iter().all(|f| f.effect.group == "Mine"));
}
#[test]
fn folder_names_that_only_say_local_presets_give_no_group() {
assert_eq!(effect_group_from_dir("Local Adjustment Presets"), None);
assert_eq!(effect_group_from_dir("LocalCorrections"), None);
assert_eq!(effect_group_from_dir("Local Adjustment Presets/Portrait Brushes"), Some("Portrait Brushes".into()));
assert_eq!(effect_group_from_dir("Skin Kit"), Some("Skin Kit".into()));
}
}
+629 -252
View File
@@ -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<String> {
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<String>,
/// Applying sets the mask's Amount to `adjust.amount` too.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub with_amount: bool,
/// Brush settings the brush takes when the effect is applied.
#[serde(skip_serializing_if = "Option::is_none")]
pub brush: Option<BrushSettings>,
/// Settings of an imported preset that we have no slider for (Lightroom key → value), kept so
/// an export carries them.
#[serde(skip_serializing_if = "Map::is_empty")]
pub extra: Map<String, Value>,
}
/// 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<String> {
let mut out: Vec<String> = FIELDS.iter().filter(|k| get(a, k).abs() > 1e-9 && **k != "color_hue").map(|k| k.to_string()).collect();
if a.color_sat.abs() > 1e-9 {
out.push("color_hue".into());
}
out
}
impl MaskEffect {
/// The sliders this effect sets.
pub fn field_list(&self) -> Vec<&str> {
if self.fields.is_empty() { FIELDS.to_vec() } else { FIELDS.iter().copied().filter(|k| self.fields.iter().any(|f| f == k)).collect() }
}
/// `a` with this preset's sliders (and Amount, if it carries it).
pub fn apply(&self, a: &LocalAdjustments) -> LocalAdjustments {
let mut out = if self.fields.is_empty() {
LocalAdjustments { amount: a.amount, ..self.adjust }
} else {
self.field_list().iter().fold(*a, |acc, k| set(&acc, k, get(&self.adjust, k)))
};
if self.with_amount {
out.amount = self.adjust.amount;
}
out
}
/// Does `a` carry exactly this preset's sliders? A tint's hue doesn't count without its
/// saturation (it does nothing then).
pub fn matches(&self, a: &LocalAdjustments) -> bool {
let fields = self.field_list();
let colorless = |x: &LocalAdjustments| x.color_sat.abs() < 1e-9;
let same = |k: &str| (get(&self.adjust, k) - get(a, k)).abs() < 1e-6;
fields.iter().all(|k| *k == "color_hue" && colorless(&self.adjust) && colorless(a) || same(k))
&& (!self.with_amount || (self.adjust.amount - a.amount).abs() < 1e-6)
}
/// Does the effect set nothing (all its sliders at 0, nothing kept from an import)?
pub fn is_empty(&self) -> bool {
self.extra.is_empty() && changed_fields(&self.adjust).iter().all(|k| !self.field_list().contains(&k.as_str()))
}
/// The same effect with its values in range and its field list normalised.
pub fn normalized(mut self) -> Self {
self.name = self.name.trim().to_string();
self.group = self.group.trim().to_string();
let amount = if self.with_amount { self.adjust.amount } else { 100.0 };
self.adjust = super::masks::clamp_local(LocalAdjustments { amount, ..self.adjust });
self.fields.retain(|f| FIELDS.contains(&f.as_str()));
if self.fields.iter().any(|f| f == "color_sat") && !self.fields.iter().any(|f| f == "color_hue") {
self.fields.push("color_hue".into());
}
self.fields.sort_by_key(|f| FIELDS.iter().position(|k| k == f));
self.fields.dedup();
if self.fields.len() == FIELDS.len() {
self.fields.clear();
}
self.brush = self.brush.map(BrushSettings::clamped);
self
}
}
/// Compare names the way people number their presets: digit runs as numbers ("2 …" before
/// "10 …"), letters ignoring case.
pub fn natural_cmp(a: &str, b: &str) -> Ordering {
fn chunks(s: &str) -> Vec<(bool, String)> {
let mut out: Vec<(bool, String)> = Vec::new();
for c in s.chars() {
let d = c.is_ascii_digit();
match out.last_mut() {
Some((kind, text)) if *kind == d => text.push(c),
_ => out.push((d, c.to_string())),
}
}
out
}
let (x, y) = (chunks(a), chunks(b));
for (p, q) in x.iter().zip(y.iter()) {
let o = match (p, q) {
((true, m), (true, n)) => {
let (m, n) = (m.trim_start_matches('0'), n.trim_start_matches('0'));
m.len().cmp(&n.len()).then_with(|| m.cmp(n))
}
((_, m), (_, n)) => m.to_lowercase().cmp(&n.to_lowercase()),
};
if o != Ordering::Equal {
return o;
}
}
x.len().cmp(&y.len()).then_with(|| a.cmp(b))
}
/// The Effect menu's order: groups in the order they first appear (ungrouped first), each with
/// its effects (indices into `effects`) sorted by name.
pub fn menu_order(effects: &[MaskEffect]) -> Vec<(String, Vec<usize>)> {
let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
if effects.iter().any(|e| e.group.is_empty()) {
groups.push((String::new(), Vec::new()));
}
for (i, e) in effects.iter().enumerate() {
match groups.iter_mut().find(|(g, _)| g.eq_ignore_ascii_case(&e.group)) {
Some((_, v)) => v.push(i),
None => groups.push((e.group.clone(), vec![i])),
}
}
for (_, v) in &mut groups {
v.sort_by(|a, b| match (effects.get(*a), effects.get(*b)) {
(Some(x), Some(y)) => natural_cmp(&x.name, &y.name),
_ => Ordering::Equal,
});
}
groups
}
/// Name of the effect the mask adjustments `a` match, if any (full presets before partial ones).
pub fn matching_effect(s: &Session, a: &LocalAdjustments) -> Option<String> {
let full = s.mask_effects.iter().filter(|e| e.fields.is_empty()).find(|e| e.matches(a));
full.or_else(|| s.mask_effects.iter().find(|e| e.matches(a))).map(|e| e.name.clone())
}
fn position(s: &Session, name: &str) -> Option<usize> {
s.mask_effects.iter().position(|x| x.name.eq_ignore_ascii_case(name.trim()))
}
/// Add or replace (same name, ignoring case) an effect; a replaced one keeps its star.
pub(crate) fn upsert(s: &mut Session, e: MaskEffect) {
match position(s, &e.name).and_then(|i| s.mask_effects.get_mut(i)) {
Some(x) => {
let favorite = x.favorite || e.favorite;
*x = MaskEffect { favorite, ..e };
}
None => s.mask_effects.push(e),
}
}
fn name_param(p: &Value, c: &str) -> Result<String> {
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<String> {
str_param(p, key).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, format!("missing `{key}`")))
}
fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
}
/// The names a command acts on: `names: [...]` or `name`.
fn names_param(p: &Value, c: &str) -> Result<Vec<String>> {
match strings(p, "names").filter(|v| !v.is_empty()) {
Some(v) => Ok(v),
None => name_param(p, c).map(|n| vec![n]),
}
}
fn effect_index(s: &Session, name: &str, c: &str) -> Result<usize> {
position(s, name).ok_or_else(|| bad(c, format!("no effect `{name}`")))
}
/// The selected mask's adjustments on the active photo.
fn selected_adjust(s: &Session, p: &Value, c: &str) -> Result<(u32, LocalAdjustments)> {
fn selected_mask(s: &Session, p: &Value, c: &str) -> Result<Mask> {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mid = p.get("id").and_then(Value::as_u64).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<String> {
m.components.first().and_then(|c| serde_json::to_value(&c.shape).ok()).and_then(|v| v.get("kind").and_then(Value::as_str).map(str::to_string))
}
/// The adjustments a new mask made with `tool` starts with: the effect last applied to a mask of
/// that kind (Lightroom carries the chosen effect over to the next mask), else all at 0.
pub fn new_mask_adjust(s: &Session, tool: &str) -> LocalAdjustments {
s.mask_tool_effects
.get(tool)
.and_then(|name| position(s, name))
.and_then(|i| s.mask_effects.get(i))
.map(|e| super::masks::clamp_local(e.apply(&LocalAdjustments::default())))
.unwrap_or_default()
}
fn list(s: &Session) -> Value {
@@ -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<Value> = menu_order(&s.mask_effects)
.into_iter()
.map(|(g, ix)| json!({"name": g, "effects": ix.iter().filter_map(|i| s.mask_effects.get(*i)).map(|e| e.name.clone()).collect::<Vec<_>>()}))
.collect();
json!({"effects": s.mask_effects, "groups": groups, "current": current, "toolEffects": s.mask_tool_effects})
}
/// Masks on `d` named like one of `names` (ignoring case).
fn masks_named(d: &DevelopSettings, names: &[String]) -> Vec<u32> {
d.masks.iter().filter(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.name))).map(|m| m.id).collect()
}
fn apply_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.applyEffect";
let name = name_param(p, C)?;
let effect = s.mask_effects.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<u32> = if p.get("all").and_then(Value::as_bool).unwrap_or(false) {
d.masks.iter().map(|m| m.id).collect()
} else if let Some(a) = p.get("ids").and_then(Value::as_array) {
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect()
} else {
vec![selected_mask(s, p, C)?.id]
};
if ids.is_empty() {
return Err(bad(C, "no masks to apply the effect to"));
}
if let Some(missing) = ids.iter().find(|m| !d.masks.iter().any(|x| x.id == **m)) {
return Err(bad(C, format!("no mask {missing}")));
}
let set_masks = |d: &DevelopSettings, ids: &[u32]| {
let mut d = d.clone();
for m in d.masks.iter_mut().filter(|m| ids.contains(&m.id)) {
m.adjust = super::masks::clamp_local(effect.apply(&m.adjust));
}
d
};
let label = format!("Mask Effect: {}", effect.name);
// with `photos: "selected"`, the other selected photos' masks of the same names too
let others: Vec<(PhotoId, DevelopSettings)> = if str_param(p, "photos") == Some("selected") {
let names: Vec<String> = d.masks.iter().filter(|m| ids.contains(&m.id)).map(|m| m.name.clone()).collect();
s.selection
.ids
.iter()
.filter(|x| **x != id)
.filter_map(|x| s.develop_of(*x).map(|o| (*x, o)))
.filter_map(|(x, o)| {
let found = masks_named(&o, &names);
(!found.is_empty()).then(|| (x, set_masks(&o, &found)))
})
.collect()
} else {
Vec::new()
};
let mine = set_masks(&d, &ids);
let photos = 1 + others.len();
if others.is_empty() {
s.set_develop(id, mine, &label)?;
} else {
// one undo step for every photo
let mut ops: Vec<_> = s.develop_op(id, mine, &label).into_iter().collect();
ops.extend(others.into_iter().filter_map(|(x, o)| s.develop_op(x, o, &label)));
s.commit(&label, lightcraft_catalog::Op::Batch { ops })?;
}
// the next mask made with this tool starts with the effect
if p.get("remember").and_then(Value::as_bool).unwrap_or(true) {
for m in d.masks.iter().filter(|m| ids.contains(&m.id)) {
if let Some(tool) = mask_tool(m) {
s.mask_tool_effects.insert(tool, effect.name.clone());
}
}
s.save_prefs()?;
}
Ok(json!({"name": effect.name, "masks": ids, "photos": photos, "brush": effect.brush}))
}
/// The fields a save keeps: `fields: [..]` | `"changed"` | `"all"` (default).
fn fields_param(p: &Value, a: &LocalAdjustments, c: &str) -> Result<Vec<String>> {
match p.get("fields") {
None | Some(Value::Null) => Ok(Vec::new()),
Some(Value::String(x)) if x == "all" => Ok(Vec::new()),
Some(Value::String(x)) if x == "changed" => {
let f = changed_fields(a);
if f.is_empty() { Err(bad(c, "the mask changes no slider; nothing to save")) } else { Ok(f) }
}
Some(Value::Array(v)) => {
let f: Vec<String> = v.iter().filter_map(Value::as_str).filter(|k| FIELDS.contains(k)).map(str::to_string).collect();
if f.is_empty() { Err(bad(c, format!("`fields` names no slider (one of {})", FIELDS.join(", ")))) } else { Ok(f) }
}
Some(_) => Err(bad(c, "`fields` is \"all\", \"changed\" or a list of slider names")),
}
}
fn brush_param(p: &Value, c: &str) -> Result<Option<BrushSettings>> {
match p.get("brush") {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
Some(v) => serde_json::from_value::<BrushSettings>(v.clone()).map(|b| Some(b.clamped())).map_err(|e| bad(c, format!("`brush`: {e}"))),
}
}
fn save_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.saveEffect";
let name = name_param(p, C)?;
let (_, adjust) = selected_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<Value> {
const C: &str = "mask.updateEffect";
let name = name_param(p, C)?;
let i = effect_index(s, &name, C)?;
let adjust = selected_mask(s, p, C)?.adjust;
let brush = brush_param(p, C)?;
let e = s.mask_effects.get_mut(i).ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
let mut next = MaskEffect { adjust, extra: Map::new(), ..e.clone() };
if let Some(f) = p.get("fields").filter(|v| !v.is_null()) {
next.fields = fields_param(&json!({"fields": f}), &adjust, C)?;
}
if let Some(a) = p.get("amount").and_then(Value::as_bool) {
next.with_amount = a;
}
if brush.is_some() {
next.brush = brush;
}
*e = next.normalized();
s.save_prefs()?;
Ok(list(s))
}
fn rename_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.renameEffect";
let name = name_param(p, C)?;
let to = name_of(p, "to", C)?;
let i = effect_index(s, &name, C)?;
if position(s, &to).is_some_and(|j| j != i) {
return Err(bad(C, format!("an effect named `{to}` exists already")));
}
if let Some(e) = s.mask_effects.get_mut(i) {
e.name = to.clone();
}
for v in s.mask_tool_effects.values_mut() {
if v.eq_ignore_ascii_case(&name) {
*v = to.clone();
}
}
s.save_prefs()?;
Ok(list(s))
}
/// A name like `base` that no effect has: `base`, `base 2`, `base 3`…
fn free_name(s: &Session, base: &str) -> String {
(1..10_000)
.map(|n| if n == 1 { base.to_string() } else { format!("{base} {n}") })
.find(|n| position(s, n).is_none())
.unwrap_or_else(|| base.into())
}
fn duplicate_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.duplicateEffect";
let name = name_param(p, C)?;
let e = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
let to = match str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()) {
Some(t) if position(s, t).is_some() => return Err(bad(C, format!("an effect named `{t}` exists already"))),
Some(t) => t.to_string(),
None => free_name(s, &format!("{} copy", e.name)),
};
s.mask_effects.push(MaskEffect { name: to.clone(), favorite: false, ..e });
s.save_prefs()?;
let mut out = list(s);
out["name"] = json!(to);
Ok(out)
}
fn delete_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.deleteEffect";
let 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<Value> {
const C: &str = "mask.favoriteEffect";
let names = names_param(p, C)?;
let mut idx = Vec::new();
for n in &names {
idx.push(effect_index(s, n, C)?);
}
let want = p.get("favorite").and_then(Value::as_bool);
for i in idx {
if let Some(e) = s.mask_effects.get_mut(i) {
e.favorite = want.unwrap_or(!e.favorite);
}
}
s.save_prefs()?;
Ok(list(s))
}
/// 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>), 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<Value> {
const C: &str = "mask.setEffectGroup";
let names = names_param(p, C)?;
let group = str_param(p, "group").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `group` (\"\" = none)"))?;
let mut idx = Vec::new();
for n in &names {
idx.push(effect_index(s, n, C)?);
}
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<String> = 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<Vec<MaskEffect>, String> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not an effect preset file: {e}"))?;
let items = match &v {
Value::Object(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<MaskEffect> = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?;
let list: Vec<MaskEffect> = list
.into_iter()
.filter(|e| !e.name.trim().is_empty())
.map(|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<Vec<(MaskEffect, Vec<String>)>, 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>, String)>) -> Result<Vec<Value>> {
let rows: Vec<Value> = 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<String> {
fn walk(dir: &std::path::Path, depth: usize, out: &mut Vec<String>) {
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<Value> {
const C: &str = "mask.importEffects";
let mut paths: Vec<String> =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
if let Some(x) = str_param(p, "path") {
paths.push(x.to_string());
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.renameEffectGroup";
let group = name_of(p, "group", C)?;
let to = str_param(p, "to").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `to` (\"\" = ungroup)"))?;
let mut n = 0;
for e in s.mask_effects.iter_mut().filter(|e| e.group.eq_ignore_ascii_case(&group)) {
e.group = to.clone();
n += 1;
}
if 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<Value> {
const C: &str = "mask.exportEffects";
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
let mut path = std::path::PathBuf::from(path);
if path.extension().is_none() {
path.set_extension(EFFECT_EXT);
fn tool_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "mask.toolEffect";
let tool = name_of(p, "tool", C)?;
match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => {
let i = effect_index(s, n, C)?;
let name = s.mask_effects.get(i).map(|e| e.name.clone()).unwrap_or_default();
s.mask_tool_effects.insert(tool, name);
}
None => {
s.mask_tool_effects.remove(&tool);
}
}
let names: Option<Vec<String>> =
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
let 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<CommandSpec> {
@@ -275,7 +597,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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 \"<name> copy\") → list + {name}",
always,
duplicate_effect
),
cmd!("mask.deleteEffect", "Delete Mask Effect", [], None, "{name | names: [name]} — removes effect presets", always, delete_effect),
cmd!("mask.favoriteEffect", "Favorite Mask Effect", [], None, "{name | names, favorite?: bool (default: toggle)}", always, favorite_effect),
cmd!("mask.setEffectGroup", "Move Mask Effect to Group", [], None, "{name | names, group: name (\"\" = none)}", always, set_group),
cmd!(
"mask.renameEffectGroup",
"Rename Mask Effect Group",
[],
None,
"{group, to} (\"\" ungroups; an existing name merges)",
always,
rename_group
),
cmd!(
"mask.toolEffect",
"Set Effect for New Masks",
[],
None,
"{tool: brush|linear|radial|subject|…, name?} — the effect new masks of that kind start with (no name: none)",
always,
tool_effect
),
cmd!(
"mask.importEffects",
"Import Mask Effects",
[],
None,
"{path | paths: [file or folder]} — 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<CommandSpec> {
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(&current);
assert_eq!((out.exposure, out.shadows, out.amount), (1.0, 0.0, 60.0));
assert!(e.matches(&out));
assert!(!e.matches(&current));
}
#[test]
fn partial_effects_leave_other_sliders_and_can_carry_amount() {
let e = MaskEffect {
name: "Lift".into(),
adjust: LocalAdjustments { shadows: 30.0, clarity: 99.0, amount: 70.0, ..Default::default() },
fields: vec!["shadows".into()],
with_amount: true,
..Default::default()
};
let current = LocalAdjustments { exposure: 0.5, clarity: 10.0, ..Default::default() };
let out = e.apply(&current);
assert_eq!((out.exposure, out.shadows, out.clarity, out.amount), (0.5, 30.0, 10.0, 70.0));
assert!(e.matches(&out));
assert!(!e.matches(&LocalAdjustments { amount: 100.0, ..out }), "Amount counts when carried");
}
#[test]
fn hue_without_saturation_still_matches() {
let e = MaskEffect {
name: "E".into(),
adjust: LocalAdjustments { color_hue: 240.0, exposure: 0.25, ..Default::default() },
..Default::default()
};
assert!(e.matches(&LocalAdjustments { exposure: 0.25, ..Default::default() }));
assert!(!e.matches(&LocalAdjustments { exposure: 0.25, color_sat: 10.0, ..Default::default() }));
}
#[test]
fn natural_order_and_groups() {
let mut v = vec!["10 b", "2 a", "Burn", "01 x", "1 y"];
v.sort_by(|a, b| natural_cmp(a, b));
assert_eq!(v, vec!["01 x", "1 y", "2 a", "10 b", "Burn"]);
let e = |n: &str, g: &str| MaskEffect { name: n.into(), group: g.into(), ..Default::default() };
let list = vec![e("12 b", "Detail"), e("3 a", ""), e("11 a", "detail"), e("20 r", "Retouch"), e("Burn", "")];
let order = menu_order(&list);
let names: Vec<(String, Vec<&str>)> = order.iter().map(|(g, ix)| (g.clone(), ix.iter().map(|i| list[*i].name.as_str()).collect())).collect();
assert_eq!(
names,
vec![("".to_string(), vec!["3 a", "Burn"]), ("Detail".to_string(), vec!["11 a", "12 b"]), ("Retouch".to_string(), vec!["20 r"]),]
);
}
#[test]
fn normalized_clamps_and_tidies_fields() {
let e = MaskEffect {
name: " X ".into(),
adjust: LocalAdjustments { exposure: 99.0, amount: 150.0, ..Default::default() },
fields: vec!["color_sat".into(), "bogus".into(), "exposure".into()],
brush: Some(BrushSettings { size: f64::NAN, flow: 0.0, ..Default::default() }),
..Default::default()
}
.normalized();
assert_eq!(e.name, "X");
assert_eq!(e.adjust.exposure, 4.0);
assert_eq!(e.adjust.amount, 100.0, "Amount isn't carried");
assert_eq!(e.fields, vec!["exposure", "color_hue", "color_sat"]);
let b = e.brush.unwrap();
assert_eq!((b.size, b.flow), (0.04, 1.0));
}
}
+5 -1
View File
@@ -171,11 +171,14 @@ pub fn specs() -> Vec<CommandSpec> {
return Ok(json!({"pending": true}));
}
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
// the effect last applied to a mask of this kind
let adjust = super::mask_effects::new_mask_adjust(s, &kind);
let out = masks_edit(s, "mask.add", "Add Mask", |masks, active| {
masks.push(Mask {
id: next,
name: name.unwrap_or_else(|| format!("Mask {next}")),
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
adjust,
..Default::default()
});
*active = Some(next);
@@ -245,11 +248,12 @@ pub fn specs() -> Vec<CommandSpec> {
};
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
}
+1
View File
@@ -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;
+29 -21
View File
@@ -2,6 +2,7 @@
use serde_json::{Value, json};
use super::mask_effect_files as mef;
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::preset_import::{group_from_dir, read_presets};
use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset};
@@ -18,28 +19,38 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
}
}
}
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<Value> {
@@ -101,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Presets",
[],
None,
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; 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
),
+6
View File
@@ -259,6 +259,9 @@ pub struct Session {
pub curve_presets: Vec<cmd::curves::CurvePreset>,
/// Mask effect presets (the Masking panel's Effect menu).
pub mask_effects: Vec<cmd::mask_effects::MaskEffect>,
/// 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<String, String>,
/// Saved colour-label name sets.
pub label_sets: Vec<cmd::manage::LabelSet>,
/// 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;
+4
View File
@@ -169,6 +169,8 @@ struct PrefsFile {
curve_presets: Vec<crate::cmd::curves::CurvePreset>,
/// Mask effect presets.
mask_effects: Vec<crate::cmd::mask_effects::MaskEffect>,
/// The effect new masks of each kind start with.
mask_tool_effects: std::collections::BTreeMap<String, String>,
/// Colour-label name sets.
label_sets: Vec<crate::cmd::manage::LabelSet>,
/// 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(),
+1 -1
View File
@@ -88,7 +88,7 @@ pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result<Vec<Preset>, String
pub fn expand_preset_paths(paths: &[String]) -> Vec<String> {
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 {
+293
View File
@@ -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<String> {
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: "<name> copy", then "<name> 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<u64> = 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<u32> = 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::<Vec<_>>(), [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::<Vec<_>>()})).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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PresetType="Local" crs:LocalSaturation="-0.5">
<crs:Name><rdf:Alt><rdf:li xml:lang="x-default">Mute</rdf:li></rdf:Alt></crs:Name>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let develop_xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0.5" crs:Name="Bright"/></rdf:RDF></x:xmpmeta>"#;
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);
}
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"];