1#![deny(missing_docs)]
7#![deny(rustdoc::broken_intra_doc_links)]
8
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use clap::{Args, CommandFactory, Parser, Subcommand};
13
14pub mod output;
15pub mod validate;
16
17pub fn create_engine(image: image::Rgb32FImage, use_gpu: bool) -> agx::Engine {
19 if use_gpu {
20 #[cfg(feature = "gpu")]
21 return agx::Engine::new_gpu_auto(image);
22 #[cfg(not(feature = "gpu"))]
23 eprintln!("Warning: --gpu requires the 'gpu' feature; using CPU");
24 }
25 agx::Engine::new(image)
26}
27
28#[derive(Parser)]
30#[command(name = "agx", about = "Photo editing CLI with portable TOML presets")]
31pub struct Cli {
32 #[arg(long, global = true)]
34 pub gpu: bool,
35 #[command(subcommand)]
37 pub command: Commands,
38}
39
40#[derive(Args)]
42pub struct OutputOpts {
43 #[arg(long, default_value_t = 92)]
45 pub quality: u8,
46 #[arg(long)]
48 format: Option<String>,
49 #[arg(long, default_value_t = agx::encode::OutputGamut::Srgb)]
52 pub output_gamut: agx::encode::OutputGamut,
53 #[cfg(feature = "profiling")]
55 #[arg(long)]
56 pub profile_output: Option<PathBuf>,
57}
58
59impl OutputOpts {
60 pub fn parse_format(&self) -> agx::Result<Option<agx::encode::OutputFormat>> {
62 self.format.as_deref().map(parse_output_format).transpose()
63 }
64
65 pub fn encode_options(&self) -> agx::Result<agx::encode::EncodeOptions> {
67 Ok(agx::encode::EncodeOptions {
68 jpeg_quality: self.quality,
69 format: self.parse_format()?,
70 output_gamut: self.output_gamut,
71 })
72 }
73}
74
75#[derive(Args)]
77pub struct HslArgs {
78 #[arg(
80 long = "hsl-red-hue",
81 visible_alias = "hsl-red-h",
82 default_value_t = 0.0,
83 allow_hyphen_values = true
84 )]
85 hsl_red_hue: f32,
86 #[arg(
88 long = "hsl-red-saturation",
89 visible_alias = "hsl-red-s",
90 default_value_t = 0.0,
91 allow_hyphen_values = true
92 )]
93 hsl_red_saturation: f32,
94 #[arg(
96 long = "hsl-red-luminance",
97 visible_alias = "hsl-red-l",
98 default_value_t = 0.0,
99 allow_hyphen_values = true
100 )]
101 hsl_red_luminance: f32,
102
103 #[arg(
105 long = "hsl-orange-hue",
106 visible_alias = "hsl-orange-h",
107 default_value_t = 0.0,
108 allow_hyphen_values = true
109 )]
110 hsl_orange_hue: f32,
111 #[arg(
113 long = "hsl-orange-saturation",
114 visible_alias = "hsl-orange-s",
115 default_value_t = 0.0,
116 allow_hyphen_values = true
117 )]
118 hsl_orange_saturation: f32,
119 #[arg(
121 long = "hsl-orange-luminance",
122 visible_alias = "hsl-orange-l",
123 default_value_t = 0.0,
124 allow_hyphen_values = true
125 )]
126 hsl_orange_luminance: f32,
127
128 #[arg(
130 long = "hsl-yellow-hue",
131 visible_alias = "hsl-yellow-h",
132 default_value_t = 0.0,
133 allow_hyphen_values = true
134 )]
135 hsl_yellow_hue: f32,
136 #[arg(
138 long = "hsl-yellow-saturation",
139 visible_alias = "hsl-yellow-s",
140 default_value_t = 0.0,
141 allow_hyphen_values = true
142 )]
143 hsl_yellow_saturation: f32,
144 #[arg(
146 long = "hsl-yellow-luminance",
147 visible_alias = "hsl-yellow-l",
148 default_value_t = 0.0,
149 allow_hyphen_values = true
150 )]
151 hsl_yellow_luminance: f32,
152
153 #[arg(
155 long = "hsl-green-hue",
156 visible_alias = "hsl-green-h",
157 default_value_t = 0.0,
158 allow_hyphen_values = true
159 )]
160 hsl_green_hue: f32,
161 #[arg(
163 long = "hsl-green-saturation",
164 visible_alias = "hsl-green-s",
165 default_value_t = 0.0,
166 allow_hyphen_values = true
167 )]
168 hsl_green_saturation: f32,
169 #[arg(
171 long = "hsl-green-luminance",
172 visible_alias = "hsl-green-l",
173 default_value_t = 0.0,
174 allow_hyphen_values = true
175 )]
176 hsl_green_luminance: f32,
177
178 #[arg(
180 long = "hsl-aqua-hue",
181 visible_alias = "hsl-aqua-h",
182 default_value_t = 0.0,
183 allow_hyphen_values = true
184 )]
185 hsl_aqua_hue: f32,
186 #[arg(
188 long = "hsl-aqua-saturation",
189 visible_alias = "hsl-aqua-s",
190 default_value_t = 0.0,
191 allow_hyphen_values = true
192 )]
193 hsl_aqua_saturation: f32,
194 #[arg(
196 long = "hsl-aqua-luminance",
197 visible_alias = "hsl-aqua-l",
198 default_value_t = 0.0,
199 allow_hyphen_values = true
200 )]
201 hsl_aqua_luminance: f32,
202
203 #[arg(
205 long = "hsl-blue-hue",
206 visible_alias = "hsl-blue-h",
207 default_value_t = 0.0,
208 allow_hyphen_values = true
209 )]
210 hsl_blue_hue: f32,
211 #[arg(
213 long = "hsl-blue-saturation",
214 visible_alias = "hsl-blue-s",
215 default_value_t = 0.0,
216 allow_hyphen_values = true
217 )]
218 hsl_blue_saturation: f32,
219 #[arg(
221 long = "hsl-blue-luminance",
222 visible_alias = "hsl-blue-l",
223 default_value_t = 0.0,
224 allow_hyphen_values = true
225 )]
226 hsl_blue_luminance: f32,
227
228 #[arg(
230 long = "hsl-purple-hue",
231 visible_alias = "hsl-purple-h",
232 default_value_t = 0.0,
233 allow_hyphen_values = true
234 )]
235 hsl_purple_hue: f32,
236 #[arg(
238 long = "hsl-purple-saturation",
239 visible_alias = "hsl-purple-s",
240 default_value_t = 0.0,
241 allow_hyphen_values = true
242 )]
243 hsl_purple_saturation: f32,
244 #[arg(
246 long = "hsl-purple-luminance",
247 visible_alias = "hsl-purple-l",
248 default_value_t = 0.0,
249 allow_hyphen_values = true
250 )]
251 hsl_purple_luminance: f32,
252
253 #[arg(
255 long = "hsl-magenta-hue",
256 visible_alias = "hsl-magenta-h",
257 default_value_t = 0.0,
258 allow_hyphen_values = true
259 )]
260 hsl_magenta_hue: f32,
261 #[arg(
263 long = "hsl-magenta-saturation",
264 visible_alias = "hsl-magenta-s",
265 default_value_t = 0.0,
266 allow_hyphen_values = true
267 )]
268 hsl_magenta_saturation: f32,
269 #[arg(
271 long = "hsl-magenta-luminance",
272 visible_alias = "hsl-magenta-l",
273 default_value_t = 0.0,
274 allow_hyphen_values = true
275 )]
276 hsl_magenta_luminance: f32,
277}
278
279impl HslArgs {
280 fn to_hsl_channels(&self) -> agx::HslChannels {
281 agx::HslChannels {
282 red: agx::HslChannel {
283 hue: self.hsl_red_hue,
284 saturation: self.hsl_red_saturation,
285 luminance: self.hsl_red_luminance,
286 },
287 orange: agx::HslChannel {
288 hue: self.hsl_orange_hue,
289 saturation: self.hsl_orange_saturation,
290 luminance: self.hsl_orange_luminance,
291 },
292 yellow: agx::HslChannel {
293 hue: self.hsl_yellow_hue,
294 saturation: self.hsl_yellow_saturation,
295 luminance: self.hsl_yellow_luminance,
296 },
297 green: agx::HslChannel {
298 hue: self.hsl_green_hue,
299 saturation: self.hsl_green_saturation,
300 luminance: self.hsl_green_luminance,
301 },
302 aqua: agx::HslChannel {
303 hue: self.hsl_aqua_hue,
304 saturation: self.hsl_aqua_saturation,
305 luminance: self.hsl_aqua_luminance,
306 },
307 blue: agx::HslChannel {
308 hue: self.hsl_blue_hue,
309 saturation: self.hsl_blue_saturation,
310 luminance: self.hsl_blue_luminance,
311 },
312 purple: agx::HslChannel {
313 hue: self.hsl_purple_hue,
314 saturation: self.hsl_purple_saturation,
315 luminance: self.hsl_purple_luminance,
316 },
317 magenta: agx::HslChannel {
318 hue: self.hsl_magenta_hue,
319 saturation: self.hsl_magenta_saturation,
320 luminance: self.hsl_magenta_luminance,
321 },
322 }
323 }
324}
325
326#[derive(Args)]
328pub struct EditArgs {
329 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
331 exposure: f32,
332 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
334 contrast: f32,
335 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
337 highlights: f32,
338 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
340 shadows: f32,
341 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
343 whites: f32,
344 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
346 blacks: f32,
347 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
349 temperature: f32,
350 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
352 tint: f32,
353 #[arg(long)]
355 lut: Option<PathBuf>,
356
357 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
359 vignette_amount: f32,
360 #[arg(long, default_value = "elliptical")]
362 vignette_shape: agx::VignetteShape,
363
364 #[arg(long = "cg-shadows-hue", default_value_t = 0.0)]
367 cg_shadows_hue: f32,
368 #[arg(long = "cg-shadows-sat", default_value_t = 0.0)]
370 cg_shadows_sat: f32,
371 #[arg(
373 long = "cg-shadows-lum",
374 default_value_t = 0.0,
375 allow_hyphen_values = true
376 )]
377 cg_shadows_lum: f32,
378 #[arg(long = "cg-midtones-hue", default_value_t = 0.0)]
380 cg_midtones_hue: f32,
381 #[arg(long = "cg-midtones-sat", default_value_t = 0.0)]
383 cg_midtones_sat: f32,
384 #[arg(
386 long = "cg-midtones-lum",
387 default_value_t = 0.0,
388 allow_hyphen_values = true
389 )]
390 cg_midtones_lum: f32,
391 #[arg(long = "cg-highlights-hue", default_value_t = 0.0)]
393 cg_highlights_hue: f32,
394 #[arg(long = "cg-highlights-sat", default_value_t = 0.0)]
396 cg_highlights_sat: f32,
397 #[arg(
399 long = "cg-highlights-lum",
400 default_value_t = 0.0,
401 allow_hyphen_values = true
402 )]
403 cg_highlights_lum: f32,
404 #[arg(long = "cg-global-hue", default_value_t = 0.0)]
406 cg_global_hue: f32,
407 #[arg(long = "cg-global-sat", default_value_t = 0.0)]
409 cg_global_sat: f32,
410 #[arg(
412 long = "cg-global-lum",
413 default_value_t = 0.0,
414 allow_hyphen_values = true
415 )]
416 cg_global_lum: f32,
417 #[arg(long = "cg-balance", default_value_t = 0.0, allow_hyphen_values = true)]
419 cg_balance: f32,
420
421 #[arg(long = "tc-rgb")]
423 tc_rgb: Option<String>,
424 #[arg(long = "tc-luma")]
426 tc_luma: Option<String>,
427 #[arg(long = "tc-red")]
429 tc_red: Option<String>,
430 #[arg(long = "tc-green")]
432 tc_green: Option<String>,
433 #[arg(long = "tc-blue")]
435 tc_blue: Option<String>,
436
437 #[arg(long = "sharpen-amount", default_value_t = 0.0)]
439 sharpen_amount: f32,
440 #[arg(long = "sharpen-radius", default_value_t = 1.0)]
442 sharpen_radius: f32,
443 #[arg(long = "sharpen-threshold", default_value_t = 25.0)]
445 sharpen_threshold: f32,
446 #[arg(long = "sharpen-masking", default_value_t = 0.0)]
448 sharpen_masking: f32,
449 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
451 clarity: f32,
452 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
454 texture: f32,
455
456 #[arg(
458 long = "dehaze-amount",
459 default_value_t = 0.0,
460 allow_hyphen_values = true
461 )]
462 dehaze_amount: f32,
463
464 #[arg(long = "nr-luminance", default_value_t = 0.0)]
466 nr_luminance: f32,
467 #[arg(long = "nr-color", default_value_t = 0.0)]
469 nr_color: f32,
470 #[arg(long = "nr-detail", default_value_t = 0.0)]
472 nr_detail: f32,
473
474 #[arg(long = "grain-type", default_value_t = agx::GrainType::Silver)]
476 grain_type: agx::GrainType,
477 #[arg(long = "grain-amount", default_value_t = 0.0)]
479 grain_amount: f32,
480 #[arg(long = "grain-size", default_value_t = 50.0)]
482 grain_size: f32,
483
484 #[command(flatten)]
485 hsl: HslArgs,
486}
487
488fn parse_curve_points(s: &str) -> Result<agx::ToneCurve, String> {
489 let mut points = Vec::new();
490 for pair in s.split(',') {
491 let pair = pair.trim();
492 let parts: Vec<&str> = pair.split(':').collect();
493 if parts.len() != 2 {
494 return Err(format!("invalid point '{pair}', expected x:y"));
495 }
496 let x: f32 = parts[0]
497 .trim()
498 .parse()
499 .map_err(|_| format!("invalid x value in '{pair}'"))?;
500 let y: f32 = parts[1]
501 .trim()
502 .parse()
503 .map_err(|_| format!("invalid y value in '{pair}'"))?;
504 points.push((x, y));
505 }
506 let curve = agx::ToneCurve { points };
507 curve.validate()?;
508 Ok(curve)
509}
510
511impl EditArgs {
512 pub fn to_params(&self) -> agx::Result<agx::Parameters> {
514 fn parse_tc(flag: &Option<String>) -> agx::Result<agx::ToneCurve> {
515 match flag {
516 Some(s) => parse_curve_points(s)
517 .map_err(|e| agx::AgxError::Preset(format!("Error parsing tone curve: {e}"))),
518 None => Ok(agx::ToneCurve::default()),
519 }
520 }
521
522 Ok(agx::Parameters {
523 exposure: self.exposure,
524 contrast: self.contrast,
525 highlights: self.highlights,
526 shadows: self.shadows,
527 whites: self.whites,
528 blacks: self.blacks,
529 temperature: self.temperature,
530 tint: self.tint,
531 hsl: self.hsl.to_hsl_channels(),
532 vignette: agx::VignetteParams {
533 amount: self.vignette_amount,
534 shape: self.vignette_shape,
535 },
536 color_grading: agx::ColorGradingParams {
537 shadows: agx::ColorWheel {
538 hue: self.cg_shadows_hue,
539 saturation: self.cg_shadows_sat,
540 luminance: self.cg_shadows_lum,
541 },
542 midtones: agx::ColorWheel {
543 hue: self.cg_midtones_hue,
544 saturation: self.cg_midtones_sat,
545 luminance: self.cg_midtones_lum,
546 },
547 highlights: agx::ColorWheel {
548 hue: self.cg_highlights_hue,
549 saturation: self.cg_highlights_sat,
550 luminance: self.cg_highlights_lum,
551 },
552 global: agx::ColorWheel {
553 hue: self.cg_global_hue,
554 saturation: self.cg_global_sat,
555 luminance: self.cg_global_lum,
556 },
557 balance: self.cg_balance,
558 },
559 tone_curve: agx::ToneCurveParams {
560 rgb: parse_tc(&self.tc_rgb)?,
561 luma: parse_tc(&self.tc_luma)?,
562 red: parse_tc(&self.tc_red)?,
563 green: parse_tc(&self.tc_green)?,
564 blue: parse_tc(&self.tc_blue)?,
565 },
566 detail: agx::DetailParams {
567 sharpening: agx::SharpeningParams {
568 amount: self.sharpen_amount,
569 radius: self.sharpen_radius,
570 threshold: self.sharpen_threshold,
571 masking: self.sharpen_masking,
572 },
573 clarity: self.clarity,
574 texture: self.texture,
575 },
576 dehaze: agx::DehazeParams {
577 amount: self.dehaze_amount,
578 },
579 noise_reduction: agx::NoiseReductionParams {
580 luminance: self.nr_luminance,
581 color: self.nr_color,
582 detail: self.nr_detail,
583 },
584 grain: agx::GrainParams {
585 grain_type: self.grain_type,
586 amount: self.grain_amount,
587 size: self.grain_size,
588 seed: None,
589 },
590 })
591 }
592
593 pub fn load_lut(&self) -> agx::Result<Option<Arc<agx::Lut3D>>> {
595 match &self.lut {
596 Some(lut_path) => Ok(Some(Arc::new(agx::Lut3D::from_cube_file(lut_path)?))),
597 None => Ok(None),
598 }
599 }
600}
601
602#[derive(Args)]
604pub struct BatchOpts {
605 #[arg(long)]
607 pub input_dir: PathBuf,
608 #[arg(long)]
610 pub output_dir: PathBuf,
611 #[arg(short, long, default_value_t = false)]
613 pub recursive: bool,
614 #[arg(short, long, default_value_t = 0)]
616 pub jobs: usize,
617 #[arg(long, default_value_t = false)]
619 pub skip_errors: bool,
620 #[arg(long)]
622 pub suffix: Option<String>,
623
624 #[command(flatten)]
626 pub output: OutputOpts,
627}
628
629#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
631pub enum OutputFormat {
632 Human,
634 Json,
636}
637
638#[derive(Subcommand)]
640pub enum Commands {
641 #[command(group = clap::ArgGroup::new("preset_source").required(true))]
643 Apply {
644 #[arg(short, long)]
646 input: PathBuf,
647 #[arg(short, long, group = "preset_source")]
649 preset: Option<PathBuf>,
650 #[arg(long, group = "preset_source", num_args = 1..)]
652 presets: Vec<PathBuf>,
653 #[arg(short, long)]
655 output: PathBuf,
656
657 #[command(flatten)]
659 output_opts: OutputOpts,
660 },
661 Edit {
663 #[arg(short, long)]
665 input: PathBuf,
666 #[arg(short, long)]
668 output: PathBuf,
669
670 #[command(flatten)]
672 edit: EditArgs,
673 #[command(flatten)]
675 output_opts: OutputOpts,
676 },
677 BatchApply {
679 #[arg(short, long)]
681 preset: PathBuf,
682
683 #[command(flatten)]
685 batch: BatchOpts,
686 },
687 BatchEdit {
689 #[command(flatten)]
691 edit: EditArgs,
692 #[command(flatten)]
694 batch: BatchOpts,
695 },
696 MultiApply {
698 #[arg(short, long)]
700 input: PathBuf,
701 #[arg(short, long, required = true, num_args = 1..)]
703 preset: Vec<PathBuf>,
704 #[arg(short, long)]
706 output: PathBuf,
707 #[arg(long, default_value_t = false)]
709 noop: bool,
710 #[arg(short, long, default_value_t = 1)]
712 jobs: usize,
713 },
714 Validate {
720 #[arg(required = true)]
722 paths: Vec<std::path::PathBuf>,
723
724 #[arg(short, long)]
726 quiet: bool,
727
728 #[arg(long, value_enum, default_value_t = OutputFormat::Human)]
730 format: OutputFormat,
731 },
732}
733
734fn parse_output_format(s: &str) -> agx::Result<agx::encode::OutputFormat> {
735 agx::encode::OutputFormat::from_extension(s).ok_or_else(|| {
736 agx::AgxError::Encode(format!(
737 "unsupported output format '{s}'. Use: jpeg, png, or tiff"
738 ))
739 })
740}
741
742pub fn build_cli() -> clap::Command {
744 Cli::command()
745}
746
747#[cfg(test)]
748mod tests {
749 use clap::Parser;
750
751 use super::{build_cli, Cli, Commands};
752
753 #[test]
754 fn build_cli_returns_valid_command() {
755 let command = build_cli();
756
757 command.clone().debug_assert();
758
759 assert_eq!(command.get_name(), "agx");
760
761 let subcommands: Vec<_> = command
762 .get_subcommands()
763 .map(|subcommand| subcommand.get_name().to_string())
764 .collect();
765
766 assert!(subcommands.iter().any(|name| name == "apply"));
767 assert!(subcommands.iter().any(|name| name == "edit"));
768 assert!(subcommands.iter().any(|name| name == "batch-apply"));
769 assert!(subcommands.iter().any(|name| name == "batch-edit"));
770 assert!(subcommands.iter().any(|name| name == "multi-apply"));
771 }
772
773 #[test]
774 fn output_gamut_flag_parses_into_encode_options() {
775 use super::{Cli, Commands};
776 let cli = Cli::parse_from([
777 "agx",
778 "apply",
779 "-i",
780 "in.png",
781 "-p",
782 "look.toml",
783 "-o",
784 "out.png",
785 "--output-gamut",
786 "p3",
787 ]);
788 let Commands::Apply { output_opts, .. } = cli.command else {
789 panic!("expected apply");
790 };
791 let opts = output_opts.encode_options().unwrap();
792 assert_eq!(opts.output_gamut, agx::encode::OutputGamut::DisplayP3);
793 }
794
795 #[test]
796 fn output_gamut_defaults_to_srgb() {
797 use super::{Cli, Commands};
798 let cli = Cli::parse_from([
799 "agx",
800 "apply",
801 "-i",
802 "in.png",
803 "-p",
804 "look.toml",
805 "-o",
806 "out.png",
807 ]);
808 let Commands::Apply { output_opts, .. } = cli.command else {
809 panic!("expected apply");
810 };
811 assert_eq!(
812 output_opts.encode_options().unwrap().output_gamut,
813 agx::encode::OutputGamut::Srgb
814 );
815 }
816
817 #[test]
818 fn edit_to_params_returns_error_for_invalid_tone_curve() {
819 let cli = Cli::parse_from([
820 "agx",
821 "edit",
822 "--input",
823 "input.png",
824 "--output",
825 "output.png",
826 "--tc-rgb",
827 "not-a-curve",
828 ]);
829
830 let Commands::Edit { edit, .. } = cli.command else {
831 panic!("expected edit command");
832 };
833
834 let error = edit.to_params().unwrap_err();
835
836 assert!(error.to_string().contains("Error parsing tone curve"));
837 }
838}