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 #[cfg(feature = "profiling")]
51 #[arg(long)]
52 pub profile_output: Option<PathBuf>,
53}
54
55impl OutputOpts {
56 pub fn parse_format(&self) -> agx::Result<Option<agx::encode::OutputFormat>> {
58 self.format.as_deref().map(parse_output_format).transpose()
59 }
60
61 pub fn encode_options(&self) -> agx::Result<agx::encode::EncodeOptions> {
63 Ok(agx::encode::EncodeOptions {
64 jpeg_quality: self.quality,
65 format: self.parse_format()?,
66 })
67 }
68}
69
70#[derive(Args)]
72pub struct HslArgs {
73 #[arg(
75 long = "hsl-red-hue",
76 visible_alias = "hsl-red-h",
77 default_value_t = 0.0,
78 allow_hyphen_values = true
79 )]
80 hsl_red_hue: f32,
81 #[arg(
83 long = "hsl-red-saturation",
84 visible_alias = "hsl-red-s",
85 default_value_t = 0.0,
86 allow_hyphen_values = true
87 )]
88 hsl_red_saturation: f32,
89 #[arg(
91 long = "hsl-red-luminance",
92 visible_alias = "hsl-red-l",
93 default_value_t = 0.0,
94 allow_hyphen_values = true
95 )]
96 hsl_red_luminance: f32,
97
98 #[arg(
100 long = "hsl-orange-hue",
101 visible_alias = "hsl-orange-h",
102 default_value_t = 0.0,
103 allow_hyphen_values = true
104 )]
105 hsl_orange_hue: f32,
106 #[arg(
108 long = "hsl-orange-saturation",
109 visible_alias = "hsl-orange-s",
110 default_value_t = 0.0,
111 allow_hyphen_values = true
112 )]
113 hsl_orange_saturation: f32,
114 #[arg(
116 long = "hsl-orange-luminance",
117 visible_alias = "hsl-orange-l",
118 default_value_t = 0.0,
119 allow_hyphen_values = true
120 )]
121 hsl_orange_luminance: f32,
122
123 #[arg(
125 long = "hsl-yellow-hue",
126 visible_alias = "hsl-yellow-h",
127 default_value_t = 0.0,
128 allow_hyphen_values = true
129 )]
130 hsl_yellow_hue: f32,
131 #[arg(
133 long = "hsl-yellow-saturation",
134 visible_alias = "hsl-yellow-s",
135 default_value_t = 0.0,
136 allow_hyphen_values = true
137 )]
138 hsl_yellow_saturation: f32,
139 #[arg(
141 long = "hsl-yellow-luminance",
142 visible_alias = "hsl-yellow-l",
143 default_value_t = 0.0,
144 allow_hyphen_values = true
145 )]
146 hsl_yellow_luminance: f32,
147
148 #[arg(
150 long = "hsl-green-hue",
151 visible_alias = "hsl-green-h",
152 default_value_t = 0.0,
153 allow_hyphen_values = true
154 )]
155 hsl_green_hue: f32,
156 #[arg(
158 long = "hsl-green-saturation",
159 visible_alias = "hsl-green-s",
160 default_value_t = 0.0,
161 allow_hyphen_values = true
162 )]
163 hsl_green_saturation: f32,
164 #[arg(
166 long = "hsl-green-luminance",
167 visible_alias = "hsl-green-l",
168 default_value_t = 0.0,
169 allow_hyphen_values = true
170 )]
171 hsl_green_luminance: f32,
172
173 #[arg(
175 long = "hsl-aqua-hue",
176 visible_alias = "hsl-aqua-h",
177 default_value_t = 0.0,
178 allow_hyphen_values = true
179 )]
180 hsl_aqua_hue: f32,
181 #[arg(
183 long = "hsl-aqua-saturation",
184 visible_alias = "hsl-aqua-s",
185 default_value_t = 0.0,
186 allow_hyphen_values = true
187 )]
188 hsl_aqua_saturation: f32,
189 #[arg(
191 long = "hsl-aqua-luminance",
192 visible_alias = "hsl-aqua-l",
193 default_value_t = 0.0,
194 allow_hyphen_values = true
195 )]
196 hsl_aqua_luminance: f32,
197
198 #[arg(
200 long = "hsl-blue-hue",
201 visible_alias = "hsl-blue-h",
202 default_value_t = 0.0,
203 allow_hyphen_values = true
204 )]
205 hsl_blue_hue: f32,
206 #[arg(
208 long = "hsl-blue-saturation",
209 visible_alias = "hsl-blue-s",
210 default_value_t = 0.0,
211 allow_hyphen_values = true
212 )]
213 hsl_blue_saturation: f32,
214 #[arg(
216 long = "hsl-blue-luminance",
217 visible_alias = "hsl-blue-l",
218 default_value_t = 0.0,
219 allow_hyphen_values = true
220 )]
221 hsl_blue_luminance: f32,
222
223 #[arg(
225 long = "hsl-purple-hue",
226 visible_alias = "hsl-purple-h",
227 default_value_t = 0.0,
228 allow_hyphen_values = true
229 )]
230 hsl_purple_hue: f32,
231 #[arg(
233 long = "hsl-purple-saturation",
234 visible_alias = "hsl-purple-s",
235 default_value_t = 0.0,
236 allow_hyphen_values = true
237 )]
238 hsl_purple_saturation: f32,
239 #[arg(
241 long = "hsl-purple-luminance",
242 visible_alias = "hsl-purple-l",
243 default_value_t = 0.0,
244 allow_hyphen_values = true
245 )]
246 hsl_purple_luminance: f32,
247
248 #[arg(
250 long = "hsl-magenta-hue",
251 visible_alias = "hsl-magenta-h",
252 default_value_t = 0.0,
253 allow_hyphen_values = true
254 )]
255 hsl_magenta_hue: f32,
256 #[arg(
258 long = "hsl-magenta-saturation",
259 visible_alias = "hsl-magenta-s",
260 default_value_t = 0.0,
261 allow_hyphen_values = true
262 )]
263 hsl_magenta_saturation: f32,
264 #[arg(
266 long = "hsl-magenta-luminance",
267 visible_alias = "hsl-magenta-l",
268 default_value_t = 0.0,
269 allow_hyphen_values = true
270 )]
271 hsl_magenta_luminance: f32,
272}
273
274impl HslArgs {
275 fn to_hsl_channels(&self) -> agx::HslChannels {
276 agx::HslChannels {
277 red: agx::HslChannel {
278 hue: self.hsl_red_hue,
279 saturation: self.hsl_red_saturation,
280 luminance: self.hsl_red_luminance,
281 },
282 orange: agx::HslChannel {
283 hue: self.hsl_orange_hue,
284 saturation: self.hsl_orange_saturation,
285 luminance: self.hsl_orange_luminance,
286 },
287 yellow: agx::HslChannel {
288 hue: self.hsl_yellow_hue,
289 saturation: self.hsl_yellow_saturation,
290 luminance: self.hsl_yellow_luminance,
291 },
292 green: agx::HslChannel {
293 hue: self.hsl_green_hue,
294 saturation: self.hsl_green_saturation,
295 luminance: self.hsl_green_luminance,
296 },
297 aqua: agx::HslChannel {
298 hue: self.hsl_aqua_hue,
299 saturation: self.hsl_aqua_saturation,
300 luminance: self.hsl_aqua_luminance,
301 },
302 blue: agx::HslChannel {
303 hue: self.hsl_blue_hue,
304 saturation: self.hsl_blue_saturation,
305 luminance: self.hsl_blue_luminance,
306 },
307 purple: agx::HslChannel {
308 hue: self.hsl_purple_hue,
309 saturation: self.hsl_purple_saturation,
310 luminance: self.hsl_purple_luminance,
311 },
312 magenta: agx::HslChannel {
313 hue: self.hsl_magenta_hue,
314 saturation: self.hsl_magenta_saturation,
315 luminance: self.hsl_magenta_luminance,
316 },
317 }
318 }
319}
320
321#[derive(Args)]
323pub struct EditArgs {
324 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
326 exposure: f32,
327 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
329 contrast: f32,
330 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
332 highlights: f32,
333 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
335 shadows: f32,
336 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
338 whites: f32,
339 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
341 blacks: f32,
342 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
344 temperature: f32,
345 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
347 tint: f32,
348 #[arg(long)]
350 lut: Option<PathBuf>,
351
352 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
354 vignette_amount: f32,
355 #[arg(long, default_value = "elliptical")]
357 vignette_shape: agx::VignetteShape,
358
359 #[arg(long = "cg-shadows-hue", default_value_t = 0.0)]
362 cg_shadows_hue: f32,
363 #[arg(long = "cg-shadows-sat", default_value_t = 0.0)]
365 cg_shadows_sat: f32,
366 #[arg(
368 long = "cg-shadows-lum",
369 default_value_t = 0.0,
370 allow_hyphen_values = true
371 )]
372 cg_shadows_lum: f32,
373 #[arg(long = "cg-midtones-hue", default_value_t = 0.0)]
375 cg_midtones_hue: f32,
376 #[arg(long = "cg-midtones-sat", default_value_t = 0.0)]
378 cg_midtones_sat: f32,
379 #[arg(
381 long = "cg-midtones-lum",
382 default_value_t = 0.0,
383 allow_hyphen_values = true
384 )]
385 cg_midtones_lum: f32,
386 #[arg(long = "cg-highlights-hue", default_value_t = 0.0)]
388 cg_highlights_hue: f32,
389 #[arg(long = "cg-highlights-sat", default_value_t = 0.0)]
391 cg_highlights_sat: f32,
392 #[arg(
394 long = "cg-highlights-lum",
395 default_value_t = 0.0,
396 allow_hyphen_values = true
397 )]
398 cg_highlights_lum: f32,
399 #[arg(long = "cg-global-hue", default_value_t = 0.0)]
401 cg_global_hue: f32,
402 #[arg(long = "cg-global-sat", default_value_t = 0.0)]
404 cg_global_sat: f32,
405 #[arg(
407 long = "cg-global-lum",
408 default_value_t = 0.0,
409 allow_hyphen_values = true
410 )]
411 cg_global_lum: f32,
412 #[arg(long = "cg-balance", default_value_t = 0.0, allow_hyphen_values = true)]
414 cg_balance: f32,
415
416 #[arg(long = "tc-rgb")]
418 tc_rgb: Option<String>,
419 #[arg(long = "tc-luma")]
421 tc_luma: Option<String>,
422 #[arg(long = "tc-red")]
424 tc_red: Option<String>,
425 #[arg(long = "tc-green")]
427 tc_green: Option<String>,
428 #[arg(long = "tc-blue")]
430 tc_blue: Option<String>,
431
432 #[arg(long = "sharpen-amount", default_value_t = 0.0)]
434 sharpen_amount: f32,
435 #[arg(long = "sharpen-radius", default_value_t = 1.0)]
437 sharpen_radius: f32,
438 #[arg(long = "sharpen-threshold", default_value_t = 25.0)]
440 sharpen_threshold: f32,
441 #[arg(long = "sharpen-masking", default_value_t = 0.0)]
443 sharpen_masking: f32,
444 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
446 clarity: f32,
447 #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
449 texture: f32,
450
451 #[arg(
453 long = "dehaze-amount",
454 default_value_t = 0.0,
455 allow_hyphen_values = true
456 )]
457 dehaze_amount: f32,
458
459 #[arg(long = "nr-luminance", default_value_t = 0.0)]
461 nr_luminance: f32,
462 #[arg(long = "nr-color", default_value_t = 0.0)]
464 nr_color: f32,
465 #[arg(long = "nr-detail", default_value_t = 0.0)]
467 nr_detail: f32,
468
469 #[arg(long = "grain-type", default_value_t = agx::GrainType::Silver)]
471 grain_type: agx::GrainType,
472 #[arg(long = "grain-amount", default_value_t = 0.0)]
474 grain_amount: f32,
475 #[arg(long = "grain-size", default_value_t = 50.0)]
477 grain_size: f32,
478
479 #[command(flatten)]
480 hsl: HslArgs,
481}
482
483fn parse_curve_points(s: &str) -> Result<agx::ToneCurve, String> {
484 let mut points = Vec::new();
485 for pair in s.split(',') {
486 let pair = pair.trim();
487 let parts: Vec<&str> = pair.split(':').collect();
488 if parts.len() != 2 {
489 return Err(format!("invalid point '{pair}', expected x:y"));
490 }
491 let x: f32 = parts[0]
492 .trim()
493 .parse()
494 .map_err(|_| format!("invalid x value in '{pair}'"))?;
495 let y: f32 = parts[1]
496 .trim()
497 .parse()
498 .map_err(|_| format!("invalid y value in '{pair}'"))?;
499 points.push((x, y));
500 }
501 let curve = agx::ToneCurve { points };
502 curve.validate()?;
503 Ok(curve)
504}
505
506impl EditArgs {
507 pub fn to_params(&self) -> agx::Result<agx::Parameters> {
509 fn parse_tc(flag: &Option<String>) -> agx::Result<agx::ToneCurve> {
510 match flag {
511 Some(s) => parse_curve_points(s)
512 .map_err(|e| agx::AgxError::Preset(format!("Error parsing tone curve: {e}"))),
513 None => Ok(agx::ToneCurve::default()),
514 }
515 }
516
517 Ok(agx::Parameters {
518 exposure: self.exposure,
519 contrast: self.contrast,
520 highlights: self.highlights,
521 shadows: self.shadows,
522 whites: self.whites,
523 blacks: self.blacks,
524 temperature: self.temperature,
525 tint: self.tint,
526 hsl: self.hsl.to_hsl_channels(),
527 vignette: agx::VignetteParams {
528 amount: self.vignette_amount,
529 shape: self.vignette_shape,
530 },
531 color_grading: agx::ColorGradingParams {
532 shadows: agx::ColorWheel {
533 hue: self.cg_shadows_hue,
534 saturation: self.cg_shadows_sat,
535 luminance: self.cg_shadows_lum,
536 },
537 midtones: agx::ColorWheel {
538 hue: self.cg_midtones_hue,
539 saturation: self.cg_midtones_sat,
540 luminance: self.cg_midtones_lum,
541 },
542 highlights: agx::ColorWheel {
543 hue: self.cg_highlights_hue,
544 saturation: self.cg_highlights_sat,
545 luminance: self.cg_highlights_lum,
546 },
547 global: agx::ColorWheel {
548 hue: self.cg_global_hue,
549 saturation: self.cg_global_sat,
550 luminance: self.cg_global_lum,
551 },
552 balance: self.cg_balance,
553 },
554 tone_curve: agx::ToneCurveParams {
555 rgb: parse_tc(&self.tc_rgb)?,
556 luma: parse_tc(&self.tc_luma)?,
557 red: parse_tc(&self.tc_red)?,
558 green: parse_tc(&self.tc_green)?,
559 blue: parse_tc(&self.tc_blue)?,
560 },
561 detail: agx::DetailParams {
562 sharpening: agx::SharpeningParams {
563 amount: self.sharpen_amount,
564 radius: self.sharpen_radius,
565 threshold: self.sharpen_threshold,
566 masking: self.sharpen_masking,
567 },
568 clarity: self.clarity,
569 texture: self.texture,
570 },
571 dehaze: agx::DehazeParams {
572 amount: self.dehaze_amount,
573 },
574 noise_reduction: agx::NoiseReductionParams {
575 luminance: self.nr_luminance,
576 color: self.nr_color,
577 detail: self.nr_detail,
578 },
579 grain: agx::GrainParams {
580 grain_type: self.grain_type,
581 amount: self.grain_amount,
582 size: self.grain_size,
583 seed: None,
584 },
585 })
586 }
587
588 pub fn load_lut(&self) -> agx::Result<Option<Arc<agx::Lut3D>>> {
590 match &self.lut {
591 Some(lut_path) => Ok(Some(Arc::new(agx::Lut3D::from_cube_file(lut_path)?))),
592 None => Ok(None),
593 }
594 }
595}
596
597#[derive(Args)]
599pub struct BatchOpts {
600 #[arg(long)]
602 pub input_dir: PathBuf,
603 #[arg(long)]
605 pub output_dir: PathBuf,
606 #[arg(short, long, default_value_t = false)]
608 pub recursive: bool,
609 #[arg(short, long, default_value_t = 0)]
611 pub jobs: usize,
612 #[arg(long, default_value_t = false)]
614 pub skip_errors: bool,
615 #[arg(long)]
617 pub suffix: Option<String>,
618
619 #[command(flatten)]
621 pub output: OutputOpts,
622}
623
624#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
626pub enum OutputFormat {
627 Human,
629 Json,
631}
632
633#[derive(Subcommand)]
635pub enum Commands {
636 #[command(group = clap::ArgGroup::new("preset_source").required(true))]
638 Apply {
639 #[arg(short, long)]
641 input: PathBuf,
642 #[arg(short, long, group = "preset_source")]
644 preset: Option<PathBuf>,
645 #[arg(long, group = "preset_source", num_args = 1..)]
647 presets: Vec<PathBuf>,
648 #[arg(short, long)]
650 output: PathBuf,
651
652 #[command(flatten)]
654 output_opts: OutputOpts,
655 },
656 Edit {
658 #[arg(short, long)]
660 input: PathBuf,
661 #[arg(short, long)]
663 output: PathBuf,
664
665 #[command(flatten)]
667 edit: EditArgs,
668 #[command(flatten)]
670 output_opts: OutputOpts,
671 },
672 BatchApply {
674 #[arg(short, long)]
676 preset: PathBuf,
677
678 #[command(flatten)]
680 batch: BatchOpts,
681 },
682 BatchEdit {
684 #[command(flatten)]
686 edit: EditArgs,
687 #[command(flatten)]
689 batch: BatchOpts,
690 },
691 MultiApply {
693 #[arg(short, long)]
695 input: PathBuf,
696 #[arg(short, long, required = true, num_args = 1..)]
698 preset: Vec<PathBuf>,
699 #[arg(short, long)]
701 output: PathBuf,
702 #[arg(long, default_value_t = false)]
704 noop: bool,
705 #[arg(short, long, default_value_t = 1)]
707 jobs: usize,
708 },
709 Validate {
715 #[arg(required = true)]
717 paths: Vec<std::path::PathBuf>,
718
719 #[arg(short, long)]
721 quiet: bool,
722
723 #[arg(long, value_enum, default_value_t = OutputFormat::Human)]
725 format: OutputFormat,
726 },
727}
728
729fn parse_output_format(s: &str) -> agx::Result<agx::encode::OutputFormat> {
730 agx::encode::OutputFormat::from_extension(s).ok_or_else(|| {
731 agx::AgxError::Encode(format!(
732 "unsupported output format '{s}'. Use: jpeg, png, or tiff"
733 ))
734 })
735}
736
737pub fn build_cli() -> clap::Command {
739 Cli::command()
740}
741
742#[cfg(test)]
743mod tests {
744 use clap::Parser;
745
746 use super::{build_cli, Cli, Commands};
747
748 #[test]
749 fn build_cli_returns_valid_command() {
750 let command = build_cli();
751
752 command.clone().debug_assert();
753
754 assert_eq!(command.get_name(), "agx");
755
756 let subcommands: Vec<_> = command
757 .get_subcommands()
758 .map(|subcommand| subcommand.get_name().to_string())
759 .collect();
760
761 assert!(subcommands.iter().any(|name| name == "apply"));
762 assert!(subcommands.iter().any(|name| name == "edit"));
763 assert!(subcommands.iter().any(|name| name == "batch-apply"));
764 assert!(subcommands.iter().any(|name| name == "batch-edit"));
765 assert!(subcommands.iter().any(|name| name == "multi-apply"));
766 }
767
768 #[test]
769 fn edit_to_params_returns_error_for_invalid_tone_curve() {
770 let cli = Cli::parse_from([
771 "agx",
772 "edit",
773 "--input",
774 "input.png",
775 "--output",
776 "output.png",
777 "--tc-rgb",
778 "not-a-curve",
779 ]);
780
781 let Commands::Edit { edit, .. } = cli.command else {
782 panic!("expected edit command");
783 };
784
785 let error = edit.to_params().unwrap_err();
786
787 assert!(error.to_string().contains("Error parsing tone curve"));
788 }
789}