Skip to main content

agx/engine/
mod.rs

1//! Render engine: pipeline executor, parameter types, and the [`Engine`] entry point.
2
3use std::sync::Arc;
4
5use image::Rgb32FImage;
6use serde::{Deserialize, Serialize};
7
8/// CPU render pipeline (and future GPU pipeline behind the `gpu` feature).
9pub mod pipeline;
10
11/// GPU render pipeline using wgpu + WGSL compute shaders.
12#[cfg(feature = "gpu")]
13pub mod gpu;
14
15/// Pluggable pipeline stages (white balance, dehaze, denoise, per-pixel, detail, grain, vignette, color-space conversions).
16pub mod stages;
17
18/// Timing data for a single render pass. Only available when compiled
19/// with the `profiling` feature.
20#[cfg(feature = "profiling")]
21#[derive(Debug, Clone, serde::Serialize)]
22pub struct RenderProfile {
23    /// Per-stage timing in milliseconds, in execution order.
24    pub stages: Vec<(String, f64)>,
25    /// Total render duration in milliseconds.
26    pub total_ms: f64,
27}
28
29/// Result of a render operation. Contains the rendered image and optional
30/// profiling data (when compiled with the `profiling` feature).
31#[derive(Debug, Clone)]
32pub struct RenderResult {
33    /// Rendered image in linear sRGB float.
34    pub image: Rgb32FImage,
35    /// Per-stage profiling data, present when compiled with `profiling` feature.
36    #[cfg(feature = "profiling")]
37    pub profile: Option<RenderProfile>,
38}
39
40/// Color space declaration for pipeline stages.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ColorSpace {
43    /// Linear sRGB (scene-referred, pre-gamma).
44    LinearSrgb,
45    /// sRGB with gamma encoding (display-referred).
46    SrgbGamma,
47}
48
49/// Shared mutable context passed through pipeline stages.
50///
51/// Owns the pixel buffer and provides read-only access to parameters and LUT.
52/// The executor creates this once per render; each stage mutates `buf` in place.
53pub struct RenderContext<'a> {
54    /// Pixel buffer in row-major order: `buf[y * width + x] = [r, g, b]`.
55    pub buf: Vec<[f32; 3]>,
56    /// Image width in pixels.
57    pub width: u32,
58    /// Image height in pixels.
59    pub height: u32,
60    /// Render parameters (read-only for stages).
61    pub params: &'a Parameters,
62    /// Optional LUT applied during the per-pixel adjustment stage.
63    pub lut: Option<&'a crate::lut::Lut3D>,
64}
65
66/// A single stage in the render pipeline.
67///
68/// Stages are executed in a fixed order by the pipeline executor. Each stage
69/// declares its working color space; the executor auto-inserts conversions
70/// when adjacent stages disagree. The pipeline order is hardcoded and not
71/// configurable — this preserves preset compatibility (same params = same output).
72///
73/// Implementors should delegate pixel math to the `adjust` module.
74pub trait Stage: Send + Sync {
75    /// Human-readable name, used in profiling output.
76    fn name(&self) -> &'static str;
77
78    /// Color space this stage expects its input buffer in.
79    fn input_color_space(&self) -> ColorSpace;
80
81    /// Color space this stage produces in the output buffer.
82    fn output_color_space(&self) -> ColorSpace;
83
84    /// Whether this stage has any effect given current params.
85    /// Returning false lets the executor skip the stage entirely.
86    fn is_active(&self, params: &Parameters) -> bool;
87
88    /// Precompute loop-invariant data from params.
89    /// Called once per render before `process()`.
90    fn prepare(&mut self, params: &Parameters);
91
92    /// Process the pixel buffer in-place.
93    fn process(&self, ctx: &mut RenderContext) -> Result<(), crate::error::AgxError>;
94}
95
96/// Per-channel HSL adjustment (hue shift, saturation, luminance).
97///
98/// Ranges: hue -180.0 to +180.0 (degrees), saturation/luminance -100.0 to +100.0.
99#[cfg_attr(feature = "docgen", derive(schemars::JsonSchema))]
100#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
101pub struct HslChannel {
102    /// Hue shift in degrees (range: -180 to +180, default: 0).
103    #[serde(default)]
104    #[cfg_attr(feature = "docgen", schemars(range(min = -180.0, max = 180.0)))]
105    pub hue: f32,
106    /// Saturation adjustment (range: -100 to +100, default: 0).
107    #[serde(default)]
108    #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
109    pub saturation: f32,
110    /// Luminance adjustment (range: -100 to +100, default: 0).
111    #[serde(default)]
112    #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
113    pub luminance: f32,
114}
115
116/// HSL adjustments for all 8 color channels.
117///
118/// Channel order: Red (0deg), Orange (30deg), Yellow (60deg), Green (120deg),
119/// Aqua (180deg), Blue (240deg), Purple (270deg), Magenta (330deg).
120#[cfg_attr(feature = "docgen", derive(schemars::JsonSchema))]
121#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
122pub struct HslChannels {
123    /// HSL adjustment for the red channel (~0°).
124    #[serde(default)]
125    pub red: HslChannel,
126    /// HSL adjustment for the orange channel (~30°).
127    #[serde(default)]
128    pub orange: HslChannel,
129    /// HSL adjustment for the yellow channel (~60°).
130    #[serde(default)]
131    pub yellow: HslChannel,
132    /// HSL adjustment for the green channel (~120°).
133    #[serde(default)]
134    pub green: HslChannel,
135    /// HSL adjustment for the aqua channel (~180°).
136    #[serde(default)]
137    pub aqua: HslChannel,
138    /// HSL adjustment for the blue channel (~240°).
139    #[serde(default)]
140    pub blue: HslChannel,
141    /// HSL adjustment for the purple channel (~270°).
142    #[serde(default)]
143    pub purple: HslChannel,
144    /// HSL adjustment for the magenta channel (~330°).
145    #[serde(default)]
146    pub magenta: HslChannel,
147}
148
149impl HslChannels {
150    /// Returns true if all channels are at default (zero) values.
151    pub fn is_default(&self) -> bool {
152        *self == Self::default()
153    }
154
155    /// Extract hue shifts as an array ordered by channel index.
156    pub fn hue_shifts(&self) -> [f32; 8] {
157        [
158            self.red.hue,
159            self.orange.hue,
160            self.yellow.hue,
161            self.green.hue,
162            self.aqua.hue,
163            self.blue.hue,
164            self.purple.hue,
165            self.magenta.hue,
166        ]
167    }
168
169    /// Extract saturation shifts as an array ordered by channel index.
170    pub fn saturation_shifts(&self) -> [f32; 8] {
171        [
172            self.red.saturation,
173            self.orange.saturation,
174            self.yellow.saturation,
175            self.green.saturation,
176            self.aqua.saturation,
177            self.blue.saturation,
178            self.purple.saturation,
179            self.magenta.saturation,
180        ]
181    }
182
183    /// Extract luminance shifts as an array ordered by channel index.
184    pub fn luminance_shifts(&self) -> [f32; 8] {
185        [
186            self.red.luminance,
187            self.orange.luminance,
188            self.yellow.luminance,
189            self.green.luminance,
190            self.aqua.luminance,
191            self.blue.luminance,
192            self.purple.luminance,
193            self.magenta.luminance,
194        ]
195    }
196}
197
198/// Minimum supported exposure in stops.
199pub const EXPOSURE_MIN: f32 = -5.0;
200/// Maximum supported exposure in stops.
201pub const EXPOSURE_MAX: f32 = 5.0;
202/// Minimum supported value for tone sliders.
203pub const TONE_SLIDER_MIN: f32 = -100.0;
204/// Maximum supported value for tone sliders.
205pub const TONE_SLIDER_MAX: f32 = 100.0;
206/// Minimum supported HSL hue shift in degrees.
207pub const HSL_HUE_MIN: f32 = -180.0;
208/// Maximum supported HSL hue shift in degrees.
209pub const HSL_HUE_MAX: f32 = 180.0;
210/// Minimum supported HSL saturation/luminance slider value.
211pub const HSL_SL_MIN: f32 = -100.0;
212/// Maximum supported HSL saturation/luminance slider value.
213pub const HSL_SL_MAX: f32 = 100.0;
214/// Minimum supported vignette amount.
215pub const VIGNETTE_AMOUNT_MIN: f32 = -100.0;
216/// Maximum supported vignette amount.
217pub const VIGNETTE_AMOUNT_MAX: f32 = 100.0;
218/// Minimum supported color grading balance value.
219pub const CG_BALANCE_MIN: f32 = -100.0;
220/// Maximum supported color grading balance value.
221pub const CG_BALANCE_MAX: f32 = 100.0;
222/// Minimum supported color wheel hue in degrees.
223pub const CW_HUE_MIN: f32 = 0.0;
224/// Maximum supported color wheel hue in degrees.
225pub const CW_HUE_MAX: f32 = 360.0;
226/// Minimum supported color wheel saturation.
227pub const CW_SATURATION_MIN: f32 = 0.0;
228/// Maximum supported color wheel saturation.
229pub const CW_SATURATION_MAX: f32 = 100.0;
230/// Minimum supported color wheel luminance.
231pub const CW_LUMINANCE_MIN: f32 = -100.0;
232/// Maximum supported color wheel luminance.
233pub const CW_LUMINANCE_MAX: f32 = 100.0;
234
235/// Vignette adjustment parameters.
236///
237/// Darkens or brightens image edges. Amount range: -100 to +100. 0 = no effect.
238#[cfg_attr(feature = "docgen", derive(schemars::JsonSchema))]
239#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
240pub struct VignetteParams {
241    /// Vignette darkening (negative) or brightening (positive) amount (range: -100 to +100, default: 0).
242    #[serde(default)]
243    #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
244    pub amount: f32,
245    /// Vignette shape (circle, oval, or rectangle).
246    #[serde(default)]
247    pub shape: crate::adjust::VignetteShape,
248}
249
250impl VignetteParams {
251    /// Returns `true` when all fields are at their default (neutral) values.
252    pub fn is_default(&self) -> bool {
253        self.amount == 0.0 && self.shape == crate::adjust::VignetteShape::default()
254    }
255}
256
257/// All adjustment parameters for the rendering engine.
258///
259/// Defaults to neutral (no change) for all values.
260#[cfg_attr(feature = "docgen", derive(schemars::JsonSchema))]
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262pub struct Parameters {
263    /// Exposure in stops, range -5.0 to +5.0
264    #[cfg_attr(feature = "docgen", schemars(range(min = -5.0, max = 5.0)))]
265    pub exposure: f32,
266    /// Contrast, range -100 to +100
267    #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
268    pub contrast: f32,
269    /// Highlights, range -100 to +100
270    #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
271    pub highlights: f32,
272    /// Shadows, range -100 to +100
273    #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
274    pub shadows: f32,
275    /// Whites, range -100 to +100
276    #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
277    pub whites: f32,
278    /// Blacks, range -100 to +100
279    #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
280    pub blacks: f32,
281    /// White balance temperature shift
282    pub temperature: f32,
283    /// White balance tint shift (green/magenta)
284    pub tint: f32,
285    /// Per-channel HSL adjustments
286    #[serde(default)]
287    pub hsl: HslChannels,
288    /// Creative vignette (edge darkening/brightening)
289    #[serde(default)]
290    pub vignette: VignetteParams,
291    /// 3-way color grading (shadows/midtones/highlights/global wheels + balance)
292    #[serde(default)]
293    pub color_grading: crate::adjust::ColorGradingParams,
294    /// 5-channel tone curves (RGB, luma, R, G, B)
295    #[serde(default)]
296    pub tone_curve: crate::adjust::ToneCurveParams,
297    /// Detail pass: sharpening, clarity, texture
298    #[serde(default)]
299    pub detail: crate::adjust::DetailParams,
300    /// Dehaze: atmospheric haze removal/addition
301    #[serde(default)]
302    pub dehaze: crate::adjust::DehazeParams,
303    /// Noise reduction: luminance, color, and detail preservation
304    #[serde(default)]
305    pub noise_reduction: crate::adjust::NoiseReductionParams,
306    /// Film grain simulation
307    #[serde(default)]
308    pub grain: crate::adjust::GrainParams,
309}
310
311impl Default for Parameters {
312    fn default() -> Self {
313        Self {
314            exposure: 0.0,
315            contrast: 0.0,
316            highlights: 0.0,
317            shadows: 0.0,
318            whites: 0.0,
319            blacks: 0.0,
320            temperature: 0.0,
321            tint: 0.0,
322            hsl: HslChannels::default(),
323            vignette: VignetteParams::default(),
324            color_grading: crate::adjust::ColorGradingParams::default(),
325            tone_curve: crate::adjust::ToneCurveParams::default(),
326            detail: crate::adjust::DetailParams::default(),
327            dehaze: crate::adjust::DehazeParams::default(),
328            noise_reduction: crate::adjust::NoiseReductionParams::default(),
329            grain: crate::adjust::GrainParams::default(),
330        }
331    }
332}
333
334/// Optional, mergeable form of [`HslChannel`] used during preset deserialization.
335///
336/// See [`HslChannel`] for field semantics.
337#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
338pub struct PartialHslChannel {
339    /// See [`HslChannel::hue`].
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub hue: Option<f32>,
342    /// See [`HslChannel::saturation`].
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub saturation: Option<f32>,
345    /// See [`HslChannel::luminance`].
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub luminance: Option<f32>,
348}
349
350impl PartialHslChannel {
351    /// Merge overlay on top of self (last-write-wins).
352    pub fn merge(&self, overlay: &Self) -> Self {
353        Self {
354            hue: overlay.hue.or(self.hue),
355            saturation: overlay.saturation.or(self.saturation),
356            luminance: overlay.luminance.or(self.luminance),
357        }
358    }
359
360    /// Convert to concrete HslChannel. None fields become 0.0.
361    pub fn materialize(&self) -> HslChannel {
362        HslChannel {
363            hue: self.hue.unwrap_or(0.0),
364            saturation: self.saturation.unwrap_or(0.0),
365            luminance: self.luminance.unwrap_or(0.0),
366        }
367    }
368}
369
370impl From<&HslChannel> for PartialHslChannel {
371    fn from(ch: &HslChannel) -> Self {
372        Self {
373            hue: Some(ch.hue),
374            saturation: Some(ch.saturation),
375            luminance: Some(ch.luminance),
376        }
377    }
378}
379
380/// Optional, mergeable form of [`HslChannels`] used during preset deserialization.
381///
382/// See [`HslChannels`] for field semantics.
383#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
384pub struct PartialHslChannels {
385    /// See [`HslChannels::red`].
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub red: Option<PartialHslChannel>,
388    /// See [`HslChannels::orange`].
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub orange: Option<PartialHslChannel>,
391    /// See [`HslChannels::yellow`].
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub yellow: Option<PartialHslChannel>,
394    /// See [`HslChannels::green`].
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub green: Option<PartialHslChannel>,
397    /// See [`HslChannels::aqua`].
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub aqua: Option<PartialHslChannel>,
400    /// See [`HslChannels::blue`].
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub blue: Option<PartialHslChannel>,
403    /// See [`HslChannels::purple`].
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub purple: Option<PartialHslChannel>,
406    /// See [`HslChannels::magenta`].
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub magenta: Option<PartialHslChannel>,
409}
410
411impl PartialHslChannels {
412    fn merge_channel(
413        base: &Option<PartialHslChannel>,
414        overlay: &Option<PartialHslChannel>,
415    ) -> Option<PartialHslChannel> {
416        match (base, overlay) {
417            (None, None) => None,
418            (Some(b), None) => Some(b.clone()),
419            (None, Some(o)) => Some(o.clone()),
420            (Some(b), Some(o)) => Some(b.merge(o)),
421        }
422    }
423
424    /// Merge overlay on top of self (last-write-wins per field).
425    pub fn merge(&self, overlay: &Self) -> Self {
426        Self {
427            red: Self::merge_channel(&self.red, &overlay.red),
428            orange: Self::merge_channel(&self.orange, &overlay.orange),
429            yellow: Self::merge_channel(&self.yellow, &overlay.yellow),
430            green: Self::merge_channel(&self.green, &overlay.green),
431            aqua: Self::merge_channel(&self.aqua, &overlay.aqua),
432            blue: Self::merge_channel(&self.blue, &overlay.blue),
433            purple: Self::merge_channel(&self.purple, &overlay.purple),
434            magenta: Self::merge_channel(&self.magenta, &overlay.magenta),
435        }
436    }
437
438    /// Convert to concrete HslChannels. None channels/fields become default (0.0).
439    pub fn materialize(&self) -> HslChannels {
440        HslChannels {
441            red: self
442                .red
443                .as_ref()
444                .map(|c| c.materialize())
445                .unwrap_or_default(),
446            orange: self
447                .orange
448                .as_ref()
449                .map(|c| c.materialize())
450                .unwrap_or_default(),
451            yellow: self
452                .yellow
453                .as_ref()
454                .map(|c| c.materialize())
455                .unwrap_or_default(),
456            green: self
457                .green
458                .as_ref()
459                .map(|c| c.materialize())
460                .unwrap_or_default(),
461            aqua: self
462                .aqua
463                .as_ref()
464                .map(|c| c.materialize())
465                .unwrap_or_default(),
466            blue: self
467                .blue
468                .as_ref()
469                .map(|c| c.materialize())
470                .unwrap_or_default(),
471            purple: self
472                .purple
473                .as_ref()
474                .map(|c| c.materialize())
475                .unwrap_or_default(),
476            magenta: self
477                .magenta
478                .as_ref()
479                .map(|c| c.materialize())
480                .unwrap_or_default(),
481        }
482    }
483}
484
485impl From<&HslChannels> for PartialHslChannels {
486    fn from(hsl: &HslChannels) -> Self {
487        Self {
488            red: Some(PartialHslChannel::from(&hsl.red)),
489            orange: Some(PartialHslChannel::from(&hsl.orange)),
490            yellow: Some(PartialHslChannel::from(&hsl.yellow)),
491            green: Some(PartialHslChannel::from(&hsl.green)),
492            aqua: Some(PartialHslChannel::from(&hsl.aqua)),
493            blue: Some(PartialHslChannel::from(&hsl.blue)),
494            purple: Some(PartialHslChannel::from(&hsl.purple)),
495            magenta: Some(PartialHslChannel::from(&hsl.magenta)),
496        }
497    }
498}
499
500/// Optional, mergeable form of [`VignetteParams`] used during preset deserialization.
501///
502/// See [`VignetteParams`] for field semantics.
503#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
504pub struct PartialVignetteParams {
505    /// See [`VignetteParams::amount`].
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub amount: Option<f32>,
508    /// See [`VignetteParams::shape`].
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub shape: Option<crate::adjust::VignetteShape>,
511}
512
513impl PartialVignetteParams {
514    /// Merge overlay on top of self (last-write-wins).
515    pub fn merge(&self, overlay: &Self) -> Self {
516        Self {
517            amount: overlay.amount.or(self.amount),
518            shape: overlay.shape.or(self.shape),
519        }
520    }
521
522    /// Convert to concrete VignetteParams. None fields become defaults.
523    pub fn materialize(&self) -> VignetteParams {
524        VignetteParams {
525            amount: self.amount.unwrap_or(0.0),
526            shape: self.shape.unwrap_or_default(),
527        }
528    }
529}
530
531impl From<&VignetteParams> for PartialVignetteParams {
532    fn from(v: &VignetteParams) -> Self {
533        Self {
534            amount: Some(v.amount),
535            shape: Some(v.shape),
536        }
537    }
538}
539
540/// Optional, mergeable form of [`crate::adjust::ColorWheel`] used during preset deserialization.
541///
542/// See [`crate::adjust::ColorWheel`] for field semantics.
543#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
544pub struct PartialColorWheel {
545    /// See [`ColorWheel::hue`](crate::adjust::ColorWheel::hue).
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub hue: Option<f32>,
548    /// See [`ColorWheel::saturation`](crate::adjust::ColorWheel::saturation).
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub saturation: Option<f32>,
551    /// See [`ColorWheel::luminance`](crate::adjust::ColorWheel::luminance).
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub luminance: Option<f32>,
554}
555
556impl PartialColorWheel {
557    /// Merge overlay on top of self (last-write-wins).
558    pub fn merge(&self, overlay: &Self) -> Self {
559        Self {
560            hue: overlay.hue.or(self.hue),
561            saturation: overlay.saturation.or(self.saturation),
562            luminance: overlay.luminance.or(self.luminance),
563        }
564    }
565
566    /// Convert to concrete ColorWheel. None fields become 0.0.
567    pub fn materialize(&self) -> crate::adjust::ColorWheel {
568        crate::adjust::ColorWheel {
569            hue: self.hue.unwrap_or(0.0),
570            saturation: self.saturation.unwrap_or(0.0),
571            luminance: self.luminance.unwrap_or(0.0),
572        }
573    }
574}
575
576impl From<&crate::adjust::ColorWheel> for PartialColorWheel {
577    fn from(w: &crate::adjust::ColorWheel) -> Self {
578        Self {
579            hue: Some(w.hue),
580            saturation: Some(w.saturation),
581            luminance: Some(w.luminance),
582        }
583    }
584}
585
586/// Optional, mergeable form of [`crate::adjust::ColorGradingParams`] used during preset deserialization.
587///
588/// See [`crate::adjust::ColorGradingParams`] for field semantics.
589#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
590pub struct PartialColorGradingParams {
591    /// See [`ColorGradingParams::shadows`](crate::adjust::ColorGradingParams::shadows).
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub shadows: Option<PartialColorWheel>,
594    /// See [`ColorGradingParams::midtones`](crate::adjust::ColorGradingParams::midtones).
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub midtones: Option<PartialColorWheel>,
597    /// See [`ColorGradingParams::highlights`](crate::adjust::ColorGradingParams::highlights).
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub highlights: Option<PartialColorWheel>,
600    /// See [`ColorGradingParams::global`](crate::adjust::ColorGradingParams::global).
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub global: Option<PartialColorWheel>,
603    /// See [`ColorGradingParams::balance`](crate::adjust::ColorGradingParams::balance).
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub balance: Option<f32>,
606}
607
608impl PartialColorGradingParams {
609    fn merge_wheel(
610        base: &Option<PartialColorWheel>,
611        overlay: &Option<PartialColorWheel>,
612    ) -> Option<PartialColorWheel> {
613        match (base, overlay) {
614            (None, None) => None,
615            (Some(b), None) => Some(b.clone()),
616            (None, Some(o)) => Some(o.clone()),
617            (Some(b), Some(o)) => Some(b.merge(o)),
618        }
619    }
620
621    /// Merge overlay on top of self (last-write-wins per field).
622    pub fn merge(&self, overlay: &Self) -> Self {
623        Self {
624            shadows: Self::merge_wheel(&self.shadows, &overlay.shadows),
625            midtones: Self::merge_wheel(&self.midtones, &overlay.midtones),
626            highlights: Self::merge_wheel(&self.highlights, &overlay.highlights),
627            global: Self::merge_wheel(&self.global, &overlay.global),
628            balance: overlay.balance.or(self.balance),
629        }
630    }
631
632    /// Convert to concrete ColorGradingParams. None fields become defaults.
633    pub fn materialize(&self) -> crate::adjust::ColorGradingParams {
634        crate::adjust::ColorGradingParams {
635            shadows: self
636                .shadows
637                .as_ref()
638                .map(|w| w.materialize())
639                .unwrap_or_default(),
640            midtones: self
641                .midtones
642                .as_ref()
643                .map(|w| w.materialize())
644                .unwrap_or_default(),
645            highlights: self
646                .highlights
647                .as_ref()
648                .map(|w| w.materialize())
649                .unwrap_or_default(),
650            global: self
651                .global
652                .as_ref()
653                .map(|w| w.materialize())
654                .unwrap_or_default(),
655            balance: self.balance.unwrap_or(0.0),
656        }
657    }
658}
659
660impl From<&crate::adjust::ColorGradingParams> for PartialColorGradingParams {
661    fn from(cg: &crate::adjust::ColorGradingParams) -> Self {
662        Self {
663            shadows: Some(PartialColorWheel::from(&cg.shadows)),
664            midtones: Some(PartialColorWheel::from(&cg.midtones)),
665            highlights: Some(PartialColorWheel::from(&cg.highlights)),
666            global: Some(PartialColorWheel::from(&cg.global)),
667            balance: Some(cg.balance),
668        }
669    }
670}
671
672// --- Partial Tone Curve Types ---
673
674/// Optional, mergeable form of [`crate::adjust::ToneCurve`] used during preset deserialization.
675///
676/// See [`crate::adjust::ToneCurve`] for field semantics.
677#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
678pub struct PartialToneCurve {
679    /// See [`ToneCurve::points`](crate::adjust::ToneCurve::points).
680    pub points: Option<Vec<(f32, f32)>>,
681}
682
683impl PartialToneCurve {
684    /// Merge `overlay` on top of `self`. Last-write-wins per field.
685    pub fn merge(&self, overlay: &Self) -> Self {
686        Self {
687            points: overlay.points.clone().or_else(|| self.points.clone()),
688        }
689    }
690
691    /// Materialize this partial into a concrete [`ToneCurve`](crate::adjust::ToneCurve). Unset fields become defaults.
692    pub fn materialize(&self) -> crate::adjust::ToneCurve {
693        crate::adjust::ToneCurve {
694            points: self
695                .points
696                .clone()
697                .unwrap_or_else(|| vec![(0.0, 0.0), (1.0, 1.0)]),
698        }
699    }
700}
701
702impl From<&crate::adjust::ToneCurve> for PartialToneCurve {
703    fn from(tc: &crate::adjust::ToneCurve) -> Self {
704        Self {
705            points: Some(tc.points.clone()),
706        }
707    }
708}
709
710/// Optional, mergeable form of [`crate::adjust::ToneCurveParams`] used during preset deserialization.
711///
712/// See [`crate::adjust::ToneCurveParams`] for field semantics.
713#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
714pub struct PartialToneCurveParams {
715    /// See [`ToneCurveParams::rgb`](crate::adjust::ToneCurveParams::rgb).
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub rgb: Option<PartialToneCurve>,
718    /// See [`ToneCurveParams::luma`](crate::adjust::ToneCurveParams::luma).
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub luma: Option<PartialToneCurve>,
721    /// See [`ToneCurveParams::red`](crate::adjust::ToneCurveParams::red).
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub red: Option<PartialToneCurve>,
724    /// See [`ToneCurveParams::green`](crate::adjust::ToneCurveParams::green).
725    #[serde(default, skip_serializing_if = "Option::is_none")]
726    pub green: Option<PartialToneCurve>,
727    /// See [`ToneCurveParams::blue`](crate::adjust::ToneCurveParams::blue).
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub blue: Option<PartialToneCurve>,
730}
731
732impl PartialToneCurveParams {
733    /// Merge `overlay` on top of `self`. Last-write-wins per field.
734    pub fn merge(&self, overlay: &Self) -> Self {
735        Self {
736            rgb: merge_opt_tone_curve(&self.rgb, &overlay.rgb),
737            luma: merge_opt_tone_curve(&self.luma, &overlay.luma),
738            red: merge_opt_tone_curve(&self.red, &overlay.red),
739            green: merge_opt_tone_curve(&self.green, &overlay.green),
740            blue: merge_opt_tone_curve(&self.blue, &overlay.blue),
741        }
742    }
743
744    /// Materialize this partial into a concrete [`ToneCurveParams`](crate::adjust::ToneCurveParams). Unset fields become defaults.
745    pub fn materialize(&self) -> crate::adjust::ToneCurveParams {
746        crate::adjust::ToneCurveParams {
747            rgb: self
748                .rgb
749                .as_ref()
750                .map(|c| c.materialize())
751                .unwrap_or_default(),
752            luma: self
753                .luma
754                .as_ref()
755                .map(|c| c.materialize())
756                .unwrap_or_default(),
757            red: self
758                .red
759                .as_ref()
760                .map(|c| c.materialize())
761                .unwrap_or_default(),
762            green: self
763                .green
764                .as_ref()
765                .map(|c| c.materialize())
766                .unwrap_or_default(),
767            blue: self
768                .blue
769                .as_ref()
770                .map(|c| c.materialize())
771                .unwrap_or_default(),
772        }
773    }
774}
775
776fn merge_opt_tone_curve(
777    base: &Option<PartialToneCurve>,
778    overlay: &Option<PartialToneCurve>,
779) -> Option<PartialToneCurve> {
780    match (base, overlay) {
781        (None, None) => None,
782        (Some(b), None) => Some(b.clone()),
783        (None, Some(o)) => Some(o.clone()),
784        (Some(b), Some(o)) => Some(b.merge(o)),
785    }
786}
787
788impl From<&crate::adjust::ToneCurveParams> for PartialToneCurveParams {
789    fn from(params: &crate::adjust::ToneCurveParams) -> Self {
790        Self {
791            rgb: Some(PartialToneCurve::from(&params.rgb)),
792            luma: Some(PartialToneCurve::from(&params.luma)),
793            red: Some(PartialToneCurve::from(&params.red)),
794            green: Some(PartialToneCurve::from(&params.green)),
795            blue: Some(PartialToneCurve::from(&params.blue)),
796        }
797    }
798}
799
800/// Optional, mergeable form of [`crate::adjust::SharpeningParams`] used during preset deserialization.
801///
802/// See [`crate::adjust::SharpeningParams`] for field semantics.
803#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
804pub struct PartialSharpeningParams {
805    /// See [`SharpeningParams::amount`](crate::adjust::SharpeningParams::amount).
806    #[serde(default, skip_serializing_if = "Option::is_none")]
807    pub amount: Option<f32>,
808    /// See [`SharpeningParams::radius`](crate::adjust::SharpeningParams::radius).
809    #[serde(default, skip_serializing_if = "Option::is_none")]
810    pub radius: Option<f32>,
811    /// See [`SharpeningParams::threshold`](crate::adjust::SharpeningParams::threshold).
812    #[serde(default, skip_serializing_if = "Option::is_none")]
813    pub threshold: Option<f32>,
814    /// See [`SharpeningParams::masking`](crate::adjust::SharpeningParams::masking).
815    #[serde(default, skip_serializing_if = "Option::is_none")]
816    pub masking: Option<f32>,
817}
818
819impl PartialSharpeningParams {
820    /// Merge `overlay` on top of `self`. Last-write-wins per field.
821    pub fn merge(&self, overlay: &Self) -> Self {
822        Self {
823            amount: overlay.amount.or(self.amount),
824            radius: overlay.radius.or(self.radius),
825            threshold: overlay.threshold.or(self.threshold),
826            masking: overlay.masking.or(self.masking),
827        }
828    }
829
830    /// Materialize this partial into a concrete [`SharpeningParams`](crate::adjust::SharpeningParams). Unset fields become defaults.
831    pub fn materialize(&self) -> crate::adjust::SharpeningParams {
832        let d = crate::adjust::SharpeningParams::default();
833        crate::adjust::SharpeningParams {
834            amount: self.amount.unwrap_or(d.amount),
835            radius: self.radius.unwrap_or(d.radius),
836            threshold: self.threshold.unwrap_or(d.threshold),
837            masking: self.masking.unwrap_or(d.masking),
838        }
839    }
840}
841
842impl From<&crate::adjust::SharpeningParams> for PartialSharpeningParams {
843    fn from(s: &crate::adjust::SharpeningParams) -> Self {
844        Self {
845            amount: Some(s.amount),
846            radius: Some(s.radius),
847            threshold: Some(s.threshold),
848            masking: Some(s.masking),
849        }
850    }
851}
852
853/// Optional, mergeable form of [`crate::adjust::DetailParams`] used during preset deserialization.
854///
855/// See [`crate::adjust::DetailParams`] for field semantics.
856#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
857pub struct PartialDetailParams {
858    /// See [`DetailParams::sharpening`](crate::adjust::DetailParams::sharpening).
859    #[serde(default, skip_serializing_if = "Option::is_none")]
860    pub sharpening: Option<PartialSharpeningParams>,
861    /// See [`DetailParams::clarity`](crate::adjust::DetailParams::clarity).
862    #[serde(default, skip_serializing_if = "Option::is_none")]
863    pub clarity: Option<f32>,
864    /// See [`DetailParams::texture`](crate::adjust::DetailParams::texture).
865    #[serde(default, skip_serializing_if = "Option::is_none")]
866    pub texture: Option<f32>,
867}
868
869impl PartialDetailParams {
870    fn merge_sharpening(
871        base: &Option<PartialSharpeningParams>,
872        overlay: &Option<PartialSharpeningParams>,
873    ) -> Option<PartialSharpeningParams> {
874        match (base, overlay) {
875            (None, None) => None,
876            (Some(b), None) => Some(b.clone()),
877            (None, Some(o)) => Some(o.clone()),
878            (Some(b), Some(o)) => Some(b.merge(o)),
879        }
880    }
881
882    /// Merge `overlay` on top of `self`. Last-write-wins per field.
883    pub fn merge(&self, overlay: &Self) -> Self {
884        Self {
885            sharpening: Self::merge_sharpening(&self.sharpening, &overlay.sharpening),
886            clarity: overlay.clarity.or(self.clarity),
887            texture: overlay.texture.or(self.texture),
888        }
889    }
890
891    /// Materialize this partial into a concrete [`DetailParams`](crate::adjust::DetailParams). Unset fields become defaults.
892    pub fn materialize(&self) -> crate::adjust::DetailParams {
893        crate::adjust::DetailParams {
894            sharpening: self
895                .sharpening
896                .as_ref()
897                .map(|s| s.materialize())
898                .unwrap_or_default(),
899            clarity: self.clarity.unwrap_or(0.0),
900            texture: self.texture.unwrap_or(0.0),
901        }
902    }
903}
904
905impl From<&crate::adjust::DetailParams> for PartialDetailParams {
906    fn from(d: &crate::adjust::DetailParams) -> Self {
907        Self {
908            sharpening: Some(PartialSharpeningParams::from(&d.sharpening)),
909            clarity: Some(d.clarity),
910            texture: Some(d.texture),
911        }
912    }
913}
914
915/// Optional, mergeable form of [`crate::adjust::DehazeParams`] used during preset deserialization.
916///
917/// See [`crate::adjust::DehazeParams`] for field semantics.
918#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
919pub struct PartialDehazeParams {
920    /// See [`DehazeParams::amount`](crate::adjust::DehazeParams::amount).
921    #[serde(default, skip_serializing_if = "Option::is_none")]
922    pub amount: Option<f32>,
923}
924
925impl PartialDehazeParams {
926    /// Merge `overlay` on top of `self`. Last-write-wins per field.
927    pub fn merge(&self, overlay: &Self) -> Self {
928        Self {
929            amount: overlay.amount.or(self.amount),
930        }
931    }
932
933    /// Materialize this partial into a concrete [`DehazeParams`](crate::adjust::DehazeParams). Unset fields become `0.0`.
934    pub fn materialize(&self) -> crate::adjust::DehazeParams {
935        crate::adjust::DehazeParams {
936            amount: self.amount.unwrap_or(0.0),
937        }
938    }
939}
940
941impl From<&crate::adjust::DehazeParams> for PartialDehazeParams {
942    fn from(d: &crate::adjust::DehazeParams) -> Self {
943        Self {
944            amount: Some(d.amount),
945        }
946    }
947}
948
949/// Optional, mergeable form of [`crate::adjust::NoiseReductionParams`] used during preset deserialization.
950///
951/// See [`crate::adjust::NoiseReductionParams`] for field semantics.
952#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
953pub struct PartialNoiseReductionParams {
954    /// See [`NoiseReductionParams::luminance`](crate::adjust::NoiseReductionParams::luminance).
955    #[serde(default, skip_serializing_if = "Option::is_none")]
956    pub luminance: Option<f32>,
957    /// See [`NoiseReductionParams::color`](crate::adjust::NoiseReductionParams::color).
958    #[serde(default, skip_serializing_if = "Option::is_none")]
959    pub color: Option<f32>,
960    /// See [`NoiseReductionParams::detail`](crate::adjust::NoiseReductionParams::detail).
961    #[serde(default, skip_serializing_if = "Option::is_none")]
962    pub detail: Option<f32>,
963}
964
965impl PartialNoiseReductionParams {
966    /// Merge `overlay` on top of `self`. Last-write-wins per field.
967    pub fn merge(&self, overlay: &Self) -> Self {
968        Self {
969            luminance: overlay.luminance.or(self.luminance),
970            color: overlay.color.or(self.color),
971            detail: overlay.detail.or(self.detail),
972        }
973    }
974
975    /// Materialize this partial into a concrete [`NoiseReductionParams`](crate::adjust::NoiseReductionParams). Unset fields become `0.0`.
976    pub fn materialize(&self) -> crate::adjust::NoiseReductionParams {
977        crate::adjust::NoiseReductionParams {
978            luminance: self.luminance.unwrap_or(0.0),
979            color: self.color.unwrap_or(0.0),
980            detail: self.detail.unwrap_or(0.0),
981        }
982    }
983}
984
985impl From<&crate::adjust::NoiseReductionParams> for PartialNoiseReductionParams {
986    fn from(p: &crate::adjust::NoiseReductionParams) -> Self {
987        Self {
988            luminance: Some(p.luminance),
989            color: Some(p.color),
990            detail: Some(p.detail),
991        }
992    }
993}
994
995/// Optional, mergeable form of [`crate::adjust::GrainParams`] used during preset deserialization.
996///
997/// See [`crate::adjust::GrainParams`] for field semantics.
998#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
999pub struct PartialGrainParams {
1000    /// See [`GrainParams::grain_type`](crate::adjust::GrainParams::grain_type).
1001    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
1002    pub grain_type: Option<crate::adjust::GrainType>,
1003    /// See [`GrainParams::amount`](crate::adjust::GrainParams::amount).
1004    #[serde(default, skip_serializing_if = "Option::is_none")]
1005    pub amount: Option<f32>,
1006    /// See [`GrainParams::size`](crate::adjust::GrainParams::size).
1007    #[serde(default, skip_serializing_if = "Option::is_none")]
1008    pub size: Option<f32>,
1009    /// See [`GrainParams::seed`](crate::adjust::GrainParams::seed).
1010    #[serde(default, skip_serializing_if = "Option::is_none")]
1011    pub seed: Option<u64>,
1012}
1013
1014impl PartialGrainParams {
1015    /// Merge `overlay` on top of `self`. Last-write-wins per field.
1016    pub fn merge(&self, overlay: &Self) -> Self {
1017        Self {
1018            grain_type: overlay.grain_type.or(self.grain_type),
1019            amount: overlay.amount.or(self.amount),
1020            size: overlay.size.or(self.size),
1021            seed: overlay.seed.or(self.seed),
1022        }
1023    }
1024
1025    /// Materialize this partial into a concrete [`GrainParams`](crate::adjust::GrainParams). Unset fields become defaults.
1026    pub fn materialize(&self) -> crate::adjust::GrainParams {
1027        crate::adjust::GrainParams {
1028            grain_type: self.grain_type.unwrap_or_default(),
1029            amount: self.amount.unwrap_or(0.0),
1030            size: self.size.unwrap_or(50.0),
1031            seed: self.seed,
1032        }
1033    }
1034}
1035
1036impl From<&crate::adjust::GrainParams> for PartialGrainParams {
1037    fn from(p: &crate::adjust::GrainParams) -> Self {
1038        Self {
1039            grain_type: Some(p.grain_type),
1040            amount: Some(p.amount),
1041            size: Some(p.size),
1042            seed: p.seed,
1043        }
1044    }
1045}
1046
1047/// Optional, mergeable form of [`Parameters`] used during preset deserialization.
1048///
1049/// See [`Parameters`] for field semantics.
1050#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1051pub struct PartialParameters {
1052    /// See [`Parameters::exposure`].
1053    #[serde(default, skip_serializing_if = "Option::is_none")]
1054    pub exposure: Option<f32>,
1055    /// See [`Parameters::contrast`].
1056    #[serde(default, skip_serializing_if = "Option::is_none")]
1057    pub contrast: Option<f32>,
1058    /// See [`Parameters::highlights`].
1059    #[serde(default, skip_serializing_if = "Option::is_none")]
1060    pub highlights: Option<f32>,
1061    /// See [`Parameters::shadows`].
1062    #[serde(default, skip_serializing_if = "Option::is_none")]
1063    pub shadows: Option<f32>,
1064    /// See [`Parameters::whites`].
1065    #[serde(default, skip_serializing_if = "Option::is_none")]
1066    pub whites: Option<f32>,
1067    /// See [`Parameters::blacks`].
1068    #[serde(default, skip_serializing_if = "Option::is_none")]
1069    pub blacks: Option<f32>,
1070    /// See [`Parameters::temperature`].
1071    #[serde(default, skip_serializing_if = "Option::is_none")]
1072    pub temperature: Option<f32>,
1073    /// See [`Parameters::tint`].
1074    #[serde(default, skip_serializing_if = "Option::is_none")]
1075    pub tint: Option<f32>,
1076    /// See [`Parameters::hsl`].
1077    #[serde(default, skip_serializing_if = "Option::is_none")]
1078    pub hsl: Option<PartialHslChannels>,
1079    /// See [`Parameters::vignette`].
1080    #[serde(default, skip_serializing_if = "Option::is_none")]
1081    pub vignette: Option<PartialVignetteParams>,
1082    /// See [`Parameters::color_grading`].
1083    #[serde(default, skip_serializing_if = "Option::is_none")]
1084    pub color_grading: Option<PartialColorGradingParams>,
1085    /// See [`Parameters::tone_curve`].
1086    #[serde(default, skip_serializing_if = "Option::is_none")]
1087    pub tone_curve: Option<PartialToneCurveParams>,
1088    /// See [`Parameters::detail`].
1089    #[serde(default, skip_serializing_if = "Option::is_none")]
1090    pub detail: Option<PartialDetailParams>,
1091    /// See [`Parameters::dehaze`].
1092    #[serde(default, skip_serializing_if = "Option::is_none")]
1093    pub dehaze: Option<PartialDehazeParams>,
1094    /// See [`Parameters::noise_reduction`].
1095    #[serde(default, skip_serializing_if = "Option::is_none")]
1096    pub noise_reduction: Option<PartialNoiseReductionParams>,
1097    /// See [`Parameters::grain`].
1098    #[serde(default, skip_serializing_if = "Option::is_none")]
1099    pub grain: Option<PartialGrainParams>,
1100}
1101
1102impl PartialParameters {
1103    /// Merge `other` on top of `self` (last-write-wins).
1104    pub fn merge(&self, other: &Self) -> Self {
1105        Self {
1106            exposure: other.exposure.or(self.exposure),
1107            contrast: other.contrast.or(self.contrast),
1108            highlights: other.highlights.or(self.highlights),
1109            shadows: other.shadows.or(self.shadows),
1110            whites: other.whites.or(self.whites),
1111            blacks: other.blacks.or(self.blacks),
1112            temperature: other.temperature.or(self.temperature),
1113            tint: other.tint.or(self.tint),
1114            hsl: match (&self.hsl, &other.hsl) {
1115                (None, None) => None,
1116                (Some(b), None) => Some(b.clone()),
1117                (None, Some(o)) => Some(o.clone()),
1118                (Some(b), Some(o)) => Some(b.merge(o)),
1119            },
1120            vignette: match (&self.vignette, &other.vignette) {
1121                (None, None) => None,
1122                (Some(b), None) => Some(b.clone()),
1123                (None, Some(o)) => Some(o.clone()),
1124                (Some(b), Some(o)) => Some(b.merge(o)),
1125            },
1126            color_grading: match (&self.color_grading, &other.color_grading) {
1127                (None, None) => None,
1128                (Some(b), None) => Some(b.clone()),
1129                (None, Some(o)) => Some(o.clone()),
1130                (Some(b), Some(o)) => Some(b.merge(o)),
1131            },
1132            tone_curve: match (&self.tone_curve, &other.tone_curve) {
1133                (None, None) => None,
1134                (Some(b), None) => Some(b.clone()),
1135                (None, Some(o)) => Some(o.clone()),
1136                (Some(b), Some(o)) => Some(b.merge(o)),
1137            },
1138            detail: match (&self.detail, &other.detail) {
1139                (None, None) => None,
1140                (Some(b), None) => Some(b.clone()),
1141                (None, Some(o)) => Some(o.clone()),
1142                (Some(b), Some(o)) => Some(b.merge(o)),
1143            },
1144            dehaze: match (&self.dehaze, &other.dehaze) {
1145                (None, None) => None,
1146                (Some(b), None) => Some(b.clone()),
1147                (None, Some(o)) => Some(o.clone()),
1148                (Some(b), Some(o)) => Some(b.merge(o)),
1149            },
1150            noise_reduction: match (&self.noise_reduction, &other.noise_reduction) {
1151                (None, None) => None,
1152                (Some(b), None) => Some(b.clone()),
1153                (None, Some(o)) => Some(o.clone()),
1154                (Some(b), Some(o)) => Some(b.merge(o)),
1155            },
1156            grain: match (&self.grain, &other.grain) {
1157                (None, None) => None,
1158                (Some(b), None) => Some(b.clone()),
1159                (None, Some(o)) => Some(o.clone()),
1160                (Some(b), Some(o)) => Some(b.merge(o)),
1161            },
1162        }
1163    }
1164
1165    /// Convert to concrete Parameters. `None` fields become their default (0.0).
1166    pub fn materialize(&self) -> Parameters {
1167        Parameters {
1168            exposure: self.exposure.unwrap_or(0.0),
1169            contrast: self.contrast.unwrap_or(0.0),
1170            highlights: self.highlights.unwrap_or(0.0),
1171            shadows: self.shadows.unwrap_or(0.0),
1172            whites: self.whites.unwrap_or(0.0),
1173            blacks: self.blacks.unwrap_or(0.0),
1174            temperature: self.temperature.unwrap_or(0.0),
1175            tint: self.tint.unwrap_or(0.0),
1176            hsl: self
1177                .hsl
1178                .as_ref()
1179                .map(|h| h.materialize())
1180                .unwrap_or_default(),
1181            vignette: self
1182                .vignette
1183                .as_ref()
1184                .map(|v| v.materialize())
1185                .unwrap_or_default(),
1186            color_grading: self
1187                .color_grading
1188                .as_ref()
1189                .map(|cg| cg.materialize())
1190                .unwrap_or_default(),
1191            tone_curve: self
1192                .tone_curve
1193                .as_ref()
1194                .map(|tc| tc.materialize())
1195                .unwrap_or_default(),
1196            detail: self
1197                .detail
1198                .as_ref()
1199                .map(|d| d.materialize())
1200                .unwrap_or_default(),
1201            dehaze: self
1202                .dehaze
1203                .as_ref()
1204                .map(|d| d.materialize())
1205                .unwrap_or_default(),
1206            noise_reduction: self
1207                .noise_reduction
1208                .as_ref()
1209                .map(|nr| nr.materialize())
1210                .unwrap_or_default(),
1211            grain: self
1212                .grain
1213                .as_ref()
1214                .map(|g| g.materialize())
1215                .unwrap_or_default(),
1216        }
1217    }
1218}
1219
1220impl From<&Parameters> for PartialParameters {
1221    fn from(params: &Parameters) -> Self {
1222        Self {
1223            exposure: Some(params.exposure),
1224            contrast: Some(params.contrast),
1225            highlights: Some(params.highlights),
1226            shadows: Some(params.shadows),
1227            whites: Some(params.whites),
1228            blacks: Some(params.blacks),
1229            temperature: Some(params.temperature),
1230            tint: Some(params.tint),
1231            hsl: Some(PartialHslChannels::from(&params.hsl)),
1232            vignette: Some(PartialVignetteParams::from(&params.vignette)),
1233            color_grading: Some(PartialColorGradingParams::from(&params.color_grading)),
1234            tone_curve: Some(PartialToneCurveParams::from(&params.tone_curve)),
1235            detail: Some(PartialDetailParams::from(&params.detail)),
1236            dehaze: Some(PartialDehazeParams::from(&params.dehaze)),
1237            noise_reduction: Some(PartialNoiseReductionParams::from(&params.noise_reduction)),
1238            grain: Some(PartialGrainParams::from(&params.grain)),
1239        }
1240    }
1241}
1242
1243/// The rendering engine. Holds an immutable original image and mutable parameters.
1244///
1245/// On each `render()` call, all adjustments are applied from scratch in a fixed
1246/// internal order. This gives user-facing order-independence.
1247pub struct Engine {
1248    original: Rgb32FImage,
1249    params: Parameters,
1250    lut: Option<Arc<crate::lut::Lut3D>>,
1251    pipeline: Pipeline,
1252}
1253
1254enum Pipeline {
1255    Cpu(pipeline::CpuPipeline),
1256    #[cfg(feature = "gpu")]
1257    Gpu(Box<gpu::GpuPipeline>),
1258}
1259
1260impl Engine {
1261    fn from_pipeline(image: Rgb32FImage, pipeline: Pipeline) -> Self {
1262        Self {
1263            original: image,
1264            params: Parameters::default(),
1265            lut: None,
1266            pipeline,
1267        }
1268    }
1269
1270    /// Create a new engine with the given linear sRGB image and neutral parameters.
1271    ///
1272    /// Always uses the CPU pipeline. This is the canonical path for deterministic
1273    /// output across all platforms. Use [`Engine::new_gpu`] or
1274    /// [`Engine::new_gpu_auto`] for GPU acceleration.
1275    pub fn new(image: Rgb32FImage) -> Self {
1276        Self::from_pipeline(image, Pipeline::Cpu(pipeline::CpuPipeline::new()))
1277    }
1278
1279    /// Create an engine that tries GPU first, falling back to CPU.
1280    ///
1281    /// Use this when the caller explicitly opts into GPU acceleration
1282    /// (e.g. via a `--gpu` CLI flag). Output may differ slightly from
1283    /// the CPU path due to floating-point precision differences.
1284    #[cfg(feature = "gpu")]
1285    pub fn new_gpu_auto(image: Rgb32FImage) -> Self {
1286        let (w, h) = image.dimensions();
1287        let pipeline = match gpu::GpuPipeline::new(w, h) {
1288            Ok(gpu) => Pipeline::Gpu(Box::new(gpu)),
1289            Err(_) => Pipeline::Cpu(pipeline::CpuPipeline::new()),
1290        };
1291        Self::from_pipeline(image, pipeline)
1292    }
1293
1294    /// Create an engine that uses the GPU pipeline.
1295    /// Returns `Err` if GPU initialization fails.
1296    #[cfg(feature = "gpu")]
1297    pub fn new_gpu(image: Rgb32FImage) -> Result<Self, crate::error::AgxError> {
1298        let (w, h) = image.dimensions();
1299        let gpu = gpu::GpuPipeline::new(w, h)?;
1300        Ok(Self::from_pipeline(image, Pipeline::Gpu(Box::new(gpu))))
1301    }
1302
1303    /// Create an engine using wgpu's software fallback adapter.
1304    /// Returns `Err` if no fallback adapter is available.
1305    #[cfg(feature = "gpu")]
1306    pub fn new_gpu_fallback(image: Rgb32FImage) -> Result<Self, crate::error::AgxError> {
1307        let (w, h) = image.dimensions();
1308        let gpu = gpu::GpuPipeline::new_fallback(w, h)?;
1309        Ok(Self::from_pipeline(image, Pipeline::Gpu(Box::new(gpu))))
1310    }
1311
1312    /// Get a reference to the original (unmodified) image.
1313    pub fn original(&self) -> &Rgb32FImage {
1314        &self.original
1315    }
1316
1317    /// Get a reference to the current parameters.
1318    pub fn params(&self) -> &Parameters {
1319        &self.params
1320    }
1321
1322    /// Get a mutable reference to the current parameters.
1323    pub fn params_mut(&mut self) -> &mut Parameters {
1324        &mut self.params
1325    }
1326
1327    /// Set parameters from a full Parameters struct.
1328    pub fn set_params(&mut self, params: Parameters) {
1329        self.params = params;
1330    }
1331
1332    /// Get a reference to the current LUT, if any.
1333    pub fn lut(&self) -> Option<&crate::lut::Lut3D> {
1334        self.lut.as_deref()
1335    }
1336
1337    /// Set or clear the 3D LUT.
1338    ///
1339    /// Accepts `Arc<Lut3D>` so the same LUT can be shared across multiple
1340    /// engine instances (e.g. in batch processing) without cloning the table.
1341    pub fn set_lut(&mut self, lut: Option<Arc<crate::lut::Lut3D>>) {
1342        self.lut = lut;
1343    }
1344
1345    /// Apply a preset, replacing the current parameters and LUT.
1346    pub fn apply_preset(&mut self, preset: &crate::preset::Preset) {
1347        self.params = preset.params();
1348        self.lut = preset.lut.clone();
1349    }
1350
1351    /// Layer a preset on top of current parameters.
1352    /// Only fields specified in the preset (Some values in partial_params)
1353    /// are overridden. Unspecified fields keep their current values.
1354    pub fn layer_preset(&mut self, preset: &crate::preset::Preset) {
1355        let current_partial = PartialParameters::from(&self.params);
1356        let merged = current_partial.merge(&preset.partial_params);
1357        self.params = merged.materialize();
1358        if preset.lut.is_some() {
1359            self.lut = preset.lut.clone();
1360        }
1361    }
1362
1363    /// Returns `"gpu"` or `"cpu"` depending on which pipeline is active.
1364    pub fn pipeline_name(&self) -> &'static str {
1365        match &self.pipeline {
1366            Pipeline::Cpu(_) => "cpu",
1367            #[cfg(feature = "gpu")]
1368            Pipeline::Gpu(_) => "gpu",
1369        }
1370    }
1371
1372    /// Render the image by applying all adjustments from scratch.
1373    ///
1374    /// Delegates to the stage-based pipeline. Each render starts from the
1375    /// original image — no state accumulates between renders.
1376    pub fn render(&mut self) -> RenderResult {
1377        match &mut self.pipeline {
1378            Pipeline::Cpu(cpu) => cpu.execute(&self.original, &self.params, self.lut.as_deref()),
1379            #[cfg(feature = "gpu")]
1380            Pipeline::Gpu(gpu) => gpu.execute(&self.original, &self.params, self.lut.as_deref()),
1381        }
1382    }
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388    use image::{ImageBuffer, Rgb};
1389
1390    fn make_test_image(r: f32, g: f32, b: f32) -> Rgb32FImage {
1391        ImageBuffer::from_pixel(2, 2, Rgb([r, g, b]))
1392    }
1393
1394    #[test]
1395    fn parameters_default_is_neutral() {
1396        let p = Parameters::default();
1397        assert_eq!(p.exposure, 0.0);
1398        assert_eq!(p.contrast, 0.0);
1399        assert_eq!(p.highlights, 0.0);
1400        assert_eq!(p.shadows, 0.0);
1401        assert_eq!(p.whites, 0.0);
1402        assert_eq!(p.blacks, 0.0);
1403        assert_eq!(p.temperature, 0.0);
1404        assert_eq!(p.tint, 0.0);
1405        assert!(p.dehaze.is_neutral());
1406        assert_eq!(p.dehaze.amount, 0.0);
1407    }
1408
1409    #[test]
1410    fn render_neutral_params_is_identity() {
1411        let img = make_test_image(0.5, 0.3, 0.1);
1412        let mut engine = Engine::new(img);
1413        let rendered = engine.render().image;
1414        let orig = engine.original().get_pixel(0, 0);
1415        let rend = rendered.get_pixel(0, 0);
1416        for i in 0..3 {
1417            assert!(
1418                (orig.0[i] - rend.0[i]).abs() < 1e-5,
1419                "Channel {}: expected {}, got {}",
1420                i,
1421                orig.0[i],
1422                rend.0[i]
1423            );
1424        }
1425    }
1426
1427    #[test]
1428    fn render_exposure_plus_one_doubles() {
1429        let img = make_test_image(0.25, 0.25, 0.25);
1430        let mut engine = Engine::new(img);
1431        engine.params_mut().exposure = 1.0;
1432        let pixel = *engine.render().image.get_pixel(0, 0);
1433        for i in 0..3 {
1434            assert!(
1435                (pixel.0[i] - 0.5).abs() < 1e-5,
1436                "Channel {}: expected 0.5, got {}",
1437                i,
1438                pixel.0[i]
1439            );
1440        }
1441    }
1442
1443    #[test]
1444    fn render_contrast_changes_output() {
1445        let img = make_test_image(0.5, 0.5, 0.5);
1446        let mut engine = Engine::new(img);
1447        engine.params_mut().contrast = 50.0;
1448        let rendered = engine.render().image;
1449        let mut neutral_engine = Engine::new(make_test_image(0.5, 0.5, 0.5));
1450        let neutral = neutral_engine.render().image;
1451        // With contrast, pixel values above/below mid should shift
1452        let rp = rendered.get_pixel(0, 0);
1453        let np = neutral.get_pixel(0, 0);
1454        // 0.5 in linear → sRGB ~0.735 → contrast pushes further from 0.5
1455        // Result should differ from neutral
1456        assert!(
1457            (rp.0[0] - np.0[0]).abs() > 1e-6 || rp.0[0] == np.0[0],
1458            "Contrast should change output for non-midpoint sRGB values"
1459        );
1460    }
1461
1462    #[test]
1463    fn render_warm_white_balance_boosts_red() {
1464        let img = make_test_image(0.5, 0.5, 0.5);
1465        let mut engine = Engine::new(img);
1466        engine.params_mut().temperature = 50.0;
1467        let pixel = *engine.render().image.get_pixel(0, 0);
1468        // Warm shift: red > blue
1469        assert!(
1470            pixel.0[0] > pixel.0[2],
1471            "Expected red > blue with warm WB, got r={} b={}",
1472            pixel.0[0],
1473            pixel.0[2]
1474        );
1475    }
1476
1477    #[test]
1478    fn render_combined_exposure_and_contrast() {
1479        let img = make_test_image(0.2, 0.2, 0.2);
1480        let mut engine = Engine::new(img);
1481        engine.params_mut().exposure = 1.0;
1482        engine.params_mut().contrast = 25.0;
1483        let pixel = *engine.render().image.get_pixel(0, 0);
1484        // Should be brighter than original 0.2
1485        assert!(pixel.0[0] > 0.2, "Expected brighter, got {}", pixel.0[0]);
1486    }
1487
1488    #[test]
1489    fn render_with_identity_lut_is_identity() {
1490        let img = make_test_image(0.5, 0.3, 0.1);
1491        let mut engine = Engine::new(img);
1492        let size = 17;
1493        let n = (size - 1) as f32;
1494        let mut table = Vec::with_capacity(size * size * size);
1495        for b in 0..size {
1496            for g in 0..size {
1497                for r in 0..size {
1498                    table.push([r as f32 / n, g as f32 / n, b as f32 / n]);
1499                }
1500            }
1501        }
1502        let lut = crate::lut::Lut3D {
1503            title: None,
1504            size,
1505            domain_min: [0.0, 0.0, 0.0],
1506            domain_max: [1.0, 1.0, 1.0],
1507            table,
1508        };
1509        engine.set_lut(Some(Arc::new(lut)));
1510
1511        let rendered = engine.render().image;
1512        let orig = engine.original().get_pixel(0, 0);
1513        let rend = rendered.get_pixel(0, 0);
1514        for i in 0..3 {
1515            assert!(
1516                (orig.0[i] - rend.0[i]).abs() < 0.01,
1517                "Channel {}: expected ~{}, got {}",
1518                i,
1519                orig.0[i],
1520                rend.0[i]
1521            );
1522        }
1523    }
1524
1525    #[test]
1526    fn render_with_no_lut_unchanged() {
1527        let img = make_test_image(0.5, 0.3, 0.1);
1528        let mut engine = Engine::new(img);
1529        assert!(engine.lut().is_none());
1530        let rendered = engine.render().image;
1531        let orig = engine.original().get_pixel(0, 0);
1532        let rend = rendered.get_pixel(0, 0);
1533        for i in 0..3 {
1534            assert!((orig.0[i] - rend.0[i]).abs() < 1e-5);
1535        }
1536    }
1537
1538    #[test]
1539    fn hsl_channel_default_is_zero() {
1540        let ch = super::HslChannel::default();
1541        assert_eq!(ch.hue, 0.0);
1542        assert_eq!(ch.saturation, 0.0);
1543        assert_eq!(ch.luminance, 0.0);
1544    }
1545
1546    #[test]
1547    fn hsl_channels_default_all_zero() {
1548        let hsl = super::HslChannels::default();
1549        assert_eq!(hsl.red, super::HslChannel::default());
1550        assert_eq!(hsl.green, super::HslChannel::default());
1551        assert_eq!(hsl.magenta, super::HslChannel::default());
1552    }
1553
1554    #[test]
1555    fn hsl_channels_is_default_true_when_default() {
1556        let hsl = super::HslChannels::default();
1557        assert!(hsl.is_default());
1558    }
1559
1560    #[test]
1561    fn hsl_channels_is_default_false_when_modified() {
1562        let mut hsl = super::HslChannels::default();
1563        hsl.red.hue = 10.0;
1564        assert!(!hsl.is_default());
1565    }
1566
1567    #[test]
1568    fn hsl_channels_extracts_shift_arrays() {
1569        let mut hsl = super::HslChannels::default();
1570        hsl.red.hue = 15.0;
1571        hsl.green.saturation = -30.0;
1572        hsl.blue.luminance = 20.0;
1573        let h = hsl.hue_shifts();
1574        let s = hsl.saturation_shifts();
1575        let l = hsl.luminance_shifts();
1576        assert_eq!(h[0], 15.0); // red
1577        assert_eq!(s[3], -30.0); // green
1578        assert_eq!(l[5], 20.0); // blue
1579    }
1580
1581    #[test]
1582    fn parameters_default_hsl_is_default() {
1583        let p = Parameters::default();
1584        assert!(p.hsl.is_default());
1585    }
1586
1587    #[test]
1588    fn render_hsl_neutral_is_identity() {
1589        // Red-ish pixel in linear space
1590        let img = make_test_image(0.5, 0.01, 0.01);
1591        let mut engine = Engine::new(img);
1592        // HSL defaults to all zeros, so render should be identity
1593        let orig = *engine.original().get_pixel(0, 0);
1594        let rend = *engine.render().image.get_pixel(0, 0);
1595        for i in 0..3 {
1596            assert!(
1597                (orig.0[i] - rend.0[i]).abs() < 1e-4,
1598                "Channel {i}: expected {}, got {}",
1599                orig.0[i],
1600                rend.0[i]
1601            );
1602        }
1603    }
1604
1605    #[test]
1606    fn render_hsl_red_saturation_decrease() {
1607        // Pure-ish red in linear space
1608        let img = make_test_image(0.5, 0.01, 0.01);
1609        let mut engine = Engine::new(img);
1610        engine.params_mut().hsl.red.saturation = -100.0;
1611        let rendered = engine.render().image;
1612        let p = rendered.get_pixel(0, 0);
1613        // Desaturated: channels should be closer together than original
1614        let spread = (p.0[0] - p.0[1]).abs() + (p.0[0] - p.0[2]).abs();
1615        let orig = engine.original().get_pixel(0, 0);
1616        let orig_spread = (orig.0[0] - orig.0[1]).abs() + (orig.0[0] - orig.0[2]).abs();
1617        assert!(
1618            spread < orig_spread,
1619            "Expected less spread after desaturation: {spread} vs {orig_spread}"
1620        );
1621    }
1622
1623    #[test]
1624    fn render_hsl_green_shift_does_not_affect_red_image() {
1625        let img = make_test_image(0.5, 0.01, 0.01);
1626        let mut engine = Engine::new(img);
1627        engine.params_mut().hsl.green.saturation = -100.0;
1628        let rendered = engine.render().image;
1629        let orig = engine.original().get_pixel(0, 0);
1630        let rend = rendered.get_pixel(0, 0);
1631        for i in 0..3 {
1632            assert!(
1633                (orig.0[i] - rend.0[i]).abs() < 1e-3,
1634                "Channel {i}: red image should be unaffected by green HSL"
1635            );
1636        }
1637    }
1638
1639    // --- PartialHslChannel tests ---
1640
1641    #[test]
1642    fn partial_hsl_channel_default_is_all_none() {
1643        let ch = super::PartialHslChannel::default();
1644        assert_eq!(ch.hue, None);
1645        assert_eq!(ch.saturation, None);
1646        assert_eq!(ch.luminance, None);
1647    }
1648
1649    #[test]
1650    fn partial_hsl_channels_default_is_all_none() {
1651        let hsl = super::PartialHslChannels::default();
1652        assert_eq!(hsl.red, None);
1653        assert_eq!(hsl.green, None);
1654        assert_eq!(hsl.blue, None);
1655    }
1656
1657    #[test]
1658    fn partial_hsl_channel_merge_overlay_wins() {
1659        let base = super::PartialHslChannel {
1660            hue: Some(10.0),
1661            saturation: Some(20.0),
1662            luminance: None,
1663        };
1664        let overlay = super::PartialHslChannel {
1665            hue: Some(30.0),
1666            saturation: None,
1667            luminance: Some(5.0),
1668        };
1669        let merged = base.merge(&overlay);
1670        assert_eq!(merged.hue, Some(30.0));
1671        assert_eq!(merged.saturation, Some(20.0));
1672        assert_eq!(merged.luminance, Some(5.0));
1673    }
1674
1675    #[test]
1676    fn partial_hsl_channels_merge_channel_level() {
1677        let base = super::PartialHslChannels {
1678            red: Some(super::PartialHslChannel {
1679                hue: Some(10.0),
1680                saturation: None,
1681                luminance: None,
1682            }),
1683            ..Default::default()
1684        };
1685        let overlay = super::PartialHslChannels {
1686            red: Some(super::PartialHslChannel {
1687                hue: None,
1688                saturation: Some(20.0),
1689                luminance: None,
1690            }),
1691            green: Some(super::PartialHslChannel {
1692                hue: Some(5.0),
1693                saturation: None,
1694                luminance: None,
1695            }),
1696            ..Default::default()
1697        };
1698        let merged = base.merge(&overlay);
1699        assert_eq!(merged.red.as_ref().unwrap().hue, Some(10.0));
1700        assert_eq!(merged.red.as_ref().unwrap().saturation, Some(20.0));
1701        assert_eq!(merged.green.as_ref().unwrap().hue, Some(5.0));
1702        assert_eq!(merged.blue, None);
1703    }
1704
1705    #[test]
1706    fn partial_hsl_channel_materialize() {
1707        let partial = super::PartialHslChannel {
1708            hue: Some(15.0),
1709            saturation: None,
1710            luminance: Some(-10.0),
1711        };
1712        let concrete = partial.materialize();
1713        assert_eq!(concrete.hue, 15.0);
1714        assert_eq!(concrete.saturation, 0.0);
1715        assert_eq!(concrete.luminance, -10.0);
1716    }
1717
1718    #[test]
1719    fn partial_hsl_channels_materialize() {
1720        let partial = super::PartialHslChannels {
1721            red: Some(super::PartialHslChannel {
1722                hue: Some(15.0),
1723                saturation: None,
1724                luminance: None,
1725            }),
1726            ..Default::default()
1727        };
1728        let concrete = partial.materialize();
1729        assert_eq!(concrete.red.hue, 15.0);
1730        assert_eq!(concrete.red.saturation, 0.0);
1731        assert_eq!(concrete.green, super::HslChannel::default());
1732    }
1733
1734    // --- PartialParameters tests ---
1735
1736    #[test]
1737    fn partial_parameters_default_is_all_none() {
1738        let p = super::PartialParameters::default();
1739        assert_eq!(p.exposure, None);
1740        assert_eq!(p.contrast, None);
1741        assert_eq!(p.hsl, None);
1742    }
1743
1744    #[test]
1745    fn partial_parameters_merge_overlay_wins() {
1746        let base = super::PartialParameters {
1747            exposure: Some(1.0),
1748            contrast: Some(20.0),
1749            ..Default::default()
1750        };
1751        let overlay = super::PartialParameters {
1752            exposure: Some(2.0),
1753            highlights: Some(-30.0),
1754            ..Default::default()
1755        };
1756        let merged = base.merge(&overlay);
1757        assert_eq!(merged.exposure, Some(2.0));
1758        assert_eq!(merged.contrast, Some(20.0));
1759        assert_eq!(merged.highlights, Some(-30.0));
1760        assert_eq!(merged.shadows, None);
1761    }
1762
1763    #[test]
1764    fn partial_parameters_materialize_defaults() {
1765        let partial = super::PartialParameters {
1766            exposure: Some(1.5),
1767            ..Default::default()
1768        };
1769        let params = partial.materialize();
1770        assert_eq!(params.exposure, 1.5);
1771        assert_eq!(params.contrast, 0.0);
1772        assert_eq!(params.temperature, 0.0);
1773        assert!(params.hsl.is_default());
1774    }
1775
1776    #[test]
1777    fn partial_parameters_from_parameters_all_some() {
1778        let params = Parameters {
1779            exposure: 1.0,
1780            contrast: 20.0,
1781            ..Default::default()
1782        };
1783        let partial = super::PartialParameters::from(&params);
1784        assert_eq!(partial.exposure, Some(1.0));
1785        assert_eq!(partial.contrast, Some(20.0));
1786        assert_eq!(partial.highlights, Some(0.0));
1787    }
1788
1789    #[test]
1790    fn partial_parameters_merge_with_hsl() {
1791        let base = super::PartialParameters {
1792            exposure: Some(1.0),
1793            ..Default::default()
1794        };
1795        let overlay = super::PartialParameters {
1796            hsl: Some(super::PartialHslChannels {
1797                red: Some(super::PartialHslChannel {
1798                    hue: Some(10.0),
1799                    saturation: None,
1800                    luminance: None,
1801                }),
1802                ..Default::default()
1803            }),
1804            ..Default::default()
1805        };
1806        let merged = base.merge(&overlay);
1807        assert_eq!(merged.exposure, Some(1.0));
1808        assert!(merged.hsl.is_some());
1809        assert_eq!(
1810            merged.hsl.as_ref().unwrap().red.as_ref().unwrap().hue,
1811            Some(10.0)
1812        );
1813    }
1814
1815    // --- layer_preset tests ---
1816
1817    #[test]
1818    fn layer_preset_only_overrides_specified_fields() {
1819        let img = make_test_image(0.5, 0.5, 0.5);
1820        let mut engine = Engine::new(img);
1821        engine.params_mut().exposure = 1.0;
1822        engine.params_mut().contrast = 20.0;
1823
1824        let mut preset = crate::preset::Preset::default();
1825        preset.partial_params.contrast = Some(50.0);
1826
1827        engine.layer_preset(&preset);
1828        assert_eq!(engine.params().exposure, 1.0);
1829        assert_eq!(engine.params().contrast, 50.0);
1830    }
1831
1832    #[test]
1833    fn layer_preset_preserves_unspecified_hsl() {
1834        let img = make_test_image(0.5, 0.5, 0.5);
1835        let mut engine = Engine::new(img);
1836        engine.params_mut().hsl.red.hue = 15.0;
1837
1838        let mut preset = crate::preset::Preset::default();
1839        preset.partial_params.hsl = Some(PartialHslChannels {
1840            green: Some(PartialHslChannel {
1841                hue: Some(10.0),
1842                saturation: None,
1843                luminance: None,
1844            }),
1845            ..Default::default()
1846        });
1847
1848        engine.layer_preset(&preset);
1849        assert_eq!(engine.params().hsl.red.hue, 15.0);
1850        assert_eq!(engine.params().hsl.green.hue, 10.0);
1851    }
1852
1853    #[test]
1854    fn layer_multiple_presets_last_wins() {
1855        let img = make_test_image(0.5, 0.5, 0.5);
1856        let mut engine = Engine::new(img);
1857
1858        let mut preset1 = crate::preset::Preset::default();
1859        preset1.partial_params.exposure = Some(1.0);
1860        preset1.partial_params.contrast = Some(20.0);
1861
1862        let mut preset2 = crate::preset::Preset::default();
1863        preset2.partial_params.exposure = Some(2.0);
1864
1865        engine.layer_preset(&preset1);
1866        engine.layer_preset(&preset2);
1867
1868        assert_eq!(engine.params().exposure, 2.0);
1869        assert_eq!(engine.params().contrast, 20.0);
1870    }
1871
1872    // --- VignetteParams tests ---
1873
1874    #[test]
1875    fn vignette_params_default() {
1876        let v = super::VignetteParams::default();
1877        assert_eq!(v.amount, 0.0);
1878        assert_eq!(v.shape, crate::adjust::VignetteShape::Elliptical);
1879    }
1880
1881    #[test]
1882    fn partial_vignette_params_default_is_all_none() {
1883        let v = super::PartialVignetteParams::default();
1884        assert_eq!(v.amount, None);
1885        assert_eq!(v.shape, None);
1886    }
1887
1888    #[test]
1889    fn partial_vignette_params_merge_overlay_wins() {
1890        let base = super::PartialVignetteParams {
1891            amount: Some(-30.0),
1892            shape: Some(crate::adjust::VignetteShape::Elliptical),
1893        };
1894        let overlay = super::PartialVignetteParams {
1895            amount: Some(-50.0),
1896            shape: None,
1897        };
1898        let merged = base.merge(&overlay);
1899        assert_eq!(merged.amount, Some(-50.0));
1900        assert_eq!(merged.shape, Some(crate::adjust::VignetteShape::Elliptical));
1901    }
1902
1903    #[test]
1904    fn partial_vignette_params_materialize_defaults() {
1905        let partial = super::PartialVignetteParams {
1906            amount: Some(-30.0),
1907            shape: None,
1908        };
1909        let concrete = partial.materialize();
1910        assert_eq!(concrete.amount, -30.0);
1911        assert_eq!(concrete.shape, crate::adjust::VignetteShape::Elliptical);
1912    }
1913
1914    #[test]
1915    fn partial_vignette_params_from_concrete() {
1916        let concrete = super::VignetteParams {
1917            amount: -30.0,
1918            shape: crate::adjust::VignetteShape::Circular,
1919        };
1920        let partial = super::PartialVignetteParams::from(&concrete);
1921        assert_eq!(partial.amount, Some(-30.0));
1922        assert_eq!(partial.shape, Some(crate::adjust::VignetteShape::Circular));
1923    }
1924
1925    #[test]
1926    fn parameters_default_vignette_is_neutral() {
1927        let p = Parameters::default();
1928        assert_eq!(p.vignette.amount, 0.0);
1929        assert_eq!(p.vignette.shape, crate::adjust::VignetteShape::Elliptical);
1930    }
1931
1932    #[test]
1933    fn apply_preset_still_does_full_replacement() {
1934        let img = make_test_image(0.5, 0.5, 0.5);
1935        let mut engine = Engine::new(img);
1936        engine.params_mut().exposure = 1.0;
1937        engine.params_mut().contrast = 20.0;
1938
1939        let mut preset = crate::preset::Preset::default();
1940        preset.partial_params.exposure = Some(0.5);
1941
1942        engine.apply_preset(&preset);
1943        assert_eq!(engine.params().exposure, 0.5);
1944        assert_eq!(engine.params().contrast, 0.0);
1945    }
1946
1947    #[test]
1948    fn render_vignette_darkens_corners() {
1949        // Use a 10x10 image so corners are clearly away from center
1950        let img: Rgb32FImage = ImageBuffer::from_pixel(10, 10, Rgb([0.5, 0.5, 0.5]));
1951        let mut engine = Engine::new(img);
1952        engine.params_mut().vignette.amount = -50.0;
1953        let rendered = engine.render().image;
1954
1955        // Center pixel should be close to original
1956        let center = rendered.get_pixel(5, 5);
1957        assert!(
1958            (center.0[0] - 0.5).abs() < 0.05,
1959            "Center should be near original, got {}",
1960            center.0[0]
1961        );
1962
1963        // Corner pixel should be darker
1964        let corner = rendered.get_pixel(0, 0);
1965        assert!(
1966            corner.0[0] < center.0[0],
1967            "Corner ({}) should be darker than center ({})",
1968            corner.0[0],
1969            center.0[0]
1970        );
1971    }
1972
1973    #[test]
1974    fn render_vignette_zero_is_identity() {
1975        let img = make_test_image(0.5, 0.3, 0.1);
1976        let mut engine = Engine::new(img);
1977        engine.params_mut().vignette.amount = 0.0;
1978        let rendered = engine.render().image;
1979        let orig = engine.original().get_pixel(0, 0);
1980        let rend = rendered.get_pixel(0, 0);
1981        for i in 0..3 {
1982            assert!(
1983                (orig.0[i] - rend.0[i]).abs() < 1e-5,
1984                "Channel {}: expected {}, got {}",
1985                i,
1986                orig.0[i],
1987                rend.0[i]
1988            );
1989        }
1990    }
1991
1992    #[test]
1993    fn full_pipeline_decode_engine_encode() {
1994        let temp_dir = std::env::temp_dir();
1995        let input = temp_dir.join("agx_e2e_in.png");
1996        let output = temp_dir.join("agx_e2e_out.png");
1997
1998        // Create sRGB 128,128,128 test image
1999        let img: ImageBuffer<image::Rgb<u8>, Vec<u8>> =
2000            ImageBuffer::from_pixel(4, 4, image::Rgb([128u8, 128, 128]));
2001        img.save(&input).unwrap();
2002
2003        // Decode → Engine +1 stop → Render → Encode
2004        let linear = crate::decode::decode_standard(&input).unwrap();
2005        let mut engine = Engine::new(linear);
2006        engine.params_mut().exposure = 1.0;
2007        let rendered = engine.render().image;
2008        crate::encode::encode_to_file(&rendered, &output).unwrap();
2009
2010        // Verify output is brighter (sRGB 128 → linear ~0.216 → *2 → ~0.432 → sRGB ~173)
2011        let out_img = image::ImageReader::open(&output)
2012            .unwrap()
2013            .decode()
2014            .unwrap()
2015            .to_rgb8();
2016        let pixel = out_img.get_pixel(0, 0);
2017        assert!(
2018            pixel.0[0] > 150 && pixel.0[0] < 190,
2019            "Expected ~173, got {}",
2020            pixel.0[0]
2021        );
2022
2023        let _ = std::fs::remove_file(&input);
2024        let _ = std::fs::remove_file(&output);
2025    }
2026
2027    // --- Color grading partial tests ---
2028
2029    #[test]
2030    fn partial_color_grading_merge() {
2031        let base = PartialColorGradingParams {
2032            shadows: Some(PartialColorWheel {
2033                hue: Some(200.0),
2034                saturation: Some(30.0),
2035                luminance: None,
2036            }),
2037            midtones: None,
2038            highlights: None,
2039            global: None,
2040            balance: Some(-10.0),
2041        };
2042        let overlay = PartialColorGradingParams {
2043            shadows: Some(PartialColorWheel {
2044                hue: None,
2045                saturation: Some(50.0),
2046                luminance: Some(-5.0),
2047            }),
2048            midtones: None,
2049            highlights: None,
2050            global: None,
2051            balance: None,
2052        };
2053        let merged = base.merge(&overlay);
2054        let shadows = merged.shadows.unwrap();
2055        assert_eq!(shadows.hue, Some(200.0));
2056        assert_eq!(shadows.saturation, Some(50.0));
2057        assert_eq!(shadows.luminance, Some(-5.0));
2058        assert_eq!(merged.balance, Some(-10.0));
2059    }
2060
2061    #[test]
2062    fn partial_color_grading_materialize_defaults() {
2063        let partial = PartialColorGradingParams::default();
2064        let materialized = partial.materialize();
2065        assert!(materialized.is_default());
2066    }
2067
2068    #[test]
2069    fn render_default_color_grading_is_identity() {
2070        let params = Parameters::default();
2071        assert!(params.color_grading.is_default());
2072    }
2073
2074    // --- Tone Curve tests ---
2075
2076    #[test]
2077    fn partial_tone_curve_merge() {
2078        let base = PartialToneCurveParams {
2079            rgb: Some(PartialToneCurve {
2080                points: Some(vec![(0.0, 0.0), (0.5, 0.6), (1.0, 1.0)]),
2081            }),
2082            luma: None,
2083            red: None,
2084            green: None,
2085            blue: None,
2086        };
2087        let overlay = PartialToneCurveParams {
2088            rgb: None,
2089            luma: Some(PartialToneCurve {
2090                points: Some(vec![(0.0, 0.1), (1.0, 0.9)]),
2091            }),
2092            red: None,
2093            green: None,
2094            blue: None,
2095        };
2096        let merged = base.merge(&overlay);
2097        assert!(merged.rgb.is_some(), "rgb should be preserved from base");
2098        assert!(merged.luma.is_some(), "luma should come from overlay");
2099    }
2100
2101    #[test]
2102    fn partial_tone_curve_materialize_defaults() {
2103        let partial = PartialToneCurveParams::default();
2104        let materialized = partial.materialize();
2105        assert!(materialized.is_default());
2106    }
2107
2108    #[test]
2109    fn render_default_tone_curves_is_identity() {
2110        let params = Parameters::default();
2111        assert!(params.tone_curve.is_default());
2112    }
2113
2114    // --- PartialDetailParams tests ---
2115
2116    #[test]
2117    fn partial_detail_merge_overlay_wins() {
2118        let base = PartialDetailParams {
2119            sharpening: Some(PartialSharpeningParams {
2120                amount: Some(40.0),
2121                radius: Some(1.5),
2122                threshold: None,
2123                masking: None,
2124            }),
2125            clarity: Some(20.0),
2126            texture: None,
2127        };
2128        let overlay = PartialDetailParams {
2129            sharpening: Some(PartialSharpeningParams {
2130                amount: Some(60.0),
2131                radius: None,
2132                threshold: Some(50.0),
2133                masking: None,
2134            }),
2135            clarity: None,
2136            texture: Some(10.0),
2137        };
2138        let merged = base.merge(&overlay);
2139        let sharp = merged.sharpening.unwrap();
2140        assert_eq!(sharp.amount, Some(60.0));
2141        assert_eq!(sharp.radius, Some(1.5));
2142        assert_eq!(sharp.threshold, Some(50.0));
2143        assert_eq!(sharp.masking, None);
2144        assert_eq!(merged.clarity, Some(20.0));
2145        assert_eq!(merged.texture, Some(10.0));
2146    }
2147
2148    #[test]
2149    fn partial_detail_materialize_defaults() {
2150        let partial = PartialDetailParams::default();
2151        let concrete = partial.materialize();
2152        assert_eq!(concrete, crate::adjust::DetailParams::default());
2153    }
2154
2155    #[test]
2156    fn partial_detail_from_concrete_roundtrip() {
2157        let concrete = crate::adjust::DetailParams {
2158            sharpening: crate::adjust::SharpeningParams {
2159                amount: 40.0,
2160                radius: 2.0,
2161                threshold: 30.0,
2162                masking: 50.0,
2163            },
2164            clarity: 25.0,
2165            texture: -10.0,
2166        };
2167        let partial = PartialDetailParams::from(&concrete);
2168        let back = partial.materialize();
2169        assert_eq!(concrete, back);
2170    }
2171
2172    #[test]
2173    fn render_with_sharpening_differs_from_neutral() {
2174        let w = 16;
2175        let h = 16;
2176        let img = Rgb32FImage::from_fn(w, h, |x, _y| {
2177            let v = x as f32 / (w - 1) as f32;
2178            Rgb([v * 0.5, v * 0.5, v * 0.5])
2179        });
2180        let mut engine = Engine::new(img.clone());
2181        let neutral_render = engine.render().image;
2182
2183        engine.params_mut().detail.sharpening.amount = 80.0;
2184        engine.params_mut().detail.sharpening.threshold = 0.0;
2185        let sharp_render = engine.render().image;
2186
2187        let mut diffs = 0;
2188        for y in 2..h - 2 {
2189            for x in 2..w - 2 {
2190                let n = neutral_render.get_pixel(x, y);
2191                let s = sharp_render.get_pixel(x, y);
2192                if (n.0[0] - s.0[0]).abs() > 1e-4 {
2193                    diffs += 1;
2194                }
2195            }
2196        }
2197        assert!(diffs > 0, "sharpening should change at least some pixels");
2198    }
2199
2200    #[test]
2201    fn render_default_detail_is_identity() {
2202        let img = make_test_image(0.5, 0.3, 0.1);
2203        let mut engine = Engine::new(img);
2204        let rendered = engine.render().image;
2205        let orig = engine.original().get_pixel(0, 0);
2206        let rend = rendered.get_pixel(0, 0);
2207        for i in 0..3 {
2208            assert!(
2209                (orig.0[i] - rend.0[i]).abs() < 1e-5,
2210                "default detail should not change output"
2211            );
2212        }
2213    }
2214
2215    #[test]
2216    fn partial_dehaze_merge_and_materialize() {
2217        let base = PartialDehazeParams { amount: Some(30.0) };
2218        let overlay = PartialDehazeParams { amount: None };
2219        let merged = base.merge(&overlay);
2220        assert_eq!(merged.amount, Some(30.0));
2221
2222        let overlay2 = PartialDehazeParams { amount: Some(50.0) };
2223        let merged2 = base.merge(&overlay2);
2224        assert_eq!(merged2.amount, Some(50.0));
2225
2226        let empty = PartialDehazeParams::default();
2227        let mat = empty.materialize();
2228        assert_eq!(mat.amount, 0.0);
2229    }
2230
2231    #[test]
2232    fn render_with_dehaze_changes_output() {
2233        // Use a gradient image so dehaze has variation to work with
2234        let mut img = Rgb32FImage::new(10, 10);
2235        for y in 0..10 {
2236            for x in 0..10 {
2237                let t = (y * 10 + x) as f32 / 100.0;
2238                img.put_pixel(x, y, Rgb([0.4 + 0.4 * t, 0.4 + 0.3 * t, 0.45 + 0.3 * t]));
2239            }
2240        }
2241        let mut engine = Engine::new(img.clone());
2242        engine.params_mut().dehaze.amount = 50.0;
2243        let dehazed = engine.render().image;
2244        let neutral = Engine::new(img).render().image;
2245        let dp = dehazed.get_pixel(0, 0);
2246        let np = neutral.get_pixel(0, 0);
2247        let differs = (0..3).any(|i| (dp.0[i] - np.0[i]).abs() > 1e-4);
2248        assert!(differs, "Dehaze should change output");
2249    }
2250
2251    #[test]
2252    fn render_default_dehaze_is_identity() {
2253        let img = make_test_image(0.5, 0.3, 0.1);
2254        let mut engine = Engine::new(img);
2255        let rendered = engine.render().image;
2256        let orig = engine.original().get_pixel(0, 0);
2257        let rend = rendered.get_pixel(0, 0);
2258        for i in 0..3 {
2259            assert!(
2260                (orig.0[i] - rend.0[i]).abs() < 1e-5,
2261                "default dehaze should not change output"
2262            );
2263        }
2264    }
2265
2266    #[test]
2267    fn partial_nr_merge_and_materialize() {
2268        let base = PartialNoiseReductionParams {
2269            luminance: Some(30.0),
2270            color: Some(20.0),
2271            detail: None,
2272        };
2273        let overlay = PartialNoiseReductionParams {
2274            luminance: None,
2275            color: Some(40.0),
2276            detail: Some(50.0),
2277        };
2278        let merged = base.merge(&overlay);
2279        assert_eq!(merged.luminance, Some(30.0));
2280        assert_eq!(merged.color, Some(40.0));
2281        assert_eq!(merged.detail, Some(50.0));
2282
2283        let mat = merged.materialize();
2284        assert!((mat.luminance - 30.0).abs() < 1e-6);
2285        assert!((mat.color - 40.0).abs() < 1e-6);
2286        assert!((mat.detail - 50.0).abs() < 1e-6);
2287
2288        let empty = PartialNoiseReductionParams::default();
2289        let mat_empty = empty.materialize();
2290        assert!(mat_empty.is_neutral());
2291    }
2292
2293    #[test]
2294    fn render_with_nr_changes_output() {
2295        let mut img = Rgb32FImage::new(32, 32);
2296        let mut rng: u64 = 42;
2297        for y in 0..32 {
2298            for x in 0..32 {
2299                let base = (y * 32 + x) as f32 / 1024.0 * 0.5 + 0.25;
2300                rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
2301                let noise = ((rng >> 33) as f32 / (1u64 << 31) as f32 - 0.5) * 0.1;
2302                let v = (base + noise).clamp(0.0, 1.0);
2303                img.put_pixel(x, y, Rgb([v, v, v]));
2304            }
2305        }
2306        let mut engine = Engine::new(img.clone());
2307        engine.params_mut().noise_reduction.luminance = 50.0;
2308        let denoised = engine.render().image;
2309        let neutral = Engine::new(img).render().image;
2310        let dp = denoised.get_pixel(0, 0);
2311        let np = neutral.get_pixel(0, 0);
2312        let differs = (0..3).any(|i| (dp.0[i] - np.0[i]).abs() > 1e-4);
2313        assert!(differs, "Noise reduction should change output");
2314    }
2315
2316    #[test]
2317    fn render_default_nr_is_identity() {
2318        let img = make_test_image(0.5, 0.3, 0.1);
2319        let mut engine = Engine::new(img);
2320        let rendered = engine.render().image;
2321        let orig = engine.original().get_pixel(0, 0);
2322        let rend = rendered.get_pixel(0, 0);
2323        for i in 0..3 {
2324            assert!(
2325                (orig.0[i] - rend.0[i]).abs() < 1e-5,
2326                "default NR should not change output"
2327            );
2328        }
2329    }
2330
2331    #[test]
2332    fn partial_grain_merge_and_materialize() {
2333        let base = PartialGrainParams {
2334            grain_type: Some(crate::adjust::GrainType::Silver),
2335            amount: Some(30.0),
2336            size: None,
2337            seed: None,
2338        };
2339        let overlay = PartialGrainParams {
2340            grain_type: None,
2341            amount: None,
2342            size: Some(60.0),
2343            seed: None,
2344        };
2345        let merged = base.merge(&overlay);
2346        let concrete = merged.materialize();
2347        assert_eq!(concrete.grain_type, crate::adjust::GrainType::Silver);
2348        assert_eq!(concrete.amount, 30.0);
2349        assert_eq!(concrete.size, 60.0);
2350    }
2351
2352    #[test]
2353    fn render_default_grain_is_identity() {
2354        let img = make_test_image(0.5, 0.3, 0.1);
2355        let mut engine = Engine::new(img);
2356        let rendered = engine.render().image;
2357        let orig = engine.original().get_pixel(0, 0);
2358        let rend = rendered.get_pixel(0, 0);
2359        for i in 0..3 {
2360            assert!(
2361                (orig.0[i] - rend.0[i]).abs() < 1e-5,
2362                "default grain should be identity"
2363            );
2364        }
2365    }
2366
2367    #[test]
2368    fn render_with_grain_changes_output() {
2369        // Use a larger image to get meaningful grain variation across pixels
2370        let img = ImageBuffer::from_pixel(64, 64, Rgb([0.5f32, 0.5, 0.5]));
2371        let mut engine = Engine::new(img);
2372        let before = engine.render().image;
2373        engine.params_mut().grain = crate::adjust::GrainParams {
2374            grain_type: crate::adjust::GrainType::Silver,
2375            amount: 50.0,
2376            size: 50.0,
2377            seed: None,
2378        };
2379        let after = engine.render().image;
2380        // Check multiple pixels — grain is random so at least some should differ
2381        let mut changed = false;
2382        for y in 0..64 {
2383            for x in 0..64 {
2384                let bp = before.get_pixel(x, y);
2385                let ap = after.get_pixel(x, y);
2386                if (0..3).any(|i| (bp.0[i] - ap.0[i]).abs() > 1e-5) {
2387                    changed = true;
2388                    break;
2389                }
2390            }
2391            if changed {
2392                break;
2393            }
2394        }
2395        assert!(changed, "grain should change render output");
2396    }
2397}
2398
2399#[cfg(all(test, feature = "docgen"))]
2400mod docgen_tests {
2401    use super::*;
2402    use schemars::schema::{RootSchema, Schema, SchemaObject};
2403
2404    fn schema_object(schema: &Schema) -> &SchemaObject {
2405        match schema {
2406            Schema::Object(object) => object,
2407            Schema::Bool(_) => panic!("expected object schema"),
2408        }
2409    }
2410
2411    fn resolve_schema_object<'a>(
2412        root: &'a RootSchema,
2413        schema: &'a SchemaObject,
2414    ) -> &'a SchemaObject {
2415        let mut current = schema;
2416        loop {
2417            if let Some(reference) = current.reference.as_deref() {
2418                let definition = reference
2419                    .strip_prefix("#/definitions/")
2420                    .unwrap_or_else(|| panic!("unsupported schema reference '{reference}'"));
2421                current = schema_object(
2422                    root.definitions
2423                        .get(definition)
2424                        .unwrap_or_else(|| panic!("missing schema definition '{definition}'")),
2425                );
2426                continue;
2427            }
2428
2429            if let Some(all_of) = current
2430                .subschemas
2431                .as_ref()
2432                .and_then(|subschemas| subschemas.all_of.as_deref())
2433            {
2434                if all_of.len() == 1 {
2435                    current = schema_object(&all_of[0]);
2436                    continue;
2437                }
2438            }
2439
2440            return current;
2441        }
2442    }
2443
2444    fn property_schema<'a>(
2445        root: &'a RootSchema,
2446        schema: &'a SchemaObject,
2447        property: &str,
2448    ) -> &'a SchemaObject {
2449        let properties = &resolve_schema_object(root, schema)
2450            .object
2451            .as_ref()
2452            .unwrap_or_else(|| panic!("schema has no object validation for {property}"))
2453            .properties;
2454        resolve_schema_object(
2455            root,
2456            properties
2457                .get(property)
2458                .map(schema_object)
2459                .unwrap_or_else(|| panic!("missing property '{property}'")),
2460        )
2461    }
2462
2463    fn property_range(root: &RootSchema, path: &[&str]) -> (f64, f64) {
2464        let mut current = &root.schema;
2465        for property in path {
2466            current = property_schema(root, current, property);
2467        }
2468
2469        let number = resolve_schema_object(root, current)
2470            .number
2471            .as_ref()
2472            .unwrap_or_else(|| {
2473                panic!("property '{}' is missing number validation", path.join("."))
2474            });
2475        (
2476            number
2477                .minimum
2478                .unwrap_or_else(|| panic!("property '{}' is missing minimum", path.join("."))),
2479            number
2480                .maximum
2481                .unwrap_or_else(|| panic!("property '{}' is missing maximum", path.join("."))),
2482        )
2483    }
2484
2485    fn assert_range(root: &RootSchema, path: &[&str], min: f32, max: f32) {
2486        let actual = property_range(root, path);
2487        let expected = (f64::from(min), f64::from(max));
2488        assert_eq!(
2489            actual,
2490            expected,
2491            "schema range drift for {}",
2492            path.join(".")
2493        );
2494    }
2495
2496    fn assert_color_wheel_ranges(root: &RootSchema, base_path: &[&str]) {
2497        let mut hue_path = base_path.to_vec();
2498        hue_path.push("hue");
2499        assert_range(root, &hue_path, CW_HUE_MIN, CW_HUE_MAX);
2500
2501        let mut saturation_path = base_path.to_vec();
2502        saturation_path.push("saturation");
2503        assert_range(root, &saturation_path, CW_SATURATION_MIN, CW_SATURATION_MAX);
2504
2505        let mut luminance_path = base_path.to_vec();
2506        luminance_path.push("luminance");
2507        assert_range(root, &luminance_path, CW_LUMINANCE_MIN, CW_LUMINANCE_MAX);
2508    }
2509
2510    fn assert_sharpening_ranges(root: &RootSchema, base_path: &[&str]) {
2511        let mut amount_path = base_path.to_vec();
2512        amount_path.push("amount");
2513        assert_range(
2514            root,
2515            &amount_path,
2516            crate::adjust::detail::SHARPEN_AMOUNT_MIN,
2517            crate::adjust::detail::SHARPEN_AMOUNT_MAX,
2518        );
2519
2520        let mut radius_path = base_path.to_vec();
2521        radius_path.push("radius");
2522        assert_range(
2523            root,
2524            &radius_path,
2525            crate::adjust::detail::SHARPEN_RADIUS_MIN,
2526            crate::adjust::detail::SHARPEN_RADIUS_MAX,
2527        );
2528
2529        let mut threshold_path = base_path.to_vec();
2530        threshold_path.push("threshold");
2531        assert_range(
2532            root,
2533            &threshold_path,
2534            crate::adjust::detail::SHARPEN_THRESHOLD_MIN,
2535            crate::adjust::detail::SHARPEN_THRESHOLD_MAX,
2536        );
2537
2538        let mut masking_path = base_path.to_vec();
2539        masking_path.push("masking");
2540        assert_range(
2541            root,
2542            &masking_path,
2543            crate::adjust::detail::SHARPEN_MASKING_MIN,
2544            crate::adjust::detail::SHARPEN_MASKING_MAX,
2545        );
2546    }
2547
2548    #[test]
2549    fn schema_ranges_match_constants() {
2550        let parameters_schema = schemars::schema_for!(Parameters);
2551        assert_range(
2552            &parameters_schema,
2553            &["hsl", "red", "hue"],
2554            HSL_HUE_MIN,
2555            HSL_HUE_MAX,
2556        );
2557        assert_range(
2558            &parameters_schema,
2559            &["hsl", "red", "saturation"],
2560            HSL_SL_MIN,
2561            HSL_SL_MAX,
2562        );
2563        assert_range(
2564            &parameters_schema,
2565            &["hsl", "red", "luminance"],
2566            HSL_SL_MIN,
2567            HSL_SL_MAX,
2568        );
2569        assert_range(
2570            &parameters_schema,
2571            &["vignette", "amount"],
2572            VIGNETTE_AMOUNT_MIN,
2573            VIGNETTE_AMOUNT_MAX,
2574        );
2575        assert_color_wheel_ranges(&parameters_schema, &["color_grading", "shadows"]);
2576        assert_color_wheel_ranges(&parameters_schema, &["color_grading", "midtones"]);
2577        assert_color_wheel_ranges(&parameters_schema, &["color_grading", "highlights"]);
2578        assert_color_wheel_ranges(&parameters_schema, &["color_grading", "global"]);
2579        assert_range(
2580            &parameters_schema,
2581            &["color_grading", "balance"],
2582            CG_BALANCE_MIN,
2583            CG_BALANCE_MAX,
2584        );
2585        assert_sharpening_ranges(&parameters_schema, &["detail", "sharpening"]);
2586        assert_range(
2587            &parameters_schema,
2588            &["detail", "clarity"],
2589            crate::adjust::detail::DETAIL_SLIDER_MIN,
2590            crate::adjust::detail::DETAIL_SLIDER_MAX,
2591        );
2592        assert_range(
2593            &parameters_schema,
2594            &["detail", "texture"],
2595            crate::adjust::detail::DETAIL_SLIDER_MIN,
2596            crate::adjust::detail::DETAIL_SLIDER_MAX,
2597        );
2598        assert_range(
2599            &parameters_schema,
2600            &["dehaze", "amount"],
2601            crate::adjust::dehaze::DEHAZE_AMOUNT_MIN,
2602            crate::adjust::dehaze::DEHAZE_AMOUNT_MAX,
2603        );
2604        for field in ["luminance", "color", "detail"] {
2605            assert_range(
2606                &parameters_schema,
2607                &["noise_reduction", field],
2608                crate::adjust::denoise::NR_MIN,
2609                crate::adjust::denoise::NR_MAX,
2610            );
2611        }
2612        for field in ["amount", "size"] {
2613            assert_range(
2614                &parameters_schema,
2615                &["grain", field],
2616                crate::adjust::grain::GRAIN_PARAM_MIN,
2617                crate::adjust::grain::GRAIN_PARAM_MAX,
2618            );
2619        }
2620        assert_range(
2621            &parameters_schema,
2622            &["exposure"],
2623            EXPOSURE_MIN,
2624            EXPOSURE_MAX,
2625        );
2626        for field in ["contrast", "highlights", "shadows", "whites", "blacks"] {
2627            assert_range(
2628                &parameters_schema,
2629                &[field],
2630                TONE_SLIDER_MIN,
2631                TONE_SLIDER_MAX,
2632            );
2633        }
2634
2635        let hsl_channel_schema = schemars::schema_for!(HslChannel);
2636        assert_range(&hsl_channel_schema, &["hue"], HSL_HUE_MIN, HSL_HUE_MAX);
2637        assert_range(&hsl_channel_schema, &["saturation"], HSL_SL_MIN, HSL_SL_MAX);
2638        assert_range(&hsl_channel_schema, &["luminance"], HSL_SL_MIN, HSL_SL_MAX);
2639
2640        let vignette_schema = schemars::schema_for!(VignetteParams);
2641        assert_range(
2642            &vignette_schema,
2643            &["amount"],
2644            VIGNETTE_AMOUNT_MIN,
2645            VIGNETTE_AMOUNT_MAX,
2646        );
2647
2648        let color_wheel_schema = schemars::schema_for!(crate::adjust::ColorWheel);
2649        assert_range(&color_wheel_schema, &["hue"], CW_HUE_MIN, CW_HUE_MAX);
2650        assert_range(
2651            &color_wheel_schema,
2652            &["saturation"],
2653            CW_SATURATION_MIN,
2654            CW_SATURATION_MAX,
2655        );
2656        assert_range(
2657            &color_wheel_schema,
2658            &["luminance"],
2659            CW_LUMINANCE_MIN,
2660            CW_LUMINANCE_MAX,
2661        );
2662
2663        let color_grading_schema = schemars::schema_for!(crate::adjust::ColorGradingParams);
2664        assert_color_wheel_ranges(&color_grading_schema, &["shadows"]);
2665        assert_color_wheel_ranges(&color_grading_schema, &["midtones"]);
2666        assert_color_wheel_ranges(&color_grading_schema, &["highlights"]);
2667        assert_color_wheel_ranges(&color_grading_schema, &["global"]);
2668        assert_range(
2669            &color_grading_schema,
2670            &["balance"],
2671            CG_BALANCE_MIN,
2672            CG_BALANCE_MAX,
2673        );
2674
2675        let grain_schema = schemars::schema_for!(crate::adjust::GrainParams);
2676        assert_range(
2677            &grain_schema,
2678            &["amount"],
2679            crate::adjust::grain::GRAIN_PARAM_MIN,
2680            crate::adjust::grain::GRAIN_PARAM_MAX,
2681        );
2682        assert_range(
2683            &grain_schema,
2684            &["size"],
2685            crate::adjust::grain::GRAIN_PARAM_MIN,
2686            crate::adjust::grain::GRAIN_PARAM_MAX,
2687        );
2688
2689        let dehaze_schema = schemars::schema_for!(crate::adjust::DehazeParams);
2690        assert_range(
2691            &dehaze_schema,
2692            &["amount"],
2693            crate::adjust::dehaze::DEHAZE_AMOUNT_MIN,
2694            crate::adjust::dehaze::DEHAZE_AMOUNT_MAX,
2695        );
2696
2697        let noise_reduction_schema = schemars::schema_for!(crate::adjust::NoiseReductionParams);
2698        for field in ["luminance", "color", "detail"] {
2699            assert_range(
2700                &noise_reduction_schema,
2701                &[field],
2702                crate::adjust::denoise::NR_MIN,
2703                crate::adjust::denoise::NR_MAX,
2704            );
2705        }
2706
2707        let sharpening_schema = schemars::schema_for!(crate::adjust::SharpeningParams);
2708        assert_sharpening_ranges(&sharpening_schema, &[]);
2709
2710        let detail_schema = schemars::schema_for!(crate::adjust::DetailParams);
2711        assert_sharpening_ranges(&detail_schema, &["sharpening"]);
2712        assert_range(
2713            &detail_schema,
2714            &["clarity"],
2715            crate::adjust::detail::DETAIL_SLIDER_MIN,
2716            crate::adjust::detail::DETAIL_SLIDER_MAX,
2717        );
2718        assert_range(
2719            &detail_schema,
2720            &["texture"],
2721            crate::adjust::detail::DETAIL_SLIDER_MIN,
2722            crate::adjust::detail::DETAIL_SLIDER_MAX,
2723        );
2724    }
2725}