M0.3/M0.5: develop settings model + control specs + presets; procedural demo scenes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-09-30 11:06:57 -04:00
co-authored by Claude Opus 5.5
parent 474e44c29e
commit 3f54765bd0
9 changed files with 2408 additions and 0 deletions
+271
View File
@@ -0,0 +1,271 @@
//! Control specs: one table describes every numeric develop slider — id, label, section, range,
//! default, step, display precision and track style. The UI builds panels from it, MCP exposes it as
//! a schema, and `get`/`set` address settings by id (e.g. `light.exposure`, `mixer.red.hue`).
use serde::Serialize;
use crate::settings::DevelopSettings;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Section {
Light,
Curve,
Color,
Mixer,
BwMix,
Grading,
Effects,
Vignette,
Grain,
Detail,
Optics,
Geometry,
Profile,
}
impl Section {
pub fn label(self) -> &'static str {
match self {
Section::Light => "Light",
Section::Curve => "Tone Curve",
Section::Color => "Color",
Section::Mixer => "Color Mixer",
Section::BwMix => "B&W Mixer",
Section::Grading => "Color Grading",
Section::Effects => "Effects",
Section::Vignette => "Vignette",
Section::Grain => "Grain",
Section::Detail => "Detail",
Section::Optics => "Optics",
Section::Geometry => "Geometry",
Section::Profile => "Profile",
}
}
}
/// How the slider track is drawn.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum Track {
/// Plain grey track, fill from the minimum.
Plain,
/// Grey track, zero in the middle.
Centered,
/// Blue → yellow (white balance temperature).
Temp,
/// Green → magenta (tint).
Tint,
/// A two-colour gradient (sRGB hex strings).
Gradient { from: &'static str, to: &'static str },
/// Hue shift for a mixer band: gradient from the neighbouring hues through the band colour.
Hue { band: u8 },
/// Saturation for a band: grey → band colour.
Sat { band: u8 },
/// Luminance for a band: dark → light band colour.
Lum { band: u8 },
/// Full hue rainbow.
Rainbow,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct ControlSpec {
pub id: &'static str,
pub label: &'static str,
pub section: Section,
pub min: f64,
pub max: f64,
pub default: f64,
pub step: f64,
/// Decimal places shown.
pub decimals: u8,
pub track: Track,
}
impl ControlSpec {
pub fn clamp(&self, v: f64) -> f64 {
if v.is_finite() { v.clamp(self.min, self.max) } else { self.default }
}
pub fn format(&self, v: f64) -> String {
let s = format!("{:.*}", self.decimals as usize, v);
if v > 0.0 && self.min < 0.0 { format!("+{s}") } else { s }
}
}
macro_rules! controls {
($( $id:literal => $($field:ident).+ , $label:literal, $section:ident, $min:expr, $max:expr, $def:expr, $step:expr, $dec:expr, $track:expr; )*) => {
/// All numeric develop controls, in panel order.
pub static CONTROLS: &[ControlSpec] = &[
$( ControlSpec { id: $id, label: $label, section: Section::$section, min: $min as f64, max: $max as f64, default: $def as f64, step: $step as f64, decimals: $dec, track: $track }, )*
];
/// Read a control value by id.
pub fn get(s: &DevelopSettings, id: &str) -> Option<f64> {
match id {
$( $id => Some(s.$($field).+ as f64), )*
_ => None,
}
}
/// Write a control value by id (clamped to the spec range). Returns false for unknown ids.
pub fn set(s: &mut DevelopSettings, id: &str, v: f64) -> bool {
let Some(spec) = find(id) else { return false };
let v = spec.clamp(v);
match id {
$( $id => { s.$($field).+ = v as _; true } )*
_ => false,
}
}
};
}
use Track::*;
controls! {
"profile.amount" => profile.amount, "Amount", Profile, 0, 200, 100, 1, 0, Plain;
"wb.temp" => wb.temp, "Temp", Color, 2000, 50000, 6500, 50, 0, Temp;
"wb.tint" => wb.tint, "Tint", Color, -150, 150, 0, 1, 0, Tint;
"light.exposure" => light.exposure, "Exposure", Light, -5, 5, 0, 0.01, 2, Centered;
"light.contrast" => light.contrast, "Contrast", Light, -100, 100, 0, 1, 0, Centered;
"light.highlights" => light.highlights, "Highlights", Light, -100, 100, 0, 1, 0, Centered;
"light.shadows" => light.shadows, "Shadows", Light, -100, 100, 0, 1, 0, Centered;
"light.whites" => light.whites, "Whites", Light, -100, 100, 0, 1, 0, Centered;
"light.blacks" => light.blacks, "Blacks", Light, -100, 100, 0, 1, 0, Centered;
"curve.highlights" => curve.highlights, "Highlights", Curve, -100, 100, 0, 1, 0, Centered;
"curve.lights" => curve.lights, "Lights", Curve, -100, 100, 0, 1, 0, Centered;
"curve.darks" => curve.darks, "Darks", Curve, -100, 100, 0, 1, 0, Centered;
"curve.shadows" => curve.shadows, "Shadows", Curve, -100, 100, 0, 1, 0, Centered;
"curve.splitShadows" => curve.split_shadows, "Shadows split", Curve, 10, 70, 25, 1, 0, Plain;
"curve.splitMid" => curve.split_mid, "Midtones split", Curve, 20, 80, 50, 1, 0, Plain;
"curve.splitHighlights" => curve.split_highlights, "Highlights split", Curve, 30, 90, 75, 1, 0, Plain;
"color.vibrance" => color.vibrance, "Vibrance", Color, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#d8406a" };
"color.saturation" => color.saturation, "Saturation", Color, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#e04a3a" };
"mixer.red.hue" => mixer.red.hue, "Red Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 0 };
"mixer.red.sat" => mixer.red.sat, "Red Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 0 };
"mixer.red.lum" => mixer.red.lum, "Red Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 0 };
"mixer.orange.hue" => mixer.orange.hue, "Orange Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 1 };
"mixer.orange.sat" => mixer.orange.sat, "Orange Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 1 };
"mixer.orange.lum" => mixer.orange.lum, "Orange Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 1 };
"mixer.yellow.hue" => mixer.yellow.hue, "Yellow Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 2 };
"mixer.yellow.sat" => mixer.yellow.sat, "Yellow Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 2 };
"mixer.yellow.lum" => mixer.yellow.lum, "Yellow Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 2 };
"mixer.green.hue" => mixer.green.hue, "Green Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 3 };
"mixer.green.sat" => mixer.green.sat, "Green Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 3 };
"mixer.green.lum" => mixer.green.lum, "Green Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 3 };
"mixer.aqua.hue" => mixer.aqua.hue, "Aqua Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 4 };
"mixer.aqua.sat" => mixer.aqua.sat, "Aqua Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 4 };
"mixer.aqua.lum" => mixer.aqua.lum, "Aqua Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 4 };
"mixer.blue.hue" => mixer.blue.hue, "Blue Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 5 };
"mixer.blue.sat" => mixer.blue.sat, "Blue Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 5 };
"mixer.blue.lum" => mixer.blue.lum, "Blue Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 5 };
"mixer.purple.hue" => mixer.purple.hue, "Purple Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 6 };
"mixer.purple.sat" => mixer.purple.sat, "Purple Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 6 };
"mixer.purple.lum" => mixer.purple.lum, "Purple Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 6 };
"mixer.magenta.hue" => mixer.magenta.hue, "Magenta Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 7 };
"mixer.magenta.sat" => mixer.magenta.sat, "Magenta Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 7 };
"mixer.magenta.lum" => mixer.magenta.lum, "Magenta Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 7 };
"bw.red" => bw_mix.red, "Red", BwMix, -100, 100, 0, 1, 0, Lum { band: 0 };
"bw.orange" => bw_mix.orange, "Orange", BwMix, -100, 100, 0, 1, 0, Lum { band: 1 };
"bw.yellow" => bw_mix.yellow, "Yellow", BwMix, -100, 100, 0, 1, 0, Lum { band: 2 };
"bw.green" => bw_mix.green, "Green", BwMix, -100, 100, 0, 1, 0, Lum { band: 3 };
"bw.aqua" => bw_mix.aqua, "Aqua", BwMix, -100, 100, 0, 1, 0, Lum { band: 4 };
"bw.blue" => bw_mix.blue, "Blue", BwMix, -100, 100, 0, 1, 0, Lum { band: 5 };
"bw.purple" => bw_mix.purple, "Purple", BwMix, -100, 100, 0, 1, 0, Lum { band: 6 };
"bw.magenta" => bw_mix.magenta, "Magenta", BwMix, -100, 100, 0, 1, 0, Lum { band: 7 };
"grading.shadows.hue" => grading.shadows.hue, "Shadows Hue", Grading, 0, 360, 0, 1, 0, Rainbow;
"grading.shadows.sat" => grading.shadows.sat, "Shadows Saturation", Grading, 0, 100, 0, 1, 0, Plain;
"grading.shadows.lum" => grading.shadows.lum, "Shadows Luminance", Grading, -100, 100, 0, 1, 0, Centered;
"grading.midtones.hue" => grading.midtones.hue, "Midtones Hue", Grading, 0, 360, 0, 1, 0, Rainbow;
"grading.midtones.sat" => grading.midtones.sat, "Midtones Saturation", Grading, 0, 100, 0, 1, 0, Plain;
"grading.midtones.lum" => grading.midtones.lum, "Midtones Luminance", Grading, -100, 100, 0, 1, 0, Centered;
"grading.highlights.hue" => grading.highlights.hue, "Highlights Hue", Grading, 0, 360, 0, 1, 0, Rainbow;
"grading.highlights.sat" => grading.highlights.sat, "Highlights Saturation", Grading, 0, 100, 0, 1, 0, Plain;
"grading.highlights.lum" => grading.highlights.lum, "Highlights Luminance", Grading, -100, 100, 0, 1, 0, Centered;
"grading.global.hue" => grading.global.hue, "Global Hue", Grading, 0, 360, 0, 1, 0, Rainbow;
"grading.global.sat" => grading.global.sat, "Global Saturation", Grading, 0, 100, 0, 1, 0, Plain;
"grading.global.lum" => grading.global.lum, "Global Luminance", Grading, -100, 100, 0, 1, 0, Centered;
"grading.blending" => grading.blending, "Blending", Grading, 0, 100, 50, 1, 0, Plain;
"grading.balance" => grading.balance, "Balance", Grading, -100, 100, 0, 1, 0, Centered;
"effects.texture" => effects.texture, "Texture", Effects, -100, 100, 0, 1, 0, Centered;
"effects.clarity" => effects.clarity, "Clarity", Effects, -100, 100, 0, 1, 0, Centered;
"effects.dehaze" => effects.dehaze, "Dehaze", Effects, -100, 100, 0, 1, 0, Centered;
"vignette.amount" => vignette.amount, "Vignette", Vignette, -100, 100, 0, 1, 0, Gradient { from: "#101010", to: "#f0f0f0" };
"vignette.midpoint" => vignette.midpoint, "Midpoint", Vignette, 0, 100, 50, 1, 0, Plain;
"vignette.roundness" => vignette.roundness, "Roundness", Vignette, -100, 100, 0, 1, 0, Centered;
"vignette.feather" => vignette.feather, "Feather", Vignette, 0, 100, 50, 1, 0, Plain;
"vignette.highlights" => vignette.highlights, "Highlights", Vignette, 0, 100, 0, 1, 0, Plain;
"grain.amount" => grain.amount, "Grain", Grain, 0, 100, 0, 1, 0, Plain;
"grain.size" => grain.size, "Size", Grain, 0, 100, 25, 1, 0, Plain;
"grain.roughness" => grain.roughness, "Roughness", Grain, 0, 100, 50, 1, 0, Plain;
"detail.sharpenAmount" => detail.sharpen_amount, "Sharpening", Detail, 0, 150, 0, 1, 0, Plain;
"detail.sharpenRadius" => detail.sharpen_radius, "Radius", Detail, 0.5, 3, 1, 0.1, 1, Plain;
"detail.sharpenDetail" => detail.sharpen_detail, "Detail", Detail, 0, 100, 25, 1, 0, Plain;
"detail.sharpenMasking" => detail.sharpen_masking, "Masking", Detail, 0, 100, 0, 1, 0, Plain;
"detail.nrLuminance" => detail.nr_luminance, "Noise Reduction", Detail, 0, 100, 0, 1, 0, Plain;
"detail.nrDetail" => detail.nr_detail, "Detail", Detail, 0, 100, 50, 1, 0, Plain;
"detail.nrContrast" => detail.nr_contrast, "Contrast", Detail, 0, 100, 0, 1, 0, Plain;
"detail.nrColor" => detail.nr_color, "Color Noise Reduction", Detail, 0, 100, 0, 1, 0, Plain;
"detail.nrColorDetail" => detail.nr_color_detail, "Detail", Detail, 0, 100, 50, 1, 0, Plain;
"detail.nrColorSmoothness" => detail.nr_color_smoothness, "Smoothness", Detail, 0, 100, 50, 1, 0, Plain;
"optics.distortion" => optics.distortion, "Distortion", Optics, -100, 100, 0, 1, 0, Centered;
"optics.vignetting" => optics.vignetting, "Vignetting", Optics, -100, 100, 0, 1, 0, Centered;
"optics.vignettingMidpoint" => optics.vignetting_midpoint, "Midpoint", Optics, 0, 100, 50, 1, 0, Plain;
"optics.profileDistortion" => optics.profile_distortion, "Profile Distortion", Optics, 0, 200, 100, 1, 0, Plain;
"optics.profileVignetting" => optics.profile_vignetting, "Profile Vignetting", Optics, 0, 200, 100, 1, 0, Plain;
"optics.defringePurple" => optics.defringe_purple_amount, "Purple Amount", Optics, 0, 20, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#a040d0" };
"optics.defringeGreen" => optics.defringe_green_amount, "Green Amount", Optics, 0, 20, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#40c040" };
"geometry.vertical" => geometry.vertical, "Vertical", Geometry, -100, 100, 0, 1, 0, Centered;
"geometry.horizontal" => geometry.horizontal, "Horizontal", Geometry, -100, 100, 0, 1, 0, Centered;
"geometry.rotate" => geometry.rotate, "Rotate", Geometry, -10, 10, 0, 0.1, 1, Centered;
"geometry.aspect" => geometry.aspect, "Aspect", Geometry, -100, 100, 0, 1, 0, Centered;
"geometry.scale" => geometry.scale, "Scale", Geometry, 50, 150, 100, 1, 0, Plain;
"geometry.offsetX" => geometry.offset_x, "Offset X", Geometry, -100, 100, 0, 0.1, 1, Centered;
"geometry.offsetY" => geometry.offset_y, "Offset Y", Geometry, -100, 100, 0, 0.1, 1, Centered;
"crop.angle" => crop.geometry.angle, "Straighten", Geometry, -45, 45, 0, 0.01, 2, Centered;
}
pub fn find(id: &str) -> Option<&'static ControlSpec> {
CONTROLS.iter().find(|c| c.id == id)
}
pub fn in_section(section: Section) -> impl Iterator<Item = &'static ControlSpec> {
CONTROLS.iter().filter(move |c| c.section == section)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_control_reads_its_default() {
let s = DevelopSettings::default();
for c in CONTROLS {
let v = get(&s, c.id).unwrap_or_else(|| panic!("no getter for {}", c.id));
assert_eq!(v, c.default, "{}", c.id);
assert!(c.min <= c.default && c.default <= c.max, "{}", c.id);
}
}
#[test]
fn ids_unique_and_settable() {
let mut seen = std::collections::HashSet::new();
let mut s = DevelopSettings::default();
for c in CONTROLS {
assert!(seen.insert(c.id), "duplicate {}", c.id);
assert!(set(&mut s, c.id, c.max + 1000.0));
assert_eq!(get(&s, c.id), Some(c.max), "{}", c.id);
assert!(set(&mut s, c.id, f64::NAN));
assert_eq!(get(&s, c.id), Some(c.default));
}
assert!(!set(&mut s, "nope", 1.0));
assert_eq!(get(&s, "nope"), None);
}
#[test]
fn formatting() {
let e = find("light.exposure").unwrap();
assert_eq!(e.format(0.5), "+0.50");
assert_eq!(e.format(-1.0), "-1.00");
assert_eq!(find("wb.temp").unwrap().format(5500.0), "5500");
}
}
+166
View File
@@ -0,0 +1,166 @@
//! The non-destructive edit model of LightCraft.
//!
//! [`DevelopSettings`] is the complete description of a photo's look: pure, serializable data.
//! The pipeline evaluates it; the catalog stores it; presets, copy/paste, sync, versions and history
//! are operations on it. Numeric sliders are addressed by id through [`controls`].
#![forbid(unsafe_code)]
pub mod controls;
pub mod presets;
pub mod settings;
pub use controls::{CONTROLS, ControlSpec, Section, Track};
pub use presets::{Preset, SettingsGroup, apply_partial, extract_groups};
pub use settings::*;
use serde_json::Value;
impl DevelopSettings {
/// Settings for a freshly imported raw file (Lightroom applies capture sharpening and colour
/// noise reduction by default to raw files).
pub fn for_raw(as_shot_temp: f64, as_shot_tint: f64) -> DevelopSettings {
let mut s = DevelopSettings::default();
s.wb.temp = as_shot_temp;
s.wb.tint = as_shot_tint;
s.detail.sharpen_amount = 40.0;
s.detail.nr_color = 25.0;
s
}
pub fn to_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
pub fn from_json(v: &Value) -> Result<DevelopSettings, serde_json::Error> {
serde_json::from_value(v.clone())
}
/// Merge a partial JSON object into these settings (fields not mentioned are kept).
pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> {
let mut v = self.to_json();
presets::deep_merge(&mut v, partial);
DevelopSettings::from_json(&v)
}
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
pub fn hash64(&self) -> u64 {
let s = serde_json::to_string(self).unwrap_or_default();
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
/// True if nothing differs from a fresh default (ignoring white balance "as shot" values).
pub fn is_unedited(&self) -> bool {
let mut a = self.clone();
let d = DevelopSettings::default();
a.wb = d.wb;
a == d
}
pub fn section_enabled(&self, section: &str) -> bool {
!self.disabled_sections.iter().any(|s| s == section)
}
pub fn set_section_enabled(&mut self, section: &str, on: bool) {
self.disabled_sections.retain(|s| s != section);
if !on {
self.disabled_sections.push(section.to_string());
}
}
/// Reset every control in `section` to its default.
pub fn reset_section(&mut self, section: Section) {
for c in controls::in_section(section) {
controls::set(self, c.id, c.default);
}
match section {
Section::Curve => {
let d = ToneCurve::default();
self.curve = d;
}
Section::Color => self.wb.mode = WbMode::AsShot,
_ => {}
}
}
/// Next free mask id.
pub fn next_mask_id(&self) -> u32 {
self.masks.iter().map(|m| m.id).max().map_or(1, |m| m + 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn serde_roundtrip_default_and_edited() {
let mut s = DevelopSettings::default();
controls::set(&mut s, "light.exposure", 1.25);
s.masks.push(Mask {
id: 1,
components: vec![MaskComponent {
op: MaskOp::Add,
invert: false,
shape: MaskShape::Radial {
center: lightcraft_geom::Point::new(0.5, 0.5),
rx: 0.2,
ry: 0.1,
angle: 10.0,
feather: 50.0,
invert: false,
},
}],
..Default::default()
});
let v = s.to_json();
let back = DevelopSettings::from_json(&v).unwrap();
assert_eq!(back, s);
assert_ne!(back.hash64(), DevelopSettings::default().hash64());
}
#[test]
fn missing_fields_take_defaults_and_unknown_ignored() {
let s = DevelopSettings::from_json(&json!({"light": {"exposure": 0.5}, "futureThing": 3})).unwrap();
assert_eq!(s.light.exposure, 0.5);
assert_eq!(s.grading.blending, 50.0);
}
#[test]
fn merge_partial() {
let s = DevelopSettings::default();
let m = s.merged(&json!({"light": {"contrast": 20}, "effects": {"clarity": 10}})).unwrap();
assert_eq!(m.light.contrast, 20.0);
assert_eq!(m.effects.clarity, 10.0);
assert_eq!(m.light.exposure, 0.0);
}
#[test]
fn unedited_and_reset() {
let mut s = DevelopSettings::default();
s.wb.temp = 5000.0;
assert!(s.is_unedited());
s.light.exposure = 1.0;
s.light.shadows = 30.0;
assert!(!s.is_unedited());
s.reset_section(Section::Light);
assert!(s.is_unedited());
}
#[test]
fn section_toggles() {
let mut s = DevelopSettings::default();
assert!(s.section_enabled("effects"));
s.set_section_enabled("effects", false);
s.set_section_enabled("effects", false);
assert!(!s.section_enabled("effects"));
assert_eq!(s.disabled_sections.len(), 1);
s.set_section_enabled("effects", true);
assert!(s.section_enabled("effects"));
}
}
+251
View File
@@ -0,0 +1,251 @@
//! 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,
}
impl SettingsGroup {
pub const ALL: [SettingsGroup; 19] = [
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,
];
/// 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",
}
}
/// 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"],
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"],
}
}
}
/// 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]}));
}
}
+736
View File
@@ -0,0 +1,736 @@
//! The develop settings schema. Every field has a neutral default; serde uses `#[serde(default)]` so
//! older/newer files load (unknown fields are ignored, missing fields take defaults).
use lightcraft_geom::{CropGeometry, Orientation, Point};
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DevelopSettings {
pub version: u32,
pub profile: Profile,
pub treatment: Treatment,
pub wb: WhiteBalance,
pub light: Light,
pub curve: ToneCurve,
pub color: ColorAdj,
pub mixer: Mixer,
pub bw_mix: BwMix,
pub grading: ColorGrading,
pub effects: Effects,
pub vignette: Vignette,
pub grain: Grain,
pub detail: Detail,
pub optics: Optics,
pub geometry: Geometry,
pub crop: Crop,
pub orientation: Orientation,
pub masks: Vec<Mask>,
pub spots: Vec<Spot>,
pub red_eye: Vec<RedEye>,
pub lens_blur: LensBlur,
pub enhance: Enhance,
/// Section on/off toggles (the "eye" buttons on panel headers): section id → enabled.
pub disabled_sections: Vec<String>,
}
impl Default for DevelopSettings {
fn default() -> Self {
Self {
version: SCHEMA_VERSION,
profile: Profile::default(),
treatment: Treatment::Color,
wb: WhiteBalance::default(),
light: Light::default(),
curve: ToneCurve::default(),
color: ColorAdj::default(),
mixer: Mixer::default(),
bw_mix: BwMix::default(),
grading: ColorGrading::default(),
effects: Effects::default(),
vignette: Vignette::default(),
grain: Grain::default(),
detail: Detail::default(),
optics: Optics::default(),
geometry: Geometry::default(),
crop: Crop::default(),
orientation: Orientation::Normal,
masks: Vec::new(),
spots: Vec::new(),
red_eye: Vec::new(),
lens_blur: LensBlur::default(),
enhance: Enhance::default(),
disabled_sections: Vec::new(),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Treatment {
#[default]
Color,
Bw,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Profile {
/// Profile id from the profile registry (e.g. `lc.color`, `lc.vivid`, `lc.mono`).
pub id: String,
/// Creative profile amount 0..200 (%).
pub amount: f64,
}
impl Default for Profile {
fn default() -> Self {
Self { id: "lc.color".into(), amount: 100.0 }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WbMode {
#[default]
AsShot,
Auto,
Daylight,
Cloudy,
Shade,
Tungsten,
Fluorescent,
Flash,
Custom,
}
impl WbMode {
pub const ALL: [WbMode; 9] = [
WbMode::AsShot,
WbMode::Auto,
WbMode::Daylight,
WbMode::Cloudy,
WbMode::Shade,
WbMode::Tungsten,
WbMode::Fluorescent,
WbMode::Flash,
WbMode::Custom,
];
pub fn label(self) -> &'static str {
match self {
WbMode::AsShot => "As Shot",
WbMode::Auto => "Auto",
WbMode::Daylight => "Daylight",
WbMode::Cloudy => "Cloudy",
WbMode::Shade => "Shade",
WbMode::Tungsten => "Tungsten",
WbMode::Fluorescent => "Fluorescent",
WbMode::Flash => "Flash",
WbMode::Custom => "Custom",
}
}
/// Preset temperature/tint for raw files (K, tint).
pub fn preset(self) -> Option<(f64, f64)> {
match self {
WbMode::Daylight => Some((5500.0, 10.0)),
WbMode::Cloudy => Some((6500.0, 10.0)),
WbMode::Shade => Some((7500.0, 10.0)),
WbMode::Tungsten => Some((2850.0, 0.0)),
WbMode::Fluorescent => Some((3800.0, 21.0)),
WbMode::Flash => Some((5500.0, 0.0)),
_ => None,
}
}
}
/// White balance. For raw files `temp` is the scene illuminant in Kelvin; for rendered files
/// (JPEG etc.) the image is assumed D65-balanced and `temp`/`tint` are the same model with an
/// "as shot" of 6500 K / 0 (the UI shows a relative −100..100 scale for those, like Lightroom).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct WhiteBalance {
pub mode: WbMode,
pub temp: f64,
pub tint: f64,
}
impl Default for WhiteBalance {
fn default() -> Self {
Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0 }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Light {
pub exposure: f64,
pub contrast: f64,
pub highlights: f64,
pub shadows: f64,
pub whites: f64,
pub blacks: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ToneCurve {
pub highlights: f64,
pub lights: f64,
pub darks: f64,
pub shadows: f64,
/// Region split points (percent), Lightroom defaults 25/50/75.
pub split_shadows: f64,
pub split_mid: f64,
pub split_highlights: f64,
/// Point curves in 0..1 (identity = empty or [(0,0),(1,1)]).
pub master: Vec<Point>,
pub red: Vec<Point>,
pub green: Vec<Point>,
pub blue: Vec<Point>,
}
impl Default for ToneCurve {
fn default() -> Self {
Self {
highlights: 0.0,
lights: 0.0,
darks: 0.0,
shadows: 0.0,
split_shadows: 25.0,
split_mid: 50.0,
split_highlights: 75.0,
master: Vec::new(),
red: Vec::new(),
green: Vec::new(),
blue: Vec::new(),
}
}
}
impl ToneCurve {
pub fn point_curve_is_identity(pts: &[Point]) -> bool {
pts.is_empty() || pts.iter().all(|p| (p.x - p.y).abs() < 1e-9)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ColorAdj {
pub vibrance: f64,
pub saturation: f64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Hsl {
pub hue: f64,
pub sat: f64,
pub lum: f64,
}
/// The 8 colour-mixer bands in Lightroom order.
pub const MIXER_BANDS: [&str; 8] = ["red", "orange", "yellow", "green", "aqua", "blue", "purple", "magenta"];
/// Centre hue of each band in degrees (sRGB-ish HSV hue).
pub const MIXER_HUES: [f64; 8] = [0.0, 30.0, 60.0, 120.0, 180.0, 225.0, 270.0, 315.0];
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Mixer {
pub red: Hsl,
pub orange: Hsl,
pub yellow: Hsl,
pub green: Hsl,
pub aqua: Hsl,
pub blue: Hsl,
pub purple: Hsl,
pub magenta: Hsl,
}
impl Mixer {
pub fn bands(&self) -> [Hsl; 8] {
[self.red, self.orange, self.yellow, self.green, self.aqua, self.blue, self.purple, self.magenta]
}
pub fn is_neutral(&self) -> bool {
self.bands().iter().all(|b| *b == Hsl::default())
}
}
/// B&W mix (used when treatment = Bw): per-band luminance contribution −100..100.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct BwMix {
pub red: f64,
pub orange: f64,
pub yellow: f64,
pub green: f64,
pub aqua: f64,
pub blue: f64,
pub purple: f64,
pub magenta: f64,
}
impl BwMix {
pub fn bands(&self) -> [f64; 8] {
[self.red, self.orange, self.yellow, self.green, self.aqua, self.blue, self.purple, self.magenta]
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Wheel {
/// Hue in degrees 0..360.
pub hue: f64,
/// Saturation 0..100.
pub sat: f64,
/// Luminance −100..100.
pub lum: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ColorGrading {
pub shadows: Wheel,
pub midtones: Wheel,
pub highlights: Wheel,
pub global: Wheel,
/// 0..100, default 50.
pub blending: f64,
/// −100..100.
pub balance: f64,
}
impl Default for ColorGrading {
fn default() -> Self {
Self {
shadows: Wheel::default(),
midtones: Wheel::default(),
highlights: Wheel::default(),
global: Wheel::default(),
blending: 50.0,
balance: 0.0,
}
}
}
impl ColorGrading {
pub fn is_neutral(&self) -> bool {
[self.shadows, self.midtones, self.highlights, self.global].iter().all(|w| w.sat == 0.0 && w.lum == 0.0)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Effects {
pub texture: f64,
pub clarity: f64,
pub dehaze: f64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum VignetteStyle {
#[default]
HighlightPriority,
ColorPriority,
PaintOverlay,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Vignette {
pub amount: f64,
pub midpoint: f64,
pub roundness: f64,
pub feather: f64,
pub highlights: f64,
pub style: VignetteStyle,
}
impl Default for Vignette {
fn default() -> Self {
Self { amount: 0.0, midpoint: 50.0, roundness: 0.0, feather: 50.0, highlights: 0.0, style: VignetteStyle::HighlightPriority }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Grain {
pub amount: f64,
pub size: f64,
pub roughness: f64,
/// Seed for the procedural grain (stable per photo).
pub seed: u32,
}
impl Default for Grain {
fn default() -> Self {
Self { amount: 0.0, size: 25.0, roughness: 50.0, seed: 0 }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Detail {
pub sharpen_amount: f64,
pub sharpen_radius: f64,
pub sharpen_detail: f64,
pub sharpen_masking: f64,
pub nr_luminance: f64,
pub nr_detail: f64,
pub nr_contrast: f64,
pub nr_color: f64,
pub nr_color_detail: f64,
pub nr_color_smoothness: f64,
}
impl Default for Detail {
/// Lightroom's raw defaults are sharpening 40/1.0/25/0 and colour NR 25/50/50; we use neutral
/// values for rendered files and apply raw defaults at import time (`DevelopSettings::for_raw`).
fn default() -> Self {
Self {
sharpen_amount: 0.0,
sharpen_radius: 1.0,
sharpen_detail: 25.0,
sharpen_masking: 0.0,
nr_luminance: 0.0,
nr_detail: 50.0,
nr_contrast: 0.0,
nr_color: 0.0,
nr_color_detail: 50.0,
nr_color_smoothness: 50.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Optics {
pub remove_ca: bool,
pub lens_profile: bool,
pub profile_distortion: f64,
pub profile_vignetting: f64,
pub defringe_purple_amount: f64,
pub defringe_purple_hue_lo: f64,
pub defringe_purple_hue_hi: f64,
pub defringe_green_amount: f64,
pub defringe_green_hue_lo: f64,
pub defringe_green_hue_hi: f64,
pub distortion: f64,
pub vignetting: f64,
pub vignetting_midpoint: f64,
}
impl Default for Optics {
fn default() -> Self {
Self {
remove_ca: false,
lens_profile: false,
profile_distortion: 100.0,
profile_vignetting: 100.0,
defringe_purple_amount: 0.0,
defringe_purple_hue_lo: 30.0,
defringe_purple_hue_hi: 70.0,
defringe_green_amount: 0.0,
defringe_green_hue_lo: 40.0,
defringe_green_hue_hi: 60.0,
distortion: 0.0,
vignetting: 0.0,
vignetting_midpoint: 50.0,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Upright {
#[default]
Off,
Auto,
Guided,
Level,
Vertical,
Full,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Geometry {
pub upright: Upright,
/// Guided Upright guides (normalized image coords), up to 4 line segments.
pub guides: Vec<(Point, Point)>,
pub vertical: f64,
pub horizontal: f64,
pub rotate: f64,
pub aspect: f64,
pub scale: f64,
pub offset_x: f64,
pub offset_y: f64,
pub constrain_crop: bool,
}
impl Default for Geometry {
fn default() -> Self {
Self {
upright: Upright::Off,
guides: Vec::new(),
vertical: 0.0,
horizontal: 0.0,
rotate: 0.0,
aspect: 0.0,
scale: 100.0,
offset_x: 0.0,
offset_y: 0.0,
constrain_crop: false,
}
}
}
impl Geometry {
pub fn is_identity(&self) -> bool {
self.upright == Upright::Off
&& self.vertical == 0.0
&& self.horizontal == 0.0
&& self.rotate == 0.0
&& self.aspect == 0.0
&& self.scale == 100.0
&& self.offset_x == 0.0
&& self.offset_y == 0.0
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Crop {
pub geometry: CropGeometry,
/// Locked aspect (w, h); None = free / original.
pub aspect: Option<(u32, u32)>,
pub flip_h: bool,
pub flip_v: bool,
}
// ---------------------------------------------------------------------------------------------
// Masks and local adjustments
/// The sliders available inside a mask (Lightroom's local adjustments).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct LocalAdjustments {
pub temp: f64,
pub tint: f64,
pub exposure: f64,
pub contrast: f64,
pub highlights: f64,
pub shadows: f64,
pub whites: f64,
pub blacks: f64,
pub texture: f64,
pub clarity: f64,
pub dehaze: f64,
pub hue: f64,
pub saturation: f64,
pub sharpness: f64,
pub noise: f64,
pub moire: f64,
pub defringe: f64,
/// Colour overlay (hue degrees, saturation 0..100); sat 0 = none.
pub color_hue: f64,
pub color_sat: f64,
/// Local vignette/grain are not in Lightroom; we keep the struct extensible.
pub amount: f64,
}
impl Default for LocalAdjustments {
fn default() -> Self {
Self {
temp: 0.0,
tint: 0.0,
exposure: 0.0,
contrast: 0.0,
highlights: 0.0,
shadows: 0.0,
whites: 0.0,
blacks: 0.0,
texture: 0.0,
clarity: 0.0,
dehaze: 0.0,
hue: 0.0,
saturation: 0.0,
sharpness: 0.0,
noise: 0.0,
moire: 0.0,
defringe: 0.0,
color_hue: 0.0,
color_sat: 0.0,
amount: 100.0,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MaskOp {
#[default]
Add,
Subtract,
Intersect,
}
/// One brush dab sequence. Coordinates normalized to the uncropped oriented image.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct BrushStroke {
pub points: Vec<Point>,
/// 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 erase: bool,
pub auto_mask: bool,
}
impl Default for BrushStroke {
fn default() -> Self {
Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum MaskShape {
Brush {
strokes: Vec<BrushStroke>,
},
/// Linear gradient: full effect at `start`, fading to none at `end` (normalized coords).
Linear {
start: Point,
end: Point,
},
/// Radial gradient: ellipse centre, radii (normalized to the long edge), rotation (deg), feather 0..100.
Radial {
center: Point,
rx: f64,
ry: f64,
angle: f64,
feather: f64,
invert: bool,
},
/// Colour range: sampled OkLab colours with a refine amount 0..100.
ColorRange {
samples: Vec<[f64; 3]>,
refine: f64,
},
/// Luminance range (0..1 bounds with feathered falloff 0..1).
LuminanceRange {
lo: f64,
hi: f64,
lo_feather: f64,
hi_feather: f64,
},
DepthRange {
lo: f64,
hi: f64,
feather: f64,
},
/// AI / automatic selections, evaluated by a segmenter.
Subject,
Sky,
Background,
Object {
hint: Vec<Point>,
},
People {
person: u32,
parts: Vec<String>,
},
Landscape {
class: String,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MaskComponent {
#[serde(default)]
pub op: MaskOp,
#[serde(default)]
pub invert: bool,
pub shape: MaskShape,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Mask {
pub id: u32,
pub name: String,
pub visible: bool,
pub invert: bool,
pub components: Vec<MaskComponent>,
pub adjust: LocalAdjustments,
}
impl Default for Mask {
fn default() -> Self {
Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default() }
}
}
// ---------------------------------------------------------------------------------------------
// Heal / remove / red eye
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SpotMode {
#[default]
Remove,
Heal,
Clone,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Spot {
pub mode: SpotMode,
/// Target: a single point (circle spot) or a brushed path. Normalized coords.
pub points: Vec<Point>,
/// Radius as a fraction of the long edge.
pub size: f64,
pub feather: f64,
pub opacity: f64,
/// Source offset (normalized) from the target; None = auto (content-aware remove).
pub source_offset: Option<Point>,
}
impl Default for Spot {
fn default() -> Self {
Self { mode: SpotMode::Remove, points: Vec::new(), size: 0.02, feather: 50.0, opacity: 100.0, source_offset: None }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct RedEye {
pub center: Point,
pub rx: f64,
pub ry: f64,
pub pupil_size: f64,
pub darken: f64,
pub pet: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct LensBlur {
pub enabled: bool,
pub amount: f64,
pub focal_distance: f64,
pub focal_range: f64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Enhance {
pub denoise: f64,
pub raw_details: bool,
pub super_resolution: bool,
}