From 89339396ff50ffaff388337e01e2d0e38dc1466c Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 23:42:36 +0200 Subject: [PATCH] Droplets: carry the actions that Conditional steps call (#6804) called_actions only followed actions.play steps, so a droplet whose action used actions.conditional saved neither branch and failed with "no action named ..." when run standalone. It now also queues the then/else targets. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/actions_cmds.rs | 33 +++++++++++++++++------- crates/engine/src/automate_cmds.rs | 2 +- crates/engine/src/automate_cmds/tests.rs | 32 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index d881a6112b..640810ee1b 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -151,18 +151,33 @@ pub fn playback_plan(s: &Session, p: &Value) -> Result<(Action, usize)> { Ok((action, from)) } -/// The actions that `steps` call through Play Action steps, followed transitively, in list order, -/// so a droplet can run without the Actions panel that made it (#2794). Index references in -/// `steps` and in the copies become names, which resolve without the rest of the list. A target -/// that doesn't resolve is left alone: it fails at playback, as it would have before. +/// The actions that `steps` call through Play Action steps and the branches of Conditional steps, +/// followed transitively, in list order, so a droplet can run without the Actions panel that made +/// it (#2794, #6804). Index references in `steps` and in the copies become names, which resolve +/// without the rest of the list. A target that doesn't resolve is left alone: it fails at +/// playback, as it would have before. pub fn called_actions(list: &[Action], steps: &mut [(String, Value)]) -> Vec { fn targets(list: &[Action], steps: &mut [(String, Value)], depth: usize, queue: &mut Vec<(usize, usize)>) { - for (_, params) in steps.iter_mut().filter(|(id, _)| id == "actions.play") { - let Ok(i) = resolve(list, params, "actions.play") else { continue }; - if let (Some(action), Some(obj)) = (list.get(i), params.as_object_mut()) { - obj.insert("action".into(), json!(action.name)); + for (id, params) in steps.iter_mut() { + match id.as_str() { + "actions.play" => { + let Ok(i) = resolve(list, params, "actions.play") else { continue }; + if let (Some(action), Some(obj)) = (list.get(i), params.as_object_mut()) { + obj.insert("action".into(), json!(action.name)); + } + queue.push((i, depth)); + } + // Conditional branches only take names, so there is nothing to rewrite. + "actions.conditional" => { + for key in ["then", "else"] { + let Some(name) = params.get(key).and_then(Value::as_str) else { continue }; + if let Some(i) = list.iter().position(|a| a.name == name) { + queue.push((i, depth)); + } + } + } + _ => {} } - queue.push((i, depth)); } } let mut queue = Vec::new(); diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 4c78f90b34..38ad1f55d0 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -558,7 +558,7 @@ pub fn specs() -> Vec { "file.automate.createDroplet", "Create Droplet…", &["File", "Automate"], - r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on Unix (writes .command calling `photocraft-cli droplet`)} → {path, shim} (the actions its Play Action steps call are saved in the droplet)"##, + r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on Unix (writes .command calling `photocraft-cli droplet`)} → {path, shim} (the actions its Play Action and Conditional steps call are saved in the droplet)"##, native, create_droplet ), diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index 06d0e88f36..322e6fb5b3 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -252,6 +252,38 @@ fn droplets_carry_the_actions_their_play_steps_call() { assert_eq!(r["files"].as_array().unwrap().len(), inputs.len(), "{r}"); } +#[test] +fn droplets_carry_the_actions_their_conditional_steps_call() { + use crate::actions_cmds::Action; + let dir = tmp("droplet-conditional"); + let inputs = images(&dir, 2, 40, 20); + let mut s = Session::new(); + let act = |name: &str, steps: Value| -> Action { serde_json::from_value(json!({"name": name, "steps": steps})).unwrap() }; + s.actions.list = vec![ + act("Unrelated", json!([["image.imageRotation.180", {}]])), + act("Rotate", json!([["image.imageRotation.90cw", {}]])), + // The then branch calls Rotate by index, as the panel records it. + act("Wide", json!([["actions.play", {"action": 1}]])), + act("Tall", json!([["image.imageRotation.180", {}]])), + ]; + let steps = json!([["actions.conditional", {"if": "landscape", "then": "Wide", "else": "Tall"}]]); + let out = format!("{dir}/out"); + let r = s + .execute("file.automate.createDroplet", json!({"path": format!("{dir}/Cond"), "steps": steps, "format": "png", "output": out, "shim": false})) + .unwrap(); + let dp = r["path"].as_str().unwrap().to_string(); + let v: Value = serde_json::from_slice(&std::fs::read(&dp).unwrap()).unwrap(); + let names: Vec<&str> = v["calledActions"].as_array().unwrap().iter().map(|a| a["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Rotate", "Wide", "Tall"], "both branches and what they call"); + + // A fresh session, as `photocraft-cli droplet` and the droplet shim use. + let mut fresh = Session::new(); + let r = fresh.execute("file.automate.runDroplet", json!({"droplet": dp, "input": [dir.clone()]})).unwrap(); + assert_eq!(r["files"].as_array().unwrap().len(), inputs.len(), "{r}"); + let img = photocraft_codecs::decode(&std::fs::read(format!("{out}/img0.png")).unwrap()).unwrap(); + assert_eq!(img.dimensions(), (20, 40), "the landscape input took the then branch"); +} + #[test] fn batch_and_droplets_skip_recorded_view_steps() { let dir = tmp("view-steps");