diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 47f4b688fb..eaa0e61acf 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -34,6 +34,7 @@ mod fields; mod files; mod steps_channels; +mod steps_content_aware; mod steps_distort; mod steps_doc; mod steps_gallery; diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index a29b4a1957..b3a8fb93f2 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -4,6 +4,7 @@ use super::descriptors::{desc, get_bool, get_ref, jbool, layer_ref, layer_target use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step}; use super::steps_channels::{match_color_from, match_color_to, merge_spot_from, merge_spot_to, split_from, split_to, trap_from, trap_to}; +use super::steps_content_aware::{caf_from, caf_to}; use super::steps_distort::{PINCH, POLAR, SPHERIZE, TWIRL, ZIGZAG, keys_from, keys_to, wave_from, wave_to}; use super::steps_doc::{ align_from, align_to, canvas_size_from, canvas_size_to, close_from, close_to, conditional_from, conditional_to, convert_mode_from, convert_mode_to, @@ -857,6 +858,7 @@ pub(super) const EVENTS: &[Event] = &[ from: |d| profile_from(d, "edit.convertToProfile"), to: profile_to, }, + Event { code: "", name: "cafWorkspace", label: "Content-Aware Fill", ids: &["edit.contentAwareFill"], from: caf_from, to: caf_to }, // File and actions. Event { code: "Cls ", name: "close", label: "Close", ids: &["file.close"], from: close_from, to: close_to }, Event { code: "Expr", name: "export", label: "Export", ids: &["file.export.saveForWebLegacy"], from: export_from, to: export_to }, diff --git a/crates/engine/src/cmd/atn_cmds/steps_content_aware.rs b/crates/engine/src/cmd/atn_cmds/steps_content_aware.rs new file mode 100644 index 0000000000..945cb8ee23 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_content_aware.rs @@ -0,0 +1,99 @@ +//! Recorded Content-Aware Fill settings; selection and job/history behavior belong to the command. +use super::descriptors::{desc, enm, jstr}; +use super::fields::{Mapped, step}; +use super::tables::lookup_back; +use super::{D, Descriptor, Map, Value}; + +const CMD: &str = "edit.contentAwareFill"; +const SOURCE: &str = "_atnCafDescriptor"; +// The measured row names these values; its ellipses are not additional enum bindings. +const SAMPLING: &[(&str, &str)] = &[("cafSamplingRegionRectangular", "rectangular")]; +const COLOR: &[(&str, &str)] = &[("cafColorAdaptationDefault", "default")]; +const ROTATION: &[(&str, &str)] = &[("cafRotationAmountNone", "none")]; +const OUTPUT: &[(&str, &str)] = &[("cafOutputToNewLayer", "new")]; +const KEYS: &[&str] = &[ + "cafSamplingRegion", + "cafSampleAllLayers", + "cafColorAdaptationLevel", + "cafRotationAmount", + "cafScale", + "cafMirror", + "cafOutput", +]; +const PARAMS: &[&str] = &["sampling", "sampleAllLayers", "colorAdaptation", "rotationAdaptation", "scale", "mirror", "output"]; + +/// All seven settings are recorded. Missing keys have no measured Photoshop default here. +fn recorded(d: &Descriptor) -> Option { + if d.items.len() != KEYS.len() || KEYS.iter().any(|name| d.items.iter().filter(|(key, _)| key.is(name)).count() != 1) { + return None; + } + let mut p = Map::new(); + for (key, param, table) in [ + (KEYS[0], PARAMS[0], SAMPLING), + (KEYS[2], PARAMS[2], COLOR), + (KEYS[3], PARAMS[3], ROTATION), + (KEYS[6], PARAMS[6], OUTPUT), + ] { + let D::Enumerated { type_id, value } = d.get(key)? else { return None }; + if !type_id.is(key) { + return None; + } + p.insert(param.into(), table.iter().find(|(wire, _)| value.is(wire))?.1.into()); + } + for (key, param) in [(KEYS[1], PARAMS[1]), (KEYS[4], PARAMS[4]), (KEYS[5], PARAMS[5])] { + let D::Boolean(value) = d.get(key)? else { return None }; + p.insert(param.into(), (*value).into()); + } + Some(Value::Object(p)) +} + +/// Write only represented native options, making their defaults explicit in all seven fields. +fn descriptor(p: &Value) -> Option { + let object = p.as_object()?; + if !super::events::pixel_target(p) + || object.keys().any(|key| !PARAMS.contains(&key.as_str()) && !matches!(key.as_str(), "target" | "seed" | SOURCE)) + || p.get("seed").is_some_and(|seed| seed.as_u64() != Some(1)) + { + return None; + } + let enumeration = |key: &str, param: &str, table: &[(&str, &str)], default: &str| { + let value = match p.get(param) { + None => default, + Some(value) => value.as_str()?, + }; + Some(enm(key, lookup_back(table, value)?)) + }; + let boolean = |param: &str| match p.get(param) { + None => Some(D::Boolean(false)), + Some(value) => value.as_bool().map(D::Boolean), + }; + Some( + desc("null") + .with(KEYS[0], enumeration(KEYS[0], PARAMS[0], SAMPLING, "auto")?) + .with(KEYS[1], boolean(PARAMS[1])?) + .with(KEYS[2], enumeration(KEYS[2], PARAMS[2], COLOR, "default")?) + .with(KEYS[3], enumeration(KEYS[3], PARAMS[3], ROTATION, "none")?) + .with(KEYS[4], boolean(PARAMS[4])?) + .with(KEYS[5], boolean(PARAMS[5])?) + .with(KEYS[6], enumeration(KEYS[6], PARAMS[6], OUTPUT, "current")?), + ) +} + +pub(super) fn caf_from(d: &Descriptor) -> Mapped { + let Some(mut p) = recorded(d) else { return Mapped::Unsupported { harmless: false } }; + if descriptor(&p).as_ref() != Some(d) { + // Key order, descriptor identity and ID encoding are wire data, not native options. + p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into(); + } + step(CMD, p) +} + +pub(super) fn caf_to(_: &str, p: &Value) -> Option { + let canonical = descriptor(p)?; + if p.get(SOURCE).is_none() { + return Some(canonical); + } + let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?; + // Edited native options must never be overwritten by stale preservation metadata. + (descriptor(&recorded(&source)?)? == canonical).then_some(source) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f153ee83af..3abe96dec6 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -494,6 +494,11 @@ fn every_mapped_command_exports_and_imports_back() { ("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"intersect"})), ("edit.pasteSpecial.pasteInto", json!({})), ("edit.pasteSpecial.pasteOutside", json!({})), + ( + "edit.contentAwareFill", + json!({"sampling":"rectangular","sampleAllLayers":true,"colorAdaptation":"default", + "rotationAdaptation":"none","scale":true,"mirror":false,"output":"new"}), + ), ("edit.fade", json!({"opacity":50,"mode":"Multiply"})), ("layer.linkLayers", json!({})), ("layer.distribute.leftEdges", json!({})), diff --git a/crates/engine/tests/it/atn_content_aware_fill.rs b/crates/engine/tests/it/atn_content_aware_fill.rs new file mode 100644 index 0000000000..d8175c0b5d --- /dev/null +++ b/crates/engine/tests/it/atn_content_aware_fill.rs @@ -0,0 +1,264 @@ +//! Recorded CAF settings use the native operation; unsupported descriptors stay whole. +use photocraft_engine::{ + Session, + actions_cmds::Action, + atn_cmds::{export, import}, +}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; +use serde_json::{Value, json}; + +const CMD: &str = "edit.contentAwareFill"; + +fn enm(key: &str, value: &str) -> D { + D::Enumerated { type_id: Id::new(key), value: Id::new(value) } +} + +fn descriptor() -> Descriptor { + Descriptor::new("null") + .with("cafSamplingRegion", enm("cafSamplingRegion", "cafSamplingRegionRectangular")) + .with("cafSampleAllLayers", D::Boolean(false)) + .with("cafColorAdaptationLevel", enm("cafColorAdaptationLevel", "cafColorAdaptationDefault")) + .with("cafRotationAmount", enm("cafRotationAmount", "cafRotationAmountNone")) + .with("cafScale", D::Boolean(false)) + .with("cafMirror", D::Boolean(false)) + .with("cafOutput", enm("cafOutput", "cafOutputToNewLayer")) +} + +fn params() -> Value { + json!({"sampling":"rectangular","sampleAllLayers":false,"colorAdaptation":"default", + "rotationAdaptation":"none","scale":false,"mirror":false,"output":"new"}) +} + +fn item(d: Descriptor) -> AtnItem { + AtnItem { event: AtnEvent::name("cafWorkspace"), dictionary: b"Content-Aware Fill".to_vec(), descriptor: Some(d), ..Default::default() } +} + +fn file(item: AtnItem) -> AtnFile { + AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item], ..Default::default() }], + ..Default::default() + } +} + +fn load(d: Descriptor) -> Action { + let loaded = import(&atn::write(&file(item(d))).unwrap()).unwrap(); + assert!(loaded.unsupported.is_empty()); + loaded.actions.into_iter().next().unwrap() +} + +fn exported(p: Value) -> Option { + let a = Action::new("A", vec![(CMD.into(), p)]); + let bytes = export("S", &[&a], &[], false).ok()?.0; + atn::parse(&bytes).ok()?.actions.into_iter().next()?.items.into_iter().next() +} + +/// Each field retains its native meaning, recorded type and position, including false booleans. +#[test] +fn caf_settings_roundtrip_seven_fields_and_boolean_choices() { + let base = descriptor(); + let mut cases = Vec::new(); + for bits in 0..8 { + let mut d = base.clone(); + let mut p = params(); + for (bit, index, param) in [(0, 1, "sampleAllLayers"), (1, 4, "scale"), (2, 5, "mirror")] { + let on = bits & (1 << bit) != 0; + d.items[index].1 = D::Boolean(on); + p[param] = json!(on); + } + cases.push((d, p)); + } + for (d, p) in cases { + let action = load(d.clone()); + assert_eq!(action.steps, vec![(CMD.into(), p.clone())]); + assert_eq!(exported(p).unwrap(), item(d.clone())); + let bytes = export("S", &[&action], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], item(d)); + } +} + +/// A noncanonical source order is preservation data, and cannot overwrite an edited option. +#[test] +fn caf_source_order_and_flags_survive_without_reviving_stale_settings() { + let mut d = descriptor(); + d.items.reverse(); + let mut source = item(d); + source.enabled = false; + source.with_dialog = true; + source.dialog_options = 2; + let imported = import(&atn::write(&file(source.clone())).unwrap()).unwrap(); + assert!(imported.unsupported.is_empty()); + let a = &imported.actions[0]; + assert!(a.steps[0].1.get("_atnCafDescriptor").is_some()); + let bytes = export("S", &[a], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], source); + let mut edited = a.steps[0].1.clone(); + edited["mirror"] = json!(true); + assert!(exported(edited).is_none()); + let mut damaged = a.steps[0].1.clone(); + damaged["_atnCafDescriptor"] = json!("not base64"); + assert!(exported(damaged).is_none()); +} + +/// Missing defaults, malformed/repeated fields and unrepresented workspace state stay complete. +#[test] +fn caf_unrepresented_descriptors_are_kept_and_never_partially_play() { + let base = descriptor(); + let mut cases = vec![Descriptor::new("null"), base.clone().with("extra", D::Boolean(true))]; + for i in 0..base.items.len() { + let mut missing = base.clone(); + missing.items.remove(i); + cases.push(missing); + let mut repeated = base.clone(); + repeated.items.push(repeated.items[i].clone()); + cases.push(repeated); + let mut wrong = base.clone(); + wrong.items[i].1 = D::Integer(0); + cases.push(wrong); + } + for (i, key, value) in [ + (0, "cafSamplingRegion", "cafSamplingRegionAuto"), + (0, "cafSamplingRegion", "cafSamplingRegionCustom"), + (2, "cafColorAdaptationLevel", "cafColorAdaptationNone"), + (2, "cafColorAdaptationLevel", "cafColorAdaptationHigh"), + (2, "cafColorAdaptationLevel", "cafColorAdaptationVeryHigh"), + (2, "cafColorAdaptationLevel", "unknown"), + (3, "cafRotationAmount", "cafRotationAmountLow"), + (3, "cafRotationAmount", "cafRotationAmountMedium"), + (3, "cafRotationAmount", "cafRotationAmountHigh"), + (3, "cafRotationAmount", "cafRotationAmountFull"), + (3, "cafRotationAmount", "unknown"), + (6, "cafOutput", "cafOutputToCurrentLayer"), + (6, "cafOutput", "cafOutputToDuplicateLayer"), + ] { + let mut d = base.clone(); + d.items[i].1 = enm(key, value); + cases.push(d); + } + let mut wrong_class = base.clone(); + wrong_class.items[0].1 = enm("unrelatedEnum", "cafSamplingRegionRectangular"); + cases.push(wrong_class); + for d in cases { + let source = item(d); + let imported = import(&atn::write(&file(source.clone())).unwrap()).unwrap(); + assert_eq!(imported.unsupported.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, "actions.photoshopStep"); + let saved = export("S", &[&imported.actions[0]], &[], false).unwrap().0; + assert_eq!(atn::parse(&saved).unwrap().actions[0].items[0], source); + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.extend(imported.actions); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); + } +} + +/// Known native defaults become explicit values; unsupported defaults/options cannot disappear. +#[test] +fn caf_native_exports_preserve_effective_defaults_and_refuse_unrepresented_options() { + for p in [ + json!({"sampling":"rectangular","output":"new"}), + json!({"sampling":"rectangular","output":"new","seed":1,"target":"pixels"}), + params(), + ] { + let a = load(exported(p).unwrap().descriptor.unwrap()); + assert_eq!(a.steps, vec![(CMD.into(), params())]); + } + for (key, value) in [ + ("sampling", json!("auto")), ("sampling", json!("custom")), ("sampling", json!("unknown")), + ("output", json!("current")), ("output", json!("duplicate")), + ("colorAdaptation", json!("none")), ("colorAdaptation", json!("high")), ("colorAdaptation", json!("veryHigh")), + ("rotationAdaptation", json!("low")), ("rotationAdaptation", json!("medium")), + ("rotationAdaptation", json!("high")), ("rotationAdaptation", json!("full")), + ("sampleAllLayers", json!(0)), ("scale", Value::Null), ("mirror", json!("true")), ("seed", json!(2)), + ("layer", json!(1)), ("margin", json!(10)), ("area", json!([0,0,4,4])), ("channel", json!(0)), + ("samplingStrokes", json!([])), ("fillEdits", json!([])), ("target", json!("mask")), ("_atnCafDescriptor", Value::Null), + ] { + let mut p = params(); + p[key] = value; + assert!(exported(p.clone()).is_none(), "{p}"); + } + for p in [json!({}), json!({"sampling":"rectangular"}), json!({"output":"new"}), json!([])] { + assert!(exported(p.clone()).is_none(), "unrepresented native default: {p}"); + } +} + +fn session(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"white"})).unwrap(); + s.execute("tools.setColors", json!({"foreground":"#ff0000"})).unwrap(); + s.execute("select.rect", json!({"x":3,"y":3,"width":2,"height":2})).unwrap(); + s.execute("edit.fill", json!({"contents":"foreground"})).unwrap(); + s +} + +/// Native and imported fills produce the same real pixels, source/selection state and Undo/Redo. +#[test] +fn caf_action_matches_native_pixels_and_undo_at_each_depth() { + for depth in [8, 16, 32] { + let mut s = session(depth); + let before = s.active().unwrap().doc.clone(); + let source = s.active().unwrap().active_layer.unwrap(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.push(load(descriptor())); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{depth}: {result}"); + assert_eq!(result["ran"], 1); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 1); + assert_eq!(after.selection, before.selection); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); + assert_eq!(after.layer(source), before.layer(source), "new output keeps its source unchanged"); + let id = s.active().unwrap().active_layer.unwrap(); + let pixels = after.layer(id).unwrap().surface().unwrap(); + let center = pixels.rgba(3, 3); + assert!(center.iter().all(|v| (*v - 1.0).abs() < 0.01), "{depth}: hole was not filled: {center:?}"); + assert_eq!(pixels.rgba(0, 0)[3], 0.0, "new output is transparent outside the selection"); + let mut native = session(depth); + native.execute(CMD, params()).unwrap(); + let expected = &native.active().unwrap().doc; + assert_eq!(after.layers.len(), expected.layers.len()); + for (a, b) in after.layers.iter().zip(&expected.layers) { + let (a, b) = (a.surface().unwrap(), b.surface().unwrap()); + for y in 0..8 { + for x in 0..8 { + assert_eq!(a.rgba(x, y), b.rgba(x, y), "{depth}: pixel{x},{y}"); + } + } + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc, after); + + // Current-layer native calls still work, although their ATN form is outside this increment. + let mut current = session(depth); + let mut p = params(); + p["output"] = json!("current"); + current.execute(CMD, p).unwrap(); + assert_eq!(current.active().unwrap().doc.layers.len(), 1); + let center = current.active().unwrap().doc.layers[0].surface().unwrap().rgba(3, 3); + assert!(center.iter().all(|v| (*v - 1.0).abs() < 0.01), "{depth}: native Current Layer control failed: {center:?}"); + } +} + +/// The adapter cannot manufacture a selection or turn a refused native operation into an edit. +#[test] +fn caf_action_without_selection_fails_without_an_edit() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.push(load(descriptor())); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 467b04720d..b8fe161da5 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -2,6 +2,7 @@ mod affinity; mod artboard_export_names; +mod atn_content_aware_fill; mod atn_crystallize_cell_size; mod atn_reference_forms; mod atn_ripple_amount; diff --git a/docs/atn.md b/docs/atn.md index 99f883fc98..cc605c60cf 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -18,7 +18,7 @@ layers and documents, newer and older events and files, and a measured reference map yet, with everything needed to implement them without the reference application. -## Supported events (144) +## Supported events (145) | Code | String ID | Label | PhotoCraft command(s) | |---|---|---|---| @@ -45,6 +45,7 @@ the reference application. | (string ID only) | collapseAllGroupsEvent | Collapse All Groups | `layer.setExpanded` | | `ClrB` | colorBalance | Color Balance | `image.adjustments.colorBalance` | | `ClrR` | colorRange | Color Range | `select.colorRange` | +| (string ID only) | cafWorkspace | Content-Aware Fill | `edit.contentAwareFill` | | `Cntc` | contract | Contract | `select.modify.contract` | | `CnvM` | convertMode | Convert Mode | `image.mode.rgb`, `image.mode.grayscale`, `image.mode.cmyk`, `image.mode.lab`, `image.mode.multichannel`, `image.mode.bits8`, `image.mode.bits16`, `image.mode.bits32`, `image.mode.bitmap` | | (string ID only) | convertToProfile | Convert to Profile | `edit.convertToProfile` | @@ -167,6 +168,34 @@ the reference application. | `Wave` | wave | Wave | `filter.distort.wave` | | `ZgZg` | zigZag | ZigZag | `filter.distort.zigZag` | +### Content-Aware Fill recordings + +`cafWorkspace` maps the measured Rectangular / Default color adaptation / None rotation / +New Layer form to `edit.contentAwareFill`, retaining all seven keys and the three booleans: +Sample All Layers, Scale and Mirror. The selection comes from the preceding selection step. + +| Recorded key, in order | Supported value | Native parameter | +|---|---|---| +| `cafSamplingRegion` | `cafSamplingRegionRectangular` | `sampling: "rectangular"` | +| `cafSampleAllLayers` | boolean | `sampleAllLayers` | +| `cafColorAdaptationLevel` | `cafColorAdaptationDefault` | `colorAdaptation: "default"` | +| `cafRotationAmount` | `cafRotationAmountNone` | `rotationAdaptation: "none"` | +| `cafScale` | boolean | `scale` | +| `cafMirror` | boolean | `mirror` | +| `cafOutput` | `cafOutputToNewLayer` | `output: "new"` | + +Descriptor types, source identity and key order survive save while its native settings are +unchanged. Native export writes the seven keys explicitly; the known native boolean, color +adaptation and rotation defaults are supported when sampling is Rectangular and output is New +Layer. A native call using Auto or Current Layer defaults cannot yet be exported. + +The reference's ellipses do not establish additional enum forms. Auto/Custom sampling, +other adaptation levels, Current/Duplicate Layer output, absent keys, unknown settings and +malformed or repeated fields remain complete kept steps. Native layer targets, sampling/fill +edits and nondefault seeds also refuse export rather than lose their settings. Playback uses +the existing native fill and its selection, authorization, job and Undo/Redo behavior. The +remaining CAF forms and the other recorded tools in #10710 are still separate work. + ### Settings retained by mapped events - Transform keeps its recorded `FTcs` reference point. Show with `TglO` true maps to @@ -460,7 +489,6 @@ the recorded steps back from the saved Actions panel. Ranges come from the dialo | Shear (`Shr `) | `ShrP` list of `Pnt ` (`Hrzn`, `Vrtc` doubles), `UndA` (`WrpA` wrap around, `RptE` repeat edge pixels), `ShrS` long, `ShrE` long | The dialog writes whole numbers: `Hrzn` -64..63 (dragging past the grid clamps), `Vrtc` 1..128 with the first point at 1 and the last at 128; dragging a point out of the grid deletes it. `ShrS` is 0 and `ShrE` the last point's index (count - 1). Script runs take any value unclamped and crashed on far-off `ShrE`/`ShrS` or a first `Vrtc` below 1. | | Gradient tool, classic (`Grdn` / `gradientClassEvent`) | `From`, `T ` points in pixels; `Opct` percent, `Md ` blend mode, `Type` (`GrdT`: `Lnr `, `Rdl `, `Angl`, `Rflc`, `Dmnd`), `Rvrs` bool, `Dthr` bool, `UsMs` bool (transparency), `gradientsInterpolationMethod` (`Perc` perceptual, …), `Grad` gradient object (as in Gradient Map) | Recorded in that order. `Opct`, `Md ` and `Rvrs` appear only when they differ from 100 %, Normal and off; `Dthr` and `UsMs` only when on. The five types are the only accepted values. A step without `Grad` fails. | | Gradient tool, default mode | two steps: Make (`null` = class `contentLayer`, `Usng` = `Type` gradient layer object) then Set (`null` = `contentLayer` target, `T ` = the same object plus `Algn` false) | The object: `Dthr`, `gradientsInterpolationMethod`, `Angl` angle, `Type`, `Scl ` percent, `Ofst` (`Hrzn`, `Vrtc` percent), `Grad`. The new tool makes a gradient fill layer; Make holds the first click, Set the finished drag. | -| Content-Aware Fill (`cafWorkspace`) | `cafSamplingRegion` (`cafSamplingRegionRectangular`, …), `cafSampleAllLayers` bool, `cafColorAdaptationLevel` (`cafColorAdaptationDefault`, …), `cafRotationAmount` (`cafRotationAmountNone`, …), `cafScale` bool, `cafMirror` bool, `cafOutput` (`cafOutputToNewLayer`, …) | Runs on the selection, which is recorded as its own step before it. | | Perspective Warp (`perspectiveWarpTransform`) | `null` = layer target, `Md ` (`perspectiveWarpMode`: `warp`), `referenceRect` rectangle in pixels, `vertices` list of points in pixels (the layout quad), `warpedVertices` (the same points after warping), `quads` list of `indices` (4 vertex indexes each) | Layout and warp are one step. | | Print (`print`) then Print One Copy (`printOneCopy`) | `print`: `null` = reference `printOutput` property of the target document; `printOutput` (`PstS`, `Inte`, `printSixteenBit`, `printerName` text, `printProofSetup`), `printViewProofSetup`, `printOutputOptions` (caption, marks, `Bckg` colour, `BrdT`, `Bld `, `Rslt`, `vectorData`, `PgPs`, `Left`, `Top `, `Scl `, crop rectangle), `osSpecificPrintInfo` raw data, `Cptn` text. `printOneCopy` has no keys. | Pressing Print in the dialog records both steps. | | Create Droplet (`CrtD`) | `In ` alias: the droplet file; `Fldr` bool (include subfolders); `Usng` reference to the action; then the Batch keys below (destination, file naming, error log) when set | The same settings as Batch, with the droplet file instead of a source folder. |