mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 13:07:43 -06:00
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:
co-authored by
Claude Opus 5.5
parent
474e44c29e
commit
3f54765bd0
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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]}));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user