Batch: Include All Subfolders (AUTO-219-5, #3753) (#3758)

* Batch: Include All Subfolders (AUTO-219-5, #3753)

File > Automate > Batch and File > Scripts > Image Processor take an
optional "includeSubfolders" (default false): a folder input also yields
the images in its subfolders, walked depth first in name order, bounded at
32 levels and 100000 files, with a seen-set of canonical folders against
symlink loops and the output folder left out. Each result keeps its
relative subfolder under the output folder, so same-named files in
different subfolders no longer collide. A non-boolean value is BadParams.

Both dialogs gain an "Include All Subfolders" checkbox (translated in
every complete catalog). Scorecard AUTO-219-5 stays partial (naming
template, progress/cancel, error log file); gaps.md G11 updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Batch: translate Include All Subfolders for Hungarian and Turkish

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
David Mann
2026-10-14 14:08:20 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 7833a6cad4
commit aad56285ad
28 changed files with 288 additions and 21 deletions
+102 -9
View File
@@ -945,13 +945,106 @@ pub(crate) fn batch_inputs(p: &Value, cmd: &str) -> Result<Vec<String>> {
}
}
/// One input of a batch run: the file, and the folder it sits in relative to the input folder
/// (`""` at the top level, `"a/b"` two levels down), which its result keeps under `output`.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct BatchInput {
pub path: String,
pub rel_dir: String,
}
/// Subfolders below a batch input folder are walked at most this deep ("Include All Subfolders").
pub(crate) const MAX_SUBFOLDER_DEPTH: usize = 32;
/// A batch over subfolders lists at most this many files; a larger tree is an error, not a run
/// that silently stops partway.
pub(crate) const MAX_SUBFOLDER_FILES: usize = 100_000;
/// [`batch_inputs`] for the commands whose Photoshop dialog has "Include All Subfolders" (Batch,
/// Image Processor): with `"includeSubfolders": true` a folder input also yields the images in its
/// 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>> {
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),
_ => 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
/// 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>> {
use std::path::{Path, PathBuf};
let canon = |p: &Path| std::fs::canonicalize(p).ok();
let root = Path::new(dir);
let root_canon = canon(root).ok_or_else(|| EngineError::Other(format!("{dir}: cannot open the folder")))?;
let skip = if skip.is_empty() { None } else { canon(Path::new(skip)) };
let mut seen = std::collections::HashSet::new();
seen.insert(root_canon);
let mut out = Vec::new();
// Depth first with an explicit stack (no recursion): (folder, relative folder, depth).
let mut stack: Vec<(PathBuf, String, usize)> = vec![(root.to_path_buf(), String::new(), 0)];
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) {
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") });
}
out.extend(files.into_iter().map(|path| BatchInput { path, rel_dir: rel.clone() }));
if depth >= MAX_SUBFOLDER_DEPTH {
continue;
}
let Ok(rd) = std::fs::read_dir(&folder) else { continue };
let mut subs: Vec<(String, PathBuf)> = rd
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir())
.filter_map(|p| Some((p.file_name()?.to_string_lossy().into_owned(), p)))
.collect();
subs.sort();
// Pushed in reverse so the stack pops them in name order.
for (name, sub) in subs.into_iter().rev() {
let Some(c) = canon(&sub) else { continue };
if skip.as_ref() == Some(&c) || !seen.insert(c) {
continue;
}
let rel = if rel.is_empty() { name } else { format!("{rel}/{name}") };
stack.push((sub, rel, depth + 1));
}
}
Ok(out)
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result<Vec<BatchInput>> {
Err(EngineError::Other(format!("cannot list {dir}: no file system on the web")))
}
/// Opens each input in a scratch session, runs `f` on it and saves it to `output` as `format`
/// (`"same"` keeps the input's extension). Errors per file are collected, not fatal.
pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value {
let outputs = inputs.iter().map(|path| {
let inputs: Vec<BatchInput> = inputs.iter().map(|path| BatchInput { path: path.clone(), rel_dir: String::new() }).collect();
process_tree(&inputs, output, format, save, suffix, f)
}
/// [`process_files`] for inputs from subfolders: each result goes to its input's relative folder
/// under `output` (created as needed), so `a/x.png` and `b/x.png` don't collide.
pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value {
let outputs = inputs.iter().map(|BatchInput { path, rel_dir }| {
let ext =
if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() };
(path.as_str(), join(output, &format!("{}{suffix}.{ext}", stem(path))))
(path.as_str(), join(&join(output, rel_dir), &format!("{}{suffix}.{ext}", stem(path))))
});
process_outputs(outputs, save, f)
}
@@ -1015,8 +1108,8 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
if let Some((id, _)) = steps.iter().find(|(id, _)| crate::commands::find(id).is_none()) {
return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") });
}
let inputs = batch_inputs(p, cmd)?;
let output = str_param(p, "output", cmd)?.to_string();
let inputs = batch_inputs_tree(p, cmd, &output)?;
let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string();
// Steps that call another action play it from the caller's actions; the playback stack comes
// along so an action that batches itself is caught as a recursive call.
@@ -1047,23 +1140,23 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
};
let r = match names {
Some(names) => process_outputs(inputs.iter().map(String::as_str).zip(names), save, &run),
None => process_files(&inputs, &output, &format, save, "", &run),
Some(names) => process_outputs(inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run),
None => process_tree(&inputs, &output, &format, save, "", &run),
};
Ok(r)
}
fn image_processor(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.scripts.imageProcessor";
let inputs = batch_inputs(p, cmd)?;
let output = str_param(p, "output", cmd)?.to_string();
let inputs = batch_inputs_tree(p, cmd, &output)?;
let format = p.get("format").and_then(Value::as_str).unwrap_or("jpg").to_string();
let fit = match (size_param(p, "width"), size_param(p, "height")) {
(None, None) => None,
(w, h) => Some(json!({"width": w.unwrap_or(1e9), "height": h.unwrap_or(1e9), "dontEnlarge": true})),
};
let to_srgb = p.get("convertToSrgb").and_then(Value::as_bool).unwrap_or(false);
let r = process_files(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch| {
let r = process_tree(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch| {
if to_srgb && scratch.active().is_some_and(|d| d.doc.mode != ColorMode::Rgb) {
scratch.execute("image.mode.rgb", json!({}))?;
}
@@ -1564,7 +1657,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
&["File", "Automate"],
None,
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"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,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"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##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"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?,"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,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"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
),
@@ -1573,7 +1666,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Image Processor…",
&["File", "Scripts"],
None,
r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
r##"{"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":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
native,
image_processor
),
+6 -5
View File
@@ -1,7 +1,7 @@
//! Batch output names are planned before opening or writing files. Names substituted from an
//! input are literal: braces in a source filename must never be interpreted as template tokens.
use super::{EngineError, Result, Value, join, stem};
use super::{BatchInput, EngineError, Result, Value, join, stem};
fn bad(message: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: "file.automate.batch".into(), msg: message.into() }
@@ -51,7 +51,7 @@ fn expand(parts: &[Part<'_>], name: &str, serial: u32, date: &str) -> String {
/// An absent or empty template preserves the old filename path exactly. An explicit template
/// generates one component per input; the export format supplies the extension separately.
pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value) -> Result<Option<Vec<String>>> {
pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) -> Result<Option<Vec<String>>> {
let start = match params.get("startingSerial") {
None => 1,
Some(n) => {
@@ -77,7 +77,7 @@ pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value
return Err(bad("naming: the current UTC date is unavailable"));
}
let mut outputs = Vec::with_capacity(inputs.len());
for (index, path) in inputs.iter().enumerate() {
for (index, BatchInput { path, rel_dir }) in inputs.iter().enumerate() {
let serial = if has_serial {
u32::try_from(index).ok().and_then(|index| start.checked_add(index)).ok_or_else(|| bad("startingSerial plus the input count exceeds 4294967295"))?
} else {
@@ -93,14 +93,15 @@ pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value
if name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) {
return Err(bad("naming must produce a filename of at most 255 bytes without path separators or reserved characters"));
}
outputs.push(join(output, &name));
// From subfolders ("Include All Subfolders"), each result keeps its subfolder.
outputs.push(join(&join(output, rel_dir), &name));
}
// Renaming within the source folder could replace a later input before it is read. Resolve
// existing paths so aliases such as `source/./001.png` cannot bypass the check. The default
// (no template) path keeps its existing in-place behavior.
#[cfg(not(target_arch = "wasm32"))]
{
let sources: std::collections::HashSet<_> = inputs.iter().filter_map(|p| std::fs::canonicalize(p).ok()).collect();
let sources: std::collections::HashSet<_> = inputs.iter().filter_map(|i| std::fs::canonicalize(&i.path).ok()).collect();
for output in &outputs {
if std::fs::canonicalize(output).ok().is_some_and(|p| sources.contains(&p)) {
return Err(bad(format!("naming would replace an input file: {output}; choose a different destination or template")));
+104
View File
@@ -1044,6 +1044,110 @@ fn reveal_in_finder_reveals_the_documents_saved_path() {
assert_eq!(r["program"], json!(program));
assert_eq!(r["args"], json!(args));
}
/// "Include All Subfolders" (AUTO-219-5, #3753): Batch and Image Processor take the images in
/// subfolders too, each result keeping its subfolder under the output folder (so same-named files
/// don't collide), and skip the output folder when it sits inside the input folder.
#[test]
fn batch_include_all_subfolders() {
let dir = tmp("batch-subfolders");
let input = join(&dir, "in");
for sub in ["sub/deeper", "z"] {
std::fs::create_dir_all(join(&input, sub)).unwrap();
}
png(&input, "a.png", 8, 8, "#ff0000");
png(&join(&input, "sub"), "b.png", 8, 8, "#00ff00");
png(&join(&input, "sub/deeper"), "a.png", 8, 8, "#0000ff");
png(&join(&input, "z"), "a.png", 8, 8, "#ffffff");
let mut s = Session::new();
// Default: the top level only, as before.
let out = join(&input, "batch");
let r = s.execute("file.automate.batch", json!({"steps": [], "input": input, "output": out, "format": "png"})).unwrap();
assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (1, 0), "{r}");
// With subfolders: every image, in folder order, results kept apart by subfolder; the output
// folder (inside the input folder, holding last run's a.png) is not an input.
let r = s
.execute(
"file.automate.batch",
json!({"steps": [["image.adjustments.invert", {}]], "input": input, "includeSubfolders": true, "output": out, "format": "png"}),
)
.unwrap();
assert!(r["errors"].as_array().unwrap().is_empty(), "{r}");
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect();
let o = out.replace('\\', "/");
assert_eq!(files, vec![format!("{o}/a.png"), format!("{o}/sub/b.png"), format!("{o}/sub/deeper/a.png"), format!("{o}/z/a.png")]);
let deep = photocraft_io::import("a.png", &std::fs::read(join(&out, "sub/deeper/a.png")).unwrap()).unwrap().document;
let px = photocraft_compose::render(&deep, Rect::new(1, 1, 2, 2)).px[0];
assert!(px[0] > 0.99 && px[1] > 0.99 && px[2] < 0.01, "blue inverted to yellow: {px:?}");
std::fs::remove_dir_all(&out).unwrap();
// A naming template applies within each kept subfolder (output outside the input folder).
let named = join(&dir, "named");
let r = s
.execute("file.automate.batch", json!({"steps": [], "input": input, "includeSubfolders": true, "output": named, "format": "png", "naming": "{serial}"}))
.unwrap();
assert!(r["errors"].as_array().unwrap().is_empty(), "{r}");
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect();
let n = named.replace('\\', "/");
assert_eq!(files, vec![format!("{n}/001.png"), format!("{n}/sub/002.png"), format!("{n}/sub/deeper/003.png"), format!("{n}/z/004.png")]);
// Image Processor shares the option (with its results out of the input folder).
let out = join(&dir, "jpg");
let r = s.execute("file.scripts.imageProcessor", json!({"input": input, "includeSubfolders": true, "output": out})).unwrap();
assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (4, 0), "{r}");
assert!(std::path::Path::new(&join(&out, "sub/deeper/a.jpg")).is_file());
// A wrong type is a parameter error, for both.
for bad in [json!("yes"), json!(1), json!([true])] {
for cmd in ["file.automate.batch", "file.scripts.imageProcessor"] {
let e = s.execute(cmd, json!({"steps": [], "input": input, "includeSubfolders": bad, "output": out})).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{cmd} {bad}: {e}");
}
}
// A missing input folder is an error, not a panic.
assert!(s.execute("file.automate.batch", json!({"steps": [], "input": join(&dir, "nope"), "includeSubfolders": true, "output": out})).is_err());
}
/// The subfolder walk stops at `MAX_SUBFOLDER_DEPTH` levels.
#[test]
fn batch_subfolder_walk_is_depth_limited() {
let dir = tmp("batch-subfolder-depth");
let mut deep = dir.clone();
for level in 1..=MAX_SUBFOLDER_DEPTH + 1 {
deep = join(&deep, "d");
std::fs::create_dir_all(&deep).unwrap();
if level >= MAX_SUBFOLDER_DEPTH {
std::fs::write(join(&deep, &format!("l{level}.png")), b"x").unwrap();
}
}
let found = list_images_tree(&dir, "", "test").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);
}
/// A symlink back up the tree is walked once, not forever. Skipped where the platform doesn't
/// let the test create directory symlinks (Windows without developer mode).
#[test]
fn batch_subfolder_walk_survives_symlink_loops() {
let dir = tmp("batch-subfolder-loop");
let sub = join(&dir, "sub");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(join(&dir, "a.png"), b"x").unwrap();
std::fs::write(join(&sub, "b.png"), b"x").unwrap();
let link = std::path::Path::new(&sub).join("loop");
#[cfg(unix)]
let made = std::os::unix::fs::symlink(&dir, &link);
#[cfg(windows)]
let made = std::os::windows::fs::symlink_dir(&dir, &link);
#[cfg(not(any(unix, windows)))]
let made: std::io::Result<()> = Err(std::io::Error::other("no symlinks"));
if made.is_err() {
eprintln!("skipped: cannot create a directory symlink here");
return;
}
let found = list_images_tree(&dir, "", "test").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())]);
}
#[test]
fn batch_naming_validates_the_whole_plan_before_writing() {
let dir = tmp("naming-invalid");
+2
View File
@@ -3741,6 +3741,8 @@ navigator Zoom Zvětšení
Action playback stopped Přehrávání akce zastaveno
Step {step} is waiting for its dialog Krok {step} čeká na svůj dialog
@plural {n} action|{n} actions {n} akce|{n} akce|{n} akcí
# Batch / Image Processor
Include All Subfolders Zahrnout všechny podsložky
Auto Add/Delete Automaticky přidat/odstranit
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3131,6 +3131,8 @@ curves Smooth Glätten
Action playback stopped Aktionswiedergabe angehalten
Step {step} is waiting for its dialog Schritt {step} wartet auf seinen Dialog
@plural {n} action|{n} actions {n} Aktion|{n} Aktionen
# Batch / Image Processor
Include All Subfolders Alle Unterordner einschließen
Auto Add/Delete Autom. hinzuf./löschen
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3831,6 +3831,8 @@ curves Smooth Εξομάλυνση
Action playback stopped Η αναπαραγωγή της ενέργειας σταμάτησε
Step {step} is waiting for its dialog Το βήμα {step} περιμένει το παράθυρο διαλόγου του
@plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες
# Batch / Image Processor
Include All Subfolders Συμπερίληψη όλων των υποφακέλων
Auto Add/Delete Αυτόματη προσθήκη/διαγραφή
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 1029 and column 18.
+2
View File
@@ -2818,6 +2818,8 @@ curves Smooth Suavizar
Action playback stopped Reproducción de la acción detenida
Step {step} is waiting for its dialog El paso {step} está esperando su cuadro de diálogo
@plural {n} action|{n} actions {n} acción|{n} acciones
# Batch / Image Processor
Include All Subfolders Incluir todas las subcarpetas
Auto Add/Delete Añadir/eliminar automáticamente
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -3135,6 +3135,8 @@ curves Smooth Lisser
Action playback stopped Exécution de l’action arrêtée
Step {step} is waiting for its dialog L’étape {step} attend sa boîte de dialogue
@plural {n} action|{n} actions {n} action|{n} actions
# Batch / Image Processor
Include All Subfolders Inclure tous les sous-dossiers
Auto Add/Delete Ajout/suppr. auto
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -606,6 +606,7 @@
HSB/HSL HSB/HSL
Huge Óriási
Image Processor Képfeldolgozó
Include All Subfolders Az összes almappa belefoglalása
Indexed Color… Indexelt szín…
Info Információ
Install Plug-in Bővítmény telepítése
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3122,6 +3122,8 @@ curves Smooth Haluskan
Action playback stopped Pemutaran tindakan dihentikan
Step {step} is waiting for its dialog Langkah {step} menunggu dialognya
@plural {n} action|{n} actions {n} tindakan
# Batch / Image Processor
Include All Subfolders Sertakan semua subfolder
Auto Add/Delete Tambah/Hapus Otomatis
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -4096,6 +4096,8 @@ navigator Zoom Zoom
Action playback stopped Esecuzione dell’azione interrotta
Step {step} is waiting for its dialog Il passaggio {step} attende la sua finestra di dialogo
@plural {n} action|{n} actions {n} azione|{n} azioni
# Batch / Image Processor
Include All Subfolders Includi tutte le sottocartelle
Auto Add/Delete Aggiungi/elimina automaticamente
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 2644 and column 53.
+2
View File
@@ -3740,6 +3740,8 @@ curves Smooth スムーズ
Action playback stopped アクションの再生を停止しました
Step {step} is waiting for its dialog ステップ {step} はダイアログを待っています
@plural {n} action|{n} actions アクション {n} 個
# Batch / Image Processor
Include All Subfolders すべてのサブフォルダーを含める
# Liquify tool names (shown via tl!(tool.label()) in the UI, so they are not found by the static scan)
Forward Warp 前方ワープ
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3308,6 +3308,8 @@ curves Smooth 매끄럽게
Action playback stopped 액션 재생이 중지되었습니다
Step {step} is waiting for its dialog {step}단계가 대화 상자를 기다리고 있습니다
@plural {n} action|{n} actions 액션 {n}개
# Batch / Image Processor
Include All Subfolders 모든 하위 폴더 포함
Auto Add/Delete 자동 추가/삭제
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 3157 and column 53.
+2
View File
@@ -3893,6 +3893,8 @@ curves Smooth Vloeiend maken
Action playback stopped Afspelen van de handeling gestopt
Step {step} is waiting for its dialog Stap {step} wacht op zijn dialoogvenster
@plural {n} action|{n} actions {n} handeling|{n} handelingen
# Batch / Image Processor
Include All Subfolders Alle submappen opnemen
Auto Add/Delete Automatisch toevoegen/verwijderen
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 8 and column 34.
+2
View File
@@ -3731,6 +3731,8 @@ curves Smooth Wygładź
Action playback stopped Odtwarzanie operacji zatrzymane
Step {step} is waiting for its dialog Krok {step} czeka na swoje okno dialogowe
@plural {n} action|{n} actions {n} operacja|{n} operacje|{n} operacji
# Batch / Image Processor
Include All Subfolders Uwzględnij wszystkie podfoldery
Auto Add/Delete Automatyczne dodawanie/usuwanie
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3923,6 +3923,8 @@ layerLabel No Color Sem cor
Action playback stopped Reprodução da ação interrompida
Step {step} is waiting for its dialog A etapa {step} está aguardando sua caixa de diálogo
@plural {n} action|{n} actions {n} ação|{n} ações
# Batch / Image Processor
Include All Subfolders Incluir todas as subpastas
Auto Add/Delete Adicionar/excluir automaticamente
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3079,6 +3079,8 @@ curves Smooth Сгладить
Action playback stopped Воспроизведение операции остановлено
Step {step} is waiting for its dialog Шаг {step} ожидает своего диалога
@plural {n} action|{n} actions {n} операция|{n} операции|{n} операций
# Batch / Image Processor
Include All Subfolders Включить все вложенные папки
Auto Add/Delete Автодобавление/удаление
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2997,6 +2997,7 @@ cameraData Lens Objektiv
Create a new rectangular frame Napravi novi pravougaoni okvir
Create a new elliptical frame Napravi novi eliptični okvir
Include in playback Uključi u reprodukciju
Include All Subfolders Uključi sve podfascikle
Perspective Crop Tool Alatka za perspektivno opsecanje
Show Grid Prikaži mrežu
Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Prevucite okvir ili kliknite četiri ugla · prevucite uglove na ivice iskošenog oblika · {commit} potvrđuje · Esc otkazuje
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3355,6 +3355,7 @@ cameraRaw Color Renk
How often fastening points are placed by themselves Sabitleme noktalarının kendi başlarına ne sıklıkla yerleştirildiği
ISO Speed ISO Hızı
Image Processor Görüntü İşlemcisi
Include All Subfolders Tüm Alt Klasörleri Dahil Et
In içinde
Install Plug-in Eklentiyi Yükle
Interpret Footage Görüntüyü Yorumlama
Can't render this file because it contains an unexpected character in line 8 and column 34.
+2
View File
@@ -3832,6 +3832,8 @@ curves Smooth Згладити
Action playback stopped Відтворення операції зупинено
Step {step} is waiting for its dialog Крок {step} очікує на свій діалог
@plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій
# Batch / Image Processor
Include All Subfolders Включити всі вкладені папки
Auto Add/Delete Автододавання/видалення
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 1385 and column 18.
+2
View File
@@ -3617,6 +3617,8 @@ curves Smooth Làm mượt
Step {step} is waiting for its dialog Bước {step} đang chờ hộp thoại của nó
@plural {n} action|{n} actions {n} hành động
Presentation Trình bày
# Batch / Image Processor
Include All Subfolders Bao gồm tất cả thư mục con
Gradient Preset Options Tùy chọn thiết lập sẵn chuyển sắc
Import .GRD Nhập .GRD
Choose a gradient file (.grd). Chọn tệp chuyển sắc (.grd).
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2743,6 +2743,8 @@ curves Smooth 平滑
Action playback stopped 动作播放已停止
Step {step} is waiting for its dialog 步骤 {step} 正在等待其对话框
@plural {n} action|{n} actions {n} 个动作
# Batch / Image Processor
Include All Subfolders 包含所有子文件夹
Auto Add/Delete 自动添加/删除
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 2590 and column 53.
+2
View File
@@ -2728,6 +2728,8 @@ curves Smooth 平滑
Action playback stopped 動作播放已停止
Step {step} is waiting for its dialog 步驟 {step} 正在等待其對話框
@plural {n} action|{n} actions {n} 個動作
# Batch / Image Processor
Include All Subfolders 包含所有子資料夾
Auto Add/Delete 自動增加/刪除
# Native Windows printing qualification
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7 -2
View File
@@ -985,6 +985,11 @@ fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: Value, choi
app.ui.open_dialog(DialogKind::Command, f)
}
/// The Batch and Image Processor dialogs' "Include All Subfolders" checkbox reads as Photoshop's.
fn subfolders_label() -> Value {
json!({"includeSubfolders": tl!("Include All Subfolders")})
}
/// Alt+Cancel (Reset): restore the `__form` params captured at open.
pub fn reset_form(f: &mut Map<String, Value>) {
if !f.contains_key("__form") {
@@ -1322,7 +1327,7 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value)
),
"file.scripts.imageProcessor" => dialog(
app,
json!({"input": dir, "output": format!("{dir}/processed"), "format": "jpg", "quality": 8, "width": 0, "height": 0, "convertToSrgb": true}),
json!({"input": dir, "includeSubfolders": false, "__labels": subfolders_label(), "output": format!("{dir}/processed"), "format": "jpg", "quality": 8, "width": 0, "height": 0, "convertToSrgb": true}),
json!({"format": ["jpg", "png", "psd", "tiff", "webp"]}),
),
"file.automate.batch" => {
@@ -1333,7 +1338,7 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value)
let name = action.name.clone();
dialog(
app,
json!({"action": name, "steps": steps, "input": dir, "output": format!("{dir}/batch"), "format": "same", "quality": null, "__batchQuality": 8, "naming": "", "startingSerial": 1}),
json!({"action": name, "steps": steps, "input": dir, "includeSubfolders": false, "__labels": subfolders_label(), "output": format!("{dir}/batch"), "format": "same", "quality": null, "__batchQuality": 8, "naming": "", "startingSerial": 1}),
json!({"format": ["same", "png", "jpg", "psd", "tiff", "webp"]}),
)
}
+26
View File
@@ -359,6 +359,32 @@ fn lookup_export_dialog_uses_the_current_adjustment_selection() {
assert!(direct["cube"].as_str().unwrap().contains("LUT_3D_SIZE 3"));
}
/// Batch and Image Processor carry Photoshop's "Include All Subfolders" checkbox (AUTO-219-5,
/// #3753): off by default, and confirming it on processes a subfolder's images too.
#[test]
fn batch_dialogs_include_all_subfolders() {
let (mut app, ctx) = app_with(1);
let dir = std::env::temp_dir().join(format!("photocraft-ui-batch-subfolders-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("in/sub")).unwrap();
let input = dir.join("in").to_string_lossy().into_owned();
app.run("file.saveACopy", json!({"path": dir.join("in/sub/a.png").to_string_lossy()})).unwrap();
app.session.actions.list.push(photocraft_engine::actions_cmds::Action::new("Invert", vec![("image.adjustments.invert".into(), json!({}))]));
for (id, out) in [("file.automate.batch", "batch"), ("file.scripts.imageProcessor", "processed")] {
let d = menu(&mut app, &ctx, id, json!({})).unwrap()["dialog"].as_u64().unwrap();
let fields = &mut app.ui.dialog_mut(d).unwrap().fields;
assert_eq!(fields["includeSubfolders"], json!(false), "{id}");
assert_eq!(fields["__labels"]["includeSubfolders"], json!("Include All Subfolders"), "{id}");
fields.insert("input".into(), json!(input));
fields.insert("output".into(), json!(dir.join(out).to_string_lossy()));
fields.insert("includeSubfolders".into(), json!(true));
let r = crate::dialogs::confirm(&mut app, d).unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(1), "{id}: {r}");
assert!(dir.join(out).join("sub").is_dir(), "{id} keeps the subfolder: {r}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Float in Window takes the document and the screen position a dragged-off tab was dropped at
/// (#3733); bad params are errors, not panics.
#[test]
+4 -3
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G11: Batch Include All Subfolders landed; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
@@ -92,8 +92,8 @@ what an agent can't finish alone.
- **2026-10-13 evidence:** Batch supports [output naming templates](batch-naming.md) using
`{name}`, `{serial}` and a captured UTC `{date}`, with a starting serial number. Colliding
names remain errors; custom output names cannot replace inputs. AUTO-219-5 stays partial.
- **Missing:** document-independent action recording (AUTO-219-1), Batch recursion, open-document inputs, progress/cancel and an error-log file
(AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the
- **Missing:** document-independent action recording (AUTO-219-1), Batch open-document inputs, progress/cancel and an error-log file
(AUTO-219-5; Include All Subfolders landed 2026-10-11, #3753), ExtendScript/UXP compatibility. `.atn` steps outside the
mapped events (paths, scripts, Generative Fill, Filter Gallery effects other than Spatter,
Duplicate Layers into a new document, Bitmap halftone screens…) load as kept Photoshop steps
that fail at playback by name.
@@ -286,6 +286,7 @@ what an agent can't finish alone.
| 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open |
| 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted |
| 2026-10-11 | minor | G8: Character panel OpenType buttons landed (TYPE-218-5, #3752); Numerals and Stylistic Sets submenus still missing |
| 2026-10-11 | minor | G11: Batch and Image Processor gained Include All Subfolders (AUTO-219-5 stays partial; #3753) |
| 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open |
| 2026-10-10 | minor | Checked against the alpha gate (no alpha blockers); #2374 marked closed |
| 2026-10-10 | major | First version, from the full re-measure against Photoshop 2026 27.11.0 and the open issues |
+1 -1
View File
@@ -231,7 +231,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T
| AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | done | [#219](https://github.com/storytold/photocraft/issues/219) | 2026-10-12: set folders (collapsible, drag between sets, actions.assignSet), dialog toggle column (playback opens the step's dialog seeded with its values and resumes on OK; actions.dialog), Insert Stop… (actions.insert), step toggles, reorder, delete a step; Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle |
| AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. |
| AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) |
| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial; no recursion, open-documents source, cancel or error-log file |
| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file |
| AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only |
| AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency |
| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check |
+1 -1
View File
@@ -44,7 +44,7 @@ id = "AUTO-219-5"
target = "Batch: subfolders, open documents, naming template, progress/cancel, error log, stop"
status = "partial"
issue = 219
note = "file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial; no recursion, open-documents source, cancel or error-log file"
note = "file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file"
[[item]]
id = "AUTO-219-6"