mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-03 06:59:53 -06:00
@@ -0,0 +1,681 @@
|
||||
//! Mask effect preset files: Lightroom Classic local adjustment presets (`.lrtemplate`, Lua,
|
||||
//! `type = "LocalizedAdjustmentPreset"`), Camera Raw local correction presets (`.xmp` with
|
||||
//! `crs:Local*` fields, from its `LocalCorrections` folder), our `.lceffect` JSON, and `.zip`
|
||||
//! bundles of these. Folders, and Lightroom users' "divider" presets (`10 ----- DETAIL ----- 10`,
|
||||
//! empty presets that head a numbered section of the menu), become groups.
|
||||
//!
|
||||
//! Read from the files' public structure and black-box observation; settings we have no slider
|
||||
//! for are reported and kept with the effect (an export carries them).
|
||||
|
||||
use lightcraft_develop::LocalAdjustments;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::mask_effects::{MaskEffect, upsert};
|
||||
use super::{bad, str_param};
|
||||
use crate::preset_import::{Lua, group_from_dir, parse_lua, read_zip};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// File extension of exported effect presets (JSON).
|
||||
pub const EFFECT_EXT: &str = "lceffect";
|
||||
/// `format` tag of an exported effect-preset file.
|
||||
const FILE_FORMAT: &str = "lightcraft.maskEffects";
|
||||
/// The Lua `type` of a Lightroom local adjustment preset.
|
||||
const LR_LOCAL_TYPE: &str = "LocalizedAdjustmentPreset";
|
||||
/// Larger files aren't presets (or bundles of them).
|
||||
const MAX_FILE: u64 = 64 << 20;
|
||||
/// How deep nested Lua tables are kept as JSON.
|
||||
const MAX_DEPTH: usize = 16;
|
||||
|
||||
/// An effect read from a file, with the settings it couldn't keep and where it came from.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Found {
|
||||
pub effect: MaskEffect,
|
||||
pub unmapped: Vec<String>,
|
||||
pub file: String,
|
||||
/// Effects of one folder (or folder inside a bundle) share this key: divider presets group
|
||||
/// the effects next to them.
|
||||
pub dir: String,
|
||||
/// The group the folder gives (when the file names none).
|
||||
pub dir_group: Option<String>,
|
||||
/// The file named its group (`.lceffect`, `crs:Group`): kept.
|
||||
pub has_group: bool,
|
||||
}
|
||||
|
||||
/// A readable name for a Lightroom local setting we can't carry over.
|
||||
fn setting_label(key: &str) -> String {
|
||||
let k = key.trim_start_matches("Local");
|
||||
let lower = k.to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"grain" => "Grain".into(),
|
||||
"toningluminance" => "Color luminance".into(),
|
||||
"pointcolors" => "Point Color".into(),
|
||||
"refinesaturation" => "Refine Saturation".into(),
|
||||
"curverefinesaturation" => "Refine Saturation".into(),
|
||||
"maincurve" | "redcurve" | "greencurve" | "bluecurve" => "Curve".into(),
|
||||
_ => k.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lua value as JSON (tables nested at most [`MAX_DEPTH`] deep).
|
||||
fn lua_json(v: &Lua, depth: usize) -> Value {
|
||||
match v {
|
||||
Lua::Nil => Value::Null,
|
||||
Lua::Bool(b) => json!(b),
|
||||
Lua::Num(n) => serde_json::Number::from_f64(*n).map(Value::Number).unwrap_or(Value::Null),
|
||||
Lua::Str(s) => json!(s),
|
||||
Lua::Table(..) if depth >= MAX_DEPTH => Value::Null,
|
||||
Lua::Table(items, fields) if fields.is_empty() => Value::Array(items.iter().map(|x| lua_json(x, depth + 1)).collect()),
|
||||
Lua::Table(_, fields) => Value::Object(fields.iter().map(|(k, x)| (k.clone(), lua_json(x, depth + 1))).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a setting value do anything (a non-zero number, a non-empty table, …)?
|
||||
fn meaningful(v: &Value) -> bool {
|
||||
match v {
|
||||
Value::Null => false,
|
||||
Value::Bool(b) => *b,
|
||||
Value::Number(n) => n.as_f64().is_some_and(|x| x.abs() > 1e-9),
|
||||
Value::String(s) => !s.is_empty(),
|
||||
Value::Array(a) => !a.is_empty(),
|
||||
Value::Object(o) => !o.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `text` a Lightroom local adjustment preset (`.lrtemplate`)?
|
||||
pub fn is_lr_local_template(text: &str) -> bool {
|
||||
parse_lua(text.trim_start_matches('\u{feff}'))
|
||||
.ok()
|
||||
.and_then(|root| root.get("type").and_then(Lua::str).map(|t| t == LR_LOCAL_TYPE))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `$$$/Path/Key=Default` (a localisable string) → `Default`.
|
||||
fn delocalize(t: &str) -> &str {
|
||||
t.strip_prefix("$$$/").and_then(|r| r.split_once('=')).map_or(t, |(_, d)| d)
|
||||
}
|
||||
|
||||
/// A Lightroom local adjustment preset → an effect named after its title (else `stem`), and the
|
||||
/// settings we have no equivalent for (e.g. `Grain`, `Point Color`), which the effect keeps in
|
||||
/// `extra`.
|
||||
pub fn parse_lr_local_template(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> {
|
||||
let root = parse_lua(text.trim_start_matches('\u{feff}'))?;
|
||||
if root.get("type").and_then(Lua::str) != Some(LR_LOCAL_TYPE) {
|
||||
return Err("not a local adjustment preset".into());
|
||||
}
|
||||
let Some(Lua::Table(_, fields)) = root.get("value") else { return Err("no adjustments in this local preset".into()) };
|
||||
// `exposure2012` → `LocalExposure2012`: the template's keys are the XMP `Local*` names, lower camel case
|
||||
let crs = |k: &str| {
|
||||
let mut c = k.chars();
|
||||
c.next().map(|f| format!("Local{}{}", f.to_ascii_uppercase(), c.as_str())).unwrap_or_default()
|
||||
};
|
||||
let num = |k: &str| fields.iter().find(|(f, _)| crs(f) == k).and_then(|(_, v)| if let Lua::Num(n) = v { Some(*n) } else { None });
|
||||
let values = crate::crs_masks::local_values(num);
|
||||
let mut extra = Map::new();
|
||||
for (k, v) in fields {
|
||||
if crate::crs_masks::is_local_field(&crs(k)) {
|
||||
continue;
|
||||
}
|
||||
let j = lua_json(v, 0);
|
||||
if meaningful(&j) {
|
||||
extra.insert(k.clone(), j);
|
||||
}
|
||||
}
|
||||
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(str::trim).filter(|t| !t.is_empty());
|
||||
let name = title.map(|t| delocalize(t).trim().to_string()).filter(|t| !t.is_empty()).unwrap_or_else(|| stem.to_string());
|
||||
build(name, values, extra)
|
||||
}
|
||||
|
||||
fn build(name: String, values: Map<String, Value>, extra: Map<String, Value>) -> std::result::Result<(MaskEffect, Vec<String>), String> {
|
||||
let adjust: LocalAdjustments = serde_json::from_value(Value::Object(values)).map_err(|e| format!("unreadable local preset: {e}"))?;
|
||||
let mut unmapped: Vec<String> = extra.keys().map(|k| setting_label(k)).collect();
|
||||
unmapped.sort();
|
||||
unmapped.dedup();
|
||||
let effect = MaskEffect { name, adjust, extra, ..Default::default() }.normalized();
|
||||
Ok((effect, unmapped))
|
||||
}
|
||||
|
||||
/// The local slider fields of a Camera Raw local correction preset: its top-level `crs:Local*`
|
||||
/// properties, or (a preset typed `Local` without them) its first correction's.
|
||||
fn xmp_local_fields(d: &lightcraft_meta::XmpData) -> Option<Map<String, Value>> {
|
||||
let mut out = Map::new();
|
||||
for (k, v) in &d.properties {
|
||||
if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local") && !n.contains('/'))
|
||||
&& let Some(x) = v.first()
|
||||
{
|
||||
out.insert(name.to_string(), json!(x.trim()));
|
||||
}
|
||||
}
|
||||
if !out.is_empty() {
|
||||
return Some(out);
|
||||
}
|
||||
let typed_local = d.properties.get("crs:PresetType").and_then(|v| v.first()).is_some_and(|t| t.trim().eq_ignore_ascii_case("local"));
|
||||
if !typed_local {
|
||||
return None;
|
||||
}
|
||||
let correction = crate::crs_masks::CONTAINERS.iter().find_map(|c| d.values.get(*c).and_then(|l| l.items().first()))?;
|
||||
let lightcraft_meta::XmpValue::Struct(m) = correction else { return None };
|
||||
for (k, v) in m {
|
||||
if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local"))
|
||||
&& let Some(x) = v.text()
|
||||
{
|
||||
out.insert(name.to_string(), json!(x));
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Is `text` a Camera Raw local correction preset (XMP)?
|
||||
pub fn is_local_xmp(text: &str) -> bool {
|
||||
lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).ok().and_then(|d| xmp_local_fields(&d)).is_some()
|
||||
}
|
||||
|
||||
/// A Camera Raw local correction preset (XMP) → an effect named after `crs:Name` (else `stem`),
|
||||
/// in `crs:Group` if it names one.
|
||||
pub fn parse_local_xmp(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> {
|
||||
let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?;
|
||||
let fields = xmp_local_fields(&d).ok_or("not a local correction preset (no crs:Local… settings)")?;
|
||||
let num = |v: &Value| v.as_str().map(|s| s.strip_prefix('+').unwrap_or(s)).and_then(|s| s.parse::<f64>().ok()).filter(|x| x.is_finite());
|
||||
let values = crate::crs_masks::local_values(|k| fields.get(k).and_then(num));
|
||||
let mut extra = Map::new();
|
||||
for (k, v) in &fields {
|
||||
if crate::crs_masks::is_local_field(k) {
|
||||
continue;
|
||||
}
|
||||
let v = num(v).map_or_else(|| v.clone(), |x| json!(x));
|
||||
if meaningful(&v) {
|
||||
extra.insert(k.clone(), v);
|
||||
}
|
||||
}
|
||||
let first = |k: &str| d.properties.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
let name = first("crs:Name").unwrap_or_else(|| stem.to_string());
|
||||
let (mut e, unmapped) = build(name, values, extra)?;
|
||||
e.group = first("crs:Group").unwrap_or_default();
|
||||
Ok((e, unmapped))
|
||||
}
|
||||
|
||||
/// Effects in an `.lceffect` file: `{format, version, effects: [...]}`, a bare array, or one effect.
|
||||
pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<MaskEffect>, String> {
|
||||
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not an effect preset file: {e}"))?;
|
||||
let items = match v {
|
||||
Value::Object(mut o) if o.contains_key("effects") => o.remove("effects").unwrap_or_default(),
|
||||
v @ Value::Array(_) => v,
|
||||
v @ Value::Object(_) => Value::Array(vec![v]),
|
||||
_ => return Err("not an effect preset file".into()),
|
||||
};
|
||||
let list: Vec<MaskEffect> = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?;
|
||||
let list: Vec<MaskEffect> = list.into_iter().filter(|e| !e.name.trim().is_empty()).map(MaskEffect::normalized).collect();
|
||||
if list.is_empty() {
|
||||
return Err("no effect presets in the file".into());
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
fn ext_of(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn stem_of(name: &str) -> String {
|
||||
std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "Effect".into())
|
||||
}
|
||||
|
||||
type Parsed = std::result::Result<Vec<(MaskEffect, Vec<String>)>, String>;
|
||||
|
||||
/// The effects in file `name` (`bytes`) if it is a local preset (`.lrtemplate` of the local type,
|
||||
/// a local correction `.xmp`, an `.lceffect`); `None` for any other file (a develop preset…).
|
||||
pub fn parse_bytes(name: &str, bytes: &[u8]) -> Option<Parsed> {
|
||||
let stem = stem_of(name);
|
||||
match ext_of(name).as_str() {
|
||||
"lrtemplate" => {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
is_lr_local_template(&text).then(|| parse_lr_local_template(&text, &stem).map(|e| vec![e]))
|
||||
}
|
||||
"xmp" => {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
is_local_xmp(&text).then(|| parse_local_xmp(&text, &stem).map(|e| vec![e]))
|
||||
}
|
||||
EFFECT_EXT => Some(parse_file(bytes).map(|v| v.into_iter().map(|e| (e, Vec::new())).collect())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The group effects in folder `dir` (relative to the imported folder) go to.
|
||||
pub fn group_for_dir(dir: &str) -> Option<String> {
|
||||
effect_group_from_dir(dir)
|
||||
}
|
||||
|
||||
/// Folder names that only say "local presets" (`Local Adjustment Presets`, `LocalCorrections`…)
|
||||
/// give no group.
|
||||
fn effect_group_from_dir(dir: &str) -> Option<String> {
|
||||
const FILLER: &[&str] = &[
|
||||
"local",
|
||||
"adjustment",
|
||||
"adjustments",
|
||||
"localcorrections",
|
||||
"corrections",
|
||||
"correction",
|
||||
"brush",
|
||||
"brushes",
|
||||
"mask",
|
||||
"masks",
|
||||
"effect",
|
||||
"effects",
|
||||
];
|
||||
let generic = |part: &str| {
|
||||
let lower = part.to_ascii_lowercase();
|
||||
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
|
||||
words.iter().any(|w| FILLER.contains(w))
|
||||
&& words.iter().all(|w| FILLER.contains(w) || matches!(*w, "presets" | "preset" | "lightroom" | "camera" | "raw" | "classic"))
|
||||
};
|
||||
let kept: Vec<&str> = dir.split(['/', '\\']).filter(|p| !p.is_empty() && !generic(p)).collect();
|
||||
group_from_dir(&kept.join("/"))
|
||||
}
|
||||
|
||||
/// Effects found in `bytes` of file `file` (a `.zip` is opened: the local presets inside).
|
||||
/// `dir_group`: the group the file's folder gives. Errors are `(file, message)`.
|
||||
pub fn found_in(file: &str, bytes: &[u8], dir_group: Option<String>, out: &mut Vec<Found>, failed: &mut Vec<(String, String)>) -> bool {
|
||||
let dir = std::path::Path::new(file).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
|
||||
if ext_of(file) == "zip" {
|
||||
let Ok(entries) = read_zip(bytes) else { return false };
|
||||
let zip_group = effect_group_from_dir(&stem_of(file)).or(dir_group);
|
||||
let mut any = false;
|
||||
for (inner, data) in entries {
|
||||
let inner_dir = inner.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or_default();
|
||||
let group = effect_group_from_dir(&inner_dir).or_else(|| zip_group.clone());
|
||||
let label = format!("{file}/{inner}");
|
||||
match parse_bytes(&inner, &data) {
|
||||
Some(Ok(list)) => {
|
||||
any = true;
|
||||
push(out, list, &label, format!("{file}/{inner_dir}"), group, &inner);
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
any = true;
|
||||
failed.push((label, e));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
return any;
|
||||
}
|
||||
match parse_bytes(file, bytes) {
|
||||
Some(Ok(list)) => {
|
||||
push(out, list, file, dir, dir_group, file);
|
||||
true
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
failed.push((file.to_string(), e));
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(out: &mut Vec<Found>, list: Vec<(MaskEffect, Vec<String>)>, file: &str, dir: String, dir_group: Option<String>, name: &str) {
|
||||
let named = ext_of(name) == EFFECT_EXT;
|
||||
for (effect, unmapped) in list {
|
||||
let has_group = named || !effect.group.is_empty();
|
||||
out.push(Found { effect, unmapped, file: file.to_string(), dir: dir.clone(), dir_group: dir_group.clone(), has_group });
|
||||
}
|
||||
}
|
||||
|
||||
/// The number a preset name starts with (`24a-Eye` → 24).
|
||||
fn leading_number(name: &str) -> Option<u64> {
|
||||
let digits: String = name.trim().chars().take_while(char::is_ascii_digit).take(18).collect();
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
/// The section title of a divider preset — an effect that sets nothing, named like
|
||||
/// `10 ----- DETAIL ENHANCERS ----- 10` (a run of at least three `-`, `=`, `_`, `*`, `#`, `~`, `—`).
|
||||
pub fn divider_title(e: &MaskEffect) -> Option<String> {
|
||||
if !e.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let marks = ['-', '=', '_', '*', '#', '~', '—', '–', '•', '.'];
|
||||
let chars: Vec<char> = e.name.chars().collect();
|
||||
let has_run = chars.windows(3).any(|w| marks.contains(&w[0]) && w[0] == w[1] && w[1] == w[2]);
|
||||
if !has_run {
|
||||
return None;
|
||||
}
|
||||
let title: String = e.name.chars().map(|c| if marks.contains(&c) { ' ' } else { c }).collect();
|
||||
let title = title.trim().trim_start_matches(|c: char| c.is_ascii_digit() || c.is_whitespace());
|
||||
let title = title.trim_end_matches(|c: char| c.is_ascii_digit() || c.is_whitespace());
|
||||
let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if !title.chars().any(char::is_alphanumeric) {
|
||||
return None;
|
||||
}
|
||||
// "DETAIL ENHANCERS" → "Detail Enhancers"
|
||||
let shouting = title.chars().any(char::is_alphabetic) && !title.chars().any(char::is_lowercase);
|
||||
Some(if shouting {
|
||||
title
|
||||
.split(' ')
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
c.next().map(|f| f.to_uppercase().chain(c.flat_map(char::to_lowercase)).collect::<String>()).unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
} else {
|
||||
title
|
||||
})
|
||||
}
|
||||
|
||||
/// Give `found` their groups; returns the divider presets (which become groups, not effects) as
|
||||
/// `(found, group)`. A numbered effect goes to the divider with the largest number not above its
|
||||
/// own (`12 Sky` under `10 --- DETAIL --- 10`); others to their folder's group.
|
||||
pub fn assign_groups(found: Vec<Found>, group: Option<&str>) -> (Vec<Found>, Vec<(Found, String)>) {
|
||||
let (dividers, mut effects): (Vec<(Found, Option<String>)>, Vec<(Found, Option<String>)>) =
|
||||
found.into_iter().map(|f| (divider_title(&f.effect).filter(|_| !f.has_group), f)).map(|(t, f)| (f, t)).partition(|(_, t)| t.is_some());
|
||||
let dividers: Vec<(Found, String)> = dividers.into_iter().filter_map(|(f, t)| t.map(|t| (f, t))).collect();
|
||||
for (f, _) in &mut effects {
|
||||
if let Some(g) = group {
|
||||
f.effect.group = g.to_string();
|
||||
continue;
|
||||
}
|
||||
if f.has_group {
|
||||
continue;
|
||||
}
|
||||
let section = leading_number(&f.effect.name).and_then(|n| {
|
||||
dividers
|
||||
.iter()
|
||||
.filter(|(d, _)| d.dir == f.dir)
|
||||
.filter_map(|(d, t)| leading_number(&d.effect.name).filter(|m| *m <= n).map(|m| (m, t)))
|
||||
.max_by_key(|(m, _)| *m)
|
||||
.map(|(_, t)| t.clone())
|
||||
});
|
||||
f.effect.group = section.or_else(|| f.dir_group.clone()).unwrap_or_default();
|
||||
}
|
||||
(effects.into_iter().map(|(f, _)| f).collect(), dividers)
|
||||
}
|
||||
|
||||
/// Add the found effects (grouped; same-named ones replaced; effects left from an earlier import
|
||||
/// of a divider dropped) and save → the report rows `(imported, dividers)`.
|
||||
pub fn add_found(s: &mut Session, found: Vec<Found>, group: Option<&str>, dry: bool) -> Result<(Vec<Value>, Vec<Value>)> {
|
||||
let (effects, dividers) = assign_groups(found, group);
|
||||
let rows: Vec<Value> =
|
||||
effects.iter().map(|f| json!({"name": f.effect.name, "group": f.effect.group, "file": f.file, "unmapped": f.unmapped})).collect();
|
||||
let div_rows: Vec<Value> = dividers.iter().map(|(f, g)| json!({"name": f.effect.name, "group": g, "file": f.file})).collect();
|
||||
if dry || (effects.is_empty() && dividers.is_empty()) {
|
||||
return Ok((rows, div_rows));
|
||||
}
|
||||
s.mask_effects.retain(|e| !(e.is_empty() && dividers.iter().any(|(d, _)| d.effect.name.eq_ignore_ascii_case(&e.name))));
|
||||
for f in effects {
|
||||
upsert(s, f.effect);
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok((rows, div_rows))
|
||||
}
|
||||
|
||||
/// The files under `paths` an effect import reads (folders walked, at most 8 levels), each with
|
||||
/// the group its folder gives and whether it was named (not found in a folder).
|
||||
fn effect_files(paths: &[String]) -> Vec<(String, Option<String>, bool)> {
|
||||
fn walk(dir: &std::path::Path, base: &std::path::Path, depth: usize, out: &mut Vec<(String, Option<String>, bool)>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect();
|
||||
entries.sort();
|
||||
for p in entries {
|
||||
if p.is_dir() {
|
||||
if depth < 8 {
|
||||
walk(&p, base, depth + 1, out);
|
||||
}
|
||||
} else if p.extension().is_some_and(|e| ["lrtemplate", EFFECT_EXT, "xmp", "zip"].iter().any(|x| e.eq_ignore_ascii_case(x))) {
|
||||
let rel = p.parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
|
||||
out.push((p.to_string_lossy().to_string(), rel.and_then(|d| effect_group_from_dir(&d)), false));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for x in paths {
|
||||
let p = std::path::Path::new(x);
|
||||
if p.is_dir() {
|
||||
walk(p, p.parent().unwrap_or(std::path::Path::new("")), 0, &mut out);
|
||||
} else {
|
||||
out.push((x.clone(), None, true));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Read a file of at most [`MAX_FILE`] bytes.
|
||||
pub fn read_capped(path: &str) -> std::result::Result<Vec<u8>, String> {
|
||||
let len = std::fs::metadata(path).map_err(|e| e.to_string())?.len();
|
||||
if len > MAX_FILE {
|
||||
return Err(format!("too large for a preset file ({} MB)", len >> 20));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn import_effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.importEffects";
|
||||
let mut paths: Vec<String> =
|
||||
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
if let Some(x) = str_param(p, "path") {
|
||||
paths.push(x.to_string());
|
||||
}
|
||||
if paths.is_empty() {
|
||||
return Err(bad(C, "missing `path` / `paths`"));
|
||||
}
|
||||
let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty());
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let (mut found, mut failed) = (Vec::new(), Vec::new());
|
||||
for (f, dir_group, named) in effect_files(&paths) {
|
||||
match read_capped(&f) {
|
||||
Ok(bytes) => {
|
||||
if !found_in(&f, &bytes, dir_group, &mut found, &mut failed) && named {
|
||||
failed.push((f, "not a local adjustment preset".into()));
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push((f, e)),
|
||||
}
|
||||
}
|
||||
let (imported, dividers) = add_found(s, found, group, dry)?;
|
||||
Ok(json!({"imported": imported, "dividers": dividers, "failed": failed.into_iter().map(|(f, e)| json!([f, e])).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub fn export_effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.exportEffects";
|
||||
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let mut path = std::path::PathBuf::from(path);
|
||||
if path.extension().is_none() {
|
||||
path.set_extension(EFFECT_EXT);
|
||||
}
|
||||
let names: Option<Vec<String>> =
|
||||
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
let group = str_param(p, "group").map(str::trim);
|
||||
let chosen: Vec<&MaskEffect> = s
|
||||
.mask_effects
|
||||
.iter()
|
||||
.filter(|e| names.as_ref().is_none_or(|n| n.iter().any(|x| x.eq_ignore_ascii_case(&e.name))))
|
||||
.filter(|e| group.is_none_or(|g| g.eq_ignore_ascii_case(&e.group)))
|
||||
.collect();
|
||||
if chosen.is_empty() {
|
||||
return Err(bad(C, "no effect presets to export"));
|
||||
}
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 2, "effects": chosen}))
|
||||
.map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
std::fs::write(&path, text).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// A synthetic template in the shape Lightroom writes (our own values).
|
||||
const TEMPLATE: &str = r#"s = {
|
||||
id = "00000000-0000-0000-0000-000000000001",
|
||||
internalName = "Soft Glow",
|
||||
title = "Soft Glow",
|
||||
type = "LocalizedAdjustmentPreset",
|
||||
value = {
|
||||
clarity = 0.5,
|
||||
clarity2012 = -0.25,
|
||||
exposure = 0,
|
||||
exposure2012 = 0.125,
|
||||
grain = 0.25,
|
||||
highlights2012 = -0.5,
|
||||
luminanceNoise = 0.1,
|
||||
refineSaturation = 0,
|
||||
temperature = 0.2,
|
||||
texture = -0.3,
|
||||
toningHue = 240,
|
||||
toningLuminance = 0.1,
|
||||
toningSaturation = 0,
|
||||
pointColors = {
|
||||
{
|
||||
HueRange = { LowerFull = 0.3, LowerNone = 0, UpperFull = 0.6, UpperNone = 1, },
|
||||
HueShift = -0.05,
|
||||
RangeAmount = 0.5,
|
||||
SrcHue = 0.8,
|
||||
},
|
||||
},
|
||||
},
|
||||
version = 0,
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn reads_a_lightroom_local_template() {
|
||||
assert!(is_lr_local_template(TEMPLATE));
|
||||
let (e, unmapped) = parse_lr_local_template(TEMPLATE, "file").unwrap();
|
||||
assert_eq!(e.name, "Soft Glow");
|
||||
assert!((e.adjust.exposure - 0.5).abs() < 1e-9, "exposure2012 is a quarter of the stops");
|
||||
assert!((e.adjust.clarity + 25.0).abs() < 1e-9, "the 2012 field wins over the old one");
|
||||
assert!((e.adjust.highlights + 50.0).abs() < 1e-9);
|
||||
assert!((e.adjust.temp - 20.0).abs() < 1e-9);
|
||||
assert!((e.adjust.texture + 30.0).abs() < 1e-9);
|
||||
assert!((e.adjust.noise - 10.0).abs() < 1e-9);
|
||||
assert!((e.adjust.amount - 100.0).abs() < 1e-9);
|
||||
assert_eq!(unmapped, vec!["Color luminance".to_string(), "Grain".into(), "Point Color".into()]);
|
||||
assert_eq!(e.extra.get("grain"), Some(&json!(0.25)), "kept for export");
|
||||
assert_eq!(e.extra["pointColors"][0]["HueRange"]["UpperFull"], json!(0.6));
|
||||
assert!(!e.extra.contains_key("refineSaturation"), "zero settings aren't kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn develop_templates_are_not_local_ones() {
|
||||
let develop = r#"s = { title = "X", type = "Develop", value = { settings = { Exposure2012 = 1 } } }"#;
|
||||
assert!(!is_lr_local_template(develop));
|
||||
assert!(parse_lr_local_template(develop, "x").is_err());
|
||||
assert!(parse_bytes("x.lrtemplate", develop.as_bytes()).is_none(), "left to the develop preset import");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_files_are_errors_not_panics() {
|
||||
for bad in [
|
||||
"garbage {",
|
||||
"",
|
||||
"s = {",
|
||||
r#"s = { type = "LocalizedAdjustmentPreset", value = 3 }"#,
|
||||
r#"s = { type = "LocalizedAdjustmentPreset" }"#,
|
||||
r#"s = { type = "LocalizedAdjustmentPreset", value = { exposure2012 = "x", grain = {{{{}}}} } }"#,
|
||||
"s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 1e400, contrast2012 = -1e400 } }",
|
||||
"\u{feff}s = { type = \"LocalizedAdjustmentPreset\", title = \"\", value = {} }",
|
||||
] {
|
||||
let _ = parse_lr_local_template(bad, "x");
|
||||
let _ = parse_bytes("x.lrtemplate", bad.as_bytes());
|
||||
}
|
||||
// deeply nested tables
|
||||
let deep = format!("s = {{ type = \"LocalizedAdjustmentPreset\", value = {{ pointColors = {}1{} }} }}", "{".repeat(200), "}".repeat(200));
|
||||
let _ = parse_lr_local_template(&deep, "x");
|
||||
let (e, _) = parse_lr_local_template("s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 9 } }", "Stem").unwrap();
|
||||
assert_eq!((e.name.as_str(), e.adjust.exposure), ("Stem", 4.0), "values clamp, the file name names it");
|
||||
for bad in ["", "<x:xmpmeta", "<x:xmpmeta></x:xmpmeta>", "\u{0}\u{1}"] {
|
||||
assert!(parse_bytes("x.xmp", bad.as_bytes()).is_none_or(|r| r.is_err()));
|
||||
}
|
||||
for bad in ["", "3", "[]", "{\"effects\": 3}", "[{\"name\": 4}]", "{\"effects\": [{\"name\": \" \"}]}"] {
|
||||
assert!(parse_file(bad.as_bytes()).is_err(), "{bad}");
|
||||
}
|
||||
assert!(!found_in("x.zip", b"PK\x05\x06garbage", None, &mut Vec::new(), &mut Vec::new()));
|
||||
}
|
||||
|
||||
/// A Camera Raw local correction preset (our own values).
|
||||
const ACR: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:PresetType="Local" crs:LocalExposure2012="+0.25" crs:LocalShadows2012="0.4" crs:LocalGrain="0.2" crs:LocalTexture="0">
|
||||
<crs:Name><rdf:Alt><rdf:li xml:lang="x-default">Lift Shadows</rdf:li></rdf:Alt></crs:Name>
|
||||
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Faces</rdf:li></rdf:Alt></crs:Group>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
|
||||
#[test]
|
||||
fn reads_a_camera_raw_local_preset() {
|
||||
assert!(is_local_xmp(ACR));
|
||||
let (e, unmapped) = parse_local_xmp(ACR, "file").unwrap();
|
||||
assert_eq!((e.name.as_str(), e.group.as_str()), ("Lift Shadows", "Faces"));
|
||||
assert_eq!((e.adjust.exposure, e.adjust.shadows), (1.0, 40.0));
|
||||
assert_eq!(unmapped, vec!["Grain".to_string()]);
|
||||
// a develop preset is not one
|
||||
let develop = ACR
|
||||
.replace(
|
||||
"crs:LocalExposure2012=\"+0.25\" crs:LocalShadows2012=\"0.4\" crs:LocalGrain=\"0.2\" crs:LocalTexture=\"0\"",
|
||||
"crs:Exposure2012=\"1\"",
|
||||
)
|
||||
.replace("PresetType=\"Local\"", "PresetType=\"Normal\"");
|
||||
assert!(!is_local_xmp(&develop));
|
||||
// a preset typed Local that keeps its sliders in a correction
|
||||
let nested = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PresetType="Local">
|
||||
<crs:MaskGroupBasedCorrections><rdf:Seq><rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.5"/></rdf:li></rdf:Seq></crs:MaskGroupBasedCorrections>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
let (e, _) = parse_local_xmp(nested, "Soft").unwrap();
|
||||
assert_eq!((e.name.as_str(), e.adjust.clarity), ("Soft", -50.0));
|
||||
}
|
||||
|
||||
fn found(name: &str, exposure: f64, dir: &str) -> Found {
|
||||
Found {
|
||||
effect: MaskEffect { name: name.into(), adjust: LocalAdjustments { exposure, ..Default::default() }, ..Default::default() },
|
||||
unmapped: Vec::new(),
|
||||
file: format!("{dir}/{name}.lrtemplate"),
|
||||
dir: dir.into(),
|
||||
dir_group: None,
|
||||
has_group: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dividers_become_groups() {
|
||||
let e = |n: &str| MaskEffect { name: n.into(), ..Default::default() };
|
||||
assert_eq!(divider_title(&e("10 ----- DETAIL ENHANCERS ----- 10")).as_deref(), Some("Detail Enhancers"));
|
||||
assert_eq!(divider_title(&e("50--------MISC-----50")).as_deref(), Some("Misc"));
|
||||
assert_eq!(divider_title(&e("=== Portraits ===")).as_deref(), Some("Portraits"));
|
||||
assert_eq!(divider_title(&e("11_General_-_All_Purpose")), None, "no run of marks");
|
||||
assert_eq!(divider_title(&e("-----")), None, "no title");
|
||||
let busy = MaskEffect { adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..e("20 --- X --- 20") };
|
||||
assert_eq!(divider_title(&busy), None, "a divider sets nothing");
|
||||
|
||||
let list = vec![
|
||||
found("01 Burn", -1.0, "a"),
|
||||
found("10 ----- DETAIL ENHANCERS ----- 10", 0.0, "a"),
|
||||
found("12 Sky", 0.5, "a"),
|
||||
found("20 ----- RETOUCHING ----- 20", 0.0, "a"),
|
||||
found("24a-Eye-Cleaner", 0.1, "a"),
|
||||
found("99 Late", 0.1, "a"),
|
||||
found("Burn (Darken)", -0.5, "a"),
|
||||
Found { dir_group: Some("Other".into()), ..found("15 Elsewhere", 0.2, "b") },
|
||||
];
|
||||
let (effects, dividers) = assign_groups(list.clone(), None);
|
||||
let groups: Vec<(&str, &str)> = effects.iter().map(|f| (f.effect.name.as_str(), f.effect.group.as_str())).collect();
|
||||
assert_eq!(
|
||||
groups,
|
||||
vec![
|
||||
("01 Burn", ""),
|
||||
("12 Sky", "Detail Enhancers"),
|
||||
("24a-Eye-Cleaner", "Retouching"),
|
||||
("99 Late", "Retouching"),
|
||||
("Burn (Darken)", ""),
|
||||
("15 Elsewhere", "Other"),
|
||||
]
|
||||
);
|
||||
assert_eq!(dividers.len(), 2);
|
||||
let (effects, _) = assign_groups(list, Some("Mine"));
|
||||
assert!(effects.iter().all(|f| f.effect.group == "Mine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_names_that_only_say_local_presets_give_no_group() {
|
||||
assert_eq!(effect_group_from_dir("Local Adjustment Presets"), None);
|
||||
assert_eq!(effect_group_from_dir("LocalCorrections"), None);
|
||||
assert_eq!(effect_group_from_dir("Local Adjustment Presets/Portrait Brushes"), Some("Portrait Brushes".into()));
|
||||
assert_eq!(effect_group_from_dir("Skin Kit"), Some("Skin Kit".into()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
//! Mask effect presets: named sets of local adjustment sliders picked from the Masking panel's
|
||||
//! Effect menu (Lightroom's local adjustment / brush presets), saved with the library preferences.
|
||||
//! Grouped (folders in the menu), favourites, partial presets (only some sliders, with or without
|
||||
//! Amount) and optional brush settings. Files: [`super::mask_effect_files`].
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::mask_effect_files as files;
|
||||
use super::{CommandSpec, always, bad, cmd, has_active, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The local sliders an effect can set (`LocalAdjustments` field names; Amount aside).
|
||||
pub const FIELDS: &[&str] = &[
|
||||
"temp",
|
||||
"tint",
|
||||
"exposure",
|
||||
"contrast",
|
||||
"highlights",
|
||||
"shadows",
|
||||
"whites",
|
||||
"blacks",
|
||||
"texture",
|
||||
"clarity",
|
||||
"dehaze",
|
||||
"hue",
|
||||
"saturation",
|
||||
"sharpness",
|
||||
"noise",
|
||||
"moire",
|
||||
"defringe",
|
||||
"color_hue",
|
||||
"color_sat",
|
||||
];
|
||||
|
||||
/// Brush settings an effect can carry (Lightroom keeps these per brush, not in presets).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BrushSettings {
|
||||
/// Radius as a fraction of the image's long edge.
|
||||
pub size: f64,
|
||||
/// 0..100.
|
||||
pub feather: f64,
|
||||
pub flow: f64,
|
||||
pub density: f64,
|
||||
pub auto_mask: bool,
|
||||
}
|
||||
|
||||
impl Default for BrushSettings {
|
||||
fn default() -> Self {
|
||||
Self { size: 0.04, feather: 50.0, flow: 100.0, density: 100.0, auto_mask: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl BrushSettings {
|
||||
fn clamped(self) -> Self {
|
||||
let c = |v: f64, lo: f64, hi: f64, d: f64| if v.is_finite() { v.clamp(lo, hi) } else { d };
|
||||
let d = Self::default();
|
||||
Self {
|
||||
size: c(self.size, 0.002, 0.5, d.size),
|
||||
feather: c(self.feather, 0.0, 100.0, d.feather),
|
||||
flow: c(self.flow, 1.0, 100.0, d.flow),
|
||||
density: c(self.density, 1.0, 100.0, d.density),
|
||||
auto_mask: self.auto_mask,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named set of local adjustment sliders. Applying one sets the sliders it names (all of them
|
||||
/// when `fields` is empty, as Lightroom's presets do: those it doesn't change go to 0); the mask's
|
||||
/// Amount stays unless `with_amount`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct MaskEffect {
|
||||
pub name: String,
|
||||
pub adjust: LocalAdjustments,
|
||||
/// The Effect menu folder ("" = none).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub group: String,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub favorite: bool,
|
||||
/// The sliders this effect sets ([`FIELDS`] names); empty = all of them.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub fields: Vec<String>,
|
||||
/// Applying sets the mask's Amount to `adjust.amount` too.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub with_amount: bool,
|
||||
/// Brush settings the brush takes when the effect is applied.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub brush: Option<BrushSettings>,
|
||||
/// Settings of an imported preset that we have no slider for (Lightroom key → value), kept so
|
||||
/// an export carries them.
|
||||
#[serde(skip_serializing_if = "Map::is_empty")]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl Default for MaskEffect {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
adjust: LocalAdjustments::default(),
|
||||
group: String::new(),
|
||||
favorite: false,
|
||||
fields: Vec::new(),
|
||||
with_amount: false,
|
||||
brush: None,
|
||||
extra: Map::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Field `k` of `a` (0 for an unknown name).
|
||||
pub fn get(a: &LocalAdjustments, k: &str) -> f64 {
|
||||
serde_json::to_value(a).ok().and_then(|v| v.get(k).and_then(Value::as_f64)).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// `a` with field `k` set to `v` (unchanged for an unknown name).
|
||||
pub fn set(a: &LocalAdjustments, k: &str, v: f64) -> LocalAdjustments {
|
||||
let Ok(Value::Object(mut o)) = serde_json::to_value(a) else { return *a };
|
||||
if !o.contains_key(k) || !v.is_finite() {
|
||||
return *a;
|
||||
}
|
||||
o.insert(k.into(), json!(v));
|
||||
serde_json::from_value(Value::Object(o)).unwrap_or(*a)
|
||||
}
|
||||
|
||||
/// The sliders of `a` away from 0 (the colour's hue only with its saturation).
|
||||
pub fn changed_fields(a: &LocalAdjustments) -> Vec<String> {
|
||||
let mut out: Vec<String> = FIELDS.iter().filter(|k| get(a, k).abs() > 1e-9 && **k != "color_hue").map(|k| k.to_string()).collect();
|
||||
if a.color_sat.abs() > 1e-9 {
|
||||
out.push("color_hue".into());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl MaskEffect {
|
||||
/// The sliders this effect sets.
|
||||
pub fn field_list(&self) -> Vec<&str> {
|
||||
if self.fields.is_empty() { FIELDS.to_vec() } else { FIELDS.iter().copied().filter(|k| self.fields.iter().any(|f| f == k)).collect() }
|
||||
}
|
||||
/// `a` with this preset's sliders (and Amount, if it carries it).
|
||||
pub fn apply(&self, a: &LocalAdjustments) -> LocalAdjustments {
|
||||
let mut out = if self.fields.is_empty() {
|
||||
LocalAdjustments { amount: a.amount, ..self.adjust }
|
||||
} else {
|
||||
self.field_list().iter().fold(*a, |acc, k| set(&acc, k, get(&self.adjust, k)))
|
||||
};
|
||||
if self.with_amount {
|
||||
out.amount = self.adjust.amount;
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Does `a` carry exactly this preset's sliders? A tint's hue doesn't count without its
|
||||
/// saturation (it does nothing then).
|
||||
pub fn matches(&self, a: &LocalAdjustments) -> bool {
|
||||
let fields = self.field_list();
|
||||
let colorless = |x: &LocalAdjustments| x.color_sat.abs() < 1e-9;
|
||||
let same = |k: &str| (get(&self.adjust, k) - get(a, k)).abs() < 1e-6;
|
||||
fields.iter().all(|k| *k == "color_hue" && colorless(&self.adjust) && colorless(a) || same(k))
|
||||
&& (!self.with_amount || (self.adjust.amount - a.amount).abs() < 1e-6)
|
||||
}
|
||||
/// Does the effect set nothing (all its sliders at 0, nothing kept from an import)?
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.extra.is_empty() && changed_fields(&self.adjust).iter().all(|k| !self.field_list().contains(&k.as_str()))
|
||||
}
|
||||
/// The same effect with its values in range and its field list normalised.
|
||||
pub fn normalized(mut self) -> Self {
|
||||
self.name = self.name.trim().to_string();
|
||||
self.group = self.group.trim().to_string();
|
||||
let amount = if self.with_amount { self.adjust.amount } else { 100.0 };
|
||||
self.adjust = super::masks::clamp_local(LocalAdjustments { amount, ..self.adjust });
|
||||
self.fields.retain(|f| FIELDS.contains(&f.as_str()));
|
||||
if self.fields.iter().any(|f| f == "color_sat") && !self.fields.iter().any(|f| f == "color_hue") {
|
||||
self.fields.push("color_hue".into());
|
||||
}
|
||||
self.fields.sort_by_key(|f| FIELDS.iter().position(|k| k == f));
|
||||
self.fields.dedup();
|
||||
if self.fields.len() == FIELDS.len() {
|
||||
self.fields.clear();
|
||||
}
|
||||
self.brush = self.brush.map(BrushSettings::clamped);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare names the way people number their presets: digit runs as numbers ("2 …" before
|
||||
/// "10 …"), letters ignoring case.
|
||||
pub fn natural_cmp(a: &str, b: &str) -> Ordering {
|
||||
fn chunks(s: &str) -> Vec<(bool, String)> {
|
||||
let mut out: Vec<(bool, String)> = Vec::new();
|
||||
for c in s.chars() {
|
||||
let d = c.is_ascii_digit();
|
||||
match out.last_mut() {
|
||||
Some((kind, text)) if *kind == d => text.push(c),
|
||||
_ => out.push((d, c.to_string())),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let (x, y) = (chunks(a), chunks(b));
|
||||
for (p, q) in x.iter().zip(y.iter()) {
|
||||
let o = match (p, q) {
|
||||
((true, m), (true, n)) => {
|
||||
let (m, n) = (m.trim_start_matches('0'), n.trim_start_matches('0'));
|
||||
m.len().cmp(&n.len()).then_with(|| m.cmp(n))
|
||||
}
|
||||
((_, m), (_, n)) => m.to_lowercase().cmp(&n.to_lowercase()),
|
||||
};
|
||||
if o != Ordering::Equal {
|
||||
return o;
|
||||
}
|
||||
}
|
||||
x.len().cmp(&y.len()).then_with(|| a.cmp(b))
|
||||
}
|
||||
|
||||
/// The Effect menu's order: groups in the order they first appear (ungrouped first), each with
|
||||
/// its effects (indices into `effects`) sorted by name.
|
||||
pub fn menu_order(effects: &[MaskEffect]) -> Vec<(String, Vec<usize>)> {
|
||||
let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
|
||||
if effects.iter().any(|e| e.group.is_empty()) {
|
||||
groups.push((String::new(), Vec::new()));
|
||||
}
|
||||
for (i, e) in effects.iter().enumerate() {
|
||||
match groups.iter_mut().find(|(g, _)| g.eq_ignore_ascii_case(&e.group)) {
|
||||
Some((_, v)) => v.push(i),
|
||||
None => groups.push((e.group.clone(), vec![i])),
|
||||
}
|
||||
}
|
||||
for (_, v) in &mut groups {
|
||||
v.sort_by(|a, b| match (effects.get(*a), effects.get(*b)) {
|
||||
(Some(x), Some(y)) => natural_cmp(&x.name, &y.name),
|
||||
_ => Ordering::Equal,
|
||||
});
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// Name of the effect the mask adjustments `a` match, if any (full presets before partial ones).
|
||||
pub fn matching_effect(s: &Session, a: &LocalAdjustments) -> Option<String> {
|
||||
let full = s.mask_effects.iter().filter(|e| e.fields.is_empty()).find(|e| e.matches(a));
|
||||
full.or_else(|| s.mask_effects.iter().find(|e| e.matches(a))).map(|e| e.name.clone())
|
||||
}
|
||||
|
||||
fn position(s: &Session, name: &str) -> Option<usize> {
|
||||
s.mask_effects.iter().position(|x| x.name.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// Add or replace (same name, ignoring case) an effect; a replaced one keeps its star.
|
||||
pub(crate) fn upsert(s: &mut Session, e: MaskEffect) {
|
||||
match position(s, &e.name).and_then(|i| s.mask_effects.get_mut(i)) {
|
||||
Some(x) => {
|
||||
let favorite = x.favorite || e.favorite;
|
||||
*x = MaskEffect { favorite, ..e };
|
||||
}
|
||||
None => s.mask_effects.push(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn name_param(p: &Value, c: &str) -> Result<String> {
|
||||
name_of(p, "name", c)
|
||||
}
|
||||
|
||||
fn name_of(p: &Value, key: &str, c: &str) -> Result<String> {
|
||||
str_param(p, key).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, format!("missing `{key}`")))
|
||||
}
|
||||
|
||||
fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
|
||||
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
}
|
||||
|
||||
/// The names a command acts on: `names: [...]` or `name`.
|
||||
fn names_param(p: &Value, c: &str) -> Result<Vec<String>> {
|
||||
match strings(p, "names").filter(|v| !v.is_empty()) {
|
||||
Some(v) => Ok(v),
|
||||
None => name_param(p, c).map(|n| vec![n]),
|
||||
}
|
||||
}
|
||||
|
||||
fn effect_index(s: &Session, name: &str, c: &str) -> Result<usize> {
|
||||
position(s, name).ok_or_else(|| bad(c, format!("no effect `{name}`")))
|
||||
}
|
||||
|
||||
/// The selected mask's adjustments on the active photo.
|
||||
fn selected_mask(s: &Session, p: &Value, c: &str) -> Result<Mask> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mid = p
|
||||
.get("id")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.or(s.active_mask)
|
||||
.ok_or_else(|| bad(c, "no mask selected (give `id`)"))?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
d.masks.iter().find(|m| m.id == mid).cloned().ok_or_else(|| bad(c, format!("no mask {mid}")))
|
||||
}
|
||||
|
||||
/// The kind of a mask's first component (`brush`, `linear`, `radial`, `subject`…): the tool the
|
||||
/// effect is remembered for.
|
||||
pub fn mask_tool(m: &Mask) -> Option<String> {
|
||||
m.components.first().and_then(|c| serde_json::to_value(&c.shape).ok()).and_then(|v| v.get("kind").and_then(Value::as_str).map(str::to_string))
|
||||
}
|
||||
|
||||
/// The adjustments a new mask made with `tool` starts with: the effect last applied to a mask of
|
||||
/// that kind (Lightroom carries the chosen effect over to the next mask), else all at 0.
|
||||
pub fn new_mask_adjust(s: &Session, tool: &str) -> LocalAdjustments {
|
||||
s.mask_tool_effects
|
||||
.get(tool)
|
||||
.and_then(|name| position(s, name))
|
||||
.and_then(|i| s.mask_effects.get(i))
|
||||
.map(|e| super::masks::clamp_local(e.apply(&LocalAdjustments::default())))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn list(s: &Session) -> Value {
|
||||
let current = s
|
||||
.active()
|
||||
.and_then(|id| s.develop_of(id))
|
||||
.and_then(|d| s.active_mask.and_then(|mid| d.masks.iter().find(|m| m.id == mid).map(|m| m.adjust)))
|
||||
.and_then(|a| matching_effect(s, &a));
|
||||
let groups: Vec<Value> = menu_order(&s.mask_effects)
|
||||
.into_iter()
|
||||
.map(|(g, ix)| json!({"name": g, "effects": ix.iter().filter_map(|i| s.mask_effects.get(*i)).map(|e| e.name.clone()).collect::<Vec<_>>()}))
|
||||
.collect();
|
||||
json!({"effects": s.mask_effects, "groups": groups, "current": current, "toolEffects": s.mask_tool_effects})
|
||||
}
|
||||
|
||||
/// Masks on `d` named like one of `names` (ignoring case).
|
||||
fn masks_named(d: &DevelopSettings, names: &[String]) -> Vec<u32> {
|
||||
d.masks.iter().filter(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.name))).map(|m| m.id).collect()
|
||||
}
|
||||
|
||||
fn apply_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.applyEffect";
|
||||
let name = name_param(p, C)?;
|
||||
let effect = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
// the masks: `ids`, `all`, else `id` or the selected one
|
||||
let ids: Vec<u32> = if p.get("all").and_then(Value::as_bool).unwrap_or(false) {
|
||||
d.masks.iter().map(|m| m.id).collect()
|
||||
} else if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect()
|
||||
} else {
|
||||
vec![selected_mask(s, p, C)?.id]
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no masks to apply the effect to"));
|
||||
}
|
||||
if let Some(missing) = ids.iter().find(|m| !d.masks.iter().any(|x| x.id == **m)) {
|
||||
return Err(bad(C, format!("no mask {missing}")));
|
||||
}
|
||||
let set_masks = |d: &DevelopSettings, ids: &[u32]| {
|
||||
let mut d = d.clone();
|
||||
for m in d.masks.iter_mut().filter(|m| ids.contains(&m.id)) {
|
||||
m.adjust = super::masks::clamp_local(effect.apply(&m.adjust));
|
||||
}
|
||||
d
|
||||
};
|
||||
let label = format!("Mask Effect: {}", effect.name);
|
||||
// with `photos: "selected"`, the other selected photos' masks of the same names too
|
||||
let others: Vec<(PhotoId, DevelopSettings)> = if str_param(p, "photos") == Some("selected") {
|
||||
let names: Vec<String> = d.masks.iter().filter(|m| ids.contains(&m.id)).map(|m| m.name.clone()).collect();
|
||||
s.selection
|
||||
.ids
|
||||
.iter()
|
||||
.filter(|x| **x != id)
|
||||
.filter_map(|x| s.develop_of(*x).map(|o| (*x, o)))
|
||||
.filter_map(|(x, o)| {
|
||||
let found = masks_named(&o, &names);
|
||||
(!found.is_empty()).then(|| (x, set_masks(&o, &found)))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mine = set_masks(&d, &ids);
|
||||
let photos = 1 + others.len();
|
||||
if others.is_empty() {
|
||||
s.set_develop(id, mine, &label)?;
|
||||
} else {
|
||||
// one undo step for every photo
|
||||
let mut ops: Vec<_> = s.develop_op(id, mine, &label).into_iter().collect();
|
||||
ops.extend(others.into_iter().filter_map(|(x, o)| s.develop_op(x, o, &label)));
|
||||
s.commit(&label, lightcraft_catalog::Op::Batch { ops })?;
|
||||
}
|
||||
// the next mask made with this tool starts with the effect
|
||||
if p.get("remember").and_then(Value::as_bool).unwrap_or(true) {
|
||||
for m in d.masks.iter().filter(|m| ids.contains(&m.id)) {
|
||||
if let Some(tool) = mask_tool(m) {
|
||||
s.mask_tool_effects.insert(tool, effect.name.clone());
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
}
|
||||
Ok(json!({"name": effect.name, "masks": ids, "photos": photos, "brush": effect.brush}))
|
||||
}
|
||||
|
||||
/// The fields a save keeps: `fields: [..]` | `"changed"` | `"all"` (default).
|
||||
fn fields_param(p: &Value, a: &LocalAdjustments, c: &str) -> Result<Vec<String>> {
|
||||
match p.get("fields") {
|
||||
None | Some(Value::Null) => Ok(Vec::new()),
|
||||
Some(Value::String(x)) if x == "all" => Ok(Vec::new()),
|
||||
Some(Value::String(x)) if x == "changed" => {
|
||||
let f = changed_fields(a);
|
||||
if f.is_empty() { Err(bad(c, "the mask changes no slider; nothing to save")) } else { Ok(f) }
|
||||
}
|
||||
Some(Value::Array(v)) => {
|
||||
let f: Vec<String> = v.iter().filter_map(Value::as_str).filter(|k| FIELDS.contains(k)).map(str::to_string).collect();
|
||||
if f.is_empty() { Err(bad(c, format!("`fields` names no slider (one of {})", FIELDS.join(", ")))) } else { Ok(f) }
|
||||
}
|
||||
Some(_) => Err(bad(c, "`fields` is \"all\", \"changed\" or a list of slider names")),
|
||||
}
|
||||
}
|
||||
|
||||
fn brush_param(p: &Value, c: &str) -> Result<Option<BrushSettings>> {
|
||||
match p.get("brush") {
|
||||
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
|
||||
Some(v) => serde_json::from_value::<BrushSettings>(v.clone()).map(|b| Some(b.clamped())).map_err(|e| bad(c, format!("`brush`: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.saveEffect";
|
||||
let name = name_param(p, C)?;
|
||||
let adjust = selected_mask(s, p, C)?.adjust;
|
||||
let group = str_param(p, "group").map(|g| g.trim().to_string()).unwrap_or_default();
|
||||
let e = MaskEffect {
|
||||
name,
|
||||
adjust,
|
||||
group,
|
||||
fields: fields_param(p, &adjust, C)?,
|
||||
with_amount: p.get("amount").and_then(Value::as_bool).unwrap_or(false),
|
||||
brush: brush_param(p, C)?,
|
||||
..Default::default()
|
||||
};
|
||||
upsert(s, e.normalized());
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// Overwrite an effect's sliders with the selected mask's (keeping which sliders and whether
|
||||
/// Amount it carries, its group and star).
|
||||
fn update_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.updateEffect";
|
||||
let name = name_param(p, C)?;
|
||||
let i = effect_index(s, &name, C)?;
|
||||
let adjust = selected_mask(s, p, C)?.adjust;
|
||||
let brush = brush_param(p, C)?;
|
||||
let e = s.mask_effects.get_mut(i).ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
|
||||
let mut next = MaskEffect { adjust, extra: Map::new(), ..e.clone() };
|
||||
if let Some(f) = p.get("fields").filter(|v| !v.is_null()) {
|
||||
next.fields = fields_param(&json!({"fields": f}), &adjust, C)?;
|
||||
}
|
||||
if let Some(a) = p.get("amount").and_then(Value::as_bool) {
|
||||
next.with_amount = a;
|
||||
}
|
||||
if brush.is_some() {
|
||||
next.brush = brush;
|
||||
}
|
||||
*e = next.normalized();
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn rename_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.renameEffect";
|
||||
let name = name_param(p, C)?;
|
||||
let to = name_of(p, "to", C)?;
|
||||
let i = effect_index(s, &name, C)?;
|
||||
if position(s, &to).is_some_and(|j| j != i) {
|
||||
return Err(bad(C, format!("an effect named `{to}` exists already")));
|
||||
}
|
||||
if let Some(e) = s.mask_effects.get_mut(i) {
|
||||
e.name = to.clone();
|
||||
}
|
||||
for v in s.mask_tool_effects.values_mut() {
|
||||
if v.eq_ignore_ascii_case(&name) {
|
||||
*v = to.clone();
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// A name like `base` that no effect has: `base`, `base 2`, `base 3`…
|
||||
fn free_name(s: &Session, base: &str) -> String {
|
||||
(1..10_000)
|
||||
.map(|n| if n == 1 { base.to_string() } else { format!("{base} {n}") })
|
||||
.find(|n| position(s, n).is_none())
|
||||
.unwrap_or_else(|| base.into())
|
||||
}
|
||||
|
||||
fn duplicate_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.duplicateEffect";
|
||||
let name = name_param(p, C)?;
|
||||
let e = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
|
||||
let to = match str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()) {
|
||||
Some(t) if position(s, t).is_some() => return Err(bad(C, format!("an effect named `{t}` exists already"))),
|
||||
Some(t) => t.to_string(),
|
||||
None => free_name(s, &format!("{} copy", e.name)),
|
||||
};
|
||||
s.mask_effects.push(MaskEffect { name: to.clone(), favorite: false, ..e });
|
||||
s.save_prefs()?;
|
||||
let mut out = list(s);
|
||||
out["name"] = json!(to);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn delete_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.deleteEffect";
|
||||
let names = names_param(p, C)?;
|
||||
if let Some(missing) = names.iter().find(|n| position(s, n).is_none()) {
|
||||
return Err(bad(C, format!("no effect `{missing}`")));
|
||||
}
|
||||
s.mask_effects.retain(|x| !names.iter().any(|n| n.eq_ignore_ascii_case(&x.name)));
|
||||
s.mask_tool_effects.retain(|_, v| !names.iter().any(|n| n.eq_ignore_ascii_case(v)));
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn favorite_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.favoriteEffect";
|
||||
let names = names_param(p, C)?;
|
||||
let mut idx = Vec::new();
|
||||
for n in &names {
|
||||
idx.push(effect_index(s, n, C)?);
|
||||
}
|
||||
let want = p.get("favorite").and_then(Value::as_bool);
|
||||
for i in idx {
|
||||
if let Some(e) = s.mask_effects.get_mut(i) {
|
||||
e.favorite = want.unwrap_or(!e.favorite);
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn set_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.setEffectGroup";
|
||||
let names = names_param(p, C)?;
|
||||
let group = str_param(p, "group").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `group` (\"\" = none)"))?;
|
||||
let mut idx = Vec::new();
|
||||
for n in &names {
|
||||
idx.push(effect_index(s, n, C)?);
|
||||
}
|
||||
// an existing group's spelling wins
|
||||
let group = s.mask_effects.iter().map(|e| &e.group).find(|g| g.eq_ignore_ascii_case(&group)).cloned().unwrap_or(group);
|
||||
for i in idx {
|
||||
if let Some(e) = s.mask_effects.get_mut(i) {
|
||||
e.group = group.clone();
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.renameEffectGroup";
|
||||
let group = name_of(p, "group", C)?;
|
||||
let to = str_param(p, "to").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `to` (\"\" = ungroup)"))?;
|
||||
let mut n = 0;
|
||||
for e in s.mask_effects.iter_mut().filter(|e| e.group.eq_ignore_ascii_case(&group)) {
|
||||
e.group = to.clone();
|
||||
n += 1;
|
||||
}
|
||||
if n == 0 {
|
||||
return Err(bad(C, format!("no group `{group}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn tool_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.toolEffect";
|
||||
let tool = name_of(p, "tool", C)?;
|
||||
match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(n) => {
|
||||
let i = effect_index(s, n, C)?;
|
||||
let name = s.mask_effects.get(i).map(|e| e.name.clone()).unwrap_or_default();
|
||||
s.mask_tool_effects.insert(tool, name);
|
||||
}
|
||||
None => {
|
||||
s.mask_tool_effects.remove(&tool);
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(json!({"toolEffects": s.mask_tool_effects}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "mask.effects",
|
||||
"Mask Effect Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {effects: [{name, adjust, group?, favorite?, fields?, with_amount?, brush?, extra?}], groups: [{name, effects: [name]}] (menu order), current: the effect the selected mask matches or null, toolEffects: {tool: effect a new mask of that kind starts with}}",
|
||||
always,
|
||||
|s, _| Ok(list(s))
|
||||
),
|
||||
cmd!(
|
||||
"mask.applyEffect",
|
||||
"Apply Mask Effect",
|
||||
[],
|
||||
None,
|
||||
"{name, id? | ids?: [maskId] | all?: bool, photos?: \"selected\" (also the masks of the same names on the other selected photos, one undo step), remember?: bool (true: new masks of this kind start with it)} — sets the effect's sliders (all, or the ones it keeps; Amount only if it carries it) → {name, masks, photos, brush}",
|
||||
has_active,
|
||||
apply_effect
|
||||
),
|
||||
cmd!(
|
||||
"mask.saveEffect",
|
||||
"Save Mask Effect",
|
||||
[],
|
||||
None,
|
||||
"{name, id?, group?, fields?: \"all\" (default) | \"changed\" | [slider], amount?: bool (carry Amount), brush?: {size, feather, flow, density, auto_mask}} — the selected mask's sliders as an effect preset (replaces one of that name), saved with the library",
|
||||
has_active,
|
||||
save_effect
|
||||
),
|
||||
cmd!(
|
||||
"mask.updateEffect",
|
||||
"Update Mask Effect",
|
||||
[],
|
||||
None,
|
||||
"{name, id?, fields?, amount?, brush?} — overwrite the effect with the selected mask's sliders (keeps its group, star and which sliders it carries unless given)",
|
||||
has_active,
|
||||
update_effect
|
||||
),
|
||||
cmd!("mask.renameEffect", "Rename Mask Effect", [], None, "{name, to}", always, rename_effect),
|
||||
cmd!(
|
||||
"mask.duplicateEffect",
|
||||
"Duplicate Mask Effect",
|
||||
[],
|
||||
None,
|
||||
"{name, to?} (default \"<name> copy\") → list + {name}",
|
||||
always,
|
||||
duplicate_effect
|
||||
),
|
||||
cmd!("mask.deleteEffect", "Delete Mask Effect", [], None, "{name | names: [name]} — removes effect presets", always, delete_effect),
|
||||
cmd!("mask.favoriteEffect", "Favorite Mask Effect", [], None, "{name | names, favorite?: bool (default: toggle)}", always, favorite_effect),
|
||||
cmd!("mask.setEffectGroup", "Move Mask Effect to Group", [], None, "{name | names, group: name (\"\" = none)}", always, set_group),
|
||||
cmd!(
|
||||
"mask.renameEffectGroup",
|
||||
"Rename Mask Effect Group",
|
||||
[],
|
||||
None,
|
||||
"{group, to} (\"\" ungroups; an existing name merges)",
|
||||
always,
|
||||
rename_group
|
||||
),
|
||||
cmd!(
|
||||
"mask.toolEffect",
|
||||
"Set Effect for New Masks",
|
||||
[],
|
||||
None,
|
||||
"{tool: brush|linear|radial|subject|…, name?} — the effect new masks of that kind start with (no name: none)",
|
||||
always,
|
||||
tool_effect
|
||||
),
|
||||
cmd!(
|
||||
"mask.importEffects",
|
||||
"Import Mask Effects",
|
||||
[],
|
||||
None,
|
||||
"{path | paths: [file or folder], group?, dryRun?} — Lightroom Classic local adjustment presets (.lrtemplate), Camera Raw local correction presets (.xmp), .lceffect files and .zip bundles of these; folders and Lightroom's divider presets (\"10 ----- DETAIL ----- 10\") become groups; same-named effects are replaced → {imported: [{name, group, file, unmapped}], dividers: [{name, group, file}], failed: [[file, error]]}",
|
||||
always,
|
||||
files::import_effects
|
||||
),
|
||||
cmd!(
|
||||
"mask.exportEffects",
|
||||
"Export Mask Effects",
|
||||
[],
|
||||
None,
|
||||
"{path, names?: [name], group?: name (default: all)} — writes an .lceffect JSON file (groups, stars and kept Lightroom settings included) → {path, count}",
|
||||
always,
|
||||
files::export_effects
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apply_keeps_amount_and_matches_ignore_it() {
|
||||
let e = MaskEffect { name: "E".into(), adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..Default::default() };
|
||||
let current = LocalAdjustments { shadows: 40.0, amount: 60.0, ..Default::default() };
|
||||
let out = e.apply(¤t);
|
||||
assert_eq!((out.exposure, out.shadows, out.amount), (1.0, 0.0, 60.0));
|
||||
assert!(e.matches(&out));
|
||||
assert!(!e.matches(¤t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_effects_leave_other_sliders_and_can_carry_amount() {
|
||||
let e = MaskEffect {
|
||||
name: "Lift".into(),
|
||||
adjust: LocalAdjustments { shadows: 30.0, clarity: 99.0, amount: 70.0, ..Default::default() },
|
||||
fields: vec!["shadows".into()],
|
||||
with_amount: true,
|
||||
..Default::default()
|
||||
};
|
||||
let current = LocalAdjustments { exposure: 0.5, clarity: 10.0, ..Default::default() };
|
||||
let out = e.apply(¤t);
|
||||
assert_eq!((out.exposure, out.shadows, out.clarity, out.amount), (0.5, 30.0, 10.0, 70.0));
|
||||
assert!(e.matches(&out));
|
||||
assert!(!e.matches(&LocalAdjustments { amount: 100.0, ..out }), "Amount counts when carried");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_without_saturation_still_matches() {
|
||||
let e = MaskEffect {
|
||||
name: "E".into(),
|
||||
adjust: LocalAdjustments { color_hue: 240.0, exposure: 0.25, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
assert!(e.matches(&LocalAdjustments { exposure: 0.25, ..Default::default() }));
|
||||
assert!(!e.matches(&LocalAdjustments { exposure: 0.25, color_sat: 10.0, ..Default::default() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natural_order_and_groups() {
|
||||
let mut v = vec!["10 b", "2 a", "Burn", "01 x", "1 y"];
|
||||
v.sort_by(|a, b| natural_cmp(a, b));
|
||||
assert_eq!(v, vec!["01 x", "1 y", "2 a", "10 b", "Burn"]);
|
||||
let e = |n: &str, g: &str| MaskEffect { name: n.into(), group: g.into(), ..Default::default() };
|
||||
let list = vec![e("12 b", "Detail"), e("3 a", ""), e("11 a", "detail"), e("20 r", "Retouch"), e("Burn", "")];
|
||||
let order = menu_order(&list);
|
||||
let names: Vec<(String, Vec<&str>)> = order.iter().map(|(g, ix)| (g.clone(), ix.iter().map(|i| list[*i].name.as_str()).collect())).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![("".to_string(), vec!["3 a", "Burn"]), ("Detail".to_string(), vec!["11 a", "12 b"]), ("Retouch".to_string(), vec!["20 r"]),]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_clamps_and_tidies_fields() {
|
||||
let e = MaskEffect {
|
||||
name: " X ".into(),
|
||||
adjust: LocalAdjustments { exposure: 99.0, amount: 150.0, ..Default::default() },
|
||||
fields: vec!["color_sat".into(), "bogus".into(), "exposure".into()],
|
||||
brush: Some(BrushSettings { size: f64::NAN, flow: 0.0, ..Default::default() }),
|
||||
..Default::default()
|
||||
}
|
||||
.normalized();
|
||||
assert_eq!(e.name, "X");
|
||||
assert_eq!(e.adjust.exposure, 4.0);
|
||||
assert_eq!(e.adjust.amount, 100.0, "Amount isn't carried");
|
||||
assert_eq!(e.fields, vec!["exposure", "color_hue", "color_sat"]);
|
||||
let b = e.brush.unwrap();
|
||||
assert_eq!((b.size, b.flow), (0.04, 1.0));
|
||||
}
|
||||
}
|
||||
@@ -178,11 +178,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);
|
||||
@@ -254,11 +257,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
|
||||
}
|
||||
@@ -981,7 +985,7 @@ fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::Deve
|
||||
lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid)
|
||||
}
|
||||
|
||||
fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
|
||||
pub(crate) fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
|
||||
let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 };
|
||||
c(&mut a.exposure, -4.0, 4.0);
|
||||
for v in [
|
||||
|
||||
@@ -24,6 +24,8 @@ pub mod keywords;
|
||||
pub mod library;
|
||||
pub mod lut_profiles;
|
||||
pub mod manage;
|
||||
pub mod mask_effect_files;
|
||||
pub mod mask_effects;
|
||||
mod masks;
|
||||
mod merge;
|
||||
pub mod metadata;
|
||||
@@ -147,6 +149,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(manage::specs());
|
||||
v.extend(previews::specs());
|
||||
v.extend(lut_profiles::specs());
|
||||
v.extend(mask_effects::specs());
|
||||
v.extend(cull::specs());
|
||||
v.extend(convert::specs());
|
||||
v.extend(convert::edit_specs());
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::mask_effect_files as mef;
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::preset_import::{group_from_dir, read_presets};
|
||||
use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset};
|
||||
@@ -18,6 +19,10 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut imported = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut skipped = 0usize;
|
||||
// local adjustment (brush) presets — Lightroom's .lrtemplate, Camera Raw's local .xmp, our
|
||||
// .lceffect, also inside zips — become mask effects
|
||||
let mut effects = Vec::new();
|
||||
let mut effect_failed = Vec::new();
|
||||
for top in &paths {
|
||||
let from_dir = std::path::Path::new(top).is_dir();
|
||||
for f in expand_preset_paths(std::slice::from_ref(top)) {
|
||||
@@ -25,8 +30,27 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// the path from the imported folder (inclusive) to the file's folder
|
||||
let base = std::path::Path::new(top).parent().unwrap_or(std::path::Path::new(""));
|
||||
let rel = std::path::Path::new(&f).parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
|
||||
let dir_group = rel.filter(|_| from_dir).and_then(|d| group_from_dir(&d));
|
||||
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| read_presets(&f, &b, dir_group));
|
||||
let rel = rel.filter(|_| from_dir);
|
||||
let bytes = match mef::read_capped(&f) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
failed.push(json!([f, e]));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let lower = f.to_ascii_lowercase();
|
||||
let maybe_local = [".lrtemplate", ".xmp", ".zip", ".lceffect"].iter().any(|x| lower.ends_with(x));
|
||||
let before = effects.len() + effect_failed.len();
|
||||
let local = maybe_local && mef::found_in(&f, &bytes, rel.as_deref().and_then(mef::group_for_dir), &mut effects, &mut effect_failed);
|
||||
// a zip may hold develop presets as well
|
||||
if local && !lower.ends_with(".zip") {
|
||||
continue;
|
||||
}
|
||||
let dir_group = rel.and_then(|d| group_from_dir(&d));
|
||||
let parsed = read_presets(&f, &bytes, dir_group);
|
||||
if local && parsed.is_err() && effects.len() + effect_failed.len() > before {
|
||||
continue;
|
||||
}
|
||||
match parsed {
|
||||
Ok(list) => {
|
||||
for mut it in list {
|
||||
@@ -45,7 +69,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed}))
|
||||
failed.extend(effect_failed.into_iter().map(|(f, e)| json!([f, e])));
|
||||
let (effects, dividers) = mef::add_found(s, effects, group.as_deref(), dry)?;
|
||||
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed, "effects": effects, "effectDividers": dividers}))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -83,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 → {imported: [{id,name,group,file,unmapped}], skipped, failed}",
|
||||
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups) → {imported: [{id,name,group,file,unmapped}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
|
||||
@@ -64,13 +64,20 @@ const LOCAL: &[(&str, &str, f64)] = &[
|
||||
("LocalClarity", "clarity", 100.0),
|
||||
];
|
||||
|
||||
fn adjust(c: &XmpValue) -> Value {
|
||||
/// Is `crs` (without the `crs:` prefix) a local slider field we map?
|
||||
pub(crate) fn is_local_field(crs: &str) -> bool {
|
||||
LOCAL.iter().any(|(k, _, _)| *k == crs)
|
||||
}
|
||||
|
||||
/// Our local adjustment values from the `Local*` slider fields that `get` finds (the name without
|
||||
/// the `crs:` prefix → the stored fraction); process-version 2012 fields win over the older ones.
|
||||
pub(crate) fn local_values(get: impl Fn(&str) -> Option<f64>) -> serde_json::Map<String, Value> {
|
||||
let mut a = serde_json::Map::new();
|
||||
for (crs, ours, k) in LOCAL {
|
||||
if a.contains_key(*ours) {
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = num(c, &format!("crs:{crs}")) {
|
||||
if let Some(v) = get(crs).filter(|v| v.is_finite()) {
|
||||
let lim = if *ours == "exposure" {
|
||||
4.0
|
||||
} else if *ours == "color_hue" {
|
||||
@@ -82,6 +89,11 @@ fn adjust(c: &XmpValue) -> Value {
|
||||
a.insert((*ours).into(), json!(v));
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
fn adjust(c: &XmpValue) -> Value {
|
||||
let mut a = local_values(|crs| num(c, &format!("crs:{crs}")));
|
||||
let amount = num(c, "crs:CorrectionAmount").unwrap_or(1.0);
|
||||
a.insert("amount".into(), json!((amount * 100.0).clamp(0.0, 200.0)));
|
||||
Value::Object(a)
|
||||
|
||||
@@ -277,6 +277,11 @@ pub struct Session {
|
||||
/// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]),
|
||||
/// persisted in prefs.json.
|
||||
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).
|
||||
@@ -414,6 +419,8 @@ impl Session {
|
||||
metadata_presets: Vec::new(),
|
||||
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(),
|
||||
@@ -1078,6 +1085,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;
|
||||
|
||||
@@ -215,6 +215,10 @@ struct PrefsFile {
|
||||
filter_presets: Vec<crate::cmd::filters::FilterPreset>,
|
||||
/// Point-curve presets.
|
||||
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.
|
||||
@@ -454,6 +458,8 @@ impl Session {
|
||||
self.metadata_presets = prefs.metadata_presets;
|
||||
self.filter_presets = prefs.filter_presets;
|
||||
self.curve_presets = prefs.curve_presets;
|
||||
self.mask_effects = prefs.mask_effects;
|
||||
self.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);
|
||||
@@ -702,6 +708,8 @@ impl Session {
|
||||
metadata_presets: self.metadata_presets.clone(),
|
||||
filter_presets: self.filter_presets.clone(),
|
||||
curve_presets: self.curve_presets.clone(),
|
||||
mask_effects: self.mask_effects.clone(),
|
||||
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(),
|
||||
|
||||
@@ -155,7 +155,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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -265,6 +265,60 @@ fn curve_presets_save_apply_export_import_and_persist() {
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_effects_import_apply_save_export_and_persist() {
|
||||
let lib = temp_dir("effects");
|
||||
let src = lib.join("Local Adjustment Presets");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
// a Lightroom local adjustment preset (synthetic values) and a develop template beside it
|
||||
std::fs::write(
|
||||
src.join("Brighten.lrtemplate"),
|
||||
"s = {\n\ttitle = \"Brighten\",\n\ttype = \"LocalizedAdjustmentPreset\",\n\tvalue = {\n\t\texposure2012 = 0.25,\n\t\tshadows2012 = 0.2,\n\t\tgrain = 0.3,\n\t},\n\tversion = 0,\n}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(src.join("Look.lrtemplate"), "s = { title = \"Look\", type = \"Develop\", value = { settings = { Exposure2012 = 0.5 } } }")
|
||||
.unwrap();
|
||||
{
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, true).unwrap();
|
||||
// the general preset import routes the local one to the effects
|
||||
let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(r["imported"].as_array().unwrap().len(), 1, "the develop template stays a develop preset");
|
||||
assert_eq!(r["effects"][0]["name"], "Brighten");
|
||||
assert_eq!(r["effects"][0]["unmapped"], json!(["Grain"]));
|
||||
assert_eq!(s.mask_effects.len(), 1);
|
||||
// apply to a mask: every slider set, Amount kept
|
||||
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
|
||||
s.execute("mask.adjust", &json!({"values": {"clarity": 30, "amount": 70}})).unwrap();
|
||||
assert!(s.execute("mask.applyEffect", &json!({"name": "nope"})).is_err());
|
||||
s.execute("mask.applyEffect", &json!({"name": "brighten"})).unwrap();
|
||||
let d = s.develop_of(s.active().unwrap()).unwrap();
|
||||
let a = d.masks.last().unwrap().adjust;
|
||||
assert_eq!((a.exposure, a.shadows, a.clarity, a.amount), (1.0, 20.0, 0.0, 70.0));
|
||||
assert_eq!(s.execute("mask.effects", &json!({})).unwrap()["current"], "Brighten");
|
||||
// save the mask's sliders under a new name, export, delete, re-import
|
||||
s.execute("mask.adjust", &json!({"values": {"dehaze": 15}})).unwrap();
|
||||
s.execute("mask.saveEffect", &json!({"name": "Brighten + Dehaze"})).unwrap();
|
||||
let r = s.execute("mask.exportEffects", &json!({"path": lib.join("out").join("mine").to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["count"], 2);
|
||||
let path = r["path"].as_str().unwrap().to_string();
|
||||
assert!(path.ends_with(".lceffect"));
|
||||
s.execute("mask.deleteEffect", &json!({"name": "brighten + dehaze"})).unwrap();
|
||||
assert!(s.execute("mask.deleteEffect", &json!({"name": "brighten + dehaze"})).is_err());
|
||||
let r = s.execute("mask.importEffects", &json!({"path": path})).unwrap();
|
||||
assert_eq!(r["imported"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(s.mask_effects.len(), 2, "same-named effects are replaced");
|
||||
s.close_library().unwrap();
|
||||
}
|
||||
// saved with the library preferences
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let names: Vec<String> = s.mask_effects.iter().map(|e| e.name.clone()).collect();
|
||||
assert_eq!(names, ["Brighten", "Brighten + Dehaze"]);
|
||||
assert_eq!(s.mask_effects[1].adjust.dehaze, 15.0);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_the_disk_cache_keeps_the_cache_and_its_thumbnails() {
|
||||
let lib = temp_dir("resize");
|
||||
|
||||
@@ -245,6 +245,18 @@
|
||||
"{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt",
|
||||
"{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt",
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt",
|
||||
" in {g} group{}": " in {g} Gruppe(n){:.0}",
|
||||
" · kept, not applied: {}": " · behalten, nicht angewendet: {}",
|
||||
" — kept but not applied: {}": " — behalten, aber nicht angewendet: {}",
|
||||
"Apply to “{mask}” on Selected Photos": "Auf „{mask}“ in den ausgewählten Fotos anwenden",
|
||||
"Delete the effect preset “{name}”?": "Effektvorgabe „{name}“ löschen?",
|
||||
"Delete these {n} effect presets?": "Diese {n} Effektvorgaben löschen?",
|
||||
"Delete {n} effect preset{}? This can't be undone.": "{n} Effektvorgabe(n) löschen?{:.0} Dies kann nicht rückgängig gemacht werden.",
|
||||
"Effect: {name}": "Effekt: {name}",
|
||||
"Exported {} effect preset{}": "Exportierte Effektvorgaben: {}{:.0}",
|
||||
"Imported {e} mask effect{} (Masking ▸ Effect)": "{e} Maskeneffekt(e) importiert{:.0} (Maskieren → Effekt)",
|
||||
"Update “{name}” with Current Settings": "„{name}“ mit aktuellen Einstellungen aktualisieren",
|
||||
"{n} preset{}": "{n} Vorgabe(n){:.0}",
|
||||
"{name} opened in the other editor": "{name} im anderen Editor geöffnet",
|
||||
"{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet",
|
||||
"· {in_source} identical copies": "· {in_source} identische Kopien",
|
||||
|
||||
@@ -245,6 +245,18 @@
|
||||
"{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple",
|
||||
"{n} added to Apple Photos": "{n} añadidas a Fotos de Apple",
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original",
|
||||
" in {g} group{}": " en {g} grupo(s){:.0}",
|
||||
" · kept, not applied: {}": " · conservado, no aplicado: {}",
|
||||
" — kept but not applied: {}": " — se conservan pero no se aplican: {}",
|
||||
"Apply to “{mask}” on Selected Photos": "Aplicar a «{mask}» en las fotos seleccionadas",
|
||||
"Delete the effect preset “{name}”?": "¿Eliminar el ajuste preestablecido de efecto «{name}»?",
|
||||
"Delete these {n} effect presets?": "¿Eliminar estos {n} ajustes preestablecidos de efecto?",
|
||||
"Delete {n} effect preset{}? This can't be undone.": "¿Eliminar {n} ajuste(s) preestablecido(s) de efecto?{:.0} No se puede deshacer.",
|
||||
"Effect: {name}": "Efecto: {name}",
|
||||
"Exported {} effect preset{}": "Ajustes de efecto exportados: {}{:.0}",
|
||||
"Imported {e} mask effect{} (Masking ▸ Effect)": "{e} efecto(s) de máscara importado(s){:.0} (Máscaras → Efecto)",
|
||||
"Update “{name}” with Current Settings": "Actualizar «{name}» con los ajustes actuales",
|
||||
"{n} preset{}": "{n} ajuste(s){:.0}",
|
||||
"{name} opened in the other editor": "{name} abierto en el otro editor",
|
||||
"{n} originals opened in the other editor": "{n} originales abiertos en el otro editor",
|
||||
"· {in_source} identical copies": "· {in_source} copias idénticas",
|
||||
|
||||
@@ -245,6 +245,18 @@
|
||||
"{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »",
|
||||
"{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos",
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original",
|
||||
" in {g} group{}": " dans {g} groupe(s){:.0}",
|
||||
" · kept, not applied: {}": " · conservé, non appliqué : {}",
|
||||
" — kept but not applied: {}": " — conservés mais non appliqués : {}",
|
||||
"Apply to “{mask}” on Selected Photos": "Appliquer à « {mask} » sur les photos sélectionnées",
|
||||
"Delete the effect preset “{name}”?": "Supprimer le paramètre prédéfini d’effet « {name} » ?",
|
||||
"Delete these {n} effect presets?": "Supprimer ces {n} paramètres prédéfinis d’effet ?",
|
||||
"Delete {n} effect preset{}? This can't be undone.": "Supprimer {n} paramètre(s) prédéfini(s) d’effet ?{:.0} Cette action est irréversible.",
|
||||
"Effect: {name}": "Effet : {name}",
|
||||
"Exported {} effect preset{}": "Paramètres d’effet exportés : {}{:.0}",
|
||||
"Imported {e} mask effect{} (Masking ▸ Effect)": "{e} effet(s) de masque importé(s){:.0} (Masquage → Effet)",
|
||||
"Update “{name}” with Current Settings": "Mettre à jour « {name} » avec les réglages actuels",
|
||||
"{n} preset{}": "{n} paramètre(s){:.0}",
|
||||
"{name} opened in the other editor": "{name} ouvert dans l’autre éditeur",
|
||||
"{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur",
|
||||
"· {in_source} identical copies": "· {in_source} copies identiques",
|
||||
|
||||
@@ -245,6 +245,18 @@
|
||||
"{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました",
|
||||
"{n} added to Apple Photos": "{n}件をAppleの写真に追加しました",
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています",
|
||||
" in {g} group{}": "({g}グループ){:.0}",
|
||||
" · kept, not applied: {}": " · 保持(適用なし):{}",
|
||||
" — kept but not applied: {}": " — 保持(適用なし):{}",
|
||||
"Apply to “{mask}” on Selected Photos": "選択した写真の「{mask}」に適用",
|
||||
"Delete the effect preset “{name}”?": "効果プリセット「{name}」を削除しますか?",
|
||||
"Delete these {n} effect presets?": "これらの効果プリセット{n}件を削除しますか?",
|
||||
"Delete {n} effect preset{}? This can't be undone.": "効果プリセット{n}件を削除しますか?{:.0}元に戻せません。",
|
||||
"Effect: {name}": "効果:{name}",
|
||||
"Exported {} effect preset{}": "効果プリセットを{}件書き出しました{:.0}",
|
||||
"Imported {e} mask effect{} (Masking ▸ Effect)": "マスク効果を{e}件読み込みました{:.0}(マスク → 効果)",
|
||||
"Update “{name}” with Current Settings": "「{name}」を現在の設定で更新",
|
||||
"{n} preset{}": "{n}件のプリセット{:.0}",
|
||||
"{name} opened in the other editor": "{name}を別のエディタで開きました",
|
||||
"{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました",
|
||||
"· {in_source} identical copies": "・同一のコピー {in_source} 件",
|
||||
|
||||
@@ -245,6 +245,18 @@
|
||||
"{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple",
|
||||
"{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple",
|
||||
"{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original",
|
||||
" in {g} group{}": " em {g} grupo(s){:.0}",
|
||||
" · kept, not applied: {}": " · mantido, não aplicado: {}",
|
||||
" — kept but not applied: {}": " — mantidos, mas não aplicados: {}",
|
||||
"Apply to “{mask}” on Selected Photos": "Aplicar a “{mask}” nas fotos selecionadas",
|
||||
"Delete the effect preset “{name}”?": "Excluir a predefinição de efeito “{name}”?",
|
||||
"Delete these {n} effect presets?": "Excluir estas {n} predefinições de efeito?",
|
||||
"Delete {n} effect preset{}? This can't be undone.": "Excluir {n} predefinição(ões) de efeito?{:.0} Não é possível desfazer.",
|
||||
"Effect: {name}": "Efeito: {name}",
|
||||
"Exported {} effect preset{}": "Predefinições de efeito exportadas: {}{:.0}",
|
||||
"Imported {e} mask effect{} (Masking ▸ Effect)": "{e} efeito(s) de máscara importado(s){:.0} (Máscaras → Efeito)",
|
||||
"Update “{name}” with Current Settings": "Atualizar “{name}” com as configurações atuais",
|
||||
"{n} preset{}": "{n} predefinição(ões){:.0}",
|
||||
"{name} opened in the other editor": "{name} aberto no outro editor",
|
||||
"{n} originals opened in the other editor": "{n} originais abertos no outro editor",
|
||||
"· {in_source} identical copies": "· {in_source} cópias idênticas",
|
||||
|
||||
@@ -245,6 +245,18 @@
|
||||
"{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}",
|
||||
"{n} added to Apple Photos": "Добавлено в Apple Фото: {n}",
|
||||
"{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом",
|
||||
" in {g} group{}": " в группах: {g}{:.0}",
|
||||
" · kept, not applied: {}": " · сохранено, не применяется: {}",
|
||||
" — kept but not applied: {}": " — сохранено, но не применяется: {}",
|
||||
"Apply to “{mask}” on Selected Photos": "Применить к «{mask}» на выбранных фото",
|
||||
"Delete the effect preset “{name}”?": "Удалить пресет эффекта «{name}»?",
|
||||
"Delete these {n} effect presets?": "Удалить эти пресеты эффектов ({n})?",
|
||||
"Delete {n} effect preset{}? This can't be undone.": "Удалить пресеты эффектов ({n})?{:.0} Это нельзя отменить.",
|
||||
"Effect: {name}": "Эффект: {name}",
|
||||
"Exported {} effect preset{}": "Экспортировано пресетов эффектов: {}{:.0}",
|
||||
"Imported {e} mask effect{} (Masking ▸ Effect)": "Импортировано эффектов маски: {e}{:.0} (Маски → Эффект)",
|
||||
"Update “{name}” with Current Settings": "Обновить «{name}» текущими настройками",
|
||||
"{n} preset{}": "Пресетов: {n}{:.0}",
|
||||
"{name} opened in the other editor": "{name} открыт в другом редакторе",
|
||||
"{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}",
|
||||
"· {in_source} identical copies": "· идентичных копий: {in_source}",
|
||||
|
||||
@@ -245,6 +245,18 @@
|
||||
"{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}",
|
||||
"{n} added to Apple Photos": "Додано до Apple Фото: {n}",
|
||||
"{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом",
|
||||
" in {g} group{}": " у групах: {g}{:.0}",
|
||||
" · kept, not applied: {}": " · збережено, не застосовується: {}",
|
||||
" — kept but not applied: {}": " — збережено, але не застосовується: {}",
|
||||
"Apply to “{mask}” on Selected Photos": "Застосувати до «{mask}» на вибраних фото",
|
||||
"Delete the effect preset “{name}”?": "Видалити пресет ефекту «{name}»?",
|
||||
"Delete these {n} effect presets?": "Видалити ці пресети ефектів ({n})?",
|
||||
"Delete {n} effect preset{}? This can't be undone.": "Видалити пресети ефектів ({n})?{:.0} Це не можна скасувати.",
|
||||
"Effect: {name}": "Ефект: {name}",
|
||||
"Exported {} effect preset{}": "Експортовано пресетів ефектів: {}{:.0}",
|
||||
"Imported {e} mask effect{} (Masking ▸ Effect)": "Імпортовано ефектів маски: {e}{:.0} (Маски → Ефект)",
|
||||
"Update “{name}” with Current Settings": "Оновити «{name}» поточними налаштуваннями",
|
||||
"{n} preset{}": "Пресетів: {n}{:.0}",
|
||||
"{name} opened in the other editor": "{name} відкрито в іншому редакторі",
|
||||
"{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}",
|
||||
"· {in_source} identical copies": "· ідентичних копій: {in_source}",
|
||||
|
||||
@@ -245,6 +245,18 @@
|
||||
"{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”",
|
||||
"{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片",
|
||||
"{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起",
|
||||
" in {g} group{}": "({g} 个分组){:.0}",
|
||||
" · kept, not applied: {}": " · 已保留,未应用:{}",
|
||||
" — kept but not applied: {}": " — 已保留但未应用:{}",
|
||||
"Apply to “{mask}” on Selected Photos": "应用到所选照片的“{mask}”",
|
||||
"Delete the effect preset “{name}”?": "删除效果预设“{name}”?",
|
||||
"Delete these {n} effect presets?": "删除这 {n} 个效果预设?",
|
||||
"Delete {n} effect preset{}? This can't be undone.": "删除 {n} 个效果预设?{:.0}此操作无法撤销。",
|
||||
"Effect: {name}": "效果:{name}",
|
||||
"Exported {} effect preset{}": "已导出 {} 个效果预设{:.0}",
|
||||
"Imported {e} mask effect{} (Masking ▸ Effect)": "已导入 {e} 个蒙版效果{:.0}(蒙版 → 效果)",
|
||||
"Update “{name}” with Current Settings": "用当前设置更新“{name}”",
|
||||
"{n} preset{}": "{n} 个预设{:.0}",
|
||||
"{name} opened in the other editor": "已在其他编辑器中打开 {name}",
|
||||
"{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件",
|
||||
"· {in_source} identical copies": "· {in_source} 个相同副本",
|
||||
|
||||
@@ -245,6 +245,18 @@
|
||||
"{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」",
|
||||
"{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片",
|
||||
"{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起",
|
||||
" in {g} group{}": "({g} 個群組){:.0}",
|
||||
" · kept, not applied: {}": " · 已保留,未套用:{}",
|
||||
" — kept but not applied: {}": " — 已保留但未套用:{}",
|
||||
"Apply to “{mask}” on Selected Photos": "套用到所選照片的「{mask}」",
|
||||
"Delete the effect preset “{name}”?": "刪除效果預設集「{name}」?",
|
||||
"Delete these {n} effect presets?": "刪除這 {n} 個效果預設集?",
|
||||
"Delete {n} effect preset{}? This can't be undone.": "刪除 {n} 個效果預設集?{:.0}此動作無法復原。",
|
||||
"Effect: {name}": "效果:{name}",
|
||||
"Exported {} effect preset{}": "已匯出 {} 個效果預設集{:.0}",
|
||||
"Imported {e} mask effect{} (Masking ▸ Effect)": "已匯入 {e} 個遮色片效果{:.0}(遮色片 → 效果)",
|
||||
"Update “{name}” with Current Settings": "以目前設定更新「{name}」",
|
||||
"{n} preset{}": "{n} 個預設集{:.0}",
|
||||
"{name} opened in the other editor": "已在其他編輯器中開啟 {name}",
|
||||
"{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案",
|
||||
"· {in_source} identical copies": "· {in_source} 個相同副本",
|
||||
|
||||
@@ -388,7 +388,10 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
if (r.is_err()
|
||||
&& matches!(
|
||||
d,
|
||||
crate::state::Dialog::Import { .. } | crate::state::Dialog::SamModel { .. } | crate::state::Dialog::SmartRules { .. }
|
||||
crate::state::Dialog::Import { .. }
|
||||
| crate::state::Dialog::SamModel { .. }
|
||||
| crate::state::Dialog::SmartRules { .. }
|
||||
| crate::state::Dialog::SaveEffect { .. }
|
||||
))
|
||||
|| (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d))
|
||||
{
|
||||
|
||||
@@ -95,6 +95,8 @@ mod tests_library_problem;
|
||||
#[cfg(test)]
|
||||
mod tests_lights_out;
|
||||
#[cfg(test)]
|
||||
mod tests_mask_effects;
|
||||
#[cfg(test)]
|
||||
mod tests_masking;
|
||||
#[cfg(test)]
|
||||
mod tests_masking_layout;
|
||||
@@ -1442,7 +1444,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
|
||||
|
||||
@@ -198,6 +198,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"),
|
||||
("file.importPresets", "Import Profiles & Presets…", None, "File"),
|
||||
("file.exportPresets", "Export Presets…", None, "File"),
|
||||
// Masking panel ▸ Effect menu
|
||||
("dialog.saveMaskEffect", "Save Current Settings as New Effect Preset…", None, ""),
|
||||
("dialog.manageMaskEffects", "Manage Effect Presets…", None, ""),
|
||||
("file.exportMaskEffects", "Export Effect Presets…", None, ""),
|
||||
("file.importKeywords", "Import Keywords…", None, "File"),
|
||||
("file.exportKeywords", "Export Keywords…", None, "File"),
|
||||
// Edit panel ▸ Curve ▸ Point Curve dropdown
|
||||
@@ -1668,9 +1672,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
if let (false, Ok(v)) = (preset_paths.is_empty(), &r) {
|
||||
let n = v["imported"].as_array().map_or(0, Vec::len);
|
||||
let failed = v["failed"].as_array().map_or(0, Vec::len);
|
||||
// Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast
|
||||
let effects = v["effects"].as_array().map_or(0, Vec::len);
|
||||
if effects > 0 {
|
||||
parts.push(crate::panels::mask_effects::import_toast(v));
|
||||
}
|
||||
let profile_news = !parts.is_empty() || !problems.is_empty();
|
||||
let msg = match (n, failed) {
|
||||
// a zip of LUTs only: the profile half already says what happened
|
||||
// a zip of LUTs (or only local presets): the other half already says what happened
|
||||
(0, 0) if profile_news => None,
|
||||
(0, 0) => Some("No new presets".to_string()),
|
||||
(n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)),
|
||||
@@ -1800,6 +1809,49 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
"dialog.saveMaskEffect" => {
|
||||
if app.session.active_mask.is_none() {
|
||||
return Some(Err("select a mask first".into()));
|
||||
}
|
||||
crate::panels::mask_effects::open_save(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.manageMaskEffects" => {
|
||||
crate::panels::mask_effects::open_manage(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"file.exportMaskEffects" => {
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => Some(x.to_string()),
|
||||
None => {
|
||||
let name = format!("{}.lceffect", p.get("group").and_then(Value::as_str).unwrap_or("Effect Presets"));
|
||||
let req = PickRequest::save(
|
||||
crate::i18n::tr("Export Effect Presets"),
|
||||
crate::i18n::tr("LightCraft Effect Presets"),
|
||||
&["lceffect"],
|
||||
name.clone(),
|
||||
);
|
||||
match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) {
|
||||
Picked::Now(v) => v.into_iter().next(),
|
||||
Picked::Later => return Some(Ok(Value::Null)),
|
||||
Picked::Unavailable => return Some(Err("no file dialog on this platform".into())),
|
||||
}
|
||||
}
|
||||
};
|
||||
let Some(path) = path else { return Some(Ok(Value::Null)) };
|
||||
let mut params = json!({"path": path});
|
||||
for k in ["names", "group"] {
|
||||
if let Some(v) = p.get(k) {
|
||||
params[k] = v.clone();
|
||||
}
|
||||
}
|
||||
let r = app.session.execute("mask.exportEffects", ¶ms).map_err(|e| e.to_string());
|
||||
match &r {
|
||||
Ok(v) => app.toast(&ctx, crate::i18n::tr_format!("Exported {} effect preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })),
|
||||
Err(e) => app.toast_error(&ctx, e.clone()),
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
"file.exportPresets" => {
|
||||
let group = p.get("group").and_then(Value::as_str).map(str::to_string);
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
@@ -1938,6 +1990,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
|
||||
}
|
||||
"app.openSamModelFolder" => app.session.segmenter.dir.is_some() && app.services.open_with.is_some(),
|
||||
"file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin),
|
||||
"file.exportMaskEffects" => !app.session.mask_effects.is_empty(),
|
||||
"dialog.saveMaskEffect" => app.session.active().is_some() && app.session.active_mask.is_some(),
|
||||
"file.exportCurvePresets" => !app.session.curve_presets.is_empty(),
|
||||
"view.compare" => app.session.catalog.len() > 1,
|
||||
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
|
||||
|
||||
@@ -257,6 +257,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::Settings { .. } => "Settings",
|
||||
Dialog::ConfirmDelete { .. } => "Delete Photos",
|
||||
Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog",
|
||||
Dialog::SaveEffect { .. } => "New Effect Preset",
|
||||
Dialog::ManageEffects { .. } => "Manage Effect Presets",
|
||||
Dialog::DeleteEffects { names } if names.len() == 1 => "Delete Effect Preset",
|
||||
Dialog::DeleteEffects { .. } => "Delete Effect Presets",
|
||||
Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library",
|
||||
Dialog::RemoveFolder { .. } => "Remove Folder from Library",
|
||||
Dialog::SynchronizeFolder { .. } => "Synchronize Folder",
|
||||
@@ -293,6 +297,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::AllMetadata { .. } => 620.0,
|
||||
Dialog::FaceModel { .. } => 460.0,
|
||||
Dialog::LightroomCatalog { .. } => 440.0,
|
||||
Dialog::ManageEffects { .. } => 560.0,
|
||||
_ => 380.0,
|
||||
})
|
||||
.show(ctx, |ui| {
|
||||
@@ -1145,6 +1150,22 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
Dialog::SaveEffect { name, group, fields, amount, brush } => {
|
||||
if crate::panels::mask_effects::save_body(app, ui, name, group, fields, amount, brush) {
|
||||
confirm = true;
|
||||
}
|
||||
}
|
||||
Dialog::ManageEffects { search, selected, rename, confirm_delete, new_group } => {
|
||||
crate::panels::mask_effects::manage_body(app, ui, search, selected, rename, confirm_delete, new_group)
|
||||
}
|
||||
Dialog::DeleteEffects { names } => {
|
||||
let what = match names.as_slice() {
|
||||
[one] => crate::i18n::tr_format!("Delete the effect preset “{name}”?", name = one),
|
||||
_ => crate::i18n::tr_format!("Delete these {n} effect presets?", n = names.len()),
|
||||
};
|
||||
ui.label(what);
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Masks keep their sliders. Export presets first to keep a copy.")).color(t.text_dim));
|
||||
}
|
||||
Dialog::ConfirmDelete { count } => {
|
||||
let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) };
|
||||
ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what));
|
||||
@@ -1235,6 +1256,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
|| matches!(
|
||||
dlg,
|
||||
Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew
|
||||
| Dialog::ManageEffects { .. }
|
||||
);
|
||||
let sam = &app.session.segmenter;
|
||||
let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some());
|
||||
@@ -1269,7 +1291,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::DenoiseModel { .. } => "Install",
|
||||
Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download",
|
||||
Dialog::FaceModel { .. } if !informational => "Install",
|
||||
Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } => "Delete",
|
||||
Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete",
|
||||
Dialog::SaveEffect { .. } => "Create",
|
||||
// (not "Merge": that is photo merging, HDR / panorama, in some languages)
|
||||
Dialog::MoveKeyword { .. } => "Merge Keywords",
|
||||
Dialog::KeywordSet { .. } => "Save",
|
||||
@@ -1344,6 +1367,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
| Dialog::KeywordTag { .. }
|
||||
| Dialog::KeywordSet { .. }
|
||||
| Dialog::SynchronizeFolder { .. }
|
||||
| Dialog::SaveEffect { .. }
|
||||
) =>
|
||||
{
|
||||
app.toast(ctx, e)
|
||||
@@ -1778,9 +1802,13 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
Dialog::LightroomCatalog { catalog, .. } => {
|
||||
app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c})))
|
||||
}
|
||||
Dialog::SaveEffect { name, group, fields, amount, brush } => {
|
||||
crate::panels::mask_effects::save_confirm(app, name, group, fields, *amount, *brush)
|
||||
}
|
||||
Dialog::DeleteEffects { names } => app.run("mask.deleteEffect", json!({"names": names})),
|
||||
Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})),
|
||||
d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d),
|
||||
Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null),
|
||||
Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::ManageEffects { .. } => Ok(serde_json::Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
//! Mask effect presets in the UI: the Effect menu above a mask's sliders (grouped, searchable,
|
||||
//! keyboard-driven, previews the effect on the photo while the pointer rests on it), the
|
||||
//! New Effect Preset dialog (which sliders, Amount, brush settings) and the Manage Effect Presets
|
||||
//! window (search, rename, update, duplicate, stars, groups, delete). Every change runs a
|
||||
//! `mask.*Effect` command.
|
||||
|
||||
use egui::{Sense, vec2};
|
||||
use lightcraft_develop::Mask;
|
||||
use lightcraft_engine::cmd::mask_effects::{self as fx, FIELDS, MaskEffect};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// A slider's label in the effect lists (the colour tint's hue and saturation are one "Color").
|
||||
pub fn field_label(k: &str) -> &'static str {
|
||||
match k {
|
||||
"color_hue" | "color_sat" => "Color",
|
||||
_ => super::masking::LOCAL.iter().find(|s| s.id == k).map_or("", |s| s.label),
|
||||
}
|
||||
}
|
||||
|
||||
/// The choices of the New Effect Preset checklist: each slider once (Color = hue + saturation).
|
||||
fn checklist() -> Vec<&'static str> {
|
||||
FIELDS.iter().copied().filter(|k| *k != "color_hue").collect()
|
||||
}
|
||||
|
||||
/// "Exposure +0.50 · Clarity −20" — what an effect sets, for the lists.
|
||||
pub fn summary(e: &MaskEffect) -> String {
|
||||
let fields = e.field_list();
|
||||
let mut parts: Vec<String> = fx::changed_fields(&e.adjust)
|
||||
.iter()
|
||||
.filter(|k| fields.contains(&k.as_str()) && k.as_str() != "color_hue")
|
||||
.map(|k| {
|
||||
let v = fx::get(&e.adjust, k);
|
||||
match k.as_str() {
|
||||
"exposure" => format!("{} {:+.2}", crate::i18n::tr(field_label(k)), v),
|
||||
"color_sat" => format!("{} {:.0}°/{:.0}", crate::i18n::tr("Color"), e.adjust.color_hue, v),
|
||||
_ => format!("{} {:+.0}", crate::i18n::tr(field_label(k)), v),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if e.with_amount {
|
||||
parts.push(format!("{} {:.0}", crate::i18n::tr("Amount"), e.adjust.amount));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
parts.push(crate::i18n::tr("No change").to_string());
|
||||
}
|
||||
parts.join(" · ").replace('-', "−")
|
||||
}
|
||||
|
||||
/// An effect's name as shown: runs of spaces collapsed (Lightroom users pad names to line up
|
||||
/// parts of them in its menu; the stored name keeps them).
|
||||
pub fn display_name(name: &str) -> String {
|
||||
name.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// The effect the selected mask's tool last applied (what Update / Rename in the menu act on).
|
||||
fn last_applied(app: &LightcraftApp, mask: &Mask) -> Option<String> {
|
||||
let tool = fx::mask_tool(mask)?;
|
||||
let name = app.session.mask_tool_effects.get(&tool)?;
|
||||
app.session.mask_effects.iter().find(|e| e.name.eq_ignore_ascii_case(name)).map(|e| e.name.clone())
|
||||
}
|
||||
|
||||
/// Apply an effect to `mask` (and take the brush settings it carries).
|
||||
pub fn apply(app: &mut LightcraftApp, ctx: &egui::Context, name: &str, params: Value) {
|
||||
let mut p = params;
|
||||
p["name"] = json!(name);
|
||||
match app.run("mask.applyEffect", p) {
|
||||
Ok(v) => take_brush(app, &v["brush"]),
|
||||
Err(e) => app.toast_error(ctx, e),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_brush(app: &mut LightcraftApp, b: &Value) {
|
||||
let Some(b) = b.as_object() else { return };
|
||||
let f = |k: &str| b.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
if let Some(v) = f("size") {
|
||||
app.ui.brush_size = (v as f32).clamp(0.002, 0.5);
|
||||
}
|
||||
if let Some(v) = f("feather") {
|
||||
app.ui.brush_feather = (v as f32).clamp(0.0, 100.0);
|
||||
}
|
||||
if let Some(v) = f("flow") {
|
||||
app.ui.brush_flow = (v as f32).clamp(1.0, 100.0);
|
||||
}
|
||||
if let Some(v) = b.get("auto_mask").and_then(Value::as_bool) {
|
||||
app.ui.brush_auto_mask = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// The brush settings in use, as an effect carries them.
|
||||
fn brush_now(app: &LightcraftApp) -> Value {
|
||||
json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, "auto_mask": app.ui.brush_auto_mask})
|
||||
}
|
||||
|
||||
/// Open the New Effect Preset dialog for the selected mask.
|
||||
pub fn open_save(app: &mut LightcraftApp) {
|
||||
let brush = app.ui.tool == "brush";
|
||||
app.ui.dialog = Some(Dialog::SaveEffect {
|
||||
name: String::new(),
|
||||
group: String::new(),
|
||||
fields: checklist().iter().map(|k| k.to_string()).collect(),
|
||||
amount: false,
|
||||
brush,
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the Manage Effect Presets window.
|
||||
pub fn open_manage(app: &mut LightcraftApp) {
|
||||
app.ui.dialog =
|
||||
Some(Dialog::ManageEffects { search: String::new(), selected: Vec::new(), rename: None, confirm_delete: false, new_group: String::new() });
|
||||
}
|
||||
|
||||
/// The menu's search text and keyboard highlight.
|
||||
#[derive(Clone, Default)]
|
||||
struct MenuState {
|
||||
search: String,
|
||||
highlight: Option<usize>,
|
||||
focused: bool,
|
||||
}
|
||||
|
||||
fn state_id() -> egui::Id {
|
||||
egui::Id::new("maskEffectMenuState")
|
||||
}
|
||||
|
||||
/// The effects the menu lists for `query`: (section heading, effect name, its row widget id).
|
||||
fn menu_rows(effects: &[MaskEffect], query: &str) -> Vec<(String, String, String)> {
|
||||
let q = query.trim().to_lowercase();
|
||||
let hit = |e: &MaskEffect| q.is_empty() || e.name.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q);
|
||||
let mut rows = Vec::new();
|
||||
if q.is_empty() {
|
||||
let mut favs: Vec<&MaskEffect> = effects.iter().filter(|e| e.favorite).collect();
|
||||
favs.sort_by(|a, b| fx::natural_cmp(&a.name, &b.name));
|
||||
rows.extend(favs.into_iter().map(|e| ("★ Favorites".to_string(), e.name.clone(), format!("maskEffectFav:{}", e.name))));
|
||||
}
|
||||
for (group, ix) in fx::menu_order(effects) {
|
||||
for e in ix.iter().filter_map(|i| effects.get(*i)).filter(|e| hit(e)) {
|
||||
rows.push((group.clone(), e.name.clone(), format!("maskEffect:{}", e.name)));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// The Effect menu above the mask's sliders.
|
||||
pub fn effect_row(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Effect")).font(t.font(12.0)).color(t.text_label));
|
||||
let current = fx::matching_effect(&app.session, &mask.adjust);
|
||||
let label = current.as_deref().map(display_name).unwrap_or_else(|| crate::i18n::tr("Custom").to_string());
|
||||
let r = crate::widgets::dropdown(ui, "maskEffect", &label, t.font(12.0), t.text);
|
||||
if r.clicked() {
|
||||
ui.data_mut(|d| d.insert_temp(state_id(), MenuState::default()));
|
||||
}
|
||||
egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| menu(app, ui, mask, current.as_deref()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, current: Option<&str>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.set_min_width(260.0);
|
||||
ui.set_max_width(320.0);
|
||||
let mut st: MenuState = ui.data(|d| d.get_temp(state_id())).unwrap_or_default();
|
||||
let before = st.search.clone();
|
||||
let r = crate::text_field::TextField::singleline("maskEffectSearch", &mut st.search)
|
||||
.hint(crate::i18n::tr("Search effects"))
|
||||
.width(f32::INFINITY)
|
||||
.show(ui);
|
||||
if !st.focused {
|
||||
r.response.request_focus();
|
||||
st.focused = true;
|
||||
}
|
||||
if st.search != before {
|
||||
st.highlight = None;
|
||||
}
|
||||
let rows = menu_rows(&app.session.mask_effects, &st.search);
|
||||
// ↑ ↓ move through the list, Return applies
|
||||
let (down, up, enter) = ui.input_mut(|i| {
|
||||
(
|
||||
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown),
|
||||
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp),
|
||||
i.key_pressed(egui::Key::Enter) && !rows.is_empty(),
|
||||
)
|
||||
});
|
||||
let moved = down || up;
|
||||
if !rows.is_empty() {
|
||||
let last = rows.len() - 1;
|
||||
st.highlight = match (st.highlight, down, up) {
|
||||
(None, true, _) => Some(0),
|
||||
(None, _, true) => Some(last),
|
||||
(Some(h), true, _) => Some((h + 1).min(last)),
|
||||
(Some(h), _, true) => Some(h.saturating_sub(1)),
|
||||
(h, _, _) => h.filter(|h| *h <= last),
|
||||
};
|
||||
}
|
||||
let mut chosen: Option<String> = None;
|
||||
if enter {
|
||||
chosen = rows.get(st.highlight.unwrap_or(0)).map(|(_, n, _)| n.clone());
|
||||
}
|
||||
let mut hovered: Option<String> = None;
|
||||
if app.session.mask_effects.is_empty() {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("No effect presets yet")).color(t.text_dim));
|
||||
} else if rows.is_empty() {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("No effect matches")).color(t.text_dim));
|
||||
}
|
||||
egui::ScrollArea::vertical().id_salt("maskEffectList").max_height(420.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
let mut section: Option<&str> = None;
|
||||
for (i, (group, name, widget)) in rows.iter().enumerate() {
|
||||
if section != Some(group.as_str()) {
|
||||
section = Some(group.as_str());
|
||||
if !group.is_empty() {
|
||||
ui.add_space(4.0);
|
||||
let heading = if group == "★ Favorites" { crate::i18n::tr("★ Favorites").to_string() } else { group.clone() };
|
||||
ui.label(egui::RichText::new(heading).font(t.semibold(11.5)).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
let fav = app.session.mask_effects.iter().any(|e| e.favorite && &e.name == name);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
// (rows scrolled out of view can't be clicked)
|
||||
if ui.is_rect_visible(rect) {
|
||||
register(ui.ctx(), widget.clone(), rect);
|
||||
}
|
||||
let lit = st.highlight == Some(i) || resp.hovered();
|
||||
if lit {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
if current == Some(name.as_str()) {
|
||||
ui.painter().text(rect.left_center() + vec2(4.0, 0.0), egui::Align2::LEFT_CENTER, "✓", t.font(12.0), t.accent);
|
||||
}
|
||||
let room = rect.width() - 22.0 - if fav { 16.0 } else { 0.0 };
|
||||
let g = crate::widgets::one_line(ui.painter(), &display_name(name), t.font(12.5), t.text, room);
|
||||
let elided = g.elided;
|
||||
ui.painter().galley(rect.left_center() + vec2(18.0, -g.size().y / 2.0), g, t.text);
|
||||
if fav {
|
||||
ui.painter().text(rect.right_center() - vec2(4.0, 0.0), egui::Align2::RIGHT_CENTER, "★", t.font(11.0), t.text_dim);
|
||||
}
|
||||
if moved && st.highlight == Some(i) {
|
||||
resp.scroll_to_me(None);
|
||||
}
|
||||
let resp = if elided { resp.on_hover_text(display_name(name)) } else { resp };
|
||||
if resp.hovered() || (st.highlight == Some(i) && moved) {
|
||||
hovered = Some(name.clone());
|
||||
}
|
||||
if resp.clicked() {
|
||||
chosen = Some(name.clone());
|
||||
}
|
||||
resp.context_menu(|ui| row_menu(app, ui, mask, name, fav));
|
||||
}
|
||||
});
|
||||
// resting on an effect previews it on the photo (no history entry)
|
||||
if let Some(name) = hovered.or_else(|| st.highlight.and_then(|h| rows.get(h)).map(|(_, n, _)| n.clone())) {
|
||||
preview(app, mask, &name);
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(state_id(), st));
|
||||
if let Some(name) = chosen {
|
||||
apply(app, ui.ctx(), &name, json!({"id": mask.id}));
|
||||
ui.close();
|
||||
return;
|
||||
}
|
||||
ui.separator();
|
||||
let item = |ui: &mut egui::Ui, id: &str, text: String, enabled: bool| {
|
||||
let r = ui.add_enabled(enabled, egui::Button::new(text).frame(false));
|
||||
register(ui.ctx(), id.to_string(), r.rect);
|
||||
r.clicked()
|
||||
};
|
||||
if item(ui, "maskEffectMenu:reset", crate::i18n::tr("Reset Sliders").to_string(), true) {
|
||||
let zero: serde_json::Map<String, Value> = FIELDS.iter().map(|k| (k.to_string(), json!(0.0))).collect();
|
||||
let _ = app.run("mask.adjust", json!({"id": mask.id, "values": zero}));
|
||||
if let Some(tool) = fx::mask_tool(mask) {
|
||||
let _ = app.run("mask.toolEffect", json!({"tool": tool}));
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "maskEffectMenu:save", crate::i18n::tr("Save Current Settings as New Preset…").to_string(), true) {
|
||||
open_save(app);
|
||||
ui.close();
|
||||
}
|
||||
if let Some(last) = last_applied(app, mask) {
|
||||
let text = crate::i18n::tr_format!("Update “{name}” with Current Settings", name = last);
|
||||
if item(ui, "maskEffectMenu:update", text, current != Some(last.as_str())) {
|
||||
let _ = app.run("mask.updateEffect", json!({"name": last, "id": mask.id}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if item(ui, "maskEffectMenu:manage", crate::i18n::tr("Manage Presets…").to_string(), true) {
|
||||
open_manage(app);
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "maskEffectMenu:import", crate::i18n::tr("Import Presets…").to_string(), app.services.pick_preset_files.is_some()) {
|
||||
let _ = app.run("file.importPresets", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
let any = !app.session.mask_effects.is_empty();
|
||||
if item(ui, "maskEffectMenu:export", crate::i18n::tr("Export Presets…").to_string(), any && app.services.save_preset_file.is_some()) {
|
||||
let _ = app.run("file.exportMaskEffects", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// The photo with `name` applied to `mask`, shown while the pointer rests on it.
|
||||
fn preview(app: &mut LightcraftApp, mask: &Mask, name: &str) {
|
||||
let Some(id) = app.session.active() else { return };
|
||||
let Some(e) = app.session.mask_effects.iter().find(|e| e.name == name) else { return };
|
||||
let Some(d) = app.session.develop_of(id) else { return };
|
||||
let mut d = (*d).clone();
|
||||
let Some(m) = d.masks.iter_mut().find(|m| m.id == mask.id) else { return };
|
||||
m.adjust = e.apply(&m.adjust);
|
||||
app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Effect: {name}", name = display_name(name)), settings: d });
|
||||
}
|
||||
|
||||
/// The right-click menu of an effect in the Effect menu.
|
||||
fn row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, name: &str, fav: bool) {
|
||||
let run = |app: &mut LightcraftApp, ui: &mut egui::Ui, text: &str, cmd: &str, p: Value| {
|
||||
let r = ui.button(crate::i18n::tr(text));
|
||||
register(ui.ctx(), format!("maskEffectRow:{text}"), r.rect);
|
||||
if r.clicked() {
|
||||
if let Err(e) = app.run(cmd, p) {
|
||||
app.toast_error(ui.ctx(), e);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
};
|
||||
let masks = app.session.active().and_then(|id| app.session.develop_of(id)).map_or(0, |d| d.masks.len());
|
||||
if ui.add_enabled(masks > 1, egui::Button::new(crate::i18n::tr("Apply to All Masks"))).clicked() {
|
||||
apply(app, ui.ctx(), name, json!({"all": true}));
|
||||
ui.close();
|
||||
}
|
||||
if app.session.selection.ids.len() > 1 {
|
||||
let text = crate::i18n::tr_format!("Apply to “{mask}” on Selected Photos", mask = mask.name);
|
||||
if ui.button(text).on_hover_text(crate::i18n::tr("Also the masks of the same name on the other selected photos")).clicked() {
|
||||
apply(app, ui.ctx(), name, json!({"id": mask.id, "photos": "selected"}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
run(app, ui, "Update with Current Settings", "mask.updateEffect", json!({"name": name, "id": mask.id}));
|
||||
if ui.button(crate::i18n::tr("Rename…")).clicked() {
|
||||
crate::panels::dialogs::prompt(app, "Rename Effect Preset", "Preset name", name, "mask.renameEffect", json!({"name": name}), "to");
|
||||
ui.close();
|
||||
}
|
||||
run(app, ui, "Duplicate", "mask.duplicateEffect", json!({"name": name}));
|
||||
run(app, ui, if fav { "Remove from Favorites" } else { "Add to Favorites" }, "mask.favoriteEffect", json!({"name": name, "favorite": !fav}));
|
||||
ui.menu_button(crate::i18n::tr("Move to Group"), |ui| group_menu(app, ui, &[name.to_string()]));
|
||||
ui.separator();
|
||||
if ui.button(crate::i18n::tr("Delete…")).clicked() {
|
||||
app.ui.dialog = Some(Dialog::DeleteEffects { names: vec![name.to_string()] });
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// The groups to move effects `names` to, None, and New Group….
|
||||
fn group_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, names: &[String]) {
|
||||
let groups: Vec<String> = fx::menu_order(&app.session.mask_effects).into_iter().map(|(g, _)| g).filter(|g| !g.is_empty()).collect();
|
||||
for g in groups.iter().map(String::as_str).chain(std::iter::once("")) {
|
||||
let label = if g.is_empty() { crate::i18n::tr("None").to_string() } else { g.to_string() };
|
||||
if ui.button(label).clicked() {
|
||||
let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": g}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(crate::i18n::tr("New Group…")).clicked() {
|
||||
let names = json!({"names": names});
|
||||
crate::panels::dialogs::prompt(app, "New Effect Group", "Group name", "", "mask.setEffectGroup", names, "group");
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// The New Effect Preset dialog's body.
|
||||
pub fn save_body(
|
||||
app: &LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
name: &mut String,
|
||||
group: &mut String,
|
||||
fields: &mut Vec<String>,
|
||||
amount: &mut bool,
|
||||
brush: &mut bool,
|
||||
) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let r = crate::text_field::TextField::singleline("field:effectName", name).hint(crate::i18n::tr("Preset name")).width(f32::INFINITY).show(ui);
|
||||
if !r.editing && name.is_empty() && ui.memory(|m| m.focused().is_none()) {
|
||||
r.response.request_focus();
|
||||
}
|
||||
let enter = r.ending == Some(crate::text_field::Ending::Return);
|
||||
ui.horizontal(|ui| {
|
||||
crate::text_field::TextField::singleline("field:effectGroup", group)
|
||||
.hint(crate::i18n::tr("Group (optional)"))
|
||||
.width(ui.available_width() - 30.0)
|
||||
.show(ui);
|
||||
let pick = ui.small_button("…").on_hover_text(crate::i18n::tr("Choose a group"));
|
||||
register(ui.ctx(), "button:effectGroupPick", pick.rect);
|
||||
egui::Popup::menu(&pick).show(|ui| {
|
||||
for (g, _) in fx::menu_order(&app.session.mask_effects) {
|
||||
if ui.button(if g.is_empty() { crate::i18n::tr("None").to_string() } else { g.clone() }).clicked() {
|
||||
*group = g;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim));
|
||||
let adjust = app
|
||||
.session
|
||||
.active()
|
||||
.and_then(|id| app.session.develop_of(id))
|
||||
.and_then(|d| app.session.active_mask.and_then(|mid| d.masks.iter().find(|m| m.id == mid).map(|m| m.adjust)));
|
||||
let changed = adjust.as_ref().map(fx::changed_fields).unwrap_or_default();
|
||||
let list = checklist();
|
||||
ui.columns(2, |cols| {
|
||||
for (i, k) in list.iter().enumerate() {
|
||||
let mut on = fields.iter().any(|f| f == k);
|
||||
let text = egui::RichText::new(crate::i18n::tr(field_label(k)));
|
||||
// the sliders this mask changes stand out; the others are dimmed
|
||||
let text = if changed.iter().any(|c| c == k) { text.color(t.text).strong() } else { text.color(t.text_dim) };
|
||||
let r = cols[i % 2].checkbox(&mut on, text);
|
||||
register(&r.ctx, format!("effectInclude:{k}"), r.rect);
|
||||
if r.changed() {
|
||||
fields.retain(|f| f != k);
|
||||
if on {
|
||||
fields.push(k.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
for (id, text) in [("all", "All"), ("none", "None"), ("changed", "Changed Only")] {
|
||||
let b = ui.small_button(crate::i18n::tr(text));
|
||||
register(ui.ctx(), format!("effectInclude:{id}"), b.rect);
|
||||
if b.clicked() {
|
||||
*fields = match id {
|
||||
"all" => list.iter().map(|k| k.to_string()).collect(),
|
||||
"none" => Vec::new(),
|
||||
_ => changed.iter().filter(|k| k.as_str() != "color_hue").cloned().collect(),
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr("Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied."))
|
||||
.color(t.text_dim)
|
||||
.size(11.0),
|
||||
);
|
||||
let r = ui.checkbox(amount, crate::i18n::tr("Include Amount"));
|
||||
register(ui.ctx(), "check:effectAmount", r.rect);
|
||||
let r = ui.checkbox(brush, crate::i18n::tr("Include brush size, feather and flow"));
|
||||
register(ui.ctx(), "check:effectBrush", r.rect);
|
||||
enter
|
||||
}
|
||||
|
||||
/// Save from the New Effect Preset dialog.
|
||||
pub fn save_confirm(app: &mut LightcraftApp, name: &str, group: &str, fields: &[String], amount: bool, brush: bool) -> Result<Value, String> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(crate::i18n::tr("Name the preset").to_string());
|
||||
}
|
||||
if fields.is_empty() {
|
||||
return Err(crate::i18n::tr("Choose at least one setting to include").to_string());
|
||||
}
|
||||
let mut keep: Vec<String> = fields.to_vec();
|
||||
if keep.iter().any(|f| f == "color_sat") {
|
||||
keep.push("color_hue".into());
|
||||
}
|
||||
let all = checklist().iter().all(|k| keep.iter().any(|f| f == k));
|
||||
let mut p = json!({"name": name.trim(), "group": group.trim(), "amount": amount});
|
||||
p["fields"] = if all { json!("all") } else { json!(keep) };
|
||||
if brush {
|
||||
p["brush"] = brush_now(app);
|
||||
}
|
||||
app.run("mask.saveEffect", p)
|
||||
}
|
||||
|
||||
/// The Manage Effect Presets window's body.
|
||||
pub fn manage_body(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
search: &mut String,
|
||||
selected: &mut Vec<String>,
|
||||
rename: &mut Option<(String, String)>,
|
||||
confirm_delete: &mut bool,
|
||||
new_group: &mut String,
|
||||
) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.set_min_width(500.0);
|
||||
// effects that went away (deleted, renamed) leave the selection
|
||||
selected.retain(|n| app.session.mask_effects.iter().any(|e| &e.name == n));
|
||||
ui.horizontal(|ui| {
|
||||
crate::text_field::TextField::singleline("field:effectSearch", search)
|
||||
.hint(crate::i18n::tr("Search effects"))
|
||||
.width(ui.available_width() - 120.0)
|
||||
.show(ui);
|
||||
let n = app.session.mask_effects.len();
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} preset{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim));
|
||||
});
|
||||
let q = search.trim().to_lowercase();
|
||||
let effects = app.session.mask_effects.clone();
|
||||
let mut toggle_star: Option<(String, bool)> = None;
|
||||
let mut commit_rename: Option<(String, String)> = None;
|
||||
egui::ScrollArea::vertical().id_salt("manageEffects").max_height(380.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for (group, ix) in fx::menu_order(&effects) {
|
||||
let list: Vec<&MaskEffect> = ix
|
||||
.iter()
|
||||
.filter_map(|i| effects.get(*i))
|
||||
.filter(|e| q.is_empty() || e.name.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q))
|
||||
.collect();
|
||||
if list.is_empty() {
|
||||
continue;
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let heading = if group.is_empty() { crate::i18n::tr("Ungrouped").to_string() } else { group.clone() };
|
||||
let h = ui.add(
|
||||
egui::Label::new(egui::RichText::new(format!("{heading} ({})", list.len())).font(t.semibold(12.0)).color(t.text_label))
|
||||
.sense(Sense::click()),
|
||||
);
|
||||
register(ui.ctx(), format!("effectGroup:{group}"), h.rect);
|
||||
if h.clicked() {
|
||||
// a click on the heading selects the group
|
||||
*selected = list.iter().map(|e| e.name.clone()).collect();
|
||||
}
|
||||
for e in list {
|
||||
let sel = selected.contains(&e.name);
|
||||
ui.horizontal(|ui| {
|
||||
let star = ui.add(egui::Button::new(if e.favorite { "★" } else { "☆" }).frame(false)).on_hover_text(crate::i18n::tr("Favorite"));
|
||||
if ui.is_rect_visible(star.rect) {
|
||||
register(ui.ctx(), format!("effectStar:{}", e.name), star.rect);
|
||||
}
|
||||
if star.clicked() {
|
||||
toggle_star = Some((e.name.clone(), !e.favorite));
|
||||
}
|
||||
if let Some((old, text)) = rename.as_mut().filter(|(old, _)| *old == e.name) {
|
||||
let r = crate::text_field::TextField::singleline("field:effectRename", text).width(220.0).select_on_focus(true).show(ui);
|
||||
if !r.editing && r.ending.is_none() {
|
||||
r.response.request_focus();
|
||||
}
|
||||
if r.cancelled() {
|
||||
*rename = None;
|
||||
} else if r.committed() {
|
||||
commit_rename = Some((old.clone(), text.trim().to_string()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (rect, r) = ui.allocate_exact_size(vec2(240.0, 22.0), Sense::click());
|
||||
if ui.is_rect_visible(rect) {
|
||||
register(ui.ctx(), format!("effectRow:{}", e.name), rect);
|
||||
}
|
||||
if sel {
|
||||
ui.painter().rect_filled(rect, 3.0, t.tool_active);
|
||||
} else if r.hovered() {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
let g = crate::widgets::one_line(ui.painter(), &display_name(&e.name), t.font(12.5), t.text, rect.width() - 8.0);
|
||||
let elided = g.elided;
|
||||
ui.painter().galley(rect.left_center() + vec2(4.0, -g.size().y / 2.0), g, t.text);
|
||||
let r = if elided { r.on_hover_text(display_name(&e.name)) } else { r };
|
||||
if r.clicked() {
|
||||
let add = ui.input(|i| i.modifiers.command || i.modifiers.shift);
|
||||
if add {
|
||||
if sel {
|
||||
selected.retain(|n| n != &e.name);
|
||||
} else {
|
||||
selected.push(e.name.clone());
|
||||
}
|
||||
} else {
|
||||
*selected = vec![e.name.clone()];
|
||||
}
|
||||
}
|
||||
if r.double_clicked() {
|
||||
*rename = Some((e.name.clone(), e.name.clone()));
|
||||
}
|
||||
let mut info = summary(e);
|
||||
if !e.extra.is_empty() {
|
||||
info += &crate::i18n::tr_format!(" · kept, not applied: {}", e.extra.keys().cloned().collect::<Vec<_>>().join(", "));
|
||||
}
|
||||
ui.add(egui::Label::new(egui::RichText::new(info).color(t.text_dim).size(11.0)).truncate());
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some((name, on)) = toggle_star {
|
||||
let _ = app.run("mask.favoriteEffect", json!({"name": name, "favorite": on}));
|
||||
}
|
||||
if let Some((old, to)) = commit_rename {
|
||||
*rename = None;
|
||||
if !to.is_empty() && to != old {
|
||||
match app.run("mask.renameEffect", json!({"name": old, "to": to})) {
|
||||
Ok(_) => *selected = vec![to],
|
||||
Err(e) => app.toast_error(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let n = selected.len();
|
||||
let one = (n == 1).then(|| selected.first().cloned()).flatten();
|
||||
let mask = app.session.active_mask.filter(|_| app.session.active().is_some());
|
||||
if *confirm_delete && n > 0 {
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr_format!("Delete {n} effect preset{}? This can't be undone.", if n == 1 { "" } else { "s" }, n = n))
|
||||
.color(t.text),
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
let d = ui.button(crate::i18n::tr("Delete"));
|
||||
register(ui.ctx(), "button:effectDeleteConfirm", d.rect);
|
||||
if d.clicked() {
|
||||
let _ = app.run("mask.deleteEffect", json!({"names": selected.clone()}));
|
||||
selected.clear();
|
||||
*confirm_delete = false;
|
||||
}
|
||||
let c = ui.button(crate::i18n::tr("Cancel"));
|
||||
register(ui.ctx(), "button:effectDeleteCancel", c.rect);
|
||||
if c.clicked() {
|
||||
*confirm_delete = false;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let button = |ui: &mut egui::Ui, id: &str, text: &str, enabled: bool| {
|
||||
let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(text)));
|
||||
register(ui.ctx(), format!("button:effect{id}"), r.rect);
|
||||
r.clicked()
|
||||
};
|
||||
if button(ui, "Apply", "Apply", one.is_some() && mask.is_some())
|
||||
&& let (Some(name), Some(mid)) = (&one, mask)
|
||||
{
|
||||
apply(app, ui.ctx(), name, json!({"id": mid}));
|
||||
}
|
||||
if button(ui, "Update", "Update with Mask", one.is_some() && mask.is_some())
|
||||
&& let (Some(name), Some(mid)) = (&one, mask)
|
||||
{
|
||||
let _ = app.run("mask.updateEffect", json!({"name": name, "id": mid}));
|
||||
}
|
||||
if button(ui, "Rename", "Rename", one.is_some())
|
||||
&& let Some(name) = &one
|
||||
{
|
||||
*rename = Some((name.clone(), name.clone()));
|
||||
}
|
||||
if button(ui, "Duplicate", "Duplicate", one.is_some())
|
||||
&& let Some(name) = &one
|
||||
&& let Ok(v) = app.run("mask.duplicateEffect", json!({"name": name}))
|
||||
&& let Some(copy) = v["name"].as_str()
|
||||
{
|
||||
*selected = vec![copy.to_string()];
|
||||
}
|
||||
if button(ui, "Favorite", "★ Favorite", n > 0) {
|
||||
let all_fav = selected.iter().all(|s| effects.iter().any(|e| &e.name == s && e.favorite));
|
||||
let _ = app.run("mask.favoriteEffect", json!({"names": selected.clone(), "favorite": !all_fav}));
|
||||
}
|
||||
let g = ui.add_enabled(n > 0, egui::Button::new(crate::i18n::tr("Move to Group…")));
|
||||
register(ui.ctx(), "button:effectGroup", g.rect);
|
||||
egui::Popup::menu(&g).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
let names = selected.clone();
|
||||
for (grp, _) in fx::menu_order(&app.session.mask_effects) {
|
||||
let label = if grp.is_empty() { crate::i18n::tr("None").to_string() } else { grp.clone() };
|
||||
if ui.button(label).clicked() {
|
||||
let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": grp}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let r = crate::text_field::TextField::singleline("field:effectNewGroup", new_group)
|
||||
.hint(crate::i18n::tr("New group — Return"))
|
||||
.width(180.0)
|
||||
.show(ui);
|
||||
if r.ending == Some(crate::text_field::Ending::Return) && !new_group.trim().is_empty() {
|
||||
let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": new_group.trim()}));
|
||||
new_group.clear();
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if button(ui, "Export", "Export…", n > 0 && app.services.save_preset_file.is_some()) {
|
||||
let _ = app.run("file.exportMaskEffects", json!({"names": selected.clone()}));
|
||||
}
|
||||
if button(ui, "Delete", "Delete…", n > 0) {
|
||||
*confirm_delete = true;
|
||||
}
|
||||
});
|
||||
if mask.is_none() {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Select a mask to apply or update presets.")).color(t.text_dim).size(11.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// The toast after an import (`preset.import` / `mask.importEffects` result): how many effects,
|
||||
/// in how many groups, and the settings they carry that LightCraft can't apply.
|
||||
pub fn import_toast(v: &Value) -> String {
|
||||
let rows: Vec<&Value> = v["effects"].as_array().or_else(|| v["imported"].as_array()).into_iter().flatten().collect();
|
||||
let n = rows.len();
|
||||
let mut msg = crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", if n == 1 { "" } else { "s" }, e = n);
|
||||
let mut groups: Vec<&str> = rows.iter().filter_map(|r| r["group"].as_str()).filter(|g| !g.is_empty()).collect();
|
||||
groups.sort_unstable();
|
||||
groups.dedup();
|
||||
if !groups.is_empty() {
|
||||
msg += &crate::i18n::tr_format!(" in {g} group{}", if groups.len() == 1 { "" } else { "s" }, g = groups.len());
|
||||
}
|
||||
let mut skipped: Vec<&str> = rows.iter().flat_map(|r| r["unmapped"].as_array().into_iter().flatten()).filter_map(Value::as_str).collect();
|
||||
skipped.sort_unstable();
|
||||
skipped.dedup();
|
||||
if !skipped.is_empty() {
|
||||
msg += &crate::i18n::tr_format!(" — kept but not applied: {}", skipped.join(", "));
|
||||
}
|
||||
msg
|
||||
}
|
||||
@@ -378,6 +378,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
brush_settings(app, ui);
|
||||
}
|
||||
divider(ui);
|
||||
super::mask_effects::effect_row(app, ui, &m);
|
||||
for s in LOCAL {
|
||||
let v = local_get(&m.adjust, s.id);
|
||||
let out = slider(ui, s, v, true, None);
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod keyword_list;
|
||||
pub mod keywording;
|
||||
pub mod left;
|
||||
pub mod library_problem;
|
||||
pub mod mask_effects;
|
||||
pub mod masking;
|
||||
pub mod notices;
|
||||
pub mod people;
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -1002,6 +1002,28 @@ pub enum Dialog {
|
||||
folder: String,
|
||||
catalog: Option<String>,
|
||||
},
|
||||
/// Save the selected mask's sliders as a mask effect preset: which sliders (`FIELDS` names,
|
||||
/// the colour as `color_sat`), whether it carries Amount and the brush settings.
|
||||
SaveEffect {
|
||||
name: String,
|
||||
group: String,
|
||||
fields: Vec<String>,
|
||||
amount: bool,
|
||||
brush: bool,
|
||||
},
|
||||
/// Manage Effect Presets: search, the selected effects, an inline rename (old, new),
|
||||
/// a delete waiting for confirmation, a new group's name being typed.
|
||||
ManageEffects {
|
||||
search: String,
|
||||
selected: Vec<String>,
|
||||
rename: Option<(String, String)>,
|
||||
confirm_delete: bool,
|
||||
new_group: String,
|
||||
},
|
||||
/// Confirm deleting mask effect presets.
|
||||
DeleteEffects {
|
||||
names: Vec<String>,
|
||||
},
|
||||
/// Confirm moving photos to Recently Deleted.
|
||||
ConfirmDelete {
|
||||
count: usize,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Headless tests of mask effect presets: the Effect menu (groups, search, keys, hover preview,
|
||||
//! right-click actions), the New Effect Preset dialog and the Manage Effect Presets window.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::Dialog;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
|
||||
fn detail() -> Headless {
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
let mut h = Headless::new(app, [1200.0, 900.0], 1.0);
|
||||
let r = h.request("ui.set", json!({"view": "detail"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
exec(&mut h, "panel.masking", json!({}));
|
||||
h
|
||||
}
|
||||
|
||||
fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
|
||||
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
|
||||
assert_eq!(r["ok"], true, "{command}: {r}");
|
||||
r["result"].clone()
|
||||
}
|
||||
|
||||
fn req(h: &mut Headless, method: &str, params: serde_json::Value) {
|
||||
let r = h.request(method, params, T);
|
||||
assert_eq!(r["ok"], true, "{method}: {r}");
|
||||
}
|
||||
|
||||
fn click(h: &mut Headless, id: &str) {
|
||||
req(h, "ui.clickWidget", json!({"id": id}));
|
||||
}
|
||||
|
||||
fn mask(h: &Headless) -> lightcraft_develop::Mask {
|
||||
let id = h.app.session.active().expect("active photo");
|
||||
let d = h.app.session.develop_of(id).unwrap_or_default();
|
||||
d.masks.iter().find(|m| Some(m.id) == h.app.session.active_mask).cloned().expect("a selected mask")
|
||||
}
|
||||
|
||||
/// Three effects in two groups, one starred; a radial mask selected.
|
||||
fn setup() -> Headless {
|
||||
let mut h = detail();
|
||||
exec(&mut h, "mask.add", json!({"kind": "radial"}));
|
||||
for (name, values, group) in [
|
||||
("01 Dodge", json!({"exposure": 0.5}), ""),
|
||||
("12 Sky", json!({"highlights": -60}), "Detail"),
|
||||
("21 Skin", json!({"clarity": -40}), "Retouch"),
|
||||
] {
|
||||
exec(&mut h, "mask.adjust", json!({"values": values}));
|
||||
exec(&mut h, "mask.saveEffect", json!({"name": name, "group": group}));
|
||||
let zero: serde_json::Map<String, serde_json::Value> =
|
||||
lightcraft_engine::cmd::mask_effects::FIELDS.iter().map(|k| (k.to_string(), json!(0))).collect();
|
||||
exec(&mut h, "mask.adjust", json!({"values": zero}));
|
||||
}
|
||||
exec(&mut h, "mask.favoriteEffect", json!({"name": "21 Skin"}));
|
||||
h.step();
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_menu_groups_searches_and_applies_with_keys() {
|
||||
let mut h = setup();
|
||||
click(&mut h, "dropdown:maskEffect");
|
||||
h.step();
|
||||
let widgets = h.request("ui.widgets", json!({}), T);
|
||||
let ids: Vec<String> =
|
||||
widgets["result"].as_array().map(|a| a.iter().filter_map(|w| w["id"].as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
for id in ["maskEffect:01 Dodge", "maskEffect:12 Sky", "maskEffect:21 Skin", "maskEffectFav:21 Skin", "maskEffectSearch", "maskEffectMenu:manage"]
|
||||
{
|
||||
assert!(ids.iter().any(|x| x == id), "{id} in {ids:?}");
|
||||
}
|
||||
// the pointer resting on an effect previews it on the photo
|
||||
req(&mut h, "ui.hoverWidget", json!({"id": "maskEffect:12 Sky"}));
|
||||
h.step();
|
||||
let preview = h.app.hover_preview.as_ref().expect("a hover preview");
|
||||
assert_eq!(preview.settings.masks.last().map(|m| m.adjust.highlights), Some(-60.0));
|
||||
assert_eq!(mask(&h).adjust.highlights, 0.0, "only previewed");
|
||||
// typing filters; ↓ and Return apply the first match
|
||||
req(&mut h, "ui.text", json!({"text": "skin"}));
|
||||
h.step();
|
||||
let widgets = h.request("ui.widgets", json!({}), T);
|
||||
let ids: Vec<String> =
|
||||
widgets["result"].as_array().map(|a| a.iter().filter_map(|w| w["id"].as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
assert!(!ids.iter().any(|x| x == "maskEffect:12 Sky"), "filtered out: {ids:?}");
|
||||
req(&mut h, "ui.key", json!({"key": "ArrowDown"}));
|
||||
h.step();
|
||||
req(&mut h, "ui.key", json!({"key": "Enter"}));
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(mask(&h).adjust.clarity, -40.0);
|
||||
assert_eq!(exec(&mut h, "mask.effects", json!({}))["current"], "21 Skin");
|
||||
// a new radial mask now starts with it (the menu remembers per tool)
|
||||
exec(&mut h, "mask.add", json!({"kind": "radial"}));
|
||||
assert_eq!(mask(&h).adjust.clarity, -40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_menu_right_click_and_delete_asks_first() {
|
||||
let mut h = setup();
|
||||
click(&mut h, "dropdown:maskEffect");
|
||||
h.step();
|
||||
// the row's actions are commands; delete goes through a confirmation
|
||||
h.app.ui.dialog = Some(Dialog::DeleteEffects { names: vec!["01 Dodge".into()] });
|
||||
h.step();
|
||||
assert_eq!(h.app.session.mask_effects.len(), 3, "nothing deleted before confirming");
|
||||
click(&mut h, "button:dialogCancel");
|
||||
assert_eq!(h.app.session.mask_effects.len(), 3);
|
||||
h.app.ui.dialog = Some(Dialog::DeleteEffects { names: vec!["01 Dodge".into()] });
|
||||
h.step();
|
||||
req(&mut h, "ui.dialog.confirm", json!({}));
|
||||
assert_eq!(h.app.session.mask_effects.len(), 2);
|
||||
// Reset Sliders zeroes the mask and forgets the tool's effect
|
||||
exec(&mut h, "mask.applyEffect", json!({"name": "12 Sky"}));
|
||||
h.step();
|
||||
click(&mut h, "dropdown:maskEffect");
|
||||
h.step();
|
||||
click(&mut h, "maskEffectMenu:reset");
|
||||
h.step();
|
||||
assert_eq!(mask(&h).adjust.highlights, 0.0);
|
||||
assert!(h.app.session.mask_tool_effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_dialog_picks_sliders_amount_and_brush() {
|
||||
let mut h = setup();
|
||||
exec(&mut h, "mask.adjust", json!({"values": {"shadows": 30, "texture": 15, "amount": 70}}));
|
||||
h.step();
|
||||
click(&mut h, "dropdown:maskEffect");
|
||||
h.step();
|
||||
click(&mut h, "maskEffectMenu:save");
|
||||
h.step();
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::SaveEffect { .. })), "{:?}", h.app.ui.dialog);
|
||||
// nothing named: an error, the dialog stays
|
||||
let r = h.request("ui.dialog.confirm", json!({}), T);
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
click(&mut h, "effectInclude:changed");
|
||||
h.step();
|
||||
click(&mut h, "check:effectAmount");
|
||||
h.step();
|
||||
req(&mut h, "ui.clickWidget", json!({"id": "field:effectName"}));
|
||||
req(&mut h, "ui.text", json!({"text": "Lift"}));
|
||||
h.step();
|
||||
let Some(Dialog::SaveEffect { fields, amount, .. }) = &h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) };
|
||||
assert_eq!((fields.clone(), *amount), (vec!["shadows".to_string(), "texture".into()], true));
|
||||
req(&mut h, "ui.dialog.confirm", json!({}));
|
||||
let e = h.app.session.mask_effects.iter().find(|e| e.name == "Lift").expect("saved");
|
||||
assert_eq!((e.fields.clone(), e.with_amount, e.adjust.amount), (vec!["shadows".to_string(), "texture".into()], true, 70.0));
|
||||
assert!(e.brush.is_none(), "not the brush tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manage_window_renames_groups_stars_duplicates_and_deletes() {
|
||||
let mut h = setup();
|
||||
exec(&mut h, "dialog.manageMaskEffects", json!({}));
|
||||
h.step();
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::ManageEffects { .. })));
|
||||
// select and rename inline
|
||||
click(&mut h, "effectRow:12 Sky");
|
||||
h.step();
|
||||
click(&mut h, "button:effectRename");
|
||||
h.step();
|
||||
req(&mut h, "ui.key", json!({"key": "A", "modifiers": {"command": true}}));
|
||||
req(&mut h, "ui.text", json!({"text": "12 Sky Darken"}));
|
||||
req(&mut h, "ui.key", json!({"key": "Enter"}));
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(
|
||||
h.app.session.mask_effects.iter().any(|e| e.name == "12 Sky Darken"),
|
||||
"{:?}",
|
||||
h.app.session.mask_effects.iter().map(|e| &e.name).collect::<Vec<_>>()
|
||||
);
|
||||
// duplicate selects the copy; star it; apply it to the mask
|
||||
click(&mut h, "button:effectDuplicate");
|
||||
h.step();
|
||||
let Some(Dialog::ManageEffects { selected, .. }) = &h.app.ui.dialog else { panic!() };
|
||||
assert_eq!(selected, &vec!["12 Sky Darken copy".to_string()]);
|
||||
click(&mut h, "effectStar:12 Sky Darken copy");
|
||||
h.step();
|
||||
assert!(h.app.session.mask_effects.iter().any(|e| e.name == "12 Sky Darken copy" && e.favorite));
|
||||
click(&mut h, "button:effectApply");
|
||||
h.step();
|
||||
assert_eq!(mask(&h).adjust.highlights, -60.0);
|
||||
// search narrows the list; a group heading selects the group
|
||||
req(&mut h, "ui.clickWidget", json!({"id": "field:effectSearch"}));
|
||||
req(&mut h, "ui.text", json!({"text": "retouch"}));
|
||||
h.step();
|
||||
click(&mut h, "effectGroup:Retouch");
|
||||
h.step();
|
||||
let Some(Dialog::ManageEffects { selected, .. }) = &h.app.ui.dialog else { panic!() };
|
||||
assert_eq!(selected, &vec!["21 Skin".to_string()]);
|
||||
// delete asks first, inside the window
|
||||
click(&mut h, "button:effectDelete");
|
||||
h.step();
|
||||
assert_eq!(h.app.session.mask_effects.len(), 4);
|
||||
click(&mut h, "button:effectDeleteConfirm");
|
||||
h.step();
|
||||
assert_eq!(h.app.session.mask_effects.len(), 3);
|
||||
assert!(matches!(h.app.ui.dialog, Some(Dialog::ManageEffects { .. })), "stays open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_effects_toast_reports_groups_and_what_was_kept() {
|
||||
let v = json!({"effects": [
|
||||
{"name": "a", "group": "Detail", "unmapped": ["Grain"]},
|
||||
{"name": "b", "group": "Retouch", "unmapped": ["Point Color", "Grain"]},
|
||||
{"name": "c", "group": "", "unmapped": []},
|
||||
]});
|
||||
let msg = crate::panels::mask_effects::import_toast(&v);
|
||||
assert!(msg.contains("3 mask effects") && msg.contains("2 groups") && msg.contains("Grain, Point Color"), "{msg}");
|
||||
}
|
||||
@@ -1526,3 +1526,30 @@ fn a_fast_brush_stroke_shows_without_gaps_before_the_button_is_released() {
|
||||
assert!((0..3).all(|i| (l[i] - k[i]).abs() < 24.0), "live {l:?} vs committed {k:?} at ({x}, {y})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_dropdown_applies_saves_and_shows_the_matching_effect() {
|
||||
let mut h = detail("panel.masking");
|
||||
exec(&mut h, "mask.add", json!({"kind": "radial"}));
|
||||
exec(&mut h, "mask.adjust", json!({"values": {"exposure": 0.7, "shadows": 25, "amount": 80}}));
|
||||
h.step();
|
||||
// save the mask's sliders from the menu (the New Effect Preset dialog)
|
||||
click(&mut h, "dropdown:maskEffect");
|
||||
click(&mut h, "maskEffectMenu:save");
|
||||
let mut dlg = h.app.ui.dialog.take().expect("the save dialog opens");
|
||||
match &mut dlg {
|
||||
crate::state::Dialog::SaveEffect { name, .. } => *name = "Lift".into(),
|
||||
d => panic!("the save dialog opens: {d:?}"),
|
||||
}
|
||||
let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg);
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
assert_eq!(h.app.session.mask_effects.len(), 1);
|
||||
// change the sliders, then pick the effect again: sliders back, Amount kept
|
||||
exec(&mut h, "mask.adjust", json!({"values": {"exposure": -1, "clarity": 40, "amount": 50}}));
|
||||
h.step();
|
||||
click(&mut h, "dropdown:maskEffect");
|
||||
click(&mut h, "maskEffect:Lift");
|
||||
let a = develop(&h).masks.last().expect("a mask").adjust;
|
||||
assert_eq!((a.exposure, a.shadows, a.clarity, a.amount), (0.7, 25.0, 0.0, 50.0));
|
||||
assert_eq!(exec(&mut h, "mask.effects", json!({}))["current"], "Lift");
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+33
-1
@@ -192,13 +192,45 @@ Applying a preset adds its masks to the photo's own (masks the photo already has
|
||||
slider scales them. A sidecar's masks replace the photo's, because a sidecar holds the whole edit. Components we can't
|
||||
carry over are listed in `preset.import`'s `unmapped` as `Mask: <kind>`.
|
||||
|
||||
## Local adjustment presets (mask effects)
|
||||
|
||||
`mask.importEffects {path | paths, group?, dryRun?}` (and `preset.import`, File ▸ Import Profiles & Presets…, drag & drop)
|
||||
reads these into the Masking panel's Effect menu (`crates/engine/src/cmd/mask_effect_files.rs`):
|
||||
|
||||
| File | How it is read |
|
||||
|---|---|
|
||||
| Lightroom Classic `.lrtemplate` (`%APPDATA%\Adobe\Lightroom\Local Adjustment Presets`, `~/Library/Application Support/Adobe/Lightroom/Local Adjustment Presets`) | a Lua table with `type = "LocalizedAdjustmentPreset"`; `title` (else `internalName`, else the file name) → name; `value` holds the `Local*` field names in lower camel case: `exposure2012` → `LocalExposure2012`, mapped as in the table above (`exposure`, `contrast`, `clarity` are the older process version's; the 2012 fields win); `luminanceNoise` → Noise, `toningHue` / `toningSaturation` → Color |
|
||||
| Camera Raw local-correction `.xmp` (`CameraRaw/LocalCorrections`) | top-level `crs:Local*` properties (or, in a file with `crs:PresetType="Local"` without them, its first correction's); `crs:Name` → name, `crs:Group` → group. Develop presets (no `crs:Local*`) stay develop presets |
|
||||
| `.lceffect` | ours: `{"format": "lightcraft.maskEffects", "version": 2, "effects": [{name, adjust, group?, favorite?, fields?, with_amount?, brush?, extra?}]}` (a bare array or one effect also reads); `mask.exportEffects {path, names?, group?}` writes it |
|
||||
| `.zip` | the presets above inside it; its folders give groups |
|
||||
|
||||
Groups: a file's folder (relative to the imported folder; names that only say "local adjustment presets" don't count)
|
||||
names its group, unless the file names one. Lightroom users mark sections of the flat preset menu with **divider
|
||||
presets**: presets that set nothing, named like `10 ----- DETAIL ENHANCERS ----- 10` (a run of three or more `-`, `=`,
|
||||
`_`, `*`, `#`, `~`, `—`, `.`). These become groups instead of effects: a preset whose name starts with a number goes to
|
||||
the divider with the largest number not above it (`12 Sky` under `10 ----- DETAIL ENHANCERS ----- 10` → *Detail
|
||||
Enhancers*); presets without a number keep their folder's group. An earlier import's divider effects are removed.
|
||||
Same-named effects are replaced (a star is kept).
|
||||
|
||||
Settings with no local slider here are listed per preset in `unmapped` (and the toast) and **kept** with the effect in
|
||||
`extra` (an `.lceffect` export carries them), but not applied: `grain` (Grain; LightCraft has no local grain),
|
||||
`pointColors` (Point Color: `SrcHue/Sat/Lum`, `HueShift`, `SatScale`, `LumScale`, `RangeAmount`, `Hue/Sat/LumRange`
|
||||
`{LowerNone, LowerFull, UpperFull, UpperNone}`; there is global Point Color only), `toningLuminance`. Zero values aren't
|
||||
listed (`refineSaturation = 0` in most files). Lightroom keeps brush size, feather, flow and density per brush, not in
|
||||
presets, so there is nothing to import for them; an effect saved here can carry them (`brush`).
|
||||
|
||||
Applying (`mask.applyEffect {name, id? | ids? | all?, photos?: "selected"}`) sets the sliders the effect carries —
|
||||
all of them for an imported preset (unnamed ones to 0, as Lightroom does), or only the ones chosen when it was saved
|
||||
(`mask.saveEffect {fields: "changed" | [..]}`) — and Amount only if it carries it. The effect is remembered for the
|
||||
mask's kind: the next brush / linear / radial / … mask starts with it (`mask.toolEffect`).
|
||||
|
||||
## Preset files
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Ours: `.lcpreset` | JSON `{"format": "lightcraft.preset", "version": 1, "presets": [{id, name, group, settings}]}`, where `settings` is a partial develop-settings object (only the groups the preset includes). A file can hold one preset or many, and every preset keeps its group. Import also accepts a bare preset object or an array of them. |
|
||||
| Export | `preset.export {path, ids?, group?}`: all user presets by default, or the given ids or one group. In the app: File ▸ Export Presets…, Presets panel ▸ ⋯ ▸ Export User Presets…, or right-click a group ▸ Export Group…. |
|
||||
| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. |
|
||||
| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Local adjustment (brush) presets — Lightroom Classic's `.lrtemplate` with `type = "LocalizedAdjustmentPreset"`, Camera Raw's local-correction `.xmp`, our `.lceffect`, also inside zips — become mask effects (`mask.effects`) instead (see *Local adjustment presets* below); the result lists them under `effects`, and divider presets under `effectDividers`. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. |
|
||||
| XMP presets | Read with the `crs:` table above, with `crs:Name` as the name (falling back to the file name) and `crs:Group` as the group (falling back to "Imported Presets"). Only the fields the preset sets are included, so applying it leaves everything else alone and the Amount slider scales it like any other preset. We only read XMP presets; we don't write them. |
|
||||
|
||||
LightCraft ships no third-party presets. Its built-in presets are its own values (`crates/engine/src/presets.rs`).
|
||||
|
||||
Reference in New Issue
Block a user