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]