mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 18:18:12 -06:00
591 lines
29 KiB
Rust
591 lines
29 KiB
Rust
//! 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`).
|
||
//!
|
||
//! Settings that come in lists (Point Color samples) are *indexed* controls: one template spec in
|
||
//! [`INDEXED`] (`pointColor.hueShift`) addresses every element as `pointColor.<i>.hueShift`.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::settings::DevelopSettings;
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum Section {
|
||
Light,
|
||
Curve,
|
||
Color,
|
||
Mixer,
|
||
BwMix,
|
||
Grading,
|
||
Effects,
|
||
Vignette,
|
||
Grain,
|
||
Detail,
|
||
Optics,
|
||
Geometry,
|
||
Profile,
|
||
Calibration,
|
||
PointColor,
|
||
RedEye,
|
||
}
|
||
|
||
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",
|
||
Section::Calibration => "Calibration",
|
||
Section::PointColor => "Point Color",
|
||
Section::RedEye => "Red Eye",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 }
|
||
}
|
||
/// Where `v` sits on the slider track, from 0 (left end) to 1 (right end). Linear, except for the Kelvin
|
||
/// temperature, which follows Lightroom's scale: 2000–7000 K fill the left half evenly (100 K per 1 %), and
|
||
/// the right half grows logarithmically up to 50000 K, so everyday temperatures aren't crowded into the
|
||
/// left sixth of the track. The relative temperature of rendered files (−100…+100, already in mireds) stays
|
||
/// linear.
|
||
pub fn position(&self, v: f64) -> f64 {
|
||
let v = self.clamp(v);
|
||
let p = match self.kelvin_scale() {
|
||
Some((centre, right)) if v > centre => 0.5 + 0.5 * (1.0 + (v - centre) / KELVIN_KNEE).ln() / right,
|
||
Some((centre, _)) => 0.5 * (v - self.min) / (centre - self.min),
|
||
None => (v - self.min) / (self.max - self.min).max(1e-9),
|
||
};
|
||
if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 }
|
||
}
|
||
/// The value at track position `p` (0…1): the inverse of [`ControlSpec::position`], not yet on the steps.
|
||
pub fn value_at(&self, p: f64) -> f64 {
|
||
let p = if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 };
|
||
let v = match self.kelvin_scale() {
|
||
Some(_) if p >= 1.0 => self.max,
|
||
Some((centre, right)) if p > 0.5 => centre + KELVIN_KNEE * (((2.0 * p - 1.0) * right).exp() - 1.0),
|
||
Some((centre, _)) => self.min + 2.0 * p * (centre - self.min),
|
||
None => self.min + p * (self.max - self.min),
|
||
};
|
||
self.clamp(v)
|
||
}
|
||
/// The Kelvin temperature's track centre and `ln` of its right half's span (see [`ControlSpec::position`]);
|
||
/// `None` for every other control, or a range the centre isn't inside.
|
||
fn kelvin_scale(&self) -> Option<(f64, f64)> {
|
||
let centre = KELVIN_CENTRE;
|
||
(self.id == "wb.temp" && self.min < centre && centre < self.max).then(|| (centre, (1.0 + (self.max - centre) / KELVIN_KNEE).ln()))
|
||
}
|
||
}
|
||
|
||
/// The Kelvin temperature at the middle of its track, as in Lightroom.
|
||
const KELVIN_CENTRE: f64 = 7000.0;
|
||
/// Where the right half of the Kelvin track turns logarithmic: 1000 K gives it close to the left half's slope
|
||
/// just past the centre (76 K against 100 K per 1 % of the track), so the thumb moves evenly across it.
|
||
const KELVIN_KNEE: f64 = 1000.0;
|
||
|
||
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), )*
|
||
_ => indexed_get(s, id),
|
||
}
|
||
}
|
||
|
||
/// 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 } )*
|
||
_ => indexed_set(s, id, v),
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
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;
|
||
"hdr.maxEv" => hdr.max_ev, "HDR Headroom", Light, 0, 5, 3, 0.1, 1, Plain;
|
||
"hdr.sdrBrightness" => hdr.sdr_brightness, "SDR Brightness", Light, -100, 100, 0, 1, 0, Centered;
|
||
"hdr.sdrContrast" => hdr.sdr_contrast, "SDR Contrast", Light, -100, 100, 0, 1, 0, Centered;
|
||
"hdr.sdrHighlights" => hdr.sdr_highlights, "SDR Highlights", Light, -100, 100, 0, 1, 0, Centered;
|
||
"hdr.sdrShadows" => hdr.sdr_shadows, "SDR Shadows", Light, -100, 100, 0, 1, 0, Centered;
|
||
"hdr.sdrWhites" => hdr.sdr_whites, "SDR Whites", Light, -100, 100, 0, 1, 0, Centered;
|
||
"hdr.sdrClarity" => hdr.sdr_clarity, "SDR Clarity", 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;
|
||
"curve.refineSaturation" => curve.refine_saturation, "Refine Saturation", Curve, 0, 100, 100, 1, 0, Gradient { from: "#7a7a7a", to: "#d85a3a" };
|
||
"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;
|
||
"enhance.denoise" => enhance.denoise, "Amount", Detail, 0, 100, 0, 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.defringePurpleHueLo" => optics.defringe_purple_hue_lo, "Purple Hue Low", Optics, 0, 90, 30, 1, 0, Gradient { from: "#6040e0", to: "#d040b0" };
|
||
"optics.defringePurpleHueHi" => optics.defringe_purple_hue_hi, "Purple Hue High", Optics, 10, 100, 70, 1, 0, Gradient { from: "#6040e0", to: "#d040b0" };
|
||
"optics.defringeGreen" => optics.defringe_green_amount, "Green Amount", Optics, 0, 20, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#40c040" };
|
||
"optics.defringeGreenHueLo" => optics.defringe_green_hue_lo, "Green Hue Low", Optics, 0, 90, 40, 1, 0, Gradient { from: "#c0d030", to: "#30c0b0" };
|
||
"optics.defringeGreenHueHi" => optics.defringe_green_hue_hi, "Green Hue High", Optics, 10, 100, 60, 1, 0, Gradient { from: "#c0d030", to: "#30c0b0" };
|
||
"optics.caRed" => optics.ca_red, "Red/Cyan Fringe", Optics, -100, 100, 0, 1, 0, Gradient { from: "#30c0c0", to: "#e04040" };
|
||
"optics.caBlue" => optics.ca_blue, "Blue/Yellow Fringe", Optics, -100, 100, 0, 1, 0, Gradient { from: "#d0c030", to: "#4060e0" };
|
||
"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;
|
||
"calibration.shadowsTint" => calibration.shadows_tint, "Shadows Tint", Calibration, -100, 100, 0, 1, 0, Tint;
|
||
"calibration.redHue" => calibration.red_hue, "Red Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#e0306a", to: "#e08a30" };
|
||
"calibration.redSat" => calibration.red_sat, "Red Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#e03a3a" };
|
||
"calibration.greenHue" => calibration.green_hue, "Green Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#b0c030", to: "#30c08a" };
|
||
"calibration.greenSat" => calibration.green_sat, "Green Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#40b050" };
|
||
"calibration.blueHue" => calibration.blue_hue, "Blue Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#30a0d0", to: "#8040d0" };
|
||
"calibration.blueSat" => calibration.blue_sat, "Blue Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#3a60e0" };
|
||
"crop.angle" => crop.geometry.angle, "Straighten", Geometry, -45, 45, 0, 0.01, 2, Centered;
|
||
}
|
||
|
||
/// Template specs of the indexed controls (`<family>.<field>`; addressed as `<family>.<i>.<field>`).
|
||
pub static INDEXED: &[ControlSpec] = &[
|
||
ControlSpec {
|
||
id: "pointColor.hueShift",
|
||
label: "Hue",
|
||
section: Section::PointColor,
|
||
min: -100.0,
|
||
max: 100.0,
|
||
default: 0.0,
|
||
step: 1.0,
|
||
decimals: 0,
|
||
track: Rainbow,
|
||
},
|
||
ControlSpec {
|
||
id: "pointColor.satShift",
|
||
label: "Saturation",
|
||
section: Section::PointColor,
|
||
min: -100.0,
|
||
max: 100.0,
|
||
default: 0.0,
|
||
step: 1.0,
|
||
decimals: 0,
|
||
track: Gradient { from: "#7a7a7a", to: "#e04a3a" },
|
||
},
|
||
ControlSpec {
|
||
id: "pointColor.lumShift",
|
||
label: "Luminance",
|
||
section: Section::PointColor,
|
||
min: -100.0,
|
||
max: 100.0,
|
||
default: 0.0,
|
||
step: 1.0,
|
||
decimals: 0,
|
||
track: Gradient { from: "#202020", to: "#f0f0f0" },
|
||
},
|
||
ControlSpec {
|
||
id: "pointColor.variance",
|
||
label: "Variance",
|
||
section: Section::PointColor,
|
||
min: -100.0,
|
||
max: 100.0,
|
||
default: 0.0,
|
||
step: 1.0,
|
||
decimals: 0,
|
||
track: Centered,
|
||
},
|
||
ControlSpec {
|
||
id: "pointColor.range",
|
||
label: "Range",
|
||
section: Section::PointColor,
|
||
min: 0.0,
|
||
max: 100.0,
|
||
default: 50.0,
|
||
step: 1.0,
|
||
decimals: 0,
|
||
track: Plain,
|
||
},
|
||
ControlSpec {
|
||
id: "pointColor.hueRange",
|
||
label: "Hue Range",
|
||
section: Section::PointColor,
|
||
min: 0.0,
|
||
max: 100.0,
|
||
default: 50.0,
|
||
step: 1.0,
|
||
decimals: 0,
|
||
track: Rainbow,
|
||
},
|
||
ControlSpec {
|
||
id: "pointColor.satRange",
|
||
label: "Saturation Range",
|
||
section: Section::PointColor,
|
||
min: 0.0,
|
||
max: 100.0,
|
||
default: 50.0,
|
||
step: 1.0,
|
||
decimals: 0,
|
||
track: Gradient { from: "#7a7a7a", to: "#e04a3a" },
|
||
},
|
||
ControlSpec {
|
||
id: "pointColor.lumRange",
|
||
label: "Luminance Range",
|
||
section: Section::PointColor,
|
||
min: 0.0,
|
||
max: 100.0,
|
||
default: 50.0,
|
||
step: 1.0,
|
||
decimals: 0,
|
||
track: Gradient { from: "#202020", to: "#f0f0f0" },
|
||
},
|
||
ControlSpec {
|
||
id: "redEye.pupilSize",
|
||
label: "Pupil Size",
|
||
section: Section::RedEye,
|
||
min: 0.0,
|
||
max: 100.0,
|
||
default: 50.0,
|
||
step: 1.0,
|
||
decimals: 0,
|
||
track: Plain,
|
||
},
|
||
ControlSpec {
|
||
id: "redEye.darken",
|
||
label: "Darken",
|
||
section: Section::RedEye,
|
||
min: 0.0,
|
||
max: 100.0,
|
||
default: 50.0,
|
||
step: 1.0,
|
||
decimals: 0,
|
||
track: Gradient { from: "#d0d0d0", to: "#101010" },
|
||
},
|
||
];
|
||
|
||
/// `pointColor.3.hueShift` → (`pointColor`, 3, `hueShift`).
|
||
fn split_indexed(id: &str) -> Option<(&str, usize, &str)> {
|
||
let mut it = id.splitn(3, '.');
|
||
let (fam, i, field) = (it.next()?, it.next()?, it.next()?);
|
||
Some((fam, i.parse().ok()?, field))
|
||
}
|
||
|
||
fn indexed_field<'a>(s: &'a mut DevelopSettings, id: &str) -> Option<&'a mut f64> {
|
||
let (fam, i, field) = split_indexed(id)?;
|
||
match fam {
|
||
"pointColor" => {
|
||
let p = s.point_colors.get_mut(i)?;
|
||
Some(match field {
|
||
"hueShift" => &mut p.hue_shift,
|
||
"satShift" => &mut p.sat_shift,
|
||
"lumShift" => &mut p.lum_shift,
|
||
"variance" => &mut p.variance,
|
||
"range" => &mut p.range,
|
||
"hueRange" => &mut p.hue_range,
|
||
"satRange" => &mut p.sat_range,
|
||
"lumRange" => &mut p.lum_range,
|
||
_ => return None,
|
||
})
|
||
}
|
||
"redEye" => {
|
||
let e = s.red_eye.get_mut(i)?;
|
||
Some(match field {
|
||
"pupilSize" => &mut e.pupil_size,
|
||
"darken" => &mut e.darken,
|
||
_ => return None,
|
||
})
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn indexed_get(s: &DevelopSettings, id: &str) -> Option<f64> {
|
||
// (a clone keeps one accessor for get and set; these lists are tiny)
|
||
let mut c = DevelopSettings { point_colors: s.point_colors.clone(), red_eye: s.red_eye.clone(), ..DevelopSettings::default() };
|
||
indexed_field(&mut c, id).map(|v| *v)
|
||
}
|
||
|
||
fn indexed_set(s: &mut DevelopSettings, id: &str, v: f64) -> bool {
|
||
match indexed_field(s, id) {
|
||
Some(f) => {
|
||
*f = v;
|
||
true
|
||
}
|
||
None => false,
|
||
}
|
||
}
|
||
|
||
/// The spec of a control id, including indexed ids (`pointColor.0.hueShift` → the
|
||
/// `pointColor.hueShift` template).
|
||
pub fn find(id: &str) -> Option<&'static ControlSpec> {
|
||
if let Some(c) = CONTROLS.iter().find(|c| c.id == id) {
|
||
return Some(c);
|
||
}
|
||
let (fam, _, field) = split_indexed(id)?;
|
||
INDEXED.iter().find(|c| c.id.strip_prefix(fam).and_then(|r| r.strip_prefix('.')) == Some(field))
|
||
}
|
||
|
||
/// Every indexed control that exists in `s` (id, template spec), e.g. `pointColor.0.hueShift`.
|
||
pub fn indexed_instances(s: &DevelopSettings) -> Vec<(String, &'static ControlSpec)> {
|
||
let mut out = Vec::new();
|
||
for spec in INDEXED {
|
||
let Some((fam, field)) = spec.id.split_once('.') else { continue };
|
||
let n = match fam {
|
||
"pointColor" => s.point_colors.len(),
|
||
"redEye" => s.red_eye.len(),
|
||
_ => 0,
|
||
};
|
||
for i in 0..n {
|
||
out.push((format!("{fam}.{i}.{field}"), spec));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
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 indexed_controls_address_list_elements() {
|
||
let mut s = DevelopSettings::default();
|
||
assert_eq!(get(&s, "pointColor.0.hueShift"), None);
|
||
assert!(!set(&mut s, "pointColor.0.hueShift", 10.0), "no sample yet");
|
||
s.point_colors.push(Default::default());
|
||
s.point_colors.push(Default::default());
|
||
assert!(set(&mut s, "pointColor.1.hueShift", 250.0));
|
||
assert_eq!(s.point_colors[1].hue_shift, 100.0, "clamped by the template spec");
|
||
assert_eq!(get(&s, "pointColor.1.range"), Some(50.0));
|
||
assert_eq!(find("pointColor.7.lumRange").map(|c| c.id), Some("pointColor.lumRange"));
|
||
assert!(find("pointColor.x.lumRange").is_none() && find("pointColor.0.nope").is_none());
|
||
let ids: Vec<String> = indexed_instances(&s).into_iter().map(|(id, _)| id).collect();
|
||
assert_eq!(ids.len(), 2 * INDEXED.iter().filter(|c| c.id.starts_with("pointColor.")).count());
|
||
for (id, spec) in indexed_instances(&s) {
|
||
assert!(get(&s, &id).is_some(), "{id}");
|
||
assert!(spec.min <= spec.default && spec.default <= spec.max);
|
||
}
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
/// The Kelvin temperature's track follows Lightroom's (positions measured in Lightroom Classic against
|
||
/// the linear Tint slider; the model is within 2 % of them); every other control stays linear.
|
||
#[test]
|
||
fn the_kelvin_track_is_lightrooms_and_others_stay_linear() {
|
||
let temp = find("wb.temp").unwrap();
|
||
for (k, lightroom, model) in [
|
||
(2000.0, 0.0, 0.0),
|
||
(3650.0, 0.167, 0.165),
|
||
(5400.0, 0.333, 0.34),
|
||
(7000.0, 0.5, 0.5),
|
||
(15000.0, 0.79, 0.79),
|
||
(20000.0, 0.853, 0.849),
|
||
(30000.0, 0.90, 0.92),
|
||
(50000.0, 1.0, 1.0),
|
||
] {
|
||
let p = temp.position(k);
|
||
assert!((p - model).abs() < 0.001, "{k} K at {p}, the model says {model}");
|
||
assert!((p - lightroom).abs() <= 0.021, "{k} K at {p}, Lightroom at {lightroom}");
|
||
}
|
||
// every 50 K step survives value → position → value, and the track never runs backwards
|
||
let mut last = -1.0;
|
||
for k in (2000..=50000).step_by(50).map(f64::from) {
|
||
let p = temp.position(k);
|
||
assert!(p > last, "{k} K");
|
||
last = p;
|
||
let back = (temp.value_at(p) / temp.step).round() * temp.step;
|
||
assert_eq!(back, k, "{k} K → {p} → {back}");
|
||
}
|
||
assert_eq!((temp.value_at(-1.0), temp.value_at(2.0), temp.value_at(f64::NAN)), (2000.0, 50000.0, 2000.0));
|
||
assert_eq!((temp.position(1.0), temp.position(1e9), temp.position(f64::NAN)), (0.0, 1.0, temp.position(temp.default)));
|
||
let exposure = find("light.exposure").unwrap();
|
||
for (v, p) in [(-5.0, 0.0), (-2.5, 0.25), (0.0, 0.5), (1.0, 0.6), (5.0, 1.0)] {
|
||
assert!((exposure.position(v) - p).abs() < 1e-12 && (exposure.value_at(p) - v).abs() < 1e-12, "{v}");
|
||
}
|
||
// the relative temperature of rendered files (its own spec, centred on 0) isn't a Kelvin scale
|
||
let relative = ControlSpec { id: "wb.tempRel", min: -100.0, max: 100.0, default: 0.0, step: 1.0, ..*temp };
|
||
assert_eq!((relative.position(-50.0), relative.value_at(0.75)), (0.25, 50.0));
|
||
// a Kelvin range that doesn't contain the centre falls back to linear rather than dividing by zero
|
||
let narrow = ControlSpec { min: 7000.0, max: 9000.0, ..*temp };
|
||
assert_eq!((narrow.position(8000.0), narrow.value_at(0.5)), (0.5, 8000.0));
|
||
}
|
||
}
|