#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
use std::path::PathBuf;
use std::sync::Arc;
use clap::{Args, CommandFactory, Parser, Subcommand};
pub fn create_engine(image: image::Rgb32FImage, use_gpu: bool) -> agx::Engine {
if use_gpu {
#[cfg(feature = "gpu")]
return agx::Engine::new_gpu_auto(image);
#[cfg(not(feature = "gpu"))]
eprintln!("Warning: --gpu requires the 'gpu' feature; using CPU");
}
agx::Engine::new(image)
}
#[derive(Parser)]
#[command(name = "agx", about = "Photo editing CLI with portable TOML presets")]
pub struct Cli {
#[arg(long, global = true)]
pub gpu: bool,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Args)]
pub struct OutputOpts {
#[arg(long, default_value_t = 92)]
pub quality: u8,
#[arg(long)]
format: Option<String>,
#[cfg(feature = "profiling")]
#[arg(long)]
pub profile_output: Option<PathBuf>,
}
impl OutputOpts {
pub fn parse_format(&self) -> agx::Result<Option<agx::encode::OutputFormat>> {
self.format.as_deref().map(parse_output_format).transpose()
}
pub fn encode_options(&self) -> agx::Result<agx::encode::EncodeOptions> {
Ok(agx::encode::EncodeOptions {
jpeg_quality: self.quality,
format: self.parse_format()?,
})
}
}
#[derive(Args)]
pub struct HslArgs {
#[arg(
long = "hsl-red-hue",
visible_alias = "hsl-red-h",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_red_hue: f32,
#[arg(
long = "hsl-red-saturation",
visible_alias = "hsl-red-s",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_red_saturation: f32,
#[arg(
long = "hsl-red-luminance",
visible_alias = "hsl-red-l",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_red_luminance: f32,
#[arg(
long = "hsl-orange-hue",
visible_alias = "hsl-orange-h",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_orange_hue: f32,
#[arg(
long = "hsl-orange-saturation",
visible_alias = "hsl-orange-s",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_orange_saturation: f32,
#[arg(
long = "hsl-orange-luminance",
visible_alias = "hsl-orange-l",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_orange_luminance: f32,
#[arg(
long = "hsl-yellow-hue",
visible_alias = "hsl-yellow-h",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_yellow_hue: f32,
#[arg(
long = "hsl-yellow-saturation",
visible_alias = "hsl-yellow-s",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_yellow_saturation: f32,
#[arg(
long = "hsl-yellow-luminance",
visible_alias = "hsl-yellow-l",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_yellow_luminance: f32,
#[arg(
long = "hsl-green-hue",
visible_alias = "hsl-green-h",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_green_hue: f32,
#[arg(
long = "hsl-green-saturation",
visible_alias = "hsl-green-s",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_green_saturation: f32,
#[arg(
long = "hsl-green-luminance",
visible_alias = "hsl-green-l",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_green_luminance: f32,
#[arg(
long = "hsl-aqua-hue",
visible_alias = "hsl-aqua-h",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_aqua_hue: f32,
#[arg(
long = "hsl-aqua-saturation",
visible_alias = "hsl-aqua-s",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_aqua_saturation: f32,
#[arg(
long = "hsl-aqua-luminance",
visible_alias = "hsl-aqua-l",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_aqua_luminance: f32,
#[arg(
long = "hsl-blue-hue",
visible_alias = "hsl-blue-h",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_blue_hue: f32,
#[arg(
long = "hsl-blue-saturation",
visible_alias = "hsl-blue-s",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_blue_saturation: f32,
#[arg(
long = "hsl-blue-luminance",
visible_alias = "hsl-blue-l",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_blue_luminance: f32,
#[arg(
long = "hsl-purple-hue",
visible_alias = "hsl-purple-h",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_purple_hue: f32,
#[arg(
long = "hsl-purple-saturation",
visible_alias = "hsl-purple-s",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_purple_saturation: f32,
#[arg(
long = "hsl-purple-luminance",
visible_alias = "hsl-purple-l",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_purple_luminance: f32,
#[arg(
long = "hsl-magenta-hue",
visible_alias = "hsl-magenta-h",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_magenta_hue: f32,
#[arg(
long = "hsl-magenta-saturation",
visible_alias = "hsl-magenta-s",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_magenta_saturation: f32,
#[arg(
long = "hsl-magenta-luminance",
visible_alias = "hsl-magenta-l",
default_value_t = 0.0,
allow_hyphen_values = true
)]
hsl_magenta_luminance: f32,
}
impl HslArgs {
fn to_hsl_channels(&self) -> agx::HslChannels {
agx::HslChannels {
red: agx::HslChannel {
hue: self.hsl_red_hue,
saturation: self.hsl_red_saturation,
luminance: self.hsl_red_luminance,
},
orange: agx::HslChannel {
hue: self.hsl_orange_hue,
saturation: self.hsl_orange_saturation,
luminance: self.hsl_orange_luminance,
},
yellow: agx::HslChannel {
hue: self.hsl_yellow_hue,
saturation: self.hsl_yellow_saturation,
luminance: self.hsl_yellow_luminance,
},
green: agx::HslChannel {
hue: self.hsl_green_hue,
saturation: self.hsl_green_saturation,
luminance: self.hsl_green_luminance,
},
aqua: agx::HslChannel {
hue: self.hsl_aqua_hue,
saturation: self.hsl_aqua_saturation,
luminance: self.hsl_aqua_luminance,
},
blue: agx::HslChannel {
hue: self.hsl_blue_hue,
saturation: self.hsl_blue_saturation,
luminance: self.hsl_blue_luminance,
},
purple: agx::HslChannel {
hue: self.hsl_purple_hue,
saturation: self.hsl_purple_saturation,
luminance: self.hsl_purple_luminance,
},
magenta: agx::HslChannel {
hue: self.hsl_magenta_hue,
saturation: self.hsl_magenta_saturation,
luminance: self.hsl_magenta_luminance,
},
}
}
}
#[derive(Args)]
pub struct EditArgs {
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
exposure: f32,
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
contrast: f32,
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
highlights: f32,
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
shadows: f32,
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
whites: f32,
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
blacks: f32,
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
temperature: f32,
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
tint: f32,
#[arg(long)]
lut: Option<PathBuf>,
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
vignette_amount: f32,
#[arg(long, default_value = "elliptical")]
vignette_shape: agx::VignetteShape,
#[arg(long = "cg-shadows-hue", default_value_t = 0.0)]
cg_shadows_hue: f32,
#[arg(long = "cg-shadows-sat", default_value_t = 0.0)]
cg_shadows_sat: f32,
#[arg(
long = "cg-shadows-lum",
default_value_t = 0.0,
allow_hyphen_values = true
)]
cg_shadows_lum: f32,
#[arg(long = "cg-midtones-hue", default_value_t = 0.0)]
cg_midtones_hue: f32,
#[arg(long = "cg-midtones-sat", default_value_t = 0.0)]
cg_midtones_sat: f32,
#[arg(
long = "cg-midtones-lum",
default_value_t = 0.0,
allow_hyphen_values = true
)]
cg_midtones_lum: f32,
#[arg(long = "cg-highlights-hue", default_value_t = 0.0)]
cg_highlights_hue: f32,
#[arg(long = "cg-highlights-sat", default_value_t = 0.0)]
cg_highlights_sat: f32,
#[arg(
long = "cg-highlights-lum",
default_value_t = 0.0,
allow_hyphen_values = true
)]
cg_highlights_lum: f32,
#[arg(long = "cg-global-hue", default_value_t = 0.0)]
cg_global_hue: f32,
#[arg(long = "cg-global-sat", default_value_t = 0.0)]
cg_global_sat: f32,
#[arg(
long = "cg-global-lum",
default_value_t = 0.0,
allow_hyphen_values = true
)]
cg_global_lum: f32,
#[arg(long = "cg-balance", default_value_t = 0.0, allow_hyphen_values = true)]
cg_balance: f32,
#[arg(long = "tc-rgb")]
tc_rgb: Option<String>,
#[arg(long = "tc-luma")]
tc_luma: Option<String>,
#[arg(long = "tc-red")]
tc_red: Option<String>,
#[arg(long = "tc-green")]
tc_green: Option<String>,
#[arg(long = "tc-blue")]
tc_blue: Option<String>,
#[arg(long = "sharpen-amount", default_value_t = 0.0)]
sharpen_amount: f32,
#[arg(long = "sharpen-radius", default_value_t = 1.0)]
sharpen_radius: f32,
#[arg(long = "sharpen-threshold", default_value_t = 25.0)]
sharpen_threshold: f32,
#[arg(long = "sharpen-masking", default_value_t = 0.0)]
sharpen_masking: f32,
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
clarity: f32,
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
texture: f32,
#[arg(
long = "dehaze-amount",
default_value_t = 0.0,
allow_hyphen_values = true
)]
dehaze_amount: f32,
#[arg(long = "nr-luminance", default_value_t = 0.0)]
nr_luminance: f32,
#[arg(long = "nr-color", default_value_t = 0.0)]
nr_color: f32,
#[arg(long = "nr-detail", default_value_t = 0.0)]
nr_detail: f32,
#[arg(long = "grain-type", default_value_t = agx::GrainType::Silver)]
grain_type: agx::GrainType,
#[arg(long = "grain-amount", default_value_t = 0.0)]
grain_amount: f32,
#[arg(long = "grain-size", default_value_t = 50.0)]
grain_size: f32,
#[command(flatten)]
hsl: HslArgs,
}
fn parse_curve_points(s: &str) -> Result<agx::ToneCurve, String> {
let mut points = Vec::new();
for pair in s.split(',') {
let pair = pair.trim();
let parts: Vec<&str> = pair.split(':').collect();
if parts.len() != 2 {
return Err(format!("invalid point '{pair}', expected x:y"));
}
let x: f32 = parts[0]
.trim()
.parse()
.map_err(|_| format!("invalid x value in '{pair}'"))?;
let y: f32 = parts[1]
.trim()
.parse()
.map_err(|_| format!("invalid y value in '{pair}'"))?;
points.push((x, y));
}
let curve = agx::ToneCurve { points };
curve.validate()?;
Ok(curve)
}
impl EditArgs {
pub fn to_params(&self) -> agx::Result<agx::Parameters> {
fn parse_tc(flag: &Option<String>) -> agx::Result<agx::ToneCurve> {
match flag {
Some(s) => parse_curve_points(s)
.map_err(|e| agx::AgxError::Preset(format!("Error parsing tone curve: {e}"))),
None => Ok(agx::ToneCurve::default()),
}
}
Ok(agx::Parameters {
exposure: self.exposure,
contrast: self.contrast,
highlights: self.highlights,
shadows: self.shadows,
whites: self.whites,
blacks: self.blacks,
temperature: self.temperature,
tint: self.tint,
hsl: self.hsl.to_hsl_channels(),
vignette: agx::VignetteParams {
amount: self.vignette_amount,
shape: self.vignette_shape,
},
color_grading: agx::ColorGradingParams {
shadows: agx::ColorWheel {
hue: self.cg_shadows_hue,
saturation: self.cg_shadows_sat,
luminance: self.cg_shadows_lum,
},
midtones: agx::ColorWheel {
hue: self.cg_midtones_hue,
saturation: self.cg_midtones_sat,
luminance: self.cg_midtones_lum,
},
highlights: agx::ColorWheel {
hue: self.cg_highlights_hue,
saturation: self.cg_highlights_sat,
luminance: self.cg_highlights_lum,
},
global: agx::ColorWheel {
hue: self.cg_global_hue,
saturation: self.cg_global_sat,
luminance: self.cg_global_lum,
},
balance: self.cg_balance,
},
tone_curve: agx::ToneCurveParams {
rgb: parse_tc(&self.tc_rgb)?,
luma: parse_tc(&self.tc_luma)?,
red: parse_tc(&self.tc_red)?,
green: parse_tc(&self.tc_green)?,
blue: parse_tc(&self.tc_blue)?,
},
detail: agx::DetailParams {
sharpening: agx::SharpeningParams {
amount: self.sharpen_amount,
radius: self.sharpen_radius,
threshold: self.sharpen_threshold,
masking: self.sharpen_masking,
},
clarity: self.clarity,
texture: self.texture,
},
dehaze: agx::DehazeParams {
amount: self.dehaze_amount,
},
noise_reduction: agx::NoiseReductionParams {
luminance: self.nr_luminance,
color: self.nr_color,
detail: self.nr_detail,
},
grain: agx::GrainParams {
grain_type: self.grain_type,
amount: self.grain_amount,
size: self.grain_size,
seed: None,
},
})
}
pub fn load_lut(&self) -> agx::Result<Option<Arc<agx::Lut3D>>> {
match &self.lut {
Some(lut_path) => Ok(Some(Arc::new(agx::Lut3D::from_cube_file(lut_path)?))),
None => Ok(None),
}
}
}
#[derive(Args)]
pub struct BatchOpts {
#[arg(long)]
pub input_dir: PathBuf,
#[arg(long)]
pub output_dir: PathBuf,
#[arg(short, long, default_value_t = false)]
pub recursive: bool,
#[arg(short, long, default_value_t = 0)]
pub jobs: usize,
#[arg(long, default_value_t = false)]
pub skip_errors: bool,
#[arg(long)]
pub suffix: Option<String>,
#[command(flatten)]
pub output: OutputOpts,
}
#[derive(Subcommand)]
pub enum Commands {
#[command(group = clap::ArgGroup::new("preset_source").required(true))]
Apply {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long, group = "preset_source")]
preset: Option<PathBuf>,
#[arg(long, group = "preset_source", num_args = 1..)]
presets: Vec<PathBuf>,
#[arg(short, long)]
output: PathBuf,
#[command(flatten)]
output_opts: OutputOpts,
},
Edit {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[command(flatten)]
edit: EditArgs,
#[command(flatten)]
output_opts: OutputOpts,
},
BatchApply {
#[arg(short, long)]
preset: PathBuf,
#[command(flatten)]
batch: BatchOpts,
},
BatchEdit {
#[command(flatten)]
edit: EditArgs,
#[command(flatten)]
batch: BatchOpts,
},
MultiApply {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long, required = true, num_args = 1..)]
preset: Vec<PathBuf>,
#[arg(short, long)]
output: PathBuf,
#[arg(long, default_value_t = false)]
noop: bool,
#[arg(short, long, default_value_t = 1)]
jobs: usize,
},
}
fn parse_output_format(s: &str) -> agx::Result<agx::encode::OutputFormat> {
agx::encode::OutputFormat::from_extension(s).ok_or_else(|| {
agx::AgxError::Encode(format!(
"unsupported output format '{s}'. Use: jpeg, png, or tiff"
))
})
}
pub fn build_cli() -> clap::Command {
Cli::command()
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::{build_cli, Cli, Commands};
#[test]
fn build_cli_returns_valid_command() {
let command = build_cli();
command.clone().debug_assert();
assert_eq!(command.get_name(), "agx");
let subcommands: Vec<_> = command
.get_subcommands()
.map(|subcommand| subcommand.get_name().to_string())
.collect();
assert!(subcommands.iter().any(|name| name == "apply"));
assert!(subcommands.iter().any(|name| name == "edit"));
assert!(subcommands.iter().any(|name| name == "batch-apply"));
assert!(subcommands.iter().any(|name| name == "batch-edit"));
assert!(subcommands.iter().any(|name| name == "multi-apply"));
}
#[test]
fn edit_to_params_returns_error_for_invalid_tone_curve() {
let cli = Cli::parse_from([
"agx",
"edit",
"--input",
"input.png",
"--output",
"output.png",
"--tc-rgb",
"not-a-curve",
]);
let Commands::Edit { edit, .. } = cli.command else {
panic!("expected edit command");
};
let error = edit.to_params().unwrap_err();
assert!(error.to_string().contains("Error parsing tone curve"));
}
}