Files
lightcraft/crates/develop/src/presets.rs
T
Brandon ThomasandClaude Opus 5.5 490fe943cd LR-EDIT-COLOR-POINTCOLOR: Point Color — sampled colours with shift, variance, range and visualize
Up to 8 OkLCh samples (pointColor.pick samples the colour after the mixer on a proxy render);
per sample hue/saturation/luminance shift, variance (compress/expand the spread around the
sample) and an overall + per-axis range with soft edges, applied after the colour mixer in
ColorOps and its WGSL twin (equivalence case). Indexed control specs
(pointColor.<i>.hueShift …) so develop.set/controls/MCP address each sample. Visualize range
is a render overlay (RenderRequest::overlay) drawn after either renderer: the sample's own
edit is left out and colours outside the range turn grey. Point Color flyout in the Color
section with swatches, eyedropper, sliders, visualize and delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:18:28 -04:00

256 lines
8.9 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`]).
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"],
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)
}
(Value::Number(b), Value::Number(p)) => {
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) };
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 }
}
pub fn apply(&self, s: &DevelopSettings, amount: f64) -> DevelopSettings {
apply_partial(s, &self.settings, amount)
}
}
#[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);
}
#[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]}));
}
}