Batch: skip recorded view steps in batch and droplets (#2752) (#2761)

* Actions: record File › Save and Save As steps and replay them (#2032)

Save and Save As run in the desktop shell (dialogs, TIFF prompt, background
save job), outside Session::execute, so they never reached the journal that
action recording copies from. The shell now journals `file.save` {} for a save
in place and `file.saveAs` {path, tiffLayers?} once a Save As has its path
(after the dialog or the TIFF Options answer). The engine treats both as
replayable shell steps; shell playback writes them synchronously without a
dialog. Batch and droplets drop recorded saves, since their destination folder
overrides them (Photoshop's "Override Action 'Save As' Commands").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Batch: skip recorded view steps in batch and droplets (#2752)

Actions recorded in the desktop app can hold the shell's view steps
(view.zoomIn, view.fitOnScreen, ...). Batch and Create Droplet checked
every step against the engine registry and rejected the whole action
with "unknown command". They now skip view steps, which don't change
the output, alongside the recorded saves #2746 already skips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-10 22:43:36 +02:00 committed by GitHub
parent 6ff2a03f33
commit cfe031f2a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 21 additions and 5 deletions

View File

@ -79,7 +79,7 @@ pub fn shell_save_command(id: &str) -> bool {
}
/// View-only menu commands recorded by the desktop shell. Headless playback reports
/// them as unsupported instead of silently dropping them.
/// them as unsupported instead of silently dropping them; Batch and droplets skip them.
pub fn shell_view_command(id: &str) -> bool {
matches!(id, "view.fitOnScreen" | "view.actualPixels" | "view.zoomIn" | "view.zoomOut")
}

View File

@ -292,8 +292,8 @@ fn create_droplet(s: &mut Session, p: &Value) -> Result<Value> {
let path = p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| bad(cmd, "missing \"path\" (where to save the droplet)"))?;
let steps_v = p.get("steps").or_else(|| p.get("action")).ok_or_else(|| bad(cmd, "missing \"steps\" (the action to run)"))?;
let mut steps = parse_steps(steps_v, cmd)?;
// The droplet's output folder replaces recorded saves, as in batch.
steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id));
// The droplet's output folder replaces recorded saves, and view steps don't apply, as in batch.
steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id) && !crate::actions_cmds::shell_view_command(id));
check_known(&steps, cmd)?;
let path = if path.ends_with(".pcdroplet") || path.ends_with(".json") { path.to_string() } else { format!("{path}.pcdroplet") };
let name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&path));

View File

@ -113,6 +113,21 @@ fn droplets_are_written_and_run() {
assert!(s.execute("file.automate.createDroplet", json!({"path": path, "steps": [["bogus", {}]]})).is_err());
}
#[test]
fn batch_and_droplets_skip_recorded_view_steps() {
let dir = tmp("view-steps");
images(&dir, 1, 40, 20);
let steps = json!([["view.zoomIn", {}], ["image.imageRotation.90cw", {}], ["view.fitOnScreen", {}]]);
let mut s = Session::new();
let r = s.execute("file.automate.batch", json!({"steps": steps, "input": dir.clone(), "output": format!("{dir}/batch"), "format": "png"})).unwrap();
assert!(r["errors"].as_array().unwrap().is_empty(), "{r}");
let out = photocraft_codecs::decode(&std::fs::read(format!("{dir}/batch/img0.png")).unwrap()).unwrap();
assert_eq!(out.dimensions(), (20, 40));
let r = s.execute("file.automate.createDroplet", json!({"path": format!("{dir}/Rotate"), "steps": steps})).unwrap();
let v: Value = serde_json::from_slice(&std::fs::read(r["path"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!(v["action"]["steps"], json!([["image.imageRotation.90cw", {}]]));
}
#[test]
fn statistics_makes_a_stack_mode_smart_object() {
let dir = tmp("stats");

View File

@ -775,8 +775,9 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let mut steps =
parse_steps(p.get("steps").or_else(|| p.get("action")).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "missing \"steps\"".into() })?)?;
// Each result goes to the destination folder, which overrides the action's recorded Save /
// Save As steps (Photoshop's "Override Action 'Save As' Commands").
steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id));
// Save As steps (Photoshop's "Override Action 'Save As' Commands"). Recorded view steps
// (zoom, fit) don't change the output, so they are skipped (#2752).
steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id) && !crate::actions_cmds::shell_view_command(id));
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") });
}