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 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
200struct 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
211fn 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#[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#[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 ¶ms,
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}