Skip to main content

agx/
batch.rs

1use std::path::{Path, PathBuf};
2use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
3use std::sync::Arc;
4use std::time::Duration;
5use std::time::Instant;
6
7use agx_cli::create_engine;
8use rayon::prelude::*;
9
10/// Standard (non-raw) image file extensions recognized by the CLI.
11const STANDARD_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "tiff", "tif"];
12
13/// Returns `true` if `path` has a standard image extension or a known raw extension.
14fn is_image_file(path: &Path) -> bool {
15    let has_standard_ext = path
16        .extension()
17        .and_then(|ext| ext.to_str())
18        .is_some_and(|ext| STANDARD_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()));
19    has_standard_ext || agx::decode::is_raw_extension(path)
20}
21
22/// Scan `dir` for image files, optionally recursing into subdirectories.
23/// Returns a sorted `Vec<PathBuf>` of discovered image files.
24pub fn discover_images(dir: &Path, recursive: bool) -> Vec<PathBuf> {
25    let mut out = Vec::new();
26    collect_images(dir, recursive, &mut out);
27    out.sort();
28    out
29}
30
31/// Recursively (or not) collect image file paths from `dir` into `out`.
32fn collect_images(dir: &Path, recursive: bool, out: &mut Vec<PathBuf>) {
33    let entries = match std::fs::read_dir(dir) {
34        Ok(entries) => entries,
35        Err(_) => return,
36    };
37    for entry in entries.flatten() {
38        let path = entry.path();
39        if path.is_dir() {
40            if recursive {
41                collect_images(&path, recursive, out);
42            }
43        } else if path.is_file() && is_image_file(&path) {
44            out.push(path);
45        }
46    }
47}
48
49/// Resolve the output path for a processed image.
50///
51/// Mirrors the subdirectory structure from `input_dir` into `output_dir`,
52/// appends an optional suffix before the extension, and overrides the
53/// extension when `format_ext` is provided.  Raw-format inputs default to
54/// `.jpg` when no explicit format is given.
55pub fn resolve_output_path(
56    input: &Path,
57    input_dir: &Path,
58    output_dir: &Path,
59    suffix: Option<&str>,
60    format_ext: Option<&str>,
61) -> PathBuf {
62    // 1. Strip the input_dir prefix to get the relative path.
63    let relative = input
64        .strip_prefix(input_dir)
65        .unwrap_or(input.file_name().map(Path::new).unwrap_or(input));
66
67    // 2. Determine extension: explicit format > raw-default "jpg" > original.
68    let ext = if let Some(fmt) = format_ext {
69        fmt.to_string()
70    } else if agx::decode::is_raw_extension(input) {
71        "jpg".to_string()
72    } else {
73        input
74            .extension()
75            .and_then(|e| e.to_str())
76            .unwrap_or("jpg")
77            .to_string()
78    };
79
80    // 3. Get the file stem from the relative path's filename.
81    let stem = relative
82        .file_stem()
83        .and_then(|s| s.to_str())
84        .unwrap_or("output");
85
86    // 4. Build filename with optional suffix.
87    let filename = match suffix {
88        Some(s) => format!("{stem}{s}.{ext}"),
89        None => format!("{stem}.{ext}"),
90    };
91
92    // 5. Join output_dir + parent of relative + filename.
93    let parent = relative.parent().unwrap_or(Path::new(""));
94    output_dir.join(parent).join(filename)
95}
96
97/// Result of processing a single image in a batch.
98pub struct BatchResult {
99    pub input: PathBuf,
100    #[allow(dead_code)]
101    pub output: PathBuf,
102    pub outcome: Result<Duration, String>,
103}
104
105/// Summary of a batch run.
106pub struct BatchSummary {
107    #[allow(dead_code)]
108    pub total: usize,
109    #[allow(dead_code)]
110    pub succeeded: usize,
111    pub failed: Vec<(PathBuf, String)>,
112    #[allow(dead_code)]
113    pub elapsed: Duration,
114}
115
116/// Print progress for a completed image. Thread-safe via atomic counter.
117fn report_progress(
118    counter: &AtomicUsize,
119    total: usize,
120    input: &Path,
121    outcome: &Result<Duration, String>,
122) {
123    let n = counter.fetch_add(1, Ordering::Relaxed) + 1;
124    let name = input.file_name().and_then(|f| f.to_str()).unwrap_or("?");
125    match outcome {
126        Ok(dur) => eprintln!("[{n}/{total}] {name}... done ({:.1}s)", dur.as_secs_f64()),
127        Err(e) => eprintln!("[{n}/{total}] {name}... FAILED: {e}"),
128    }
129}
130
131/// Summarize batch results and print to stderr.
132pub fn summarize(results: &[BatchResult], elapsed: Duration) -> BatchSummary {
133    let total = results.len();
134    let mut succeeded = 0;
135    let mut failed = Vec::new();
136
137    for r in results {
138        match &r.outcome {
139            Ok(_) => succeeded += 1,
140            Err(e) => failed.push((r.input.clone(), e.clone())),
141        }
142    }
143
144    eprintln!(
145        "\nBatch complete: {succeeded}/{total} succeeded in {:.1}s",
146        elapsed.as_secs_f64()
147    );
148    if !failed.is_empty() {
149        eprintln!("Errors ({}):", failed.len());
150        for (path, err) in &failed {
151            eprintln!("  {}: {err}", path.display());
152        }
153    }
154
155    BatchSummary {
156        total,
157        succeeded,
158        failed,
159        elapsed,
160    }
161}
162
163/// Get the number of available CPU cores.
164fn num_cpus() -> usize {
165    std::thread::available_parallelism()
166        .map(|n| n.get())
167        .unwrap_or(1)
168}
169
170/// Process a single image: decode, configure engine via closure, render, encode.
171fn process_single(
172    input: &Path,
173    output: &Path,
174    quality: u8,
175    format: Option<agx::encode::OutputFormat>,
176    use_gpu: bool,
177    configure: impl FnOnce(&mut agx::Engine),
178) -> Result<Duration, String> {
179    let start = Instant::now();
180    let metadata = agx::metadata::extract_metadata(input);
181    let linear = agx::decode::decode(input).map_err(|e| e.to_string())?;
182    let mut engine = create_engine(linear, use_gpu);
183    configure(&mut engine);
184    let result = engine.render();
185    let rendered = result.image;
186    let opts = agx::encode::EncodeOptions {
187        jpeg_quality: quality,
188        format,
189    };
190
191    if let Some(parent) = output.parent() {
192        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
193    }
194
195    agx::encode::encode_to_file_with_options(&rendered, output, &opts, metadata.as_ref())
196        .map_err(|e| e.to_string())?;
197    Ok(start.elapsed())
198}
199
200/// Pre-derived runner state for batch operations.
201struct BatchContext<'a> {
202    input_dir: &'a Path,
203    output_dir: &'a Path,
204    recursive: bool,
205    format_ext: Option<&'static str>,
206    suffix: Option<&'a str>,
207    jobs: usize,
208    skip_errors: bool,
209}
210
211/// Generic batch processing: discover images, process in parallel, summarize.
212fn run_batch<F>(opts: &BatchContext<'_>, process: F) -> BatchSummary
213where
214    F: Fn(&Path, &Path) -> Result<Duration, String> + Sync,
215{
216    let batch_start = Instant::now();
217
218    let images = discover_images(opts.input_dir, opts.recursive);
219    if images.is_empty() {
220        eprintln!("No image files found in {}", opts.input_dir.display());
221        return BatchSummary {
222            total: 0,
223            succeeded: 0,
224            failed: Vec::new(),
225            elapsed: batch_start.elapsed(),
226        };
227    }
228    let total = images.len();
229    let counter = AtomicUsize::new(0);
230    let should_stop = AtomicBool::new(false);
231
232    let pool = rayon::ThreadPoolBuilder::new()
233        .num_threads(if opts.jobs == 0 {
234            num_cpus()
235        } else {
236            opts.jobs
237        })
238        .build()
239        .expect("failed to create thread pool");
240
241    let num_threads = pool.current_num_threads();
242    eprintln!("Processing {total} images with {num_threads} workers...");
243
244    let results: Vec<BatchResult> = pool.install(|| {
245        images
246            .par_iter()
247            .map(|input| {
248                if !opts.skip_errors && should_stop.load(Ordering::Relaxed) {
249                    return BatchResult {
250                        input: input.clone(),
251                        output: PathBuf::new(),
252                        outcome: Err("skipped (earlier error in fail-fast mode)".to_string()),
253                    };
254                }
255
256                let output = resolve_output_path(
257                    input,
258                    opts.input_dir,
259                    opts.output_dir,
260                    opts.suffix,
261                    opts.format_ext,
262                );
263                let outcome = process(input, &output);
264
265                if outcome.is_err() && !opts.skip_errors {
266                    should_stop.store(true, Ordering::Relaxed);
267                }
268
269                report_progress(&counter, total, input, &outcome);
270                BatchResult {
271                    input: input.clone(),
272                    output,
273                    outcome,
274                }
275            })
276            .collect()
277    });
278
279    summarize(&results, batch_start.elapsed())
280}
281
282/// Run batch-apply: apply a preset to all images in a directory, in parallel.
283#[allow(clippy::too_many_arguments)]
284pub fn run_batch_apply(
285    input_dir: &Path,
286    preset_path: &Path,
287    output_dir: &Path,
288    recursive: bool,
289    quality: u8,
290    format: Option<agx::encode::OutputFormat>,
291    suffix: Option<&str>,
292    jobs: usize,
293    skip_errors: bool,
294    use_gpu: bool,
295) -> BatchSummary {
296    let preset = match agx::Preset::load_from_file(preset_path) {
297        Ok(p) => p,
298        Err(e) => {
299            eprintln!("Failed to load preset: {e}");
300            let images = discover_images(input_dir, recursive);
301            return BatchSummary {
302                total: images.len(),
303                succeeded: 0,
304                failed: images
305                    .iter()
306                    .map(|p| (p.clone(), format!("preset load failed: {e}")))
307                    .collect(),
308                elapsed: Duration::ZERO,
309            };
310        }
311    };
312
313    let opts = BatchContext {
314        input_dir,
315        output_dir,
316        recursive,
317        format_ext: format.map(|f| f.extension()),
318        suffix,
319        jobs,
320        skip_errors,
321    };
322    run_batch(&opts, |input, output| {
323        process_single(input, output, quality, format, use_gpu, |engine| {
324            engine.apply_preset(&preset);
325        })
326    })
327}
328
329/// Run batch-edit: apply inline parameters to all images in a directory, in parallel.
330#[allow(clippy::too_many_arguments)]
331pub fn run_batch_edit(
332    input_dir: &Path,
333    output_dir: &Path,
334    recursive: bool,
335    params: &agx::Parameters,
336    lut: Option<Arc<agx::Lut3D>>,
337    quality: u8,
338    format: Option<agx::encode::OutputFormat>,
339    suffix: Option<&str>,
340    jobs: usize,
341    skip_errors: bool,
342    use_gpu: bool,
343) -> BatchSummary {
344    let opts = BatchContext {
345        input_dir,
346        output_dir,
347        recursive,
348        format_ext: format.map(|f| f.extension()),
349        suffix,
350        jobs,
351        skip_errors,
352    };
353    run_batch(&opts, |input, output| {
354        process_single(input, output, quality, format, use_gpu, |engine| {
355            engine.set_params(params.clone());
356            if let Some(l) = &lut {
357                engine.set_lut(Some(Arc::clone(l)));
358            }
359        })
360    })
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use std::fs;
367    use tempfile::TempDir;
368
369    #[test]
370    fn discover_finds_image_files() {
371        let tmp = TempDir::new().unwrap();
372        fs::write(tmp.path().join("photo.jpg"), b"").unwrap();
373        fs::write(tmp.path().join("photo.jpeg"), b"").unwrap();
374        fs::write(tmp.path().join("photo.png"), b"").unwrap();
375        fs::write(tmp.path().join("notes.txt"), b"").unwrap();
376
377        let found = discover_images(tmp.path(), false);
378        assert_eq!(found.len(), 3);
379        assert!(found.iter().all(|p| p.extension().unwrap() != "txt"));
380    }
381
382    #[test]
383    fn discover_skips_non_image_files() {
384        let tmp = TempDir::new().unwrap();
385        fs::write(tmp.path().join("readme.md"), b"").unwrap();
386        fs::write(tmp.path().join("data.txt"), b"").unwrap();
387        fs::write(tmp.path().join(".hidden"), b"").unwrap();
388
389        let found = discover_images(tmp.path(), false);
390        assert!(found.is_empty());
391    }
392
393    #[test]
394    fn discover_recursive_finds_subdirs() {
395        let tmp = TempDir::new().unwrap();
396        fs::write(tmp.path().join("a.jpg"), b"").unwrap();
397        let sub = tmp.path().join("sub");
398        fs::create_dir(&sub).unwrap();
399        fs::write(sub.join("b.png"), b"").unwrap();
400
401        let flat = discover_images(tmp.path(), false);
402        assert_eq!(flat.len(), 1);
403
404        let deep = discover_images(tmp.path(), true);
405        assert_eq!(deep.len(), 2);
406    }
407
408    #[test]
409    fn discover_case_insensitive_extensions() {
410        let tmp = TempDir::new().unwrap();
411        fs::write(tmp.path().join("a.JPG"), b"").unwrap();
412        fs::write(tmp.path().join("b.Png"), b"").unwrap();
413        fs::write(tmp.path().join("c.TIFF"), b"").unwrap();
414
415        let found = discover_images(tmp.path(), false);
416        assert_eq!(found.len(), 3);
417    }
418
419    #[test]
420    fn discover_sorted_by_name() {
421        let tmp = TempDir::new().unwrap();
422        fs::write(tmp.path().join("charlie.jpg"), b"").unwrap();
423        fs::write(tmp.path().join("alpha.png"), b"").unwrap();
424        fs::write(tmp.path().join("bravo.tiff"), b"").unwrap();
425
426        let found = discover_images(tmp.path(), false);
427        let names: Vec<&str> = found
428            .iter()
429            .map(|p| p.file_name().unwrap().to_str().unwrap())
430            .collect();
431        assert_eq!(names, vec!["alpha.png", "bravo.tiff", "charlie.jpg"]);
432    }
433
434    #[test]
435    fn resolve_output_preserves_filename() {
436        let result = resolve_output_path(
437            Path::new("/photos/IMG_001.jpg"),
438            Path::new("/photos"),
439            Path::new("/edited"),
440            None,
441            None,
442        );
443        assert_eq!(result, PathBuf::from("/edited/IMG_001.jpg"));
444    }
445
446    #[test]
447    fn resolve_output_preserves_subdirectory() {
448        let result = resolve_output_path(
449            Path::new("/photos/day1/IMG_001.jpg"),
450            Path::new("/photos"),
451            Path::new("/edited"),
452            None,
453            None,
454        );
455        assert_eq!(result, PathBuf::from("/edited/day1/IMG_001.jpg"));
456    }
457
458    #[test]
459    fn resolve_output_applies_suffix() {
460        let result = resolve_output_path(
461            Path::new("/photos/IMG_001.jpg"),
462            Path::new("/photos"),
463            Path::new("/edited"),
464            Some("_processed"),
465            None,
466        );
467        assert_eq!(result, PathBuf::from("/edited/IMG_001_processed.jpg"));
468    }
469
470    #[test]
471    fn resolve_output_overrides_format() {
472        let result = resolve_output_path(
473            Path::new("/photos/IMG_001.png"),
474            Path::new("/photos"),
475            Path::new("/edited"),
476            None,
477            Some("jpeg"),
478        );
479        assert_eq!(result, PathBuf::from("/edited/IMG_001.jpeg"));
480    }
481
482    #[test]
483    fn resolve_output_raw_defaults_to_jpg() {
484        let result = resolve_output_path(
485            Path::new("/photos/IMG_001.cr2"),
486            Path::new("/photos"),
487            Path::new("/edited"),
488            None,
489            None,
490        );
491        assert_eq!(result, PathBuf::from("/edited/IMG_001.jpg"));
492    }
493
494    #[test]
495    fn resolve_output_suffix_plus_format() {
496        let result = resolve_output_path(
497            Path::new("/photos/IMG_001.cr2"),
498            Path::new("/photos"),
499            Path::new("/edited"),
500            Some("_edited"),
501            Some("tiff"),
502        );
503        assert_eq!(result, PathBuf::from("/edited/IMG_001_edited.tiff"));
504    }
505
506    fn write_test_png(path: &Path) {
507        use image::ImageBuffer;
508        let img: ImageBuffer<image::Rgb<u8>, Vec<u8>> =
509            ImageBuffer::from_pixel(2, 2, image::Rgb([128u8, 64, 32]));
510        img.save(path).unwrap();
511    }
512
513    #[test]
514    fn batch_apply_processes_multiple_images() {
515        let dir = TempDir::new().unwrap();
516        let input_dir = dir.path().join("input");
517        let output_dir = dir.path().join("output");
518        fs::create_dir(&input_dir).unwrap();
519
520        write_test_png(&input_dir.join("a.png"));
521        write_test_png(&input_dir.join("b.png"));
522
523        let preset_path = dir.path().join("test.toml");
524        fs::write(
525            &preset_path,
526            "[metadata]\nname = \"test\"\nversion = \"1.0\"\nauthor = \"test\"\n",
527        )
528        .unwrap();
529
530        let summary = run_batch_apply(
531            &input_dir,
532            &preset_path,
533            &output_dir,
534            false,
535            92,
536            None,
537            None,
538            1,
539            false,
540            false,
541        );
542
543        assert_eq!(summary.total, 2);
544        assert_eq!(summary.succeeded, 2);
545        assert!(summary.failed.is_empty());
546        assert!(output_dir.join("a.png").exists());
547        assert!(output_dir.join("b.png").exists());
548    }
549
550    #[test]
551    fn batch_edit_processes_with_params() {
552        let dir = TempDir::new().unwrap();
553        let input_dir = dir.path().join("input");
554        let output_dir = dir.path().join("output");
555        fs::create_dir(&input_dir).unwrap();
556
557        write_test_png(&input_dir.join("photo.png"));
558
559        let params = agx::Parameters::default();
560
561        let summary = run_batch_edit(
562            &input_dir,
563            &output_dir,
564            false,
565            &params,
566            None,
567            92,
568            None,
569            None,
570            1,
571            false,
572            false,
573        );
574
575        assert_eq!(summary.total, 1);
576        assert_eq!(summary.succeeded, 1);
577        assert!(summary.failed.is_empty());
578        assert!(output_dir.join("photo.png").exists());
579    }
580}