Skip to main content

agx/
main.rs

1//! AgX command-line interface.
2//!
3//! See the [project site](https://zhjngli.github.io/AgX/reference/cli.html)
4//! for the full CLI reference.
5
6#![deny(missing_docs)]
7#![deny(rustdoc::broken_intra_doc_links)]
8
9use std::path::PathBuf;
10use std::process;
11
12use clap::Parser;
13
14use agx::Preset;
15use agx_cli::{create_engine, BatchOpts, Cli, Commands, EditArgs, OutputOpts};
16
17mod batch;
18
19/// Print preset unknown-field warnings to stderr. Apply-time variant of the
20/// validate command's unknown-field detection — warns rather than errors so
21/// apply continues with what it understood.
22///
23/// Runs both the structural top-level pass and the semantic nested-unknowns
24/// walk so that all 12 preset tables are covered.
25fn warn_unknown_preset_fields(preset_path: &std::path::Path) {
26    let toml_str = match std::fs::read_to_string(preset_path) {
27        Ok(s) => s,
28        Err(_) => return, // Apply path will surface the read error
29    };
30    let mut diags = agx::preset::validate::detect_unknown_fields(&toml_str);
31    diags.extend(agx::preset::validate::find_unknown_fields(&toml_str));
32    for diag in &diags {
33        eprintln!(
34            "warning: {}:{}: {}",
35            preset_path.display(),
36            diag.location.line,
37            diag.message,
38        );
39    }
40}
41
42fn main() {
43    let cli = Cli::parse();
44    let use_gpu = cli.gpu;
45
46    let result = match cli.command {
47        Commands::Validate {
48            paths,
49            quiet,
50            format,
51        } => {
52            process::exit(agx_cli::validate::run_validate(&paths, quiet, format));
53        }
54        Commands::Apply {
55            input,
56            preset,
57            presets,
58            output,
59            output_opts,
60        } => run_apply(
61            &input,
62            preset.as_deref(),
63            &presets,
64            &output,
65            &output_opts,
66            use_gpu,
67        ),
68        Commands::Edit {
69            input,
70            output,
71            edit,
72            output_opts,
73        } => run_edit(&input, &output, &edit, &output_opts, use_gpu),
74        Commands::BatchApply { preset, batch } => run_batch_apply(&preset, &batch, use_gpu),
75        Commands::BatchEdit { edit, batch } => run_batch_edit(&edit, &batch, use_gpu),
76        Commands::MultiApply {
77            input,
78            preset,
79            output,
80            noop,
81            jobs,
82        } => run_multi_apply(&input, &preset, &output, noop, jobs, use_gpu),
83    };
84
85    if let Err(e) = result {
86        eprintln!("Error: {e}");
87        process::exit(1);
88    }
89}
90
91#[cfg(feature = "profiling")]
92fn write_profile_entry(
93    path: &std::path::Path,
94    image_name: &str,
95    preset_name: &str,
96    dimensions: (u32, u32),
97    decode_ms: f64,
98    render_profile: &agx::RenderProfile,
99    encode_ms: f64,
100) -> agx::Result<()> {
101    use std::io::Write;
102
103    let mut stages = serde_json::Map::new();
104    stages.insert("decode".to_string(), serde_json::Value::from(decode_ms));
105    for (name, ms) in &render_profile.stages {
106        stages.insert(name.clone(), serde_json::Value::from(*ms));
107    }
108    stages.insert("encode".to_string(), serde_json::Value::from(encode_ms));
109
110    let total_ms = decode_ms + render_profile.total_ms + encode_ms;
111
112    let entry = serde_json::json!({
113        "image": image_name,
114        "preset": preset_name,
115        "dimensions": [dimensions.0, dimensions.1],
116        "stages": stages,
117        "total_ms": total_ms,
118    });
119
120    let mut entries: Vec<serde_json::Value> = match std::fs::read_to_string(path) {
121        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
122        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
123        Err(e) => return Err(agx::AgxError::Io(e)),
124    };
125    entries.push(entry);
126
127    let mut file = std::fs::File::create(path).map_err(agx::AgxError::Io)?;
128    file.write_all(serde_json::to_string_pretty(&entries).unwrap().as_bytes())
129        .map_err(agx::AgxError::Io)?;
130    Ok(())
131}
132
133fn run_apply(
134    input: &std::path::Path,
135    preset_path: Option<&std::path::Path>,
136    presets: &[PathBuf],
137    output: &std::path::Path,
138    output_opts: &OutputOpts,
139    use_gpu: bool,
140) -> agx::Result<()> {
141    #[cfg(feature = "profiling")]
142    let decode_start = std::time::Instant::now();
143
144    let metadata = agx::metadata::extract_metadata(input);
145    let linear = agx::decode::decode(input)?;
146
147    #[cfg(feature = "profiling")]
148    let decode_ms = decode_start.elapsed().as_secs_f64() * 1000.0;
149
150    let mut engine = create_engine(linear, use_gpu);
151
152    #[cfg(feature = "profiling")]
153    let preset_name = if !presets.is_empty() {
154        presets
155            .iter()
156            .map(|p| {
157                p.file_stem()
158                    .unwrap_or_default()
159                    .to_string_lossy()
160                    .to_string()
161            })
162            .collect::<Vec<_>>()
163            .join("+")
164    } else if let Some(path) = preset_path {
165        path.file_stem()
166            .unwrap_or_default()
167            .to_string_lossy()
168            .to_string()
169    } else {
170        "none".to_string()
171    };
172
173    if !presets.is_empty() {
174        for path in presets {
175            warn_unknown_preset_fields(path);
176            let preset = Preset::load_from_file(path)?;
177            engine.layer_preset(&preset);
178        }
179    } else if let Some(path) = preset_path {
180        warn_unknown_preset_fields(path);
181        let preset = Preset::load_from_file(path)?;
182        engine.apply_preset(&preset);
183    }
184
185    let result = engine.render();
186    let rendered = result.image;
187    let opts = output_opts.encode_options()?;
188
189    #[cfg(feature = "profiling")]
190    let encode_start = std::time::Instant::now();
191
192    let final_path =
193        agx::encode::encode_to_file_with_options(&rendered, output, &opts, metadata.as_ref())?;
194
195    #[cfg(feature = "profiling")]
196    let encode_ms = encode_start.elapsed().as_secs_f64() * 1000.0;
197
198    println!("Saved to {}", final_path.display());
199
200    #[cfg(feature = "profiling")]
201    if let Some(ref profile_path) = output_opts.profile_output {
202        if let Some(profile) = result.profile {
203            let dims = (rendered.width(), rendered.height());
204            let image_name = input.file_name().unwrap_or_default().to_string_lossy();
205            write_profile_entry(
206                profile_path,
207                &image_name,
208                &preset_name,
209                dims,
210                decode_ms,
211                &profile,
212                encode_ms,
213            )?;
214        }
215    }
216
217    Ok(())
218}
219
220fn run_edit(
221    input: &std::path::Path,
222    output: &std::path::Path,
223    edit: &EditArgs,
224    output_opts: &OutputOpts,
225    use_gpu: bool,
226) -> agx::Result<()> {
227    #[cfg(feature = "profiling")]
228    let decode_start = std::time::Instant::now();
229
230    let metadata = agx::metadata::extract_metadata(input);
231    let linear = agx::decode::decode(input)?;
232
233    #[cfg(feature = "profiling")]
234    let decode_ms = decode_start.elapsed().as_secs_f64() * 1000.0;
235
236    let mut engine = create_engine(linear, use_gpu);
237    engine.set_params(edit.to_params()?);
238    if let Some(lut) = edit.load_lut()? {
239        engine.set_lut(Some(lut));
240    }
241    let result = engine.render();
242    let rendered = result.image;
243    let opts = output_opts.encode_options()?;
244
245    #[cfg(feature = "profiling")]
246    let encode_start = std::time::Instant::now();
247
248    let final_path =
249        agx::encode::encode_to_file_with_options(&rendered, output, &opts, metadata.as_ref())?;
250
251    #[cfg(feature = "profiling")]
252    let encode_ms = encode_start.elapsed().as_secs_f64() * 1000.0;
253
254    println!("Saved to {}", final_path.display());
255
256    #[cfg(feature = "profiling")]
257    if let Some(ref profile_path) = output_opts.profile_output {
258        if let Some(profile) = result.profile {
259            let dims = (rendered.width(), rendered.height());
260            let image_name = input.file_name().unwrap_or_default().to_string_lossy();
261            write_profile_entry(
262                profile_path,
263                &image_name,
264                "edit",
265                dims,
266                decode_ms,
267                &profile,
268                encode_ms,
269            )?;
270        }
271    }
272
273    Ok(())
274}
275
276fn run_batch_apply(
277    preset_path: &std::path::Path,
278    batch: &BatchOpts,
279    use_gpu: bool,
280) -> agx::Result<()> {
281    warn_unknown_preset_fields(preset_path);
282    let fmt = batch.output.parse_format()?;
283    let summary = batch::run_batch_apply(
284        &batch.input_dir,
285        preset_path,
286        &batch.output_dir,
287        batch.recursive,
288        batch.output.quality,
289        fmt,
290        batch.suffix.as_deref(),
291        batch.jobs,
292        batch.skip_errors,
293        use_gpu,
294    );
295    if !summary.failed.is_empty() {
296        process::exit(1);
297    }
298    Ok(())
299}
300
301fn run_batch_edit(edit: &EditArgs, batch: &BatchOpts, use_gpu: bool) -> agx::Result<()> {
302    let params = edit.to_params()?;
303    let lut_data = edit.load_lut()?;
304    let fmt = batch.output.parse_format()?;
305    let summary = batch::run_batch_edit(
306        &batch.input_dir,
307        &batch.output_dir,
308        batch.recursive,
309        &params,
310        lut_data,
311        batch.output.quality,
312        fmt,
313        batch.suffix.as_deref(),
314        batch.jobs,
315        batch.skip_errors,
316        use_gpu,
317    );
318    if !summary.failed.is_empty() {
319        process::exit(1);
320    }
321    Ok(())
322}
323
324/// Render a decoded image with an optional preset and encode to PNG.
325/// Used by `run_multi_apply` for both sequential and parallel paths.
326fn render_and_encode(
327    image: image::Rgb32FImage,
328    preset: Option<&agx::Preset>,
329    output_path: &std::path::Path,
330    metadata: Option<&agx::metadata::ImageMetadata>,
331    use_gpu: bool,
332) -> agx::Result<()> {
333    let mut engine = create_engine(image, use_gpu);
334    if let Some(p) = preset {
335        engine.apply_preset(p);
336    }
337    let result = engine.render();
338    let final_path = agx::encode::encode_to_file_with_options(
339        &result.image,
340        output_path,
341        &agx::encode::EncodeOptions::default(),
342        metadata,
343    )?;
344    println!("Saved to {}", final_path.display());
345    Ok(())
346}
347
348fn run_multi_apply(
349    input: &std::path::Path,
350    presets: &[PathBuf],
351    output_dir: &std::path::Path,
352    noop: bool,
353    jobs: usize,
354    use_gpu: bool,
355) -> agx::Result<()> {
356    let image_stem = input
357        .file_stem()
358        .and_then(|s| s.to_str())
359        .unwrap_or("output");
360
361    std::fs::create_dir_all(output_dir).map_err(agx::AgxError::Io)?;
362
363    let metadata = agx::metadata::extract_metadata(input);
364    let decoded = agx::decode::decode(input)?;
365
366    for path in presets {
367        warn_unknown_preset_fields(path);
368    }
369
370    let loaded: Vec<(String, agx::Preset)> = presets
371        .iter()
372        .map(|path| {
373            let name = path
374                .file_stem()
375                .and_then(|s| s.to_str())
376                .unwrap_or("unknown")
377                .to_string();
378            let preset = agx::Preset::load_from_file(path)?;
379            Ok((name, preset))
380        })
381        .collect::<agx::Result<Vec<_>>>()?;
382
383    if noop {
384        let noop_path = output_dir.join(format!("{image_stem}_noop.png"));
385        render_and_encode(
386            decoded.clone(),
387            None,
388            &noop_path,
389            metadata.as_ref(),
390            use_gpu,
391        )?;
392    }
393
394    if jobs <= 1 {
395        for (name, preset) in &loaded {
396            let out_path = output_dir.join(format!("{image_stem}_{name}.png"));
397            render_and_encode(
398                decoded.clone(),
399                Some(preset),
400                &out_path,
401                metadata.as_ref(),
402                use_gpu,
403            )?;
404        }
405    } else {
406        // OS threads for concurrency control; each render's internal rayon
407        // parallelism uses the global pool. Chunks bound concurrent memory usage.
408        let errors: std::sync::Mutex<Vec<agx::AgxError>> = std::sync::Mutex::new(Vec::new());
409
410        for chunk in loaded.chunks(jobs) {
411            std::thread::scope(|s| {
412                for (name, preset) in chunk {
413                    let decoded = &decoded;
414                    let metadata = &metadata;
415                    let errors = &errors;
416                    s.spawn(move || {
417                        let out_path = output_dir.join(format!("{image_stem}_{name}.png"));
418                        match render_and_encode(
419                            decoded.clone(),
420                            Some(preset),
421                            &out_path,
422                            metadata.as_ref(),
423                            use_gpu,
424                        ) {
425                            Ok(()) => {}
426                            Err(e) => errors.lock().unwrap().push(e),
427                        }
428                    });
429                }
430            });
431        }
432
433        let errs = errors.into_inner().unwrap();
434        if let Some(first) = errs.into_iter().next() {
435            return Err(first);
436        }
437    }
438
439    Ok(())
440}