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 encode = batch.output.encode_options()?;
283    let run = batch::BatchRun {
284        input_dir: &batch.input_dir,
285        output_dir: &batch.output_dir,
286        recursive: batch.recursive,
287        encode: &encode,
288        suffix: batch.suffix.as_deref(),
289        jobs: batch.jobs,
290        skip_errors: batch.skip_errors,
291        use_gpu,
292    };
293    let summary = batch::run_batch_apply(&run, preset_path);
294    if !summary.failed.is_empty() {
295        process::exit(1);
296    }
297    Ok(())
298}
299
300fn run_batch_edit(edit: &EditArgs, batch: &BatchOpts, use_gpu: bool) -> agx::Result<()> {
301    let params = edit.to_params()?;
302    let lut_data = edit.load_lut()?;
303    let encode = batch.output.encode_options()?;
304    let run = batch::BatchRun {
305        input_dir: &batch.input_dir,
306        output_dir: &batch.output_dir,
307        recursive: batch.recursive,
308        encode: &encode,
309        suffix: batch.suffix.as_deref(),
310        jobs: batch.jobs,
311        skip_errors: batch.skip_errors,
312        use_gpu,
313    };
314    let summary = batch::run_batch_edit(&run, &params, lut_data);
315    if !summary.failed.is_empty() {
316        process::exit(1);
317    }
318    Ok(())
319}
320
321/// Render a decoded image with an optional preset and encode to PNG.
322/// Used by `run_multi_apply` for both sequential and parallel paths.
323fn render_and_encode(
324    image: image::Rgb32FImage,
325    preset: Option<&agx::Preset>,
326    output_path: &std::path::Path,
327    metadata: Option<&agx::metadata::ImageMetadata>,
328    use_gpu: bool,
329) -> agx::Result<()> {
330    let mut engine = create_engine(image, use_gpu);
331    if let Some(p) = preset {
332        engine.apply_preset(p);
333    }
334    let result = engine.render();
335    let final_path = agx::encode::encode_to_file_with_options(
336        &result.image,
337        output_path,
338        &agx::encode::EncodeOptions::default(),
339        metadata,
340    )?;
341    println!("Saved to {}", final_path.display());
342    Ok(())
343}
344
345fn run_multi_apply(
346    input: &std::path::Path,
347    presets: &[PathBuf],
348    output_dir: &std::path::Path,
349    noop: bool,
350    jobs: usize,
351    use_gpu: bool,
352) -> agx::Result<()> {
353    let image_stem = input
354        .file_stem()
355        .and_then(|s| s.to_str())
356        .unwrap_or("output");
357
358    std::fs::create_dir_all(output_dir).map_err(agx::AgxError::Io)?;
359
360    let metadata = agx::metadata::extract_metadata(input);
361    let decoded = agx::decode::decode(input)?;
362
363    for path in presets {
364        warn_unknown_preset_fields(path);
365    }
366
367    let loaded: Vec<(String, agx::Preset)> = presets
368        .iter()
369        .map(|path| {
370            let name = path
371                .file_stem()
372                .and_then(|s| s.to_str())
373                .unwrap_or("unknown")
374                .to_string();
375            let preset = agx::Preset::load_from_file(path)?;
376            Ok((name, preset))
377        })
378        .collect::<agx::Result<Vec<_>>>()?;
379
380    if noop {
381        let noop_path = output_dir.join(format!("{image_stem}_noop.png"));
382        render_and_encode(
383            decoded.clone(),
384            None,
385            &noop_path,
386            metadata.as_ref(),
387            use_gpu,
388        )?;
389    }
390
391    if jobs <= 1 {
392        for (name, preset) in &loaded {
393            let out_path = output_dir.join(format!("{image_stem}_{name}.png"));
394            render_and_encode(
395                decoded.clone(),
396                Some(preset),
397                &out_path,
398                metadata.as_ref(),
399                use_gpu,
400            )?;
401        }
402    } else {
403        // OS threads for concurrency control; each render's internal rayon
404        // parallelism uses the global pool. Chunks bound concurrent memory usage.
405        let errors: std::sync::Mutex<Vec<agx::AgxError>> = std::sync::Mutex::new(Vec::new());
406
407        for chunk in loaded.chunks(jobs) {
408            std::thread::scope(|s| {
409                for (name, preset) in chunk {
410                    let decoded = &decoded;
411                    let metadata = &metadata;
412                    let errors = &errors;
413                    s.spawn(move || {
414                        let out_path = output_dir.join(format!("{image_stem}_{name}.png"));
415                        match render_and_encode(
416                            decoded.clone(),
417                            Some(preset),
418                            &out_path,
419                            metadata.as_ref(),
420                            use_gpu,
421                        ) {
422                            Ok(()) => {}
423                            Err(e) => errors.lock().unwrap().push(e),
424                        }
425                    });
426                }
427            });
428        }
429
430        let errs = errors.into_inner().unwrap();
431        if let Some(first) = errs.into_iter().next() {
432            return Err(first);
433        }
434    }
435
436    Ok(())
437}