lightcraft/crates/develop/src/presets.rs

303 lines
12 KiB
Rust

//! Presets and copy/paste subsets.
//!
//! A preset is a *partial* settings object (JSON) — only the groups it includes. Applying merges it
//! into the photo's settings; `amount` (0..200 %) interpolates numeric values between the current
//! settings and the preset's. Copy/paste/sync use the same group selection ([`SettingsGroup`]).
//!
//! The rendering process ([`crate::ProcessVersion`]) is in no group: copy, paste, sync and presets
//! made from a photo never carry it, so the photos they change keep their own. A partial that names
//! `process` itself (Apply Settings JSON) sets it like any other field.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::DevelopSettings;
/// Groups shown in "Choose settings to copy" / "Create preset" (Lightroom's checklist).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SettingsGroup {
Profile,
Treatment,
WhiteBalance,
Light,
ToneCurve,
Color,
ColorMixer,
ColorGrading,
Effects,
Vignette,
Grain,
Detail,
Optics,
Geometry,
Crop,
Masks,
Spots,
RedEye,
LensBlur,
Calibration,
}
impl SettingsGroup {
pub const ALL: [SettingsGroup; 20] = [
SettingsGroup::Profile,
SettingsGroup::Treatment,
SettingsGroup::WhiteBalance,
SettingsGroup::Light,
SettingsGroup::ToneCurve,
SettingsGroup::Color,
SettingsGroup::ColorMixer,
SettingsGroup::ColorGrading,
SettingsGroup::Effects,
SettingsGroup::Vignette,
SettingsGroup::Grain,
SettingsGroup::Detail,
SettingsGroup::Optics,
SettingsGroup::Geometry,
SettingsGroup::Crop,
SettingsGroup::Masks,
SettingsGroup::Spots,
SettingsGroup::RedEye,
SettingsGroup::LensBlur,
SettingsGroup::Calibration,
];
/// Groups included by default when copying (Lightroom excludes crop, masks and spots by default).
pub fn default_copy() -> Vec<SettingsGroup> {
SettingsGroup::ALL
.into_iter()
.filter(|g| !matches!(g, SettingsGroup::Crop | SettingsGroup::Masks | SettingsGroup::Spots | SettingsGroup::RedEye))
.collect()
}
pub fn label(self) -> &'static str {
match self {
SettingsGroup::Profile => "Profile",
SettingsGroup::Treatment => "Treatment",
SettingsGroup::WhiteBalance => "White Balance",
SettingsGroup::Light => "Light",
SettingsGroup::ToneCurve => "Tone Curve",
SettingsGroup::Color => "Color",
SettingsGroup::ColorMixer => "Color Mixer",
SettingsGroup::ColorGrading => "Color Grading",
SettingsGroup::Effects => "Effects",
SettingsGroup::Vignette => "Vignette",
SettingsGroup::Grain => "Grain",
SettingsGroup::Detail => "Detail",
SettingsGroup::Optics => "Optics",
SettingsGroup::Geometry => "Geometry",
SettingsGroup::Crop => "Crop",
SettingsGroup::Masks => "Masking",
SettingsGroup::Spots => "Remove",
SettingsGroup::RedEye => "Red Eye",
SettingsGroup::LensBlur => "Lens Blur",
SettingsGroup::Calibration => "Calibration",
}
}
/// Top-level JSON keys of `DevelopSettings` that belong to the group.
pub fn keys(self) -> &'static [&'static str] {
match self {
SettingsGroup::Profile => &["profile"],
SettingsGroup::Treatment => &["treatment"],
SettingsGroup::WhiteBalance => &["wb"],
SettingsGroup::Light => &["light", "hdr"],
SettingsGroup::ToneCurve => &["curve"],
SettingsGroup::Color => &["color"],
SettingsGroup::ColorMixer => &["mixer", "bw_mix", "point_colors"],
SettingsGroup::ColorGrading => &["grading"],
SettingsGroup::Effects => &["effects"],
SettingsGroup::Vignette => &["vignette"],
SettingsGroup::Grain => &["grain"],
SettingsGroup::Detail => &["detail", "enhance"],
SettingsGroup::Optics => &["optics"],
SettingsGroup::Geometry => &["geometry"],
SettingsGroup::Crop => &["crop", "orientation"],
SettingsGroup::Masks => &["masks"],
SettingsGroup::Spots => &["spots"],
SettingsGroup::RedEye => &["red_eye"],
SettingsGroup::LensBlur => &["lens_blur"],
SettingsGroup::Calibration => &["calibration"],
}
}
}
/// The subset of `s` covering `groups`, as a partial JSON object.
pub fn extract_groups(s: &DevelopSettings, groups: &[SettingsGroup]) -> Value {
let full = s.to_json();
let mut out = Map::new();
for g in groups {
for k in g.keys() {
if let Some(v) = full.get(*k) {
out.insert((*k).to_string(), v.clone());
}
}
}
Value::Object(out)
}
/// Recursively merge `patch` into `base` (objects merge; everything else replaces).
pub fn deep_merge(base: &mut Value, patch: &Value) {
match (base, patch) {
(Value::Object(b), Value::Object(p)) => {
for (k, v) in p {
match b.get_mut(k) {
Some(bv) if bv.is_object() && v.is_object() => deep_merge(bv, v),
_ => {
b.insert(k.clone(), v.clone());
}
}
}
}
(b, p) => *b = p.clone(),
}
}
/// Interpolate numbers in `patch` towards/away from `base` by `t` (1 = patch as-is); non-numeric
/// values are taken from the patch when `t >= 0.5`.
fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
match (base, patch) {
(Value::Object(b), Value::Object(p)) => {
let mut out = Map::new();
for (k, pv) in p {
let bv = b.get(k).unwrap_or(&Value::Null);
out.insert(k.clone(), scale_patch(bv, pv, t));
}
Value::Object(out)
}
// Integer fields (the grain seed, ids) are discrete like strings: an interpolated seed would
// not deserialize as an integer, and the whole preset would silently not apply.
(Value::Number(b), Value::Number(p)) if b.is_f64() || p.is_f64() => {
let (b, p) = (b.as_f64().unwrap_or(0.0), p.as_f64().unwrap_or(0.0));
serde_json::Number::from_f64(b + (p - b) * t).map(Value::Number).unwrap_or(Value::Null)
}
(b, p) => {
if t >= 0.5 {
p.clone()
} else {
b.clone()
}
}
}
}
/// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped
/// through the control specs.
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
let base = s.to_json();
let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) };
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
for c in crate::controls::CONTROLS {
if let Some(v) = crate::controls::get(&out, c.id) {
crate::controls::set(&mut out, c.id, v);
}
}
out
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Preset {
pub id: String,
pub name: String,
pub group: String,
/// Partial settings.
pub settings: Value,
#[serde(default)]
pub favorite: bool,
/// Built-in presets ship with the app and can't be deleted.
#[serde(default)]
pub builtin: bool,
}
impl Preset {
pub fn from_settings(id: &str, name: &str, group: &str, s: &DevelopSettings, groups: &[SettingsGroup]) -> Preset {
Preset { id: id.into(), name: name.into(), group: group.into(), settings: extract_groups(s, groups), favorite: false, builtin: false }
}
/// Apply at `amount` (1.0 = 100 %). Masks in the preset are added to the photo's own (ones it
/// already has are skipped), their effect scaled by the amount.
pub fn apply(&self, s: &DevelopSettings, amount: f64) -> DevelopSettings {
let Some(masks) = self.settings.get("masks").and_then(Value::as_array).filter(|m| !m.is_empty()) else {
return apply_partial(s, &self.settings, amount);
};
let mut rest = self.settings.clone();
if let Some(o) = rest.as_object_mut() {
o.remove("masks");
}
let mut out = apply_partial(s, &rest, amount);
let mut next = out.masks.iter().map(|m| m.id).max().unwrap_or(0);
for m in masks {
let Ok(mut m) = serde_json::from_value::<crate::Mask>(m.clone()) else { continue };
if out.masks.iter().any(|q| q.name == m.name && q.components == m.components) {
continue;
}
next += 1;
m.id = next;
m.adjust.amount = (m.adjust.amount * amount).clamp(0.0, 200.0);
out.masks.push(m);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extract_and_apply_roundtrip() {
let mut a = DevelopSettings::default();
a.light.exposure = 1.0;
a.effects.clarity = 30.0;
a.crop.geometry.angle = 5.0;
let p = Preset::from_settings("p", "P", "User", &a, &SettingsGroup::default_copy());
assert!(p.settings.get("crop").is_none());
let b = p.apply(&DevelopSettings::default(), 1.0);
assert_eq!(b.light.exposure, 1.0);
assert_eq!(b.effects.clarity, 30.0);
assert_eq!(b.crop.geometry.angle, 0.0);
}
#[test]
fn amount_scales_numbers() {
let base = DevelopSettings::default();
let partial = json!({"light": {"exposure": 1.0, "contrast": 40}});
let half = apply_partial(&base, &partial, 0.5);
assert!((half.light.exposure - 0.5).abs() < 1e-9);
assert!((half.light.contrast - 20.0).abs() < 1e-9);
let more = apply_partial(&base, &partial, 2.0);
assert!((more.light.contrast - 80.0).abs() < 1e-9);
// clamped through specs
let lots = apply_partial(&base, &json!({"light": {"exposure": 4.0}}), 2.0);
assert_eq!(lots.light.exposure, 5.0);
}
/// A preset with the Grain group (its seed is an integer) used to do nothing at any amount
/// other than 100 %: the interpolated seed failed to deserialize and the preset was dropped.
#[test]
fn amount_keeps_integer_fields_whole() {
let mut a = DevelopSettings::default();
a.light.exposure = 1.0;
a.grain.amount = 40.0;
a.grain.seed = 12345;
let p = Preset::from_settings("p", "P", "User", &a, &SettingsGroup::default_copy());
assert!(p.settings.pointer("/grain/seed").is_some_and(Value::is_u64), "the default copy groups include the grain seed");
let base = DevelopSettings::default();
for (amount, seed) in [(0.3, 0), (0.5, 12345), (1.5, 12345), (2.0, 12345)] {
let out = p.apply(&base, amount);
assert!((out.light.exposure - amount.min(5.0)).abs() < 1e-9, "{amount}: exposure {}", out.light.exposure);
assert!((out.grain.amount - (40.0 * amount).min(100.0)).abs() < 1e-9, "{amount}: grain {}", out.grain.amount);
assert_eq!(out.grain.seed, seed, "{amount}: seed");
}
}
#[test]
fn deep_merge_objects() {
let mut a = json!({"x": {"a": 1, "b": 2}, "y": [1]});
deep_merge(&mut a, &json!({"x": {"b": 3}, "y": [2, 3]}));
assert_eq!(a, json!({"x": {"a": 1, "b": 3}, "y": [2, 3]}));
}
}