From 60dba86c223bd520da05cfff5d95f043e4e92113 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 19:40:11 -0800 Subject: [PATCH] Report unsupported files in recorded Batch folders --- crates/engine/src/cmd/atn_cmds/steps_batch.rs | 7 ++- crates/engine/src/cmd/atn_cmds/tests.rs | 15 +++-- crates/engine/src/cmd/file_cmds.rs | 4 +- crates/engine/src/cmd/file_cmds/batch_run.rs | 52 ++++++++++++---- crates/engine/src/cmd/file_cmds/paths.rs | 11 +++- crates/engine/src/cmd/file_cmds/tests.rs | 62 +++++++++++++++---- docs/atn.md | 11 ++-- 7 files changed, 120 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_batch.rs b/crates/engine/src/cmd/atn_cmds/steps_batch.rs index 7e30b9e598..6e89f9ee28 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_batch.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_batch.rs @@ -118,7 +118,7 @@ fn read(d: &Descriptor) -> Option { return None; } } - let mut p = json!({"input":path_text(d.get("null")?)?,"output":path_text(d.get("T ")?)?,"flatOutput":true,"stopOnError":true}); + let mut p = json!({"input":path_text(d.get("null")?)?,"output":path_text(d.get("T ")?)?,"flatOutput":true,"stopOnError":true,"includeNonImages":true}); let D::Reference(items) = d.get("Usng")? else { return None }; match items.as_slice() { [ReferenceItem::Name { class: action, name }, ReferenceItem::Name { class: set, name: set_name }] @@ -167,6 +167,7 @@ pub(super) fn batch_to(_: &str, p: &Value) -> Option { "includeSubfolders", "overrideSaveAs", "stopOnError", + "includeNonImages", "namingComponents", "startingSerial", "compatibility", @@ -176,8 +177,8 @@ pub(super) fn batch_to(_: &str, p: &Value) -> Option { }) { return None; } - // Without Log, the recorded dialog stops at the first failing file. Native defaults continue. - if p.get("stopOnError") != Some(&Value::Bool(true)) { + // Recorded folders include unsupported files and stop at the first error without Log. + if p.get("stopOnError") != Some(&Value::Bool(true)) || p.get("includeNonImages") != Some(&Value::Bool(true)) { return None; } if let Some(original) = p.get(ORIGINAL) { diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 9f6b258843..7026cdf992 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -107,7 +107,7 @@ fn batch_descriptor(component: &str) -> Descriptor { /// Preserve the recorded template in the generic registry round-trip witness. fn batch_case() -> Value { - json!({"input":"/input","output":"/output","action":"Child","set":"S","flatOutput":true,"includeSubfolders":false,"overrideSaveAs":false,"stopOnError":true, + json!({"input":"/input","output":"/output","action":"Child","set":"S","flatOutput":true,"includeSubfolders":false,"overrideSaveAs":false,"stopOnError":true,"includeNonImages":true, "namingComponents":[{"component":"serial3"}],"startingSerial":7,"compatibility":{"macintosh":true,"windows":false,"unix":true}, "__atnBatch":b64(&batch_descriptor("threeDigit").to_bytes())}) } @@ -124,6 +124,7 @@ fn batch_all_documented_components_and_compatibility_flags_round_trip() { assert_eq!(p["namingComponents"], json!([{"component":native}])); assert_eq!(p["startingSerial"], 7); assert_eq!(p["stopOnError"], true); + assert_eq!(p["includeNonImages"], true); assert_eq!(p["compatibility"], json!({"macintosh":true,"windows":false,"unix":true})); let (out, skipped) = export("S", &[&imported.actions[0]], &[], false).unwrap(); assert!(skipped.is_empty()); @@ -151,14 +152,16 @@ fn batch_unknown_components_wrong_start_placement_and_unsupported_modes_stay_who let mut p = batch_case(); p["target"] = json!("mask"); assert!(super::steps_batch::batch_to("file.automate.batch", &p).is_none()); - for value in [Value::Null, json!(false), json!("yes")] { + for key in ["stopOnError", "includeNonImages"] { + for value in [Value::Null, json!(false), json!("yes")] { + let mut p = batch_case(); + p[key] = value; + assert!(super::steps_batch::batch_to("file.automate.batch", &p).is_none()); + } let mut p = batch_case(); - p["stopOnError"] = value; + p.as_object_mut().unwrap().remove(key); assert!(super::steps_batch::batch_to("file.automate.batch", &p).is_none()); } - let mut p = batch_case(); - p.as_object_mut().unwrap().remove("stopOnError"); - assert!(super::steps_batch::batch_to("file.automate.batch", &p).is_none()); } /// A set with a mapped filter, an adjustment layer, selection steps, a layer by name, a Stop, diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index ae1cc28705..950a1dd7d9 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -31,7 +31,7 @@ mod save; pub use batch_run::OutputClaims; pub(crate) use batch_run::{BatchInput, batch_action, batch_inputs, batch_keeps_step, batch_save_point, process_files}; #[cfg(test)] -pub(crate) use batch_run::{MAX_SUBFOLDER_DEPTH, list_images_tree}; +pub(crate) use batch_run::{MAX_SUBFOLDER_DEPTH, list_files_tree}; use batch_run::{batch, same_format}; pub use export::{LayerExportReport, SkippedExportLayer, bake_cube, export_layers_report_with, export_layers_with}; use export::{color_lookup_tables, layers_to_files, push_unique}; @@ -308,7 +308,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"action":name|index? (instead of steps),"set":str?,"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; a Save, Save As or Save a Copy step marks the document written to output, in this command's format and options: as the save found it if it was closed or another document is active at the end, else as the action leaves it; with no such step, the active document at the end),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"namingComponents":[{"component":choice}|{"text":str}]? (up to six; the exact filename, with no automatic extension),"compatibility":{"macintosh":bool,"windows":bool,"unix":bool}?,"flatOutput":bool=false (structured naming only),"overrideSaveAs":bool? (recorded routing; requires a directly represented Save configuration),"stopOnError":bool=false (report the first failed input and stop; earlier outputs remain saved),"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"action":name|index? (instead of steps),"set":str?,"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; a Save, Save As or Save a Copy step marks the document written to output, in this command's format and options: as the save found it if it was closed or another document is active at the end, else as the action leaves it; with no such step, the active document at the end),"input":folder|[paths],"includeNonImages":bool=false (folder inputs report unsupported files as per-file errors instead of skipping them; arrays are unchanged),"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"namingComponents":[{"component":choice}|{"text":str}]? (up to six; the exact filename, with no automatic extension),"compatibility":{"macintosh":bool,"windows":bool,"unix":bool}?,"flatOutput":bool=false (structured naming only),"overrideSaveAs":bool? (recorded routing; requires a directly represented Save configuration),"stopOnError":bool=false (report the first failed input and stop; earlier outputs remain saved),"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, batch ), diff --git a/crates/engine/src/cmd/file_cmds/batch_run.rs b/crates/engine/src/cmd/file_cmds/batch_run.rs index c124015a2a..2996c7d72c 100644 --- a/crates/engine/src/cmd/file_cmds/batch_run.rs +++ b/crates/engine/src/cmd/file_cmds/batch_run.rs @@ -1,5 +1,5 @@ use super::open::import_at_raw_ppi; -use super::paths::{extension, file_name, join, list_images, read_file, stem}; +use super::paths::{extension, file_name, join, list_files, list_images, read_file, stem}; use super::processing::parse_steps; use super::save::{SaveOpts, save_doc}; use super::{Arc, Document, EngineError, Result, Session, Value, batch_naming, json, str_param}; @@ -33,23 +33,31 @@ pub(crate) const MAX_SUBFOLDER_FILES: usize = 100_000; /// subfolders, each tagged with its relative folder. `output` (when inside the input folder, as /// the dialog's default `/batch` is) is not walked, so a rerun doesn't process its results. pub(crate) fn batch_inputs_tree(p: &Value, cmd: &str, output: &str) -> Result> { + collect_inputs(p, cmd, output, false) +} + +/// Collect folder inputs with Batch's explicit unsupported-file policy; arrays keep their order. +fn collect_inputs(p: &Value, cmd: &str, output: &str, include_non_images: bool) -> Result> { let recurse = match p.get("includeSubfolders") { None | Some(Value::Null) => false, Some(Value::Bool(b)) => *b, Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"includeSubfolders\" must be true or false".into() }), }; match p.get("input").or_else(|| p.get("files")) { - Some(Value::String(dir)) if recurse => list_images_tree(dir, output, cmd), + Some(Value::String(dir)) if recurse => list_files_tree(dir, output, cmd, include_non_images), + Some(Value::String(dir)) if include_non_images => { + Ok(list_files(dir, true)?.into_iter().map(|path| BatchInput { path, rel_dir: String::new() }).collect()) + } _ => Ok(batch_inputs(p, cmd)?.into_iter().map(|path| BatchInput { path, rel_dir: String::new() }).collect()), } } -/// The images under `dir` and its subfolders: each folder's files sorted by name, then its +/// The selected files under `dir` and its subfolders: each folder's files sorted by name, then its /// subfolders in name order (depth first). Bounded by [`MAX_SUBFOLDER_DEPTH`] and /// [`MAX_SUBFOLDER_FILES`]; a folder reached twice (a symlink loop) is walked once, and `skip` /// (the output folder) is left out. #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result> { +pub(crate) fn list_files_tree(dir: &str, skip: &str, cmd: &str, include_non_images: bool) -> Result> { use std::path::{Path, PathBuf}; let canon = |p: &Path| std::fs::canonicalize(p).ok(); let root = Path::new(dir); @@ -63,13 +71,13 @@ pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result f, Err(e) if depth == 0 => return Err(e), Err(_) => continue, }; if out.len().saturating_add(files.len()) > MAX_SUBFOLDER_FILES { - return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("{dir} holds more than {MAX_SUBFOLDER_FILES} images with its subfolders") }); + return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("{dir} holds more than {MAX_SUBFOLDER_FILES} files with its subfolders") }); } out.extend(files.into_iter().map(|path| BatchInput { path, rel_dir: rel.clone() })); if depth >= MAX_SUBFOLDER_DEPTH { @@ -97,7 +105,7 @@ pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result Result> { +pub(crate) fn list_files_tree(dir: &str, _skip: &str, _cmd: &str, _include_non_images: bool) -> Result> { Err(EngineError::Other(format!("cannot list {dir}: no file system on the web"))) } @@ -129,7 +137,7 @@ pub(crate) fn process_tree( suffix: &str, f: &dyn Fn(&mut Session, &str) -> Result, ) -> Value { - process_outputs(s, default_outputs(inputs, output, format, suffix), save, None, false, f) + process_outputs(s, default_outputs(inputs, output, format, suffix), save, None, false, false, f) } /// Stream legacy destination names without collecting another path list for large batches. @@ -188,6 +196,7 @@ fn process_outputs<'a>( save: SaveOpts, content_format: Option<&str>, stop_on_error: bool, + check_folder_format: bool, f: &dyn Fn(&mut Session, &str) -> Result, ) -> Value { // The scratch sessions have default preferences; a raw opens at the user's Raw Defaults. @@ -198,6 +207,13 @@ fn process_outputs<'a>( for (path, out) in outputs { let r = (|| -> Result<()> { written.check(&out).map_err(EngineError::Other)?; + // A folder must not silently use the embedded preview of an unsupported raw format. + #[cfg(not(target_arch = "wasm32"))] + if check_folder_format && !super::paths::openable_name(path) { + return Err(EngineError::Other(format!("unsupported Batch folder input: {path}"))); + } + #[cfg(target_arch = "wasm32")] + let _ = check_folder_format; let bytes = read_file(path)?; let mut scratch = Session::new(); let doc = import_at_raw_ppi(raw_ppi, &file_name(path), &bytes)?; @@ -286,6 +302,11 @@ pub(crate) fn batch_action(list: &[crate::actions_cmds::Action], p: &Value) -> R /// Apply the selected file-error policy while retaining per-file results for direct callers. pub(super) fn batch(s: &mut Session, p: &Value) -> Result { let cmd = "file.automate.batch"; + let include_non_images = match p.get("includeNonImages") { + None | Some(Value::Null) => false, + Some(Value::Bool(value)) => *value, + _ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "includeNonImages must be a boolean".into() }), + }; let stop_on_error = match p.get("stopOnError") { None | Some(Value::Null) => false, Some(Value::Bool(value)) => *value, @@ -338,7 +359,8 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") }); } let output = str_param(p, "output", cmd)?.to_string(); - let inputs = batch_inputs_tree(p, cmd, &output)?; + let inputs = collect_inputs(p, cmd, &output, include_non_images)?; + let check_folder_format = include_non_images && p.get("input").or_else(|| p.get("files")).is_some_and(Value::is_string); let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string(); // A batch inside a played action charges its inputs × steps (at least one each: an input is read, // decoded and saved even with no steps) to the playback tree it is in and @@ -405,8 +427,16 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { result }; let r = match names { - Some(names) => process_outputs(s, inputs.iter().map(|i| i.path.as_str()).zip(names), save, structured.then_some(format.as_str()), stop_on_error, &run), - None => process_outputs(s, default_outputs(&inputs, &output, &format, ""), save, None, stop_on_error, &run), + Some(names) => process_outputs( + s, + inputs.iter().map(|i| i.path.as_str()).zip(names), + save, + structured.then_some(format.as_str()), + stop_on_error, + check_folder_format, + &run, + ), + None => process_outputs(s, default_outputs(&inputs, &output, &format, ""), save, None, stop_on_error, check_folder_format, &run), }; if s.actions.playing > 0 { s.actions.playback_steps = carried.get(); diff --git a/crates/engine/src/cmd/file_cmds/paths.rs b/crates/engine/src/cmd/file_cmds/paths.rs index 8f3abdc6b8..8b934e3972 100644 --- a/crates/engine/src/cmd/file_cmds/paths.rs +++ b/crates/engine/src/cmd/file_cmds/paths.rs @@ -37,13 +37,18 @@ pub(crate) fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { } /// Files of a folder that look like images we can open, sorted by name. -#[cfg(not(target_arch = "wasm32"))] pub fn list_images(dir: &str) -> Result> { + list_files(dir, false) +} + +/// Sorted regular files, optionally retaining unsupported names for per-file Batch errors. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn list_files(dir: &str, include_non_images: bool) -> Result> { let rd = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?; let mut out: Vec = rd .filter_map(|e| e.ok()) .map(|e| e.path()) - .filter(|p| p.is_file() && openable_name(&p.to_string_lossy())) + .filter(|p| p.is_file() && (include_non_images || openable_name(&p.to_string_lossy()))) .map(|p| p.to_string_lossy().into_owned()) .collect(); out.sort(); @@ -51,7 +56,7 @@ pub fn list_images(dir: &str) -> Result> { } #[cfg(target_arch = "wasm32")] -pub fn list_images(dir: &str) -> Result> { +pub(super) fn list_files(dir: &str, _include_non_images: bool) -> Result> { Err(EngineError::Other(format!("cannot list {dir}: no file system on the web"))) } diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 7ba131b76f..3a77558d0b 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -69,7 +69,7 @@ fn batch_structured_names_keep_image_format_and_refuse_unsupported_save_routing( child.set = Some("S".into()); s.actions.list.push(child); let params = json!({"input":dir,"output":output,"action":"Child","set":"S","includeSubfolders":false,"flatOutput":true, - "overrideSaveAs":false,"stopOnError":true,"namingComponents":[{"text":"from-atn"}]}); + "overrideSaveAs":false,"stopOnError":true,"includeNonImages":true,"namingComponents":[{"text":"from-atn"}]}); let driver = crate::actions_cmds::Action::new("Driver", vec![("file.automate.batch".into(), params)]); let (bytes, skipped) = crate::atn_cmds::export("S", &[&driver], &[], false).unwrap(); assert!(skipped.is_empty()); @@ -84,10 +84,12 @@ fn batch_structured_names_keep_image_format_and_refuse_unsupported_save_routing( #[test] fn batch_recorded_stop_for_errors_retains_earlier_outputs_and_stops_before_later_inputs() { let dir = tmp("batch-stop-for-errors"); - let first = png(&dir, "a.png", 3, 2, "#ff0000"); - let bad = join(&dir, "b.png"); - std::fs::write(&bad, b"not a PNG").unwrap(); - let last = png(&dir, "c.png", 3, 2, "#0000ff"); + let input = join(&dir, "in"); + std::fs::create_dir_all(&input).unwrap(); + let first = png(&input, "a.png", 3, 2, "#ff0000"); + let bad = join(&input, "b.txt"); + std::fs::write(&bad, b"not an image").unwrap(); + let last = png(&input, "c.png", 3, 2, "#0000ff"); let mut s = Session::new(); for (mode, stop) in [("default", None), ("continue", Some(false)), ("stop", Some(true))] { let output = join(&dir, mode); @@ -102,11 +104,15 @@ fn batch_recorded_stop_for_errors_retains_earlier_outputs_and_stops_before_later assert!(std::path::Path::new(&join(&output, "a.png")).is_file()); assert_eq!(std::path::Path::new(&join(&output, "c.png")).exists(), stop != Some(true)); } + // Native folder defaults still select images only. + let native = s.execute("file.automate.batch", json!({"input":input,"output":join(&dir,"folder-default"),"steps":[]})).unwrap(); + assert_eq!(native["files"].as_array().unwrap().len(), 2, "{native}"); + assert!(native["errors"].as_array().unwrap().is_empty(), "{native}"); let mut child = crate::actions_cmds::Action::new("Child", vec![]); child.set = Some("S".into()); s.actions.list.push(child); let output = join(&dir, "recorded"); - let p = json!({"input":dir,"output":output,"action":"Child","set":"S","stopOnError":true, + let p = json!({"input":input,"output":output,"action":"Child","set":"S","stopOnError":true,"includeNonImages":true, "includeSubfolders":false,"overrideSaveAs":false,"flatOutput":true, "namingComponents":[{"component":"name"},{"component":"extension"}]}); let sentinel = json!({"width":7,"height":9}); @@ -119,7 +125,7 @@ fn batch_recorded_stop_for_errors_retains_earlier_outputs_and_stops_before_later let result = s.execute("actions.play", json!({"action":"Driver"})).unwrap(); assert_eq!(result["ran"], 0, "{result}"); assert_eq!(result["failed"]["id"], "file.automate.batch"); - assert!(result["failed"]["error"].as_str().unwrap().contains("b.png"), "{result}"); + assert!(result["failed"]["error"].as_str().unwrap().contains("b.txt"), "{result}"); assert!(s.documents().is_empty(), "the command after the failed Batch must not run"); assert!(std::path::Path::new(&join(&output, "a.png")).is_file()); assert!(!std::path::Path::new(&join(&output, "c.png")).exists()); @@ -128,10 +134,40 @@ fn batch_recorded_stop_for_errors_retains_earlier_outputs_and_stops_before_later assert_eq!(result["ok"], true, "{result}"); assert_eq!(result["results"][0]["result"]["errors"].as_array().unwrap().len(), 1); assert_eq!(s.documents().len(), 1, "the direct script caller still runs its next command"); - let invalid_output = join(&dir, "invalid"); - let err = s.execute("file.automate.batch", json!({"input":[first],"output":invalid_output,"steps":[],"stopOnError":"true"})).unwrap_err(); - assert!(err.to_string().contains("stopOnError"), "{err}"); - assert!(!std::path::Path::new(&invalid_output).exists()); + // The recursive mode keeps output exclusion and reports excluded RAW names before import. + let output = join(&input, "out"); + std::fs::create_dir_all(&output).unwrap(); + png(&output, "ignored.png", 3, 2, "#ffffff"); + let sub = join(&input, "sub"); + std::fs::create_dir_all(&sub).unwrap(); + let raw = join(&sub, "d.cr3"); + std::fs::copy(&first, &raw).unwrap(); + let result = s + .execute( + "file.automate.batch", + json!({"input":input,"output":output,"steps":[],"format":"png", + "includeSubfolders":true,"includeNonImages":true}), + ) + .unwrap(); + assert_eq!(result["files"], json!([join(&output, "a.png"), join(&output, "c.png")]), "{result}"); + assert_eq!(result["errors"].as_array().unwrap().len(), 2, "{result}"); + assert_eq!(result["errors"][0]["file"], bad); + assert_eq!(result["errors"][1]["file"], raw); + assert!(result["errors"][1]["error"].as_str().unwrap().contains("unsupported Batch folder input"), "{result}"); + assert!(!std::path::Path::new(&join(&output, "sub/d.png")).exists()); + // Explicit arrays still sniff the image content, including with the folder option enabled. + let output = join(&dir, "explicit-array"); + let result = s.execute("file.automate.batch", json!({"input":[raw],"output":output,"steps":[],"format":"png","includeNonImages":true})).unwrap(); + assert_eq!(result["files"], json!([join(&output, "d.png")]), "{result}"); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + for key in ["stopOnError", "includeNonImages"] { + let invalid_output = join(&dir, key); + let mut p = json!({"input":[first],"output":invalid_output,"steps":[]}); + p[key] = json!("true"); + let err = s.execute("file.automate.batch", p).unwrap_err(); + assert!(err.to_string().contains(key), "{err}"); + assert!(!std::path::Path::new(&invalid_output).exists()); + } } fn tmp(name: &str) -> String { @@ -1796,7 +1832,7 @@ fn batch_subfolder_walk_is_depth_limited() { std::fs::write(join(&deep, &format!("l{level}.png")), b"x").unwrap(); } } - let found = list_images_tree(&dir, "", "test").unwrap(); + let found = list_files_tree(&dir, "", "test", false).unwrap(); let names: Vec<_> = found.iter().map(|i| file_name(&i.path)).collect(); assert_eq!(names, vec![format!("l{MAX_SUBFOLDER_DEPTH}.png")], "{found:?}"); assert_eq!(found[0].rel_dir.split('/').count(), MAX_SUBFOLDER_DEPTH); @@ -1822,7 +1858,7 @@ fn batch_subfolder_walk_survives_symlink_loops() { eprintln!("skipped: cannot create a directory symlink here"); return; } - let found = list_images_tree(&dir, "", "test").unwrap(); + let found = list_files_tree(&dir, "", "test", false).unwrap(); let names: Vec<_> = found.iter().map(|i| (i.rel_dir.as_str(), file_name(&i.path))).collect(); assert_eq!(names, vec![("", "a.png".to_string()), ("sub", "b.png".to_string())]); } diff --git a/docs/atn.md b/docs/atn.md index 9455f96782..b03d763664 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -254,10 +254,13 @@ The original native template format and subfolder layout remain the defaults; recorded folder steps use flat structured output. Duplicate output protection remains in place rather than silently overwriting an earlier result. -Recorded folder steps without an error log set `stopOnError`: the first failed -input is reported, earlier outputs stay saved and later inputs are not processed. -The native command still continues by default. That continuing mode cannot be -exported as a no-log Batch step, which would change its error policy. +Recorded folder steps set `includeNonImages`: ordinary non-image files and +unsupported RAW extensions become per-file errors instead of being skipped or +opened through an embedded preview. Native folder inputs still select images +only by default; explicit file arrays are unchanged. Without an error log, +recorded steps also set `stopOnError`: the first failed input is reported, +earlier outputs stay saved and later inputs are not processed. Native Batch +continues by default. Export requires both recorded policies to preserve them. Any reported file error fails a recorded Batch step, so its parent action does not run later steps. A direct scripting call still returns the collected file results successfully, as measured in the reference application.