mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 14:07:34 -06:00
Mask ids come unchecked from develop.merge, sidecars (lc:settings) and pastes. With a mask id of u32::MAX, next_mask_id() and Preset::apply added 1 to it: debug builds panicked, release builds wrapped to 0, so the second new mask got the same id as the first. next_mask_id() now falls back to the lowest id no mask uses when the highest is at the limit, and Preset::apply takes each preset mask's id from it instead of counting on its own, so mask.add, mask.brushStroke, mask.duplicate and presets with masks all get unused ids.
314 lines
12 KiB
Rust
314 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 std::borrow::Cow;
|
|
|
|
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, as every merge does ([`DevelopSettings::merged`]).
|
|
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
|
|
let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) };
|
|
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
|
|
s.merged(&patch).unwrap_or_else(|_| s.clone())
|
|
}
|
|
|
|
#[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);
|
|
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;
|
|
}
|
|
m.id = out.next_mask_id();
|
|
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]}));
|
|
}
|
|
|
|
#[test]
|
|
fn preset_masks_get_unused_ids_after_a_mask_id_at_the_limit() {
|
|
let s = DevelopSettings { masks: vec![crate::Mask { id: u32::MAX, name: "Theirs".into(), ..Default::default() }], ..Default::default() };
|
|
let p = Preset {
|
|
id: "p".into(),
|
|
name: "P".into(),
|
|
group: "User".into(),
|
|
settings: json!({"masks": [{"id": 7, "name": "A"}, {"id": 7, "name": "B"}]}),
|
|
favorite: false,
|
|
builtin: false,
|
|
};
|
|
let out = p.apply(&s, 1.0);
|
|
let mut ids: Vec<u32> = out.masks.iter().map(|m| m.id).collect();
|
|
assert_eq!(ids.len(), 3);
|
|
ids.sort_unstable();
|
|
ids.dedup();
|
|
assert_eq!(ids.len(), 3, "{:?}", out.masks);
|
|
}
|
|
}
|