Report unsupported files in recorded Batch folders

This commit is contained in:
yoriswind committed 2026-11-01 19:40:11 -08:00
1 parent f3e5c63383
commit 60dba86c22
7 files changed
+120 -42

No files matched your search

@@ -118,7 +118,7 @@ fn read(d: &Descriptor) -> Option<Value> {
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<Descriptor> {
"includeSubfolders",
"overrideSaveAs",
"stopOnError",
"includeNonImages",
"namingComponents",
"startingSerial",
"compatibility",
@@ -176,8 +177,8 @@ pub(super) fn batch_to(_: &str, p: &Value) -> Option<Descriptor> {
}) {
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) {
+9 -6
View File
@@ -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,
+2 -2
View File
@@ -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<CommandSpec> {
"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
),
+41 -11
View File
@@ -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 `<input>/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<Vec<BatchInput>> {
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<Vec<BatchInput>> {
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<Vec<BatchInput>> {
pub(crate) fn list_files_tree(dir: &str, skip: &str, cmd: &str, include_non_images: bool) -> Result<Vec<BatchInput>> {
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<Vec<B
while let Some((folder, rel, depth)) = stack.pop() {
let shown = folder.to_string_lossy().into_owned();
// The top folder must be readable; a subfolder that isn't is skipped.
let files = match list_images(&shown) {
let files = match list_files(&shown, include_non_images) {
Ok(f) => 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<Vec<B
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result<Vec<BatchInput>> {
pub(crate) fn list_files_tree(dir: &str, _skip: &str, _cmd: &str, _include_non_images: bool) -> Result<Vec<BatchInput>> {
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<bool>,
) -> 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<bool>,
) -> 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<Value> {
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<Value> {
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<Value> {
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();
+8 -3
View File
@@ -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<Vec<String>> {
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<Vec<String>> {
let rd = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?;
let mut out: Vec<String> = 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<Vec<String>> {
}
#[cfg(target_arch = "wasm32")]
pub fn list_images(dir: &str) -> Result<Vec<String>> {
pub(super) fn list_files(dir: &str, _include_non_images: bool) -> Result<Vec<String>> {
Err(EngineError::Other(format!("cannot list {dir}: no file system on the web")))
}
+49 -13
View File
@@ -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())]);
}
+7 -4
View File
@@ -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.