mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 03:27:14 -06:00
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>
256 lines
8.9 KiB
Rust
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]}));
|
|
}
|
|
}
|