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    encode: &agx::encode::EncodeOptions,
175    use_gpu: bool,
176    configure: impl FnOnce(&mut agx::Engine),
177) -> Result<Duration, String> {
178    let start = Instant::now();
179    let metadata = agx::metadata::extract_metadata(input);
180    let linear = agx::decode::decode(input).map_err(|e| e.to_string())?;
181    let mut engine = create_engine(linear, use_gpu);
182    configure(&mut engine);
183    let result = engine.render();
184    let rendered = result.image;
185
186    if let Some(parent) = output.parent() {
187        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
188    }
189
190    agx::encode::encode_to_file_with_options(&rendered, output, encode, metadata.as_ref())
191        .map_err(|e| e.to_string())?;
192    Ok(start.elapsed())
193}
194
195/// Pre-derived runner state for batch operations.
196struct BatchContext<'a> {
197    input_dir: &'a Path,
198    output_dir: &'a Path,
199    recursive: bool,
200    format_ext: Option<&'static str>,
201    suffix: Option<&'a str>,
202    jobs: usize,
203    skip_errors: bool,
204}
205
206impl<'a> BatchContext<'a> {
207    fn from_run(run: &BatchRun<'a>) -> Self {
208        Self {
209            input_dir: run.input_dir,
210            output_dir: run.output_dir,
211            recursive: run.recursive,
212            format_ext: run.encode.format.map(|f| f.extension()),
213            suffix: run.suffix,
214            jobs: run.jobs,
215            skip_errors: run.skip_errors,
216        }
217    }
218}
219
220/// Shared configuration for a batch run: where to read and write, how many
221/// workers to use, and how to encode each output. Bundles the parameters common
222/// to batch-apply and batch-edit so the public runners take a few named fields
223/// instead of a long positional list (which is easy to transpose as more
224/// encode-side knobs are added).
225pub struct BatchRun<'a> {
226    /// Directory to read input images from.
227    pub input_dir: &'a Path,
228    /// Directory to write outputs to (created if missing).
229    pub output_dir: &'a Path,
230    /// Recurse into subdirectories.
231    pub recursive: bool,
232    /// Encoding options (quality, format, output gamut) applied to every output.
233    pub encode: &'a agx::encode::EncodeOptions,
234    /// Optional suffix appended to each output filename (e.g. `_edited`).
235    pub suffix: Option<&'a str>,
236    /// Worker count (0 = auto-detect CPU cores).
237    pub jobs: usize,
238    /// Continue processing after individual files fail.
239    pub skip_errors: bool,
240    /// Use the GPU pipeline.
241    pub use_gpu: bool,
242}
243
244/// Generic batch processing: discover images, process in parallel, summarize.
245fn run_batch<F>(opts: &BatchContext<'_>, process: F) -> BatchSummary
246where
247    F: Fn(&Path, &Path) -> Result<Duration, String> + Sync,
248{
249    let batch_start = Instant::now();
250
251    let images = discover_images(opts.input_dir, opts.recursive);
252    if images.is_empty() {
253        eprintln!("No image files found in {}", opts.input_dir.display());
254        return BatchSummary {
255            total: 0,
256            succeeded: 0,
257            failed: Vec::new(),
258            elapsed: batch_start.elapsed(),
259        };
260    }
261    let total = images.len();
262    let counter = AtomicUsize::new(0);
263    let should_stop = AtomicBool::new(false);
264
265    let pool = rayon::ThreadPoolBuilder::new()
266        .num_threads(if opts.jobs == 0 {
267            num_cpus()
268        } else {
269            opts.jobs
270        })
271        .build()
272        .expect("failed to create thread pool");
273
274    let num_threads = pool.current_num_threads();
275    eprintln!("Processing {total} images with {num_threads} workers...");
276
277    let results: Vec<BatchResult> = pool.install(|| {
278        images
279            .par_iter()
280            .map(|input| {
281                if !opts.skip_errors && should_stop.load(Ordering::Relaxed) {
282                    return BatchResult {
283                        input: input.clone(),
284                        output: PathBuf::new(),
285                        outcome: Err("skipped (earlier error in fail-fast mode)".to_string()),
286                    };
287                }
288
289                let output = resolve_output_path(
290                    input,
291                    opts.input_dir,
292                    opts.output_dir,
293                    opts.suffix,
294                    opts.format_ext,
295                );
296                let outcome = process(input, &output);
297
298                if outcome.is_err() && !opts.skip_errors {
299                    should_stop.store(true, Ordering::Relaxed);
300                }
301
302                report_progress(&counter, total, input, &outcome);
303                BatchResult {
304                    input: input.clone(),
305                    output,
306                    outcome,
307                }
308            })
309            .collect()
310    });
311
312    summarize(&results, batch_start.elapsed())
313}
314
315/// Run batch-apply: apply a preset to all images in a directory, in parallel.
316pub fn run_batch_apply(run: &BatchRun, preset_path: &Path) -> BatchSummary {
317    let preset = match agx::Preset::load_from_file(preset_path) {
318        Ok(p) => p,
319        Err(e) => {
320            eprintln!("Failed to load preset: {e}");
321            let images = discover_images(run.input_dir, run.recursive);
322            return BatchSummary {
323                total: images.len(),
324                succeeded: 0,
325                failed: images
326                    .iter()
327                    .map(|p| (p.clone(), format!("preset load failed: {e}")))
328                    .collect(),
329                elapsed: Duration::ZERO,
330            };
331        }
332    };
333
334    let opts = BatchContext::from_run(run);
335    run_batch(&opts, |input, output| {
336        process_single(input, output, run.encode, run.use_gpu, |engine| {
337            engine.apply_preset(&preset);
338        })
339    })
340}
341
342/// Run batch-edit: apply inline parameters to all images in a directory, in parallel.
343pub fn run_batch_edit(
344    run: &BatchRun,
345    params: &agx::Parameters,
346    lut: Option<Arc<agx::Lut3D>>,
347) -> BatchSummary {
348    let opts = BatchContext::from_run(run);
349    run_batch(&opts, |input, output| {
350        process_single(input, output, run.encode, run.use_gpu, |engine| {
351            engine.set_params(params.clone());
352            if let Some(l) = &lut {
353                engine.set_lut(Some(Arc::clone(l)));
354            }
355        })
356    })
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use std::fs;
363    use tempfile::TempDir;
364
365    #[test]
366    fn discover_finds_image_files() {
367        let tmp = TempDir::new().unwrap();
368        fs::write(tmp.path().join("photo.jpg"), b"").unwrap();
369        fs::write(tmp.path().join("photo.jpeg"), b"").unwrap();
370        fs::write(tmp.path().join("photo.png"), b"").unwrap();
371        fs::write(tmp.path().join("notes.txt"), b"").unwrap();
372
373        let found = discover_images(tmp.path(), false);
374        assert_eq!(found.len(), 3);
375        assert!(found.iter().all(|p| p.extension().unwrap() != "txt"));
376    }
377
378    #[test]
379    fn discover_skips_non_image_files() {
380        let tmp = TempDir::new().unwrap();
381        fs::write(tmp.path().join("readme.md"), b"").unwrap();
382        fs::write(tmp.path().join("data.txt"), b"").unwrap();
383        fs::write(tmp.path().join(".hidden"), b"").unwrap();
384
385        let found = discover_images(tmp.path(), false);
386        assert!(found.is_empty());
387    }
388
389    #[test]
390    fn discover_recursive_finds_subdirs() {
391        let tmp = TempDir::new().unwrap();
392        fs::write(tmp.path().join("a.jpg"), b"").unwrap();
393        let sub = tmp.path().join("sub");
394        fs::create_dir(&sub).unwrap();
395        fs::write(sub.join("b.png"), b"").unwrap();
396
397        let flat = discover_images(tmp.path(), false);
398        assert_eq!(flat.len(), 1);
399
400        let deep = discover_images(tmp.path(), true);
401        assert_eq!(deep.len(), 2);
402    }
403
404    #[test]
405    fn discover_case_insensitive_extensions() {
406        let tmp = TempDir::new().unwrap();
407        fs::write(tmp.path().join("a.JPG"), b"").unwrap();
408        fs::write(tmp.path().join("b.Png"), b"").unwrap();
409        fs::write(tmp.path().join("c.TIFF"), b"").unwrap();
410
411        let found = discover_images(tmp.path(), false);
412        assert_eq!(found.len(), 3);
413    }
414
415    #[test]
416    fn discover_sorted_by_name() {
417        let tmp = TempDir::new().unwrap();
418        fs::write(tmp.path().join("charlie.jpg"), b"").unwrap();
419        fs::write(tmp.path().join("alpha.png"), b"").unwrap();
420        fs::write(tmp.path().join("bravo.tiff"), b"").unwrap();
421
422        let found = discover_images(tmp.path(), false);
423        let names: Vec<&str> = found
424            .iter()
425            .map(|p| p.file_name().unwrap().to_str().unwrap())
426            .collect();
427        assert_eq!(names, vec!["alpha.png", "bravo.tiff", "charlie.jpg"]);
428    }
429
430    #[test]
431    fn resolve_output_preserves_filename() {
432        let result = resolve_output_path(
433            Path::new("/photos/IMG_001.jpg"),
434            Path::new("/photos"),
435            Path::new("/edited"),
436            None,
437            None,
438        );
439        assert_eq!(result, PathBuf::from("/edited/IMG_001.jpg"));
440    }
441
442    #[test]
443    fn resolve_output_preserves_subdirectory() {
444        let result = resolve_output_path(
445            Path::new("/photos/day1/IMG_001.jpg"),
446            Path::new("/photos"),
447            Path::new("/edited"),
448            None,
449            None,
450        );
451        assert_eq!(result, PathBuf::from("/edited/day1/IMG_001.jpg"));
452    }
453
454    #[test]
455    fn resolve_output_applies_suffix() {
456        let result = resolve_output_path(
457            Path::new("/photos/IMG_001.jpg"),
458            Path::new("/photos"),
459            Path::new("/edited"),
460            Some("_processed"),
461            None,
462        );
463        assert_eq!(result, PathBuf::from("/edited/IMG_001_processed.jpg"));
464    }
465
466    #[test]
467    fn resolve_output_overrides_format() {
468        let result = resolve_output_path(
469            Path::new("/photos/IMG_001.png"),
470            Path::new("/photos"),
471            Path::new("/edited"),
472            None,
473            Some("jpeg"),
474        );
475        assert_eq!(result, PathBuf::from("/edited/IMG_001.jpeg"));
476    }
477
478    #[test]
479    fn resolve_output_raw_defaults_to_jpg() {
480        let result = resolve_output_path(
481            Path::new("/photos/IMG_001.cr2"),
482            Path::new("/photos"),
483            Path::new("/edited"),
484            None,
485            None,
486        );
487        assert_eq!(result, PathBuf::from("/edited/IMG_001.jpg"));
488    }
489
490    #[test]
491    fn resolve_output_suffix_plus_format() {
492        let result = resolve_output_path(
493            Path::new("/photos/IMG_001.cr2"),
494            Path::new("/photos"),
495            Path::new("/edited"),
496            Some("_edited"),
497            Some("tiff"),
498        );
499        assert_eq!(result, PathBuf::from("/edited/IMG_001_edited.tiff"));
500    }
501
502    fn write_test_png(path: &Path) {
503        use image::ImageBuffer;
504        let img: ImageBuffer<image::Rgb<u8>, Vec<u8>> =
505            ImageBuffer::from_pixel(2, 2, image::Rgb([128u8, 64, 32]));
506        img.save(path).unwrap();
507    }
508
509    #[test]
510    fn batch_apply_processes_multiple_images() {
511        let dir = TempDir::new().unwrap();
512        let input_dir = dir.path().join("input");
513        let output_dir = dir.path().join("output");
514        fs::create_dir(&input_dir).unwrap();
515
516        write_test_png(&input_dir.join("a.png"));
517        write_test_png(&input_dir.join("b.png"));
518
519        let preset_path = dir.path().join("test.toml");
520        fs::write(
521            &preset_path,
522            "[metadata]\nname = \"test\"\nversion = \"1.0\"\nauthor = \"test\"\n",
523        )
524        .unwrap();
525
526        let encode = agx::encode::EncodeOptions::default();
527        let run = BatchRun {
528            input_dir: &input_dir,
529            output_dir: &output_dir,
530            recursive: false,
531            encode: &encode,
532            suffix: None,
533            jobs: 1,
534            skip_errors: false,
535            use_gpu: false,
536        };
537        let summary = run_batch_apply(&run, &preset_path);
538
539        assert_eq!(summary.total, 2);
540        assert_eq!(summary.succeeded, 2);
541        assert!(summary.failed.is_empty());
542        assert!(output_dir.join("a.png").exists());
543        assert!(output_dir.join("b.png").exists());
544    }
545
546    #[test]
547    fn batch_edit_processes_with_params() {
548        let dir = TempDir::new().unwrap();
549        let input_dir = dir.path().join("input");
550        let output_dir = dir.path().join("output");
551        fs::create_dir(&input_dir).unwrap();
552
553        write_test_png(&input_dir.join("photo.png"));
554
555        let params = agx::Parameters::default();
556
557        let encode = agx::encode::EncodeOptions::default();
558        let run = BatchRun {
559            input_dir: &input_dir,
560            output_dir: &output_dir,
561            recursive: false,
562            encode: &encode,
563            suffix: None,
564            jobs: 1,
565            skip_errors: false,
566            use_gpu: false,
567        };
568        let summary = run_batch_edit(&run, &params, None);
569
570        assert_eq!(summary.total, 1);
571        assert_eq!(summary.succeeded, 1);
572        assert!(summary.failed.is_empty());
573        assert!(output_dir.join("photo.png").exists());
574    }
575}