1use std::sync::Arc;
4
5use image::Rgb32FImage;
6use serde::{Deserialize, Serialize};
7
8pub mod pipeline;
10
11#[cfg(feature = "gpu")]
13pub mod gpu;
14
15pub mod stages;
17
18#[cfg(feature = "profiling")]
21#[derive(Debug, Clone, serde::Serialize)]
22pub struct RenderProfile {
23 pub stages: Vec<(String, f64)>,
25 pub total_ms: f64,
27}
28
29#[derive(Debug, Clone)]
32pub struct RenderResult {
33 pub image: Rgb32FImage,
35 #[cfg(feature = "profiling")]
37 pub profile: Option<RenderProfile>,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ColorSpace {
43 LinearSrgb,
45 SrgbGamma,
47}
48
49pub struct RenderContext<'a> {
54 pub buf: Vec<[f32; 3]>,
56 pub width: u32,
58 pub height: u32,
60 pub params: &'a Parameters,
62 pub lut: Option<&'a crate::lut::Lut3D>,
64}
65
66pub trait Stage: Send + Sync {
75 fn name(&self) -> &'static str;
77
78 fn input_color_space(&self) -> ColorSpace;
80
81 fn output_color_space(&self) -> ColorSpace;
83
84 fn is_active(&self, params: &Parameters) -> bool;
87
88 fn prepare(&mut self, params: &Parameters);
91
92 fn process(&self, ctx: &mut RenderContext) -> Result<(), crate::error::AgxError>;
94}
95
96#[cfg_attr(feature = "docgen", derive(schemars::JsonSchema))]
100#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
101pub struct HslChannel {
102 #[serde(default)]
104 #[cfg_attr(feature = "docgen", schemars(range(min = -180.0, max = 180.0)))]
105 pub hue: f32,
106 #[serde(default)]
108 #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
109 pub saturation: f32,
110 #[serde(default)]
112 #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
113 pub luminance: f32,
114}
115
116#[cfg_attr(feature = "docgen", derive(schemars::JsonSchema))]
121#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
122pub struct HslChannels {
123 #[serde(default)]
125 pub red: HslChannel,
126 #[serde(default)]
128 pub orange: HslChannel,
129 #[serde(default)]
131 pub yellow: HslChannel,
132 #[serde(default)]
134 pub green: HslChannel,
135 #[serde(default)]
137 pub aqua: HslChannel,
138 #[serde(default)]
140 pub blue: HslChannel,
141 #[serde(default)]
143 pub purple: HslChannel,
144 #[serde(default)]
146 pub magenta: HslChannel,
147}
148
149impl HslChannels {
150 pub fn is_default(&self) -> bool {
152 *self == Self::default()
153 }
154
155 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 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 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
198pub const EXPOSURE_MIN: f32 = -5.0;
200pub const EXPOSURE_MAX: f32 = 5.0;
202pub const TONE_SLIDER_MIN: f32 = -100.0;
204pub const TONE_SLIDER_MAX: f32 = 100.0;
206pub const HSL_HUE_MIN: f32 = -180.0;
208pub const HSL_HUE_MAX: f32 = 180.0;
210pub const HSL_SL_MIN: f32 = -100.0;
212pub const HSL_SL_MAX: f32 = 100.0;
214pub const VIGNETTE_AMOUNT_MIN: f32 = -100.0;
216pub const VIGNETTE_AMOUNT_MAX: f32 = 100.0;
218pub const CG_BALANCE_MIN: f32 = -100.0;
220pub const CG_BALANCE_MAX: f32 = 100.0;
222pub const CW_HUE_MIN: f32 = 0.0;
224pub const CW_HUE_MAX: f32 = 360.0;
226pub const CW_SATURATION_MIN: f32 = 0.0;
228pub const CW_SATURATION_MAX: f32 = 100.0;
230pub const CW_LUMINANCE_MIN: f32 = -100.0;
232pub const CW_LUMINANCE_MAX: f32 = 100.0;
234
235#[cfg_attr(feature = "docgen", derive(schemars::JsonSchema))]
239#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
240pub struct VignetteParams {
241 #[serde(default)]
243 #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
244 pub amount: f32,
245 #[serde(default)]
247 pub shape: crate::adjust::VignetteShape,
248}
249
250impl VignetteParams {
251 pub fn is_default(&self) -> bool {
253 self.amount == 0.0 && self.shape == crate::adjust::VignetteShape::default()
254 }
255}
256
257#[cfg_attr(feature = "docgen", derive(schemars::JsonSchema))]
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262pub struct Parameters {
263 #[cfg_attr(feature = "docgen", schemars(range(min = -5.0, max = 5.0)))]
265 pub exposure: f32,
266 #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
268 pub contrast: f32,
269 #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
271 pub highlights: f32,
272 #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
274 pub shadows: f32,
275 #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
277 pub whites: f32,
278 #[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
280 pub blacks: f32,
281 pub temperature: f32,
283 pub tint: f32,
285 #[serde(default)]
287 pub hsl: HslChannels,
288 #[serde(default)]
290 pub vignette: VignetteParams,
291 #[serde(default)]
293 pub color_grading: crate::adjust::ColorGradingParams,
294 #[serde(default)]
296 pub tone_curve: crate::adjust::ToneCurveParams,
297 #[serde(default)]
299 pub detail: crate::adjust::DetailParams,
300 #[serde(default)]
302 pub dehaze: crate::adjust::DehazeParams,
303 #[serde(default)]
305 pub noise_reduction: crate::adjust::NoiseReductionParams,
306 #[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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
338pub struct PartialHslChannel {
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub hue: Option<f32>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub saturation: Option<f32>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub luminance: Option<f32>,
348}
349
350impl PartialHslChannel {
351 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 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
384pub struct PartialHslChannels {
385 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub red: Option<PartialHslChannel>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub orange: Option<PartialHslChannel>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub yellow: Option<PartialHslChannel>,
394 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub green: Option<PartialHslChannel>,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub aqua: Option<PartialHslChannel>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub blue: Option<PartialHslChannel>,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub purple: Option<PartialHslChannel>,
406 #[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 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 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
504pub struct PartialVignetteParams {
505 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub amount: Option<f32>,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub shape: Option<crate::adjust::VignetteShape>,
511}
512
513impl PartialVignetteParams {
514 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 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
544pub struct PartialColorWheel {
545 #[serde(default, skip_serializing_if = "Option::is_none")]
547 pub hue: Option<f32>,
548 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub saturation: Option<f32>,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub luminance: Option<f32>,
554}
555
556impl PartialColorWheel {
557 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 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
590pub struct PartialColorGradingParams {
591 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub shadows: Option<PartialColorWheel>,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub midtones: Option<PartialColorWheel>,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub highlights: Option<PartialColorWheel>,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub global: Option<PartialColorWheel>,
603 #[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 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 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
678pub struct PartialToneCurve {
679 pub points: Option<Vec<(f32, f32)>>,
681}
682
683impl PartialToneCurve {
684 pub fn merge(&self, overlay: &Self) -> Self {
686 Self {
687 points: overlay.points.clone().or_else(|| self.points.clone()),
688 }
689 }
690
691 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
714pub struct PartialToneCurveParams {
715 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub rgb: Option<PartialToneCurve>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub luma: Option<PartialToneCurve>,
721 #[serde(default, skip_serializing_if = "Option::is_none")]
723 pub red: Option<PartialToneCurve>,
724 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub green: Option<PartialToneCurve>,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub blue: Option<PartialToneCurve>,
730}
731
732impl PartialToneCurveParams {
733 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 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(¶ms.rgb)),
792 luma: Some(PartialToneCurve::from(¶ms.luma)),
793 red: Some(PartialToneCurve::from(¶ms.red)),
794 green: Some(PartialToneCurve::from(¶ms.green)),
795 blue: Some(PartialToneCurve::from(¶ms.blue)),
796 }
797 }
798}
799
800#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
804pub struct PartialSharpeningParams {
805 #[serde(default, skip_serializing_if = "Option::is_none")]
807 pub amount: Option<f32>,
808 #[serde(default, skip_serializing_if = "Option::is_none")]
810 pub radius: Option<f32>,
811 #[serde(default, skip_serializing_if = "Option::is_none")]
813 pub threshold: Option<f32>,
814 #[serde(default, skip_serializing_if = "Option::is_none")]
816 pub masking: Option<f32>,
817}
818
819impl PartialSharpeningParams {
820 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 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
857pub struct PartialDetailParams {
858 #[serde(default, skip_serializing_if = "Option::is_none")]
860 pub sharpening: Option<PartialSharpeningParams>,
861 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub clarity: Option<f32>,
864 #[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 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 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
919pub struct PartialDehazeParams {
920 #[serde(default, skip_serializing_if = "Option::is_none")]
922 pub amount: Option<f32>,
923}
924
925impl PartialDehazeParams {
926 pub fn merge(&self, overlay: &Self) -> Self {
928 Self {
929 amount: overlay.amount.or(self.amount),
930 }
931 }
932
933 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
953pub struct PartialNoiseReductionParams {
954 #[serde(default, skip_serializing_if = "Option::is_none")]
956 pub luminance: Option<f32>,
957 #[serde(default, skip_serializing_if = "Option::is_none")]
959 pub color: Option<f32>,
960 #[serde(default, skip_serializing_if = "Option::is_none")]
962 pub detail: Option<f32>,
963}
964
965impl PartialNoiseReductionParams {
966 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 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
999pub struct PartialGrainParams {
1000 #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
1002 pub grain_type: Option<crate::adjust::GrainType>,
1003 #[serde(default, skip_serializing_if = "Option::is_none")]
1005 pub amount: Option<f32>,
1006 #[serde(default, skip_serializing_if = "Option::is_none")]
1008 pub size: Option<f32>,
1009 #[serde(default, skip_serializing_if = "Option::is_none")]
1011 pub seed: Option<u64>,
1012}
1013
1014impl PartialGrainParams {
1015 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 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1051pub struct PartialParameters {
1052 #[serde(default, skip_serializing_if = "Option::is_none")]
1054 pub exposure: Option<f32>,
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1057 pub contrast: Option<f32>,
1058 #[serde(default, skip_serializing_if = "Option::is_none")]
1060 pub highlights: Option<f32>,
1061 #[serde(default, skip_serializing_if = "Option::is_none")]
1063 pub shadows: Option<f32>,
1064 #[serde(default, skip_serializing_if = "Option::is_none")]
1066 pub whites: Option<f32>,
1067 #[serde(default, skip_serializing_if = "Option::is_none")]
1069 pub blacks: Option<f32>,
1070 #[serde(default, skip_serializing_if = "Option::is_none")]
1072 pub temperature: Option<f32>,
1073 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub tint: Option<f32>,
1076 #[serde(default, skip_serializing_if = "Option::is_none")]
1078 pub hsl: Option<PartialHslChannels>,
1079 #[serde(default, skip_serializing_if = "Option::is_none")]
1081 pub vignette: Option<PartialVignetteParams>,
1082 #[serde(default, skip_serializing_if = "Option::is_none")]
1084 pub color_grading: Option<PartialColorGradingParams>,
1085 #[serde(default, skip_serializing_if = "Option::is_none")]
1087 pub tone_curve: Option<PartialToneCurveParams>,
1088 #[serde(default, skip_serializing_if = "Option::is_none")]
1090 pub detail: Option<PartialDetailParams>,
1091 #[serde(default, skip_serializing_if = "Option::is_none")]
1093 pub dehaze: Option<PartialDehazeParams>,
1094 #[serde(default, skip_serializing_if = "Option::is_none")]
1096 pub noise_reduction: Option<PartialNoiseReductionParams>,
1097 #[serde(default, skip_serializing_if = "Option::is_none")]
1099 pub grain: Option<PartialGrainParams>,
1100}
1101
1102impl PartialParameters {
1103 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 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(¶ms.hsl)),
1232 vignette: Some(PartialVignetteParams::from(¶ms.vignette)),
1233 color_grading: Some(PartialColorGradingParams::from(¶ms.color_grading)),
1234 tone_curve: Some(PartialToneCurveParams::from(¶ms.tone_curve)),
1235 detail: Some(PartialDetailParams::from(¶ms.detail)),
1236 dehaze: Some(PartialDehazeParams::from(¶ms.dehaze)),
1237 noise_reduction: Some(PartialNoiseReductionParams::from(¶ms.noise_reduction)),
1238 grain: Some(PartialGrainParams::from(¶ms.grain)),
1239 }
1240 }
1241}
1242
1243pub 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 pub fn new(image: Rgb32FImage) -> Self {
1276 Self::from_pipeline(image, Pipeline::Cpu(pipeline::CpuPipeline::new()))
1277 }
1278
1279 #[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 #[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 #[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 pub fn original(&self) -> &Rgb32FImage {
1314 &self.original
1315 }
1316
1317 pub fn params(&self) -> &Parameters {
1319 &self.params
1320 }
1321
1322 pub fn params_mut(&mut self) -> &mut Parameters {
1324 &mut self.params
1325 }
1326
1327 pub fn set_params(&mut self, params: Parameters) {
1329 self.params = params;
1330 }
1331
1332 pub fn lut(&self) -> Option<&crate::lut::Lut3D> {
1334 self.lut.as_deref()
1335 }
1336
1337 pub fn set_lut(&mut self, lut: Option<Arc<crate::lut::Lut3D>>) {
1342 self.lut = lut;
1343 }
1344
1345 pub fn apply_preset(&mut self, preset: &crate::preset::Preset) {
1347 self.params = preset.params();
1348 self.lut = preset.lut.clone();
1349 }
1350
1351 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 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 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 let rp = rendered.get_pixel(0, 0);
1453 let np = neutral.get_pixel(0, 0);
1454 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 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 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); assert_eq!(s[3], -30.0); assert_eq!(l[5], 20.0); }
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 let img = make_test_image(0.5, 0.01, 0.01);
1591 let mut engine = Engine::new(img);
1592 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 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 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 #[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 #[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(¶ms);
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 #[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 #[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 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 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 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 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 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 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 #[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 #[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 #[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 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 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 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 ¶meters_schema,
2553 &["hsl", "red", "hue"],
2554 HSL_HUE_MIN,
2555 HSL_HUE_MAX,
2556 );
2557 assert_range(
2558 ¶meters_schema,
2559 &["hsl", "red", "saturation"],
2560 HSL_SL_MIN,
2561 HSL_SL_MAX,
2562 );
2563 assert_range(
2564 ¶meters_schema,
2565 &["hsl", "red", "luminance"],
2566 HSL_SL_MIN,
2567 HSL_SL_MAX,
2568 );
2569 assert_range(
2570 ¶meters_schema,
2571 &["vignette", "amount"],
2572 VIGNETTE_AMOUNT_MIN,
2573 VIGNETTE_AMOUNT_MAX,
2574 );
2575 assert_color_wheel_ranges(¶meters_schema, &["color_grading", "shadows"]);
2576 assert_color_wheel_ranges(¶meters_schema, &["color_grading", "midtones"]);
2577 assert_color_wheel_ranges(¶meters_schema, &["color_grading", "highlights"]);
2578 assert_color_wheel_ranges(¶meters_schema, &["color_grading", "global"]);
2579 assert_range(
2580 ¶meters_schema,
2581 &["color_grading", "balance"],
2582 CG_BALANCE_MIN,
2583 CG_BALANCE_MAX,
2584 );
2585 assert_sharpening_ranges(¶meters_schema, &["detail", "sharpening"]);
2586 assert_range(
2587 ¶meters_schema,
2588 &["detail", "clarity"],
2589 crate::adjust::detail::DETAIL_SLIDER_MIN,
2590 crate::adjust::detail::DETAIL_SLIDER_MAX,
2591 );
2592 assert_range(
2593 ¶meters_schema,
2594 &["detail", "texture"],
2595 crate::adjust::detail::DETAIL_SLIDER_MIN,
2596 crate::adjust::detail::DETAIL_SLIDER_MAX,
2597 );
2598 assert_range(
2599 ¶meters_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 ¶meters_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 ¶meters_schema,
2615 &["grain", field],
2616 crate::adjust::grain::GRAIN_PARAM_MIN,
2617 crate::adjust::grain::GRAIN_PARAM_MAX,
2618 );
2619 }
2620 assert_range(
2621 ¶meters_schema,
2622 &["exposure"],
2623 EXPOSURE_MIN,
2624 EXPOSURE_MAX,
2625 );
2626 for field in ["contrast", "highlights", "shadows", "whites", "blacks"] {
2627 assert_range(
2628 ¶meters_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}