editmamei 0.19.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/NOTICES.md +17 -1
  2. package/README.md +9 -8
  3. package/dist/api/extendscript/_helpers.js +1 -1
  4. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  5. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  6. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  7. package/dist/cli/config.js +3 -3
  8. package/dist/cli/help.js +4 -0
  9. package/dist/cli/install.js +2 -2
  10. package/dist/cli/report.js +11 -0
  11. package/dist/cli/router.js +20 -0
  12. package/dist/core/server.js +8 -8
  13. package/dist/core/settings.js +2 -2
  14. package/dist/core/tool-groups.js +92 -82
  15. package/dist/core/tool-tiers.js +91 -81
  16. package/dist/detection/landmark-detection-client.js +8 -0
  17. package/dist/detection/mesh-eye-correction.js +126 -0
  18. package/dist/detection/sam-segmenter.js +83 -0
  19. package/dist/diagnostics/collect.js +169 -0
  20. package/dist/index.js +2 -0
  21. package/dist/license/entitlement.js +52 -0
  22. package/dist/modules/ce/index.js +6 -4
  23. package/dist/perception/grounding-anchors.js +37 -0
  24. package/dist/perception/grounding-corners.js +93 -0
  25. package/dist/perception/grounding-edge-trace.js +90 -0
  26. package/dist/perception/grounding-extrema.js +41 -0
  27. package/dist/perception/grounding-gate.js +154 -0
  28. package/dist/perception/grounding-geometry.js +132 -0
  29. package/dist/perception/grounding-grid.js +34 -0
  30. package/dist/perception/grounding-landmarks.js +40 -0
  31. package/dist/perception/grounding-locate.js +341 -0
  32. package/dist/perception/grounding-resolver.js +202 -0
  33. package/dist/perception/grounding-review-crop.js +85 -0
  34. package/dist/perception/grounding-warp.js +182 -0
  35. package/dist/perception/sky-mask-transfer.js +50 -18
  36. package/dist/skills/editmamei-skill.zip +0 -0
  37. package/dist/tools/adjustment-tools.js +3 -3
  38. package/dist/tools/brush-tools.js +111 -23
  39. package/dist/tools/channel-compose-tools.js +241 -0
  40. package/dist/tools/detection-tools.js +1 -1
  41. package/dist/tools/diagnostics-tools.js +66 -0
  42. package/dist/tools/document-tools.js +6 -6
  43. package/dist/tools/filter-tools.js +476 -19
  44. package/dist/tools/group-tools.js +10 -10
  45. package/dist/tools/guide-tools.js +4 -4
  46. package/dist/tools/history-tools.js +4 -4
  47. package/dist/tools/image-placement-tools.js +1 -1
  48. package/dist/tools/image-tools.js +62 -19
  49. package/dist/tools/inspect-tools.js +2 -2
  50. package/dist/tools/layer-ordering-tools.js +1 -1
  51. package/dist/tools/layer-properties-tools.js +42 -23
  52. package/dist/tools/layer-tools.js +10 -10
  53. package/dist/tools/layer-transform-tools.js +80 -41
  54. package/dist/tools/overview-tools.js +42 -42
  55. package/dist/tools/path-tools.js +53 -10
  56. package/dist/tools/portrait-tools.js +3 -3
  57. package/dist/tools/preview-tools.js +7 -82
  58. package/dist/tools/retouch-tools.js +93 -25
  59. package/dist/tools/scene-tools.js +13 -13
  60. package/dist/tools/selection-tools.js +319 -40
  61. package/dist/tools/shape-tools.js +216 -0
  62. package/dist/tools/text-tools.js +1 -1
  63. package/dist/tools/transform-canvas-tools.js +2 -2
  64. package/dist/tools/vector-mask-tools.js +23 -8
  65. package/dist/utils/log-buffer.js +41 -0
  66. package/dist/utils/logger.js +12 -1
  67. package/dist/utils/session-log-reader.js +27 -1
  68. package/dist/version.js +1 -1
  69. package/package.json +1 -1
  70. package/dist/preview/coordinate-grid.js +0 -250
  71. package/dist/tools/object-tools.js +0 -193
  72. package/dist/tools/text-on-object-tools.js +0 -225
@@ -0,0 +1,182 @@
1
+ export function curveLength(curve) {
2
+ let total = 0;
3
+ for (let k = 0; k < curve.length - 1; k++)
4
+ total += Math.hypot(curve[k + 1].x - curve[k].x, curve[k + 1].y - curve[k].y);
5
+ return total;
6
+ }
7
+ export function sampleCurveAtLengths(curve, lengths) {
8
+ if (curve.length < 2)
9
+ throw new Error('curve needs at least 2 points');
10
+ if (curve.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y)))
11
+ throw new Error('curve has non-finite points');
12
+ const cum = [];
13
+ const seg = [];
14
+ let total = 0;
15
+ for (let k = 0; k < curve.length - 1; k++) {
16
+ cum.push(total);
17
+ const l = Math.hypot(curve[k + 1].x - curve[k].x, curve[k + 1].y - curve[k].y);
18
+ seg.push(l);
19
+ total += l;
20
+ }
21
+ if (total === 0)
22
+ throw new Error('curve has zero length (all points coincident)');
23
+ return lengths.map((target) => {
24
+ const tt = target < 0 ? 0 : target > total ? total : target;
25
+ let k = 0;
26
+ while (k < seg.length - 1 && cum[k] + seg[k] < tt)
27
+ k++;
28
+ const t = seg[k] > 0 ? (tt - cum[k]) / seg[k] : 0;
29
+ const a = curve[k];
30
+ const b = curve[k + 1];
31
+ const point = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
32
+ const dx = b.x - a.x;
33
+ const dy = b.y - a.y;
34
+ const m = Math.hypot(dx, dy) || 1;
35
+ return { point, normal: { x: -dy / m, y: dx / m } };
36
+ });
37
+ }
38
+ export function resampleByArcLength(curve, count) {
39
+ if (count < 2)
40
+ throw new Error('count must be at least 2');
41
+ const total = curveLength(curve);
42
+ const lengths = Array.from({ length: count }, (_, i) => (i / (count - 1)) * total);
43
+ return sampleCurveAtLengths(curve, lengths);
44
+ }
45
+ export function smoothCurve(curve, iterations) {
46
+ if (iterations <= 0 || curve.length < 3)
47
+ return curve.map((p) => ({ x: p.x, y: p.y }));
48
+ let pts = curve.map((p) => ({ x: p.x, y: p.y }));
49
+ for (let it = 0; it < iterations; it++) {
50
+ const next = pts.map((p) => ({ x: p.x, y: p.y }));
51
+ for (let i = 1; i < pts.length - 1; i++) {
52
+ next[i] = {
53
+ x: 0.5 * pts[i].x + 0.25 * pts[i - 1].x + 0.25 * pts[i + 1].x,
54
+ y: 0.5 * pts[i].y + 0.25 * pts[i - 1].y + 0.25 * pts[i + 1].y,
55
+ };
56
+ }
57
+ pts = next;
58
+ }
59
+ return pts;
60
+ }
61
+ export function warpAlongCurve(bounds, curve, opts) {
62
+ const { runAxis, alongCells, acrossCells } = opts;
63
+ const horizontal = runAxis === 'horizontal';
64
+ const alongCount = 3 * alongCells + 1;
65
+ const acrossCount = 3 * acrossCells + 1;
66
+ const along = horizontal ? bounds.right - bounds.left : bounds.bottom - bounds.top;
67
+ const thickness = horizontal ? bounds.bottom - bounds.top : bounds.right - bounds.left;
68
+ const src = (opts.smooth ?? 0) > 0 ? smoothCurve(curve, opts.smooth) : curve;
69
+ const total = curveLength(src);
70
+ const spanLen = (opts.fit ?? 'stretch') === 'preserve' ? Math.min(along, total) : total;
71
+ const curveCovered = total > 0 ? spanLen / total : 1;
72
+ const lengths = Array.from({ length: alongCount }, (_, i) => (i / (alongCount - 1)) * spanLen);
73
+ const samples = sampleCurveAtLengths(src, lengths);
74
+ const ncx = horizontal ? alongCells : acrossCells;
75
+ const ncy = horizontal ? acrossCells : alongCells;
76
+ const ncols = 3 * ncx + 1;
77
+ const nrows = 3 * ncy + 1;
78
+ const meshPoints = [];
79
+ let l = Infinity;
80
+ let t = Infinity;
81
+ let r = -Infinity;
82
+ let b = -Infinity;
83
+ for (let rj = 0; rj < nrows; rj++) {
84
+ for (let ci = 0; ci < ncols; ci++) {
85
+ const alongIdx = horizontal ? ci : rj;
86
+ const acrossIdx = horizontal ? rj : ci;
87
+ const s = samples[alongIdx];
88
+ const v = acrossCount > 1 ? acrossIdx / (acrossCount - 1) : 0.5;
89
+ const off = (v - 0.5) * thickness;
90
+ const x = s.point.x + s.normal.x * off;
91
+ const y = s.point.y + s.normal.y * off;
92
+ meshPoints.push({ x, y });
93
+ if (x < l)
94
+ l = x;
95
+ if (y < t)
96
+ t = y;
97
+ if (x > r)
98
+ r = x;
99
+ if (y > b)
100
+ b = y;
101
+ }
102
+ }
103
+ return {
104
+ meshPoints,
105
+ ncx,
106
+ ncy,
107
+ ncols,
108
+ nrows,
109
+ destBBox: { left: l, top: t, right: r, bottom: b },
110
+ curveCovered,
111
+ };
112
+ }
113
+ export function warpRadial(bounds, center, radius, amount, opts) {
114
+ const cells = opts.cells;
115
+ const ncx = cells;
116
+ const ncy = cells;
117
+ const ncols = 3 * ncx + 1;
118
+ const nrows = 3 * ncy + 1;
119
+ const W = bounds.right - bounds.left;
120
+ const H = bounds.bottom - bounds.top;
121
+ const meshPoints = [];
122
+ let maxDisplacement = 0;
123
+ let l = Infinity;
124
+ let t = Infinity;
125
+ let r = -Infinity;
126
+ let b = -Infinity;
127
+ for (let rj = 0; rj < nrows; rj++) {
128
+ for (let ci = 0; ci < ncols; ci++) {
129
+ const hx = bounds.left + (ci / (ncols - 1)) * W;
130
+ const hy = bounds.top + (rj / (nrows - 1)) * H;
131
+ const vx = hx - center.x;
132
+ const vy = hy - center.y;
133
+ const rr = Math.hypot(vx, vy);
134
+ const u = radius > 0 ? rr / radius : 1;
135
+ let dx = 0;
136
+ let dy = 0;
137
+ if (u < 1) {
138
+ const w = (1 - u * u) ** 2;
139
+ dx = vx * amount * w;
140
+ dy = vy * amount * w;
141
+ }
142
+ const x = hx + dx;
143
+ const y = hy + dy;
144
+ const d = Math.hypot(dx, dy);
145
+ if (d > maxDisplacement)
146
+ maxDisplacement = d;
147
+ meshPoints.push({ x, y });
148
+ if (x < l)
149
+ l = x;
150
+ if (y < t)
151
+ t = y;
152
+ if (x > r)
153
+ r = x;
154
+ if (y > b)
155
+ b = y;
156
+ }
157
+ }
158
+ return {
159
+ meshPoints,
160
+ ncx,
161
+ ncy,
162
+ ncols,
163
+ nrows,
164
+ destBBox: { left: l, top: t, right: r, bottom: b },
165
+ maxDisplacement,
166
+ };
167
+ }
168
+ export function farEndLift(bounds, pinEdge, target) {
169
+ if (pinEdge === 'left' || pinEdge === 'right') {
170
+ return target.y - (bounds.top + bounds.bottom) / 2;
171
+ }
172
+ return target.x - (bounds.left + bounds.right) / 2;
173
+ }
174
+ export function activeLayerBounds(context) {
175
+ const al = context?.activeLayer;
176
+ const b = al?.bounds;
177
+ if (!b || ![b.left, b.top, b.right, b.bottom].every((n) => Number.isFinite(n)))
178
+ throw new Error('could not read the active layer bounds (no active layer?)');
179
+ if (b.right - b.left <= 0 || b.bottom - b.top <= 0)
180
+ throw new Error('the active layer has empty bounds — nothing to warp');
181
+ return { left: b.left, top: b.top, right: b.right, bottom: b.bottom };
182
+ }
@@ -15,10 +15,10 @@ function encodeMaskJpeg(mask, w, h) {
15
15
  }
16
16
  return encode({ data: rgba, width: w, height: h }, 92).data;
17
17
  }
18
- function buildLoadScript(maskPath, docW, docH) {
18
+ function buildLoadScript(maskPath, docW, docH, selType) {
19
19
  return `
20
20
  ${getContextInfo}
21
- function __skyRestoreComposite(d) {
21
+ function __restoreComposite(d) {
22
22
  try {
23
23
  app.activeDocument = d;
24
24
  var ref = new ActionReference();
@@ -28,11 +28,27 @@ function buildLoadScript(maskPath, docW, docH) {
28
28
  app.executeAction(app.charIDToTypeID('slct'), desc, DialogModes.NO);
29
29
  } catch (eRC) {}
30
30
  }
31
+ function __selExists(d) {
32
+ try {
33
+ var r = new ActionReference();
34
+ r.putProperty(app.charIDToTypeID('Prpr'), app.stringIDToTypeID('selection'));
35
+ r.putEnumerated(app.charIDToTypeID('Dcmn'), app.charIDToTypeID('Ordn'), app.charIDToTypeID('Trgt'));
36
+ return app.executeActionGet(r).hasKey(app.stringIDToTypeID('selection'));
37
+ } catch (eSe) { return false; }
38
+ }
31
39
  if (app.documents.length === 0) { throw new Error('No active document'); }
32
40
  var orig = app.activeDocument;
41
+ var combine = '${selType}';
33
42
 
34
- // Bring the upscaled mask onto the clipboard FIRST, so no document switch sits
35
- // between the channel setup and the paste below.
43
+ // Save the prior selection (combine ops only) BEFORE the mask setup deselects it.
44
+ var priorCh = null;
45
+ if (combine !== 'REPLACE' && __selExists(orig)) {
46
+ priorCh = orig.channels.add();
47
+ orig.selection.store(priorCh);
48
+ }
49
+
50
+ // Bring the upscaled mask onto the clipboard, then paste it INTO a fresh full-canvas
51
+ // alpha channel on the original (selectAll + paste-into keeps it aligned to canvas).
36
52
  var maskDoc = app.open(new File(${jsLit(maskPath)}));
37
53
  maskDoc.resizeImage(
38
54
  UnitValue(${jsNum(docW, 1)}, 'px'),
@@ -44,34 +60,50 @@ function buildLoadScript(maskPath, docW, docH) {
44
60
  maskDoc.selection.copy();
45
61
  maskDoc.close(SaveOptions.DONOTSAVECHANGES);
46
62
 
47
- // Paste the doc-sized mask INTO a full-canvas alpha channel on the original. With
48
- // the alpha channel the explicit active channel, paste writes grayscale into it (no
49
- // new layer). selectAll + paste-into keeps the doc-sized clipboard aligned to canvas.
50
63
  app.activeDocument = orig;
51
- var ch = orig.channels.add();
52
- orig.activeChannels = [ch];
64
+ var maskCh = orig.channels.add();
65
+ orig.activeChannels = [maskCh];
53
66
  orig.selection.selectAll();
54
67
  orig.paste(true);
55
68
  orig.selection.deselect();
56
69
 
57
- // Load the channel as the working selection (white = sky), then drop the temp channel.
58
- orig.selection.load(ch, SelectionType.REPLACE);
59
- try { ch.remove(); } catch (eRm) {}
60
- __skyRestoreComposite(orig);
70
+ // Load the mask (REPLACE), then re-combine with the saved prior selection.
71
+ orig.selection.load(maskCh, SelectionType.REPLACE);
72
+ if (priorCh) {
73
+ if (combine === 'EXTEND') {
74
+ orig.selection.load(priorCh, SelectionType.EXTEND); // mask ∪ prior
75
+ } else if (combine === 'DIMINISH') {
76
+ orig.selection.load(priorCh, SelectionType.REPLACE); // = prior
77
+ orig.selection.load(maskCh, SelectionType.DIMINISH); // prior − mask
78
+ } else if (combine === 'INTERSECT') {
79
+ orig.selection.load(priorCh, SelectionType.REPLACE); // = prior
80
+ orig.selection.load(maskCh, SelectionType.INTERSECT); // prior ∩ mask
81
+ }
82
+ try { priorCh.remove(); } catch (eP) {}
83
+ }
84
+ try { maskCh.remove(); } catch (eRm) {}
85
+ __restoreComposite(orig);
61
86
  return { ok: true };
62
87
  `;
63
88
  }
64
- export async function loadSkyMaskAsSelection(connection, mask, maskW, maskH, docW, docH) {
89
+ const SELECTION_TYPE_ENUM = {
90
+ replace: 'REPLACE',
91
+ add: 'EXTEND',
92
+ subtract: 'DIMINISH',
93
+ intersect: 'INTERSECT',
94
+ };
95
+ export async function loadMaskAsSelection(connection, mask, maskW, maskH, docW, docH, selectionType = 'replace') {
65
96
  const jpg = encodeMaskJpeg(mask, maskW, maskH);
66
97
  const dir = process.platform === 'darwin'
67
- ? await TempDir.createWithRoot(userOwnedTempRoot(), 'editmamei-sky-')
68
- : await TempDir.create('editmamei-sky-');
98
+ ? await TempDir.createWithRoot(userOwnedTempRoot(), 'editmamei-mask-')
99
+ : await TempDir.create('editmamei-mask-');
69
100
  try {
70
- const maskPath = dir.path('skymask.jpg');
101
+ const maskPath = dir.path('mask.jpg');
71
102
  await fs.writeFile(maskPath, jpg);
72
- await runScript(connection, buildLoadScript(maskPath, docW, docH));
103
+ await runScript(connection, buildLoadScript(maskPath, docW, docH, SELECTION_TYPE_ENUM[selectionType]));
73
104
  }
74
105
  finally {
75
106
  await dir.cleanup();
76
107
  }
77
108
  }
109
+ export const loadSkyMaskAsSelection = loadMaskAsSelection;
Binary file
@@ -626,8 +626,8 @@ export function createAdjustmentTools(connection, snippetClient) {
626
626
  return [
627
627
  {
628
628
  tool: {
629
- name: 'photoshop_apply_adjustment',
630
- description: "Apply a DESTRUCTIVE tonal adjustment that Photoshop does NOT offer as an adjustment layer — chosen via `type`. Runs on a DUPLICATE of the active layer by default (auto-duplicate-first per Bundle O — the original is preserved; revert by deleting the copy); pass `apply_to_active_layer: true` to bake in place. Auto-rasterizes text/smart-object layers. `shadows_highlights` recovers blown highlights + crushed shadows in one pass (defaults match Adobe's dialog: 35 shadow amount, +20 color correction); `equalize` stretches/flattens the histogram (parameter-free); `color_lookup` bakes a 3DLUT grade (cl_lut_name required — leaf name of a file in Presets/3DLUTs/ or an absolute .cube/.3dl/.look path). For EDITABLE tonal/color work, prefer photoshop_add_adjustment_layer.",
629
+ name: 'ps_apply_adjustment',
630
+ description: "Apply a DESTRUCTIVE tonal adjustment that Photoshop does NOT offer as an adjustment layer — chosen via `type`. Runs on a DUPLICATE of the active layer by default (auto-duplicate-first per Bundle O — the original is preserved; revert by deleting the copy); pass `apply_to_active_layer: true` to bake in place. Auto-rasterizes text/smart-object layers. `shadows_highlights` recovers blown highlights + crushed shadows in one pass (defaults match Adobe's dialog: 35 shadow amount, +20 color correction); `equalize` stretches/flattens the histogram (parameter-free); `color_lookup` bakes a 3DLUT grade (cl_lut_name required — leaf name of a file in Presets/3DLUTs/ or an absolute .cube/.3dl/.look path). For EDITABLE tonal/color work, prefer ps_add_adjustment_layer.",
631
631
  inputSchema: APPLY_ADJUSTMENT_INPUT_SCHEMA,
632
632
  outputSchema: {
633
633
  type: 'object',
@@ -659,7 +659,7 @@ export function createAdjustmentTools(connection, snippetClient) {
659
659
  },
660
660
  {
661
661
  tool: {
662
- name: 'photoshop_add_adjustment_layer',
662
+ name: 'ps_add_adjustment_layer',
663
663
  description: "Create a non-destructive adjustment layer above the active layer. Supports the full real-Photoshop tonal/color surface: Curves (with S-curve presets), Levels, Hue/Saturation, Brightness/Contrast, Black & White (with optional tint), Color Balance, Photo Filter (preset or custom color), Vibrance, Channel Mixer, Selective Color, Gradient Map (preset), Exposure (stops + offset + gamma), Color Lookup (3DLUT presets or custom file path), and Invert. Values are editable, maskable, and removable. This is the canonical entry point for ALL tonal/color adjustments; the old destructive bake tools (auto_levels / auto_contrast / desaturate / invert) were removed on 2026-05-31 — if you genuinely need a pixel bake, follow this call with `photoshop_merge_visible_layers`. Optionally clips the adjustment to only affect the layer directly below it. If a selection is active at call time, the new layer is automatically masked by it (toggle with mask_from_selection / mask_inverted). For destructive ops that don't have an adjustment-layer equivalent in Photoshop (Shadows/Highlights — single-pass shadow/highlight recovery), use `photoshop_apply_shadows_highlights` which auto-duplicates the active layer to keep the original intact. Returns context — the new adjustment layer becomes active.",
664
664
  inputSchema: addAdjustmentLayerSchema,
665
665
  outputSchema: {
@@ -1,6 +1,8 @@
1
1
  import { SUPPORTED_BRUSH_TOOLS } from '../api/brush-tool-names.js';
2
2
  import { runScript } from '../utils/run-script.js';
3
3
  import { validateArgs } from '../utils/validate.js';
4
+ import { OnnxLandmarkDetectionClient } from '../detection/landmark-detection-client.js';
5
+ import { resolveExpectedPlacement, PLACEMENT_SCHEMA } from '../perception/grounding-locate.js';
4
6
  const TOOLS_REQUIRING_SOURCE = ['clone_stamp', 'healing_brush'];
5
7
  const applyBrushStrokeSchema = {
6
8
  type: 'object',
@@ -10,6 +12,14 @@ const applyBrushStrokeSchema = {
10
12
  enum: SUPPORTED_BRUSH_TOOLS,
11
13
  description: 'Which brush-family tool to dispatch. Headline retouch options: `healing_brush` and `clone_stamp` (both REQUIRE `source_point` — set the sample location, then stroke the path); `burn` darkens; `dodge` lightens; `blur` smooths; `sharpen` enhances local contrast; `smudge` pushes pixels in the stroke direction. Paint family: `brush` (the standard paintbrush — honors `foreground_color`), `pencil` (hard-edge), `eraser`. Specialty: `pattern_stamp`, `art_history_brush`, `history_brush`, `color_replacement`, `background_eraser`, `sponge`.',
12
14
  },
15
+ placement: {
16
+ ...PLACEMENT_SCHEMA,
17
+ description: 'ANCHOR-RELATIONAL stroke path (preferred over supplying pixels): a PATH relation — `along` a traced edge ' +
18
+ 'or a Pro face-mesh landmark curve, `offset-curve`, or `segment` between two anchors — so the stroke traces ' +
19
+ 'the resolved, gate-verified curve (the FULL polyline, not just endpoints: paint along the jaw / horizon / ' +
20
+ 'under-eye). Strokes ONLY if the gate PASSES. When set, `path` is ignored; tool/brush_size/source_point/' +
21
+ 'colors/dynamics/jitter still apply. See ps_resolve_placement for the anchors + relation vocabulary.',
22
+ },
13
23
  path: {
14
24
  type: 'array',
15
25
  description: "Ordered list of anchor points the stroke traces. **Minimum 2 anchors.** Each anchor is `{x, y}` for a sharp corner OR `{x, y, in: [hx, hy], out: [hx, hy]}` for a smooth bezier point. The `in` and `out` handles MUST be positioned **tangent** to the curve at the anchor — `in` placed in the direction of the PREVIOUS anchor in the array, `out` placed in the direction of the NEXT anchor. Handles placed RADIALLY (toward/away from the shape's center) produce loops + concave curves instead of smooth convex ones. Mix corner + smooth points freely. Coordinates are document pixels; (0, 0) is top-left. Partial handles (only `in` OR only `out`) degrade to a sharp corner. **Recipes for common natural-stroke shapes** (compute these client-side and emit the resulting `[{x, y}, ...]` array): (1) **Hand-drawn straight line A→B with sketchy feel**: sample 8-30 evenly-spaced corner anchors along the line, then perturb each interior anchor by ±2-5px on the perpendicular axis — OR pass clean anchors and use `jitter_px` to apply the perturbation server-side (preferred — cheaper, deterministic). (2) **Sine wave A→B, amplitude a, periods n**: for i in 0..N, x_i = A.x + (i/N)*(B.x - A.x), y_i = A.y + a * sin((i/N) * 2π * n). Use 20-40 anchors for a smooth wave. (3) **Parabolic arc A→B peaking height h above midline**: for i in 0..N, t = i/N, x_i = lerp(A.x, B.x, t), y_i = lerp(A.y, B.y, t) - 4*h*t*(1-t). (4) **Canonical clockwise circle of radius r around (cx, cy)** with k = r * 0.5523: TOP `{x: cx, y: cy-r, in: [cx-k, cy-r], out: [cx+k, cy-r]}`, RIGHT `{x: cx+r, y: cy, in: [cx+r, cy-k], out: [cx+r, cy+k]}`, BOTTOM `{x: cx, y: cy+r, in: [cx+k, cy+r], out: [cx-k, cy+r]}`, LEFT `{x: cx-r, y: cy, in: [cx-r, cy+k], out: [cx-r, cy-k]}` — close with `closed: true`. (5) **Many short overlapping strokes for ink-on-paper texture**: chain multiple `apply_brush_stroke` calls along the same trajectory with small position offsets and varying `brush_size`; reads more natural than one long stroke.",
@@ -62,7 +72,7 @@ const applyBrushStrokeSchema = {
62
72
  },
63
73
  source_point: {
64
74
  type: 'object',
65
- description: 'Sample point for `clone_stamp` / `healing_brush` — the pixel location PS samples from while the stroke progresses. REQUIRED for those two tools. Ignored for all others. `layer_name` defaults to the active layer at call time when omitted.',
75
+ description: 'Sample point for `clone_stamp` / `healing_brush` — the pixel location PS samples from while the stroke progresses. REQUIRED for those two tools (unless `source_placement` names it instead). Ignored for all others. `layer_name` defaults to the active layer at call time when omitted.',
66
76
  properties: {
67
77
  x: { type: 'number', description: 'Source x in document pixels.' },
68
78
  y: { type: 'number', description: 'Source y in document pixels.' },
@@ -73,6 +83,10 @@ const applyBrushStrokeSchema = {
73
83
  },
74
84
  required: ['x', 'y'],
75
85
  },
86
+ source_placement: {
87
+ ...PLACEMENT_SCHEMA,
88
+ description: 'Grounded alternative to `source_point` for `clone_stamp` / `healing_brush`: NAME the sample location (resolves to a POINT via the grounding resolver + objective gate — e.g. an `extremum` for the cleanest/smoothest nearby skin, a `grid` intersection, or a landmark point) instead of guessing pixels. Resolves to a POINT relation (centroid / midpoint / offset / extremum / grid / landmark point); the resolved point supplies `source_point` and WINS over an explicit `source_point`. Strokes only if the source gate PASSES.',
89
+ },
76
90
  foreground_color: {
77
91
  type: 'object',
78
92
  description: "RGB foreground color for paint-family tools (`brush`, `pencil`). The retouch family (`burn`/`dodge`/`blur`/`sharpen`/`smudge`/`clone_stamp`/`healing_brush`) ignores foreground color — setting it on those tools is harmless but pointless. The user's previous foreground color is restored after the stroke completes.",
@@ -101,14 +115,14 @@ const applyBrushStrokeSchema = {
101
115
  default: false,
102
116
  },
103
117
  },
104
- required: ['tool', 'path', 'brush_size'],
118
+ required: ['tool', 'brush_size'],
105
119
  };
106
- export function createBrushTools(connection, snippetClient) {
120
+ export function createBrushTools(connection, snippetClient, client = new OnnxLandmarkDetectionClient()) {
107
121
  return [
108
122
  {
109
123
  tool: {
110
- name: 'photoshop_apply_brush_stroke',
111
- description: 'Paint along a caller-supplied path with one of PS\'s 16 brush-family tools — the retouch tools (`healing_brush`, `clone_stamp`, `burn`, `dodge`, `blur`, `sharpen`, `smudge`), the paint family (`brush`, `pencil`, `eraser`), and the specialty tools (`pattern_stamp`, `art_history_brush`, `history_brush`, `color_replacement`, `background_eraser`, `sponge`). The `path` parameter takes a list of anchor points with optional bezier handles, so the same tool handles straight-line strokes, freeform curves, and closed shapes — all by varying the path geometry. **Reach for this when**: (a) cloning out a distraction along a specific shape (clone_stamp with a source_point + a path tracing the unwanted edge); (b) healing a scratch or seam (healing_brush with source_point); (c) dodging / burning to redirect tonal balance along a contour; (d) painting a freehand line into the canvas (brush + foreground_color). Active layer must be a normal pixel layer (background auto-promotes); rasterize adjustment/shape/text/smart-object layers first. Auto-duplicates per Bundle O — the original is preserved and a "Brush Stroke (<name>)" copy receives the paint. **Brush dynamics**: hardness, opacity, and flow are independently settable via `hardness_pct` / `opacity_pct` / `flow_pct` (the live tool options are mutated before stroking and restored to the user\'s prior state in `finally`). Sampled brushes (custom shape-stamp presets) silently ignore hardness/diameter mutations — vary their character via `brush_preset` instead.',
124
+ name: 'ps_apply_brush_stroke',
125
+ description: 'Paint along a path with one of PS\'s 16 brush-family tools — supply the path EITHER as an anchor-relational `placement` (preferred: a path relation → the stroke traces a resolved, gate-verified curve along a traced edge / landmark / between two anchors, no pixel-guessing) OR as an explicit `path` list of anchor points — the retouch tools (`healing_brush`, `clone_stamp`, `burn`, `dodge`, `blur`, `sharpen`, `smudge`), the paint family (`brush`, `pencil`, `eraser`), and the specialty tools (`pattern_stamp`, `art_history_brush`, `history_brush`, `color_replacement`, `background_eraser`, `sponge`). The `path` parameter takes a list of anchor points with optional bezier handles, so the same tool handles straight-line strokes, freeform curves, and closed shapes — all by varying the path geometry. **Reach for this when**: (a) cloning out a distraction along a specific shape (clone_stamp with a source_point + a path tracing the unwanted edge); (b) healing a scratch or seam (healing_brush with source_point); (c) dodging / burning to redirect tonal balance along a contour; (d) painting a freehand line into the canvas (brush + foreground_color). Active layer must be a normal pixel layer (background auto-promotes); rasterize adjustment/shape/text/smart-object layers first. Auto-duplicates per Bundle O — the original is preserved and a "Brush Stroke (<name>)" copy receives the paint. **Brush dynamics**: hardness, opacity, and flow are independently settable via `hardness_pct` / `opacity_pct` / `flow_pct` (the live tool options are mutated before stroking and restored to the user\'s prior state in `finally`). Sampled brushes (custom shape-stamp presets) silently ignore hardness/diameter mutations — vary their character via `brush_preset` instead.',
112
126
  inputSchema: applyBrushStrokeSchema,
113
127
  outputSchema: {
114
128
  type: 'object',
@@ -132,6 +146,24 @@ export function createBrushTools(connection, snippetClient) {
132
146
  target_was_copy: { type: 'boolean' },
133
147
  target_layer_name: { type: 'string' },
134
148
  original_layer_name: { type: 'string' },
149
+ stroke_envelope: {
150
+ type: 'object',
151
+ description: 'The doc-pixel bbox the stroke should occupy (path bbox + brush radius) — the objective target to verify stroke occupancy against.',
152
+ },
153
+ placement: {
154
+ type: 'object',
155
+ description: 'Present when the stroke path came from anchor-relational placement: the resolved curve + gate verdict.',
156
+ properties: {
157
+ target: { type: 'string' },
158
+ gate: { type: 'object' },
159
+ anchors: { type: 'object' },
160
+ points: { type: 'number' },
161
+ },
162
+ },
163
+ source_placement: {
164
+ type: 'object',
165
+ description: 'Present when the clone/heal sample point came from a source_placement: the resolved point + gate verdict.',
166
+ },
135
167
  context: { type: 'object' },
136
168
  },
137
169
  },
@@ -141,37 +173,58 @@ export function createBrushTools(connection, snippetClient) {
141
173
  idempotentHint: false,
142
174
  },
143
175
  },
144
- handler: async (args) => applyBrushStroke(connection, snippetClient, args),
176
+ handler: async (args) => applyBrushStroke(connection, snippetClient, client, args),
145
177
  },
146
178
  ];
147
179
  }
148
- async function applyBrushStroke(connection, snippetClient, rawArgs) {
180
+ async function applyBrushStroke(connection, snippetClient, client, rawArgs) {
149
181
  try {
150
182
  const args = validateArgs(applyBrushStrokeSchema, rawArgs);
151
183
  const tool = args.tool;
152
- const path = args.path;
153
- const brush_size = args.brush_size;
154
- const brush_preset = args.brush_preset;
155
- const source_point = args.source_point;
156
- const foreground_color = args.foreground_color;
157
- const closed = args.closed ?? false;
158
- const jitter_px = args.jitter_px ?? 0;
159
- const hardness_pct = args.hardness_pct;
160
- const opacity_pct = args.opacity_pct;
161
- const flow_pct = args.flow_pct;
162
- const apply_to_active_layer = args.apply_to_active_layer ?? false;
163
- if (TOOLS_REQUIRING_SOURCE.includes(tool) && !source_point) {
184
+ if (TOOLS_REQUIRING_SOURCE.includes(tool) &&
185
+ !args.source_point &&
186
+ !args.source_placement) {
164
187
  return {
165
188
  content: [
166
189
  {
167
190
  type: 'text',
168
- text: `${tool} requires source_point ({x, y, layer_name?}) — set the sample location ` +
169
- `before stroking. (Healing brush and clone stamp both need to know what to copy from.)`,
191
+ text: `${tool} requires a sample location — set source_point ({x, y, layer_name?}) or name it ` +
192
+ `via source_placement (resolved + gated) before stroking. (Healing brush and clone stamp ` +
193
+ `both need to know what to copy from.)`,
170
194
  },
171
195
  ],
172
196
  isError: true,
173
197
  };
174
198
  }
199
+ let path;
200
+ let placementInfo;
201
+ if (args.placement) {
202
+ const rp = await resolveExpectedPlacement(connection, client, args.placement, 'path', `${tool} stroke`);
203
+ path = rp.curve.map((p) => ({ x: p.x, y: p.y }));
204
+ placementInfo = { target: rp.target, anchors: rp.anchors, points: rp.curve.length };
205
+ }
206
+ else {
207
+ path = args.path;
208
+ if (!Array.isArray(path) || path.length < 2) {
209
+ throw new Error('path needs at least 2 anchors, or a placement (anchors + a path relation).');
210
+ }
211
+ }
212
+ const brush_size = args.brush_size;
213
+ const brush_preset = args.brush_preset;
214
+ let source_point = args.source_point;
215
+ let sourcePlacementInfo;
216
+ if (args.source_placement) {
217
+ const sp = await resolveExpectedPlacement(connection, client, args.source_placement, 'point', `${tool} source`);
218
+ source_point = { x: sp.point.x, y: sp.point.y };
219
+ sourcePlacementInfo = { target: sp.target, point: sp.point };
220
+ }
221
+ const foreground_color = args.foreground_color;
222
+ const closed = args.closed ?? false;
223
+ const jitter_px = args.jitter_px ?? 0;
224
+ const hardness_pct = args.hardness_pct;
225
+ const opacity_pct = args.opacity_pct;
226
+ const flow_pct = args.flow_pct;
227
+ const apply_to_active_layer = args.apply_to_active_layer ?? false;
175
228
  const pathToStroke = jitter_px > 0 ? applyJitter(path, jitter_px) : path;
176
229
  const script = await snippetClient.build('applyBrushStroke', {
177
230
  tool,
@@ -192,15 +245,50 @@ async function applyBrushStroke(connection, snippetClient, rawArgs) {
192
245
  const targetSuffix = result.target_was_copy
193
246
  ? ` on copy "${result.target_layer_name}"`
194
247
  : ` on layer "${result.target_layer_name}"`;
248
+ const placementSuffix = placementInfo ? ' via placement (gate PASS)' : '';
249
+ const xs = pathToStroke.map((a) => a.x);
250
+ const ys = pathToStroke.map((a) => a.y);
251
+ const rad = result.brush_size / 2;
252
+ const strokeEnvelope = {
253
+ left: Math.round(Math.min(...xs) - rad),
254
+ top: Math.round(Math.min(...ys) - rad),
255
+ right: Math.round(Math.max(...xs) + rad),
256
+ bottom: Math.round(Math.max(...ys) + rad),
257
+ };
258
+ const structured = {
259
+ ...result,
260
+ stroke_envelope: strokeEnvelope,
261
+ };
262
+ if (placementInfo) {
263
+ structured.placement = {
264
+ target: placementInfo.target,
265
+ gate: { pass: true },
266
+ anchors: placementInfo.anchors,
267
+ points: placementInfo.points,
268
+ };
269
+ }
270
+ if (sourcePlacementInfo) {
271
+ structured.source_placement = {
272
+ target: sourcePlacementInfo.target,
273
+ gate: { pass: true },
274
+ point: {
275
+ x: Math.round(sourcePlacementInfo.point.x),
276
+ y: Math.round(sourcePlacementInfo.point.y),
277
+ },
278
+ };
279
+ }
280
+ const srcPlaceSuffix = sourcePlacementInfo ? ' [source via placement, gate PASS]' : '';
195
281
  return {
196
282
  content: [
197
283
  {
198
284
  type: 'text',
199
285
  text: `${tool} stroke (${result.anchors} anchors, size ${result.brush_size}px${presetSuffix})` +
200
- `${sourceSuffix}${targetSuffix}.`,
286
+ `${sourceSuffix}${srcPlaceSuffix}${targetSuffix}${placementSuffix}. Expected envelope ` +
287
+ `[${strokeEnvelope.left},${strokeEnvelope.top},${strokeEnvelope.right},${strokeEnvelope.bottom}] — ` +
288
+ `verify occupancy there (ps_get_preview / ps_compare_regions).`,
201
289
  },
202
290
  ],
203
- structuredContent: result,
291
+ structuredContent: structured,
204
292
  };
205
293
  }
206
294
  catch (error) {