From a8c8036f49c7164f4e95ff14b33668b157a5ddfe Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 17:39:13 -0500 Subject: [PATCH 001/109] Record actions in a document-independent form (#219) Journal entries are what Actions record, so a step must mean the same thing on another document. The UI names the layer it clicked by id, which only replays on the document the recording came from. Before an entry is journaled (actions_record.rs): - a `layer` id that was the active (and only selected) layer is dropped, so the step acts on the active layer when played; - any other id becomes the layer's name when unique in the document (`layer.select` always, like Photoshop's "Select layer"); an ambiguous name keeps the id; - Type tool keystrokes (`type.edit` with `replace` and the session's `coalesce` key) fold into the `type.create` that made the layer or into one `type.edit` with the whole text, as Photoshop records "Make text layer" and "Set current text layer". The dispatcher captures the document before the command, also for background jobs, and remembers which text layer the last entry typed into, since the Type tool selects a clicked layer without a command. Tests: 11 in actions_record/tests.rs, including a recording that replays on a document whose layers have other ids. Scorecard AUTO-219-1 done; gaps, parity and protocol docs updated. --- crates/engine/src/actions_record.rs | 128 +++++++++++++++ crates/engine/src/actions_record/tests.rs | 184 ++++++++++++++++++++++ crates/engine/src/jobs.rs | 29 +++- crates/engine/src/lib.rs | 3 + docs/control-protocol.md | 2 +- docs/gaps.md | 9 +- docs/scorecard.md | 4 +- docs/target-app-parity.md | 3 +- scorecard/automation.toml | 4 +- 9 files changed, 352 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/actions_record.rs create mode 100644 crates/engine/src/actions_record/tests.rs diff --git a/crates/engine/src/actions_record.rs b/crates/engine/src/actions_record.rs new file mode 100644 index 0000000000..5c43797271 --- /dev/null +++ b/crates/engine/src/actions_record.rs @@ -0,0 +1,128 @@ +//! Document-independent recording (#219). +//! +//! The journal is what Actions record, so an entry must mean the same thing on another document. +//! The UI names the layer it clicked by id (`"layer": 7`); ids are per document, so a step kept +//! that way replays only on the document it came from. Before an entry is journaled: +//! +//! - a `layer` that was the active (and only selected) layer when the command ran is dropped: +//! the step acts on the active layer, as Photoshop's "current layer" does; +//! - any other `layer` id becomes the layer's name when the name is unique in the document, and +//! `layer.select` always does (selecting is choosing a layer by name, as Photoshop records +//! it); an ambiguous name keeps the id, which still replays on the recording document; +//! - the Type tool's keystrokes (`type.edit` with `replace` and the editing session's `coalesce` +//! key) fold into the entry before them: the `type.create` that made the layer gets the final +//! text, and keystrokes in an existing layer become one `type.edit` with the whole text, as +//! Photoshop records "Make text layer" and "Set current text layer". +//! +//! Ids are still accepted everywhere; only what the journal keeps changes. + +use std::sync::Arc; + +use photocraft_doc::{Document, LayerContent, LayerId}; +use serde_json::Value; + +use crate::Session; + +/// The active document as it was before a command ran, captured by the dispatcher. +#[derive(Clone, Default)] +pub(crate) struct Before { + doc: Option>, + active_layer: Option, + /// Every selected layer: a `layer` that is one of several stays explicit, because without it + /// a layer command acts on the whole selection ([`crate::layer_multi_cmds::targets`]). + selected: Vec, +} + +impl Before { + pub(crate) fn capture(s: &Session) -> Self { + match s.active() { + Some(st) => Self { doc: Some(st.doc.clone()), active_layer: st.active_layer, selected: st.selected_layers() }, + None => Self::default(), + } + } +} + +/// `params` with its `layer` id made portable (see the module doc). +pub(crate) fn portable(id: &str, mut params: Value, before: &Before) -> Value { + let Some(doc) = before.doc.as_deref() else { return params }; + let Some(target) = params.get("layer").and_then(Value::as_u64).map(LayerId) else { return params }; + let Some(obj) = params.as_object_mut() else { return params }; + let only_selected = before.selected.len() <= 1; + if id != "layer.select" && before.active_layer == Some(target) && only_selected { + obj.remove("layer"); + return params; + } + if let Some(layer) = doc.layer(target) + && doc.walk().iter().filter(|(_, _, l)| l.name == layer.name).count() == 1 + { + obj.insert("layer".into(), Value::String(layer.name.clone())); + } + params +} + +/// The text layer a journaled `type.create` made or `type.edit` changed, so the keystrokes that +/// follow fold into that entry only when they type into the same layer (the Type tool selects +/// a clicked layer without a command, so the journal alone cannot tell). +pub(crate) fn typed_layer(s: &Session, id: &str, params: &Value) -> Option { + match id { + "type.create" => s.active()?.active_layer, + "type.edit" => crate::commands::layer_param(s, params).ok(), + _ => None, + } +} + +/// What the journal keeps of a Type-tool keystroke. +pub(crate) enum Keystroke { + /// Not a keystroke: the entry is journaled as it is. + None, + /// Folded into the entry before it. + Folded, + /// A new entry setting the whole text, so a later keystroke can fold into it. + Step(Value), +} + +/// Folds a Type-tool keystroke into the journal entry before it (see the module doc), or turns +/// it into a whole-text step. `previous` is the layer the last entry typed into. +pub(crate) fn fold_keystroke(s: &mut Session, id: &str, params: &Value, previous: Option) -> Keystroke { + if id != "type.edit" { + return Keystroke::None; + } + let Some(obj) = params.as_object() else { return Keystroke::None }; + let Some(key) = obj.get("coalesce").and_then(Value::as_str) else { return Keystroke::None }; + if !obj.contains_key("replace") || !obj.keys().all(|k| matches!(k.as_str(), "layer" | "replace" | "coalesce")) { + return Keystroke::None; + } + let Ok(target) = crate::commands::layer_param(s, params) else { return Keystroke::None }; + // The text as it is now, after this keystroke: the step sets it whole. + let Some(text) = s.active().and_then(|st| st.doc.layer(target)).and_then(|l| match &l.content { + LayerContent::Text(t) => Some(t.text.clone()), + _ => None, + }) else { + return Keystroke::None; + }; + let layer = obj.get("layer"); + let whole = || { + let mut step = obj.clone(); + step.remove("replace"); + step.insert("text".into(), Value::String(text.clone())); + Keystroke::Step(Value::Object(step)) + }; + if previous != Some(target) { + return whole(); + } + let Some((last, lp)) = s.journal.last_mut() else { return whole() }; + let Some(lo) = lp.as_object_mut() else { return whole() }; + let folds = match last.as_str() { + "type.create" => layer.is_none(), + "type.edit" => lo.get("coalesce").and_then(Value::as_str) == Some(key) && lo.get("layer") == layer && lo.contains_key("text"), + _ => false, + }; + if !folds { + return whole(); + } + lo.insert("text".into(), Value::String(text)); + Keystroke::Folded +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/actions_record/tests.rs b/crates/engine/src/actions_record/tests.rs new file mode 100644 index 0000000000..2da6e21401 --- /dev/null +++ b/crates/engine/src/actions_record/tests.rs @@ -0,0 +1,184 @@ +use photocraft_doc::LayerId; +use serde_json::{Value, json}; + +use crate::jobs::Started; +use crate::{EngineError, Session}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + s +} + +fn new_layer(s: &mut Session, name: &str) -> u64 { + s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap() +} + +fn last(s: &Session) -> &(String, Value) { + s.journal.last().unwrap() +} + +fn background(s: &Session) -> u64 { + s.active().unwrap().doc.layers[0].id.0 +} + +#[test] +fn the_active_layer_is_implicit() { + let mut s = session(); + let shade = new_layer(&mut s, "Shade"); + s.execute("layer.setProps", json!({"layer": shade, "opacity": 0.5})).unwrap(); + assert_eq!(last(&s), &("layer.setProps".into(), json!({"opacity": 0.5})), "the active layer's id is dropped"); + assert_eq!(s.active().unwrap().doc.layer(LayerId(shade)).unwrap().opacity, 0.5, "the command still ran on it"); +} + +#[test] +fn another_layer_is_named() { + let mut s = session(); + let bg = background(&s); + new_layer(&mut s, "Shade"); + s.execute("layer.setProps", json!({"layer": bg, "visible": false})).unwrap(); + assert_eq!(last(&s), &("layer.setProps".into(), json!({"layer": "Background", "visible": false}))); +} + +#[test] +fn selecting_a_layer_is_always_by_name() { + let mut s = session(); + let shade = new_layer(&mut s, "Shade"); + s.execute("layer.select", json!({"layer": shade})).unwrap(); + assert_eq!(last(&s), &("layer.select".into(), json!({"layer": "Shade"})), "even though it was the active layer"); + s.execute("layer.select", json!({"layer": shade, "mode": "toggle"})).unwrap(); + assert_eq!(last(&s).1["mode"], "toggle", "the other params are kept"); +} + +#[test] +fn an_ambiguous_name_keeps_the_id() { + let mut s = session(); + let a = new_layer(&mut s, "Copy"); + let b = new_layer(&mut s, "Copy"); + s.execute("layer.setProps", json!({"layer": a, "opacity": 0.5})).unwrap(); + assert_eq!(last(&s), &("layer.setProps".into(), json!({"layer": a, "opacity": 0.5})), "two layers are called Copy"); + s.execute("layer.setProps", json!({"layer": b, "opacity": 0.5})).unwrap(); + assert_eq!(last(&s).1, json!({"opacity": 0.5}), "the active one is still implicit"); +} + +#[test] +fn one_of_several_selected_layers_stays_named() { + let mut s = session(); + let bg = background(&s); + let shade = new_layer(&mut s, "Shade"); + s.execute("layer.select", json!({"layer": bg, "mode": "add"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers().len(), 2); + let active = s.active().unwrap().active_layer.unwrap().0; + let name = if active == shade { "Shade" } else { "Background" }; + s.execute("layer.setProps", json!({"layer": active, "opacity": 0.5})).unwrap(); + assert_eq!(last(&s).1, json!({"layer": name, "opacity": 0.5}), "without the layer, a layer command acts on the whole selection"); +} + +#[test] +fn a_name_or_no_document_is_left_alone() { + let mut s = session(); + new_layer(&mut s, "Shade"); + s.execute("layer.setProps", json!({"layer": "Background", "visible": false})).unwrap(); + assert_eq!(last(&s).1, json!({"layer": "Background", "visible": false})); + let e = s.execute("layer.setProps", json!({"layer": 999, "visible": false})).unwrap_err(); + assert!(matches!(e, EngineError::NoLayer(_)), "{e}"); +} + +#[test] +fn a_background_job_journals_the_same_way() { + let mut s = session(); + let shade = new_layer(&mut s, "Shade"); + let id = match s.start("filter.blur.gaussianBlur", json!({"layer": shade, "radius": 2})).unwrap() { + Started::Job(id) => id, + Started::Done(v) => panic!("ran inline: {v}"), + }; + s.wait_job(id).unwrap(); + assert_eq!(last(&s), &("filter.blur.gaussianBlur".into(), json!({"radius": 2}))); +} + +#[test] +fn keystrokes_fold_into_the_type_layer_they_made() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let n = s.journal.len(); + for (i, ch) in "Hey".chars().enumerate() { + s.execute("type.edit", json!({"layer": id, "replace": {"start": i, "end": i, "text": ch.to_string()}, "coalesce": "t1"})).unwrap(); + } + // Backspace, then more. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 3, "text": ""}, "coalesce": "t1"})).unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 2, "text": "llo"}, "coalesce": "t1"})).unwrap(); + assert_eq!(s.journal.len(), n, "every keystroke folded into Make Type Layer"); + let (cmd, p) = last(&s); + assert_eq!(cmd, "type.create"); + assert_eq!(p["text"], "Hello"); + assert!(p.get("replace").is_none() && p.get("coalesce").is_none(), "{p}"); +} + +#[test] +fn keystrokes_in_an_existing_layer_become_one_edit() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "Hi", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"opacity": 0.9})).unwrap(); + let n = s.journal.len(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 2, "text": "!"}, "coalesce": "t2"})).unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 0, "end": 1, "text": "h"}, "coalesce": "t2"})).unwrap(); + assert_eq!(s.journal.len(), n + 1); + assert_eq!(last(&s), &("type.edit".into(), json!({"text": "hi!", "coalesce": "t2"}))); + // A new editing session starts a new step. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 3, "end": 3, "text": "?"}, "coalesce": "t3"})).unwrap(); + assert_eq!(s.journal.len(), n + 2); + assert_eq!(last(&s).1, json!({"text": "hi!?", "coalesce": "t3"})); +} + +#[test] +fn keystrokes_into_another_layer_do_not_fold() { + let mut s = session(); + let first = s.execute("type.create", json!({"x": 2, "y": 10, "text": "One", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let second = s.execute("type.create", json!({"x": 2, "y": 20, "text": "", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + // The Type tool selects a clicked layer without a command. + s.select_layer(LayerId(first)).unwrap(); + let n = s.journal.len(); + s.execute("type.edit", json!({"layer": first, "replace": {"start": 3, "end": 3, "text": "!"}, "coalesce": "t4"})).unwrap(); + assert_eq!(s.journal.len(), n + 1, "typing into the first layer is its own step"); + assert_eq!(last(&s).1, json!({"text": "One!", "coalesce": "t4"})); + assert_eq!(s.journal[n - 1].1["text"], "", "the second Make Type Layer keeps its own text"); + // Programmatic edits (no session key) and other edits stand on their own. + s.execute("type.edit", json!({"layer": second, "replace": {"start": 0, "end": 0, "text": "x"}})).unwrap(); + assert!(last(&s).1["layer"].is_string() && last(&s).1.get("replace").is_some(), "{}", last(&s).1); +} + +#[test] +fn a_recording_replays_on_a_document_with_other_layer_ids() { + let mut s = session(); + let bg = background(&s); + s.execute("actions.record", json!({"name": "Shade"})).unwrap(); + let shade = new_layer(&mut s, "Shade"); + s.execute("layer.setProps", json!({"layer": shade, "opacity": 0.5})).unwrap(); + s.execute("layer.select", json!({"layer": bg})).unwrap(); + s.execute("edit.fill", json!({"layer": bg, "color": "#ff0000"})).unwrap(); + let id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + for (i, ch) in "Hi".chars().enumerate() { + s.execute("type.edit", json!({"layer": id, "replace": {"start": i, "end": i, "text": ch.to_string()}, "coalesce": "t5"})).unwrap(); + } + s.execute("actions.stop", json!({})).unwrap(); + let steps = s.execute("actions.get", json!({"action": "Shade"})).unwrap()["steps"].clone(); + let ids: Vec<&str> = steps.as_array().unwrap().iter().map(|st| st[0].as_str().unwrap()).collect(); + assert_eq!(ids, ["layer.new.layer", "layer.setProps", "layer.select", "edit.fill", "type.create"], "{steps}"); + assert!(steps.as_array().unwrap().iter().all(|st| st[1].get("layer").is_none_or(Value::is_string)), "no layer ids: {steps}"); + + // A second document whose layers have other ids and a layer stacked over the background. + s.execute("file.new", json!({"width": 40, "height": 40, "background": "black"})).unwrap(); + new_layer(&mut s, "Top"); + assert_ne!(background(&s), bg); + let played = s.execute("actions.play", json!({"action": "Shade"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let d = &s.active().unwrap().doc; + let shade = d.walk().into_iter().find(|(_, _, l)| l.name == "Shade").map(|(_, _, l)| l).unwrap(); + assert_eq!(shade.opacity, 0.5, "the new layer, not the one of the first document, got the opacity"); + let text = d.walk().into_iter().find_map(|(_, _, l)| match &l.content { + photocraft_doc::LayerContent::Text(t) => Some(t.text.clone()), + _ => None, + }); + assert_eq!(text.as_deref(), Some("Hi"), "the typed text replayed as one step"); + assert!(s.documents()[0].doc.layer(LayerId(bg)).is_some()); +} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 9c8cb9cdd0..7e839ec436 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -182,6 +182,8 @@ struct Running { started_ms: f64, coalesce: Option, color_restrict: Option, + /// The document before the command, for document-independent journaling. + before: crate::actions_record::Before, rx: std::sync::mpsc::Receiver>, apply: Option, #[cfg(not(target_arch = "wasm32"))] @@ -335,6 +337,7 @@ pub fn run( started_ms: now_ms(), coalesce: s.coalesce_request.clone(), color_restrict: s.color_restrict, + before: crate::actions_record::Before::capture(s), rx, apply: Some(erased), #[cfg(not(target_arch = "wasm32"))] @@ -659,6 +662,7 @@ impl Session { // action it plays (a script step) must not flush the steps it ran so far into the action // being recorded, nor move the recording mark past the entries it is about to replace. let recording = if spec.journal && crate::actions_cmds::replayable(id) { self.actions.recording.take() } else { None }; + let before = crate::actions_record::Before::capture(self); // Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an // error instead of taking the app down. `edit` only commits a document after its closure // returns, so the documents are unchanged. @@ -686,7 +690,7 @@ impl Session { Ok(Started::Job(jid)) } (Ok(v), None) => { - self.after_command(id, params, spec.journal, Some(nested_from)); + self.after_command(id, params, spec.journal, Some(nested_from), &before); Ok(Started::Done(v)) } } @@ -699,7 +703,11 @@ impl Session { /// dropped, so a recorded action replays the command once, not its steps as well (#3067). /// Edit › Transform › Again (and Again on a Copy) is not replayable and keeps the concrete /// steps it ran instead. - fn after_command(&mut self, id: &str, params: Value, journal: bool, nested_from: Option) { + /// + /// `before` is the active document before the command: the journal names layers as the + /// command's caller saw them, in a form that replays on another document + /// ([`crate::actions_record`]). + fn after_command(&mut self, id: &str, params: Value, journal: bool, nested_from: Option, before: &crate::actions_record::Before) { // A layer-mask view ends when another layer becomes active (#196). if let Some(st) = self.active_mut() { crate::mask_view_cmds::fix(st); @@ -713,9 +721,18 @@ impl Session { if let Some(from) = nested_from.filter(|_| crate::actions_cmds::replayable(id)) { self.journal.truncate(from); } - if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { - self.journal.push((id.to_string(), params)); + let typed = crate::actions_record::typed_layer(self, id, ¶ms); + let params = crate::actions_record::portable(id, params, before); + match crate::actions_record::fold_keystroke(self, id, ¶ms, self.typed_layer) { + crate::actions_record::Keystroke::Folded => {} + crate::actions_record::Keystroke::Step(step) => self.journal.push((id.to_string(), step)), + crate::actions_record::Keystroke::None => { + if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { + self.journal.push((id.to_string(), params)); + } + } } + self.typed_layer = typed; } /// Apply (or report the failure of) a job whose worker has delivered `r`. @@ -735,7 +752,7 @@ impl Session { } fn apply_job(&mut self, job: &Running, apply: Option, payload: Payload) -> Result { - let Running { label, doc, snapshot, coalesce, color_restrict, command, params, journal, .. } = job; + let Running { label, doc, snapshot, coalesce, color_restrict, command, params, journal, before, .. } = job; let prev_active = self.active; let target = match doc { Some(d) => { @@ -770,7 +787,7 @@ impl Session { // Restoring the viewed document first would attribute Fade and slice updates // to that document instead, when the user switched tabs mid-job. if r.is_ok() { - self.after_command(command, params.clone(), *journal, Some(nested_from)); + self.after_command(command, params.clone(), *journal, Some(nested_from), before); } // Keep the user's active document unless the job opened a new one. // This also restores the selection when the apply step returned an error. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 58a8974298..495d9a7fdf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,6 +9,7 @@ pub mod actions_cmds; pub mod actions_edit_cmds; +pub mod actions_record; pub mod adjust_cmds; pub mod adjust_params; pub mod align_cmds; @@ -377,6 +378,8 @@ pub struct Session { pub journal: Vec<(String, Value)>, /// Coalescing key of the command being executed. coalesce_request: Option, + /// The text layer the last journal entry made or typed into ([`actions_record`]). + typed_layer: Option, /// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop). pub clipboard: Option, /// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e571e94f8b..c39ff69f8e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -123,7 +123,7 @@ store expose and persist the same setting; scripts keep using canonical command | `actions.list` | `{}` → `{actions:[{name, steps}], recording}` (the name being recorded, or null) | | `actions.get` | `{"action": name or index}` → `{name, steps:[[id, params], …], disabled:[step, …]}`. `steps` is the shape `file.automate.batch` and droplets take; `disabled` lists the 0-based steps playback skips | | `actions.record` | `{"name":"Red"}` starts a new action (default name `Action N`). `{"action": name or index}` appends to one that exists | -| `actions.stop` | `{}` → `{action, steps}`. Copies replayable journal entries since `actions.record` (queries and action-editing commands omitted) | +| `actions.stop` | `{}` → `{action, steps}`. Copies replayable journal entries since `actions.record` (queries and action-editing commands omitted). Steps are document-independent: a `layer` id that was the active layer is dropped (the step acts on the active layer when played), any other layer is recorded by name when the name is unique in the document (`layer.select` always), and the Type tool's keystrokes fold into one `type.create`/`type.edit` step carrying the whole text | | `actions.play` | `{"action": name or index, "from": step?}`. `from` and `failed.step` are 0-based. Returns `{action, ran, failed?:{step, id, error}}` and still returns ok when a step fails, so a partial run is reported. Leaves one history step per step that ran. Supports nested action calls up to 16 levels, rejecting cycles. While recording, playing another action appends one named `actions.play` step instead of its expanded commands. On an untrusted session each step is authorized the same way as a top-level command | | `actions.move` | `{"action": name or index, "step": index?, "to": index}` moves a step within its action, or moves the whole action when `step` is omitted. `to` is the final zero-based index. Pending steps can be reordered while recording; invalid moves leave the list unchanged. | | `actions.delete` | `{"action": name or index, "step": index?}`. With a zero-based `step`: `{action, deletedStep, steps}`, also allowed while recording. Without `step`: `{deleted}`, refused while recording | diff --git a/docs/gaps.md b/docs/gaps.md index 23b3751235..af365f972e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G11: document-independent recording landed, AUTO-219-1 done; 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) · **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 @@ -88,7 +88,7 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting -- **Missing:** document-independent action recording (AUTO-219-1), Batch recursion and naming +- **Missing:** Batch recursion and naming (AUTO-219-5), 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 @@ -111,6 +111,10 @@ what an agent can't finish alone. in set folders, has the dialog toggle column (playback pauses on the step's dialog and resumes on OK) and Insert Stop…; `actions.insert`, `actions.dialog`, `actions.assignSet` for agents (AUTO-219-2 done). +- **Done 2026-10-12:** recording is document-independent (AUTO-219-1): a step on the active + layer records no layer, any other layer records by name (Select Layer always), and the Type + tool's keystrokes fold into one Make Type Layer / Edit Type step with the whole text, as + Photoshop records them; a recording replays on a document whose layers have other ids. - **Ahead of Photoshop:** CLI, MCP and the JSON control channel drive every command. - **Estimate:** 120–200 h. **Human:** whether to support a JavaScript scripting dialect at all (AGENTS.md forbids JS in the app). @@ -266,6 +270,7 @@ what an agent can't finish alone. | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | | 2026-10-12 | minor | Refreshed against main: G5 items fixed by #2480, #2327, #2609, #2684, #2675, #2556, #2396, #2527, #2625 and TOOL-214-5/#2442; G7 TOOL-214-7 and Transform On Drop done; G2 #2377 fixed (#2417); G9 UI scale (UI-217-9) and Stamp Visible (UI-217-22) done, prefs 27 of 156; G10 #2225 done (#2482); G13 Khmer, Lao, Myanmar fallback (#3259); G14 CR3 line; G17 Windows ARM64 done (DIST-220-2) | +| 2026-10-12 | minor | G11: document-independent recording landed (AUTO-219-1 done) | | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 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 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 1ad3f3aa3d..369137da50 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | | [Type](#type) | 4 | 3 | 2 | | -| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 6 | 2 | 3 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -227,7 +227,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | Id | Target | Status | Issue | Note | |---|---|---|---|---| | AUTO-1 | Every menu item dispatches a command id (UI, CLI, control channel and MCP share them) | done | | docs/parity-checklist.md: all menu items live; menus.rs is_live | -| AUTO-219-1 | Record actions in a document-independent form (implicit layer, merged type keystrokes) | missing | [#219](https://github.com/storytold/photocraft/issues/219) | actions.rs:47 copies raw journal entries with explicit layer ids | +| AUTO-219-1 | Record actions in a document-independent form (implicit layer, merged type keystrokes) | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_record.rs: the active layer's id is dropped, other layers are journaled by name (layer.select always), Type tool keystrokes fold into one type.create/type.edit with the whole text; replay test on a document with other layer ids | | 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) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f6209c58db..b09372edd4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -177,7 +177,7 @@ parity for the area and are already contained in the dimension rows above. | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); missing on-canvas corner radius (TOOL-214-12), path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | -| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; recording document-independent and the panel complete since 2026-10-12 (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | | Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | @@ -567,6 +567,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | 2026-10-12 | minor | `.atn` import: 112 events mapped (TrueGrit Texture Supply sets play end to end); percentages unchanged | | 2026-10-12 | minor | Photoshop Actions `.atn` load and save (90 events mapped both ways); percentages unchanged | | 2026-10-12 | minor | Rows checked against `main` and the regenerated scorecard: menu count 631, Workspace checklist and preference count, transform context menu, Puppet Warp mesh, Select and Mask, anchor-point tools, Smart Object conversion, Stamp Visible, mask targeting, Free Transform commit/cancel, Vietnamese catalog, Windows ARM64; percentages, weights and hours not re-measured | +| 2026-10-12 | minor | Document-independent action recording (AUTO-219-1); percentages unchanged | | 2026-10-10 | minor | Added measured Tilt-Shift computation/filtered-proxy gains and inconclusive cases; parity percentages and budgets unchanged | | 2026-10-10 | minor | Readiness-by-audience table: percent and hours for full, mainstream and essentials; full number confirmed as the additive weighted sum over dimensions (46%, reported ~45%) | | 2026-10-10 | minor | Added mainstream-practitioner (~43%) and essentials-user (~61%) numbers with written weights and discounts; full ready-for-real-work rechecked against its written weights (46%, reported ~45%), unchanged | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 0de2ab3bd2..5ae52f6d32 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -14,9 +14,9 @@ note = "docs/parity-checklist.md: all menu items live; menus.rs is_live" [[item]] id = "AUTO-219-1" target = "Record actions in a document-independent form (implicit layer, merged type keystrokes)" -status = "missing" +status = "done" issue = 219 -note = "actions.rs:47 copies raw journal entries with explicit layer ids" +note = "actions_record.rs: the active layer's id is dropped, other layers are journaled by name (layer.select always), Type tool keystrokes fold into one type.create/type.edit with the whole text; replay test on a document with other layer ids" [[item]] id = "AUTO-219-2" From bc08629d292e1383a41e0db254a00a907f42c9e2 Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 18:35:48 -0500 Subject: [PATCH 002/109] Batch: subfolders, open documents, file naming, stop on error, error log, progress and cancel (#219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Automate › Batch now covers Photoshop's dialog (AUTO-219-5): - Source: a folder with Include All Subfolders (the output mirrors the folder tree, so same-named files in different subfolders don't collide), or the open documents (`"source": "open"`: the steps run on every open document as history steps; with an output folder a copy of each is saved there). - File Naming: a template with Photoshop's fields as tokens: {name} {NAME} {Name}, {serial} to {serial:4} from startSerial, {letter} {LETTER}, {date:mmddyy|mmdd|yyyymmdd|yymmdd|yyddmm|ddmmyy}, {ext} {EXT}, other text literal. Default {name}.{ext}, the old naming. - Errors: stopOnError stops at the first failing file and reports the skipped ones; errorLog writes the failures to a text file. - The command runs as a job: the status bar shows "n of total: file" and Cancel stops after the current file. The options live in the new engine module `batch` and are shared by Image Processor, the Lens Correction batch and the CLI's `batch` (--recursive --naming --start-serial --stop-on-error --error-log). The generic form dialog takes an `__order` hint so the Batch dialog is grouped as Photoshop's (Play, Source, Destination, Errors). chrono (clock, native only) supplies the local date for the date tokens. Tests: 9 in batch/tests.rs (tokens and rejected templates, recursive listing, mirrored output, stop/skip/log, cancelled context, background job, open-documents source), one CLI test, one form-order test. The batch_export_options helpers pick the nearest field on a label's row, since the taller dialog's Quality row now shares a height with the Layers panel's opacity field. Scorecard AUTO-219-5 done; gaps, parity and development docs updated. --- Cargo.lock | 1 + apps/photocraft-cli/src/lib.rs | 146 +++++++- crates/engine/Cargo.toml | 2 + crates/engine/src/batch.rs | 338 +++++++++++++++++++ crates/engine/src/batch/tests.rs | 248 ++++++++++++++ crates/engine/src/file_cmds.rs | 213 ++++++++++-- crates/engine/src/lens_cmds.rs | 8 +- crates/engine/src/lib.rs | 1 + crates/ui-egui/src/view_cmds.rs | 38 ++- crates/ui-egui/tests/batch_export_options.rs | 8 +- docs/development.md | 2 +- docs/gaps.md | 17 +- docs/scorecard.md | 4 +- docs/target-app-parity.md | 3 +- scorecard/automation.toml | 4 +- 15 files changed, 972 insertions(+), 61 deletions(-) create mode 100644 crates/engine/src/batch.rs create mode 100644 crates/engine/src/batch/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 79e7cd895e..3f1564a963 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4360,6 +4360,7 @@ name = "photocraft-engine" version = "0.8.2" dependencies = [ "blake3", + "chrono", "craft-print", "exr", "flate2", diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 87050381a5..f403365de3 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -27,8 +27,13 @@ USAGE: photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Open a file, run engine commands in order, save the result. Each --params applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] - Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a + photocraft-cli batch --actions [--action ] --in --out [--recursive] [--naming