From a31086333077cf228ef2cb5c864392ef9a696cf7 Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:30:15 -0400 Subject: [PATCH] Actions: Photo Filter saves to .atn under the name Photoshop runs (#8301) Save Actions wrote Photo Filter steps with the four-char event code PhtF. Photoshop loads the file but stops on playback ("The command 'Photo Filter' is not currently available"). The same descriptor runs under the string event photoFilter (measured by the reporter in Photoshop 27.11 at 8 and 16 bits). PhtF is the adjustment's class, not an event. - Photo Filter now exports by name (photoFilter), like Black & White, Box Blur and Surface Blur since dfe94d6a. The descriptor is unchanged. - Import still accepts PhtF through LEGACY_CODES, so actions PhotoCraft saved before keep loading, and they re-save under the name. - Tests: Photo Filter joins the by-name/legacy-code test (export id, import by name and old code, near misses stay Photoshop steps). A new test records the issue's exact step, saves it strictly with nothing skipped, loads a Photoshop-authored step and an old PhtF step, re-saves both as photoFilter, and plays both the same as a direct run at 8 and 16 bits. Both tests fail without the fix. --- crates/engine/src/atn_cmds.rs | 9 ++-- crates/engine/src/atn_cmds/tests.rs | 76 +++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 343a5920a0..06f2cfa4ef 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -795,7 +795,8 @@ fn gradient_map_to(p: &Value) -> Option { ) } -/// An adjustment: its Photoshop class (also the direct event's code), the command suffix (`image.adjustments.`, `layer.newAdjustmentLayer.`), and +/// An adjustment: its Photoshop class (usually also the direct event's code; Black & White and +/// Photo Filter run by name), the command suffix (`image.adjustments.`, `layer.newAdjustmentLayer.`), and /// the converters between the adjustment descriptor and the command params. struct Adjustment { class: &'static str, @@ -2772,7 +2773,8 @@ const EVENTS: &[Event] = &[ adjustment_event!("ClrB", "ClrB", "colorBalance", "Color Balance"), adjustment_event!("Vbrn", "Vbrn", "vibrance", "Vibrance"), adjustment_event!("Exps", "Exps", "exposure", "Exposure"), - adjustment_event!("PhtF", "PhtF", "photoFilter", "Photo Filter"), + // Photoshop runs Photo Filter by name only: `PhtF` is the class, not an event (#8301). + adjustment_event!("", "PhtF", "photoFilter", "Photo Filter"), Event { code: "", name: "blackAndWhite", @@ -3057,7 +3059,8 @@ const EVENTS: &[Event] = &[ /// these codes fails ("the command is not currently available"), and `Fltn` is the /// `flatness` property, not the flatten event. Import keeps accepting them so actions saved /// before still load; export never writes them. -const LEGACY_CODES: &[(&str, &str)] = &[("BoxB", "boxblur"), ("SrfB", "surfaceBlur"), ("Fltn", "flattenImage"), ("BlnW", "blackAndWhite")]; +const LEGACY_CODES: &[(&str, &str)] = + &[("BoxB", "boxblur"), ("SrfB", "surfaceBlur"), ("Fltn", "flattenImage"), ("BlnW", "blackAndWhite"), ("PhtF", "photoFilter")]; fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 638fc45cb5..76f7ca74a7 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1084,17 +1084,19 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } -/// Measured in Photoshop: `executeAction` fails for the codes `BoxB`, `SrfB`, `Fltn` and -/// `BlnW` ("the command is not currently available"), while the names `boxblur`, -/// `surfaceBlur`, `flattenImage`, `blackAndWhite` and the code `FltI` run. +/// Measured in Photoshop: `executeAction` fails for the codes `BoxB`, `SrfB`, `Fltn`, `BlnW` +/// and `PhtF` ("the command is not currently available"), while the names `boxblur`, +/// `surfaceBlur`, `flattenImage`, `blackAndWhite`, `photoFilter` and the code `FltI` run. #[test] fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import() { let bw = json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": false, "tintColor": "#e4d5b0"}); + let photo_filter = json!({"color": "#0022cd", "density": 67, "preserveLuminosity": false}); let cases = [ ("filter.blur.boxBlur", json!({"radius": 3}), "boxblur", "BoxB"), ("filter.blur.surfaceBlur", json!({"radius": 5, "threshold": 15}), "surfaceBlur", "SrfB"), ("layer.flattenImage", json!({}), "FltI", "Fltn"), ("image.adjustments.blackWhite", bw, "blackAndWhite", "BlnW"), + ("image.adjustments.photoFilter", photo_filter, "photoFilter", "PhtF"), ]; for (id, params, written, legacy) in &cases { let exported = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); @@ -1141,7 +1143,7 @@ fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import( // Unknown ids, near misses and the legacy codes written as names stay Photoshop steps, // never a panic or a wrong command. - for event in ["BoxBlur", "boxBlur", "BOXB", "Fltn ", "FLTI", "", "\u{0}\u{0}\u{0}\u{0}", "BlnWx"] { + for event in ["BoxBlur", "boxBlur", "BOXB", "Fltn ", "FLTI", "", "\u{0}\u{0}\u{0}\u{0}", "BlnWx", "PHTF", "PhtF ", "photofilter"] { let e = event_for(&item(event, "", None).event); assert!(e.is_none(), "{event:?} maps to {:?}", e.map(|e| e.ids)); } @@ -1155,6 +1157,72 @@ fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import( } } +/// #8301: Photoshop stops on a Photo Filter step saved as `PhtF` ("not currently available") +/// and runs the same descriptor under the name `photoFilter`. A recorded step saves by name, +/// a Photoshop-authored step re-saves by name, and actions saved before with `PhtF` still load +/// and play the same. +#[test] +fn photo_filter_saves_under_the_name_photoshop_runs() { + let params = json!({"color": "#0022cd", "density": 67, "preserveLuminosity": false}); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "#808080"})).unwrap(); + s.execute("actions.record", json!({"name": "Filter"})).unwrap(); + s.execute("image.adjustments.photoFilter", params.clone()).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let saved = s.execute("actions.save", json!({"action": "Filter"})).unwrap(); + assert_eq!(saved["skipped"], json!([]), "{saved}"); + let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let step = &file.actions[0].items[0]; + assert_eq!(step.event, AtnEvent::Name("photoFilter".into()), "never the code PhtF"); + assert_eq!(step.dictionary, "Photo Filter"); + let d = step.descriptor.clone().unwrap(); + for key in ["Clr ", "Dnst", "PrsL"] { + assert!(d.get(key).is_some(), "the descriptor keeps {key}: {d:?}"); + } + + // A Photoshop-authored step (by name) and an old PhotoCraft one (`PhtF`) both load to the + // command, and saving them again writes the name. + let data = + set_of("S", vec![("Photoshop", vec![item("photoFilter", "Photo Filter", Some(d.clone()))]), ("Legacy", vec![item("PhtF", "Photo Filter", Some(d))])]); + let mut t = Session::new(); + let r = t.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(r["unsupported"], json!([]), "{r}"); + for a in &t.actions.list { + assert_eq!(a.steps.len(), 1, "{}", a.name); + assert_eq!(a.steps[0].0, "image.adjustments.photoFilter", "{}", a.name); + let mut p = a.steps[0].1.clone(); + normalise(&mut p); + assert_eq!(p, params, "{}", a.name); + } + let resaved = t.execute("actions.save", json!({"set": "S"})).unwrap(); + assert_eq!(resaved["skipped"], json!([]), "{resaved}"); + let file = atn::parse(&photocraft_paint::tile::b64_decode(resaved["data"].as_str().unwrap()).unwrap()).unwrap(); + let events: Vec = file.actions.iter().flat_map(|a| a.items.iter().map(|i| i.event.id())).collect(); + assert_eq!(events, ["photoFilter", "photoFilter"]); + + // Both play like the command run directly, at 8 and 16 bits. + for depth in [8, 16] { + let pixel = |s: &Session| { + let st = s.active().unwrap(); + let surface = st.doc.layers.first().unwrap().surface().unwrap(); + [0, 1, 2].map(|c| surface.sample_channel(3, 3, c)) + }; + let new_doc = |t: &mut Session| { + t.execute("file.new", json!({"width": 8, "height": 8, "background": "#808080", "depth": depth})).unwrap(); + }; + new_doc(&mut t); + t.execute("image.adjustments.photoFilter", params.clone()).unwrap(); + let direct = pixel(&t); + assert_ne!(direct, [128.0 / 255.0; 3], "the filter changes the grey"); + for action in ["Photoshop", "Legacy"] { + new_doc(&mut t); + let r = t.execute("actions.play", json!({"action": action})).unwrap(); + assert!(r.get("failed").is_none(), "{action}: {r}"); + assert_eq!(pixel(&t), direct, "{action} at {depth} bits"); + } + } +} + /// Smart Blur's quality uses Photoshop's `SmBQ` codes (`SBQL`/`SBQM`/`SBQH`): measured in /// Photoshop, the old `Hgh `/`Mdm `/`Low ` play back as its fallback quality. Old codes still read. #[test]