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
10const STANDARD_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "tiff", "tif"];
12
13fn 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
22pub 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
31fn 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
49pub 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 let relative = input
64 .strip_prefix(input_dir)
65 .unwrap_or(input.file_name().map(Path::new).unwrap_or(input));
66
67 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 let stem = relative
82 .file_stem()
83 .and_then(|s| s.to_str())
84 .unwrap_or("output");
85
86 let filename = match suffix {
88 Some(s) => format!("{stem}{s}.{ext}"),
89 None => format!("{stem}.{ext}"),
90 };
91
92 let parent = relative.parent().unwrap_or(Path::new(""));
94 output_dir.join(parent).join(filename)
95}
96
97pub struct BatchResult {
99 pub input: PathBuf,
100 #[allow(dead_code)]
101 pub output: PathBuf,
102 pub outcome: Result<Duration, String>,
103}
104
105pub 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
116fn 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
131pub 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
163fn num_cpus() -> usize {
165 std::thread::available_parallelism()
166 .map(|n| n.get())
167 .unwrap_or(1)
168}
169
170fn 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
195struct 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
220pub struct BatchRun<'a> {
226 pub input_dir: &'a Path,
228 pub output_dir: &'a Path,
230 pub recursive: bool,
232 pub encode: &'a agx::encode::EncodeOptions,
234 pub suffix: Option<&'a str>,
236 pub jobs: usize,
238 pub skip_errors: bool,
240 pub use_gpu: bool,
242}
243
244fn 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
315pub 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
342pub 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, ¶ms, 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}